1 Commits

Author SHA1 Message Date
gitea-actions[bot] 082c01fc46 chore(release): build 09.24.00-rc [skip ci] 2026-06-04 22:59:27 +00:00
738 changed files with 10009 additions and 80391 deletions
-12
View File
@@ -1,12 +0,0 @@
[submodule "templates/repos/Template-Client"]
path = templates/repos/Template-Client
url = https://git.mokoconsulting.tech/MokoConsulting/Template-Client.git
[submodule "templates/repos/Template-Generic"]
path = templates/repos/Template-Generic
url = https://git.mokoconsulting.tech/MokoConsulting/Template-Generic.git
[submodule "templates/repos/Template-Joomla"]
path = templates/repos/Template-Joomla
url = https://git.mokoconsulting.tech/MokoConsulting/Template-Joomla.git
[submodule "templates/repos/Template-MCP"]
path = templates/repos/Template-MCP
url = https://git.mokoconsulting.tech/MokoConsulting/Template-MCP.git
-76
View File
@@ -1,76 +0,0 @@
# mokocli
Enterprise automation, validation, sync, and governance engine for all Moko Consulting repositories.
## Quick Reference
| Field | Value |
|---|---|
| **Language** | PHP 8.1+ |
| **Version** | 09.01.00 |
| **Branch** | develop on `dev`, merge to `main` (protected) |
| **Wiki** | [mokocli Wiki](https://git.mokoconsulting.tech/MokoConsulting/mokocli/wiki) |
## Commands
```bash
composer install # Install PHP dependencies
php bin/moko health --path . # Repo health check
php bin/moko check:syntax --path . # PHP syntax check
php bin/moko drift --org MokoConsulting # Scan for standards drift
php bin/moko dashboard --token $TOKEN -o dashboard.html # Client dashboard
# Code quality
php vendor/bin/phpcs --standard=phpcs.xml -n lib/ validate/ automation/ cli/
php vendor/bin/phpcbf --standard=phpcs.xml lib/ validate/ automation/ cli/
php vendor/bin/phpstan analyse -c phpstan.neon --memory-limit=512M
composer check # Run all checks
```
## Architecture
| Directory | Purpose |
|---|---|
| `cli/` | 32 standalone CLI tools (version, release, build, repo management) |
| `validate/` | 20 validation scripts (syntax, structure, manifests, drift) |
| `automation/` | 7 bulk operations (sync, push files, templates, cleanup) |
| `lib/Enterprise/` | Core library — CliFramework, ApiClient, adapters, validators |
| `lib/Enterprise/Plugins/` | 11 platform plugins (Joomla, Dolibarr, Node.js, Python, etc.) |
| `deploy/` | SFTP deployment scripts (Joomla, Dolibarr, health checks) |
| `templates/` | Universal templates, configs, governance schema |
| `.mokogitea/workflows/` | CI/CD workflows (Gitea Actions) |
| `bin/moko` | Unified CLI dispatcher — `php bin/moko <command>` |
| `monitoring/sites.json` | Sites list for mcp_mokomonitor |
### CLI Framework
All CLI tools extend `MokoCli\CliFramework` (`lib/Enterprise/CliFramework.php`).
Built-in flags: `--help`, `--verbose`, `--quiet`, `--dry-run`.
After adding a CLI tool, register it in `bin/moko` COMMAND_MAP.
### Platform Adapters
- `MokoGiteaAdapter` — git.mokoconsulting.tech (primary)
- `GitHubAdapter` — github.com mirrors
### Plugin System
Platform-specific logic in `lib/Enterprise/Plugins/`. Each implements `ProjectPluginInterface` with health checks, validation, build commands, config schemas.
## Code Quality
| Tool | Level | Config |
|---|---|---|
| PHPCS | PSR-12 (errors only) | `phpcs.xml` |
| PHPStan | Level 2 (advisory) | `phpstan.neon` |
PHPStan runs with `--memory-limit=512M`. CI enforces PHPCS errors; PHPStan is `continue-on-error`.
## Rules
- **Never commit** `.claude/`, `.mcp.json`, `TODO.md`, `*.min.css`/`*.min.js`
- **Attribution**: `Authored-by: Moko Consulting`
- **Workflow directory**: `.mokogitea/` (not `.gitea/` or `.github/`)
- **Wiki**: documentation lives in the Gitea wiki, not `docs/` files
- **New CLI tools**: extend `CliFramework`, not `CLIApp` (legacy)
- **Standards**: [MokoCli](https://git.mokoconsulting.tech/MokoConsulting/mokocli/wiki/Home)
+2 -2
View File
@@ -7,8 +7,8 @@ contact_links:
- name: 💬 Ask a Question - name: 💬 Ask a Question
url: https://mokoconsulting.tech/ url: https://mokoconsulting.tech/
about: Get help or ask questions through our website about: Get help or ask questions through our website
- name: 📚 mokocli Documentation - name: 📚 moko-platform Documentation
url: https://git.mokoconsulting.tech/MokoConsulting/mokocli url: https://git.mokoconsulting.tech/MokoConsulting/moko-platform
about: View our coding standards and best practices about: View our coding standards and best practices
- name: 🔒 Report a Security Vulnerability - name: 🔒 Report a Security Vulnerability
url: https://git.mokoconsulting.tech/mokoconsulting-tech/.github-private/security/advisories/new url: https://git.mokoconsulting.tech/mokoconsulting-tech/.github-private/security/advisories/new
+1 -1
View File
@@ -42,7 +42,7 @@ Suggested text here
<!-- Add any other context, screenshots, or references --> <!-- Add any other context, screenshots, or references -->
## Standards Alignment ## Standards Alignment
- [ ] Follows mokocli documentation guidelines - [ ] Follows moko-platform documentation guidelines
- [ ] Uses en_US/en_GB localization - [ ] Uses en_US/en_GB localization
- [ ] Includes proper SPDX headers where applicable - [ ] Includes proper SPDX headers where applicable
+1 -1
View File
@@ -37,7 +37,7 @@ If you have ideas about how this could be implemented, share them here:
Add any other context, mockups, or screenshots about the feature request here. Add any other context, mockups, or screenshots about the feature request here.
## Relevant Standards ## Relevant Standards
Does this relate to any standards in [mokocli](https://git.mokoconsulting.tech/MokoConsulting/mokocli)? Does this relate to any standards in [moko-platform](https://git.mokoconsulting.tech/MokoConsulting/moko-platform)?
- [ ] Accessibility (WCAG 2.1 AA) - [ ] Accessibility (WCAG 2.1 AA)
- [ ] Localization (en_US/en_GB) - [ ] Localization (en_US/en_GB)
- [ ] Security best practices - [ ] Security best practices
+1 -1
View File
@@ -35,7 +35,7 @@ Use this template only for:
<!-- Describe how this could be addressed --> <!-- Describe how this could be addressed -->
## Standards Reference ## Standards Reference
Does this relate to security standards in [mokocli](https://git.mokoconsulting.tech/MokoConsulting/mokocli)? Does this relate to security standards in [moko-platform](https://git.mokoconsulting.tech/MokoConsulting/moko-platform)?
- [ ] SPDX license identifiers - [ ] SPDX license identifiers
- [ ] Secret management - [ ] Secret management
- [ ] Dependency security - [ ] Dependency security
+6 -6
View File
@@ -2,8 +2,8 @@
# SPDX-License-Identifier: GPL-3.0-or-later # SPDX-License-Identifier: GPL-3.0-or-later
# FILE INFORMATION # FILE INFORMATION
# DEFGROUP: Gitea.Workflow # DEFGROUP: Gitea.Workflow
# INGROUP: mokocli.Automation # INGROUP: moko-platform.Automation
# REPO: https://git.mokoconsulting.tech/MokoConsulting/mokocli # REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform
# PATH: /.gitea/workflows/branch-protection.yml # PATH: /.gitea/workflows/branch-protection.yml
# BRIEF: Apply standardised branch protection rules to all governed repositories # BRIEF: Apply standardised branch protection rules to all governed repositories
# #
@@ -57,13 +57,13 @@ jobs:
- name: Determine target repos - name: Determine target repos
id: repos id: repos
env: env:
GA_TOKEN: ${{ secrets.MOKOGITEA_TOKEN }} GA_TOKEN: ${{ secrets.GA_TOKEN }}
run: | run: |
API="${GITEA_URL}/api/v1" API="${GITEA_URL}/api/v1"
# Platform/standards/infra repos to exclude # Platform/standards/infra repos to exclude
EXCLUDE="gitea-org-config org-profile gitea-private .mokogitea-private mokocli MokoTesting" EXCLUDE="gitea-org-config org-profile gitea-private .mokogitea-private moko-platform MokoTesting"
EXCLUDE="$EXCLUDE MokoCli-Template-Client MokoCli-Template-Dolibarr MokoCli-Template-Generic MokoCli-Template-Joomla MokoDoliProjTemplate" EXCLUDE="$EXCLUDE MokoStandards-Template-Client MokoStandards-Template-Dolibarr MokoStandards-Template-Generic MokoStandards-Template-Joomla MokoDoliProjTemplate"
if [ -n "${{ inputs.repos }}" ]; then if [ -n "${{ inputs.repos }}" ]; then
# User-specified repos # User-specified repos
@@ -105,7 +105,7 @@ jobs:
- name: Apply protection rules - name: Apply protection rules
env: env:
GA_TOKEN: ${{ secrets.MOKOGITEA_TOKEN }} GA_TOKEN: ${{ secrets.GA_TOKEN }}
DRY_RUN: ${{ inputs.dry_run || 'false' }} DRY_RUN: ${{ inputs.dry_run || 'false' }}
run: | run: |
API="${GITEA_URL}/api/v1" API="${GITEA_URL}/api/v1"
+5 -5
View File
@@ -2,8 +2,8 @@
# SPDX-License-Identifier: GPL-3.0-or-later # SPDX-License-Identifier: GPL-3.0-or-later
# FILE INFORMATION # FILE INFORMATION
# DEFGROUP: Gitea.Workflow # DEFGROUP: Gitea.Workflow
# INGROUP: mokocli.Automation # INGROUP: moko-platform.Automation
# REPO: https://git.mokoconsulting.tech/MokoConsulting/mokocli # REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform
# PATH: /.gitea/workflows/bulk-repo-sync.yml # PATH: /.gitea/workflows/bulk-repo-sync.yml
# BRIEF: Bulk repo sync — runs from API repo, syncs standards to all governed repos # BRIEF: Bulk repo sync — runs from API repo, syncs standards to all governed repos
@@ -84,8 +84,8 @@ jobs:
echo "Running: php automation/bulk_sync.php ${{ steps.args.outputs.args }}" echo "Running: php automation/bulk_sync.php ${{ steps.args.outputs.args }}"
php automation/bulk_sync.php ${{ steps.args.outputs.args }} 2>&1 | tee /tmp/bulk_sync.log php automation/bulk_sync.php ${{ steps.args.outputs.args }} 2>&1 | tee /tmp/bulk_sync.log
env: env:
GA_TOKEN: ${{ secrets.MOKOGITEA_TOKEN }} GA_TOKEN: ${{ secrets.GA_TOKEN }}
GH_TOKEN: ${{ secrets.GH_PAT }} GH_TOKEN: ${{ secrets.GH_TOKEN }}
GIT_PLATFORM: gitea GIT_PLATFORM: gitea
GITEA_URL: https://git.mokoconsulting.tech GITEA_URL: https://git.mokoconsulting.tech
GITEA_ORG: MokoConsulting GITEA_ORG: MokoConsulting
@@ -112,7 +112,7 @@ jobs:
bash automation/enforce_tags.sh || echo "Tag enforcement had errors (non-fatal)" bash automation/enforce_tags.sh || echo "Tag enforcement had errors (non-fatal)"
fi fi
env: env:
GA_TOKEN: ${{ secrets.MOKOGITEA_TOKEN }} GA_TOKEN: ${{ secrets.GA_TOKEN }}
GITEA_URL: https://git.mokoconsulting.tech GITEA_URL: https://git.mokoconsulting.tech
GITEA_ORG: MokoConsulting GITEA_ORG: MokoConsulting
+3 -3
View File
@@ -2,9 +2,9 @@
# SPDX-License-Identifier: GPL-3.0-or-later # SPDX-License-Identifier: GPL-3.0-or-later
# #
# FILE INFORMATION # FILE INFORMATION
# DEFGROUP: mokocli.CI # DEFGROUP: moko-platform.CI
# INGROUP: mokocli # INGROUP: moko-platform
# REPO: https://git.mokoconsulting.tech/MokoConsulting/mokocli # REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform
# PATH: /.gitea/workflows/pr-branch-check.yml # PATH: /.gitea/workflows/pr-branch-check.yml
# BRIEF: PR branch merge policy enforcement # BRIEF: PR branch merge policy enforcement
# #
+6 -6
View File
@@ -4,8 +4,8 @@
# #
# FILE INFORMATION # FILE INFORMATION
# DEFGROUP: Gitea.Workflow # DEFGROUP: Gitea.Workflow
# INGROUP: mokocli.Automation # INGROUP: moko-platform.Automation
# REPO: https://git.mokoconsulting.tech/MokoConsulting/mokocli # REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform
# PATH: /.gitea/workflows/renovate.yml # PATH: /.gitea/workflows/renovate.yml
# BRIEF: Run Renovate Bot across all governed repos for dependency updates # BRIEF: Run Renovate Bot across all governed repos for dependency updates
# #
@@ -57,12 +57,12 @@ jobs:
- name: Determine target repos - name: Determine target repos
id: repos id: repos
env: env:
GA_TOKEN: ${{ secrets.MOKOGITEA_TOKEN }} GA_TOKEN: ${{ secrets.GA_TOKEN }}
run: | run: |
API="${GITEA_URL}/api/v1" API="${GITEA_URL}/api/v1"
EXCLUDE="gitea-org-config org-profile gitea-private .mokogitea-private mokocli MokoTesting" EXCLUDE="gitea-org-config org-profile gitea-private .mokogitea-private moko-platform MokoTesting"
EXCLUDE="$EXCLUDE MokoCli-Template-Client MokoCli-Template-Dolibarr MokoCli-Template-Generic MokoCli-Template-Joomla MokoDoliProjTemplate" EXCLUDE="$EXCLUDE MokoStandards-Template-Client MokoStandards-Template-Dolibarr MokoStandards-Template-Generic MokoStandards-Template-Joomla MokoDoliProjTemplate"
if [ -n "${{ inputs.repos }}" ]; then if [ -n "${{ inputs.repos }}" ]; then
REPOS=$(echo "${{ inputs.repos }}" | tr ',' ' ') REPOS=$(echo "${{ inputs.repos }}" | tr ',' ' ')
@@ -107,7 +107,7 @@ jobs:
- name: Run Renovate - name: Run Renovate
if: steps.repos.outputs.repo_list != '' if: steps.repos.outputs.repo_list != ''
env: env:
RENOVATE_TOKEN: ${{ secrets.MOKOGITEA_TOKEN }} RENOVATE_TOKEN: ${{ secrets.GA_TOKEN }}
RENOVATE_PLATFORM: gitea RENOVATE_PLATFORM: gitea
RENOVATE_ENDPOINT: ${{ env.GITEA_URL }}/api/v1 RENOVATE_ENDPOINT: ${{ env.GITEA_URL }}/api/v1
RENOVATE_GIT_AUTHOR: 'Renovate Bot <renovate@mokoconsulting.tech>' RENOVATE_GIT_AUTHOR: 'Renovate Bot <renovate@mokoconsulting.tech>'
+3 -3
View File
@@ -4,8 +4,8 @@
# #
# FILE INFORMATION # FILE INFORMATION
# DEFGROUP: Gitea.Workflow # DEFGROUP: Gitea.Workflow
# INGROUP: mokocli.Maintenance # INGROUP: moko-platform.Maintenance
# REPO: https://git.mokoconsulting.tech/MokoConsulting/mokocli # REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform
# PATH: /.gitea/workflows/sync-wikis.yml # PATH: /.gitea/workflows/sync-wikis.yml
# BRIEF: Daily sync of all Gitea wikis to consolidated GitHub wiki repo # BRIEF: Daily sync of all Gitea wikis to consolidated GitHub wiki repo
@@ -31,7 +31,7 @@ jobs:
- name: Sync all wikis - name: Sync all wikis
env: env:
GH_TOKEN: ${{ secrets.GH_PAT }} GH_TOKEN: ${{ secrets.GH_TOKEN }}
GITEA_URL: ${{ vars.GITEA_URL || 'https://git.mokoconsulting.tech' }} GITEA_URL: ${{ vars.GITEA_URL || 'https://git.mokoconsulting.tech' }}
run: | run: |
if [ -z "$GH_TOKEN" ]; then if [ -z "$GH_TOKEN" ]; then
+10 -10
View File
@@ -4,10 +4,10 @@
# #
# FILE INFORMATION # FILE INFORMATION
# DEFGROUP: Gitea.Workflow # DEFGROUP: Gitea.Workflow
# INGROUP: mokocli.Release # INGROUP: moko-platform.Release
# REPO: https://git.mokoconsulting.tech/MokoConsulting/mokocli # REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform
# PATH: /.mokogitea/workflows/auto-bump.yml # PATH: /.mokogitea/workflows/auto-bump.yml
# VERSION: 09.02.00 # VERSION: 09.23.00
# BRIEF: Auto patch-bump version on every push to dev (skips merge commits) # BRIEF: Auto patch-bump version on every push to dev (skips merge commits)
name: "Universal: Auto Version Bump" name: "Universal: Auto Version Bump"
@@ -43,19 +43,19 @@ jobs:
token: ${{ secrets.MOKOGITEA_TOKEN }} token: ${{ secrets.MOKOGITEA_TOKEN }}
fetch-depth: 1 fetch-depth: 1
- name: Setup mokocli tools - name: Setup moko-platform tools
run: | run: |
if ! command -v composer &> /dev/null; then if ! command -v composer &> /dev/null; then
sudo apt-get update -qq && sudo apt-get install -y -qq php-cli php-mbstring php-xml php-zip php-curl composer >/dev/null 2>&1 sudo apt-get update -qq && sudo apt-get install -y -qq php-cli php-mbstring php-xml php-zip php-curl composer >/dev/null 2>&1
fi fi
if [ -d "/opt/mokocli/cli" ]; then if [ -d "/opt/moko-platform/cli" ]; then
echo "MOKO_CLI=/opt/mokocli/cli" >> "$GITHUB_ENV" echo "MOKO_CLI=/opt/moko-platform/cli" >> "$GITHUB_ENV"
else else
git clone --depth 1 --branch main --quiet \ git clone --depth 1 --branch main --quiet \
"https://x-access-token:${{ secrets.MOKOGITEA_TOKEN }}@git.mokoconsulting.tech/MokoConsulting/mokocli.git" \ "https://x-access-token:${{ secrets.MOKOGITEA_TOKEN }}@git.mokoconsulting.tech/MokoConsulting/moko-platform.git" \
/tmp/mokocli /tmp/moko-platform-api
cd /tmp/mokocli && composer install --no-dev --no-interaction --quiet cd /tmp/moko-platform-api && composer install --no-dev --no-interaction --quiet
echo "MOKO_CLI=/tmp/mokocli/cli" >> "$GITHUB_ENV" echo "MOKO_CLI=/tmp/moko-platform-api/cli" >> "$GITHUB_ENV"
fi fi
- name: Bump version - name: Bump version
+285 -466
View File
@@ -1,466 +1,285 @@
# 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
# #
# FILE INFORMATION # FILE INFORMATION
# DEFGROUP: Gitea.Workflow # DEFGROUP: Gitea.Workflow
# INGROUP: mokocli.Release # INGROUP: moko-platform.Release
# REPO: https://git.mokoconsulting.tech/mokoconsulting-tech/mokocli # REPO: https://git.mokoconsulting.tech/mokoconsulting-tech/moko-platform
# PATH: /templates/workflows/universal/auto-release.yml.template # PATH: /templates/workflows/universal/auto-release.yml.template
# VERSION: 05.00.00 # VERSION: 05.00.00
# BRIEF: Universal build & release detects platform from manifest.xml # BRIEF: Universal build & release detects platform from manifest.xml
# #
# +=======================================================================+ # +========================================================================+
# | UNIVERSAL BUILD & RELEASE PIPELINE | # | UNIVERSAL BUILD & RELEASE PIPELINE |
# +=======================================================================+ # +========================================================================+
# | | # | |
# | Reads manifest.xml (joomla|dolibarr|generic) to branch logic. | # | Reads manifest.xml (joomla|dolibarr|generic) to branch logic. |
# | | # | |
# | Platform-specific: | # | Platform-specific: |
# | joomla: XML manifest, type-prefixed packages | # | joomla: XML manifest, updates.xml, type-prefixed packages |
# | dolibarr: mod*.class.php, update.txt, dev version reset | # | dolibarr: mod*.class.php, update.txt, dev version reset |
# | generic: README-only, no update stream | # | generic: README-only, no update stream |
# | | # | |
# +=======================================================================+ # +========================================================================+
name: "Universal: Build & Release" name: "Universal: Build & Release"
on: on:
pull_request: pull_request:
types: [opened, closed] types: [opened, closed]
branches: branches:
- main - main
paths-ignore: workflow_dispatch:
- '.mokogitea/workflows/**' inputs:
- '*.md' action:
- 'wiki/**' description: 'Action to perform'
- '.editorconfig' required: false
- '.gitignore' type: choice
- '.gitattributes' default: release
- '.gitmessage' options:
- 'LICENSE' - release
workflow_dispatch: - promote-rc
inputs:
action: env:
description: 'Action to perform' FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
required: false GITEA_URL: ${{ vars.GITEA_URL || 'https://git.mokoconsulting.tech' }}
type: choice GITEA_ORG: ${{ vars.GITEA_ORG || github.repository_owner }}
default: release GITEA_REPO: ${{ vars.GITEA_REPO || github.event.repository.name }}
options:
- release permissions:
- promote-rc contents: write
env: jobs:
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true # ── PR Opened → Rename branch to RC and build RC release ─────────────────────
GITEA_URL: ${{ vars.GITEA_URL || 'https://git.mokoconsulting.tech' }} promote-rc:
GITEA_ORG: ${{ vars.GITEA_ORG || github.repository_owner }} name: Promote to RC
GITEA_REPO: ${{ vars.GITEA_REPO || github.event.repository.name }} runs-on: release
if: >-
permissions: (github.event.action == 'opened' && github.event.pull_request.merged != true) ||
contents: write (github.event_name == 'workflow_dispatch' && inputs.action == 'promote-rc')
jobs: steps:
# ── PR Opened → Rename branch to RC and build RC release ───────────────────────── - name: Checkout repository
promote-rc: uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
name: Promote to RC with:
runs-on: release token: ${{ secrets.MOKOGITEA_TOKEN }}
if: >- fetch-depth: 1
(github.event.action == 'opened' && github.event.pull_request.merged != true) ||
(github.event_name == 'workflow_dispatch' && inputs.action == 'promote-rc') - name: Setup moko-platform tools
env:
steps: MOKO_CLONE_TOKEN: ${{ secrets.MOKOGITEA_TOKEN }}
- name: Checkout repository MOKO_CLONE_HOST: git.mokoconsulting.tech/MokoConsulting
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 run: |
with: if ! command -v composer &> /dev/null; then
token: ${{ secrets.MOKOGITEA_TOKEN }} sudo apt-get update -qq && sudo apt-get install -y -qq php-cli php-mbstring php-xml php-zip php-curl composer >/dev/null 2>&1
fetch-depth: 1 fi
# Always fetch latest CLI tools — never use stale cache from previous runs
- name: Setup mokocli tools rm -rf /tmp/moko-platform-api
env: git clone --depth 1 --branch main --quiet \
MOKO_CLONE_TOKEN: ${{ secrets.MOKOGITEA_TOKEN }} "https://x-access-token:${MOKO_CLONE_TOKEN}@${MOKO_CLONE_HOST}/moko-platform.git" \
MOKO_CLONE_HOST: git.mokoconsulting.tech/MokoConsulting /tmp/moko-platform-api
run: | cd /tmp/moko-platform-api
if [ -f /opt/mokocli/cli/version_bump.php ] && [ -f /opt/mokocli/vendor/autoload.php ]; then composer install --no-dev --no-interaction --quiet
echo Using pre-installed /opt/mokocli
echo MOKO_CLI=/opt/mokocli/cli >> $GITHUB_ENV - name: Rename branch to rc
else run: |
echo Falling back to fresh clone php /tmp/moko-platform-api/cli/branch_rename.php \
if ! command -v composer > /dev/null 2>&1; then --from "${{ github.event.pull_request.head.ref || 'dev' }}" --to rc \
sudo apt-get update -qq && sudo apt-get install -y -qq php-cli php-mbstring php-xml php-zip php-curl composer > /dev/null 2>&1 --token "${{ secrets.MOKOGITEA_TOKEN }}" \
fi --api-base "${GITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}" \
rm -rf /tmp/mokocli --pr "${{ github.event.pull_request.number }}"
CLONE_URL=https://x-access-token:${MOKO_CLONE_TOKEN}@${MOKO_CLONE_HOST}/mokocli.git
git clone --depth 1 --branch main --quiet $CLONE_URL /tmp/mokocli - name: Checkout rc and configure git
cd /tmp/mokocli run: |
composer install --no-dev --no-interaction --quiet git fetch origin rc
echo MOKO_CLI=/tmp/mokocli/cli >> $GITHUB_ENV git checkout rc
fi git config --local user.email "gitea-actions[bot]@mokoconsulting.tech"
git config --local user.name "gitea-actions[bot]"
- name: Rename branch to rc git remote set-url origin "https://x-access-token:${{ secrets.MOKOGITEA_TOKEN }}@git.mokoconsulting.tech/${{ github.repository }}.git"
run: |
php ${MOKO_CLI}/branch_rename.php \ - name: Publish RC release
--from "${{ github.event.pull_request.head.ref || 'dev' }}" --to rc \ run: |
--token "${{ secrets.MOKOGITEA_TOKEN }}" \ php /tmp/moko-platform-api/cli/release_publish.php \
--api-base "${GITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}" \ --path . --stability rc --bump minor --branch rc \
--pr "${{ github.event.pull_request.number }}" --token "${{ secrets.MOKOGITEA_TOKEN }}" \
--skip-update-stream
- name: Checkout rc and configure git
run: | - name: Summary
git fetch origin rc if: always()
git checkout rc run: |
git config --local user.email "gitea-actions[bot]@mokoconsulting.tech" echo "## Promoted to Release Candidate" >> $GITHUB_STEP_SUMMARY
git config --local user.name "gitea-actions[bot]" echo "Branch renamed to rc, minor bump, RC release built (updates.xml managed by Gitea Pages)" >> $GITHUB_STEP_SUMMARY
git remote set-url origin "https://x-access-token:${{ secrets.MOKOGITEA_TOKEN }}@git.mokoconsulting.tech/${{ github.repository }}.git"
# ── Merged PR → Build & Release (or promote RC to stable) ────────────────────
- name: Publish RC release release:
run: | name: Build & Release Pipeline
php ${MOKO_CLI}/release_publish.php \ runs-on: release
--path . --stability rc --bump minor --branch rc \ if: >-
--token "${{ secrets.MOKOGITEA_TOKEN }}" github.event.pull_request.merged == true ||
(github.event_name == 'workflow_dispatch' && inputs.action != 'promote-rc')
- name: Update RC release notes from CHANGELOG.md
run: | steps:
API_BASE="${GITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}" - name: Checkout repository
TOKEN="${{ secrets.MOKOGITEA_TOKEN }}" uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
with:
# Extract [Unreleased] section from changelog token: ${{ secrets.MOKOGITEA_TOKEN }}
NOTES="" fetch-depth: 0
if [ -f "CHANGELOG.md" ]; then
NOTES=$(awk '/^## \[Unreleased\]/{found=1; next} /^## \[/{if(found) exit} found{print}' CHANGELOG.md) - name: Configure git for bot pushes
fi run: |
[ -z "$NOTES" ] && NOTES="Release candidate" git config --local user.email "gitea-actions[bot]@mokoconsulting.tech"
git config --local user.name "gitea-actions[bot]"
# Find the RC release and update its body git remote set-url origin "https://x-access-token:${{ secrets.MOKOGITEA_TOKEN }}@git.mokoconsulting.tech/${{ github.repository }}.git"
RELEASE_ID=$(curl -sf -H "Authorization: token ${TOKEN}" \
"${API_BASE}/releases/tags/release-candidate" \ - name: Check for merge conflict markers
| python3 -c "import json,sys; print(json.load(sys.stdin).get('id',''))" 2>/dev/null || true) run: |
CONFLICTS=$(grep -rn '<<<<<<< \|>>>>>>> \|^=======$' --include='*.php' --include='*.xml' --include='*.css' --include='*.js' --include='*.json' --include='*.md' --include='*.yml' --include='*.yaml' --include='*.ini' --include='*.txt' . 2>/dev/null | grep -v '.git/' || true)
if [ -n "$RELEASE_ID" ]; then if [ -n "$CONFLICTS" ]; then
python3 -c " echo "::error::Merge conflict markers found — aborting release"
import json, urllib.request echo "## Release Blocked: Conflict Markers" >> $GITHUB_STEP_SUMMARY
body = open('/dev/stdin').read() echo '```' >> $GITHUB_STEP_SUMMARY
payload = json.dumps({'body': body}).encode() echo "$CONFLICTS" >> $GITHUB_STEP_SUMMARY
req = urllib.request.Request( echo '```' >> $GITHUB_STEP_SUMMARY
'${API_BASE}/releases/${RELEASE_ID}', exit 1
data=payload, method='PATCH', fi
headers={ echo "No conflict markers found"
'Authorization': 'token ${TOKEN}',
'Content-Type': 'application/json' - name: Setup moko-platform tools
}) env:
urllib.request.urlopen(req) MOKO_CLONE_TOKEN: ${{ secrets.MOKOGITEA_TOKEN }}
" <<< "$NOTES" MOKO_CLONE_HOST: git.mokoconsulting.tech/MokoConsulting
echo "RC release notes updated from CHANGELOG.md" COMPOSER_AUTH: '{"github-oauth":{"github.com":"${{ secrets.GH_MIRROR_TOKEN }}"}}'
fi run: |
# Ensure PHP + Composer are available
- name: Summary if ! command -v composer &> /dev/null; then
if: always() sudo apt-get update -qq && sudo apt-get install -y -qq php-cli php-mbstring php-xml php-zip php-curl composer >/dev/null 2>&1
run: | fi
echo "## Promoted to Release Candidate" >> $GITHUB_STEP_SUMMARY # Always fetch latest CLI tools — never use stale cache from previous runs
echo "Branch renamed to rc, minor bump, RC release built" >> $GITHUB_STEP_SUMMARY rm -rf /tmp/moko-platform-api
git clone --depth 1 --branch main --quiet \
# ── Merged PR → Build & Release (or promote RC to stable) ───────────────────────── "https://x-access-token:${MOKO_CLONE_TOKEN}@${MOKO_CLONE_HOST}/moko-platform.git" \
release: /tmp/moko-platform-api
name: Build & Release Pipeline cd /tmp/moko-platform-api
runs-on: release composer install --no-dev --no-interaction --quiet
if: >-
github.event.pull_request.merged == true ||
(github.event_name == 'workflow_dispatch' && inputs.action != 'promote-rc') - name: "Publish stable release"
run: |
steps: php /tmp/moko-platform-api/cli/release_publish.php \
- name: Checkout repository --path . --stability stable --bump minor --branch main \
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 --token "${{ secrets.MOKOGITEA_TOKEN }}" \
with: --skip-update-stream
token: ${{ secrets.MOKOGITEA_TOKEN }}
fetch-depth: 0 # -- STEP 9: Mirror to GitHub (stable only) --------------------------------
- name: "Step 9: Mirror release to GitHub"
- name: Configure git for bot pushes if: >-
run: | steps.version.outputs.skip != 'true' &&
git config --local user.email "gitea-actions[bot]@mokoconsulting.tech" secrets.GH_MIRROR_TOKEN != ''
git config --local user.name "gitea-actions[bot]" continue-on-error: true
git remote set-url origin "https://x-access-token:${{ secrets.MOKOGITEA_TOKEN }}@git.mokoconsulting.tech/${{ github.repository }}.git" run: |
VERSION="${{ steps.bump.outputs.version || steps.version.outputs.version }}"
- name: Check for merge conflict markers RELEASE_TAG="${{ steps.version.outputs.release_tag }}"
run: | GH_REPO="${{ vars.GH_MIRROR_REPO || github.repository }}"
CONFLICTS=$(grep -rn '<<<<<<< \|>>>>>>> \|^=======$' --include='*.php' --include='*.xml' --include='*.css' --include='*.js' --include='*.json' --include='*.md' --include='*.yml' --include='*.yaml' --include='*.ini' --include='*.txt' . 2>/dev/null | grep -v '.git/' || true) API_BASE="${GITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}"
if [ -n "$CONFLICTS" ]; then php /tmp/moko-platform-api/cli/release_mirror.php \
echo "::error::Merge conflict markers found — aborting release" --version "$VERSION" --tag "$RELEASE_TAG" \
echo "## Release Blocked: Conflict Markers" >> $GITHUB_STEP_SUMMARY --token "${{ secrets.MOKOGITEA_TOKEN }}" --api-base "$API_BASE" \
echo '```' >> $GITHUB_STEP_SUMMARY --gh-token "${{ secrets.GH_MIRROR_TOKEN }}" --gh-repo "$GH_REPO" \
echo "$CONFLICTS" >> $GITHUB_STEP_SUMMARY --branch main 2>&1 || true
echo '```' >> $GITHUB_STEP_SUMMARY echo "GitHub mirror updated" >> $GITHUB_STEP_SUMMARY
exit 1
fi # -- STEP 10: Sync main branch to GitHub mirror ----------------------------
echo "No conflict markers found" - name: "Step 10: Push main to GitHub mirror"
if: >-
- name: Setup mokocli tools steps.version.outputs.skip != 'true' &&
env: secrets.GH_MIRROR_TOKEN != ''
MOKO_CLONE_TOKEN: ${{ secrets.MOKOGITEA_TOKEN }} continue-on-error: true
MOKO_CLONE_HOST: git.mokoconsulting.tech/MokoConsulting run: |
COMPOSER_AUTH: '{"github-oauth":{"github.com":"${{ secrets.GH_MIRROR_TOKEN }}"}}' GH_REPO="${{ vars.GH_MIRROR_REPO || github.repository }}"
run: | GH_ORG=$(echo "$GH_REPO" | cut -d/ -f1)
if [ -f /opt/mokocli/cli/version_bump.php ] && [ -f /opt/mokocli/vendor/autoload.php ]; then GH_NAME=$(echo "$GH_REPO" | cut -d/ -f2)
echo Using pre-installed /opt/mokocli git remote add github "https://x-access-token:${{ secrets.GH_MIRROR_TOKEN }}@github.com/${GH_ORG}/${GH_NAME}.git" 2>/dev/null || \
echo MOKO_CLI=/opt/mokocli/cli >> $GITHUB_ENV git remote set-url github "https://x-access-token:${{ secrets.GH_MIRROR_TOKEN }}@github.com/${GH_ORG}/${GH_NAME}.git"
else git fetch origin main --depth=1
echo Falling back to fresh clone git push github origin/main:refs/heads/main --force 2>/dev/null \
if ! command -v composer > /dev/null 2>&1; then && echo "main branch pushed to GitHub mirror" \
sudo apt-get update -qq && sudo apt-get install -y -qq php-cli php-mbstring php-xml php-zip php-curl composer > /dev/null 2>&1 || echo "WARNING: GitHub mirror push failed"
fi
rm -rf /tmp/mokocli - name: "Step 11: Delete rc branch and recreate dev from main"
CLONE_URL=https://x-access-token:${MOKO_CLONE_TOKEN}@${MOKO_CLONE_HOST}/mokocli.git if: steps.version.outputs.skip != 'true'
git clone --depth 1 --branch main --quiet $CLONE_URL /tmp/mokocli continue-on-error: true
cd /tmp/mokocli run: |
composer install --no-dev --no-interaction --quiet API_BASE="${GITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}"
echo MOKO_CLI=/tmp/mokocli/cli >> $GITHUB_ENV TOKEN="${{ secrets.MOKOGITEA_TOKEN }}"
fi
# Delete rc branch (ephemeral — created by promote-rc)
- name: "Detect platform" curl -sf -X DELETE -H "Authorization: token ${TOKEN}" \
id: platform "${API_BASE}/branches/rc" 2>/dev/null \
run: | && echo "Deleted rc branch" || echo "rc branch not found"
php ${MOKO_CLI}/platform_detect.php --path . --github-output 2>/dev/null || true
php ${MOKO_CLI}/manifest_read.php --path . --github-output 2>/dev/null || true # Delete dev branch
curl -sf -X DELETE -H "Authorization: token ${TOKEN}" \
- name: "Determine version bump level" "${API_BASE}/branches/dev" 2>/dev/null && echo "Deleted dev branch"
id: bump
run: | # Recreate dev from main (now includes version bump + changelog promotion)
# Fix/patch branches: version was already bumped by pre-release, just strip suffix curl -sf -X POST -H "Authorization: token ${TOKEN}" \
# Feature/dev branches: bump minor for the new stable release -H "Content-Type: application/json" \
HEAD_REF="${{ github.event.pull_request.head.ref || 'dev' }}" "${API_BASE}/branches" \
case "$HEAD_REF" in -d '{"new_branch_name":"dev","old_branch_name":"main"}' 2>/dev/null && echo "Recreated dev from main"
fix/*|patch/*|hotfix/*|bugfix/*) BUMP="none" ;;
*) BUMP="minor" ;; echo "Pre-release branches cleaned, dev reset from main" >> $GITHUB_STEP_SUMMARY
esac
echo "level=${BUMP}" >> "$GITHUB_OUTPUT" - name: "Step 12: Create version branch from main"
echo "Bump level: ${BUMP} (from branch: ${HEAD_REF})" if: steps.version.outputs.skip != 'true'
continue-on-error: true
- name: "Publish stable release" run: |
run: | API_BASE="${GITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}"
BUMP_FLAG="" TOKEN="${{ secrets.MOKOGITEA_TOKEN }}"
if [ "${{ steps.bump.outputs.level }}" != "none" ]; then VERSION="${{ steps.bump.outputs.version || steps.version.outputs.version }}"
BUMP_FLAG="--bump ${{ steps.bump.outputs.level }}" BRANCH_NAME="version/${VERSION}"
fi MAIN_SHA=$(git rev-parse HEAD)
php ${MOKO_CLI}/release_publish.php \
--path . --stability stable ${BUMP_FLAG} --branch main \ # Delete old version branch if it exists (same version re-release)
--token "${{ secrets.MOKOGITEA_TOKEN }}" curl -sf -X DELETE -H "Authorization: token ${TOKEN}" "${API_BASE}/branches/${BRANCH_NAME}" 2>/dev/null && echo "Deleted old ${BRANCH_NAME}"
- name: "Read published version" # Create version/XX.YY.ZZ from main
id: version curl -sf -X POST -H "Authorization: token ${TOKEN}" -H "Content-Type: application/json" "${API_BASE}/branches" -d "{\"new_branch_name\":\"${BRANCH_NAME}\",\"old_branch_name\":\"main\"}" 2>/dev/null && echo "Created ${BRANCH_NAME} from main (${MAIN_SHA})" || echo "WARNING: ${BRANCH_NAME} creation failed"
run: |
VERSION=$(php ${MOKO_CLI}/version_read.php --path . 2>/dev/null || echo "") echo "Version branch created: ${BRANCH_NAME} (${MAIN_SHA})" >> $GITHUB_STEP_SUMMARY
VERSION=$(echo "$VERSION" | sed 's/-\(dev\|alpha\|beta\|rc\)$//')
[ -z "$VERSION" ] && VERSION="00.00.00" && echo "skip=true" >> "$GITHUB_OUTPUT"
echo "version=${VERSION}" >> "$GITHUB_OUTPUT"
PLATFORM="${{ steps.platform.outputs.platform }}" # -- Dolibarr post-release: Reset dev version -----------------------------
if [[ "$PLATFORM" == joomla* ]]; then - name: "Post-release: Reset dev version"
echo "tag=stable" >> "$GITHUB_OUTPUT" if: steps.version.outputs.skip != 'true'
echo "release_tag=stable" >> "$GITHUB_OUTPUT" continue-on-error: true
else run: |
echo "tag=v${VERSION}" >> "$GITHUB_OUTPUT" API_BASE="${GITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}"
echo "release_tag=v${VERSION}" >> "$GITHUB_OUTPUT" php /tmp/moko-platform-api/cli/version_reset_dev.php \
fi --token "${{ secrets.MOKOGITEA_TOKEN }}" --api-base "${API_BASE}" \
echo "branch=main" >> "$GITHUB_OUTPUT" --branch dev --path . 2>&1 || true
echo "Published version: ${VERSION}"
# -- Summary --------------------------------------------------------------
- name: "Create semver tag for non-Joomla repos" - name: Pipeline Summary
id: semver if: always()
if: | run: |
steps.version.outputs.skip != 'true' && VERSION="${{ steps.bump.outputs.version || steps.version.outputs.version }}"
!startsWith(steps.platform.outputs.platform, 'joomla') PLATFORM="${{ steps.platform.outputs.platform }}"
run: | if [ "${{ steps.version.outputs.skip }}" = "true" ]; then
VERSION="${{ steps.version.outputs.version }}" echo "## Release Skipped" >> $GITHUB_STEP_SUMMARY
API_BASE="${GITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}" echo "No VERSION in README.md" >> $GITHUB_STEP_SUMMARY
TOKEN="${{ secrets.MOKOGITEA_TOKEN }}" elif [ "${{ steps.check.outputs.already_released }}" = "true" ]; then
SEMVER_TAG="v${VERSION}" echo "## Already Released — ${VERSION}" >> $GITHUB_STEP_SUMMARY
else
echo "Creating semver tag: ${SEMVER_TAG}" echo "" >> $GITHUB_STEP_SUMMARY
echo "## Build & Release Complete (${PLATFORM})" >> $GITHUB_STEP_SUMMARY
# Create the git tag via API echo "" >> $GITHUB_STEP_SUMMARY
HTTP_CODE=$(curl -sf -o /dev/null -w "%{http_code}" \ echo "| Step | Result |" >> $GITHUB_STEP_SUMMARY
-X POST -H "Authorization: token ${TOKEN}" \ echo "|------|--------|" >> $GITHUB_STEP_SUMMARY
-H "Content-Type: application/json" \ echo "| Platform | \`${PLATFORM}\` |" >> $GITHUB_STEP_SUMMARY
"${API_BASE}/tags" \ echo "| Version | \`${VERSION}\` |" >> $GITHUB_STEP_SUMMARY
-d "{\"tag_name\":\"${SEMVER_TAG}\",\"target\":\"main\",\"message\":\"Release ${VERSION}\"}" 2>/dev/null || echo "000") echo "| Branch | \`${{ steps.version.outputs.branch }}\` |" >> $GITHUB_STEP_SUMMARY
echo "| Tag | \`${{ steps.version.outputs.tag }}\` |" >> $GITHUB_STEP_SUMMARY
if [ "$HTTP_CODE" = "201" ] || [ "$HTTP_CODE" = "200" ]; then echo "| Release | [View](${GITEA_URL}/${GITEA_ORG}/${GITEA_REPO}/releases/tag/${{ steps.version.outputs.tag }}) |" >> $GITHUB_STEP_SUMMARY
echo "Created semver tag: ${SEMVER_TAG}" fi
elif [ "$HTTP_CODE" = "409" ]; then
echo "Semver tag ${SEMVER_TAG} already exists (skipped)"
else
echo "::warning::Failed to create semver tag ${SEMVER_TAG} (HTTP ${HTTP_CODE})"
fi
echo "semver_tag=${SEMVER_TAG}" >> "$GITHUB_OUTPUT"
- name: Update release notes and promote changelog
run: |
API_BASE="${GITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}"
TOKEN="${{ secrets.MOKOGITEA_TOKEN }}"
# Get the stable release info (version and ID)
RELEASE_JSON=$(curl -sf -H "Authorization: token ${TOKEN}" \
"${API_BASE}/releases/tags/stable" 2>/dev/null || echo '{}')
RELEASE_ID=$(python3 -c "import json,sys; print(json.load(sys.stdin).get('id',''))" <<< "$RELEASE_JSON" 2>/dev/null || true)
# Extract version from release name (e.g. "06.17.00" or "v06.17.00")
VERSION=$(python3 -c "
import json, sys, re
r = json.load(sys.stdin)
name = r.get('name', '')
m = re.search(r'(\d+\.\d+\.\d+)', name)
print(m.group(1) if m else '')
" <<< "$RELEASE_JSON" 2>/dev/null || true)
# Extract [Unreleased] section from changelog
NOTES=""
if [ -f "CHANGELOG.md" ]; then
NOTES=$(awk '/^## \[Unreleased\]/{found=1; next} /^## \[/{if(found) exit} found{print}' CHANGELOG.md)
fi
[ -z "$NOTES" ] && NOTES="Stable release"
# Update release body via API
if [ -n "$RELEASE_ID" ]; then
python3 -c "
import json, urllib.request
body = open('/dev/stdin').read()
payload = json.dumps({'body': body}).encode()
req = urllib.request.Request(
'${API_BASE}/releases/${RELEASE_ID}',
data=payload, method='PATCH',
headers={
'Authorization': 'token ${TOKEN}',
'Content-Type': 'application/json'
})
urllib.request.urlopen(req)
" <<< "$NOTES"
echo "Release notes updated from CHANGELOG.md"
fi
# Promote [Unreleased] → [version] in CHANGELOG.md and reset
if [ -n "$VERSION" ] && [ -f "CHANGELOG.md" ]; then
DATE=$(date +%Y-%m-%d)
python3 -c "
import sys
version, date = sys.argv[1], sys.argv[2]
content = open('CHANGELOG.md').read()
old = '## [Unreleased]'
new = f'## [Unreleased]\n\n## [{version}] --- {date}'
content = content.replace(old, new, 1)
open('CHANGELOG.md', 'w').write(content)
" "$VERSION" "$DATE"
git add CHANGELOG.md
git commit -m "chore: promote changelog [Unreleased] → [${VERSION}]" || true
git push origin main || true
echo "Changelog promoted: [Unreleased] → [${VERSION}]"
fi
# -- STEP 9: Mirror to GitHub (stable only) --------------------------------
- name: "Step 9: Mirror release to GitHub"
if: >-
steps.version.outputs.skip != 'true' &&
secrets.GH_MIRROR_TOKEN != ''
continue-on-error: true
run: |
VERSION="${{ steps.bump.outputs.version || steps.version.outputs.version }}"
RELEASE_TAG="${{ steps.version.outputs.release_tag }}"
GH_REPO="${{ vars.GH_MIRROR_REPO || github.repository }}"
API_BASE="${GITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}"
php ${MOKO_CLI}/release_mirror.php \
--version "$VERSION" --tag "$RELEASE_TAG" \
--token "${{ secrets.MOKOGITEA_TOKEN }}" --api-base "$API_BASE" \
--gh-token "${{ secrets.GH_MIRROR_TOKEN }}" --gh-repo "$GH_REPO" \
--branch main 2>&1 || true
echo "GitHub mirror updated" >> $GITHUB_STEP_SUMMARY
# -- STEP 10: Sync main branch to GitHub mirror ----------------------------
- name: "Step 10: Push main to GitHub mirror"
if: >-
steps.version.outputs.skip != 'true' &&
secrets.GH_MIRROR_TOKEN != ''
continue-on-error: true
run: |
GH_REPO="${{ vars.GH_MIRROR_REPO || github.repository }}"
GH_ORG=$(echo "$GH_REPO" | cut -d/ -f1)
GH_NAME=$(echo "$GH_REPO" | cut -d/ -f2)
git remote add github "https://x-access-token:${{ secrets.GH_MIRROR_TOKEN }}@github.com/${GH_ORG}/${GH_NAME}.git" 2>/dev/null || \
git remote set-url github "https://x-access-token:${{ secrets.GH_MIRROR_TOKEN }}@github.com/${GH_ORG}/${GH_NAME}.git"
git fetch origin main --depth=1
git push github origin/main:refs/heads/main --force 2>/dev/null \
&& echo "main branch pushed to GitHub mirror" \
|| echo "WARNING: GitHub mirror push failed"
- name: "Step 11: Delete rc branch and recreate dev from main"
if: steps.version.outputs.skip != 'true'
continue-on-error: true
run: |
API_BASE="${GITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}"
TOKEN="${{ secrets.MOKOGITEA_TOKEN }}"
# Delete rc branch (ephemeral — created by promote-rc)
curl -sf -X DELETE -H "Authorization: token ${TOKEN}" \
"${API_BASE}/branches/rc" 2>/dev/null \
&& echo "Deleted rc branch" || echo "rc branch not found"
# Delete dev branch
curl -sf -X DELETE -H "Authorization: token ${TOKEN}" \
"${API_BASE}/branches/dev" 2>/dev/null && echo "Deleted dev branch"
# Recreate dev from main (now includes version bump + changelog promotion)
curl -sf -X POST -H "Authorization: token ${TOKEN}" \
-H "Content-Type: application/json" \
"${API_BASE}/branches" \
-d '{"new_branch_name":"dev","old_branch_name":"main"}' 2>/dev/null && echo "Recreated dev from main"
echo "Pre-release branches cleaned, dev reset from main" >> $GITHUB_STEP_SUMMARY
- name: "Step 12: Create version branch from main"
if: steps.version.outputs.skip != 'true'
continue-on-error: true
run: |
API_BASE="${GITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}"
TOKEN="${{ secrets.MOKOGITEA_TOKEN }}"
VERSION="${{ steps.bump.outputs.version || steps.version.outputs.version }}"
BRANCH_NAME="version/${VERSION}"
MAIN_SHA=$(git rev-parse HEAD)
# Delete old version branch if it exists (same version re-release)
curl -sf -X DELETE -H "Authorization: token ${TOKEN}" "${API_BASE}/branches/${BRANCH_NAME}" 2>/dev/null && echo "Deleted old ${BRANCH_NAME}"
# Create version/XX.YY.ZZ from main
curl -sf -X POST -H "Authorization: token ${TOKEN}" -H "Content-Type: application/json" "${API_BASE}/branches" -d "{\"new_branch_name\":\"${BRANCH_NAME}\",\"old_branch_name\":\"main\"}" 2>/dev/null && echo "Created ${BRANCH_NAME} from main (${MAIN_SHA})" || echo "WARNING: ${BRANCH_NAME} creation failed"
echo "Version branch created: ${BRANCH_NAME} (${MAIN_SHA})" >> $GITHUB_STEP_SUMMARY
# -- Dolibarr post-release: Reset dev version -----------------------------
- name: "Post-release: Reset dev version"
if: steps.version.outputs.skip != 'true'
continue-on-error: true
run: |
API_BASE="${GITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}"
php ${MOKO_CLI}/version_reset_dev.php \
--token "${{ secrets.MOKOGITEA_TOKEN }}" --api-base "${API_BASE}" \
--branch dev --path . 2>&1 || true
# -- Summary --------------------------------------------------------------
- name: Pipeline Summary
if: always()
run: |
VERSION="${{ steps.bump.outputs.version || steps.version.outputs.version }}"
PLATFORM="${{ steps.platform.outputs.platform }}"
if [ "${{ steps.version.outputs.skip }}" = "true" ]; then
echo "## Release Skipped" >> $GITHUB_STEP_SUMMARY
echo "No VERSION in README.md" >> $GITHUB_STEP_SUMMARY
elif [ "${{ steps.check.outputs.already_released }}" = "true" ]; then
echo "## Already Released — ${VERSION}" >> $GITHUB_STEP_SUMMARY
else
echo "" >> $GITHUB_STEP_SUMMARY
echo "## Build & Release Complete (${PLATFORM})" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "| Step | Result |" >> $GITHUB_STEP_SUMMARY
echo "|------|--------|" >> $GITHUB_STEP_SUMMARY
echo "| Platform | \`${PLATFORM}\` |" >> $GITHUB_STEP_SUMMARY
echo "| Version | \`${VERSION}\` |" >> $GITHUB_STEP_SUMMARY
echo "| Branch | \`${{ steps.version.outputs.branch }}\` |" >> $GITHUB_STEP_SUMMARY
echo "| Tag | \`${{ steps.version.outputs.tag }}\` |" >> $GITHUB_STEP_SUMMARY
echo "| Release | [View](${GITEA_URL}/${GITEA_ORG}/${GITEA_REPO}/releases/tag/${{ steps.version.outputs.tag }}) |" >> $GITHUB_STEP_SUMMARY
fi
+3 -3
View File
@@ -4,10 +4,10 @@
# #
# FILE INFORMATION # FILE INFORMATION
# DEFGROUP: Gitea.Workflow # DEFGROUP: Gitea.Workflow
# INGROUP: MokoStandards.Universal # INGROUP: MokoPlatform.Universal
# REPO: https://git.mokoconsulting.tech/MokoConsulting/mokocli # REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform
# PATH: /.mokogitea/workflows/branch-cleanup.yml # PATH: /.mokogitea/workflows/branch-cleanup.yml
# VERSION: 01.00.00 # VERSION: 09.23.00
# BRIEF: Delete feature branches after PR merge # BRIEF: Delete feature branches after PR merge
name: "Branch Cleanup" name: "Branch Cleanup"
@@ -1,54 +0,0 @@
# Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
#
# SPDX-License-Identifier: GPL-3.0-or-later
#
# Enforce branch protection rules across all org repos.
# Runs weekly and on manual dispatch.
name: "Org: Enforce Branch Protections"
on:
schedule:
- cron: '0 6 * * 1' # Every Monday at 6am UTC
workflow_dispatch:
inputs:
dry_run:
description: 'Dry run (show changes without applying)'
required: false
type: boolean
default: false
jobs:
enforce:
name: Enforce Branch Protections
runs-on: release
steps:
- name: Checkout MokoCLI
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
with:
token: ${{ secrets.MOKOGITEA_TOKEN }}
- name: Setup PHP
run: |
if ! command -v php > /dev/null 2>&1; then
sudo apt-get update -qq && sudo apt-get install -y -qq php-cli php-curl > /dev/null 2>&1
fi
- name: Run branch protection enforcement
run: |
DRY_RUN=""
if [ "${{ inputs.dry_run }}" = "true" ]; then
DRY_RUN="--dry-run"
fi
php cli/branch_protect_org.php \
--token "${{ secrets.MOKOGITEA_TOKEN }}" \
--org "MokoConsulting" \
$DRY_RUN
- name: Summary
if: always()
run: |
echo "## Branch Protection Enforcement" >> $GITHUB_STEP_SUMMARY
echo "All repos checked for main, dev, rc, beta, alpha protections" >> $GITHUB_STEP_SUMMARY
echo "Push whitelist: jmiller only" >> $GITHUB_STEP_SUMMARY
-191
View File
@@ -1,191 +0,0 @@
# Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
#
# SPDX-License-Identifier: GPL-3.0-or-later
#
# FILE INFORMATION
# DEFGROUP: Gitea.Workflow
# INGROUP: MokoStandards.CI
# REPO: https://git.mokoconsulting.tech/MokoConsulting/Template-Generic
# PATH: /.gitea/workflows/ci-generic.yml
# VERSION: 01.00.00
# BRIEF: CI pipeline — lint, validate, and test for generic projects (PHP + Node.js)
name: "Generic: Project CI"
on:
workflow_dispatch:
permissions:
contents: read
env:
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
jobs:
# ── Lint & Validate ───────────────────────────────────────────────────
lint:
name: Lint & Validate
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Detect toolchain
id: detect
run: |
HAS_PHP=false
HAS_NODE=false
[ -f "composer.json" ] && HAS_PHP=true
[ -f "package.json" ] && HAS_NODE=true
echo "has_php=$HAS_PHP" >> "$GITHUB_OUTPUT"
echo "has_node=$HAS_NODE" >> "$GITHUB_OUTPUT"
echo "Toolchain: PHP=$HAS_PHP Node=$HAS_NODE"
- name: Setup PHP
if: steps.detect.outputs.has_php == 'true'
run: |
if ! command -v php &> /dev/null; then
sudo apt-get update -qq
sudo apt-get install -y -qq php-cli php-mbstring php-xml >/dev/null 2>&1
fi
php -v
- name: Setup Node.js
if: steps.detect.outputs.has_node == 'true'
uses: actions/setup-node@v4
with:
node-version: '20'
- name: Install PHP dependencies
if: steps.detect.outputs.has_php == 'true'
run: |
if [ -f "composer.json" ]; then
composer install --no-interaction --prefer-dist --quiet 2>/dev/null || true
fi
- name: Install Node.js dependencies
if: steps.detect.outputs.has_node == 'true'
run: |
if [ -f "package.json" ]; then
npm ci --quiet 2>/dev/null || npm install --quiet 2>/dev/null || true
fi
- name: PHP syntax check
if: steps.detect.outputs.has_php == 'true'
run: |
ERRORS=0
while IFS= read -r -d '' file; do
if ! php -l "$file" 2>&1 | grep -q "No syntax errors"; then
echo "::error file=${file}::PHP syntax error"
ERRORS=$((ERRORS + 1))
fi
done < <(find . -name "*.php" -not -path "./.git/*" -not -path "./vendor/*" -not -path "./node_modules/*" -print0)
echo "## PHP Lint" >> $GITHUB_STEP_SUMMARY
if [ "$ERRORS" -eq 0 ]; then
echo "All PHP files passed syntax check." >> $GITHUB_STEP_SUMMARY
else
echo "${ERRORS} file(s) with syntax errors." >> $GITHUB_STEP_SUMMARY
exit 1
fi
- name: TypeScript/JavaScript lint
if: steps.detect.outputs.has_node == 'true'
run: |
if [ -f "node_modules/.bin/eslint" ]; then
npx eslint src/ --quiet 2>&1 || { echo "::error::ESLint errors found"; exit 1; }
echo "## ESLint" >> $GITHUB_STEP_SUMMARY
echo "All files passed ESLint." >> $GITHUB_STEP_SUMMARY
elif [ -f ".eslintrc.json" ] || [ -f ".eslintrc.js" ] || [ -f "eslint.config.js" ]; then
echo "::warning::ESLint config found but eslint not installed"
else
echo "No ESLint configured — skipping"
fi
- name: TypeScript compile check
if: steps.detect.outputs.has_node == 'true'
run: |
if [ -f "tsconfig.json" ] && [ -f "node_modules/.bin/tsc" ]; then
npx tsc --noEmit 2>&1 || { echo "::error::TypeScript compilation errors"; exit 1; }
echo "## TypeScript" >> $GITHUB_STEP_SUMMARY
echo "TypeScript compilation passed." >> $GITHUB_STEP_SUMMARY
fi
- name: PHPStan static analysis
if: steps.detect.outputs.has_php == 'true'
run: |
if [ -f "phpstan.neon" ] && [ -f "vendor/bin/phpstan" ]; then
vendor/bin/phpstan analyse --no-progress 2>&1 || { echo "::warning::PHPStan found issues"; }
fi
# ── Tests ─────────────────────────────────────────────────────────────
test:
name: Tests
runs-on: ubuntu-latest
needs: lint
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Detect toolchain
id: detect
run: |
HAS_PHP=false
HAS_NODE=false
[ -f "composer.json" ] && HAS_PHP=true
[ -f "package.json" ] && HAS_NODE=true
echo "has_php=$HAS_PHP" >> "$GITHUB_OUTPUT"
echo "has_node=$HAS_NODE" >> "$GITHUB_OUTPUT"
- name: Setup PHP
if: steps.detect.outputs.has_php == 'true'
run: |
if ! command -v php &> /dev/null; then
sudo apt-get update -qq
sudo apt-get install -y -qq php-cli php-mbstring php-xml >/dev/null 2>&1
fi
- name: Setup Node.js
if: steps.detect.outputs.has_node == 'true'
uses: actions/setup-node@v4
with:
node-version: '20'
- name: Install dependencies
run: |
[ -f "composer.json" ] && composer install --no-interaction --prefer-dist --quiet 2>/dev/null || true
[ -f "package.json" ] && { npm ci --quiet 2>/dev/null || npm install --quiet 2>/dev/null || true; }
- name: Run PHP tests
if: steps.detect.outputs.has_php == 'true'
run: |
if [ -f "vendor/bin/phpunit" ]; then
vendor/bin/phpunit --testdox 2>&1
echo "## PHPUnit" >> $GITHUB_STEP_SUMMARY
echo "Tests passed." >> $GITHUB_STEP_SUMMARY
elif [ -f "phpunit.xml" ] || [ -f "phpunit.xml.dist" ]; then
echo "::warning::PHPUnit config found but phpunit not installed"
else
echo "No PHPUnit configured — skipping"
fi
- name: Run Node.js tests
if: steps.detect.outputs.has_node == 'true'
run: |
if jq -e '.scripts.test' package.json > /dev/null 2>&1; then
npm test 2>&1
echo "## Node.js Tests" >> $GITHUB_STEP_SUMMARY
echo "Tests passed." >> $GITHUB_STEP_SUMMARY
else
echo "No test script in package.json — skipping"
fi
- name: Build check
run: |
if [ -f "Makefile" ]; then
make build 2>&1 || echo "::warning::Build failed or not configured"
elif [ -f "package.json" ] && jq -e '.scripts.build' package.json > /dev/null 2>&1; then
npm run build 2>&1 || echo "::warning::Build failed"
fi
+11 -60
View File
@@ -4,18 +4,18 @@
# #
# FILE INFORMATION # FILE INFORMATION
# DEFGROUP: Gitea.Workflow # DEFGROUP: Gitea.Workflow
# INGROUP: mokocli.CI # INGROUP: moko-platform.CI
# REPO: https://git.mokoconsulting.tech/MokoConsulting/mokocli # REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform
# PATH: /.mokogitea/workflows/ci-platform.yml # PATH: /.gitea/workflows/ci-platform.yml
# VERSION: 09.23.00 # VERSION: 09.23.00
# BRIEF: mokocli CI — the standards engine validates itself # BRIEF: moko-platform CI — the standards engine validates itself
# #
# +========================================================================+ # +========================================================================+
# | MOKO-PLATFORM CI | # | MOKO-PLATFORM CI |
# +========================================================================+ # +========================================================================+
# | | # | |
# | This is NOT a generic CI workflow. This is the self-validation | # | This is NOT a generic CI workflow. This is the self-validation |
# | pipeline for the central mokocli enterprise engine. | # | pipeline for the central moko-platform enterprise engine. |
# | | # | |
# | It dogfoods every tool the platform ships to governed repos: | # | It dogfoods every tool the platform ships to governed repos: |
# | | # | |
@@ -29,7 +29,7 @@
# | | # | |
# +========================================================================+ # +========================================================================+
name: "Platform: mokocli CI" name: "Platform: moko-platform CI"
on: on:
push: push:
@@ -41,7 +41,7 @@ on:
paths-ignore: paths-ignore:
- '**.md' - '**.md'
- 'wiki/**' - 'wiki/**'
- '.mokogitea/ISSUE_TEMPLATE/**' - '.gitea/ISSUE_TEMPLATE/**'
pull_request: pull_request:
branches: branches:
- main - main
@@ -104,7 +104,7 @@ jobs:
echo "::error file=${file}::PHP syntax error" echo "::error file=${file}::PHP syntax error"
ERRORS=$((ERRORS + 1)) ERRORS=$((ERRORS + 1))
fi fi
done < <(find lib/ validate/ automation/ cli/ source/ src/ deploy/ -name "*.php" -print0 2>/dev/null) done < <(find lib/ validate/ automation/ cli/ src/ deploy/ -name "*.php" -print0 2>/dev/null)
{ {
echo "### PHP Syntax" echo "### PHP Syntax"
@@ -270,7 +270,7 @@ jobs:
echo "::warning file=${file}::Missing SPDX header" echo "::warning file=${file}::Missing SPDX header"
MISSING=$((MISSING + 1)) MISSING=$((MISSING + 1))
fi fi
done < <(find lib/ validate/ cli/ source/ src/ automation/ deploy/ -name "*.php" -print0 2>/dev/null) done < <(find lib/ validate/ cli/ src/ automation/ deploy/ -name "*.php" -print0 2>/dev/null)
{ {
echo "### License Headers" echo "### License Headers"
@@ -289,7 +289,7 @@ jobs:
echo "::error file=${file}::Potential hardcoded secret detected" echo "::error file=${file}::Potential hardcoded secret detected"
FOUND=$((FOUND + 1)) FOUND=$((FOUND + 1))
fi fi
done < <(find lib/ validate/ cli/ source/ src/ automation/ deploy/ -name "*.php" -print0 2>/dev/null) done < <(find lib/ validate/ cli/ src/ automation/ deploy/ -name "*.php" -print0 2>/dev/null)
{ {
echo "### Secret Detection" echo "### Secret Detection"
@@ -412,16 +412,10 @@ jobs:
if: always() if: always()
steps: steps:
- name: Checkout
uses: actions/checkout@v4
with:
sparse-checkout: automation/ci-issue-reporter.sh
sparse-checkout-cone-mode: false
- name: Check gate results - name: Check gate results
run: | run: |
{ {
echo "# mokocli CI" echo "# moko-platform CI"
echo "" echo ""
echo "| Gate | Job | Status |" echo "| Gate | Job | Status |"
echo "|---|---|---|" echo "|---|---|---|"
@@ -443,46 +437,3 @@ jobs:
echo "::error::One or more CI gates failed" echo "::error::One or more CI gates failed"
exit 1 exit 1
fi fi
- name: "File issues for failed gates"
if: >-
always() &&
(needs.code-quality.result == 'failure' ||
needs.tests.result == 'failure' ||
needs.self-health.result == 'failure' ||
needs.governance.result == 'failure' ||
needs.templates.result == 'failure')
env:
GITEA_TOKEN: ${{ secrets.MOKOGITEA_TOKEN }}
GITEA_URL: ${{ vars.GITEA_URL || 'https://git.mokoconsulting.tech' }}
run: |
chmod +x automation/ci-issue-reporter.sh
REPORTER="./automation/ci-issue-reporter.sh"
WF="Platform CI"
report_gate() {
local gate="$1" result="$2" details="$3"
if [ "$result" = "failure" ]; then
"$REPORTER" --gate "$gate" --details "$details" --workflow "$WF" --severity error
fi
}
report_gate "Code Quality" \
"${{ needs.code-quality.result }}" \
"PHPCS (PSR-12), PHPStan, or PHP syntax checks failed. Run \`composer check\` locally to reproduce."
report_gate "Unit Tests" \
"${{ needs.tests.result }}" \
"PHPUnit tests failed on one or more PHP versions (8.1, 8.2, 8.3). Run \`vendor/bin/phpunit --testdox\` locally."
report_gate "Self-Health" \
"${{ needs.self-health.result }}" \
"Self-health score fell below the 80% threshold. Run \`php bin/moko health -- --path .\` locally."
report_gate "Governance" \
"${{ needs.governance.result }}" \
"Governance checks failed (license headers, secrets, or version consistency). Check the CI run summary for specifics."
report_gate "Template Integrity" \
"${{ needs.templates.result }}" \
"Workflow or gitignore templates failed YAML validation or are missing required entries."
+10 -10
View File
@@ -4,10 +4,10 @@
# #
# FILE INFORMATION # FILE INFORMATION
# DEFGROUP: Gitea.Workflow # DEFGROUP: Gitea.Workflow
# INGROUP: MokoStandards.Maintenance # INGROUP: moko-platform.Maintenance
# REPO: https://git.mokoconsulting.tech/MokoConsulting/MokoStandards # REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform
# PATH: /.gitea/workflows/cleanup.yml # PATH: /.gitea/workflows/cleanup.yml
# VERSION: 01.00.00 # VERSION: 09.23.00
# BRIEF: Scheduled cleanup — delete merged branches and old workflow runs # BRIEF: Scheduled cleanup — delete merged branches and old workflow runs
name: "Universal: Repository Cleanup" name: "Universal: Repository Cleanup"
@@ -33,17 +33,17 @@ jobs:
uses: actions/checkout@v4 uses: actions/checkout@v4
with: with:
fetch-depth: 0 fetch-depth: 0
token: ${{ secrets.GA_TOKEN }} token: ${{ secrets.MOKOGITEA_TOKEN }}
- name: Delete merged branches - name: Delete merged branches
env: env:
GA_TOKEN: ${{ secrets.GA_TOKEN }} GA_TOKEN: ${{ secrets.MOKOGITEA_TOKEN }}
run: | run: |
echo "=== Merged Branch Cleanup ===" echo "=== Merged Branch Cleanup ==="
API="${GITEA_URL}/api/v1/repos/${{ github.repository }}" API="${GITEA_URL}/api/v1/repos/${{ github.repository }}"
# List branches via API # List branches via API
BRANCHES=$(curl -sS -H "Authorization: token ${GA_TOKEN}" \ BRANCHES=$(curl -sS -H "Authorization: token ${GITEA_TOKEN}" \
"${API}/branches?limit=50" | jq -r '.[].name') "${API}/branches?limit=50" | jq -r '.[].name')
DELETED=0 DELETED=0
@@ -56,7 +56,7 @@ jobs:
# Check if branch is merged into main # Check if branch is merged into main
if git merge-base --is-ancestor "origin/${BRANCH}" origin/main 2>/dev/null; then if git merge-base --is-ancestor "origin/${BRANCH}" origin/main 2>/dev/null; then
echo " Deleting merged branch: ${BRANCH}" echo " Deleting merged branch: ${BRANCH}"
curl -sS -X DELETE -H "Authorization: token ${GA_TOKEN}" \ curl -sS -X DELETE -H "Authorization: token ${GITEA_TOKEN}" \
"${API}/branches/${BRANCH}" 2>/dev/null || true "${API}/branches/${BRANCH}" 2>/dev/null || true
DELETED=$((DELETED + 1)) DELETED=$((DELETED + 1))
fi fi
@@ -66,20 +66,20 @@ jobs:
- name: Clean old workflow runs - name: Clean old workflow runs
env: env:
GA_TOKEN: ${{ secrets.GA_TOKEN }} GA_TOKEN: ${{ secrets.MOKOGITEA_TOKEN }}
run: | run: |
echo "=== Workflow Run Cleanup ===" echo "=== Workflow Run Cleanup ==="
API="${GITEA_URL}/api/v1/repos/${{ github.repository }}" API="${GITEA_URL}/api/v1/repos/${{ github.repository }}"
CUTOFF=$(date -d "30 days ago" +%Y-%m-%dT%H:%M:%SZ 2>/dev/null || date -v-30d +%Y-%m-%dT%H:%M:%SZ) CUTOFF=$(date -d "30 days ago" +%Y-%m-%dT%H:%M:%SZ 2>/dev/null || date -v-30d +%Y-%m-%dT%H:%M:%SZ)
# Get old completed runs # Get old completed runs
RUNS=$(curl -sS -H "Authorization: token ${GA_TOKEN}" \ RUNS=$(curl -sS -H "Authorization: token ${GITEA_TOKEN}" \
"${API}/actions/runs?status=completed&limit=50" | \ "${API}/actions/runs?status=completed&limit=50" | \
jq -r ".workflow_runs[] | select(.created_at < \"${CUTOFF}\") | .id" 2>/dev/null) jq -r ".workflow_runs[] | select(.created_at < \"${CUTOFF}\") | .id" 2>/dev/null)
DELETED=0 DELETED=0
for RUN_ID in $RUNS; do for RUN_ID in $RUNS; do
curl -sS -X DELETE -H "Authorization: token ${GA_TOKEN}" \ curl -sS -X DELETE -H "Authorization: token ${GITEA_TOKEN}" \
"${API}/actions/runs/${RUN_ID}" 2>/dev/null || true "${API}/actions/runs/${RUN_ID}" 2>/dev/null || true
DELETED=$((DELETED + 1)) DELETED=$((DELETED + 1))
done done
-76
View File
@@ -1,76 +0,0 @@
# Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
# SPDX-License-Identifier: GPL-3.0-or-later
name: "Publish to Composer"
on:
push:
tags:
- 'v*'
- '[0-9]*.[0-9]*.[0-9]*'
release:
types: [published]
workflow_dispatch:
env:
GITEA_URL: ${{ vars.GITEA_URL || 'https://git.mokoconsulting.tech' }}
jobs:
publish:
name: Publish Package
runs-on: ubuntu-latest
if: >-
!contains(github.event.head_commit.message, '[skip ci]') &&
!contains(github.event.head_commit.message, '[skip publish]')
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup PHP
run: |
if ! command -v php &> /dev/null; then
sudo apt-get update -qq
sudo apt-get install -y -qq php-cli php-mbstring php-xml php-zip php-curl composer >/dev/null 2>&1
fi
- name: Install dependencies
run: composer install --no-dev --no-interaction --prefer-dist --quiet
- name: Determine version
id: version
run: |
VERSION=$(php -r "echo json_decode(file_get_contents('composer.json'))->version;")
echo "version=${VERSION}" >> "$GITHUB_OUTPUT"
echo "Package version: ${VERSION}"
# Gitea Composer Registry — auto-publishes from tags
# The tag push itself registers the package at:
# https://git.mokoconsulting.tech/api/packages/MokoConsulting/composer
- name: Verify Gitea registry
run: |
echo "Gitea Composer registry auto-publishes from tags."
echo "Package available at: ${GITEA_URL}/api/packages/MokoConsulting/composer"
echo "Install: composer require mokoconsulting/mokocli"
# Packagist — notify of new version
- name: Notify Packagist
if: secrets.PACKAGIST_TOKEN != ''
run: |
VERSION="${{ steps.version.outputs.version }}"
echo "Notifying Packagist of version ${VERSION}..."
curl -sf -X POST \
-H "Content-Type: application/json" \
-d '{"repository":{"url":"https://git.mokoconsulting.tech/MokoConsulting/mokocli"}}' \
"https://packagist.org/api/update-package?username=mokoconsulting&apiToken=${{ secrets.PACKAGIST_TOKEN }}" \
&& echo "Packagist notified" \
|| echo "::warning::Packagist notification failed (package may not be registered yet)"
- name: Summary
run: |
VERSION="${{ steps.version.outputs.version }}"
echo "## Composer Package Published" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "| Registry | Status |" >> $GITHUB_STEP_SUMMARY
echo "|----------|--------|" >> $GITHUB_STEP_SUMMARY
echo "| Gitea | \`composer require mokoconsulting/mokocli:${VERSION}\` |" >> $GITHUB_STEP_SUMMARY
echo "| Packagist | \`composer require mokoconsulting/mokocli\` |" >> $GITHUB_STEP_SUMMARY
-126
View File
@@ -1,126 +0,0 @@
# Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
#
# SPDX-License-Identifier: GPL-3.0-or-later
#
# FILE INFORMATION
# DEFGROUP: Gitea.Workflow
# INGROUP: MokoStandards.Deploy
# REPO: https://git.mokoconsulting.tech/MokoConsulting/MokoStandards-API
# PATH: /templates/workflows/joomla/deploy-manual.yml.template
# VERSION: 04.07.00
# BRIEF: Manual SFTP deploy to dev server for Joomla repos
name: "Universal: Deploy to Dev (Manual)"
on:
workflow_dispatch:
inputs:
clear_remote:
description: 'Delete all remote files before uploading'
required: false
default: 'false'
type: boolean
env:
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
permissions:
contents: read
jobs:
deploy:
name: SFTP Deploy to Dev
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
- name: Setup PHP
run: |
php -v && composer --version
- name: Setup MokoStandards tools
env:
GA_TOKEN: ${{ secrets.GA_TOKEN || secrets.GA_TOKEN || github.token }}
MOKO_CLONE_TOKEN: ${{ secrets.GA_TOKEN || secrets.GA_TOKEN || github.token }}
MOKO_CLONE_HOST: ${{ secrets.GA_TOKEN && 'git.mokoconsulting.tech/MokoConsulting' || 'github.com/mokoconsulting-tech' }}
COMPOSER_AUTH: '{"github-oauth":{"github.com":"${{ secrets.GA_TOKEN || github.token }}"}}'
run: |
git clone --depth 1 --branch main --quiet \
"https://x-access-token:${MOKO_CLONE_TOKEN}@${MOKO_CLONE_HOST}/MokoStandards-API.git" \
/tmp/mokostandards-api 2>/dev/null || true
if [ -d "/tmp/mokostandards-api" ] && [ -f "/tmp/mokostandards-api/composer.json" ]; then
cd /tmp/mokostandards-api && composer install --no-dev --no-interaction --quiet 2>/dev/null || true
fi
- name: Check FTP configuration
id: check
env:
HOST: ${{ vars.DEV_FTP_HOST }}
PATH_VAR: ${{ vars.DEV_FTP_PATH }}
PORT: ${{ vars.DEV_FTP_PORT }}
run: |
if [ -z "$HOST" ] || [ -z "$PATH_VAR" ]; then
echo "DEV_FTP_HOST or DEV_FTP_PATH not configured -- cannot deploy"
echo "skip=true" >> "$GITHUB_OUTPUT"
exit 0
fi
echo "skip=false" >> "$GITHUB_OUTPUT"
echo "host=$HOST" >> "$GITHUB_OUTPUT"
REMOTE="${PATH_VAR%/}"
echo "remote=$REMOTE" >> "$GITHUB_OUTPUT"
[ -z "$PORT" ] && PORT="22"
echo "port=$PORT" >> "$GITHUB_OUTPUT"
- name: Deploy via SFTP
if: steps.check.outputs.skip != 'true'
env:
SFTP_KEY: ${{ secrets.DEV_FTP_KEY }}
SFTP_PASS: ${{ secrets.DEV_FTP_PASSWORD }}
SFTP_USER: ${{ vars.DEV_FTP_USERNAME }}
run: |
SOURCE_DIR="src"
[ ! -d "$SOURCE_DIR" ] && SOURCE_DIR="htdocs"
[ ! -d "$SOURCE_DIR" ] && { echo "No src/ or htdocs/ -- nothing to deploy"; exit 0; }
printf '{"host":"%s","port":%s,"username":"%s","remotePath":"%s"' \
"${{ steps.check.outputs.host }}" "${{ steps.check.outputs.port }}" "$SFTP_USER" "${{ steps.check.outputs.remote }}" \
> /tmp/sftp-config.json
if [ -n "$SFTP_KEY" ]; then
echo "$SFTP_KEY" > /tmp/deploy_key
chmod 600 /tmp/deploy_key
printf ',"privateKeyPath":"/tmp/deploy_key"}' >> /tmp/sftp-config.json
else
printf ',"password":"%s"}' "$SFTP_PASS" >> /tmp/sftp-config.json
fi
DEPLOY_ARGS=(--path . --src-dir "$SOURCE_DIR" --config /tmp/sftp-config.json)
[ "${{ inputs.clear_remote }}" = "true" ] && DEPLOY_ARGS+=(--clear-remote)
PLATFORM=$(php /tmp/mokostandards-api/cli/platform_detect.php --path . 2>/dev/null || true)
if [ "$PLATFORM" = "waas-component" ] && [ -f "/tmp/mokostandards-api/deploy/deploy-joomla.php" ]; then
php /tmp/mokostandards-api/deploy/deploy-joomla.php "${DEPLOY_ARGS[@]}"
else
php /tmp/mokostandards-api/deploy/deploy-sftp.php "${DEPLOY_ARGS[@]}"
fi
rm -f /tmp/deploy_key /tmp/sftp-config.json
- name: Summary
if: always()
run: |
if [ "${{ steps.check.outputs.skip }}" = "true" ]; then
echo "### Deploy Skipped -- FTP not configured" >> $GITHUB_STEP_SUMMARY
else
echo "### Manual Dev Deploy Complete" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "| Field | Value |" >> $GITHUB_STEP_SUMMARY
echo "|-------|-------|" >> $GITHUB_STEP_SUMMARY
echo "| Host | \`${{ steps.check.outputs.host }}\` |" >> $GITHUB_STEP_SUMMARY
echo "| Remote | \`${{ steps.check.outputs.remote }}\` |" >> $GITHUB_STEP_SUMMARY
echo "| Clear | ${{ inputs.clear_remote }} |" >> $GITHUB_STEP_SUMMARY
fi
+7 -3
View File
@@ -4,10 +4,10 @@
# #
# FILE INFORMATION # FILE INFORMATION
# DEFGROUP: Gitea.Workflow # DEFGROUP: Gitea.Workflow
# INGROUP: MokoStandards.Security # INGROUP: moko-platform.Security
# REPO: https://git.mokoconsulting.tech/mokoconsulting-tech/MokoStandards-API # REPO: https://git.mokoconsulting.tech/mokoconsulting-tech/moko-platform
# PATH: /templates/workflows/gitleaks.yml.template # PATH: /templates/workflows/gitleaks.yml.template
# VERSION: 01.00.00 # VERSION: 09.23.00
# BRIEF: Secret scanning — detect leaked credentials, API keys, and tokens # BRIEF: Secret scanning — detect leaked credentials, API keys, and tokens
# #
# +========================================================================+ # +========================================================================+
@@ -25,6 +25,10 @@
name: "Universal: Secret Scanning" name: "Universal: Secret Scanning"
on: on:
pull_request:
branches:
- main
- 'dev/**'
schedule: schedule:
- cron: '0 5 * * 1' # Weekly Monday 05:00 UTC - cron: '0 5 * * 1' # Weekly Monday 05:00 UTC
workflow_dispatch: workflow_dispatch:
+3 -3
View File
@@ -4,8 +4,8 @@
# #
# FILE INFORMATION # FILE INFORMATION
# DEFGROUP: Gitea.Workflow # DEFGROUP: Gitea.Workflow
# INGROUP: mokocli.Automation # INGROUP: moko-platform.Automation
# VERSION: 01.00.00 # VERSION: 09.24.00
# BRIEF: Auto-create feature branch when an issue is opened # BRIEF: Auto-create feature branch when an issue is opened
name: "Universal: Issue Branch" name: "Universal: Issue Branch"
@@ -28,7 +28,7 @@ jobs:
steps: steps:
- name: Create branch and comment - name: Create branch and comment
run: | run: |
TOKEN="${{ secrets.GA_TOKEN }}" TOKEN="${{ secrets.MOKOGITEA_TOKEN }}"
API="${GITEA_URL}/api/v1/repos/${{ github.repository }}" API="${GITEA_URL}/api/v1/repos/${{ github.repository }}"
ISSUE_NUM="${{ github.event.issue.number }}" ISSUE_NUM="${{ github.event.issue.number }}"
ISSUE_TITLE="${{ github.event.issue.title }}" ISSUE_TITLE="${{ github.event.issue.title }}"
+3 -3
View File
@@ -4,10 +4,10 @@
# #
# FILE INFORMATION # FILE INFORMATION
# DEFGROUP: Gitea.Workflow # DEFGROUP: Gitea.Workflow
# INGROUP: MokoStandards.Notifications # INGROUP: moko-platform.Notifications
# REPO: https://git.mokoconsulting.tech/MokoConsulting/MokoStandards # REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform
# PATH: /.gitea/workflows/notify.yml # PATH: /.gitea/workflows/notify.yml
# VERSION: 01.00.00 # VERSION: 09.23.00
# BRIEF: Push notifications via ntfy on release success or workflow failure # BRIEF: Push notifications via ntfy on release success or workflow failure
name: "Universal: Notifications" name: "Universal: Notifications"
-26
View File
@@ -96,32 +96,6 @@ jobs:
echo "Branch policy: OK (${HEAD} → ${BASE})" echo "Branch policy: OK (${HEAD} → ${BASE})"
echo "## Branch Policy: Passed" >> $GITHUB_STEP_SUMMARY echo "## Branch Policy: Passed" >> $GITHUB_STEP_SUMMARY
# ── Secret Scanning ──────────────────────────────────────────────────
gitleaks:
name: Secret Scan
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Install Gitleaks
run: |
GITLEAKS_VERSION="8.21.2"
curl -sSL "https://github.com/gitleaks/gitleaks/releases/download/v${GITLEAKS_VERSION}/gitleaks_${GITLEAKS_VERSION}_linux_x64.tar.gz" \
| tar -xz -C /usr/local/bin gitleaks
- name: Scan PR commits for secrets
run: |
if gitleaks detect --source . --verbose \
--log-opts=${{ github.event.pull_request.base.sha }}..${{ github.event.pull_request.head.sha }} 2>&1; then
echo "**No secrets detected.**" >> $GITHUB_STEP_SUMMARY
else
echo "::error::Potential secrets detected in PR commits"
exit 1
fi
# ── Code Validation ──────────────────────────────────────────────────── # ── Code Validation ────────────────────────────────────────────────────
validate: validate:
name: Validate PR name: Validate PR
+33 -117
View File
@@ -4,26 +4,19 @@
# #
# FILE INFORMATION # FILE INFORMATION
# DEFGROUP: Gitea.Workflow # DEFGROUP: Gitea.Workflow
# INGROUP: mokocli.Release # INGROUP: moko-platform.Release
# REPO: https://git.mokoconsulting.tech/MokoConsulting/mokocli # REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform
# PATH: /templates/workflows/universal/pre-release.yml.template # PATH: /templates/workflows/universal/pre-release.yml.template
# VERSION: 05.01.00 # VERSION: 09.23.00
# BRIEF: Auto pre-release on push to dev/alpha/beta/rc branches # BRIEF: Manual pre-release -- builds dev/alpha/beta/rc packages from any branch
name: "Universal: Pre-Release" name: "Universal: Pre-Release"
on: on:
push: pull_request:
types: [closed]
branches: branches:
- dev - dev
- 'fix/**'
- 'patch/**'
- 'hotfix/**'
- 'bugfix/**'
- 'chore/**'
- alpha
- beta
- rc
workflow_dispatch: workflow_dispatch:
inputs: inputs:
stability: stability:
@@ -46,11 +39,11 @@ env:
jobs: jobs:
build: build:
name: "Build Pre-Release (${{ inputs.stability || github.ref_name }})" name: "Build Pre-Release (${{ inputs.stability || 'development' }})"
runs-on: release runs-on: release
if: >- if: >-
github.event_name == 'workflow_dispatch' || github.event_name == 'workflow_dispatch' ||
github.event_name == 'push' (github.event.pull_request.merged == true && github.event.pull_request.base.ref == 'dev')
steps: steps:
- name: Checkout - name: Checkout
@@ -58,93 +51,56 @@ jobs:
with: with:
fetch-depth: 0 fetch-depth: 0
token: ${{ secrets.MOKOGITEA_TOKEN }} token: ${{ secrets.MOKOGITEA_TOKEN }}
ref: ${{ github.ref_name }}
- name: Setup mokocli tools - name: Setup moko-platform tools
env: env:
MOKO_CLONE_TOKEN: ${{ secrets.MOKOGITEA_TOKEN }} MOKO_CLONE_TOKEN: ${{ secrets.MOKOGITEA_TOKEN }}
MOKO_CLONE_HOST: git.mokoconsulting.tech/MokoConsulting MOKO_CLONE_HOST: git.mokoconsulting.tech/MokoConsulting
run: | run: |
# Use pre-installed /opt/mokocli if available (updated by cron every 6h) if ! command -v composer &> /dev/null; then
if [ -f /opt/mokocli/cli/version_bump.php ] && [ -f /opt/mokocli/cli/manifest_element.php ] && [ -f /opt/mokocli/vendor/autoload.php ]; then sudo apt-get update -qq && sudo apt-get install -y -qq php-cli php-mbstring php-xml php-zip php-curl composer >/dev/null 2>&1
echo Using pre-installed /opt/mokocli
echo MOKO_CLI=/opt/mokocli/cli >> $GITHUB_ENV
else
echo Falling back to fresh clone
if ! command -v composer > /dev/null 2>&1; then
sudo apt-get update -qq && sudo apt-get install -y -qq php-cli php-mbstring php-xml php-zip php-curl composer > /dev/null 2>&1
fi
rm -rf /tmp/mokocli
CLONE_URL=https://x-access-token:${MOKO_CLONE_TOKEN}@${MOKO_CLONE_HOST}/mokocli.git
git clone --depth 1 --branch main --quiet $CLONE_URL /tmp/mokocli
cd /tmp/mokocli && composer install --no-dev --no-interaction --quiet
echo MOKO_CLI=/tmp/mokocli/cli >> $GITHUB_ENV
fi fi
# Always fetch latest CLI tools — never use stale cache from previous runs
rm -rf /tmp/moko-platform-api
git clone --depth 1 --branch main --quiet \
"https://x-access-token:${MOKO_CLONE_TOKEN}@${MOKO_CLONE_HOST}/moko-platform.git" \
/tmp/moko-platform-api
cd /tmp/moko-platform-api && composer install --no-dev --no-interaction --quiet
echo "MOKO_CLI=/tmp/moko-platform-api/cli" >> "$GITHUB_ENV"
- name: Detect platform - name: Detect platform
id: platform id: platform
run: | run: |
# Auto-detect and update platform if not set in manifest
php ${MOKO_CLI}/platform_detect.php --path . --github-output 2>/dev/null || true
php ${MOKO_CLI}/manifest_read.php --path . --github-output php ${MOKO_CLI}/manifest_read.php --path . --github-output
- name: Check platform eligibility (Joomla only)
id: eligibility
run: |
PLATFORM="${{ steps.platform.outputs.platform }}"
if [[ "$PLATFORM" == joomla* ]] || [[ "$PLATFORM" == "joomla" ]]; then
echo "proceed=true" >> "$GITHUB_OUTPUT"
else
echo "proceed=false" >> "$GITHUB_OUTPUT"
echo "::notice::Platform '$PLATFORM' — non-Joomla, skipping pre-release auto-bump"
fi
- name: Resolve metadata and bump version - name: Resolve metadata and bump version
id: meta id: meta
if: steps.eligibility.outputs.proceed == 'true'
run: | run: |
# Auto-detect stability from branch name on push, or use input on dispatch STABILITY="${{ inputs.stability || 'development' }}"
if [ "${{ github.event_name }}" = "push" ]; then
case "${{ github.ref_name }}" in
rc) STABILITY="release-candidate" ;;
alpha) STABILITY="alpha" ;;
beta) STABILITY="beta" ;;
*) STABILITY="development" ;;
esac
else
STABILITY="${{ inputs.stability || 'development' }}"
fi
case "$STABILITY" in case "$STABILITY" in
development) SUFFIX="-dev"; TAG="development" ;; development) TAG="development" ;;
alpha) SUFFIX="-alpha"; TAG="alpha" ;; alpha) TAG="alpha" ;;
beta) SUFFIX="-beta"; TAG="beta" ;; beta) TAG="beta" ;;
release-candidate) SUFFIX="-rc"; TAG="release-candidate" ;; release-candidate) TAG="release-candidate" ;;
esac esac
# Bump version via CLI: patch for dev/alpha/beta, minor for RC # Bump version: patch for dev/alpha/beta, minor for RC
case "$STABILITY" in case "$STABILITY" in
release-candidate) BUMP="minor" ;; release-candidate) php ${MOKO_CLI}/version_bump.php --path . --minor 2>/dev/null || true ;;
*) BUMP="patch" ;; *) php ${MOKO_CLI}/version_bump.php --path . 2>/dev/null || true ;;
esac esac
php ${MOKO_CLI}/version_bump.php --path . $([ "$BUMP" = "minor" ] && echo "--minor") 2>/dev/null || true # Set stability suffix and fix consistency
VERSION=$(php ${MOKO_CLI}/version_read.php --path . 2>/dev/null || echo '00.00.01')
# Set stability suffix and verify consistency
VERSION=$(php ${MOKO_CLI}/version_read.php --path . 2>/dev/null || echo "00.00.01")
VERSION=$(echo "$VERSION" | sed 's/-\(dev\|alpha\|beta\|rc\)$//') VERSION=$(echo "$VERSION" | sed 's/-\(dev\|alpha\|beta\|rc\)$//')
php ${MOKO_CLI}/version_set_platform.php \ php ${MOKO_CLI}/version_set_platform.php \
--path . --version "$VERSION" --branch "${{ github.ref_name }}" --stability "$STABILITY" 2>/dev/null || true --path . --version "$VERSION" --branch "${{ github.ref_name }}" --stability "$STABILITY" 2>/dev/null || true
php ${MOKO_CLI}/version_check.php --path . --fix 2>/dev/null || true php ${MOKO_CLI}/version_check.php --path . --fix 2>/dev/null || true
# Ensure licensing tags (updateservers, dlid) if enabled in manifest.xml # Read final version with suffix
php ${MOKO_CLI}/manifest_licensing.php --path . --fix 2>/dev/null || true VERSION=$(php ${MOKO_CLI}/version_read.php --path . 2>/dev/null)
[ -z "$VERSION" ] && VERSION="00.00.01"
# Append suffix for output
if [ -n "$SUFFIX" ]; then
VERSION="${VERSION}${SUFFIX}"
fi
# Commit version bump # Commit version bump
git config --local user.email "gitea-actions[bot]@mokoconsulting.tech" git config --local user.email "gitea-actions[bot]@mokoconsulting.tech"
@@ -169,16 +125,14 @@ jobs:
echo "version=${VERSION}" >> "$GITHUB_OUTPUT" echo "version=${VERSION}" >> "$GITHUB_OUTPUT"
echo "stability=${STABILITY}" >> "$GITHUB_OUTPUT" echo "stability=${STABILITY}" >> "$GITHUB_OUTPUT"
echo "suffix=${SUFFIX}" >> "$GITHUB_OUTPUT"
echo "tag=${TAG}" >> "$GITHUB_OUTPUT" echo "tag=${TAG}" >> "$GITHUB_OUTPUT"
echo "zip_name=${ZIP_NAME}" >> "$GITHUB_OUTPUT" echo "zip_name=${ZIP_NAME}" >> "$GITHUB_OUTPUT"
echo "ext_element=${EXT_ELEMENT}" >> "$GITHUB_OUTPUT" echo "ext_element=${EXT_ELEMENT}" >> "$GITHUB_OUTPUT"
echo "=== Pre-Release: ${EXT_ELEMENT} ${VERSION}${SUFFIX} ===" echo "=== Pre-Release: ${EXT_ELEMENT} ${VERSION} ==="
- name: Create release - name: Create release
id: release id: release
if: steps.eligibility.outputs.proceed == 'true'
run: | run: |
TAG="${{ steps.meta.outputs.tag }}" TAG="${{ steps.meta.outputs.tag }}"
VERSION="${{ steps.meta.outputs.version }}" VERSION="${{ steps.meta.outputs.version }}"
@@ -186,47 +140,10 @@ jobs:
php ${MOKO_CLI}/release_create.php \ php ${MOKO_CLI}/release_create.php \
--path . --version "$VERSION" --tag "$TAG" \ --path . --version "$VERSION" --tag "$TAG" \
--token "${{ secrets.MOKOGITEA_TOKEN }}" --api-base "$API_BASE" \ --token "${{ secrets.MOKOGITEA_TOKEN }}" --api-base "$API_BASE" \
--repo "${GITEA_REPO}" --branch "${{ github.ref_name }}" --prerelease --repo "${GITEA_REPO}" --branch dev --prerelease
- name: Update release notes from CHANGELOG.md
if: steps.eligibility.outputs.proceed == 'true'
run: |
TAG="${{ steps.meta.outputs.tag }}"
VERSION="${{ steps.meta.outputs.version }}"
API_BASE="${GITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}"
# Extract [Unreleased] section from changelog (everything between [Unreleased] and next ## heading)
if [ -f "CHANGELOG.md" ]; then
NOTES=$(awk '/^## \[Unreleased\]/{found=1; next} /^## \[/{if(found) exit} found{print}' CHANGELOG.md)
[ -z "$NOTES" ] && NOTES="Release ${VERSION}"
else
NOTES="Release ${VERSION}"
fi
# Update release body via API
RELEASE_ID=$(curl -sf -H "Authorization: token ${{ secrets.MOKOGITEA_TOKEN }}" \
"${API_BASE}/releases/tags/${TAG}" | python3 -c "import json,sys; print(json.load(sys.stdin).get('id',''))" 2>/dev/null || true)
if [ -n "$RELEASE_ID" ]; then
python3 -c "
import json, urllib.request
body = open('/dev/stdin').read()
payload = json.dumps({'body': body}).encode()
req = urllib.request.Request(
'${API_BASE}/releases/${RELEASE_ID}',
data=payload, method='PATCH',
headers={
'Authorization': 'token ${{ secrets.MOKOGITEA_TOKEN }}',
'Content-Type': 'application/json'
})
urllib.request.urlopen(req)
" <<< "$NOTES"
echo "Release notes updated from CHANGELOG.md"
fi
- name: Build package and upload - name: Build package and upload
id: package id: package
if: steps.eligibility.outputs.proceed == 'true'
run: | run: |
VERSION="${{ steps.meta.outputs.version }}" VERSION="${{ steps.meta.outputs.version }}"
TAG="${{ steps.meta.outputs.tag }}" TAG="${{ steps.meta.outputs.tag }}"
@@ -240,7 +157,6 @@ jobs:
# No need to build, commit, or sync updates.xml from workflows # No need to build, commit, or sync updates.xml from workflows
- name: "Delete lesser pre-release channels (cascade)" - name: "Delete lesser pre-release channels (cascade)"
if: steps.eligibility.outputs.proceed == 'true'
continue-on-error: true continue-on-error: true
run: | run: |
API_BASE="${GITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}" API_BASE="${GITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}"
-66
View File
@@ -1,66 +0,0 @@
# Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
#
# SPDX-License-Identifier: GPL-3.0-or-later
#
# FILE INFORMATION
# DEFGROUP: Gitea.Workflow
# INGROUP: mokocli.Universal
# REPO: https://git.mokoconsulting.tech/MokoConsulting/mokocli
# PATH: /.mokogitea/workflows/rc-revert.yml
# VERSION: 09.23.00
# BRIEF: Rename rc/ branch back to dev/ when PR is closed without merge
name: "RC Revert"
on:
pull_request:
types: [closed]
env:
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
jobs:
revert:
name: Rename rc/ back to dev/
runs-on: ubuntu-latest
if: >-
github.event.pull_request.merged == false &&
startsWith(github.event.pull_request.head.ref, 'rc/')
steps:
- name: Rename branch
run: |
BRANCH="${{ github.event.pull_request.head.ref }}"
SUFFIX="${BRANCH#rc/}"
DEV_BRANCH="dev/${SUFFIX}"
API="${{ vars.GITEA_URL || 'https://git.mokoconsulting.tech' }}/api/v1/repos/${{ github.repository }}/branches"
TOKEN="${{ secrets.MOKOGITEA_TOKEN }}"
# Create dev/ branch from rc/ branch
STATUS=$(curl -sf -o /dev/null -w "%{http_code}" -X POST \
-H "Authorization: token ${TOKEN}" \
-H "Content-Type: application/json" \
-d "{\"new_branch_name\": \"${DEV_BRANCH}\", \"old_branch_name\": \"${BRANCH}\"}" \
"${API}" 2>/dev/null || true)
if [ "$STATUS" = "201" ]; then
echo "Created branch: ${DEV_BRANCH}" >> $GITHUB_STEP_SUMMARY
else
echo "::error::Failed to create ${DEV_BRANCH} from ${BRANCH} (HTTP ${STATUS})"
exit 1
fi
# Delete rc/ branch
ENCODED=$(php -r "echo rawurlencode('${BRANCH}');")
STATUS=$(curl -sf -o /dev/null -w "%{http_code}" -X DELETE \
-H "Authorization: token ${TOKEN}" \
"${API}/${ENCODED}" 2>/dev/null || true)
if [ "$STATUS" = "204" ]; then
echo "Deleted branch: ${BRANCH}" >> $GITHUB_STEP_SUMMARY
else
echo "::warning::Failed to delete ${BRANCH} (HTTP ${STATUS})"
fi
echo "### RC Reverted" >> $GITHUB_STEP_SUMMARY
echo "${BRANCH} → ${DEV_BRANCH}" >> $GITHUB_STEP_SUMMARY
+3 -4
View File
@@ -7,8 +7,8 @@
# #
# FILE INFORMATION # FILE INFORMATION
# DEFGROUP: Gitea.Workflow # DEFGROUP: Gitea.Workflow
# INGROUP: mokocli.Validation # INGROUP: moko-platform.Validation
# REPO: https://git.mokoconsulting.tech/mokoconsulting-tech/mokocli # REPO: https://git.mokoconsulting.tech/mokoconsulting-tech/moko-platform
# PATH: /templates/workflows/joomla/repo_health.yml.template # PATH: /templates/workflows/joomla/repo_health.yml.template
# VERSION: 09.23.00 # VERSION: 09.23.00
# BRIEF: Enforces repository guardrails by validating scripts governance, tooling availability, and core repository health artifacts. # BRIEF: Enforces repository guardrails by validating scripts governance, tooling availability, and core repository health artifacts.
@@ -33,8 +33,7 @@ on:
- scripts - scripts
- repo - repo
pull_request: pull_request:
branches: push:
- main
permissions: permissions:
contents: read contents: read
+19 -3
View File
@@ -4,10 +4,10 @@
# #
# FILE INFORMATION # FILE INFORMATION
# DEFGROUP: Gitea.Workflow # DEFGROUP: Gitea.Workflow
# INGROUP: MokoStandards.Security # INGROUP: moko-platform.Security
# REPO: https://git.mokoconsulting.tech/MokoConsulting/MokoStandards # REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform
# PATH: /.gitea/workflows/security-audit.yml # PATH: /.gitea/workflows/security-audit.yml
# VERSION: 01.00.00 # VERSION: 09.23.00
# BRIEF: Dependency vulnerability scanning for composer and npm packages # BRIEF: Dependency vulnerability scanning for composer and npm packages
name: "Universal: Security Audit" name: "Universal: Security Audit"
@@ -80,3 +80,19 @@ jobs:
-H "Priority: high" \ -H "Priority: high" \
-d "Security audit found vulnerabilities. Review dependency updates." \ -d "Security audit found vulnerabilities. Review dependency updates." \
"${NTFY_URL}/${NTFY_TOPIC}" || true "${NTFY_URL}/${NTFY_TOPIC}" || true
- name: Joomla version audit
if: always()
run: |
if [ -f "monitoring/joomla-version-audit.php" ] && [ -n "$JOOMLA_SITES" ]; then
echo "$JOOMLA_SITES" > /tmp/sites.json
php monitoring/joomla-version-audit.php --sites /tmp/sites.json || true
echo "### Joomla Version Audit" >> $GITHUB_STEP_SUMMARY
rm -f /tmp/sites.json
else
echo "Joomla audit skipped (no script or JOOMLA_SITES_JSON not configured)"
fi
env:
JOOMLA_SITES: ${{ vars.JOOMLA_SITES_JSON }}
@@ -1,103 +0,0 @@
# Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
#
# SPDX-License-Identifier: GPL-3.0-or-later
#
# FILE INFORMATION
# DEFGROUP: Gitea.Workflow
# INGROUP: mokocli.Automation
# REPO: https://git.mokoconsulting.tech/MokoConsulting/mokocli
# PATH: /.mokogitea/workflows/sync-feature-versions.yml
# VERSION: 01.00.00
# BRIEF: Merge dev into open feature branches after version bumps
name: "Universal: Sync Feature Branch Versions"
on:
push:
branches:
- dev
workflow_dispatch:
permissions:
contents: write
env:
GITEA_URL: ${{ vars.GITEA_URL || 'https://git.mokoconsulting.tech' }}
jobs:
sync:
name: Sync feature branches with dev
runs-on: ubuntu-latest
if: >-
github.event_name == 'workflow_dispatch' ||
contains(github.event.head_commit.message, 'chore(version)')
steps:
- name: Checkout dev
uses: actions/checkout@v4
with:
fetch-depth: 0
ref: dev
token: ${{ secrets.MOKOGITEA_TOKEN }}
- name: Configure git
run: |
git config --local user.email "gitea-actions[bot]@mokoconsulting.tech"
git config --local user.name "gitea-actions[bot]"
git remote set-url origin "https://x-access-token:${{ secrets.MOKOGITEA_TOKEN }}@git.mokoconsulting.tech/${{ github.repository }}.git"
- name: Merge dev into feature branches
run: |
echo "=== Syncing feature branches with dev ==="
# Fetch all remote branches
git fetch origin
# Find feature branches (feature/*, fix/*, patch/*, hotfix/*, bugfix/*, chore/*)
BRANCHES=$(git branch -r --list 'origin/feature/*' 'origin/fix/*' 'origin/patch/*' 'origin/hotfix/*' 'origin/bugfix/*' 'origin/chore/*' | sed 's|origin/||; s/^[[:space:]]*//')
if [ -z "$BRANCHES" ]; then
echo "No feature branches found — nothing to sync"
exit 0
fi
SYNCED=0
SKIPPED=0
FAILED=0
for BRANCH in $BRANCHES; do
echo ""
echo "--- ${BRANCH} ---"
# Skip branches that are already up to date with dev
if git merge-base --is-ancestor dev "origin/${BRANCH}" 2>/dev/null; then
echo "Already up to date"
SKIPPED=$((SKIPPED + 1))
continue
fi
# Try to merge dev into the branch
git checkout "origin/${BRANCH}" -B "$BRANCH" 2>/dev/null
if git merge dev --no-edit -m "chore: merge dev into ${BRANCH} (version sync) [skip ci]" 2>/dev/null; then
git push origin "$BRANCH" 2>/dev/null
echo "Synced successfully"
SYNCED=$((SYNCED + 1))
else
git merge --abort 2>/dev/null || true
echo "Merge conflict — skipping (manual rebase needed)"
FAILED=$((FAILED + 1))
fi
done
# Return to dev
git checkout dev 2>/dev/null || true
echo ""
echo "=== Summary ==="
echo "Synced: ${SYNCED}"
echo "Already current: ${SKIPPED}"
echo "Conflicts (skipped): ${FAILED}"
if [ "$FAILED" -gt 0 ]; then
echo "::warning::${FAILED} branch(es) had merge conflicts and need manual rebase"
fi
+302
View File
@@ -0,0 +1,302 @@
# Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
#
# SPDX-License-Identifier: GPL-3.0-or-later
#
# FILE INFORMATION
# DEFGROUP: Gitea.Workflow
# INGROUP: moko-platform.Universal
# REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform
# PATH: /templates/workflows/update-server.yml
# VERSION: 09.23.00
# BRIEF: Pre-release build + update server XML for dev/alpha/beta/rc branches
#
# Thin wrapper around moko-platform CLI tools.
# Builds packages, updates updates.xml, and optionally deploys via SFTP.
#
# Joomla filters update entries by the user's "Minimum Stability" setting.
name: "Update Server"
on:
push:
branches:
- 'dev'
- 'dev/**'
- 'alpha/**'
- 'beta/**'
- 'rc/**'
paths:
- 'src/**'
- 'htdocs/**'
pull_request:
types: [closed]
branches:
- 'dev'
- 'dev/**'
- 'alpha/**'
- 'beta/**'
- 'rc/**'
paths:
- 'src/**'
- 'htdocs/**'
workflow_dispatch:
inputs:
stability:
description: 'Stability tag'
required: true
default: 'development'
type: choice
options:
- development
- alpha
- beta
- rc
- stable
env:
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
GITEA_URL: ${{ vars.GITEA_URL || 'https://git.mokoconsulting.tech' }}
GITEA_ORG: ${{ vars.GITEA_ORG || github.repository_owner }}
GITEA_REPO: ${{ vars.GITEA_REPO || github.event.repository.name }}
permissions:
contents: write
jobs:
update-xml:
name: Update Server
runs-on: release
if: >-
github.event.pull_request.merged == true || github.event_name == 'workflow_dispatch' || github.event_name == 'push'
steps:
- name: Checkout repository
uses: actions/checkout@v4
with:
token: ${{ secrets.MOKOGITEA_TOKEN }}
fetch-depth: 0
- name: Setup moko-platform tools
env:
MOKO_CLONE_TOKEN: ${{ secrets.MOKOGITEA_TOKEN }}
MOKO_CLONE_HOST: git.mokoconsulting.tech/MokoConsulting
COMPOSER_AUTH: '{"http-basic":{"git.mokoconsulting.tech":{"username":"token","password":"${{ secrets.MOKOGITEA_TOKEN }}"}}}'
run: |
if ! command -v composer &> /dev/null; then
sudo apt-get update -qq && sudo apt-get install -y -qq php-cli php-mbstring php-xml php-zip php-curl composer >/dev/null 2>&1
fi
# Always fetch latest CLI tools — never use stale cache from previous runs
rm -rf /tmp/moko-platform
git clone --depth 1 --branch main --quiet \
"https://x-access-token:${MOKO_CLONE_TOKEN}@${MOKO_CLONE_HOST}/moko-platform.git" \
/tmp/moko-platform 2>/dev/null || true
if [ -d "/tmp/moko-platform" ] && [ -f "/tmp/moko-platform/composer.json" ]; then
cd /tmp/moko-platform && composer install --no-dev --no-interaction --quiet 2>/dev/null || true
fi
echo "MOKO_CLI=/tmp/moko-platform/cli" >> "$GITHUB_ENV"
- name: Detect platform
id: platform
run: php ${MOKO_CLI}/manifest_read.php --path . --github-output
- name: Resolve stability and bump version
id: meta
run: |
BRANCH="${{ github.ref_name }}"
# Configure git for bot pushes
git config --local user.email "gitea-actions[bot]@mokoconsulting.tech"
git config --local user.name "gitea-actions[bot]"
git remote set-url origin "https://x-access-token:${{ secrets.MOKOGITEA_TOKEN }}@git.mokoconsulting.tech/${{ github.repository }}.git"
# Determine stability from branch or manual input
if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
STABILITY="${{ inputs.stability }}"
elif [[ "$BRANCH" == rc/* ]]; then
STABILITY="rc"
elif [[ "$BRANCH" == beta/* ]]; then
STABILITY="beta"
elif [[ "$BRANCH" == alpha/* ]]; then
STABILITY="alpha"
else
STABILITY="development"
fi
# Gitea release tag per stability
case "$STABILITY" in
development) TAG="development" ;;
alpha) TAG="alpha" ;;
beta) TAG="beta" ;;
rc) TAG="release-candidate" ;;
*) TAG="stable" ;;
esac
# Bump patch, set platform suffix, fix consistency — version_bump preserves suffix
php ${MOKO_CLI}/version_set_platform.php \
--path . --version "$(php ${MOKO_CLI}/version_read.php --path . 2>/dev/null || echo '00.00.01')" \
--branch "$BRANCH" --stability "$STABILITY" 2>/dev/null || true
php ${MOKO_CLI}/version_bump.php --path . 2>/dev/null || true
php ${MOKO_CLI}/version_check.php --path . --fix 2>/dev/null || true
# Read final version (includes suffix, e.g. 01.02.15-dev)
VERSION=$(php ${MOKO_CLI}/version_read.php --path . 2>/dev/null || echo "00.00.01")
echo "version=${VERSION}" >> "$GITHUB_OUTPUT"
echo "stability=${STABILITY}" >> "$GITHUB_OUTPUT"
echo "tag=${TAG}" >> "$GITHUB_OUTPUT"
# Commit version bump if changed
git add -A
git diff --cached --quiet || {
git commit -m "chore(version): auto-bump ${VERSION} [skip ci]" \
--author="gitea-actions[bot] <gitea-actions[bot]@mokoconsulting.tech>"
git push
}
- name: Create release and upload package
id: package
run: |
VERSION="${{ steps.meta.outputs.version }}"
TAG="${{ steps.meta.outputs.tag }}"
API_BASE="${GITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}"
# Create or update Gitea release
php ${MOKO_CLI}/release_create.php \
--path . --version "$VERSION" --tag "$TAG" \
--token "${{ secrets.MOKOGITEA_TOKEN }}" --api-base "$API_BASE" \
--repo "${GITEA_REPO}" --branch "${{ github.ref_name }}" --prerelease
# Build package and upload
php ${MOKO_CLI}/release_package.php \
--path . --version "$VERSION" --tag "$TAG" \
--token "${{ secrets.MOKOGITEA_TOKEN }}" --api-base "$API_BASE" \
--repo "${GITEA_REPO}" --output /tmp || true
- name: Update updates.xml
if: steps.platform.outputs.platform == 'joomla'
run: |
VERSION="${{ steps.meta.outputs.version }}"
STABILITY="${{ steps.meta.outputs.stability }}"
SHA256="${{ steps.package.outputs.sha256_zip }}"
if [ ! -f "updates.xml" ]; then
echo "No updates.xml — skipping"
exit 0
fi
SHA_FLAG=""
[ -n "$SHA256" ] && SHA_FLAG="--sha ${SHA256}"
php ${MOKO_CLI}/updates_xml_build.php \
--path . --version "${VERSION}" --stability "${STABILITY}" \
--gitea-url "${GITEA_URL}" --org "${GITEA_ORG}" --repo "${GITEA_REPO}" \
${SHA_FLAG}
# Commit and push updates.xml
git add updates.xml
git diff --cached --quiet || {
git commit -m "chore: update ${STABILITY} channel ${VERSION} [skip ci]"
git push
}
- name: Sync updates.xml to main
if: github.ref_name != 'main' && steps.platform.outputs.platform == 'joomla'
run: |
API_BASE="${GITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}"
GITEA_TOKEN="${{ secrets.MOKOGITEA_TOKEN }}"
FILE_SHA=$(curl -sf -H "Authorization: token ${GITEA_TOKEN}" \
"${API_BASE}/contents/updates.xml?ref=main" | python3 -c "import sys,json; print(json.load(sys.stdin).get('sha',''))" 2>/dev/null || true)
if [ -n "$FILE_SHA" ] && [ -f "updates.xml" ]; then
python3 -c "
import base64, json, urllib.request, sys
with open('updates.xml', 'rb') as f:
content = base64.b64encode(f.read()).decode()
payload = json.dumps({
'content': content,
'sha': '${FILE_SHA}',
'message': 'chore: sync updates.xml from ${{ steps.meta.outputs.stability }} [skip ci]',
'branch': 'main'
}).encode()
req = urllib.request.Request(
'${API_BASE}/contents/updates.xml',
data=payload, method='PUT',
headers={
'Authorization': 'token ${GITEA_TOKEN}',
'Content-Type': 'application/json'
})
try:
urllib.request.urlopen(req)
print('updates.xml synced to main')
except Exception as e:
print(f'WARNING: sync to main failed: {e}', file=sys.stderr)
"
fi
- name: SFTP deploy to dev server
if: contains(github.ref, 'dev/') || github.ref == 'refs/heads/dev'
env:
DEV_HOST: ${{ vars.DEV_FTP_HOST }}
DEV_PATH: ${{ vars.DEV_FTP_PATH }}
DEV_SUFFIX: ${{ vars.DEV_FTP_SUFFIX }}
DEV_USER: ${{ vars.DEV_FTP_USERNAME }}
DEV_PORT: ${{ vars.DEV_FTP_PORT }}
DEV_KEY: ${{ secrets.DEV_FTP_KEY }}
DEV_PASS: ${{ secrets.DEV_FTP_PASSWORD }}
run: |
# Permission check: admin or maintain role required
ACTOR="${{ github.actor }}"
API_BASE="${GITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}"
PERMISSION=$(curl -sf -H "Authorization: token ${{ secrets.MOKOGITEA_TOKEN }}" \
"${API_BASE}/collaborators/${ACTOR}/permission" 2>/dev/null | \
python3 -c "import sys,json; print(json.load(sys.stdin).get('permission','read'))" 2>/dev/null || echo "read")
case "$PERMISSION" in
admin|maintain|write) ;;
*)
echo "Deploy denied: ${ACTOR} has '${PERMISSION}' — requires admin, maintain, or write"
exit 0
;;
esac
[ -z "$DEV_HOST" ] || [ -z "$DEV_PATH" ] && { echo "DEV FTP not configured — skipping SFTP"; exit 0; }
SOURCE_DIR="src"
[ ! -d "$SOURCE_DIR" ] && SOURCE_DIR="htdocs"
[ ! -d "$SOURCE_DIR" ] && exit 0
PORT="${DEV_PORT:-22}"
REMOTE="${DEV_PATH%/}"
[ -n "$DEV_SUFFIX" ] && REMOTE="${REMOTE}/${DEV_SUFFIX#/}"
printf '{"host":"%s","port":%s,"username":"%s","remotePath":"%s"' \
"$DEV_HOST" "$PORT" "$DEV_USER" "$REMOTE" > /tmp/sftp-config.json
if [ -n "$DEV_KEY" ]; then
echo "$DEV_KEY" > /tmp/deploy_key && chmod 600 /tmp/deploy_key
printf ',"privateKeyPath":"/tmp/deploy_key"}' >> /tmp/sftp-config.json
else
printf ',"password":"%s"}' "$DEV_PASS" >> /tmp/sftp-config.json
fi
PLATFORM=$(php ${MOKO_CLI}/platform_detect.php --path . 2>/dev/null || true)
if [ "$PLATFORM" = "waas-component" ] && [ -f "${MOKO_CLI}/../deploy/deploy-joomla.php" ]; then
php ${MOKO_CLI}/../deploy/deploy-joomla.php --path . --src-dir "$SOURCE_DIR" --config /tmp/sftp-config.json
elif [ -f "${MOKO_CLI}/../deploy/deploy-sftp.php" ]; then
php ${MOKO_CLI}/../deploy/deploy-sftp.php --path . --src-dir "$SOURCE_DIR" --config /tmp/sftp-config.json
fi
rm -f /tmp/deploy_key /tmp/sftp-config.json
echo "SFTP deploy to dev complete" >> $GITHUB_STEP_SUMMARY
- name: Summary
if: always()
run: |
VERSION="${{ steps.meta.outputs.version }}"
STABILITY="${{ steps.meta.outputs.stability }}"
DISPLAY="${VERSION}"
echo "## Update Server" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "| Field | Value |" >> $GITHUB_STEP_SUMMARY
echo "|-------|-------|" >> $GITHUB_STEP_SUMMARY
echo "| Stability | \`${STABILITY}\` |" >> $GITHUB_STEP_SUMMARY
echo "| Version | \`${DISPLAY}\` |" >> $GITHUB_STEP_SUMMARY
@@ -1,73 +0,0 @@
# Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
#
# SPDX-License-Identifier: GPL-3.0-or-later
#
# FILE INFORMATION
# DEFGROUP: Gitea.Workflow
# INGROUP: mokocli.Universal
# REPO: https://git.mokoconsulting.tech/MokoConsulting/mokocli
# PATH: /.mokogitea/workflows/workflow-sync-trigger.yml
# VERSION: 01.01.00
# BRIEF: Trigger workflow sync to live repos when a PR is merged to main
name: "Universal: Workflow Sync Trigger"
on:
pull_request:
types: [closed]
branches:
- main
env:
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
jobs:
sync:
name: Sync workflows to live repos
runs-on: ubuntu-latest
if: >-
github.event.pull_request.merged == true &&
!contains(github.event.pull_request.title, '[skip sync]')
steps:
- name: Determine platform from repo name
id: platform
run: |
REPO="${{ github.event.repository.name }}"
case "$REPO" in
Template-Joomla) PLATFORM="joomla" ;;
Template-Dolibarr) PLATFORM="dolibarr" ;;
Template-Go) PLATFORM="go" ;;
Template-MCP) PLATFORM="mcp" ;;
Template-Generic) PLATFORM="" ;;
*) PLATFORM="" ;;
esac
echo "platform=$PLATFORM" >> "$GITHUB_OUTPUT"
echo "Platform: ${PLATFORM:-all}"
- name: Clone mokocli
env:
MOKOGITEA_TOKEN: ${{ secrets.MOKOGITEA_TOKEN }}
run: |
GITEA_URL="${{ vars.GITEA_URL || 'https://git.mokoconsulting.tech' }}"
git clone --depth 1 "${GITEA_URL}/MokoConsulting/mokocli.git" /tmp/mokocli
- name: Install dependencies
run: |
cd /tmp/mokocli
composer install --no-dev --no-interaction --quiet 2>/dev/null || true
- name: Run workflow sync
env:
MOKOGITEA_TOKEN: ${{ secrets.MOKOGITEA_TOKEN }}
run: |
ARGS="--token ${MOKOGITEA_TOKEN}"
ARGS="${ARGS} --org ${{ vars.GITEA_ORG || github.repository_owner }}"
ARGS="${ARGS} --phase repos"
PLATFORM="${{ steps.platform.outputs.platform }}"
if [ -n "$PLATFORM" ]; then
ARGS="${ARGS} --platform-filter ${PLATFORM}"
fi
php /tmp/mokocli/cli/workflow_sync.php ${ARGS}
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"metadata": { "metadata": {
"generated_at": "2026-03-10T19:51:42.238134Z", "generated_at": "2026-03-10T19:51:42.238134Z",
"repository": "MokoConsulting/mokocli", "repository": "MokoConsulting/moko-platform",
"version": "1.0.0" "version": "1.0.0"
}, },
"scripts": [ "scripts": [
+18 -9
View File
@@ -2,9 +2,9 @@
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
FILE INFORMATION FILE INFORMATION
DEFGROUP: MokoCli.Root DEFGROUP: MokoStandards.Root
INGROUP: MokoCli INGROUP: MokoStandards
REPO: https://git.mokoconsulting.tech/MokoConsulting/mokoplatform REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform
PATH: /CHANGELOG.md PATH: /CHANGELOG.md
BRIEF: Release changelog BRIEF: Release changelog
--> -->
@@ -12,14 +12,23 @@ BRIEF: Release changelog
# Changelog # Changelog
## [Unreleased] ## [Unreleased]
## [09.38.00] --- 2026-06-21
## [09.38.00] --- 2026-06-21 ## [09.24.00] --- 2026-06-04
## [09.38.00] --- 2026-06-21 ## [09.23] --- 2026-05-31
## [09.38.00] --- 2026-06-21 ## [09.22] --- 2026-05-31
## [09.37.00] --- 2026-06-21 ### Changed
- **refactor(cli):** migrate 64 legacy scripts to CliFramework (#235) — all tools in cli/, automation/, maintenance/, deploy/, release/ now extend CliFramework with free --help, --verbose, --quiet, --dry-run, --json, banners, and coloured logging
## [09.37.00] --- 2026-06-21 ### Fixed
- fix: auto-detect org/repo in updates_xml_build from manifest and git remote
- fix: restore hyphen in version suffixes
- fix: release names use standardized format
- fix: remove lesser stream copies, each stream updates independently
- fix: sort updates.xml entries dev first, stable last
## [09.21] --- 2026-05-30
## [09.20] --- 2026-05-30
+102
View File
@@ -0,0 +1,102 @@
# CLAUDE.md
This file provides guidance to Claude Code when working with this repository.
## Project Overview
**moko-platform** — Enterprise automation, validation, sync, and governance engine for all Moko Consulting repositories
| Field | Value |
|---|---|
| **Language** | PHP 8.1+ |
| **Default branch** | main |
| **License** | GPL-3.0-or-later |
| **Version** | 09.01.00 |
| **Wiki** | [moko-platform Wiki](https://git.mokoconsulting.tech/MokoConsulting/moko-platform/wiki) |
## Common Commands
```bash
composer install # Install PHP dependencies
php bin/moko health --path . # Run repo health check
php bin/moko check:syntax --path . # PHP syntax check
php bin/moko drift --org MokoConsulting # Scan for standards drift
php bin/moko dashboard --token $TOKEN -o dashboard.html # Generate client dashboard
# Code quality
php vendor/bin/phpcs --standard=phpcs.xml -n lib/ validate/ automation/ cli/
php vendor/bin/phpcbf --standard=phpcs.xml lib/ validate/ automation/ cli/
php vendor/bin/phpstan analyse -c phpstan.neon --memory-limit=512M
# Run all checks
composer check
```
## Architecture
### Directory Layout
| Directory | Purpose |
|-----------|---------|
| `cli/` | 32 standalone CLI tools (version, release, build, repo management) |
| `validate/` | 20 validation scripts (syntax, structure, manifests, drift) |
| `automation/` | 7 bulk operations (sync, push files, templates, cleanup) |
| `lib/Enterprise/` | Core library — CliFramework, ApiClient, adapters, validators |
| `lib/Enterprise/Plugins/` | 11 platform plugins (Joomla, Dolibarr, Node.js, Python, etc.) |
| `deploy/` | SFTP deployment scripts (Joomla, Dolibarr, health checks) |
| `templates/` | Universal templates, configs, governance schema |
| `.mokogitea/workflows/` | CI/CD workflows (Gitea Actions) |
| `bin/moko` | Unified CLI dispatcher — runs any tool via `php bin/moko <command>` |
### CLI Framework
All CLI tools extend `MokoEnterprise\CliFramework` (defined in `lib/Enterprise/CliFramework.php`).
Pattern for new tools:
```php
class MyTool extends CliFramework {
protected function configure(): void {
$this->setDescription('What this tool does');
$this->addArgument('--name', 'Description', 'default');
}
protected function run(): int {
$name = $this->getArgument('--name');
// ... business logic ...
return 0;
}
}
$app = new MyTool();
exit($app->execute());
```
Built-in flags: `--help`, `--verbose`, `--quiet`, `--dry-run`
### Platform Adapters
Git operations are abstracted via `GitPlatformAdapter` interface:
- `MokoGiteaAdapter` — for git.mokoconsulting.tech (primary)
- `GitHubAdapter` — for github.com mirrors
### Plugin System
Platform-specific logic lives in `lib/Enterprise/Plugins/`. Each plugin implements `ProjectPluginInterface` with methods for health checks, validation, build commands, and config schemas.
## Code Quality
| Tool | Level | Config |
|------|-------|--------|
| PHPCS | PSR-12 (errors only) | `phpcs.xml` |
| PHPStan | Level 2 | `phpstan.neon` |
PHPStan runs with `--memory-limit=512M` due to large codebase. CI enforces PHPCS errors; PHPStan is advisory (`continue-on-error`).
## Rules
- **Workflow directory**: `.mokogitea/` (not `.gitea/` or `.github/`)
- **Never commit** `.claude/`, `.mcp.json`, `TODO.md`, or `*.min.css`/`*.min.js`
- **Attribution**: use `Authored-by: Moko Consulting` in commits
- **Branch strategy**: develop on `dev`, merge to `main` for release
- **Minification**: handled at build time (CI) and runtime (MokoMinifyHelper for Joomla templates)
- **Wiki**: documentation lives in the Gitea wiki, not in `docs/` files
- **New CLI tools**: extend `CliFramework`, not `CLIApp` (legacy)
- **After adding a CLI tool**: register it in `bin/moko` COMMAND_MAP
+161 -161
View File
@@ -1,161 +1,161 @@
# Contributing to Moko Consulting Projects # Contributing to Moko Consulting Projects
Thank you for your interest in contributing. All Moko Consulting repositories follow this universal workflow and version policy. Thank you for your interest in contributing. All Moko Consulting repositories follow this universal workflow and version policy.
## Branching Workflow ## Branching Workflow
``` ```
feature/* ──PR──> dev ──draft PR──> (renamed to rc) ──merge──> main feature/* ──PR──> dev ──draft PR──> (renamed to rc) ──merge──> main
``` ```
### Step by step ### Step by step
1. **Create a feature branch** from `dev`: 1. **Create a feature branch** from `dev`:
```bash ```bash
git checkout dev && git pull git checkout dev && git pull
git checkout -b feature/my-change git checkout -b feature/my-change
``` ```
2. **Work and commit** on your feature branch. Push to origin. 2. **Work and commit** on your feature branch. Push to origin.
3. **Open a PR**: `feature/my-change` → `dev`. After review and checks, merge it. 3. **Open a PR**: `feature/my-change` → `dev`. After review and checks, merge it.
4. **When ready for release**, open a **draft PR**: `dev` → `main`. 4. **When ready for release**, open a **draft PR**: `dev` → `main`.
- This automatically renames the source branch to `rc` (release candidate) - This automatically renames the source branch to `rc` (release candidate)
- An RC pre-release is built and uploaded - An RC pre-release is built and uploaded
5. **Alpha and beta branches** are created by manually renaming the branch before the RC stage: 5. **Alpha and beta branches** are created by manually renaming the branch before the RC stage:
- Rename `dev` to `alpha` for early testing → alpha pre-release is built - Rename `dev` to `alpha` for early testing → alpha pre-release is built
- Rename `alpha` to `beta` for feature-complete testing → beta pre-release is built - Rename `alpha` to `beta` for feature-complete testing → beta pre-release is built
- When the draft PR is created, the branch is renamed to `rc` - When the draft PR is created, the branch is renamed to `rc`
6. **Once PR checks pass** on the `rc` branch, mark the PR as ready and merge to `main`. 6. **Once PR checks pass** on the `rc` branch, mark the PR as ready and merge to `main`.
7. **Merging to main** triggers the stable release pipeline: 7. **Merging to main** triggers the stable release pipeline:
- Minor version bump (e.g., `02.09.xx` → `02.10.00`) - Minor version bump (e.g., `02.09.xx` → `02.10.00`)
- Stability suffix stripped (clean version) - Stability suffix stripped (clean version)
- Gitea release created with ZIP/tar.gz packages - Gitea release created with ZIP/tar.gz packages
- `updates.xml` updated (Joomla extensions) - `updates.xml` updated (Joomla extensions)
- `dev` branch recreated from `main` - `dev` branch recreated from `main`
### Branch summary ### Branch summary
| Branch | Purpose | Created by | | Branch | Purpose | Created by |
|--------|---------|-----------| |--------|---------|-----------|
| `feature/*` | New features and fixes | Developer | | `feature/*` | New features and fixes | Developer |
| `dev` | Integration branch | Auto-recreated after release | | `dev` | Integration branch | Auto-recreated after release |
| `alpha` | Alpha pre-release testing | Manual rename from `dev` | | `alpha` | Alpha pre-release testing | Manual rename from `dev` |
| `beta` | Beta pre-release testing | Manual rename from `alpha` | | `beta` | Beta pre-release testing | Manual rename from `alpha` |
| `rc` | Release candidate | Auto-renamed on draft PR to main | | `rc` | Release candidate | Auto-renamed on draft PR to main |
| `main` | Stable releases | Protected, merge only | | `main` | Stable releases | Protected, merge only |
| `version/XX.YY.ZZ` | Archived release snapshots | Auto-created by CI | | `version/XX.YY.ZZ` | Archived release snapshots | Auto-created by CI |
### Protected branches ### Protected branches
| Branch | Direct push | Merge via | | Branch | Direct push | Merge via |
|--------|------------|-----------| |--------|------------|-----------|
| `main` | Blocked (CI bot whitelisted) | PR merge only | | `main` | Blocked (CI bot whitelisted) | PR merge only |
| `dev` | Blocked (CI bot whitelisted) | PR merge from feature/* | | `dev` | Blocked (CI bot whitelisted) | PR merge from feature/* |
| `rc` | Blocked (CI bot whitelisted) | Auto-created on draft PR | | `rc` | Blocked (CI bot whitelisted) | Auto-created on draft PR |
| `alpha` | Blocked (CI bot whitelisted) | Manual rename | | `alpha` | Blocked (CI bot whitelisted) | Manual rename |
| `beta` | Blocked (CI bot whitelisted) | Manual rename | | `beta` | Blocked (CI bot whitelisted) | Manual rename |
| `feature/*` | Open | N/A (source branch) | | `feature/*` | Open | N/A (source branch) |
## Version Policy ## Version Policy
### Format ### Format
All versions use `XX.YY.ZZ` — three two-digit segments, zero-padded: All versions use `XX.YY.ZZ` — three two-digit segments, zero-padded:
- **XX** — Major version (breaking changes) - **XX** — Major version (breaking changes)
- **YY** — Minor version (new features, bumped on release to main) - **YY** — Minor version (new features, bumped on release to main)
- **ZZ** — Patch version (auto-incremented on every push to dev/feature branches) - **ZZ** — Patch version (auto-incremented on every push to dev/feature branches)
Rollover: patch `99` → `00` increments minor; minor `99` → `00` increments major. Rollover: patch `99` → `00` increments minor; minor `99` → `00` increments major.
### Stability suffixes ### Stability suffixes
Each branch appends a suffix to indicate stability: Each branch appends a suffix to indicate stability:
| Branch | Suffix | Example | | Branch | Suffix | Example |
|--------|--------|---------| |--------|--------|---------|
| `main` | (none) | `02.09.00` | | `main` | (none) | `02.09.00` |
| `dev` | `-dev` | `02.09.01-dev` | | `dev` | `-dev` | `02.09.01-dev` |
| `feature/*` | `-dev` | `02.09.01-dev` | | `feature/*` | `-dev` | `02.09.01-dev` |
| `alpha` | `-alpha` | `02.09.01-alpha` | | `alpha` | `-alpha` | `02.09.01-alpha` |
| `beta` | `-beta` | `02.09.01-beta` | | `beta` | `-beta` | `02.09.01-beta` |
| `rc` | `-rc` | `02.09.01-rc` | | `rc` | `-rc` | `02.09.01-rc` |
### Auto version bump ### Auto version bump
On every push to `dev`, `feature/*`, or `patch/*`: On every push to `dev`, `feature/*`, or `patch/*`:
1. Patch version incremented 1. Patch version incremented
2. Stability suffix `-dev` applied 2. Stability suffix `-dev` applied
3. All version-bearing files updated (manifests, CHANGELOG, PHP headers, etc.) 3. All version-bearing files updated (manifests, CHANGELOG, PHP headers, etc.)
4. Commit created with `[skip ci]` to avoid loops 4. Commit created with `[skip ci]` to avoid loops
### Release version flow ### Release version flow
Version bumps happen at specific release events: Version bumps happen at specific release events:
| Event | Bump | Example | | Event | Bump | Example |
|-------|------|---------| |-------|------|---------|
| Feature merged to dev | Patch bump after dev release | `02.09.01-dev` → release → `02.09.02-dev` | | Feature merged to dev | Patch bump after dev release | `02.09.01-dev` → release → `02.09.02-dev` |
| Dev promoted to RC | Minor bump | `02.09.02-dev` → `02.10.00-rc` | | Dev promoted to RC | Minor bump | `02.09.02-dev` → `02.10.00-rc` |
| RC merged to main | Minor bump | `02.10.00-rc` → `02.11.00` (stable) | | RC merged to main | Minor bump | `02.10.00-rc` → `02.11.00` (stable) |
| Dev recreated from main | Patch bump | `02.11.00` → `02.11.01-dev` | | Dev recreated from main | Patch bump | `02.11.00` → `02.11.01-dev` |
### Release stream copies ### Release stream copies
When a higher-stability release is published, copies are created for all lesser streams with the same base version: When a higher-stability release is published, copies are created for all lesser streams with the same base version:
- **RC `02.10.00-rc`** also creates: `02.10.00-dev`, `02.10.00-alpha`, `02.10.00-beta` - **RC `02.10.00-rc`** also creates: `02.10.00-dev`, `02.10.00-alpha`, `02.10.00-beta`
- **Stable `02.11.00`** also creates: `02.11.00-dev`, `02.11.00-alpha`, `02.11.00-beta`, `02.11.00-rc` - **Stable `02.11.00`** also creates: `02.11.00-dev`, `02.11.00-alpha`, `02.11.00-beta`, `02.11.00-rc`
This ensures Joomla sites on ANY stability channel see the update (Joomla only shows versions higher than what's installed). This ensures Joomla sites on ANY stability channel see the update (Joomla only shows versions higher than what's installed).
### Version files ### Version files
The version tools update all files containing version stamps: The version tools update all files containing version stamps:
- `.mokogitea/manifest.xml` (canonical source) - `.mokogitea/manifest.xml` (canonical source)
- Joomla XML manifests (`<version>` tag) - Joomla XML manifests (`<version>` tag)
- `README.md`, `CHANGELOG.md` (`VERSION:` pattern) - `README.md`, `CHANGELOG.md` (`VERSION:` pattern)
- `package.json`, `pyproject.toml` - `package.json`, `pyproject.toml`
- Any text file with a `VERSION: XX.YY.ZZ` label - Any text file with a `VERSION: XX.YY.ZZ` label
Files synced from other repos (with a `# REPO:` header) are not touched. Files synced from other repos (with a `# REPO:` header) are not touched.
## Code Standards ## Code Standards
- **PHP**: PSR-12, tabs for indentation - **PHP**: PSR-12, tabs for indentation
- **Copyright**: all files must include the Moko Consulting copyright header - **Copyright**: all files must include the Moko Consulting copyright header
- **License**: SPDX identifier `GPL-3.0-or-later` (or as specified per repo) - **License**: SPDX identifier `GPL-3.0-or-later` (or as specified per repo)
- **Attribution**: use `Authored-by: Moko Consulting` in commits, not individual names - **Attribution**: use `Authored-by: Moko Consulting` in commits, not individual names
## Commit Messages ## Commit Messages
Use conventional commit format: Use conventional commit format:
``` ```
type(scope): short description type(scope): short description
Optional body with context. Optional body with context.
Authored-by: Moko Consulting Authored-by: Moko Consulting
``` ```
Types: `feat`, `fix`, `chore`, `docs`, `style`, `refactor`, `test`, `ci` Types: `feat`, `fix`, `chore`, `docs`, `style`, `refactor`, `test`, `ci`
Special flags in commit messages: Special flags in commit messages:
- `[skip ci]` — skip all CI workflows - `[skip ci]` — skip all CI workflows
- `[skip bump]` — skip auto version bump only - `[skip bump]` — skip auto version bump only
## Reporting Issues ## Reporting Issues
Use the repository's issue tracker with the appropriate template. Use the repository's issue tracker with the appropriate template.
--- ---
*Moko Consulting <hello@mokoconsulting.tech>* *Moko Consulting <hello@mokoconsulting.tech>*
+2 -2
View File
@@ -4,14 +4,14 @@ SPDX-License-Identifier: GPL-3.0-or-later
FILE INFORMATION FILE INFORMATION
DEFGROUP: MokoPlatform.Root DEFGROUP: MokoPlatform.Root
INGROUP: MokoPlatform INGROUP: MokoPlatform
REPO: https://git.mokoconsulting.tech/MokoConsulting/mokocli REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform
PATH: /PLUGIN_SCRIPTS.md PATH: /PLUGIN_SCRIPTS.md
BRIEF: Plugin system CLI documentation BRIEF: Plugin system CLI documentation
--> -->
# Plugin System CLI Scripts # Plugin System CLI Scripts
Command-line scripts for validating, health checking, and managing projects using the mokocli plugin system. Command-line scripts for validating, health checking, and managing projects using the moko-platform plugin system.
## Available Scripts ## Available Scripts
+6 -6
View File
@@ -4,20 +4,20 @@ SPDX-License-Identifier: GPL-3.0-or-later
FILE INFORMATION FILE INFORMATION
DEFGROUP: MokoPlatform.Root DEFGROUP: MokoPlatform.Root
INGROUP: MokoPlatform INGROUP: MokoPlatform
REPO: https://git.mokoconsulting.tech/MokoConsulting/mokocli REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform
PATH: /README.md PATH: /README.md
VERSION: 09.38.00 VERSION: 09.24.00
BRIEF: Project overview and documentation BRIEF: Project overview and documentation
--> -->
# mokocli Enterprise API # moko-platform Enterprise API
![Version](https://img.shields.io/badge/version-09.01.00-blue) ![PHP](https://img.shields.io/badge/PHP-8.1%2B-777BB4) ![License](https://img.shields.io/badge/license-GPL--3.0--or--later-green) ![Version](https://img.shields.io/badge/version-09.01.00-blue) ![PHP](https://img.shields.io/badge/PHP-8.1%2B-777BB4) ![License](https://img.shields.io/badge/license-GPL--3.0--or--later-green)
PHP implementation of mokocli — enterprise standards, automation framework, workflow templates, and bulk sync tooling. PHP implementation of moko-platform — enterprise standards, automation framework, workflow templates, and bulk sync tooling.
> **Primary platform**: [Gitea — git.mokoconsulting.tech](https://git.mokoconsulting.tech/MokoConsulting/MokoCli-API) > **Primary platform**: [Gitea — git.mokoconsulting.tech](https://git.mokoconsulting.tech/MokoConsulting/MokoStandards-API)
> **Backup mirror**: [GitHub](https://github.com/MokoConsulting/MokoCli-API) *(read-only mirror)* > **Backup mirror**: [GitHub](https://github.com/MokoConsulting/MokoStandards-API) *(read-only mirror)*
## What Lives Here ## What Lives Here
+1 -1
View File
@@ -4,7 +4,7 @@ SPDX-License-Identifier: GPL-3.0-or-later
FILE INFORMATION FILE INFORMATION
DEFGROUP: MokoPlatform.Index DEFGROUP: MokoPlatform.Index
INGROUP: MokoPlatform.Analysis INGROUP: MokoPlatform.Analysis
REPO: https://git.mokoconsulting.tech/MokoConsulting/mokocli REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform
PATH: /analysis/index.md PATH: /analysis/index.md
BRIEF: Analysis directory index BRIEF: Analysis directory index
--> -->
+937
View File
@@ -0,0 +1,937 @@
#!/usr/bin/env php
<?php
/**
* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
*
* This file is part of a Moko Consulting project.
*
* SPDX-License-Identifier: GPL-3.0-or-later
*
* FILE INFORMATION
* DEFGROUP: MokoPlatform.Automation
* INGROUP: MokoPlatform.Scripts
* REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform
* PATH: /automation/bulk_joomla_template.php
* BRIEF: Bulk scaffold and sync Joomla template repositories
*
* USAGE
* php automation/bulk_joomla_template.php --scaffold --name=MokoTheme
* php automation/bulk_joomla_template.php --scaffold --name=MokoTheme --client=administrator
* php automation/bulk_joomla_template.php --sync --repos=MokoTheme,MokoDarkTheme
* php automation/bulk_joomla_template.php --sync --all
* php automation/bulk_joomla_template.php --list
*/
declare(strict_types=1);
require_once __DIR__ . '/../vendor/autoload.php';
require_once __DIR__ . '/../lib/Enterprise/CliFramework.php';
use MokoEnterprise\{
AuditLogger,
CliFramework,
Config,
GitPlatformAdapter,
MetricsCollector,
PlatformAdapterFactory
};
/**
* Bulk Joomla Template Manager
*
* Provides three operations for Joomla template projects:
* --scaffold: Create a new template repository with the full directory structure
* --sync: Push moko-platform files to existing template repositories
* --list: List all repositories tagged as joomla-template
*
* Works with both GitHub and Gitea via the PlatformAdapterFactory.
*/
class BulkJoomlaTemplate extends CliFramework
{
public const DEFAULT_ORG = 'MokoConsulting';
public const VERSION = '09.23.00';
private GitPlatformAdapter $adapter;
private Config $config;
protected function configure(): void
{
$this->setDescription('Bulk Joomla template management');
$this->addArgument('--org', 'Organization', self::DEFAULT_ORG);
$this->addArgument('--scaffold', 'Create new template repo', false);
$this->addArgument('--sync', 'Sync files to template repos', false);
$this->addArgument('--list', 'List template repos', false);
$this->addArgument('--name', 'Template name for scaffold', '');
$this->addArgument('--client', 'Joomla client: site or admin', 'site');
$this->addArgument('--repos', 'Target repos (comma-separated)', '');
$this->addArgument('--all', 'Sync all tagged repos', false);
$this->addArgument('--sync-updates', 'Sync updates.xml', false);
$this->addArgument('--private', 'Create as private repo', false);
$this->addArgument('--yes', 'Auto-confirm', false);
}
protected function run(): int
{
$this->log("🎨 Joomla Template Manager v" . self::VERSION, 'INFO');
$this->config = Config::load();
try {
$this->adapter = PlatformAdapterFactory::create($this->config);
} catch (\Exception $e) {
$this->log("❌ Failed to initialize: " . $e->getMessage(), 'ERROR');
return 1;
}
$org = $this->getArgument('--org', self::DEFAULT_ORG);
$platform = $this->adapter->getPlatformName();
$this->log("Platform: {$platform} | Organization: {$org}", 'INFO');
if ($this->getArgument('--list', false)) {
return $this->listTemplateRepos($org);
}
if ($this->getArgument('--scaffold', false)) {
return $this->scaffoldTemplate($org);
}
if ($this->getArgument('--sync', false)) {
return $this->syncTemplates($org);
}
if ($this->getArgument('--sync-updates', false)) {
return $this->syncUpdatesBetweenPlatforms($org);
}
$this->log("❌ Specify --scaffold, --sync, --sync-updates, or --list", 'ERROR');
return 1;
}
// ── List ─────────────────────────────────────────────────────────────
private function listTemplateRepos(string $org): int
{
$repos = $this->findTemplateRepos($org);
if (empty($repos)) {
$this->log("No joomla-template repositories found in {$org}", 'INFO');
return 0;
}
$this->log("\nJoomla template repositories in {$org}:", 'INFO');
foreach ($repos as $repo) {
$vis = ($repo['private'] ?? false) ? 'private' : 'public';
$url = $this->adapter->getRepoWebUrl($org, $repo['name']);
$this->log(" - {$repo['name']} ({$vis}) {$url}", 'INFO');
}
$this->log("\nTotal: " . count($repos), 'INFO');
return 0;
}
// ── Scaffold ─────────────────────────────────────────────────────────
private function scaffoldTemplate(string $org): int
{
$name = $this->getArgument('--name', '');
$client = $this->getArgument('--client', 'site');
$dryRun = $this->dryRun;
if (empty($name)) {
$this->log("❌ --name is required for --scaffold", 'ERROR');
$this->log(" Example: --name=MokoTheme", 'ERROR');
return 1;
}
if (!in_array($client, ['site', 'administrator'], true)) {
$this->log("❌ --client must be 'site' or 'administrator'", 'ERROR');
return 1;
}
$shortName = $this->deriveShortName($name);
$this->log("\nScaffolding Joomla template:", 'INFO');
$this->log(" Name: {$name}", 'INFO');
$this->log(" Short name: {$shortName}", 'INFO');
$this->log(" Client: {$client}", 'INFO');
$this->log(" Element: tpl_{$shortName}", 'INFO');
if ($dryRun) {
$this->log("\n[DRY RUN] Would create repository and scaffold files", 'INFO');
$this->printScaffoldPlan($shortName);
return 0;
}
// Check if repo already exists
try {
$this->adapter->getRepo($org, $name);
$this->log("❌ Repository {$org}/{$name} already exists", 'ERROR');
return 1;
} catch (\Exception $e) {
$this->adapter->getApiClient()->resetCircuitBreaker();
}
// Confirm
if (!$this->getArgument('--yes', false)) {
echo "\nCreate repository {$org}/{$name}? [y/N]: ";
$handle = fopen('php://stdin', 'r');
$line = fgets($handle);
if ($handle) {
fclose($handle);
}
if (!is_string($line) || strtolower(trim($line)) !== 'y') {
$this->log("Cancelled.", 'INFO');
return 0;
}
}
// Create repository
$this->log("\nCreating repository...", 'INFO');
try {
$isPrivate = $this->getArgument('--private', false);
$this->adapter->createOrgRepo($org, $name, [
'description' => "Joomla {$client} template — {$name}",
'private' => $isPrivate,
'auto_init' => true,
]);
$this->log(" ✓ Repository created: {$org}/{$name}", 'INFO');
} catch (\Exception $e) {
$this->log("❌ Failed to create repository: " . $e->getMessage(), 'ERROR');
return 1;
}
// Set topics
try {
$this->adapter->setRepoTopics($org, $name, [
'joomla', 'joomla-template', 'template', "joomla-{$client}",
]);
$this->log(" ✓ Topics set", 'INFO');
} catch (\Exception $e) {
$this->log(" ⚠️ Could not set topics: " . $e->getMessage(), 'WARN');
$this->adapter->getApiClient()->resetCircuitBreaker();
}
// Scaffold files
$this->log("\nScaffolding template files...", 'INFO');
$files = $this->getScaffoldFiles($name, $shortName, $client, $org);
$created = 0;
foreach ($files as $path => $content) {
try {
$this->adapter->createOrUpdateFile(
$org,
$name,
$path,
$content,
"chore: scaffold {$path}"
);
$this->log("{$path}", 'INFO');
$created++;
} catch (\Exception $e) {
$this->log("{$path}: " . $e->getMessage(), 'ERROR');
$this->adapter->getApiClient()->resetCircuitBreaker();
}
}
// Apply branch protection
try {
$this->adapter->setBranchProtection($org, $name, 'main', [
'required_reviews' => 1,
'dismiss_stale' => true,
'block_on_rejected' => true,
]);
$this->log(" ✓ Branch protection applied", 'INFO');
} catch (\Exception $e) {
$this->log(" ⚠️ Branch protection: " . $e->getMessage(), 'WARN');
$this->adapter->getApiClient()->resetCircuitBreaker();
}
$url = $this->adapter->getRepoWebUrl($org, $name);
$this->log("\n✅ Template scaffolded: {$url}", 'INFO');
$this->log(" {$created} files created", 'INFO');
return 0;
}
// ── Sync ─────────────────────────────────────────────────────────────
private function syncTemplates(string $org): int
{
$repos = [];
if ($this->getArgument('--all', false)) {
$repos = $this->findTemplateRepos($org);
} else {
$reposArg = $this->getArgument('--repos', '');
if (empty($reposArg)) {
$this->log("❌ --repos or --all required for --sync", 'ERROR');
return 1;
}
$names = array_filter(array_map('trim', explode(',', $reposArg)));
foreach ($names as $name) {
$repos[] = ['name' => $name];
}
}
if (empty($repos)) {
$this->log("No template repositories to sync", 'INFO');
return 0;
}
$this->log("\nSyncing " . count($repos) . " template repo(s)...", 'INFO');
$dryRun = $this->dryRun;
$success = 0;
$failed = 0;
foreach ($repos as $repo) {
$name = $repo['name'];
$this->log("\n[{$name}]", 'INFO');
try {
$repoData = $this->adapter->getRepo($org, $name);
$shortName = $this->deriveShortName($name);
$branch = $repoData['default_branch'] ?? 'main';
$syncFiles = $this->getSyncFiles($name, $shortName);
$updated = 0;
foreach ($syncFiles as $path => $content) {
if ($dryRun) {
$this->log(" (dry-run) {$path}", 'INFO');
$updated++;
continue;
}
// Check if file exists
$existingSha = null;
try {
$existing = $this->adapter->getFileContents($org, $name, $path, $branch);
$existingSha = $existing['sha'] ?? null;
} catch (\Exception $e) {
$this->adapter->getApiClient()->resetCircuitBreaker();
}
try {
$this->adapter->createOrUpdateFile(
$org,
$name,
$path,
$content,
"chore: update {$path} from moko-platform",
$existingSha,
$branch
);
$this->log("{$path}", 'INFO');
$updated++;
} catch (\Exception $e) {
$this->log("{$path}: " . $e->getMessage(), 'ERROR');
$this->adapter->getApiClient()->resetCircuitBreaker();
}
}
$this->log(" {$updated} file(s) synced", 'INFO');
$success++;
} catch (\Exception $e) {
$this->log("{$name}: " . $e->getMessage(), 'ERROR');
$failed++;
$this->adapter->getApiClient()->resetCircuitBreaker();
}
}
$this->log("\n" . str_repeat('=', 50), 'INFO');
$this->log("Sync complete: {$success} succeeded, {$failed} failed", 'INFO');
return $failed > 0 ? 1 : 0;
}
// ── Helpers ──────────────────────────────────────────────────────────
private function findTemplateRepos(string $org): array
{
$allRepos = $this->adapter->listOrgRepos($org, true);
$templates = [];
foreach ($allRepos as $repo) {
try {
$topics = $this->adapter->getRepoTopics($org, $repo['name']);
if (in_array('joomla-template', $topics, true)) {
$templates[] = $repo;
}
} catch (\Exception $e) {
$this->adapter->getApiClient()->resetCircuitBreaker();
}
}
return $templates;
}
private function deriveShortName(string $name): string
{
// MokoTheme → mokotheme, Moko-Dark-Theme → mokodarktheme
return strtolower(preg_replace('/[^a-zA-Z0-9]/', '', $name));
}
private function printScaffoldPlan(string $shortName): void
{
$files = [
'templateDetails.xml',
'updates.xml',
'src/index.php',
'src/error.php',
'src/offline.php',
'src/component.php',
'src/html/index.html',
'src/css/.gitkeep',
'src/js/.gitkeep',
'src/images/.gitkeep',
"src/language/en-GB/tpl_{$shortName}.ini",
"src/language/en-GB/tpl_{$shortName}.sys.ini",
'media/css/.gitkeep',
'media/js/.gitkeep',
'media/images/.gitkeep',
'media/scss/.gitkeep',
'.editorconfig',
];
$this->log("\nFiles that would be created:", 'INFO');
foreach ($files as $f) {
$this->log(" + {$f}", 'INFO');
}
}
/**
* Generate the full set of scaffold files for a new template.
*
* @return array<string, string> path => content
*/
private function getScaffoldFiles(string $name, string $shortName, string $client, string $org): array
{
$element = "tpl_{$shortName}";
$now = date('Y-m-d');
$files = [];
// templateDetails.xml
$files['templateDetails.xml'] = <<<XML
<?xml version="1.0" encoding="utf-8"?>
<extension type="template" client="{$client}" method="upgrade">
<name>{$name}</name>
<creationDate>{$now}</creationDate>
<author>Moko Consulting</author>
<authorEmail>hello@mokoconsulting.tech</authorEmail>
<authorUrl>https://mokoconsulting.tech</authorUrl>
<copyright>Copyright (C) 2026 Moko Consulting. All rights reserved.</copyright>
<license>GPL-3.0-or-later</license>
<version>1.0.0</version>
<description>{$name} — Joomla {$client} template by Moko Consulting</description>
<files>
<filename>index.php</filename>
<filename>component.php</filename>
<filename>error.php</filename>
<filename>offline.php</filename>
<filename>templateDetails.xml</filename>
<folder>html</folder>
<folder>css</folder>
<folder>js</folder>
<folder>images</folder>
<folder>language</folder>
</files>
<media destination="templates/{$client}/{$shortName}" folder="media">
<folder>css</folder>
<folder>js</folder>
<folder>images</folder>
<folder>scss</folder>
</media>
<positions>
<position>topbar</position>
<position>navbar</position>
<position>hero</position>
<position>breadcrumbs</position>
<position>sidebar-left</position>
<position>sidebar-right</position>
<position>main-top</position>
<position>main-bottom</position>
<position>footer</position>
<position>debug</position>
</positions>
<updateservers>
<server type="extension" priority="1" name="{$name} Update Server">
https://git.mokoconsulting.tech/{$org}/{$name}/raw/branch/main/updates.xml
</server>
<server type="extension" priority="2" name="{$name} Update Server">
https://raw.githubusercontent.com/{$org}/{$name}/main/updates.xml
</server>
</updateservers>
<config>
<fields name="params">
<fieldset name="basic">
<field name="logoFile" type="media" label="Logo" />
<field name="siteTitle" type="text" label="Site Title" default="" />
<field name="siteDescription" type="text" label="Site Description" default="" />
<field name="colorScheme" type="list" label="Color Scheme" default="light">
<option value="light">Light</option>
<option value="dark">Dark</option>
<option value="auto">Auto (system preference)</option>
</field>
</fieldset>
</fields>
</config>
</extension>
XML;
$files['templateDetails.xml'] = preg_replace('/^\t\t/m', '', $files['templateDetails.xml']);
// updates.xml — dual-platform download URLs (Gitea primary, GitHub secondary)
$files['updates.xml'] = <<<XML
<updates>
<update>
<name>{$name}</name>
<description>{$name} — Moko Consulting Joomla template</description>
<element>tpl_{$shortName}</element>
<type>template</type>
<version>1.0.0</version>
<downloads>
<downloadurl type="full" format="zip">
https://git.mokoconsulting.tech/{$org}/{$name}/releases/download/v1.0.0/{$shortName}.zip
</downloadurl>
<downloadurl type="full" format="zip">
https://github.com/{$org}/{$name}/releases/download/v1.0.0/{$shortName}.zip
</downloadurl>
</downloads>
<targetplatform name="joomla" version="[56].*"/>
<php_minimum>8.1</php_minimum>
</update>
</updates>
XML;
$files['updates.xml'] = preg_replace('/^\t\t/m', '', $files['updates.xml']);
// src/index.php
$files['src/index.php'] = <<<'PHP'
<?php
/**
* @package Joomla.Site
* @subpackage Templates.TEMPLATE_SHORT_NAME
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
* @license GPL-3.0-or-later
*/
defined('_JEXEC') or die;
use Joomla\CMS\Factory;
$app = Factory::getApplication();
$wa = $this->getWebAssetManager();
?>
<!DOCTYPE html>
<html lang="<?php echo $this->language; ?>" dir="<?php echo $this->direction; ?>">
<head>
<jdoc:include type="metas" />
<jdoc:include type="styles" />
<jdoc:include type="scripts" />
</head>
<body class="site <?php echo $this->direction === 'rtl' ? 'rtl' : 'ltr'; ?>">
<header>
<jdoc:include type="modules" name="topbar" style="none" />
<jdoc:include type="modules" name="navbar" style="none" />
</header>
<jdoc:include type="modules" name="hero" style="none" />
<jdoc:include type="modules" name="breadcrumbs" style="none" />
<main>
<jdoc:include type="modules" name="main-top" style="html5" />
<jdoc:include type="message" />
<jdoc:include type="component" />
<jdoc:include type="modules" name="main-bottom" style="html5" />
</main>
<footer>
<jdoc:include type="modules" name="footer" style="none" />
</footer>
<jdoc:include type="modules" name="debug" style="none" />
</body>
</html>
PHP;
$files['src/index.php'] = str_replace('TEMPLATE_SHORT_NAME', $shortName, $files['src/index.php']);
$files['src/index.php'] = preg_replace('/^\t\t/m', '', $files['src/index.php']);
// src/error.php
$files['src/error.php'] = <<<'PHP'
<?php
/**
* @package Joomla.Site
* @subpackage Templates.error
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
* @license GPL-3.0-or-later
*/
defined('_JEXEC') or die;
/** @var Joomla\CMS\Document\ErrorDocument $this */
$code = $this->error->getCode();
$message = htmlspecialchars($this->error->getMessage(), ENT_QUOTES, 'UTF-8');
?>
<!DOCTYPE html>
<html lang="<?php echo $this->language; ?>">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title><?php echo $code; ?> — <?php echo $message; ?></title>
</head>
<body>
<div style="max-width:600px;margin:80px auto;text-align:center;font-family:sans-serif">
<h1><?php echo $code; ?></h1>
<p><?php echo $message; ?></p>
<p><a href="<?php echo $this->baseurl; ?>/">Return to homepage</a></p>
</div>
</body>
</html>
PHP;
$files['src/error.php'] = preg_replace('/^\t\t/m', '', $files['src/error.php']);
// src/offline.php
$files['src/offline.php'] = <<<'PHP'
<?php
/**
* @package Joomla.Site
* @subpackage Templates.offline
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
* @license GPL-3.0-or-later
*/
defined('_JEXEC') or die;
use Joomla\CMS\Factory;
$app = Factory::getApplication();
?>
<!DOCTYPE html>
<html lang="<?php echo $this->language; ?>">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title><?php echo htmlspecialchars($app->get('sitename')); ?> — Maintenance</title>
</head>
<body>
<div style="max-width:600px;margin:80px auto;text-align:center;font-family:sans-serif">
<h1><?php echo htmlspecialchars($app->get('sitename')); ?></h1>
<p><?php echo $app->get('offline_message', 'This site is currently undergoing maintenance. Please check back soon.'); ?></p>
<jdoc:include type="message" />
<form action="<?php echo $this->baseurl; ?>/index.php" method="post">
<input type="text" name="username" placeholder="Username" />
<input type="password" name="password" placeholder="Password" />
<button type="submit">Login</button>
<input type="hidden" name="option" value="com_users" />
<input type="hidden" name="task" value="user.login" />
<?php echo \Joomla\CMS\HTML\HTMLHelper::_('form.token'); ?>
</form>
</div>
</body>
</html>
PHP;
$files['src/offline.php'] = preg_replace('/^\t\t/m', '', $files['src/offline.php']);
// src/component.php
$files['src/component.php'] = <<<'PHP'
<?php
/**
* @package Joomla.Site
* @subpackage Templates.component
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
* @license GPL-3.0-or-later
*/
defined('_JEXEC') or die;
?>
<!DOCTYPE html>
<html lang="<?php echo $this->language; ?>">
<head>
<jdoc:include type="head" />
</head>
<body class="contentpane">
<jdoc:include type="message" />
<jdoc:include type="component" />
</body>
</html>
PHP;
$files['src/component.php'] = preg_replace('/^\t\t/m', '', $files['src/component.php']);
// Directory keepfiles
$files['src/html/index.html'] = '<!DOCTYPE html><title></title>';
$files['src/css/.gitkeep'] = '';
$files['src/js/.gitkeep'] = '';
$files['src/images/.gitkeep'] = '';
$files['media/css/.gitkeep'] = '';
$files['media/js/.gitkeep'] = '';
$files['media/images/.gitkeep'] = '';
$files['media/scss/.gitkeep'] = '';
// Language files
$files["src/language/en-GB/{$element}.ini"] = "; {$name} language strings\n";
$files["src/language/en-GB/{$element}.sys.ini"] =
"; {$name} system language strings\n"
. "{$element}=\"{$name}\"\n"
. "{$element}_XML_DESCRIPTION=\"{$name} Joomla template by Moko Consulting\"\n";
// .editorconfig
$repoRoot = dirname(__DIR__, 2);
$editorConfig = "{$repoRoot}/templates/configs/.editorconfig";
if (file_exists($editorConfig)) {
$files['.editorconfig'] = file_get_contents($editorConfig) ?: '';
}
return $files;
}
/**
* Get files to sync to existing template repos (standards-only, no template code).
*
* @return array<string, string> path => content
*/
private function getSyncFiles(string $name, string $shortName): array
{
$repoRoot = dirname(__DIR__, 2);
$files = [];
// Sync standards files from templates/
$standardsFiles = [
'SECURITY.md' => 'templates/docs/required/template-SECURITY.md',
'CODE_OF_CONDUCT.md' => 'templates/docs/extra/template-CODE_OF_CONDUCT.md',
'CONTRIBUTING.md' => 'templates/docs/required/template-CONTRIBUTING.md',
'.editorconfig' => 'templates/configs/.editorconfig',
];
foreach ($standardsFiles as $dest => $source) {
$fullPath = "{$repoRoot}/{$source}";
if (file_exists($fullPath)) {
$files[$dest] = file_get_contents($fullPath) ?: '';
}
}
return $files;
}
// ── Sync updates.xml between platforms ───────────────────────────────
/**
* Sync updates.xml (or updates.xml) between Gitea and GitHub for Joomla repos.
*
* Reads the file from both platforms, compares by latest <version> tag,
* and pushes the newer one to the stale platform.
*
* Designed to be called from a CI workflow via:
* php automation/bulk_joomla_template.php --sync-updates --repos=MokoCassiopeia
*/
private function syncUpdatesBetweenPlatforms(string $org): int
{
$repos = [];
if ($this->getArgument('--all', false)) {
$repos = $this->findTemplateRepos($org);
// Also include waas-component repos
$allRepos = $this->adapter->listOrgRepos($org, true);
foreach ($allRepos as $repo) {
try {
$topics = $this->adapter->getRepoTopics($org, $repo['name']);
if (in_array('joomla', $topics, true) || in_array('joomla-extension', $topics, true)) {
$repos[] = $repo;
}
} catch (\Exception $e) {
$this->adapter->getApiClient()->resetCircuitBreaker();
}
}
// Deduplicate
$seen = [];
$repos = array_filter($repos, function ($r) use (&$seen) {
if (isset($seen[$r['name']])) {
return false;
}
$seen[$r['name']] = true;
return true;
});
} else {
$reposArg = $this->getArgument('--repos', '');
if (empty($reposArg)) {
$this->log("❌ --repos or --all required for --sync-updates", 'ERROR');
return 1;
}
$names = array_filter(array_map('trim', explode(',', $reposArg)));
foreach ($names as $name) {
$repos[] = ['name' => $name];
}
}
if (empty($repos)) {
$this->log("No Joomla repositories to sync updates for", 'INFO');
return 0;
}
// Create both platform adapters
try {
$adapters = PlatformAdapterFactory::createBoth($this->config);
} catch (\Exception $e) {
$this->log("❌ Both platform tokens required for --sync-updates: " . $e->getMessage(), 'ERROR');
return 1;
}
$gitea = $adapters['gitea'];
$github = $adapters['github'];
$dryRun = $this->dryRun;
$this->log("\nSyncing updates.xml across Gitea <-> GitHub for " . count($repos) . " repo(s)...", 'INFO');
$synced = 0;
$failed = 0;
foreach ($repos as $repo) {
$name = $repo['name'];
$this->log("\n[{$name}]", 'INFO');
// Try both updates.xml and updates.xml filenames
$updateFile = $this->resolveUpdateFile($gitea, $github, $org, $name);
if ($updateFile === null) {
$this->log(" ⊘ No update(s).xml found on either platform", 'INFO');
continue;
}
$fileName = $updateFile['name'];
$source = $updateFile['source']; // 'gitea' or 'github'
$content = $updateFile['content'];
$target = $source === 'gitea' ? 'github' : 'gitea';
$targetAdapter = $source === 'gitea' ? $github : $gitea;
$this->log(" Source: {$source} ({$fileName})", 'INFO');
if ($dryRun) {
$this->log(" (dry-run) Would push {$fileName} to {$target}", 'INFO');
$synced++;
continue;
}
// Push to the other platform
try {
$existingSha = null;
try {
$existing = $targetAdapter->getFileContents($org, $name, $fileName);
$existingSha = $existing['sha'] ?? null;
// Compare content — skip if identical
$existingContent = base64_decode($existing['content'] ?? '');
if (trim($existingContent) === trim($content)) {
$this->log(" ✓ Already in sync", 'INFO');
$synced++;
continue;
}
} catch (\Exception $e) {
$targetAdapter->getApiClient()->resetCircuitBreaker();
}
$targetAdapter->createOrUpdateFile(
$org,
$name,
$fileName,
$content,
"chore: sync {$fileName} from {$source}",
$existingSha
);
$this->log(" ✓ Pushed to {$target}", 'INFO');
$synced++;
} catch (\Exception $e) {
$this->log(" ✗ Failed to push to {$target}: " . $e->getMessage(), 'ERROR');
$targetAdapter->getApiClient()->resetCircuitBreaker();
$failed++;
}
}
$this->log("\n" . str_repeat('=', 50), 'INFO');
$this->log("Updates sync complete: {$synced} synced, {$failed} failed", 'INFO');
return $failed > 0 ? 1 : 0;
}
/**
* Find the updates file on both platforms, return the one with the higher version.
*
* Checks both `updates.xml` and `updates.xml` filenames.
* Returns the content from the platform with the newer <version>.
* Gitea wins ties (primary platform).
*
* @return array{name: string, source: string, content: string}|null
*/
private function resolveUpdateFile(
GitPlatformAdapter $gitea,
GitPlatformAdapter $github,
string $org,
string $name
): ?array {
$candidates = ['updates.xml', 'updates.xml'];
$found = []; // platform => [name, content, version]
foreach (['gitea' => $gitea, 'github' => $github] as $platform => $adapter) {
foreach ($candidates as $fileName) {
try {
$file = $adapter->getFileContents($org, $name, $fileName);
$content = base64_decode($file['content'] ?? '');
// Extract latest version from the XML
$version = '0.0.0';
if (preg_match('/<version>([^<]+)<\/version>/', $content, $m)) {
$version = trim($m[1]);
}
$found[$platform] = [
'name' => $fileName,
'content' => $content,
'version' => $version,
];
break; // Found one — stop checking other filenames for this platform
} catch (\Exception $e) {
$adapter->getApiClient()->resetCircuitBreaker();
}
}
}
if (empty($found)) {
return null;
}
// If only one platform has it, that's the source
if (count($found) === 1) {
$platform = array_key_first($found);
return [
'name' => $found[$platform]['name'],
'source' => $platform,
'content' => $found[$platform]['content'],
];
}
// Both have it — pick the one with the higher version (Gitea wins ties)
$giteaVer = $found['gitea']['version'];
$githubVer = $found['github']['version'];
$source = version_compare($githubVer, $giteaVer, '>') ? 'github' : 'gitea';
return [
'name' => $found[$source]['name'],
'source' => $source,
'content' => $found[$source]['content'],
];
}
}
// Execute if run directly
if (php_sapi_name() === 'cli' && isset($argv[0]) && realpath($argv[0]) === __FILE__) {
$app = new BulkJoomlaTemplate();
exit($app->execute());
}
+1428
View File
File diff suppressed because it is too large Load Diff
+123
View File
@@ -0,0 +1,123 @@
#!/usr/bin/env bash
# Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
# SPDX-License-Identifier: GPL-3.0-or-later
# BRIEF: Trigger a workflow across all client-waas repos in a Gitea org
set -euo pipefail
# ---------------------------------------------------------------------------
# Usage
# ---------------------------------------------------------------------------
usage() {
cat <<EOF
Usage: $(basename "$0") GITEA_URL TOKEN ORG WORKFLOW [REF] [INPUTS]
Arguments:
GITEA_URL Base URL of the Gitea instance (e.g. https://git.mokoconsulting.tech)
TOKEN Gitea API token with repo/action permissions
ORG Organisation or user that owns the repos
WORKFLOW Workflow filename to trigger (e.g. dependency-audit.yml)
REF Branch ref to run against (default: main)
INPUTS Optional JSON object of workflow inputs (e.g. '{"dry_run":"true"}')
Example:
$(basename "$0") https://git.mokoconsulting.tech abc123 MokoConsulting dependency-audit.yml main '{"notify":"true"}'
EOF
exit 1
}
# ---------------------------------------------------------------------------
# Argument parsing
# ---------------------------------------------------------------------------
if [ $# -lt 4 ]; then
usage
fi
GITEA_URL="${1%/}"
TOKEN="$2"
ORG="$3"
WORKFLOW="$4"
REF="${5:-main}"
INPUTS="${6:-{\}}"
# ---------------------------------------------------------------------------
# Fetch all repos in the org, paginated
# ---------------------------------------------------------------------------
echo "Fetching repos for org '${ORG}' on ${GITEA_URL} ..."
PAGE=1
LIMIT=50
ALL_REPOS=""
while true; do
RESPONSE=$(curl -s \
-H "Authorization: token ${TOKEN}" \
-H "Accept: application/json" \
"${GITEA_URL}/api/v1/orgs/${ORG}/repos?page=${PAGE}&limit=${LIMIT}")
# Break if empty array
COUNT=$(echo "$RESPONSE" | jq -r 'length')
if [ "$COUNT" -eq 0 ]; then
break
fi
NAMES=$(echo "$RESPONSE" | jq -r '.[].name')
ALL_REPOS="${ALL_REPOS}${NAMES}"$'\n'
if [ "$COUNT" -lt "$LIMIT" ]; then
break
fi
PAGE=$((PAGE + 1))
done
# ---------------------------------------------------------------------------
# Filter for client-waas repos
# ---------------------------------------------------------------------------
CLIENT_REPOS=$(echo "$ALL_REPOS" | grep 'client-waas' | sort || true)
if [ -z "$CLIENT_REPOS" ]; then
echo "No client-waas repos found in org '${ORG}'."
exit 0
fi
TOTAL=$(echo "$CLIENT_REPOS" | wc -l | tr -d ' ')
echo "Found ${TOTAL} client-waas repo(s). Triggering workflow '${WORKFLOW}' (ref: ${REF}) ..."
echo ""
# ---------------------------------------------------------------------------
# Trigger workflow for each repo
# ---------------------------------------------------------------------------
SUCCESS=0
FAIL=0
while IFS= read -r REPO; do
[ -z "$REPO" ] && continue
PAYLOAD=$(jq -n --arg ref "$REF" --argjson inputs "$INPUTS" '{ref: $ref, inputs: $inputs}')
HTTP_CODE=$(curl -s -o /dev/null -w '%{http_code}' \
-X POST \
-H "Authorization: token ${TOKEN}" \
-H "Content-Type: application/json" \
-d "$PAYLOAD" \
"${GITEA_URL}/api/v1/repos/${ORG}/${REPO}/actions/workflows/${WORKFLOW}/dispatches")
if [ "$HTTP_CODE" -eq 204 ] || [ "$HTTP_CODE" -eq 201 ]; then
echo " [OK] ${ORG}/${REPO} (HTTP ${HTTP_CODE})"
SUCCESS=$((SUCCESS + 1))
else
echo " [FAIL] ${ORG}/${REPO} (HTTP ${HTTP_CODE})"
FAIL=$((FAIL + 1))
fi
done <<< "$CLIENT_REPOS"
# ---------------------------------------------------------------------------
# Summary
# ---------------------------------------------------------------------------
echo ""
echo "Done. Success: ${SUCCESS} | Failed: ${FAIL} | Total: ${TOTAL}"
if [ "$FAIL" -gt 0 ]; then
exit 1
fi
+108
View File
@@ -0,0 +1,108 @@
#!/usr/bin/env bash
# =============================================================================
# enforce_tags.sh — Ensure all repos have the 5 standard release channel tags
#
# Standard tags: development, alpha, beta, release-candidate, stable
# Also removes non-standard tags (keeps vXX production tags)
#
# Usage:
# GA_TOKEN=xxx ./enforce_tags.sh [--dry-run] [--repos repo1,repo2]
#
# Called by: bulk-repo-sync.yml, infrastructure-tests/mirror-check.yml
# =============================================================================
set -euo pipefail
GITEA_URL="${GITEA_URL:-https://git.mokoconsulting.tech}"
ORG="${GITEA_ORG:-MokoConsulting}"
TOKEN="${GA_TOKEN:?GA_TOKEN required}"
DRY_RUN=false
FILTER_REPOS=""
STANDARD_TAGS=("development" "alpha" "beta" "release-candidate" "stable")
while [[ $# -gt 0 ]]; do
case "$1" in
--dry-run) DRY_RUN=true; shift ;;
--repos) FILTER_REPOS="$2"; shift 2 ;;
*) shift ;;
esac
done
api() {
local method="$1" path="$2" data="${3:-}"
local args=(-sf -H "Authorization: token $TOKEN" -H "Content-Type: application/json" -X "$method")
[[ -n "$data" ]] && args+=(-d "$data")
curl "${args[@]}" "$GITEA_URL/api/v1$path" 2>/dev/null
}
# Get repos
REPOS=""
for page in 1 2 3; do
BATCH=$(api GET "/orgs/$ORG/repos?limit=50&page=$page" | python3 -c "
import sys,json
for r in json.load(sys.stdin):
if not r.get(empty) and not r.get(archived):
print(r[name])
" 2>/dev/null)
[[ -z "$BATCH" ]] && break
REPOS="$REPOS $BATCH"
done
# Filter if specified
if [[ -n "$FILTER_REPOS" ]]; then
FILTERED=""
IFS=, read -ra FILTER_ARR <<< "$FILTER_REPOS"
for repo in $REPOS; do
for f in "${FILTER_ARR[@]}"; do
[[ "$repo" == "$f" ]] && FILTERED="$FILTERED $repo"
done
done
REPOS="$FILTERED"
fi
TOTAL=$(echo $REPOS | wc -w)
ADDED=0
DELETED=0
ERRORS=0
echo "Enforcing tags on $TOTAL repos (dry_run=$DRY_RUN)"
for repo in $REPOS; do
TAGS=$(api GET "/repos/$ORG/$repo/tags?limit=50" | python3 -c "import sys,json; print( .join(t[name] for t in json.load(sys.stdin)))" 2>/dev/null)
MAIN_SHA=$(api GET "/repos/$ORG/$repo/branches/main" | python3 -c "import sys,json; print(json.load(sys.stdin)[commit][id])" 2>/dev/null)
[[ -z "$MAIN_SHA" ]] && continue
# Add missing standard tags
for st in "${STANDARD_TAGS[@]}"; do
if ! echo " $TAGS " | grep -q " $st "; then
if [[ "$DRY_RUN" == "true" ]]; then
echo " [DRY] ADD $repo: $st"
else
STATUS=$(api POST "/repos/$ORG/$repo/tags" "{\"tag_name\":\"$st\",\"target\":\"$MAIN_SHA\"}" | python3 -c "import sys,json; print(ok)" 2>/dev/null || echo "err")
[[ "$STATUS" == "ok" ]] && ADDED=$((ADDED + 1)) || ERRORS=$((ERRORS + 1))
fi
fi
done
# Remove non-standard tags
for t in $TAGS; do
IS_STD=false
for st in "${STANDARD_TAGS[@]}"; do [[ "$t" == "$st" ]] && IS_STD=true; done
# Keep vXX production tags
if [[ "$t" =~ ^v[0-9]{1,3}$ ]]; then IS_STD=true; fi
if [[ "$IS_STD" == "false" ]]; then
if [[ "$DRY_RUN" == "true" ]]; then
echo " [DRY] DEL $repo: $t"
else
# Delete release first if exists
api DELETE "/repos/$ORG/$repo/releases/tags/$t" > /dev/null 2>&1 || true
api DELETE "/repos/$ORG/$repo/tags/$t" > /dev/null 2>&1
DELETED=$((DELETED + 1))
echo " DEL $repo: $t"
fi
fi
done
done
echo "Done: $ADDED added, $DELETED deleted, $ERRORS errors (dry_run=$DRY_RUN)"
+481
View File
@@ -0,0 +1,481 @@
#!/usr/bin/env php
<?php
/**
* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
* SPDX-License-Identifier: GPL-3.0-or-later
*
* FILE INFORMATION
* DEFGROUP: MokoPlatform.Automation
* INGROUP: MokoPlatform
* REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform
* PATH: /automation/enrich_manifest_xml.php
* BRIEF: Enrich XML manifests with repo-specific build and deploy details
*
* Note: This script uses proc_open for shell commands. All arguments are escaped
* via escapeshellarg(). No user-supplied input reaches the shell unescaped.
*/
declare(strict_types=1);
require_once __DIR__ . '/../vendor/autoload.php';
require_once __DIR__ . '/../lib/Enterprise/CliFramework.php';
use MokoEnterprise\CliFramework;
use MokoEnterprise\MokoStandardsParser;
class EnrichManifestXmlCli extends CliFramework
{
protected function configure(): void
{
$this->setDescription('Enrich XML manifests with repo-specific build and deploy details');
$this->addArgument('--repo', 'Filter to a single repo name', '');
$this->addArgument('--skip', 'Comma-separated list of repos to skip', '');
}
protected function run(): int
{
$giteaUrl = rtrim(getenv('GITEA_URL') ?: 'https://git.mokoconsulting.tech', '/');
$giteaOrg = getenv('GITEA_ORG') ?: 'MokoConsulting';
$token = getenv('GA_TOKEN') ?: getenv('GH_TOKEN') ?: '';
$repoFilter = $this->getArgument('--repo') ?: null;
$skipStr = $this->getArgument('--skip');
$skipRepos = $skipStr !== '' ? array_map('trim', explode(',', $skipStr)) : [];
$parser = new MokoStandardsParser();
$tmpBase = sys_get_temp_dir() . '/moko-enrich-' . getmypid();
echo "=== moko-platform XML Manifest Enrichment ===\n";
echo "Mode: " . ($this->dryRun ? "DRY RUN" : "LIVE") . "\n";
if (!empty($skipRepos)) {
echo "Skipping: " . implode(', ', $skipRepos) . "\n";
}
echo "\n";
if (empty($token)) {
$this->log('ERROR', 'GA_TOKEN required');
return 1;
}
$repos = $this->fetchRepos($giteaUrl, $giteaOrg, $token);
echo "Found " . count($repos) . " repositories\n\n";
$stats = ['enriched' => 0, 'skipped' => 0, 'failed' => 0];
foreach ($repos as $repo) {
$name = $repo['name'];
if ($repoFilter && $name !== $repoFilter) {
continue;
}
if (in_array($name, $skipRepos, true)) {
echo " {$name} ... SKIP (excluded)\n";
$stats['skipped']++;
continue;
}
if ($repo['archived'] ?? false) {
$stats['skipped']++;
continue;
}
$defaultBranch = $repo['default_branch'] ?? 'main';
$httpsUrl = $repo['clone_url'] ?? "{$giteaUrl}/{$giteaOrg}/{$name}.git";
$authedUrl = preg_replace('#^https://#', "https://gitea-actions:{$token}@", $httpsUrl);
echo " {$name} ... ";
$workDir = "{$tmpBase}/{$name}";
@mkdir($workDir, 0755, true);
[$ret] = $this->safeExec(
'git clone --depth 1 --branch ' . escapeshellarg($defaultBranch)
. ' ' . escapeshellarg($authedUrl) . ' ' . escapeshellarg($workDir)
);
if ($ret !== 0) {
echo "FAIL (clone)\n";
$stats['failed']++;
continue;
}
$manifestPath = "{$workDir}/.mokogitea/manifest.xml";
if (!file_exists($manifestPath) || !str_contains(file_get_contents($manifestPath), '<moko-platform')) {
echo "SKIP (no XML manifest)\n";
$stats['skipped']++;
$this->rmTree($workDir);
continue;
}
$existingXml = file_get_contents($manifestPath);
$platform = $parser->extractPlatform($existingXml) ?? 'default-repository';
$enrichment = $this->inspectRepo($workDir, $platform);
if (!isset($enrichment['build'])) {
$enrichment['build'] = [];
}
$enrichment['build']['language'] = $enrichment['build']['language']
?? $repo['language']
?? MokoStandardsParser::platformLanguage($platform);
$enrichment['build']['package_type'] = $enrichment['build']['package_type'] ?? MokoStandardsParser::platformPackageType($platform);
$enrichedXml = $this->enrichManifestXml($existingXml, $enrichment);
$dc = count($enrichment['deploy'] ?? []);
$sc = count($enrichment['scripts'] ?? []);
$details = "deploy={$dc} scripts={$sc}";
if ($this->dryRun) {
echo "WOULD ENRICH [{$details}]\n";
$stats['enriched']++;
$this->rmTree($workDir);
continue;
}
file_put_contents($manifestPath, $enrichedXml);
$this->gitCmd($workDir, 'config', 'user.name', 'gitea-actions[bot]');
$this->gitCmd($workDir, 'config', 'user.email', 'gitea-actions[bot]@git.mokoconsulting.tech');
$this->gitCmd($workDir, 'add', '.mokogitea/manifest.xml');
[$cr, $co] = $this->gitCmd($workDir, 'commit', '-m', "chore: enrich manifest.xml with build/deploy/scripts\n\nAuto-detected: {$details}");
if ($cr !== 0) {
echo "SKIP (no diff)\n";
$stats['skipped']++;
$this->rmTree($workDir);
continue;
}
[$pr] = $this->gitCmd($workDir, 'push', 'origin', $defaultBranch);
if ($pr !== 0) {
echo "FAIL (push)\n";
$stats['failed']++;
} else {
echo "ENRICHED [{$details}]\n";
$stats['enriched']++;
}
$this->rmTree($workDir);
}
@rmdir($tmpBase);
echo "\n=== Summary ===\nEnriched: {$stats['enriched']}\nSkipped: {$stats['skipped']}\nFailed: {$stats['failed']}\n";
return 0;
}
private function inspectRepo(string $workDir, string $platform): array
{
$enrichment = [];
$build = [];
// Detect entry point
if (is_dir("{$workDir}/src")) {
foreach (glob("{$workDir}/src/*.xml") ?: [] as $xf) {
$c = file_get_contents($xf);
if (str_contains($c, '<extension') || str_contains($c, '<install')) {
$build['entry_point'] = 'src/' . basename($xf);
break;
}
}
foreach (glob("{$workDir}/src/core/modules/mod*.class.php") ?: [] as $mf) {
$build['entry_point'] = str_replace("{$workDir}/", '', $mf);
break;
}
}
// composer.json
if (file_exists("{$workDir}/composer.json")) {
$composer = json_decode(file_get_contents("{$workDir}/composer.json"), true) ?: [];
$phpReq = $composer['require']['php'] ?? null;
if ($phpReq) {
$build['runtime'] = "php:{$phpReq}";
}
$deps = [];
foreach (['joomla/cms', 'joomla/framework', 'dolibarr/dolibarr'] as $pd) {
if (isset($composer['require'][$pd])) {
$deps[] = ['name' => $pd, 'version' => $composer['require'][$pd], 'type' => 'platform'];
}
}
if (isset($composer['require']['mokoconsulting-tech/enterprise'])) {
$deps[] = [
'name' => 'mokoconsulting-tech/enterprise',
'version' => $composer['require']['mokoconsulting-tech/enterprise'],
'type' => 'composer',
];
}
if (!empty($deps)) {
$build['dependencies'] = $deps;
}
}
// Artifact from Makefile
if (file_exists("{$workDir}/Makefile")) {
$mk = file_get_contents("{$workDir}/Makefile");
if (preg_match('/\bdist\/(\S+\.zip)\b/', $mk, $m)) {
$build['artifact'] = ['format' => 'zip', 'path' => 'dist/', 'filename' => $m[1]];
}
}
if (!empty($build)) {
$enrichment['build'] = $build;
}
// Deploy targets from workflows
$targets = [];
$wfDir = is_dir("{$workDir}/.gitea/workflows") ? "{$workDir}/.gitea/workflows" : "{$workDir}/.github/workflows";
if (is_dir($wfDir)) {
foreach (['deploy-dev', 'deploy-demo', 'deploy-rs'] as $dn) {
$wf = "{$wfDir}/{$dn}.yml";
if (!file_exists($wf)) {
continue;
}
$wc = file_get_contents($wf);
$t = ['name' => str_replace('deploy-', '', $dn)];
if (str_contains($wc, 'sftp') || str_contains($wc, 'SFTP')) {
$t['method'] = 'sftp';
} elseif (str_contains($wc, 'rsync')) {
$t['method'] = 'rsync';
}
if (str_contains($wc, 'src/')) {
$t['src_dir'] = 'src/';
}
if (preg_match('/branches:\s*\n\s*-\s*["\']?([^"\'}\s]+)/', $wc, $m)) {
$t['branch'] = $m[1];
}
$targets[] = $t;
}
}
if (!empty($targets)) {
$enrichment['deploy'] = $targets;
}
// Scripts from Makefile + composer
$scripts = [];
if (file_exists("{$workDir}/Makefile")) {
$mk = file_get_contents("{$workDir}/Makefile");
$known = [
'build' => 'build', 'test' => 'test', 'lint' => 'lint',
'clean' => 'build', 'package' => 'build',
'validate' => 'validate', 'release' => 'release',
];
if (preg_match_all('/^([a-zA-Z_-]+)\s*:/m', $mk, $matches)) {
foreach ($matches[1] as $tgt) {
$tl = strtolower($tgt);
if (isset($known[$tl])) {
$scripts[] = [
'name' => $tl, 'phase' => $known[$tl],
'command' => "make {$tgt}",
'desc' => ucfirst($tl) . ' via make',
'runner' => 'make',
];
}
}
}
}
if (file_exists("{$workDir}/composer.json")) {
$composer = json_decode(file_get_contents("{$workDir}/composer.json"), true) ?: [];
$km = ['test' => 'test','lint' => 'lint','cs' => 'lint','phpcs' => 'lint','phpstan' => 'lint','validate' => 'validate'];
foreach ($composer['scripts'] ?? [] as $sn => $cmd) {
$sl = strtolower($sn);
foreach ($km as $match => $phase) {
if (str_contains($sl, $match)) {
$exists = false;
foreach ($scripts as $s) {
if ($s['name'] === $sl) {
$exists = true;
break;
}
}
if (!$exists) {
$scripts[] = [
'name' => $sn, 'phase' => $phase,
'command' => "composer run {$sn}",
'desc' => is_string($cmd) ? $cmd : "Run {$sn}",
'runner' => 'composer',
];
}
break;
}
}
}
}
if (!empty($scripts)) {
$enrichment['scripts'] = $scripts;
}
return $enrichment;
}
private function enrichManifestXml(string $xml, array $enrichment): string
{
$dom = new \DOMDocument('1.0', 'UTF-8');
$dom->preserveWhiteSpace = false;
$dom->formatOutput = true;
if (!$dom->loadXML($xml)) {
return $xml;
}
$ns = MokoStandardsParser::NAMESPACE_URI;
$root = $dom->documentElement;
foreach (['build', 'deploy', 'scripts'] as $tag) {
$toRemove = [];
$existing = $root->getElementsByTagNameNS($ns, $tag);
for ($i = 0; $i < $existing->length; $i++) {
$toRemove[] = $existing->item($i);
}
foreach ($toRemove as $node) {
$root->removeChild($node);
}
}
if (!empty($enrichment['build'])) {
$buildEl = $dom->createElementNS($ns, 'build');
$b = $enrichment['build'];
foreach (['language', 'runtime'] as $f) {
if (isset($b[$f])) {
$buildEl->appendChild($dom->createElementNS($ns, $f, htmlspecialchars($b[$f], ENT_XML1)));
}
}
if (isset($b['package_type'])) {
$buildEl->appendChild($dom->createElementNS($ns, 'package-type', htmlspecialchars($b['package_type'], ENT_XML1)));
}
if (isset($b['entry_point'])) {
$buildEl->appendChild($dom->createElementNS($ns, 'entry-point', htmlspecialchars($b['entry_point'], ENT_XML1)));
}
if (isset($b['artifact'])) {
$art = $dom->createElementNS($ns, 'artifact');
foreach (['format','path','filename'] as $af) {
if (isset($b['artifact'][$af])) {
$art->appendChild($dom->createElementNS($ns, $af, htmlspecialchars($b['artifact'][$af], ENT_XML1)));
}
}
$buildEl->appendChild($art);
}
if (isset($b['dependencies'])) {
$deps = $dom->createElementNS($ns, 'dependencies');
foreach ($b['dependencies'] as $d) {
$req = $dom->createElementNS($ns, 'requires', '');
$req->setAttribute('name', $d['name']);
if (isset($d['version'])) {
$req->setAttribute('version', $d['version']);
}
if (isset($d['type'])) {
$req->setAttribute('type', $d['type']);
}
$deps->appendChild($req);
}
$buildEl->appendChild($deps);
}
$root->appendChild($buildEl);
}
if (!empty($enrichment['deploy'])) {
$deploy = $dom->createElementNS($ns, 'deploy');
foreach ($enrichment['deploy'] as $t) {
$target = $dom->createElementNS($ns, 'target');
$target->setAttribute('name', $t['name']);
$target->appendChild($dom->createElementNS($ns, 'host', '${{ secrets.' . strtoupper($t['name']) . '_HOST }}'));
$target->appendChild($dom->createElementNS($ns, 'path', '${{ secrets.' . strtoupper($t['name']) . '_PATH }}'));
if (isset($t['method'])) {
$target->appendChild($dom->createElementNS($ns, 'method', $t['method']));
}
if (isset($t['branch'])) {
$target->appendChild($dom->createElementNS($ns, 'branch', htmlspecialchars($t['branch'], ENT_XML1)));
}
if (isset($t['src_dir'])) {
$target->appendChild($dom->createElementNS($ns, 'src-dir', htmlspecialchars($t['src_dir'], ENT_XML1)));
}
$deploy->appendChild($target);
}
$root->appendChild($deploy);
}
if (!empty($enrichment['scripts'])) {
$scriptsEl = $dom->createElementNS($ns, 'scripts');
foreach ($enrichment['scripts'] as $s) {
$script = $dom->createElementNS($ns, 'script');
$script->setAttribute('name', $s['name']);
if (isset($s['phase'])) {
$script->setAttribute('phase', $s['phase']);
}
$script->appendChild($dom->createElementNS($ns, 'command', htmlspecialchars($s['command'], ENT_XML1)));
if (isset($s['desc'])) {
$script->appendChild($dom->createElementNS($ns, 'description', htmlspecialchars($s['desc'], ENT_XML1)));
}
if (isset($s['runner'])) {
$script->appendChild($dom->createElementNS($ns, 'runner', htmlspecialchars($s['runner'], ENT_XML1)));
}
$scriptsEl->appendChild($script);
}
$root->appendChild($scriptsEl);
}
return $dom->saveXML();
}
/** @return array{int, string} */
private function safeExec(string $command, string $cwd = '.'): array
{
$proc = proc_open($command, [1 => ['pipe', 'w'], 2 => ['pipe', 'w']], $pipes, $cwd);
if (!is_resource($proc)) {
return [1, "proc_open failed"];
}
$stdout = stream_get_contents($pipes[1]);
$stderr = stream_get_contents($pipes[2]);
fclose($pipes[1]);
fclose($pipes[2]);
return [proc_close($proc), trim($stdout . "\n" . $stderr)];
}
private function rmTree(string $dir): void
{
if (!is_dir($dir)) {
return;
}
$it = new \RecursiveDirectoryIterator($dir, \FilesystemIterator::SKIP_DOTS);
$files = new \RecursiveIteratorIterator($it, \RecursiveIteratorIterator::CHILD_FIRST);
foreach ($files as $file) {
if ($file->isDir()) {
@rmdir($file->getPathname());
} else {
@chmod($file->getPathname(), 0777);
@unlink($file->getPathname());
}
}
@rmdir($dir);
}
/** @return array{int, string} */
private function gitCmd(string $workDir, string ...$args): array
{
$cmd = 'git';
foreach ($args as $a) {
$cmd .= ' ' . escapeshellarg($a);
}
return $this->safeExec($cmd, $workDir);
}
private function fetchRepos(string $url, string $org, string $token): array
{
$repos = [];
$page = 1;
do {
$ch = curl_init("{$url}/api/v1/orgs/{$org}/repos?page={$page}&limit=50");
curl_setopt_array($ch, [CURLOPT_RETURNTRANSFER => true, CURLOPT_HTTPHEADER => ["Authorization: token {$token}"], CURLOPT_TIMEOUT => 30]);
$body = curl_exec($ch);
$code = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
if ($code !== 200) {
break;
}
$batch = json_decode($body, true);
if (empty($batch)) {
break;
}
$repos = array_merge($repos, $batch);
$page++;
} while (count($batch) >= 50);
return $repos;
}
}
$app = new EnrichManifestXmlCli();
exit($app->execute());
+484
View File
@@ -0,0 +1,484 @@
#!/usr/bin/env php
<?php
/**
* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
* SPDX-License-Identifier: GPL-3.0-or-later
*
* FILE INFORMATION
* DEFGROUP: MokoPlatform.Automation
* INGROUP: MokoPlatform
* REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform
* PATH: /automation/enrich_mokostandards_xml.php
* BRIEF: Enrich XML manifests with repo-specific build and deploy details
*
* Note: This script uses proc_open for shell commands. All arguments are escaped
* via escapeshellarg(). No user-supplied input reaches the shell unescaped.
*/
declare(strict_types=1);
require_once __DIR__ . '/../vendor/autoload.php';
require_once __DIR__ . '/../lib/Enterprise/CliFramework.php';
use MokoEnterprise\CliFramework;
use MokoEnterprise\MokoStandardsParser;
class EnrichMokostandardsXmlCli extends CliFramework
{
protected function configure(): void
{
$this->setDescription('Enrich XML manifests with repo-specific build and deploy details');
$this->addArgument('--repo', 'Filter to a single repo name', '');
$this->addArgument('--skip', 'Comma-separated list of repos to skip', '');
}
protected function run(): int
{
$giteaUrl = rtrim(getenv('GITEA_URL') ?: 'https://git.mokoconsulting.tech', '/');
$giteaOrg = getenv('GITEA_ORG') ?: 'MokoConsulting';
$token = getenv('GA_TOKEN') ?: getenv('GH_TOKEN') ?: '';
$repoFilter = $this->getArgument('--repo') ?: null;
$skipStr = $this->getArgument('--skip');
$skipRepos = $skipStr !== '' ? array_map('trim', explode(',', $skipStr)) : [];
$parser = new MokoStandardsParser();
$tmpBase = sys_get_temp_dir() . '/moko-enrich-' . getmypid();
echo "=== moko-platform XML Manifest Enrichment ===\n";
echo "Mode: " . ($this->dryRun ? "DRY RUN" : "LIVE") . "\n";
if (!empty($skipRepos)) {
echo "Skipping: " . implode(', ', $skipRepos) . "\n";
}
echo "\n";
if (empty($token)) {
$this->log('ERROR', 'GA_TOKEN required');
return 1;
}
$repos = $this->fetchRepos($giteaUrl, $giteaOrg, $token);
echo "Found " . count($repos) . " repositories\n\n";
$stats = ['enriched' => 0, 'skipped' => 0, 'failed' => 0];
foreach ($repos as $repo) {
$name = $repo['name'];
if ($repoFilter && $name !== $repoFilter) {
continue;
}
if (in_array($name, $skipRepos, true)) {
echo " {$name} ... SKIP (excluded)\n";
$stats['skipped']++;
continue;
}
if ($repo['archived'] ?? false) {
$stats['skipped']++;
continue;
}
$defaultBranch = $repo['default_branch'] ?? 'main';
$httpsUrl = $repo['clone_url'] ?? "{$giteaUrl}/{$giteaOrg}/{$name}.git";
$authedUrl = preg_replace('#^https://#', "https://gitea-actions:{$token}@", $httpsUrl);
echo " {$name} ... ";
$workDir = "{$tmpBase}/{$name}";
@mkdir($workDir, 0755, true);
[$ret] = $this->safeExec(
'git clone --depth 1 --branch ' . escapeshellarg($defaultBranch)
. ' ' . escapeshellarg($authedUrl) . ' ' . escapeshellarg($workDir)
);
if ($ret !== 0) {
echo "FAIL (clone)\n";
$stats['failed']++;
continue;
}
$manifestPath = "{$workDir}/.mokogitea/manifest.xml";
if (!file_exists($manifestPath) || !str_contains(file_get_contents($manifestPath), '<moko-platform')) {
echo "SKIP (no XML manifest)\n";
$stats['skipped']++;
$this->rmTree($workDir);
continue;
}
$existingXml = file_get_contents($manifestPath);
$platform = $parser->extractPlatform($existingXml) ?? 'default-repository';
$enrichment = $this->inspectRepo($workDir, $platform);
if (!isset($enrichment['build'])) {
$enrichment['build'] = [];
}
$enrichment['build']['language'] = $enrichment['build']['language']
?? $repo['language']
?? MokoStandardsParser::platformLanguage($platform);
$enrichment['build']['package_type'] = $enrichment['build']['package_type'] ?? MokoStandardsParser::platformPackageType($platform);
$enrichedXml = $this->enrichManifestXml($existingXml, $enrichment);
$dc = count($enrichment['deploy'] ?? []);
$sc = count($enrichment['scripts'] ?? []);
$details = "deploy={$dc} scripts={$sc}";
if ($this->dryRun) {
echo "WOULD ENRICH [{$details}]\n";
$stats['enriched']++;
$this->rmTree($workDir);
continue;
}
file_put_contents($manifestPath, $enrichedXml);
$this->gitCmd($workDir, 'config', 'user.name', 'gitea-actions[bot]');
$this->gitCmd($workDir, 'config', 'user.email', 'gitea-actions[bot]@git.mokoconsulting.tech');
$this->gitCmd($workDir, 'add', '.mokogitea/manifest.xml');
$commitMsg = "chore: enrich .mokostandards"
. " with build/deploy/scripts\n\n"
. "Auto-detected: {$details}";
[$cr, $co] = $this->gitCmd(
$workDir,
'commit',
'-m',
$commitMsg
);
if ($cr !== 0) {
echo "SKIP (no diff)\n";
$stats['skipped']++;
$this->rmTree($workDir);
continue;
}
[$pr] = $this->gitCmd($workDir, 'push', 'origin', $defaultBranch);
if ($pr !== 0) {
echo "FAIL (push)\n";
$stats['failed']++;
} else {
echo "ENRICHED [{$details}]\n";
$stats['enriched']++;
}
$this->rmTree($workDir);
}
@rmdir($tmpBase);
echo "\n=== Summary ===\nEnriched: {$stats['enriched']}\nSkipped: {$stats['skipped']}\nFailed: {$stats['failed']}\n";
return 0;
}
private function inspectRepo(string $workDir, string $platform): array
{
$enrichment = [];
$build = [];
if (is_dir("{$workDir}/src")) {
foreach (glob("{$workDir}/src/*.xml") ?: [] as $xf) {
$c = file_get_contents($xf);
if (str_contains($c, '<extension') || str_contains($c, '<install')) {
$build['entry_point'] = 'src/' . basename($xf);
break;
}
}
foreach (glob("{$workDir}/src/core/modules/mod*.class.php") ?: [] as $mf) {
$build['entry_point'] = str_replace("{$workDir}/", '', $mf);
break;
}
}
if (file_exists("{$workDir}/composer.json")) {
$composer = json_decode(file_get_contents("{$workDir}/composer.json"), true) ?: [];
$phpReq = $composer['require']['php'] ?? null;
if ($phpReq) {
$build['runtime'] = "php:{$phpReq}";
}
$deps = [];
foreach (['joomla/cms', 'joomla/framework', 'dolibarr/dolibarr'] as $pd) {
if (isset($composer['require'][$pd])) {
$deps[] = ['name' => $pd, 'version' => $composer['require'][$pd], 'type' => 'platform'];
}
}
if (isset($composer['require']['mokoconsulting-tech/enterprise'])) {
$deps[] = [
'name' => 'mokoconsulting-tech/enterprise',
'version' => $composer['require']['mokoconsulting-tech/enterprise'],
'type' => 'composer',
];
}
if (!empty($deps)) {
$build['dependencies'] = $deps;
}
}
if (file_exists("{$workDir}/Makefile")) {
$mk = file_get_contents("{$workDir}/Makefile");
if (preg_match('/\bdist\/(\S+\.zip)\b/', $mk, $m)) {
$build['artifact'] = ['format' => 'zip', 'path' => 'dist/', 'filename' => $m[1]];
}
}
if (!empty($build)) {
$enrichment['build'] = $build;
}
$targets = [];
$wfDir = is_dir("{$workDir}/.gitea/workflows") ? "{$workDir}/.gitea/workflows" : "{$workDir}/.github/workflows";
if (is_dir($wfDir)) {
foreach (['deploy-dev', 'deploy-demo', 'deploy-rs'] as $dn) {
$wf = "{$wfDir}/{$dn}.yml";
if (!file_exists($wf)) {
continue;
}
$wc = file_get_contents($wf);
$t = ['name' => str_replace('deploy-', '', $dn)];
if (str_contains($wc, 'sftp') || str_contains($wc, 'SFTP')) {
$t['method'] = 'sftp';
} elseif (str_contains($wc, 'rsync')) {
$t['method'] = 'rsync';
}
if (str_contains($wc, 'src/')) {
$t['src_dir'] = 'src/';
}
if (preg_match('/branches:\s*\n\s*-\s*["\']?([^"\'}\s]+)/', $wc, $m)) {
$t['branch'] = $m[1];
}
$targets[] = $t;
}
}
if (!empty($targets)) {
$enrichment['deploy'] = $targets;
}
$scripts = [];
if (file_exists("{$workDir}/Makefile")) {
$mk = file_get_contents("{$workDir}/Makefile");
$known = [
'build' => 'build', 'test' => 'test', 'lint' => 'lint',
'clean' => 'build', 'package' => 'build',
'validate' => 'validate', 'release' => 'release',
];
if (preg_match_all('/^([a-zA-Z_-]+)\s*:/m', $mk, $matches)) {
foreach ($matches[1] as $tgt) {
$tl = strtolower($tgt);
if (isset($known[$tl])) {
$scripts[] = [
'name' => $tl, 'phase' => $known[$tl],
'command' => "make {$tgt}",
'desc' => ucfirst($tl) . ' via make',
'runner' => 'make',
];
}
}
}
}
if (file_exists("{$workDir}/composer.json")) {
$composer = json_decode(file_get_contents("{$workDir}/composer.json"), true) ?: [];
$km = ['test' => 'test','lint' => 'lint','cs' => 'lint','phpcs' => 'lint','phpstan' => 'lint','validate' => 'validate'];
foreach ($composer['scripts'] ?? [] as $sn => $cmd) {
$sl = strtolower($sn);
foreach ($km as $match => $phase) {
if (str_contains($sl, $match)) {
$exists = false;
foreach ($scripts as $s) {
if ($s['name'] === $sl) {
$exists = true;
break;
}
}
if (!$exists) {
$scripts[] = [
'name' => $sn, 'phase' => $phase,
'command' => "composer run {$sn}",
'desc' => is_string($cmd) ? $cmd : "Run {$sn}",
'runner' => 'composer',
];
}
break;
}
}
}
}
if (!empty($scripts)) {
$enrichment['scripts'] = $scripts;
}
return $enrichment;
}
private function enrichManifestXml(string $xml, array $enrichment): string
{
$dom = new \DOMDocument('1.0', 'UTF-8');
$dom->preserveWhiteSpace = false;
$dom->formatOutput = true;
if (!$dom->loadXML($xml)) {
return $xml;
}
$ns = MokoStandardsParser::NAMESPACE_URI;
$root = $dom->documentElement;
foreach (['build', 'deploy', 'scripts'] as $tag) {
$toRemove = [];
$existing = $root->getElementsByTagNameNS($ns, $tag);
for ($i = 0; $i < $existing->length; $i++) {
$toRemove[] = $existing->item($i);
}
foreach ($toRemove as $node) {
$root->removeChild($node);
}
}
if (!empty($enrichment['build'])) {
$buildEl = $dom->createElementNS($ns, 'build');
$b = $enrichment['build'];
foreach (['language', 'runtime'] as $f) {
if (isset($b[$f])) {
$buildEl->appendChild($dom->createElementNS($ns, $f, htmlspecialchars($b[$f], ENT_XML1)));
}
}
if (isset($b['package_type'])) {
$buildEl->appendChild($dom->createElementNS($ns, 'package-type', htmlspecialchars($b['package_type'], ENT_XML1)));
}
if (isset($b['entry_point'])) {
$buildEl->appendChild($dom->createElementNS($ns, 'entry-point', htmlspecialchars($b['entry_point'], ENT_XML1)));
}
if (isset($b['artifact'])) {
$art = $dom->createElementNS($ns, 'artifact');
foreach (['format','path','filename'] as $af) {
if (isset($b['artifact'][$af])) {
$art->appendChild($dom->createElementNS($ns, $af, htmlspecialchars($b['artifact'][$af], ENT_XML1)));
}
}
$buildEl->appendChild($art);
}
if (isset($b['dependencies'])) {
$deps = $dom->createElementNS($ns, 'dependencies');
foreach ($b['dependencies'] as $d) {
$req = $dom->createElementNS($ns, 'requires', '');
$req->setAttribute('name', $d['name']);
if (isset($d['version'])) {
$req->setAttribute('version', $d['version']);
}
if (isset($d['type'])) {
$req->setAttribute('type', $d['type']);
}
$deps->appendChild($req);
}
$buildEl->appendChild($deps);
}
$root->appendChild($buildEl);
}
if (!empty($enrichment['deploy'])) {
$deploy = $dom->createElementNS($ns, 'deploy');
foreach ($enrichment['deploy'] as $t) {
$target = $dom->createElementNS($ns, 'target');
$target->setAttribute('name', $t['name']);
$target->appendChild($dom->createElementNS($ns, 'host', '${{ secrets.' . strtoupper($t['name']) . '_HOST }}'));
$target->appendChild($dom->createElementNS($ns, 'path', '${{ secrets.' . strtoupper($t['name']) . '_PATH }}'));
if (isset($t['method'])) {
$target->appendChild($dom->createElementNS($ns, 'method', $t['method']));
}
if (isset($t['branch'])) {
$target->appendChild($dom->createElementNS($ns, 'branch', htmlspecialchars($t['branch'], ENT_XML1)));
}
if (isset($t['src_dir'])) {
$target->appendChild($dom->createElementNS($ns, 'src-dir', htmlspecialchars($t['src_dir'], ENT_XML1)));
}
$deploy->appendChild($target);
}
$root->appendChild($deploy);
}
if (!empty($enrichment['scripts'])) {
$scriptsEl = $dom->createElementNS($ns, 'scripts');
foreach ($enrichment['scripts'] as $s) {
$script = $dom->createElementNS($ns, 'script');
$script->setAttribute('name', $s['name']);
if (isset($s['phase'])) {
$script->setAttribute('phase', $s['phase']);
}
$script->appendChild($dom->createElementNS($ns, 'command', htmlspecialchars($s['command'], ENT_XML1)));
if (isset($s['desc'])) {
$script->appendChild($dom->createElementNS($ns, 'description', htmlspecialchars($s['desc'], ENT_XML1)));
}
if (isset($s['runner'])) {
$script->appendChild($dom->createElementNS($ns, 'runner', htmlspecialchars($s['runner'], ENT_XML1)));
}
$scriptsEl->appendChild($script);
}
$root->appendChild($scriptsEl);
}
return $dom->saveXML();
}
/** @return array{int, string} */
private function safeExec(string $command, string $cwd = '.'): array
{
$proc = proc_open($command, [1 => ['pipe', 'w'], 2 => ['pipe', 'w']], $pipes, $cwd);
if (!is_resource($proc)) {
return [1, "proc_open failed"];
}
$stdout = stream_get_contents($pipes[1]);
$stderr = stream_get_contents($pipes[2]);
fclose($pipes[1]);
fclose($pipes[2]);
return [proc_close($proc), trim($stdout . "\n" . $stderr)];
}
private function rmTree(string $dir): void
{
if (!is_dir($dir)) {
return;
}
$it = new \RecursiveDirectoryIterator($dir, \FilesystemIterator::SKIP_DOTS);
$files = new \RecursiveIteratorIterator($it, \RecursiveIteratorIterator::CHILD_FIRST);
foreach ($files as $file) {
if ($file->isDir()) {
@rmdir($file->getPathname());
} else {
@chmod($file->getPathname(), 0777);
@unlink($file->getPathname());
}
}
@rmdir($dir);
}
/** @return array{int, string} */
private function gitCmd(string $workDir, string ...$args): array
{
$cmd = 'git';
foreach ($args as $a) {
$cmd .= ' ' . escapeshellarg($a);
}
return $this->safeExec($cmd, $workDir);
}
private function fetchRepos(string $url, string $org, string $token): array
{
$repos = [];
$page = 1;
do {
$ch = curl_init("{$url}/api/v1/orgs/{$org}/repos?page={$page}&limit=50");
curl_setopt_array($ch, [CURLOPT_RETURNTRANSFER => true, CURLOPT_HTTPHEADER => ["Authorization: token {$token}"], CURLOPT_TIMEOUT => 30]);
$body = curl_exec($ch);
$code = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
if ($code !== 200) {
break;
}
$batch = json_decode($body, true);
if (empty($batch)) {
break;
}
$repos = array_merge($repos, $batch);
$page++;
} while (count($batch) >= 50);
return $repos;
}
}
$app = new EnrichMokostandardsXmlCli();
exit($app->execute());
@@ -0,0 +1,12 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"description": "Example configuration file for file-distributor.ps1 v02.00.00",
"SourceFile": "C:\\path\\to\\your\\source\\file.txt",
"RootDirectory": "C:\\path\\to\\root\\directory",
"Depth": 1,
"DryRun": true,
"Overwrite": false,
"ConfirmEach": false,
"IncludeHidden": true,
"LogDirectory": "C:\\path\\to\\logs"
}
+32
View File
@@ -0,0 +1,32 @@
<!--
Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
SPDX-License-Identifier: GPL-3.0-or-later
FILE INFORMATION
DEFGROUP: MokoPlatform.Index
INGROUP: MokoPlatform.Automation
REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform
PATH: /automation/index.md
BRIEF: Automation directory index
-->
# Docs Index: /api/automation
## Purpose
This index provides navigation to documentation within this folder.
## Documents
- [README-file-distributor](./README-file-distributor.md)
- [README](./README.md)
## Metadata
- **Document Type:** index
- **Auto-generated:** This file is automatically generated by rebuild_indexes.py
## Revision History
| Date | Author | Change | Notes |
| ---------- | ------------------ | ----------------- | ------------------------------------------ |
| Auto | rebuild_indexes.py | Automated update | Generated by documentation index automation |
+300
View File
@@ -0,0 +1,300 @@
#!/usr/bin/env php
<?php
/* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
*
* This file is part of a Moko Consulting project.
*
* SPDX-License-Identifier: GPL-3.0-or-later
*
* FILE INFORMATION
* DEFGROUP: MokoPlatform.Automation
* INGROUP: MokoPlatform
* REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform
* PATH: /automation/migrate_to_gitea.php
* BRIEF: Migrate repositories from GitHub to self-hosted Gitea instance
*
* USAGE
* php automation/migrate_to_gitea.php --dry-run
* php automation/migrate_to_gitea.php --repos MokoCRM MokoDoliMods
* php automation/migrate_to_gitea.php --exclude moko-platform --skip-archived
* php automation/migrate_to_gitea.php --resume
*/
declare(strict_types=1);
require_once __DIR__ . '/../vendor/autoload.php';
use MokoEnterprise\CheckpointManager;
use MokoEnterprise\CliFramework;
use MokoEnterprise\Config;
use MokoEnterprise\PlatformAdapterFactory;
use MokoEnterprise\GitHubAdapter;
use MokoEnterprise\MokoGiteaAdapter;
/**
* Gitea Migration Script
*
* Migrates repositories from GitHub to a self-hosted Gitea instance.
* Uses Gitea's built-in migration endpoint for git history, tags, releases,
* issues, and labels. Post-migration applies branch protection, topics,
* and workflow conversion.
*/
class MigrateToGitea extends CliFramework
{
private ?GitHubAdapter $github = null;
private ?MokoGiteaAdapter $gitea = null;
private ?CheckpointManager $checkpoints = null;
protected function configure(): void
{
$this->setDescription('Migrate repositories from GitHub to Gitea');
$this->addArgument('--dry-run', 'Show what would be migrated without making changes', false);
$this->addArgument('--repos', 'Specific repositories to migrate (space-separated)', '');
$this->addArgument('--exclude', 'Repositories to exclude (space-separated)', '');
$this->addArgument('--skip-archived', 'Skip archived repositories', false);
$this->addArgument('--resume', 'Resume from last checkpoint', false);
$this->addArgument('--github-token', 'GitHub token override', '');
$this->addArgument('--gitea-token', 'Gitea token override', '');
}
protected function run(): int
{
$dryRun = (bool) $this->getArgument('--dry-run');
$specificRepos = array_filter(explode(' ', (string) $this->getArgument('--repos')));
$excludeRepos = array_filter(explode(' ', (string) $this->getArgument('--exclude')));
$skipArchived = (bool) $this->getArgument('--skip-archived');
$resume = (bool) $this->getArgument('--resume');
$config = Config::load();
// Override tokens if provided
$ghToken = (string) $this->getArgument('--github-token');
$giteaToken = (string) $this->getArgument('--gitea-token');
if ($ghToken !== '') {
$config->set('github.token', $ghToken);
}
if ($giteaToken !== '') {
$config->set('gitea.token', $giteaToken);
}
// Create both adapters
try {
$adapters = PlatformAdapterFactory::createBoth($config);
$this->github = $adapters['github'];
$this->gitea = $adapters['gitea'];
} catch (\RuntimeException $e) {
$this->log('ERROR', $e->getMessage());
return 1;
}
$this->checkpoints = new CheckpointManager('.checkpoints/migration');
$org = $config->getString('github.organization', 'MokoConsulting');
$giteaOrg = $config->getString('gitea.organization', 'MokoConsulting');
echo "=== Gitea Migration Tool ===\n";
echo "Source: GitHub ({$org})\n";
echo "Destination: Gitea ({$giteaOrg}) at " . $config->getString('gitea.url') . "\n";
echo "Mode: " . ($dryRun ? 'DRY RUN' : 'LIVE') . "\n\n";
// ── Phase 1: Discovery ──────────────────────────────────────────
$this->section('Phase 1: Discovery');
$ghRepos = $this->github->listOrgRepos($org, $skipArchived);
echo "Found " . count($ghRepos) . " repositories on GitHub\n";
// Filter repos
if (!empty($specificRepos)) {
$ghRepos = array_filter($ghRepos, fn($r) => in_array($r['name'], $specificRepos, true));
}
if (!empty($excludeRepos)) {
$ghRepos = array_filter($ghRepos, fn($r) => !in_array($r['name'], $excludeRepos, true));
}
// Check which already exist on Gitea
$giteaRepos = [];
try {
$existing = $this->gitea->listOrgRepos($giteaOrg);
foreach ($existing as $r) {
$giteaRepos[$r['name']] = true;
}
} catch (\Exception $e) {
echo "Note: Could not list Gitea repos (org may not exist yet): {$e->getMessage()}\n";
}
$toMigrate = [];
$toSkip = [];
foreach ($ghRepos as $repo) {
$name = $repo['name'];
if (isset($giteaRepos[$name])) {
$toSkip[] = $name;
} else {
$toMigrate[] = $repo;
}
}
echo "\nMigration plan:\n";
echo " Migrate: " . count($toMigrate) . " repositories\n";
echo " Skip: " . count($toSkip) . " (already on Gitea)\n";
if (!empty($toSkip)) {
echo " Skipped: " . implode(', ', $toSkip) . "\n";
}
echo "\n";
if (empty($toMigrate)) {
echo "Nothing to migrate.\n";
return 0;
}
if ($dryRun) {
echo "Repositories to migrate:\n";
foreach ($toMigrate as $repo) {
$vis = $repo['private'] ? 'private' : 'public';
echo " - {$repo['name']} ({$vis})\n";
}
echo "\nDry run complete. Use without --dry-run to execute.\n";
return 0;
}
// ── Phase 2: Migrate ────────────────────────────────────────────
$this->section('Phase 2: Migration');
$ghToken = $config->getString('github.token');
$results = ['migrated' => [], 'failed' => [], 'skipped' => $toSkip];
// Resume support
$checkpoint = $resume ? $this->checkpoints->loadCheckpoint('gitea_migration') : null;
$startFrom = $checkpoint['last_completed'] ?? '';
$skipUntil = !empty($startFrom);
foreach ($toMigrate as $index => $repo) {
$name = $repo['name'];
if ($skipUntil) {
if ($name === $startFrom) {
$skipUntil = false;
}
echo " Skipping {$name} (already migrated)\n";
continue;
}
echo "\n [{$index}/{" . count($toMigrate) . "}] Migrating {$name}...\n";
try {
// Shallow migration — copy current branch state only, no past
// commit history. This gives every repo a clean start on Gitea.
$this->gitea->migrateRepository([
'clone_addr' => "https://github.com/{$org}/{$name}.git",
'repo_name' => $name,
'repo_owner' => $giteaOrg,
'service' => 'github',
'auth_token' => $ghToken,
'mirror' => false,
'private' => $repo['private'],
'issues' => false,
'labels' => true,
'milestones' => false,
'releases' => false,
'pull_requests' => false,
'wiki' => false,
]);
echo " Migrated successfully\n";
$results['migrated'][] = $name;
// Save checkpoint after each successful migration
$this->checkpoints->saveCheckpoint('gitea_migration', [
'last_completed' => $name,
'migrated' => $results['migrated'],
'failed' => $results['failed'],
]);
} catch (\Exception $e) {
echo " FAILED: " . $e->getMessage() . "\n";
$results['failed'][] = ['name' => $name, 'error' => $e->getMessage()];
$this->gitea->getApiClient()->resetCircuitBreaker();
}
}
// ── Phase 3: Post-migration ─────────────────────────────────────
$this->section('Phase 3: Post-migration');
foreach ($results['migrated'] as $name) {
echo " Post-processing {$name}...\n";
try {
// Apply topics from GitHub
$ghTopics = $this->github->getRepoTopics($org, $name);
if (!empty($ghTopics)) {
$this->gitea->setRepoTopics($giteaOrg, $name, $ghTopics);
echo " Topics applied\n";
}
// Apply branch protection
$this->gitea->setBranchProtection($giteaOrg, $name, 'main', [
'required_reviews' => 1,
'dismiss_stale' => true,
'block_on_rejected' => true,
]);
echo " Branch protection applied\n";
} catch (\Exception $e) {
echo " Warning: post-processing issue: " . $e->getMessage() . "\n";
$this->gitea->getApiClient()->resetCircuitBreaker();
}
}
// ── Phase 4: Verification ───────────────────────────────────────
$this->section('Phase 4: Verification');
$report = "## Migration Report\n\n";
$report .= "**Date:** " . gmdate('Y-m-d H:i:s') . " UTC\n";
$report .= "**Source:** GitHub ({$org})\n";
$report .= "**Destination:** Gitea ({$giteaOrg})\n\n";
$report .= "### Results\n\n";
$report .= "| Status | Count |\n|--------|-------|\n";
$report .= "| Migrated | " . count($results['migrated']) . " |\n";
$report .= "| Failed | " . count($results['failed']) . " |\n";
$report .= "| Skipped (existing) | " . count($results['skipped']) . " |\n\n";
if (!empty($results['migrated'])) {
$report .= "### Migrated Repositories\n\n";
foreach ($results['migrated'] as $name) {
$report .= "- {$name}\n";
}
$report .= "\n";
}
if (!empty($results['failed'])) {
$report .= "### Failed Repositories\n\n";
foreach ($results['failed'] as $fail) {
$report .= "- **{$fail['name']}**: {$fail['error']}\n";
}
$report .= "\n";
}
echo $report;
// Create summary issue on Gitea
try {
$this->gitea->createIssue(
$giteaOrg,
'moko-platform',
'chore: GitHub → Gitea migration report — ' . count($results['migrated']) . ' repos migrated',
$report,
['labels' => ['automation', 'type: chore']]
);
echo "Migration report issue created on Gitea.\n";
} catch (\Exception $e) {
echo "Could not create report issue: " . $e->getMessage() . "\n";
}
echo "\nMigration complete: " . count($results['migrated']) . " migrated, "
. count($results['failed']) . " failed, "
. count($results['skipped']) . " skipped\n";
return count($results['failed']) > 0 ? 1 : 0;
}
}
$script = new MigrateToGitea('migrate_to_gitea', 'Migrate repositories from GitHub to Gitea');
exit($script->execute());
+682
View File
@@ -0,0 +1,682 @@
#!/usr/bin/env php
<?php
/**
* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
*
* This file is part of a Moko Consulting project.
*
* SPDX-License-Identifier: GPL-3.0-or-later
*
* FILE INFORMATION
* DEFGROUP: MokoPlatform.Automation
* INGROUP: MokoPlatform.Scripts
* REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform
* PATH: /automation/push_files.php
* BRIEF: Push one or more specific files to one or more remote repositories
*/
declare(strict_types=1);
require_once __DIR__ . '/../vendor/autoload.php';
require_once __DIR__ . '/../lib/Enterprise/CliFramework.php';
use MokoEnterprise\{
ApiClient,
AuditLogger,
CliFramework,
Config,
GitPlatformAdapter,
MetricsCollector,
PlatformAdapterFactory,
ProjectTypeDetector
};
/**
* Targeted File Push Tool
*
* Pushes one or more specific files from moko-platform templates to one or
* more remote repositories — without running a full sync.
*
* Files are specified by their destination path as they appear in the target
* repository (e.g., ".github/ISSUE_TEMPLATE/config.yml"). The tool looks up
* the matching source template from the appropriate platform definition.
*
* Files may also be given as "source:destination" pairs to bypass definition
* lookup and push any arbitrary local file.
*
* Usage:
* php push_files.php --files=.github/ISSUE_TEMPLATE/config.yml --repos=MokoCRM
* php push_files.php --files=".github/workflows/ci.yml,.github/workflows/codeql-analysis.yml" --repos=MokoCRM,WaasComponent
* php push_files.php --files=templates/foo.txt:docs/foo.txt --repos=MyRepo --direct
*/
class PushFiles extends CliFramework
{
public const DEFAULT_ORG = 'MokoConsulting';
public const VERSION = '09.23.00';
private ApiClient $api;
private GitPlatformAdapter $adapter;
private AuditLogger $logger;
private ProjectTypeDetector $typeDetector;
/**
* Setup command-line arguments
*/
protected function configure(): void
{
$this->setDescription('Push files to remote repositories');
$this->addArgument('--org', 'GitHub organization', self::DEFAULT_ORG);
$this->addArgument('--repos', 'Target repos (comma-separated)', '');
$this->addArgument('--files', 'Files to push (comma-separated)', '');
$this->addArgument('--message', 'Custom commit message', '');
$this->addArgument('--branch', 'Target branch for direct pushes', '');
$this->addArgument('--direct', 'Push directly instead of PR', false);
$this->addArgument('--yes', 'Auto-confirm without prompting', false);
$this->addArgument('--no-issue', 'Skip creating tracking issue', false);
}
/**
* Main execution
*/
protected function run(): int
{
$this->log('📦 moko-platform File Push v' . self::VERSION, 'INFO');
if (!$this->initializeComponents()) {
return 1;
}
$org = $this->getArgument('--org', self::DEFAULT_ORG);
$reposArg = $this->getArgument('--repos', '');
$filesArg = $this->getArgument('--files', '');
$direct = $this->getArgument('--direct', false);
$autoYes = $this->getArgument('--yes', false);
// Validate required arguments
if (empty($reposArg)) {
$this->log('❌ --repos is required. Specify one or more repository names.', 'ERROR');
$this->log(' Example: --repos=MokoCRM,WaasComponent', 'ERROR');
return 1;
}
if (empty($filesArg)) {
$this->log('❌ --files is required. Specify destination paths or source:destination pairs.', 'ERROR');
$this->log(' Example: --files=.github/ISSUE_TEMPLATE/config.yml', 'ERROR');
return 1;
}
$repos = $this->parseList($reposArg);
$files = $this->parseList($filesArg);
$this->log("Organisation: {$org}", 'INFO');
$this->log('Repositories: ' . implode(', ', $repos), 'INFO');
$this->log('Files: ' . implode(', ', $files), 'INFO');
$this->log('Mode: ' . ($direct ? 'direct commit' : 'pull request'), 'INFO');
// Resolve file mappings for each repo
$this->log("\n🔍 Resolving file mappings...", 'INFO');
$repoFileMaps = $this->buildRepoFileMaps($org, $repos, $files);
if (empty($repoFileMaps)) {
$this->log('❌ No files could be resolved. Check file paths and platform definitions.', 'ERROR');
return 1;
}
// Confirm before proceeding
if (!$autoYes && !$this->confirmPush($repoFileMaps, $direct)) {
$this->log('❌ Cancelled.', 'INFO');
return 0;
}
// Execute pushes
$results = $this->executePushes($org, $repoFileMaps, $direct);
$this->displayResults($results);
if ($results['failed'] > 0 && !isset($this->options['no-issue']) && !$this->dryRun) {
$this->createFailureIssue($org, $results);
}
return $results['failed'] > 0 ? 1 : 0;
}
/**
* Initialize enterprise components
*/
private function initializeComponents(): bool
{
$config = Config::load();
try {
$this->adapter = PlatformAdapterFactory::create($config);
$this->api = $this->adapter->getApiClient();
$this->logger = new AuditLogger('push_files');
$this->typeDetector = new ProjectTypeDetector($this->logger);
$platform = $this->adapter->getPlatformName();
$this->log("✓ Components initialized for platform: {$platform}", 'INFO');
return true;
} catch (\Exception $e) {
$this->log('❌ Failed to initialize: ' . $e->getMessage(), 'ERROR');
return false;
}
}
/**
* Parse a comma- or space-separated list into a clean array
*/
private function parseList(string $input): array
{
return array_values(array_filter(
array_map('trim', preg_split('/[\s,]+/', $input)),
fn($v) => $v !== ''
));
}
/**
* Build per-repo file maps: repo → [ [source, destination], … ]
*
* Each entry in $files is either:
* - "destination/path" → looked up in the platform definition
* - "source/path:destination/path" → used as-is (raw mode)
*
* @param string[] $repos
* @param string[] $files
* @return array<string, list<array{source: string, destination: string}>>
*/
private function buildRepoFileMaps(string $org, array $repos, array $files): array
{
$repoRoot = dirname(__DIR__, 2);
$maps = [];
foreach ($repos as $repo) {
// Detect the repo's platform so we load the right definition
$platform = $this->detectRepoPlatform($org, $repo);
$this->log(" {$repo}: platform = {$platform}", 'INFO');
$resolved = [];
foreach ($files as $fileSpec) {
if (str_contains($fileSpec, ':')) {
// Raw source:destination pair
[$src, $dest] = explode(':', $fileSpec, 2);
} else {
// Same path as source and destination
$src = $fileSpec;
$dest = $fileSpec;
}
$dest = ltrim($dest, '/');
$srcAbs = rtrim($repoRoot, '/') . '/' . ltrim($src, '/');
if (!file_exists($srcAbs)) {
$this->log(" ⚠️ Source not found for {$repo}: {$src}", 'WARN');
continue;
}
$resolved[] = ['source' => $srcAbs, 'destination' => $dest];
$this->log("{$dest}", 'INFO');
}
if (!empty($resolved)) {
$maps[$repo] = $resolved;
}
}
return $maps;
}
/**
* Detect platform for a repo via manifest or live detection.
*/
private function detectRepoPlatform(string $org, string $repo): string
{
// Read platform from repo's .mokogitea/manifest.xml via API
try {
$manifestData = $this->adapter->getFileContent($org, $repo, '.mokogitea/manifest.xml', 'main');
if (!empty($manifestData)) {
$xml = @simplexml_load_string($manifestData);
if ($xml !== false) {
$platform = (string)($xml->governance->platform ?? '');
if (!empty($platform)) {
return $platform;
}
}
}
} catch (\Exception $e) {
// Fall through to local detection
}
// Fall back to live detection
try {
$result = $this->typeDetector->detect('.');
return $result['type'] ?? 'default';
} catch (\Exception $e) {
$this->log(" ⚠️ Could not detect platform for {$repo}, using 'default'", 'WARN');
return 'default';
}
}
/**
* Prompt for confirmation before pushing
*
* @param array<string, list<array{source: string, destination: string}>> $repoFileMaps
*/
private function confirmPush(array $repoFileMaps, bool $direct): bool
{
if ($this->quiet) {
return true;
}
$totalFiles = array_sum(array_map('count', $repoFileMaps));
$totalRepos = count($repoFileMaps);
$mode = $direct ? 'direct commit' : 'PR';
echo "\n";
foreach ($repoFileMaps as $repo => $entries) {
echo " {$repo}:\n";
foreach ($entries as $entry) {
echo "{$entry['destination']}\n";
}
}
echo "\n";
echo "⚠️ About to push {$totalFiles} file(s) to {$totalRepos} repo(s) via {$mode}.\n";
echo "Continue? [y/N]: ";
$handle = fopen('php://stdin', 'r');
$line = fgets($handle);
if ($handle) {
fclose($handle);
}
return is_string($line) && strtolower(trim($line)) === 'y';
}
/**
* Execute all file pushes
*
* @param array<string, list<array{source: string, destination: string}>> $repoFileMaps
* @return array{total: int, success: int, failed: int, repos: array<string, string>}
*/
private function executePushes(string $org, array $repoFileMaps, bool $direct): array
{
$results = [
'total' => count($repoFileMaps),
'success' => 0,
'failed' => 0,
'repos' => [],
];
$customMessage = $this->getArgument('--message', '');
$targetBranch = $this->getArgument('--branch', '');
foreach ($repoFileMaps as $repo => $entries) {
$this->log("\n[{$repo}] Pushing " . count($entries) . ' file(s)...', 'INFO');
try {
// Resolve the default branch
$repoData = $this->adapter->getRepo($org, $repo);
$defaultBranch = $repoData['default_branch'] ?? 'main';
$branch = $direct
? ($targetBranch ?: $defaultBranch)
: $this->createSyncBranch($org, $repo, $defaultBranch);
$pushed = 0;
foreach ($entries as $entry) {
if ($this->pushSingleFile($org, $repo, $entry['source'], $entry['destination'], $branch, $customMessage)) {
$pushed++;
$this->log("{$entry['destination']}", 'INFO');
} else {
$this->log("{$entry['destination']}", 'ERROR');
}
}
if ($pushed === 0) {
$results['failed']++;
$results['repos'][$repo] = 'failed';
continue;
}
$prNumber = null;
if (!$direct) {
$prTitle = "chore: push " . count($entries) . " file(s) from moko-platform";
$prBody = $this->buildPRBody($entries);
$pr = $this->adapter->createPullRequest(
$org,
$repo,
$prTitle,
$branch,
$defaultBranch,
$prBody,
['assignees' => ['jmiller']]
);
$prNumber = $pr['number'] ?? null;
$this->log(" 📋 PR #{$prNumber} created", 'INFO');
$results['repos'][$repo] = "pr#{$prNumber}";
} else {
$results['repos'][$repo] = 'pushed';
}
if (!isset($this->options['no-issue']) && !$this->dryRun) {
$this->createTargetRepoIssue($org, $repo, $entries, $prNumber, $direct ? $branch : null);
}
$results['success']++;
} catch (\Exception $e) {
$this->log("{$repo}: " . $e->getMessage(), 'ERROR');
$results['failed']++;
$results['repos'][$repo] = 'failed';
}
}
return $results;
}
/**
* Create a uniquely-named sync branch off the default branch
*/
private function createSyncBranch(string $org, string $repo, string $base): string
{
$branchName = 'moko/push-files-' . date('Ymd-His');
// Resolve the base branch to a commit SHA using the adapter
$sha = $this->adapter->resolveRef($org, $repo, $base);
if (empty($sha)) {
throw new \RuntimeException("Cannot resolve SHA for branch {$base} in {$repo}");
}
$this->api->post("/repos/{$org}/{$repo}/git/refs", [
'ref' => "refs/heads/{$branchName}",
'sha' => $sha,
]);
$this->log(" 🌿 Branch created: {$branchName}", 'INFO');
return $branchName;
}
/**
* Push a single file to a repository branch via the Contents API
*
* @return bool True on success
*/
private function pushSingleFile(
string $org,
string $repo,
string $sourcePath,
string $destPath,
string $branch,
string $customMessage
): bool {
$content = file_get_contents($sourcePath);
if ($content === false) {
$this->log(" ⚠️ Cannot read source: {$sourcePath}", 'WARN');
return false;
}
$message = !empty($customMessage)
? $customMessage
: "chore: update {$destPath} from moko-platform";
// Fetch existing file SHA (needed for updates)
$existingSha = null;
try {
$existing = $this->adapter->getFileContents($org, $repo, $destPath, $branch);
$existingSha = $existing['sha'] ?? null;
} catch (\Exception $e) {
// File does not exist — create it (no sha needed)
$this->adapter->getApiClient()->resetCircuitBreaker();
}
try {
$this->adapter->createOrUpdateFile(
$org,
$repo,
$destPath,
$content,
$message,
$existingSha,
$branch
);
return true;
} catch (\Exception $e) {
$this->log(" ✗ API error pushing {$destPath}: " . $e->getMessage(), 'ERROR');
return false;
}
}
/**
* Create a tracking issue in the target repository after a successful push.
*
* @param list<array{source: string, destination: string}> $entries
*/
private function createTargetRepoIssue(
string $org,
string $repo,
array $entries,
?int $prNumber,
?string $directBranch
): void {
$now = gmdate('Y-m-d H:i:s') . ' UTC';
$version = self::VERSION;
$source = $this->adapter->getRepoWebUrl($org, 'moko-platform');
$title = "chore: moko-platform file push tracking";
$deliveryLine = $prNumber !== null
? "| **Pull request** | [#{$prNumber}](" . $this->adapter->getPullRequestWebUrl($org, $repo, $prNumber) . ") |"
: "| **Delivery** | Direct commit to `{$directBranch}` |";
$fileRows = implode("\n", array_map(
fn($e) => "- `{$e['destination']}`",
$entries
));
$body = <<<MD
## moko-platform File Push
One or more files were pushed to this repository from moko-platform.
| Field | Value |
|-------|-------|
| **Pushed** | {$now} |
| **Standards version** | `{$version}` |
{$deliveryLine}
| **Source** | [{$source}]({$source}) |
### Files pushed
{$fileRows}
---
*Generated automatically by [moko-platform]({$source}) `push_files.php`*
MD;
$body = preg_replace('/^ /m', '', $body);
$labels = ['standards-update', 'moko-platform', 'type: chore', 'automation'];
try {
$existing = $this->api->get("/repos/{$org}/{$repo}/issues", [
'labels' => 'standards-update',
'state' => 'all',
'per_page' => 1,
'sort' => 'created',
'direction' => 'desc',
]);
$existing = array_values($existing);
if (!empty($existing) && isset($existing[0]['number'])) {
$num = $existing[0]['number'];
$patch = ['title' => $title, 'body' => $body, 'assignees' => ['jmiller']];
if (($existing[0]['state'] ?? 'open') === 'closed') {
$patch['state'] = 'open';
}
$this->api->patch("/repos/{$org}/{$repo}/issues/{$num}", $patch);
try {
$this->api->post("/repos/{$org}/{$repo}/issues/{$num}/labels", ['labels' => $labels]);
} catch (\Exception $le) {
/* non-fatal */
}
$this->log(" 📋 Tracking issue #{$num} updated in {$repo}", 'INFO');
} else {
$issue = $this->api->post("/repos/{$org}/{$repo}/issues", [
'title' => $title,
'body' => $body,
'labels' => $labels,
'assignees' => ['jmiller'],
]);
$num = $issue['number'] ?? null;
$this->log(" 📋 Tracking issue #{$num} created in {$repo}", 'INFO');
}
// Cross-link: patch the sync PR body to reference the tracking issue
// so GitHub shows it in the PR's Development sidebar.
if ($prNumber !== null && is_int($num)) {
try {
$pr = $this->api->get("/repos/{$org}/{$repo}/pulls/{$prNumber}");
$currentBody = $pr['body'] ?? '';
$ref = "Linked to #{$num}";
if (!str_contains($currentBody, $ref)) {
$this->api->patch("/repos/{$org}/{$repo}/pulls/{$prNumber}", [
'body' => $ref . "\n\n" . $currentBody,
]);
}
} catch (\Exception $le) {
/* non-fatal */
}
}
} catch (\Exception $e) {
$this->log(" ⚠️ Could not create/update tracking issue in {$repo}: " . $e->getMessage(), 'WARN');
}
}
/**
* Create or update a failure issue in moko-platform when repos fail to receive files.
* Uses the 'push-failure' label. Reopens a closed issue rather than creating a duplicate.
*/
private function createFailureIssue(string $org, array $results): void
{
$now = gmdate('Y-m-d H:i:s') . ' UTC';
$failed = $results['failed'];
$version = self::VERSION;
$failedRepos = array_keys(array_filter(
$results['repos'] ?? [],
fn($s) => $s === 'failed'
));
$repoList = implode("\n", array_map(fn($r) => "- `{$r}`", $failedRepos));
$fileArgs = $this->getArgument('--files', '');
$title = "fix: push_files failed for {$failed} repo(s) — action required";
$body = <<<MD
## File Push Failure
`push_files.php` v{$version} encountered failures pushing files on {$now}.
### Failed repositories
{$repoList}
### Files that were being pushed
```
{$fileArgs}
```
### Next steps
1. Check the output above for the specific error per repo.
2. Fix the underlying issue (API token, branch permissions, file path, etc.).
3. Re-run: `php automation/push_files.php --org={$org} --repos=<repo> --files=<files> --yes`
4. Close this issue once resolved.
---
*Auto-created by `push_files.php` — close once resolved.*
MD;
$body = preg_replace('/^ /m', '', $body);
try {
$existing = $this->api->get("/repos/{$org}/moko-platform/issues", [
'labels' => 'push-failure',
'state' => 'all',
'per_page' => 1,
'sort' => 'created',
'direction' => 'desc',
]);
$existing = array_values($existing);
if (!empty($existing) && isset($existing[0]['number'])) {
$num = $existing[0]['number'];
$patch = ['title' => $title, 'body' => $body, 'assignees' => ['jmiller']];
if (($existing[0]['state'] ?? 'open') === 'closed') {
$patch['state'] = 'open';
}
$this->api->patch("/repos/{$org}/moko-platform/issues/{$num}", $patch);
$this->log("🚨 Failure issue #{$num} updated: {$org}/moko-platform#{$num}", 'WARN');
} else {
$issue = $this->api->post("/repos/{$org}/moko-platform/issues", [
'title' => $title,
'body' => $body,
'labels' => ['push-failure'],
'assignees' => ['jmiller'],
]);
$num = $issue['number'] ?? '?';
$this->log("🚨 Failure issue created: {$org}/moko-platform#{$num}", 'WARN');
}
} catch (\Exception $e) {
$this->log("⚠️ Could not create/update failure issue: " . $e->getMessage(), 'WARN');
}
}
/**
* Build a markdown PR body listing every pushed file
*
* @param list<array{source: string, destination: string}> $entries
*/
private function buildPRBody(array $entries): string
{
$now = gmdate('Y-m-d H:i:s') . ' UTC';
$lines = ["## moko-platform File Push\n", "**Pushed:** {$now}\n", '### Files\n'];
foreach ($entries as $entry) {
$lines[] = "- `{$entry['destination']}`";
}
$sourceUrl = $this->adapter->getRepoWebUrl(self::DEFAULT_ORG, 'moko-platform');
$lines[] = "\n---\n*Generated by [moko-platform]({$sourceUrl}) `push_files.php`*";
return implode("\n", $lines);
}
/**
* Display final results
*
* @param array{total: int, success: int, failed: int, repos: array<string, string>} $results
*/
private function displayResults(array $results): void
{
$this->log("\n" . str_repeat('=', 60), 'INFO');
$this->log('📊 Push Complete', 'INFO');
$this->log(str_repeat('=', 60), 'INFO');
$this->log(sprintf('Total: %d repos', $results['total']), 'INFO');
$this->log(sprintf('Success: %d', $results['success']), 'INFO');
$this->log(sprintf('Failed: %d', $results['failed']), 'INFO');
if ($this->verbose) {
$this->log("\n📋 Details:", 'INFO');
foreach ($results['repos'] as $repo => $outcome) {
$icon = str_starts_with($outcome, 'pr#') || $outcome === 'pushed' ? '✓' : '✗';
$this->log(" {$icon} {$repo}: {$outcome}", 'INFO');
}
}
$this->log(str_repeat('=', 60), 'INFO');
}
}
// Execute if run directly
if (php_sapi_name() === 'cli' && isset($argv[0]) && realpath($argv[0]) === __FILE__) {
$app = new PushFiles();
exit($app->execute());
}
+345
View File
@@ -0,0 +1,345 @@
#!/usr/bin/env php
<?php
/**
* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
* SPDX-License-Identifier: GPL-3.0-or-later
*
* FILE INFORMATION
* DEFGROUP: MokoPlatform.Automation
* INGROUP: MokoPlatform
* REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform
* PATH: /automation/push_manifest_xml.php
* BRIEF: Push XML manifests to all governed repositories
*/
declare(strict_types=1);
require_once __DIR__ . '/../vendor/autoload.php';
require_once __DIR__ . '/../lib/Enterprise/CliFramework.php';
use MokoEnterprise\CliFramework;
use MokoEnterprise\MokoStandardsParser;
class PushManifestXmlCli extends CliFramework
{
private const CRM_PLATFORM_REPOS = ['MokoDolibarr', 'MokoDoliMods'];
protected function configure(): void
{
$this->setDescription('Push XML manifest.xml to all governed repositories');
$this->addArgument('--repo', 'Filter to a single repo name', '');
$this->addArgument('--skip', 'Comma-separated list of repos to skip', '');
$this->addArgument('--force', 'Force overwrite even if already XML', false);
}
protected function run(): int
{
$giteaUrl = rtrim(getenv('GITEA_URL') ?: 'https://git.mokoconsulting.tech', '/');
$giteaOrg = getenv('GITEA_ORG') ?: 'MokoConsulting';
$token = getenv('GA_TOKEN') ?: getenv('GH_TOKEN') ?: '';
$force = $this->getArgument('--force');
$repoFilter = $this->getArgument('--repo') ?: null;
$skipStr = $this->getArgument('--skip');
$skipRepos = $skipStr !== '' ? array_map('trim', explode(',', $skipStr)) : [];
$parser = new MokoStandardsParser();
$tmpBase = sys_get_temp_dir() . '/moko-manifest-push-' . getmypid();
echo "=== moko-platform XML Manifest Push ===\n";
echo "Org: {$giteaOrg}\n";
echo "Mode: " . ($this->dryRun ? "DRY RUN" : "LIVE") . "\n";
if ($repoFilter) {
echo "Filter: {$repoFilter}\n";
}
echo "\n";
if (empty($token)) {
$this->log('ERROR', 'GA_TOKEN or GH_TOKEN environment variable required');
return 1;
}
$repos = $this->fetchRepos($giteaUrl, $giteaOrg, $token);
echo "Found " . count($repos) . " repositories\n\n";
$stats = ['created' => 0, 'updated' => 0, 'skipped' => 0, 'failed' => 0];
foreach ($repos as $repo) {
$name = $repo['name'];
if ($repoFilter && $name !== $repoFilter) {
continue;
}
if (in_array($name, $skipRepos, true)) {
echo " SKIP {$name} (excluded)\n";
$stats['skipped']++;
continue;
}
if ($repo['archived'] ?? false) {
echo " SKIP {$name} (archived)\n";
$stats['skipped']++;
continue;
}
$platform = $this->detectPlatform($repo);
$defaultBranch = $repo['default_branch'] ?? 'main';
$httpsUrl = $repo['clone_url'] ?? "{$giteaUrl}/{$giteaOrg}/{$name}.git";
$authedUrl = preg_replace('#^https://#', "https://gitea-actions:{$token}@", $httpsUrl);
echo " {$name} [{$platform}] ... ";
// Generate XML manifest
$xmlContent = $parser->generate([
'name' => $name,
'org' => $giteaOrg,
'platform' => $platform,
'standards_version' => '04.07.00',
'description' => $repo['description'] ?? '',
'license' => 'GPL-3.0-or-later',
'topics' => $repo['topics'] ?? [],
'language' => $repo['language'] ?? MokoStandardsParser::platformLanguage($platform),
'package_type' => MokoStandardsParser::platformPackageType($platform),
'last_synced' => date('c'),
]);
if ($this->dryRun) {
echo "WOULD WRITE ({$platform})\n";
$stats['created']++;
continue;
}
// Clone shallow via HTTPS (token-authed)
$workDir = "{$tmpBase}/{$name}";
@mkdir($workDir, 0755, true);
[$ret, $out] = $this->safeExec(
'git clone --depth 1 --branch ' . escapeshellarg($defaultBranch) . ' '
. escapeshellarg($authedUrl) . ' ' . escapeshellarg($workDir)
);
if ($ret !== 0) {
echo "FAIL (clone)\n";
fprintf(STDERR, " %s\n", $out);
$stats['failed']++;
continue;
}
// Check if already XML and up-to-date
$manifestPath = "{$workDir}/.mokogitea/manifest.xml";
$existingIsXml = file_exists($manifestPath) && str_contains(file_get_contents($manifestPath), '<moko-platform');
if ($existingIsXml && !$force) {
$existingPlatform = $parser->extractPlatform(file_get_contents($manifestPath));
if ($existingPlatform === $platform) {
echo "SKIP (already XML)\n";
$stats['skipped']++;
$this->rmTree($workDir);
continue;
}
}
// Write manifest
@mkdir("{$workDir}/.gitea", 0755, true);
file_put_contents($manifestPath, $xmlContent);
// Delete legacy files if present
$legacyDeleted = [];
foreach (['.mokostandards', '.github/.mokostandards', '.gitea/.mokostandards', '.mokogitea/.mokostandards'] as $legacy) {
$legacyPath = "{$workDir}/{$legacy}";
if (file_exists($legacyPath)) {
unlink($legacyPath);
$legacyDeleted[] = $legacy;
}
}
// Commit
$isNew = !$existingIsXml;
$commitMsg = $isNew
? 'chore: add XML manifest.xml'
: 'chore: update manifest.xml';
if (!empty($legacyDeleted)) {
$commitMsg .= "\n\nRemoved legacy: " . implode(', ', $legacyDeleted);
}
$this->gitCmd($workDir, 'config', 'user.name', 'gitea-actions[bot]');
$this->gitCmd($workDir, 'config', 'user.email', 'gitea-actions[bot]@git.mokoconsulting.tech');
$this->gitCmd($workDir, 'add', '.mokogitea/manifest.xml');
foreach ($legacyDeleted as $lf) {
$this->gitCmd($workDir, 'add', $lf);
}
[$commitRet, $commitOut] = $this->gitCmd($workDir, 'commit', '-m', $commitMsg);
if ($commitRet !== 0 && str_contains($commitOut, 'nothing to commit')) {
echo "SKIP (no changes)\n";
$stats['skipped']++;
$this->rmTree($workDir);
continue;
}
if ($commitRet !== 0) {
echo "FAIL (commit)\n";
fprintf(STDERR, " %s\n", $commitOut);
$stats['failed']++;
$this->rmTree($workDir);
continue;
}
[$pushRet, $pushOut] = $this->gitCmd($workDir, 'push', 'origin', $defaultBranch);
if ($pushRet !== 0) {
echo "FAIL (push)\n";
fprintf(STDERR, " %s\n", $pushOut);
$stats['failed']++;
} else {
$action = $isNew ? 'CREATED' : 'UPDATED';
echo "{$action}\n";
$stats[$isNew ? 'created' : 'updated']++;
}
// Cleanup
$this->rmTree($workDir);
}
// Cleanup tmp base
@rmdir($tmpBase);
echo "\n=== Summary ===\n";
echo "Created: {$stats['created']}\n";
echo "Updated: {$stats['updated']}\n";
echo "Skipped: {$stats['skipped']}\n";
echo "Failed: {$stats['failed']}\n";
return 0;
}
private function detectPlatform(array $repo): string
{
$name = $repo['name'] ?? '';
$nameLower = strtolower($name);
$description = strtolower($repo['description'] ?? '');
$topics = $repo['topics'] ?? [];
if (in_array($name, self::CRM_PLATFORM_REPOS, true)) {
return 'crm-platform';
}
if (in_array('dolibarr-platform', $topics)) {
return 'crm-platform';
}
if (in_array('joomla-template', $topics)) {
return 'joomla-template';
}
if (in_array('joomla', $topics) || in_array('joomla-extension', $topics)) {
return 'waas-component';
}
if (in_array('dolibarr', $topics) || in_array('dolibarr-module', $topics)) {
return 'crm-module';
}
if (str_contains($nameLower, 'template') && (str_contains($nameLower, 'joomla') || str_contains($nameLower, 'tpl'))) {
return 'joomla-template';
}
if (str_contains($nameLower, 'joomla') || str_contains($nameLower, 'waas')) {
return 'waas-component';
}
if (str_contains($nameLower, 'doli') || str_contains($nameLower, 'crm')) {
return 'crm-module';
}
if (str_contains($description, 'joomla template')) {
return 'joomla-template';
}
if (str_contains($description, 'joomla') || str_contains($description, 'component')) {
return 'waas-component';
}
if (str_contains($description, 'dolibarr') || str_contains($description, 'module')) {
return 'crm-module';
}
if (str_contains($nameLower, 'standard')) {
return 'standards-repository';
}
return 'default-repository';
}
/**
* @return array{int, string}
*/
private function safeExec(string $command, string $cwd = '.'): array
{
$proc = proc_open(
$command,
[1 => ['pipe', 'w'], 2 => ['pipe', 'w']],
$pipes,
$cwd
);
if (!is_resource($proc)) {
return [1, "proc_open failed for: {$command}"];
}
$stdout = stream_get_contents($pipes[1]);
$stderr = stream_get_contents($pipes[2]);
fclose($pipes[1]);
fclose($pipes[2]);
$code = proc_close($proc);
return [$code, trim($stdout . "\n" . $stderr)];
}
private function rmTree(string $dir): void
{
if (!is_dir($dir)) {
return;
}
$it = new \RecursiveDirectoryIterator($dir, \FilesystemIterator::SKIP_DOTS);
$files = new \RecursiveIteratorIterator($it, \RecursiveIteratorIterator::CHILD_FIRST);
foreach ($files as $file) {
if ($file->isDir()) {
@rmdir($file->getPathname());
} else {
@chmod($file->getPathname(), 0777);
@unlink($file->getPathname());
}
}
@rmdir($dir);
}
/**
* @return array{int, string}
*/
private function gitCmd(string $workDir, string ...$args): array
{
$cmd = 'git';
foreach ($args as $a) {
$cmd .= ' ' . escapeshellarg($a);
}
return $this->safeExec($cmd, $workDir);
}
private function fetchRepos(string $url, string $org, string $token): array
{
$repos = [];
$page = 1;
do {
$ch = curl_init("{$url}/api/v1/orgs/{$org}/repos?page={$page}&limit=50");
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_HTTPHEADER => ["Authorization: token {$token}"],
CURLOPT_TIMEOUT => 30,
]);
$body = curl_exec($ch);
$code = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
if ($code !== 200) {
$this->log('ERROR', "API error (HTTP {$code}) fetching repos page {$page}");
break;
}
$batch = json_decode($body, true);
if (empty($batch)) {
break;
}
$repos = array_merge($repos, $batch);
$page++;
} while (count($batch) >= 50);
return $repos;
}
}
$app = new PushManifestXmlCli();
exit($app->execute());
+345
View File
@@ -0,0 +1,345 @@
#!/usr/bin/env php
<?php
/**
* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
* SPDX-License-Identifier: GPL-3.0-or-later
*
* FILE INFORMATION
* DEFGROUP: MokoPlatform.Automation
* INGROUP: MokoPlatform
* REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform
* PATH: /automation/push_mokostandards_xml.php
* BRIEF: Push XML manifests to all governed repositories
*/
declare(strict_types=1);
require_once __DIR__ . '/../vendor/autoload.php';
require_once __DIR__ . '/../lib/Enterprise/CliFramework.php';
use MokoEnterprise\CliFramework;
use MokoEnterprise\MokoStandardsParser;
class PushMokostandardsXmlCli extends CliFramework
{
private const CRM_PLATFORM_REPOS = ['MokoDolibarr', 'MokoDoliMods'];
protected function configure(): void
{
$this->setDescription('Push XML manifests to all governed repositories');
$this->addArgument('--repo', 'Filter to a single repo name', '');
$this->addArgument('--skip', 'Comma-separated list of repos to skip', '');
$this->addArgument('--force', 'Force overwrite even if already XML', false);
}
protected function run(): int
{
$giteaUrl = rtrim(getenv('GITEA_URL') ?: 'https://git.mokoconsulting.tech', '/');
$giteaOrg = getenv('GITEA_ORG') ?: 'MokoConsulting';
$token = getenv('GA_TOKEN') ?: getenv('GH_TOKEN') ?: '';
$force = $this->getArgument('--force');
$repoFilter = $this->getArgument('--repo') ?: null;
$skipStr = $this->getArgument('--skip');
$skipRepos = $skipStr !== '' ? array_map('trim', explode(',', $skipStr)) : [];
$parser = new MokoStandardsParser();
$tmpBase = sys_get_temp_dir() . '/moko-manifest-push-' . getmypid();
echo "=== moko-platform XML Manifest Push ===\n";
echo "Org: {$giteaOrg}\n";
echo "Mode: " . ($this->dryRun ? "DRY RUN" : "LIVE") . "\n";
if ($repoFilter) {
echo "Filter: {$repoFilter}\n";
}
echo "\n";
if (empty($token)) {
$this->log('ERROR', 'GA_TOKEN or GH_TOKEN environment variable required');
return 1;
}
$repos = $this->fetchRepos($giteaUrl, $giteaOrg, $token);
echo "Found " . count($repos) . " repositories\n\n";
$stats = ['created' => 0, 'updated' => 0, 'skipped' => 0, 'failed' => 0];
foreach ($repos as $repo) {
$name = $repo['name'];
if ($repoFilter && $name !== $repoFilter) {
continue;
}
if (in_array($name, $skipRepos, true)) {
echo " SKIP {$name} (excluded)\n";
$stats['skipped']++;
continue;
}
if ($repo['archived'] ?? false) {
echo " SKIP {$name} (archived)\n";
$stats['skipped']++;
continue;
}
$platform = $this->detectPlatform($repo);
$defaultBranch = $repo['default_branch'] ?? 'main';
$httpsUrl = $repo['clone_url'] ?? "{$giteaUrl}/{$giteaOrg}/{$name}.git";
$authedUrl = preg_replace('#^https://#', "https://gitea-actions:{$token}@", $httpsUrl);
echo " {$name} [{$platform}] ... ";
// Generate XML manifest
$xmlContent = $parser->generate([
'name' => $name,
'org' => $giteaOrg,
'platform' => $platform,
'standards_version' => '04.07.00',
'description' => $repo['description'] ?? '',
'license' => 'GPL-3.0-or-later',
'topics' => $repo['topics'] ?? [],
'language' => $repo['language'] ?? MokoStandardsParser::platformLanguage($platform),
'package_type' => MokoStandardsParser::platformPackageType($platform),
'last_synced' => date('c'),
]);
if ($this->dryRun) {
echo "WOULD WRITE ({$platform})\n";
$stats['created']++;
continue;
}
// Clone shallow via HTTPS (token-authed)
$workDir = "{$tmpBase}/{$name}";
@mkdir($workDir, 0755, true);
[$ret, $out] = $this->safeExec(
'git clone --depth 1 --branch ' . escapeshellarg($defaultBranch) . ' '
. escapeshellarg($authedUrl) . ' ' . escapeshellarg($workDir)
);
if ($ret !== 0) {
echo "FAIL (clone)\n";
fprintf(STDERR, " %s\n", $out);
$stats['failed']++;
continue;
}
// Check if already XML and up-to-date
$manifestPath = "{$workDir}/.mokogitea/manifest.xml";
$existingIsXml = file_exists($manifestPath) && str_contains(file_get_contents($manifestPath), '<moko-platform');
if ($existingIsXml && !$force) {
$existingPlatform = $parser->extractPlatform(file_get_contents($manifestPath));
if ($existingPlatform === $platform) {
echo "SKIP (already XML)\n";
$stats['skipped']++;
$this->rmTree($workDir);
continue;
}
}
// Write manifest
@mkdir("{$workDir}/.gitea", 0755, true);
file_put_contents($manifestPath, $xmlContent);
// Delete legacy files if present
$legacyDeleted = [];
foreach (['.mokostandards', '.github/.mokostandards'] as $legacy) {
$legacyPath = "{$workDir}/{$legacy}";
if (file_exists($legacyPath)) {
unlink($legacyPath);
$legacyDeleted[] = $legacy;
}
}
// Commit
$isNew = !$existingIsXml;
$commitMsg = $isNew
? 'chore: add XML manifest.xml'
: 'chore: update .mokostandards to XML format';
if (!empty($legacyDeleted)) {
$commitMsg .= "\n\nRemoved legacy: " . implode(', ', $legacyDeleted);
}
$this->gitCmd($workDir, 'config', 'user.name', 'gitea-actions[bot]');
$this->gitCmd($workDir, 'config', 'user.email', 'gitea-actions[bot]@git.mokoconsulting.tech');
$this->gitCmd($workDir, 'add', '.mokogitea/manifest.xml');
foreach ($legacyDeleted as $lf) {
$this->gitCmd($workDir, 'add', $lf);
}
[$commitRet, $commitOut] = $this->gitCmd($workDir, 'commit', '-m', $commitMsg);
if ($commitRet !== 0 && str_contains($commitOut, 'nothing to commit')) {
echo "SKIP (no changes)\n";
$stats['skipped']++;
$this->rmTree($workDir);
continue;
}
if ($commitRet !== 0) {
echo "FAIL (commit)\n";
fprintf(STDERR, " %s\n", $commitOut);
$stats['failed']++;
$this->rmTree($workDir);
continue;
}
[$pushRet, $pushOut] = $this->gitCmd($workDir, 'push', 'origin', $defaultBranch);
if ($pushRet !== 0) {
echo "FAIL (push)\n";
fprintf(STDERR, " %s\n", $pushOut);
$stats['failed']++;
} else {
$action = $isNew ? 'CREATED' : 'UPDATED';
echo "{$action}\n";
$stats[$isNew ? 'created' : 'updated']++;
}
// Cleanup
$this->rmTree($workDir);
}
// Cleanup tmp base
@rmdir($tmpBase);
echo "\n=== Summary ===\n";
echo "Created: {$stats['created']}\n";
echo "Updated: {$stats['updated']}\n";
echo "Skipped: {$stats['skipped']}\n";
echo "Failed: {$stats['failed']}\n";
return 0;
}
private function detectPlatform(array $repo): string
{
$name = $repo['name'] ?? '';
$nameLower = strtolower($name);
$description = strtolower($repo['description'] ?? '');
$topics = $repo['topics'] ?? [];
if (in_array($name, self::CRM_PLATFORM_REPOS, true)) {
return 'crm-platform';
}
if (in_array('dolibarr-platform', $topics)) {
return 'crm-platform';
}
if (in_array('joomla-template', $topics)) {
return 'joomla-template';
}
if (in_array('joomla', $topics) || in_array('joomla-extension', $topics)) {
return 'waas-component';
}
if (in_array('dolibarr', $topics) || in_array('dolibarr-module', $topics)) {
return 'crm-module';
}
if (str_contains($nameLower, 'template') && (str_contains($nameLower, 'joomla') || str_contains($nameLower, 'tpl'))) {
return 'joomla-template';
}
if (str_contains($nameLower, 'joomla') || str_contains($nameLower, 'waas')) {
return 'waas-component';
}
if (str_contains($nameLower, 'doli') || str_contains($nameLower, 'crm')) {
return 'crm-module';
}
if (str_contains($description, 'joomla template')) {
return 'joomla-template';
}
if (str_contains($description, 'joomla') || str_contains($description, 'component')) {
return 'waas-component';
}
if (str_contains($description, 'dolibarr') || str_contains($description, 'module')) {
return 'crm-module';
}
if (str_contains($nameLower, 'standard')) {
return 'standards-repository';
}
return 'default-repository';
}
/**
* @return array{int, string}
*/
private function safeExec(string $command, string $cwd = '.'): array
{
$proc = proc_open(
$command,
[1 => ['pipe', 'w'], 2 => ['pipe', 'w']],
$pipes,
$cwd
);
if (!is_resource($proc)) {
return [1, "proc_open failed for: {$command}"];
}
$stdout = stream_get_contents($pipes[1]);
$stderr = stream_get_contents($pipes[2]);
fclose($pipes[1]);
fclose($pipes[2]);
$code = proc_close($proc);
return [$code, trim($stdout . "\n" . $stderr)];
}
private function rmTree(string $dir): void
{
if (!is_dir($dir)) {
return;
}
$it = new \RecursiveDirectoryIterator($dir, \FilesystemIterator::SKIP_DOTS);
$files = new \RecursiveIteratorIterator($it, \RecursiveIteratorIterator::CHILD_FIRST);
foreach ($files as $file) {
if ($file->isDir()) {
@rmdir($file->getPathname());
} else {
@chmod($file->getPathname(), 0777);
@unlink($file->getPathname());
}
}
@rmdir($dir);
}
/**
* @return array{int, string}
*/
private function gitCmd(string $workDir, string ...$args): array
{
$cmd = 'git';
foreach ($args as $a) {
$cmd .= ' ' . escapeshellarg($a);
}
return $this->safeExec($cmd, $workDir);
}
private function fetchRepos(string $url, string $org, string $token): array
{
$repos = [];
$page = 1;
do {
$ch = curl_init("{$url}/api/v1/orgs/{$org}/repos?page={$page}&limit=50");
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_HTTPHEADER => ["Authorization: token {$token}"],
CURLOPT_TIMEOUT => 30,
]);
$body = curl_exec($ch);
$code = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
if ($code !== 200) {
$this->log('ERROR', "API error (HTTP {$code}) fetching repos page {$page}");
break;
}
$batch = json_decode($body, true);
if (empty($batch)) {
break;
}
$repos = array_merge($repos, $batch);
$page++;
} while (count($batch) >= 50);
return $repos;
}
}
$app = new PushMokostandardsXmlCli();
exit($app->execute());
+517
View File
@@ -0,0 +1,517 @@
#!/usr/bin/env php
<?php
/**
* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
*
* This file is part of a Moko Consulting project.
*
* SPDX-License-Identifier: GPL-3.0-or-later
*
* FILE INFORMATION
* DEFGROUP: MokoPlatform.Automation
* INGROUP: MokoPlatform.Scripts
* REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform
* PATH: /automation/repo_cleanup.php
* BRIEF: Enterprise repository cleanup — branches, PRs, issues, workflows, labels, logs
*/
declare(strict_types=1);
require_once __DIR__ . '/../vendor/autoload.php';
require_once __DIR__ . '/../lib/Enterprise/CliFramework.php';
use MokoEnterprise\{ApiClient, AuditLogger, CliFramework, Config, GitPlatformAdapter, MetricsCollector, PlatformAdapterFactory};
/**
* Enterprise Repository Cleanup
*
* Comprehensive maintenance tool for governed repositories:
* 1. Delete stale sync branches (keeps current versioned branch)
* 2. Close superseded PRs on deleted branches
* 3. Close/lock resolved tracking issues where linked PR is merged
* 4. Delete retired workflow files from repos
* 5. Clean cancelled/stale workflow runs
* 6. Delete workflow run logs older than N days
* 7. Verify and provision standard labels
* 8. Version drift detection
*/
class RepoCleanup extends CliFramework
{
private const VERSION = '09.23.00';
private const SYNC_PREFIX = 'chore/sync-moko-platform-';
private const CURRENT_BRANCH = 'chore/sync-moko-platform-v04.02.00';
/** Workflow files that have been retired and should be deleted from governed repos. */
private const RETIRED_WORKFLOWS = [
'build.yml', 'code-quality.yml', 'release-cycle.yml', 'release-pipeline.yml',
'branch-cleanup.yml', 'auto-update-changelog.yml', 'enterprise-issue-manager.yml',
'flush-actions-cache.yml', 'moko-platform-script-runner.yml', 'unified-ci.yml',
'unified-platform-testing.yml', 'reusable-build.yml', 'reusable-ci-validation.yml',
'reusable-deploy.yml', 'reusable-php-quality.yml', 'reusable-platform-testing.yml',
'reusable-project-detector.yml', 'reusable-release.yml', 'reusable-script-executor.yml',
'rebuild-docs-indexes.yml', 'setup-project-v2.yml', 'sync-docs-to-project.yml',
'release.yml', 'sync-changelogs.yml', 'version_branch.yml',
'publish-to-mokodolibarr.yml', 'ci.yml',
'deploy-rs.yml',
];
private ApiClient $api;
private GitPlatformAdapter $adapter;
protected bool $dryRun = false;
private float $startTime;
protected function configure(): void
{
$this->setDescription('Enterprise repository cleanup');
$this->addArgument('--org', 'GitHub organization', 'MokoConsulting');
$this->addArgument('--repos', 'Specific repos (space-separated)', '');
$this->addArgument('--skip-archived', 'Skip archived repos', false);
$this->addArgument('--close-issues', 'Close resolved tracking issues', false);
$this->addArgument('--lock-old-issues', 'Lock issues closed >30 days', false);
$this->addArgument('--clean-workflows', 'Delete stale workflow runs', false);
$this->addArgument('--clean-logs', 'Delete old workflow logs', false);
$this->addArgument('--log-days', 'Days to keep logs', '30');
$this->addArgument('--delete-retired', 'Delete retired workflows', false);
$this->addArgument('--check-labels', 'Verify labels exist', false);
$this->addArgument('--check-drift', 'Check version drift', false);
$this->addArgument('--all', 'Run all operations', false);
$this->addArgument('--yes', 'Auto-confirm', false);
$this->addArgument('--json', 'Output as JSON', false);
}
protected function run(): int
{
$this->startTime = microtime(true);
$org = $this->getArgument('--org', 'MokoConsulting');
$this->dryRun = (bool) $this->getArgument('--dry-run', false);
$runAll = (bool) $this->getArgument('--all', false);
$config = Config::load();
try {
$this->adapter = PlatformAdapterFactory::create($config);
$this->api = $this->adapter->getApiClient();
} catch (\Exception $e) {
$this->errorMsg('Failed to initialize platform adapter: ' . $e->getMessage());
return 1;
}
$this->logMsg("🧹 moko-platform Repository Cleanup v" . self::VERSION);
$this->logMsg("Organization: {$org}");
$this->logMsg("Current sync branch: " . self::CURRENT_BRANCH);
if ($this->dryRun) {
$this->logMsg("⚠️ DRY RUN — no changes will be made");
}
$this->logMsg('');
$repos = $this->fetchRepositories($org);
$this->logMsg("Found " . count($repos) . " repositories");
$this->logMsg('');
$results = [
'repos_processed' => 0,
'repos_cleaned' => 0,
'branches_deleted' => 0,
'prs_closed' => 0,
'issues_closed' => 0,
'issues_locked' => 0,
'workflows_deleted' => 0,
'runs_deleted' => 0,
'logs_deleted' => 0,
'labels_missing' => 0,
'version_drift' => 0,
'retired_files' => 0,
'errors' => 0,
];
foreach ($repos as $i => $repo) {
$name = $repo['name'];
$num = $i + 1;
$total = count($repos);
$this->logMsg("[{$num}/{$total}] {$name}");
$results['repos_processed']++;
try {
$this->api->resetCircuitBreaker();
$cleaned = false;
// Always: delete old sync branches + close their PRs
$cleaned = $this->cleanBranches($org, $name, $results) || $cleaned;
// Optional: close resolved issues
if ($runAll || $this->getArgument('--close-issues', false)) {
$cleaned = $this->closeResolvedIssues($org, $name, $results) || $cleaned;
}
// Optional: lock old closed issues
if ($runAll || $this->getArgument('--lock-old-issues', false)) {
$cleaned = $this->lockOldIssues($org, $name, $results) || $cleaned;
}
// Optional: delete retired workflow files
if ($runAll || $this->getArgument('--delete-retired', false)) {
$cleaned = $this->deleteRetiredWorkflows($org, $name, $results) || $cleaned;
}
// Optional: clean workflow runs
if ($runAll || $this->getArgument('--clean-workflows', false)) {
$cleaned = $this->cleanWorkflowRuns($org, $name, $results) || $cleaned;
}
// Optional: clean old logs
if ($runAll || $this->getArgument('--clean-logs', false)) {
$cleaned = $this->cleanOldLogs($org, $name, $results) || $cleaned;
}
// Optional: check labels
if ($runAll || $this->getArgument('--check-labels', false)) {
$this->checkLabels($org, $name, $results);
}
// Optional: check version drift
if ($runAll || $this->getArgument('--check-drift', false)) {
$this->checkVersionDrift($org, $name, $results);
}
if ($cleaned) {
$results['repos_cleaned']++;
}
} catch (\Exception $e) {
$this->errorMsg("{$name}: " . $e->getMessage());
$results['errors']++;
}
}
$duration = round(microtime(true) - $this->startTime, 1);
$this->logMsg('');
$this->logMsg('============================================================');
$this->logMsg("🧹 Cleanup Complete ({$duration}s)");
$this->logMsg('============================================================');
$this->logMsg("Repos processed: {$results['repos_processed']}");
$this->logMsg("Repos with changes: {$results['repos_cleaned']}");
$this->logMsg("Branches deleted: {$results['branches_deleted']}");
$this->logMsg("PRs closed: {$results['prs_closed']}");
$this->logMsg("Issues closed: {$results['issues_closed']}");
$this->logMsg("Issues locked: {$results['issues_locked']}");
$this->logMsg("Retired files: {$results['retired_files']}");
$this->logMsg("Workflow runs: {$results['runs_deleted']}");
$this->logMsg("Logs cleaned: {$results['logs_deleted']}");
$this->logMsg("Labels missing: {$results['labels_missing']}");
$this->logMsg("Version drift: {$results['version_drift']}");
$this->logMsg("Errors: {$results['errors']}");
$this->logMsg('============================================================');
if ($this->getArgument('--json', false)) {
$results['duration_seconds'] = $duration;
echo json_encode($results, JSON_PRETTY_PRINT) . "\n";
}
return $results['errors'] > 0 ? 1 : 0;
}
// ─── Repository fetching ─────────────────────────────────────────────
private function fetchRepositories(string $org): array
{
$specificRepos = trim((string) $this->getArgument('--repos', ''));
$skipArchived = (bool) $this->getArgument('--skip-archived', false);
if (!empty($specificRepos)) {
$names = preg_split('/[\s,]+/', $specificRepos);
return array_map(fn($n) => ['name' => trim($n), 'archived' => false], $names);
}
$allRepos = $this->adapter->listOrgRepos($org, $skipArchived);
return array_filter($allRepos, fn($r) => !in_array($r['name'], ['moko-platform', '.github-private'], true));
}
// ─── Cleanup operations ──────────────────────────────────────────────
private function cleanBranches(string $org, string $repo, array &$results): bool
{
$changed = false;
try {
$branches = $this->api->get("/repos/{$org}/{$repo}/branches", ['per_page' => 100]);
} catch (\Exception $e) {
return false;
}
foreach ($branches as $branch) {
$name = $branch['name'] ?? '';
if (!str_starts_with($name, self::SYNC_PREFIX) || $name === self::CURRENT_BRANCH) {
continue;
}
// Close open PRs on this branch
try {
$prs = $this->api->get("/repos/{$org}/{$repo}/pulls", [
'state' => 'open', 'head' => "{$org}:{$name}", 'per_page' => 10,
]);
foreach ($prs as $pr) {
if (($pr['number'] ?? 0) > 0 && !$this->dryRun) {
$this->api->patch("/repos/{$org}/{$repo}/pulls/{$pr['number']}", ['state' => 'closed']);
}
$this->logMsg(" 🔒 Closed PR #{$pr['number']} ({$name})");
$results['prs_closed']++;
$changed = true;
}
} catch (\Exception $e) {
/* non-fatal */
}
if (!$this->dryRun) {
try {
$this->api->delete("/repos/{$org}/{$repo}/git/refs/heads/{$name}");
} catch (\Exception $e) {
continue;
}
}
$this->logMsg(" 🗑️ Deleted branch: {$name}");
$results['branches_deleted']++;
$changed = true;
}
return $changed;
}
private function closeResolvedIssues(string $org, string $repo, array &$results): bool
{
$changed = false;
foreach (['standards-update', 'standards-drift'] as $label) {
try {
$issues = $this->api->get("/repos/{$org}/{$repo}/issues", [
'labels' => $label, 'state' => 'open', 'per_page' => 10,
]);
} catch (\Exception $e) {
continue;
}
foreach ($issues as $issue) {
$num = $issue['number'] ?? 0;
$body = $issue['body'] ?? '';
if (preg_match('/\[#(\d+)\]/', $body, $m)) {
$prNum = (int) $m[1];
try {
$pr = $this->api->get("/repos/{$org}/{$repo}/pulls/{$prNum}");
if (!empty($pr['merged_at'])) {
if (!$this->dryRun) {
$this->api->patch("/repos/{$org}/{$repo}/issues/{$num}", [
'state' => 'closed', 'state_reason' => 'completed',
]);
}
$this->logMsg(" ✅ Closed issue #{$num} (PR #{$prNum} merged)");
$results['issues_closed']++;
$changed = true;
}
} catch (\Exception $e) {
/* non-fatal */
}
}
}
}
return $changed;
}
private function lockOldIssues(string $org, string $repo, array &$results): bool
{
$changed = false;
$cutoff = date('Y-m-d\TH:i:s\Z', strtotime('-30 days'));
try {
$issues = $this->api->get("/repos/{$org}/{$repo}/issues", [
'state' => 'closed', 'per_page' => 50, 'sort' => 'updated', 'direction' => 'asc',
]);
} catch (\Exception $e) {
return false;
}
foreach ($issues as $issue) {
$closedAt = $issue['closed_at'] ?? '';
$locked = $issue['locked'] ?? false;
$num = $issue['number'] ?? 0;
if ($locked || $closedAt > $cutoff || $num === 0) {
continue;
}
if (!$this->dryRun) {
try {
$this->api->put("/repos/{$org}/{$repo}/issues/{$num}/lock", [
'lock_reason' => 'resolved',
]);
} catch (\Exception $e) {
continue;
}
}
$results['issues_locked']++;
$changed = true;
}
if ($results['issues_locked'] > 0) {
$this->logMsg(" 🔒 Locked {$results['issues_locked']} old closed issue(s)");
}
return $changed;
}
private function deleteRetiredWorkflows(string $org, string $repo, array &$results): bool
{
$changed = false;
$defaultBranch = 'main';
try {
$repoInfo = $this->api->get("/repos/{$org}/{$repo}");
$defaultBranch = $repoInfo['default_branch'] ?? 'main';
} catch (\Exception $e) {
/* fallback to main */
}
// Check both workflow directories for retired workflows (supports dual-platform repos)
$wfDirs = array_unique(['.github/workflows', '.mokogitea/workflows', $this->adapter->getWorkflowDir()]);
foreach (self::RETIRED_WORKFLOWS as $wf) {
foreach ($wfDirs as $wfDir) {
$path = "{$wfDir}/{$wf}";
try {
$file = $this->api->get("/repos/{$org}/{$repo}/contents/{$path}");
$sha = $file['sha'] ?? '';
if (empty($sha)) {
continue;
}
if (!$this->dryRun) {
$this->api->delete("/repos/{$org}/{$repo}/contents/{$path}", [
'message' => "chore: delete retired workflow {$wf}",
'sha' => $sha,
'branch' => $defaultBranch,
]);
}
$this->logMsg(" Deleted retired: {$wf} (from {$wfDir})");
$results['retired_files']++;
$changed = true;
} catch (\Exception $e) {
// File doesn't exist in this dir — skip
$this->api->resetCircuitBreaker();
}
}
}
return $changed;
}
private function cleanWorkflowRuns(string $org, string $repo, array &$results): bool
{
$changed = false;
foreach (['cancelled', 'stale'] as $status) {
try {
$runs = $this->api->get("/repos/{$org}/{$repo}/actions/runs", [
'status' => $status, 'per_page' => 100,
]);
foreach (($runs['workflow_runs'] ?? []) as $run) {
$id = $run['id'] ?? 0;
if ($id > 0 && !$this->dryRun) {
try {
$this->api->delete("/repos/{$org}/{$repo}/actions/runs/{$id}");
$results['runs_deleted']++;
$changed = true;
} catch (\Exception $e) {
$this->api->resetCircuitBreaker();
}
}
}
} catch (\Exception $e) {
/* non-fatal */
}
}
if ($results['runs_deleted'] > 0) {
$this->logMsg(" 🔄 Cleaned {$results['runs_deleted']} workflow run(s)");
}
return $changed;
}
private function cleanOldLogs(string $org, string $repo, array &$results): bool
{
$changed = false;
$days = (int) $this->getArgument('--log-days', '30');
$cutoff = date('Y-m-d\TH:i:s\Z', strtotime("-{$days} days"));
try {
$runs = $this->api->get("/repos/{$org}/{$repo}/actions/runs", [
'created' => "<{$cutoff}", 'per_page' => 100,
]);
foreach (($runs['workflow_runs'] ?? []) as $run) {
$id = $run['id'] ?? 0;
if ($id > 0 && !$this->dryRun) {
try {
$this->api->delete("/repos/{$org}/{$repo}/actions/runs/{$id}/logs");
$results['logs_deleted']++;
$changed = true;
} catch (\Exception $e) {
$this->api->resetCircuitBreaker();
}
}
}
} catch (\Exception $e) {
/* non-fatal */
}
if ($results['logs_deleted'] > 0) {
$this->logMsg(" 📋 Cleaned {$results['logs_deleted']} old log(s)");
}
return $changed;
}
private function checkLabels(string $org, string $repo, array &$results): void
{
try {
$this->api->get("/repos/{$org}/{$repo}/labels/moko-platform");
} catch (\Exception $e) {
$this->logMsg(" ⚠️ Missing 'moko-platform' label");
$results['labels_missing']++;
$this->api->resetCircuitBreaker();
}
}
private function checkVersionDrift(string $org, string $repo, array &$results): void
{
try {
$file = $this->api->get("/repos/{$org}/{$repo}/contents/README.md");
$content = base64_decode($file['content'] ?? '');
if (preg_match('/^\s*VERSION:\s*(\d{2}\.\d{2}\.\d{2})/m', $content, $m)) {
$version = $m[1];
// Check manifest.xml for the tracked moko-platform version
try {
$mokoFile = $this->api->get("/repos/{$org}/{$repo}/contents/.mokogitea/manifest.xml");
$mokoContent = base64_decode($mokoFile['content'] ?? '');
if (preg_match('/standards_version:\s*(\d{2}\.\d{2}\.\d{2})/m', $mokoContent, $vm)) {
if ($vm[1] !== self::VERSION) {
$this->logMsg(" ⚠️ Standards drift: {$vm[1]} (expected " . self::VERSION . ")");
$results['version_drift']++;
}
}
} catch (\Exception $e) {
$this->api->resetCircuitBreaker();
}
}
} catch (\Exception $e) {
$this->api->resetCircuitBreaker();
}
}
// ─── Helpers ─────────────────────────────────────────────────────────
private function logMsg(string $message): void
{
if (!$this->quiet) {
echo $message . "\n";
}
}
private function errorMsg(string $message): void
{
fwrite(STDERR, $message . "\n");
}
}
$app = new RepoCleanup();
exit($app->execute());
+678
View File
@@ -0,0 +1,678 @@
#!/usr/bin/env bash
# server-autoheal.sh - Auto-heal on restart + split backup management
#
# Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
# SPDX-License-Identifier: GPL-3.0-or-later
#
# DEFGROUP: MokoPlatform.Automation.ServerAutoheal
# INGROUP: MokoPlatform.Automation
# REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform
# PATH: /automation/server-autoheal.sh
# BRIEF: Server auto-heal on unclean restart + split system/content backups
#
# Usage:
# server-autoheal.sh <command> [options]
#
# Commands:
# boot-check Run at boot — auto-heals if no safe point exists
# set-safepoint Mark current state as safe (call before planned shutdown)
# backup-system Run a system backup (configs, packages, services)
# backup-content Run a content backup (site files, databases, uploads)
# cleanup Prune expired backups per retention policy
# status Show safe point and backup status
#
# Scheduling (cron):
# @reboot server-autoheal.sh boot-check
# 0 3 * * * server-autoheal.sh backup-system (daily at 3am)
# 0 */2 * * * server-autoheal.sh backup-content (every 2 hours)
# 30 */2 * * * server-autoheal.sh cleanup (30 min after content backup)
set -euo pipefail
# ──────────────────────────────────────────────
# Configuration — override via /etc/moko/autoheal.conf
# ──────────────────────────────────────────────
CONF_FILE="/etc/moko/autoheal.conf"
[[ -f "$CONF_FILE" ]] && source "$CONF_FILE"
BACKUP_ROOT="${BACKUP_ROOT:-/var/backups/moko}"
SAFEPOINT_FILE="${SAFEPOINT_FILE:-/var/run/moko/safepoint}"
LOG_FILE="${LOG_FILE:-/var/log/moko/autoheal.log}"
LOCK_DIR="${LOCK_DIR:-/var/run/moko}"
# System backup: configs, package lists, service state, cron
SYSTEM_BACKUP_DIR="${BACKUP_ROOT}/system"
SYSTEM_BACKUP_RETAIN="${SYSTEM_BACKUP_RETAIN:-7}" # keep 7 daily system backups
# Content backup: web roots, databases, uploads
CONTENT_BACKUP_DIR="${BACKUP_ROOT}/content"
CONTENT_BACKUP_RETAIN_HOURS="${CONTENT_BACKUP_RETAIN_HOURS:-24}" # 1 day of content backups
# Paths to back up — override these in /etc/moko/autoheal.conf
SYSTEM_PATHS="${SYSTEM_PATHS:-/etc/nginx /etc/php /etc/mysql /etc/cron.d /etc/systemd/system}"
CONTENT_PATHS="${CONTENT_PATHS:-/var/www}"
DB_NAMES="${DB_NAMES:-}" # space-separated list, empty = auto-detect all
# ──────────────────────────────────────────────
# Helpers
# ──────────────────────────────────────────────
log() {
local level="$1"; shift
local ts
ts=$(date -u '+%Y-%m-%dT%H:%M:%SZ')
local msg="[$ts] [$level] $*"
echo "$msg" | tee -a "$LOG_FILE" >&2
}
ensure_dirs() {
mkdir -p "$SYSTEM_BACKUP_DIR" "$CONTENT_BACKUP_DIR" \
"$LOCK_DIR" "$(dirname "$LOG_FILE")"
}
acquire_lock() {
local lockfile="${LOCK_DIR}/autoheal-${1}.lock"
if [[ -f "$lockfile" ]]; then
local pid
pid=$(<"$lockfile")
if kill -0 "$pid" 2>/dev/null; then
log WARN "Another $1 operation is running (PID $pid), skipping"
exit 0
fi
rm -f "$lockfile"
fi
echo $$ > "$lockfile"
trap "rm -f '$lockfile'" EXIT
}
timestamp() {
date -u '+%Y%m%d_%H%M%S'
}
# ──────────────────────────────────────────────
# Safe-point management
# ──────────────────────────────────────────────
cmd_set_safepoint() {
ensure_dirs
local ts
ts=$(timestamp)
cat > "$SAFEPOINT_FILE" <<EOF
timestamp=$ts
hostname=$(hostname)
kernel=$(uname -r)
uptime=$(uptime -s 2>/dev/null || echo "unknown")
set_by=${SUDO_USER:-$(whoami)}
EOF
log INFO "Safe point set at $ts by ${SUDO_USER:-$(whoami)}"
}
cmd_clear_safepoint() {
rm -f "$SAFEPOINT_FILE"
log INFO "Safe point cleared"
}
has_safepoint() {
[[ -f "$SAFEPOINT_FILE" ]]
}
# ──────────────────────────────────────────────
# System backup (daily)
# ──────────────────────────────────────────────
cmd_backup_system() {
ensure_dirs
acquire_lock "system-backup"
local ts
ts=$(timestamp)
local archive="${SYSTEM_BACKUP_DIR}/system_${ts}.tar.gz"
local manifest="${SYSTEM_BACKUP_DIR}/system_${ts}.manifest"
log INFO "Starting system backup → $archive"
# Collect existing paths only
local existing_paths=()
for p in $SYSTEM_PATHS; do
[[ -e "$p" ]] && existing_paths+=("$p")
done
if [[ ${#existing_paths[@]} -eq 0 ]]; then
log WARN "No system paths found to back up"
return 1
fi
# Archive configs and system files
tar -czf "$archive" "${existing_paths[@]}" 2>/dev/null || true
# Capture package list and service state as manifest
{
echo "=== PACKAGES ==="
if command -v dpkg &>/dev/null; then
dpkg --get-selections
elif command -v rpm &>/dev/null; then
rpm -qa --qf '%{NAME}\t%{VERSION}\n'
fi
echo ""
echo "=== ENABLED SERVICES ==="
if command -v systemctl &>/dev/null; then
systemctl list-unit-files --state=enabled --no-pager 2>/dev/null || true
fi
echo ""
echo "=== CRONTABS ==="
for user_home in /var/spool/cron/crontabs/*; do
[[ -f "$user_home" ]] && echo "--- $(basename "$user_home") ---" && cat "$user_home"
done 2>/dev/null || true
} > "$manifest"
local size
size=$(du -sh "$archive" 2>/dev/null | cut -f1)
log INFO "System backup complete: $archive ($size)"
# Prune old system backups (keep $SYSTEM_BACKUP_RETAIN)
local count
count=$(find "$SYSTEM_BACKUP_DIR" -name 'system_*.tar.gz' | wc -l)
if [[ "$count" -gt "$SYSTEM_BACKUP_RETAIN" ]]; then
local to_remove=$((count - SYSTEM_BACKUP_RETAIN))
find "$SYSTEM_BACKUP_DIR" -name 'system_*.tar.gz' -printf '%T+ %p\n' \
| sort | head -n "$to_remove" | awk '{print $2}' \
| while read -r f; do
rm -f "$f" "${f%.tar.gz}.manifest"
log INFO "Pruned old system backup: $f"
done
fi
}
# ──────────────────────────────────────────────
# Content backup (every 2 hours)
# ──────────────────────────────────────────────
cmd_backup_content() {
ensure_dirs
acquire_lock "content-backup"
local ts
ts=$(timestamp)
local archive="${CONTENT_BACKUP_DIR}/content_${ts}.tar.gz"
local db_dump="${CONTENT_BACKUP_DIR}/content_${ts}.sql.gz"
log INFO "Starting content backup → $archive"
# Back up web content / uploads
local existing_paths=()
for p in $CONTENT_PATHS; do
[[ -e "$p" ]] && existing_paths+=("$p")
done
if [[ ${#existing_paths[@]} -gt 0 ]]; then
tar -czf "$archive" "${existing_paths[@]}" 2>/dev/null || true
local size
size=$(du -sh "$archive" 2>/dev/null | cut -f1)
log INFO "Content files archived: $archive ($size)"
else
log WARN "No content paths found to back up"
fi
# Database dump
if command -v mysqldump &>/dev/null || command -v mariadb-dump &>/dev/null; then
local dump_cmd="mysqldump"
command -v mariadb-dump &>/dev/null && dump_cmd="mariadb-dump"
local databases=()
if [[ -n "$DB_NAMES" ]]; then
read -ra databases <<< "$DB_NAMES"
else
# Auto-detect: dump all databases except system ones
databases=($(${dump_cmd%dump} -N -e \
"SELECT schema_name FROM information_schema.schemata
WHERE schema_name NOT IN ('information_schema','performance_schema','mysql','sys')" \
2>/dev/null | tr '\n' ' ')) || true
fi
if [[ ${#databases[@]} -gt 0 ]]; then
$dump_cmd --single-transaction --routines --triggers \
--databases "${databases[@]}" 2>/dev/null \
| gzip > "$db_dump"
local db_size
db_size=$(du -sh "$db_dump" 2>/dev/null | cut -f1)
log INFO "Database dump complete: $db_dump ($db_size)"
else
log WARN "No databases found to dump"
fi
fi
}
# ──────────────────────────────────────────────
# Cleanup — prune content backups older than retention
# ──────────────────────────────────────────────
cmd_cleanup() {
ensure_dirs
local before_count after_count
# Content: keep only last 24 hours (1 day)
before_count=$(find "$CONTENT_BACKUP_DIR" -name 'content_*' -type f | wc -l)
find "$CONTENT_BACKUP_DIR" -name 'content_*' -type f \
-mmin +$((CONTENT_BACKUP_RETAIN_HOURS * 60)) -delete 2>/dev/null || true
after_count=$(find "$CONTENT_BACKUP_DIR" -name 'content_*' -type f | wc -l)
local removed=$((before_count - after_count))
[[ "$removed" -gt 0 ]] && log INFO "Pruned $removed content backup(s) older than ${CONTENT_BACKUP_RETAIN_HOURS}h"
# System: keep N most recent (handled in backup-system, but double-check here)
before_count=$(find "$SYSTEM_BACKUP_DIR" -name 'system_*' -type f | wc -l)
local max_system_files=$((SYSTEM_BACKUP_RETAIN * 2)) # .tar.gz + .manifest
if [[ "$before_count" -gt "$max_system_files" ]]; then
local excess=$((before_count - max_system_files))
find "$SYSTEM_BACKUP_DIR" -name 'system_*' -type f -printf '%T+ %p\n' \
| sort | head -n "$excess" | awk '{print $2}' \
| xargs -r rm -f
log INFO "Pruned excess system backups"
fi
log INFO "Cleanup complete"
}
# ──────────────────────────────────────────────
# Boot check — the auto-heal entry point
# ──────────────────────────────────────────────
cmd_boot_check() {
ensure_dirs
acquire_lock "boot-check"
log INFO "=== Boot check started ==="
log INFO "Hostname: $(hostname), Kernel: $(uname -r)"
if has_safepoint; then
log INFO "Safe point found — server was shut down cleanly"
log INFO "Clearing safe point for next cycle"
cmd_clear_safepoint
log INFO "=== Boot check passed (clean restart) ==="
return 0
fi
log WARN "NO safe point found — server restarted without clean shutdown"
log WARN "Initiating auto-heal sequence..."
auto_heal
local rc=$?
# Set safe point after successful heal
if [[ $rc -eq 0 ]]; then
cmd_set_safepoint
log INFO "=== Boot check complete (healed successfully) ==="
else
log ERROR "=== Boot check FAILED — manual intervention required ==="
fi
return $rc
}
# ──────────────────────────────────────────────
# Auto-heal strategy
#
# TODO: This is the core decision point. Implement the recovery
# steps that match your server's architecture. See guidance below.
#
# Trade-offs to consider:
# - Restore-from-backup: safest, but content may be up to 2h stale
# - Service-restart-only: faster, keeps current data, but won't fix
# corrupted configs or broken filesystem state
# - Hybrid: restart services first, verify health, only restore if
# health checks fail — best of both worlds but more complex
#
# The function receives no arguments. Use the latest system + content
# backups to restore if needed. Return 0 on success, 1 on failure.
# ──────────────────────────────────────────────
auto_heal() {
log INFO "Phase 1: Verify and repair filesystem"
# Check for common post-crash issues
repair_filesystem
log INFO "Phase 2: Restore system configuration if corrupted"
restore_system_if_needed
log INFO "Phase 3: Restart core services"
restart_services
log INFO "Phase 4: Verify health"
if ! verify_health; then
log WARN "Health check failed after service restart — restoring from backup"
restore_from_backup
restart_services
if ! verify_health; then
log ERROR "Health check still failing after restore — giving up"
return 1
fi
fi
log INFO "Auto-heal completed successfully"
return 0
}
# ──────────────────────────────────────────────
# Heal sub-steps
# ──────────────────────────────────────────────
repair_filesystem() {
# Fix common post-crash filesystem issues
# Clear stale PID/lock/socket files that prevent services from starting
local stale_files=(
/var/run/nginx.pid
/var/run/mysqld/mysqld.pid
/var/run/php-fpm.pid
/var/lib/mysql/*.pid
)
for f in "${stale_files[@]}"; do
for expanded in $f; do
if [[ -f "$expanded" ]]; then
local pid
pid=$(<"$expanded") 2>/dev/null || true
if [[ -n "$pid" ]] && ! kill -0 "$pid" 2>/dev/null; then
rm -f "$expanded"
log INFO "Removed stale PID file: $expanded"
fi
fi
done
done
# Fix permissions on critical dirs that may get mangled
[[ -d /var/run/mysqld ]] && chown mysql:mysql /var/run/mysqld 2>/dev/null || true
[[ -d /var/lib/php/sessions ]] && chmod 1733 /var/lib/php/sessions 2>/dev/null || true
# Repair tmp/cache dirs
for d in /tmp /var/tmp; do
[[ -d "$d" ]] && chmod 1777 "$d" 2>/dev/null || true
done
}
restore_system_if_needed() {
# Find latest system backup
local latest_system
latest_system=$(find "$SYSTEM_BACKUP_DIR" -name 'system_*.tar.gz' -printf '%T+ %p\n' \
2>/dev/null | sort -r | head -1 | awk '{print $2}')
if [[ -z "$latest_system" ]]; then
log WARN "No system backup available to verify against"
return 0
fi
# Check if critical configs exist and are non-empty
local needs_restore=false
local critical_configs=("/etc/nginx/nginx.conf" "/etc/php" "/etc/mysql")
for cfg in "${critical_configs[@]}"; do
if [[ -e "$cfg" ]]; then
# Config exists — check if it's a file and non-empty, or a directory
if [[ -f "$cfg" && ! -s "$cfg" ]]; then
log WARN "Critical config is empty: $cfg"
needs_restore=true
break
fi
fi
done
if $needs_restore; then
log WARN "Restoring system config from $latest_system"
tar -xzf "$latest_system" -C / 2>/dev/null || {
log ERROR "System restore failed from $latest_system"
return 1
}
log INFO "System config restored"
else
log INFO "System configs look intact — skipping restore"
fi
}
restart_services() {
if ! command -v systemctl &>/dev/null; then
log WARN "systemctl not available — skipping service restart"
return 0
fi
local services=("mysql" "mariadb" "nginx" "apache2" "php-fpm" "php8.1-fpm" "php8.2-fpm" "php8.3-fpm")
for svc in "${services[@]}"; do
if systemctl is-enabled "$svc" &>/dev/null; then
log INFO "Restarting $svc..."
systemctl restart "$svc" 2>/dev/null && \
log INFO "$svc restarted OK" || \
log WARN "$svc restart failed"
fi
done
}
verify_health() {
local failures=0
# Check critical services are running
local services=("mysql" "mariadb" "nginx" "apache2")
for svc in "${services[@]}"; do
if systemctl is-enabled "$svc" &>/dev/null; then
if ! systemctl is-active "$svc" &>/dev/null; then
log WARN "Service not running: $svc"
((failures++))
fi
fi
done
# Check if web server responds
if command -v curl &>/dev/null; then
if ! curl -sf -o /dev/null --max-time 10 "http://localhost/" 2>/dev/null; then
log WARN "Local web server not responding"
((failures++))
fi
fi
# Check if database accepts connections
if command -v mysqladmin &>/dev/null; then
if ! mysqladmin ping --silent 2>/dev/null; then
log WARN "Database not responding to ping"
((failures++))
fi
fi
[[ $failures -eq 0 ]]
}
restore_from_backup() {
log WARN "=== Full restore from backup ==="
# Restore system config
local latest_system
latest_system=$(find "$SYSTEM_BACKUP_DIR" -name 'system_*.tar.gz' -printf '%T+ %p\n' \
2>/dev/null | sort -r | head -1 | awk '{print $2}')
if [[ -n "$latest_system" ]]; then
log INFO "Restoring system from $latest_system"
tar -xzf "$latest_system" -C / 2>/dev/null || \
log ERROR "System restore failed"
fi
# Restore content
local latest_content
latest_content=$(find "$CONTENT_BACKUP_DIR" -name 'content_*.tar.gz' -printf '%T+ %p\n' \
2>/dev/null | sort -r | head -1 | awk '{print $2}')
if [[ -n "$latest_content" ]]; then
log INFO "Restoring content from $latest_content"
tar -xzf "$latest_content" -C / 2>/dev/null || \
log ERROR "Content restore failed"
fi
# Restore database
local latest_db
latest_db=$(find "$CONTENT_BACKUP_DIR" -name 'content_*.sql.gz' -printf '%T+ %p\n' \
2>/dev/null | sort -r | head -1 | awk '{print $2}')
if [[ -n "$latest_db" ]]; then
log INFO "Restoring database from $latest_db"
local mysql_cmd="mysql"
command -v mariadb &>/dev/null && mysql_cmd="mariadb"
zcat "$latest_db" | $mysql_cmd 2>/dev/null || \
log ERROR "Database restore failed"
fi
}
# ──────────────────────────────────────────────
# Status
# ──────────────────────────────────────────────
cmd_status() {
echo "=== Moko Server Auto-Heal Status ==="
echo ""
# Safe point
if has_safepoint; then
echo "Safe point: SET"
cat "$SAFEPOINT_FILE" | sed 's/^/ /'
else
echo "Safe point: NOT SET (will auto-heal on next boot)"
fi
echo ""
# System backups
echo "System backups (${SYSTEM_BACKUP_DIR}):"
local sys_count
sys_count=$(find "$SYSTEM_BACKUP_DIR" -name 'system_*.tar.gz' 2>/dev/null | wc -l)
echo " Count: $sys_count (retain $SYSTEM_BACKUP_RETAIN)"
local latest_sys
latest_sys=$(find "$SYSTEM_BACKUP_DIR" -name 'system_*.tar.gz' -printf '%T+ %p\n' \
2>/dev/null | sort -r | head -1)
if [[ -n "$latest_sys" ]]; then
echo " Latest: $(echo "$latest_sys" | awk '{print $2}')"
echo " Timestamp: $(echo "$latest_sys" | awk '{print $1}')"
else
echo " Latest: (none)"
fi
echo ""
# Content backups
echo "Content backups (${CONTENT_BACKUP_DIR}):"
local cnt_count
cnt_count=$(find "$CONTENT_BACKUP_DIR" -name 'content_*.tar.gz' 2>/dev/null | wc -l)
echo " Count: $cnt_count (retain ${CONTENT_BACKUP_RETAIN_HOURS}h)"
local latest_cnt
latest_cnt=$(find "$CONTENT_BACKUP_DIR" -name 'content_*.tar.gz' -printf '%T+ %p\n' \
2>/dev/null | sort -r | head -1)
if [[ -n "$latest_cnt" ]]; then
echo " Latest: $(echo "$latest_cnt" | awk '{print $2}')"
echo " Timestamp: $(echo "$latest_cnt" | awk '{print $1}')"
else
echo " Latest: (none)"
fi
echo ""
# Disk usage
echo "Backup disk usage:"
du -sh "$SYSTEM_BACKUP_DIR" "$CONTENT_BACKUP_DIR" 2>/dev/null | sed 's/^/ /'
}
# ──────────────────────────────────────────────
# Install helper — sets up cron + systemd
# ──────────────────────────────────────────────
cmd_install() {
local script_path
script_path=$(readlink -f "$0")
echo "Installing Moko Auto-Heal..."
# Create config directory
mkdir -p /etc/moko "$(dirname "$LOG_FILE")" "$LOCK_DIR"
# Write example config if none exists
if [[ ! -f "$CONF_FILE" ]]; then
cat > "$CONF_FILE" <<'CONF'
# /etc/moko/autoheal.conf — Server auto-heal configuration
# Uncomment and modify as needed
# BACKUP_ROOT="/var/backups/moko"
# SAFEPOINT_FILE="/var/run/moko/safepoint"
# LOG_FILE="/var/log/moko/autoheal.log"
# System backup paths (space-separated)
# SYSTEM_PATHS="/etc/nginx /etc/php /etc/mysql /etc/cron.d /etc/systemd/system"
# Content backup paths (space-separated)
# CONTENT_PATHS="/var/www"
# Database names (space-separated, empty = auto-detect all)
# DB_NAMES=""
# Retention
# SYSTEM_BACKUP_RETAIN=7 # daily backups to keep
# CONTENT_BACKUP_RETAIN_HOURS=24 # hours of content backups to keep
CONF
echo " Created config: $CONF_FILE"
fi
# Install cron jobs
local cron_file="/etc/cron.d/moko-autoheal"
cat > "$cron_file" <<CRON
# Moko Server Auto-Heal — managed by server-autoheal.sh install
SHELL=/bin/bash
PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
# Boot check — auto-heal if no safe point
@reboot root ${script_path} boot-check
# System backup — daily at 3:00 AM
0 3 * * * root ${script_path} backup-system
# Content backup — every 2 hours
0 */2 * * * root ${script_path} backup-content
# Cleanup expired backups — 30 min after each content backup
30 */2 * * * root ${script_path} cleanup
CRON
echo " Installed cron: $cron_file"
# Install shutdown hook to set safe point on clean shutdown
local shutdown_hook="/etc/systemd/system/moko-safepoint.service"
cat > "$shutdown_hook" <<UNIT
[Unit]
Description=Moko Safe Point — mark clean shutdown
DefaultDependencies=no
Before=shutdown.target reboot.target halt.target
[Service]
Type=oneshot
RemainAfterExit=yes
ExecStart=/bin/true
ExecStop=${script_path} set-safepoint
[Install]
WantedBy=multi-user.target
UNIT
systemctl daemon-reload
systemctl enable moko-safepoint.service
echo " Installed systemd hook: $shutdown_hook"
echo ""
echo "Done! Edit $CONF_FILE to configure paths for your server."
echo "Run '${script_path} status' to verify."
}
# ──────────────────────────────────────────────
# Main dispatcher
# ──────────────────────────────────────────────
main() {
local cmd="${1:-help}"
case "$cmd" in
boot-check) cmd_boot_check ;;
set-safepoint) cmd_set_safepoint ;;
clear-safepoint) cmd_clear_safepoint ;;
backup-system) cmd_backup_system ;;
backup-content) cmd_backup_content ;;
cleanup) cmd_cleanup ;;
status) cmd_status ;;
install) cmd_install ;;
help|--help|-h)
sed -n '2,/^$/s/^# //p' "$0"
echo ""
echo "Commands: boot-check, set-safepoint, clear-safepoint,"
echo " backup-system, backup-content, cleanup, status, install"
;;
*)
echo "Unknown command: $cmd" >&2
echo "Run '$0 help' for usage" >&2
exit 1
;;
esac
}
main "$@"
+7 -13
View File
@@ -9,11 +9,11 @@
* SPDX-License-Identifier: GPL-3.0-or-later * SPDX-License-Identifier: GPL-3.0-or-later
* *
* FILE INFORMATION * FILE INFORMATION
* DEFGROUP: MokoCli.CLI * DEFGROUP: MokoStandards.CLI
* INGROUP: MokoCli * INGROUP: MokoStandards
* REPO: https://git.mokoconsulting.tech/MokoConsulting/mokocli * REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform
* PATH: /bin/moko * PATH: /bin/moko
* BRIEF: Unified CLI dispatcher — run any MokoCli script without needing GitHub Actions * BRIEF: Unified CLI dispatcher — run any MokoStandards script without needing GitHub Actions
* *
* USAGE * USAGE
* php bin/moko <command> [options] (all platforms) * php bin/moko <command> [options] (all platforms)
@@ -89,7 +89,6 @@ const COMMAND_MAP = [
// Automation // Automation
'sync' => 'automation/bulk_sync.php', 'sync' => 'automation/bulk_sync.php',
'deps:update' => 'automation/update_dependencies.php',
'automation:cleanup' => 'automation/repo_cleanup.php', 'automation:cleanup' => 'automation/repo_cleanup.php',
'automation:migrate-gitea' => 'automation/migrate_to_gitea.php', 'automation:migrate-gitea' => 'automation/migrate_to_gitea.php',
@@ -178,7 +177,6 @@ const COMMAND_MAP = [
'repo:archive' => 'cli/archive_repo.php', 'repo:archive' => 'cli/archive_repo.php',
'repo:scaffold-client' => 'cli/scaffold_client.php', 'repo:scaffold-client' => 'cli/scaffold_client.php',
'repo:provision' => 'cli/client_provision.php', 'repo:provision' => 'cli/client_provision.php',
'repo:wizard' => 'cli/repo_wizard.php',
'repo:rename-branch' => 'cli/branch_rename.php', 'repo:rename-branch' => 'cli/branch_rename.php',
'repo:reset-dev' => 'cli/dev_branch_reset.php', 'repo:reset-dev' => 'cli/dev_branch_reset.php',
@@ -200,7 +198,6 @@ const COMMAND_MAP = [
'deploy:sftp' => 'deploy/deploy-sftp.php', 'deploy:sftp' => 'deploy/deploy-sftp.php',
'deploy:backup' => 'deploy/backup-before-deploy.php', 'deploy:backup' => 'deploy/backup-before-deploy.php',
'deploy:health-check' => 'deploy/health-check.php', 'deploy:health-check' => 'deploy/health-check.php',
'deploy:verify' => 'deploy/deploy-and-verify.php',
'deploy:rollback' => 'deploy/rollback-joomla.php', 'deploy:rollback' => 'deploy/rollback-joomla.php',
'deploy:sync' => 'deploy/sync-joomla.php', 'deploy:sync' => 'deploy/sync-joomla.php',
@@ -223,9 +220,6 @@ const COMMAND_MAP = [
// Licensing // Licensing
'license' => 'cli/license_manage.php', 'license' => 'cli/license_manage.php',
// Security
'security:advisories' => 'security/advisory_scan.php',
// Shell completion // Shell completion
'completion' => 'cli/completion.php', 'completion' => 'cli/completion.php',
@@ -298,10 +292,10 @@ function printHelp(): void
{ {
echo <<<'HELP' echo <<<'HELP'
╔══════════════════════════════════════════════════════════╗ ╔══════════════════════════════════════════════════════════╗
║ MokoCli CLI (bin/moko) ║ ║ MokoStandards CLI (bin/moko) ║
╚══════════════════════════════════════════════════════════╝ ╚══════════════════════════════════════════════════════════╝
Run any MokoCli script locally without GitHub Actions. Run any MokoStandards script locally without GitHub Actions.
USAGE USAGE
php bin/moko <command> [options] (all platforms) php bin/moko <command> [options] (all platforms)
@@ -403,7 +397,7 @@ function loadPluginCommands(): array
$commands = []; $commands = [];
foreach (glob("{$pluginDir}/*Plugin.php") as $file) { foreach (glob("{$pluginDir}/*Plugin.php") as $file) {
$className = 'MokoCli\\Plugins\\' $className = 'MokoEnterprise\\Plugins\\'
. pathinfo($file, PATHINFO_FILENAME); . pathinfo($file, PATHINFO_FILENAME);
if (!class_exists($className)) { if (!class_exists($className)) {
+8 -8
View File
@@ -8,9 +8,9 @@
* SPDX-License-Identifier: GPL-3.0-or-later * SPDX-License-Identifier: GPL-3.0-or-later
* *
* FILE INFORMATION * FILE INFORMATION
* DEFGROUP: mokocli.CLI * DEFGROUP: moko-platform.CLI
* INGROUP: mokocli * INGROUP: moko-platform
* REPO: https://git.mokoconsulting.tech/MokoConsulting/mokocli * REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform
* PATH: /cli/archive_repo.php * PATH: /cli/archive_repo.php
* BRIEF: Gracefully retire a governed repository — archive, close issues/PRs, remove sync def * BRIEF: Gracefully retire a governed repository — archive, close issues/PRs, remove sync def
*/ */
@@ -20,9 +20,9 @@ declare(strict_types=1);
require_once __DIR__ . '/../vendor/autoload.php'; require_once __DIR__ . '/../vendor/autoload.php';
require_once __DIR__ . '/../lib/Enterprise/CliFramework.php'; require_once __DIR__ . '/../lib/Enterprise/CliFramework.php';
use MokoCli\CliFramework; use MokoEnterprise\CliFramework;
use MokoCli\Config; use MokoEnterprise\Config;
use MokoCli\PlatformAdapterFactory; use MokoEnterprise\PlatformAdapterFactory;
class ArchiveRepoCli extends CliFramework class ArchiveRepoCli extends CliFramework
{ {
@@ -135,7 +135,7 @@ class ArchiveRepoCli extends CliFramework
try { try {
$issue = $adapter->createIssue( $issue = $adapter->createIssue(
$org, $org,
'mokocli', 'moko-platform',
"chore: archived repository {$repoName}", "chore: archived repository {$repoName}",
"## Repository Archived\n\n" "## Repository Archived\n\n"
. "**Repository:** `{$org}/{$repoName}`\n" . "**Repository:** `{$org}/{$repoName}`\n"
@@ -150,7 +150,7 @@ class ArchiveRepoCli extends CliFramework
] ]
); );
if (isset($issue['number'])) { if (isset($issue['number'])) {
echo " Archival record: mokocli#{$issue['number']}\n"; echo " Archival record: moko-platform#{$issue['number']}\n";
} }
} catch (\Exception $e) { } catch (\Exception $e) {
echo " Warning: could not create archival record: " . $e->getMessage() . "\n"; echo " Warning: could not create archival record: " . $e->getMessage() . "\n";
+2 -2
View File
@@ -16,7 +16,7 @@
* FILE INFORMATION * FILE INFORMATION
* DEFGROUP: MokoPlatform.Enterprise.CLI * DEFGROUP: MokoPlatform.Enterprise.CLI
* INGROUP: MokoPlatform.Enterprise * INGROUP: MokoPlatform.Enterprise
* REPO: https://git.mokoconsulting.tech/MokoConsulting/mokocli * REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform
* PATH: /cli/audit_query.php * PATH: /cli/audit_query.php
* BRIEF: Search, filter, and export audit logs * BRIEF: Search, filter, and export audit logs
*/ */
@@ -25,7 +25,7 @@ declare(strict_types=1);
require_once __DIR__ . '/../lib/Enterprise/CliFramework.php'; require_once __DIR__ . '/../lib/Enterprise/CliFramework.php';
use MokoCli\CliFramework; use MokoEnterprise\CliFramework;
/** /**
* CLI tool to search, filter, and export audit logs. * CLI tool to search, filter, and export audit logs.
+4 -4
View File
@@ -6,9 +6,9 @@
* SPDX-License-Identifier: GPL-3.0-or-later * SPDX-License-Identifier: GPL-3.0-or-later
* *
* FILE INFORMATION * FILE INFORMATION
* DEFGROUP: mokocli.CLI * DEFGROUP: moko-platform.CLI
* INGROUP: mokocli * INGROUP: moko-platform
* REPO: https://git.mokoconsulting.tech/MokoConsulting/mokocli * REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform
* PATH: /cli/badge_update.php * PATH: /cli/badge_update.php
* BRIEF: Update [VERSION: XX.XX.XX] badges in all markdown files * BRIEF: Update [VERSION: XX.XX.XX] badges in all markdown files
*/ */
@@ -17,7 +17,7 @@ declare(strict_types=1);
require_once __DIR__ . '/../lib/Enterprise/CliFramework.php'; require_once __DIR__ . '/../lib/Enterprise/CliFramework.php';
use MokoCli\CliFramework; use MokoEnterprise\CliFramework;
class BadgeUpdateCli extends CliFramework class BadgeUpdateCli extends CliFramework
{ {
-158
View File
@@ -1,158 +0,0 @@
<?php
/**
* @package MokoCLI
* @subpackage cli
* @author Moko Consulting <hello@mokoconsulting.tech>
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
* @license GNU General Public License version 3 or later; see LICENSE
* SPDX-License-Identifier: GPL-3.0-or-later
*
* Enforce branch protection rules across all repos in the org.
*
* Usage:
* php cli/branch_protect_org.php --token TOKEN [--org MokoConsulting] [--dry-run]
*
* Branch flow: feature/* -> dev -> rc -> main
* main, dev, rc: push whitelist only (no direct push)
* alpha, beta: push whitelist only (pre-release)
*/
declare(strict_types=1);
$options = getopt('', ['token:', 'org:', 'api-base:', 'dry-run', 'help']);
if (isset($options['help']) || empty($options['token'])) {
echo "Usage: php cli/branch_protect_org.php --token TOKEN [--org ORG] [--api-base URL] [--dry-run]\n";
echo "\n";
echo "Options:\n";
echo " --token Gitea API token (required)\n";
echo " --org Organization name (default: MokoConsulting)\n";
echo " --api-base API base URL (default: https://git.mokoconsulting.tech/api/v1)\n";
echo " --dry-run Show what would be changed without making changes\n";
exit(0);
}
$token = $options['token'];
$org = $options['org'] ?? 'MokoConsulting';
$apiBase = rtrim($options['api-base'] ?? 'https://git.mokoconsulting.tech/api/v1', '/');
$dryRun = isset($options['dry-run']);
// Protected branches and their rules
$branchRules = [
// Primary branches (flow: feature/* -> dev -> rc -> main)
'main' => ['enable_push' => true, 'enable_push_whitelist' => true, 'push_whitelist_usernames' => ['jmiller']],
'dev' => ['enable_push' => true, 'enable_push_whitelist' => true, 'push_whitelist_usernames' => ['jmiller']],
'rc' => ['enable_push' => true, 'enable_push_whitelist' => true, 'push_whitelist_usernames' => ['jmiller']],
'beta' => ['enable_push' => true, 'enable_push_whitelist' => true, 'push_whitelist_usernames' => ['jmiller']],
'alpha' => ['enable_push' => true, 'enable_push_whitelist' => true, 'push_whitelist_usernames' => ['jmiller']],
// Synonyms (prevent bypass via alternate names)
'master' => ['enable_push' => true, 'enable_push_whitelist' => true, 'push_whitelist_usernames' => ['jmiller']],
'develop' => ['enable_push' => true, 'enable_push_whitelist' => true, 'push_whitelist_usernames' => ['jmiller']],
'release' => ['enable_push' => true, 'enable_push_whitelist' => true, 'push_whitelist_usernames' => ['jmiller']],
'production' => ['enable_push' => true, 'enable_push_whitelist' => true, 'push_whitelist_usernames' => ['jmiller']],
'stable' => ['enable_push' => true, 'enable_push_whitelist' => true, 'push_whitelist_usernames' => ['jmiller']],
'staging' => ['enable_push' => true, 'enable_push_whitelist' => true, 'push_whitelist_usernames' => ['jmiller']],
];
function apiRequest(string $method, string $url, string $token, ?array $body = null): array
{
$ch = curl_init($url);
curl_setopt_array($ch, [
CURLOPT_CUSTOMREQUEST => $method,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_HTTPHEADER => [
'Authorization: token ' . $token,
'Content-Type: application/json',
'Accept: application/json',
],
CURLOPT_TIMEOUT => 30,
]);
if ($body !== null) {
curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($body));
}
$response = curl_exec($ch);
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
return [
'status' => $httpCode,
'data' => json_decode($response, true) ?: [],
];
}
// 1. List all org repos
echo "Fetching repos for {$org}...\n";
$page = 1;
$repos = [];
do {
$result = apiRequest('GET', "{$apiBase}/orgs/{$org}/repos?limit=50&page={$page}", $token);
$batch = $result['data'];
$repos = array_merge($repos, $batch);
$page++;
} while (count($batch) === 50);
echo sprintf("Found %d repos\n\n", count($repos));
$summary = ['protected' => 0, 'added' => 0, 'skipped' => 0, 'errors' => 0];
foreach ($repos as $repo) {
$repoName = $repo['name'];
if ($repo['archived'] ?? false) {
continue;
}
// Get existing protections
$existing = apiRequest('GET', "{$apiBase}/repos/{$org}/{$repoName}/branch_protections", $token);
$existingNames = array_map(fn($p) => $p['branch_name'] ?? '', $existing['data'] ?: []);
$added = [];
$skipped = [];
foreach ($branchRules as $branch => $rules) {
if (in_array($branch, $existingNames, true)) {
$skipped[] = $branch;
$summary['skipped']++;
continue;
}
if ($dryRun) {
$added[] = $branch;
$summary['added']++;
continue;
}
$body = array_merge($rules, ['branch_name' => $branch]);
$result = apiRequest('POST', "{$apiBase}/repos/{$org}/{$repoName}/branch_protections", $token, $body);
if ($result['status'] >= 200 && $result['status'] < 300) {
$added[] = $branch;
$summary['added']++;
} elseif ($result['status'] === 422) {
$skipped[] = $branch;
$summary['skipped']++;
} else {
$added[] = "{$branch}(ERR:{$result['status']})";
$summary['errors']++;
}
}
$summary['protected']++;
if (!empty($added)) {
$prefix = $dryRun ? '[DRY-RUN] ' : '';
echo sprintf(" %s%-35s added: %s\n", $prefix, $repoName, implode(', ', $added));
}
}
echo "\n";
echo sprintf("Summary: %d repos, %d rules added, %d already existed, %d errors\n",
$summary['protected'], $summary['added'], $summary['skipped'], $summary['errors']);
if ($dryRun) {
echo "\n(Dry run - no changes made)\n";
}
+5 -5
View File
@@ -6,11 +6,11 @@
* SPDX-License-Identifier: GPL-3.0-or-later * SPDX-License-Identifier: GPL-3.0-or-later
* *
* FILE INFORMATION * FILE INFORMATION
* DEFGROUP: mokocli.CLI * DEFGROUP: moko-platform.CLI
* INGROUP: mokocli * INGROUP: moko-platform
* REPO: https://git.mokoconsulting.tech/MokoConsulting/mokocli * REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform
* PATH: /cli/branch_rename.php * PATH: /cli/branch_rename.php
* VERSION: 09.38.00 * VERSION: 09.24.00
* BRIEF: Rename a git branch via Gitea API (create new, update PR, delete old) * BRIEF: Rename a git branch via Gitea API (create new, update PR, delete old)
*/ */
@@ -18,7 +18,7 @@ declare(strict_types=1);
require_once __DIR__ . '/../lib/Enterprise/CliFramework.php'; require_once __DIR__ . '/../lib/Enterprise/CliFramework.php';
use MokoCli\CliFramework; use MokoEnterprise\CliFramework;
class BranchRenameCli extends CliFramework class BranchRenameCli extends CliFramework
{ {
+7 -7
View File
@@ -8,11 +8,11 @@
* SPDX-License-Identifier: GPL-3.0-or-later * SPDX-License-Identifier: GPL-3.0-or-later
* *
* FILE INFORMATION * FILE INFORMATION
* DEFGROUP: mokocli.CLI * DEFGROUP: moko-platform.CLI
* INGROUP: mokocli * INGROUP: moko-platform
* REPO: https://git.mokoconsulting.tech/MokoConsulting/mokocli * REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform
* PATH: /cli/bulk_workflow_push.php * PATH: /cli/bulk_workflow_push.php
* VERSION: 09.38.00 * VERSION: 09.24.00
* BRIEF: Push a workflow file to all governed repos via the Gitea Contents API * BRIEF: Push a workflow file to all governed repos via the Gitea Contents API
*/ */
@@ -20,7 +20,7 @@ declare(strict_types=1);
require_once __DIR__ . '/../lib/Enterprise/CliFramework.php'; require_once __DIR__ . '/../lib/Enterprise/CliFramework.php';
use MokoCli\CliFramework; use MokoEnterprise\CliFramework;
class BulkWorkflowPushCli extends CliFramework class BulkWorkflowPushCli extends CliFramework
{ {
@@ -154,7 +154,7 @@ class BulkWorkflowPushCli extends CliFramework
'content' => $encodedContent, 'content' => $encodedContent,
'sha' => $remoteSha, 'sha' => $remoteSha,
'message' => "chore: sync {$destPath} " 'message' => "chore: sync {$destPath} "
. "from mokocli [skip ci]", . "from moko-platform [skip ci]",
'branch' => $branch, 'branch' => $branch,
]); ]);
@@ -184,7 +184,7 @@ class BulkWorkflowPushCli extends CliFramework
$payload = json_encode([ $payload = json_encode([
'content' => $encodedContent, 'content' => $encodedContent,
'message' => "chore: add {$destPath} " 'message' => "chore: add {$destPath} "
. "from mokocli [skip ci]", . "from moko-platform [skip ci]",
'branch' => $branch, 'branch' => $branch,
]); ]);
+5 -5
View File
@@ -8,11 +8,11 @@
* SPDX-License-Identifier: GPL-3.0-or-later * SPDX-License-Identifier: GPL-3.0-or-later
* *
* FILE INFORMATION * FILE INFORMATION
* DEFGROUP: mokocli.CLI * DEFGROUP: moko-platform.CLI
* INGROUP: mokocli * INGROUP: moko-platform
* REPO: https://git.mokoconsulting.tech/MokoConsulting/mokocli * REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform
* PATH: /cli/bulk_workflow_trigger.php * PATH: /cli/bulk_workflow_trigger.php
* VERSION: 09.38.00 * VERSION: 09.24.00
* BRIEF: Trigger a workflow across multiple repos at once * BRIEF: Trigger a workflow across multiple repos at once
*/ */
@@ -20,7 +20,7 @@ declare(strict_types=1);
require_once __DIR__ . '/../lib/Enterprise/CliFramework.php'; require_once __DIR__ . '/../lib/Enterprise/CliFramework.php';
use MokoCli\CliFramework; use MokoEnterprise\CliFramework;
class BulkWorkflowTriggerCli extends CliFramework class BulkWorkflowTriggerCli extends CliFramework
{ {
+4 -4
View File
@@ -6,9 +6,9 @@
* SPDX-License-Identifier: GPL-3.0-or-later * SPDX-License-Identifier: GPL-3.0-or-later
* *
* FILE INFORMATION * FILE INFORMATION
* DEFGROUP: mokocli.CLI * DEFGROUP: moko-platform.CLI
* INGROUP: mokocli * INGROUP: moko-platform
* REPO: https://git.mokoconsulting.tech/MokoConsulting/mokocli * REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform
* PATH: /cli/changelog_promote.php * PATH: /cli/changelog_promote.php
* BRIEF: Promote [Unreleased] section in CHANGELOG.md to a versioned entry * BRIEF: Promote [Unreleased] section in CHANGELOG.md to a versioned entry
*/ */
@@ -17,7 +17,7 @@ declare(strict_types=1);
require_once __DIR__ . '/../lib/Enterprise/CliFramework.php'; require_once __DIR__ . '/../lib/Enterprise/CliFramework.php';
use MokoCli\CliFramework; use MokoEnterprise\CliFramework;
class ChangelogPromoteCli extends CliFramework class ChangelogPromoteCli extends CliFramework
{ {
+4 -4
View File
@@ -6,9 +6,9 @@
* SPDX-License-Identifier: GPL-3.0-or-later * SPDX-License-Identifier: GPL-3.0-or-later
* *
* FILE INFORMATION * FILE INFORMATION
* DEFGROUP: mokocli.CLI * DEFGROUP: moko-platform.CLI
* INGROUP: mokocli * INGROUP: moko-platform
* REPO: https://git.mokoconsulting.tech/MokoConsulting/mokocli * REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform
* PATH: /cli/changelog_prune.php * PATH: /cli/changelog_prune.php
* BRIEF: Prune old CHANGELOG.md entries — keeps [Unreleased] + last N releases * BRIEF: Prune old CHANGELOG.md entries — keeps [Unreleased] + last N releases
*/ */
@@ -17,7 +17,7 @@ declare(strict_types=1);
require_once __DIR__ . '/../lib/Enterprise/CliFramework.php'; require_once __DIR__ . '/../lib/Enterprise/CliFramework.php';
use MokoCli\CliFramework; use MokoEnterprise\CliFramework;
class ChangelogPruneCli extends CliFramework class ChangelogPruneCli extends CliFramework
{ {
+5 -5
View File
@@ -8,11 +8,11 @@
* SPDX-License-Identifier: GPL-3.0-or-later * SPDX-License-Identifier: GPL-3.0-or-later
* *
* FILE INFORMATION * FILE INFORMATION
* DEFGROUP: mokocli.CLI * DEFGROUP: moko-platform.CLI
* INGROUP: mokocli * INGROUP: moko-platform
* REPO: https://git.mokoconsulting.tech/MokoConsulting/mokocli * REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform
* PATH: /cli/client_dashboard.php * PATH: /cli/client_dashboard.php
* VERSION: 09.38.00 * VERSION: 09.24.00
* BRIEF: Generate unified client dashboard HTML * BRIEF: Generate unified client dashboard HTML
*/ */
@@ -20,7 +20,7 @@ declare(strict_types=1);
require_once __DIR__ . '/../lib/Enterprise/CliFramework.php'; require_once __DIR__ . '/../lib/Enterprise/CliFramework.php';
use MokoCli\CliFramework; use MokoEnterprise\CliFramework;
class ClientDashboardCli extends CliFramework class ClientDashboardCli extends CliFramework
{ {
+4 -4
View File
@@ -6,9 +6,9 @@
* SPDX-License-Identifier: GPL-3.0-or-later * SPDX-License-Identifier: GPL-3.0-or-later
* *
* FILE INFORMATION * FILE INFORMATION
* DEFGROUP: mokocli.CLI * DEFGROUP: moko-platform.CLI
* INGROUP: mokocli * INGROUP: moko-platform
* REPO: https://git.mokoconsulting.tech/MokoConsulting/mokocli * REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform
* PATH: /cli/client_health_check.php * PATH: /cli/client_health_check.php
* BRIEF: Verify a client site's update server, installed version, and release availability * BRIEF: Verify a client site's update server, installed version, and release availability
*/ */
@@ -17,7 +17,7 @@ declare(strict_types=1);
require_once __DIR__ . '/../lib/Enterprise/CliFramework.php'; require_once __DIR__ . '/../lib/Enterprise/CliFramework.php';
use MokoCli\CliFramework; use MokoEnterprise\CliFramework;
class ClientHealthCheckCli extends CliFramework class ClientHealthCheckCli extends CliFramework
{ {
+5 -5
View File
@@ -8,11 +8,11 @@
* SPDX-License-Identifier: GPL-3.0-or-later * SPDX-License-Identifier: GPL-3.0-or-later
* *
* FILE INFORMATION * FILE INFORMATION
* DEFGROUP: mokocli.CLI * DEFGROUP: moko-platform.CLI
* INGROUP: mokocli * INGROUP: moko-platform
* REPO: https://git.mokoconsulting.tech/MokoConsulting/mokocli * REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform
* PATH: /cli/client_inventory.php * PATH: /cli/client_inventory.php
* VERSION: 09.38.00 * VERSION: 09.24.00
* BRIEF: Discover and list all client-waas repos with their server configuration status * BRIEF: Discover and list all client-waas repos with their server configuration status
*/ */
@@ -20,7 +20,7 @@ declare(strict_types=1);
require_once __DIR__ . '/../lib/Enterprise/CliFramework.php'; require_once __DIR__ . '/../lib/Enterprise/CliFramework.php';
use MokoCli\CliFramework; use MokoEnterprise\CliFramework;
class ClientInventoryCli extends CliFramework class ClientInventoryCli extends CliFramework
{ {
+5 -5
View File
@@ -8,11 +8,11 @@
* SPDX-License-Identifier: GPL-3.0-or-later * SPDX-License-Identifier: GPL-3.0-or-later
* *
* FILE INFORMATION * FILE INFORMATION
* DEFGROUP: mokocli.CLI * DEFGROUP: moko-platform.CLI
* INGROUP: mokocli * INGROUP: moko-platform
* REPO: https://git.mokoconsulting.tech/MokoConsulting/mokocli * REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform
* PATH: /cli/client_provision.php * PATH: /cli/client_provision.php
* VERSION: 09.38.00 * VERSION: 09.24.00
* BRIEF: Provision a new client environment end-to-end * BRIEF: Provision a new client environment end-to-end
*/ */
@@ -20,7 +20,7 @@ declare(strict_types=1);
require_once __DIR__ . '/../lib/Enterprise/CliFramework.php'; require_once __DIR__ . '/../lib/Enterprise/CliFramework.php';
use MokoCli\CliFramework; use MokoEnterprise\CliFramework;
class ClientProvisionCli extends CliFramework class ClientProvisionCli extends CliFramework
{ {
+4 -4
View File
@@ -6,9 +6,9 @@
* SPDX-License-Identifier: GPL-3.0-or-later * SPDX-License-Identifier: GPL-3.0-or-later
* *
* FILE INFORMATION * FILE INFORMATION
* DEFGROUP: mokocli.CLI * DEFGROUP: moko-platform.CLI
* INGROUP: mokocli * INGROUP: moko-platform
* REPO: https://git.mokoconsulting.tech/MokoConsulting/mokocli * REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform
* PATH: /cli/completion.php * PATH: /cli/completion.php
* BRIEF: Generate bash/zsh tab completion scripts for bin/moko * BRIEF: Generate bash/zsh tab completion scripts for bin/moko
*/ */
@@ -17,7 +17,7 @@ declare(strict_types=1);
require_once __DIR__ . '/../lib/Enterprise/CliFramework.php'; require_once __DIR__ . '/../lib/Enterprise/CliFramework.php';
use MokoCli\CliFramework; use MokoEnterprise\CliFramework;
class CompletionCli extends CliFramework class CompletionCli extends CliFramework
{ {
+12 -12
View File
@@ -8,9 +8,9 @@
* SPDX-License-Identifier: GPL-3.0-or-later * SPDX-License-Identifier: GPL-3.0-or-later
* *
* FILE INFORMATION * FILE INFORMATION
* DEFGROUP: mokocli.CLI * DEFGROUP: moko-platform.CLI
* INGROUP: mokocli * INGROUP: moko-platform
* REPO: https://git.mokoconsulting.tech/MokoConsulting/mokocli * REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform
* PATH: /cli/create_project.php * PATH: /cli/create_project.php
* BRIEF: Create baseline GitHub Projects for repositories with standard fields and views * BRIEF: Create baseline GitHub Projects for repositories with standard fields and views
*/ */
@@ -19,12 +19,12 @@ declare(strict_types=1);
require_once __DIR__ . '/../lib/Enterprise/CliFramework.php'; require_once __DIR__ . '/../lib/Enterprise/CliFramework.php';
use MokoCli\CliFramework; use MokoEnterprise\CliFramework;
class CreateProjectCli extends CliFramework class CreateProjectCli extends CliFramework
{ {
/** @var string[] */ /** @var string[] */
private array $ALWAYS_EXCLUDE = ['mokocli', '.github-private']; private array $ALWAYS_EXCLUDE = ['moko-platform', '.github-private'];
/** @var array<string, string> */ /** @var array<string, string> */
private array $PLATFORM_TO_TYPE = [ private array $PLATFORM_TO_TYPE = [
@@ -80,10 +80,10 @@ class CreateProjectCli extends CliFramework
return 2; return 2;
} }
$config = \MokoCli\Config::load(); $config = \MokoEnterprise\Config::load();
$platformName = $config->getString('platform', 'gitea'); $platformName = $config->getString('platform', 'gitea');
try { try {
$adapter = \MokoCli\PlatformAdapterFactory::create($config); $adapter = \MokoEnterprise\PlatformAdapterFactory::create($config);
$api = $adapter->getApiClient(); $api = $adapter->getApiClient();
} catch (\Exception $e) { } catch (\Exception $e) {
$this->log('ERROR', "Platform initialization failed: " . $e->getMessage()); $this->log('ERROR', "Platform initialization failed: " . $e->getMessage());
@@ -183,7 +183,7 @@ class CreateProjectCli extends CliFramework
CURLOPT_HTTPHEADER => [ CURLOPT_HTTPHEADER => [
'Authorization: bearer ' . $token, 'Authorization: bearer ' . $token,
'Content-Type: application/json', 'Content-Type: application/json',
'User-Agent: mokocli-CreateProject', 'User-Agent: moko-platform-CreateProject',
], ],
]); ]);
$body = (string) curl_exec($ch); $body = (string) curl_exec($ch);
@@ -205,7 +205,7 @@ class CreateProjectCli extends CliFramework
return $data['data'] ?? []; return $data['data'] ?? [];
} }
private function restGet(string $path, string $token, ?\MokoCli\ApiClient $apiClient = null): array private function restGet(string $path, string $token, ?\MokoEnterprise\ApiClient $apiClient = null): array
{ {
if ($apiClient !== null) { if ($apiClient !== null) {
try { try {
@@ -217,7 +217,7 @@ class CreateProjectCli extends CliFramework
return []; return [];
} }
private function detectRepoPlatform(string $org, string $repo, string $token, ?\MokoCli\ApiClient $apiClient = null): string private function detectRepoPlatform(string $org, string $repo, string $token, ?\MokoEnterprise\ApiClient $apiClient = null): string
{ {
foreach (['.github/.mokostandards', '.mokogitea/.mokostandards', '.mokostandards'] as $path) { foreach (['.github/.mokostandards', '.mokogitea/.mokostandards', '.mokostandards'] as $path) {
$data = $this->restGet("repos/{$org}/{$repo}/contents/{$path}", $token, $apiClient); $data = $this->restGet("repos/{$org}/{$repo}/contents/{$path}", $token, $apiClient);
@@ -422,14 +422,14 @@ class CreateProjectCli extends CliFramework
updateProjectV2(input: { updateProjectV2(input: {
projectId: $projectId, projectId: $projectId,
shortDescription: $shortDescription, shortDescription: $shortDescription,
readme: "Managed by mokocli. Run `php cli/create_project.php` to regenerate." readme: "Managed by moko-platform. Run `php cli/create_project.php` to regenerate."
}) { }) {
projectV2 { id } projectV2 { id }
} }
}', }',
[ [
'projectId' => $projectId, 'projectId' => $projectId,
'shortDescription' => "Standard project board for {$repo}. Auto-created by mokocli.", 'shortDescription' => "Standard project board for {$repo}. Auto-created by moko-platform.",
], ],
$token $token
); );
+21 -21
View File
@@ -8,11 +8,11 @@
* SPDX-License-Identifier: GPL-3.0-or-later * SPDX-License-Identifier: GPL-3.0-or-later
* *
* FILE INFORMATION * FILE INFORMATION
* DEFGROUP: mokocli.CLI * DEFGROUP: moko-platform.CLI
* INGROUP: mokocli * INGROUP: moko-platform
* REPO: https://git.mokoconsulting.tech/MokoConsulting/mokocli * REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform
* PATH: /cli/create_repo.php * PATH: /cli/create_repo.php
* BRIEF: Scaffold a new governed repository with full mokocli baseline * BRIEF: Scaffold a new governed repository with full moko-platform baseline
*/ */
declare(strict_types=1); declare(strict_types=1);
@@ -20,15 +20,15 @@ declare(strict_types=1);
require_once __DIR__ . '/../vendor/autoload.php'; require_once __DIR__ . '/../vendor/autoload.php';
require_once __DIR__ . '/../lib/Enterprise/CliFramework.php'; require_once __DIR__ . '/../lib/Enterprise/CliFramework.php';
use MokoCli\CliFramework; use MokoEnterprise\CliFramework;
use MokoCli\Config; use MokoEnterprise\Config;
use MokoCli\PlatformAdapterFactory; 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 mokocli 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', '');
@@ -60,16 +60,16 @@ class CreateRepoCli extends CliFramework
'generic' => 'generic', 'generic' => 'generic',
]; ];
$TYPE_TO_TOPICS = [ $TYPE_TO_TOPICS = [
'dolibarr' => ['dolibarr', 'erp', 'crm', 'php', 'mokocli'], 'dolibarr' => ['dolibarr', 'erp', 'crm', 'php', 'moko-platform'],
'joomla' => ['joomla', 'cms', 'php', 'mokocli'], 'joomla' => ['joomla', 'cms', 'php', 'moko-platform'],
'nodejs' => ['nodejs', 'javascript', 'typescript', 'mokocli'], 'nodejs' => ['nodejs', 'javascript', 'typescript', 'moko-platform'],
'terraform' => ['terraform', 'infrastructure', 'iac', 'mokocli'], 'terraform' => ['terraform', 'infrastructure', 'iac', 'moko-platform'],
'python' => ['python', 'mokocli'], 'python' => ['python', 'moko-platform'],
'wordpress' => ['wordpress', 'php', 'cms', 'mokocli'], 'wordpress' => ['wordpress', 'php', 'cms', 'moko-platform'],
'generic' => ['mokocli'], 'generic' => ['moko-platform'],
]; ];
$platform = $TYPE_TO_PLATFORM[$type] ?? 'generic'; $platform = $TYPE_TO_PLATFORM[$type] ?? 'generic';
$topics = $TYPE_TO_TOPICS[$type] ?? ['mokocli']; $topics = $TYPE_TO_TOPICS[$type] ?? ['moko-platform'];
$platformName = $adapter->getPlatformName(); $platformName = $adapter->getPlatformName();
$vis = $private ? 'private' : 'public'; $vis = $private ? 'private' : 'public';
echo "Scaffolding new repository: {$org}/{$name}" echo "Scaffolding new repository: {$org}/{$name}"
@@ -84,7 +84,7 @@ class CreateRepoCli extends CliFramework
if (!$this->dryRun) { if (!$this->dryRun) {
try { try {
$data = $adapter->createOrgRepo($org, $name, [ $data = $adapter->createOrgRepo($org, $name, [
'description' => $description ?: "Managed by mokocli ({$type})", 'description' => $description ?: "Managed by moko-platform ({$type})",
'private' => $private, 'private' => $private,
'has_issues' => true, 'has_issues' => true,
'has_projects' => true, 'has_projects' => true,
@@ -138,12 +138,12 @@ class CreateRepoCli extends CliFramework
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}"; $repoUrl = "{$baseUrl}/{$org}/{$name}";
$standardsUrl = "{$baseUrl}/{$org}/MokoCli"; $standardsUrl = "{$baseUrl}/{$org}/MokoStandards";
$readmeContent = "<!--\n" $readmeContent = "<!--\n"
. "Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>\n" . "Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>\n"
. "SPDX-License-Identifier: GPL-3.0-or-later\n" . "SPDX-License-Identifier: GPL-3.0-or-later\n"
. "DEFGROUP: {$name}\n" . "DEFGROUP: {$name}\n"
. "INGROUP: mokocli\n" . "INGROUP: moko-platform\n"
. "REPO: {$repoUrl}\n" . "REPO: {$repoUrl}\n"
. "PATH: /README.md\n" . "PATH: /README.md\n"
. "BRIEF: {$description}\n" . "BRIEF: {$description}\n"
@@ -152,7 +152,7 @@ class CreateRepoCli extends CliFramework
. "{$description}\n\n" . "{$description}\n\n"
. "## Getting Started\n\n" . "## Getting Started\n\n"
. "This repository is governed by" . "This repository is governed by"
. " [mokocli]({$standardsUrl}).\n\n" . " [moko-platform]({$standardsUrl}).\n\n"
. "## License\n\n" . "## License\n\n"
. "GPL-3.0-or-later. See [LICENSE](LICENSE)" . "GPL-3.0-or-later. See [LICENSE](LICENSE)"
. " for details.\n"; . " for details.\n";
@@ -169,7 +169,7 @@ class CreateRepoCli extends CliFramework
$name, $name,
'README.md', 'README.md',
$readmeContent, $readmeContent,
'docs: initialize README with mokocli header [skip ci]', 'docs: initialize README with moko-platform header [skip ci]',
$sha $sha
); );
echo " README.md created\n"; echo " README.md created\n";
+7 -7
View File
@@ -10,7 +10,7 @@
* FILE INFORMATION * FILE INFORMATION
* DEFGROUP: MokoPlatform.CLI * DEFGROUP: MokoPlatform.CLI
* INGROUP: MokoPlatform * INGROUP: MokoPlatform
* REPO: https://git.mokoconsulting.tech/MokoConsulting/mokocli * REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform
* PATH: /cli/deploy_joomla.php * PATH: /cli/deploy_joomla.php
* BRIEF: Smart Joomla deploy — routes files to correct server directories by extension type * BRIEF: Smart Joomla deploy — routes files to correct server directories by extension type
* *
@@ -31,7 +31,7 @@ require_once __DIR__ . '/../vendor/autoload.php';
require_once __DIR__ . '/../lib/Enterprise/CliFramework.php'; require_once __DIR__ . '/../lib/Enterprise/CliFramework.php';
use MokoCli\{CliFramework, SourceResolver}; use MokoEnterprise\CliFramework;
use phpseclib3\Net\SFTP; use phpseclib3\Net\SFTP;
use phpseclib3\Crypt\PublicKeyLoader; use phpseclib3\Crypt\PublicKeyLoader;
@@ -866,11 +866,11 @@ class DeployJoomla extends CliFramework
} }
} }
// 3-5. Fallback chain (source/ → src/ → htdocs/) // 3-5. Fallback chain
$resolved = SourceResolver::resolveAbsolute($repoPath); foreach (['src', 'htdocs'] as $candidate) {
if ($resolved !== null) { if (is_dir("{$repoPath}/{$candidate}")) {
SourceResolver::warnIfLegacy($repoPath); return "{$repoPath}/{$candidate}";
return $resolved; }
} }
// Last resort: repo root itself // Last resort: repo root itself
+4 -4
View File
@@ -6,9 +6,9 @@
* SPDX-License-Identifier: GPL-3.0-or-later * SPDX-License-Identifier: GPL-3.0-or-later
* *
* FILE INFORMATION * FILE INFORMATION
* DEFGROUP: mokocli.CLI * DEFGROUP: moko-platform.CLI
* INGROUP: mokocli * INGROUP: moko-platform
* REPO: https://git.mokoconsulting.tech/MokoConsulting/mokocli * REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform
* PATH: /cli/dev_branch_reset.php * PATH: /cli/dev_branch_reset.php
* BRIEF: Delete and recreate dev branch from main via Gitea API * BRIEF: Delete and recreate dev branch from main via Gitea API
*/ */
@@ -17,7 +17,7 @@ declare(strict_types=1);
require_once __DIR__ . '/../lib/Enterprise/CliFramework.php'; require_once __DIR__ . '/../lib/Enterprise/CliFramework.php';
use MokoCli\CliFramework; use MokoEnterprise\CliFramework;
class DevBranchResetCli extends CliFramework class DevBranchResetCli extends CliFramework
{ {
+5 -5
View File
@@ -8,11 +8,11 @@
* SPDX-License-Identifier: GPL-3.0-or-later * SPDX-License-Identifier: GPL-3.0-or-later
* *
* FILE INFORMATION * FILE INFORMATION
* DEFGROUP: mokocli.CLI * DEFGROUP: moko-platform.CLI
* INGROUP: mokocli * INGROUP: moko-platform
* REPO: https://git.mokoconsulting.tech/MokoConsulting/mokocli * REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform
* PATH: /cli/grafana_dashboard.php * PATH: /cli/grafana_dashboard.php
* VERSION: 09.38.00 * VERSION: 09.24.00
* BRIEF: Manage Grafana dashboards via API * BRIEF: Manage Grafana dashboards via API
*/ */
@@ -20,7 +20,7 @@ declare(strict_types=1);
require_once __DIR__ . '/../lib/Enterprise/CliFramework.php'; require_once __DIR__ . '/../lib/Enterprise/CliFramework.php';
use MokoCli\CliFramework; use MokoEnterprise\CliFramework;
class GrafanaDashboardCli extends CliFramework class GrafanaDashboardCli extends CliFramework
{ {
+13 -8
View File
@@ -6,11 +6,11 @@
* SPDX-License-Identifier: GPL-3.0-or-later * SPDX-License-Identifier: GPL-3.0-or-later
* *
* FILE INFORMATION * FILE INFORMATION
* DEFGROUP: mokocli.CLI * DEFGROUP: moko-platform.CLI
* INGROUP: mokocli * INGROUP: moko-platform
* REPO: https://git.mokoconsulting.tech/MokoConsulting/mokocli * REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform
* PATH: /cli/joomla_build.php * PATH: /cli/joomla_build.php
* VERSION: 09.38.00 * VERSION: 09.24.00
* BRIEF: Build a Joomla extension ZIP from manifest — all types supported * BRIEF: Build a Joomla extension ZIP from manifest — all types supported
* NOTE: Called by pre-release and auto-release workflows. * NOTE: Called by pre-release and auto-release workflows.
*/ */
@@ -19,7 +19,7 @@ declare(strict_types=1);
require_once __DIR__ . '/../lib/Enterprise/CliFramework.php'; require_once __DIR__ . '/../lib/Enterprise/CliFramework.php';
use MokoCli\{CliFramework, SourceResolver}; use MokoEnterprise\CliFramework;
class JoomlaBuildCli extends CliFramework class JoomlaBuildCli extends CliFramework
{ {
@@ -49,12 +49,17 @@ class JoomlaBuildCli extends CliFramework
$path = realpath($path) ?: $path; $path = realpath($path) ?: $path;
// ── Find source directory ────────────────────────────────────────────── // ── Find source directory ──────────────────────────────────────────────
$srcDir = SourceResolver::resolveAbsolute($path); $srcDir = null;
foreach (['src', 'htdocs'] as $d) {
if (is_dir("{$path}/{$d}")) {
$srcDir = "{$path}/{$d}";
break;
}
}
if ($srcDir === null) { if ($srcDir === null) {
$this->log('ERROR', "::error::No source/ or src/ directory in {$path}"); $this->log('ERROR', "::error::No src/ or htdocs/ directory in {$path}");
return 1; return 1;
} }
SourceResolver::warnIfLegacy($path);
// ── Find manifest ────────────────────────────────────────────────────── // ── Find manifest ──────────────────────────────────────────────────────
$manifest = $this->findManifest($srcDir); $manifest = $this->findManifest($srcDir);
+4 -4
View File
@@ -6,9 +6,9 @@
* SPDX-License-Identifier: GPL-3.0-or-later * SPDX-License-Identifier: GPL-3.0-or-later
* *
* FILE INFORMATION * FILE INFORMATION
* DEFGROUP: mokocli.CLI * DEFGROUP: moko-platform.CLI
* INGROUP: mokocli * INGROUP: moko-platform
* REPO: https://git.mokoconsulting.tech/MokoConsulting/mokocli * REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform
* PATH: /cli/joomla_compat_check.php * PATH: /cli/joomla_compat_check.php
* BRIEF: Check if extension targetplatform regex matches the latest Joomla version * BRIEF: Check if extension targetplatform regex matches the latest Joomla version
*/ */
@@ -17,7 +17,7 @@ declare(strict_types=1);
require_once __DIR__ . '/../lib/Enterprise/CliFramework.php'; require_once __DIR__ . '/../lib/Enterprise/CliFramework.php';
use MokoCli\CliFramework; use MokoEnterprise\CliFramework;
class JoomlaCompatCheckCli extends CliFramework class JoomlaCompatCheckCli extends CliFramework
{ {
-507
View File
@@ -1,507 +0,0 @@
#!/usr/bin/env php
<?php
/* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
*
* SPDX-License-Identifier: GPL-3.0-or-later
*
* FILE INFORMATION
* DEFGROUP: mokocli.CLI
* INGROUP: mokocli
* REPO: https://git.mokoconsulting.tech/MokoConsulting/mokocli
* PATH: /cli/joomla_metadata_validate.php
* VERSION: 09.38.00
* BRIEF: Validate MokoGitea repo metadata against Joomla extension manifest XML
*/
declare(strict_types=1);
require_once __DIR__ . '/../lib/Enterprise/CliFramework.php';
use MokoCli\CliFramework;
class JoomlaMetadataValidateCli extends CliFramework
{
/** Joomla element prefix map — must match MokoGitea's cleanJoomlaElement() */
private const JOOMLA_PREFIX = [
'package' => 'pkg_',
'component' => 'com_',
'module' => 'mod_',
'template' => 'tpl_',
'library' => 'lib_',
'file' => 'file_',
];
protected function configure(): void
{
$this->setDescription('Validate MokoGitea repo metadata against Joomla extension manifest XML');
$this->addArgument('--path', 'Repo root path (default: current directory)', '.');
$this->addArgument('--token', 'Gitea API token (or GITEA_TOKEN env)', '');
$this->addArgument('--org', 'Gitea org', 'MokoConsulting');
$this->addArgument('--repo', 'Repo name (auto-detected from git if empty)', '');
$this->addArgument('--api-base', 'Gitea API base URL', 'https://git.mokoconsulting.tech/api/v1');
$this->addArgument('--ci', 'CI mode: exit 1 on any error', false);
$this->addArgument('--json', 'Output as JSON', false);
}
protected function run(): int
{
$path = realpath($this->getArgument('--path')) ?: $this->getArgument('--path');
$token = $this->getArgument('--token') ?: getenv('GITEA_TOKEN') ?: '';
$org = $this->getArgument('--org');
$repoName = $this->getArgument('--repo');
$apiBase = rtrim($this->getArgument('--api-base'), '/');
$ciMode = (bool) $this->getArgument('--ci');
$jsonMode = (bool) $this->getArgument('--json');
if (!is_dir($path)) {
$this->log('ERROR', "Path does not exist: {$path}");
return 1;
}
if ($repoName === '') {
$repoName = $this->detectRepoName($path);
}
// ── Step 1: Find the Joomla extension manifest XML ──────────
$joomlaXml = $this->findJoomlaManifest($path);
if ($joomlaXml === null) {
$this->log('ERROR', 'No Joomla extension manifest XML found');
return 1;
}
$this->log('INFO', "Joomla manifest: {$joomlaXml['path']}");
// ── Step 2: Load MokoGitea metadata ─────────────────────────
$metadata = $this->loadMetadata($path, $org, $repoName, $token, $apiBase);
if ($metadata === null) {
$this->log('ERROR', 'Could not load MokoGitea metadata');
return 1;
}
// ── Step 3: Compare ─────────────────────────────────────────
$results = $this->compare($metadata, $joomlaXml, $path);
// ── Step 4: Output ──────────────────────────────────────────
if ($jsonMode) {
echo json_encode([
'repo' => $repoName,
'results' => $results,
], JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES) . "\n";
} else {
$this->printResults($repoName, $results);
}
$errors = count(array_filter($results, fn($r) => $r['status'] === 'error'));
return ($ciMode && $errors > 0) ? 1 : 0;
}
// =================================================================
// Find Joomla manifest XML
// =================================================================
private function findJoomlaManifest(string $root): ?array
{
// Search common locations for a Joomla extension manifest
$candidates = [];
// Package manifest: source/pkg_*.xml
foreach (glob("{$root}/source/pkg_*.xml") as $file) {
$candidates[] = $file;
}
// Component manifest: source/packages/com_*/[name].xml
foreach (glob("{$root}/source/packages/com_*/*.xml") as $file) {
$basename = basename($file);
// Skip access.xml, config.xml, etc.
if (in_array($basename, ['access.xml', 'config.xml'], true)) {
continue;
}
$candidates[] = $file;
}
// Direct source/*.xml
foreach (glob("{$root}/source/*.xml") as $file) {
if (basename($file) !== 'pkg_mokosuitebackup.xml') {
// Already caught above
}
$candidates[] = $file;
}
// src/ fallback
foreach (glob("{$root}/src/pkg_*.xml") as $file) {
$candidates[] = $file;
}
// Find the first one that has <extension type="...">
foreach (array_unique($candidates) as $file) {
$content = file_get_contents($file);
if ($content === false) {
continue;
}
if (preg_match('/<extension\s[^>]*type=["\']([^"\']+)["\']/', $content, $typeMatch)) {
$xml = @simplexml_load_string($content);
if ($xml === false) {
$relPath = str_replace($root . '/', '', $file);
$relPath = str_replace($root . '\\', '', $relPath);
$this->log('WARN', "Skipping {$relPath}: malformed XML");
continue;
}
$type = strtolower($typeMatch[1]);
$relPath = str_replace($root . '/', '', $file);
$relPath = str_replace($root . '\\', '', $relPath);
return [
'path' => $relPath,
'type' => $type,
'xml' => $xml,
];
}
}
return null;
}
// =================================================================
// Load metadata (from API)
// =================================================================
private function loadMetadata(string $root, string $org, string $repoName, string $token, string $apiBase): ?array
{
if ($token === '') {
$this->log('ERROR', 'No API token provided (use --token or set GITEA_TOKEN env var)');
return null;
}
$url = "{$apiBase}/repos/{$org}/{$repoName}/metadata";
$ctx = stream_context_create([
'http' => [
'header' => "Authorization: token {$token}\r\nAccept: application/json\r\n",
'timeout' => 10,
'ignore_errors' => true,
],
]);
$body = file_get_contents($url, false, $ctx);
// Extract HTTP status from response headers
$httpCode = 0;
if (isset($http_response_header[0]) && preg_match('/\d{3}/', $http_response_header[0], $m)) {
$httpCode = (int) $m[0];
}
if ($body === false) {
$this->log('ERROR', "Failed to connect to {$url} — check network or TLS configuration");
return null;
}
if ($httpCode === 404) {
$this->log('ERROR', "API endpoint not found: {$url}");
$this->log('ERROR', 'Server may need MokoGitea-Fork >= #650 (metadata endpoint rename)');
return null;
}
if ($httpCode === 401 || $httpCode === 403) {
$this->log('ERROR', "Authentication failed (HTTP {$httpCode}) — check your API token");
return null;
}
if ($httpCode >= 400) {
$this->log('ERROR', "API returned HTTP {$httpCode}: " . substr($body, 0, 200));
return null;
}
$data = json_decode($body, true);
if (!is_array($data)) {
$this->log('ERROR', "API returned invalid JSON from {$url}");
return null;
}
$data['source'] = 'api';
return $data;
}
// =================================================================
// Compare metadata against Joomla manifest
// =================================================================
private function compare(array $metadata, array $joomlaXml, string $root): array
{
$results = [];
$xml = $joomlaXml['xml'];
$type = $joomlaXml['type'];
// 1. Extension type
$metaType = $this->normalizeExtensionType(
$metadata['extension_type'] ?? $metadata['package_type'] ?? ''
);
$results[] = [
'field' => 'extension_type',
'metadata' => $metaType,
'joomla' => $type,
'status' => ($metaType === $type) ? 'ok' : 'error',
'message' => ($metaType === $type)
? "matches <extension type=\"{$type}\">"
: "metadata has \"{$metaType}\" but Joomla manifest has \"{$type}\"",
];
// 2. Element name
$metaName = strtolower($metadata['name'] ?? '');
$metaElement = $this->deriveElement($metaType, $metaName);
$joomlaElement = $this->extractJoomlaElement($xml, $type);
$elementMatch = ($metaElement === $joomlaElement);
$results[] = [
'field' => 'element',
'metadata' => $metaElement,
'joomla' => $joomlaElement,
'status' => $elementMatch ? 'ok' : 'error',
'message' => $elementMatch
? "derived correctly"
: "metadata derives \"{$metaElement}\" but Joomla uses \"{$joomlaElement}\"",
];
// 3. Version
$metaVersion = $metadata['version'] ?? '';
$joomlaVersion = (string) ($xml->version ?? '');
if ($metaVersion !== '' && $joomlaVersion !== '') {
// Strip dev/rc suffixes for comparison (CI bumps these)
$metaBase = preg_replace('/-(dev|rc|alpha|beta)\d*$/', '', $metaVersion);
$joomlaBase = preg_replace('/-(dev|rc|alpha|beta)\d*$/', '', $joomlaVersion);
$versionMatch = ($metaBase === $joomlaBase);
$results[] = [
'field' => 'version',
'metadata' => $metaVersion,
'joomla' => $joomlaVersion,
'status' => $versionMatch ? 'ok' : 'warn',
'message' => $versionMatch
? 'matches (base version)'
: "metadata has \"{$metaVersion}\" but Joomla has \"{$joomlaVersion}\"",
];
}
// 4. PHP minimum (from composer.json)
$composerPhp = $this->readComposerPhpRequirement($root);
$metaPhp = $metadata['php_minimum'] ?? '';
if ($composerPhp !== '' && $metaPhp !== '') {
$phpMatch = ($metaPhp === $composerPhp);
$results[] = [
'field' => 'php_minimum',
'metadata' => $metaPhp,
'joomla' => $composerPhp . ' (composer.json)',
'status' => $phpMatch ? 'ok' : 'warn',
'message' => $phpMatch
? 'matches composer.json'
: "metadata has \"{$metaPhp}\" but composer.json requires \"{$composerPhp}\"",
];
}
// 5. Description
$metaDesc = $metadata['description'] ?? '';
$joomlaDesc = (string) ($xml->description ?? '');
// Joomla descriptions are often language keys, skip those
if ($metaDesc !== '' && $joomlaDesc !== '' && !str_starts_with($joomlaDesc, 'COM_') && !str_starts_with($joomlaDesc, 'PKG_')) {
$descMatch = ($metaDesc === $joomlaDesc);
$results[] = [
'field' => 'description',
'metadata' => substr($metaDesc, 0, 60) . (strlen($metaDesc) > 60 ? '...' : ''),
'joomla' => substr($joomlaDesc, 0, 60) . (strlen($joomlaDesc) > 60 ? '...' : ''),
'status' => $descMatch ? 'ok' : 'info',
'message' => $descMatch ? 'matches' : 'descriptions differ (informational)',
];
}
return $results;
}
// =================================================================
// Helpers
// =================================================================
/**
* Normalize extension_type — map MokoGitea types to Joomla types.
*/
private function normalizeExtensionType(string $type): string
{
return match (strtolower($type)) {
'joomla-extension' => 'package', // legacy mapping
default => strtolower($type),
};
}
/**
* Derive the Joomla element name from type + name.
* Replicates MokoGitea's cleanJoomlaElement() + prefix logic.
*/
private function deriveElement(string $type, string $name): string
{
// Clean: lowercase, strip non-alphanumeric except . _ -
$clean = strtolower($name);
$clean = preg_replace('/[^a-z0-9._-]/', '', $clean);
$prefix = self::JOOMLA_PREFIX[$type] ?? '';
return $prefix . $clean;
}
/**
* Extract the element name from a Joomla manifest XML.
* Follows the same logic as Joomla's InstallerAdapter::getElement().
*/
private function extractJoomlaElement(\SimpleXMLElement $xml, string $type): string
{
switch ($type) {
case 'package':
$packagename = (string) ($xml->packagename ?? '');
if ($packagename !== '') {
return 'pkg_' . strtolower(preg_replace('/[^a-zA-Z0-9._-]/', '', $packagename));
}
break;
case 'component':
$element = (string) ($xml->element ?? '');
if ($element !== '') {
$element = strtolower($element);
return str_starts_with($element, 'com_') ? $element : 'com_' . $element;
}
$name = (string) ($xml->name ?? '');
$name = strtolower(preg_replace('/[^a-zA-Z0-9._-]/', '', $name));
return str_starts_with($name, 'com_') ? $name : 'com_' . $name;
case 'module':
$element = (string) ($xml->element ?? '');
if ($element !== '') {
return strtolower($element);
}
break;
case 'plugin':
// Plugins derive element from the file attribute
if (isset($xml->files)) {
foreach ($xml->files->children() as $file) {
$plugin = (string) ($file->attributes()->plugin ?? '');
if ($plugin !== '') {
return strtolower($plugin);
}
}
}
break;
case 'library':
$libname = (string) ($xml->libraryname ?? '');
if ($libname !== '') {
return strtolower($libname);
}
break;
}
// Fallback: use <name> tag
$name = (string) ($xml->name ?? '');
return strtolower(preg_replace('/[^a-zA-Z0-9._-]/', '', $name));
}
/**
* Read PHP version requirement from composer.json.
*/
private function readComposerPhpRequirement(string $root): string
{
$composerFile = "{$root}/composer.json";
if (!is_file($composerFile)) {
return '';
}
$data = json_decode(file_get_contents($composerFile), true);
if (!is_array($data)) {
return '';
}
$phpReq = $data['require']['php'] ?? '';
// Extract version number from constraint like ">=8.1"
if (preg_match('/(\d+\.\d+)/', $phpReq, $m)) {
return $m[1];
}
return '';
}
private function detectRepoName(string $root): string
{
$gitConfig = "{$root}/.git/config";
if (!file_exists($gitConfig)) {
return basename($root);
}
$content = file_get_contents($gitConfig);
if (preg_match('/url\s*=\s*.*\/([^\/\s]+?)(?:\.git)?\s*$/m', $content, $m)) {
return $m[1];
}
return basename($root);
}
// =================================================================
// Output
// =================================================================
private function printResults(string $repoName, array $results): void
{
$errors = count(array_filter($results, fn($r) => $r['status'] === 'error'));
$warns = count(array_filter($results, fn($r) => $r['status'] === 'warn'));
$oks = count(array_filter($results, fn($r) => $r['status'] === 'ok'));
$this->log('INFO', "Validating {$repoName} Joomla metadata...\n");
foreach ($results as $r) {
$icon = match ($r['status']) {
'ok' => "\xE2\x9C\x93", // ✓
'error' => "\xE2\x9C\x97", // ✗
'warn' => "\xE2\x9A\xA0", // ⚠
default => "\xE2\x84\xB9", //
};
$line = sprintf(
" %s %-16s %s",
$icon,
$r['field'],
$r['message']
);
$this->log(
match ($r['status']) {
'error' => 'ERROR',
'warn' => 'WARN',
'ok' => 'OK',
default => 'INFO',
},
$line
);
}
echo "\n";
if ($errors > 0) {
$this->log('ERROR', "{$errors} error(s) — update delivery will fail");
} elseif ($warns > 0) {
$this->log('WARN', "All critical checks passed, {$warns} warning(s)");
} else {
$this->log('OK', "All {$oks} checks passed");
}
}
}
$app = new JoomlaMetadataValidateCli();
exit($app->execute());
+8 -9
View File
@@ -8,9 +8,9 @@
* SPDX-License-Identifier: GPL-3.0-or-later * SPDX-License-Identifier: GPL-3.0-or-later
* *
* FILE INFORMATION * FILE INFORMATION
* DEFGROUP: mokocli.CLI * DEFGROUP: moko-platform.CLI
* INGROUP: mokocli * INGROUP: moko-platform
* REPO: https://git.mokoconsulting.tech/MokoConsulting/mokocli * REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform
* PATH: /cli/joomla_release.php * PATH: /cli/joomla_release.php
* BRIEF: Joomla release pipeline — build ZIP+tar.gz, upload to GitHub Release, update updates.xml * BRIEF: Joomla release pipeline — build ZIP+tar.gz, upload to GitHub Release, update updates.xml
* *
@@ -25,7 +25,7 @@ declare(strict_types=1);
require_once __DIR__ . '/../vendor/autoload.php'; require_once __DIR__ . '/../vendor/autoload.php';
use MokoCli\{ApiClient, AuditLogger, CliFramework, Config, PlatformAdapterFactory, SourceResolver}; use MokoEnterprise\{ApiClient, AuditLogger, CliFramework, Config, PlatformAdapterFactory};
/** /**
* Joomla Release Manager * Joomla Release Manager
@@ -57,7 +57,7 @@ class JoomlaRelease extends CliFramework
]; ];
private ApiClient $api; private ApiClient $api;
private \MokoCli\GitPlatformAdapter $adapter; private \MokoEnterprise\GitPlatformAdapter $adapter;
protected function configure(): void protected function configure(): void
{ {
@@ -121,12 +121,11 @@ class JoomlaRelease extends CliFramework
$this->log('INFO', "Version: {$displayVersion} | Release tag: {$releaseTag}"); $this->log('INFO', "Version: {$displayVersion} | Release tag: {$releaseTag}");
// ── Step 3: Build packages ──────────────────────────────────── // ── Step 3: Build packages ────────────────────────────────────
$srcDir = SourceResolver::resolveAbsolute($path); $srcDir = is_dir("{$path}/src") ? "{$path}/src" : (is_dir("{$path}/htdocs") ? "{$path}/htdocs" : null);
if ($srcDir === null) { if ($srcDir === null) {
$this->log('ERROR', 'No source/ or src/ directory'); $this->log('ERROR', 'No src/ or htdocs/ directory');
return 1; return 1;
} }
SourceResolver::warnIfLegacy($path);
$prefix = $this->typePrefix($meta); $prefix = $this->typePrefix($meta);
$zipName = "{$prefix}{$meta['element']}-{$displayVersion}.zip"; $zipName = "{$prefix}{$meta['element']}-{$displayVersion}.zip";
@@ -407,7 +406,7 @@ class JoomlaRelease extends CliFramework
$this->api->post("/repos/{$repo}/releases", [ $this->api->post("/repos/{$repo}/releases", [
'tag_name' => $tag, 'tag_name' => $tag,
'name' => $releaseName, 'name' => $releaseName,
'body' => "## {$version}\n\nCreated by mokocli release pipeline.", 'body' => "## {$version}\n\nCreated by moko-platform release pipeline.",
'prerelease' => ($stability !== 'stable'), 'prerelease' => ($stability !== 'stable'),
]); ]);
} }
+4 -4
View File
@@ -6,9 +6,9 @@
* SPDX-License-Identifier: GPL-3.0-or-later * SPDX-License-Identifier: GPL-3.0-or-later
* *
* FILE INFORMATION * FILE INFORMATION
* DEFGROUP: mokocli.CLI * DEFGROUP: moko-platform.CLI
* INGROUP: mokocli * INGROUP: moko-platform
* REPO: https://git.mokoconsulting.tech/MokoConsulting/mokocli * REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform
* PATH: /cli/license_manage.php * PATH: /cli/license_manage.php
* BRIEF: Manage license packages and keys via MokoGitea licensing API * BRIEF: Manage license packages and keys via MokoGitea licensing API
* *
@@ -28,7 +28,7 @@ declare(strict_types=1);
require_once __DIR__ . '/../vendor/autoload.php'; require_once __DIR__ . '/../vendor/autoload.php';
require_once __DIR__ . '/../lib/Enterprise/CliFramework.php'; require_once __DIR__ . '/../lib/Enterprise/CliFramework.php';
use MokoCli\CliFramework; use MokoEnterprise\CliFramework;
class LicenseManage extends CliFramework class LicenseManage extends CliFramework
{ {
-749
View File
@@ -1,749 +0,0 @@
#!/usr/bin/env php
<?php
/* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
*
* SPDX-License-Identifier: GPL-3.0-or-later
*
* FILE INFORMATION
* DEFGROUP: mokocli.CLI
* INGROUP: mokocli
* REPO: https://git.mokoconsulting.tech/MokoConsulting/mokocli
* PATH: /cli/manifest_detect.php
* VERSION: 09.38.00
* BRIEF: Auto-detect manifest fields from source files and optionally push to API
*/
declare(strict_types=1);
require_once __DIR__ . '/../lib/Enterprise/CliFramework.php';
use MokoCli\{CliFramework, SourceResolver};
class ManifestDetectCli extends CliFramework
{
protected function configure(): void
{
$this->setDescription('Auto-detect manifest fields from source files');
$this->addArgument('--path', 'Repository root path', '.');
$this->addArgument('--json', 'Output as JSON', false);
$this->addArgument('--diff', 'Show diff against current manifest API values', false);
$this->addArgument('--update', 'Push detected fields to manifest API', false);
$this->addArgument('--token', 'Gitea API token (or GITEA_TOKEN env)', '');
$this->addArgument('--api-base', 'Gitea API base URL', 'https://git.mokoconsulting.tech/api/v1');
$this->addArgument('--org', 'Gitea org', 'MokoConsulting');
$this->addArgument('--repo', 'Gitea repo name (auto-detected from remote if empty)', '');
$this->addArgument('--github-output', 'Append fields to $GITHUB_OUTPUT', false);
}
protected function run(): int
{
$path = $this->getArgument('--path');
$jsonMode = (bool) $this->getArgument('--json');
$diffMode = (bool) $this->getArgument('--diff');
$updateMode = (bool) $this->getArgument('--update');
$ghOutput = (bool) $this->getArgument('--github-output');
$token = $this->getArgument('--token') ?: getenv('GITEA_TOKEN') ?: '';
$apiBase = rtrim($this->getArgument('--api-base'), '/');
$org = $this->getArgument('--org');
$repoName = $this->getArgument('--repo');
$root = realpath($path) ?: $path;
if (!is_dir($root)) {
$this->log('ERROR', "Path does not exist: {$path}");
return 1;
}
// Auto-detect repo name from git remote
if ($repoName === '') {
$repoName = $this->detectRepoName($root);
}
// ── Detect all fields ───────────────────────────────────────
$detected = $this->detectAll($root, $repoName);
// ── Warn about missing fields ────────────────────────────────
$expected = ['platform', 'name', 'version', 'package_type', 'language', 'entry_point'];
foreach ($expected as $field) {
if (!isset($detected[$field]) || $detected[$field] === '') {
$this->log('WARN', "Could not detect: {$field}");
}
}
// ── Output ──────────────────────────────────────────────────
if ($diffMode || $updateMode) {
if ($token === '') {
$this->log('ERROR', 'API token required for --diff/--update (use --token or GITEA_TOKEN env)');
return 1;
}
if ($repoName === '') {
$this->log('ERROR', 'Could not determine repo name (use --repo)');
return 1;
}
$current = $this->fetchManifest($apiBase, $org, $repoName, $token);
if ($current === null) {
$this->log('ERROR', 'Failed to fetch current manifest from API');
return 1;
}
$changes = $this->computeDiff($current, $detected);
if ($diffMode) {
if (empty($changes)) {
$this->log('INFO', 'No differences — manifest matches source');
} else {
$this->sectionHeader('Manifest Drift');
foreach ($changes as $field => $info) {
$this->log('WARN', sprintf(
'%-20s API: %-30s Detected: %s',
$field,
$info['current'] === '' ? '(empty)' : $info['current'],
$info['detected']
));
}
}
}
if ($updateMode) {
if (empty($changes)) {
$this->log('INFO', 'Nothing to update');
} else {
$update = array_map(fn($i) => $i['detected'], $changes);
$ok = $this->pushManifest($apiBase, $org, $repoName, $token, $current, $update);
if ($ok) {
$this->log('OK', 'Updated ' . count($update) . ' field(s): ' . implode(', ', array_keys($update)));
} else {
$this->log('ERROR', 'Failed to push manifest update');
return 1;
}
}
}
return 0;
}
if ($ghOutput) {
$outputFile = getenv('GITHUB_OUTPUT');
$lines = [];
foreach ($detected as $k => $v) {
$envKey = str_replace('-', '_', $k);
$lines[] = "{$envKey}={$v}";
}
if ($outputFile !== false && $outputFile !== '') {
file_put_contents($outputFile, implode("\n", $lines) . "\n", FILE_APPEND);
$this->log('INFO', 'Wrote ' . count($detected) . ' fields to GITHUB_OUTPUT');
} else {
$this->log('WARN', 'GITHUB_OUTPUT not set — printing to stdout instead');
echo implode("\n", $lines) . "\n";
}
return 0;
}
if ($jsonMode) {
echo json_encode($detected, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES) . "\n";
} else {
foreach ($detected as $k => $v) {
echo "{$k}={$v}\n";
}
}
return 0;
}
// =====================================================================
// Detection engine
// =====================================================================
private function detectAll(string $root, string $repoName): array
{
$platform = $this->detectPlatform($root);
$fields = [
'platform' => $platform,
'name' => '',
'description' => '',
'version' => '',
'element_name' => '',
'package_type' => '',
'language' => '',
'entry_point' => '',
'license_spdx' => '',
'display_name' => '',
'target_version' => '',
'php_minimum' => '',
];
switch ($platform) {
case 'joomla':
$this->detectJoomla($root, $repoName, $fields);
break;
case 'dolibarr':
$this->detectDolibarr($root, $repoName, $fields);
break;
case 'go':
$this->detectGo($root, $repoName, $fields);
break;
case 'mcp':
$this->detectNode($root, $repoName, $fields);
break;
case 'node':
$this->detectNode($root, $repoName, $fields);
$fields['platform'] = 'node';
break;
default:
$this->detectGeneric($root, $repoName, $fields);
break;
}
// Fallbacks
if ($fields['name'] === '') {
$fields['name'] = $repoName ?: basename($root);
}
if ($fields['entry_point'] === '') {
$fields['entry_point'] = $this->detectEntryPoint($root);
}
if ($fields['license_spdx'] === '') {
$fields['license_spdx'] = $this->detectLicense($root);
}
// description: only from platform-specific source, never guessed
// Strip empty values
return array_filter($fields, fn($v) => $v !== '');
}
// ── Platform detection ──────────────────────────────────────────
private function detectPlatform(string $root): string
{
// Joomla: look for pkg_*.xml or extension XML in source dirs
$joomlaXmls = array_merge(
SourceResolver::globSource($root, 'pkg_*.xml'),
glob("{$root}/pkg_*.xml") ?: []
);
if (!empty($joomlaXmls)) {
return 'joomla';
}
// Check source dirs for any Joomla extension XML
foreach (SourceResolver::globSource($root, '*.xml') as $xmlFile) {
$content = file_get_contents($xmlFile);
if (strpos($content, '<extension') !== false) {
return 'joomla';
}
}
// Dolibarr: mod*.class.php with DolibarrModules
$modFiles = array_merge(
SourceResolver::globSource($root, 'core/modules/mod*.class.php'),
glob("{$root}/core/modules/mod*.class.php") ?: []
);
foreach ($modFiles as $file) {
if (strpos(file_get_contents($file), 'DolibarrModules') !== false) {
return 'dolibarr';
}
}
// Go
if (file_exists("{$root}/go.mod")) {
return 'go';
}
// MCP: package.json with mcp-related content
if (file_exists("{$root}/package.json")) {
$pkg = json_decode(file_get_contents("{$root}/package.json"), true) ?? [];
$deps = array_merge(
array_keys($pkg['dependencies'] ?? []),
array_keys($pkg['devDependencies'] ?? [])
);
foreach ($deps as $dep) {
if (strpos($dep, '@modelcontextprotocol/') === 0 || $dep === '@anthropic/mcp-sdk') {
return 'mcp';
}
}
return 'node';
}
// Python
if (file_exists("{$root}/pyproject.toml") || file_exists("{$root}/setup.py")) {
return 'python';
}
return 'generic';
}
// ── Joomla ──────────────────────────────────────────────────────
private function detectJoomla(string $root, string $repoName, array &$fields): void
{
$fields['language'] = 'PHP';
// Find the primary extension manifest XML
$extManifest = $this->findJoomlaManifest($root);
if ($extManifest === null) {
return;
}
$xml = file_get_contents($extManifest);
// Type
$extType = '';
if (preg_match('/type="([^"]*)"/', $xml, $m)) {
$extType = $m[1];
}
$fields['package_type'] = $extType;
// Element name
$element = '';
if (preg_match('/<element>([^<]+)<\/element>/', $xml, $m)) {
$element = $m[1];
}
if ($element === '' && preg_match('/module="([^"]*)"/', $xml, $m)) {
$element = $m[1];
}
if ($element === '' && preg_match('/plugin="([^"]*)"/', $xml, $m)) {
$element = $m[1];
}
if ($extType === 'package' && preg_match('/<packagename>([^<]+)<\/packagename>/', $xml, $m)) {
$element = $m[1];
}
if ($element === '') {
$element = strtolower(basename($extManifest, '.xml'));
}
// Ensure element has type prefix (API stores full element_name like pkg_mokosuite)
$prefixMap = [
'package' => 'pkg_', 'component' => 'com_', 'module' => 'mod_',
'template' => 'tpl_', 'library' => 'lib_', 'file' => 'file_',
];
if (isset($prefixMap[$extType])) {
$prefix = $prefixMap[$extType];
// Only add prefix if not already present (check all known prefixes)
$hasPrefix = false;
foreach ($prefixMap as $p) {
if (strpos($element, $p) === 0) { $hasPrefix = true; break; }
}
if (strpos($element, 'plg_') === 0) { $hasPrefix = true; }
if (!$hasPrefix) {
$element = $prefix . $element;
}
} elseif ($extType === 'plugin') {
$folder = '';
if (preg_match('/group="([^"]*)"/', $xml, $gm)) {
$folder = $gm[1];
}
if ($folder !== '' && strpos($element, 'plg_') !== 0) {
$element = "plg_{$folder}_" . $element;
}
}
$fields['element_name'] = $element;
// Name
if (preg_match('/<name>([^<]+)<\/name>/', $xml, $m)) {
$fields['name'] = trim($m[1]);
}
// Version
if (preg_match('/<version>([^<]+)<\/version>/', $xml, $m)) {
$fields['version'] = trim($m[1]);
}
// Description
if (preg_match('/<description>([^<]+)<\/description>/', $xml, $m)) {
$desc = trim($m[1]);
// Skip language string keys like COM_MOKOSUITE_DESCRIPTION
if (strpos($desc, '_') === false || strlen($desc) > 60) {
$fields['description'] = $desc;
}
}
// Display name for update feeds
if (!empty($fields['name'])) {
$name = $fields['name'];
// If name already has "Type - " prefix, use as-is
if (preg_match('/^(Package|Component|Module|Plugin|Template|Library)\s*-\s*/i', $name)) {
$fields['display_name'] = $name;
} elseif (!empty($extType)) {
$fields['display_name'] = ucfirst($extType) . ' - ' . $name;
}
}
// Target Joomla version
if (preg_match('/<targetplatform\s[^>]*version="([^"]+)"/', $xml, $m)) {
$fields['target_version'] = trim($m[1]);
} else {
// Default for Joomla 5/6
$fields['target_version'] = '(5|6)\..*';
}
// PHP minimum
if (preg_match('/<php_minimum>([^<]+)<\/php_minimum>/', $xml, $m)) {
$fields['php_minimum'] = trim($m[1]);
}
// License
if (preg_match('/<license>([^<]+)<\/license>/', $xml, $m)) {
$fields['license_spdx'] = $this->normalizeLicense(trim($m[1]));
}
}
private function findJoomlaManifest(string $root): ?string
{
// Priority: pkg_*.xml (package manifest)
$pkgXmls = array_merge(
SourceResolver::globSource($root, 'pkg_*.xml'),
glob("{$root}/pkg_*.xml") ?: []
);
if (!empty($pkgXmls)) {
return $pkgXmls[0];
}
// Any extension XML in source dir
foreach (SourceResolver::globSource($root, '*.xml') as $file) {
$content = file_get_contents($file);
if (strpos($content, '<extension') !== false) {
return $file;
}
}
// Root level
foreach (glob("{$root}/*.xml") ?: [] as $file) {
$content = file_get_contents($file);
if (strpos($content, '<extension') !== false) {
return $file;
}
}
return null;
}
// ── Dolibarr ────────────────────────────────────────────────────
private function detectDolibarr(string $root, string $repoName, array &$fields): void
{
$fields['language'] = 'PHP';
$fields['package_type'] = 'dolibarr-module';
$modFile = $this->findDolibarrModule($root);
if ($modFile === null) {
return;
}
$content = file_get_contents($modFile);
// Element name from class file
$modBasename = basename($modFile, '.class.php');
$fields['element_name'] = strtolower(preg_replace('/^mod/', '', $modBasename));
// Name
if (preg_match('/\$this->name\s*=\s*[\'"]([^\'"]+)[\'"]/', $content, $m)) {
$fields['name'] = $m[1];
}
// Version
if (preg_match('/\$this->version\s*=\s*[\'"]([^\'"]+)[\'"]/', $content, $m)) {
$fields['version'] = $m[1];
}
// Description
if (preg_match('/\$this->description\s*=\s*[\'"]([^\'"]+)[\'"]/', $content, $m)) {
$desc = $m[1];
if (strpos($desc, '$') === false) {
$fields['description'] = $desc;
}
}
// License
if (preg_match('/SPDX-License-Identifier:\s*(\S+)/', $content, $m)) {
$fields['license_spdx'] = $m[1];
}
}
private function findDolibarrModule(string $root): ?string
{
$candidates = array_merge(
SourceResolver::globSource($root, 'core/modules/mod*.class.php'),
glob("{$root}/core/modules/mod*.class.php") ?: []
);
foreach ($candidates as $file) {
if (strpos(file_get_contents($file), 'DolibarrModules') !== false) {
return $file;
}
}
return null;
}
// ── Go ──────────────────────────────────────────────────────────
private function detectGo(string $root, string $repoName, array &$fields): void
{
$fields['language'] = 'Go';
$fields['package_type'] = 'application';
$fields['entry_point'] = './';
$goMod = "{$root}/go.mod";
if (!file_exists($goMod)) {
return;
}
$content = file_get_contents($goMod);
// Module path → name
if (preg_match('/^module\s+(\S+)/m', $content, $m)) {
$modulePath = $m[1];
$parts = explode('/', $modulePath);
$fields['name'] = end($parts);
}
// Go version
if (preg_match('/^go\s+(\S+)/m', $content, $m)) {
// This is Go language version, not the project version
// Project version comes from git tags or source files
}
// License
$fields['license_spdx'] = $this->detectLicense($root);
}
// ── Node / MCP ──────────────────────────────────────────────────
private function detectNode(string $root, string $repoName, array &$fields): void
{
$pkgFile = "{$root}/package.json";
if (!file_exists($pkgFile)) {
return;
}
$pkg = json_decode(file_get_contents($pkgFile), true) ?? [];
$fields['name'] = $pkg['name'] ?? '';
// Strip npm scope
if (strpos($fields['name'], '/') !== false) {
$fields['name'] = explode('/', $fields['name'])[1];
}
$fields['version'] = $pkg['version'] ?? '';
$fields['description'] = $pkg['description'] ?? '';
$fields['license_spdx'] = $pkg['license'] ?? '';
// Language detection
if (file_exists("{$root}/tsconfig.json")) {
$fields['language'] = 'TypeScript';
} else {
$fields['language'] = 'JavaScript';
}
// Package type
$deps = array_merge(
array_keys($pkg['dependencies'] ?? []),
array_keys($pkg['devDependencies'] ?? [])
);
$isMcp = false;
foreach ($deps as $dep) {
if (strpos($dep, '@modelcontextprotocol/') === 0 || $dep === '@anthropic/mcp-sdk') {
$isMcp = true;
break;
}
}
$fields['package_type'] = $isMcp ? 'mcp-server' : 'application';
// Entry point
if (file_exists("{$root}/dist")) {
$fields['entry_point'] = 'dist/';
} elseif (file_exists("{$root}/src")) {
$fields['entry_point'] = 'src/';
} else {
$fields['entry_point'] = './';
}
}
// ── Generic ─────────────────────────────────────────────────────
private function detectGeneric(string $root, string $repoName, array &$fields): void
{
$fields['package_type'] = 'generic';
// Try to detect language from file extensions
$fields['language'] = $this->detectLanguageFromFiles($root);
$fields['license_spdx'] = $this->detectLicense($root);
}
// =====================================================================
// Shared detection helpers
// =====================================================================
private function detectEntryPoint(string $root): string
{
$abs = SourceResolver::resolveAbsolute($root);
if ($abs !== null) {
return basename($abs) . '/';
}
if (is_dir("{$root}/dist")) return 'dist/';
if (is_dir("{$root}/src")) return 'src/';
return './';
}
private function detectLicense(string $root): string
{
// Check LICENSE file
foreach (['LICENSE', 'LICENSE.md', 'LICENSE.txt', 'COPYING'] as $name) {
$file = "{$root}/{$name}";
if (!file_exists($file)) continue;
$content = file_get_contents($file);
// SPDX header
if (preg_match('/SPDX-License-Identifier:\s*(\S+)/', $content, $m)) {
return $m[1];
}
// Common license patterns
if (strpos($content, 'GNU GENERAL PUBLIC LICENSE') !== false) {
if (strpos($content, 'Version 3') !== false) return 'GPL-3.0-or-later';
if (strpos($content, 'Version 2') !== false) return 'GPL-2.0-or-later';
}
if (strpos($content, 'MIT License') !== false) return 'MIT';
if (strpos($content, 'Apache License') !== false && strpos($content, 'Version 2.0') !== false) return 'Apache-2.0';
}
return '';
}
private function detectLanguageFromFiles(string $root): string
{
$counts = ['PHP' => 0, 'Go' => 0, 'TypeScript' => 0, 'JavaScript' => 0, 'Python' => 0, 'Shell' => 0];
$extensions = [
'php' => 'PHP', 'go' => 'Go', 'ts' => 'TypeScript',
'js' => 'JavaScript', 'py' => 'Python', 'sh' => 'Shell',
];
// Quick scan: only check top two levels
foreach (glob("{$root}/*") ?: [] as $item) {
$ext = pathinfo($item, PATHINFO_EXTENSION);
if (isset($extensions[$ext])) {
$counts[$extensions[$ext]]++;
}
if (is_dir($item) && basename($item)[0] !== '.') {
foreach (glob("{$item}/*") ?: [] as $subItem) {
$ext = pathinfo($subItem, PATHINFO_EXTENSION);
if (isset($extensions[$ext])) {
$counts[$extensions[$ext]]++;
}
}
}
}
arsort($counts);
$top = key($counts);
return $counts[$top] > 0 ? $top : '';
}
private function normalizeLicense(string $license): string
{
$lower = strtolower($license);
$isGpl = strpos($lower, 'gpl') !== false || strpos($lower, 'general public license') !== false;
if ($isGpl && strpos($lower, '3') !== false) return 'GPL-3.0-or-later';
if ($isGpl && strpos($lower, '2') !== false) return 'GPL-2.0-or-later';
if ($lower === 'mit' || strpos($lower, 'mit license') !== false) return 'MIT';
if (strpos($lower, 'apache') !== false) return 'Apache-2.0';
return $license;
}
private function detectRepoName(string $root): string
{
$gitConfig = "{$root}/.git/config";
if (!file_exists($gitConfig)) {
return basename($root);
}
$content = file_get_contents($gitConfig);
if (preg_match('/url\s*=\s*.*\/([^\/\s]+?)(?:\.git)?\s*$/m', $content, $m)) {
return $m[1];
}
return basename($root);
}
// =====================================================================
// API interaction
// =====================================================================
private function fetchManifest(string $apiBase, string $org, string $repo, string $token): ?array
{
$url = "{$apiBase}/repos/{$org}/{$repo}/manifest";
$ctx = stream_context_create([
'http' => [
'header' => "Authorization: token {$token}\r\nAccept: application/json\r\n",
'timeout' => 10,
],
]);
$body = @file_get_contents($url, false, $ctx);
if ($body === false) return null;
return json_decode($body, true);
}
private function computeDiff(array $current, array $detected): array
{
// Map detected keys to API keys (underscores match)
$changes = [];
foreach ($detected as $key => $value) {
$apiKey = $key;
$currentVal = $current[$apiKey] ?? '';
// Only flag as changed if detected value is non-empty and differs
if ($value !== '' && $value !== $currentVal) {
// Don't overwrite a non-empty API value with a detected value
// unless the API value is actually empty
if ($currentVal === '' || $this->shouldOverride($key, $currentVal, $value)) {
$changes[$key] = [
'current' => $currentVal,
'detected' => $value,
];
}
}
}
return $changes;
}
private function shouldOverride(string $field, string $current, string $detected): bool
{
// Version: detected from source is authoritative
if ($field === 'version') return true;
// These fields: source files are authoritative
if (in_array($field, ['element_name', 'package_type', 'language', 'entry_point'], true)) {
return true;
}
// For other fields, only fill empty — don't overwrite manual edits
return false;
}
private function pushManifest(string $apiBase, string $org, string $repo, string $token, array $current, array $update): bool
{
$merged = array_merge($current, $update);
$url = "{$apiBase}/repos/{$org}/{$repo}/manifest";
$payload = json_encode($merged);
$ctx = stream_context_create([
'http' => [
'method' => 'PUT',
'header' => "Authorization: token {$token}\r\nContent-Type: application/json\r\nAccept: application/json\r\n",
'content' => $payload,
'timeout' => 10,
],
]);
$body = @file_get_contents($url, false, $ctx);
return $body !== false;
}
}
$app = new ManifestDetectCli();
exit($app->execute());
+7 -6
View File
@@ -6,9 +6,9 @@
* SPDX-License-Identifier: GPL-3.0-or-later * SPDX-License-Identifier: GPL-3.0-or-later
* *
* FILE INFORMATION * FILE INFORMATION
* DEFGROUP: mokocli.CLI * DEFGROUP: moko-platform.CLI
* INGROUP: mokocli * INGROUP: moko-platform
* REPO: https://git.mokoconsulting.tech/MokoConsulting/mokocli * REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform
* PATH: /cli/manifest_element.php * PATH: /cli/manifest_element.php
* BRIEF: Extract element name, type, type prefix, and ZIP name from manifest * BRIEF: Extract element name, type, type prefix, and ZIP name from manifest
*/ */
@@ -17,7 +17,7 @@ declare(strict_types=1);
require_once __DIR__ . '/../lib/Enterprise/CliFramework.php'; require_once __DIR__ . '/../lib/Enterprise/CliFramework.php';
use MokoCli\{CliFramework, SourceResolver}; use MokoEnterprise\CliFramework;
class ManifestElementCli extends CliFramework class ManifestElementCli extends CliFramework
{ {
@@ -48,7 +48,7 @@ class ManifestElementCli extends CliFramework
} }
} }
$extManifest = null; $extManifest = null;
$manifestFiles = array_merge(SourceResolver::globSource($root, 'pkg_*.xml'), SourceResolver::globSource($root, '*.xml'), glob("{$root}/*.xml") ?: []); $manifestFiles = array_merge(glob("{$root}/src/pkg_*.xml") ?: [], glob("{$root}/src/*.xml") ?: [], glob("{$root}/*.xml") ?: []);
foreach ($manifestFiles as $file) { foreach ($manifestFiles as $file) {
$c = file_get_contents($file); $c = file_get_contents($file);
if (strpos($c, '<extension') !== false) { if (strpos($c, '<extension') !== false) {
@@ -58,7 +58,8 @@ class ManifestElementCli extends CliFramework
} }
$modFile = null; $modFile = null;
$modFiles = array_merge( $modFiles = array_merge(
SourceResolver::globSource($root, 'core/modules/mod*.class.php'), glob("{$root}/src/core/modules/mod*.class.php") ?: [],
glob("{$root}/htdocs/core/modules/mod*.class.php") ?: [],
glob("{$root}/core/modules/mod*.class.php") ?: [] glob("{$root}/core/modules/mod*.class.php") ?: []
); );
foreach ($modFiles as $file) { foreach ($modFiles as $file) {
-564
View File
@@ -1,564 +0,0 @@
#!/usr/bin/env php
<?php
/* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
*
* SPDX-License-Identifier: GPL-3.0-or-later
*
* FILE INFORMATION
* DEFGROUP: mokocli.CLI
* INGROUP: mokocli
* REPO: https://git.mokoconsulting.tech/MokoConsulting/mokocli
* PATH: /cli/manifest_integrity.php
* VERSION: 09.38.00
* BRIEF: Cross-check manifest API fields against repo contents across the org
*/
declare(strict_types=1);
require_once __DIR__ . '/../lib/Enterprise/CliFramework.php';
use MokoCli\CliFramework;
class ManifestIntegrityCli extends CliFramework
{
protected function configure(): void
{
$this->setDescription('Cross-check manifest fields against repo contents across the org');
$this->addArgument('--path', 'Single repo path (local mode)', '');
$this->addArgument('--org', 'Gitea org (bulk mode)', 'MokoConsulting');
$this->addArgument('--repo', 'Single repo name (remote mode)', '');
$this->addArgument('--token', 'Gitea API token (or GITEA_TOKEN env)', '');
$this->addArgument('--api-base', 'Gitea API base URL', 'https://git.mokoconsulting.tech/api/v1');
$this->addArgument('--fix', 'Push fixes for detected drift', false);
$this->addArgument('--json', 'Output as JSON', false);
$this->addArgument('--quiet', 'Only show repos with issues', false);
}
protected function run(): int
{
$path = $this->getArgument('--path');
$org = $this->getArgument('--org');
$repoName = $this->getArgument('--repo');
$token = $this->getArgument('--token') ?: getenv('GITEA_TOKEN') ?: '';
$apiBase = rtrim($this->getArgument('--api-base'), '/');
$fixMode = (bool) $this->getArgument('--fix');
$jsonMode = (bool) $this->getArgument('--json');
$quiet = (bool) $this->getArgument('--quiet');
if ($token === '') {
$this->log('ERROR', 'API token required (use --token or GITEA_TOKEN env)');
return 1;
}
// ── Mode selection ──────────────────────────────────────────
if ($path !== '') {
// Local mode: detect from source + compare to API
return $this->checkLocal($path, $org, $repoName, $token, $apiBase, $fixMode, $jsonMode);
}
if ($repoName !== '') {
// Single remote repo
return $this->checkRemoteRepo($org, $repoName, $token, $apiBase, $fixMode, $jsonMode);
}
// Bulk mode: all repos in org
return $this->checkOrg($org, $token, $apiBase, $fixMode, $jsonMode, $quiet);
}
// =====================================================================
// Local mode — detect from source, compare to API
// =====================================================================
private function checkLocal(string $path, string $org, string $repoName, string $token, string $apiBase, bool $fix, bool $json): int
{
$root = realpath($path) ?: $path;
if (!is_dir($root)) {
$this->log('ERROR', "Path does not exist: {$path}");
return 1;
}
if ($repoName === '') {
$repoName = $this->detectRepoName($root);
}
// Run manifest_detect logic
$detected = $this->runDetect($root, $repoName);
$current = $this->fetchManifest($apiBase, $org, $repoName, $token);
if ($current === null) {
$this->log('ERROR', "Failed to fetch manifest for {$org}/{$repoName}");
return 1;
}
$issues = $this->validate($current, $detected, $repoName);
if ($json) {
echo json_encode(['repo' => $repoName, 'issues' => $issues], JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES) . "\n";
} else {
$this->printIssues($repoName, $issues);
}
if ($fix && !empty($issues)) {
return $this->applyFixes($apiBase, $org, $repoName, $token, $current, $issues);
}
return empty($issues) ? 0 : 1;
}
// =====================================================================
// Remote single repo mode — fetch source files via API
// =====================================================================
private function checkRemoteRepo(string $org, string $repoName, string $token, string $apiBase, bool $fix, bool $json): int
{
$current = $this->fetchManifest($apiBase, $org, $repoName, $token);
if ($current === null) {
$this->log('ERROR', "Failed to fetch manifest for {$org}/{$repoName}");
return 1;
}
$issues = $this->validateManifestOnly($current, $repoName);
if ($json) {
echo json_encode(['repo' => $repoName, 'issues' => $issues], JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES) . "\n";
} else {
$this->printIssues($repoName, $issues);
}
if ($fix && !empty($issues)) {
return $this->applyFixes($apiBase, $org, $repoName, $token, $current, $issues);
}
return empty($issues) ? 0 : 1;
}
// =====================================================================
// Bulk org mode — check all repos
// =====================================================================
private function checkOrg(string $org, string $token, string $apiBase, bool $fix, bool $json, bool $quiet): int
{
$repos = $this->fetchOrgRepos($apiBase, $org, $token);
if ($repos === null) {
$this->log('ERROR', "Failed to fetch repos for org {$org}");
return 1;
}
$this->log('INFO', "Manifest Integrity Check — {$org} (" . count($repos) . " repos)");
$allResults = [];
$totalIssues = 0;
$reposWithIssues = 0;
foreach ($repos as $repo) {
$name = $repo['name'];
$manifest = $this->fetchManifest($apiBase, $org, $name, $token);
if ($manifest === null) {
if (!$quiet) {
$this->log('WARN', "{$name}: no manifest");
}
continue;
}
$issues = $this->validateManifestOnly($manifest, $name);
if (!empty($issues)) {
$reposWithIssues++;
$totalIssues += count($issues);
if ($json) {
$allResults[] = ['repo' => $name, 'issues' => $issues];
} else {
$this->printIssues($name, $issues);
}
if ($fix) {
$this->applyFixes($apiBase, $org, $name, $token, $manifest, $issues);
}
} elseif (!$quiet && !$json) {
$this->log('OK', "{$name}: clean");
}
}
if ($json) {
echo json_encode($allResults, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES) . "\n";
} else {
echo "\n";
$level = $reposWithIssues > 0 ? 'WARN' : 'OK';
$this->log($level, sprintf(
'Summary: %d repos checked, %d with issues (%d total issues)',
count($repos),
$reposWithIssues,
$totalIssues
));
}
return $reposWithIssues > 0 ? 1 : 0;
}
// =====================================================================
// Validation rules
// =====================================================================
/**
* Full validation: compare API manifest against locally-detected fields.
*/
private function validate(array $current, array $detected, string $repoName): array
{
$issues = [];
// Required fields that should never be empty
$required = ['platform', 'name', 'version', 'package_type', 'language', 'entry_point'];
foreach ($required as $field) {
if (empty($current[$field])) {
$fix = $detected[$field] ?? null;
$issues[] = [
'field' => $field,
'severity' => 'error',
'message' => 'Missing required field',
'current' => '',
'fix' => $fix,
];
}
}
// Drift detection: detected value differs from API
foreach ($detected as $field => $detectedValue) {
$currentValue = $current[$field] ?? '';
if ($detectedValue !== '' && $currentValue !== '' && $detectedValue !== $currentValue) {
// Version drift is expected on dev branches (suffix)
if ($field === 'version' && strpos($detectedValue, $currentValue) === 0) {
continue; // e.g., detected "02.34.50-dev" vs API "02.34.50"
}
if ($field === 'version' && strpos($currentValue, $detectedValue) === 0) {
continue;
}
$issues[] = [
'field' => $field,
'severity' => 'warn',
'message' => 'Drift: source differs from manifest',
'current' => $currentValue,
'fix' => $detectedValue,
];
}
}
// Platform-specific structure validation
$platform = $current['platform'] ?? '';
$issues = array_merge($issues, $this->validatePlatformStructure($platform, $current, $repoName));
return $issues;
}
/**
* API-only validation: check manifest fields for completeness and consistency
* without access to source files.
*/
private function validateManifestOnly(array $manifest, string $repoName): array
{
$issues = [];
// Required fields
$required = ['platform', 'name', 'version', 'language'];
foreach ($required as $field) {
if (empty($manifest[$field])) {
$issues[] = [
'field' => $field,
'severity' => 'error',
'message' => 'Missing required field',
'current' => '',
'fix' => null,
];
}
}
// Recommended fields
$recommended = ['package_type', 'entry_point', 'license_spdx', 'description'];
foreach ($recommended as $field) {
if (empty($manifest[$field])) {
$issues[] = [
'field' => $field,
'severity' => 'info',
'message' => 'Recommended field is empty',
'current' => '',
'fix' => null,
];
}
}
// Platform-specific checks
$platform = $manifest['platform'] ?? '';
$issues = array_merge($issues, $this->validatePlatformStructure($platform, $manifest, $repoName));
return $issues;
}
/**
* Platform-specific validation rules.
*/
private function validatePlatformStructure(string $platform, array $manifest, string $repoName): array
{
$issues = [];
switch ($platform) {
case 'joomla':
case 'waas-component':
// Joomla repos must have element_name
if (empty($manifest['element_name'])) {
$issues[] = [
'field' => 'element_name',
'severity' => 'error',
'message' => 'Joomla repos require element_name',
'current' => '',
'fix' => null,
];
}
// Language should be PHP
if (!empty($manifest['language']) && $manifest['language'] !== 'PHP') {
$issues[] = [
'field' => 'language',
'severity' => 'warn',
'message' => 'Joomla repos should have language=PHP',
'current' => $manifest['language'],
'fix' => 'PHP',
];
}
break;
case 'dolibarr':
case 'crm-module':
if (!empty($manifest['language']) && $manifest['language'] !== 'PHP') {
$issues[] = [
'field' => 'language',
'severity' => 'warn',
'message' => 'Dolibarr repos should have language=PHP',
'current' => $manifest['language'],
'fix' => 'PHP',
];
}
break;
case 'go':
if (!empty($manifest['language']) && $manifest['language'] !== 'Go') {
$issues[] = [
'field' => 'language',
'severity' => 'warn',
'message' => 'Go repos should have language=Go',
'current' => $manifest['language'],
'fix' => 'Go',
];
}
break;
case 'mcp':
if (!empty($manifest['language']) && !in_array($manifest['language'], ['TypeScript', 'JavaScript'], true)) {
$issues[] = [
'field' => 'language',
'severity' => 'warn',
'message' => 'MCP repos should have language=TypeScript or JavaScript',
'current' => $manifest['language'],
'fix' => null,
];
}
break;
}
// Version format check: should be XX.YY.ZZ
$version = $manifest['version'] ?? '';
if ($version !== '' && !preg_match('/^\d{2}\.\d{2}\.\d{2}/', $version)) {
// Allow semver for node/go repos
if (!in_array($platform, ['mcp', 'node', 'go'], true)) {
$issues[] = [
'field' => 'version',
'severity' => 'info',
'message' => 'Version does not match XX.YY.ZZ format',
'current' => $version,
'fix' => null,
];
}
}
return $issues;
}
// =====================================================================
// Output
// =====================================================================
private function printIssues(string $repoName, array $issues): void
{
if (empty($issues)) {
return;
}
$errors = count(array_filter($issues, fn($i) => $i['severity'] === 'error'));
$warns = count(array_filter($issues, fn($i) => $i['severity'] === 'warn'));
$infos = count($issues) - $errors - $warns;
echo "\n";
$summary = [];
if ($errors > 0) $summary[] = "{$errors} error(s)";
if ($warns > 0) $summary[] = "{$warns} warning(s)";
if ($infos > 0) $summary[] = "{$infos} info";
$this->log($errors > 0 ? 'ERROR' : 'WARN', "{$repoName}" . implode(', ', $summary));
foreach ($issues as $issue) {
$icon = match ($issue['severity']) {
'error' => 'ERROR',
'warn' => 'WARN',
default => 'INFO',
};
$msg = sprintf(' %-18s %s', $issue['field'], $issue['message']);
if ($issue['current'] !== '') {
$msg .= " (current: {$issue['current']})";
}
if ($issue['fix'] !== null) {
$msg .= " → fix: {$issue['fix']}";
}
$this->log($icon, $msg);
}
}
// =====================================================================
// Fix application
// =====================================================================
private function applyFixes(string $apiBase, string $org, string $repo, string $token, array $current, array $issues): int
{
$fixes = [];
foreach ($issues as $issue) {
if ($issue['fix'] !== null && $issue['fix'] !== '') {
$fixes[$issue['field']] = $issue['fix'];
}
}
if (empty($fixes)) {
$this->log('INFO', "{$repo}: no auto-fixable issues");
return 0;
}
$merged = array_merge($current, $fixes);
$url = "{$apiBase}/repos/{$org}/{$repo}/manifest";
$payload = json_encode($merged);
$ctx = stream_context_create([
'http' => [
'method' => 'PUT',
'header' => "Authorization: token {$token}\r\nContent-Type: application/json\r\nAccept: application/json\r\n",
'content' => $payload,
'timeout' => 10,
],
]);
$body = @file_get_contents($url, false, $ctx);
if ($body === false) {
$this->log('ERROR', "{$repo}: failed to push fixes");
return 1;
}
$this->log('OK', "{$repo}: fixed " . implode(', ', array_keys($fixes)));
return 0;
}
// =====================================================================
// API helpers
// =====================================================================
private function fetchManifest(string $apiBase, string $org, string $repo, string $token): ?array
{
$url = "{$apiBase}/repos/{$org}/{$repo}/manifest";
$ctx = stream_context_create([
'http' => [
'header' => "Authorization: token {$token}\r\nAccept: application/json\r\n",
'timeout' => 10,
],
]);
$body = @file_get_contents($url, false, $ctx);
if ($body === false) return null;
$data = json_decode($body, true);
return is_array($data) ? $data : null;
}
private function fetchOrgRepos(string $apiBase, string $org, string $token): ?array
{
$allRepos = [];
$page = 1;
$limit = 50;
while (true) {
$url = "{$apiBase}/orgs/{$org}/repos?page={$page}&limit={$limit}";
$ctx = stream_context_create([
'http' => [
'header' => "Authorization: token {$token}\r\nAccept: application/json\r\n",
'timeout' => 15,
],
]);
$body = @file_get_contents($url, false, $ctx);
if ($body === false) return null;
$repos = json_decode($body, true);
if (!is_array($repos) || empty($repos)) break;
$allRepos = array_merge($allRepos, $repos);
if (count($repos) < $limit) break;
$page++;
}
// Filter out archived and empty repos
return array_filter($allRepos, fn($r) => !($r['archived'] ?? false) && !($r['empty'] ?? false));
}
// =====================================================================
// Detection (delegates to manifest_detect logic)
// =====================================================================
private function runDetect(string $root, string $repoName): array
{
$script = __DIR__ . '/manifest_detect.php';
$redirect = PHP_OS_FAMILY === 'Windows' ? '2>NUL' : '2>/dev/null';
$cmd = sprintf(
'php %s --path %s --repo %s --json --quiet %s',
escapeshellarg($script),
escapeshellarg($root),
escapeshellarg($repoName),
$redirect
);
$output = shell_exec($cmd) ?? '';
// Extract JSON object from output (skip banner/log lines)
if (preg_match('/\{[^{}]*(?:\{[^{}]*\}[^{}]*)*\}/s', $output, $m)) {
$data = json_decode($m[0], true);
if (is_array($data)) {
return $data;
}
}
return [];
}
private function detectRepoName(string $root): string
{
$gitConfig = "{$root}/.git/config";
if (!file_exists($gitConfig)) {
return basename($root);
}
$content = file_get_contents($gitConfig);
if (preg_match('/url\s*=\s*.*\/([^\/\s]+?)(?:\.git)?\s*$/m', $content, $m)) {
return $m[1];
}
return basename($root);
}
}
$app = new ManifestIntegrityCli();
exit($app->execute());
-280
View File
@@ -1,280 +0,0 @@
#!/usr/bin/env php
<?php
/* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
*
* SPDX-License-Identifier: GPL-3.0-or-later
*
* FILE INFORMATION
* DEFGROUP: mokocli.CLI
* INGROUP: mokocli
* REPO: https://git.mokoconsulting.tech/MokoConsulting/mokocli
* PATH: /cli/manifest_licensing.php
* VERSION: 09.38.00
* BRIEF: Ensure licensing tags (updateservers, dlid) in Joomla extension manifests
*/
declare(strict_types=1);
require_once __DIR__ . '/../lib/Enterprise/CliFramework.php';
use MokoCli\{CliFramework, SourceResolver};
/**
* Reads the <licensing> block from .mokogitea/manifest.xml and ensures that the
* Joomla extension manifest contains the correct <updateservers> and <dlid> tags.
*
* manifest.xml licensing block example:
*
* <licensing>
* <enabled>true</enabled>
* <dlid>true</dlid>
* <update-server>https://git.mokoconsulting.tech/{org}/{repo}/updates.xml</update-server>
* <update-server-name>MyExtension Updates</update-server-name>
* </licensing>
*
* Supports {org} and {repo} placeholders in update-server URL, resolved from
* the manifest's <identity> block or git remote.
*/
class ManifestLicensingCli extends CliFramework
{
protected function configure(): void
{
$this->setDescription('Ensure licensing tags (updateservers, dlid) in Joomla extension manifests');
$this->addArgument('--path', 'Repository root path', '.');
$this->addArgument('--fix', 'Apply fixes (default: dry-run check only)', false);
$this->addArgument('--github-output', 'Write results to $GITHUB_OUTPUT', false);
}
protected function run(): int
{
$root = realpath($this->getArgument('--path')) ?: $this->getArgument('--path');
$fix = (bool) $this->getArgument('--fix');
$ghOutput = (bool) $this->getArgument('--github-output');
// ── 1. Read manifest.xml ──────────────────────────────────────────
$manifestFile = "{$root}/.mokogitea/manifest.xml";
if (!file_exists($manifestFile)) {
$this->log('WARN', "No manifest.xml found at {$manifestFile}");
$this->outputResult($ghOutput, 'skipped', 'No manifest.xml');
return 0;
}
$xml = @simplexml_load_file($manifestFile);
if ($xml === false) {
$this->log('ERROR', "Failed to parse {$manifestFile}");
return 1;
}
// ── 2. Check if licensing is enabled ──────────────────────────────
if (!isset($xml->licensing) || (string) ($xml->licensing->enabled ?? '') !== 'true') {
$this->log('INFO', 'Licensing not enabled in manifest.xml — skipping');
$this->outputResult($ghOutput, 'skipped', 'Licensing not enabled');
return 0;
}
$licensingNode = $xml->licensing;
$dlidEnabled = ((string) ($licensingNode->dlid ?? 'true')) === 'true';
$updateServerUrl = (string) ($licensingNode->{'update-server'} ?? '');
$updateServerName = (string) ($licensingNode->{'update-server-name'} ?? '');
// ── 3. Resolve placeholders ───────────────────────────────────────
$org = (string) ($xml->identity->org ?? '');
$repo = (string) ($xml->identity->name ?? '');
// Fallback to git remote if manifest doesn't have org/name
if (empty($org) || empty($repo)) {
$remote = trim((string) @shell_exec("cd " . escapeshellarg($root) . " && git remote get-url origin 2>/dev/null"));
if (preg_match('#[/:]([^/]+)/([^/.]+?)(?:\.git)?$#', $remote, $m)) {
if (empty($org)) {
$org = $m[1];
}
if (empty($repo)) {
$repo = $m[2];
}
}
}
// Default update server URL if not specified
if (empty($updateServerUrl) && !empty($org) && !empty($repo)) {
$updateServerUrl = "https://git.mokoconsulting.tech/{$org}/{$repo}/updates.xml";
}
// Resolve {org} and {repo} placeholders
$updateServerUrl = str_replace(['{org}', '{repo}'], [$org, $repo], $updateServerUrl);
// Default server name from display-name or repo name
if (empty($updateServerName)) {
$displayName = (string) ($xml->identity->{'display-name'} ?? $repo);
$updateServerName = $displayName . ' Updates';
}
if (empty($updateServerUrl)) {
$this->log('ERROR', 'Cannot determine update server URL — set <update-server> in manifest.xml or ensure org/repo are available');
return 1;
}
$this->log('INFO', "Licensing enabled — org={$org}, repo={$repo}");
$this->log('INFO', "Update server: {$updateServerUrl}");
$this->log('INFO', "DLID required: " . ($dlidEnabled ? 'yes' : 'no'));
// ── 4. Find Joomla extension manifests ────────────────────────────
$xmlFiles = array_merge(
SourceResolver::globSource($root, '*.xml'),
SourceResolver::globSource($root, 'packages/*/*.xml'),
glob("{$root}/*.xml") ?: []
);
$packageManifest = null;
foreach ($xmlFiles as $file) {
$content = file_get_contents($file);
if (!str_contains($content, '<extension')) {
continue;
}
// Find the package manifest (type="package") or the main extension manifest
if (str_contains($content, 'type="package"')) {
$packageManifest = $file;
break;
}
// Fallback: first extension manifest found
if ($packageManifest === null) {
$packageManifest = $file;
}
}
if ($packageManifest === null) {
$this->log('WARN', 'No Joomla extension manifest found');
$this->outputResult($ghOutput, 'skipped', 'No extension manifest');
return 0;
}
$relPath = str_replace($root . '/', '', str_replace('\\', '/', $packageManifest));
$this->log('INFO', "Package manifest: {$relPath}");
// ── 5. Check and fix the manifest ─────────────────────────────────
$content = file_get_contents($packageManifest);
$original = $content;
$changes = [];
// --- 5a. Ensure <updateservers> block with correct URL ---
if (preg_match('#<updateservers>\s*</updateservers>#s', $content)) {
// Empty updateservers block — inject the server
$replacement = "<updateservers>\n"
. " <server type=\"extension\" name=\"{$updateServerName}\">{$updateServerUrl}</server>\n"
. " </updateservers>";
$content = preg_replace('#<updateservers>\s*</updateservers>#s', $replacement, $content);
$changes[] = 'Added update server URL to empty <updateservers>';
} elseif (!str_contains($content, '<updateservers>')) {
// No updateservers at all — add before </extension>
$serverBlock = "\n <updateservers>\n"
. " <server type=\"extension\" name=\"{$updateServerName}\">{$updateServerUrl}</server>\n"
. " </updateservers>\n";
$content = str_replace('</extension>', $serverBlock . '</extension>', $content);
$changes[] = 'Added <updateservers> block';
} else {
// updateservers exists — verify URL is correct
if (preg_match('#<server[^>]*>([^<]+)</server>#', $content, $m)) {
if ($m[1] !== $updateServerUrl) {
$content = preg_replace(
'#(<server[^>]*>)[^<]+(</server>)#',
"\${1}{$updateServerUrl}\${2}",
$content
);
$changes[] = "Updated server URL: {$m[1]}{$updateServerUrl}";
}
}
}
// --- 5b. Ensure <dlid> tag if required ---
if ($dlidEnabled) {
if (!str_contains($content, '<dlid')) {
// Add before <updateservers> if present, otherwise before </extension>
$dlidTag = ' <dlid prefix="dlid=" suffix=""/>' . "\n";
if (str_contains($content, '<updateservers>')) {
$content = str_replace('<updateservers>', $dlidTag . "\n <updateservers>", $content);
} else {
$content = str_replace('</extension>', $dlidTag . '</extension>', $content);
}
$changes[] = 'Added <dlid> tag';
}
}
// --- 5c. Ensure <blockChildUninstall> for packages ---
if (str_contains($content, 'type="package"') && !str_contains($content, '<blockChildUninstall>')) {
$blockTag = ' <blockChildUninstall>true</blockChildUninstall>' . "\n";
if (str_contains($content, '<dlid')) {
// Add after <dlid>
$content = preg_replace(
'#(<dlid[^/]*/>\s*\n)#',
"\${1}{$blockTag}",
$content
);
} elseif (str_contains($content, '<updateservers>')) {
$content = str_replace('<updateservers>', $blockTag . "\n <updateservers>", $content);
} else {
$content = str_replace('</extension>', $blockTag . '</extension>', $content);
}
$changes[] = 'Added <blockChildUninstall>true</blockChildUninstall>';
}
// ── 6. Report and apply ───────────────────────────────────────────
if (empty($changes)) {
$this->log('INFO', 'All licensing tags are correct — no changes needed');
$this->outputResult($ghOutput, 'ok', 'No changes needed');
return 0;
}
foreach ($changes as $change) {
$this->log($fix ? 'INFO' : 'WARN', ($fix ? 'Fixed: ' : 'Needs fix: ') . $change);
}
if ($fix) {
file_put_contents($packageManifest, $content);
$this->log('INFO', "Wrote {$relPath} with " . count($changes) . " change(s)");
$this->outputResult($ghOutput, 'fixed', implode('; ', $changes));
} else {
$this->log('WARN', 'Run with --fix to apply changes');
$this->outputResult($ghOutput, 'needs-fix', implode('; ', $changes));
return 1;
}
return 0;
}
/**
* Write result to $GITHUB_OUTPUT if requested.
*/
private function outputResult(bool $ghOutput, string $status, string $detail): void
{
if (!$ghOutput) {
return;
}
$outputFile = getenv('GITHUB_OUTPUT');
if ($outputFile === false || $outputFile === '') {
echo "licensing_status={$status}\n";
echo "licensing_detail={$detail}\n";
return;
}
$fh = fopen($outputFile, 'a');
fwrite($fh, "licensing_status={$status}\n");
fwrite($fh, "licensing_detail={$detail}\n");
fclose($fh);
}
}
$app = new ManifestLicensingCli();
exit($app->execute());
+94 -398
View File
@@ -6,431 +6,126 @@
* SPDX-License-Identifier: GPL-3.0-or-later * SPDX-License-Identifier: GPL-3.0-or-later
* *
* FILE INFORMATION * FILE INFORMATION
* DEFGROUP: mokocli.CLI * DEFGROUP: moko-platform.CLI
* INGROUP: mokocli * INGROUP: moko-platform
* REPO: https://git.mokoconsulting.tech/MokoConsulting/mokocli * REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform
* PATH: /cli/manifest_read.php * PATH: /cli/manifest_read.php
* VERSION: 09.38.00 * VERSION: 09.24.00
* BRIEF: Read repo metadata from Gitea manifest API, auto-detect the rest * BRIEF: Parse .manifest.xml and output requested field(s) for CI consumption
*/ */
declare(strict_types=1); declare(strict_types=1);
require_once __DIR__ . '/../lib/Enterprise/CliFramework.php'; require_once __DIR__ . '/../lib/Enterprise/CliFramework.php';
use MokoCli\CliFramework; use MokoEnterprise\CliFramework;
class ManifestReadCli extends CliFramework class ManifestReadCli extends CliFramework
{ {
/** Joomla extension XML element names searched in root and source/ dirs. */
private const JOOMLA_XML_ROOTS = ['extension', 'install'];
protected function configure(): void protected function configure(): void
{ {
$this->setDescription('Read repo metadata from Gitea API with auto-detection fallback'); $this->setDescription('Parse manifest.xml and output requested field(s) for CI consumption');
$this->addArgument('--path', 'Repository root path', '.'); $this->addArgument('--path', 'Repository root path', '.');
$this->addArgument('--field', 'Single field name to output', ''); $this->addArgument('--field', 'Single field name to output', '');
$this->addArgument('--all', 'Print all fields as KEY=VALUE lines', false); $this->addArgument('--all', 'Print all fields as KEY=VALUE lines', false);
$this->addArgument('--github-output', 'Append all fields to $GITHUB_OUTPUT', false); $this->addArgument('--github-output', 'Append all fields to $GITHUB_OUTPUT', false);
$this->addArgument('--json', 'Output all fields as JSON', false); $this->addArgument('--json', 'Output all fields as JSON', false);
} }
protected function run(): int protected function run(): int
{ {
$path = $this->getArgument('--path'); $path = $this->getArgument('--path');
$field = $this->getArgument('--field'); $field = $this->getArgument('--field');
$showAll = $this->getArgument('--all'); $showAll = $this->getArgument('--all');
$ghOut = $this->getArgument('--github-output'); $ghOutput = $this->getArgument('--github-output');
$jsonMode = $this->getArgument('--json'); $jsonMode = $this->getArgument('--json');
$mode = match (true) { // Determine mode
(bool) $ghOut => 'github-output', if ($ghOutput) {
(bool) $showAll => 'all', $mode = 'github-output';
(bool) $jsonMode => 'json', } elseif ($showAll) {
default => 'field', $mode = 'all';
}; } elseif ($jsonMode) {
$mode = 'json';
} else {
$mode = 'field';
}
// -- Locate manifest --
$root = realpath($path) ?: $path; $root = realpath($path) ?: $path;
$manifestFile = null;
// ── 1. Resolve org/repo ────────────────────────────────────────── // Priority: manifest.xml (current standard)
[$org, $repo] = $this->resolveOrgRepo($root); $candidates = [
"{$root}/.mokogitea/manifest.xml",
// ── 2. Primary: Gitea manifest API ─────────────────────────────── "{$root}/.mokogitea/.manifest.xml", // legacy (dot-prefixed)
$fields = null; "{$root}/.mokogitea/.moko-platform", // legacy v4
if ($org !== '' && $repo !== '') {
$fields = $this->fetchFromApi($org, $repo);
}
// ── 3. Fallback: auto-detect from source tree ────────────────────
if ($fields === null) {
$this->log('INFO', 'API unavailable — falling back to source-tree detection');
$fields = $this->autoDetect($root, $repo);
}
if (empty($fields)) {
$this->log('ERROR', "Could not resolve metadata for {$root}");
return 1;
}
// Provide backward-compatible aliases (hyphenated → underscore)
$fields = $this->addAliases($fields);
// Strip empty values
$fields = array_filter($fields, fn($v) => $v !== '' && $v !== null);
// ── 4. Output ────────────────────────────────────────────────────
return $this->outputFields($fields, $mode, $field);
}
// ── Gitea manifest API ───────────────────────────────────────────────
private function fetchFromApi(string $org, string $repo): ?array
{
$token = getenv('GA_TOKEN') ?: getenv('GITEA_TOKEN') ?: '';
$baseUrl = getenv('GITEA_URL') ?: 'https://git.mokoconsulting.tech';
$baseUrl = rtrim($baseUrl, '/');
if ($token === '') {
return null;
}
$url = "{$baseUrl}/api/v1/repos/{$org}/{$repo}/manifest";
$ctx = stream_context_create([
'http' => [
'header' => "Authorization: token {$token}\r\nAccept: application/json\r\n",
'timeout' => 10,
'ignore_errors' => true,
],
]);
$body = @file_get_contents($url, false, $ctx);
if ($body === false) {
return null;
}
// Check HTTP status from response headers
$status = 0;
if (isset($http_response_header[0])) {
preg_match('/\d{3}/', $http_response_header[0], $m);
$status = (int) ($m[0] ?? 0);
}
if ($status < 200 || $status >= 300) {
return null;
}
$data = json_decode($body, true);
if (!is_array($data) || empty($data)) {
return null;
}
$this->log('INFO', "Loaded metadata from Gitea manifest API ({$org}/{$repo})");
return $data;
}
// ── Auto-detection fallback ──────────────────────────────────────────
private function autoDetect(string $root, string $repoName): array
{
$fields = [
'name' => $repoName ?: basename($root),
'org' => 'MokoConsulting',
]; ];
// Resolve source directory (source/ or src/) foreach ($candidates as $candidate) {
$srcDir = null; if (file_exists($candidate)) {
foreach (['source', 'src'] as $candidate) { $manifestFile = $candidate;
if (is_dir("{$root}/{$candidate}")) {
$srcDir = $candidate;
break; break;
} }
} }
// ── Try Joomla detection ───────────────────────────────────── if ($manifestFile === null) {
$joomlaResult = $this->detectJoomla($root, $srcDir); $this->log('ERROR', "No manifest found in {$root}");
if ($joomlaResult !== null) { return 1;
$fields = array_merge($fields, $joomlaResult);
$this->log('INFO', "Auto-detected platform: joomla ({$fields['extension_type']}{$fields['element_name']})");
return $fields;
} }
// ── Try Dolibarr detection ─────────────────────────────────── // -- Parse XML --
$dolibarrResult = $this->detectDolibarr($root); $xml = @simplexml_load_file($manifestFile);
if ($dolibarrResult !== null) {
$fields = array_merge($fields, $dolibarrResult);
$this->log('INFO', "Auto-detected platform: dolibarr");
return $fields;
}
// ── Generic fallback ───────────────────────────────────────── if ($xml === false) {
$fields['platform'] = $this->detectGenericPlatform($root); // Fallback: try YAML format (.mokostandards legacy)
$fields['element_name'] = strtolower($fields['name']); $content = file_get_contents($manifestFile);
$fields['extension_type'] = 'application'; $fields = [];
$fields['language'] = $this->detectLanguage($root); if (preg_match('/^platform:\s*(.+)/m', $content, $m)) {
if ($srcDir !== null) { $fields['platform'] = trim($m[1], " \t\n\r\"'");
$fields['entry_point'] = "{$srcDir}/";
}
$this->log('INFO', "Auto-detected platform: {$fields['platform']}");
return $fields;
}
/**
* Detect Joomla platform by scanning for extension XML manifests.
*
* Searches root and source/ dirs for XML files containing <extension type="...">.
* Extracts element name from the filename (pkg_*, com_*, mod_*, plg_*, tpl_*) or
* from the <element> tag inside the manifest.
*/
private function detectJoomla(string $root, ?string $srcDir): ?array
{
$searchDirs = [$root];
if ($srcDir !== null) {
$searchDirs[] = "{$root}/{$srcDir}";
}
foreach ($searchDirs as $dir) {
$xmlFiles = glob("{$dir}/*.xml") ?: [];
foreach ($xmlFiles as $xmlFile) {
$content = @file_get_contents($xmlFile);
if ($content === false) {
continue;
}
// Match <extension type="component|module|plugin|package|template|file|library">
if (!preg_match('/<extension\s+[^>]*type="([^"]+)"/', $content, $typeMatch)) {
// Also try legacy <install type="...">
if (!preg_match('/<install\s+[^>]*type="([^"]+)"/', $content, $typeMatch)) {
continue;
}
}
$extType = strtolower($typeMatch[1]);
$basename = pathinfo($xmlFile, PATHINFO_FILENAME);
// Try to extract element name from XML <element> tag
$xml = @simplexml_load_string($content);
$element = '';
if ($xml !== false) {
// Package manifests have <files><file ...>element</file></files>
// Component/module manifests have <element> or use filename
$element = (string) ($xml->element ?? '');
if ($element === '') {
$element = strtolower($basename);
}
} else {
$element = strtolower($basename);
}
// Derive display name
$displayName = (string) ($xml->name ?? ucfirst(str_replace('_', ' ', $basename)));
return [
'platform' => 'joomla',
'extension_type' => $extType,
'element_name' => $element,
'display_name' => $displayName,
'language' => 'PHP',
'entry_point' => ($srcDir ?? '.') . '/',
];
} }
if (preg_match('/^standards_version:\s*(.+)/m', $content, $m)) {
// Also check for pkg_*.xml pattern specifically $fields['standards-version'] = trim($m[1], " \t\n\r\"'");
$pkgFiles = glob("{$dir}/pkg_*.xml") ?: [];
if (!empty($pkgFiles)) {
$basename = pathinfo($pkgFiles[0], PATHINFO_FILENAME);
return [
'platform' => 'joomla',
'extension_type' => 'package',
'element_name' => strtolower($basename),
'display_name' => ucfirst(str_replace('_', ' ', $basename)),
'language' => 'PHP',
'entry_point' => ($srcDir ?? '.') . '/',
];
} }
} if (preg_match('/^governed_repo:\s*(.+)/m', $content, $m)) {
$fields['name'] = trim($m[1], " \t\n\r\"'");
// Check for com_*/manifest.xml pattern (component subdirectory)
$comDirs = glob("{$root}/com_*", GLOB_ONLYDIR) ?: [];
foreach ($comDirs as $comDir) {
$comManifest = glob("{$comDir}/*.xml") ?: [];
foreach ($comManifest as $xmlFile) {
$content = @file_get_contents($xmlFile);
if ($content && preg_match('/<extension\s+[^>]*type="component"/', $content)) {
return [
'platform' => 'joomla',
'extension_type' => 'component',
'element_name' => strtolower(basename($comDir)),
'display_name' => ucfirst(str_replace('com_', '', basename($comDir))),
'language' => 'PHP',
'entry_point' => ($srcDir ?? '.') . '/',
];
}
} }
} } else {
// Register namespace for XPath (optional, simple path works without)
return null; $fields = [
} 'name' => (string)($xml->identity->name ?? ''),
'display-name' => (string)($xml->identity->{"display-name"} ?? ''),
/** 'org' => (string)($xml->identity->org ?? ''),
* Detect Dolibarr platform by scanning for module descriptor files. 'description' => (string)($xml->identity->description ?? ''),
*/ 'license' => (string)($xml->identity->license ?? ''),
private function detectDolibarr(string $root): ?array 'license-spdx' => (string)($xml->identity->license['spdx'] ?? ''),
{ 'platform' => (string)($xml->governance->platform ?? ''),
// Look for mod*.class.php containing DolibarrModules 'standards-version' => (string)($xml->governance->{"standards-version"} ?? ''),
$searchPaths = [ 'standards-source' => (string)($xml->governance->{"standards-source"} ?? ''),
"{$root}/core/modules/mod*.class.php", 'language' => (string)($xml->build->language ?? ''),
"{$root}/*/core/modules/mod*.class.php", 'package-type' => (string)($xml->build->{"package-type"} ?? ''),
]; 'entry-point' => (string)($xml->build->{"entry-point"} ?? ''),
'version' => (string)($xml->identity->version ?? ''),
foreach ($searchPaths as $pattern) { 'source-dir' => (string)($xml->deploy->{"source-dir"} ?? ''),
$files = glob($pattern) ?: []; 'remote-subdir' => (string)($xml->deploy->{"remote-subdir"} ?? ''),
foreach ($files as $file) { 'excludes' => (string)($xml->deploy->excludes ?? ''),
$content = @file_get_contents($file); 'dev-host' => (string)($xml->deploy->{"dev-host"} ?? ''),
if ($content && str_contains($content, 'DolibarrModules')) { 'demo-host' => (string)($xml->deploy->{"demo-host"} ?? ''),
$modName = pathinfo($file, PATHINFO_FILENAME); 'manifest-file' => $manifestFile,
// modMyModule.class → mymodule
$element = strtolower(preg_replace('/^mod/', '', str_replace('.class', '', $modName)));
return [
'platform' => 'dolibarr',
'extension_type' => 'module',
'element_name' => $element,
'display_name' => ucfirst($element),
'language' => 'PHP',
'entry_point' => './',
];
}
}
}
// Secondary: check for update.txt (Dolibarr marker)
if (file_exists("{$root}/update.txt")) {
return [
'platform' => 'dolibarr',
'extension_type' => 'module',
'element_name' => strtolower(basename($root)),
'display_name' => basename($root),
'language' => 'PHP',
'entry_point' => './',
]; ];
} }
return null; // Strip empty values for cleaner output
} $fields = array_filter($fields, fn($v) => $v !== '');
/** // -- Output --
* Detect generic platform type (php, nodejs, python, etc.) from project files.
*/
private function detectGenericPlatform(string $root): string
{
if (file_exists("{$root}/composer.json")) {
return 'php';
}
if (file_exists("{$root}/package.json")) {
return 'nodejs';
}
if (file_exists("{$root}/pyproject.toml") || file_exists("{$root}/setup.py")) {
return 'python';
}
if (file_exists("{$root}/go.mod")) {
return 'go';
}
if (file_exists("{$root}/Cargo.toml")) {
return 'rust';
}
return 'generic';
}
/**
* Detect primary language from project files.
*/
private function detectLanguage(string $root): string
{
if (file_exists("{$root}/composer.json")) {
return 'PHP';
}
if (file_exists("{$root}/tsconfig.json")) {
return 'TypeScript';
}
if (file_exists("{$root}/package.json")) {
return 'JavaScript';
}
if (file_exists("{$root}/pyproject.toml") || file_exists("{$root}/setup.py")) {
return 'Python';
}
return '';
}
// ── Org/repo resolution ──────────────────────────────────────────────
/**
* Resolve org and repo name from environment or git remote.
*
* @return array{0: string, 1: string} [org, repo]
*/
private function resolveOrgRepo(string $root): array
{
// 1. GITHUB_REPOSITORY env (set in Gitea Actions / GitHub Actions)
$envRepo = getenv('GITHUB_REPOSITORY') ?: '';
if ($envRepo !== '' && str_contains($envRepo, '/')) {
return explode('/', $envRepo, 2);
}
// 2. Parse git remote origin URL
$remoteUrl = trim((string) shell_exec(
'git -C ' . escapeshellarg($root) . ' remote get-url origin 2>/dev/null'
));
if ($remoteUrl !== '') {
// SSH: git@host:Org/Repo.git or HTTPS: https://host/Org/Repo.git
if (preg_match('#[/:]([^/]+)/([^/]+?)(?:\.git)?$#', $remoteUrl, $m)) {
return [$m[1], $m[2]];
}
}
return ['', basename($root)];
}
// ── Backward-compatible aliases ──────────────────────────────────────
/**
* Add hyphenated aliases for underscore fields (backward compat with old manifest.xml consumers).
* Also map old field names to new ones.
*/
private function addAliases(array $fields): array
{
// Map API field names → old manifest.xml hyphenated names
$aliases = [
'display_name' => 'display-name',
'license_spdx' => 'license-spdx',
'license_name' => 'license',
'standards_version' => 'standards-version',
'standards_source' => 'standards-source',
'extension_type' => 'package-type',
'entry_point' => 'entry-point',
'element_name' => 'name',
];
foreach ($aliases as $newKey => $oldKey) {
if (isset($fields[$newKey]) && !isset($fields[$oldKey])) {
$fields[$oldKey] = $fields[$newKey];
}
}
return $fields;
}
// ── Output ───────────────────────────────────────────────────────────
private function outputFields(array $fields, string $mode, string $field): int
{
switch ($mode) { switch ($mode) {
case 'field': case 'field':
if ($field === '') { if ($field === '') {
$this->log('ERROR', "Usage: manifest:read --path <dir> --field <name>"); $this->log('ERROR', "Usage: manifest_read.php --path <dir> --field <name>");
$this->log('ERROR', " manifest:read --path <dir> --all"); $this->log('ERROR', " manifest_read.php --path <dir> --all");
$this->log('ERROR', " manifest:read --path <dir> --json"); $this->log('ERROR', " manifest_read.php --path <dir> --json");
$this->log('ERROR', " manifest:read --path <dir> --github-output"); $this->log('ERROR', " manifest_read.php --path <dir> --github-output");
return 2; return 2;
} }
echo ($fields[$field] ?? '') . "\n"; echo ($fields[$field] ?? '') . "\n";
@@ -447,21 +142,22 @@ class ManifestReadCli extends CliFramework
break; break;
case 'github-output': case 'github-output':
$outputFile = getenv('GITHUB_OUTPUT') ?: getenv('GITEA_OUTPUT') ?: ''; $outputFile = getenv('GITHUB_OUTPUT');
$lines = []; if ($outputFile === false || $outputFile === '') {
foreach ($fields as $k => $v) { $this->log('ERROR', 'GITHUB_OUTPUT not set — printing to stdout instead');
$envKey = str_replace('-', '_', $k); foreach ($fields as $k => $v) {
$lines[$envKey] = "{$envKey}={$v}\n"; // Convert field-name to FIELD_NAME for env var style
} $envKey = str_replace('-', '_', $k);
// Deduplicate (aliases may collide after underscore conversion) echo "{$envKey}={$v}\n";
$output = implode('', $lines); }
if ($outputFile === '') {
$this->log('WARNING', 'GITHUB_OUTPUT not set — printing to stdout');
echo $output;
} else { } else {
file_put_contents($outputFile, $output, FILE_APPEND); $fh = fopen($outputFile, 'a');
$this->log('INFO', "Wrote " . count($lines) . " fields to GITHUB_OUTPUT"); foreach ($fields as $k => $v) {
$envKey = str_replace('-', '_', $k);
fwrite($fh, "{$envKey}={$v}\n");
}
fclose($fh);
$this->log('INFO', "Wrote " . count($fields) . " fields to GITHUB_OUTPUT");
} }
break; break;
} }
+12 -7
View File
@@ -6,9 +6,9 @@
* SPDX-License-Identifier: GPL-3.0-or-later * SPDX-License-Identifier: GPL-3.0-or-later
* *
* FILE INFORMATION * FILE INFORMATION
* DEFGROUP: mokocli.CLI * DEFGROUP: moko-platform.CLI
* INGROUP: mokocli * INGROUP: moko-platform
* REPO: https://git.mokoconsulting.tech/MokoConsulting/mokocli * REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform
* PATH: /cli/package_build.php * PATH: /cli/package_build.php
* BRIEF: Build ZIP and tar.gz install packages for Joomla/Dolibarr/generic projects * BRIEF: Build ZIP and tar.gz install packages for Joomla/Dolibarr/generic projects
* *
@@ -19,7 +19,7 @@ declare(strict_types=1);
require_once __DIR__ . '/../lib/Enterprise/CliFramework.php'; require_once __DIR__ . '/../lib/Enterprise/CliFramework.php';
use MokoCli\{CliFramework, SourceResolver}; use MokoEnterprise\CliFramework;
class PackageBuildCli extends CliFramework class PackageBuildCli extends CliFramework
{ {
@@ -56,13 +56,18 @@ class PackageBuildCli extends CliFramework
} }
// -- Determine source directory ----------------------------------------------- // -- Determine source directory -----------------------------------------------
$sourceDir = SourceResolver::resolveAbsolute($root); $sourceDir = null;
foreach (['src', 'htdocs'] as $candidate) {
if (is_dir("{$root}/{$candidate}")) {
$sourceDir = "{$root}/{$candidate}";
break;
}
}
if ($sourceDir === null) { if ($sourceDir === null) {
$this->log('ERROR', "No source/ or src/ directory found in {$root}"); $this->log('ERROR', "No src/ or htdocs/ directory found in {$root}");
return 1; return 1;
} }
SourceResolver::warnIfLegacy($root);
// -- Determine element and type prefix from manifest -------------------------- // -- Determine element and type prefix from manifest --------------------------
$extElement = $elementOverride; $extElement = $elementOverride;
+20 -163
View File
@@ -6,32 +6,25 @@
* SPDX-License-Identifier: GPL-3.0-or-later * SPDX-License-Identifier: GPL-3.0-or-later
* *
* FILE INFORMATION * FILE INFORMATION
* DEFGROUP: mokocli.CLI * DEFGROUP: moko-platform.CLI
* INGROUP: mokocli * INGROUP: moko-platform
* REPO: https://git.mokoconsulting.tech/MokoConsulting/mokocli * REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform
* PATH: /cli/platform_detect.php * PATH: /cli/platform_detect.php
* VERSION: 09.38.00 * BRIEF: Detect platform from manifest.xml file — outputs platform string
* BRIEF: Auto-detect repository platform type and optionally update manifest
*/ */
declare(strict_types=1); declare(strict_types=1);
require_once __DIR__ . '/../lib/Enterprise/CliFramework.php'; require_once __DIR__ . '/../lib/Enterprise/CliFramework.php';
use MokoCli\CliFramework; use MokoEnterprise\CliFramework;
class PlatformDetectCli extends CliFramework class PlatformDetectCli extends CliFramework
{ {
protected function configure(): void protected function configure(): void
{ {
$this->setDescription('Auto-detect repository platform type and optionally update manifest'); $this->setDescription('Detect platform from manifest.xml file');
$this->addArgument('--path', 'Local repo path to scan (default: .)', '.'); $this->addArgument('--path', 'Repository root path', '.');
$this->addArgument('--token', 'Gitea API token for updating manifest', '');
$this->addArgument('--gitea-url', 'Gitea URL (default: https://git.mokoconsulting.tech)', 'https://git.mokoconsulting.tech');
$this->addArgument('--owner', 'Repo owner for API update', '');
$this->addArgument('--repo', 'Repo name for API update', '');
$this->addArgument('--update', 'Update manifest.platform via API (flag)', 'false');
$this->addArgument('--github-output', 'Append platform=xxx to $GITHUB_OUTPUT (flag)', 'false');
} }
protected function run(): int protected function run(): int
@@ -39,161 +32,25 @@ class PlatformDetectCli extends CliFramework
$path = $this->getArgument('--path'); $path = $this->getArgument('--path');
$root = realpath($path) ?: $path; $root = realpath($path) ?: $path;
$token = $this->getArgument('--token'); // Check .mokogitea/manifest.xml first, fallback to root
$giteaUrl = rtrim($this->getArgument('--gitea-url'), '/'); $file = "{$root}/.mokogitea/manifest.xml";
$owner = $this->getArgument('--owner'); if (!file_exists($file)) {
$repo = $this->getArgument('--repo'); $file = "{$root}/.mokostandards";
$doUpdate = $this->isFlagSet('--update'); }
$githubOutput = $this->isFlagSet('--github-output'); if (!file_exists($file)) {
echo "unknown\n";
$platform = $this->detectPlatform($root); return 0;
$this->log('INFO', "Detected platform: {$platform}");
echo $platform . "\n";
// Append to $GITHUB_OUTPUT if requested
if ($githubOutput) {
$outputFile = getenv('GITHUB_OUTPUT');
if ($outputFile !== false && $outputFile !== '') {
file_put_contents($outputFile, "platform={$platform}\n", FILE_APPEND);
$this->log('INFO', "Appended platform={$platform} to \$GITHUB_OUTPUT");
} else {
$this->log('WARN', '$GITHUB_OUTPUT is not set; skipping output append.');
}
} }
// Update manifest via API if requested $content = file_get_contents($file);
if ($doUpdate) { if (preg_match('/^platform:\s*(.+)/m', $content, $m)) {
if ($token === '' || $owner === '' || $repo === '') { echo trim($m[1], " \t\n\r\"'") . "\n";
$this->log('ERROR', '--update requires --token, --owner, and --repo.'); } else {
return 1; echo "unknown\n";
}
if ($this->dryRun) {
$this->log('INFO', "[DRY RUN] Would update manifest.platform to \"{$platform}\" "
. "for {$owner}/{$repo}.");
return 0;
}
$this->log('INFO', "Updating manifest.platform for {$owner}/{$repo} to \"{$platform}\"...");
$response = $this->apiRequest(
$giteaUrl,
$token,
'PATCH',
"/api/v1/repos/{$owner}/{$repo}/metadata",
json_encode(['platform' => $platform])
);
if ($response['code'] >= 200 && $response['code'] < 300) {
$this->log('INFO', "Manifest updated successfully (HTTP {$response['code']}).");
} else {
$this->log('ERROR', "Failed to update manifest (HTTP {$response['code']}): "
. $response['body']);
return 1;
}
} }
return 0; return 0;
} }
private function detectPlatform(string $root): string
{
// 1. Joomla — has pkg_*.xml or Joomla-style extension manifest
$joomlaIndicators = array_merge(
glob("{$root}/source/pkg_*.xml") ?: [],
glob("{$root}/pkg_*.xml") ?: [],
glob("{$root}/source/packages/*/services/provider.php") ?: [],
glob("{$root}/**/templateDetails.xml") ?: [],
);
if (!empty($joomlaIndicators)) {
return 'joomla';
}
// 2. Dolibarr — has mod*.class.php or dolibarr module descriptor
$doliIndicators = array_merge(
glob("{$root}/core/modules/mod*.class.php") ?: [],
glob("{$root}/class/*.class.php") ?: [],
);
if (!empty($doliIndicators) && file_exists("{$root}/langs")) {
return 'dolibarr';
}
// 3. Go — has go.mod
if (file_exists("{$root}/go.mod")) {
return 'go';
}
// 4. MCP — has package.json with mcp-related content or dist/index.js pattern
if (file_exists("{$root}/package.json")) {
$pkg = json_decode(file_get_contents("{$root}/package.json"), true);
$name = $pkg['name'] ?? '';
if (str_contains($name, 'mcp') || isset($pkg['dependencies']['@modelcontextprotocol/sdk'])) {
return 'mcp';
}
}
// 5. Platform — is mokocli itself or org-config
$repoName = basename($root);
if (in_array($repoName, ['mokocli', 'mokogitea-org-config'])) {
return 'platform';
}
// 6. Default
return 'generic';
}
private function isFlagSet(string $flag): bool
{
$value = $this->getArgument($flag);
return $value === 'true' || $value === '1' || $value === 'yes';
}
private function apiRequest(
string $giteaUrl,
string $token,
string $method,
string $endpoint,
?string $body = null
): array {
$url = $giteaUrl . $endpoint;
$ch = curl_init();
curl_setopt($ch, CURLOPT_URL, $url);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_CUSTOMREQUEST, $method);
curl_setopt($ch, CURLOPT_HTTPHEADER, [
'Content-Type: application/json',
'Accept: application/json',
"Authorization: token {$token}",
]);
if ($body !== null) {
curl_setopt($ch, CURLOPT_POSTFIELDS, $body);
}
$responseBody = curl_exec($ch);
$httpCode = (int) curl_getinfo(
$ch,
CURLINFO_HTTP_CODE
);
if (curl_errno($ch)) {
$error = curl_error($ch);
curl_close($ch);
return [
'code' => 0,
'body' => "cURL error: {$error}",
];
}
curl_close($ch);
return ['code' => $httpCode, 'body' => $responseBody];
}
} }
$app = new PlatformDetectCli(); $app = new PlatformDetectCli();
+6 -6
View File
@@ -6,24 +6,24 @@
* SPDX-License-Identifier: GPL-3.0-or-later * SPDX-License-Identifier: GPL-3.0-or-later
* *
* FILE INFORMATION * FILE INFORMATION
* DEFGROUP: mokocli.CLI * DEFGROUP: moko-platform.CLI
* INGROUP: mokocli * INGROUP: moko-platform
* REPO: https://git.mokoconsulting.tech/MokoConsulting/mokocli * REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform
* PATH: /cli/release.php * PATH: /cli/release.php
* BRIEF: Automate the mokocli version branch release flow * BRIEF: Automate the moko-platform version branch release flow
*/ */
declare(strict_types=1); declare(strict_types=1);
require_once __DIR__ . '/../lib/Enterprise/CliFramework.php'; require_once __DIR__ . '/../lib/Enterprise/CliFramework.php';
use MokoCli\CliFramework; use MokoEnterprise\CliFramework;
class ReleaseCli extends CliFramework class ReleaseCli extends CliFramework
{ {
protected function configure(): void protected function configure(): void
{ {
$this->setDescription('Automate the mokocli version branch release flow'); $this->setDescription('Automate the moko-platform version branch release flow');
$this->addArgument('--bump', 'Bump type: patch, minor, or major', ''); $this->addArgument('--bump', 'Bump type: patch, minor, or major', '');
} }
+4 -4
View File
@@ -6,9 +6,9 @@
* SPDX-License-Identifier: GPL-3.0-or-later * SPDX-License-Identifier: GPL-3.0-or-later
* *
* FILE INFORMATION * FILE INFORMATION
* DEFGROUP: mokocli.CLI * DEFGROUP: moko-platform.CLI
* INGROUP: mokocli * INGROUP: moko-platform
* REPO: https://git.mokoconsulting.tech/MokoConsulting/mokocli * REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform
* PATH: /cli/release_body_update.php * PATH: /cli/release_body_update.php
* BRIEF: Update Gitea release body with changelog extract and checksums * BRIEF: Update Gitea release body with changelog extract and checksums
*/ */
@@ -17,7 +17,7 @@ declare(strict_types=1);
require_once __DIR__ . '/../lib/Enterprise/CliFramework.php'; require_once __DIR__ . '/../lib/Enterprise/CliFramework.php';
use MokoCli\CliFramework; use MokoEnterprise\CliFramework;
class ReleaseBodyUpdateCli extends CliFramework class ReleaseBodyUpdateCli extends CliFramework
{ {
+9 -314
View File
@@ -6,336 +6,31 @@
* SPDX-License-Identifier: GPL-3.0-or-later * SPDX-License-Identifier: GPL-3.0-or-later
* *
* FILE INFORMATION * FILE INFORMATION
* DEFGROUP: mokocli.CLI * DEFGROUP: moko-platform.CLI
* INGROUP: mokocli * INGROUP: moko-platform
* REPO: https://git.mokoconsulting.tech/MokoConsulting/mokocli * REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform
* PATH: /cli/release_cascade.php * PATH: /cli/release_cascade.php
* VERSION: 09.38.00 * VERSION: 09.24.00
* BRIEF: Cascade release zip to all lower stability channels * BRIEF: DEPRECATED cascade behavior removed. Each release stream is independent.
*/ */
declare(strict_types=1); declare(strict_types=1);
require_once __DIR__ . '/../lib/Enterprise/CliFramework.php'; require_once __DIR__ . '/../lib/Enterprise/CliFramework.php';
use MokoCli\CliFramework; use MokoEnterprise\CliFramework;
class ReleaseCascadeCli extends CliFramework class ReleaseCascadeCli extends CliFramework
{ {
/** Channel hierarchy: highest stability first. */
private const CHANNELS = ['stable', 'release-candidate', 'beta', 'alpha', 'development'];
/** Map stability input names to canonical tag names. */
private const TAG_MAP = [
'stable' => 'stable',
'release-candidate' => 'release-candidate',
'rc' => 'release-candidate',
'beta' => 'beta',
'alpha' => 'alpha',
'development' => 'development',
'dev' => 'development',
];
protected function configure(): void protected function configure(): void
{ {
$this->setDescription('Cascade release zip to all lower stability channels'); $this->setDescription('DEPRECATED — cascade behavior removed');
$this->addArgument('--stability', 'Source stability channel (required)', '');
$this->addArgument('--token', 'Gitea API token (required)', '');
$this->addArgument('--api-base', 'Gitea API base URL for the repo (required)', '');
} }
protected function run(): int protected function run(): int
{ {
$stability = strtolower($this->getArgument('--stability')); $this->log('INFO', 'No-op (cascade behavior removed — each stream is independent)');
$token = $this->getArgument('--token'); return 0;
$apiBase = rtrim($this->getArgument('--api-base'), '/');
if ($token === '') {
$envToken = getenv('MOKOGITEA_TOKEN');
if ($envToken === false || $envToken === '') {
$envToken = getenv('GITEA_TOKEN');
}
if ($envToken !== false && $envToken !== '') {
$token = $envToken;
}
}
if ($stability === '' || $token === '' || $apiBase === '') {
$this->log('ERROR', 'Usage: release_cascade.php --stability CHANNEL --token TOKEN --api-base URL');
return 1;
}
$sourceTag = self::TAG_MAP[$stability] ?? null;
if ($sourceTag === null) {
$this->log('ERROR', "Unknown stability: {$stability}");
return 1;
}
// Find lower channels to cascade to
$lowerChannels = $this->getLowerChannels($sourceTag);
if (count($lowerChannels) === 0) {
$this->log('INFO', "No lower channels for '{$stability}' — nothing to cascade.");
return 0;
}
$this->log('INFO', "Cascading from '{$sourceTag}' to: " . implode(', ', $lowerChannels));
if ($this->dryRun) {
$this->log('INFO', '[DRY RUN] No changes will be made.');
}
// 1. Get source release
$sourceRelease = $this->giteaApi("{$apiBase}/releases/tags/{$sourceTag}", $token);
if ($sourceRelease === null) {
$this->log('WARN', "No release found at tag '{$sourceTag}' — nothing to cascade.");
return 0;
}
$sourceVersion = $sourceRelease['name'] ?? $sourceTag;
$sourceBody = $sourceRelease['body'] ?? '';
$sourceAssets = $sourceRelease['assets'] ?? [];
// Find zip assets (exclude .sha256 sidecars)
$zipAssets = array_filter($sourceAssets, function (array $asset): bool {
$name = strtolower($asset['name'] ?? '');
return str_ends_with($name, '.zip') && !str_ends_with($name, '.sha256');
});
// Also grab sha256 sidecars
$sha256Assets = array_filter($sourceAssets, function (array $asset): bool {
return str_ends_with(strtolower($asset['name'] ?? ''), '.zip.sha256');
});
if (count($zipAssets) === 0) {
$this->log('WARN', "Source release '{$sourceTag}' has no zip assets — nothing to cascade.");
return 0;
}
$this->log('INFO', "Source: {$sourceVersion}" . count($zipAssets) . " zip(s)");
echo "\n";
// 2. Download source assets to temp files
$downloads = [];
foreach (array_merge($zipAssets, $sha256Assets) as $asset) {
$url = $asset['browser_download_url'] ?? '';
if ($url === '') {
continue;
}
$tmpFile = tempnam(sys_get_temp_dir(), 'cascade_');
if ($this->downloadFile($url, $token, $tmpFile)) {
$downloads[] = ['name' => $asset['name'], 'path' => $tmpFile];
$this->log('INFO', "Downloaded: {$asset['name']}");
} else {
$this->log('ERROR', "Failed to download: {$asset['name']}");
}
}
if (count($downloads) === 0) {
$this->log('ERROR', 'Could not download any source assets.');
return 1;
}
// 3. Cascade to each lower channel
$errors = 0;
foreach ($lowerChannels as $targetTag) {
echo "\n";
$result = $this->cascadeToChannel(
$apiBase, $token, $targetTag,
$sourceVersion, $sourceBody, $downloads
);
if (!$result) {
$errors++;
}
}
// 4. Cleanup temp files
foreach ($downloads as $dl) {
@unlink($dl['path']);
}
echo "\n";
$this->log('INFO', "Cascade complete. " . (count($lowerChannels) - $errors)
. "/" . count($lowerChannels) . " channels updated.");
return $errors > 0 ? 1 : 0;
}
/**
* Cascade assets to a single target channel.
*/
private function cascadeToChannel(
string $apiBase,
string $token,
string $targetTag,
string $sourceVersion,
string $sourceBody,
array $downloads
): bool {
$this->log('INFO', "{$targetTag}");
if ($this->dryRun) {
$this->log('INFO', " [DRY RUN] Would cascade to {$targetTag}");
return true;
}
// Find existing release at target tag
$existing = $this->giteaApi("{$apiBase}/releases/tags/{$targetTag}", $token);
if ($existing !== null && !empty($existing['id'])) {
$releaseId = (int) $existing['id'];
// Delete existing assets
$existingAssets = $existing['assets'] ?? [];
foreach ($existingAssets as $asset) {
$assetId = $asset['id'] ?? 0;
if ($assetId > 0) {
$this->giteaApi(
"{$apiBase}/releases/{$releaseId}/assets/{$assetId}",
$token, 'DELETE'
);
}
}
// Update release metadata
$updatePayload = json_encode([
'name' => $sourceVersion,
'body' => $sourceBody,
]);
$this->giteaApi(
"{$apiBase}/releases/{$releaseId}",
$token, 'PATCH', $updatePayload
);
$this->log('INFO', " Updated release metadata (id: {$releaseId})");
} else {
// Create new release at target tag
// Use the source release's target commitish so the tag points to the same commit
$createPayload = json_encode([
'tag_name' => $targetTag,
'target_commitish' => 'main',
'name' => $sourceVersion,
'body' => $sourceBody,
'prerelease' => ($targetTag !== 'stable'),
]);
$newRelease = $this->giteaApi("{$apiBase}/releases", $token, 'POST', $createPayload);
if ($newRelease === null || empty($newRelease['id'])) {
$this->log('ERROR', " Failed to create release at tag '{$targetTag}'");
return false;
}
$releaseId = (int) $newRelease['id'];
$this->log('INFO', " Created release (id: {$releaseId})");
}
// Upload assets
foreach ($downloads as $dl) {
$uploadUrl = "{$apiBase}/releases/{$releaseId}/assets?name=" . rawurlencode($dl['name']);
$success = $this->uploadAsset($uploadUrl, $token, $dl['path'], $dl['name']);
if ($success) {
$this->log('INFO', " Uploaded: {$dl['name']}");
} else {
$this->log('ERROR', " Failed to upload: {$dl['name']}");
}
}
return true;
}
/**
* Get all channels below the given source channel.
*/
private function getLowerChannels(string $sourceTag): array
{
$idx = array_search($sourceTag, self::CHANNELS, true);
if ($idx === false) {
return [];
}
return array_slice(self::CHANNELS, $idx + 1);
}
/**
* Download a file via HTTP.
*/
private function downloadFile(string $url, string $token, string $destPath): bool
{
$ch = curl_init($url);
if ($ch === false) {
return false;
}
$fp = fopen($destPath, 'wb');
if ($fp === false) {
return false;
}
curl_setopt_array($ch, [
CURLOPT_FOLLOWLOCATION => true,
CURLOPT_FILE => $fp,
CURLOPT_HTTPHEADER => ["Authorization: token {$token}"],
CURLOPT_TIMEOUT => 120,
]);
curl_exec($ch);
$code = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
fclose($fp);
return $code >= 200 && $code < 300;
}
/**
* Upload a file as a release asset via multipart form.
*/
private function uploadAsset(string $url, string $token, string $filePath, string $fileName): bool
{
$ch = curl_init($url);
if ($ch === false) {
return false;
}
$cfile = new CURLFile($filePath, 'application/octet-stream', $fileName);
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_POST => true,
CURLOPT_POSTFIELDS => ['attachment' => $cfile],
CURLOPT_HTTPHEADER => ["Authorization: token {$token}"],
CURLOPT_TIMEOUT => 120,
]);
$response = curl_exec($ch);
$code = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
return $code >= 200 && $code < 300;
}
/**
* Make an HTTP request to the Gitea API.
*/
private function giteaApi(
string $url,
string $token,
string $method = 'GET',
?string $body = null
): ?array {
$ch = curl_init($url);
if ($ch === false) {
return null;
}
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_HTTPHEADER => [
"Authorization: token {$token}",
'Content-Type: application/json',
],
CURLOPT_TIMEOUT => 30,
CURLOPT_CUSTOMREQUEST => $method,
]);
if ($body !== null) {
curl_setopt($ch, CURLOPT_POSTFIELDS, $body);
}
$response = curl_exec($ch);
$httpCode = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
if ($httpCode < 200 || $httpCode >= 300 || empty($response) || !is_string($response)) {
return null;
}
$decoded = json_decode($response, true);
return is_array($decoded) ? $decoded : null;
} }
} }
+8 -7
View File
@@ -6,9 +6,9 @@
* SPDX-License-Identifier: GPL-3.0-or-later * SPDX-License-Identifier: GPL-3.0-or-later
* *
* FILE INFORMATION * FILE INFORMATION
* DEFGROUP: mokocli.CLI * DEFGROUP: moko-platform.CLI
* INGROUP: mokocli * INGROUP: moko-platform
* REPO: https://git.mokoconsulting.tech/MokoConsulting/mokocli * REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform
* PATH: /cli/release_create.php * PATH: /cli/release_create.php
* BRIEF: Create or overwrite a Gitea release with proper naming * BRIEF: Create or overwrite a Gitea release with proper naming
*/ */
@@ -17,7 +17,7 @@ declare(strict_types=1);
require_once __DIR__ . '/../lib/Enterprise/CliFramework.php'; require_once __DIR__ . '/../lib/Enterprise/CliFramework.php';
use MokoCli\{CliFramework, SourceResolver}; use MokoEnterprise\CliFramework;
class ReleaseCreateCli extends CliFramework class ReleaseCreateCli extends CliFramework
{ {
@@ -97,8 +97,8 @@ class ReleaseCreateCli extends CliFramework
// Find extension manifest (Joomla XML) // Find extension manifest (Joomla XML)
$extManifest = null; $extManifest = null;
$manifestFiles = array_merge( $manifestFiles = array_merge(
SourceResolver::globSource($root, 'pkg_*.xml'), glob("{$root}/src/pkg_*.xml") ?: [],
SourceResolver::globSource($root, '*.xml'), glob("{$root}/src/*.xml") ?: [],
glob("{$root}/*.xml") ?: [] glob("{$root}/*.xml") ?: []
); );
foreach ($manifestFiles as $file) { foreach ($manifestFiles as $file) {
@@ -112,7 +112,8 @@ class ReleaseCreateCli extends CliFramework
// Find Dolibarr module file // Find Dolibarr module file
$modFile = null; $modFile = null;
$modFiles = array_merge( $modFiles = array_merge(
SourceResolver::globSource($root, 'core/modules/mod*.class.php'), glob("{$root}/src/core/modules/mod*.class.php") ?: [],
glob("{$root}/htdocs/core/modules/mod*.class.php") ?: [],
glob("{$root}/core/modules/mod*.class.php") ?: [] glob("{$root}/core/modules/mod*.class.php") ?: []
); );
foreach ($modFiles as $file) { foreach ($modFiles as $file) {
+4 -4
View File
@@ -6,9 +6,9 @@
* SPDX-License-Identifier: GPL-3.0-or-later * SPDX-License-Identifier: GPL-3.0-or-later
* *
* FILE INFORMATION * FILE INFORMATION
* DEFGROUP: mokocli.CLI * DEFGROUP: moko-platform.CLI
* INGROUP: mokocli * INGROUP: moko-platform
* REPO: https://git.mokoconsulting.tech/MokoConsulting/mokocli * REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform
* PATH: /cli/release_manage.php * PATH: /cli/release_manage.php
* BRIEF: Create/update Gitea releases, upload assets, update release body * BRIEF: Create/update Gitea releases, upload assets, update release body
*/ */
@@ -17,7 +17,7 @@ declare(strict_types=1);
require_once __DIR__ . '/../lib/Enterprise/CliFramework.php'; require_once __DIR__ . '/../lib/Enterprise/CliFramework.php';
use MokoCli\CliFramework; use MokoEnterprise\CliFramework;
class ReleaseManageCli extends CliFramework class ReleaseManageCli extends CliFramework
{ {
+6 -6
View File
@@ -6,9 +6,9 @@
* SPDX-License-Identifier: GPL-3.0-or-later * SPDX-License-Identifier: GPL-3.0-or-later
* *
* FILE INFORMATION * FILE INFORMATION
* DEFGROUP: mokocli.CLI * DEFGROUP: moko-platform.CLI
* INGROUP: mokocli * INGROUP: moko-platform
* REPO: https://git.mokoconsulting.tech/MokoConsulting/mokocli * REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform
* PATH: /cli/release_mirror.php * PATH: /cli/release_mirror.php
* BRIEF: Mirror a Gitea release (with assets) to a GitHub repository * BRIEF: Mirror a Gitea release (with assets) to a GitHub repository
*/ */
@@ -17,7 +17,7 @@ declare(strict_types=1);
require_once __DIR__ . '/../lib/Enterprise/CliFramework.php'; require_once __DIR__ . '/../lib/Enterprise/CliFramework.php';
use MokoCli\CliFramework; use MokoEnterprise\CliFramework;
class ReleaseMirrorCli extends CliFramework class ReleaseMirrorCli extends CliFramework
{ {
@@ -201,7 +201,7 @@ class ReleaseMirrorCli extends CliFramework
CURLOPT_HTTPHEADER => [ CURLOPT_HTTPHEADER => [
"Authorization: token {$token}", "Authorization: token {$token}",
'Accept: application/vnd.github+json', 'Accept: application/vnd.github+json',
'User-Agent: mokocli', 'User-Agent: moko-platform',
'Content-Type: application/json', 'Content-Type: application/json',
], ],
CURLOPT_TIMEOUT => 30, CURLOPT_TIMEOUT => 30,
@@ -229,7 +229,7 @@ class ReleaseMirrorCli extends CliFramework
CURLOPT_HTTPHEADER => [ CURLOPT_HTTPHEADER => [
"Authorization: token {$token}", "Authorization: token {$token}",
'Accept: application/vnd.github+json', 'Accept: application/vnd.github+json',
'User-Agent: mokocli', 'User-Agent: moko-platform',
'Content-Type: application/octet-stream', 'Content-Type: application/octet-stream',
], ],
CURLOPT_POSTFIELDS => file_get_contents($filePath), CURLOPT_POSTFIELDS => file_get_contents($filePath),
+4 -4
View File
@@ -6,9 +6,9 @@
* SPDX-License-Identifier: GPL-3.0-or-later * SPDX-License-Identifier: GPL-3.0-or-later
* *
* FILE INFORMATION * FILE INFORMATION
* DEFGROUP: mokocli.CLI * DEFGROUP: moko-platform.CLI
* INGROUP: mokocli * INGROUP: moko-platform
* REPO: https://git.mokoconsulting.tech/MokoConsulting/mokocli * REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform
* PATH: /cli/release_notes.php * PATH: /cli/release_notes.php
* BRIEF: Extract release notes from CHANGELOG.md for a given version * BRIEF: Extract release notes from CHANGELOG.md for a given version
*/ */
@@ -17,7 +17,7 @@ declare(strict_types=1);
require_once __DIR__ . '/../lib/Enterprise/CliFramework.php'; require_once __DIR__ . '/../lib/Enterprise/CliFramework.php';
use MokoCli\CliFramework; use MokoEnterprise\CliFramework;
class ReleaseNotesCli extends CliFramework class ReleaseNotesCli extends CliFramework
{ {
+12 -52
View File
@@ -6,9 +6,9 @@
* SPDX-License-Identifier: GPL-3.0-or-later * SPDX-License-Identifier: GPL-3.0-or-later
* *
* FILE INFORMATION * FILE INFORMATION
* DEFGROUP: mokocli.CLI * DEFGROUP: moko-platform.CLI
* INGROUP: mokocli * INGROUP: moko-platform
* REPO: https://git.mokoconsulting.tech/MokoConsulting/mokocli * REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform
* PATH: /cli/release_package.php * PATH: /cli/release_package.php
* BRIEF: Build packages (ZIP + tar.gz) with SHA-256 and upload to Gitea release * BRIEF: Build packages (ZIP + tar.gz) with SHA-256 and upload to Gitea release
*/ */
@@ -17,7 +17,7 @@ declare(strict_types=1);
require_once __DIR__ . '/../lib/Enterprise/CliFramework.php'; require_once __DIR__ . '/../lib/Enterprise/CliFramework.php';
use MokoCli\{CliFramework, SourceResolver}; use MokoEnterprise\CliFramework;
class ReleasePackageCli extends CliFramework class ReleasePackageCli extends CliFramework
{ {
@@ -99,10 +99,9 @@ class ReleasePackageCli extends CliFramework
$extFolder = ''; $extFolder = '';
$typePrefix = ''; $typePrefix = '';
SourceResolver::warnIfLegacy($root);
$manifestFiles = array_merge( $manifestFiles = array_merge(
SourceResolver::globSource($root, 'pkg_*.xml'), glob("{$root}/src/pkg_*.xml") ?: [],
SourceResolver::globSource($root, '*.xml'), glob("{$root}/src/*.xml") ?: [],
glob("{$root}/*.xml") ?: [] glob("{$root}/*.xml") ?: []
); );
@@ -201,12 +200,14 @@ class ReleasePackageCli extends CliFramework
} }
} }
if ($sourceDir === null) { if ($sourceDir === null && is_dir("{$root}/src")) {
$sourceDir = SourceResolver::resolveAbsolute($root); $sourceDir = "{$root}/src";
} elseif ($sourceDir === null && is_dir("{$root}/htdocs")) {
$sourceDir = "{$root}/htdocs";
} }
if ($sourceDir === null) { if ($sourceDir === null) {
echo "No source/ or src/ directory found — skipping package build\n"; echo "No src/ or htdocs/ directory found — skipping package build\n";
return 0; return 0;
} }
@@ -224,58 +225,17 @@ class ReleasePackageCli extends CliFramework
return 1; return 1;
} }
// Only zip sub-extensions listed in the package manifest
$manifestedSubs = [];
$pkgManifestFiles = glob("{$sourceDir}/pkg_*.xml") ?: [];
foreach ($pkgManifestFiles as $pmf) {
$pmXml = @simplexml_load_file($pmf);
if ($pmXml && isset($pmXml->files)) {
foreach ($pmXml->files->file as $fileNode) {
$zipName = pathinfo((string) $fileNode, PATHINFO_FILENAME);
if (!empty($zipName)) {
$manifestedSubs[$zipName] = true;
}
}
}
}
$packageDirs = glob("{$sourceDir}/packages/*", GLOB_ONLYDIR) ?: []; $packageDirs = glob("{$sourceDir}/packages/*", GLOB_ONLYDIR) ?: [];
foreach ($packageDirs as $pkgDir) { foreach ($packageDirs as $pkgDir) {
$subName = basename($pkgDir); $subName = basename($pkgDir);
// Skip directories not listed in the package manifest
if (!empty($manifestedSubs) && !isset($manifestedSubs[$subName])) {
echo " Skipping {$subName} (not in package manifest)\n";
continue;
}
$subZipPath = "{$outputDir}/{$subName}.zip"; $subZipPath = "{$outputDir}/{$subName}.zip";
// If sub-package is a full repo checkout (e.g. git submodule),
// look for a source/ or src/ subdirectory containing a Joomla manifest XML
// and zip that instead of the repo root.
$subSourceDir = $pkgDir;
$subSrcAbs = SourceResolver::resolveAbsolute($pkgDir);
if ($subSrcAbs !== null) {
$srcManifests = array_merge(
glob("{$subSrcAbs}/*.xml") ?: [],
glob("{$subSrcAbs}/pkg_*.xml") ?: []
);
foreach ($srcManifests as $mf) {
if (strpos(file_get_contents($mf) ?: '', '<extension') !== false) {
$subSourceDir = $subSrcAbs;
$subSrcName = SourceResolver::resolve($pkgDir);
echo " Sub-package {$subName}: using {$subSrcName}/ entry-point\n";
break;
}
}
}
$subZip = new \ZipArchive(); $subZip = new \ZipArchive();
if ($subZip->open($subZipPath, \ZipArchive::CREATE | \ZipArchive::OVERWRITE) !== true) { if ($subZip->open($subZipPath, \ZipArchive::CREATE | \ZipArchive::OVERWRITE) !== true) {
$this->log('ERROR', "Failed to create sub-package ZIP: {$subZipPath}"); $this->log('ERROR', "Failed to create sub-package ZIP: {$subZipPath}");
continue; continue;
} }
$this->addDirToZip($subZip, $subSourceDir, '', $this->excludePatterns); $this->addDirToZip($subZip, $pkgDir, '', $this->excludePatterns);
$subZip->close(); $subZip->close();
$zip->addFile($subZipPath, "packages/{$subName}.zip"); $zip->addFile($subZipPath, "packages/{$subName}.zip");
+6 -6
View File
@@ -6,9 +6,9 @@
* SPDX-License-Identifier: GPL-3.0-or-later * SPDX-License-Identifier: GPL-3.0-or-later
* *
* FILE INFORMATION * FILE INFORMATION
* DEFGROUP: mokocli.CLI * DEFGROUP: moko-platform.CLI
* INGROUP: mokocli * INGROUP: moko-platform
* REPO: https://git.mokoconsulting.tech/MokoConsulting/mokocli * REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform
* PATH: /cli/release_promote.php * PATH: /cli/release_promote.php
* BRIEF: Promote a Gitea release from one channel to another (rename release, tag, assets) * BRIEF: Promote a Gitea release from one channel to another (rename release, tag, assets)
*/ */
@@ -17,7 +17,7 @@ declare(strict_types=1);
require_once __DIR__ . '/../lib/Enterprise/CliFramework.php'; require_once __DIR__ . '/../lib/Enterprise/CliFramework.php';
use MokoCli\{CliFramework, SourceResolver}; use MokoEnterprise\CliFramework;
class ReleasePromoteCli extends CliFramework class ReleasePromoteCli extends CliFramework
{ {
@@ -109,8 +109,8 @@ class ReleasePromoteCli extends CliFramework
if ($to === 'stable') { if ($to === 'stable') {
$root = realpath($path) ?: $path; $root = realpath($path) ?: $path;
$manifestFiles = array_merge( $manifestFiles = array_merge(
SourceResolver::globSource($root, 'pkg_*.xml'), glob("{$root}/src/pkg_*.xml") ?: [],
SourceResolver::globSource($root, '*.xml'), glob("{$root}/src/*.xml") ?: [],
glob("{$root}/*.xml") ?: [] glob("{$root}/*.xml") ?: []
); );
foreach ($manifestFiles as $xmlFile) { foreach ($manifestFiles as $xmlFile) {
+5 -5
View File
@@ -6,11 +6,11 @@
* SPDX-License-Identifier: GPL-3.0-or-later * SPDX-License-Identifier: GPL-3.0-or-later
* *
* FILE INFORMATION * FILE INFORMATION
* DEFGROUP: mokocli.CLI * DEFGROUP: moko-platform.CLI
* INGROUP: mokocli * INGROUP: moko-platform
* REPO: https://git.mokoconsulting.tech/MokoConsulting/mokocli * REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform
* PATH: /cli/release_publish.php * PATH: /cli/release_publish.php
* VERSION: 09.38.00 * VERSION: 09.24.00
* BRIEF: Publish a release and create copies for all lesser stability streams. * BRIEF: Publish a release and create copies for all lesser stability streams.
*/ */
@@ -18,7 +18,7 @@ declare(strict_types=1);
require_once __DIR__ . '/../lib/Enterprise/CliFramework.php'; require_once __DIR__ . '/../lib/Enterprise/CliFramework.php';
use MokoCli\CliFramework; use MokoEnterprise\CliFramework;
class ReleasePublishCli extends CliFramework class ReleasePublishCli extends CliFramework
{ {
+8 -11
View File
@@ -6,9 +6,9 @@
* SPDX-License-Identifier: GPL-3.0-or-later * SPDX-License-Identifier: GPL-3.0-or-later
* *
* FILE INFORMATION * FILE INFORMATION
* DEFGROUP: mokocli.CLI * DEFGROUP: moko-platform.CLI
* INGROUP: mokocli * INGROUP: moko-platform
* REPO: https://git.mokoconsulting.tech/MokoConsulting/mokocli * REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform
* PATH: /cli/release_validate.php * PATH: /cli/release_validate.php
* BRIEF: Pre-release validation -- version consistency, required files, manifest checks * BRIEF: Pre-release validation -- version consistency, required files, manifest checks
*/ */
@@ -17,7 +17,7 @@ declare(strict_types=1);
require_once __DIR__ . '/../lib/Enterprise/CliFramework.php'; require_once __DIR__ . '/../lib/Enterprise/CliFramework.php';
use MokoCli\{CliFramework, SourceResolver}; use MokoEnterprise\CliFramework;
class ReleaseValidateCli extends CliFramework class ReleaseValidateCli extends CliFramework
{ {
@@ -66,10 +66,8 @@ class ReleaseValidateCli extends CliFramework
$platform = 'generic'; $platform = 'generic';
} }
} }
$hasSource = SourceResolver::resolveAbsolute($root) !== null; $hasSource = is_dir("{$root}/src") || is_dir("{$root}/htdocs");
SourceResolver::warnIfLegacy($root); $this->addVResult('Source directory', $hasSource ? 'PASS' : 'WARN', $hasSource ? 'src/ or htdocs/ found' : 'No src/ or htdocs/ directory');
$srcDirName = SourceResolver::resolve($root);
$this->addVResult('Source directory', $hasSource ? 'PASS' : 'WARN', $hasSource ? "{$srcDirName}/ found" : 'No source/ or src/ directory');
if (!file_exists("{$root}/README.md")) { if (!file_exists("{$root}/README.md")) {
$this->addVResult('README.md', 'FAIL', 'Not found'); $this->addVResult('README.md', 'FAIL', 'Not found');
} else { } else {
@@ -111,8 +109,7 @@ class ReleaseValidateCli extends CliFramework
$this->addVResult('LICENSE', $licenseFound ? 'PASS' : 'FAIL', $licenseFound ? 'Found' : 'Not found'); $this->addVResult('LICENSE', $licenseFound ? 'PASS' : 'FAIL', $licenseFound ? 'Found' : 'Not found');
if ($platform === 'joomla') { if ($platform === 'joomla') {
$manifest = null; $manifest = null;
$srcAbs = SourceResolver::resolveAbsolute($root); foreach (["{$root}/src", $root] as $dir) {
foreach (array_filter([$srcAbs, $root]) as $dir) {
if (!is_dir($dir)) { if (!is_dir($dir)) {
continue; continue;
} foreach (glob("{$dir}/*.xml") as $xmlFile) { } foreach (glob("{$dir}/*.xml") as $xmlFile) {
@@ -159,7 +156,7 @@ class ReleaseValidateCli extends CliFramework
} }
} elseif ($platform === 'dolibarr') { } elseif ($platform === 'dolibarr') {
$modFile = null; $modFile = null;
foreach (SourceResolver::getCandidates() as $sd) { foreach (['src', 'htdocs'] as $sd) {
$matches = glob("{$root}/{$sd}/mod*.class.php"); $matches = glob("{$root}/{$sd}/mod*.class.php");
if (!empty($matches)) { if (!empty($matches)) {
$modFile = $matches[0]; $modFile = $matches[0];
+4 -4
View File
@@ -6,9 +6,9 @@
* SPDX-License-Identifier: GPL-3.0-or-later * SPDX-License-Identifier: GPL-3.0-or-later
* *
* FILE INFORMATION * FILE INFORMATION
* DEFGROUP: mokocli.CLI * DEFGROUP: moko-platform.CLI
* INGROUP: mokocli * INGROUP: moko-platform
* REPO: https://git.mokoconsulting.tech/MokoConsulting/mokocli * REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform
* PATH: /cli/release_verify.php * PATH: /cli/release_verify.php
* BRIEF: Verify a built release artifact version, SHA256, disallowed files * BRIEF: Verify a built release artifact version, SHA256, disallowed files
*/ */
@@ -17,7 +17,7 @@ declare(strict_types=1);
require_once __DIR__ . '/../lib/Enterprise/CliFramework.php'; require_once __DIR__ . '/../lib/Enterprise/CliFramework.php';
use MokoCli\CliFramework; use MokoEnterprise\CliFramework;
class ReleaseVerifyCli extends CliFramework class ReleaseVerifyCli extends CliFramework
{ {
-429
View File
@@ -1,429 +0,0 @@
#!/usr/bin/env php
<?php
/* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
* SPDX-License-Identifier: GPL-3.0-or-later
* FILE INFORMATION
* DEFGROUP: mokocli.CLI
* INGROUP: mokocli
* REPO: https://git.mokoconsulting.tech/MokoConsulting/mokocli
* PATH: /cli/repo_wizard.php
* BRIEF: Interactive configuration wizard for new repositories
*/
declare(strict_types=1);
require_once __DIR__ . '/../vendor/autoload.php';
require_once __DIR__ . '/../lib/Enterprise/CliFramework.php';
use MokoCli\{CliFramework, Config, PlatformAdapterFactory};
/**
* Interactive repo setup wizard.
*
* Walks through platform selection, generates config files, workflows,
* and optionally creates the repo on Gitea via API.
*
* @see https://git.mokoconsulting.tech/MokoConsulting/mokocli/issues/145
*/
class RepoWizard extends CliFramework
{
private const PLATFORMS = [
'joomla' => 'Joomla extension (component, module, plugin, package)',
'dolibarr' => 'Dolibarr ERP module',
'nodejs' => 'Node.js / TypeScript project',
'python' => 'Python project',
'mcp-server' => 'MCP server (Model Context Protocol)',
'generic' => 'Generic PHP or multi-language project',
];
private const LICENSES = [
'GPL-3.0-or-later' => 'GNU General Public License v3',
'MIT' => 'MIT License',
'Apache-2.0' => 'Apache License 2.0',
'proprietary' => 'Proprietary / All rights reserved',
];
/** Collected wizard answers. */
private array $answers = [];
/** When true, skip all interactive prompts and use defaults. */
private bool $nonInteractive = false;
protected function configure(): void
{
$this->setDescription('Interactive configuration wizard for new repositories');
$this->addArgument('--path', 'Directory to generate files in', '.');
$this->addArgument('--create-remote', 'Create repo on Gitea via API', false);
$this->addArgument('--non-interactive', 'Use defaults (no prompts)', false);
}
protected function run(): int
{
$rawPath = $this->getArgument('--path', '.');
$targetPath = realpath($rawPath) ?: $rawPath;
$this->nonInteractive = (bool) $this->getArgument('--non-interactive', false);
// Validate target path
if (!is_dir($targetPath) && !@mkdir($targetPath, 0755, true)) {
$this->log('ERROR', "Target path does not exist and cannot be created: {$targetPath}");
return self::EXIT_USAGE;
}
$targetPath = realpath($targetPath) ?: $targetPath;
$this->section('MokoCli Repository Wizard');
// ── Gather info ──────────────────────────────────────────────
$this->answers['name'] = $this->ask('Repository name', basename($targetPath));
$this->answers['platform'] = $this->choose('Platform type', self::PLATFORMS, 'generic');
$this->answers['org'] = $this->ask('Organization', 'MokoConsulting');
$this->answers['description'] = $this->ask('Description', '');
$this->answers['license'] = $this->choose('License', self::LICENSES, 'GPL-3.0-or-later');
// ── Confirm ──────────────────────────────────────────────────
$this->section('Configuration Summary');
foreach ($this->answers as $key => $value) {
$this->log('INFO', sprintf(' %-12s %s', $key . ':', $value));
}
if (!$this->confirm('Proceed with these settings?', true)) {
$this->log('INFO', 'Wizard cancelled');
return 0;
}
// ── Generate files ───────────────────────────────────────────
$this->section('Generating files');
$generated = $this->generateFiles($targetPath);
foreach ($generated as $file) {
$this->status(true, $file);
}
// ── Create remote repo ───────────────────────────────────────
if ($this->getArgument('--create-remote', false)) {
$this->section('Creating remote repository');
$this->createRemoteRepo();
}
$this->log('INFO', '');
$this->log('INFO', 'Generated ' . count($generated) . " files in {$targetPath}");
$this->log('INFO', 'Next: git init && git add -A && git commit -m "chore: initial scaffold"');
return 0;
}
// ── File generation ──────────────────────────────────────────────
private function generateFiles(string $path): array
{
$platform = $this->answers['platform'];
$name = $this->answers['name'];
$generated = [];
// .editorconfig
$generated[] = $this->writeFile($path, '.editorconfig', $this->editorconfig());
// README.md
$generated[] = $this->writeFile($path, 'README.md', $this->readme());
// CHANGELOG.md
$generated[] = $this->writeFile($path, 'CHANGELOG.md', $this->changelog());
// LICENSE
if ($this->answers['license'] !== 'proprietary') {
$generated[] = $this->writeFile($path, 'LICENSE', "See SPDX: {$this->answers['license']}");
}
// Platform-specific configs
switch ($platform) {
case 'joomla':
case 'dolibarr':
case 'generic':
$generated[] = $this->writeFile($path, 'phpcs.xml', $this->phpcsXml());
$generated[] = $this->writeFile($path, 'phpstan.neon', $this->phpstanNeon());
$generated[] = $this->writeFile($path, 'composer.json', $this->composerJson());
break;
case 'nodejs':
case 'mcp-server':
$generated[] = $this->writeFile($path, 'package.json', $this->packageJson());
$generated[] = $this->writeFile($path, 'tsconfig.json', $this->tsconfigJson());
$generated[] = $this->writeFile($path, '.eslintrc.json', $this->eslintrc());
break;
case 'python':
$generated[] = $this->writeFile($path, 'pyproject.toml', $this->pyprojectToml());
$generated[] = $this->writeFile($path, 'requirements.txt', '');
break;
}
// .mokogitea/workflows
$generated[] = $this->writeFile($path, '.mokogitea/workflows/pr-check.yml',
"# PR check workflow — synced from mokocli templates\n# Run: moko sync to update\n");
// .gitignore
$generated[] = $this->writeFile($path, '.gitignore', $this->gitignore($platform));
// Source directory
$srcDir = in_array($platform, ['joomla', 'dolibarr', 'generic']) ? 'source' : 'src';
if (!is_dir("{$path}/{$srcDir}")) {
@mkdir("{$path}/{$srcDir}", 0755, true);
$generated[] = "{$srcDir}/";
}
return array_filter($generated);
}
private function writeFile(string $basePath, string $relativePath, string $content): ?string
{
$fullPath = $basePath . '/' . $relativePath;
$dir = dirname($fullPath);
if (file_exists($fullPath)) {
$this->log('DEBUG', " SKIP {$relativePath} (already exists)");
return null;
}
if ($this->dryRun) {
$this->log('INFO', "[dry-run] Would create {$relativePath}");
return $relativePath;
}
if (!is_dir($dir)) {
@mkdir($dir, 0755, true);
}
file_put_contents($fullPath, $content);
return $relativePath;
}
// ── Remote repo creation ─────────────────────────────────────────
private function createRemoteRepo(): void
{
try {
$config = Config::load();
$adapter = PlatformAdapterFactory::create($config);
$org = $this->answers['org'];
if ($this->dryRun) {
$this->log('INFO', "[dry-run] Would create {$org}/{$this->answers['name']} on Gitea");
return;
}
$result = $adapter->createRepository($org, $this->answers['name'], [
'description' => $this->answers['description'],
'private' => false,
]);
$url = $result['html_url'] ?? "{$org}/{$this->answers['name']}";
$this->log('INFO', "Created: {$url}");
} catch (\Exception $e) {
$this->log('ERROR', "Failed to create remote repo: {$e->getMessage()}");
}
}
// ── Interactive helpers (respect --non-interactive) ─────────────
private function ask(string $prompt, string $default): string
{
if ($this->nonInteractive) {
return $default;
}
return $this->input($prompt, $default);
}
private function choose(string $prompt, array $options, string $default): string
{
if ($this->nonInteractive) {
return $default;
}
$keys = array_keys($options);
$labels = [];
foreach ($options as $key => $desc) {
$labels[] = "{$key}{$desc}";
}
$chosen = $this->select($prompt, $labels);
// Extract the key from "key — description"
$chosenKey = explode(' — ', $chosen, 2)[0] ?? $default;
return in_array($chosenKey, $keys, true) ? $chosenKey : $default;
}
// ── Template content ─────────────────────────────────────────────
private function editorconfig(): string
{
return <<<'CONF'
root = true
[*]
charset = utf-8
end_of_line = lf
indent_size = 4
indent_style = tab
insert_final_newline = true
trim_trailing_whitespace = true
[*.{yml,yaml}]
indent_style = space
indent_size = 2
[*.md]
trim_trailing_whitespace = false
CONF;
}
private function readme(): string
{
$name = $this->answers['name'];
$desc = $this->answers['description'] ?: 'A Moko Consulting project.';
$license = $this->answers['license'];
return <<<MD
# {$name}
{$desc}
## License
{$license}
MD;
}
private function changelog(): string
{
return <<<MD
# Changelog
## [Unreleased]
### Added
- Initial project scaffold
MD;
}
private function composerJson(): string
{
$data = [
'name' => 'mokoconsulting/' . strtolower($this->answers['name']),
'description' => $this->answers['description'] ?: $this->answers['name'],
'type' => 'library',
'license' => $this->answers['license'],
'require' => ['php' => '>=8.1'],
'autoload' => ['psr-4' => new \stdClass()],
];
return json_encode($data, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES) . "\n";
}
private function phpcsXml(): string
{
return <<<'XML'
<?xml version="1.0"?>
<ruleset name="MokoCli">
<rule ref="PSR12"/>
<file>source/</file>
<exclude-pattern>vendor/*</exclude-pattern>
</ruleset>
XML;
}
private function phpstanNeon(): string
{
return <<<'NEON'
parameters:
level: 6
paths:
- source/
NEON;
}
private function packageJson(): string
{
$data = [
'name' => '@mokoconsulting/' . strtolower($this->answers['name']),
'version' => '0.1.0',
'description' => $this->answers['description'] ?: $this->answers['name'],
'type' => 'module',
'scripts' => ['build' => 'tsc', 'start' => 'node dist/index.js'],
'devDependencies' => ['typescript' => '^5.0'],
];
return json_encode($data, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES) . "\n";
}
private function tsconfigJson(): string
{
return <<<'JSON'
{
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"outDir": "./dist",
"rootDir": "./src",
"strict": true,
"declaration": true
},
"include": ["src/**/*"]
}
JSON;
}
private function eslintrc(): string
{
return <<<'JSON'
{
"root": true,
"parser": "@typescript-eslint/parser",
"plugins": ["@typescript-eslint"],
"extends": ["eslint:recommended", "plugin:@typescript-eslint/recommended"]
}
JSON;
}
private function pyprojectToml(): string
{
$name = strtolower($this->answers['name']);
$desc = str_replace(['\\', '"'], ['\\\\', '\\"'], $this->answers['description'] ?: $this->answers['name']);
return <<<TOML
[project]
name = "{$name}"
version = "0.1.0"
description = "{$desc}"
requires-python = ">=3.10"
[build-system]
requires = ["setuptools>=68.0"]
build-backend = "setuptools.build_meta"
TOML;
}
private function gitignore(string $platform): string
{
$common = <<<'GI'
# IDE
.idea/
.vscode/
*.swp
*.swo
# OS
.DS_Store
Thumbs.db
desktop.ini
# Logs
*.log
GI;
$extra = match ($platform) {
'joomla', 'dolibarr', 'generic' => "\n# PHP\nvendor/\n.phpunit.result.cache\n",
'nodejs', 'mcp-server' => "\n# Node\nnode_modules/\ndist/\n*.tsbuildinfo\n",
'python' => "\n# Python\n__pycache__/\n*.pyc\n.venv/\n*.egg-info/\n",
default => '',
};
return $common . $extra;
}
}
$app = new RepoWizard('repo_wizard');
exit($app->execute());
+5 -5
View File
@@ -8,11 +8,11 @@
* SPDX-License-Identifier: GPL-3.0-or-later * SPDX-License-Identifier: GPL-3.0-or-later
* *
* FILE INFORMATION * FILE INFORMATION
* DEFGROUP: mokocli.CLI * DEFGROUP: moko-platform.CLI
* INGROUP: mokocli * INGROUP: moko-platform
* REPO: https://git.mokoconsulting.tech/MokoConsulting/mokocli * REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform
* PATH: /cli/scaffold_client.php * PATH: /cli/scaffold_client.php
* VERSION: 09.38.00 * VERSION: 09.24.00
* BRIEF: Scaffold a new client-waas repo from Template-Client-WaaS with pre-configured settings * BRIEF: Scaffold a new client-waas repo from Template-Client-WaaS with pre-configured settings
*/ */
@@ -20,7 +20,7 @@ declare(strict_types=1);
require_once __DIR__ . '/../lib/Enterprise/CliFramework.php'; require_once __DIR__ . '/../lib/Enterprise/CliFramework.php';
use MokoCli\CliFramework; use MokoEnterprise\CliFramework;
class ScaffoldClientCli extends CliFramework class ScaffoldClientCli extends CliFramework
{ {
+7 -7
View File
@@ -8,9 +8,9 @@
* SPDX-License-Identifier: GPL-3.0-or-later * SPDX-License-Identifier: GPL-3.0-or-later
* *
* FILE INFORMATION * FILE INFORMATION
* DEFGROUP: mokocli.CLI * DEFGROUP: moko-platform.CLI
* INGROUP: mokocli * INGROUP: moko-platform
* REPO: https://git.mokoconsulting.tech/MokoConsulting/mokocli * REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform
* PATH: /cli/sync_rulesets.php * PATH: /cli/sync_rulesets.php
* BRIEF: Apply branch protection rules to all repos via platform adapter * BRIEF: Apply branch protection rules to all repos via platform adapter
*/ */
@@ -20,9 +20,9 @@ declare(strict_types=1);
require_once __DIR__ . '/../vendor/autoload.php'; require_once __DIR__ . '/../vendor/autoload.php';
require_once __DIR__ . '/../lib/Enterprise/CliFramework.php'; require_once __DIR__ . '/../lib/Enterprise/CliFramework.php';
use MokoCli\CliFramework; use MokoEnterprise\CliFramework;
use MokoCli\Config; use MokoEnterprise\Config;
use MokoCli\PlatformAdapterFactory; use MokoEnterprise\PlatformAdapterFactory;
class SyncRulesetsCli extends CliFramework class SyncRulesetsCli extends CliFramework
{ {
@@ -46,7 +46,7 @@ class SyncRulesetsCli extends CliFramework
); );
$platformName = $adapter->getPlatformName(); $platformName = $adapter->getPlatformName();
$ALWAYS_EXCLUDE = ['mokocli', '.github-private']; $ALWAYS_EXCLUDE = ['moko-platform', '.github-private'];
// -- Protection rules (platform-agnostic format) -- // -- Protection rules (platform-agnostic format) --
$PROTECTIONS = [ $PROTECTIONS = [

Some files were not shown because too many files have changed in this diff Show More