Public Access
style: fix PHPCS violations across migrated CLI scripts
Generic: Repo Health / Access control (push) Has been cancelled
Generic: Repo Health / Site Health (push) Has been cancelled
Universal: PR Check / Branch Policy (pull_request) Has been cancelled
Universal: Auto Version Bump / Version Bump (push) Has been cancelled
Universal: Secret Scanning / Gitleaks Secret Scan (pull_request) Has been cancelled
Universal: PR Check / Validate PR (pull_request) Has been cancelled
Generic: Repo Health / Site Health (pull_request) Has been cancelled
Generic: Repo Health / Access control (pull_request) Has been cancelled
Platform: moko-platform CI / Gate 1: Code Quality (pull_request) Has been cancelled
Platform: moko-platform CI / Gate 1: Code Quality (push) Has been cancelled
Platform: moko-platform CI / Gate 2: Unit Tests (8.1) (pull_request) Has been cancelled
Platform: moko-platform CI / Gate 2: Unit Tests (8.2) (pull_request) Has been cancelled
Platform: moko-platform CI / Gate 2: Unit Tests (8.3) (pull_request) Has been cancelled
Platform: moko-platform CI / Gate 3: Self-Health Check (pull_request) Has been cancelled
Platform: moko-platform CI / Gate 4: Governance (pull_request) Has been cancelled
Platform: moko-platform CI / Gate 5: Template Integrity (pull_request) Has been cancelled
Platform: moko-platform CI / CI Summary (pull_request) Has been cancelled
Platform: moko-platform CI / Gate 2: Unit Tests (8.1) (push) Has been cancelled
Platform: moko-platform CI / Gate 2: Unit Tests (8.2) (push) Has been cancelled
Platform: moko-platform CI / Gate 2: Unit Tests (8.3) (push) Has been cancelled
Platform: moko-platform CI / Gate 3: Self-Health Check (push) Has been cancelled
Platform: moko-platform CI / Gate 4: Governance (push) Has been cancelled
Platform: moko-platform CI / Gate 5: Template Integrity (push) Has been cancelled
Platform: moko-platform CI / CI Summary (push) Has been cancelled
Generic: Repo Health / Release configuration (push) Has been cancelled
Generic: Repo Health / Scripts governance (push) Has been cancelled
Generic: Repo Health / Repository health (push) Has been cancelled
Universal: PR Check / Build RC Package (pull_request) Has been cancelled
Generic: Repo Health / Release configuration (pull_request) Has been cancelled
Generic: Repo Health / Scripts governance (pull_request) Has been cancelled
Generic: Repo Health / Repository health (pull_request) Has been cancelled
Generic: Repo Health / Access control (push) Has been cancelled
Generic: Repo Health / Site Health (push) Has been cancelled
Universal: PR Check / Branch Policy (pull_request) Has been cancelled
Universal: Auto Version Bump / Version Bump (push) Has been cancelled
Universal: Secret Scanning / Gitleaks Secret Scan (pull_request) Has been cancelled
Universal: PR Check / Validate PR (pull_request) Has been cancelled
Generic: Repo Health / Site Health (pull_request) Has been cancelled
Generic: Repo Health / Access control (pull_request) Has been cancelled
Platform: moko-platform CI / Gate 1: Code Quality (pull_request) Has been cancelled
Platform: moko-platform CI / Gate 1: Code Quality (push) Has been cancelled
Platform: moko-platform CI / Gate 2: Unit Tests (8.1) (pull_request) Has been cancelled
Platform: moko-platform CI / Gate 2: Unit Tests (8.2) (pull_request) Has been cancelled
Platform: moko-platform CI / Gate 2: Unit Tests (8.3) (pull_request) Has been cancelled
Platform: moko-platform CI / Gate 3: Self-Health Check (pull_request) Has been cancelled
Platform: moko-platform CI / Gate 4: Governance (pull_request) Has been cancelled
Platform: moko-platform CI / Gate 5: Template Integrity (pull_request) Has been cancelled
Platform: moko-platform CI / CI Summary (pull_request) Has been cancelled
Platform: moko-platform CI / Gate 2: Unit Tests (8.1) (push) Has been cancelled
Platform: moko-platform CI / Gate 2: Unit Tests (8.2) (push) Has been cancelled
Platform: moko-platform CI / Gate 2: Unit Tests (8.3) (push) Has been cancelled
Platform: moko-platform CI / Gate 3: Self-Health Check (push) Has been cancelled
Platform: moko-platform CI / Gate 4: Governance (push) Has been cancelled
Platform: moko-platform CI / Gate 5: Template Integrity (push) Has been cancelled
Platform: moko-platform CI / CI Summary (push) Has been cancelled
Generic: Repo Health / Release configuration (push) Has been cancelled
Generic: Repo Health / Scripts governance (push) Has been cancelled
Generic: Repo Health / Repository health (push) Has been cancelled
Universal: PR Check / Build RC Package (pull_request) Has been cancelled
Generic: Repo Health / Release configuration (pull_request) Has been cancelled
Generic: Repo Health / Scripts governance (pull_request) Has been cancelled
Generic: Repo Health / Repository health (pull_request) Has been cancelled
Auto-fixed 5006 tab-indent and line-ending errors via phpcbf, then manually broke 100 lines exceeding 150-char limit. All 74 files in cli/, automation/, maintenance/, deploy/ now pass PHPCS PSR-12 clean. Authored-by: Moko Consulting Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -111,7 +111,9 @@ class EnrichManifestXmlCli extends CliFramework
|
|||||||
if (!isset($enrichment['build'])) {
|
if (!isset($enrichment['build'])) {
|
||||||
$enrichment['build'] = [];
|
$enrichment['build'] = [];
|
||||||
}
|
}
|
||||||
$enrichment['build']['language'] = $enrichment['build']['language'] ?? $repo['language'] ?? MokoStandardsParser::platformLanguage($platform);
|
$enrichment['build']['language'] = $enrichment['build']['language']
|
||||||
|
?? $repo['language']
|
||||||
|
?? MokoStandardsParser::platformLanguage($platform);
|
||||||
$enrichment['build']['package_type'] = $enrichment['build']['package_type'] ?? MokoStandardsParser::platformPackageType($platform);
|
$enrichment['build']['package_type'] = $enrichment['build']['package_type'] ?? MokoStandardsParser::platformPackageType($platform);
|
||||||
|
|
||||||
$enrichedXml = $this->enrichManifestXml($existingXml, $enrichment);
|
$enrichedXml = $this->enrichManifestXml($existingXml, $enrichment);
|
||||||
|
|||||||
@@ -111,7 +111,9 @@ class EnrichMokostandardsXmlCli extends CliFramework
|
|||||||
if (!isset($enrichment['build'])) {
|
if (!isset($enrichment['build'])) {
|
||||||
$enrichment['build'] = [];
|
$enrichment['build'] = [];
|
||||||
}
|
}
|
||||||
$enrichment['build']['language'] = $enrichment['build']['language'] ?? $repo['language'] ?? MokoStandardsParser::platformLanguage($platform);
|
$enrichment['build']['language'] = $enrichment['build']['language']
|
||||||
|
?? $repo['language']
|
||||||
|
?? MokoStandardsParser::platformLanguage($platform);
|
||||||
$enrichment['build']['package_type'] = $enrichment['build']['package_type'] ?? MokoStandardsParser::platformPackageType($platform);
|
$enrichment['build']['package_type'] = $enrichment['build']['package_type'] ?? MokoStandardsParser::platformPackageType($platform);
|
||||||
|
|
||||||
$enrichedXml = $this->enrichManifestXml($existingXml, $enrichment);
|
$enrichedXml = $this->enrichManifestXml($existingXml, $enrichment);
|
||||||
@@ -131,7 +133,15 @@ class EnrichMokostandardsXmlCli extends CliFramework
|
|||||||
$this->gitCmd($workDir, 'config', 'user.email', 'gitea-actions[bot]@git.mokoconsulting.tech');
|
$this->gitCmd($workDir, 'config', 'user.email', 'gitea-actions[bot]@git.mokoconsulting.tech');
|
||||||
$this->gitCmd($workDir, 'add', '.mokogitea/manifest.xml');
|
$this->gitCmd($workDir, 'add', '.mokogitea/manifest.xml');
|
||||||
|
|
||||||
[$cr, $co] = $this->gitCmd($workDir, 'commit', '-m', "chore: enrich .mokostandards with build/deploy/scripts\n\nAuto-detected: {$details}");
|
$commitMsg = "chore: enrich .mokostandards"
|
||||||
|
. " with build/deploy/scripts\n\n"
|
||||||
|
. "Auto-detected: {$details}";
|
||||||
|
[$cr, $co] = $this->gitCmd(
|
||||||
|
$workDir,
|
||||||
|
'commit',
|
||||||
|
'-m',
|
||||||
|
$commitMsg
|
||||||
|
);
|
||||||
if ($cr !== 0) {
|
if ($cr !== 0) {
|
||||||
echo "SKIP (no diff)\n";
|
echo "SKIP (no diff)\n";
|
||||||
$stats['skipped']++;
|
$stats['skipped']++;
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
#!/usr/bin/env php
|
#!/usr/bin/env php
|
||||||
<?php
|
<?php
|
||||||
|
|
||||||
/* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
|
/* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
|
||||||
*
|
*
|
||||||
* This file is part of a Moko Consulting project.
|
* This file is part of a Moko Consulting project.
|
||||||
@@ -136,7 +137,13 @@ class ArchiveRepoCli extends CliFramework
|
|||||||
$org,
|
$org,
|
||||||
'moko-platform',
|
'moko-platform',
|
||||||
"chore: archived repository {$repoName}",
|
"chore: archived repository {$repoName}",
|
||||||
"## Repository Archived\n\n**Repository:** `{$org}/{$repoName}`\n**Archived:** {$now}\n**Platform:** {$platformName}\n**Sync definition removed:** yes\n\n---\n*Auto-created by `archive_repo.php`*\n",
|
"## Repository Archived\n\n"
|
||||||
|
. "**Repository:** `{$org}/{$repoName}`\n"
|
||||||
|
. "**Archived:** {$now}\n"
|
||||||
|
. "**Platform:** {$platformName}\n"
|
||||||
|
. "**Sync definition removed:** yes\n\n"
|
||||||
|
. "---\n"
|
||||||
|
. "*Auto-created by `archive_repo.php`*\n",
|
||||||
[
|
[
|
||||||
'labels' => ['type: chore', 'automation', 'archived'],
|
'labels' => ['type: chore', 'automation', 'archived'],
|
||||||
'assignees' => ['jmiller'],
|
'assignees' => ['jmiller'],
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
#!/usr/bin/env php
|
#!/usr/bin/env php
|
||||||
<?php
|
<?php
|
||||||
|
|
||||||
/* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
|
/* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
|
||||||
*
|
*
|
||||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
#!/usr/bin/env php
|
#!/usr/bin/env php
|
||||||
<?php
|
<?php
|
||||||
|
|
||||||
/* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
|
/* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
|
||||||
*
|
*
|
||||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
#!/usr/bin/env php
|
#!/usr/bin/env php
|
||||||
<?php
|
<?php
|
||||||
|
|
||||||
/* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
|
/* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
|
||||||
*
|
*
|
||||||
* This file is part of a Moko Consulting project.
|
* This file is part of a Moko Consulting project.
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
#!/usr/bin/env php
|
#!/usr/bin/env php
|
||||||
<?php
|
<?php
|
||||||
|
|
||||||
/* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
|
/* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
|
||||||
*
|
*
|
||||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
#!/usr/bin/env php
|
#!/usr/bin/env php
|
||||||
<?php
|
<?php
|
||||||
|
|
||||||
/* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
|
/* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
|
||||||
*
|
*
|
||||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
#!/usr/bin/env php
|
#!/usr/bin/env php
|
||||||
<?php
|
<?php
|
||||||
|
|
||||||
/* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
|
/* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
|
||||||
*
|
*
|
||||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
#!/usr/bin/env php
|
#!/usr/bin/env php
|
||||||
<?php
|
<?php
|
||||||
|
|
||||||
/* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
|
/* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
|
||||||
*
|
*
|
||||||
* This file is part of a Moko Consulting project.
|
* This file is part of a Moko Consulting project.
|
||||||
@@ -127,7 +128,12 @@ class ClientInventoryCli extends CliFramework
|
|||||||
$this->log('INFO', '');
|
$this->log('INFO', '');
|
||||||
$this->log('INFO', sprintf(
|
$this->log('INFO', sprintf(
|
||||||
'%-20s | %-35s | %-10s | %-11s | %-19s | %s',
|
'%-20s | %-35s | %-10s | %-11s | %-19s | %s',
|
||||||
'Org', 'Repo', 'Dev Config', 'Live Config', 'Last Push', 'Status'
|
'Org',
|
||||||
|
'Repo',
|
||||||
|
'Dev Config',
|
||||||
|
'Live Config',
|
||||||
|
'Last Push',
|
||||||
|
'Status'
|
||||||
));
|
));
|
||||||
$this->log('INFO', str_repeat('-', 120));
|
$this->log('INFO', str_repeat('-', 120));
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
#!/usr/bin/env php
|
#!/usr/bin/env php
|
||||||
<?php
|
<?php
|
||||||
|
|
||||||
/* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
|
/* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
|
||||||
*
|
*
|
||||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
#!/usr/bin/env php
|
#!/usr/bin/env php
|
||||||
<?php
|
<?php
|
||||||
|
|
||||||
/* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
|
/* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
|
||||||
*
|
*
|
||||||
* This file is part of a Moko Consulting project.
|
* This file is part of a Moko Consulting project.
|
||||||
@@ -282,7 +283,10 @@ class CreateProjectCli extends CliFramework
|
|||||||
$result['name'] = $m[1];
|
$result['name'] = $m[1];
|
||||||
}
|
}
|
||||||
|
|
||||||
if (preg_match_all('/\{\s*name\s*=\s*"([^"]+)"\s*type\s*=\s*"([^"]+)"\s*description\s*=\s*"([^"]+)"(?:\s*options\s*=\s*\[([^\]]*)\])?\s*\}/s', $content, $matches, PREG_SET_ORDER)) {
|
$fieldPattern = '/\{\s*name\s*=\s*"([^"]+)"\s*type\s*=\s*"([^"]+)"'
|
||||||
|
. '\s*description\s*=\s*"([^"]+)"'
|
||||||
|
. '(?:\s*options\s*=\s*\[([^\]]*)\])?\s*\}/s';
|
||||||
|
if (preg_match_all($fieldPattern, $content, $matches, PREG_SET_ORDER)) {
|
||||||
foreach ($matches as $match) {
|
foreach ($matches as $match) {
|
||||||
$field = [
|
$field = [
|
||||||
'name' => $match[1],
|
'name' => $match[1],
|
||||||
@@ -376,7 +380,10 @@ class CreateProjectCli extends CliFramework
|
|||||||
$vars['singleSelectOptions'] = $optionInputs;
|
$vars['singleSelectOptions'] = $optionInputs;
|
||||||
|
|
||||||
$this->graphql(
|
$this->graphql(
|
||||||
'mutation($projectId: ID!, $name: String!, $dataType: ProjectV2CustomFieldType!, $singleSelectOptions: [ProjectV2SingleSelectFieldOptionInput!]) {
|
'mutation($projectId: ID!, $name: String!,'
|
||||||
|
. ' $dataType: ProjectV2CustomFieldType!,'
|
||||||
|
. ' $singleSelectOptions:'
|
||||||
|
. ' [ProjectV2SingleSelectFieldOptionInput!]) {
|
||||||
createProjectV2Field(input: {
|
createProjectV2Field(input: {
|
||||||
projectId: $projectId,
|
projectId: $projectId,
|
||||||
dataType: $dataType,
|
dataType: $dataType,
|
||||||
|
|||||||
+187
-61
@@ -1,5 +1,6 @@
|
|||||||
#!/usr/bin/env php
|
#!/usr/bin/env php
|
||||||
<?php
|
<?php
|
||||||
|
|
||||||
/* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
|
/* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
|
||||||
*
|
*
|
||||||
* This file is part of a Moko Consulting project.
|
* This file is part of a Moko Consulting project.
|
||||||
@@ -25,76 +26,201 @@ use MokoEnterprise\PlatformAdapterFactory;
|
|||||||
|
|
||||||
class CreateRepoCli extends CliFramework
|
class CreateRepoCli extends CliFramework
|
||||||
{
|
{
|
||||||
protected function configure(): void
|
protected function configure(): void
|
||||||
{
|
{
|
||||||
$this->setDescription('Scaffold a new governed repository with full moko-platform baseline');
|
$this->setDescription('Scaffold a new governed repository with full moko-platform baseline');
|
||||||
$this->addArgument('--name', 'Repository name', null);
|
$this->addArgument('--name', 'Repository name', null);
|
||||||
$this->addArgument('--type', 'Project type', null);
|
$this->addArgument('--type', 'Project type', null);
|
||||||
$this->addArgument('--description', 'Repository description', '');
|
$this->addArgument('--description', 'Repository description', '');
|
||||||
$this->addArgument('--private', 'Create as private', false);
|
$this->addArgument('--private', 'Create as private', false);
|
||||||
}
|
}
|
||||||
|
|
||||||
protected function run(): int
|
protected function run(): int
|
||||||
{
|
{
|
||||||
$name = $this->getArgument('--name'); $type = $this->getArgument('--type');
|
$name = $this->getArgument('--name');
|
||||||
$description = $this->getArgument('--description'); $private = (bool) $this->getArgument('--private');
|
$type = $this->getArgument('--type');
|
||||||
if (!$name || !$type) { $this->log('ERROR', "Usage: php create_repo.php --name <RepoName> --type <type> [--description \"...\"] [--private] [--dry-run]"); return 2; }
|
$description = $this->getArgument('--description');
|
||||||
$config = Config::load(); $adapter = PlatformAdapterFactory::create($config);
|
$private = (bool) $this->getArgument('--private');
|
||||||
$org = $config->getString($adapter->getPlatformName() . '.organization', 'mokoconsulting-tech');
|
if (!$name || !$type) {
|
||||||
$repoRoot = dirname(__DIR__, 2);
|
$this->log('ERROR', "Usage: php create_repo.php --name <RepoName> --type <type> [--description \"...\"] [--private] [--dry-run]");
|
||||||
$TYPE_TO_PLATFORM = ['dolibarr' => 'crm-module', 'dolibarr-platform' => 'crm-platform', 'joomla' => 'waas-component', 'nodejs' => 'nodejs', 'terraform' => 'terraform', 'python' => 'python', 'wordpress' => 'wordpress', 'generic' => 'generic'];
|
return 2;
|
||||||
$TYPE_TO_TOPICS = ['dolibarr' => ['dolibarr', 'erp', 'crm', 'php', 'mokostandards'], 'joomla' => ['joomla', 'cms', 'php', 'mokostandards'], 'nodejs' => ['nodejs', 'javascript', 'typescript', 'mokostandards'], 'terraform' => ['terraform', 'infrastructure', 'iac', 'mokostandards'], 'python' => ['python', 'mokostandards'], 'wordpress' => ['wordpress', 'php', 'cms', 'mokostandards'], 'generic' => ['mokostandards']];
|
}
|
||||||
$platform = $TYPE_TO_PLATFORM[$type] ?? 'generic'; $topics = $TYPE_TO_TOPICS[$type] ?? ['mokostandards'];
|
$config = Config::load();
|
||||||
$platformName = $adapter->getPlatformName();
|
$adapter = PlatformAdapterFactory::create($config);
|
||||||
echo "Scaffolding new repository: {$org}/{$name} (on {$platformName})\n Type: {$type} (platform: {$platform})\n Visibility: " . ($private ? 'private' : 'public') . "\n";
|
$org = $config->getString($adapter->getPlatformName() . '.organization', 'mokoconsulting-tech');
|
||||||
if ($description) { echo " Description: {$description}\n"; } echo "\n";
|
$repoRoot = dirname(__DIR__, 2);
|
||||||
|
$TYPE_TO_PLATFORM = [
|
||||||
|
'dolibarr' => 'crm-module',
|
||||||
|
'dolibarr-platform' => 'crm-platform',
|
||||||
|
'joomla' => 'waas-component',
|
||||||
|
'nodejs' => 'nodejs',
|
||||||
|
'terraform' => 'terraform',
|
||||||
|
'python' => 'python',
|
||||||
|
'wordpress' => 'wordpress',
|
||||||
|
'generic' => 'generic',
|
||||||
|
];
|
||||||
|
$TYPE_TO_TOPICS = [
|
||||||
|
'dolibarr' => ['dolibarr', 'erp', 'crm', 'php', 'mokostandards'],
|
||||||
|
'joomla' => ['joomla', 'cms', 'php', 'mokostandards'],
|
||||||
|
'nodejs' => ['nodejs', 'javascript', 'typescript', 'mokostandards'],
|
||||||
|
'terraform' => ['terraform', 'infrastructure', 'iac', 'mokostandards'],
|
||||||
|
'python' => ['python', 'mokostandards'],
|
||||||
|
'wordpress' => ['wordpress', 'php', 'cms', 'mokostandards'],
|
||||||
|
'generic' => ['mokostandards'],
|
||||||
|
];
|
||||||
|
$platform = $TYPE_TO_PLATFORM[$type] ?? 'generic';
|
||||||
|
$topics = $TYPE_TO_TOPICS[$type] ?? ['mokostandards'];
|
||||||
|
$platformName = $adapter->getPlatformName();
|
||||||
|
$vis = $private ? 'private' : 'public';
|
||||||
|
echo "Scaffolding new repository: {$org}/{$name}"
|
||||||
|
. " (on {$platformName})\n"
|
||||||
|
. " Type: {$type} (platform: {$platform})\n"
|
||||||
|
. " Visibility: {$vis}\n";
|
||||||
|
if ($description) {
|
||||||
|
echo " Description: {$description}\n";
|
||||||
|
} echo "\n";
|
||||||
|
|
||||||
echo "Step 1: Creating repository...\n";
|
echo "Step 1: Creating repository...\n";
|
||||||
if (!$this->dryRun) {
|
if (!$this->dryRun) {
|
||||||
try {
|
try {
|
||||||
$data = $adapter->createOrgRepo($org, $name, ['description' => $description ?: "Managed by moko-platform ({$type})", 'private' => $private, 'has_issues' => true, 'has_projects' => true, 'has_wiki' => false, 'auto_init' => true, 'delete_branch_on_merge' => true, 'allow_squash_merge' => true, 'allow_merge_commit' => false, 'allow_rebase_merge' => false]);
|
$data = $adapter->createOrgRepo($org, $name, [
|
||||||
echo " Created: " . ($data['html_url'] ?? "{$org}/{$name}") . "\n";
|
'description' => $description ?: "Managed by moko-platform ({$type})",
|
||||||
} catch (\Exception $e) {
|
'private' => $private,
|
||||||
if (str_contains($e->getMessage(), '422') || str_contains($e->getMessage(), 'already exists')) { echo " Repository already exists -- continuing with setup\n"; }
|
'has_issues' => true,
|
||||||
else { $this->log('ERROR', "Failed to create repo: " . $e->getMessage()); return 1; }
|
'has_projects' => true,
|
||||||
}
|
'has_wiki' => false,
|
||||||
} else { echo " (dry-run) would create {$org}/{$name}\n"; }
|
'auto_init' => true,
|
||||||
|
'delete_branch_on_merge' => true,
|
||||||
|
'allow_squash_merge' => true,
|
||||||
|
'allow_merge_commit' => false,
|
||||||
|
'allow_rebase_merge' => false,
|
||||||
|
]);
|
||||||
|
echo " Created: " . ($data['html_url'] ?? "{$org}/{$name}") . "\n";
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
if (str_contains($e->getMessage(), '422') || str_contains($e->getMessage(), 'already exists')) {
|
||||||
|
echo " Repository already exists -- continuing with setup\n";
|
||||||
|
} else {
|
||||||
|
$this->log('ERROR', "Failed to create repo: " . $e->getMessage());
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
echo " (dry-run) would create {$org}/{$name}\n";
|
||||||
|
}
|
||||||
|
|
||||||
echo "Step 2: Setting topics...\n";
|
echo "Step 2: Setting topics...\n";
|
||||||
if (!$this->dryRun) { $adapter->setRepoTopics($org, $name, $topics); echo " Topics: " . implode(', ', $topics) . "\n"; }
|
if (!$this->dryRun) {
|
||||||
else { echo " (dry-run) would set topics: " . implode(', ', $topics) . "\n"; }
|
$adapter->setRepoTopics($org, $name, $topics);
|
||||||
|
echo " Topics: " . implode(', ', $topics) . "\n";
|
||||||
|
} else {
|
||||||
|
echo " (dry-run) would set topics: " . implode(', ', $topics) . "\n";
|
||||||
|
}
|
||||||
|
|
||||||
echo "Step 3: Creating .github/.mokostandards...\n";
|
echo "Step 3: Creating .github/.mokostandards...\n";
|
||||||
$mokoContent = "platform: {$platform}\nversion: 04.02.30\nmanaged: true\n";
|
$mokoContent = "platform: {$platform}\nversion: 04.02.30\nmanaged: true\n";
|
||||||
if (!$this->dryRun) { try { $adapter->createOrUpdateFile($org, $name, '.github/.mokostandards', $mokoContent, 'chore: add .mokostandards platform config [skip ci]'); echo " .mokostandards created\n"; } catch (\Exception $e) { echo " Warning: " . $e->getMessage() . "\n"; } }
|
if (!$this->dryRun) {
|
||||||
else { echo " (dry-run) would create .github/.mokostandards\n"; }
|
try {
|
||||||
|
$adapter->createOrUpdateFile(
|
||||||
|
$org,
|
||||||
|
$name,
|
||||||
|
'.github/.mokostandards',
|
||||||
|
$mokoContent,
|
||||||
|
'chore: add .mokostandards platform config [skip ci]'
|
||||||
|
);
|
||||||
|
echo " .mokostandards created\n";
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
echo " Warning: " . $e->getMessage() . "\n";
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
echo " (dry-run) would create .github/.mokostandards\n";
|
||||||
|
}
|
||||||
|
|
||||||
echo "Step 4: Creating README.md...\n";
|
echo "Step 4: Creating README.md...\n";
|
||||||
$baseUrl = $platformName === 'gitea' ? $config->getString('gitea.url', 'https://git.mokoconsulting.tech') : 'https://github.com';
|
$baseUrl = $platformName === 'gitea' ? $config->getString('gitea.url', 'https://git.mokoconsulting.tech') : 'https://github.com';
|
||||||
$repoUrl = "{$baseUrl}/{$org}/{$name}"; $standardsUrl = "{$baseUrl}/{$org}/MokoStandards";
|
$repoUrl = "{$baseUrl}/{$org}/{$name}";
|
||||||
$readmeContent = "<!--\nCopyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>\nSPDX-License-Identifier: GPL-3.0-or-later\nDEFGROUP: {$name}\nINGROUP: moko-platform\nREPO: {$repoUrl}\nPATH: /README.md\nBRIEF: {$description}\n-->\n\n# {$name}\n\n{$description}\n\n## Getting Started\n\nThis repository is governed by [moko-platform]({$standardsUrl}).\n\n## License\n\nGPL-3.0-or-later. See [LICENSE](LICENSE) for details.\n";
|
$standardsUrl = "{$baseUrl}/{$org}/MokoStandards";
|
||||||
if (!$this->dryRun) {
|
$readmeContent = "<!--\n"
|
||||||
$sha = null;
|
. "Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>\n"
|
||||||
try { $existing = $adapter->getFileContents($org, $name, 'README.md'); $sha = $existing['sha'] ?? null; } catch (\Exception $e) { $adapter->getApiClient()->resetCircuitBreaker(); }
|
. "SPDX-License-Identifier: GPL-3.0-or-later\n"
|
||||||
$adapter->createOrUpdateFile($org, $name, 'README.md', $readmeContent, 'docs: initialize README with moko-platform header [skip ci]', $sha);
|
. "DEFGROUP: {$name}\n"
|
||||||
echo " README.md created\n";
|
. "INGROUP: moko-platform\n"
|
||||||
} else { echo " (dry-run) would create README.md\n"; }
|
. "REPO: {$repoUrl}\n"
|
||||||
|
. "PATH: /README.md\n"
|
||||||
|
. "BRIEF: {$description}\n"
|
||||||
|
. "-->\n\n"
|
||||||
|
. "# {$name}\n\n"
|
||||||
|
. "{$description}\n\n"
|
||||||
|
. "## Getting Started\n\n"
|
||||||
|
. "This repository is governed by"
|
||||||
|
. " [moko-platform]({$standardsUrl}).\n\n"
|
||||||
|
. "## License\n\n"
|
||||||
|
. "GPL-3.0-or-later. See [LICENSE](LICENSE)"
|
||||||
|
. " for details.\n";
|
||||||
|
if (!$this->dryRun) {
|
||||||
|
$sha = null;
|
||||||
|
try {
|
||||||
|
$existing = $adapter->getFileContents($org, $name, 'README.md');
|
||||||
|
$sha = $existing['sha'] ?? null;
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
$adapter->getApiClient()->resetCircuitBreaker();
|
||||||
|
}
|
||||||
|
$adapter->createOrUpdateFile(
|
||||||
|
$org,
|
||||||
|
$name,
|
||||||
|
'README.md',
|
||||||
|
$readmeContent,
|
||||||
|
'docs: initialize README with moko-platform header [skip ci]',
|
||||||
|
$sha
|
||||||
|
);
|
||||||
|
echo " README.md created\n";
|
||||||
|
} else {
|
||||||
|
echo " (dry-run) would create README.md\n";
|
||||||
|
}
|
||||||
|
|
||||||
echo "Step 5: Provisioning labels...\n";
|
echo "Step 5: Provisioning labels...\n";
|
||||||
if (!$this->dryRun) { $labelScript = "{$repoRoot}/api/maintenance/setup_labels.php"; if (file_exists($labelScript)) { $exitCode = 0; passthru("php " . escapeshellarg($labelScript) . " --org " . escapeshellarg($org) . " --repo " . escapeshellarg($name), $exitCode); } else { echo " Labels will be provisioned on next sync\n"; } }
|
if (!$this->dryRun) {
|
||||||
else { echo " (dry-run) would provision standard labels\n"; }
|
$labelScript = "{$repoRoot}/api/maintenance/setup_labels.php";
|
||||||
|
if (file_exists($labelScript)) {
|
||||||
|
$exitCode = 0;
|
||||||
|
passthru("php " . escapeshellarg($labelScript) . " --org " . escapeshellarg($org) . " --repo " . escapeshellarg($name), $exitCode);
|
||||||
|
} else {
|
||||||
|
echo " Labels will be provisioned on next sync\n";
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
echo " (dry-run) would provision standard labels\n";
|
||||||
|
}
|
||||||
|
|
||||||
echo "Step 6: Running initial sync...\n";
|
echo "Step 6: Running initial sync...\n";
|
||||||
if (!$this->dryRun) { $syncScript = "{$repoRoot}/api/automation/bulk_sync.php"; if (file_exists($syncScript)) { passthru("php " . escapeshellarg($syncScript) . " --repos " . escapeshellarg($name) . " --force --yes"); } else { echo " Run manually: php automation/bulk_sync.php --repos {$name} --force --yes\n"; } }
|
if (!$this->dryRun) {
|
||||||
else { echo " (dry-run) would run initial sync\n"; }
|
$syncScript = "{$repoRoot}/api/automation/bulk_sync.php";
|
||||||
|
if (file_exists($syncScript)) {
|
||||||
|
passthru("php " . escapeshellarg($syncScript) . " --repos " . escapeshellarg($name) . " --force --yes");
|
||||||
|
} else {
|
||||||
|
echo " Run manually: php automation/bulk_sync.php --repos {$name} --force --yes\n";
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
echo " (dry-run) would run initial sync\n";
|
||||||
|
}
|
||||||
|
|
||||||
echo "Step 7: Creating Project...\n";
|
echo "Step 7: Creating Project...\n";
|
||||||
if (!$this->dryRun) { $projectScript = "{$repoRoot}/api/cli/create_project.php"; if (file_exists($projectScript)) { passthru("php " . escapeshellarg($projectScript) . " --repo " . escapeshellarg($name) . " --type " . escapeshellarg($type)); } else { echo " Run manually: php cli/create_project.php --repo {$name} --type {$type}\n"; } }
|
if (!$this->dryRun) {
|
||||||
else { echo " (dry-run) would create Project\n"; }
|
$projectScript = "{$repoRoot}/api/cli/create_project.php";
|
||||||
|
if (file_exists($projectScript)) {
|
||||||
|
passthru("php " . escapeshellarg($projectScript) . " --repo " . escapeshellarg($name) . " --type " . escapeshellarg($type));
|
||||||
|
} else {
|
||||||
|
echo " Run manually: php cli/create_project.php --repo {$name} --type {$type}\n";
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
echo " (dry-run) would create Project\n";
|
||||||
|
}
|
||||||
|
|
||||||
echo "\n" . str_repeat('-', 50) . "\nRepository {$org}/{$name} scaffolded successfully\n URL: {$repoUrl}\n Platform: {$platform} ({$platformName})\n Next: verify the sync and merge any PRs\n";
|
echo "\n" . str_repeat('-', 50) . "\n"
|
||||||
return 0;
|
. "Repository {$org}/{$name} scaffolded successfully\n"
|
||||||
}
|
. " URL: {$repoUrl}\n"
|
||||||
|
. " Platform: {$platform} ({$platformName})\n"
|
||||||
|
. " Next: verify the sync and merge any PRs\n";
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
$app = new CreateRepoCli();
|
$app = new CreateRepoCli();
|
||||||
|
|||||||
@@ -233,7 +233,10 @@ class DeployJoomla extends CliFramework
|
|||||||
/**
|
/**
|
||||||
* Parse extension metadata from the Joomla XML manifest.
|
* Parse extension metadata from the Joomla XML manifest.
|
||||||
*
|
*
|
||||||
* @return array{type:string, element:string, client:string, group:string, name:string, shortName:string, version:string, subExtensions:list<array{type:string, id:string, group:string, client:string, path:string}>}|null
|
* @return array{type:string, element:string, client:string,
|
||||||
|
* group:string, name:string, shortName:string, version:string,
|
||||||
|
* subExtensions:list<array{type:string, id:string,
|
||||||
|
* group:string, client:string, path:string}>}|null
|
||||||
*/
|
*/
|
||||||
private function parseExtensionManifest(string $path): ?array
|
private function parseExtensionManifest(string $path): ?array
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
#!/usr/bin/env php
|
#!/usr/bin/env php
|
||||||
<?php
|
<?php
|
||||||
|
|
||||||
/* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
|
/* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
|
||||||
*
|
*
|
||||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
|||||||
+59
-19
@@ -1,5 +1,6 @@
|
|||||||
#!/usr/bin/env php
|
#!/usr/bin/env php
|
||||||
<?php
|
<?php
|
||||||
|
|
||||||
/* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
|
/* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
|
||||||
*
|
*
|
||||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
@@ -50,7 +51,10 @@ class JoomlaBuildCli extends CliFramework
|
|||||||
// ── Find source directory ──────────────────────────────────────────────
|
// ── Find source directory ──────────────────────────────────────────────
|
||||||
$srcDir = null;
|
$srcDir = null;
|
||||||
foreach (['src', 'htdocs'] as $d) {
|
foreach (['src', 'htdocs'] as $d) {
|
||||||
if (is_dir("{$path}/{$d}")) { $srcDir = "{$path}/{$d}"; break; }
|
if (is_dir("{$path}/{$d}")) {
|
||||||
|
$srcDir = "{$path}/{$d}";
|
||||||
|
break;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if ($srcDir === null) {
|
if ($srcDir === null) {
|
||||||
$this->log('ERROR', "::error::No src/ or htdocs/ directory in {$path}");
|
$this->log('ERROR', "::error::No src/ or htdocs/ directory in {$path}");
|
||||||
@@ -72,7 +76,9 @@ class JoomlaBuildCli extends CliFramework
|
|||||||
// Resolve language-key names (e.g. PLG_SYSTEM_MOKOWAAS -> "System - Moko WaaS")
|
// Resolve language-key names (e.g. PLG_SYSTEM_MOKOWAAS -> "System - Moko WaaS")
|
||||||
if (preg_match('/^[A-Z_]+$/', $meta['name'])) {
|
if (preg_match('/^[A-Z_]+$/', $meta['name'])) {
|
||||||
$resolved = $this->resolveLanguageKey($srcDir, $meta['name']);
|
$resolved = $this->resolveLanguageKey($srcDir, $meta['name']);
|
||||||
if ($resolved !== null) { $meta['name'] = $resolved; }
|
if ($resolved !== null) {
|
||||||
|
$meta['name'] = $resolved;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
$prefix = $this->typePrefix($meta);
|
$prefix = $this->typePrefix($meta);
|
||||||
@@ -87,7 +93,9 @@ class JoomlaBuildCli extends CliFramework
|
|||||||
$this->log('INFO', " Output: {$zipName}");
|
$this->log('INFO', " Output: {$zipName}");
|
||||||
|
|
||||||
// ── Build ──────────────────────────────────────────────────────────────
|
// ── Build ──────────────────────────────────────────────────────────────
|
||||||
if (!is_dir($outputDir)) { mkdir($outputDir, 0755, true); }
|
if (!is_dir($outputDir)) {
|
||||||
|
mkdir($outputDir, 0755, true);
|
||||||
|
}
|
||||||
|
|
||||||
if ($meta['type'] === 'package') {
|
if ($meta['type'] === 'package') {
|
||||||
$this->buildPackageZip($srcDir, $zipPath);
|
$this->buildPackageZip($srcDir, $zipPath);
|
||||||
@@ -114,11 +122,15 @@ class JoomlaBuildCli extends CliFramework
|
|||||||
|
|
||||||
if ($ghOutput && ($ghFile = getenv('GITHUB_OUTPUT')) !== false && $ghFile !== '') {
|
if ($ghOutput && ($ghFile = getenv('GITHUB_OUTPUT')) !== false && $ghFile !== '') {
|
||||||
$fh = fopen($ghFile, 'a');
|
$fh = fopen($ghFile, 'a');
|
||||||
foreach ($vars as $k => $v) { fwrite($fh, "{$k}={$v}\n"); }
|
foreach ($vars as $k => $v) {
|
||||||
|
fwrite($fh, "{$k}={$v}\n");
|
||||||
|
}
|
||||||
fclose($fh);
|
fclose($fh);
|
||||||
$this->log('INFO', "Wrote " . count($vars) . " outputs to GITHUB_OUTPUT");
|
$this->log('INFO', "Wrote " . count($vars) . " outputs to GITHUB_OUTPUT");
|
||||||
} else {
|
} else {
|
||||||
foreach ($vars as $k => $v) { echo "{$k}={$v}\n"; }
|
foreach ($vars as $k => $v) {
|
||||||
|
echo "{$k}={$v}\n";
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return 0;
|
return 0;
|
||||||
@@ -131,9 +143,13 @@ class JoomlaBuildCli extends CliFramework
|
|||||||
private function findManifest(string $dir): ?string
|
private function findManifest(string $dir): ?string
|
||||||
{
|
{
|
||||||
// Priority: pkg_*.xml (packages), then any *.xml with <extension>
|
// Priority: pkg_*.xml (packages), then any *.xml with <extension>
|
||||||
foreach (glob("{$dir}/pkg_*.xml") ?: [] as $f) { return $f; }
|
foreach (glob("{$dir}/pkg_*.xml") ?: [] as $f) {
|
||||||
|
return $f;
|
||||||
|
}
|
||||||
foreach (glob("{$dir}/*.xml") ?: [] as $f) {
|
foreach (glob("{$dir}/*.xml") ?: [] as $f) {
|
||||||
if (str_contains((string) file_get_contents($f), '<extension')) { return $f; }
|
if (str_contains((string) file_get_contents($f), '<extension')) {
|
||||||
|
return $f;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
// Broader nested search
|
// Broader nested search
|
||||||
$iter = new RecursiveIteratorIterator(
|
$iter = new RecursiveIteratorIterator(
|
||||||
@@ -167,8 +183,12 @@ class JoomlaBuildCli extends CliFramework
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Fallback element detection
|
// Fallback element detection
|
||||||
if ($element === '') { $element = (string) ($xml->attributes()->plugin ?? ''); }
|
if ($element === '') {
|
||||||
if ($element === '') { $element = (string) ($xml->attributes()->module ?? ''); }
|
$element = (string) ($xml->attributes()->plugin ?? '');
|
||||||
|
}
|
||||||
|
if ($element === '') {
|
||||||
|
$element = (string) ($xml->attributes()->module ?? '');
|
||||||
|
}
|
||||||
if ($element === '') {
|
if ($element === '') {
|
||||||
$element = strtolower(basename($file, '.xml'));
|
$element = strtolower(basename($file, '.xml'));
|
||||||
if (in_array($element, ['templatedetails', 'manifest'], true)) {
|
if (in_array($element, ['templatedetails', 'manifest'], true)) {
|
||||||
@@ -179,7 +199,9 @@ class JoomlaBuildCli extends CliFramework
|
|||||||
// Strip existing type prefix to prevent duplication (e.g. pkg_mokowaas -> mokowaas)
|
// Strip existing type prefix to prevent duplication (e.g. pkg_mokowaas -> mokowaas)
|
||||||
$element = preg_replace('/^(pkg_|com_|mod_|plg_\w+_|tpl_|lib_)/', '', $element);
|
$element = preg_replace('/^(pkg_|com_|mod_|plg_\w+_|tpl_|lib_)/', '', $element);
|
||||||
|
|
||||||
if ($name === '') { $name = $element; }
|
if ($name === '') {
|
||||||
|
$name = $element;
|
||||||
|
}
|
||||||
|
|
||||||
return compact('name', 'type', 'element', 'group');
|
return compact('name', 'type', 'element', 'group');
|
||||||
}
|
}
|
||||||
@@ -216,10 +238,18 @@ class JoomlaBuildCli extends CliFramework
|
|||||||
|
|
||||||
private function isExcluded(string $name): bool
|
private function isExcluded(string $name): bool
|
||||||
{
|
{
|
||||||
if ($name === '.ftpignore') return true;
|
if ($name === '.ftpignore') {
|
||||||
if (str_starts_with($name, 'sftp-config')) return true;
|
return true;
|
||||||
if (str_starts_with($name, '.env')) return true;
|
}
|
||||||
if (str_starts_with($name, '.build-trigger')) return true;
|
if (str_starts_with($name, 'sftp-config')) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (str_starts_with($name, '.env')) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (str_starts_with($name, '.build-trigger')) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
$ext = pathinfo($name, PATHINFO_EXTENSION);
|
$ext = pathinfo($name, PATHINFO_EXTENSION);
|
||||||
return in_array($ext, ['ppk', 'pem', 'key', 'local'], true);
|
return in_array($ext, ['ppk', 'pem', 'key', 'local'], true);
|
||||||
}
|
}
|
||||||
@@ -237,7 +267,9 @@ class JoomlaBuildCli extends CliFramework
|
|||||||
);
|
);
|
||||||
foreach ($iter as $file) {
|
foreach ($iter as $file) {
|
||||||
$local = str_replace('\\', '/', substr($file->getPathname(), strlen($srcDir) + 1));
|
$local = str_replace('\\', '/', substr($file->getPathname(), strlen($srcDir) + 1));
|
||||||
if ($this->isExcluded(basename($local))) continue;
|
if ($this->isExcluded(basename($local))) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
$file->isDir() ? $zip->addEmptyDir($local) : $zip->addFile($file->getPathname(), $local);
|
$file->isDir() ? $zip->addEmptyDir($local) : $zip->addFile($file->getPathname(), $local);
|
||||||
}
|
}
|
||||||
$zip->close();
|
$zip->close();
|
||||||
@@ -268,8 +300,12 @@ class JoomlaBuildCli extends CliFramework
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 2. Copy package-level files (manifest, script, language)
|
// 2. Copy package-level files (manifest, script, language)
|
||||||
foreach (glob("{$srcDir}/*.xml") ?: [] as $f) copy($f, "{$staging}/" . basename($f));
|
foreach (glob("{$srcDir}/*.xml") ?: [] as $f) {
|
||||||
foreach (glob("{$srcDir}/*.php") ?: [] as $f) copy($f, "{$staging}/" . basename($f));
|
copy($f, "{$staging}/" . basename($f));
|
||||||
|
}
|
||||||
|
foreach (glob("{$srcDir}/*.php") ?: [] as $f) {
|
||||||
|
copy($f, "{$staging}/" . basename($f));
|
||||||
|
}
|
||||||
foreach (['language', 'administrator'] as $d) {
|
foreach (['language', 'administrator'] as $d) {
|
||||||
if (is_dir("{$srcDir}/{$d}")) {
|
if (is_dir("{$srcDir}/{$d}")) {
|
||||||
$this->copyTree("{$srcDir}/{$d}", "{$staging}/{$d}");
|
$this->copyTree("{$srcDir}/{$d}", "{$staging}/{$d}");
|
||||||
@@ -285,7 +321,9 @@ class JoomlaBuildCli extends CliFramework
|
|||||||
|
|
||||||
private function copyTree(string $src, string $dst): void
|
private function copyTree(string $src, string $dst): void
|
||||||
{
|
{
|
||||||
if (!is_dir($dst)) mkdir($dst, 0755, true);
|
if (!is_dir($dst)) {
|
||||||
|
mkdir($dst, 0755, true);
|
||||||
|
}
|
||||||
$iter = new RecursiveIteratorIterator(
|
$iter = new RecursiveIteratorIterator(
|
||||||
new RecursiveDirectoryIterator($src, FilesystemIterator::SKIP_DOTS),
|
new RecursiveDirectoryIterator($src, FilesystemIterator::SKIP_DOTS),
|
||||||
RecursiveIteratorIterator::SELF_FIRST
|
RecursiveIteratorIterator::SELF_FIRST
|
||||||
@@ -298,7 +336,9 @@ class JoomlaBuildCli extends CliFramework
|
|||||||
|
|
||||||
private function rmTree(string $dir): void
|
private function rmTree(string $dir): void
|
||||||
{
|
{
|
||||||
if (!is_dir($dir)) return;
|
if (!is_dir($dir)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
$iter = new RecursiveIteratorIterator(
|
$iter = new RecursiveIteratorIterator(
|
||||||
new RecursiveDirectoryIterator($dir, FilesystemIterator::SKIP_DOTS),
|
new RecursiveDirectoryIterator($dir, FilesystemIterator::SKIP_DOTS),
|
||||||
RecursiveIteratorIterator::CHILD_FIRST
|
RecursiveIteratorIterator::CHILD_FIRST
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
#!/usr/bin/env php
|
#!/usr/bin/env php
|
||||||
<?php
|
<?php
|
||||||
|
|
||||||
/* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
|
/* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
|
||||||
*
|
*
|
||||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
|||||||
+44
-17
@@ -1,5 +1,6 @@
|
|||||||
#!/usr/bin/env php
|
#!/usr/bin/env php
|
||||||
<?php
|
<?php
|
||||||
|
|
||||||
/* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
|
/* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
|
||||||
*
|
*
|
||||||
* This file is part of a Moko Consulting project.
|
* This file is part of a Moko Consulting project.
|
||||||
@@ -55,17 +56,17 @@ class JoomlaRelease extends CliFramework
|
|||||||
'stable' => '',
|
'stable' => '',
|
||||||
];
|
];
|
||||||
|
|
||||||
private ApiClient $api;
|
private ApiClient $api;
|
||||||
private \MokoEnterprise\GitPlatformAdapter $adapter;
|
private \MokoEnterprise\GitPlatformAdapter $adapter;
|
||||||
|
|
||||||
protected function configure(): void
|
protected function configure(): void
|
||||||
{
|
{
|
||||||
$this->setDescription('Joomla release pipeline — build packages, upload, update updates.xml');
|
$this->setDescription('Joomla release pipeline — build packages, upload, update updates.xml');
|
||||||
$this->addArgument('--repo', 'Repository name (e.g., MokoCassiopeia)', '');
|
$this->addArgument('--repo', 'Repository name (e.g., MokoCassiopeia)', '');
|
||||||
$this->addArgument('--path', 'Local repo path (alternative to --repo)', '.');
|
$this->addArgument('--path', 'Local repo path (alternative to --repo)', '.');
|
||||||
$this->addArgument('--stability', 'Stability level: development|alpha|beta|rc|stable', 'stable');
|
$this->addArgument('--stability', 'Stability level: development|alpha|beta|rc|stable', 'stable');
|
||||||
$this->addArgument('--dry-run', 'Preview without making changes', false);
|
$this->addArgument('--dry-run', 'Preview without making changes', false);
|
||||||
$this->addArgument('--verbose', 'Show detailed output', false);
|
$this->addArgument('--verbose', 'Show detailed output', false);
|
||||||
}
|
}
|
||||||
|
|
||||||
protected function run(): int
|
protected function run(): int
|
||||||
@@ -86,7 +87,9 @@ class JoomlaRelease extends CliFramework
|
|||||||
|
|
||||||
if ($repo !== '') {
|
if ($repo !== '') {
|
||||||
$path = $this->cloneRepo($repo);
|
$path = $this->cloneRepo($repo);
|
||||||
if ($path === null) { return 1; }
|
if ($path === null) {
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
$path = rtrim($path, '/\\');
|
$path = rtrim($path, '/\\');
|
||||||
|
|
||||||
@@ -191,7 +194,9 @@ class JoomlaRelease extends CliFramework
|
|||||||
private function findManifest(string $path): ?string
|
private function findManifest(string $path): ?string
|
||||||
{
|
{
|
||||||
foreach ([$path, "{$path}/src", "{$path}/htdocs"] as $dir) {
|
foreach ([$path, "{$path}/src", "{$path}/htdocs"] as $dir) {
|
||||||
if (!is_dir($dir)) { continue; }
|
if (!is_dir($dir)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
foreach (glob("{$dir}/*.xml") as $file) {
|
foreach (glob("{$dir}/*.xml") as $file) {
|
||||||
if (str_contains((string) file_get_contents($file), '<extension')) {
|
if (str_contains((string) file_get_contents($file), '<extension')) {
|
||||||
return $file;
|
return $file;
|
||||||
@@ -235,7 +240,9 @@ class JoomlaRelease extends CliFramework
|
|||||||
private function readVersion(string $path): ?string
|
private function readVersion(string $path): ?string
|
||||||
{
|
{
|
||||||
$readme = "{$path}/README.md";
|
$readme = "{$path}/README.md";
|
||||||
if (!is_file($readme)) { return null; }
|
if (!is_file($readme)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
if (preg_match('/VERSION:\s*(\d{2}\.\d{2}\.\d{2})/', file_get_contents($readme), $m)) {
|
if (preg_match('/VERSION:\s*(\d{2}\.\d{2}\.\d{2})/', file_get_contents($readme), $m)) {
|
||||||
return $m[1];
|
return $m[1];
|
||||||
}
|
}
|
||||||
@@ -301,8 +308,12 @@ class JoomlaRelease extends CliFramework
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 2. Copy package-level files (manifest, script, language)
|
// 2. Copy package-level files (manifest, script, language)
|
||||||
foreach (glob("{$srcDir}/*.xml") as $f) { copy($f, "{$staging}/" . basename($f)); }
|
foreach (glob("{$srcDir}/*.xml") as $f) {
|
||||||
foreach (glob("{$srcDir}/*.php") as $f) { copy($f, "{$staging}/" . basename($f)); }
|
copy($f, "{$staging}/" . basename($f));
|
||||||
|
}
|
||||||
|
foreach (glob("{$srcDir}/*.php") as $f) {
|
||||||
|
copy($f, "{$staging}/" . basename($f));
|
||||||
|
}
|
||||||
foreach (['language', 'administrator'] as $d) {
|
foreach (['language', 'administrator'] as $d) {
|
||||||
if (is_dir("{$srcDir}/{$d}")) {
|
if (is_dir("{$srcDir}/{$d}")) {
|
||||||
$this->copyDir("{$srcDir}/{$d}", "{$staging}/{$d}");
|
$this->copyDir("{$srcDir}/{$d}", "{$staging}/{$d}");
|
||||||
@@ -321,7 +332,9 @@ class JoomlaRelease extends CliFramework
|
|||||||
*/
|
*/
|
||||||
private function copyDir(string $src, string $dst): void
|
private function copyDir(string $src, string $dst): void
|
||||||
{
|
{
|
||||||
if (!is_dir($dst)) { mkdir($dst, 0755, true); }
|
if (!is_dir($dst)) {
|
||||||
|
mkdir($dst, 0755, true);
|
||||||
|
}
|
||||||
$iter = new \RecursiveIteratorIterator(
|
$iter = new \RecursiveIteratorIterator(
|
||||||
new \RecursiveDirectoryIterator($src, \FilesystemIterator::SKIP_DOTS),
|
new \RecursiveDirectoryIterator($src, \FilesystemIterator::SKIP_DOTS),
|
||||||
\RecursiveIteratorIterator::SELF_FIRST
|
\RecursiveIteratorIterator::SELF_FIRST
|
||||||
@@ -342,7 +355,9 @@ class JoomlaRelease extends CliFramework
|
|||||||
);
|
);
|
||||||
foreach ($iter as $file) {
|
foreach ($iter as $file) {
|
||||||
$local = str_replace('\\', '/', str_replace($srcDir . DIRECTORY_SEPARATOR, '', $file->getPathname()));
|
$local = str_replace('\\', '/', str_replace($srcDir . DIRECTORY_SEPARATOR, '', $file->getPathname()));
|
||||||
if ($this->isExcluded(basename($local))) { continue; }
|
if ($this->isExcluded(basename($local))) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
$file->isDir() ? $zip->addEmptyDir($local) : $zip->addFile($file->getPathname(), $local);
|
$file->isDir() ? $zip->addEmptyDir($local) : $zip->addFile($file->getPathname(), $local);
|
||||||
}
|
}
|
||||||
$zip->close();
|
$zip->close();
|
||||||
@@ -359,17 +374,29 @@ class JoomlaRelease extends CliFramework
|
|||||||
|
|
||||||
private function isExcluded(string $name): bool
|
private function isExcluded(string $name): bool
|
||||||
{
|
{
|
||||||
if ($name === '.ftpignore') { return true; }
|
if ($name === '.ftpignore') {
|
||||||
if (str_starts_with($name, 'sftp-config')) { return true; }
|
return true;
|
||||||
if (str_starts_with($name, '.env')) { return true; }
|
}
|
||||||
|
if (str_starts_with($name, 'sftp-config')) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (str_starts_with($name, '.env')) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
$ext = pathinfo($name, PATHINFO_EXTENSION);
|
$ext = pathinfo($name, PATHINFO_EXTENSION);
|
||||||
return in_array($ext, ['ppk', 'pem', 'key'], true);
|
return in_array($ext, ['ppk', 'pem', 'key'], true);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── GitHub Release ───────────────────────────────────────────────
|
// ── GitHub Release ───────────────────────────────────────────────
|
||||||
|
|
||||||
private function ensureRelease(string $repo, string $tag, string $version, string $stability, string $extName = '', string $packageName = ''): void
|
private function ensureRelease(
|
||||||
{
|
string $repo,
|
||||||
|
string $tag,
|
||||||
|
string $version,
|
||||||
|
string $stability,
|
||||||
|
string $extName = '',
|
||||||
|
string $packageName = ''
|
||||||
|
): void {
|
||||||
$releaseName = $extName !== ''
|
$releaseName = $extName !== ''
|
||||||
? "{$extName} {$version} ({$packageName})"
|
? "{$extName} {$version} ({$packageName})"
|
||||||
: (($stability === 'stable') ? "v" . explode('.', $version)[0] . " (latest: {$version})" : "{$tag} ({$version})");
|
: (($stability === 'stable') ? "v" . explode('.', $version)[0] . " (latest: {$version})" : "{$tag} ({$version})");
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
#!/usr/bin/env php
|
#!/usr/bin/env php
|
||||||
<?php
|
<?php
|
||||||
|
|
||||||
/* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
|
/* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
|
||||||
*
|
*
|
||||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
@@ -90,11 +91,13 @@ class LicenseManage extends CliFramework
|
|||||||
// Determine subcommand from argv
|
// Determine subcommand from argv
|
||||||
global $argv;
|
global $argv;
|
||||||
foreach ($argv as $arg) {
|
foreach ($argv as $arg) {
|
||||||
if (in_array($arg, [
|
if (
|
||||||
|
in_array($arg, [
|
||||||
'list', 'create-package', 'update-package', 'delete-package',
|
'list', 'create-package', 'update-package', 'delete-package',
|
||||||
'issue', 'revoke', 'activate', 'renew', 'validate',
|
'issue', 'revoke', 'activate', 'renew', 'validate',
|
||||||
'usage', 'master-key', 'keys', 'packages',
|
'usage', 'master-key', 'keys', 'packages',
|
||||||
], true)) {
|
], true)
|
||||||
|
) {
|
||||||
$this->subcommand = $arg;
|
$this->subcommand = $arg;
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|||||||
+164
-66
@@ -21,73 +21,171 @@ use MokoEnterprise\CliFramework;
|
|||||||
|
|
||||||
class ManifestElementCli extends CliFramework
|
class ManifestElementCli extends CliFramework
|
||||||
{
|
{
|
||||||
protected function configure(): void
|
protected function configure(): void
|
||||||
{
|
{
|
||||||
$this->setDescription('Extract element name, type, type prefix, and ZIP name from manifest');
|
$this->setDescription('Extract element name, type, type prefix, and ZIP name from manifest');
|
||||||
$this->addArgument('--path', 'Repository root', '.');
|
$this->addArgument('--path', 'Repository root', '.');
|
||||||
$this->addArgument('--version', 'Version string', null);
|
$this->addArgument('--version', 'Version string', null);
|
||||||
$this->addArgument('--stability', 'Stability level', 'stable');
|
$this->addArgument('--stability', 'Stability level', 'stable');
|
||||||
$this->addArgument('--repo', 'Repository name', '');
|
$this->addArgument('--repo', 'Repository name', '');
|
||||||
$this->addArgument('--github-output', 'Export results to $GITHUB_OUTPUT', false);
|
$this->addArgument('--github-output', 'Export results to $GITHUB_OUTPUT', false);
|
||||||
}
|
}
|
||||||
|
|
||||||
protected function run(): int
|
protected function run(): int
|
||||||
{
|
{
|
||||||
$path = $this->getArgument('--path'); $version = $this->getArgument('--version');
|
$path = $this->getArgument('--path');
|
||||||
$stability = $this->getArgument('--stability'); $repoName = $this->getArgument('--repo');
|
$version = $this->getArgument('--version');
|
||||||
$githubOutput = (bool) $this->getArgument('--github-output');
|
$stability = $this->getArgument('--stability');
|
||||||
$root = realpath($path) ?: $path;
|
$repoName = $this->getArgument('--repo');
|
||||||
$platform = 'generic';
|
$githubOutput = (bool) $this->getArgument('--github-output');
|
||||||
$manifestXml = "{$root}/.mokogitea/manifest.xml";
|
$root = realpath($path) ?: $path;
|
||||||
if (file_exists($manifestXml)) { $content = file_get_contents($manifestXml); if (preg_match('/<platform>([^<]+)<\/platform>/', $content, $pm)) { $platform = trim($pm[1]); } }
|
$platform = 'generic';
|
||||||
$extManifest = null;
|
$manifestXml = "{$root}/.mokogitea/manifest.xml";
|
||||||
$manifestFiles = array_merge(glob("{$root}/src/pkg_*.xml") ?: [], glob("{$root}/src/*.xml") ?: [], glob("{$root}/*.xml") ?: []);
|
if (file_exists($manifestXml)) {
|
||||||
foreach ($manifestFiles as $file) { $c = file_get_contents($file); if (strpos($c, '<extension') !== false) { $extManifest = $file; break; } }
|
$content = file_get_contents($manifestXml);
|
||||||
$modFile = null;
|
if (preg_match('/<platform>([^<]+)<\/platform>/', $content, $pm)) {
|
||||||
$modFiles = array_merge(glob("{$root}/src/core/modules/mod*.class.php") ?: [], glob("{$root}/htdocs/core/modules/mod*.class.php") ?: [], glob("{$root}/core/modules/mod*.class.php") ?: []);
|
$platform = trim($pm[1]);
|
||||||
foreach ($modFiles as $file) { $c = file_get_contents($file); if (strpos($c, 'extends DolibarrModules') !== false) { $modFile = $file; break; } }
|
}
|
||||||
$extElement = ''; $extType = ''; $extFolder = ''; $extName = '';
|
}
|
||||||
switch (true) {
|
$extManifest = null;
|
||||||
case in_array($platform, ['joomla', 'waas-component'], true) && $extManifest !== null:
|
$manifestFiles = array_merge(glob("{$root}/src/pkg_*.xml") ?: [], glob("{$root}/src/*.xml") ?: [], glob("{$root}/*.xml") ?: []);
|
||||||
$xml = file_get_contents($extManifest);
|
foreach ($manifestFiles as $file) {
|
||||||
if (preg_match('/type="([^"]*)"/', $xml, $tm)) { $extType = $tm[1]; }
|
$c = file_get_contents($file);
|
||||||
if (preg_match('/group="([^"]*)"/', $xml, $gm)) { $extFolder = $gm[1]; }
|
if (strpos($c, '<extension') !== false) {
|
||||||
if (preg_match('/<element>([^<]+)<\/element>/', $xml, $em)) { $extElement = $em[1]; }
|
$extManifest = $file;
|
||||||
if (empty($extElement) && preg_match('/module="([^"]*)"/', $xml, $mm)) { $extElement = $mm[1]; }
|
break;
|
||||||
if (empty($extElement) && preg_match('/plugin="([^"]*)"/', $xml, $pm2)) { $extElement = $pm2[1]; }
|
}
|
||||||
if ($extType === 'package' && preg_match('/<packagename>([^<]+)<\/packagename>/', $xml, $pn)) { $extElement = $pn[1]; }
|
}
|
||||||
if (empty($extElement)) { $extElement = strtolower(basename($extManifest, '.xml')); if (in_array($extElement, ['templatedetails', 'manifest'], true)) { $extElement = strtolower(str_replace([' ', '-'], '', $repoName ?: basename($root))); } }
|
$modFile = null;
|
||||||
if (preg_match('/<name>([^<]+)<\/name>/', $xml, $nm)) { $extName = trim($nm[1]); }
|
$modFiles = array_merge(
|
||||||
break;
|
glob("{$root}/src/core/modules/mod*.class.php") ?: [],
|
||||||
case in_array($platform, ['dolibarr', 'crm-module'], true) && $modFile !== null:
|
glob("{$root}/htdocs/core/modules/mod*.class.php") ?: [],
|
||||||
$extType = 'dolibarr-module'; $modBasename = basename($modFile, '.class.php');
|
glob("{$root}/core/modules/mod*.class.php") ?: []
|
||||||
$extElement = strtolower(preg_replace('/^mod/', '', $modBasename));
|
);
|
||||||
$modContent = file_get_contents($modFile);
|
foreach ($modFiles as $file) {
|
||||||
if (preg_match('/\$this->name\s*=\s*[\'"]([^\'"]+)[\'"]/', $modContent, $nm)) { $extName = $nm[1]; }
|
$c = file_get_contents($file);
|
||||||
break;
|
if (strpos($c, 'extends DolibarrModules') !== false) {
|
||||||
default:
|
$modFile = $file;
|
||||||
$extElement = strtolower(str_replace([' ', '-'], '', $repoName ?: basename($root))); $extType = 'generic'; break;
|
break;
|
||||||
}
|
}
|
||||||
$extElement = preg_replace('/^(pkg_|com_|mod_|plg_[a-z]+_|tpl_|lib_)/', '', $extElement);
|
}
|
||||||
$typePrefix = '';
|
$extElement = '';
|
||||||
switch ($extType) {
|
$extType = '';
|
||||||
case 'plugin': $typePrefix = "plg_{$extFolder}_"; break; case 'module': $typePrefix = 'mod_'; break;
|
$extFolder = '';
|
||||||
case 'component': $typePrefix = 'com_'; break; case 'template': $typePrefix = 'tpl_'; break;
|
$extName = '';
|
||||||
case 'library': $typePrefix = 'lib_'; break; case 'package': $typePrefix = 'pkg_'; break;
|
switch (true) {
|
||||||
}
|
case in_array($platform, ['joomla', 'waas-component'], true) && $extManifest !== null:
|
||||||
$suffixMap = ['development' => '-dev', 'dev' => '-dev', 'alpha' => '-alpha', 'beta' => '-beta', 'rc' => '-rc', 'release-candidate' => '-rc', 'stable' => ''];
|
$xml = file_get_contents($extManifest);
|
||||||
$suffix = $suffixMap[$stability] ?? ''; $zipName = '';
|
if (preg_match('/type="([^"]*)"/', $xml, $tm)) {
|
||||||
if ($version !== null) { $zipName = "{$typePrefix}{$extElement}-{$version}{$suffix}.zip"; }
|
$extType = $tm[1];
|
||||||
if (empty($extName)) { $extName = $repoName ?: basename($root); }
|
}
|
||||||
$outputs = ['platform' => $platform, 'ext_element' => $extElement, 'ext_type' => $extType, 'ext_folder' => $extFolder, 'ext_name' => $extName, 'type_prefix' => $typePrefix, 'zip_name' => $zipName];
|
if (preg_match('/group="([^"]*)"/', $xml, $gm)) {
|
||||||
if ($githubOutput) {
|
$extFolder = $gm[1];
|
||||||
$ghOutput = getenv('GITHUB_OUTPUT'); $lines = [];
|
}
|
||||||
foreach ($outputs as $key => $value) { $lines[] = "{$key}={$value}"; }
|
if (preg_match('/<element>([^<]+)<\/element>/', $xml, $em)) {
|
||||||
if ($ghOutput) { file_put_contents($ghOutput, implode("\n", $lines) . "\n", FILE_APPEND); }
|
$extElement = $em[1];
|
||||||
else { foreach ($outputs as $key => $value) { echo "::set-output name={$key}::{$value}\n"; } }
|
}
|
||||||
} else { foreach ($outputs as $key => $value) { echo "{$key}={$value}\n"; } }
|
if (empty($extElement) && preg_match('/module="([^"]*)"/', $xml, $mm)) {
|
||||||
return 0;
|
$extElement = $mm[1];
|
||||||
}
|
}
|
||||||
|
if (empty($extElement) && preg_match('/plugin="([^"]*)"/', $xml, $pm2)) {
|
||||||
|
$extElement = $pm2[1];
|
||||||
|
}
|
||||||
|
if ($extType === 'package' && preg_match('/<packagename>([^<]+)<\/packagename>/', $xml, $pn)) {
|
||||||
|
$extElement = $pn[1];
|
||||||
|
}
|
||||||
|
if (empty($extElement)) {
|
||||||
|
$extElement = strtolower(basename($extManifest, '.xml'));
|
||||||
|
if (in_array($extElement, ['templatedetails', 'manifest'], true)) {
|
||||||
|
$extElement = strtolower(str_replace([' ', '-'], '', $repoName ?: basename($root)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (preg_match('/<name>([^<]+)<\/name>/', $xml, $nm)) {
|
||||||
|
$extName = trim($nm[1]);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case in_array($platform, ['dolibarr', 'crm-module'], true) && $modFile !== null:
|
||||||
|
$extType = 'dolibarr-module';
|
||||||
|
$modBasename = basename($modFile, '.class.php');
|
||||||
|
$extElement = strtolower(preg_replace('/^mod/', '', $modBasename));
|
||||||
|
$modContent = file_get_contents($modFile);
|
||||||
|
if (preg_match('/\$this->name\s*=\s*[\'"]([^\'"]+)[\'"]/', $modContent, $nm)) {
|
||||||
|
$extName = $nm[1];
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
$extElement = strtolower(str_replace([' ', '-'], '', $repoName ?: basename($root)));
|
||||||
|
$extType = 'generic';
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
$extElement = preg_replace('/^(pkg_|com_|mod_|plg_[a-z]+_|tpl_|lib_)/', '', $extElement);
|
||||||
|
$typePrefix = '';
|
||||||
|
switch ($extType) {
|
||||||
|
case 'plugin':
|
||||||
|
$typePrefix = "plg_{$extFolder}_";
|
||||||
|
break;
|
||||||
|
case 'module':
|
||||||
|
$typePrefix = 'mod_';
|
||||||
|
break;
|
||||||
|
case 'component':
|
||||||
|
$typePrefix = 'com_';
|
||||||
|
break;
|
||||||
|
case 'template':
|
||||||
|
$typePrefix = 'tpl_';
|
||||||
|
break;
|
||||||
|
case 'library':
|
||||||
|
$typePrefix = 'lib_';
|
||||||
|
break;
|
||||||
|
case 'package':
|
||||||
|
$typePrefix = 'pkg_';
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
$suffixMap = [
|
||||||
|
'development' => '-dev',
|
||||||
|
'dev' => '-dev',
|
||||||
|
'alpha' => '-alpha',
|
||||||
|
'beta' => '-beta',
|
||||||
|
'rc' => '-rc',
|
||||||
|
'release-candidate' => '-rc',
|
||||||
|
'stable' => '',
|
||||||
|
];
|
||||||
|
$suffix = $suffixMap[$stability] ?? '';
|
||||||
|
$zipName = '';
|
||||||
|
if ($version !== null) {
|
||||||
|
$zipName = "{$typePrefix}{$extElement}-{$version}{$suffix}.zip";
|
||||||
|
}
|
||||||
|
if (empty($extName)) {
|
||||||
|
$extName = $repoName ?: basename($root);
|
||||||
|
}
|
||||||
|
$outputs = [
|
||||||
|
'platform' => $platform,
|
||||||
|
'ext_element' => $extElement,
|
||||||
|
'ext_type' => $extType,
|
||||||
|
'ext_folder' => $extFolder,
|
||||||
|
'ext_name' => $extName,
|
||||||
|
'type_prefix' => $typePrefix,
|
||||||
|
'zip_name' => $zipName,
|
||||||
|
];
|
||||||
|
if ($githubOutput) {
|
||||||
|
$ghOutput = getenv('GITHUB_OUTPUT');
|
||||||
|
$lines = [];
|
||||||
|
foreach ($outputs as $key => $value) {
|
||||||
|
$lines[] = "{$key}={$value}";
|
||||||
|
}
|
||||||
|
if ($ghOutput) {
|
||||||
|
file_put_contents($ghOutput, implode("\n", $lines) . "\n", FILE_APPEND);
|
||||||
|
} else {
|
||||||
|
foreach ($outputs as $key => $value) {
|
||||||
|
echo "::set-output name={$key}::{$value}\n";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
foreach ($outputs as $key => $value) {
|
||||||
|
echo "{$key}={$value}\n";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
$app = new ManifestElementCli();
|
$app = new ManifestElementCli();
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
#!/usr/bin/env php
|
#!/usr/bin/env php
|
||||||
<?php
|
<?php
|
||||||
|
|
||||||
/* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
|
/* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
|
||||||
*
|
*
|
||||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
#!/usr/bin/env php
|
#!/usr/bin/env php
|
||||||
<?php
|
<?php
|
||||||
|
|
||||||
/* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
|
/* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
|
||||||
*
|
*
|
||||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
#!/usr/bin/env php
|
#!/usr/bin/env php
|
||||||
<?php
|
<?php
|
||||||
|
|
||||||
/* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
|
/* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
|
||||||
*
|
*
|
||||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
#!/usr/bin/env php
|
#!/usr/bin/env php
|
||||||
<?php
|
<?php
|
||||||
|
|
||||||
/* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
|
/* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
|
||||||
*
|
*
|
||||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
#!/usr/bin/env php
|
#!/usr/bin/env php
|
||||||
<?php
|
<?php
|
||||||
|
|
||||||
/* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
|
/* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
|
||||||
*
|
*
|
||||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
|||||||
+146
-80
@@ -1,5 +1,6 @@
|
|||||||
#!/usr/bin/env php
|
#!/usr/bin/env php
|
||||||
<?php
|
<?php
|
||||||
|
|
||||||
/* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
|
/* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
|
||||||
*
|
*
|
||||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
@@ -20,89 +21,154 @@ use MokoEnterprise\CliFramework;
|
|||||||
|
|
||||||
class ReleaseManageCli extends CliFramework
|
class ReleaseManageCli extends CliFramework
|
||||||
{
|
{
|
||||||
protected function configure(): void
|
protected function configure(): void
|
||||||
{
|
{
|
||||||
$this->setDescription('Create/update Gitea releases, upload assets, update release body');
|
$this->setDescription('Create/update Gitea releases, upload assets, update release body');
|
||||||
$this->addArgument('--action', 'create | upload | update-body | delete', null);
|
$this->addArgument('--action', 'create | upload | update-body | delete', null);
|
||||||
$this->addArgument('--tag', 'Release tag name', null);
|
$this->addArgument('--tag', 'Release tag name', null);
|
||||||
$this->addArgument('--name', 'Release name/title', null);
|
$this->addArgument('--name', 'Release name/title', null);
|
||||||
$this->addArgument('--body', 'Release body/description', null);
|
$this->addArgument('--body', 'Release body/description', null);
|
||||||
$this->addArgument('--body-file', 'Read body from file', null);
|
$this->addArgument('--body-file', 'Read body from file', null);
|
||||||
$this->addArgument('--target', 'Target branch/commitish', 'main');
|
$this->addArgument('--target', 'Target branch/commitish', 'main');
|
||||||
$this->addArgument('--files', 'Comma-separated file paths to upload', null);
|
$this->addArgument('--files', 'Comma-separated file paths to upload', null);
|
||||||
$this->addArgument('--token', 'Gitea API token', null);
|
$this->addArgument('--token', 'Gitea API token', null);
|
||||||
$this->addArgument('--api-base', 'Gitea API base URL', null);
|
$this->addArgument('--api-base', 'Gitea API base URL', null);
|
||||||
}
|
}
|
||||||
|
|
||||||
protected function run(): int
|
protected function run(): int
|
||||||
{
|
{
|
||||||
$action = $this->getArgument('--action'); $tag = $this->getArgument('--tag');
|
$action = $this->getArgument('--action');
|
||||||
$name = $this->getArgument('--name'); $body = $this->getArgument('--body');
|
$tag = $this->getArgument('--tag');
|
||||||
$bodyFile = $this->getArgument('--body-file'); $target = $this->getArgument('--target');
|
$name = $this->getArgument('--name');
|
||||||
$filesArg = $this->getArgument('--files'); $token = $this->getArgument('--token');
|
$body = $this->getArgument('--body');
|
||||||
$apiBase = $this->getArgument('--api-base');
|
$bodyFile = $this->getArgument('--body-file');
|
||||||
$files = $filesArg !== null ? array_filter(explode(',', $filesArg)) : [];
|
$target = $this->getArgument('--target');
|
||||||
if ($token === null) { $token = getenv('MOKOGITEA_TOKEN') ?: getenv('GITEA_TOKEN') ?: null; }
|
$filesArg = $this->getArgument('--files');
|
||||||
if ($bodyFile !== null && file_exists($bodyFile)) { $body = file_get_contents($bodyFile); }
|
$token = $this->getArgument('--token');
|
||||||
if ($action === null || $tag === null || $token === null || $apiBase === null) { $this->log('ERROR', "Usage: release_manage.php --action [create|upload|update-body|delete] --tag TAG --token TOKEN --api-base URL"); return 1; }
|
$apiBase = $this->getArgument('--api-base');
|
||||||
switch ($action) {
|
$files = $filesArg !== null ? array_filter(explode(',', $filesArg)) : [];
|
||||||
case 'create':
|
if ($token === null) {
|
||||||
$existing = $this->getReleaseByTag($apiBase, $tag, $token);
|
$token = getenv('MOKOGITEA_TOKEN') ?: getenv('GITEA_TOKEN') ?: null;
|
||||||
if ($existing !== null) { $existingId = $existing['id']; $this->releaseGiteaApi("{$apiBase}/releases/{$existingId}", 'DELETE', $token); $this->releaseGiteaApi("{$apiBase}/tags/{$tag}", 'DELETE', $token); echo "Deleted previous release: {$tag} (id: {$existingId})\n"; }
|
}
|
||||||
$payload = json_encode(['tag_name' => $tag, 'name' => $name ?? $tag, 'body' => $body ?? '', 'target_commitish' => $target]);
|
if ($bodyFile !== null && file_exists($bodyFile)) {
|
||||||
$result = $this->releaseGiteaApi("{$apiBase}/releases", 'POST', $token, $payload);
|
$body = file_get_contents($bodyFile);
|
||||||
if ($result['code'] >= 200 && $result['code'] < 300) { $releaseId = $result['data']['id'] ?? 'unknown'; echo "Release created: {$name} (tag: {$tag}, id: {$releaseId})\n"; }
|
}
|
||||||
else { $this->log('ERROR', "Failed to create release: HTTP {$result['code']}"); return 1; }
|
if ($action === null || $tag === null || $token === null || $apiBase === null) {
|
||||||
break;
|
$this->log('ERROR', "Usage: release_manage.php --action [create|upload|update-body|delete] --tag TAG --token TOKEN --api-base URL");
|
||||||
case 'upload':
|
return 1;
|
||||||
if (empty($files)) { $this->log('ERROR', "No files specified. Use --files /path/to/file1,/path/to/file2"); return 1; }
|
}
|
||||||
$release = $this->getReleaseByTag($apiBase, $tag, $token);
|
switch ($action) {
|
||||||
if ($release === null) { $this->log('ERROR', "No release found for tag: {$tag}"); return 1; }
|
case 'create':
|
||||||
$releaseId = $release['id'];
|
$existing = $this->getReleaseByTag($apiBase, $tag, $token);
|
||||||
$assetsResult = $this->releaseGiteaApi("{$apiBase}/releases/{$releaseId}/assets", 'GET', $token);
|
if ($existing !== null) {
|
||||||
$existingAssets = $assetsResult['data'] ?? [];
|
$existingId = $existing['id'];
|
||||||
foreach ($files as $filePath) {
|
$this->releaseGiteaApi("{$apiBase}/releases/{$existingId}", 'DELETE', $token);
|
||||||
$filePath = trim($filePath); if (!file_exists($filePath)) { $this->log('ERROR', "File not found: {$filePath}"); continue; }
|
$this->releaseGiteaApi("{$apiBase}/tags/{$tag}", 'DELETE', $token);
|
||||||
$fileName = basename($filePath);
|
echo "Deleted previous release: {$tag} (id: {$existingId})\n";
|
||||||
foreach ($existingAssets as $asset) { if (($asset['name'] ?? '') === $fileName) { $this->releaseGiteaApi("{$apiBase}/releases/{$releaseId}/assets/{$asset['id']}", 'DELETE', $token); echo "Deleted existing asset: {$fileName}\n"; break; } }
|
}
|
||||||
$uploadUrl = "{$apiBase}/releases/{$releaseId}/assets?name=" . urlencode($fileName);
|
$payload = json_encode(['tag_name' => $tag, 'name' => $name ?? $tag, 'body' => $body ?? '', 'target_commitish' => $target]);
|
||||||
$result = $this->releaseGiteaApi($uploadUrl, 'POST', $token, null, $filePath);
|
$result = $this->releaseGiteaApi("{$apiBase}/releases", 'POST', $token, $payload);
|
||||||
if ($result['code'] >= 200 && $result['code'] < 300) { echo "Uploaded: {$fileName}\n"; } else { $this->log('ERROR', "Failed to upload {$fileName}: HTTP {$result['code']}"); }
|
if ($result['code'] >= 200 && $result['code'] < 300) {
|
||||||
}
|
$releaseId = $result['data']['id'] ?? 'unknown';
|
||||||
break;
|
echo "Release created: {$name} (tag: {$tag}, id: {$releaseId})\n";
|
||||||
case 'update-body':
|
} else {
|
||||||
$release = $this->getReleaseByTag($apiBase, $tag, $token);
|
$this->log('ERROR', "Failed to create release: HTTP {$result['code']}");
|
||||||
if ($release === null) { $this->log('ERROR', "No release found for tag: {$tag}"); return 1; }
|
return 1;
|
||||||
$payload = json_encode(['body' => $body ?? '']);
|
}
|
||||||
$result = $this->releaseGiteaApi("{$apiBase}/releases/{$release['id']}", 'PATCH', $token, $payload);
|
break;
|
||||||
if ($result['code'] >= 200 && $result['code'] < 300) { echo "Release body updated for tag: {$tag}\n"; } else { $this->log('ERROR', "Failed to update body: HTTP {$result['code']}"); return 1; }
|
case 'upload':
|
||||||
break;
|
if (empty($files)) {
|
||||||
case 'delete':
|
$this->log('ERROR', "No files specified. Use --files /path/to/file1,/path/to/file2");
|
||||||
$existing = $this->getReleaseByTag($apiBase, $tag, $token);
|
return 1;
|
||||||
if ($existing !== null) { $this->releaseGiteaApi("{$apiBase}/releases/{$existing['id']}", 'DELETE', $token); $this->releaseGiteaApi("{$apiBase}/tags/{$tag}", 'DELETE', $token); echo "Deleted: {$tag} (id: {$existing['id']})\n"; }
|
}
|
||||||
else { echo "No release found for tag: {$tag}\n"; }
|
$release = $this->getReleaseByTag($apiBase, $tag, $token);
|
||||||
break;
|
if ($release === null) {
|
||||||
default: $this->log('ERROR', "Unknown action: {$action}"); return 1;
|
$this->log('ERROR', "No release found for tag: {$tag}");
|
||||||
}
|
return 1;
|
||||||
return 0;
|
}
|
||||||
}
|
$releaseId = $release['id'];
|
||||||
|
$assetsResult = $this->releaseGiteaApi("{$apiBase}/releases/{$releaseId}/assets", 'GET', $token);
|
||||||
|
$existingAssets = $assetsResult['data'] ?? [];
|
||||||
|
foreach ($files as $filePath) {
|
||||||
|
$filePath = trim($filePath);
|
||||||
|
if (!file_exists($filePath)) {
|
||||||
|
$this->log('ERROR', "File not found: {$filePath}");
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$fileName = basename($filePath);
|
||||||
|
foreach ($existingAssets as $asset) {
|
||||||
|
if (($asset['name'] ?? '') === $fileName) {
|
||||||
|
$this->releaseGiteaApi("{$apiBase}/releases/{$releaseId}/assets/{$asset['id']}", 'DELETE', $token);
|
||||||
|
echo "Deleted existing asset: {$fileName}\n";
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
$uploadUrl = "{$apiBase}/releases/{$releaseId}/assets?name=" . urlencode($fileName);
|
||||||
|
$result = $this->releaseGiteaApi($uploadUrl, 'POST', $token, null, $filePath);
|
||||||
|
if ($result['code'] >= 200 && $result['code'] < 300) {
|
||||||
|
echo "Uploaded: {$fileName}\n";
|
||||||
|
} else {
|
||||||
|
$this->log('ERROR', "Failed to upload {$fileName}: HTTP {$result['code']}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case 'update-body':
|
||||||
|
$release = $this->getReleaseByTag($apiBase, $tag, $token);
|
||||||
|
if ($release === null) {
|
||||||
|
$this->log('ERROR', "No release found for tag: {$tag}");
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
$payload = json_encode(['body' => $body ?? '']);
|
||||||
|
$result = $this->releaseGiteaApi("{$apiBase}/releases/{$release['id']}", 'PATCH', $token, $payload);
|
||||||
|
if ($result['code'] >= 200 && $result['code'] < 300) {
|
||||||
|
echo "Release body updated for tag: {$tag}\n";
|
||||||
|
} else {
|
||||||
|
$this->log('ERROR', "Failed to update body: HTTP {$result['code']}");
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case 'delete':
|
||||||
|
$existing = $this->getReleaseByTag($apiBase, $tag, $token);
|
||||||
|
if ($existing !== null) {
|
||||||
|
$this->releaseGiteaApi("{$apiBase}/releases/{$existing['id']}", 'DELETE', $token);
|
||||||
|
$this->releaseGiteaApi("{$apiBase}/tags/{$tag}", 'DELETE', $token);
|
||||||
|
echo "Deleted: {$tag} (id: {$existing['id']})\n";
|
||||||
|
} else {
|
||||||
|
echo "No release found for tag: {$tag}\n";
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
$this->log('ERROR', "Unknown action: {$action}");
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
private function releaseGiteaApi(string $url, string $method, string $token, ?string $jsonBody = null, ?string $filePath = null): array
|
private function releaseGiteaApi(string $url, string $method, string $token, ?string $jsonBody = null, ?string $filePath = null): array
|
||||||
{
|
{
|
||||||
$ch = curl_init($url); $headers = ["Authorization: token {$token}"];
|
$ch = curl_init($url);
|
||||||
$opts = [CURLOPT_RETURNTRANSFER => true, CURLOPT_TIMEOUT => 60, CURLOPT_CUSTOMREQUEST => $method];
|
$headers = ["Authorization: token {$token}"];
|
||||||
if ($jsonBody !== null) { $headers[] = 'Content-Type: application/json'; $opts[CURLOPT_POSTFIELDS] = $jsonBody; }
|
$opts = [CURLOPT_RETURNTRANSFER => true, CURLOPT_TIMEOUT => 60, CURLOPT_CUSTOMREQUEST => $method];
|
||||||
elseif ($filePath !== null) { $headers[] = 'Content-Type: application/octet-stream'; $opts[CURLOPT_POSTFIELDS] = file_get_contents($filePath); }
|
if ($jsonBody !== null) {
|
||||||
$opts[CURLOPT_HTTPHEADER] = $headers; curl_setopt_array($ch, $opts);
|
$headers[] = 'Content-Type: application/json';
|
||||||
$response = curl_exec($ch); $httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE); curl_close($ch);
|
$opts[CURLOPT_POSTFIELDS] = $jsonBody;
|
||||||
return ['code' => $httpCode, 'data' => json_decode($response ?: '{}', true) ?: []];
|
} elseif ($filePath !== null) {
|
||||||
}
|
$headers[] = 'Content-Type: application/octet-stream';
|
||||||
|
$opts[CURLOPT_POSTFIELDS] = file_get_contents($filePath);
|
||||||
|
}
|
||||||
|
$opts[CURLOPT_HTTPHEADER] = $headers;
|
||||||
|
curl_setopt_array($ch, $opts);
|
||||||
|
$response = curl_exec($ch);
|
||||||
|
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||||||
|
curl_close($ch);
|
||||||
|
return ['code' => $httpCode, 'data' => json_decode($response ?: '{}', true) ?: []];
|
||||||
|
}
|
||||||
|
|
||||||
private function getReleaseByTag(string $apiBase, string $tag, string $token): ?array
|
private function getReleaseByTag(string $apiBase, string $tag, string $token): ?array
|
||||||
{
|
{
|
||||||
$result = $this->releaseGiteaApi("{$apiBase}/releases/tags/{$tag}", 'GET', $token);
|
$result = $this->releaseGiteaApi("{$apiBase}/releases/tags/{$tag}", 'GET', $token);
|
||||||
return ($result['code'] === 200 && isset($result['data']['id'])) ? $result['data'] : null;
|
return ($result['code'] === 200 && isset($result['data']['id'])) ? $result['data'] : null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
$app = new ReleaseManageCli();
|
$app = new ReleaseManageCli();
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
#!/usr/bin/env php
|
#!/usr/bin/env php
|
||||||
<?php
|
<?php
|
||||||
|
|
||||||
/* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
|
/* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
|
||||||
*
|
*
|
||||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
|||||||
+497
-497
File diff suppressed because it is too large
Load Diff
+100
-24
@@ -1,5 +1,6 @@
|
|||||||
#!/usr/bin/env php
|
#!/usr/bin/env php
|
||||||
<?php
|
<?php
|
||||||
|
|
||||||
/* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
|
/* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
|
||||||
*
|
*
|
||||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
@@ -61,10 +62,18 @@ class ReleasePublishCli extends CliFramework
|
|||||||
|
|
||||||
// Auto-detect org/repo from git remote if not set
|
// Auto-detect org/repo from git remote if not set
|
||||||
if (empty($org) || empty($repo)) {
|
if (empty($org) || empty($repo)) {
|
||||||
$remote = trim((string) @shell_exec((PHP_OS_FAMILY === 'Windows' ? "cd /d " : "cd ") . escapeshellarg($resolvedPath) . " && git remote get-url origin 2>/dev/null"));
|
$cd = PHP_OS_FAMILY === 'Windows' ? "cd /d " : "cd ";
|
||||||
|
$remote = trim((string) @shell_exec(
|
||||||
|
$cd . escapeshellarg($resolvedPath)
|
||||||
|
. " && git remote get-url origin 2>/dev/null"
|
||||||
|
));
|
||||||
if (preg_match('#/([^/]+)/([^/.]+?)(?:\.git)?$#', $remote, $m)) {
|
if (preg_match('#/([^/]+)/([^/.]+?)(?:\.git)?$#', $remote, $m)) {
|
||||||
if (empty($org)) $org = $m[1];
|
if (empty($org)) {
|
||||||
if (empty($repo)) $repo = $m[2];
|
$org = $m[1];
|
||||||
|
}
|
||||||
|
if (empty($repo)) {
|
||||||
|
$repo = $m[2];
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -76,7 +85,12 @@ class ReleasePublishCli extends CliFramework
|
|||||||
|
|
||||||
// Auto-detect branch
|
// Auto-detect branch
|
||||||
if (empty($branch)) {
|
if (empty($branch)) {
|
||||||
$branch = getenv('GITHUB_REF_NAME') ?: trim((string) @shell_exec((PHP_OS_FAMILY === 'Windows' ? "cd /d " : "cd ") . escapeshellarg($resolvedPath) . " && git rev-parse --abbrev-ref HEAD 2>/dev/null"));
|
$cdCmd = PHP_OS_FAMILY === 'Windows' ? "cd /d " : "cd ";
|
||||||
|
$branch = getenv('GITHUB_REF_NAME')
|
||||||
|
?: trim((string) @shell_exec(
|
||||||
|
$cdCmd . escapeshellarg($resolvedPath)
|
||||||
|
. " && git rev-parse --abbrev-ref HEAD 2>/dev/null"
|
||||||
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
$apiBase = "{$giteaUrl}/api/v1/repos/{$org}/{$repo}";
|
$apiBase = "{$giteaUrl}/api/v1/repos/{$org}/{$repo}";
|
||||||
@@ -145,12 +159,23 @@ class ReleasePublishCli extends CliFramework
|
|||||||
|
|
||||||
// -- Step 2b: Update badges and changelog --
|
// -- Step 2b: Update badges and changelog --
|
||||||
if (!$this->dryRun) {
|
if (!$this->dryRun) {
|
||||||
passthru("{$php} {$cli}/badge_update.php --path " . escapeshellarg($path) . " --version " . escapeshellarg($baseVersion) . " 2>/dev/null");
|
passthru(
|
||||||
|
"{$php} {$cli}/badge_update.php --path "
|
||||||
|
. escapeshellarg($path) . " --version "
|
||||||
|
. escapeshellarg($baseVersion) . " 2>/dev/null"
|
||||||
|
);
|
||||||
|
|
||||||
$changelogFile = realpath($path) . '/CHANGELOG.md';
|
$changelogFile = realpath($path) . '/CHANGELOG.md';
|
||||||
if (file_exists($changelogFile)) {
|
if (file_exists($changelogFile)) {
|
||||||
passthru("{$php} {$cli}/changelog_promote.php --path " . escapeshellarg($path) . " --version " . escapeshellarg($baseVersion) . " 2>/dev/null");
|
passthru(
|
||||||
passthru("{$php} {$cli}/changelog_prune.php --path " . escapeshellarg($path) . " --keep 5 2>/dev/null");
|
"{$php} {$cli}/changelog_promote.php --path "
|
||||||
|
. escapeshellarg($path) . " --version "
|
||||||
|
. escapeshellarg($baseVersion) . " 2>/dev/null"
|
||||||
|
);
|
||||||
|
passthru(
|
||||||
|
"{$php} {$cli}/changelog_prune.php --path "
|
||||||
|
. escapeshellarg($path) . " --keep 5 2>/dev/null"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -158,30 +183,67 @@ class ReleasePublishCli extends CliFramework
|
|||||||
$root = realpath($path) ?: $path;
|
$root = realpath($path) ?: $path;
|
||||||
if (!$this->dryRun) {
|
if (!$this->dryRun) {
|
||||||
// Configure git
|
// Configure git
|
||||||
@shell_exec((PHP_OS_FAMILY === 'Windows' ? "cd /d " : "cd ") . escapeshellarg($root) . " && git config --local user.email \"gitea-actions[bot]@mokoconsulting.tech\"");
|
$cdPfx = PHP_OS_FAMILY === 'Windows' ? "cd /d " : "cd ";
|
||||||
@shell_exec((PHP_OS_FAMILY === 'Windows' ? "cd /d " : "cd ") . escapeshellarg($root) . " && git config --local user.name \"gitea-actions[bot]\"");
|
$cdR = $cdPfx . escapeshellarg($root);
|
||||||
|
@shell_exec(
|
||||||
|
$cdR . " && git config --local user.email"
|
||||||
|
. " \"gitea-actions[bot]@mokoconsulting.tech\""
|
||||||
|
);
|
||||||
|
@shell_exec(
|
||||||
|
$cdR . " && git config --local user.name"
|
||||||
|
. " \"gitea-actions[bot]\""
|
||||||
|
);
|
||||||
if (!empty($repoUrl)) {
|
if (!empty($repoUrl)) {
|
||||||
@shell_exec((PHP_OS_FAMILY === 'Windows' ? "cd /d " : "cd ") . escapeshellarg($root) . " && git remote set-url origin " . escapeshellarg($repoUrl));
|
@shell_exec(
|
||||||
|
$cdR . " && git remote set-url origin "
|
||||||
|
. escapeshellarg($repoUrl)
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Ensure we're on the actual branch (not detached HEAD from PR merge)
|
// Ensure we're on the actual branch (not detached HEAD from PR merge)
|
||||||
@shell_exec((PHP_OS_FAMILY === 'Windows' ? "cd /d " : "cd ") . escapeshellarg($root) . " && git fetch origin " . escapeshellarg($branch) . " 2>/dev/null");
|
@shell_exec(
|
||||||
@shell_exec((PHP_OS_FAMILY === 'Windows' ? "cd /d " : "cd ") . escapeshellarg($root) . " && git checkout -B " . escapeshellarg($branch) . " FETCH_HEAD 2>/dev/null");
|
$cdR . " && git fetch origin "
|
||||||
|
. escapeshellarg($branch) . " 2>/dev/null"
|
||||||
|
);
|
||||||
|
@shell_exec(
|
||||||
|
$cdR . " && git checkout -B "
|
||||||
|
. escapeshellarg($branch) . " FETCH_HEAD 2>/dev/null"
|
||||||
|
);
|
||||||
|
|
||||||
// Re-apply version changes on the checked-out branch
|
// Re-apply version changes on the checked-out branch
|
||||||
passthru("{$php} {$cli}/version_set_platform.php --path " . escapeshellarg($path)
|
passthru("{$php} {$cli}/version_set_platform.php --path " . escapeshellarg($path)
|
||||||
. " --version " . escapeshellarg($baseVersion)
|
. " --version " . escapeshellarg($baseVersion)
|
||||||
. " --branch " . escapeshellarg($branch)
|
. " --branch " . escapeshellarg($branch)
|
||||||
. " --stability " . escapeshellarg($stability) . " 2>/dev/null");
|
. " --stability " . escapeshellarg($stability) . " 2>/dev/null");
|
||||||
passthru("{$php} {$cli}/version_check.php --path " . escapeshellarg($path) . " --fix 2>/dev/null");
|
passthru(
|
||||||
passthru("{$php} {$cli}/badge_update.php --path " . escapeshellarg($path) . " --version " . escapeshellarg($baseVersion) . " 2>/dev/null");
|
"{$php} {$cli}/version_check.php --path "
|
||||||
|
. escapeshellarg($path) . " --fix 2>/dev/null"
|
||||||
|
);
|
||||||
|
passthru(
|
||||||
|
"{$php} {$cli}/badge_update.php --path "
|
||||||
|
. escapeshellarg($path) . " --version "
|
||||||
|
. escapeshellarg($baseVersion) . " 2>/dev/null"
|
||||||
|
);
|
||||||
|
|
||||||
$diffCheck = trim((string) @shell_exec((PHP_OS_FAMILY === 'Windows' ? "cd /d " : "cd ") . escapeshellarg($root) . " && git diff --quiet && git diff --cached --quiet 2>&1 && echo clean || echo dirty"));
|
$diffCheck = trim((string) @shell_exec(
|
||||||
|
$cdR . " && git diff --quiet"
|
||||||
|
. " && git diff --cached --quiet"
|
||||||
|
. " 2>&1 && echo clean || echo dirty"
|
||||||
|
));
|
||||||
if ($diffCheck === 'dirty') {
|
if ($diffCheck === 'dirty') {
|
||||||
@shell_exec((PHP_OS_FAMILY === 'Windows' ? "cd /d " : "cd ") . escapeshellarg($root) . " && git add -A");
|
@shell_exec($cdR . " && git add -A");
|
||||||
@shell_exec((PHP_OS_FAMILY === 'Windows' ? "cd /d " : "cd ") . escapeshellarg($root) . " && git commit -m " . escapeshellarg("chore(release): build {$releaseVersion} [skip ci]")
|
$commitMsg = "chore(release): build"
|
||||||
. " --author=\"gitea-actions[bot] <gitea-actions[bot]@mokoconsulting.tech>\"");
|
. " {$releaseVersion} [skip ci]";
|
||||||
$pushResult = @shell_exec((PHP_OS_FAMILY === 'Windows' ? "cd /d " : "cd ") . escapeshellarg($root) . " && git push origin " . escapeshellarg($branch) . " 2>&1");
|
@shell_exec(
|
||||||
|
$cdR . " && git commit -m "
|
||||||
|
. escapeshellarg($commitMsg)
|
||||||
|
. " --author=\"gitea-actions[bot]"
|
||||||
|
. " <gitea-actions[bot]@mokoconsulting.tech>\""
|
||||||
|
);
|
||||||
|
$pushResult = @shell_exec(
|
||||||
|
$cdR . " && git push origin "
|
||||||
|
. escapeshellarg($branch) . " 2>&1"
|
||||||
|
);
|
||||||
echo " Committed release changes\n";
|
echo " Committed release changes\n";
|
||||||
echo " Push: " . trim($pushResult ?? '') . "\n";
|
echo " Push: " . trim($pushResult ?? '') . "\n";
|
||||||
}
|
}
|
||||||
@@ -258,12 +320,26 @@ class ReleasePublishCli extends CliFramework
|
|||||||
$root = realpath($path) ?: $path;
|
$root = realpath($path) ?: $path;
|
||||||
|
|
||||||
if (!$this->dryRun) {
|
if (!$this->dryRun) {
|
||||||
$diffCheck = trim((string) @shell_exec((PHP_OS_FAMILY === 'Windows' ? "cd /d " : "cd ") . escapeshellarg($root) . " && git diff --quiet updates.xml 2>&1 && echo clean || echo dirty"));
|
$cdX = PHP_OS_FAMILY === 'Windows' ? "cd /d " : "cd ";
|
||||||
|
$cdRt = $cdX . escapeshellarg($root);
|
||||||
|
$diffCheck = trim((string) @shell_exec(
|
||||||
|
$cdRt . " && git diff --quiet updates.xml"
|
||||||
|
. " 2>&1 && echo clean || echo dirty"
|
||||||
|
));
|
||||||
if ($diffCheck === 'dirty') {
|
if ($diffCheck === 'dirty') {
|
||||||
@shell_exec((PHP_OS_FAMILY === 'Windows' ? "cd /d " : "cd ") . escapeshellarg($root) . " && git add updates.xml");
|
@shell_exec($cdRt . " && git add updates.xml");
|
||||||
@shell_exec((PHP_OS_FAMILY === 'Windows' ? "cd /d " : "cd ") . escapeshellarg($root) . " && git commit -m " . escapeshellarg("chore: update channels for {$releaseVersion} [skip ci]")
|
$chMsg = "chore: update channels for"
|
||||||
. " --author=\"gitea-actions[bot] <gitea-actions[bot]@mokoconsulting.tech>\"");
|
. " {$releaseVersion} [skip ci]";
|
||||||
@shell_exec((PHP_OS_FAMILY === 'Windows' ? "cd /d " : "cd ") . escapeshellarg($root) . " && git push origin " . escapeshellarg($branch) . " 2>&1");
|
@shell_exec(
|
||||||
|
$cdRt . " && git commit -m "
|
||||||
|
. escapeshellarg($chMsg)
|
||||||
|
. " --author=\"gitea-actions[bot]"
|
||||||
|
. " <gitea-actions[bot]@mokoconsulting.tech>\""
|
||||||
|
);
|
||||||
|
@shell_exec(
|
||||||
|
$cdRt . " && git push origin "
|
||||||
|
. escapeshellarg($branch) . " 2>&1"
|
||||||
|
);
|
||||||
echo " Committed updates.xml\n";
|
echo " Committed updates.xml\n";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+207
-61
@@ -21,70 +21,216 @@ use MokoEnterprise\CliFramework;
|
|||||||
|
|
||||||
class ReleaseValidateCli extends CliFramework
|
class ReleaseValidateCli extends CliFramework
|
||||||
{
|
{
|
||||||
private int $pass = 0;
|
private int $pass = 0;
|
||||||
private int $fail = 0;
|
private int $fail = 0;
|
||||||
private int $warn = 0;
|
private int $warn = 0;
|
||||||
private array $results = [];
|
private array $results = [];
|
||||||
|
|
||||||
protected function configure(): void
|
protected function configure(): void
|
||||||
{
|
{
|
||||||
$this->setDescription('Pre-release validation -- version consistency, required files, manifest checks');
|
$this->setDescription('Pre-release validation -- version consistency, required files, manifest checks');
|
||||||
$this->addArgument('--path', 'Repository root', '.');
|
$this->addArgument('--path', 'Repository root', '.');
|
||||||
$this->addArgument('--version', 'Expected version string', null);
|
$this->addArgument('--version', 'Expected version string', null);
|
||||||
$this->addArgument('--platform', 'joomla|dolibarr|generic', null);
|
$this->addArgument('--platform', 'joomla|dolibarr|generic', null);
|
||||||
$this->addArgument('--output-summary', 'Write markdown to $GITHUB_STEP_SUMMARY', false);
|
$this->addArgument('--output-summary', 'Write markdown to $GITHUB_STEP_SUMMARY', false);
|
||||||
$this->addArgument('--github-output', 'Export counts to $GITHUB_OUTPUT', false);
|
$this->addArgument('--github-output', 'Export counts to $GITHUB_OUTPUT', false);
|
||||||
}
|
}
|
||||||
|
|
||||||
protected function run(): int
|
protected function run(): int
|
||||||
{
|
{
|
||||||
$path = $this->getArgument('--path'); $version = $this->getArgument('--version');
|
$path = $this->getArgument('--path');
|
||||||
$platform = $this->getArgument('--platform');
|
$version = $this->getArgument('--version');
|
||||||
$outputSummary = (bool) $this->getArgument('--output-summary');
|
$platform = $this->getArgument('--platform');
|
||||||
$githubOutput = (bool) $this->getArgument('--github-output');
|
$outputSummary = (bool) $this->getArgument('--output-summary');
|
||||||
if ($version === null) { $this->log('ERROR', "Usage: release_validate.php --path . --version XX.YY.ZZ [--platform joomla]"); return 1; }
|
$githubOutput = (bool) $this->getArgument('--github-output');
|
||||||
$root = realpath($path) ?: $path;
|
if ($version === null) {
|
||||||
if ($platform === null) {
|
$this->log('ERROR', "Usage: release_validate.php --path . --version XX.YY.ZZ [--platform joomla]");
|
||||||
$manifestXml = "{$root}/.mokogitea/manifest.xml";
|
return 1;
|
||||||
if (file_exists($manifestXml)) { $mContent = file_get_contents($manifestXml); if (preg_match('/<platform>([^<]+)<\/platform>/', $mContent, $pm)) { $platform = trim($pm[1]); } }
|
}
|
||||||
if (in_array($platform, ['waas-component'], true)) { $platform = 'joomla'; }
|
$root = realpath($path) ?: $path;
|
||||||
if (in_array($platform, ['crm-module'], true)) { $platform = 'dolibarr'; }
|
if ($platform === null) {
|
||||||
if ($platform === null) { $platform = 'generic'; }
|
$manifestXml = "{$root}/.mokogitea/manifest.xml";
|
||||||
}
|
if (file_exists($manifestXml)) {
|
||||||
$hasSource = is_dir("{$root}/src") || is_dir("{$root}/htdocs");
|
$mContent = file_get_contents($manifestXml);
|
||||||
$this->addVResult('Source directory', $hasSource ? 'PASS' : 'WARN', $hasSource ? 'src/ or htdocs/ found' : 'No src/ or htdocs/ directory');
|
if (preg_match('/<platform>([^<]+)<\/platform>/', $mContent, $pm)) {
|
||||||
if (!file_exists("{$root}/README.md")) { $this->addVResult('README.md', 'FAIL', 'Not found'); }
|
$platform = trim($pm[1]);
|
||||||
else { $readme = file_get_contents("{$root}/README.md"); $this->addVResult('README.md version', (preg_match('/VERSION:\s*' . preg_quote($version, '/') . '/', $readme) || strpos($readme, $version) !== false) ? 'PASS' : 'FAIL', (preg_match('/VERSION:\s*' . preg_quote($version, '/') . '/', $readme) || strpos($readme, $version) !== false) ? "`{$version}` found" : "`{$version}` not found"); }
|
}
|
||||||
if (!file_exists("{$root}/CHANGELOG.md")) { $this->addVResult('CHANGELOG.md', 'WARN', 'Not found'); }
|
}
|
||||||
else { $cl = file_get_contents("{$root}/CHANGELOG.md"); $this->addVResult('CHANGELOG.md version', preg_match('/^##\s.*' . preg_quote($version, '/') . '/m', $cl) ? 'PASS' : 'WARN', preg_match('/^##\s.*' . preg_quote($version, '/') . '/m', $cl) ? "Section found" : "No section header"); }
|
if (in_array($platform, ['waas-component'], true)) {
|
||||||
$licenseFound = false; foreach (['LICENSE', 'LICENSE.md', 'LICENSE.txt', 'COPYING'] as $lf) { if (file_exists("{$root}/{$lf}")) { $licenseFound = true; break; } }
|
$platform = 'joomla';
|
||||||
$this->addVResult('LICENSE', $licenseFound ? 'PASS' : 'FAIL', $licenseFound ? 'Found' : 'Not found');
|
}
|
||||||
if ($platform === 'joomla') {
|
if (in_array($platform, ['crm-module'], true)) {
|
||||||
$manifest = null; foreach (["{$root}/src", $root] as $dir) { if (!is_dir($dir)) continue; foreach (glob("{$dir}/*.xml") as $xmlFile) { $content = file_get_contents($xmlFile); if (strpos($content, '<extension') !== false) { $manifest = $xmlFile; break 2; } } }
|
$platform = 'dolibarr';
|
||||||
if ($manifest === null) { $this->addVResult('XML manifest', 'FAIL', 'No Joomla manifest found'); }
|
}
|
||||||
else { if (preg_match('/<version>([^<]+)<\/version>/', file_get_contents($manifest), $m)) { $mVer = trim($m[1]); $this->addVResult('Manifest version', $mVer === $version ? 'PASS' : 'FAIL', $mVer === $version ? "`{$mVer}` matches" : "`{$mVer}` != `{$version}`"); } else { $this->addVResult('Manifest version', 'FAIL', 'No <version> tag'); } }
|
if ($platform === null) {
|
||||||
if (!file_exists("{$root}/updates.xml")) { $this->addVResult('updates.xml', 'WARN', 'Not found'); }
|
$platform = 'generic';
|
||||||
else { $ux = file_get_contents("{$root}/updates.xml"); $this->addVResult('updates.xml version', preg_match('/<version>' . preg_quote($version, '/') . '<\/version>/', $ux) ? 'PASS' : 'FAIL', preg_match('/<version>' . preg_quote($version, '/') . '<\/version>/', $ux) ? "`{$version}` found" : "`{$version}` not found"); }
|
}
|
||||||
} elseif ($platform === 'dolibarr') {
|
}
|
||||||
$modFile = null; foreach (['src', 'htdocs'] as $sd) { $matches = glob("{$root}/{$sd}/mod*.class.php"); if (!empty($matches)) { $modFile = $matches[0]; break; } }
|
$hasSource = is_dir("{$root}/src") || is_dir("{$root}/htdocs");
|
||||||
if ($modFile === null) { $this->addVResult('Dolibarr mod file', 'FAIL', 'No mod*.class.php found'); }
|
$this->addVResult('Source directory', $hasSource ? 'PASS' : 'WARN', $hasSource ? 'src/ or htdocs/ found' : 'No src/ or htdocs/ directory');
|
||||||
else { $mc = file_get_contents($modFile); $this->addVResult('Dolibarr version', preg_match("/\\\$this->version\s*=\s*'" . preg_quote($version, '/') . "'/", $mc) ? 'PASS' : 'FAIL', preg_match("/\\\$this->version\s*=\s*'" . preg_quote($version, '/') . "'/", $mc) ? "`{$version}` matches" : "`{$version}` not found"); }
|
if (!file_exists("{$root}/README.md")) {
|
||||||
}
|
$this->addVResult('README.md', 'FAIL', 'Not found');
|
||||||
if (file_exists("{$root}/composer.json")) { $composer = json_decode(file_get_contents("{$root}/composer.json"), true); if (isset($composer['version'])) { $this->addVResult('composer.json version', $composer['version'] === $version ? 'PASS' : 'WARN', $composer['version'] === $version ? "`{$version}` matches" : "`{$composer['version']}` != `{$version}`"); } }
|
} else {
|
||||||
$table = "| Check | Result | Details |\n|-------|--------|--------|\n";
|
$readme = file_get_contents("{$root}/README.md");
|
||||||
foreach ($this->results as $r) { $table .= "| {$r['check']} | {$r['status']} | {$r['details']} |\n"; }
|
$quotedVer = preg_quote($version, '/');
|
||||||
$table .= "\n**Validation: {$this->pass} passed, {$this->fail} failed, {$this->warn} warnings**\n";
|
$readmeHasVer = preg_match(
|
||||||
echo $table;
|
'/VERSION:\s*' . $quotedVer . '/',
|
||||||
if ($outputSummary) { $summaryFile = getenv('GITHUB_STEP_SUMMARY'); if ($summaryFile) { file_put_contents($summaryFile, "## Pre-Release Sanity Checks ({$platform})\n\n{$table}\n", FILE_APPEND); } }
|
$readme
|
||||||
if ($githubOutput) { $ghOutput = getenv('GITHUB_OUTPUT'); if ($ghOutput) { file_put_contents($ghOutput, "validation_pass={$this->pass}\nvalidation_fail={$this->fail}\nvalidation_warn={$this->warn}\nvalidation_platform={$platform}\n", FILE_APPEND); } }
|
) || strpos($readme, $version) !== false;
|
||||||
return $this->fail > 0 ? 1 : 0;
|
$this->addVResult(
|
||||||
}
|
'README.md version',
|
||||||
|
$readmeHasVer ? 'PASS' : 'FAIL',
|
||||||
|
$readmeHasVer
|
||||||
|
? "`{$version}` found"
|
||||||
|
: "`{$version}` not found"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (!file_exists("{$root}/CHANGELOG.md")) {
|
||||||
|
$this->addVResult('CHANGELOG.md', 'WARN', 'Not found');
|
||||||
|
} else {
|
||||||
|
$cl = file_get_contents("{$root}/CHANGELOG.md");
|
||||||
|
$clHasVer = preg_match(
|
||||||
|
'/^##\s.*' . preg_quote($version, '/') . '/m',
|
||||||
|
$cl
|
||||||
|
);
|
||||||
|
$this->addVResult(
|
||||||
|
'CHANGELOG.md version',
|
||||||
|
$clHasVer ? 'PASS' : 'WARN',
|
||||||
|
$clHasVer ? "Section found" : "No section header"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
$licenseFound = false;
|
||||||
|
foreach (['LICENSE', 'LICENSE.md', 'LICENSE.txt', 'COPYING'] as $lf) {
|
||||||
|
if (file_exists("{$root}/{$lf}")) {
|
||||||
|
$licenseFound = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
$this->addVResult('LICENSE', $licenseFound ? 'PASS' : 'FAIL', $licenseFound ? 'Found' : 'Not found');
|
||||||
|
if ($platform === 'joomla') {
|
||||||
|
$manifest = null;
|
||||||
|
foreach (["{$root}/src", $root] as $dir) {
|
||||||
|
if (!is_dir($dir)) {
|
||||||
|
continue;
|
||||||
|
} foreach (glob("{$dir}/*.xml") as $xmlFile) {
|
||||||
|
$content = file_get_contents($xmlFile);
|
||||||
|
if (strpos($content, '<extension') !== false) {
|
||||||
|
$manifest = $xmlFile;
|
||||||
|
break 2;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if ($manifest === null) {
|
||||||
|
$this->addVResult('XML manifest', 'FAIL', 'No Joomla manifest found');
|
||||||
|
} else {
|
||||||
|
$manifestContent = file_get_contents($manifest);
|
||||||
|
if (preg_match('/<version>([^<]+)<\/version>/', $manifestContent, $m)) {
|
||||||
|
$mVer = trim($m[1]);
|
||||||
|
$this->addVResult(
|
||||||
|
'Manifest version',
|
||||||
|
$mVer === $version ? 'PASS' : 'FAIL',
|
||||||
|
$mVer === $version
|
||||||
|
? "`{$mVer}` matches"
|
||||||
|
: "`{$mVer}` != `{$version}`"
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
$this->addVResult('Manifest version', 'FAIL', 'No <version> tag');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!file_exists("{$root}/updates.xml")) {
|
||||||
|
$this->addVResult('updates.xml', 'WARN', 'Not found');
|
||||||
|
} else {
|
||||||
|
$ux = file_get_contents("{$root}/updates.xml");
|
||||||
|
$uxHasVer = preg_match(
|
||||||
|
'/<version>' . preg_quote($version, '/')
|
||||||
|
. '<\/version>/',
|
||||||
|
$ux
|
||||||
|
);
|
||||||
|
$this->addVResult(
|
||||||
|
'updates.xml version',
|
||||||
|
$uxHasVer ? 'PASS' : 'FAIL',
|
||||||
|
$uxHasVer
|
||||||
|
? "`{$version}` found"
|
||||||
|
: "`{$version}` not found"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} elseif ($platform === 'dolibarr') {
|
||||||
|
$modFile = null;
|
||||||
|
foreach (['src', 'htdocs'] as $sd) {
|
||||||
|
$matches = glob("{$root}/{$sd}/mod*.class.php");
|
||||||
|
if (!empty($matches)) {
|
||||||
|
$modFile = $matches[0];
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if ($modFile === null) {
|
||||||
|
$this->addVResult('Dolibarr mod file', 'FAIL', 'No mod*.class.php found');
|
||||||
|
} else {
|
||||||
|
$mc = file_get_contents($modFile);
|
||||||
|
$dolPattern = "/\\\$this->version\s*=\s*'"
|
||||||
|
. preg_quote($version, '/') . "'/";
|
||||||
|
$dolMatch = preg_match($dolPattern, $mc);
|
||||||
|
$this->addVResult(
|
||||||
|
'Dolibarr version',
|
||||||
|
$dolMatch ? 'PASS' : 'FAIL',
|
||||||
|
$dolMatch
|
||||||
|
? "`{$version}` matches"
|
||||||
|
: "`{$version}` not found"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (file_exists("{$root}/composer.json")) {
|
||||||
|
$composer = json_decode(file_get_contents("{$root}/composer.json"), true);
|
||||||
|
if (isset($composer['version'])) {
|
||||||
|
$compMatch = $composer['version'] === $version;
|
||||||
|
$this->addVResult(
|
||||||
|
'composer.json version',
|
||||||
|
$compMatch ? 'PASS' : 'WARN',
|
||||||
|
$compMatch
|
||||||
|
? "`{$version}` matches"
|
||||||
|
: "`{$composer['version']}` != `{$version}`"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
$table = "| Check | Result | Details |\n|-------|--------|--------|\n";
|
||||||
|
foreach ($this->results as $r) {
|
||||||
|
$table .= "| {$r['check']} | {$r['status']} | {$r['details']} |\n";
|
||||||
|
}
|
||||||
|
$table .= "\n**Validation: {$this->pass} passed, {$this->fail} failed, {$this->warn} warnings**\n";
|
||||||
|
echo $table;
|
||||||
|
if ($outputSummary) {
|
||||||
|
$summaryFile = getenv('GITHUB_STEP_SUMMARY');
|
||||||
|
if ($summaryFile) {
|
||||||
|
file_put_contents($summaryFile, "## Pre-Release Sanity Checks ({$platform})\n\n{$table}\n", FILE_APPEND);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if ($githubOutput) {
|
||||||
|
$ghOutput = getenv('GITHUB_OUTPUT');
|
||||||
|
if ($ghOutput) {
|
||||||
|
file_put_contents(
|
||||||
|
$ghOutput,
|
||||||
|
"validation_pass={$this->pass}\n"
|
||||||
|
. "validation_fail={$this->fail}\n"
|
||||||
|
. "validation_warn={$this->warn}\n"
|
||||||
|
. "validation_platform={$platform}\n",
|
||||||
|
FILE_APPEND
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return $this->fail > 0 ? 1 : 0;
|
||||||
|
}
|
||||||
|
|
||||||
private function addVResult(string $check, string $status, string $details): void
|
private function addVResult(string $check, string $status, string $details): void
|
||||||
{
|
{
|
||||||
$this->results[] = ['check' => $check, 'status' => $status, 'details' => $details];
|
$this->results[] = ['check' => $check, 'status' => $status, 'details' => $details];
|
||||||
if ($status === 'PASS') { $this->pass++; } elseif ($status === 'FAIL') { $this->fail++; } elseif ($status === 'WARN') { $this->warn++; }
|
if ($status === 'PASS') {
|
||||||
}
|
$this->pass++;
|
||||||
|
} elseif ($status === 'FAIL') {
|
||||||
|
$this->fail++;
|
||||||
|
} elseif ($status === 'WARN') {
|
||||||
|
$this->warn++;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
$app = new ReleaseValidateCli();
|
$app = new ReleaseValidateCli();
|
||||||
|
|||||||
+15
-2
@@ -1,5 +1,6 @@
|
|||||||
#!/usr/bin/env php
|
#!/usr/bin/env php
|
||||||
<?php
|
<?php
|
||||||
|
|
||||||
/* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
|
/* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
|
||||||
*
|
*
|
||||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
@@ -103,7 +104,13 @@ class ReleaseVerifyCli extends CliFramework
|
|||||||
if ($zipSha === $expectedSha) {
|
if ($zipSha === $expectedSha) {
|
||||||
$this->addResult('SHA256 vs updates.xml', 'PASS', '`' . substr($zipSha, 0, 16) . '...`');
|
$this->addResult('SHA256 vs updates.xml', 'PASS', '`' . substr($zipSha, 0, 16) . '...`');
|
||||||
} else {
|
} else {
|
||||||
$this->addResult('SHA256 vs updates.xml', 'FAIL', "ZIP=`" . substr($zipSha, 0, 16) . "...` updates.xml=`" . substr($expectedSha, 0, 16) . "...`");
|
$this->addResult(
|
||||||
|
'SHA256 vs updates.xml',
|
||||||
|
'FAIL',
|
||||||
|
"ZIP=`" . substr($zipSha, 0, 16)
|
||||||
|
. "...` updates.xml=`"
|
||||||
|
. substr($expectedSha, 0, 16) . "...`"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
$this->addResult('SHA256 vs updates.xml', 'WARN', 'No <sha256> in updates.xml');
|
$this->addResult('SHA256 vs updates.xml', 'WARN', 'No <sha256> in updates.xml');
|
||||||
@@ -145,7 +152,13 @@ class ReleaseVerifyCli extends CliFramework
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Clean up
|
// Clean up
|
||||||
$rit = new \RecursiveIteratorIterator(new \RecursiveDirectoryIterator($tmpDir, \RecursiveDirectoryIterator::SKIP_DOTS), \RecursiveIteratorIterator::CHILD_FIRST);
|
$rit = new \RecursiveIteratorIterator(
|
||||||
|
new \RecursiveDirectoryIterator(
|
||||||
|
$tmpDir,
|
||||||
|
\RecursiveDirectoryIterator::SKIP_DOTS
|
||||||
|
),
|
||||||
|
\RecursiveIteratorIterator::CHILD_FIRST
|
||||||
|
);
|
||||||
foreach ($rit as $file) {
|
foreach ($rit as $file) {
|
||||||
$file->isDir() ? rmdir($file->getPathname()) : unlink($file->getPathname());
|
$file->isDir() ? rmdir($file->getPathname()) : unlink($file->getPathname());
|
||||||
}
|
}
|
||||||
|
|||||||
+103
-51
@@ -1,5 +1,6 @@
|
|||||||
#!/usr/bin/env php
|
#!/usr/bin/env php
|
||||||
<?php
|
<?php
|
||||||
|
|
||||||
/* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
|
/* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
|
||||||
*
|
*
|
||||||
* This file is part of a Moko Consulting project.
|
* This file is part of a Moko Consulting project.
|
||||||
@@ -23,60 +24,111 @@ use MokoEnterprise\CliFramework;
|
|||||||
|
|
||||||
class ScaffoldClientCli extends CliFramework
|
class ScaffoldClientCli extends CliFramework
|
||||||
{
|
{
|
||||||
protected function configure(): void
|
protected function configure(): void
|
||||||
{
|
{
|
||||||
$this->setDescription('Scaffold a new client-waas repo from Template-Client-WaaS');
|
$this->setDescription('Scaffold a new client-waas repo from Template-Client-WaaS');
|
||||||
$this->addArgument('--name', 'Client name', '');
|
$this->addArgument('--name', 'Client name', '');
|
||||||
$this->addArgument('--org', 'Gitea organization', '');
|
$this->addArgument('--org', 'Gitea organization', '');
|
||||||
$this->addArgument('--gitea-url', 'Gitea URL', 'https://git.mokoconsulting.tech');
|
$this->addArgument('--gitea-url', 'Gitea URL', 'https://git.mokoconsulting.tech');
|
||||||
$this->addArgument('--token', 'Gitea API token', '');
|
$this->addArgument('--token', 'Gitea API token', '');
|
||||||
}
|
}
|
||||||
|
|
||||||
protected function run(): int
|
protected function run(): int
|
||||||
{
|
{
|
||||||
$name = $this->getArgument('--name'); $org = $this->getArgument('--org');
|
$name = $this->getArgument('--name');
|
||||||
$giteaUrl = rtrim($this->getArgument('--gitea-url'), '/'); $token = $this->getArgument('--token');
|
$org = $this->getArgument('--org');
|
||||||
if ($name === '' || $org === '' || $token === '') { $this->log('ERROR', '--name, --org, and --token are required.'); return 1; }
|
$giteaUrl = rtrim($this->getArgument('--gitea-url'), '/');
|
||||||
$repoName = 'client-waas-' . $name;
|
$token = $this->getArgument('--token');
|
||||||
$this->log('INFO', "Scaffolding client repo: {$org}/{$repoName}");
|
if ($name === '' || $org === '' || $token === '') {
|
||||||
$this->log('INFO', "Gitea URL: {$giteaUrl}");
|
$this->log('ERROR', '--name, --org, and --token are required.');
|
||||||
if ($this->dryRun) {
|
return 1;
|
||||||
$this->log('INFO', '[DRY RUN] Would create repo from template MokoConsulting/Template-Client-WaaS');
|
}
|
||||||
$this->log('INFO', "[DRY RUN] Repo: {$org}/{$repoName}");
|
$repoName = 'client-waas-' . $name;
|
||||||
$this->printPostSetupInstructions($repoName, $giteaUrl, $org);
|
$this->log('INFO', "Scaffolding client repo: {$org}/{$repoName}");
|
||||||
return 0;
|
$this->log('INFO', "Gitea URL: {$giteaUrl}");
|
||||||
}
|
if ($this->dryRun) {
|
||||||
$this->log('INFO', 'Step 1: Creating repo from template...');
|
$this->log('INFO', '[DRY RUN] Would create repo from template MokoConsulting/Template-Client-WaaS');
|
||||||
$createPayload = json_encode(['owner' => $org, 'name' => $repoName, 'description' => "{$name} WaaS site", 'private' => true, 'git_content' => true, 'topics' => true, 'labels' => true]);
|
$this->log('INFO', "[DRY RUN] Repo: {$org}/{$repoName}");
|
||||||
$response = $this->apiRequest('POST', "/api/v1/repos/MokoConsulting/Template-Client-WaaS/generate", $giteaUrl, $token, $createPayload);
|
$this->printPostSetupInstructions($repoName, $giteaUrl, $org);
|
||||||
if ($response['code'] < 200 || $response['code'] >= 300) { $this->log('ERROR', "Failed to create repo (HTTP {$response['code']})."); return 1; }
|
return 0;
|
||||||
$this->log('INFO', "Repo created: {$org}/{$repoName}");
|
}
|
||||||
$this->log('INFO', 'Step 2: Updating repo description...');
|
$this->log('INFO', 'Step 1: Creating repo from template...');
|
||||||
$this->apiRequest('PATCH', "/api/v1/repos/{$org}/{$repoName}", $giteaUrl, $token, json_encode(['description' => "{$name} WaaS site"]));
|
$createPayload = json_encode([
|
||||||
$this->log('INFO', 'Step 3: Creating dev branch from main...');
|
'owner' => $org,
|
||||||
$response = $this->apiRequest('POST', "/api/v1/repos/{$org}/{$repoName}/branches", $giteaUrl, $token, json_encode(['new_branch_name' => 'dev', 'old_branch_name' => 'main']));
|
'name' => $repoName,
|
||||||
if ($response['code'] >= 200 && $response['code'] < 300) { $this->log('INFO', 'Branch "dev" created from "main".'); }
|
'description' => "{$name} WaaS site",
|
||||||
else { $this->log('WARN', "Could not create dev branch (HTTP {$response['code']})."); }
|
'private' => true,
|
||||||
$this->printPostSetupInstructions($repoName, $giteaUrl, $org);
|
'git_content' => true,
|
||||||
$this->log('INFO', 'Scaffold complete.');
|
'topics' => true,
|
||||||
return 0;
|
'labels' => true,
|
||||||
}
|
]);
|
||||||
|
$response = $this->apiRequest(
|
||||||
|
'POST',
|
||||||
|
"/api/v1/repos/MokoConsulting/Template-Client-WaaS/generate",
|
||||||
|
$giteaUrl,
|
||||||
|
$token,
|
||||||
|
$createPayload
|
||||||
|
);
|
||||||
|
if ($response['code'] < 200 || $response['code'] >= 300) {
|
||||||
|
$this->log('ERROR', "Failed to create repo (HTTP {$response['code']}).");
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
$this->log('INFO', "Repo created: {$org}/{$repoName}");
|
||||||
|
$this->log('INFO', 'Step 2: Updating repo description...');
|
||||||
|
$this->apiRequest('PATCH', "/api/v1/repos/{$org}/{$repoName}", $giteaUrl, $token, json_encode(['description' => "{$name} WaaS site"]));
|
||||||
|
$this->log('INFO', 'Step 3: Creating dev branch from main...');
|
||||||
|
$response = $this->apiRequest(
|
||||||
|
'POST',
|
||||||
|
"/api/v1/repos/{$org}/{$repoName}/branches",
|
||||||
|
$giteaUrl,
|
||||||
|
$token,
|
||||||
|
json_encode([
|
||||||
|
'new_branch_name' => 'dev',
|
||||||
|
'old_branch_name' => 'main',
|
||||||
|
])
|
||||||
|
);
|
||||||
|
if ($response['code'] >= 200 && $response['code'] < 300) {
|
||||||
|
$this->log('INFO', 'Branch "dev" created from "main".');
|
||||||
|
} else {
|
||||||
|
$this->log('WARN', "Could not create dev branch (HTTP {$response['code']}).");
|
||||||
|
}
|
||||||
|
$this->printPostSetupInstructions($repoName, $giteaUrl, $org);
|
||||||
|
$this->log('INFO', 'Scaffold complete.');
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
private function printPostSetupInstructions(string $repoName, string $giteaUrl, string $org): void
|
private function printPostSetupInstructions(string $repoName, string $giteaUrl, string $org): void
|
||||||
{
|
{
|
||||||
fwrite(STDERR, "\n=== POST-SETUP INSTRUCTIONS ===\n\nNavigate to: {$giteaUrl}/{$org}/{$repoName}/settings\n\nSet REPO VARIABLES:\n DEV_SYNC_HOST, DEV_SYNC_PORT, DEV_SYNC_USER, DEV_SYNC_PATH\n LIVE_SSH_HOST, LIVE_SSH_PORT, LIVE_SSH_USER, LIVE_SYNC_PATH\n\nSet REPO SECRETS:\n DEV_SYNC_KEY, LIVE_SSH_KEY\n\n================================\n");
|
fwrite(STDERR, "\n=== POST-SETUP INSTRUCTIONS ===\n\n"
|
||||||
}
|
. "Navigate to: {$giteaUrl}/{$org}/{$repoName}/settings\n\n"
|
||||||
|
. "Set REPO VARIABLES:\n"
|
||||||
|
. " DEV_SYNC_HOST, DEV_SYNC_PORT, DEV_SYNC_USER, DEV_SYNC_PATH\n"
|
||||||
|
. " LIVE_SSH_HOST, LIVE_SSH_PORT, LIVE_SSH_USER, LIVE_SYNC_PATH\n\n"
|
||||||
|
. "Set REPO SECRETS:\n"
|
||||||
|
. " DEV_SYNC_KEY, LIVE_SSH_KEY\n\n"
|
||||||
|
. "================================\n");
|
||||||
|
}
|
||||||
|
|
||||||
private function apiRequest(string $method, string $endpoint, string $giteaUrl, string $token, ?string $body = null): array
|
private function apiRequest(string $method, string $endpoint, string $giteaUrl, string $token, ?string $body = null): array
|
||||||
{
|
{
|
||||||
$ch = curl_init(); curl_setopt($ch, CURLOPT_URL, $giteaUrl . $endpoint);
|
$ch = curl_init();
|
||||||
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); curl_setopt($ch, CURLOPT_CUSTOMREQUEST, $method);
|
curl_setopt($ch, CURLOPT_URL, $giteaUrl . $endpoint);
|
||||||
curl_setopt($ch, CURLOPT_HTTPHEADER, ['Content-Type: application/json', 'Accept: application/json', "Authorization: token {$token}"]);
|
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
|
||||||
if ($body !== null) { curl_setopt($ch, CURLOPT_POSTFIELDS, $body); }
|
curl_setopt($ch, CURLOPT_CUSTOMREQUEST, $method);
|
||||||
$responseBody = curl_exec($ch); $httpCode = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
curl_setopt($ch, CURLOPT_HTTPHEADER, ['Content-Type: application/json', 'Accept: application/json', "Authorization: token {$token}"]);
|
||||||
if (curl_errno($ch)) { $error = curl_error($ch); curl_close($ch); return ['code' => 0, 'body' => "cURL error: {$error}"]; }
|
if ($body !== null) {
|
||||||
curl_close($ch); return ['code' => $httpCode, 'body' => $responseBody];
|
curl_setopt($ch, CURLOPT_POSTFIELDS, $body);
|
||||||
}
|
}
|
||||||
|
$responseBody = curl_exec($ch);
|
||||||
|
$httpCode = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||||||
|
if (curl_errno($ch)) {
|
||||||
|
$error = curl_error($ch);
|
||||||
|
curl_close($ch);
|
||||||
|
return ['code' => 0, 'body' => "cURL error: {$error}"];
|
||||||
|
}
|
||||||
|
curl_close($ch);
|
||||||
|
return ['code' => $httpCode, 'body' => $responseBody];
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
$app = new ScaffoldClientCli();
|
$app = new ScaffoldClientCli();
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
#!/usr/bin/env php
|
#!/usr/bin/env php
|
||||||
<?php
|
<?php
|
||||||
|
|
||||||
/* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
|
/* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
|
||||||
*
|
*
|
||||||
* This file is part of a Moko Consulting project.
|
* This file is part of a Moko Consulting project.
|
||||||
|
|||||||
+153
-139
@@ -1,5 +1,6 @@
|
|||||||
#!/usr/bin/env php
|
#!/usr/bin/env php
|
||||||
<?php
|
<?php
|
||||||
|
|
||||||
/* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
|
/* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
|
||||||
*
|
*
|
||||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
@@ -20,159 +21,172 @@ use MokoEnterprise\CliFramework;
|
|||||||
|
|
||||||
class ThemeLintCli extends CliFramework
|
class ThemeLintCli extends CliFramework
|
||||||
{
|
{
|
||||||
protected function configure(): void
|
protected function configure(): void
|
||||||
{
|
{
|
||||||
$this->setDescription('Lint theme files -- CSS syntax, image sizes, hardcoded URLs');
|
$this->setDescription('Lint theme files -- CSS syntax, image sizes, hardcoded URLs');
|
||||||
$this->addArgument('--path', 'Repository root', '.');
|
$this->addArgument('--path', 'Repository root', '.');
|
||||||
$this->addArgument('--max-image-kb', 'Maximum image file size in KB', '500');
|
$this->addArgument('--max-image-kb', 'Maximum image file size in KB', '500');
|
||||||
$this->addArgument('--github-output', 'Export results to $GITHUB_OUTPUT', false);
|
$this->addArgument('--github-output', 'Export results to $GITHUB_OUTPUT', false);
|
||||||
$this->addArgument('--strict', 'Exit 1 on any warning', false);
|
$this->addArgument('--strict', 'Exit 1 on any warning', false);
|
||||||
}
|
}
|
||||||
|
|
||||||
protected function run(): int
|
protected function run(): int
|
||||||
{
|
{
|
||||||
$path = $this->getArgument('--path');
|
$path = $this->getArgument('--path');
|
||||||
$maxImageKb = (int) $this->getArgument('--max-image-kb');
|
$maxImageKb = (int) $this->getArgument('--max-image-kb');
|
||||||
$ghOutput = (bool) $this->getArgument('--github-output');
|
$ghOutput = (bool) $this->getArgument('--github-output');
|
||||||
$strict = (bool) $this->getArgument('--strict');
|
$strict = (bool) $this->getArgument('--strict');
|
||||||
|
|
||||||
$root = realpath($path) ?: $path;
|
$root = realpath($path) ?: $path;
|
||||||
$errors = 0;
|
$errors = 0;
|
||||||
$warnings = 0;
|
$warnings = 0;
|
||||||
|
|
||||||
$srcDir = null;
|
$srcDir = null;
|
||||||
foreach (['src', 'htdocs'] as $d) {
|
foreach (['src', 'htdocs'] as $d) {
|
||||||
if (is_dir("{$root}/{$d}")) { $srcDir = "{$root}/{$d}"; break; }
|
if (is_dir("{$root}/{$d}")) {
|
||||||
}
|
$srcDir = "{$root}/{$d}";
|
||||||
if ($srcDir === null) {
|
break;
|
||||||
$this->log('ERROR', "No src/ or htdocs/ directory in {$root}");
|
}
|
||||||
return 1;
|
}
|
||||||
}
|
if ($srcDir === null) {
|
||||||
|
$this->log('ERROR', "No src/ or htdocs/ directory in {$root}");
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
|
||||||
echo "Theme Lint: {$srcDir}\n\n";
|
echo "Theme Lint: {$srcDir}\n\n";
|
||||||
|
|
||||||
echo "--- CSS Syntax ---\n";
|
echo "--- CSS Syntax ---\n";
|
||||||
$cssFiles = $this->findFiles($srcDir, '*.css');
|
$cssFiles = $this->findFiles($srcDir, '*.css');
|
||||||
$cssMinFiles = $this->findFiles($srcDir, '*.min.css');
|
$cssMinFiles = $this->findFiles($srcDir, '*.min.css');
|
||||||
$cssToCheck = array_diff($cssFiles, $cssMinFiles);
|
$cssToCheck = array_diff($cssFiles, $cssMinFiles);
|
||||||
|
|
||||||
if (empty($cssToCheck)) {
|
if (empty($cssToCheck)) {
|
||||||
echo " No CSS files to check\n";
|
echo " No CSS files to check\n";
|
||||||
} else {
|
} else {
|
||||||
foreach ($cssToCheck as $file) {
|
foreach ($cssToCheck as $file) {
|
||||||
$content = file_get_contents($file);
|
$content = file_get_contents($file);
|
||||||
$relPath = str_replace($root . '/', '', $file);
|
$relPath = str_replace($root . '/', '', $file);
|
||||||
$openBraces = substr_count($content, '{');
|
$openBraces = substr_count($content, '{');
|
||||||
$closeBraces = substr_count($content, '}');
|
$closeBraces = substr_count($content, '}');
|
||||||
if ($openBraces !== $closeBraces) {
|
if ($openBraces !== $closeBraces) {
|
||||||
echo " ERROR: {$relPath}: unmatched braces (open={$openBraces}, close={$closeBraces})\n";
|
echo " ERROR: {$relPath}: unmatched braces (open={$openBraces}, close={$closeBraces})\n";
|
||||||
$errors++;
|
$errors++;
|
||||||
}
|
}
|
||||||
if (preg_match_all('/\{[\s]*\}/', $content, $m)) {
|
if (preg_match_all('/\{[\s]*\}/', $content, $m)) {
|
||||||
$count = count($m[0]);
|
$count = count($m[0]);
|
||||||
echo " WARN: {$relPath}: {$count} empty rule(s)\n";
|
echo " WARN: {$relPath}: {$count} empty rule(s)\n";
|
||||||
$warnings++;
|
$warnings++;
|
||||||
}
|
}
|
||||||
$importantCount = substr_count($content, '!important');
|
$importantCount = substr_count($content, '!important');
|
||||||
if ($importantCount > 10) {
|
if ($importantCount > 10) {
|
||||||
echo " WARN: {$relPath}: {$importantCount} !important declarations (consider refactoring)\n";
|
echo " WARN: {$relPath}: {$importantCount} !important declarations (consider refactoring)\n";
|
||||||
$warnings++;
|
$warnings++;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if ($errors === 0) {
|
if ($errors === 0) {
|
||||||
echo " OK: " . count($cssToCheck) . " CSS file(s) checked\n";
|
echo " OK: " . count($cssToCheck) . " CSS file(s) checked\n";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
echo "\n--- Image Sizes (max {$maxImageKb}KB) ---\n";
|
echo "\n--- Image Sizes (max {$maxImageKb}KB) ---\n";
|
||||||
$imageExts = ['*.jpg', '*.jpeg', '*.png', '*.gif', '*.webp', '*.svg', '*.bmp'];
|
$imageExts = ['*.jpg', '*.jpeg', '*.png', '*.gif', '*.webp', '*.svg', '*.bmp'];
|
||||||
$images = [];
|
$images = [];
|
||||||
foreach ($imageExts as $ext) {
|
foreach ($imageExts as $ext) {
|
||||||
$images = array_merge($images, $this->findFiles($srcDir, $ext));
|
$images = array_merge($images, $this->findFiles($srcDir, $ext));
|
||||||
}
|
}
|
||||||
if (is_dir("{$root}/images")) {
|
if (is_dir("{$root}/images")) {
|
||||||
foreach ($imageExts as $ext) {
|
foreach ($imageExts as $ext) {
|
||||||
$images = array_merge($images, $this->findFiles("{$root}/images", $ext));
|
$images = array_merge($images, $this->findFiles("{$root}/images", $ext));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
$oversized = 0;
|
$oversized = 0;
|
||||||
$totalSize = 0;
|
$totalSize = 0;
|
||||||
foreach ($images as $file) {
|
foreach ($images as $file) {
|
||||||
$size = filesize($file);
|
$size = filesize($file);
|
||||||
$totalSize += $size;
|
$totalSize += $size;
|
||||||
$relPath = str_replace($root . '/', '', $file);
|
$relPath = str_replace($root . '/', '', $file);
|
||||||
$sizeKb = round($size / 1024);
|
$sizeKb = round($size / 1024);
|
||||||
if ($sizeKb > $maxImageKb) {
|
if ($sizeKb > $maxImageKb) {
|
||||||
echo " WARN: {$relPath}: {$sizeKb}KB (exceeds {$maxImageKb}KB limit)\n";
|
echo " WARN: {$relPath}: {$sizeKb}KB (exceeds {$maxImageKb}KB limit)\n";
|
||||||
$oversized++;
|
$oversized++;
|
||||||
$warnings++;
|
$warnings++;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
$totalMb = round($totalSize / 1024 / 1024, 1);
|
$totalMb = round($totalSize / 1024 / 1024, 1);
|
||||||
echo " " . count($images) . " image(s), {$totalMb}MB total";
|
echo " " . count($images) . " image(s), {$totalMb}MB total";
|
||||||
if ($oversized > 0) { echo ", {$oversized} oversized"; }
|
if ($oversized > 0) {
|
||||||
echo "\n";
|
echo ", {$oversized} oversized";
|
||||||
|
}
|
||||||
|
echo "\n";
|
||||||
|
|
||||||
echo "\n--- Hardcoded URLs ---\n";
|
echo "\n--- Hardcoded URLs ---\n";
|
||||||
$codeFiles = array_merge($this->findFiles($srcDir, '*.css'), $this->findFiles($srcDir, '*.js'));
|
$codeFiles = array_merge($this->findFiles($srcDir, '*.css'), $this->findFiles($srcDir, '*.js'));
|
||||||
$codeFiles = array_filter($codeFiles, function ($f) {
|
$codeFiles = array_filter($codeFiles, function ($f) {
|
||||||
return !preg_match('/\.min\.(css|js)$/', $f);
|
return !preg_match('/\.min\.(css|js)$/', $f);
|
||||||
});
|
});
|
||||||
$urlPatterns = [
|
$urlPatterns = [
|
||||||
'/https?:\/\/clarksvillefurs\.com/' => 'hardcoded production URL',
|
'/https?:\/\/clarksvillefurs\.com/' => 'hardcoded production URL',
|
||||||
'/https?:\/\/[a-z]+\.dev\.mokoconsulting\.tech/' => 'hardcoded dev URL',
|
'/https?:\/\/[a-z]+\.dev\.mokoconsulting\.tech/' => 'hardcoded dev URL',
|
||||||
'/https?:\/\/localhost/' => 'localhost reference',
|
'/https?:\/\/localhost/' => 'localhost reference',
|
||||||
];
|
];
|
||||||
$urlIssues = 0;
|
$urlIssues = 0;
|
||||||
foreach ($codeFiles as $file) {
|
foreach ($codeFiles as $file) {
|
||||||
$content = file_get_contents($file);
|
$content = file_get_contents($file);
|
||||||
$relPath = str_replace($root . '/', '', $file);
|
$relPath = str_replace($root . '/', '', $file);
|
||||||
foreach ($urlPatterns as $pattern => $desc) {
|
foreach ($urlPatterns as $pattern => $desc) {
|
||||||
if (preg_match_all($pattern, $content, $matches)) {
|
if (preg_match_all($pattern, $content, $matches)) {
|
||||||
$count = count($matches[0]);
|
$count = count($matches[0]);
|
||||||
echo " WARN: {$relPath}: {$count} {$desc}\n";
|
echo " WARN: {$relPath}: {$count} {$desc}\n";
|
||||||
$urlIssues++;
|
$urlIssues++;
|
||||||
$warnings++;
|
$warnings++;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if ($urlIssues === 0) { echo " OK: No hardcoded URLs found\n"; }
|
if ($urlIssues === 0) {
|
||||||
|
echo " OK: No hardcoded URLs found\n";
|
||||||
|
}
|
||||||
|
|
||||||
echo "\n=== Summary ===\n";
|
echo "\n=== Summary ===\n";
|
||||||
echo "Errors: {$errors}\n";
|
echo "Errors: {$errors}\n";
|
||||||
echo "Warnings: {$warnings}\n";
|
echo "Warnings: {$warnings}\n";
|
||||||
|
|
||||||
if ($ghOutput) {
|
if ($ghOutput) {
|
||||||
$ghFile = getenv('GITHUB_OUTPUT');
|
$ghFile = getenv('GITHUB_OUTPUT');
|
||||||
if ($ghFile) {
|
if ($ghFile) {
|
||||||
file_put_contents($ghFile, "lint_errors={$errors}\n", FILE_APPEND);
|
file_put_contents($ghFile, "lint_errors={$errors}\n", FILE_APPEND);
|
||||||
file_put_contents($ghFile, "lint_warnings={$warnings}\n", FILE_APPEND);
|
file_put_contents($ghFile, "lint_warnings={$warnings}\n", FILE_APPEND);
|
||||||
file_put_contents($ghFile, "lint_images=" . count($images) . "\n", FILE_APPEND);
|
file_put_contents($ghFile, "lint_images=" . count($images) . "\n", FILE_APPEND);
|
||||||
file_put_contents($ghFile, "lint_css=" . count($cssToCheck) . "\n", FILE_APPEND);
|
file_put_contents($ghFile, "lint_css=" . count($cssToCheck) . "\n", FILE_APPEND);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($errors > 0) { return 1; }
|
if ($errors > 0) {
|
||||||
if ($strict && $warnings > 0) { return 1; }
|
return 1;
|
||||||
return 0;
|
}
|
||||||
}
|
if ($strict && $warnings > 0) {
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
private function findFiles(string $dir, string $pattern): array
|
private function findFiles(string $dir, string $pattern): array
|
||||||
{
|
{
|
||||||
$results = [];
|
$results = [];
|
||||||
if (!is_dir($dir)) { return $results; }
|
if (!is_dir($dir)) {
|
||||||
$iterator = new RecursiveIteratorIterator(
|
return $results;
|
||||||
new RecursiveDirectoryIterator($dir, RecursiveDirectoryIterator::SKIP_DOTS)
|
}
|
||||||
);
|
$iterator = new RecursiveIteratorIterator(
|
||||||
foreach ($iterator as $file) {
|
new RecursiveDirectoryIterator($dir, RecursiveDirectoryIterator::SKIP_DOTS)
|
||||||
if (fnmatch($pattern, $file->getFilename())) {
|
);
|
||||||
$results[] = $file->getPathname();
|
foreach ($iterator as $file) {
|
||||||
}
|
if (fnmatch($pattern, $file->getFilename())) {
|
||||||
}
|
$results[] = $file->getPathname();
|
||||||
return $results;
|
}
|
||||||
}
|
}
|
||||||
|
return $results;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
$app = new ThemeLintCli();
|
$app = new ThemeLintCli();
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
#!/usr/bin/env php
|
#!/usr/bin/env php
|
||||||
<?php
|
<?php
|
||||||
|
|
||||||
/* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
|
/* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
|
||||||
*
|
*
|
||||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
@@ -94,7 +95,8 @@ class UpdatesXmlSyncCli extends CliFramework
|
|||||||
$discovered = [];
|
$discovered = [];
|
||||||
foreach ($branchList as $b) {
|
foreach ($branchList as $b) {
|
||||||
$name = $b['name'] ?? '';
|
$name = $b['name'] ?? '';
|
||||||
if ($name !== '' && $name !== $current
|
if (
|
||||||
|
$name !== '' && $name !== $current
|
||||||
&& !str_starts_with($name, 'version/')
|
&& !str_starts_with($name, 'version/')
|
||||||
&& !str_starts_with($name, 'feature/')
|
&& !str_starts_with($name, 'feature/')
|
||||||
&& !str_starts_with($name, 'patch/')
|
&& !str_starts_with($name, 'patch/')
|
||||||
@@ -144,8 +146,14 @@ class UpdatesXmlSyncCli extends CliFramework
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
$ok = $this->putFile($apiBase, $token, $branch, $encoded, $sha,
|
$ok = $this->putFile(
|
||||||
"chore: sync updates.xml{$vLabel} from {$current} [skip ci]");
|
$apiBase,
|
||||||
|
$token,
|
||||||
|
$branch,
|
||||||
|
$encoded,
|
||||||
|
$sha,
|
||||||
|
"chore: sync updates.xml{$vLabel} from {$current} [skip ci]"
|
||||||
|
);
|
||||||
|
|
||||||
if ($ok) {
|
if ($ok) {
|
||||||
$this->log('INFO', "Synced to {$branch}");
|
$this->log('INFO', "Synced to {$branch}");
|
||||||
@@ -166,9 +174,14 @@ class UpdatesXmlSyncCli extends CliFramework
|
|||||||
return $resp['sha'] ?? null;
|
return $resp['sha'] ?? null;
|
||||||
}
|
}
|
||||||
|
|
||||||
private function putFile(string $apiBase, string $token, string $branch,
|
private function putFile(
|
||||||
string $encoded, string $sha, string $msg): bool
|
string $apiBase,
|
||||||
{
|
string $token,
|
||||||
|
string $branch,
|
||||||
|
string $encoded,
|
||||||
|
string $sha,
|
||||||
|
string $msg
|
||||||
|
): bool {
|
||||||
$resp = $this->apiCall('PUT', "{$apiBase}/contents/updates.xml", $token, [
|
$resp = $this->apiCall('PUT', "{$apiBase}/contents/updates.xml", $token, [
|
||||||
'content' => $encoded,
|
'content' => $encoded,
|
||||||
'sha' => $sha,
|
'sha' => $sha,
|
||||||
@@ -194,8 +207,11 @@ class UpdatesXmlSyncCli extends CliFramework
|
|||||||
curl_setopt($ch, CURLOPT_FOLLOWLOCATION, true);
|
curl_setopt($ch, CURLOPT_FOLLOWLOCATION, true);
|
||||||
|
|
||||||
if ($data !== null) {
|
if ($data !== null) {
|
||||||
curl_setopt($ch, CURLOPT_POSTFIELDS,
|
curl_setopt(
|
||||||
json_encode($data, JSON_UNESCAPED_SLASHES));
|
$ch,
|
||||||
|
CURLOPT_POSTFIELDS,
|
||||||
|
json_encode($data, JSON_UNESCAPED_SLASHES)
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
$body = curl_exec($ch);
|
$body = curl_exec($ch);
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
#!/usr/bin/env php
|
#!/usr/bin/env php
|
||||||
<?php
|
<?php
|
||||||
|
|
||||||
/* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
|
/* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
|
||||||
*
|
*
|
||||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
@@ -82,8 +83,11 @@ class VersionAutoBumpCli extends CliFramework
|
|||||||
$shouldBump = true;
|
$shouldBump = true;
|
||||||
if (!empty($watchPath)) {
|
if (!empty($watchPath)) {
|
||||||
$root = realpath($path) ?: $path;
|
$root = realpath($path) ?: $path;
|
||||||
|
$cdCmd = PHP_OS_FAMILY === 'Windows' ? "cd /d " : "cd ";
|
||||||
$diffOutput = trim((string) @shell_exec(
|
$diffOutput = trim((string) @shell_exec(
|
||||||
(PHP_OS_FAMILY === 'Windows' ? "cd /d " : "cd ") . escapeshellarg($root) . " && git diff --name-only HEAD~1 HEAD -- " . escapeshellarg($watchPath) . " 2>/dev/null"
|
$cdCmd . escapeshellarg($root)
|
||||||
|
. " && git diff --name-only HEAD~1 HEAD -- "
|
||||||
|
. escapeshellarg($watchPath) . " 2>/dev/null"
|
||||||
));
|
));
|
||||||
if (empty($diffOutput)) {
|
if (empty($diffOutput)) {
|
||||||
echo "No changes in {$watchPath} — skipping version bump\n";
|
echo "No changes in {$watchPath} — skipping version bump\n";
|
||||||
@@ -148,28 +152,50 @@ class VersionAutoBumpCli extends CliFramework
|
|||||||
$root = realpath($path) ?: $path;
|
$root = realpath($path) ?: $path;
|
||||||
|
|
||||||
// Check if anything changed
|
// Check if anything changed
|
||||||
$diffStatus = trim((string) @shell_exec((PHP_OS_FAMILY === 'Windows' ? "cd /d " : "cd ") . escapeshellarg($root) . " && git diff --quiet && git diff --cached --quiet 2>&1 && echo clean || echo dirty"));
|
$cdPrefix = PHP_OS_FAMILY === 'Windows' ? "cd /d " : "cd ";
|
||||||
|
$diffStatus = trim((string) @shell_exec(
|
||||||
|
$cdPrefix . escapeshellarg($root)
|
||||||
|
. " && git diff --quiet && git diff --cached --quiet"
|
||||||
|
. " 2>&1 && echo clean || echo dirty"
|
||||||
|
));
|
||||||
if ($diffStatus === 'clean') {
|
if ($diffStatus === 'clean') {
|
||||||
echo "No version changes to commit\n";
|
echo "No version changes to commit\n";
|
||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Configure git
|
// Configure git
|
||||||
@shell_exec((PHP_OS_FAMILY === 'Windows' ? "cd /d " : "cd ") . escapeshellarg($root) . " && git config --local user.email \"gitea-actions[bot]@mokoconsulting.tech\"");
|
$cd = PHP_OS_FAMILY === 'Windows' ? "cd /d " : "cd ";
|
||||||
@shell_exec((PHP_OS_FAMILY === 'Windows' ? "cd /d " : "cd ") . escapeshellarg($root) . " && git config --local user.name \"gitea-actions[bot]\"");
|
$cdRoot = $cd . escapeshellarg($root);
|
||||||
|
@shell_exec(
|
||||||
|
$cdRoot . " && git config --local user.email"
|
||||||
|
. " \"gitea-actions[bot]@mokoconsulting.tech\""
|
||||||
|
);
|
||||||
|
@shell_exec(
|
||||||
|
$cdRoot . " && git config --local user.name"
|
||||||
|
. " \"gitea-actions[bot]\""
|
||||||
|
);
|
||||||
|
|
||||||
if (!empty($repoUrl)) {
|
if (!empty($repoUrl)) {
|
||||||
@shell_exec((PHP_OS_FAMILY === 'Windows' ? "cd /d " : "cd ") . escapeshellarg($root) . " && git remote set-url origin " . escapeshellarg($repoUrl));
|
@shell_exec(
|
||||||
|
$cdRoot . " && git remote set-url origin "
|
||||||
|
. escapeshellarg($repoUrl)
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@shell_exec((PHP_OS_FAMILY === 'Windows' ? "cd /d " : "cd ") . escapeshellarg($root) . " && git add -A");
|
@shell_exec($cdRoot . " && git add -A");
|
||||||
$commitMsg = $shouldBump
|
$commitMsg = $shouldBump
|
||||||
? "chore(version): auto-bump patch {$displayVersion} [skip ci]"
|
? "chore(version): auto-bump patch {$displayVersion} [skip ci]"
|
||||||
: "chore(version): set {$stability} suffix {$displayVersion} [skip ci]";
|
: "chore(version): set {$stability} suffix {$displayVersion} [skip ci]";
|
||||||
@shell_exec((PHP_OS_FAMILY === 'Windows' ? "cd /d " : "cd ") . escapeshellarg($root) . " && git commit -m " . escapeshellarg($commitMsg)
|
@shell_exec(
|
||||||
. " --author=\"gitea-actions[bot] <gitea-actions[bot]@mokoconsulting.tech>\"");
|
$cdRoot . " && git commit -m " . escapeshellarg($commitMsg)
|
||||||
|
. " --author=\"gitea-actions[bot]"
|
||||||
|
. " <gitea-actions[bot]@mokoconsulting.tech>\""
|
||||||
|
);
|
||||||
|
|
||||||
$pushResult = @shell_exec((PHP_OS_FAMILY === 'Windows' ? "cd /d " : "cd ") . escapeshellarg($root) . " && git push origin " . escapeshellarg($branch) . " 2>&1");
|
$pushResult = @shell_exec(
|
||||||
|
$cdRoot . " && git push origin "
|
||||||
|
. escapeshellarg($branch) . " 2>&1"
|
||||||
|
);
|
||||||
echo $pushResult ?? '';
|
echo $pushResult ?? '';
|
||||||
|
|
||||||
echo "Bumped to {$displayVersion}\n";
|
echo "Bumped to {$displayVersion}\n";
|
||||||
|
|||||||
+227
-64
@@ -1,5 +1,6 @@
|
|||||||
#!/usr/bin/env php
|
#!/usr/bin/env php
|
||||||
<?php
|
<?php
|
||||||
|
|
||||||
/* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
|
/* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
|
||||||
*
|
*
|
||||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
@@ -20,71 +21,233 @@ use MokoEnterprise\CliFramework;
|
|||||||
|
|
||||||
class VersionBumpCli extends CliFramework
|
class VersionBumpCli extends CliFramework
|
||||||
{
|
{
|
||||||
protected function configure(): void
|
protected function configure(): void
|
||||||
{
|
{
|
||||||
$this->setDescription('Auto-increment version -- manifest.xml is canonical, cascades to all XML and MD files');
|
$this->setDescription('Auto-increment version -- manifest.xml is canonical, cascades to all XML and MD files');
|
||||||
$this->addArgument('--path', 'Repository root', '.');
|
$this->addArgument('--path', 'Repository root', '.');
|
||||||
$this->addArgument('--minor', 'Bump minor version', false);
|
$this->addArgument('--minor', 'Bump minor version', false);
|
||||||
$this->addArgument('--major', 'Bump major version', false);
|
$this->addArgument('--major', 'Bump major version', false);
|
||||||
}
|
}
|
||||||
|
|
||||||
protected function run(): int
|
protected function run(): int
|
||||||
{
|
{
|
||||||
$path = $this->getArgument('--path'); $type = 'patch';
|
$path = $this->getArgument('--path');
|
||||||
if ($this->getArgument('--minor')) $type = 'minor';
|
$type = 'patch';
|
||||||
if ($this->getArgument('--major')) $type = 'major';
|
if ($this->getArgument('--minor')) {
|
||||||
$root = realpath($path) ?: $path;
|
$type = 'minor';
|
||||||
$mokoVersion = null; $mokoManifest = "{$root}/.mokogitea/manifest.xml"; $mokoContent = '';
|
}
|
||||||
if (file_exists($mokoManifest)) { $mokoContent = file_get_contents($mokoManifest); if (preg_match('#<version>(\d{2}\.\d{2}\.\d{2})(?:-((?:(?:dev|alpha|beta|rc)-?)+))?</version>#', $mokoContent, $m)) { $mokoVersion = $m[1]; } }
|
if ($this->getArgument('--major')) {
|
||||||
$readmeVersion = null; $readme = "{$root}/README.md"; $readmeContent = '';
|
$type = 'major';
|
||||||
if (file_exists($readme)) { $readmeContent = file_get_contents($readme); if (preg_match('/VERSION:\s*(\d{2}\.\d{2}\.\d{2})/m', $readmeContent, $m)) { $readmeVersion = $m[1]; } }
|
}
|
||||||
$manifestVersion = null;
|
$root = realpath($path) ?: $path;
|
||||||
$manifestFiles = array_merge(glob("{$root}/src/pkg_*.xml") ?: [], glob("{$root}/src/*.xml") ?: [], glob("{$root}/src/packages/*/mokowaas.xml") ?: [], glob("{$root}/src/packages/*/*.xml") ?: [], glob("{$root}/*.xml") ?: []);
|
$mokoVersion = null;
|
||||||
foreach ($manifestFiles as $xmlFile) { $xmlContent = file_get_contents($xmlFile); if (strpos($xmlContent, '<extension') === false && strpos($xmlContent, '<version>') === false) { continue; } if (preg_match('#<version>(\d{2}\.\d{2}\.\d{2})((?:-(?:dev|alpha|beta|rc))+)?</version>#', $xmlContent, $xm)) { $candidate = $xm[1]; if ($manifestVersion === null || version_compare($candidate, $manifestVersion, '>')) { $manifestVersion = $candidate; } } }
|
$mokoManifest = "{$root}/.mokogitea/manifest.xml";
|
||||||
$baseVersion = null; $candidates = array_filter([$mokoVersion, $readmeVersion, $manifestVersion]);
|
$mokoContent = '';
|
||||||
foreach ($candidates as $v) { if ($baseVersion === null || version_compare($v, $baseVersion, '>')) { $baseVersion = $v; } }
|
if (file_exists($mokoManifest)) {
|
||||||
if ($baseVersion === null) { $this->log('ERROR', "No version found in manifest.xml, README.md, or Joomla XML"); return 1; }
|
$mokoContent = file_get_contents($mokoManifest);
|
||||||
if (!preg_match('/^(\d{2})\.(\d{2})\.(\d{2})$/', $baseVersion, $parts)) { $this->log('ERROR', "Invalid version format: {$baseVersion}"); return 1; }
|
if (preg_match('#<version>(\d{2}\.\d{2}\.\d{2})(?:-((?:(?:dev|alpha|beta|rc)-?)+))?</version>#', $mokoContent, $m)) {
|
||||||
$major = (int)$parts[1]; $minor = (int)$parts[2]; $patch = (int)$parts[3];
|
$mokoVersion = $m[1];
|
||||||
$old = sprintf('%02d.%02d.%02d', $major, $minor, $patch);
|
}
|
||||||
switch ($type) { case 'major': $major++; $minor = 0; $patch = 0; break; case 'minor': $minor++; $patch = 0; break; default: $patch++; if ($patch > 99) { $minor++; $patch = 0; } if ($minor > 99) { $major++; $minor = 0; } break; }
|
}
|
||||||
$newFull = sprintf('%02d.%02d.%02d', $major, $minor, $patch);
|
$readmeVersion = null;
|
||||||
if (file_exists($mokoManifest) && !empty($mokoContent)) { $updated = preg_replace('#<version>\d{2}\.\d{2}\.\d{2}(?:(?:-(?:dev|alpha|beta|rc))+)?</version>#', "<version>{$newFull}</version>", $mokoContent, 1); if ($updated !== null) { file_put_contents($mokoManifest, $updated); } }
|
$readme = "{$root}/README.md";
|
||||||
if (file_exists($readme) && !empty($readmeContent)) { $updated = preg_replace('/(VERSION:\s*)\d{2}\.\d{2}\.\d{2}(?:(?:-(?:dev|alpha|beta|rc))+)?/m', '${1}' . $newFull, $readmeContent, 1); if ($updated !== null) { file_put_contents($readme, $updated); } }
|
$readmeContent = '';
|
||||||
$updatedFiles = [];
|
if (file_exists($readme)) {
|
||||||
foreach (["{$root}/src/pkg_*.xml", "{$root}/src/*.xml", "{$root}/src/packages/*/*.xml", "{$root}/*.xml"] as $pattern) {
|
$readmeContent = file_get_contents($readme);
|
||||||
foreach (glob($pattern) ?: [] as $xmlFile) { $content = file_get_contents($xmlFile); if (strpos($content, '<extension') === false) { continue; } $newContent = preg_replace('#<version>\d{2}\.\d{2}\.\d{2}(?:(?:-(?:dev|alpha|beta|rc))+)?</version>#', "<version>{$newFull}</version>", $content); if ($newContent !== null && $newContent !== $content) { file_put_contents($xmlFile, $newContent); $updatedFiles[] = substr($xmlFile, strlen($root) + 1); } }
|
if (preg_match('/VERSION:\s*(\d{2}\.\d{2}\.\d{2})/m', $readmeContent, $m)) {
|
||||||
}
|
$readmeVersion = $m[1];
|
||||||
if (!empty($updatedFiles)) { fwrite(STDERR, "Updated " . count($updatedFiles) . " Joomla manifest(s): " . implode(', ', $updatedFiles) . "\n"); }
|
}
|
||||||
$packageJsonFile = "{$root}/package.json";
|
}
|
||||||
if (file_exists($packageJsonFile)) { $pkgContent = file_get_contents($packageJsonFile); $updatedPkg = preg_replace('/("version"\s*:\s*")\d{2}\.\d{2}\.\d{2}(?:(?:-(?:dev|alpha|beta|rc))+)?(")/m', '${1}' . $newFull . '${2}', $pkgContent); if ($updatedPkg !== $pkgContent) { file_put_contents($packageJsonFile, $updatedPkg); fwrite(STDERR, "Updated package.json\n"); } }
|
$manifestVersion = null;
|
||||||
$pyprojectFile = "{$root}/pyproject.toml";
|
$manifestFiles = array_merge(
|
||||||
if (file_exists($pyprojectFile)) { $pyContent = file_get_contents($pyprojectFile); $updatedPy = preg_replace('/^(version\s*=\s*")\d{2}\.\d{2}\.\d{2}(?:(?:-(?:dev|alpha|beta|rc))+)?(")/m', '${1}' . $newFull . '${2}', $pyContent); if ($updatedPy !== $pyContent) { file_put_contents($pyprojectFile, $updatedPy); fwrite(STDERR, "Updated pyproject.toml\n"); } }
|
glob("{$root}/src/pkg_*.xml") ?: [],
|
||||||
$changelogFile = "{$root}/CHANGELOG.md";
|
glob("{$root}/src/*.xml") ?: [],
|
||||||
if (file_exists($changelogFile)) { $clContent = file_get_contents($changelogFile); $updatedCl = preg_replace('/(VERSION:\s*)\d{2}\.\d{2}\.\d{2}(?:(?:-(?:dev|alpha|beta|rc))+)?/m', '${1}' . $newFull, $clContent); if ($updatedCl !== null && $updatedCl !== $clContent) { file_put_contents($changelogFile, $updatedCl); fwrite(STDERR, "Updated CHANGELOG.md\n"); } }
|
glob("{$root}/src/packages/*/mokowaas.xml") ?: [],
|
||||||
$scanExtensions = ['php', 'yml', 'yaml', 'md', 'txt', 'xml', 'sh', 'toml', 'ini', 'css', 'js'];
|
glob("{$root}/src/packages/*/*.xml") ?: [],
|
||||||
$excludeDirs = ['.git', 'vendor', 'node_modules', 'build', 'dist', '.claude'];
|
glob("{$root}/*.xml") ?: []
|
||||||
$versionPattern = '/(VERSION:\s*)\d{2}\.\d{2}\.\d{2}(?:(?:-(?:dev|alpha|beta|rc))+)?/m';
|
);
|
||||||
$directory = new RecursiveDirectoryIterator($root, RecursiveDirectoryIterator::SKIP_DOTS);
|
foreach ($manifestFiles as $xmlFile) {
|
||||||
$filter = new RecursiveCallbackFilterIterator($directory, function ($current, $key, $iterator) use ($excludeDirs) { if ($current->isDir() && in_array($current->getFilename(), $excludeDirs, true)) { return false; } return true; });
|
$xmlContent = file_get_contents($xmlFile);
|
||||||
$iterator = new RecursiveIteratorIterator($filter);
|
if (strpos($xmlContent, '<extension') === false && strpos($xmlContent, '<version>') === false) {
|
||||||
$genericUpdated = [];
|
continue;
|
||||||
foreach ($iterator as $fileInfo) {
|
} if (preg_match('#<version>(\d{2}\.\d{2}\.\d{2})((?:-(?:dev|alpha|beta|rc))+)?</version>#', $xmlContent, $xm)) {
|
||||||
if ($fileInfo->isDir()) { continue; }
|
$candidate = $xm[1];
|
||||||
$ext = strtolower($fileInfo->getExtension()); if (!in_array($ext, $scanExtensions, true)) { continue; }
|
if ($manifestVersion === null || version_compare($candidate, $manifestVersion, '>')) {
|
||||||
$filePath = $fileInfo->getPathname(); $relPath = str_replace([$root . '/', $root . '\\'], '', $filePath);
|
$manifestVersion = $candidate;
|
||||||
if (in_array($relPath, ['README.md', 'CHANGELOG.md', 'package.json', 'pyproject.toml'], true)) { continue; }
|
}
|
||||||
if (in_array($relPath, $updatedFiles ?? [], true)) { continue; }
|
}
|
||||||
if (strpos($relPath, '.mokogitea/manifest.xml') !== false) { continue; }
|
}
|
||||||
$content = @file_get_contents($filePath); if ($content === false) { continue; }
|
$baseVersion = null;
|
||||||
if (preg_match('/^#\s*REPO:\s*https?:\/\//m', $content)) { continue; }
|
$candidates = array_filter([$mokoVersion, $readmeVersion, $manifestVersion]);
|
||||||
$updated = preg_replace($versionPattern, '${1}' . $newFull, $content);
|
foreach ($candidates as $v) {
|
||||||
if ($updated !== null && $updated !== $content) { file_put_contents($filePath, $updated); $genericUpdated[] = $relPath; }
|
if ($baseVersion === null || version_compare($v, $baseVersion, '>')) {
|
||||||
}
|
$baseVersion = $v;
|
||||||
if (!empty($genericUpdated)) { fwrite(STDERR, "Updated VERSION: in " . count($genericUpdated) . " file(s): " . implode(', ', $genericUpdated) . "\n"); }
|
}
|
||||||
echo "{$old} -> {$newFull}\n";
|
}
|
||||||
return 0;
|
if ($baseVersion === null) {
|
||||||
}
|
$this->log('ERROR', "No version found in manifest.xml, README.md, or Joomla XML");
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
if (!preg_match('/^(\d{2})\.(\d{2})\.(\d{2})$/', $baseVersion, $parts)) {
|
||||||
|
$this->log('ERROR', "Invalid version format: {$baseVersion}");
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
$major = (int)$parts[1];
|
||||||
|
$minor = (int)$parts[2];
|
||||||
|
$patch = (int)$parts[3];
|
||||||
|
$old = sprintf('%02d.%02d.%02d', $major, $minor, $patch);
|
||||||
|
switch ($type) {
|
||||||
|
case 'major':
|
||||||
|
$major++;
|
||||||
|
$minor = 0;
|
||||||
|
$patch = 0;
|
||||||
|
break;
|
||||||
|
case 'minor':
|
||||||
|
$minor++;
|
||||||
|
$patch = 0;
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
$patch++;
|
||||||
|
if ($patch > 99) {
|
||||||
|
$minor++;
|
||||||
|
$patch = 0;
|
||||||
|
} if ($minor > 99) {
|
||||||
|
$major++;
|
||||||
|
$minor = 0;
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
$newFull = sprintf('%02d.%02d.%02d', $major, $minor, $patch);
|
||||||
|
if (file_exists($mokoManifest) && !empty($mokoContent)) {
|
||||||
|
$pattern = '#<version>\d{2}\.\d{2}\.\d{2}'
|
||||||
|
. '(?:(?:-(?:dev|alpha|beta|rc))+)?</version>#';
|
||||||
|
$updated = preg_replace(
|
||||||
|
$pattern,
|
||||||
|
"<version>{$newFull}</version>",
|
||||||
|
$mokoContent,
|
||||||
|
1
|
||||||
|
);
|
||||||
|
if ($updated !== null) {
|
||||||
|
file_put_contents($mokoManifest, $updated);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (file_exists($readme) && !empty($readmeContent)) {
|
||||||
|
$updated = preg_replace('/(VERSION:\s*)\d{2}\.\d{2}\.\d{2}(?:(?:-(?:dev|alpha|beta|rc))+)?/m', '${1}' . $newFull, $readmeContent, 1);
|
||||||
|
if ($updated !== null) {
|
||||||
|
file_put_contents($readme, $updated);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
$updatedFiles = [];
|
||||||
|
foreach (["{$root}/src/pkg_*.xml", "{$root}/src/*.xml", "{$root}/src/packages/*/*.xml", "{$root}/*.xml"] as $pattern) {
|
||||||
|
foreach (glob($pattern) ?: [] as $xmlFile) {
|
||||||
|
$content = file_get_contents($xmlFile);
|
||||||
|
if (strpos($content, '<extension') === false) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$xmlPattern = '#<version>\d{2}\.\d{2}\.\d{2}'
|
||||||
|
. '(?:(?:-(?:dev|alpha|beta|rc))+)?</version>#';
|
||||||
|
$newContent = preg_replace(
|
||||||
|
$xmlPattern,
|
||||||
|
"<version>{$newFull}</version>",
|
||||||
|
$content
|
||||||
|
);
|
||||||
|
if ($newContent !== null && $newContent !== $content) {
|
||||||
|
file_put_contents($xmlFile, $newContent);
|
||||||
|
$updatedFiles[] = substr($xmlFile, strlen($root) + 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!empty($updatedFiles)) {
|
||||||
|
fwrite(STDERR, "Updated " . count($updatedFiles) . " Joomla manifest(s): " . implode(', ', $updatedFiles) . "\n");
|
||||||
|
}
|
||||||
|
$packageJsonFile = "{$root}/package.json";
|
||||||
|
if (file_exists($packageJsonFile)) {
|
||||||
|
$pkgContent = file_get_contents($packageJsonFile);
|
||||||
|
$pkgPattern = '/("version"\s*:\s*")\d{2}\.\d{2}\.\d{2}'
|
||||||
|
. '(?:(?:-(?:dev|alpha|beta|rc))+)?(")/m';
|
||||||
|
$updatedPkg = preg_replace(
|
||||||
|
$pkgPattern,
|
||||||
|
'${1}' . $newFull . '${2}',
|
||||||
|
$pkgContent
|
||||||
|
);
|
||||||
|
if ($updatedPkg !== $pkgContent) {
|
||||||
|
file_put_contents($packageJsonFile, $updatedPkg);
|
||||||
|
fwrite(STDERR, "Updated package.json\n");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
$pyprojectFile = "{$root}/pyproject.toml";
|
||||||
|
if (file_exists($pyprojectFile)) {
|
||||||
|
$pyContent = file_get_contents($pyprojectFile);
|
||||||
|
$pyPattern = '/^(version\s*=\s*")\d{2}\.\d{2}\.\d{2}'
|
||||||
|
. '(?:(?:-(?:dev|alpha|beta|rc))+)?(")/m';
|
||||||
|
$updatedPy = preg_replace(
|
||||||
|
$pyPattern,
|
||||||
|
'${1}' . $newFull . '${2}',
|
||||||
|
$pyContent
|
||||||
|
);
|
||||||
|
if ($updatedPy !== $pyContent) {
|
||||||
|
file_put_contents($pyprojectFile, $updatedPy);
|
||||||
|
fwrite(STDERR, "Updated pyproject.toml\n");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
$changelogFile = "{$root}/CHANGELOG.md";
|
||||||
|
if (file_exists($changelogFile)) {
|
||||||
|
$clContent = file_get_contents($changelogFile);
|
||||||
|
$updatedCl = preg_replace('/(VERSION:\s*)\d{2}\.\d{2}\.\d{2}(?:(?:-(?:dev|alpha|beta|rc))+)?/m', '${1}' . $newFull, $clContent);
|
||||||
|
if ($updatedCl !== null && $updatedCl !== $clContent) {
|
||||||
|
file_put_contents($changelogFile, $updatedCl);
|
||||||
|
fwrite(STDERR, "Updated CHANGELOG.md\n");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
$scanExtensions = ['php', 'yml', 'yaml', 'md', 'txt', 'xml', 'sh', 'toml', 'ini', 'css', 'js'];
|
||||||
|
$excludeDirs = ['.git', 'vendor', 'node_modules', 'build', 'dist', '.claude'];
|
||||||
|
$versionPattern = '/(VERSION:\s*)\d{2}\.\d{2}\.\d{2}(?:(?:-(?:dev|alpha|beta|rc))+)?/m';
|
||||||
|
$directory = new RecursiveDirectoryIterator($root, RecursiveDirectoryIterator::SKIP_DOTS);
|
||||||
|
$filter = new RecursiveCallbackFilterIterator($directory, function ($current, $key, $iterator) use ($excludeDirs) {
|
||||||
|
if ($current->isDir() && in_array($current->getFilename(), $excludeDirs, true)) {
|
||||||
|
return false;
|
||||||
|
} return true;
|
||||||
|
});
|
||||||
|
$iterator = new RecursiveIteratorIterator($filter);
|
||||||
|
$genericUpdated = [];
|
||||||
|
foreach ($iterator as $fileInfo) {
|
||||||
|
if ($fileInfo->isDir()) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$ext = strtolower($fileInfo->getExtension());
|
||||||
|
if (!in_array($ext, $scanExtensions, true)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$filePath = $fileInfo->getPathname();
|
||||||
|
$relPath = str_replace([$root . '/', $root . '\\'], '', $filePath);
|
||||||
|
if (in_array($relPath, ['README.md', 'CHANGELOG.md', 'package.json', 'pyproject.toml'], true)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (in_array($relPath, $updatedFiles ?? [], true)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (strpos($relPath, '.mokogitea/manifest.xml') !== false) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$content = @file_get_contents($filePath);
|
||||||
|
if ($content === false) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (preg_match('/^#\s*REPO:\s*https?:\/\//m', $content)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$updated = preg_replace($versionPattern, '${1}' . $newFull, $content);
|
||||||
|
if ($updated !== null && $updated !== $content) {
|
||||||
|
file_put_contents($filePath, $updated);
|
||||||
|
$genericUpdated[] = $relPath;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!empty($genericUpdated)) {
|
||||||
|
fwrite(STDERR, "Updated VERSION: in " . count($genericUpdated) . " file(s): " . implode(', ', $genericUpdated) . "\n");
|
||||||
|
}
|
||||||
|
echo "{$old} -> {$newFull}\n";
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
$app = new VersionBumpCli();
|
$app = new VersionBumpCli();
|
||||||
|
|||||||
+171
-85
@@ -1,5 +1,6 @@
|
|||||||
#!/usr/bin/env php
|
#!/usr/bin/env php
|
||||||
<?php
|
<?php
|
||||||
|
|
||||||
/* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
|
/* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
|
||||||
*
|
*
|
||||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
@@ -20,95 +21,180 @@ use MokoEnterprise\CliFramework;
|
|||||||
|
|
||||||
class VersionBumpRemoteCli extends CliFramework
|
class VersionBumpRemoteCli extends CliFramework
|
||||||
{
|
{
|
||||||
protected function configure(): void
|
protected function configure(): void
|
||||||
{
|
{
|
||||||
$this->setDescription('Bump version in manifest XML and CHANGELOG.md on a remote branch via Gitea API');
|
$this->setDescription('Bump version in manifest XML and CHANGELOG.md on a remote branch via Gitea API');
|
||||||
$this->addArgument('--path', 'Repository root', '.');
|
$this->addArgument('--path', 'Repository root', '.');
|
||||||
$this->addArgument('--branch', 'Target branch to bump (required)', null);
|
$this->addArgument('--branch', 'Target branch to bump (required)', null);
|
||||||
$this->addArgument('--bump', 'Bump type: patch | minor | major', 'minor');
|
$this->addArgument('--bump', 'Bump type: patch | minor | major', 'minor');
|
||||||
$this->addArgument('--token', 'Gitea API token (or MOKOGITEA_TOKEN env var)', null);
|
$this->addArgument('--token', 'Gitea API token (or MOKOGITEA_TOKEN env var)', null);
|
||||||
$this->addArgument('--api-base', 'Gitea API base URL for the repo', null);
|
$this->addArgument('--api-base', 'Gitea API base URL for the repo', null);
|
||||||
$this->addArgument('--no-changelog', 'Skip CHANGELOG.md bump', false);
|
$this->addArgument('--no-changelog', 'Skip CHANGELOG.md bump', false);
|
||||||
$this->addArgument('--repo', 'Repository path (owner/repo)', null);
|
$this->addArgument('--repo', 'Repository path (owner/repo)', null);
|
||||||
$this->addArgument('--gitea-url', 'Gitea instance URL', null);
|
$this->addArgument('--gitea-url', 'Gitea instance URL', null);
|
||||||
}
|
}
|
||||||
|
|
||||||
protected function run(): int
|
protected function run(): int
|
||||||
{
|
{
|
||||||
$path = $this->getArgument('--path'); $branch = $this->getArgument('--branch');
|
$path = $this->getArgument('--path');
|
||||||
$bumpType = $this->getArgument('--bump'); $token = $this->getArgument('--token');
|
$branch = $this->getArgument('--branch');
|
||||||
$apiBase = $this->getArgument('--api-base'); $noChangelog = (bool) $this->getArgument('--no-changelog');
|
$bumpType = $this->getArgument('--bump');
|
||||||
$repo = $this->getArgument('--repo'); $giteaUrl = $this->getArgument('--gitea-url');
|
$token = $this->getArgument('--token');
|
||||||
if ($token === null) $token = getenv('MOKOGITEA_TOKEN') ?: getenv('GITEA_TOKEN') ?: null;
|
$apiBase = $this->getArgument('--api-base');
|
||||||
if ($giteaUrl === null) $giteaUrl = getenv('GITEA_URL') ?: 'https://git.mokoconsulting.tech';
|
$noChangelog = (bool) $this->getArgument('--no-changelog');
|
||||||
if ($apiBase === null && $repo !== null) { $apiBase = rtrim($giteaUrl, '/') . '/api/v1/repos/' . $repo; }
|
$repo = $this->getArgument('--repo');
|
||||||
if ($branch === null || $token === null || $apiBase === null) {
|
$giteaUrl = $this->getArgument('--gitea-url');
|
||||||
$this->log('ERROR', "Usage: version_bump_remote.php --branch BRANCH --token TOKEN --api-base URL [--bump minor|patch|major]");
|
if ($token === null) {
|
||||||
return 1;
|
$token = getenv('MOKOGITEA_TOKEN') ?: getenv('GITEA_TOKEN') ?: null;
|
||||||
}
|
}
|
||||||
$root = realpath($path) ?: $path;
|
if ($giteaUrl === null) {
|
||||||
$version = null; $manifestFile = null;
|
$giteaUrl = getenv('GITEA_URL') ?: 'https://git.mokoconsulting.tech';
|
||||||
foreach (["{$root}/src", $root] as $dir) {
|
}
|
||||||
if (!is_dir($dir)) continue;
|
if ($apiBase === null && $repo !== null) {
|
||||||
foreach (glob("{$dir}/*.xml") ?: [] as $f) {
|
$apiBase = rtrim($giteaUrl, '/') . '/api/v1/repos/' . $repo;
|
||||||
$xml = file_get_contents($f);
|
}
|
||||||
if (strpos($xml, '<extension') !== false || strpos($xml, '<version>') !== false) {
|
if ($branch === null || $token === null || $apiBase === null) {
|
||||||
if (preg_match('|<version>(\d{2}\.\d{2}\.\d{2})</version>|', $xml, $m)) {
|
$this->log('ERROR', "Usage: version_bump_remote.php --branch BRANCH --token TOKEN --api-base URL [--bump minor|patch|major]");
|
||||||
if ($version === null || version_compare($m[1], $version, '>')) { $version = $m[1]; $manifestFile = basename($f); }
|
return 1;
|
||||||
}
|
}
|
||||||
}
|
$root = realpath($path) ?: $path;
|
||||||
}
|
$version = null;
|
||||||
}
|
$manifestFile = null;
|
||||||
if ($version === null) { $this->log('ERROR', "No version found in manifest XML"); return 1; }
|
foreach (["{$root}/src", $root] as $dir) {
|
||||||
if (!preg_match('/^(\d{2})\.(\d{2})\.(\d{2})$/', $version, $parts)) { $this->log('ERROR', "Invalid version format: {$version}"); return 1; }
|
if (!is_dir($dir)) {
|
||||||
$major = (int)$parts[1]; $minor = (int)$parts[2]; $patch = (int)$parts[3];
|
continue;
|
||||||
switch ($bumpType) { case 'major': $major++; $minor = 0; $patch = 0; break; case 'minor': $minor++; $patch = 0; break; default: $patch++; break; }
|
}
|
||||||
$nextVersion = sprintf('%02d.%02d.%02d', $major, $minor, $patch);
|
foreach (glob("{$dir}/*.xml") ?: [] as $f) {
|
||||||
echo "{$version} -> {$nextVersion} ({$branch})\n";
|
$xml = file_get_contents($f);
|
||||||
|
if (strpos($xml, '<extension') !== false || strpos($xml, '<version>') !== false) {
|
||||||
|
if (preg_match('|<version>(\d{2}\.\d{2}\.\d{2})</version>|', $xml, $m)) {
|
||||||
|
if ($version === null || version_compare($m[1], $version, '>')) {
|
||||||
|
$version = $m[1];
|
||||||
|
$manifestFile = basename($f);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if ($version === null) {
|
||||||
|
$this->log('ERROR', "No version found in manifest XML");
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
if (!preg_match('/^(\d{2})\.(\d{2})\.(\d{2})$/', $version, $parts)) {
|
||||||
|
$this->log('ERROR', "Invalid version format: {$version}");
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
$major = (int)$parts[1];
|
||||||
|
$minor = (int)$parts[2];
|
||||||
|
$patch = (int)$parts[3];
|
||||||
|
switch ($bumpType) {
|
||||||
|
case 'major':
|
||||||
|
$major++;
|
||||||
|
$minor = 0;
|
||||||
|
$patch = 0;
|
||||||
|
break;
|
||||||
|
case 'minor':
|
||||||
|
$minor++;
|
||||||
|
$patch = 0;
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
$patch++;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
$nextVersion = sprintf('%02d.%02d.%02d', $major, $minor, $patch);
|
||||||
|
echo "{$version} -> {$nextVersion} ({$branch})\n";
|
||||||
|
|
||||||
$manifestPaths = [];
|
$manifestPaths = [];
|
||||||
if ($manifestFile !== null) { $manifestPaths[] = "src/{$manifestFile}"; }
|
if ($manifestFile !== null) {
|
||||||
$manifestPaths = array_merge($manifestPaths, ['src/templateDetails.xml', 'src/manifest.xml']);
|
$manifestPaths[] = "src/{$manifestFile}";
|
||||||
$manifestUpdated = false;
|
}
|
||||||
foreach ($manifestPaths as $mPath) {
|
$manifestPaths = array_merge($manifestPaths, ['src/templateDetails.xml', 'src/manifest.xml']);
|
||||||
$result = $this->updateRemoteFile($apiBase, $token, $mPath, $branch, function (string $content) use ($version, $nextVersion): string { return str_replace("<version>{$version}</version>", "<version>{$nextVersion}</version>", $content); }, "chore(version): bump {$version} -> {$nextVersion} [skip ci]");
|
$manifestUpdated = false;
|
||||||
if ($result) { $manifestUpdated = true; break; }
|
foreach ($manifestPaths as $mPath) {
|
||||||
}
|
$result = $this->updateRemoteFile($apiBase, $token, $mPath, $branch, function (string $content) use ($version, $nextVersion): string {
|
||||||
if (!$manifestUpdated) { $this->log('WARN', "could not update manifest on {$branch}"); }
|
return str_replace("<version>{$version}</version>", "<version>{$nextVersion}</version>", $content);
|
||||||
if (!$noChangelog) {
|
}, "chore(version): bump {$version} -> {$nextVersion} [skip ci]");
|
||||||
$this->updateRemoteFile($apiBase, $token, 'CHANGELOG.md', $branch, function (string $content) use ($version, $nextVersion): string {
|
if ($result) {
|
||||||
$content = str_replace("VERSION: {$version}", "VERSION: {$nextVersion}", $content);
|
$manifestUpdated = true;
|
||||||
if (strpos($content, '[Unreleased]') === false && strpos($content, "## [{$nextVersion}]") === false) {
|
break;
|
||||||
$marker = "## [{$version}]";
|
}
|
||||||
if (strpos($content, $marker) !== false) { $content = str_replace($marker, "## [{$nextVersion}] - Unreleased\n\n### Added\n\n### Changed\n\n### Fixed\n\n" . $marker, $content); }
|
}
|
||||||
}
|
if (!$manifestUpdated) {
|
||||||
return $content;
|
$this->log('WARN', "could not update manifest on {$branch}");
|
||||||
}, "chore(version): bump CHANGELOG {$version} -> {$nextVersion} [skip ci]");
|
}
|
||||||
}
|
if (!$noChangelog) {
|
||||||
return 0;
|
$this->updateRemoteFile($apiBase, $token, 'CHANGELOG.md', $branch, function (string $content) use ($version, $nextVersion): string {
|
||||||
}
|
$content = str_replace("VERSION: {$version}", "VERSION: {$nextVersion}", $content);
|
||||||
|
if (strpos($content, '[Unreleased]') === false && strpos($content, "## [{$nextVersion}]") === false) {
|
||||||
|
$marker = "## [{$version}]";
|
||||||
|
if (strpos($content, $marker) !== false) {
|
||||||
|
$header = "## [{$nextVersion}] - Unreleased\n\n"
|
||||||
|
. "### Added\n\n### Changed\n\n"
|
||||||
|
. "### Fixed\n\n";
|
||||||
|
$content = str_replace(
|
||||||
|
$marker,
|
||||||
|
$header . $marker,
|
||||||
|
$content
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return $content;
|
||||||
|
}, "chore(version): bump CHANGELOG {$version} -> {$nextVersion} [skip ci]");
|
||||||
|
}
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
private function giteaApi(string $method, string $url, string $token, ?string $body = null): ?array
|
private function giteaApi(string $method, string $url, string $token, ?string $body = null): ?array
|
||||||
{
|
{
|
||||||
$ch = curl_init($url);
|
$ch = curl_init($url);
|
||||||
curl_setopt_array($ch, [CURLOPT_RETURNTRANSFER => true, CURLOPT_HTTPHEADER => ["Authorization: token {$token}", 'Content-Type: application/json'], CURLOPT_CUSTOMREQUEST => $method, CURLOPT_TIMEOUT => 30]);
|
curl_setopt_array($ch, [
|
||||||
if ($body !== null) { curl_setopt($ch, CURLOPT_POSTFIELDS, $body); }
|
CURLOPT_RETURNTRANSFER => true,
|
||||||
$response = curl_exec($ch); $httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE); curl_close($ch);
|
CURLOPT_HTTPHEADER => [
|
||||||
if ($httpCode >= 400 || $response === false) { return null; }
|
"Authorization: token {$token}",
|
||||||
return json_decode($response, true) ?: [];
|
'Content-Type: application/json',
|
||||||
}
|
],
|
||||||
|
CURLOPT_CUSTOMREQUEST => $method,
|
||||||
|
CURLOPT_TIMEOUT => 30,
|
||||||
|
]);
|
||||||
|
if ($body !== null) {
|
||||||
|
curl_setopt($ch, CURLOPT_POSTFIELDS, $body);
|
||||||
|
}
|
||||||
|
$response = curl_exec($ch);
|
||||||
|
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||||||
|
curl_close($ch);
|
||||||
|
if ($httpCode >= 400 || $response === false) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return json_decode($response, true) ?: [];
|
||||||
|
}
|
||||||
|
|
||||||
private function updateRemoteFile(string $apiBase, string $token, string $filePath, string $branch, callable $transform, string $commitMessage): bool
|
private function updateRemoteFile(
|
||||||
{
|
string $apiBase,
|
||||||
$file = $this->giteaApi('GET', "{$apiBase}/contents/{$filePath}?ref={$branch}", $token);
|
string $token,
|
||||||
if ($file === null || !isset($file['sha']) || !isset($file['content'])) { return false; }
|
string $filePath,
|
||||||
$content = base64_decode($file['content']); $newContent = $transform($content);
|
string $branch,
|
||||||
if ($newContent === $content) { $this->log('INFO', "{$filePath}: no changes needed"); return true; }
|
callable $transform,
|
||||||
$payload = json_encode(['content' => base64_encode($newContent), 'sha' => $file['sha'], 'message' => $commitMessage, 'branch' => $branch]);
|
string $commitMessage
|
||||||
$result = $this->giteaApi('PUT', "{$apiBase}/contents/{$filePath}", $token, $payload);
|
): bool {
|
||||||
if ($result === null) { $this->log('ERROR', "{$filePath}: failed to update"); return false; }
|
$file = $this->giteaApi('GET', "{$apiBase}/contents/{$filePath}?ref={$branch}", $token);
|
||||||
echo " {$filePath}: updated on {$branch}\n"; return true;
|
if ($file === null || !isset($file['sha']) || !isset($file['content'])) {
|
||||||
}
|
return false;
|
||||||
|
}
|
||||||
|
$content = base64_decode($file['content']);
|
||||||
|
$newContent = $transform($content);
|
||||||
|
if ($newContent === $content) {
|
||||||
|
$this->log('INFO', "{$filePath}: no changes needed");
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
$payload = json_encode(['content' => base64_encode($newContent), 'sha' => $file['sha'], 'message' => $commitMessage, 'branch' => $branch]);
|
||||||
|
$result = $this->giteaApi('PUT', "{$apiBase}/contents/{$filePath}", $token, $payload);
|
||||||
|
if ($result === null) {
|
||||||
|
$this->log('ERROR', "{$filePath}: failed to update");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
echo " {$filePath}: updated on {$branch}\n";
|
||||||
|
return true;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
$app = new VersionBumpRemoteCli();
|
$app = new VersionBumpRemoteCli();
|
||||||
|
|||||||
+175
-50
@@ -1,5 +1,6 @@
|
|||||||
#!/usr/bin/env php
|
#!/usr/bin/env php
|
||||||
<?php
|
<?php
|
||||||
|
|
||||||
/* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
|
/* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
|
||||||
*
|
*
|
||||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
@@ -21,57 +22,181 @@ use MokoEnterprise\CliFramework;
|
|||||||
|
|
||||||
class VersionCheckCli extends CliFramework
|
class VersionCheckCli extends CliFramework
|
||||||
{
|
{
|
||||||
protected function configure(): void
|
protected function configure(): void
|
||||||
{
|
{
|
||||||
$this->setDescription('Validate version consistency across README, manifests, and sub-packages');
|
$this->setDescription('Validate version consistency across README, manifests, and sub-packages');
|
||||||
$this->addArgument('--path', 'Repository root', '.');
|
$this->addArgument('--path', 'Repository root', '.');
|
||||||
$this->addArgument('--strict', 'Exit 1 on mismatch', false);
|
$this->addArgument('--strict', 'Exit 1 on mismatch', false);
|
||||||
$this->addArgument('--fix', 'Fix mismatches to highest version', false);
|
$this->addArgument('--fix', 'Fix mismatches to highest version', false);
|
||||||
}
|
}
|
||||||
|
|
||||||
protected function run(): int
|
protected function run(): int
|
||||||
{
|
{
|
||||||
$path = $this->getArgument('--path'); $strict = (bool) $this->getArgument('--strict'); $fix = (bool) $this->getArgument('--fix');
|
$path = $this->getArgument('--path');
|
||||||
$root = realpath($path) ?: $path; $errors = 0; $versions = [];
|
$strict = (bool) $this->getArgument('--strict');
|
||||||
$mokoManifest = "{$root}/.mokogitea/manifest.xml";
|
$fix = (bool) $this->getArgument('--fix');
|
||||||
if (file_exists($mokoManifest)) { $xml = @simplexml_load_file($mokoManifest); if ($xml !== false) { $v = (string)($xml->identity->version ?? ''); $base = preg_replace('/(-(dev|alpha|beta|rc))+$/', '', $v); if (preg_match('/^\d{2}\.\d{2}\.\d{2}$/', $base)) { $versions['.mokogitea/manifest.xml'] = $base; } } }
|
$root = realpath($path) ?: $path;
|
||||||
$readme = "{$root}/README.md";
|
$errors = 0;
|
||||||
if (file_exists($readme)) { $content = file_get_contents($readme); if (preg_match('/VERSION:\s*(\d{2}\.\d{2}\.\d{2})/m', $content, $m)) { $versions['README.md'] = $m[1]; } }
|
$versions = [];
|
||||||
$changelog = "{$root}/CHANGELOG.md";
|
$mokoManifest = "{$root}/.mokogitea/manifest.xml";
|
||||||
if (file_exists($changelog)) { $content = file_get_contents($changelog); if (preg_match('/VERSION:\s*(\d{2}\.\d{2}\.\d{2})/m', $content, $m)) { $versions['CHANGELOG.md'] = $m[1]; } }
|
if (file_exists($mokoManifest)) {
|
||||||
$packageJson = "{$root}/package.json";
|
$xml = @simplexml_load_file($mokoManifest);
|
||||||
if (file_exists($packageJson)) { $pkg = json_decode(file_get_contents($packageJson), true); if (isset($pkg['version']) && preg_match('/^\d{2}\.\d{2}\.\d{2}$/', $pkg['version'])) { $versions['package.json'] = $pkg['version']; } }
|
if ($xml !== false) {
|
||||||
$pyproject = "{$root}/pyproject.toml";
|
$v = (string)($xml->identity->version ?? '');
|
||||||
if (file_exists($pyproject)) { $content = file_get_contents($pyproject); if (preg_match('/^version\s*=\s*"(\d{2}\.\d{2}\.\d{2})"/m', $content, $m)) { $versions['pyproject.toml'] = $m[1]; } }
|
$base = preg_replace('/(-(dev|alpha|beta|rc))+$/', '', $v);
|
||||||
foreach (["{$root}/src/pkg_*.xml", "{$root}/src/*.xml", "{$root}/src/packages/*/*.xml", "{$root}/*.xml"] as $glob) {
|
if (preg_match('/^\d{2}\.\d{2}\.\d{2}$/', $base)) {
|
||||||
foreach (glob($glob) ?: [] as $file) {
|
$versions['.mokogitea/manifest.xml'] = $base;
|
||||||
if (basename($file) === 'updates.xml') continue;
|
}
|
||||||
$xmlContent = file_get_contents($file); if (strpos($xmlContent, '<extension') === false) continue;
|
}
|
||||||
if (preg_match('#<version>(\d{2}\.\d{2}\.\d{2})(?:(?:-(?:dev|alpha|beta|rc))+)?</version>#', $xmlContent, $xm)) { $relPath = str_replace([$root . '/', $root . '\\'], '', $file); $versions[$relPath] = $xm[1]; }
|
}
|
||||||
}
|
$readme = "{$root}/README.md";
|
||||||
}
|
if (file_exists($readme)) {
|
||||||
if (empty($versions)) { $this->log('ERROR', "No version sources found"); return 1; }
|
$content = file_get_contents($readme);
|
||||||
$uniqueVersions = array_unique(array_values($versions)); $highestVersion = '00.00.00';
|
if (preg_match('/VERSION:\s*(\d{2}\.\d{2}\.\d{2})/m', $content, $m)) {
|
||||||
foreach ($versions as $v) { if (version_compare($v, $highestVersion, '>')) { $highestVersion = $v; } }
|
$versions['README.md'] = $m[1];
|
||||||
echo "=== Version Consistency Check ===\n";
|
}
|
||||||
foreach ($versions as $source => $ver) { $status = ($ver === $highestVersion) ? 'OK' : 'MISMATCH'; if ($status === 'MISMATCH') $errors++; echo sprintf(" %-50s %s %s\n", $source, $ver, $status === 'OK' ? '' : "** MISMATCH (expected {$highestVersion})"); }
|
}
|
||||||
if (count($uniqueVersions) === 1) { echo "\nAll {$ver} -- consistent.\n"; }
|
$changelog = "{$root}/CHANGELOG.md";
|
||||||
else {
|
if (file_exists($changelog)) {
|
||||||
echo "\n** {$errors} mismatch(es) found. Highest version: {$highestVersion}\n";
|
$content = file_get_contents($changelog);
|
||||||
if ($fix) {
|
if (preg_match('/VERSION:\s*(\d{2}\.\d{2}\.\d{2})/m', $content, $m)) {
|
||||||
echo "\n=== Fixing mismatches to {$highestVersion} ===\n";
|
$versions['CHANGELOG.md'] = $m[1];
|
||||||
if (isset($versions['README.md']) && $versions['README.md'] !== $highestVersion) { $content = file_get_contents($readme); $updated = preg_replace('/(VERSION:\s*)\d{2}\.\d{2}\.\d{2}/m', '${1}' . $highestVersion, $content, 1); if ($updated !== null) { file_put_contents($readme, $updated); } echo " Fixed: README.md -> {$highestVersion}\n"; }
|
}
|
||||||
if (isset($versions['.mokogitea/manifest.xml']) && $versions['.mokogitea/manifest.xml'] !== $highestVersion) { $content = file_get_contents($mokoManifest); $updated = preg_replace('#<version>\d{2}\.\d{2}\.\d{2}(?:(?:-(?:dev|alpha|beta|rc))+)?</version>#', "<version>{$highestVersion}</version>", $content); if ($updated !== null) { file_put_contents($mokoManifest, $updated); } echo " Fixed: .mokogitea/manifest.xml -> {$highestVersion}\n"; }
|
}
|
||||||
if (isset($versions['CHANGELOG.md']) && $versions['CHANGELOG.md'] !== $highestVersion) { $content = file_get_contents($changelog); $updated = preg_replace('/(VERSION:\s*)\d{2}\.\d{2}\.\d{2}(?:(?:-(?:dev|alpha|beta|rc))+)?/m', '${1}' . $highestVersion, $content); if ($updated !== null) { file_put_contents($changelog, $updated); } echo " Fixed: CHANGELOG.md -> {$highestVersion}\n"; }
|
$packageJson = "{$root}/package.json";
|
||||||
if (isset($versions['package.json']) && $versions['package.json'] !== $highestVersion) { $content = file_get_contents($packageJson); $updated = preg_replace('/("version"\s*:\s*")\d{2}\.\d{2}\.\d{2}(?:(?:-(?:dev|alpha|beta|rc))+)?(")/m', '${1}' . $highestVersion . '${2}', $content); if ($updated !== null) { file_put_contents($packageJson, $updated); } echo " Fixed: package.json -> {$highestVersion}\n"; }
|
if (file_exists($packageJson)) {
|
||||||
if (isset($versions['pyproject.toml']) && $versions['pyproject.toml'] !== $highestVersion) { $content = file_get_contents($pyproject); $updated = preg_replace('/^(version\s*=\s*")\d{2}\.\d{2}\.\d{2}(?:(?:-(?:dev|alpha|beta|rc))+)?(")/m', '${1}' . $highestVersion . '${2}', $content); if ($updated !== null) { file_put_contents($pyproject, $updated); } echo " Fixed: pyproject.toml -> {$highestVersion}\n"; }
|
$pkg = json_decode(file_get_contents($packageJson), true);
|
||||||
foreach ($versions as $source => $ver) { if (in_array($source, ['README.md', 'CHANGELOG.md', '.mokogitea/manifest.xml', 'package.json', 'pyproject.toml'], true)) continue; if ($ver === $highestVersion) continue; $file = "{$root}/{$source}"; if (!file_exists($file)) continue; $content = file_get_contents($file); $updated = preg_replace('#<version>[^<]*</version>#', "<version>{$highestVersion}</version>", $content); if ($updated !== null) { file_put_contents($file, $updated); } echo " Fixed: {$source} -> {$highestVersion}\n"; }
|
if (isset($pkg['version']) && preg_match('/^\d{2}\.\d{2}\.\d{2}$/', $pkg['version'])) {
|
||||||
echo "Done.\n";
|
$versions['package.json'] = $pkg['version'];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if ($strict && $errors > 0) { return 1; }
|
$pyproject = "{$root}/pyproject.toml";
|
||||||
return 0;
|
if (file_exists($pyproject)) {
|
||||||
}
|
$content = file_get_contents($pyproject);
|
||||||
|
if (preg_match('/^version\s*=\s*"(\d{2}\.\d{2}\.\d{2})"/m', $content, $m)) {
|
||||||
|
$versions['pyproject.toml'] = $m[1];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
foreach (["{$root}/src/pkg_*.xml", "{$root}/src/*.xml", "{$root}/src/packages/*/*.xml", "{$root}/*.xml"] as $glob) {
|
||||||
|
foreach (glob($glob) ?: [] as $file) {
|
||||||
|
if (basename($file) === 'updates.xml') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$xmlContent = file_get_contents($file);
|
||||||
|
if (strpos($xmlContent, '<extension') === false) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (preg_match('#<version>(\d{2}\.\d{2}\.\d{2})(?:(?:-(?:dev|alpha|beta|rc))+)?</version>#', $xmlContent, $xm)) {
|
||||||
|
$relPath = str_replace([$root . '/', $root . '\\'], '', $file);
|
||||||
|
$versions[$relPath] = $xm[1];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (empty($versions)) {
|
||||||
|
$this->log('ERROR', "No version sources found");
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
$uniqueVersions = array_unique(array_values($versions));
|
||||||
|
$highestVersion = '00.00.00';
|
||||||
|
foreach ($versions as $v) {
|
||||||
|
if (version_compare($v, $highestVersion, '>')) {
|
||||||
|
$highestVersion = $v;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
echo "=== Version Consistency Check ===\n";
|
||||||
|
foreach ($versions as $source => $ver) {
|
||||||
|
$status = ($ver === $highestVersion) ? 'OK' : 'MISMATCH';
|
||||||
|
if ($status === 'MISMATCH') {
|
||||||
|
$errors++;
|
||||||
|
} echo sprintf(" %-50s %s %s\n", $source, $ver, $status === 'OK' ? '' : "** MISMATCH (expected {$highestVersion})");
|
||||||
|
}
|
||||||
|
if (count($uniqueVersions) === 1) {
|
||||||
|
echo "\nAll {$ver} -- consistent.\n";
|
||||||
|
} else {
|
||||||
|
echo "\n** {$errors} mismatch(es) found. Highest version: {$highestVersion}\n";
|
||||||
|
if ($fix) {
|
||||||
|
echo "\n=== Fixing mismatches to {$highestVersion} ===\n";
|
||||||
|
if (isset($versions['README.md']) && $versions['README.md'] !== $highestVersion) {
|
||||||
|
$content = file_get_contents($readme);
|
||||||
|
$updated = preg_replace('/(VERSION:\s*)\d{2}\.\d{2}\.\d{2}/m', '${1}' . $highestVersion, $content, 1);
|
||||||
|
if ($updated !== null) {
|
||||||
|
file_put_contents($readme, $updated);
|
||||||
|
} echo " Fixed: README.md -> {$highestVersion}\n";
|
||||||
|
}
|
||||||
|
if (isset($versions['.mokogitea/manifest.xml']) && $versions['.mokogitea/manifest.xml'] !== $highestVersion) {
|
||||||
|
$content = file_get_contents($mokoManifest);
|
||||||
|
$vPat = '#<version>\d{2}\.\d{2}\.\d{2}'
|
||||||
|
. '(?:(?:-(?:dev|alpha|beta|rc))+)?</version>#';
|
||||||
|
$updated = preg_replace(
|
||||||
|
$vPat,
|
||||||
|
"<version>{$highestVersion}</version>",
|
||||||
|
$content
|
||||||
|
);
|
||||||
|
if ($updated !== null) {
|
||||||
|
file_put_contents($mokoManifest, $updated);
|
||||||
|
} echo " Fixed: .mokogitea/manifest.xml -> {$highestVersion}\n";
|
||||||
|
}
|
||||||
|
if (isset($versions['CHANGELOG.md']) && $versions['CHANGELOG.md'] !== $highestVersion) {
|
||||||
|
$content = file_get_contents($changelog);
|
||||||
|
$clPat = '/(VERSION:\s*)\d{2}\.\d{2}\.\d{2}'
|
||||||
|
. '(?:(?:-(?:dev|alpha|beta|rc))+)?/m';
|
||||||
|
$updated = preg_replace(
|
||||||
|
$clPat,
|
||||||
|
'${1}' . $highestVersion,
|
||||||
|
$content
|
||||||
|
);
|
||||||
|
if ($updated !== null) {
|
||||||
|
file_put_contents($changelog, $updated);
|
||||||
|
} echo " Fixed: CHANGELOG.md -> {$highestVersion}\n";
|
||||||
|
}
|
||||||
|
if (isset($versions['package.json']) && $versions['package.json'] !== $highestVersion) {
|
||||||
|
$content = file_get_contents($packageJson);
|
||||||
|
$pkPat = '/("version"\s*:\s*")\d{2}\.\d{2}\.\d{2}'
|
||||||
|
. '(?:(?:-(?:dev|alpha|beta|rc))+)?(")/m';
|
||||||
|
$updated = preg_replace(
|
||||||
|
$pkPat,
|
||||||
|
'${1}' . $highestVersion . '${2}',
|
||||||
|
$content
|
||||||
|
);
|
||||||
|
if ($updated !== null) {
|
||||||
|
file_put_contents($packageJson, $updated);
|
||||||
|
} echo " Fixed: package.json -> {$highestVersion}\n";
|
||||||
|
}
|
||||||
|
if (isset($versions['pyproject.toml']) && $versions['pyproject.toml'] !== $highestVersion) {
|
||||||
|
$content = file_get_contents($pyproject);
|
||||||
|
$pyPat = '/^(version\s*=\s*")\d{2}\.\d{2}\.\d{2}'
|
||||||
|
. '(?:(?:-(?:dev|alpha|beta|rc))+)?(")/m';
|
||||||
|
$updated = preg_replace(
|
||||||
|
$pyPat,
|
||||||
|
'${1}' . $highestVersion . '${2}',
|
||||||
|
$content
|
||||||
|
);
|
||||||
|
if ($updated !== null) {
|
||||||
|
file_put_contents($pyproject, $updated);
|
||||||
|
} echo " Fixed: pyproject.toml -> {$highestVersion}\n";
|
||||||
|
}
|
||||||
|
foreach ($versions as $source => $ver) {
|
||||||
|
if (in_array($source, ['README.md', 'CHANGELOG.md', '.mokogitea/manifest.xml', 'package.json', 'pyproject.toml'], true)) {
|
||||||
|
continue;
|
||||||
|
} if ($ver === $highestVersion) {
|
||||||
|
continue;
|
||||||
|
} $file = "{$root}/{$source}";
|
||||||
|
if (!file_exists($file)) {
|
||||||
|
continue;
|
||||||
|
} $content = file_get_contents($file);
|
||||||
|
$updated = preg_replace('#<version>[^<]*</version>#', "<version>{$highestVersion}</version>", $content);
|
||||||
|
if ($updated !== null) {
|
||||||
|
file_put_contents($file, $updated);
|
||||||
|
} echo " Fixed: {$source} -> {$highestVersion}\n";
|
||||||
|
}
|
||||||
|
echo "Done.\n";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if ($strict && $errors > 0) {
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
$app = new VersionCheckCli();
|
$app = new VersionCheckCli();
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
#!/usr/bin/env php
|
#!/usr/bin/env php
|
||||||
<?php
|
<?php
|
||||||
|
|
||||||
/* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
|
/* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
|
||||||
*
|
*
|
||||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
#!/usr/bin/env php
|
#!/usr/bin/env php
|
||||||
<?php
|
<?php
|
||||||
|
|
||||||
/* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
|
/* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
|
||||||
*
|
*
|
||||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
|||||||
+7
-2
@@ -1,5 +1,6 @@
|
|||||||
#!/usr/bin/env php
|
#!/usr/bin/env php
|
||||||
<?php
|
<?php
|
||||||
|
|
||||||
/* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
|
/* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
|
||||||
*
|
*
|
||||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
@@ -204,7 +205,9 @@ class WikiSyncCli extends CliFramework
|
|||||||
];
|
];
|
||||||
$ctx = stream_context_create($opts);
|
$ctx = stream_context_create($opts);
|
||||||
$result = @file_get_contents($url, false, $ctx);
|
$result = @file_get_contents($url, false, $ctx);
|
||||||
if ($result === false) return null;
|
if ($result === false) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
$data = json_decode($result, true);
|
$data = json_decode($result, true);
|
||||||
return is_array($data) ? $data : null;
|
return is_array($data) ? $data : null;
|
||||||
}
|
}
|
||||||
@@ -232,7 +235,9 @@ class WikiSyncCli extends CliFramework
|
|||||||
];
|
];
|
||||||
$ctx = stream_context_create($opts);
|
$ctx = stream_context_create($opts);
|
||||||
$result = @file_get_contents($url, false, $ctx);
|
$result = @file_get_contents($url, false, $ctx);
|
||||||
if ($result === false) return null;
|
if ($result === false) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
$data = json_decode($result, true);
|
$data = json_decode($result, true);
|
||||||
return is_array($data) ? $data : null;
|
return is_array($data) ? $data : null;
|
||||||
}
|
}
|
||||||
|
|||||||
+137
-136
@@ -1,5 +1,6 @@
|
|||||||
#!/usr/bin/env php
|
#!/usr/bin/env php
|
||||||
<?php
|
<?php
|
||||||
|
|
||||||
/* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
|
/* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
|
||||||
*
|
*
|
||||||
* This file is part of a Moko Consulting project.
|
* This file is part of a Moko Consulting project.
|
||||||
@@ -23,171 +24,171 @@ use MokoEnterprise\CliFramework;
|
|||||||
|
|
||||||
class BackupBeforeDeployCli extends CliFramework
|
class BackupBeforeDeployCli extends CliFramework
|
||||||
{
|
{
|
||||||
private const JOOMLA_DIRS = [
|
private const JOOMLA_DIRS = [
|
||||||
'administrator/components',
|
'administrator/components',
|
||||||
'administrator/language',
|
'administrator/language',
|
||||||
'administrator/modules',
|
'administrator/modules',
|
||||||
'administrator/templates',
|
'administrator/templates',
|
||||||
'components',
|
'components',
|
||||||
'language',
|
'language',
|
||||||
'layouts',
|
'layouts',
|
||||||
'libraries',
|
'libraries',
|
||||||
'media',
|
'media',
|
||||||
'modules',
|
'modules',
|
||||||
'plugins',
|
'plugins',
|
||||||
'templates',
|
'templates',
|
||||||
];
|
];
|
||||||
|
|
||||||
protected function configure(): void
|
protected function configure(): void
|
||||||
{
|
{
|
||||||
$this->setDescription('Snapshot Joomla directories before deployment for rollback capability');
|
$this->setDescription('Snapshot Joomla directories before deployment for rollback capability');
|
||||||
$this->addArgument('--config', 'Path to sftp-config.json', '');
|
$this->addArgument('--config', 'Path to sftp-config.json', '');
|
||||||
$this->addArgument('--output', 'Local output directory for snapshot', '');
|
$this->addArgument('--output', 'Local output directory for snapshot', '');
|
||||||
}
|
}
|
||||||
|
|
||||||
protected function run(): int
|
protected function run(): int
|
||||||
{
|
{
|
||||||
$configPath = $this->getArgument('--config');
|
$configPath = $this->getArgument('--config');
|
||||||
$outputDir = $this->getArgument('--output');
|
$outputDir = $this->getArgument('--output');
|
||||||
|
|
||||||
if ($configPath === '') {
|
if ($configPath === '') {
|
||||||
$this->log('ERROR', 'Usage: backup-before-deploy.php --config <sftp-config.json> [--output <local-dir>] [--verbose]');
|
$this->log('ERROR', 'Usage: backup-before-deploy.php --config <sftp-config.json> [--output <local-dir>] [--verbose]');
|
||||||
return 1;
|
return 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($outputDir === '') {
|
if ($outputDir === '') {
|
||||||
$outputDir = '/tmp/moko-snapshot-' . date('Ymd-His');
|
$outputDir = '/tmp/moko-snapshot-' . date('Ymd-His');
|
||||||
}
|
}
|
||||||
|
|
||||||
$config = $this->loadConfig($configPath);
|
$config = $this->loadConfig($configPath);
|
||||||
if ($config === null) {
|
if ($config === null) {
|
||||||
return 1;
|
return 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
$host = $config['host'] ?? '';
|
$host = $config['host'] ?? '';
|
||||||
$user = $config['user'] ?? '';
|
$user = $config['user'] ?? '';
|
||||||
$port = (int) ($config['port'] ?? 22);
|
$port = (int) ($config['port'] ?? 22);
|
||||||
$remotePath = rtrim($config['remote_path'] ?? '', '/');
|
$remotePath = rtrim($config['remote_path'] ?? '', '/');
|
||||||
$sshKey = $config['ssh_key_file'] ?? '';
|
$sshKey = $config['ssh_key_file'] ?? '';
|
||||||
|
|
||||||
if ($host === '' || $user === '' || $remotePath === '') {
|
if ($host === '' || $user === '' || $remotePath === '') {
|
||||||
$this->log('ERROR', 'Config must contain host, user, and remote_path.');
|
$this->log('ERROR', 'Config must contain host, user, and remote_path.');
|
||||||
return 1;
|
return 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create output directory
|
// Create output directory
|
||||||
if (!is_dir($outputDir)) {
|
if (!is_dir($outputDir)) {
|
||||||
if (!mkdir($outputDir, 0755, true)) {
|
if (!mkdir($outputDir, 0755, true)) {
|
||||||
$this->log('ERROR', "Could not create output directory: {$outputDir}");
|
$this->log('ERROR', "Could not create output directory: {$outputDir}");
|
||||||
return 1;
|
return 1;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
$this->log('INFO', 'Starting pre-deploy snapshot...');
|
$this->log('INFO', 'Starting pre-deploy snapshot...');
|
||||||
$this->log('INFO', "Source: {$user}@{$host}:{$remotePath}");
|
$this->log('INFO', "Source: {$user}@{$host}:{$remotePath}");
|
||||||
$this->log('INFO', "Output: {$outputDir}");
|
$this->log('INFO', "Output: {$outputDir}");
|
||||||
|
|
||||||
$failed = 0;
|
$failed = 0;
|
||||||
|
|
||||||
foreach (self::JOOMLA_DIRS as $dir) {
|
foreach (self::JOOMLA_DIRS as $dir) {
|
||||||
$remoteSource = "{$remotePath}/{$dir}/";
|
$remoteSource = "{$remotePath}/{$dir}/";
|
||||||
$localTarget = rtrim($outputDir, '/\\') . '/' . $dir . '/';
|
$localTarget = rtrim($outputDir, '/\\') . '/' . $dir . '/';
|
||||||
|
|
||||||
// Ensure local subdirectory exists
|
// Ensure local subdirectory exists
|
||||||
if (!is_dir($localTarget)) {
|
if (!is_dir($localTarget)) {
|
||||||
mkdir($localTarget, 0755, true);
|
mkdir($localTarget, 0755, true);
|
||||||
}
|
}
|
||||||
|
|
||||||
$sshCmd = "ssh -p {$port}";
|
$sshCmd = "ssh -p {$port}";
|
||||||
if ($sshKey !== '') {
|
if ($sshKey !== '') {
|
||||||
$sshCmd .= " -i " . escapeshellarg($sshKey);
|
$sshCmd .= " -i " . escapeshellarg($sshKey);
|
||||||
}
|
}
|
||||||
|
|
||||||
$cmd = $this->buildRsyncCommand(
|
$cmd = $this->buildRsyncCommand(
|
||||||
$sshCmd,
|
$sshCmd,
|
||||||
"{$user}@{$host}:{$remoteSource}",
|
"{$user}@{$host}:{$remoteSource}",
|
||||||
$localTarget
|
$localTarget
|
||||||
);
|
);
|
||||||
|
|
||||||
$this->log('INFO', "Downloading: {$dir}");
|
$this->log('INFO', "Downloading: {$dir}");
|
||||||
if ($this->verbose) {
|
if ($this->verbose) {
|
||||||
$this->log('INFO', "CMD: {$cmd}");
|
$this->log('INFO', "CMD: {$cmd}");
|
||||||
}
|
}
|
||||||
|
|
||||||
$output = [];
|
$output = [];
|
||||||
$exitCode = 0;
|
$exitCode = 0;
|
||||||
exec($cmd, $output, $exitCode);
|
exec($cmd, $output, $exitCode);
|
||||||
|
|
||||||
if ($exitCode !== 0) {
|
if ($exitCode !== 0) {
|
||||||
$this->log('ERROR', "rsync failed for {$dir} (exit code {$exitCode})");
|
$this->log('ERROR', "rsync failed for {$dir} (exit code {$exitCode})");
|
||||||
foreach ($output as $line) {
|
foreach ($output as $line) {
|
||||||
$this->log('ERROR', " {$line}");
|
$this->log('ERROR', " {$line}");
|
||||||
}
|
}
|
||||||
$failed++;
|
$failed++;
|
||||||
} else {
|
} else {
|
||||||
if ($this->verbose) {
|
if ($this->verbose) {
|
||||||
foreach ($output as $line) {
|
foreach ($output as $line) {
|
||||||
$this->log('INFO', " {$line}");
|
$this->log('INFO', " {$line}");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($failed > 0) {
|
if ($failed > 0) {
|
||||||
$this->log('ERROR', "Snapshot completed with {$failed} error(s).");
|
$this->log('ERROR', "Snapshot completed with {$failed} error(s).");
|
||||||
return 1;
|
return 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
$this->log('INFO', '');
|
$this->log('INFO', '');
|
||||||
$this->log('INFO', 'Snapshot completed successfully.');
|
$this->log('INFO', 'Snapshot completed successfully.');
|
||||||
$this->log('INFO', "SNAPSHOT_PATH={$outputDir}");
|
$this->log('INFO', "SNAPSHOT_PATH={$outputDir}");
|
||||||
$this->log('INFO', '');
|
$this->log('INFO', '');
|
||||||
$this->log('INFO', 'To rollback, run:');
|
$this->log('INFO', 'To rollback, run:');
|
||||||
$this->log('INFO', " php rollback-joomla.php --config {$configPath} --snapshot-dir {$outputDir}");
|
$this->log('INFO', " php rollback-joomla.php --config {$configPath} --snapshot-dir {$outputDir}");
|
||||||
|
|
||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
private function loadConfig(string $path): ?array
|
private function loadConfig(string $path): ?array
|
||||||
{
|
{
|
||||||
if (!is_file($path)) {
|
if (!is_file($path)) {
|
||||||
$this->log('ERROR', "Config file not found: {$path}");
|
$this->log('ERROR', "Config file not found: {$path}");
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
$raw = file_get_contents($path);
|
$raw = file_get_contents($path);
|
||||||
if ($raw === false) {
|
if ($raw === false) {
|
||||||
$this->log('ERROR', "Could not read config file: {$path}");
|
$this->log('ERROR', "Could not read config file: {$path}");
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Strip // comments (sftp-config.json style)
|
// Strip // comments (sftp-config.json style)
|
||||||
$cleaned = preg_replace('#^\s*//.*$#m', '', $raw);
|
$cleaned = preg_replace('#^\s*//.*$#m', '', $raw);
|
||||||
$config = json_decode($cleaned, true);
|
$config = json_decode($cleaned, true);
|
||||||
|
|
||||||
if (!is_array($config)) {
|
if (!is_array($config)) {
|
||||||
$this->log('ERROR', 'Invalid JSON in config file.');
|
$this->log('ERROR', 'Invalid JSON in config file.');
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
return $config;
|
return $config;
|
||||||
}
|
}
|
||||||
|
|
||||||
private function buildRsyncCommand(string $sshCmd, string $source, string $dest): string
|
private function buildRsyncCommand(string $sshCmd, string $source, string $dest): string
|
||||||
{
|
{
|
||||||
$parts = ['rsync', '-rlptz', '--exclude=configuration.php'];
|
$parts = ['rsync', '-rlptz', '--exclude=configuration.php'];
|
||||||
|
|
||||||
if ($this->verbose) {
|
if ($this->verbose) {
|
||||||
$parts[] = '-v';
|
$parts[] = '-v';
|
||||||
}
|
}
|
||||||
|
|
||||||
$parts[] = '-e';
|
$parts[] = '-e';
|
||||||
$parts[] = escapeshellarg($sshCmd);
|
$parts[] = escapeshellarg($sshCmd);
|
||||||
$parts[] = escapeshellarg($source);
|
$parts[] = escapeshellarg($source);
|
||||||
$parts[] = escapeshellarg($dest);
|
$parts[] = escapeshellarg($dest);
|
||||||
|
|
||||||
return implode(' ', $parts);
|
return implode(' ', $parts);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
$app = new BackupBeforeDeployCli();
|
$app = new BackupBeforeDeployCli();
|
||||||
|
|||||||
+204
-203
@@ -1,5 +1,6 @@
|
|||||||
#!/usr/bin/env php
|
#!/usr/bin/env php
|
||||||
<?php
|
<?php
|
||||||
|
|
||||||
/* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
|
/* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
|
||||||
*
|
*
|
||||||
* This file is part of a Moko Consulting project.
|
* This file is part of a Moko Consulting project.
|
||||||
@@ -23,258 +24,258 @@ use MokoEnterprise\CliFramework;
|
|||||||
|
|
||||||
class DeployDolibarrCli extends CliFramework
|
class DeployDolibarrCli extends CliFramework
|
||||||
{
|
{
|
||||||
private string $source = '';
|
private string $source = '';
|
||||||
|
|
||||||
private const MODULE_DIRS = [
|
private const MODULE_DIRS = [
|
||||||
'core/modules',
|
'core/modules',
|
||||||
'class',
|
'class',
|
||||||
'lib',
|
'lib',
|
||||||
'sql',
|
'sql',
|
||||||
'langs',
|
'langs',
|
||||||
'css',
|
'css',
|
||||||
'js',
|
'js',
|
||||||
'img',
|
'img',
|
||||||
];
|
];
|
||||||
|
|
||||||
private const EXCLUDES = [
|
private const EXCLUDES = [
|
||||||
'.git/',
|
'.git/',
|
||||||
'vendor/',
|
'vendor/',
|
||||||
'tests/',
|
'tests/',
|
||||||
'node_modules/',
|
'node_modules/',
|
||||||
];
|
];
|
||||||
|
|
||||||
protected function configure(): void
|
protected function configure(): void
|
||||||
{
|
{
|
||||||
$this->setDescription('Deploy Dolibarr module files to a remote server via SFTP/rsync');
|
$this->setDescription('Deploy Dolibarr module files to a remote server via SFTP/rsync');
|
||||||
$this->addArgument('--source', 'Local source directory', '');
|
$this->addArgument('--source', 'Local source directory', '');
|
||||||
$this->addArgument('--config', 'Path to sftp-config.json', '');
|
$this->addArgument('--config', 'Path to sftp-config.json', '');
|
||||||
}
|
}
|
||||||
|
|
||||||
protected function run(): int
|
protected function run(): int
|
||||||
{
|
{
|
||||||
$configPath = $this->getArgument('--config');
|
$configPath = $this->getArgument('--config');
|
||||||
$this->source = $this->getArgument('--source');
|
$this->source = $this->getArgument('--source');
|
||||||
|
|
||||||
if ($configPath === '' || $this->source === '') {
|
if ($configPath === '' || $this->source === '') {
|
||||||
$this->log('ERROR', 'Usage: deploy-dolibarr.php --source <local-path> --config <sftp-config.json> [--dry-run] [--verbose]');
|
$this->log('ERROR', 'Usage: deploy-dolibarr.php --source <local-path> --config <sftp-config.json> [--dry-run] [--verbose]');
|
||||||
return 1;
|
return 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!is_dir($this->source)) {
|
if (!is_dir($this->source)) {
|
||||||
$this->log('ERROR', "Source directory does not exist: {$this->source}");
|
$this->log('ERROR', "Source directory does not exist: {$this->source}");
|
||||||
return 1;
|
return 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
$moduleName = $this->detectModuleName();
|
$moduleName = $this->detectModuleName();
|
||||||
if ($moduleName === null) {
|
if ($moduleName === null) {
|
||||||
$this->log('ERROR', 'Could not auto-detect module name. Expected core/modules/mod*.class.php');
|
$this->log('ERROR', 'Could not auto-detect module name. Expected core/modules/mod*.class.php');
|
||||||
return 1;
|
return 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
$config = $this->loadConfig($configPath);
|
$config = $this->loadConfig($configPath);
|
||||||
if ($config === null) {
|
if ($config === null) {
|
||||||
return 1;
|
return 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
$host = $config['host'] ?? '';
|
$host = $config['host'] ?? '';
|
||||||
$user = $config['user'] ?? '';
|
$user = $config['user'] ?? '';
|
||||||
$port = (int) ($config['port'] ?? 22);
|
$port = (int) ($config['port'] ?? 22);
|
||||||
$remotePath = rtrim($config['remote_path'] ?? '', '/');
|
$remotePath = rtrim($config['remote_path'] ?? '', '/');
|
||||||
$sshKey = $config['ssh_key_file'] ?? '';
|
$sshKey = $config['ssh_key_file'] ?? '';
|
||||||
|
|
||||||
if ($host === '' || $user === '' || $remotePath === '') {
|
if ($host === '' || $user === '' || $remotePath === '') {
|
||||||
$this->log('ERROR', 'Config must contain host, user, and remote_path.');
|
$this->log('ERROR', 'Config must contain host, user, and remote_path.');
|
||||||
return 1;
|
return 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
$remoteBase = "{$remotePath}/htdocs/custom/{$moduleName}";
|
$remoteBase = "{$remotePath}/htdocs/custom/{$moduleName}";
|
||||||
|
|
||||||
$this->log('INFO', "Deploying Dolibarr module: {$moduleName}");
|
$this->log('INFO', "Deploying Dolibarr module: {$moduleName}");
|
||||||
$this->log('INFO', "Source: {$this->source}");
|
$this->log('INFO', "Source: {$this->source}");
|
||||||
$this->log('INFO', "Target: {$user}@{$host}:{$remoteBase}");
|
$this->log('INFO', "Target: {$user}@{$host}:{$remoteBase}");
|
||||||
|
|
||||||
if ($this->dryRun) {
|
if ($this->dryRun) {
|
||||||
$this->log('INFO', '*** DRY RUN — no changes will be made ***');
|
$this->log('INFO', '*** DRY RUN — no changes will be made ***');
|
||||||
}
|
}
|
||||||
|
|
||||||
$failed = 0;
|
$failed = 0;
|
||||||
|
|
||||||
// Deploy subdirectories
|
// Deploy subdirectories
|
||||||
foreach (self::MODULE_DIRS as $dir) {
|
foreach (self::MODULE_DIRS as $dir) {
|
||||||
$localDir = rtrim($this->source, '/\\') . '/' . $dir . '/';
|
$localDir = rtrim($this->source, '/\\') . '/' . $dir . '/';
|
||||||
|
|
||||||
if (!is_dir($localDir)) {
|
if (!is_dir($localDir)) {
|
||||||
if ($this->verbose) {
|
if ($this->verbose) {
|
||||||
$this->log('INFO', "SKIP: {$dir} (not present in source)");
|
$this->log('INFO', "SKIP: {$dir} (not present in source)");
|
||||||
}
|
}
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
$remoteTarget = "{$remoteBase}/{$dir}/";
|
$remoteTarget = "{$remoteBase}/{$dir}/";
|
||||||
$result = $this->rsyncDir($localDir, $remoteTarget, $host, $user, $port, $sshKey);
|
$result = $this->rsyncDir($localDir, $remoteTarget, $host, $user, $port, $sshKey);
|
||||||
|
|
||||||
if (!$result) {
|
if (!$result) {
|
||||||
$failed++;
|
$failed++;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Deploy root PHP files
|
// Deploy root PHP files
|
||||||
$rootPhpFiles = glob(rtrim($this->source, '/\\') . '/*.php');
|
$rootPhpFiles = glob(rtrim($this->source, '/\\') . '/*.php');
|
||||||
if (!empty($rootPhpFiles)) {
|
if (!empty($rootPhpFiles)) {
|
||||||
$this->log('INFO', 'Syncing root PHP files...');
|
$this->log('INFO', 'Syncing root PHP files...');
|
||||||
$sourceRoot = rtrim($this->source, '/\\') . '/';
|
$sourceRoot = rtrim($this->source, '/\\') . '/';
|
||||||
$remoteTarget = "{$remoteBase}/";
|
$remoteTarget = "{$remoteBase}/";
|
||||||
|
|
||||||
$sshCmd = "ssh -p {$port}";
|
$sshCmd = "ssh -p {$port}";
|
||||||
if ($sshKey !== '') {
|
if ($sshKey !== '') {
|
||||||
$sshCmd .= " -i " . escapeshellarg($sshKey);
|
$sshCmd .= " -i " . escapeshellarg($sshKey);
|
||||||
}
|
}
|
||||||
|
|
||||||
$cmd = $this->buildRsyncCommand(
|
$cmd = $this->buildRsyncCommand(
|
||||||
$sshCmd,
|
$sshCmd,
|
||||||
$sourceRoot,
|
$sourceRoot,
|
||||||
"{$user}@{$host}:{$remoteTarget}",
|
"{$user}@{$host}:{$remoteTarget}",
|
||||||
['--include=*.php', '--exclude=*/', '--exclude=.*']
|
['--include=*.php', '--exclude=*/', '--exclude=.*']
|
||||||
);
|
);
|
||||||
|
|
||||||
if ($this->verbose) {
|
if ($this->verbose) {
|
||||||
$this->log('INFO', "CMD: {$cmd}");
|
$this->log('INFO', "CMD: {$cmd}");
|
||||||
}
|
}
|
||||||
|
|
||||||
$output = [];
|
$output = [];
|
||||||
$exitCode = 0;
|
$exitCode = 0;
|
||||||
exec($cmd, $output, $exitCode);
|
exec($cmd, $output, $exitCode);
|
||||||
|
|
||||||
if ($exitCode !== 0) {
|
if ($exitCode !== 0) {
|
||||||
$this->log('ERROR', "rsync failed for root PHP files (exit code {$exitCode})");
|
$this->log('ERROR', "rsync failed for root PHP files (exit code {$exitCode})");
|
||||||
foreach ($output as $line) {
|
foreach ($output as $line) {
|
||||||
$this->log('ERROR', " {$line}");
|
$this->log('ERROR', " {$line}");
|
||||||
}
|
}
|
||||||
$failed++;
|
$failed++;
|
||||||
} else {
|
} else {
|
||||||
if ($this->verbose) {
|
if ($this->verbose) {
|
||||||
foreach ($output as $line) {
|
foreach ($output as $line) {
|
||||||
$this->log('INFO', " {$line}");
|
$this->log('INFO', " {$line}");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($failed > 0) {
|
if ($failed > 0) {
|
||||||
$this->log('ERROR', "Deployment completed with {$failed} error(s).");
|
$this->log('ERROR', "Deployment completed with {$failed} error(s).");
|
||||||
return 1;
|
return 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
$this->log('INFO', 'Deployment completed successfully.');
|
$this->log('INFO', 'Deployment completed successfully.');
|
||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
private function detectModuleName(): ?string
|
private function detectModuleName(): ?string
|
||||||
{
|
{
|
||||||
$pattern = rtrim($this->source, '/\\') . '/core/modules/mod*.class.php';
|
$pattern = rtrim($this->source, '/\\') . '/core/modules/mod*.class.php';
|
||||||
$matches = glob($pattern);
|
$matches = glob($pattern);
|
||||||
|
|
||||||
if (empty($matches)) {
|
if (empty($matches)) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
$filename = basename($matches[0]);
|
$filename = basename($matches[0]);
|
||||||
// mod{ModuleName}.class.php -> extract ModuleName, lowercase it
|
// mod{ModuleName}.class.php -> extract ModuleName, lowercase it
|
||||||
if (preg_match('/^mod(.+)\.class\.php$/', $filename, $m)) {
|
if (preg_match('/^mod(.+)\.class\.php$/', $filename, $m)) {
|
||||||
return strtolower($m[1]);
|
return strtolower($m[1]);
|
||||||
}
|
}
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
private function loadConfig(string $path): ?array
|
private function loadConfig(string $path): ?array
|
||||||
{
|
{
|
||||||
if (!is_file($path)) {
|
if (!is_file($path)) {
|
||||||
$this->log('ERROR', "Config file not found: {$path}");
|
$this->log('ERROR', "Config file not found: {$path}");
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
$raw = file_get_contents($path);
|
$raw = file_get_contents($path);
|
||||||
if ($raw === false) {
|
if ($raw === false) {
|
||||||
$this->log('ERROR', "Could not read config file: {$path}");
|
$this->log('ERROR', "Could not read config file: {$path}");
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Strip // comments (sftp-config.json style)
|
// Strip // comments (sftp-config.json style)
|
||||||
$cleaned = preg_replace('#^\s*//.*$#m', '', $raw);
|
$cleaned = preg_replace('#^\s*//.*$#m', '', $raw);
|
||||||
$config = json_decode($cleaned, true);
|
$config = json_decode($cleaned, true);
|
||||||
|
|
||||||
if (!is_array($config)) {
|
if (!is_array($config)) {
|
||||||
$this->log('ERROR', 'Invalid JSON in config file.');
|
$this->log('ERROR', 'Invalid JSON in config file.');
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
return $config;
|
return $config;
|
||||||
}
|
}
|
||||||
|
|
||||||
private function rsyncDir(string $localDir, string $remoteTarget, string $host, string $user, int $port, string $sshKey): bool
|
private function rsyncDir(string $localDir, string $remoteTarget, string $host, string $user, int $port, string $sshKey): bool
|
||||||
{
|
{
|
||||||
$dirName = basename(rtrim($localDir, '/'));
|
$dirName = basename(rtrim($localDir, '/'));
|
||||||
$sshCmd = "ssh -p {$port}";
|
$sshCmd = "ssh -p {$port}";
|
||||||
if ($sshKey !== '') {
|
if ($sshKey !== '') {
|
||||||
$sshCmd .= " -i " . escapeshellarg($sshKey);
|
$sshCmd .= " -i " . escapeshellarg($sshKey);
|
||||||
}
|
}
|
||||||
|
|
||||||
$cmd = $this->buildRsyncCommand($sshCmd, $localDir, "{$user}@{$host}:{$remoteTarget}");
|
$cmd = $this->buildRsyncCommand($sshCmd, $localDir, "{$user}@{$host}:{$remoteTarget}");
|
||||||
|
|
||||||
$this->log('INFO', "Syncing: {$dirName}");
|
$this->log('INFO', "Syncing: {$dirName}");
|
||||||
if ($this->verbose) {
|
if ($this->verbose) {
|
||||||
$this->log('INFO', "CMD: {$cmd}");
|
$this->log('INFO', "CMD: {$cmd}");
|
||||||
}
|
}
|
||||||
|
|
||||||
$output = [];
|
$output = [];
|
||||||
$exitCode = 0;
|
$exitCode = 0;
|
||||||
exec($cmd, $output, $exitCode);
|
exec($cmd, $output, $exitCode);
|
||||||
|
|
||||||
if ($exitCode !== 0) {
|
if ($exitCode !== 0) {
|
||||||
$this->log('ERROR', "rsync failed for {$dirName} (exit code {$exitCode})");
|
$this->log('ERROR', "rsync failed for {$dirName} (exit code {$exitCode})");
|
||||||
foreach ($output as $line) {
|
foreach ($output as $line) {
|
||||||
$this->log('ERROR', " {$line}");
|
$this->log('ERROR', " {$line}");
|
||||||
}
|
}
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($this->verbose) {
|
if ($this->verbose) {
|
||||||
foreach ($output as $line) {
|
foreach ($output as $line) {
|
||||||
$this->log('INFO', " {$line}");
|
$this->log('INFO', " {$line}");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
private function buildRsyncCommand(string $sshCmd, string $source, string $dest, array $extraArgs = []): string
|
private function buildRsyncCommand(string $sshCmd, string $source, string $dest, array $extraArgs = []): string
|
||||||
{
|
{
|
||||||
$parts = ['rsync', '-rlptz', '--delete'];
|
$parts = ['rsync', '-rlptz', '--delete'];
|
||||||
|
|
||||||
foreach (self::EXCLUDES as $exclude) {
|
foreach (self::EXCLUDES as $exclude) {
|
||||||
$parts[] = '--exclude=' . $exclude;
|
$parts[] = '--exclude=' . $exclude;
|
||||||
}
|
}
|
||||||
|
|
||||||
foreach ($extraArgs as $arg) {
|
foreach ($extraArgs as $arg) {
|
||||||
$parts[] = $arg;
|
$parts[] = $arg;
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($this->dryRun) {
|
if ($this->dryRun) {
|
||||||
$parts[] = '--dry-run';
|
$parts[] = '--dry-run';
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($this->verbose) {
|
if ($this->verbose) {
|
||||||
$parts[] = '-v';
|
$parts[] = '-v';
|
||||||
}
|
}
|
||||||
|
|
||||||
$parts[] = '-e';
|
$parts[] = '-e';
|
||||||
$parts[] = escapeshellarg($sshCmd);
|
$parts[] = escapeshellarg($sshCmd);
|
||||||
$parts[] = escapeshellarg($source);
|
$parts[] = escapeshellarg($source);
|
||||||
$parts[] = escapeshellarg($dest);
|
$parts[] = escapeshellarg($dest);
|
||||||
|
|
||||||
return implode(' ', $parts);
|
return implode(' ', $parts);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
$app = new DeployDolibarrCli();
|
$app = new DeployDolibarrCli();
|
||||||
|
|||||||
+440
-439
@@ -1,5 +1,6 @@
|
|||||||
#!/usr/bin/env php
|
#!/usr/bin/env php
|
||||||
<?php
|
<?php
|
||||||
|
|
||||||
/* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
|
/* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
|
||||||
*
|
*
|
||||||
* This file is part of a Moko Consulting project.
|
* This file is part of a Moko Consulting project.
|
||||||
@@ -68,499 +69,499 @@ use phpseclib3\Crypt\PublicKeyLoader;
|
|||||||
*/
|
*/
|
||||||
class DeployJoomlaCli extends CliFramework
|
class DeployJoomlaCli extends CliFramework
|
||||||
{
|
{
|
||||||
private string $repoPath = '.';
|
private string $repoPath = '.';
|
||||||
private string $srcDir = 'src';
|
private string $srcDir = 'src';
|
||||||
private array $config = [];
|
private array $config = [];
|
||||||
private int $uploaded = 0;
|
private int $uploaded = 0;
|
||||||
private int $unchanged = 0;
|
private int $unchanged = 0;
|
||||||
private int $skipped = 0;
|
private int $skipped = 0;
|
||||||
private int $deleted = 0;
|
private int $deleted = 0;
|
||||||
private array $ignorePatterns = [];
|
private array $ignorePatterns = [];
|
||||||
|
|
||||||
protected function configure(): void
|
protected function configure(): void
|
||||||
{
|
{
|
||||||
$this->setDescription('Smart Joomla deploy — routes files to correct Joomla directories based on XML manifest');
|
$this->setDescription('Smart Joomla deploy — routes files to correct Joomla directories based on XML manifest');
|
||||||
$this->addArgument('--path', 'Repository root path', '.');
|
$this->addArgument('--path', 'Repository root path', '.');
|
||||||
$this->addArgument('--src-dir', 'Source directory relative to path', 'src');
|
$this->addArgument('--src-dir', 'Source directory relative to path', 'src');
|
||||||
$this->addArgument('--config', 'Path to sftp-config.json', '');
|
$this->addArgument('--config', 'Path to sftp-config.json', '');
|
||||||
$this->addArgument('--key-passphrase', 'SSH key passphrase', '');
|
$this->addArgument('--key-passphrase', 'SSH key passphrase', '');
|
||||||
}
|
}
|
||||||
|
|
||||||
protected function run(): int
|
protected function run(): int
|
||||||
{
|
{
|
||||||
$this->repoPath = $this->getArgument('--path');
|
$this->repoPath = $this->getArgument('--path');
|
||||||
$this->srcDir = $this->getArgument('--src-dir');
|
$this->srcDir = $this->getArgument('--src-dir');
|
||||||
$configPath = $this->getArgument('--config');
|
$configPath = $this->getArgument('--config');
|
||||||
$keyPassphrase = $this->getArgument('--key-passphrase');
|
$keyPassphrase = $this->getArgument('--key-passphrase');
|
||||||
|
|
||||||
if ($keyPassphrase !== '') {
|
if ($keyPassphrase !== '') {
|
||||||
$this->config['key_passphrase'] = $keyPassphrase;
|
$this->config['key_passphrase'] = $keyPassphrase;
|
||||||
}
|
}
|
||||||
|
|
||||||
$this->repoPath = realpath($this->repoPath) ?: $this->repoPath;
|
$this->repoPath = realpath($this->repoPath) ?: $this->repoPath;
|
||||||
|
|
||||||
// Resolve src dir
|
// Resolve src dir
|
||||||
if (!str_starts_with($this->srcDir, '/')) {
|
if (!str_starts_with($this->srcDir, '/')) {
|
||||||
$this->srcDir = "{$this->repoPath}/{$this->srcDir}";
|
$this->srcDir = "{$this->repoPath}/{$this->srcDir}";
|
||||||
}
|
}
|
||||||
// Try htdocs/ as fallback
|
// Try htdocs/ as fallback
|
||||||
if (!is_dir($this->srcDir) && is_dir("{$this->repoPath}/htdocs")) {
|
if (!is_dir($this->srcDir) && is_dir("{$this->repoPath}/htdocs")) {
|
||||||
$this->srcDir = "{$this->repoPath}/htdocs";
|
$this->srcDir = "{$this->repoPath}/htdocs";
|
||||||
}
|
}
|
||||||
|
|
||||||
// Load config
|
// Load config
|
||||||
if ($configPath !== '' && file_exists($configPath)) {
|
if ($configPath !== '' && file_exists($configPath)) {
|
||||||
$json = file_get_contents($configPath);
|
$json = file_get_contents($configPath);
|
||||||
$json = preg_replace('#^\s*//.*$#m', '', $json);
|
$json = preg_replace('#^\s*//.*$#m', '', $json);
|
||||||
$json = preg_replace('#,\s*([\]}])#', '$1', $json);
|
$json = preg_replace('#,\s*([\]}])#', '$1', $json);
|
||||||
$parsed = json_decode($json, true);
|
$parsed = json_decode($json, true);
|
||||||
if (is_array($parsed)) {
|
if (is_array($parsed)) {
|
||||||
$this->config = array_merge($this->config, $parsed);
|
$this->config = array_merge($this->config, $parsed);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
$manifest = $this->findManifest();
|
$manifest = $this->findManifest();
|
||||||
if ($manifest === null) {
|
if ($manifest === null) {
|
||||||
$this->log('ERROR', "No Joomla XML manifest found in {$this->srcDir}");
|
$this->log('ERROR', "No Joomla XML manifest found in {$this->srcDir}");
|
||||||
return 1;
|
return 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
$ext = $this->parseManifest($manifest);
|
$ext = $this->parseManifest($manifest);
|
||||||
if ($ext === null) {
|
if ($ext === null) {
|
||||||
$this->log('ERROR', "Failed to parse manifest: {$manifest}");
|
$this->log('ERROR', "Failed to parse manifest: {$manifest}");
|
||||||
return 1;
|
return 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
$this->log('INFO', "Extension: {$ext['type']} / {$ext['element']} (client: {$ext['client']})");
|
$this->log('INFO', "Extension: {$ext['type']} / {$ext['element']} (client: {$ext['client']})");
|
||||||
|
|
||||||
$deployMap = $this->buildDeployMap($ext);
|
$deployMap = $this->buildDeployMap($ext);
|
||||||
if (empty($deployMap)) {
|
if (empty($deployMap)) {
|
||||||
$this->log('ERROR', "No deploy mappings for extension type: {$ext['type']}");
|
$this->log('ERROR', "No deploy mappings for extension type: {$ext['type']}");
|
||||||
return 1;
|
return 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
$this->log('INFO', "Deploy mappings:");
|
$this->log('INFO', "Deploy mappings:");
|
||||||
foreach ($deployMap as $map) {
|
foreach ($deployMap as $map) {
|
||||||
$this->log('INFO', " {$map['local']} -> {$map['remote']}");
|
$this->log('INFO', " {$map['local']} -> {$map['remote']}");
|
||||||
}
|
}
|
||||||
|
|
||||||
// Load ignore patterns
|
// Load ignore patterns
|
||||||
$this->ignorePatterns = $this->loadFtpIgnore();
|
$this->ignorePatterns = $this->loadFtpIgnore();
|
||||||
|
|
||||||
// Check if manifest changed (warn user about reinstall)
|
// Check if manifest changed (warn user about reinstall)
|
||||||
$this->checkManifestChange($ext, $manifest);
|
$this->checkManifestChange($ext, $manifest);
|
||||||
|
|
||||||
if ($this->dryRun) {
|
if ($this->dryRun) {
|
||||||
$this->log('INFO', "[DRY RUN] Would deploy " . count($deployMap) . " mappings");
|
$this->log('INFO', "[DRY RUN] Would deploy " . count($deployMap) . " mappings");
|
||||||
foreach ($deployMap as $map) {
|
foreach ($deployMap as $map) {
|
||||||
if (is_dir($map['local'])) {
|
if (is_dir($map['local'])) {
|
||||||
$count = iterator_count(
|
$count = iterator_count(
|
||||||
new \RecursiveIteratorIterator(
|
new \RecursiveIteratorIterator(
|
||||||
new \RecursiveDirectoryIterator($map['local'], \FilesystemIterator::SKIP_DOTS)
|
new \RecursiveDirectoryIterator($map['local'], \FilesystemIterator::SKIP_DOTS)
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
$this->log('INFO', " {$map['local']} ({$count} files) -> {$map['remote']}");
|
$this->log('INFO', " {$map['local']} ({$count} files) -> {$map['remote']}");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Connect
|
// Connect
|
||||||
$sftp = $this->connectSftp();
|
$sftp = $this->connectSftp();
|
||||||
if ($sftp === null) {
|
if ($sftp === null) {
|
||||||
return 1;
|
return 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Deploy each mapping
|
// Deploy each mapping
|
||||||
$errors = 0;
|
$errors = 0;
|
||||||
foreach ($deployMap as $map) {
|
foreach ($deployMap as $map) {
|
||||||
if (!is_dir($map['local'])) {
|
if (!is_dir($map['local'])) {
|
||||||
if ($this->verbose) {
|
if ($this->verbose) {
|
||||||
$this->log('INFO', " SKIP: {$map['local']} (directory not found)");
|
$this->log('INFO', " SKIP: {$map['local']} (directory not found)");
|
||||||
}
|
}
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Ensure remote directory exists
|
// Ensure remote directory exists
|
||||||
$stat = @$sftp->stat($map['remote']);
|
$stat = @$sftp->stat($map['remote']);
|
||||||
if ($stat === false) {
|
if ($stat === false) {
|
||||||
$this->log('INFO', " MKDIR: {$map['remote']}");
|
$this->log('INFO', " MKDIR: {$map['remote']}");
|
||||||
$sftp->mkdir($map['remote'], -1, true);
|
$sftp->mkdir($map['remote'], -1, true);
|
||||||
}
|
}
|
||||||
|
|
||||||
$result = $this->uploadDirectory($sftp, $map['local'], $map['remote']);
|
$result = $this->uploadDirectory($sftp, $map['local'], $map['remote']);
|
||||||
if ($result !== 0) {
|
if ($result !== 0) {
|
||||||
$errors++;
|
$errors++;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Also deploy the manifest file itself to the admin component directory
|
// Also deploy the manifest file itself to the admin component directory
|
||||||
if ($ext['type'] === 'component' && file_exists($manifest)) {
|
if ($ext['type'] === 'component' && file_exists($manifest)) {
|
||||||
$adminRemote = $this->getRemotePath($ext, 'admin');
|
$adminRemote = $this->getRemotePath($ext, 'admin');
|
||||||
$manifestName = basename($manifest);
|
$manifestName = basename($manifest);
|
||||||
$remoteDest = "{$adminRemote}/{$manifestName}";
|
$remoteDest = "{$adminRemote}/{$manifestName}";
|
||||||
$this->uploadFile($sftp, $manifest, $remoteDest);
|
$this->uploadFile($sftp, $manifest, $remoteDest);
|
||||||
$this->log('INFO', " Manifest: {$manifestName} -> {$remoteDest}");
|
$this->log('INFO', " Manifest: {$manifestName} -> {$remoteDest}");
|
||||||
}
|
}
|
||||||
|
|
||||||
$this->log('INFO', "Done. Uploaded: {$this->uploaded}, Unchanged: {$this->unchanged}, Skipped: {$this->skipped}");
|
$this->log('INFO', "Done. Uploaded: {$this->uploaded}, Unchanged: {$this->unchanged}, Skipped: {$this->skipped}");
|
||||||
|
|
||||||
return $errors > 0 ? 1 : 0;
|
return $errors > 0 ? 1 : 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Find the Joomla XML manifest file.
|
* Find the Joomla XML manifest file.
|
||||||
*/
|
*/
|
||||||
private function findManifest(): ?string
|
private function findManifest(): ?string
|
||||||
{
|
{
|
||||||
$searchDirs = [$this->srcDir, $this->repoPath];
|
$searchDirs = [$this->srcDir, $this->repoPath];
|
||||||
foreach ($searchDirs as $dir) {
|
foreach ($searchDirs as $dir) {
|
||||||
$iterator = new \DirectoryIterator($dir);
|
$iterator = new \DirectoryIterator($dir);
|
||||||
foreach ($iterator as $file) {
|
foreach ($iterator as $file) {
|
||||||
if ($file->isFile() && $file->getExtension() === 'xml') {
|
if ($file->isFile() && $file->getExtension() === 'xml') {
|
||||||
$content = file_get_contents($file->getPathname());
|
$content = file_get_contents($file->getPathname());
|
||||||
if ($content !== false && str_contains($content, '<extension')) {
|
if ($content !== false && str_contains($content, '<extension')) {
|
||||||
return $file->getPathname();
|
return $file->getPathname();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Also check one level deep
|
// Also check one level deep
|
||||||
foreach (new \DirectoryIterator($dir) as $subdir) {
|
foreach (new \DirectoryIterator($dir) as $subdir) {
|
||||||
if ($subdir->isDir() && !$subdir->isDot()) {
|
if ($subdir->isDir() && !$subdir->isDot()) {
|
||||||
foreach (new \DirectoryIterator($subdir->getPathname()) as $file) {
|
foreach (new \DirectoryIterator($subdir->getPathname()) as $file) {
|
||||||
if ($file->isFile() && $file->getExtension() === 'xml') {
|
if ($file->isFile() && $file->getExtension() === 'xml') {
|
||||||
$content = file_get_contents($file->getPathname());
|
$content = file_get_contents($file->getPathname());
|
||||||
if ($content !== false && str_contains($content, '<extension')) {
|
if ($content !== false && str_contains($content, '<extension')) {
|
||||||
return $file->getPathname();
|
return $file->getPathname();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Parse extension metadata from the XML manifest.
|
* Parse extension metadata from the XML manifest.
|
||||||
*
|
*
|
||||||
* @return array{type: string, element: string, client: string, group: string, name: string}|null
|
* @return array{type: string, element: string, client: string, group: string, name: string}|null
|
||||||
*/
|
*/
|
||||||
private function parseManifest(string $path): ?array
|
private function parseManifest(string $path): ?array
|
||||||
{
|
{
|
||||||
$xml = @simplexml_load_file($path);
|
$xml = @simplexml_load_file($path);
|
||||||
if ($xml === false || $xml->getName() !== 'extension') {
|
if ($xml === false || $xml->getName() !== 'extension') {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
$type = (string) ($xml['type'] ?? 'component');
|
$type = (string) ($xml['type'] ?? 'component');
|
||||||
$client = (string) ($xml['client'] ?? 'site');
|
$client = (string) ($xml['client'] ?? 'site');
|
||||||
$group = (string) ($xml['group'] ?? '');
|
$group = (string) ($xml['group'] ?? '');
|
||||||
$element = (string) ($xml->element ?? '');
|
$element = (string) ($xml->element ?? '');
|
||||||
$name = (string) ($xml->name ?? '');
|
$name = (string) ($xml->name ?? '');
|
||||||
|
|
||||||
// Derive element from type + name if not explicit
|
// Derive element from type + name if not explicit
|
||||||
if (empty($element)) {
|
if (empty($element)) {
|
||||||
$cleanName = strtolower(preg_replace('/[^a-zA-Z0-9_]/', '', $name));
|
$cleanName = strtolower(preg_replace('/[^a-zA-Z0-9_]/', '', $name));
|
||||||
$element = match ($type) {
|
$element = match ($type) {
|
||||||
'component' => "com_{$cleanName}",
|
'component' => "com_{$cleanName}",
|
||||||
'module' => "mod_{$cleanName}",
|
'module' => "mod_{$cleanName}",
|
||||||
'plugin' => "plg_{$group}_{$cleanName}",
|
'plugin' => "plg_{$group}_{$cleanName}",
|
||||||
'template' => "tpl_{$cleanName}",
|
'template' => "tpl_{$cleanName}",
|
||||||
'library' => "lib_{$cleanName}",
|
'library' => "lib_{$cleanName}",
|
||||||
default => $cleanName,
|
default => $cleanName,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// For plugins, derive the short name (without plg_group_ prefix)
|
// For plugins, derive the short name (without plg_group_ prefix)
|
||||||
$shortName = $element;
|
$shortName = $element;
|
||||||
if ($type === 'plugin' && preg_match('/^plg_\w+_(.+)$/', $element, $m)) {
|
if ($type === 'plugin' && preg_match('/^plg_\w+_(.+)$/', $element, $m)) {
|
||||||
$shortName = $m[1];
|
$shortName = $m[1];
|
||||||
} elseif ($type === 'template' && preg_match('/^tpl_(.+)$/', $element, $m)) {
|
} elseif ($type === 'template' && preg_match('/^tpl_(.+)$/', $element, $m)) {
|
||||||
$shortName = $m[1];
|
$shortName = $m[1];
|
||||||
}
|
}
|
||||||
|
|
||||||
return [
|
return [
|
||||||
'type' => $type,
|
'type' => $type,
|
||||||
'element' => $element,
|
'element' => $element,
|
||||||
'client' => $client,
|
'client' => $client,
|
||||||
'group' => $group,
|
'group' => $group,
|
||||||
'name' => $name,
|
'name' => $name,
|
||||||
'shortName' => $shortName,
|
'shortName' => $shortName,
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Build the local->remote deploy mapping based on extension type.
|
* Build the local->remote deploy mapping based on extension type.
|
||||||
*
|
*
|
||||||
* @return array<int, array{local: string, remote: string}>
|
* @return array<int, array{local: string, remote: string}>
|
||||||
*/
|
*/
|
||||||
private function buildDeployMap(array $ext): array
|
private function buildDeployMap(array $ext): array
|
||||||
{
|
{
|
||||||
$remotePath = rtrim((string) $this->config['remote_path'], '/');
|
$remotePath = rtrim((string) $this->config['remote_path'], '/');
|
||||||
$src = $this->srcDir;
|
$src = $this->srcDir;
|
||||||
$map = [];
|
$map = [];
|
||||||
|
|
||||||
switch ($ext['type']) {
|
switch ($ext['type']) {
|
||||||
case 'component':
|
case 'component':
|
||||||
// Admin files
|
// Admin files
|
||||||
if (is_dir("{$src}/admin") || is_dir("{$src}/administrator")) {
|
if (is_dir("{$src}/admin") || is_dir("{$src}/administrator")) {
|
||||||
$adminLocal = is_dir("{$src}/admin") ? "{$src}/admin" : "{$src}/administrator";
|
$adminLocal = is_dir("{$src}/admin") ? "{$src}/admin" : "{$src}/administrator";
|
||||||
$map[] = ['local' => $adminLocal, 'remote' => "{$remotePath}/administrator/components/{$ext['element']}"];
|
$map[] = ['local' => $adminLocal, 'remote' => "{$remotePath}/administrator/components/{$ext['element']}"];
|
||||||
}
|
}
|
||||||
// Site files
|
// Site files
|
||||||
if (is_dir("{$src}/site")) {
|
if (is_dir("{$src}/site")) {
|
||||||
$map[] = ['local' => "{$src}/site", 'remote' => "{$remotePath}/components/{$ext['element']}"];
|
$map[] = ['local' => "{$src}/site", 'remote' => "{$remotePath}/components/{$ext['element']}"];
|
||||||
}
|
}
|
||||||
// Media files
|
// Media files
|
||||||
if (is_dir("{$src}/media")) {
|
if (is_dir("{$src}/media")) {
|
||||||
$map[] = ['local' => "{$src}/media", 'remote' => "{$remotePath}/media/{$ext['element']}"];
|
$map[] = ['local' => "{$src}/media", 'remote' => "{$remotePath}/media/{$ext['element']}"];
|
||||||
}
|
}
|
||||||
// API files (Joomla 4+)
|
// API files (Joomla 4+)
|
||||||
if (is_dir("{$src}/api")) {
|
if (is_dir("{$src}/api")) {
|
||||||
$map[] = ['local' => "{$src}/api", 'remote' => "{$remotePath}/api/components/{$ext['element']}"];
|
$map[] = ['local' => "{$src}/api", 'remote' => "{$remotePath}/api/components/{$ext['element']}"];
|
||||||
}
|
}
|
||||||
// Language files (admin)
|
// Language files (admin)
|
||||||
if (is_dir("{$src}/language/admin") || is_dir("{$src}/admin/language")) {
|
if (is_dir("{$src}/language/admin") || is_dir("{$src}/admin/language")) {
|
||||||
$langDir = is_dir("{$src}/language/admin") ? "{$src}/language/admin" : "{$src}/admin/language";
|
$langDir = is_dir("{$src}/language/admin") ? "{$src}/language/admin" : "{$src}/admin/language";
|
||||||
$map[] = ['local' => $langDir, 'remote' => "{$remotePath}/administrator/language"];
|
$map[] = ['local' => $langDir, 'remote' => "{$remotePath}/administrator/language"];
|
||||||
}
|
}
|
||||||
// Language files (site)
|
// Language files (site)
|
||||||
if (is_dir("{$src}/language/site") || is_dir("{$src}/site/language")) {
|
if (is_dir("{$src}/language/site") || is_dir("{$src}/site/language")) {
|
||||||
$langDir = is_dir("{$src}/language/site") ? "{$src}/language/site" : "{$src}/site/language";
|
$langDir = is_dir("{$src}/language/site") ? "{$src}/language/site" : "{$src}/site/language";
|
||||||
$map[] = ['local' => $langDir, 'remote' => "{$remotePath}/language"];
|
$map[] = ['local' => $langDir, 'remote' => "{$remotePath}/language"];
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case 'module':
|
case 'module':
|
||||||
$base = $ext['client'] === 'administrator'
|
$base = $ext['client'] === 'administrator'
|
||||||
? "{$remotePath}/administrator/modules/{$ext['element']}"
|
? "{$remotePath}/administrator/modules/{$ext['element']}"
|
||||||
: "{$remotePath}/modules/{$ext['element']}";
|
: "{$remotePath}/modules/{$ext['element']}";
|
||||||
$map[] = ['local' => $src, 'remote' => $base];
|
$map[] = ['local' => $src, 'remote' => $base];
|
||||||
if (is_dir("{$src}/media")) {
|
if (is_dir("{$src}/media")) {
|
||||||
$map[] = ['local' => "{$src}/media", 'remote' => "{$remotePath}/media/{$ext['element']}"];
|
$map[] = ['local' => "{$src}/media", 'remote' => "{$remotePath}/media/{$ext['element']}"];
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case 'plugin':
|
case 'plugin':
|
||||||
$map[] = ['local' => $src, 'remote' => "{$remotePath}/plugins/{$ext['group']}/{$ext['shortName']}"];
|
$map[] = ['local' => $src, 'remote' => "{$remotePath}/plugins/{$ext['group']}/{$ext['shortName']}"];
|
||||||
if (is_dir("{$src}/media")) {
|
if (is_dir("{$src}/media")) {
|
||||||
$map[] = ['local' => "{$src}/media", 'remote' => "{$remotePath}/media/{$ext['element']}"];
|
$map[] = ['local' => "{$src}/media", 'remote' => "{$remotePath}/media/{$ext['element']}"];
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case 'template':
|
case 'template':
|
||||||
$clientDir = $ext['client'] === 'administrator' ? 'administrator/' : '';
|
$clientDir = $ext['client'] === 'administrator' ? 'administrator/' : '';
|
||||||
$map[] = ['local' => $src, 'remote' => "{$remotePath}/{$clientDir}templates/{$ext['shortName']}"];
|
$map[] = ['local' => $src, 'remote' => "{$remotePath}/{$clientDir}templates/{$ext['shortName']}"];
|
||||||
if (is_dir("{$src}/media")) {
|
if (is_dir("{$src}/media")) {
|
||||||
$mediaClient = $ext['client'] === 'administrator' ? 'administrator' : 'site';
|
$mediaClient = $ext['client'] === 'administrator' ? 'administrator' : 'site';
|
||||||
$map[] = ['local' => "{$src}/media", 'remote' => "{$remotePath}/media/templates/{$mediaClient}/{$ext['shortName']}"];
|
$map[] = ['local' => "{$src}/media", 'remote' => "{$remotePath}/media/templates/{$mediaClient}/{$ext['shortName']}"];
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case 'library':
|
case 'library':
|
||||||
$map[] = ['local' => $src, 'remote' => "{$remotePath}/libraries/{$ext['shortName']}"];
|
$map[] = ['local' => $src, 'remote' => "{$remotePath}/libraries/{$ext['shortName']}"];
|
||||||
if (is_dir("{$src}/media")) {
|
if (is_dir("{$src}/media")) {
|
||||||
$map[] = ['local' => "{$src}/media", 'remote' => "{$remotePath}/media/{$ext['element']}"];
|
$map[] = ['local' => "{$src}/media", 'remote' => "{$remotePath}/media/{$ext['element']}"];
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case 'package':
|
case 'package':
|
||||||
// Packages deploy their sub-extensions individually
|
// Packages deploy their sub-extensions individually
|
||||||
// For now, deploy to administrator/manifests/packages/
|
// For now, deploy to administrator/manifests/packages/
|
||||||
$map[] = ['local' => $src, 'remote' => "{$remotePath}/administrator/manifests/packages"];
|
$map[] = ['local' => $src, 'remote' => "{$remotePath}/administrator/manifests/packages"];
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
return $map;
|
return $map;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get the remote path for a specific section of the extension.
|
* Get the remote path for a specific section of the extension.
|
||||||
*/
|
*/
|
||||||
private function getRemotePath(array $ext, string $section): string
|
private function getRemotePath(array $ext, string $section): string
|
||||||
{
|
{
|
||||||
$remotePath = rtrim((string) $this->config['remote_path'], '/');
|
$remotePath = rtrim((string) $this->config['remote_path'], '/');
|
||||||
return match ($section) {
|
return match ($section) {
|
||||||
'admin' => "{$remotePath}/administrator/components/{$ext['element']}",
|
'admin' => "{$remotePath}/administrator/components/{$ext['element']}",
|
||||||
'site' => "{$remotePath}/components/{$ext['element']}",
|
'site' => "{$remotePath}/components/{$ext['element']}",
|
||||||
'media' => "{$remotePath}/media/{$ext['element']}",
|
'media' => "{$remotePath}/media/{$ext['element']}",
|
||||||
default => $remotePath,
|
default => $remotePath,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Check if the XML manifest has changed and warn about reinstall.
|
* Check if the XML manifest has changed and warn about reinstall.
|
||||||
*/
|
*/
|
||||||
private function checkManifestChange(array $ext, string $manifestPath): void
|
private function checkManifestChange(array $ext, string $manifestPath): void
|
||||||
{
|
{
|
||||||
$manifestName = basename($manifestPath);
|
$manifestName = basename($manifestPath);
|
||||||
$this->log('INFO', '');
|
$this->log('INFO', '');
|
||||||
$this->log('INFO', "NOTE: If {$manifestName} has changed (new fields, permissions, menu items,");
|
$this->log('INFO', "NOTE: If {$manifestName} has changed (new fields, permissions, menu items,");
|
||||||
$this->log('INFO', ' database schema), you must reinstall the extension through Joomla.');
|
$this->log('INFO', ' database schema), you must reinstall the extension through Joomla.');
|
||||||
$this->log('INFO', ' Code changes (PHP, JS, CSS, language) do NOT require reinstall.');
|
$this->log('INFO', ' Code changes (PHP, JS, CSS, language) do NOT require reinstall.');
|
||||||
$this->log('INFO', '');
|
$this->log('INFO', '');
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Upload a directory recursively to the remote server.
|
* Upload a directory recursively to the remote server.
|
||||||
*/
|
*/
|
||||||
private function uploadDirectory(SFTP $sftp, string $localDir, string $remoteDir): int
|
private function uploadDirectory(SFTP $sftp, string $localDir, string $remoteDir): int
|
||||||
{
|
{
|
||||||
$errors = 0;
|
$errors = 0;
|
||||||
$iterator = new \RecursiveIteratorIterator(
|
$iterator = new \RecursiveIteratorIterator(
|
||||||
new \RecursiveDirectoryIterator($localDir, \FilesystemIterator::SKIP_DOTS),
|
new \RecursiveDirectoryIterator($localDir, \FilesystemIterator::SKIP_DOTS),
|
||||||
\RecursiveIteratorIterator::SELF_FIRST
|
\RecursiveIteratorIterator::SELF_FIRST
|
||||||
);
|
);
|
||||||
|
|
||||||
foreach ($iterator as $item) {
|
foreach ($iterator as $item) {
|
||||||
$relativePath = substr($item->getPathname(), strlen($localDir) + 1);
|
$relativePath = substr($item->getPathname(), strlen($localDir) + 1);
|
||||||
$relativePath = str_replace('\\', '/', $relativePath);
|
$relativePath = str_replace('\\', '/', $relativePath);
|
||||||
$remotePath = "{$remoteDir}/{$relativePath}";
|
$remotePath = "{$remoteDir}/{$relativePath}";
|
||||||
|
|
||||||
// Check ignore patterns
|
// Check ignore patterns
|
||||||
if ($this->shouldIgnore($relativePath)) {
|
if ($this->shouldIgnore($relativePath)) {
|
||||||
$this->skipped++;
|
$this->skipped++;
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($item->isDir()) {
|
if ($item->isDir()) {
|
||||||
$stat = @$sftp->stat($remotePath);
|
$stat = @$sftp->stat($remotePath);
|
||||||
if ($stat === false) {
|
if ($stat === false) {
|
||||||
$sftp->mkdir($remotePath, -1, true);
|
$sftp->mkdir($remotePath, -1, true);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
$result = $this->uploadFile($sftp, $item->getPathname(), $remotePath);
|
$result = $this->uploadFile($sftp, $item->getPathname(), $remotePath);
|
||||||
if (!$result) {
|
if (!$result) {
|
||||||
$errors++;
|
$errors++;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return $errors;
|
return $errors;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Upload a single file with smart diff (skip if unchanged).
|
* Upload a single file with smart diff (skip if unchanged).
|
||||||
*/
|
*/
|
||||||
private function uploadFile(SFTP $sftp, string $localPath, string $remotePath): bool
|
private function uploadFile(SFTP $sftp, string $localPath, string $remotePath): bool
|
||||||
{
|
{
|
||||||
$localSize = filesize($localPath);
|
$localSize = filesize($localPath);
|
||||||
$remoteStat = @$sftp->stat($remotePath);
|
$remoteStat = @$sftp->stat($remotePath);
|
||||||
|
|
||||||
// Smart diff: skip if same size and hash
|
// Smart diff: skip if same size and hash
|
||||||
if ($remoteStat !== false && ($remoteStat['size'] ?? -1) === $localSize) {
|
if ($remoteStat !== false && ($remoteStat['size'] ?? -1) === $localSize) {
|
||||||
$remoteContent = @$sftp->get($remotePath);
|
$remoteContent = @$sftp->get($remotePath);
|
||||||
if ($remoteContent !== false && md5($remoteContent) === md5_file($localPath)) {
|
if ($remoteContent !== false && md5($remoteContent) === md5_file($localPath)) {
|
||||||
$this->unchanged++;
|
$this->unchanged++;
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Ensure parent directory exists
|
// Ensure parent directory exists
|
||||||
$parentDir = dirname($remotePath);
|
$parentDir = dirname($remotePath);
|
||||||
$parentStat = @$sftp->stat($parentDir);
|
$parentStat = @$sftp->stat($parentDir);
|
||||||
if ($parentStat === false) {
|
if ($parentStat === false) {
|
||||||
$sftp->mkdir($parentDir, -1, true);
|
$sftp->mkdir($parentDir, -1, true);
|
||||||
}
|
}
|
||||||
|
|
||||||
$result = $sftp->put($remotePath, $localPath, SFTP::SOURCE_LOCAL_FILE);
|
$result = $sftp->put($remotePath, $localPath, SFTP::SOURCE_LOCAL_FILE);
|
||||||
if ($result) {
|
if ($result) {
|
||||||
$this->uploaded++;
|
$this->uploaded++;
|
||||||
if ($this->verbose) {
|
if ($this->verbose) {
|
||||||
$this->log('INFO', " UPLOAD: {$remotePath}");
|
$this->log('INFO', " UPLOAD: {$remotePath}");
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
$this->log('ERROR', " FAIL: {$remotePath}");
|
$this->log('ERROR', " FAIL: {$remotePath}");
|
||||||
}
|
}
|
||||||
return $result;
|
return $result;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Check if a relative path should be ignored.
|
* Check if a relative path should be ignored.
|
||||||
*/
|
*/
|
||||||
private function shouldIgnore(string $relativePath): bool
|
private function shouldIgnore(string $relativePath): bool
|
||||||
{
|
{
|
||||||
foreach ($this->ignorePatterns as $pattern) {
|
foreach ($this->ignorePatterns as $pattern) {
|
||||||
if (preg_match($pattern, $relativePath)) {
|
if (preg_match($pattern, $relativePath)) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Always skip dotfiles and common non-deploy files
|
// Always skip dotfiles and common non-deploy files
|
||||||
$basename = basename($relativePath);
|
$basename = basename($relativePath);
|
||||||
if (str_starts_with($basename, '.') && $basename !== '.htaccess') {
|
if (str_starts_with($basename, '.') && $basename !== '.htaccess') {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Load .ftpignore patterns.
|
* Load .ftpignore patterns.
|
||||||
*
|
*
|
||||||
* @return string[] Regex patterns
|
* @return string[] Regex patterns
|
||||||
*/
|
*/
|
||||||
private function loadFtpIgnore(): array
|
private function loadFtpIgnore(): array
|
||||||
{
|
{
|
||||||
$patterns = [];
|
$patterns = [];
|
||||||
foreach ([$this->srcDir, $this->repoPath] as $dir) {
|
foreach ([$this->srcDir, $this->repoPath] as $dir) {
|
||||||
$file = "{$dir}/.ftpignore";
|
$file = "{$dir}/.ftpignore";
|
||||||
if (!file_exists($file)) {
|
if (!file_exists($file)) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
foreach (file($file, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES) as $line) {
|
foreach (file($file, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES) as $line) {
|
||||||
$line = trim($line);
|
$line = trim($line);
|
||||||
if ($line === '' || str_starts_with($line, '#')) {
|
if ($line === '' || str_starts_with($line, '#')) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
// Convert glob to regex
|
// Convert glob to regex
|
||||||
$regex = str_replace(['.', '*', '?'], ['\\.', '.*', '.'], $line);
|
$regex = str_replace(['.', '*', '?'], ['\\.', '.*', '.'], $line);
|
||||||
$patterns[] = "#^{$regex}(/|$)#i";
|
$patterns[] = "#^{$regex}(/|$)#i";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return $patterns;
|
return $patterns;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Connect to the SFTP server.
|
* Connect to the SFTP server.
|
||||||
*/
|
*/
|
||||||
private function connectSftp(): ?SFTP
|
private function connectSftp(): ?SFTP
|
||||||
{
|
{
|
||||||
$host = (string) $this->config['host'];
|
$host = (string) $this->config['host'];
|
||||||
$port = (int) ($this->config['port'] ?? 22);
|
$port = (int) ($this->config['port'] ?? 22);
|
||||||
$user = (string) $this->config['user'];
|
$user = (string) $this->config['user'];
|
||||||
|
|
||||||
$this->log('INFO', "Connecting to {$user}@{$host}:{$port}...");
|
$this->log('INFO', "Connecting to {$user}@{$host}:{$port}...");
|
||||||
|
|
||||||
$sftp = new SFTP($host, $port, 30);
|
$sftp = new SFTP($host, $port, 30);
|
||||||
|
|
||||||
// Try key auth first
|
// Try key auth first
|
||||||
if (!empty($this->config['ssh_key_file'])) {
|
if (!empty($this->config['ssh_key_file'])) {
|
||||||
$keyPath = $this->config['ssh_key_file'];
|
$keyPath = $this->config['ssh_key_file'];
|
||||||
if (!file_exists($keyPath)) {
|
if (!file_exists($keyPath)) {
|
||||||
$keyPath = "{$this->repoPath}/scripts/keys/{$keyPath}";
|
$keyPath = "{$this->repoPath}/scripts/keys/{$keyPath}";
|
||||||
}
|
}
|
||||||
if (file_exists($keyPath)) {
|
if (file_exists($keyPath)) {
|
||||||
$passphrase = $this->config['key_passphrase'] ?? '';
|
$passphrase = $this->config['key_passphrase'] ?? '';
|
||||||
$key = PublicKeyLoader::load(file_get_contents($keyPath), $passphrase);
|
$key = PublicKeyLoader::load(file_get_contents($keyPath), $passphrase);
|
||||||
if ($sftp->login($user, $key)) {
|
if ($sftp->login($user, $key)) {
|
||||||
$this->log('INFO', 'Connected via SSH key');
|
$this->log('INFO', 'Connected via SSH key');
|
||||||
return $sftp;
|
return $sftp;
|
||||||
}
|
}
|
||||||
$this->warning('Key auth failed');
|
$this->warning('Key auth failed');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fallback to password
|
// Fallback to password
|
||||||
if (!empty($this->config['password'])) {
|
if (!empty($this->config['password'])) {
|
||||||
if ($sftp->login($user, $this->config['password'])) {
|
if ($sftp->login($user, $this->config['password'])) {
|
||||||
$this->log('INFO', 'Connected via password');
|
$this->log('INFO', 'Connected via password');
|
||||||
return $sftp;
|
return $sftp;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
$this->log('ERROR', 'Authentication failed');
|
$this->log('ERROR', 'Authentication failed');
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
$app = new DeployJoomlaCli();
|
$app = new DeployJoomlaCli();
|
||||||
|
|||||||
+668
-667
File diff suppressed because it is too large
Load Diff
+151
-150
@@ -1,5 +1,6 @@
|
|||||||
#!/usr/bin/env php
|
#!/usr/bin/env php
|
||||||
<?php
|
<?php
|
||||||
|
|
||||||
/* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
|
/* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
|
||||||
*
|
*
|
||||||
* This file is part of a Moko Consulting project.
|
* This file is part of a Moko Consulting project.
|
||||||
@@ -23,192 +24,192 @@ use MokoEnterprise\CliFramework;
|
|||||||
|
|
||||||
class HealthCheckCli extends CliFramework
|
class HealthCheckCli extends CliFramework
|
||||||
{
|
{
|
||||||
private string $url = '';
|
private string $url = '';
|
||||||
private int $timeout = 30;
|
private int $timeout = 30;
|
||||||
private array $checks = ['http'];
|
private array $checks = ['http'];
|
||||||
|
|
||||||
private int $passed = 0;
|
private int $passed = 0;
|
||||||
private int $failed = 0;
|
private int $failed = 0;
|
||||||
|
|
||||||
protected function configure(): void
|
protected function configure(): void
|
||||||
{
|
{
|
||||||
$this->setDescription('Post-deploy health check — verify a Joomla site is responding correctly');
|
$this->setDescription('Post-deploy health check — verify a Joomla site is responding correctly');
|
||||||
$this->addArgument('--url', 'Site URL to check', '');
|
$this->addArgument('--url', 'Site URL to check', '');
|
||||||
$this->addArgument('--timeout', 'Request timeout in seconds', '30');
|
$this->addArgument('--timeout', 'Request timeout in seconds', '30');
|
||||||
$this->addArgument('--checks', 'Comma-separated list of checks: http,admin,api', 'http');
|
$this->addArgument('--checks', 'Comma-separated list of checks: http,admin,api', 'http');
|
||||||
}
|
}
|
||||||
|
|
||||||
protected function run(): int
|
protected function run(): int
|
||||||
{
|
{
|
||||||
$this->url = $this->getArgument('--url');
|
$this->url = $this->getArgument('--url');
|
||||||
$this->timeout = (int) $this->getArgument('--timeout');
|
$this->timeout = (int) $this->getArgument('--timeout');
|
||||||
$checksRaw = $this->getArgument('--checks');
|
$checksRaw = $this->getArgument('--checks');
|
||||||
$this->checks = array_map('trim', explode(',', $checksRaw));
|
$this->checks = array_map('trim', explode(',', $checksRaw));
|
||||||
|
|
||||||
if ($this->url === '') {
|
if ($this->url === '') {
|
||||||
$this->log('ERROR', 'Usage: health-check.php --url <site-url> [--timeout <seconds>] [--checks <http,admin,api>]');
|
$this->log('ERROR', 'Usage: health-check.php --url <site-url> [--timeout <seconds>] [--checks <http,admin,api>]');
|
||||||
return 1;
|
return 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
$this->url = rtrim($this->url, '/');
|
$this->url = rtrim($this->url, '/');
|
||||||
|
|
||||||
$this->log('INFO', "Health check for: {$this->url}");
|
$this->log('INFO', "Health check for: {$this->url}");
|
||||||
$this->log('INFO', "Timeout: {$this->timeout}s");
|
$this->log('INFO', "Timeout: {$this->timeout}s");
|
||||||
$this->log('INFO', "Checks: " . implode(', ', $this->checks));
|
$this->log('INFO', "Checks: " . implode(', ', $this->checks));
|
||||||
$this->log('INFO', '');
|
$this->log('INFO', '');
|
||||||
|
|
||||||
foreach ($this->checks as $check) {
|
foreach ($this->checks as $check) {
|
||||||
switch ($check) {
|
switch ($check) {
|
||||||
case 'http':
|
case 'http':
|
||||||
$this->checkHttp();
|
$this->checkHttp();
|
||||||
break;
|
break;
|
||||||
case 'admin':
|
case 'admin':
|
||||||
$this->checkAdmin();
|
$this->checkAdmin();
|
||||||
break;
|
break;
|
||||||
case 'api':
|
case 'api':
|
||||||
$this->checkApi();
|
$this->checkApi();
|
||||||
break;
|
break;
|
||||||
default:
|
default:
|
||||||
$this->log('WARN', "UNKNOWN CHECK: {$check} — skipping");
|
$this->log('WARN', "UNKNOWN CHECK: {$check} — skipping");
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
$this->log('INFO', '');
|
$this->log('INFO', '');
|
||||||
$this->log('INFO', "Results: {$this->passed} passed, {$this->failed} failed");
|
$this->log('INFO', "Results: {$this->passed} passed, {$this->failed} failed");
|
||||||
|
|
||||||
return $this->failed > 0 ? 1 : 0;
|
return $this->failed > 0 ? 1 : 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
private function checkHttp(): void
|
private function checkHttp(): void
|
||||||
{
|
{
|
||||||
$this->log('INFO', '[http] GET ' . $this->url);
|
$this->log('INFO', '[http] GET ' . $this->url);
|
||||||
|
|
||||||
$result = $this->curlGet($this->url);
|
$result = $this->curlGet($this->url);
|
||||||
|
|
||||||
if ($result === null) {
|
if ($result === null) {
|
||||||
$this->fail('http', 'Request failed — could not connect');
|
$this->fail('http', 'Request failed — could not connect');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($result['http_code'] !== 200) {
|
if ($result['http_code'] !== 200) {
|
||||||
$this->fail('http', "Expected HTTP 200, got {$result['http_code']}");
|
$this->fail('http', "Expected HTTP 200, got {$result['http_code']}");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($this->containsFatalError($result['body'])) {
|
if ($this->containsFatalError($result['body'])) {
|
||||||
$this->fail('http', 'Response body contains PHP fatal error');
|
$this->fail('http', 'Response body contains PHP fatal error');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
$this->pass('http', "HTTP 200 OK ({$result['time_ms']}ms)");
|
$this->pass('http', "HTTP 200 OK ({$result['time_ms']}ms)");
|
||||||
}
|
}
|
||||||
|
|
||||||
private function checkAdmin(): void
|
private function checkAdmin(): void
|
||||||
{
|
{
|
||||||
$adminUrl = $this->url . '/administrator/';
|
$adminUrl = $this->url . '/administrator/';
|
||||||
$this->log('INFO', '[admin] GET ' . $adminUrl);
|
$this->log('INFO', '[admin] GET ' . $adminUrl);
|
||||||
|
|
||||||
$result = $this->curlGet($adminUrl);
|
$result = $this->curlGet($adminUrl);
|
||||||
|
|
||||||
if ($result === null) {
|
if ($result === null) {
|
||||||
$this->fail('admin', 'Request failed — could not connect');
|
$this->fail('admin', 'Request failed — could not connect');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($result['http_code'] !== 200) {
|
if ($result['http_code'] !== 200) {
|
||||||
$this->fail('admin', "Expected HTTP 200, got {$result['http_code']}");
|
$this->fail('admin', "Expected HTTP 200, got {$result['http_code']}");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
$this->pass('admin', "HTTP 200 OK ({$result['time_ms']}ms)");
|
$this->pass('admin', "HTTP 200 OK ({$result['time_ms']}ms)");
|
||||||
}
|
}
|
||||||
|
|
||||||
private function checkApi(): void
|
private function checkApi(): void
|
||||||
{
|
{
|
||||||
$apiUrl = $this->url . '/api/index.php/v1';
|
$apiUrl = $this->url . '/api/index.php/v1';
|
||||||
$this->log('INFO', '[api] GET ' . $apiUrl);
|
$this->log('INFO', '[api] GET ' . $apiUrl);
|
||||||
|
|
||||||
$result = $this->curlGet($apiUrl);
|
$result = $this->curlGet($apiUrl);
|
||||||
|
|
||||||
if ($result === null) {
|
if ($result === null) {
|
||||||
$this->fail('api', 'Request failed — could not connect');
|
$this->fail('api', 'Request failed — could not connect');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($result['http_code'] !== 200 && $result['http_code'] !== 401) {
|
if ($result['http_code'] !== 200 && $result['http_code'] !== 401) {
|
||||||
$this->fail('api', "Expected HTTP 200 or 401, got {$result['http_code']}");
|
$this->fail('api', "Expected HTTP 200 or 401, got {$result['http_code']}");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
$this->pass('api', "HTTP {$result['http_code']} — API is alive ({$result['time_ms']}ms)");
|
$this->pass('api', "HTTP {$result['http_code']} — API is alive ({$result['time_ms']}ms)");
|
||||||
}
|
}
|
||||||
|
|
||||||
private function curlGet(string $url): ?array
|
private function curlGet(string $url): ?array
|
||||||
{
|
{
|
||||||
$ch = curl_init();
|
$ch = curl_init();
|
||||||
|
|
||||||
curl_setopt_array($ch, [
|
curl_setopt_array($ch, [
|
||||||
CURLOPT_URL => $url,
|
CURLOPT_URL => $url,
|
||||||
CURLOPT_RETURNTRANSFER => true,
|
CURLOPT_RETURNTRANSFER => true,
|
||||||
CURLOPT_FOLLOWLOCATION => true,
|
CURLOPT_FOLLOWLOCATION => true,
|
||||||
CURLOPT_MAXREDIRS => 5,
|
CURLOPT_MAXREDIRS => 5,
|
||||||
CURLOPT_TIMEOUT => $this->timeout,
|
CURLOPT_TIMEOUT => $this->timeout,
|
||||||
CURLOPT_CONNECTTIMEOUT => $this->timeout,
|
CURLOPT_CONNECTTIMEOUT => $this->timeout,
|
||||||
CURLOPT_SSL_VERIFYPEER => true,
|
CURLOPT_SSL_VERIFYPEER => true,
|
||||||
CURLOPT_USERAGENT => 'MokoHealthCheck/1.0',
|
CURLOPT_USERAGENT => 'MokoHealthCheck/1.0',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$body = curl_exec($ch);
|
$body = curl_exec($ch);
|
||||||
|
|
||||||
if (curl_errno($ch)) {
|
if (curl_errno($ch)) {
|
||||||
$error = curl_error($ch);
|
$error = curl_error($ch);
|
||||||
$this->log('ERROR', " cURL error: {$error}");
|
$this->log('ERROR', " cURL error: {$error}");
|
||||||
curl_close($ch);
|
curl_close($ch);
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
$httpCode = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
$httpCode = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||||||
$totalTime = curl_getinfo($ch, CURLINFO_TOTAL_TIME);
|
$totalTime = curl_getinfo($ch, CURLINFO_TOTAL_TIME);
|
||||||
curl_close($ch);
|
curl_close($ch);
|
||||||
|
|
||||||
return [
|
return [
|
||||||
'http_code' => $httpCode,
|
'http_code' => $httpCode,
|
||||||
'body' => is_string($body) ? $body : '',
|
'body' => is_string($body) ? $body : '',
|
||||||
'time_ms' => (int) round($totalTime * 1000),
|
'time_ms' => (int) round($totalTime * 1000),
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
private function containsFatalError(string $body): bool
|
private function containsFatalError(string $body): bool
|
||||||
{
|
{
|
||||||
$patterns = [
|
$patterns = [
|
||||||
'Fatal error:',
|
'Fatal error:',
|
||||||
'Fatal Error',
|
'Fatal Error',
|
||||||
'Parse error:',
|
'Parse error:',
|
||||||
'Uncaught Error:',
|
'Uncaught Error:',
|
||||||
'Uncaught Exception:',
|
'Uncaught Exception:',
|
||||||
];
|
];
|
||||||
|
|
||||||
foreach ($patterns as $pattern) {
|
foreach ($patterns as $pattern) {
|
||||||
if (stripos($body, $pattern) !== false) {
|
if (stripos($body, $pattern) !== false) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
private function pass(string $check, string $message): void
|
private function pass(string $check, string $message): void
|
||||||
{
|
{
|
||||||
$this->passed++;
|
$this->passed++;
|
||||||
$this->log('INFO', "[{$check}] PASS: {$message}");
|
$this->log('INFO', "[{$check}] PASS: {$message}");
|
||||||
}
|
}
|
||||||
|
|
||||||
private function fail(string $check, string $message): void
|
private function fail(string $check, string $message): void
|
||||||
{
|
{
|
||||||
$this->failed++;
|
$this->failed++;
|
||||||
$this->log('ERROR', "[{$check}] FAIL: {$message}");
|
$this->log('ERROR', "[{$check}] FAIL: {$message}");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
$app = new HealthCheckCli();
|
$app = new HealthCheckCli();
|
||||||
|
|||||||
+130
-129
@@ -1,5 +1,6 @@
|
|||||||
#!/usr/bin/env php
|
#!/usr/bin/env php
|
||||||
<?php
|
<?php
|
||||||
|
|
||||||
/* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
|
/* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
|
||||||
*
|
*
|
||||||
* This file is part of a Moko Consulting project.
|
* This file is part of a Moko Consulting project.
|
||||||
@@ -23,164 +24,164 @@ use MokoEnterprise\CliFramework;
|
|||||||
|
|
||||||
class RollbackJoomlaCli extends CliFramework
|
class RollbackJoomlaCli extends CliFramework
|
||||||
{
|
{
|
||||||
private const JOOMLA_DIRS = [
|
private const JOOMLA_DIRS = [
|
||||||
'administrator/components',
|
'administrator/components',
|
||||||
'administrator/language',
|
'administrator/language',
|
||||||
'administrator/modules',
|
'administrator/modules',
|
||||||
'administrator/templates',
|
'administrator/templates',
|
||||||
'components',
|
'components',
|
||||||
'language',
|
'language',
|
||||||
'layouts',
|
'layouts',
|
||||||
'libraries',
|
'libraries',
|
||||||
'media',
|
'media',
|
||||||
'modules',
|
'modules',
|
||||||
'plugins',
|
'plugins',
|
||||||
'templates',
|
'templates',
|
||||||
];
|
];
|
||||||
|
|
||||||
protected function configure(): void
|
protected function configure(): void
|
||||||
{
|
{
|
||||||
$this->setDescription('Rollback a Joomla deployment by restoring from a pre-deploy snapshot');
|
$this->setDescription('Rollback a Joomla deployment by restoring from a pre-deploy snapshot');
|
||||||
$this->addArgument('--config', 'Path to sftp-config.json', '');
|
$this->addArgument('--config', 'Path to sftp-config.json', '');
|
||||||
$this->addArgument('--snapshot-dir', 'Path to snapshot directory', '');
|
$this->addArgument('--snapshot-dir', 'Path to snapshot directory', '');
|
||||||
}
|
}
|
||||||
|
|
||||||
protected function run(): int
|
protected function run(): int
|
||||||
{
|
{
|
||||||
$configPath = $this->getArgument('--config');
|
$configPath = $this->getArgument('--config');
|
||||||
$snapshotDir = $this->getArgument('--snapshot-dir');
|
$snapshotDir = $this->getArgument('--snapshot-dir');
|
||||||
|
|
||||||
if ($configPath === '' || $snapshotDir === '') {
|
if ($configPath === '' || $snapshotDir === '') {
|
||||||
$this->log('ERROR', 'Usage: rollback-joomla.php --config <sftp-config.json> --snapshot-dir <path> [--dry-run] [--verbose]');
|
$this->log('ERROR', 'Usage: rollback-joomla.php --config <sftp-config.json> --snapshot-dir <path> [--dry-run] [--verbose]');
|
||||||
return 1;
|
return 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!is_dir($snapshotDir)) {
|
if (!is_dir($snapshotDir)) {
|
||||||
$this->log('ERROR', "Snapshot directory does not exist: {$snapshotDir}");
|
$this->log('ERROR', "Snapshot directory does not exist: {$snapshotDir}");
|
||||||
return 1;
|
return 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
$config = $this->loadConfig($configPath);
|
$config = $this->loadConfig($configPath);
|
||||||
if ($config === null) {
|
if ($config === null) {
|
||||||
return 1;
|
return 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
$host = $config['host'] ?? '';
|
$host = $config['host'] ?? '';
|
||||||
$user = $config['user'] ?? '';
|
$user = $config['user'] ?? '';
|
||||||
$port = (int) ($config['port'] ?? 22);
|
$port = (int) ($config['port'] ?? 22);
|
||||||
$remotePath = rtrim($config['remote_path'] ?? '', '/');
|
$remotePath = rtrim($config['remote_path'] ?? '', '/');
|
||||||
$sshKey = $config['ssh_key_file'] ?? '';
|
$sshKey = $config['ssh_key_file'] ?? '';
|
||||||
|
|
||||||
if ($host === '' || $user === '' || $remotePath === '') {
|
if ($host === '' || $user === '' || $remotePath === '') {
|
||||||
$this->log('ERROR', 'Config must contain host, user, and remote_path.');
|
$this->log('ERROR', 'Config must contain host, user, and remote_path.');
|
||||||
return 1;
|
return 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
$this->log('INFO', 'Starting Joomla rollback from snapshot...');
|
$this->log('INFO', 'Starting Joomla rollback from snapshot...');
|
||||||
$this->log('INFO', "Snapshot: {$snapshotDir}");
|
$this->log('INFO', "Snapshot: {$snapshotDir}");
|
||||||
$this->log('INFO', "Target: {$user}@{$host}:{$remotePath}");
|
$this->log('INFO', "Target: {$user}@{$host}:{$remotePath}");
|
||||||
|
|
||||||
if ($this->dryRun) {
|
if ($this->dryRun) {
|
||||||
$this->log('INFO', '*** DRY RUN — no changes will be made ***');
|
$this->log('INFO', '*** DRY RUN — no changes will be made ***');
|
||||||
}
|
}
|
||||||
|
|
||||||
$failed = 0;
|
$failed = 0;
|
||||||
|
|
||||||
foreach (self::JOOMLA_DIRS as $dir) {
|
foreach (self::JOOMLA_DIRS as $dir) {
|
||||||
$localDir = rtrim($snapshotDir, '/\\') . '/' . $dir . '/';
|
$localDir = rtrim($snapshotDir, '/\\') . '/' . $dir . '/';
|
||||||
|
|
||||||
if (!is_dir($localDir)) {
|
if (!is_dir($localDir)) {
|
||||||
if ($this->verbose) {
|
if ($this->verbose) {
|
||||||
$this->log('INFO', "SKIP: {$dir} (not present in snapshot)");
|
$this->log('INFO', "SKIP: {$dir} (not present in snapshot)");
|
||||||
}
|
}
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
$remoteTarget = "{$remotePath}/{$dir}/";
|
$remoteTarget = "{$remotePath}/{$dir}/";
|
||||||
$sshCmd = "ssh -p {$port}";
|
$sshCmd = "ssh -p {$port}";
|
||||||
if ($sshKey !== '') {
|
if ($sshKey !== '') {
|
||||||
$sshCmd .= " -i " . escapeshellarg($sshKey);
|
$sshCmd .= " -i " . escapeshellarg($sshKey);
|
||||||
}
|
}
|
||||||
|
|
||||||
$cmd = $this->buildRsyncCommand($sshCmd, $localDir, "{$user}@{$host}:{$remoteTarget}");
|
$cmd = $this->buildRsyncCommand($sshCmd, $localDir, "{$user}@{$host}:{$remoteTarget}");
|
||||||
|
|
||||||
$this->log('INFO', "Restoring: {$dir}");
|
$this->log('INFO', "Restoring: {$dir}");
|
||||||
if ($this->verbose) {
|
if ($this->verbose) {
|
||||||
$this->log('INFO', "CMD: {$cmd}");
|
$this->log('INFO', "CMD: {$cmd}");
|
||||||
}
|
}
|
||||||
|
|
||||||
$output = [];
|
$output = [];
|
||||||
$exitCode = 0;
|
$exitCode = 0;
|
||||||
exec($cmd, $output, $exitCode);
|
exec($cmd, $output, $exitCode);
|
||||||
|
|
||||||
if ($exitCode !== 0) {
|
if ($exitCode !== 0) {
|
||||||
$this->log('ERROR', "rsync failed for {$dir} (exit code {$exitCode})");
|
$this->log('ERROR', "rsync failed for {$dir} (exit code {$exitCode})");
|
||||||
foreach ($output as $line) {
|
foreach ($output as $line) {
|
||||||
$this->log('ERROR', " {$line}");
|
$this->log('ERROR', " {$line}");
|
||||||
}
|
}
|
||||||
$failed++;
|
$failed++;
|
||||||
} else {
|
} else {
|
||||||
if ($this->verbose) {
|
if ($this->verbose) {
|
||||||
foreach ($output as $line) {
|
foreach ($output as $line) {
|
||||||
$this->log('INFO', " {$line}");
|
$this->log('INFO', " {$line}");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($failed > 0) {
|
if ($failed > 0) {
|
||||||
$this->log('ERROR', "Rollback completed with {$failed} error(s).");
|
$this->log('ERROR', "Rollback completed with {$failed} error(s).");
|
||||||
return 1;
|
return 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
$this->log('INFO', 'Rollback completed successfully.');
|
$this->log('INFO', 'Rollback completed successfully.');
|
||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
private function loadConfig(string $path): ?array
|
private function loadConfig(string $path): ?array
|
||||||
{
|
{
|
||||||
if (!is_file($path)) {
|
if (!is_file($path)) {
|
||||||
$this->log('ERROR', "Config file not found: {$path}");
|
$this->log('ERROR', "Config file not found: {$path}");
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
$raw = file_get_contents($path);
|
$raw = file_get_contents($path);
|
||||||
if ($raw === false) {
|
if ($raw === false) {
|
||||||
$this->log('ERROR', "Could not read config file: {$path}");
|
$this->log('ERROR', "Could not read config file: {$path}");
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Strip // comments (sftp-config.json style)
|
// Strip // comments (sftp-config.json style)
|
||||||
$cleaned = preg_replace('#^\s*//.*$#m', '', $raw);
|
$cleaned = preg_replace('#^\s*//.*$#m', '', $raw);
|
||||||
$config = json_decode($cleaned, true);
|
$config = json_decode($cleaned, true);
|
||||||
|
|
||||||
if (!is_array($config)) {
|
if (!is_array($config)) {
|
||||||
$this->log('ERROR', 'Invalid JSON in config file.');
|
$this->log('ERROR', 'Invalid JSON in config file.');
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
return $config;
|
return $config;
|
||||||
}
|
}
|
||||||
|
|
||||||
private function buildRsyncCommand(string $sshCmd, string $source, string $dest): string
|
private function buildRsyncCommand(string $sshCmd, string $source, string $dest): string
|
||||||
{
|
{
|
||||||
$parts = ['rsync', '-rlptz', '--delete', '--exclude=configuration.php'];
|
$parts = ['rsync', '-rlptz', '--delete', '--exclude=configuration.php'];
|
||||||
|
|
||||||
if ($this->dryRun) {
|
if ($this->dryRun) {
|
||||||
$parts[] = '--dry-run';
|
$parts[] = '--dry-run';
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($this->verbose) {
|
if ($this->verbose) {
|
||||||
$parts[] = '-v';
|
$parts[] = '-v';
|
||||||
}
|
}
|
||||||
|
|
||||||
$parts[] = '-e';
|
$parts[] = '-e';
|
||||||
$parts[] = escapeshellarg($sshCmd);
|
$parts[] = escapeshellarg($sshCmd);
|
||||||
$parts[] = escapeshellarg($source);
|
$parts[] = escapeshellarg($source);
|
||||||
$parts[] = escapeshellarg($dest);
|
$parts[] = escapeshellarg($dest);
|
||||||
|
|
||||||
return implode(' ', $parts);
|
return implode(' ', $parts);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
$app = new RollbackJoomlaCli();
|
$app = new RollbackJoomlaCli();
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
#!/usr/bin/env php
|
#!/usr/bin/env php
|
||||||
<?php
|
<?php
|
||||||
|
|
||||||
/* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
|
/* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
|
||||||
*
|
*
|
||||||
* This file is part of a Moko Consulting project.
|
* This file is part of a Moko Consulting project.
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
#!/usr/bin/env php
|
#!/usr/bin/env php
|
||||||
<?php
|
<?php
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
|
* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
|
||||||
*
|
*
|
||||||
@@ -136,11 +137,13 @@ class PinActionShasCli extends CliFramework
|
|||||||
*/
|
*/
|
||||||
private function processLine(string $line, string $file, int $lineNum): ?string
|
private function processLine(string $line, string $file, int $lineNum): ?string
|
||||||
{
|
{
|
||||||
if (!preg_match(
|
if (
|
||||||
'/^(\s+uses:\s+)([\w.\-]+\/[\w.\-\/]+)@([^\s#]+)((?:\s+#.*)?)$/',
|
!preg_match(
|
||||||
$line,
|
'/^(\s+uses:\s+)([\w.\-]+\/[\w.\-\/]+)@([^\s#]+)((?:\s+#.*)?)$/',
|
||||||
$m
|
$line,
|
||||||
)) {
|
$m
|
||||||
|
)
|
||||||
|
) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+202
-110
@@ -1,5 +1,6 @@
|
|||||||
#!/usr/bin/env php
|
#!/usr/bin/env php
|
||||||
<?php
|
<?php
|
||||||
|
|
||||||
/* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
|
/* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
|
||||||
*
|
*
|
||||||
* This file is part of a Moko Consulting project.
|
* This file is part of a Moko Consulting project.
|
||||||
@@ -22,125 +23,216 @@ use MokoEnterprise\CliFramework;
|
|||||||
|
|
||||||
class RepoInventoryCli extends CliFramework
|
class RepoInventoryCli extends CliFramework
|
||||||
{
|
{
|
||||||
private $api = null;
|
private $api = null;
|
||||||
private string $token = '';
|
private string $token = '';
|
||||||
private $platformConfig = null;
|
private $platformConfig = null;
|
||||||
private const ALWAYS_EXCLUDE = ['moko-platform', '.github-private'];
|
private const ALWAYS_EXCLUDE = ['moko-platform', '.github-private'];
|
||||||
|
|
||||||
protected function configure(): void
|
protected function configure(): void
|
||||||
{
|
{
|
||||||
$this->setDescription('Generate a live inventory dashboard of all governed repos');
|
$this->setDescription('Generate a live inventory dashboard of all governed repos');
|
||||||
$this->addArgument('--org', 'Organization', 'mokoconsulting-tech');
|
$this->addArgument('--org', 'Organization', 'mokoconsulting-tech');
|
||||||
$this->addArgument('--json', 'JSON output to stdout', false);
|
$this->addArgument('--json', 'JSON output to stdout', false);
|
||||||
}
|
}
|
||||||
|
|
||||||
protected function initialize(): void
|
protected function initialize(): void
|
||||||
{
|
{
|
||||||
$this->platformConfig = \MokoEnterprise\Config::load();
|
$this->platformConfig = \MokoEnterprise\Config::load();
|
||||||
try {
|
try {
|
||||||
$adapter = \MokoEnterprise\PlatformAdapterFactory::create($this->platformConfig);
|
$adapter = \MokoEnterprise\PlatformAdapterFactory::create($this->platformConfig);
|
||||||
$this->api = $adapter->getApiClient();
|
$this->api = $adapter->getApiClient();
|
||||||
} catch (\Exception $e) {
|
} catch (\Exception $e) {
|
||||||
$this->log('ERROR', "Platform init failed: " . $e->getMessage());
|
$this->log('ERROR', "Platform init failed: " . $e->getMessage());
|
||||||
exit(1);
|
exit(1);
|
||||||
}
|
}
|
||||||
$this->token = $this->platformConfig->getString('platform', 'gitea') === 'gitea'
|
$this->token = $this->platformConfig->getString('platform', 'gitea') === 'gitea'
|
||||||
? $this->platformConfig->getString('gitea.token', '') : $this->platformConfig->getString('github.token', '');
|
? $this->platformConfig->getString('gitea.token', '') : $this->platformConfig->getString('github.token', '');
|
||||||
}
|
}
|
||||||
|
|
||||||
protected function run(): int
|
protected function run(): int
|
||||||
{
|
{
|
||||||
$org = $this->getArgument('--org');
|
$org = $this->getArgument('--org');
|
||||||
$jsonOut = (bool) $this->getArgument('--json');
|
$jsonOut = (bool) $this->getArgument('--json');
|
||||||
if (!$jsonOut) { echo "Fetching repositories from {$org}...\n"; }
|
if (!$jsonOut) {
|
||||||
$allRepos = []; $page = 1;
|
echo "Fetching repositories from {$org}...\n";
|
||||||
do {
|
}
|
||||||
[$_, $batch] = $this->ghApi('GET', "orgs/{$org}/repos?per_page=100&page={$page}&type=all&sort=full_name", null);
|
$allRepos = [];
|
||||||
$allRepos = array_merge($allRepos, $batch); $page++;
|
$page = 1;
|
||||||
} while (count($batch) === 100);
|
do {
|
||||||
if (!$jsonOut) { echo "Found " . count($allRepos) . " total repositories\n\n"; }
|
[$_, $batch] = $this->ghApi('GET', "orgs/{$org}/repos?per_page=100&page={$page}&type=all&sort=full_name", null);
|
||||||
|
$allRepos = array_merge($allRepos, $batch);
|
||||||
|
$page++;
|
||||||
|
} while (count($batch) === 100);
|
||||||
|
if (!$jsonOut) {
|
||||||
|
echo "Found " . count($allRepos) . " total repositories\n\n";
|
||||||
|
}
|
||||||
|
|
||||||
$inventory = [];
|
$inventory = [];
|
||||||
foreach ($allRepos as $repo) {
|
foreach ($allRepos as $repo) {
|
||||||
$name = $repo['name'];
|
$name = $repo['name'];
|
||||||
if (in_array($name, self::ALWAYS_EXCLUDE, true)) { continue; }
|
if (in_array($name, self::ALWAYS_EXCLUDE, true)) {
|
||||||
$entry = ['name' => $name, 'visibility' => $repo['private'] ? 'private' : 'public', 'archived' => $repo['archived'] ?? false, 'platform' => '-', 'version' => '-', 'last_push' => $repo['pushed_at'] ?? '-', 'open_issues' => $repo['open_issues_count'] ?? 0, 'has_project' => false, 'rulesets' => 0];
|
continue;
|
||||||
if ($entry['archived']) { $inventory[] = $entry; continue; }
|
}
|
||||||
foreach (['.github/.mokostandards', '.mokostandards'] as $path) {
|
$entry = [
|
||||||
[$status, $data] = $this->ghApi('GET', "repos/{$org}/{$name}/contents/{$path}", null);
|
'name' => $name,
|
||||||
if ($status === 200 && !empty($data['content'])) {
|
'visibility' => $repo['private'] ? 'private' : 'public',
|
||||||
$content = base64_decode($data['content']);
|
'archived' => $repo['archived'] ?? false,
|
||||||
if (preg_match('/^platform:\s*(.+)/m', $content, $m)) { $entry['platform'] = trim($m[1], " \t\n\r\"'"); }
|
'platform' => '-',
|
||||||
if (preg_match('/^version:\s*(.+)/m', $content, $m)) { $entry['version'] = trim($m[1], " \t\n\r\"'"); }
|
'version' => '-',
|
||||||
break;
|
'last_push' => $repo['pushed_at'] ?? '-',
|
||||||
}
|
'open_issues' => $repo['open_issues_count'] ?? 0,
|
||||||
}
|
'has_project' => false,
|
||||||
[$status, $rulesets] = $this->ghApi('GET', "repos/{$org}/{$name}/rulesets?per_page=100&includes_parents=true", null);
|
'rulesets' => 0,
|
||||||
if ($status === 200 && is_array($rulesets)) { $entry['rulesets'] = count($rulesets); }
|
];
|
||||||
$gql = $this->graphql('query($owner:String!,$name:String!){repository(owner:$owner,name:$name){projectsV2(first:1){totalCount}}}', ['owner' => $org, 'name' => $name]);
|
if ($entry['archived']) {
|
||||||
$entry['has_project'] = ($gql['repository']['projectsV2']['totalCount'] ?? 0) > 0;
|
$inventory[] = $entry;
|
||||||
$inventory[] = $entry;
|
continue;
|
||||||
if (!$jsonOut) { echo " {$name}: {$entry['platform']} | v{$entry['version']} | rulesets:{$entry['rulesets']} | project:" . ($entry['has_project'] ? 'yes' : 'no') . "\n"; }
|
}
|
||||||
}
|
foreach (['.github/.mokostandards', '.mokostandards'] as $path) {
|
||||||
|
[$status, $data] = $this->ghApi('GET', "repos/{$org}/{$name}/contents/{$path}", null);
|
||||||
|
if ($status === 200 && !empty($data['content'])) {
|
||||||
|
$content = base64_decode($data['content']);
|
||||||
|
if (preg_match('/^platform:\s*(.+)/m', $content, $m)) {
|
||||||
|
$entry['platform'] = trim($m[1], " \t\n\r\"'");
|
||||||
|
}
|
||||||
|
if (preg_match('/^version:\s*(.+)/m', $content, $m)) {
|
||||||
|
$entry['version'] = trim($m[1], " \t\n\r\"'");
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
[$status, $rulesets] = $this->ghApi('GET', "repos/{$org}/{$name}/rulesets?per_page=100&includes_parents=true", null);
|
||||||
|
if ($status === 200 && is_array($rulesets)) {
|
||||||
|
$entry['rulesets'] = count($rulesets);
|
||||||
|
}
|
||||||
|
$gql = $this->graphql(
|
||||||
|
'query($owner:String!,$name:String!)'
|
||||||
|
. '{repository(owner:$owner,name:$name)'
|
||||||
|
. '{projectsV2(first:1){totalCount}}}',
|
||||||
|
['owner' => $org, 'name' => $name]
|
||||||
|
);
|
||||||
|
$entry['has_project'] = ($gql['repository']['projectsV2']['totalCount'] ?? 0) > 0;
|
||||||
|
$inventory[] = $entry;
|
||||||
|
if (!$jsonOut) {
|
||||||
|
$proj = $entry['has_project'] ? 'yes' : 'no';
|
||||||
|
echo " {$name}: {$entry['platform']}"
|
||||||
|
. " | v{$entry['version']}"
|
||||||
|
. " | rulesets:{$entry['rulesets']}"
|
||||||
|
. " | project:{$proj}\n";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if ($jsonOut) { echo json_encode($inventory, JSON_PRETTY_PRINT) . "\n"; return 0; }
|
if ($jsonOut) {
|
||||||
|
echo json_encode($inventory, JSON_PRETTY_PRINT) . "\n";
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
$now = gmdate('Y-m-d H:i:s') . ' UTC';
|
$now = gmdate('Y-m-d H:i:s') . ' UTC';
|
||||||
$active = array_filter($inventory, fn($r) => !$r['archived']);
|
$active = array_filter($inventory, fn($r) => !$r['archived']);
|
||||||
$archived = array_filter($inventory, fn($r) => $r['archived']);
|
$archived = array_filter($inventory, fn($r) => $r['archived']);
|
||||||
$withRules = count(array_filter($active, fn($r) => $r['rulesets'] >= 3));
|
$withRules = count(array_filter($active, fn($r) => $r['rulesets'] >= 3));
|
||||||
$withProj = count(array_filter($active, fn($r) => $r['has_project']));
|
$withProj = count(array_filter($active, fn($r) => $r['has_project']));
|
||||||
$activeN = count($active); $archivedN = count($archived);
|
$activeN = count($active);
|
||||||
$rows = [];
|
$archivedN = count($archived);
|
||||||
foreach ($inventory as $r) {
|
$rows = [];
|
||||||
$vis = $r['visibility'] === 'private' ? 'prv' : 'pub';
|
foreach ($inventory as $r) {
|
||||||
$arch = $r['archived'] ? ' archived' : '';
|
$vis = $r['visibility'] === 'private' ? 'prv' : 'pub';
|
||||||
$proj = $r['has_project'] ? 'yes' : '-';
|
$arch = $r['archived'] ? ' archived' : '';
|
||||||
$rs = $r['archived'] ? '-' : ($r['rulesets'] >= 3 ? '3/3' : "{$r['rulesets']}/3");
|
$proj = $r['has_project'] ? 'yes' : '-';
|
||||||
$rows[] = "| `{$r['name']}` | {$vis}{$arch} | {$r['platform']} | {$r['version']} | {$rs} | {$proj} | {$r['open_issues']} |";
|
$rs = $r['archived'] ? '-' : ($r['rulesets'] >= 3 ? '3/3' : "{$r['rulesets']}/3");
|
||||||
}
|
$rows[] = "| `{$r['name']}` | {$vis}{$arch} | {$r['platform']} | {$r['version']} | {$rs} | {$proj} | {$r['open_issues']} |";
|
||||||
$table = implode("\n", $rows);
|
}
|
||||||
$body = "## Repository Inventory Dashboard\n\n**Organisation:** `{$org}`\n**Generated:** {$now}\n**Active:** {$activeN} | **Archived:** {$archivedN} | **Rulesets 3/3:** {$withRules} | **Projects:** {$withProj}\n\n| Repository | Visibility | Platform | Version | Rulesets | Project | Issues |\n|---|---|---|---|---|---|---|\n{$table}\n\n---\n*Auto-generated by `repo_inventory.php`*\n";
|
$table = implode("\n", $rows);
|
||||||
echo "\n" . str_repeat('-', 50) . "\n";
|
$body = "## Repository Inventory Dashboard\n\n"
|
||||||
echo "Active: {$activeN} | Archived: {$archivedN} | Rulesets 3/3: {$withRules} | Projects: {$withProj}\n";
|
. "**Organisation:** `{$org}`\n"
|
||||||
|
. "**Generated:** {$now}\n"
|
||||||
|
. "**Active:** {$activeN} | **Archived:** {$archivedN}"
|
||||||
|
. " | **Rulesets 3/3:** {$withRules}"
|
||||||
|
. " | **Projects:** {$withProj}\n\n"
|
||||||
|
. "| Repository | Visibility | Platform"
|
||||||
|
. " | Version | Rulesets | Project | Issues |\n"
|
||||||
|
. "|---|---|---|---|---|---|---|\n"
|
||||||
|
. "{$table}\n\n---\n"
|
||||||
|
. "*Auto-generated by `repo_inventory.php`*\n";
|
||||||
|
echo "\n" . str_repeat('-', 50) . "\n";
|
||||||
|
echo "Active: {$activeN} | Archived: {$archivedN}"
|
||||||
|
. " | Rulesets 3/3: {$withRules}"
|
||||||
|
. " | Projects: {$withProj}\n";
|
||||||
|
|
||||||
if (!$this->dryRun) {
|
if (!$this->dryRun) {
|
||||||
$title = "dashboard: repository inventory ({$org})";
|
$title = "dashboard: repository inventory ({$org})";
|
||||||
[$_, $existing] = $this->ghApi('GET', "repos/{$org}/moko-platform/issues?labels=inventory&state=all&per_page=1&sort=created&direction=desc", null);
|
$issueQuery = "repos/{$org}/moko-platform/issues"
|
||||||
if (!empty($existing[0]['number'])) {
|
. "?labels=inventory&state=all&per_page=1"
|
||||||
$num = $existing[0]['number'];
|
. "&sort=created&direction=desc";
|
||||||
$this->ghApi('PATCH', "repos/{$org}/moko-platform/issues/{$num}", ['title' => $title, 'body' => $body, 'state' => 'open', 'assignees' => ['jmiller']]);
|
[$_, $existing] = $this->ghApi('GET', $issueQuery, null);
|
||||||
echo "Updated inventory issue #{$num}\n";
|
if (!empty($existing[0]['number'])) {
|
||||||
} else {
|
$num = $existing[0]['number'];
|
||||||
[$_, $issue] = $this->ghApi('POST', "repos/{$org}/moko-platform/issues", ['title' => $title, 'body' => $body, 'labels' => ['inventory', 'type: chore', 'automation'], 'assignees' => ['jmiller']]);
|
$this->ghApi(
|
||||||
echo "Created inventory issue #{$issue['number']}\n";
|
'PATCH',
|
||||||
}
|
"repos/{$org}/moko-platform/issues/{$num}",
|
||||||
} else { echo "(dry-run) would post inventory dashboard issue\n"; }
|
[
|
||||||
return 0;
|
'title' => $title,
|
||||||
}
|
'body' => $body,
|
||||||
|
'state' => 'open',
|
||||||
|
'assignees' => ['jmiller'],
|
||||||
|
]
|
||||||
|
);
|
||||||
|
echo "Updated inventory issue #{$num}\n";
|
||||||
|
} else {
|
||||||
|
[$_, $issue] = $this->ghApi(
|
||||||
|
'POST',
|
||||||
|
"repos/{$org}/moko-platform/issues",
|
||||||
|
[
|
||||||
|
'title' => $title,
|
||||||
|
'body' => $body,
|
||||||
|
'labels' => ['inventory', 'type: chore', 'automation'],
|
||||||
|
'assignees' => ['jmiller'],
|
||||||
|
]
|
||||||
|
);
|
||||||
|
echo "Created inventory issue #{$issue['number']}\n";
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
echo "(dry-run) would post inventory dashboard issue\n";
|
||||||
|
}
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
private function ghApi(string $method, string $path, ?array $body): array
|
private function ghApi(string $method, string $path, ?array $body): array
|
||||||
{
|
{
|
||||||
try {
|
try {
|
||||||
$result = match ($method) {
|
$result = match ($method) {
|
||||||
'GET' => $this->api->get("/{$path}"), 'POST' => $this->api->post("/{$path}", $body ?? []),
|
'GET' => $this->api->get("/{$path}"), 'POST' => $this->api->post("/{$path}", $body ?? []),
|
||||||
'PATCH' => $this->api->patch("/{$path}", $body ?? []), 'PUT' => $this->api->put("/{$path}", $body ?? []),
|
'PATCH' => $this->api->patch("/{$path}", $body ?? []), 'PUT' => $this->api->put("/{$path}", $body ?? []),
|
||||||
'DELETE' => $this->api->delete("/{$path}"), default => throw new \RuntimeException("Unsupported: {$method}"),
|
'DELETE' => $this->api->delete("/{$path}"), default => throw new \RuntimeException("Unsupported: {$method}"),
|
||||||
};
|
};
|
||||||
return [200, $result];
|
return [200, $result];
|
||||||
} catch (\Exception $e) { return [500, ['message' => $e->getMessage()]]; }
|
} catch (\Exception $e) {
|
||||||
}
|
return [500, ['message' => $e->getMessage()]];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private function graphql(string $query, array $variables): array
|
private function graphql(string $query, array $variables): array
|
||||||
{
|
{
|
||||||
$pf = $this->platformConfig !== null ? $this->platformConfig->getString('platform', 'gitea') : 'gitea';
|
$pf = $this->platformConfig !== null ? $this->platformConfig->getString('platform', 'gitea') : 'gitea';
|
||||||
if ($pf !== 'github') { return []; }
|
if ($pf !== 'github') {
|
||||||
$payload = json_encode(['query' => $query, 'variables' => $variables]);
|
return [];
|
||||||
$ch = curl_init('https://api.github.com/graphql');
|
}
|
||||||
curl_setopt_array($ch, [CURLOPT_RETURNTRANSFER => true, CURLOPT_POST => true, CURLOPT_POSTFIELDS => $payload, CURLOPT_HTTPHEADER => ['Authorization: bearer ' . $this->token, 'Content-Type: application/json', 'User-Agent: moko-platform-Inventory']]);
|
$payload = json_encode(['query' => $query, 'variables' => $variables]);
|
||||||
$body = (string) curl_exec($ch); curl_close($ch);
|
$ch = curl_init('https://api.github.com/graphql');
|
||||||
return json_decode($body, true)['data'] ?? [];
|
curl_setopt_array($ch, [
|
||||||
}
|
CURLOPT_RETURNTRANSFER => true,
|
||||||
|
CURLOPT_POST => true,
|
||||||
|
CURLOPT_POSTFIELDS => $payload,
|
||||||
|
CURLOPT_HTTPHEADER => [
|
||||||
|
'Authorization: bearer ' . $this->token,
|
||||||
|
'Content-Type: application/json',
|
||||||
|
'User-Agent: moko-platform-Inventory',
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
$body = (string) curl_exec($ch);
|
||||||
|
curl_close($ch);
|
||||||
|
return json_decode($body, true)['data'] ?? [];
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
$app = new RepoInventoryCli();
|
$app = new RepoInventoryCli();
|
||||||
|
|||||||
+224
-145
@@ -1,5 +1,6 @@
|
|||||||
#!/usr/bin/env php
|
#!/usr/bin/env php
|
||||||
<?php
|
<?php
|
||||||
|
|
||||||
/* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
|
/* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
|
||||||
*
|
*
|
||||||
* This file is part of a Moko Consulting project.
|
* This file is part of a Moko Consulting project.
|
||||||
@@ -22,164 +23,242 @@ use MokoEnterprise\CliFramework;
|
|||||||
|
|
||||||
class RotateSecretsCli extends CliFramework
|
class RotateSecretsCli extends CliFramework
|
||||||
{
|
{
|
||||||
private $api = null;
|
private $api = null;
|
||||||
private string $token = '';
|
private string $token = '';
|
||||||
private const ALWAYS_EXCLUDE = ['moko-platform', '.github-private'];
|
private const ALWAYS_EXCLUDE = ['moko-platform', '.github-private'];
|
||||||
private const ENVS = [
|
private const ENVS = [
|
||||||
'DEV' => ['vars' => ['DEV_FTP_HOST', 'DEV_FTP_PATH', 'DEV_FTP_USERNAME', 'DEV_FTP_SUFFIX'], 'secrets' => ['DEV_FTP_KEY', 'DEV_FTP_PASSWORD']],
|
'DEV' => [
|
||||||
'DEMO' => ['vars' => ['DEMO_FTP_HOST', 'DEMO_FTP_PATH', 'DEMO_FTP_USERNAME', 'DEMO_FTP_SUFFIX'], 'secrets' => ['DEMO_FTP_KEY', 'DEMO_FTP_PASSWORD']],
|
'vars' => ['DEV_FTP_HOST', 'DEV_FTP_PATH', 'DEV_FTP_USERNAME', 'DEV_FTP_SUFFIX'],
|
||||||
'RS' => ['vars' => ['RS_FTP_HOST', 'RS_FTP_PATH', 'RS_FTP_USERNAME', 'RS_FTP_SUFFIX'], 'secrets' => ['RS_FTP_KEY', 'RS_FTP_PASSWORD']],
|
'secrets' => ['DEV_FTP_KEY', 'DEV_FTP_PASSWORD'],
|
||||||
];
|
],
|
||||||
|
'DEMO' => [
|
||||||
|
'vars' => ['DEMO_FTP_HOST', 'DEMO_FTP_PATH', 'DEMO_FTP_USERNAME', 'DEMO_FTP_SUFFIX'],
|
||||||
|
'secrets' => ['DEMO_FTP_KEY', 'DEMO_FTP_PASSWORD'],
|
||||||
|
],
|
||||||
|
'RS' => [
|
||||||
|
'vars' => ['RS_FTP_HOST', 'RS_FTP_PATH', 'RS_FTP_USERNAME', 'RS_FTP_SUFFIX'],
|
||||||
|
'secrets' => ['RS_FTP_KEY', 'RS_FTP_PASSWORD'],
|
||||||
|
],
|
||||||
|
];
|
||||||
|
|
||||||
protected function configure(): void
|
protected function configure(): void
|
||||||
{
|
{
|
||||||
$this->setDescription('Audit FTP secrets and variables across all governed repos');
|
$this->setDescription('Audit FTP secrets and variables across all governed repos');
|
||||||
$this->addArgument('--all', 'Audit all repos', false);
|
$this->addArgument('--all', 'Audit all repos', false);
|
||||||
$this->addArgument('--repo', 'Single repo name', null);
|
$this->addArgument('--repo', 'Single repo name', null);
|
||||||
$this->addArgument('--org', 'Organization', 'mokoconsulting-tech');
|
$this->addArgument('--org', 'Organization', 'mokoconsulting-tech');
|
||||||
$this->addArgument('--json', 'JSON output', false);
|
$this->addArgument('--json', 'JSON output', false);
|
||||||
$this->addArgument('--create-issue', 'Post results as issue', false);
|
$this->addArgument('--create-issue', 'Post results as issue', false);
|
||||||
}
|
}
|
||||||
|
|
||||||
protected function initialize(): void
|
protected function initialize(): void
|
||||||
{
|
{
|
||||||
$config = \MokoEnterprise\Config::load();
|
$config = \MokoEnterprise\Config::load();
|
||||||
try {
|
try {
|
||||||
$adapter = \MokoEnterprise\PlatformAdapterFactory::create($config);
|
$adapter = \MokoEnterprise\PlatformAdapterFactory::create($config);
|
||||||
$this->api = $adapter->getApiClient();
|
$this->api = $adapter->getApiClient();
|
||||||
} catch (\Exception $e) {
|
} catch (\Exception $e) {
|
||||||
$this->log('ERROR', "Platform init failed: " . $e->getMessage());
|
$this->log('ERROR', "Platform init failed: " . $e->getMessage());
|
||||||
exit(1);
|
exit(1);
|
||||||
}
|
}
|
||||||
$this->token = $config->getString('platform', 'gitea') === 'gitea'
|
$this->token = $config->getString('platform', 'gitea') === 'gitea'
|
||||||
? $config->getString('gitea.token', '')
|
? $config->getString('gitea.token', '')
|
||||||
: $config->getString('github.token', '');
|
: $config->getString('github.token', '');
|
||||||
}
|
}
|
||||||
|
|
||||||
protected function run(): int
|
protected function run(): int
|
||||||
{
|
{
|
||||||
$allMode = (bool) $this->getArgument('--all');
|
$allMode = (bool) $this->getArgument('--all');
|
||||||
$jsonOut = (bool) $this->getArgument('--json');
|
$jsonOut = (bool) $this->getArgument('--json');
|
||||||
$createIssue = (bool) $this->getArgument('--create-issue');
|
$createIssue = (bool) $this->getArgument('--create-issue');
|
||||||
$org = $this->getArgument('--org');
|
$org = $this->getArgument('--org');
|
||||||
$repoName = $this->getArgument('--repo');
|
$repoName = $this->getArgument('--repo');
|
||||||
|
|
||||||
if (!$repoName && !$allMode) {
|
if (!$repoName && !$allMode) {
|
||||||
$this->log('ERROR', "Usage: php rotate_secrets.php --all | --repo <name> [--json] [--create-issue]");
|
$this->log('ERROR', "Usage: php rotate_secrets.php --all | --repo <name> [--json] [--create-issue]");
|
||||||
return 2;
|
return 2;
|
||||||
}
|
}
|
||||||
|
|
||||||
$repos = [];
|
$repos = [];
|
||||||
if ($allMode) {
|
if ($allMode) {
|
||||||
if (!$jsonOut) { echo "Fetching repositories from {$org}...\n"; }
|
if (!$jsonOut) {
|
||||||
$page = 1;
|
echo "Fetching repositories from {$org}...\n";
|
||||||
do {
|
}
|
||||||
[$_, $batch] = $this->ghApi('GET', "orgs/{$org}/repos?per_page=100&page={$page}&type=all", null);
|
$page = 1;
|
||||||
foreach ($batch as $r) {
|
do {
|
||||||
if (!($r['archived'] ?? false) && !in_array($r['name'], self::ALWAYS_EXCLUDE, true)) {
|
[$_, $batch] = $this->ghApi('GET', "orgs/{$org}/repos?per_page=100&page={$page}&type=all", null);
|
||||||
$repos[] = $r['name'];
|
foreach ($batch as $r) {
|
||||||
}
|
if (!($r['archived'] ?? false) && !in_array($r['name'], self::ALWAYS_EXCLUDE, true)) {
|
||||||
}
|
$repos[] = $r['name'];
|
||||||
$page++;
|
}
|
||||||
} while (count($batch) === 100);
|
}
|
||||||
sort($repos);
|
$page++;
|
||||||
if (!$jsonOut) { echo "Found " . count($repos) . " repositories\n\n"; }
|
} while (count($batch) === 100);
|
||||||
} else {
|
sort($repos);
|
||||||
$repos = [$repoName];
|
if (!$jsonOut) {
|
||||||
}
|
echo "Found " . count($repos) . " repositories\n\n";
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
$repos = [$repoName];
|
||||||
|
}
|
||||||
|
|
||||||
$results = [];
|
$results = [];
|
||||||
$issueCount = 0;
|
$issueCount = 0;
|
||||||
|
|
||||||
foreach ($repos as $repo) {
|
foreach ($repos as $repo) {
|
||||||
$fullRepo = "{$org}/{$repo}";
|
$fullRepo = "{$org}/{$repo}";
|
||||||
$repoVars = $this->listNames("repos/{$fullRepo}/actions/variables", 'variables');
|
$repoVars = $this->listNames("repos/{$fullRepo}/actions/variables", 'variables');
|
||||||
$repoSecrets = $this->listNames("repos/{$fullRepo}/actions/secrets", 'secrets');
|
$repoSecrets = $this->listNames("repos/{$fullRepo}/actions/secrets", 'secrets');
|
||||||
$result = ['repo' => $repo, 'envs' => [], 'missing' => []];
|
$result = ['repo' => $repo, 'envs' => [], 'missing' => []];
|
||||||
|
|
||||||
foreach (self::ENVS as $env => $envConfig) {
|
foreach (self::ENVS as $env => $envConfig) {
|
||||||
$missingVars = array_diff($envConfig['vars'], $repoVars);
|
$missingVars = array_diff($envConfig['vars'], $repoVars);
|
||||||
$hasAuth = !empty(array_intersect($envConfig['secrets'], $repoSecrets));
|
$hasAuth = !empty(array_intersect($envConfig['secrets'], $repoSecrets));
|
||||||
$hostVar = "{$env}_FTP_HOST";
|
$hostVar = "{$env}_FTP_HOST";
|
||||||
$configured = in_array($hostVar, $repoVars, true);
|
$configured = in_array($hostVar, $repoVars, true);
|
||||||
$result['envs'][$env] = ['configured' => $configured, 'missing_vars' => array_values($missingVars), 'has_auth' => $hasAuth];
|
$result['envs'][$env] = ['configured' => $configured, 'missing_vars' => array_values($missingVars), 'has_auth' => $hasAuth];
|
||||||
if ($configured) {
|
if ($configured) {
|
||||||
foreach ($missingVars as $v) {
|
foreach ($missingVars as $v) {
|
||||||
if ($v !== "{$env}_FTP_SUFFIX") { $result['missing'][] = "{$env}: missing {$v}"; $issueCount++; }
|
if ($v !== "{$env}_FTP_SUFFIX") {
|
||||||
}
|
$result['missing'][] = "{$env}: missing {$v}";
|
||||||
if (!$hasAuth) { $result['missing'][] = "{$env}: no auth key/password"; $issueCount++; }
|
$issueCount++;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if (!$hasAuth) {
|
||||||
|
$result['missing'][] = "{$env}: no auth key/password";
|
||||||
|
$issueCount++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (!$jsonOut) {
|
if (!$jsonOut) {
|
||||||
$parts = [];
|
$parts = [];
|
||||||
foreach (self::ENVS as $env => $_) {
|
foreach (self::ENVS as $env => $_) {
|
||||||
$e = $result['envs'][$env];
|
$e = $result['envs'][$env];
|
||||||
if ($e['configured'] && $e['has_auth'] && empty($e['missing_vars'])) { $parts[] = "{$env}:OK"; }
|
if ($e['configured'] && $e['has_auth'] && empty($e['missing_vars'])) {
|
||||||
elseif ($e['configured']) { $parts[] = "{$env}:INCOMPLETE"; }
|
$parts[] = "{$env}:OK";
|
||||||
else { $parts[] = "{$env}:--"; }
|
} elseif ($e['configured']) {
|
||||||
}
|
$parts[] = "{$env}:INCOMPLETE";
|
||||||
echo "{$repo}: " . implode(' | ', $parts) . (empty($result['missing']) ? '' : ' [' . implode('; ', $result['missing']) . ']') . "\n";
|
} else {
|
||||||
}
|
$parts[] = "{$env}:--";
|
||||||
$results[] = $result;
|
}
|
||||||
}
|
}
|
||||||
|
echo "{$repo}: " . implode(' | ', $parts) . (empty($result['missing']) ? '' : ' [' . implode('; ', $result['missing']) . ']') . "\n";
|
||||||
|
}
|
||||||
|
$results[] = $result;
|
||||||
|
}
|
||||||
|
|
||||||
if ($jsonOut) {
|
if ($jsonOut) {
|
||||||
echo json_encode($results, JSON_PRETTY_PRINT) . "\n";
|
echo json_encode($results, JSON_PRETTY_PRINT) . "\n";
|
||||||
} else {
|
} else {
|
||||||
echo "\n" . str_repeat('-', 50) . "\n";
|
echo "\n" . str_repeat('-', 50) . "\n";
|
||||||
$total = count($results);
|
$total = count($results);
|
||||||
$devReady = count(array_filter($results, fn($r) => ($r['envs']['DEV']['configured'] ?? false) && ($r['envs']['DEV']['has_auth'] ?? false)));
|
$devReady = count(array_filter(
|
||||||
$demoReady = count(array_filter($results, fn($r) => ($r['envs']['DEMO']['configured'] ?? false) && ($r['envs']['DEMO']['has_auth'] ?? false)));
|
$results,
|
||||||
$rsReady = count(array_filter($results, fn($r) => ($r['envs']['RS']['configured'] ?? false) && ($r['envs']['RS']['has_auth'] ?? false)));
|
fn($r) => ($r['envs']['DEV']['configured'] ?? false)
|
||||||
echo "Total: {$total} | DEV: {$devReady} | DEMO: {$demoReady} | RS: {$rsReady} | Issues: {$issueCount}\n";
|
&& ($r['envs']['DEV']['has_auth'] ?? false)
|
||||||
}
|
));
|
||||||
|
$demoReady = count(array_filter(
|
||||||
|
$results,
|
||||||
|
fn($r) => ($r['envs']['DEMO']['configured'] ?? false)
|
||||||
|
&& ($r['envs']['DEMO']['has_auth'] ?? false)
|
||||||
|
));
|
||||||
|
$rsReady = count(array_filter(
|
||||||
|
$results,
|
||||||
|
fn($r) => ($r['envs']['RS']['configured'] ?? false)
|
||||||
|
&& ($r['envs']['RS']['has_auth'] ?? false)
|
||||||
|
));
|
||||||
|
echo "Total: {$total} | DEV: {$devReady} | DEMO: {$demoReady} | RS: {$rsReady} | Issues: {$issueCount}\n";
|
||||||
|
}
|
||||||
|
|
||||||
if ($createIssue && $issueCount > 0) {
|
if ($createIssue && $issueCount > 0) {
|
||||||
$now = gmdate('Y-m-d H:i:s') . ' UTC';
|
$now = gmdate('Y-m-d H:i:s') . ' UTC';
|
||||||
$rows = [];
|
$rows = [];
|
||||||
foreach ($results as $r) {
|
foreach ($results as $r) {
|
||||||
foreach ($r['missing'] as $m) { $rows[] = "| `{$r['repo']}` | {$m} |"; }
|
foreach ($r['missing'] as $m) {
|
||||||
}
|
$rows[] = "| `{$r['repo']}` | {$m} |";
|
||||||
$table = implode("\n", $rows);
|
}
|
||||||
$body = "## FTP Secret/Variable Audit\n\n**Date:** {$now}\n**Issues:** {$issueCount}\n\n| Repository | Issue |\n|---|---|\n{$table}\n\n---\n*Auto-created by `rotate_secrets.php`*\n";
|
}
|
||||||
[$_, $existing] = $this->ghApi('GET', "repos/{$org}/moko-platform/issues?labels=secret-audit&state=all&per_page=1&sort=created&direction=desc", null);
|
$table = implode("\n", $rows);
|
||||||
if (!empty($existing[0]['number'])) {
|
$body = "## FTP Secret/Variable Audit\n\n"
|
||||||
$num = $existing[0]['number'];
|
. "**Date:** {$now}\n"
|
||||||
$this->ghApi('PATCH', "repos/{$org}/moko-platform/issues/{$num}", ['title' => "audit: FTP secrets -- {$issueCount} issues", 'body' => $body, 'state' => 'open', 'assignees' => ['jmiller']]);
|
. "**Issues:** {$issueCount}\n\n"
|
||||||
if (!$jsonOut) { echo "Updated audit issue #{$num}\n"; }
|
. "| Repository | Issue |\n|---|---|\n"
|
||||||
} else {
|
. "{$table}\n\n---\n"
|
||||||
[$_, $issue] = $this->ghApi('POST', "repos/{$org}/moko-platform/issues", ['title' => "audit: FTP secrets -- {$issueCount} issues", 'body' => $body, 'labels' => ['secret-audit', 'type: chore', 'automation'], 'assignees' => ['jmiller']]);
|
. "*Auto-created by `rotate_secrets.php`*\n";
|
||||||
if (!$jsonOut) { echo "Created audit issue #{$issue['number']}\n"; }
|
$auditQuery = "repos/{$org}/moko-platform/issues"
|
||||||
}
|
. "?labels=secret-audit&state=all"
|
||||||
}
|
. "&per_page=1&sort=created&direction=desc";
|
||||||
return $issueCount > 0 ? 1 : 0;
|
[$_, $existing] = $this->ghApi('GET', $auditQuery, null);
|
||||||
}
|
$auditTitle = "audit: FTP secrets"
|
||||||
|
. " -- {$issueCount} issues";
|
||||||
|
if (!empty($existing[0]['number'])) {
|
||||||
|
$num = $existing[0]['number'];
|
||||||
|
$this->ghApi(
|
||||||
|
'PATCH',
|
||||||
|
"repos/{$org}/moko-platform/issues/{$num}",
|
||||||
|
[
|
||||||
|
'title' => $auditTitle,
|
||||||
|
'body' => $body,
|
||||||
|
'state' => 'open',
|
||||||
|
'assignees' => ['jmiller'],
|
||||||
|
]
|
||||||
|
);
|
||||||
|
if (!$jsonOut) {
|
||||||
|
echo "Updated audit issue #{$num}\n";
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
[$_, $issue] = $this->ghApi(
|
||||||
|
'POST',
|
||||||
|
"repos/{$org}/moko-platform/issues",
|
||||||
|
[
|
||||||
|
'title' => $auditTitle,
|
||||||
|
'body' => $body,
|
||||||
|
'labels' => ['secret-audit', 'type: chore', 'automation'],
|
||||||
|
'assignees' => ['jmiller'],
|
||||||
|
]
|
||||||
|
);
|
||||||
|
if (!$jsonOut) {
|
||||||
|
echo "Created audit issue #{$issue['number']}\n";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return $issueCount > 0 ? 1 : 0;
|
||||||
|
}
|
||||||
|
|
||||||
private function ghApi(string $method, string $path, ?array $body): array
|
private function ghApi(string $method, string $path, ?array $body): array
|
||||||
{
|
{
|
||||||
try {
|
try {
|
||||||
$result = match ($method) {
|
$result = match ($method) {
|
||||||
'GET' => $this->api->get("/{$path}"), 'POST' => $this->api->post("/{$path}", $body ?? []),
|
'GET' => $this->api->get("/{$path}"), 'POST' => $this->api->post("/{$path}", $body ?? []),
|
||||||
'PATCH' => $this->api->patch("/{$path}", $body ?? []), 'PUT' => $this->api->put("/{$path}", $body ?? []),
|
'PATCH' => $this->api->patch("/{$path}", $body ?? []), 'PUT' => $this->api->put("/{$path}", $body ?? []),
|
||||||
'DELETE' => $this->api->delete("/{$path}"), default => throw new \RuntimeException("Unsupported method: {$method}"),
|
'DELETE' => $this->api->delete("/{$path}"), default => throw new \RuntimeException("Unsupported method: {$method}"),
|
||||||
};
|
};
|
||||||
return [200, $result];
|
return [200, $result];
|
||||||
} catch (\Exception $e) { return [500, ['message' => $e->getMessage()]]; }
|
} catch (\Exception $e) {
|
||||||
}
|
return [500, ['message' => $e->getMessage()]];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private function listNames(string $path, string $key): array
|
private function listNames(string $path, string $key): array
|
||||||
{
|
{
|
||||||
$names = []; $page = 1;
|
$names = [];
|
||||||
do {
|
$page = 1;
|
||||||
[$status, $data] = $this->ghApi('GET', "{$path}?per_page=100&page={$page}", null);
|
do {
|
||||||
if ($status !== 200) { break; }
|
[$status, $data] = $this->ghApi('GET', "{$path}?per_page=100&page={$page}", null);
|
||||||
$items = ($key === '') ? $data : ($data[$key] ?? []);
|
if ($status !== 200) {
|
||||||
foreach ($items as $item) { if (isset($item['name'])) { $names[] = $item['name']; } }
|
break;
|
||||||
$page++;
|
}
|
||||||
} while (count($items) === 100);
|
$items = ($key === '') ? $data : ($data[$key] ?? []);
|
||||||
return $names;
|
foreach ($items as $item) {
|
||||||
}
|
if (isset($item['name'])) {
|
||||||
|
$names[] = $item['name'];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
$page++;
|
||||||
|
} while (count($items) === 100);
|
||||||
|
return $names;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
$app = new RotateSecretsCli();
|
$app = new RotateSecretsCli();
|
||||||
|
|||||||
+189
-188
@@ -1,5 +1,6 @@
|
|||||||
#!/usr/bin/env php
|
#!/usr/bin/env php
|
||||||
<?php
|
<?php
|
||||||
|
|
||||||
/* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
|
/* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
|
||||||
*
|
*
|
||||||
* REQUIRED FILE: This file must be present in all moko-platform-compliant repositories
|
* REQUIRED FILE: This file must be present in all moko-platform-compliant repositories
|
||||||
@@ -33,218 +34,218 @@ use MokoEnterprise\PlatformAdapterFactory;
|
|||||||
*/
|
*/
|
||||||
class SetupLabels extends CliFramework
|
class SetupLabels extends CliFramework
|
||||||
{
|
{
|
||||||
private ?GitPlatformAdapter $adapter = null;
|
private ?GitPlatformAdapter $adapter = null;
|
||||||
/**
|
/**
|
||||||
* Label definitions — [name, hexColor (no #), description].
|
* Label definitions — [name, hexColor (no #), description].
|
||||||
*
|
*
|
||||||
* @var list<array{0: string, 1: string, 2: string}>
|
* @var list<array{0: string, 1: string, 2: string}>
|
||||||
*/
|
*/
|
||||||
private const LABELS = [
|
private const LABELS = [
|
||||||
// Project Type
|
// Project Type
|
||||||
['joomla', '7F52FF', 'Joomla extension or component'],
|
['joomla', '7F52FF', 'Joomla extension or component'],
|
||||||
['dolibarr', 'FF6B6B', 'Dolibarr module or extension'],
|
['dolibarr', 'FF6B6B', 'Dolibarr module or extension'],
|
||||||
['generic', '808080', 'Generic project or library'],
|
['generic', '808080', 'Generic project or library'],
|
||||||
|
|
||||||
// Language
|
// Language
|
||||||
['php', '4F5D95', 'PHP code changes'],
|
['php', '4F5D95', 'PHP code changes'],
|
||||||
['javascript', 'F7DF1E', 'JavaScript code changes'],
|
['javascript', 'F7DF1E', 'JavaScript code changes'],
|
||||||
['typescript', '3178C6', 'TypeScript code changes'],
|
['typescript', '3178C6', 'TypeScript code changes'],
|
||||||
['python', '3776AB', 'Python code changes'],
|
['python', '3776AB', 'Python code changes'],
|
||||||
['css', '1572B6', 'CSS/styling changes'],
|
['css', '1572B6', 'CSS/styling changes'],
|
||||||
['html', 'E34F26', 'HTML template changes'],
|
['html', 'E34F26', 'HTML template changes'],
|
||||||
|
|
||||||
// Component
|
// Component
|
||||||
['documentation', '0075CA', 'Documentation changes'],
|
['documentation', '0075CA', 'Documentation changes'],
|
||||||
['ci-cd', '000000', 'CI/CD pipeline changes'],
|
['ci-cd', '000000', 'CI/CD pipeline changes'],
|
||||||
['docker', '2496ED', 'Docker configuration changes'],
|
['docker', '2496ED', 'Docker configuration changes'],
|
||||||
['tests', '00FF00', 'Test suite changes'],
|
['tests', '00FF00', 'Test suite changes'],
|
||||||
['security', 'FF0000', 'Security-related changes'],
|
['security', 'FF0000', 'Security-related changes'],
|
||||||
['dependencies', '0366D6', 'Dependency updates'],
|
['dependencies', '0366D6', 'Dependency updates'],
|
||||||
['config', 'F9D0C4', 'Configuration file changes'],
|
['config', 'F9D0C4', 'Configuration file changes'],
|
||||||
['build', 'FFA500', 'Build system changes'],
|
['build', 'FFA500', 'Build system changes'],
|
||||||
|
|
||||||
// Workflow / Process
|
// Workflow / Process
|
||||||
['automation', '8B4513', 'Automated processes or scripts'],
|
['automation', '8B4513', 'Automated processes or scripts'],
|
||||||
['moko-platform', 'B60205', 'moko-platform compliance'],
|
['moko-platform', 'B60205', 'moko-platform compliance'],
|
||||||
['needs-review', 'FBCA04', 'Awaiting code review'],
|
['needs-review', 'FBCA04', 'Awaiting code review'],
|
||||||
['work-in-progress', 'D93F0B', 'Work in progress, not ready for merge'],
|
['work-in-progress', 'D93F0B', 'Work in progress, not ready for merge'],
|
||||||
['breaking-change', 'D73A4A', 'Breaking API or functionality change'],
|
['breaking-change', 'D73A4A', 'Breaking API or functionality change'],
|
||||||
|
|
||||||
// Priority
|
// Priority
|
||||||
['priority: critical', 'B60205', 'Critical priority, must be addressed immediately'],
|
['priority: critical', 'B60205', 'Critical priority, must be addressed immediately'],
|
||||||
['priority: high', 'D93F0B', 'High priority'],
|
['priority: high', 'D93F0B', 'High priority'],
|
||||||
['priority: medium', 'FBCA04', 'Medium priority'],
|
['priority: medium', 'FBCA04', 'Medium priority'],
|
||||||
['priority: low', '0E8A16', 'Low priority'],
|
['priority: low', '0E8A16', 'Low priority'],
|
||||||
|
|
||||||
// Type
|
// Type
|
||||||
['type: bug', 'D73A4A', "Something isn't working"],
|
['type: bug', 'D73A4A', "Something isn't working"],
|
||||||
['type: feature', 'A2EEEF', 'New feature or request'],
|
['type: feature', 'A2EEEF', 'New feature or request'],
|
||||||
['type: enhancement', '84B6EB', 'Enhancement to existing feature'],
|
['type: enhancement', '84B6EB', 'Enhancement to existing feature'],
|
||||||
['type: refactor', 'F9D0C4', 'Code refactoring'],
|
['type: refactor', 'F9D0C4', 'Code refactoring'],
|
||||||
['type: chore', 'FEF2C0', 'Maintenance tasks'],
|
['type: chore', 'FEF2C0', 'Maintenance tasks'],
|
||||||
|
|
||||||
// Status
|
// Status
|
||||||
['status: pending', 'FBCA04', 'Pending action or decision'],
|
['status: pending', 'FBCA04', 'Pending action or decision'],
|
||||||
['status: in-progress', '0E8A16', 'Currently being worked on'],
|
['status: in-progress', '0E8A16', 'Currently being worked on'],
|
||||||
['status: blocked', 'B60205', 'Blocked by another issue or dependency'],
|
['status: blocked', 'B60205', 'Blocked by another issue or dependency'],
|
||||||
['status: on-hold', 'D4C5F9', 'Temporarily on hold'],
|
['status: on-hold', 'D4C5F9', 'Temporarily on hold'],
|
||||||
['status: wontfix', 'FFFFFF', 'This will not be worked on'],
|
['status: wontfix', 'FFFFFF', 'This will not be worked on'],
|
||||||
|
|
||||||
// Size
|
// Size
|
||||||
['size/xs', 'C5DEF5', 'Extra small change (1-10 lines)'],
|
['size/xs', 'C5DEF5', 'Extra small change (1-10 lines)'],
|
||||||
['size/s', '6FD1E2', 'Small change (11-30 lines)'],
|
['size/s', '6FD1E2', 'Small change (11-30 lines)'],
|
||||||
['size/m', 'F9DD72', 'Medium change (31-100 lines)'],
|
['size/m', 'F9DD72', 'Medium change (31-100 lines)'],
|
||||||
['size/l', 'FFA07A', 'Large change (101-300 lines)'],
|
['size/l', 'FFA07A', 'Large change (101-300 lines)'],
|
||||||
['size/xl', 'FF6B6B', 'Extra large change (301-1000 lines)'],
|
['size/xl', 'FF6B6B', 'Extra large change (301-1000 lines)'],
|
||||||
['size/xxl', 'B60205', 'Extremely large change (1000+ lines)'],
|
['size/xxl', 'B60205', 'Extremely large change (1000+ lines)'],
|
||||||
|
|
||||||
// Health
|
// Health
|
||||||
['health: excellent', '0E8A16', 'Health score 90-100'],
|
['health: excellent', '0E8A16', 'Health score 90-100'],
|
||||||
['health: good', 'FBCA04', 'Health score 70-89'],
|
['health: good', 'FBCA04', 'Health score 70-89'],
|
||||||
['health: fair', 'FFA500', 'Health score 50-69'],
|
['health: fair', 'FFA500', 'Health score 50-69'],
|
||||||
['health: poor', 'FF6B6B', 'Health score below 50'],
|
['health: poor', 'FF6B6B', 'Health score below 50'],
|
||||||
|
|
||||||
// Sync / Automation
|
// Sync / Automation
|
||||||
['standards-update', 'B60205', 'moko-platform sync update'],
|
['standards-update', 'B60205', 'moko-platform sync update'],
|
||||||
['standards-drift', 'FBCA04', 'Repository drifted from moko-platform'],
|
['standards-drift', 'FBCA04', 'Repository drifted from moko-platform'],
|
||||||
['sync-report', '0075CA', 'Bulk sync run report'],
|
['sync-report', '0075CA', 'Bulk sync run report'],
|
||||||
['sync-failure', 'D73A4A', 'Bulk sync failure requiring attention'],
|
['sync-failure', 'D73A4A', 'Bulk sync failure requiring attention'],
|
||||||
['push-failure', 'D73A4A', 'File push failure requiring attention'],
|
['push-failure', 'D73A4A', 'File push failure requiring attention'],
|
||||||
['health-check', '0E8A16', 'Repository health check results'],
|
['health-check', '0E8A16', 'Repository health check results'],
|
||||||
['version-drift', 'FFA500', 'Version mismatch detected'],
|
['version-drift', 'FFA500', 'Version mismatch detected'],
|
||||||
['deploy-failure', 'CC0000', 'Automated deploy failure tracking'],
|
['deploy-failure', 'CC0000', 'Automated deploy failure tracking'],
|
||||||
['template-validation-failure', 'D73A4A', 'Template workflow validation failure'],
|
['template-validation-failure', 'D73A4A', 'Template workflow validation failure'],
|
||||||
['version', '0E8A16', 'Version bump or release'],
|
['version', '0E8A16', 'Version bump or release'],
|
||||||
['type: version', '0E8A16', 'Version-related change'],
|
['type: version', '0E8A16', 'Version-related change'],
|
||||||
|
|
||||||
// Testing
|
// Testing
|
||||||
['type: test', '00FF00', 'Test suite additions or changes'],
|
['type: test', '00FF00', 'Test suite additions or changes'],
|
||||||
['needs-testing', 'FBCA04', 'Requires manual or automated testing'],
|
['needs-testing', 'FBCA04', 'Requires manual or automated testing'],
|
||||||
['test-failure', 'D73A4A', 'Automated test failure'],
|
['test-failure', 'D73A4A', 'Automated test failure'],
|
||||||
['regression', 'B60205', 'Regression from a previous working state'],
|
['regression', 'B60205', 'Regression from a previous working state'],
|
||||||
|
|
||||||
// Version & Release
|
// Version & Release
|
||||||
['type: release', '0E8A16', 'Release preparation or tracking'],
|
['type: release', '0E8A16', 'Release preparation or tracking'],
|
||||||
['release-candidate', 'BFD4F2', 'Release candidate build'],
|
['release-candidate', 'BFD4F2', 'Release candidate build'],
|
||||||
['minor-release', '0E8A16', 'Minor version release (XX.YY.00)'],
|
['minor-release', '0E8A16', 'Minor version release (XX.YY.00)'],
|
||||||
['patch-release', 'C5DEF5', 'Patch version release (XX.YY.ZZ)'],
|
['patch-release', 'C5DEF5', 'Patch version release (XX.YY.ZZ)'],
|
||||||
['major-release', 'B60205', 'Major version release (breaking changes)'],
|
['major-release', 'B60205', 'Major version release (breaking changes)'],
|
||||||
['version-branch', '1D76DB', 'Version branch related'],
|
['version-branch', '1D76DB', 'Version branch related'],
|
||||||
];
|
];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Configure available arguments.
|
* Configure available arguments.
|
||||||
*/
|
*/
|
||||||
protected function configure(): void
|
protected function configure(): void
|
||||||
{
|
{
|
||||||
$this->setDescription('REQUIRED: Deploy standard labels to repository');
|
$this->setDescription('REQUIRED: Deploy standard labels to repository');
|
||||||
$this->addArgument('--dry-run', 'Show what would be created without actually creating labels', false);
|
$this->addArgument('--dry-run', 'Show what would be created without actually creating labels', false);
|
||||||
$this->addArgument('--org', 'Organization name', 'mokoconsulting-tech');
|
$this->addArgument('--org', 'Organization name', 'mokoconsulting-tech');
|
||||||
$this->addArgument('--repo', 'Repository name (defaults to current repo)', '');
|
$this->addArgument('--repo', 'Repository name (defaults to current repo)', '');
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Run the label deployment.
|
* Run the label deployment.
|
||||||
*
|
*
|
||||||
* @return int Exit code: 0 on success, 1 on error.
|
* @return int Exit code: 0 on success, 1 on error.
|
||||||
*/
|
*/
|
||||||
protected function run(): int
|
protected function run(): int
|
||||||
{
|
{
|
||||||
$dryRun = (bool) $this->getArgument('--dry-run');
|
$dryRun = (bool) $this->getArgument('--dry-run');
|
||||||
|
|
||||||
$config = Config::load();
|
$config = Config::load();
|
||||||
try {
|
try {
|
||||||
$this->adapter = PlatformAdapterFactory::create($config);
|
$this->adapter = PlatformAdapterFactory::create($config);
|
||||||
} catch (\RuntimeException $e) {
|
} catch (\RuntimeException $e) {
|
||||||
$this->log('ERROR', $e->getMessage());
|
$this->log('ERROR', $e->getMessage());
|
||||||
return 1;
|
return 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
$orgArg = (string) $this->getArgument('--org');
|
$orgArg = (string) $this->getArgument('--org');
|
||||||
$repoArg = (string) $this->getArgument('--repo');
|
$repoArg = (string) $this->getArgument('--repo');
|
||||||
$org = $orgArg ?: $config->getString($this->adapter->getPlatformName() . '.organization', 'mokoconsulting-tech');
|
$org = $orgArg ?: $config->getString($this->adapter->getPlatformName() . '.organization', 'mokoconsulting-tech');
|
||||||
$repo = $repoArg ?: basename(getcwd() ?: '.');
|
$repo = $repoArg ?: basename(getcwd() ?: '.');
|
||||||
|
|
||||||
$this->log('INFO', "Setting up labels for repository: {$org}/{$repo} ({$this->adapter->getPlatformName()})");
|
$this->log('INFO', "Setting up labels for repository: {$org}/{$repo} ({$this->adapter->getPlatformName()})");
|
||||||
|
|
||||||
echo "\n";
|
echo "\n";
|
||||||
|
|
||||||
$this->deployGroup('Creating REQUIRED project type labels...', 0, 2, $org, $repo, $dryRun);
|
$this->deployGroup('Creating REQUIRED project type labels...', 0, 2, $org, $repo, $dryRun);
|
||||||
$this->deployGroup('Creating REQUIRED language labels...', 3, 8, $org, $repo, $dryRun);
|
$this->deployGroup('Creating REQUIRED language labels...', 3, 8, $org, $repo, $dryRun);
|
||||||
$this->deployGroup('Creating REQUIRED component labels...', 9, 16, $org, $repo, $dryRun);
|
$this->deployGroup('Creating REQUIRED component labels...', 9, 16, $org, $repo, $dryRun);
|
||||||
$this->deployGroup('Creating REQUIRED workflow labels...', 17, 21, $org, $repo, $dryRun);
|
$this->deployGroup('Creating REQUIRED workflow labels...', 17, 21, $org, $repo, $dryRun);
|
||||||
$this->deployGroup('Creating REQUIRED priority labels...', 22, 25, $org, $repo, $dryRun);
|
$this->deployGroup('Creating REQUIRED priority labels...', 22, 25, $org, $repo, $dryRun);
|
||||||
$this->deployGroup('Creating REQUIRED type labels...', 26, 30, $org, $repo, $dryRun);
|
$this->deployGroup('Creating REQUIRED type labels...', 26, 30, $org, $repo, $dryRun);
|
||||||
$this->deployGroup('Creating REQUIRED status labels...', 31, 35, $org, $repo, $dryRun);
|
$this->deployGroup('Creating REQUIRED status labels...', 31, 35, $org, $repo, $dryRun);
|
||||||
$this->deployGroup('Creating REQUIRED size labels...', 36, 41, $org, $repo, $dryRun);
|
$this->deployGroup('Creating REQUIRED size labels...', 36, 41, $org, $repo, $dryRun);
|
||||||
$this->deployGroup('Creating REQUIRED health labels...', 42, 45, $org, $repo, $dryRun);
|
$this->deployGroup('Creating REQUIRED health labels...', 42, 45, $org, $repo, $dryRun);
|
||||||
$this->deployGroup('Creating REQUIRED sync/automation labels...', 46, 56, $org, $repo, $dryRun);
|
$this->deployGroup('Creating REQUIRED sync/automation labels...', 46, 56, $org, $repo, $dryRun);
|
||||||
$this->deployGroup('Creating REQUIRED testing labels...', 57, 60, $org, $repo, $dryRun);
|
$this->deployGroup('Creating REQUIRED testing labels...', 57, 60, $org, $repo, $dryRun);
|
||||||
$this->deployGroup('Creating REQUIRED version/release labels...', 61, 66, $org, $repo, $dryRun);
|
$this->deployGroup('Creating REQUIRED version/release labels...', 61, 66, $org, $repo, $dryRun);
|
||||||
|
|
||||||
echo "\n============================================================\n";
|
echo "\n============================================================\n";
|
||||||
if ($dryRun) {
|
if ($dryRun) {
|
||||||
$this->log('INFO', '[DRY-RUN] Label deployment simulation completed');
|
$this->log('INFO', '[DRY-RUN] Label deployment simulation completed');
|
||||||
} else {
|
} else {
|
||||||
$this->log('INFO', 'Label deployment completed successfully!');
|
$this->log('INFO', 'Label deployment completed successfully!');
|
||||||
echo "\n - TOTAL: " . count(self::LABELS) . " labels\n";
|
echo "\n - TOTAL: " . count(self::LABELS) . " labels\n";
|
||||||
}
|
}
|
||||||
echo "============================================================\n\n";
|
echo "============================================================\n\n";
|
||||||
|
|
||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Private helpers ───────────────────────────────────────────────────────
|
// ── Private helpers ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Deploy a named group of labels by index range in self::LABELS.
|
* Deploy a named group of labels by index range in self::LABELS.
|
||||||
*
|
*
|
||||||
* @param string $heading Informational banner printed before the group.
|
* @param string $heading Informational banner printed before the group.
|
||||||
* @param int $fromIndex First label index (inclusive).
|
* @param int $fromIndex First label index (inclusive).
|
||||||
* @param int $toIndex Last label index (inclusive).
|
* @param int $toIndex Last label index (inclusive).
|
||||||
* @param string $org Organization name.
|
* @param string $org Organization name.
|
||||||
* @param string $repo Repository name.
|
* @param string $repo Repository name.
|
||||||
* @param bool $dryRun When true, preview only.
|
* @param bool $dryRun When true, preview only.
|
||||||
*/
|
*/
|
||||||
private function deployGroup(string $heading, int $fromIndex, int $toIndex, string $org, string $repo, bool $dryRun): void
|
private function deployGroup(string $heading, int $fromIndex, int $toIndex, string $org, string $repo, bool $dryRun): void
|
||||||
{
|
{
|
||||||
$this->log('INFO', $heading);
|
$this->log('INFO', $heading);
|
||||||
for ($i = $fromIndex; $i <= $toIndex; $i++) {
|
for ($i = $fromIndex; $i <= $toIndex; $i++) {
|
||||||
[$name, $color, $desc] = self::LABELS[$i];
|
[$name, $color, $desc] = self::LABELS[$i];
|
||||||
$this->createLabelViaApi($name, $color, $desc, $org, $repo, $dryRun);
|
$this->createLabelViaApi($name, $color, $desc, $org, $repo, $dryRun);
|
||||||
}
|
}
|
||||||
echo "\n";
|
echo "\n";
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Create or update a single label via the platform adapter.
|
* Create or update a single label via the platform adapter.
|
||||||
*
|
*
|
||||||
* @param string $name Label name.
|
* @param string $name Label name.
|
||||||
* @param string $color Hex colour without the leading '#'.
|
* @param string $color Hex colour without the leading '#'.
|
||||||
* @param string $desc Short description text.
|
* @param string $desc Short description text.
|
||||||
* @param string $org Organization name.
|
* @param string $org Organization name.
|
||||||
* @param string $repo Repository name.
|
* @param string $repo Repository name.
|
||||||
* @param bool $dryRun When true, preview only.
|
* @param bool $dryRun When true, preview only.
|
||||||
*/
|
*/
|
||||||
private function createLabelViaApi(string $name, string $color, string $desc, string $org, string $repo, bool $dryRun): void
|
private function createLabelViaApi(string $name, string $color, string $desc, string $org, string $repo, bool $dryRun): void
|
||||||
{
|
{
|
||||||
if ($dryRun) {
|
if ($dryRun) {
|
||||||
echo "[DRY-RUN] Would create label: {$name} (color: #{$color}, description: {$desc})\n";
|
echo "[DRY-RUN] Would create label: {$name} (color: #{$color}, description: {$desc})\n";
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
$this->adapter->createLabel($org, $repo, $name, $color, $desc);
|
$this->adapter->createLabel($org, $repo, $name, $color, $desc);
|
||||||
$this->log('INFO', "Created/updated label: {$name}");
|
$this->log('INFO', "Created/updated label: {$name}");
|
||||||
} catch (\Exception $e) {
|
} catch (\Exception $e) {
|
||||||
// Label may already exist — that's fine
|
// Label may already exist — that's fine
|
||||||
if (str_contains($e->getMessage(), '422') || str_contains($e->getMessage(), 'already exists')) {
|
if (str_contains($e->getMessage(), '422') || str_contains($e->getMessage(), 'already exists')) {
|
||||||
$this->log('INFO', "Label already exists: {$name}");
|
$this->log('INFO', "Label already exists: {$name}");
|
||||||
} else {
|
} else {
|
||||||
$this->log('WARNING', "Failed to create label: {$name} — " . $e->getMessage());
|
$this->log('WARNING', "Failed to create label: {$name} — " . $e->getMessage());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
$script = new SetupLabels('setup_labels', 'REQUIRED: Deploy standard labels to repository');
|
$script = new SetupLabels('setup_labels', 'REQUIRED: Deploy standard labels to repository');
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
#!/usr/bin/env php
|
#!/usr/bin/env php
|
||||||
<?php
|
<?php
|
||||||
|
|
||||||
/* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
|
/* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
|
||||||
*
|
*
|
||||||
* This file is part of a Moko Consulting project.
|
* This file is part of a Moko Consulting project.
|
||||||
@@ -34,215 +35,224 @@ use MokoEnterprise\CliFramework;
|
|||||||
*/
|
*/
|
||||||
class SyncDolibarrReadmes extends CliFramework
|
class SyncDolibarrReadmes extends CliFramework
|
||||||
{
|
{
|
||||||
/**
|
/**
|
||||||
* Configure available arguments.
|
* Configure available arguments.
|
||||||
*/
|
*/
|
||||||
protected function configure(): void
|
protected function configure(): void
|
||||||
{
|
{
|
||||||
$this->setDescription('Keeps root README.md and src/README.md in sync for Dolibarr module repos');
|
$this->setDescription('Keeps root README.md and src/README.md in sync for Dolibarr module repos');
|
||||||
$this->addArgument('--path', 'Dolibarr module repo root', '.');
|
$this->addArgument('--path', 'Dolibarr module repo root', '.');
|
||||||
$this->addArgument('--dry-run', 'Preview changes without writing', false);
|
$this->addArgument('--dry-run', 'Preview changes without writing', false);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Run the sync.
|
* Run the sync.
|
||||||
*
|
*
|
||||||
* @return int Exit code: 0 on success, 1 on error.
|
* @return int Exit code: 0 on success, 1 on error.
|
||||||
*/
|
*/
|
||||||
protected function run(): int
|
protected function run(): int
|
||||||
{
|
{
|
||||||
$repoRoot = rtrim((string) $this->getArgument('--path'), '/');
|
$repoRoot = rtrim((string) $this->getArgument('--path'), '/');
|
||||||
$dryRun = (bool) $this->getArgument('--dry-run');
|
$dryRun = (bool) $this->getArgument('--dry-run');
|
||||||
$rootReadme = $repoRoot . '/README.md';
|
$rootReadme = $repoRoot . '/README.md';
|
||||||
$srcReadme = $repoRoot . '/src/README.md';
|
$srcReadme = $repoRoot . '/src/README.md';
|
||||||
|
|
||||||
if (!is_file($rootReadme)) {
|
if (!is_file($rootReadme)) {
|
||||||
$this->log('ERROR', "Root README.md not found at {$rootReadme}");
|
$this->log('ERROR', "Root README.md not found at {$rootReadme}");
|
||||||
return 1;
|
return 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!is_dir($repoRoot . '/src')) {
|
if (!is_dir($repoRoot . '/src')) {
|
||||||
$this->log('ERROR', 'src/ directory not found — is this a Dolibarr module repository?');
|
$this->log('ERROR', 'src/ directory not found — is this a Dolibarr module repository?');
|
||||||
return 1;
|
return 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
$rootContent = (string) file_get_contents($rootReadme);
|
$rootContent = (string) file_get_contents($rootReadme);
|
||||||
|
|
||||||
if (!preg_match('/^\s*VERSION:\s*(\d{2}\.\d{2}\.\d{2})/m', $rootContent, $m)) {
|
if (!preg_match('/^\s*VERSION:\s*(\d{2}\.\d{2}\.\d{2})/m', $rootContent, $m)) {
|
||||||
$this->log('ERROR', 'Could not find VERSION in root README.md FILE INFORMATION block');
|
$this->log('ERROR', 'Could not find VERSION in root README.md FILE INFORMATION block');
|
||||||
return 1;
|
return 1;
|
||||||
}
|
}
|
||||||
$version = $m[1];
|
$version = $m[1];
|
||||||
|
|
||||||
$moduleName = $this->extractModuleName($rootContent, $repoRoot);
|
$moduleName = $this->extractModuleName($rootContent, $repoRoot);
|
||||||
$repoUrl = $this->extractField($rootContent, 'REPO', 'https://git.mokoconsulting.tech/MokoConsulting');
|
$repoUrl = $this->extractField($rootContent, 'REPO', 'https://git.mokoconsulting.tech/MokoConsulting');
|
||||||
$defgroup = $this->extractField($rootContent, 'DEFGROUP', 'MokoPlatform.Module');
|
$defgroup = $this->extractField($rootContent, 'DEFGROUP', 'MokoPlatform.Module');
|
||||||
$ingroup = $this->extractField($rootContent, 'INGROUP', 'moko-platform');
|
$ingroup = $this->extractField($rootContent, 'INGROUP', 'moko-platform');
|
||||||
$brief = $this->extractField($rootContent, 'BRIEF', "{$moduleName} end-user documentation");
|
$brief = $this->extractField($rootContent, 'BRIEF', "{$moduleName} end-user documentation");
|
||||||
|
|
||||||
$installSection = $this->extractSection($rootContent, 'Installation');
|
$installSection = $this->extractSection($rootContent, 'Installation');
|
||||||
$configSection = $this->extractSection($rootContent, 'Configuration');
|
$configSection = $this->extractSection($rootContent, 'Configuration');
|
||||||
$usageSection = $this->extractSection($rootContent, 'Usage');
|
$usageSection = $this->extractSection($rootContent, 'Usage');
|
||||||
$supportSection = $this->extractSection($rootContent, 'Support');
|
$supportSection = $this->extractSection($rootContent, 'Support');
|
||||||
|
|
||||||
echo "═══════════════════════════════════════════════════════════\n";
|
echo "═══════════════════════════════════════════════════════════\n";
|
||||||
echo " Dolibarr README Sync\n";
|
echo " Dolibarr README Sync\n";
|
||||||
echo "═══════════════════════════════════════════════════════════\n\n";
|
echo "═══════════════════════════════════════════════════════════\n\n";
|
||||||
echo "Module: {$moduleName}\n";
|
echo "Module: {$moduleName}\n";
|
||||||
echo "Version: {$version}\n";
|
echo "Version: {$version}\n";
|
||||||
echo "Root: {$rootReadme}\n";
|
echo "Root: {$rootReadme}\n";
|
||||||
echo "Src: {$srcReadme}\n";
|
echo "Src: {$srcReadme}\n";
|
||||||
if ($dryRun) {
|
if ($dryRun) {
|
||||||
echo " DRY RUN — no files will be written\n";
|
echo " DRY RUN — no files will be written\n";
|
||||||
}
|
}
|
||||||
echo "\n";
|
echo "\n";
|
||||||
|
|
||||||
echo "Step 1: Update root README.md badges and VERSION field...\n";
|
echo "Step 1: Update root README.md badges and VERSION field...\n";
|
||||||
$this->updateRootReadme($rootReadme, $rootContent, $version, $dryRun);
|
$this->updateRootReadme($rootReadme, $rootContent, $version, $dryRun);
|
||||||
|
|
||||||
echo "Step 2: Sync src/README.md...\n";
|
echo "Step 2: Sync src/README.md...\n";
|
||||||
$today = gmdate('Y-m-d');
|
$today = gmdate('Y-m-d');
|
||||||
$newSrcContent = $this->buildSrcReadme(
|
$newSrcContent = $this->buildSrcReadme(
|
||||||
$version, $moduleName, $repoUrl, $defgroup, $ingroup, $brief, $today,
|
$version,
|
||||||
$installSection, $configSection, $usageSection, $supportSection
|
$moduleName,
|
||||||
);
|
$repoUrl,
|
||||||
$this->syncSrcReadme($srcReadme, $newSrcContent, $dryRun);
|
$defgroup,
|
||||||
|
$ingroup,
|
||||||
|
$brief,
|
||||||
|
$today,
|
||||||
|
$installSection,
|
||||||
|
$configSection,
|
||||||
|
$usageSection,
|
||||||
|
$supportSection
|
||||||
|
);
|
||||||
|
$this->syncSrcReadme($srcReadme, $newSrcContent, $dryRun);
|
||||||
|
|
||||||
echo "\n═══════════════════════════════════════════════════════════\n";
|
echo "\n═══════════════════════════════════════════════════════════\n";
|
||||||
if ($dryRun) {
|
if ($dryRun) {
|
||||||
echo " Dry Run Complete\n";
|
echo " Dry Run Complete\n";
|
||||||
echo "═══════════════════════════════════════════════════════════\n";
|
echo "═══════════════════════════════════════════════════════════\n";
|
||||||
echo "Run without --dry-run to apply changes.\n";
|
echo "Run without --dry-run to apply changes.\n";
|
||||||
} else {
|
} else {
|
||||||
echo " Dolibarr README Sync Complete\n";
|
echo " Dolibarr README Sync Complete\n";
|
||||||
echo "═══════════════════════════════════════════════════════════\n";
|
echo "═══════════════════════════════════════════════════════════\n";
|
||||||
echo "Module version: {$version}\n\n";
|
echo "Module version: {$version}\n\n";
|
||||||
echo "Next steps:\n";
|
echo "Next steps:\n";
|
||||||
echo " git diff && git add README.md src/README.md\n";
|
echo " git diff && git add README.md src/README.md\n";
|
||||||
echo " git commit -m \"docs(readme): sync src/README.md from root for version {$version}\"\n";
|
echo " git commit -m \"docs(readme): sync src/README.md from root for version {$version}\"\n";
|
||||||
}
|
}
|
||||||
echo "\n";
|
echo "\n";
|
||||||
|
|
||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Private helpers ───────────────────────────────────────────────────────
|
// ── Private helpers ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Extract a named field from the FILE INFORMATION block.
|
* Extract a named field from the FILE INFORMATION block.
|
||||||
*
|
*
|
||||||
* @param string $content Full file content.
|
* @param string $content Full file content.
|
||||||
* @param string $field Field name (e.g. 'REPO').
|
* @param string $field Field name (e.g. 'REPO').
|
||||||
* @param string $fallback Value to use when the field is absent.
|
* @param string $fallback Value to use when the field is absent.
|
||||||
* @return string Field value or fallback.
|
* @return string Field value or fallback.
|
||||||
*/
|
*/
|
||||||
private function extractField(string $content, string $field, string $fallback): string
|
private function extractField(string $content, string $field, string $fallback): string
|
||||||
{
|
{
|
||||||
if (preg_match('/^\s*' . preg_quote($field, '/') . ':\s*(.+)$/m', $content, $m)) {
|
if (preg_match('/^\s*' . preg_quote($field, '/') . ':\s*(.+)$/m', $content, $m)) {
|
||||||
return trim($m[1]);
|
return trim($m[1]);
|
||||||
}
|
}
|
||||||
return $fallback;
|
return $fallback;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Extract the module name from the first H1 heading after the closing '-->' of the header.
|
* Extract the module name from the first H1 heading after the closing '-->' of the header.
|
||||||
*
|
*
|
||||||
* @param string $content Full root README.md content.
|
* @param string $content Full root README.md content.
|
||||||
* @param string $repoRoot Repository root path (used as fallback).
|
* @param string $repoRoot Repository root path (used as fallback).
|
||||||
* @return string Module name.
|
* @return string Module name.
|
||||||
*/
|
*/
|
||||||
private function extractModuleName(string $content, string $repoRoot): string
|
private function extractModuleName(string $content, string $repoRoot): string
|
||||||
{
|
{
|
||||||
if (preg_match('/-->\s*\n+# (.+)/u', $content, $m)) {
|
if (preg_match('/-->\s*\n+# (.+)/u', $content, $m)) {
|
||||||
return trim($m[1]);
|
return trim($m[1]);
|
||||||
}
|
}
|
||||||
return basename($repoRoot);
|
return basename($repoRoot);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Extract a Markdown H2 section (from '## Heading' to the next '## ').
|
* Extract a Markdown H2 section (from '## Heading' to the next '## ').
|
||||||
*
|
*
|
||||||
* @param string $content Full file content.
|
* @param string $content Full file content.
|
||||||
* @param string $heading Section heading (without '## ' prefix).
|
* @param string $heading Section heading (without '## ' prefix).
|
||||||
* @return string The extracted section text, or '' if not found.
|
* @return string The extracted section text, or '' if not found.
|
||||||
*/
|
*/
|
||||||
private function extractSection(string $content, string $heading): string
|
private function extractSection(string $content, string $heading): string
|
||||||
{
|
{
|
||||||
$quoted = preg_quote($heading, '/');
|
$quoted = preg_quote($heading, '/');
|
||||||
if (!preg_match('/^## ' . $quoted . '$/m', $content)) {
|
if (!preg_match('/^## ' . $quoted . '$/m', $content)) {
|
||||||
return '';
|
return '';
|
||||||
}
|
}
|
||||||
if (preg_match('/^## ' . $quoted . '$(.*?)(?=^## |\Z)/ms', $content, $m)) {
|
if (preg_match('/^## ' . $quoted . '$(.*?)(?=^## |\Z)/ms', $content, $m)) {
|
||||||
return '## ' . $heading . $m[1];
|
return '## ' . $heading . $m[1];
|
||||||
}
|
}
|
||||||
return '';
|
return '';
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Update the version badge and VERSION field in root README.md.
|
* Update the version badge and VERSION field in root README.md.
|
||||||
*
|
*
|
||||||
* @param string $path Path to root README.md.
|
* @param string $path Path to root README.md.
|
||||||
* @param string $content Current file content.
|
* @param string $content Current file content.
|
||||||
* @param string $version New version string.
|
* @param string $version New version string.
|
||||||
* @param bool $dryRun When true, preview only.
|
* @param bool $dryRun When true, preview only.
|
||||||
*/
|
*/
|
||||||
private function updateRootReadme(string $path, string $content, string $version, bool $dryRun): void
|
private function updateRootReadme(string $path, string $content, string $version, bool $dryRun): void
|
||||||
{
|
{
|
||||||
$updated = preg_replace(
|
$updated = preg_replace(
|
||||||
'/(https:\/\/img\.shields\.io\/badge\/MokoStandards-)\d{2}\.\d{2}\.\d{2}/i',
|
'/(https:\/\/img\.shields\.io\/badge\/MokoStandards-)\d{2}\.\d{2}\.\d{2}/i',
|
||||||
'${1}' . $version,
|
'${1}' . $version,
|
||||||
$content
|
$content
|
||||||
);
|
);
|
||||||
$updated = preg_replace(
|
$updated = preg_replace(
|
||||||
'/^(\s*VERSION:\s*)\d{2}\.\d{2}\.\d{2}/m',
|
'/^(\s*VERSION:\s*)\d{2}\.\d{2}\.\d{2}/m',
|
||||||
'${1}' . $version,
|
'${1}' . $version,
|
||||||
(string) $updated
|
(string) $updated
|
||||||
);
|
);
|
||||||
|
|
||||||
if ($updated === $content) {
|
if ($updated === $content) {
|
||||||
echo " ✓ root README.md already current\n";
|
echo " ✓ root README.md already current\n";
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($dryRun) {
|
if ($dryRun) {
|
||||||
echo " ~ root README.md (would update version fields)\n";
|
echo " ~ root README.md (would update version fields)\n";
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
file_put_contents($path, (string) $updated);
|
file_put_contents($path, (string) $updated);
|
||||||
echo " ✓ root README.md updated\n";
|
echo " ✓ root README.md updated\n";
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Build the full content for src/README.md.
|
* Build the full content for src/README.md.
|
||||||
*
|
*
|
||||||
* @param string $version Version string.
|
* @param string $version Version string.
|
||||||
* @param string $moduleName Module display name.
|
* @param string $moduleName Module display name.
|
||||||
* @param string $repoUrl Repository URL.
|
* @param string $repoUrl Repository URL.
|
||||||
* @param string $defgroup DEFGROUP value.
|
* @param string $defgroup DEFGROUP value.
|
||||||
* @param string $ingroup INGROUP value.
|
* @param string $ingroup INGROUP value.
|
||||||
* @param string $brief BRIEF value.
|
* @param string $brief BRIEF value.
|
||||||
* @param string $today ISO date string (YYYY-MM-DD).
|
* @param string $today ISO date string (YYYY-MM-DD).
|
||||||
* @param string $installSection Extracted Installation section (may be '').
|
* @param string $installSection Extracted Installation section (may be '').
|
||||||
* @param string $configSection Extracted Configuration section (may be '').
|
* @param string $configSection Extracted Configuration section (may be '').
|
||||||
* @param string $usageSection Extracted Usage section (may be '').
|
* @param string $usageSection Extracted Usage section (may be '').
|
||||||
* @param string $supportSection Extracted Support section (may be '').
|
* @param string $supportSection Extracted Support section (may be '').
|
||||||
* @return string Complete file content.
|
* @return string Complete file content.
|
||||||
*/
|
*/
|
||||||
private function buildSrcReadme(
|
private function buildSrcReadme(
|
||||||
string $version,
|
string $version,
|
||||||
string $moduleName,
|
string $moduleName,
|
||||||
string $repoUrl,
|
string $repoUrl,
|
||||||
string $defgroup,
|
string $defgroup,
|
||||||
string $ingroup,
|
string $ingroup,
|
||||||
string $brief,
|
string $brief,
|
||||||
string $today,
|
string $today,
|
||||||
string $installSection,
|
string $installSection,
|
||||||
string $configSection,
|
string $configSection,
|
||||||
string $usageSection,
|
string $usageSection,
|
||||||
string $supportSection
|
string $supportSection
|
||||||
): string {
|
): string {
|
||||||
$content = <<<SRCREADME
|
$content = <<<SRCREADME
|
||||||
<!--
|
<!--
|
||||||
Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
|
Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
|
||||||
|
|
||||||
@@ -270,54 +280,54 @@ NOTE: This file is auto-generated by sync_dolibarr_readmes.php from root README.
|
|||||||
|
|
||||||
SRCREADME;
|
SRCREADME;
|
||||||
|
|
||||||
foreach ([$installSection, $configSection, $usageSection, $supportSection] as $section) {
|
foreach ([$installSection, $configSection, $usageSection, $supportSection] as $section) {
|
||||||
if ($section !== '') {
|
if ($section !== '') {
|
||||||
$content .= "\n" . $section;
|
$content .= "\n" . $section;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
$content .= "\n---\n\n*Documentation generated from root `README.md` — do not edit this file directly.*\n";
|
$content .= "\n---\n\n*Documentation generated from root `README.md` — do not edit this file directly.*\n";
|
||||||
return $content;
|
return $content;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Compare and write (or preview) src/README.md.
|
* Compare and write (or preview) src/README.md.
|
||||||
*
|
*
|
||||||
* @param string $path Path to src/README.md.
|
* @param string $path Path to src/README.md.
|
||||||
* @param string $content Desired file content.
|
* @param string $content Desired file content.
|
||||||
* @param bool $dryRun When true, preview only.
|
* @param bool $dryRun When true, preview only.
|
||||||
*/
|
*/
|
||||||
private function syncSrcReadme(string $path, string $content, bool $dryRun): void
|
private function syncSrcReadme(string $path, string $content, bool $dryRun): void
|
||||||
{
|
{
|
||||||
if (is_file($path)) {
|
if (is_file($path)) {
|
||||||
$existing = (string) file_get_contents($path);
|
$existing = (string) file_get_contents($path);
|
||||||
if ($existing === $content) {
|
if ($existing === $content) {
|
||||||
echo " ✓ src/README.md already current\n";
|
echo " ✓ src/README.md already current\n";
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if ($dryRun) {
|
if ($dryRun) {
|
||||||
echo " ~ src/README.md (would regenerate)\n";
|
echo " ~ src/README.md (would regenerate)\n";
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!is_dir(dirname($path))) {
|
if (!is_dir(dirname($path))) {
|
||||||
mkdir(dirname($path), 0755, true);
|
mkdir(dirname($path), 0755, true);
|
||||||
}
|
}
|
||||||
file_put_contents($path, $content);
|
file_put_contents($path, $content);
|
||||||
echo " ✓ src/README.md regenerated\n";
|
echo " ✓ src/README.md regenerated\n";
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($dryRun) {
|
if ($dryRun) {
|
||||||
echo " ~ src/README.md (would create — file does not exist)\n";
|
echo " ~ src/README.md (would create — file does not exist)\n";
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!is_dir(dirname($path))) {
|
if (!is_dir(dirname($path))) {
|
||||||
mkdir(dirname($path), 0755, true);
|
mkdir(dirname($path), 0755, true);
|
||||||
}
|
}
|
||||||
file_put_contents($path, $content);
|
file_put_contents($path, $content);
|
||||||
echo " ✓ src/README.md created\n";
|
echo " ✓ src/README.md created\n";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
$script = new SyncDolibarrReadmes('sync_dolibarr_readmes', 'Keeps root README.md and src/README.md in sync for Dolibarr module repos');
|
$script = new SyncDolibarrReadmes('sync_dolibarr_readmes', 'Keeps root README.md and src/README.md in sync for Dolibarr module repos');
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
#!/usr/bin/env php
|
#!/usr/bin/env php
|
||||||
<?php
|
<?php
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
|
* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
|
||||||
*
|
*
|
||||||
@@ -36,274 +37,274 @@ use MokoEnterprise\PlatformAdapterFactory;
|
|||||||
*/
|
*/
|
||||||
class UpdateRepoInventory extends CliFramework
|
class UpdateRepoInventory extends CliFramework
|
||||||
{
|
{
|
||||||
private ?GitPlatformAdapter $adapter = null;
|
private ?GitPlatformAdapter $adapter = null;
|
||||||
|
|
||||||
/** Marker that begins the auto-generated block. */
|
/** Marker that begins the auto-generated block. */
|
||||||
private const MARKER_START = '<!-- INVENTORY_TABLE_START -->';
|
private const MARKER_START = '<!-- INVENTORY_TABLE_START -->';
|
||||||
|
|
||||||
/** Marker that ends the auto-generated block. */
|
/** Marker that ends the auto-generated block. */
|
||||||
private const MARKER_END = '<!-- INVENTORY_TABLE_END -->';
|
private const MARKER_END = '<!-- INVENTORY_TABLE_END -->';
|
||||||
|
|
||||||
/** Path to the Dolibarr module registry relative to repo root. */
|
/** Path to the Dolibarr module registry relative to repo root. */
|
||||||
private const REGISTRY_PATH = 'docs/development/crm/module-registry.md';
|
private const REGISTRY_PATH = 'docs/development/crm/module-registry.md';
|
||||||
|
|
||||||
/** Path to the inventory file relative to repo root. */
|
/** Path to the inventory file relative to repo root. */
|
||||||
private const INVENTORY_PATH = 'docs/reference/REPOSITORY_INVENTORY.md';
|
private const INVENTORY_PATH = 'docs/reference/REPOSITORY_INVENTORY.md';
|
||||||
|
|
||||||
protected function configure(): void
|
protected function configure(): void
|
||||||
{
|
{
|
||||||
$this->setDescription('Updates docs/reference/REPOSITORY_INVENTORY.md with current org repo list');
|
$this->setDescription('Updates docs/reference/REPOSITORY_INVENTORY.md with current org repo list');
|
||||||
$this->addArgument('--org', 'Organisation to query', 'mokoconsulting-tech');
|
$this->addArgument('--org', 'Organisation to query', 'mokoconsulting-tech');
|
||||||
$this->addArgument('--path', 'Repository root path', '.');
|
$this->addArgument('--path', 'Repository root path', '.');
|
||||||
}
|
}
|
||||||
|
|
||||||
protected function run(): int
|
protected function run(): int
|
||||||
{
|
{
|
||||||
$root = rtrim((string) $this->getArgument('--path'), '/\\');
|
$root = rtrim((string) $this->getArgument('--path'), '/\\');
|
||||||
|
|
||||||
$config = Config::load();
|
$config = Config::load();
|
||||||
try {
|
try {
|
||||||
$this->adapter = PlatformAdapterFactory::create($config);
|
$this->adapter = PlatformAdapterFactory::create($config);
|
||||||
} catch (\RuntimeException $e) {
|
} catch (\RuntimeException $e) {
|
||||||
$this->status(false, 'auth', $e->getMessage());
|
$this->status(false, 'auth', $e->getMessage());
|
||||||
return 2;
|
return 2;
|
||||||
}
|
}
|
||||||
|
|
||||||
$orgArg = (string) $this->getArgument('--org');
|
$orgArg = (string) $this->getArgument('--org');
|
||||||
$org = $orgArg ?: $config->getString($this->adapter->getPlatformName() . '.organization', 'mokoconsulting-tech');
|
$org = $orgArg ?: $config->getString($this->adapter->getPlatformName() . '.organization', 'mokoconsulting-tech');
|
||||||
|
|
||||||
// ── 1. Fetch repositories ─────────────────────────────────────────────
|
// ── 1. Fetch repositories ─────────────────────────────────────────────
|
||||||
$this->section("Fetching repositories for {$org} ({$this->adapter->getPlatformName()})");
|
$this->section("Fetching repositories for {$org} ({$this->adapter->getPlatformName()})");
|
||||||
|
|
||||||
$repos = $this->fetchAllRepos($org);
|
$repos = $this->fetchAllRepos($org);
|
||||||
if ($repos === null) {
|
if ($repos === null) {
|
||||||
return 1;
|
return 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
$this->status(true, 'API', sprintf('Fetched %d repositories', count($repos)));
|
$this->status(true, 'API', sprintf('Fetched %d repositories', count($repos)));
|
||||||
|
|
||||||
// ── 2. Load Dolibarr module registry ──────────────────────────────────
|
// ── 2. Load Dolibarr module registry ──────────────────────────────────
|
||||||
$this->section('Loading Dolibarr module registry');
|
$this->section('Loading Dolibarr module registry');
|
||||||
|
|
||||||
$moduleMap = $this->parseModuleRegistry($root . '/' . self::REGISTRY_PATH);
|
$moduleMap = $this->parseModuleRegistry($root . '/' . self::REGISTRY_PATH);
|
||||||
$this->status(true, 'registry', sprintf('Loaded %d module ID entries', count($moduleMap)));
|
$this->status(true, 'registry', sprintf('Loaded %d module ID entries', count($moduleMap)));
|
||||||
|
|
||||||
// ── 3. Build the Markdown tables ──────────────────────────────────────
|
// ── 3. Build the Markdown tables ──────────────────────────────────────
|
||||||
$this->section('Building inventory tables');
|
$this->section('Building inventory tables');
|
||||||
|
|
||||||
$table = $this->buildTables($repos, $moduleMap, $org);
|
$table = $this->buildTables($repos, $moduleMap, $org);
|
||||||
|
|
||||||
// ── 4. Rewrite the inventory file ────────────────────────────────────
|
// ── 4. Rewrite the inventory file ────────────────────────────────────
|
||||||
$this->section('Updating ' . self::INVENTORY_PATH);
|
$this->section('Updating ' . self::INVENTORY_PATH);
|
||||||
|
|
||||||
$inventoryPath = $root . '/' . self::INVENTORY_PATH;
|
$inventoryPath = $root . '/' . self::INVENTORY_PATH;
|
||||||
if (!is_file($inventoryPath)) {
|
if (!is_file($inventoryPath)) {
|
||||||
$this->status(false, self::INVENTORY_PATH, 'file not found');
|
$this->status(false, self::INVENTORY_PATH, 'file not found');
|
||||||
return 2;
|
return 2;
|
||||||
}
|
}
|
||||||
|
|
||||||
$original = (string) file_get_contents($inventoryPath);
|
$original = (string) file_get_contents($inventoryPath);
|
||||||
$updated = $this->replaceSection($original, $table);
|
$updated = $this->replaceSection($original, $table);
|
||||||
|
|
||||||
if ($original === $updated) {
|
if ($original === $updated) {
|
||||||
$this->status(true, self::INVENTORY_PATH, 'no changes needed');
|
$this->status(true, self::INVENTORY_PATH, 'no changes needed');
|
||||||
} elseif (!$this->isDryRun()) {
|
} elseif (!$this->isDryRun()) {
|
||||||
file_put_contents($inventoryPath, $updated);
|
file_put_contents($inventoryPath, $updated);
|
||||||
$this->status(true, self::INVENTORY_PATH, 'updated');
|
$this->status(true, self::INVENTORY_PATH, 'updated');
|
||||||
} else {
|
} else {
|
||||||
$this->status(true, self::INVENTORY_PATH, '[dry-run] would update');
|
$this->status(true, self::INVENTORY_PATH, '[dry-run] would update');
|
||||||
}
|
}
|
||||||
|
|
||||||
$this->printSummary(1, 0, $this->elapsed());
|
$this->printSummary(1, 0, $this->elapsed());
|
||||||
|
|
||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Platform API ──────────────────────────────────────────────────────────
|
// ── Platform API ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Fetch all repositories for the org via the platform adapter.
|
* Fetch all repositories for the org via the platform adapter.
|
||||||
*
|
*
|
||||||
* @return list<array<string,mixed>>|null Null on API error.
|
* @return list<array<string,mixed>>|null Null on API error.
|
||||||
*/
|
*/
|
||||||
private function fetchAllRepos(string $org): ?array
|
private function fetchAllRepos(string $org): ?array
|
||||||
{
|
{
|
||||||
try {
|
try {
|
||||||
// Use the adapter's paginated listing — returns full repo objects
|
// Use the adapter's paginated listing — returns full repo objects
|
||||||
$repos = $this->adapter->paginateAll("/orgs/{$org}/repos", ['type' => 'all']);
|
$repos = $this->adapter->paginateAll("/orgs/{$org}/repos", ['type' => 'all']);
|
||||||
$this->progress(count($repos), count($repos), '', true);
|
$this->progress(count($repos), count($repos), '', true);
|
||||||
return $repos;
|
return $repos;
|
||||||
} catch (\Exception $e) {
|
} catch (\Exception $e) {
|
||||||
$this->status(false, 'API', $e->getMessage());
|
$this->status(false, 'API', $e->getMessage());
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Module registry ───────────────────────────────────────────────────────
|
// ── Module registry ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Parse the Dolibarr module registry Markdown table.
|
* Parse the Dolibarr module registry Markdown table.
|
||||||
*
|
*
|
||||||
* @return array<string,int> Map of lower-case repo name → module number.
|
* @return array<string,int> Map of lower-case repo name → module number.
|
||||||
*/
|
*/
|
||||||
private function parseModuleRegistry(string $path): array
|
private function parseModuleRegistry(string $path): array
|
||||||
{
|
{
|
||||||
if (!is_file($path)) {
|
if (!is_file($path)) {
|
||||||
$this->warning("Module registry not found: {$path}");
|
$this->warning("Module registry not found: {$path}");
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
$map = [];
|
$map = [];
|
||||||
$content = (string) file_get_contents($path);
|
$content = (string) file_get_contents($path);
|
||||||
|
|
||||||
// Match table rows: | ModuleName | 185051 | Status | … |
|
// Match table rows: | ModuleName | 185051 | Status | … |
|
||||||
preg_match_all('/^\|\s*(\w+)\s*\|\s*(\d{6})\s*\|/m', $content, $matches, PREG_SET_ORDER);
|
preg_match_all('/^\|\s*(\w+)\s*\|\s*(\d{6})\s*\|/m', $content, $matches, PREG_SET_ORDER);
|
||||||
|
|
||||||
foreach ($matches as $match) {
|
foreach ($matches as $match) {
|
||||||
$id = (int) $match[2];
|
$id = (int) $match[2];
|
||||||
if ($id >= 100000) {
|
if ($id >= 100000) {
|
||||||
$map[strtolower($match[1])] = $id;
|
$map[strtolower($match[1])] = $id;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return $map;
|
return $map;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Table builder ─────────────────────────────────────────────────────────
|
// ── Table builder ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Build the full Markdown replacement for the inventory tables.
|
* Build the full Markdown replacement for the inventory tables.
|
||||||
*
|
*
|
||||||
* @param list<array<string,mixed>> $repos
|
* @param list<array<string,mixed>> $repos
|
||||||
* @param array<string,int> $moduleMap
|
* @param array<string,int> $moduleMap
|
||||||
*/
|
*/
|
||||||
private function buildTables(array $repos, array $moduleMap, string $org): string
|
private function buildTables(array $repos, array $moduleMap, string $org): string
|
||||||
{
|
{
|
||||||
// Sort: active first, then archived; within each group alphabetically.
|
// Sort: active first, then archived; within each group alphabetically.
|
||||||
usort($repos, static function (array $a, array $b): int {
|
usort($repos, static function (array $a, array $b): int {
|
||||||
$aArch = (bool) ($a['archived'] ?? false);
|
$aArch = (bool) ($a['archived'] ?? false);
|
||||||
$bArch = (bool) ($b['archived'] ?? false);
|
$bArch = (bool) ($b['archived'] ?? false);
|
||||||
if ($aArch !== $bArch) {
|
if ($aArch !== $bArch) {
|
||||||
return $aArch ? 1 : -1;
|
return $aArch ? 1 : -1;
|
||||||
}
|
}
|
||||||
return strcasecmp((string) ($a['name'] ?? ''), (string) ($b['name'] ?? ''));
|
return strcasecmp((string) ($a['name'] ?? ''), (string) ($b['name'] ?? ''));
|
||||||
});
|
});
|
||||||
|
|
||||||
/** @var array<string,list<array<string,mixed>>> $groups */
|
/** @var array<string,list<array<string,mixed>>> $groups */
|
||||||
$groups = ['core' => [], 'product' => [], 'extension' => [], 'template' => [], 'internal' => [], 'archived' => []];
|
$groups = ['core' => [], 'product' => [], 'extension' => [], 'template' => [], 'internal' => [], 'archived' => []];
|
||||||
|
|
||||||
foreach ($repos as $repo) {
|
foreach ($repos as $repo) {
|
||||||
$name = (string) ($repo['name'] ?? '');
|
$name = (string) ($repo['name'] ?? '');
|
||||||
$topics = array_map('strtolower', (array) ($repo['topics'] ?? []));
|
$topics = array_map('strtolower', (array) ($repo['topics'] ?? []));
|
||||||
$archived = (bool) ($repo['archived'] ?? false);
|
$archived = (bool) ($repo['archived'] ?? false);
|
||||||
|
|
||||||
if ($archived) {
|
if ($archived) {
|
||||||
$groups['archived'][] = $repo;
|
$groups['archived'][] = $repo;
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
$lower = strtolower($name);
|
$lower = strtolower($name);
|
||||||
|
|
||||||
if (in_array('mokostandards-core', $topics, true) || $name === 'moko-platform' || $name === '.github-private') {
|
if (in_array('mokostandards-core', $topics, true) || $name === 'moko-platform' || $name === '.github-private') {
|
||||||
$groups['core'][] = $repo;
|
$groups['core'][] = $repo;
|
||||||
} elseif (
|
} elseif (
|
||||||
in_array('dolibarr-module', $topics, true)
|
in_array('dolibarr-module', $topics, true)
|
||||||
|| str_starts_with($lower, 'mokodoli')
|
|| str_starts_with($lower, 'mokodoli')
|
||||||
|| (str_starts_with($lower, 'mokocrm') && $lower !== 'mokocrmtheme')
|
|| (str_starts_with($lower, 'mokocrm') && $lower !== 'mokocrmtheme')
|
||||||
) {
|
) {
|
||||||
$groups['extension'][] = $repo;
|
$groups['extension'][] = $repo;
|
||||||
} elseif (in_array('product', $topics, true) || in_array('platform', $topics, true)) {
|
} elseif (in_array('product', $topics, true) || in_array('platform', $topics, true)) {
|
||||||
$groups['product'][] = $repo;
|
$groups['product'][] = $repo;
|
||||||
} elseif (in_array('template', $topics, true) || str_contains($lower, 'template')) {
|
} elseif (in_array('template', $topics, true) || str_contains($lower, 'template')) {
|
||||||
$groups['template'][] = $repo;
|
$groups['template'][] = $repo;
|
||||||
} else {
|
} else {
|
||||||
$groups['internal'][] = $repo;
|
$groups['internal'][] = $repo;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
$updated = (new \DateTimeImmutable('now', new \DateTimeZone('UTC')))->format('Y-m-d H:i:s T');
|
$updated = (new \DateTimeImmutable('now', new \DateTimeZone('UTC')))->format('Y-m-d H:i:s T');
|
||||||
$lines = [
|
$lines = [
|
||||||
"> ⚙️ **Auto-generated** by `update_repo_inventory.php` — last updated {$updated}.",
|
"> ⚙️ **Auto-generated** by `update_repo_inventory.php` — last updated {$updated}.",
|
||||||
'> Do not edit this section manually; it is overwritten on every bulk sync.',
|
'> Do not edit this section manually; it is overwritten on every bulk sync.',
|
||||||
'',
|
'',
|
||||||
];
|
];
|
||||||
|
|
||||||
$groupLabels = [
|
$groupLabels = [
|
||||||
'core' => 'Core Repositories',
|
'core' => 'Core Repositories',
|
||||||
'product' => 'Product Repositories',
|
'product' => 'Product Repositories',
|
||||||
'extension' => 'Extension Repositories (Dolibarr / CRM)',
|
'extension' => 'Extension Repositories (Dolibarr / CRM)',
|
||||||
'template' => 'Template Repositories',
|
'template' => 'Template Repositories',
|
||||||
'internal' => 'Internal and Testing',
|
'internal' => 'Internal and Testing',
|
||||||
'archived' => 'Archived Repositories',
|
'archived' => 'Archived Repositories',
|
||||||
];
|
];
|
||||||
|
|
||||||
foreach ($groupLabels as $key => $label) {
|
foreach ($groupLabels as $key => $label) {
|
||||||
if (empty($groups[$key])) {
|
if (empty($groups[$key])) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
$lines[] = "### {$label}";
|
$lines[] = "### {$label}";
|
||||||
$lines[] = '';
|
$lines[] = '';
|
||||||
$isExt = ($key === 'extension');
|
$isExt = ($key === 'extension');
|
||||||
|
|
||||||
if ($isExt) {
|
if ($isExt) {
|
||||||
$lines[] = '| Repository | Status | Description | Module ID | Language | Visibility |';
|
$lines[] = '| Repository | Status | Description | Module ID | Language | Visibility |';
|
||||||
$lines[] = '|------------|--------|-------------|-----------|----------|------------|';
|
$lines[] = '|------------|--------|-------------|-----------|----------|------------|';
|
||||||
} else {
|
} else {
|
||||||
$lines[] = '| Repository | Status | Description | Language | Visibility |';
|
$lines[] = '| Repository | Status | Description | Language | Visibility |';
|
||||||
$lines[] = '|------------|--------|-------------|----------|------------|';
|
$lines[] = '|------------|--------|-------------|----------|------------|';
|
||||||
}
|
}
|
||||||
|
|
||||||
foreach ($groups[$key] as $repo) {
|
foreach ($groups[$key] as $repo) {
|
||||||
$name = (string) ($repo['name'] ?? '');
|
$name = (string) ($repo['name'] ?? '');
|
||||||
$desc = str_replace('|', '\\|', (string) ($repo['description'] ?? ''));
|
$desc = str_replace('|', '\\|', (string) ($repo['description'] ?? ''));
|
||||||
$url = (string) ($repo['html_url'] ?? "https://github.com/{$org}/{$name}");
|
$url = (string) ($repo['html_url'] ?? "https://github.com/{$org}/{$name}");
|
||||||
$lang = (string) ($repo['language'] ?? '—');
|
$lang = (string) ($repo['language'] ?? '—');
|
||||||
$private = (bool) ($repo['private'] ?? false);
|
$private = (bool) ($repo['private'] ?? false);
|
||||||
$archived = (bool) ($repo['archived'] ?? false);
|
$archived = (bool) ($repo['archived'] ?? false);
|
||||||
$status = $archived ? '🗄 Archived' : '✅ Active';
|
$status = $archived ? '🗄 Archived' : '✅ Active';
|
||||||
$vis = $private ? 'Private' : 'Public';
|
$vis = $private ? 'Private' : 'Public';
|
||||||
$modId = $moduleMap[strtolower($name)] ?? null;
|
$modId = $moduleMap[strtolower($name)] ?? null;
|
||||||
$modCell = $modId !== null ? (string) $modId : '—';
|
$modCell = $modId !== null ? (string) $modId : '—';
|
||||||
|
|
||||||
if ($isExt) {
|
if ($isExt) {
|
||||||
$lines[] = "| [{$name}]({$url}) | {$status} | {$desc} | {$modCell} | {$lang} | {$vis} |";
|
$lines[] = "| [{$name}]({$url}) | {$status} | {$desc} | {$modCell} | {$lang} | {$vis} |";
|
||||||
} else {
|
} else {
|
||||||
$lines[] = "| [{$name}]({$url}) | {$status} | {$desc} | {$lang} | {$vis} |";
|
$lines[] = "| [{$name}]({$url}) | {$status} | {$desc} | {$lang} | {$vis} |";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
$lines[] = '';
|
$lines[] = '';
|
||||||
}
|
}
|
||||||
|
|
||||||
return implode("\n", $lines);
|
return implode("\n", $lines);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── File rewriter ─────────────────────────────────────────────────────────
|
// ── File rewriter ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Replace content between the start/end markers in the inventory file.
|
* Replace content between the start/end markers in the inventory file.
|
||||||
* If markers are absent, appends a new section at the end.
|
* If markers are absent, appends a new section at the end.
|
||||||
*/
|
*/
|
||||||
private function replaceSection(string $original, string $newContent): string
|
private function replaceSection(string $original, string $newContent): string
|
||||||
{
|
{
|
||||||
$startPos = strpos($original, self::MARKER_START);
|
$startPos = strpos($original, self::MARKER_START);
|
||||||
$endPos = strpos($original, self::MARKER_END);
|
$endPos = strpos($original, self::MARKER_END);
|
||||||
|
|
||||||
if ($startPos === false || $endPos === false) {
|
if ($startPos === false || $endPos === false) {
|
||||||
$this->warning('Inventory markers not found; appending section to end of file.');
|
$this->warning('Inventory markers not found; appending section to end of file.');
|
||||||
return $original
|
return $original
|
||||||
. "\n\n## Active Repositories\n\n"
|
. "\n\n## Active Repositories\n\n"
|
||||||
. self::MARKER_START . "\n"
|
. self::MARKER_START . "\n"
|
||||||
. $newContent . "\n"
|
. $newContent . "\n"
|
||||||
. self::MARKER_END . "\n";
|
. self::MARKER_END . "\n";
|
||||||
}
|
}
|
||||||
|
|
||||||
$before = substr($original, 0, $startPos + strlen(self::MARKER_START));
|
$before = substr($original, 0, $startPos + strlen(self::MARKER_START));
|
||||||
$after = substr($original, $endPos);
|
$after = substr($original, $endPos);
|
||||||
|
|
||||||
return $before . "\n" . $newContent . "\n" . $after;
|
return $before . "\n" . $newContent . "\n" . $after;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
$script = new UpdateRepoInventory('update_repo_inventory', 'Updates the repository inventory documentation after sync');
|
$script = new UpdateRepoInventory('update_repo_inventory', 'Updates the repository inventory documentation after sync');
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
#!/usr/bin/env php
|
#!/usr/bin/env php
|
||||||
<?php
|
<?php
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
|
* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
#!/usr/bin/env php
|
#!/usr/bin/env php
|
||||||
<?php
|
<?php
|
||||||
|
|
||||||
/* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
|
/* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
|
||||||
*
|
*
|
||||||
* This file is part of a Moko Consulting project.
|
* This file is part of a Moko Consulting project.
|
||||||
@@ -36,449 +37,451 @@ use MokoEnterprise\{ApiClient, AuditLogger, CliFramework};
|
|||||||
*/
|
*/
|
||||||
class UpdateVersionFromReadme extends CliFramework
|
class UpdateVersionFromReadme extends CliFramework
|
||||||
{
|
{
|
||||||
private AuditLogger $logger;
|
private AuditLogger $logger;
|
||||||
private ?ApiClient $apiClient = null;
|
private ?ApiClient $apiClient = null;
|
||||||
|
|
||||||
/** Files updated during this run */
|
/** Files updated during this run */
|
||||||
private array $updatedFiles = [];
|
private array $updatedFiles = [];
|
||||||
|
|
||||||
/** Errors encountered during this run */
|
/** Errors encountered during this run */
|
||||||
private array $errors = [];
|
private array $errors = [];
|
||||||
|
|
||||||
protected function configure(): void
|
protected function configure(): void
|
||||||
{
|
{
|
||||||
$this->setDescription('Propagate README.md version to all badges and FILE INFORMATION headers');
|
$this->setDescription('Propagate README.md version to all badges and FILE INFORMATION headers');
|
||||||
$this->addArgument('--path', 'Repository root path', '.');
|
$this->addArgument('--path', 'Repository root path', '.');
|
||||||
$this->addArgument('--dry-run', 'Preview changes without writing', false);
|
$this->addArgument('--dry-run', 'Preview changes without writing', false);
|
||||||
$this->addArgument('--create-issue', 'Create GitHub issue if version mismatches remain', false);
|
$this->addArgument('--create-issue', 'Create GitHub issue if version mismatches remain', false);
|
||||||
$this->addArgument('--repo', 'GitHub repo for issue creation (owner/repo)', '');
|
$this->addArgument('--repo', 'GitHub repo for issue creation (owner/repo)', '');
|
||||||
}
|
}
|
||||||
|
|
||||||
protected function initialize(): void
|
protected function initialize(): void
|
||||||
{
|
{
|
||||||
parent::initialize();
|
parent::initialize();
|
||||||
$this->logger = new AuditLogger('update_version_from_readme');
|
$this->logger = new AuditLogger('update_version_from_readme');
|
||||||
}
|
}
|
||||||
|
|
||||||
protected function run(): int
|
protected function run(): int
|
||||||
{
|
{
|
||||||
$repoRoot = rtrim((string) $this->getArgument('--path'), '/');
|
$repoRoot = rtrim((string) $this->getArgument('--path'), '/');
|
||||||
$dryRun = (bool) $this->getArgument('--dry-run');
|
$dryRun = (bool) $this->getArgument('--dry-run');
|
||||||
$createIssue = (bool) $this->getArgument('--create-issue');
|
$createIssue = (bool) $this->getArgument('--create-issue');
|
||||||
$repo = (string) $this->getArgument('--repo');
|
$repo = (string) $this->getArgument('--repo');
|
||||||
|
|
||||||
$readmePath = $repoRoot . '/README.md';
|
$readmePath = $repoRoot . '/README.md';
|
||||||
if (!file_exists($readmePath)) {
|
if (!file_exists($readmePath)) {
|
||||||
$this->error("README.md not found at {$readmePath}");
|
$this->error("README.md not found at {$readmePath}");
|
||||||
return 1;
|
return 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── 1. Extract version from README.md ────────────────────────────
|
// ── 1. Extract version from README.md ────────────────────────────
|
||||||
$version = $this->extractVersionFromReadme($readmePath);
|
$version = $this->extractVersionFromReadme($readmePath);
|
||||||
if ($version === null) {
|
if ($version === null) {
|
||||||
$this->error("Could not find VERSION field in README.md FILE INFORMATION block");
|
$this->error("Could not find VERSION field in README.md FILE INFORMATION block");
|
||||||
return 1;
|
return 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
$this->log("✅ README.md version: {$version}");
|
$this->log("✅ README.md version: {$version}");
|
||||||
if ($dryRun) {
|
if ($dryRun) {
|
||||||
$this->log("🔍 DRY RUN — no files will be written");
|
$this->log("🔍 DRY RUN — no files will be written");
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── 2. Scan and update every tracked file ────────────────────────
|
// ── 2. Scan and update every tracked file ────────────────────────
|
||||||
$this->processFiles($repoRoot, $version, $dryRun);
|
$this->processFiles($repoRoot, $version, $dryRun);
|
||||||
|
|
||||||
// ── 3. Update composer.json ──────────────────────────────────────
|
// ── 3. Update composer.json ──────────────────────────────────────
|
||||||
$this->updateComposerJson($repoRoot, $version, $dryRun);
|
$this->updateComposerJson($repoRoot, $version, $dryRun);
|
||||||
|
|
||||||
// ── 4. Summary ───────────────────────────────────────────────────
|
// ── 4. Summary ───────────────────────────────────────────────────
|
||||||
$count = count($this->updatedFiles);
|
$count = count($this->updatedFiles);
|
||||||
if ($dryRun) {
|
if ($dryRun) {
|
||||||
$this->log("🔍 DRY RUN complete — {$count} file(s) would be updated");
|
$this->log("🔍 DRY RUN complete — {$count} file(s) would be updated");
|
||||||
} else {
|
} else {
|
||||||
$this->log("✅ Updated {$count} file(s) to version {$version}");
|
$this->log("✅ Updated {$count} file(s) to version {$version}");
|
||||||
}
|
}
|
||||||
|
|
||||||
foreach ($this->updatedFiles as $f) {
|
foreach ($this->updatedFiles as $f) {
|
||||||
$this->log(" ✓ {$f}");
|
$this->log(" ✓ {$f}");
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── 5. Create issue if mismatches remain (non-dry-run only) ──────
|
// ── 5. Create issue if mismatches remain (non-dry-run only) ──────
|
||||||
if (!$dryRun && $createIssue && !empty($repo)) {
|
if (!$dryRun && $createIssue && !empty($repo)) {
|
||||||
$remaining = $this->countRemainingMismatches($repoRoot, $version);
|
$remaining = $this->countRemainingMismatches($repoRoot, $version);
|
||||||
if ($remaining > 0) {
|
if ($remaining > 0) {
|
||||||
$this->log("⚠ {$remaining} version reference(s) could not be auto-updated");
|
$this->log("⚠ {$remaining} version reference(s) could not be auto-updated");
|
||||||
$this->createDriftIssue($repo, $version, $remaining);
|
$this->createDriftIssue($repo, $version, $remaining);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return empty($this->errors) ? 0 : 1;
|
return empty($this->errors) ? 0 : 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ────────────────────────────────────────────────────────────────────
|
// ────────────────────────────────────────────────────────────────────
|
||||||
// Version extraction
|
// Version extraction
|
||||||
// ────────────────────────────────────────────────────────────────────
|
// ────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Extract the VERSION value from the FILE INFORMATION block in README.md.
|
* Extract the VERSION value from the FILE INFORMATION block in README.md.
|
||||||
*
|
*
|
||||||
* Handles both indented (` VERSION: X`) and unindented (`VERSION: X`) forms.
|
* Handles both indented (` VERSION: X`) and unindented (`VERSION: X`) forms.
|
||||||
*
|
*
|
||||||
* @param string $path Full path to README.md
|
* @param string $path Full path to README.md
|
||||||
* @return string|null Version string (e.g. "04.00.04"), or null if not found
|
* @return string|null Version string (e.g. "04.00.04"), or null if not found
|
||||||
*/
|
*/
|
||||||
private function extractVersionFromReadme(string $path): ?string
|
private function extractVersionFromReadme(string $path): ?string
|
||||||
{
|
{
|
||||||
$content = file_get_contents($path);
|
$content = file_get_contents($path);
|
||||||
if ($content === false) {
|
if ($content === false) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
// Match "VERSION: XX.YY.ZZ" allowing leading whitespace/tab
|
// Match "VERSION: XX.YY.ZZ" allowing leading whitespace/tab
|
||||||
if (preg_match('/^\s*VERSION:\s*([0-9]{2}\.[0-9]{2}\.[0-9]{2})\s*$/m', $content, $m)) {
|
if (preg_match('/^\s*VERSION:\s*([0-9]{2}\.[0-9]{2}\.[0-9]{2})\s*$/m', $content, $m)) {
|
||||||
return $m[1];
|
return $m[1];
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ────────────────────────────────────────────────────────────────────
|
// ────────────────────────────────────────────────────────────────────
|
||||||
// File processing
|
// File processing
|
||||||
// ────────────────────────────────────────────────────────────────────
|
// ────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Walk the repository tree and update every eligible file.
|
* Walk the repository tree and update every eligible file.
|
||||||
*
|
*
|
||||||
* @param string $repoRoot Absolute path to repository root
|
* @param string $repoRoot Absolute path to repository root
|
||||||
* @param string $version Target version string
|
* @param string $version Target version string
|
||||||
* @param bool $dryRun If true, compute but do not write changes
|
* @param bool $dryRun If true, compute but do not write changes
|
||||||
*/
|
*/
|
||||||
private function processFiles(string $repoRoot, string $version, bool $dryRun): void
|
private function processFiles(string $repoRoot, string $version, bool $dryRun): void
|
||||||
{
|
{
|
||||||
$extensions = ['md', 'php', 'yml', 'yaml', 'sh', 'ps1', 'py', 'tf'];
|
$extensions = ['md', 'php', 'yml', 'yaml', 'sh', 'ps1', 'py', 'tf'];
|
||||||
$excludeDirs = ['vendor', '.git', 'node_modules', 'logs'];
|
$excludeDirs = ['vendor', '.git', 'node_modules', 'logs'];
|
||||||
|
|
||||||
$iterator = new RecursiveIteratorIterator(
|
$iterator = new RecursiveIteratorIterator(
|
||||||
new RecursiveCallbackFilterIterator(
|
new RecursiveCallbackFilterIterator(
|
||||||
new RecursiveDirectoryIterator(
|
new RecursiveDirectoryIterator(
|
||||||
$repoRoot,
|
$repoRoot,
|
||||||
RecursiveDirectoryIterator::SKIP_DOTS
|
RecursiveDirectoryIterator::SKIP_DOTS
|
||||||
),
|
),
|
||||||
function (\SplFileInfo $fi) use ($excludeDirs): bool {
|
function (\SplFileInfo $fi) use ($excludeDirs): bool {
|
||||||
if ($fi->isDir()) {
|
if ($fi->isDir()) {
|
||||||
return !in_array($fi->getFilename(), $excludeDirs, true);
|
return !in_array($fi->getFilename(), $excludeDirs, true);
|
||||||
}
|
}
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
|
|
||||||
foreach ($iterator as $file) {
|
foreach ($iterator as $file) {
|
||||||
/** @var \SplFileInfo $file */
|
/** @var \SplFileInfo $file */
|
||||||
if (!$file->isFile()) {
|
if (!$file->isFile()) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
$ext = strtolower($file->getExtension());
|
$ext = strtolower($file->getExtension());
|
||||||
// Strip .template suffix for extension matching
|
// Strip .template suffix for extension matching
|
||||||
if ($ext === 'template') {
|
if ($ext === 'template') {
|
||||||
$inner = strtolower(pathinfo($file->getBasename('.template'), PATHINFO_EXTENSION));
|
$inner = strtolower(pathinfo($file->getBasename('.template'), PATHINFO_EXTENSION));
|
||||||
if (in_array($inner, $extensions, true)) {
|
if (in_array($inner, $extensions, true)) {
|
||||||
$ext = $inner;
|
$ext = $inner;
|
||||||
} else {
|
} else {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
} elseif (!in_array($ext, $extensions, true)) {
|
} elseif (!in_array($ext, $extensions, true)) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
$this->processFile($file->getPathname(), $repoRoot, $version, $dryRun, $ext);
|
$this->processFile($file->getPathname(), $repoRoot, $version, $dryRun, $ext);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Apply version replacements to a single file.
|
* Apply version replacements to a single file.
|
||||||
*
|
*
|
||||||
* @param string $path Absolute file path
|
* @param string $path Absolute file path
|
||||||
* @param string $repoRoot Repository root (for display)
|
* @param string $repoRoot Repository root (for display)
|
||||||
* @param string $version Target version
|
* @param string $version Target version
|
||||||
* @param bool $dryRun If true, do not write
|
* @param bool $dryRun If true, do not write
|
||||||
* @param string $ext Canonical extension (without .template)
|
* @param string $ext Canonical extension (without .template)
|
||||||
*/
|
*/
|
||||||
private function processFile(
|
private function processFile(
|
||||||
string $path,
|
string $path,
|
||||||
string $repoRoot,
|
string $repoRoot,
|
||||||
string $version,
|
string $version,
|
||||||
bool $dryRun,
|
bool $dryRun,
|
||||||
string $ext
|
string $ext
|
||||||
): void {
|
): void {
|
||||||
$original = file_get_contents($path);
|
$original = file_get_contents($path);
|
||||||
if ($original === false) {
|
if ($original === false) {
|
||||||
$this->errors[] = "Cannot read: {$path}";
|
$this->errors[] = "Cannot read: {$path}";
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
$updated = $original;
|
$updated = $original;
|
||||||
|
|
||||||
// ── Badge replacement (all file types) ───────────────────────────
|
// ── Badge replacement (all file types) ───────────────────────────
|
||||||
// shields.io badge: []
|
// shields.io badge: []
|
||||||
$updated = preg_replace(
|
$updated = preg_replace(
|
||||||
'/(\[!\[MokoStandards\]\(https:\/\/img\.shields\.io\/badge\/MokoStandards-)[0-9]{2}\.[0-9]{2}\.[0-9]{2}(-[a-z]+\)\])/',
|
'/(\[!\[MokoStandards\]\(https:\/\/img\.shields\.io\/badge\/MokoStandards-)[0-9]{2}\.[0-9]{2}\.[0-9]{2}(-[a-z]+\)\])/',
|
||||||
'${1}' . $version . '${2}',
|
'${1}' . $version . '${2}',
|
||||||
$updated
|
$updated
|
||||||
);
|
);
|
||||||
// Plain text version badge: [VERSION: XX.YY.ZZ]
|
// Plain text version badge: [VERSION: XX.YY.ZZ]
|
||||||
$updated = preg_replace(
|
$updated = preg_replace(
|
||||||
'/\[VERSION:\s*[0-9]{2}\.[0-9]{2}\.[0-9]{2}\]/',
|
'/\[VERSION:\s*[0-9]{2}\.[0-9]{2}\.[0-9]{2}\]/',
|
||||||
'[VERSION: ' . $version . ']',
|
'[VERSION: ' . $version . ']',
|
||||||
$updated
|
$updated
|
||||||
);
|
);
|
||||||
|
|
||||||
// ── FILE INFORMATION VERSION replacement ──────────────────────────
|
// ── FILE INFORMATION VERSION replacement ──────────────────────────
|
||||||
// Markdown inside <!-- -->: VERSION: OLD or <tab>VERSION: OLD
|
// Markdown inside <!-- -->: VERSION: OLD or <tab>VERSION: OLD
|
||||||
if ($ext === 'md') {
|
if ($ext === 'md') {
|
||||||
$updated = preg_replace(
|
$updated = preg_replace(
|
||||||
'/^(\s*VERSION:\s*)[0-9]{2}\.[0-9]{2}\.[0-9]{2}(\s*)$/m',
|
'/^(\s*VERSION:\s*)[0-9]{2}\.[0-9]{2}\.[0-9]{2}(\s*)$/m',
|
||||||
'${1}' . $version . '${2}',
|
'${1}' . $version . '${2}',
|
||||||
$updated
|
$updated
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// PHP inside /** */ or /* */: * VERSION: OLD
|
// PHP inside /** */ or /* */: * VERSION: OLD
|
||||||
if ($ext === 'php') {
|
if ($ext === 'php') {
|
||||||
$updated = preg_replace(
|
$updated = preg_replace(
|
||||||
'/^(\s*\*\s*VERSION:\s*)[0-9]{2}\.[0-9]{2}\.[0-9]{2}(\s*)$/m',
|
'/^(\s*\*\s*VERSION:\s*)[0-9]{2}\.[0-9]{2}\.[0-9]{2}(\s*)$/m',
|
||||||
'${1}' . $version . '${2}',
|
'${1}' . $version . '${2}',
|
||||||
$updated
|
$updated
|
||||||
);
|
);
|
||||||
|
|
||||||
// PHP class VERSION constants:
|
// PHP class VERSION constants:
|
||||||
// private const VERSION = '09.22.00';
|
// private const VERSION = '09.22.00';
|
||||||
// public const VERSION = '09.22.00';
|
// public const VERSION = '09.22.00';
|
||||||
// private const VERSION = '09.22.00';
|
// private const VERSION = '09.22.00';
|
||||||
$updated = preg_replace(
|
$updated = preg_replace(
|
||||||
'/((?:private|public|protected)\s+const\s+VERSION\s*=\s*[\'"])[0-9]{2}\.[0-9]{2}\.[0-9]{2}([\'"])/',
|
'/((?:private|public|protected)\s+const\s+VERSION\s*=\s*[\'"])[0-9]{2}\.[0-9]{2}\.[0-9]{2}([\'"])/',
|
||||||
'${1}' . $version . '${2}',
|
'${1}' . $version . '${2}',
|
||||||
$updated
|
$updated
|
||||||
);
|
);
|
||||||
|
|
||||||
// composer.json "version" field (handled separately for JSON files)
|
// composer.json "version" field (handled separately for JSON files)
|
||||||
}
|
}
|
||||||
|
|
||||||
// YAML / Shell / PowerShell / Python: # VERSION: OLD
|
// YAML / Shell / PowerShell / Python: # VERSION: OLD
|
||||||
if (in_array($ext, ['yml', 'yaml', 'sh', 'ps1', 'py'], true)) {
|
if (in_array($ext, ['yml', 'yaml', 'sh', 'ps1', 'py'], true)) {
|
||||||
$updated = preg_replace(
|
$updated = preg_replace(
|
||||||
'/^(#\s*VERSION:\s*)[0-9]{2}\.[0-9]{2}\.[0-9]{2}(\s*)$/m',
|
'/^(#\s*VERSION:\s*)[0-9]{2}\.[0-9]{2}\.[0-9]{2}(\s*)$/m',
|
||||||
'${1}' . $version . '${2}',
|
'${1}' . $version . '${2}',
|
||||||
$updated
|
$updated
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Terraform (.tf / .tf.template) — three locations:
|
// Terraform (.tf / .tf.template) — three locations:
|
||||||
// 1. # VERSION: OLD (hash-comment header, template-style files)
|
// 1. # VERSION: OLD (hash-comment header, template-style files)
|
||||||
// 2. * Version: OLD (block-comment header, definition files)
|
// 2. * Version: OLD (block-comment header, definition files)
|
||||||
// 3. version = "OLD" (HCL metadata field)
|
// 3. version = "OLD" (HCL metadata field)
|
||||||
if ($ext === 'tf') {
|
if ($ext === 'tf') {
|
||||||
$updated = preg_replace(
|
$updated = preg_replace(
|
||||||
'/^(#\s*VERSION:\s*)[0-9]{2}\.[0-9]{2}\.[0-9]{2}(\s*)$/m',
|
'/^(#\s*VERSION:\s*)[0-9]{2}\.[0-9]{2}\.[0-9]{2}(\s*)$/m',
|
||||||
'${1}' . $version . '${2}',
|
'${1}' . $version . '${2}',
|
||||||
$updated
|
$updated
|
||||||
);
|
);
|
||||||
$updated = preg_replace(
|
$updated = preg_replace(
|
||||||
'/^(\s*\*\s*Version:\s*)[0-9]{2}\.[0-9]{2}\.[0-9]{2}(\s*)$/m',
|
'/^(\s*\*\s*Version:\s*)[0-9]{2}\.[0-9]{2}\.[0-9]{2}(\s*)$/m',
|
||||||
'${1}' . $version . '${2}',
|
'${1}' . $version . '${2}',
|
||||||
$updated
|
$updated
|
||||||
);
|
);
|
||||||
$updated = preg_replace(
|
$updated = preg_replace(
|
||||||
'/^(\s*version\s*=\s*")[0-9]{2}\.[0-9]{2}\.[0-9]{2}("\s*)$/m',
|
'/^(\s*version\s*=\s*")[0-9]{2}\.[0-9]{2}\.[0-9]{2}("\s*)$/m',
|
||||||
'${1}' . $version . '${2}',
|
'${1}' . $version . '${2}',
|
||||||
$updated
|
$updated
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($updated === $original) {
|
if ($updated === $original) {
|
||||||
return; // Nothing to change
|
return; // Nothing to change
|
||||||
}
|
}
|
||||||
|
|
||||||
$rel = ltrim(str_replace($repoRoot, '', $path), '/');
|
$rel = ltrim(str_replace($repoRoot, '', $path), '/');
|
||||||
|
|
||||||
if (!$dryRun) {
|
if (!$dryRun) {
|
||||||
if (file_put_contents($path, $updated) === false) {
|
if (file_put_contents($path, $updated) === false) {
|
||||||
$this->errors[] = "Cannot write: {$path}";
|
$this->errors[] = "Cannot write: {$path}";
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
$this->updatedFiles[] = $rel;
|
$this->updatedFiles[] = $rel;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Update the "version" key in composer.json if it exists.
|
* Update the "version" key in composer.json if it exists.
|
||||||
*
|
*
|
||||||
* @param string $repoRoot Repository root
|
* @param string $repoRoot Repository root
|
||||||
* @param string $version Target version
|
* @param string $version Target version
|
||||||
* @param bool $dryRun If true, do not write
|
* @param bool $dryRun If true, do not write
|
||||||
*/
|
*/
|
||||||
private function updateComposerJson(string $repoRoot, string $version, bool $dryRun): void
|
private function updateComposerJson(string $repoRoot, string $version, bool $dryRun): void
|
||||||
{
|
{
|
||||||
$path = $repoRoot . '/composer.json';
|
$path = $repoRoot . '/composer.json';
|
||||||
if (!file_exists($path)) {
|
if (!file_exists($path)) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
$content = file_get_contents($path);
|
$content = file_get_contents($path);
|
||||||
if ($content === false) {
|
if ($content === false) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
$updated = preg_replace(
|
$updated = preg_replace(
|
||||||
'/("version"\s*:\s*")[0-9]{2}\.[0-9]{2}\.[0-9]{2}(")/m',
|
'/("version"\s*:\s*")[0-9]{2}\.[0-9]{2}\.[0-9]{2}(")/m',
|
||||||
'${1}' . $version . '${2}',
|
'${1}' . $version . '${2}',
|
||||||
$content
|
$content
|
||||||
);
|
);
|
||||||
|
|
||||||
if ($updated === $content) {
|
if ($updated === $content) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!$dryRun) {
|
if (!$dryRun) {
|
||||||
file_put_contents($path, $updated);
|
file_put_contents($path, $updated);
|
||||||
}
|
}
|
||||||
|
|
||||||
$this->updatedFiles[] = 'composer.json';
|
$this->updatedFiles[] = 'composer.json';
|
||||||
}
|
}
|
||||||
|
|
||||||
// ────────────────────────────────────────────────────────────────────
|
// ────────────────────────────────────────────────────────────────────
|
||||||
// Drift detection
|
// Drift detection
|
||||||
// ────────────────────────────────────────────────────────────────────
|
// ────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Count FILE INFORMATION VERSION lines that still differ from $version.
|
* Count FILE INFORMATION VERSION lines that still differ from $version.
|
||||||
*
|
*
|
||||||
* @param string $repoRoot Repository root
|
* @param string $repoRoot Repository root
|
||||||
* @param string $version Expected version
|
* @param string $version Expected version
|
||||||
* @return int Number of remaining mismatches
|
* @return int Number of remaining mismatches
|
||||||
*/
|
*/
|
||||||
private function countRemainingMismatches(string $repoRoot, string $version): int
|
private function countRemainingMismatches(string $repoRoot, string $version): int
|
||||||
{
|
{
|
||||||
$escaped = preg_quote($version, '/');
|
$escaped = preg_quote($version, '/');
|
||||||
$count = 0;
|
$count = 0;
|
||||||
$versionRe = '/VERSION:\s*(?!' . $escaped . ')[0-9]{2}\.[0-9]{2}\.[0-9]{2}/';
|
$versionRe = '/VERSION:\s*(?!' . $escaped . ')[0-9]{2}\.[0-9]{2}\.[0-9]{2}/';
|
||||||
|
|
||||||
$extensions = ['md', 'php', 'yml', 'yaml', 'sh', 'tf'];
|
$extensions = ['md', 'php', 'yml', 'yaml', 'sh', 'tf'];
|
||||||
$excludeDirs = ['vendor', '.git', 'node_modules', 'logs'];
|
$excludeDirs = ['vendor', '.git', 'node_modules', 'logs'];
|
||||||
|
|
||||||
$iterator = new RecursiveIteratorIterator(
|
$iterator = new RecursiveIteratorIterator(
|
||||||
new RecursiveCallbackFilterIterator(
|
new RecursiveCallbackFilterIterator(
|
||||||
new RecursiveDirectoryIterator($repoRoot, RecursiveDirectoryIterator::SKIP_DOTS),
|
new RecursiveDirectoryIterator($repoRoot, RecursiveDirectoryIterator::SKIP_DOTS),
|
||||||
function (\SplFileInfo $fi) use ($excludeDirs): bool {
|
function (\SplFileInfo $fi) use ($excludeDirs): bool {
|
||||||
return !($fi->isDir() && in_array($fi->getFilename(), $excludeDirs, true));
|
return !($fi->isDir() && in_array($fi->getFilename(), $excludeDirs, true));
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
|
|
||||||
foreach ($iterator as $file) {
|
foreach ($iterator as $file) {
|
||||||
/** @var \SplFileInfo $file */
|
/** @var \SplFileInfo $file */
|
||||||
if (!$file->isFile()) {
|
if (!$file->isFile()) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
$ext = strtolower($file->getExtension());
|
$ext = strtolower($file->getExtension());
|
||||||
if ($ext === 'template') {
|
if ($ext === 'template') {
|
||||||
$ext = strtolower(pathinfo($file->getBasename('.template'), PATHINFO_EXTENSION));
|
$ext = strtolower(pathinfo($file->getBasename('.template'), PATHINFO_EXTENSION));
|
||||||
}
|
}
|
||||||
if (!in_array($ext, $extensions, true)) {
|
if (!in_array($ext, $extensions, true)) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
$content = file_get_contents($file->getPathname());
|
$content = file_get_contents($file->getPathname());
|
||||||
if ($content !== false && preg_match($versionRe, $content)) {
|
if ($content !== false && preg_match($versionRe, $content)) {
|
||||||
$count++;
|
$count++;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return $count;
|
return $count;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ────────────────────────────────────────────────────────────────────
|
// ────────────────────────────────────────────────────────────────────
|
||||||
// GitHub issue creation
|
// GitHub issue creation
|
||||||
// ────────────────────────────────────────────────────────────────────
|
// ────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Create or update a GitHub issue listing files that could not be auto-updated.
|
* Create or update a GitHub issue listing files that could not be auto-updated.
|
||||||
*
|
*
|
||||||
* @param string $repo owner/repo
|
* @param string $repo owner/repo
|
||||||
* @param string $version Expected version
|
* @param string $version Expected version
|
||||||
* @param int $remaining Number of remaining mismatches
|
* @param int $remaining Number of remaining mismatches
|
||||||
*/
|
*/
|
||||||
private function createDriftIssue(string $repo, string $version, int $remaining): void
|
private function createDriftIssue(string $repo, string $version, int $remaining): void
|
||||||
{
|
{
|
||||||
if (!isset($this->apiClient)) {
|
if (!isset($this->apiClient)) {
|
||||||
$config = \MokoEnterprise\Config::load();
|
$config = \MokoEnterprise\Config::load();
|
||||||
try {
|
try {
|
||||||
$adapter = \MokoEnterprise\PlatformAdapterFactory::create($config);
|
$adapter = \MokoEnterprise\PlatformAdapterFactory::create($config);
|
||||||
$this->apiClient = $adapter->getApiClient();
|
$this->apiClient = $adapter->getApiClient();
|
||||||
} catch (\Exception $e) {
|
} catch (\Exception $e) {
|
||||||
$this->error('Platform initialization failed: ' . $e->getMessage());
|
$this->error('Platform initialization failed: ' . $e->getMessage());
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
$title = "⚠️ Version drift: {$remaining} file(s) not updated to {$version}";
|
$title = "⚠️ Version drift: {$remaining} file(s) not updated to {$version}";
|
||||||
$labels = ['version-drift', 'maintenance', 'type: chore', 'automation'];
|
$labels = ['version-drift', 'maintenance', 'type: chore', 'automation'];
|
||||||
$body = implode("\n", [
|
$body = implode("\n", [
|
||||||
"## ⚠️ Version Sync: {$remaining} file(s) could not be auto-updated",
|
"## ⚠️ Version Sync: {$remaining} file(s) could not be auto-updated",
|
||||||
"",
|
"",
|
||||||
"**Target version:** `{$version}` (from README.md)",
|
"**Target version:** `{$version}` (from README.md)",
|
||||||
"",
|
"",
|
||||||
"After the automatic version propagation run, **{$remaining}** file(s) still contain",
|
"After the automatic version propagation run, **{$remaining}** file(s) still contain",
|
||||||
"a VERSION field that does not match the README.md version.",
|
"a VERSION field that does not match the README.md version.",
|
||||||
"",
|
"",
|
||||||
"### How to fix",
|
"### How to fix",
|
||||||
"",
|
"",
|
||||||
"1. Run the sync script locally:",
|
"1. Run the sync script locally:",
|
||||||
" ```bash",
|
" ```bash",
|
||||||
" php maintenance/update_version_from_readme.php --path . --dry-run",
|
" php maintenance/update_version_from_readme.php --path . --dry-run",
|
||||||
" php maintenance/update_version_from_readme.php --path .",
|
" php maintenance/update_version_from_readme.php --path .",
|
||||||
" ```",
|
" ```",
|
||||||
"2. Inspect any files still flagged — they may use a non-standard VERSION format.",
|
"2. Inspect any files still flagged — they may use a non-standard VERSION format.",
|
||||||
"3. Update them manually to match `VERSION: {$version}`.",
|
"3. Update them manually to match `VERSION: {$version}`.",
|
||||||
"4. Commit and push — this issue will be closed automatically on the next successful sync.",
|
"4. Commit and push — this issue will be closed automatically on the next successful sync.",
|
||||||
"",
|
"",
|
||||||
"---",
|
"---",
|
||||||
"*Automatically created by [update_version_from_readme.php](maintenance/update_version_from_readme.php)*",
|
"*Automatically created by [update_version_from_readme.php](maintenance/update_version_from_readme.php)*",
|
||||||
]);
|
]);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Check for an existing version-drift issue to avoid duplicates
|
// Check for an existing version-drift issue to avoid duplicates
|
||||||
$existing = $this->apiClient->get("/repos/{$repo}/issues", [
|
$existing = $this->apiClient->get("/repos/{$repo}/issues", [
|
||||||
'labels' => 'version-drift',
|
'labels' => 'version-drift',
|
||||||
'state' => 'all',
|
'state' => 'all',
|
||||||
'per_page' => 1,
|
'per_page' => 1,
|
||||||
'sort' => 'created',
|
'sort' => 'created',
|
||||||
'direction' => 'desc',
|
'direction' => 'desc',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
if (!empty($existing[0]['number'])) {
|
if (!empty($existing[0]['number'])) {
|
||||||
$num = (int) $existing[0]['number'];
|
$num = (int) $existing[0]['number'];
|
||||||
$patch = ['title' => $title, 'body' => $body, 'assignees' => ['jmiller']];
|
$patch = ['title' => $title, 'body' => $body, 'assignees' => ['jmiller']];
|
||||||
if (($existing[0]['state'] ?? 'open') === 'closed') {
|
if (($existing[0]['state'] ?? 'open') === 'closed') {
|
||||||
$patch['state'] = 'open';
|
$patch['state'] = 'open';
|
||||||
}
|
}
|
||||||
$this->apiClient->patch("/repos/{$repo}/issues/{$num}", $patch);
|
$this->apiClient->patch("/repos/{$repo}/issues/{$num}", $patch);
|
||||||
try {
|
try {
|
||||||
$this->apiClient->post("/repos/{$repo}/issues/{$num}/labels", ['labels' => $labels]);
|
$this->apiClient->post("/repos/{$repo}/issues/{$num}/labels", ['labels' => $labels]);
|
||||||
} catch (\Exception $le) { /* non-fatal */ }
|
} catch (\Exception $le) {
|
||||||
$this->log("✅ Updated issue #{$num} in {$repo}");
|
/* non-fatal */
|
||||||
} else {
|
}
|
||||||
$issue = $this->apiClient->post("/repos/{$repo}/issues", [
|
$this->log("✅ Updated issue #{$num} in {$repo}");
|
||||||
'title' => $title,
|
} else {
|
||||||
'body' => $body,
|
$issue = $this->apiClient->post("/repos/{$repo}/issues", [
|
||||||
'labels' => $labels,
|
'title' => $title,
|
||||||
'assignees' => ['jmiller'],
|
'body' => $body,
|
||||||
]);
|
'labels' => $labels,
|
||||||
$this->log('✅ Created issue #' . ($issue['number'] ?? '?') . " in {$repo}");
|
'assignees' => ['jmiller'],
|
||||||
}
|
]);
|
||||||
} catch (\Exception $e) {
|
$this->log('✅ Created issue #' . ($issue['number'] ?? '?') . " in {$repo}");
|
||||||
$this->error('Failed to create/update issue: ' . $e->getMessage());
|
}
|
||||||
}
|
} catch (\Exception $e) {
|
||||||
}
|
$this->error('Failed to create/update issue: ' . $e->getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
$script = new UpdateVersionFromReadme();
|
$script = new UpdateVersionFromReadme();
|
||||||
|
|||||||
Reference in New Issue
Block a user