From f2d1695ac3985603f2471eef68d4c8697725a74a Mon Sep 17 00:00:00 2001 From: Jonathan Miller Date: Thu, 21 May 2026 16:05:54 -0500 Subject: [PATCH 01/12] fix(ci): pipefail and rsync issues in release workflows (#20, #21) - Add || true to all find|grep|head pipelines to prevent grep exit-code 1 from killing steps under bash -e -o pipefail - Replace rsync with cp -a in pre-release Build Package step since rsync is not always available in runner containers (exit 127) Fixes #20, Fixes #21 Co-Authored-By: Claude Opus 4.6 (1M context) --- .gitea/workflows/auto-release.yml | 6 +++--- .gitea/workflows/pre-release.yml | 20 ++++++++------------ 2 files changed, 11 insertions(+), 15 deletions(-) diff --git a/.gitea/workflows/auto-release.yml b/.gitea/workflows/auto-release.yml index 84fc701..22c4e7e 100644 --- a/.gitea/workflows/auto-release.yml +++ b/.gitea/workflows/auto-release.yml @@ -85,8 +85,8 @@ jobs: [ -z "$PLATFORM" ] && PLATFORM="generic" echo "platform=$PLATFORM" >> "$GITHUB_OUTPUT" echo "Platform detected: ${PLATFORM}" - MANIFEST=$(find . -maxdepth 3 -name "*.xml" ! -path "./.git/*" -exec grep -l '/dev/null | head -1) - MOD_FILE=$(find . -maxdepth 4 -name "mod*.class.php" ! -path "./.git/*" -exec grep -l 'extends DolibarrModules' {} \; 2>/dev/null | head -1) + MANIFEST=$(find . -maxdepth 3 -name "*.xml" ! -path "./.git/*" -exec grep -l '/dev/null | head -1 || true) + MOD_FILE=$(find . -maxdepth 4 -name "mod*.class.php" ! -path "./.git/*" -exec grep -l 'extends DolibarrModules' {} \; 2>/dev/null | head -1 || true) echo "manifest=${MANIFEST}" >> "$GITHUB_OUTPUT" echo "mod_file=${MOD_FILE}" >> "$GITHUB_OUTPUT" @@ -362,7 +362,7 @@ jobs: REPO="${{ github.repository }}" # -- Parse extension metadata from XML manifest ---------------- - MANIFEST=$(find . -maxdepth 2 -name "*.xml" -exec grep -l '/dev/null | head -1) + MANIFEST=$(find . -maxdepth 2 -name "*.xml" -exec grep -l '/dev/null | head -1 || true) if [ -z "$MANIFEST" ]; then echo "Warning: No Joomla XML manifest found — skipping updates.xml" >> $GITHUB_STEP_SUMMARY exit 0 diff --git a/.gitea/workflows/pre-release.yml b/.gitea/workflows/pre-release.yml index c70ea7d..a121900 100644 --- a/.gitea/workflows/pre-release.yml +++ b/.gitea/workflows/pre-release.yml @@ -60,8 +60,8 @@ jobs: [ -z "$PLATFORM" ] && PLATFORM=$(cat .mokogitea/manifest.xml 2>/dev/null | tr -d '[:space:]') [ -z "$PLATFORM" ] && PLATFORM="generic" echo "platform=$PLATFORM" >> "$GITHUB_OUTPUT" - MANIFEST=$(find . -maxdepth 3 -name "*.xml" ! -path "./.git/*" -exec grep -l '/dev/null | head -1) - MOD_FILE=$(find . -maxdepth 4 -name "mod*.class.php" ! -path "./.git/*" -exec grep -l 'extends DolibarrModules' {} \; 2>/dev/null | head -1) + MANIFEST=$(find . -maxdepth 3 -name "*.xml" ! -path "./.git/*" -exec grep -l '/dev/null | head -1 || true) + MOD_FILE=$(find . -maxdepth 4 -name "mod*.class.php" ! -path "./.git/*" -exec grep -l 'extends DolibarrModules' {} \; 2>/dev/null | head -1 || true) echo "manifest=${MANIFEST}" >> "$GITHUB_OUTPUT" echo "mod_file=${MOD_FILE}" >> "$GITHUB_OUTPUT" @@ -190,16 +190,12 @@ jobs: fi mkdir -p build/package - rsync -a \ - --exclude='sftp-config*' \ - --exclude='.ftpignore' \ - --exclude='*.ppk' \ - --exclude='*.pem' \ - --exclude='*.key' \ - --exclude='.env*' \ - --exclude='*.local' \ - --exclude='.build-trigger' \ - "${SOURCE_DIR}/" build/package/ + # Use cp instead of rsync (not always available in runner containers) + cp -a "${SOURCE_DIR}/." build/package/ + # Remove excluded files + cd build/package + rm -f sftp-config* .ftpignore *.ppk *.pem *.key .env* *.local .build-trigger + cd "$OLDPWD" - name: Create ZIP id: zip -- 2.52.0 From eb3e2af1ff0041dd5add53c19f41a40b71a64d2a Mon Sep 17 00:00:00 2001 From: Jonathan Miller Date: Thu, 21 May 2026 16:18:26 -0500 Subject: [PATCH 02/12] refactor: rename all MokoStandards-API references to moko-platform MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bulk rename across all workflows, issue templates, and configs: - Clone URL: MokoStandards-API.git → moko-platform.git - Local path: /tmp/mokostandards-api → /tmp/moko-platform-api - DEFGROUP/INGROUP: MokoStandards.* → moko-platform.* - Step names and comments updated - REPO URLs updated to MokoConsulting/moko-platform EXCLUDE lists retain old repo names for backward compatibility during migration. Co-Authored-By: Claude Opus 4.6 (1M context) --- .mokogitea/ISSUE_TEMPLATE/config.yml | 2 +- .mokogitea/ISSUE_TEMPLATE/feature_request.md | 2 +- .mokogitea/ISSUE_TEMPLATE/security.md | 2 +- .mokogitea/branch-protection.yml | 4 +- .mokogitea/bulk-repo-sync.yml | 2 +- .mokogitea/manifest.xml | 2 +- .mokogitea/pr-branch-check.yml | 194 +++++++++---------- .mokogitea/renovate.yml | 4 +- .mokogitea/sync-wikis.yml | 2 +- .mokogitea/workflows/auto-release.yml | 20 +- .mokogitea/workflows/cascade-dev.yml | 4 +- .mokogitea/workflows/ci-platform.yml | 10 +- .mokogitea/workflows/cleanup.yml | 4 +- .mokogitea/workflows/deploy-manual.yml | 22 +-- .mokogitea/workflows/gitleaks.yml | 4 +- .mokogitea/workflows/notify.yml | 4 +- .mokogitea/workflows/pr-check.yml | 4 +- .mokogitea/workflows/pre-release.yml | 4 +- .mokogitea/workflows/repo-health.yml | 4 +- .mokogitea/workflows/security-audit.yml | 4 +- 20 files changed, 149 insertions(+), 149 deletions(-) diff --git a/.mokogitea/ISSUE_TEMPLATE/config.yml b/.mokogitea/ISSUE_TEMPLATE/config.yml index d4d49ec..06221e2 100644 --- a/.mokogitea/ISSUE_TEMPLATE/config.yml +++ b/.mokogitea/ISSUE_TEMPLATE/config.yml @@ -7,7 +7,7 @@ contact_links: - name: 💬 Ask a Question url: https://mokoconsulting.tech/ about: Get help or ask questions through our website - - name: 📚 MokoStandards Documentation + - name: 📚 moko-platform Documentation url: https://git.mokoconsulting.tech/MokoConsulting/moko-platform about: View our coding standards and best practices - name: 🔒 Report a Security Vulnerability diff --git a/.mokogitea/ISSUE_TEMPLATE/feature_request.md b/.mokogitea/ISSUE_TEMPLATE/feature_request.md index 7b76dc9..20544f9 100644 --- a/.mokogitea/ISSUE_TEMPLATE/feature_request.md +++ b/.mokogitea/ISSUE_TEMPLATE/feature_request.md @@ -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. ## Relevant Standards -Does this relate to any standards in [MokoStandards](https://git.mokoconsulting.tech/MokoConsulting/MokoStandards)? +Does this relate to any standards in [MokoStandards](https://git.mokoconsulting.tech/MokoConsulting/moko-platform)? - [ ] Accessibility (WCAG 2.1 AA) - [ ] Localization (en_US/en_GB) - [ ] Security best practices diff --git a/.mokogitea/ISSUE_TEMPLATE/security.md b/.mokogitea/ISSUE_TEMPLATE/security.md index f57b284..6cdd84f 100644 --- a/.mokogitea/ISSUE_TEMPLATE/security.md +++ b/.mokogitea/ISSUE_TEMPLATE/security.md @@ -35,7 +35,7 @@ Use this template only for: ## Standards Reference -Does this relate to security standards in [MokoStandards](https://git.mokoconsulting.tech/MokoConsulting/MokoStandards)? +Does this relate to security standards in [MokoStandards](https://git.mokoconsulting.tech/MokoConsulting/moko-platform)? - [ ] SPDX license identifiers - [ ] Secret management - [ ] Dependency security diff --git a/.mokogitea/branch-protection.yml b/.mokogitea/branch-protection.yml index 5372dda..6fef3e3 100644 --- a/.mokogitea/branch-protection.yml +++ b/.mokogitea/branch-protection.yml @@ -2,7 +2,7 @@ # SPDX-License-Identifier: GPL-3.0-or-later # FILE INFORMATION # DEFGROUP: Gitea.Workflow -# INGROUP: MokoStandards-API.Automation +# INGROUP: moko-platform.Automation # REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform # PATH: /.gitea/workflows/branch-protection.yml # BRIEF: Apply standardised branch protection rules to all governed repositories @@ -62,7 +62,7 @@ jobs: API="${GITEA_URL}/api/v1" # Platform/standards/infra repos to exclude - EXCLUDE="gitea-org-config org-profile gitea-private .mokogitea-private MokoStandards MokoStandards-API MokoTesting" + EXCLUDE="gitea-org-config org-profile gitea-private .mokogitea-private MokoStandards moko-platform MokoTesting" EXCLUDE="$EXCLUDE MokoStandards-Template-Client MokoStandards-Template-Dolibarr MokoStandards-Template-Generic MokoStandards-Template-Joomla MokoDoliProjTemplate" if [ -n "${{ inputs.repos }}" ]; then diff --git a/.mokogitea/bulk-repo-sync.yml b/.mokogitea/bulk-repo-sync.yml index c81f0ad..d0fdeb2 100644 --- a/.mokogitea/bulk-repo-sync.yml +++ b/.mokogitea/bulk-repo-sync.yml @@ -2,7 +2,7 @@ # SPDX-License-Identifier: GPL-3.0-or-later # FILE INFORMATION # DEFGROUP: Gitea.Workflow -# INGROUP: MokoStandards-API.Automation +# INGROUP: moko-platform.Automation # REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform # PATH: /.gitea/workflows/bulk-repo-sync.yml # BRIEF: Bulk repo sync — runs from API repo, syncs standards to all governed repos diff --git a/.mokogitea/manifest.xml b/.mokogitea/manifest.xml index 9e74dff..21b9bd1 100644 --- a/.mokogitea/manifest.xml +++ b/.mokogitea/manifest.xml @@ -1,6 +1,6 @@ diff --git a/.mokogitea/pr-branch-check.yml b/.mokogitea/pr-branch-check.yml index 5f3010e..e8b1750 100644 --- a/.mokogitea/pr-branch-check.yml +++ b/.mokogitea/pr-branch-check.yml @@ -1,97 +1,97 @@ -# Copyright (C) 2026 Moko Consulting -# SPDX-License-Identifier: GPL-3.0-or-later -# -# FILE INFORMATION -# DEFGROUP: MokoStandards.CI -# INGROUP: MokoStandards -# REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform -# PATH: /.gitea/workflows/pr-branch-check.yml -# BRIEF: PR branch merge policy enforcement -# -# Enforces branch merge policy: -# feature/* → dev only -# fix/* → dev only -# hotfix/* → dev or main (emergency) -# dev → main only -# alpha/* → dev only -# beta/* → dev only -# rc/* → main only - -name: Branch Policy Check - -on: - pull_request: - types: [opened, synchronize, reopened, edited] - -jobs: - check-target: - name: Verify merge target - runs-on: ubuntu-latest - steps: - - name: Check branch policy - run: | - HEAD="${{ github.head_ref }}" - BASE="${{ github.base_ref }}" - - echo "PR: ${HEAD} → ${BASE}" - - ALLOWED=true - REASON="" - - case "$HEAD" in - feature/*|feat/*) - if [ "$BASE" != "dev" ]; then - ALLOWED=false - REASON="Feature branches must target 'dev', not '${BASE}'" - fi - ;; - fix/*|bugfix/*) - if [ "$BASE" != "dev" ]; then - ALLOWED=false - REASON="Fix branches must target 'dev', not '${BASE}'" - fi - ;; - hotfix/*) - if [ "$BASE" != "dev" ] && [ "$BASE" != "main" ]; then - ALLOWED=false - REASON="Hotfix branches can only target 'dev' or 'main', not '${BASE}'" - fi - ;; - alpha/*|beta/*) - if [ "$BASE" != "dev" ]; then - ALLOWED=false - REASON="Pre-release branches must target 'dev', not '${BASE}'" - fi - ;; - rc/*) - if [ "$BASE" != "main" ]; then - ALLOWED=false - REASON="Release candidate branches must target 'main', not '${BASE}'" - fi - ;; - dev) - if [ "$BASE" != "main" ]; then - ALLOWED=false - REASON="Dev branch can only merge into 'main', not '${BASE}'" - fi - ;; - esac - - if [ "$ALLOWED" = false ]; then - echo "::error::${REASON}" - echo "" - echo "## Branch Policy Violation" >> $GITHUB_STEP_SUMMARY - echo "" >> $GITHUB_STEP_SUMMARY - echo "${REASON}" >> $GITHUB_STEP_SUMMARY - echo "" >> $GITHUB_STEP_SUMMARY - echo "### Allowed merge paths:" >> $GITHUB_STEP_SUMMARY - echo "- \`feature/*\` → \`dev\`" >> $GITHUB_STEP_SUMMARY - echo "- \`fix/*\` → \`dev\`" >> $GITHUB_STEP_SUMMARY - echo "- \`hotfix/*\` → \`dev\` or \`main\`" >> $GITHUB_STEP_SUMMARY - echo "- \`dev\` → \`main\`" >> $GITHUB_STEP_SUMMARY - echo "- \`rc/*\` → \`main\`" >> $GITHUB_STEP_SUMMARY - exit 1 - fi - - echo "Branch policy: OK (${HEAD} → ${BASE})" - echo "## Branch Policy: Passed" >> $GITHUB_STEP_SUMMARY +# Copyright (C) 2026 Moko Consulting +# SPDX-License-Identifier: GPL-3.0-or-later +# +# FILE INFORMATION +# DEFGROUP: moko-platform.CI +# INGROUP: moko-platform +# REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform +# PATH: /.gitea/workflows/pr-branch-check.yml +# BRIEF: PR branch merge policy enforcement +# +# Enforces branch merge policy: +# feature/* → dev only +# fix/* → dev only +# hotfix/* → dev or main (emergency) +# dev → main only +# alpha/* → dev only +# beta/* → dev only +# rc/* → main only + +name: Branch Policy Check + +on: + pull_request: + types: [opened, synchronize, reopened, edited] + +jobs: + check-target: + name: Verify merge target + runs-on: ubuntu-latest + steps: + - name: Check branch policy + run: | + HEAD="${{ github.head_ref }}" + BASE="${{ github.base_ref }}" + + echo "PR: ${HEAD} → ${BASE}" + + ALLOWED=true + REASON="" + + case "$HEAD" in + feature/*|feat/*) + if [ "$BASE" != "dev" ]; then + ALLOWED=false + REASON="Feature branches must target 'dev', not '${BASE}'" + fi + ;; + fix/*|bugfix/*) + if [ "$BASE" != "dev" ]; then + ALLOWED=false + REASON="Fix branches must target 'dev', not '${BASE}'" + fi + ;; + hotfix/*) + if [ "$BASE" != "dev" ] && [ "$BASE" != "main" ]; then + ALLOWED=false + REASON="Hotfix branches can only target 'dev' or 'main', not '${BASE}'" + fi + ;; + alpha/*|beta/*) + if [ "$BASE" != "dev" ]; then + ALLOWED=false + REASON="Pre-release branches must target 'dev', not '${BASE}'" + fi + ;; + rc/*) + if [ "$BASE" != "main" ]; then + ALLOWED=false + REASON="Release candidate branches must target 'main', not '${BASE}'" + fi + ;; + dev) + if [ "$BASE" != "main" ]; then + ALLOWED=false + REASON="Dev branch can only merge into 'main', not '${BASE}'" + fi + ;; + esac + + if [ "$ALLOWED" = false ]; then + echo "::error::${REASON}" + echo "" + echo "## Branch Policy Violation" >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + echo "${REASON}" >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + echo "### Allowed merge paths:" >> $GITHUB_STEP_SUMMARY + echo "- \`feature/*\` → \`dev\`" >> $GITHUB_STEP_SUMMARY + echo "- \`fix/*\` → \`dev\`" >> $GITHUB_STEP_SUMMARY + echo "- \`hotfix/*\` → \`dev\` or \`main\`" >> $GITHUB_STEP_SUMMARY + echo "- \`dev\` → \`main\`" >> $GITHUB_STEP_SUMMARY + echo "- \`rc/*\` → \`main\`" >> $GITHUB_STEP_SUMMARY + exit 1 + fi + + echo "Branch policy: OK (${HEAD} → ${BASE})" + echo "## Branch Policy: Passed" >> $GITHUB_STEP_SUMMARY diff --git a/.mokogitea/renovate.yml b/.mokogitea/renovate.yml index 5181ff6..dd05e74 100644 --- a/.mokogitea/renovate.yml +++ b/.mokogitea/renovate.yml @@ -4,7 +4,7 @@ # # FILE INFORMATION # DEFGROUP: Gitea.Workflow -# INGROUP: MokoStandards-API.Automation +# INGROUP: moko-platform.Automation # REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform # PATH: /.gitea/workflows/renovate.yml # BRIEF: Run Renovate Bot across all governed repos for dependency updates @@ -61,7 +61,7 @@ jobs: run: | API="${GITEA_URL}/api/v1" - EXCLUDE="gitea-org-config org-profile gitea-private .mokogitea-private MokoStandards MokoStandards-API MokoTesting" + EXCLUDE="gitea-org-config org-profile gitea-private .mokogitea-private MokoStandards moko-platform MokoTesting" EXCLUDE="$EXCLUDE MokoStandards-Template-Client MokoStandards-Template-Dolibarr MokoStandards-Template-Generic MokoStandards-Template-Joomla MokoDoliProjTemplate" if [ -n "${{ inputs.repos }}" ]; then diff --git a/.mokogitea/sync-wikis.yml b/.mokogitea/sync-wikis.yml index 6c88dbb..af71890 100644 --- a/.mokogitea/sync-wikis.yml +++ b/.mokogitea/sync-wikis.yml @@ -4,7 +4,7 @@ # # FILE INFORMATION # DEFGROUP: Gitea.Workflow -# INGROUP: MokoStandards.Maintenance +# INGROUP: moko-platform.Maintenance # REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform # PATH: /.gitea/workflows/sync-wikis.yml # BRIEF: Daily sync of all Gitea wikis to consolidated GitHub wiki repo diff --git a/.mokogitea/workflows/auto-release.yml b/.mokogitea/workflows/auto-release.yml index 22c4e7e..b7212a0 100644 --- a/.mokogitea/workflows/auto-release.yml +++ b/.mokogitea/workflows/auto-release.yml @@ -4,8 +4,8 @@ # # FILE INFORMATION # DEFGROUP: Gitea.Workflow -# INGROUP: MokoStandards.Release -# REPO: https://git.mokoconsulting.tech/mokoconsulting-tech/MokoStandards-API +# INGROUP: moko-platform.Release +# REPO: https://git.mokoconsulting.tech/mokoconsulting-tech/moko-platform # PATH: /templates/workflows/universal/auto-release.yml.template # VERSION: 05.00.00 # BRIEF: Universal build & release � detects platform from manifest.xml @@ -58,7 +58,7 @@ jobs: token: ${{ secrets.GA_TOKEN }} fetch-depth: 0 - - name: Setup MokoStandards tools + - name: Setup moko-platform tools env: MOKO_CLONE_TOKEN: ${{ secrets.GA_TOKEN }} MOKO_CLONE_HOST: git.mokoconsulting.tech/MokoConsulting @@ -69,9 +69,9 @@ jobs: 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 git clone --depth 1 --branch main --quiet \ - "https://x-access-token:${MOKO_CLONE_TOKEN}@${MOKO_CLONE_HOST}/MokoStandards-API.git" \ - /tmp/mokostandards-api - cd /tmp/mokostandards-api + "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 @@ -94,7 +94,7 @@ jobs: - name: "Step 1: Read version from README.md" id: version run: | - VERSION=$(php /tmp/mokostandards-api/cli/version_read.php --path . 2>/dev/null) + VERSION=$(php /tmp/moko-platform-api/cli/version_read.php --path . 2>/dev/null) if [ -z "$VERSION" ]; then echo "No VERSION in README.md — skipping release" echo "skip=true" >> "$GITHUB_OUTPUT" @@ -335,7 +335,7 @@ jobs: steps.check.outputs.already_released != 'true' run: | VERSION="${{ steps.bump.outputs.version || steps.version.outputs.version }}" - php /tmp/mokostandards-api/cli/version_set_platform.php \ + php /tmp/moko-platform-api/cli/version_set_platform.php \ --path . --version "$VERSION" --branch main # -- STEP 4: Update version badges ---------------------------------------- @@ -559,7 +559,7 @@ jobs: fi [ -z "$EXT_NAME" ] && EXT_NAME="${GITEA_REPO}" - NOTES=$(php /tmp/mokostandards-api/cli/release_notes.php --path . --version "$VERSION" 2>/dev/null) + NOTES=$(php /tmp/moko-platform-api/cli/release_notes.php --path . --version "$VERSION" 2>/dev/null) [ -z "$NOTES" ] && NOTES="Release ${VERSION}" # Build release name: "Pretty Name VERSION (type_element-VERSION)" @@ -866,7 +866,7 @@ jobs: BRANCH="${{ steps.version.outputs.branch }}" GH_REPO="${{ vars.GH_MIRROR_REPO || github.repository }}" - NOTES=$(php /tmp/mokostandards-api/cli/release_notes.php --path . --version "$VERSION" 2>/dev/null || true) + NOTES=$(php /tmp/moko-platform-api/cli/release_notes.php --path . --version "$VERSION" 2>/dev/null || true) [ -z "$NOTES" ] && NOTES="Release ${VERSION}" echo "$NOTES" > /tmp/release_notes.md diff --git a/.mokogitea/workflows/cascade-dev.yml b/.mokogitea/workflows/cascade-dev.yml index 4dbb135..23b11a2 100644 --- a/.mokogitea/workflows/cascade-dev.yml +++ b/.mokogitea/workflows/cascade-dev.yml @@ -4,8 +4,8 @@ # # FILE INFORMATION # DEFGROUP: Gitea.Workflow -# INGROUP: MokoStandards.Maintenance -# REPO: https://git.mokoconsulting.tech/mokoconsulting-tech/MokoStandards-API +# INGROUP: moko-platform.Maintenance +# REPO: https://git.mokoconsulting.tech/mokoconsulting-tech/moko-platform # PATH: /templates/workflows/cascade-dev.yml.template # VERSION: 02.00.00 # BRIEF: Forward-merge main → all open branches after every push to main diff --git a/.mokogitea/workflows/ci-platform.yml b/.mokogitea/workflows/ci-platform.yml index 745b820..14bb85d 100644 --- a/.mokogitea/workflows/ci-platform.yml +++ b/.mokogitea/workflows/ci-platform.yml @@ -4,18 +4,18 @@ # # FILE INFORMATION # DEFGROUP: Gitea.Workflow -# INGROUP: MokoStandards.CI +# INGROUP: moko-platform.CI # REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform # PATH: /.gitea/workflows/ci-platform.yml # VERSION: 01.00.00 -# BRIEF: MokoStandards Platform CI — the standards engine validates itself +# BRIEF: moko-platform CI — the standards engine validates itself # # +========================================================================+ # | MOKOSTANDARDS PLATFORM CI | # +========================================================================+ # | | # | This is NOT a generic CI workflow. This is the self-validation | -# | pipeline for the central MokoStandards enterprise platform. | +# | pipeline for the central moko-platform enterprise engine. | # | | # | It dogfoods every tool the platform ships to governed repos: | # | | @@ -29,7 +29,7 @@ # | | # +========================================================================+ -name: "Platform: MokoStandards CI" +name: "Platform: moko-platform CI" on: push: @@ -407,7 +407,7 @@ jobs: - name: Check gate results run: | { - echo "# MokoStandards Platform CI" + echo "# moko-platform CI" echo "" echo "| Gate | Job | Status |" echo "|---|---|---|" diff --git a/.mokogitea/workflows/cleanup.yml b/.mokogitea/workflows/cleanup.yml index 3a81856..a890001 100644 --- a/.mokogitea/workflows/cleanup.yml +++ b/.mokogitea/workflows/cleanup.yml @@ -4,8 +4,8 @@ # # FILE INFORMATION # DEFGROUP: Gitea.Workflow -# INGROUP: MokoStandards.Maintenance -# REPO: https://git.mokoconsulting.tech/MokoConsulting/MokoStandards +# INGROUP: moko-platform.Maintenance +# REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform # PATH: /.gitea/workflows/cleanup.yml # VERSION: 01.00.00 # BRIEF: Scheduled cleanup — delete merged branches and old workflow runs diff --git a/.mokogitea/workflows/deploy-manual.yml b/.mokogitea/workflows/deploy-manual.yml index 132f488..6429460 100644 --- a/.mokogitea/workflows/deploy-manual.yml +++ b/.mokogitea/workflows/deploy-manual.yml @@ -4,8 +4,8 @@ # # FILE INFORMATION # DEFGROUP: Gitea.Workflow -# INGROUP: MokoStandards.Deploy -# REPO: https://git.mokoconsulting.tech/MokoConsulting/MokoStandards-API +# INGROUP: moko-platform.Deploy +# REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform # PATH: /templates/workflows/joomla/deploy-manual.yml.template # VERSION: 04.07.00 # BRIEF: Manual SFTP deploy to dev server for Joomla repos @@ -40,7 +40,7 @@ jobs: run: | php -v && composer --version - - name: Setup MokoStandards tools + - name: Setup moko-platform tools env: GA_TOKEN: ${{ secrets.GA_TOKEN || secrets.GA_TOKEN || github.token }} MOKO_CLONE_TOKEN: ${{ secrets.GA_TOKEN || secrets.GA_TOKEN || github.token }} @@ -48,10 +48,10 @@ jobs: 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 + "https://x-access-token:${MOKO_CLONE_TOKEN}@${MOKO_CLONE_HOST}/moko-platform.git" \ + /tmp/moko-platform-api 2>/dev/null || true + if [ -d "/tmp/moko-platform-api" ] && [ -f "/tmp/moko-platform-api/composer.json" ]; then + cd /tmp/moko-platform-api && composer install --no-dev --no-interaction --quiet 2>/dev/null || true fi - name: Check FTP configuration @@ -101,11 +101,11 @@ jobs: 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[@]}" + PLATFORM=$(php /tmp/moko-platform-api/cli/platform_detect.php --path . 2>/dev/null || true) + if [ "$PLATFORM" = "waas-component" ] && [ -f "/tmp/moko-platform-api/deploy/deploy-joomla.php" ]; then + php /tmp/moko-platform-api/deploy/deploy-joomla.php "${DEPLOY_ARGS[@]}" else - php /tmp/mokostandards-api/deploy/deploy-sftp.php "${DEPLOY_ARGS[@]}" + php /tmp/moko-platform-api/deploy/deploy-sftp.php "${DEPLOY_ARGS[@]}" fi rm -f /tmp/deploy_key /tmp/sftp-config.json diff --git a/.mokogitea/workflows/gitleaks.yml b/.mokogitea/workflows/gitleaks.yml index 0c07612..e0fdd1d 100644 --- a/.mokogitea/workflows/gitleaks.yml +++ b/.mokogitea/workflows/gitleaks.yml @@ -4,8 +4,8 @@ # # FILE INFORMATION # DEFGROUP: Gitea.Workflow -# INGROUP: MokoStandards.Security -# REPO: https://git.mokoconsulting.tech/mokoconsulting-tech/MokoStandards-API +# INGROUP: moko-platform.Security +# REPO: https://git.mokoconsulting.tech/mokoconsulting-tech/moko-platform # PATH: /templates/workflows/gitleaks.yml.template # VERSION: 01.00.00 # BRIEF: Secret scanning — detect leaked credentials, API keys, and tokens diff --git a/.mokogitea/workflows/notify.yml b/.mokogitea/workflows/notify.yml index 51dfcb5..cde4541 100644 --- a/.mokogitea/workflows/notify.yml +++ b/.mokogitea/workflows/notify.yml @@ -4,8 +4,8 @@ # # FILE INFORMATION # DEFGROUP: Gitea.Workflow -# INGROUP: MokoStandards.Notifications -# REPO: https://git.mokoconsulting.tech/MokoConsulting/MokoStandards +# INGROUP: moko-platform.Notifications +# REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform # PATH: /.gitea/workflows/notify.yml # VERSION: 01.00.00 # BRIEF: Push notifications via ntfy on release success or workflow failure diff --git a/.mokogitea/workflows/pr-check.yml b/.mokogitea/workflows/pr-check.yml index 99e063f..bc1a001 100644 --- a/.mokogitea/workflows/pr-check.yml +++ b/.mokogitea/workflows/pr-check.yml @@ -4,8 +4,8 @@ # # FILE INFORMATION # DEFGROUP: Gitea.Workflow -# INGROUP: MokoStandards.CI -# REPO: https://git.mokoconsulting.tech/mokoconsulting-tech/MokoStandards-API +# INGROUP: moko-platform.CI +# REPO: https://git.mokoconsulting.tech/mokoconsulting-tech/moko-platform # PATH: /templates/workflows/universal/pr-check.yml.template # VERSION: 05.00.00 # BRIEF: PR gate — branch policy + code validation before merge diff --git a/.mokogitea/workflows/pre-release.yml b/.mokogitea/workflows/pre-release.yml index a121900..948cd3f 100644 --- a/.mokogitea/workflows/pre-release.yml +++ b/.mokogitea/workflows/pre-release.yml @@ -4,8 +4,8 @@ # # FILE INFORMATION # DEFGROUP: Gitea.Workflow -# INGROUP: MokoStandards.Release -# REPO: https://git.mokoconsulting.tech/MokoConsulting/MokoStandards +# INGROUP: moko-platform.Release +# REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform # PATH: /templates/workflows/universal/pre-release.yml.template # VERSION: 05.00.00 # BRIEF: Manual pre-release — builds dev/alpha/beta/rc packages from any branch diff --git a/.mokogitea/workflows/repo-health.yml b/.mokogitea/workflows/repo-health.yml index 5392de3..d738ad7 100644 --- a/.mokogitea/workflows/repo-health.yml +++ b/.mokogitea/workflows/repo-health.yml @@ -7,8 +7,8 @@ # # FILE INFORMATION # DEFGROUP: Gitea.Workflow -# INGROUP: MokoStandards.Validation -# REPO: https://git.mokoconsulting.tech/mokoconsulting-tech/MokoStandards-API +# INGROUP: moko-platform.Validation +# REPO: https://git.mokoconsulting.tech/mokoconsulting-tech/moko-platform # PATH: /templates/workflows/joomla/repo_health.yml.template # VERSION: 04.06.00 # BRIEF: Enforces repository guardrails by validating release configuration, scripts governance, tooling availability, and core repository health artifacts. diff --git a/.mokogitea/workflows/security-audit.yml b/.mokogitea/workflows/security-audit.yml index f316b90..714d407 100644 --- a/.mokogitea/workflows/security-audit.yml +++ b/.mokogitea/workflows/security-audit.yml @@ -4,8 +4,8 @@ # # FILE INFORMATION # DEFGROUP: Gitea.Workflow -# INGROUP: MokoStandards.Security -# REPO: https://git.mokoconsulting.tech/MokoConsulting/MokoStandards +# INGROUP: moko-platform.Security +# REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform # PATH: /.gitea/workflows/security-audit.yml # VERSION: 01.00.00 # BRIEF: Dependency vulnerability scanning for composer and npm packages -- 2.52.0 From e19ca4d7a9c6b50a82264df9cd09a0acbbe0cd80 Mon Sep 17 00:00:00 2001 From: Jonathan Miller Date: Thu, 21 May 2026 16:43:07 -0500 Subject: [PATCH 03/12] feat(ci): type-aware Joomla build via PHP API (#20, #21) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add cli/joomla_build.php — standalone build tool that detects all Joomla extension types from the XML manifest and builds accordingly: - plugin, module, component, template, library, file: flat ZIP - package: nested ZIPs for each sub-extension in packages/ Update both workflows to call joomla_build.php via the moko-platform PHP API instead of inlining bash build logic. Also extends joomla_release.php with: - typePrefix() for correct naming (plg_, mod_, com_, tpl_, pkg_, lib_) - buildPackageZip() for multi-extension package assembly - copyDir() helper Co-Authored-By: Claude Opus 4.6 (1M context) --- .mokogitea/workflows/auto-release.yml | 21 +- .mokogitea/workflows/pre-release.yml | 7 + cli/joomla_build.php | 295 ++++++++++++++++++++++++++ cli/joomla_release.php | 101 ++++++++- 4 files changed, 410 insertions(+), 14 deletions(-) create mode 100644 cli/joomla_build.php diff --git a/.mokogitea/workflows/auto-release.yml b/.mokogitea/workflows/auto-release.yml index b7212a0..91f0b06 100644 --- a/.mokogitea/workflows/auto-release.yml +++ b/.mokogitea/workflows/auto-release.yml @@ -648,19 +648,18 @@ jobs: # -- Build install packages from src/ ---------------------------- SOURCE_DIR="src" [ ! -d "$SOURCE_DIR" ] && SOURCE_DIR="htdocs" - [ ! -d "$SOURCE_DIR" ] && { echo "No src/ or htdocs/ — skipping package"; exit 0; } + [ ! -d "$SOURCE_DIR" ] && { echo "No src/ or htdocs/"; exit 0; } - EXCLUDES=".ftpignore sftp-config* *.ppk *.pem *.key .env*" + # ZIP package (type-aware via moko-platform PHP API) + php /tmp/moko-platform-api/cli/joomla_build.php --path . --version "${VERSION}" --output /tmp + # Match the expected ZIP_NAME for upload + BUILT_ZIP=$(ls /tmp/${TYPE_PREFIX}${EXT_ELEMENT}-${VERSION}.zip 2>/dev/null | head -1 || true) + if [ -n "$BUILT_ZIP" ] && [ "$BUILT_ZIP" != "/tmp/${ZIP_NAME}" ]; then + mv "$BUILT_ZIP" "/tmp/${ZIP_NAME}" + fi - # ZIP package - cd "$SOURCE_DIR" - zip -r "/tmp/${ZIP_NAME}" . -x $EXCLUDES - cd .. - - # tar.gz package - tar -czf "/tmp/${TAR_NAME}" -C "$SOURCE_DIR" \ - --exclude='.ftpignore' --exclude='sftp-config*' \ - --exclude='*.ppk' --exclude='*.pem' --exclude='*.key' --exclude='.env*' . + # tar.gz package (flat source archive) + tar -czf "/tmp/${TAR_NAME}" -C "$SOURCE_DIR" --exclude='.ftpignore' --exclude='sftp-config*' --exclude='*.ppk' --exclude='*.pem' --exclude='*.key' --exclude='.env*' . ZIP_SIZE=$(stat -c%s "/tmp/${ZIP_NAME}" 2>/dev/null || stat -f%z "/tmp/${ZIP_NAME}" 2>/dev/null || echo "unknown") TAR_SIZE=$(stat -c%s "/tmp/${TAR_NAME}" 2>/dev/null || stat -f%z "/tmp/${TAR_NAME}" 2>/dev/null || echo "unknown") diff --git a/.mokogitea/workflows/pre-release.yml b/.mokogitea/workflows/pre-release.yml index 948cd3f..1ec5d77 100644 --- a/.mokogitea/workflows/pre-release.yml +++ b/.mokogitea/workflows/pre-release.yml @@ -52,6 +52,13 @@ jobs: sudo apt-get install -y -qq php-cli php-mbstring php-xml php-zip >/dev/null 2>&1 fi + - name: Setup moko-platform tools + env: + MOKO_CLONE_TOKEN: ${{ secrets.GA_TOKEN }} + MOKO_CLONE_HOST: git.mokoconsulting.tech/MokoConsulting + run: | + git clone --depth 1 --branch main --quiet "https://x-access-token:${MOKO_CLONE_TOKEN}@${MOKO_CLONE_HOST}/moko-platform.git" /tmp/moko-platform-api + - name: Detect platform id: platform run: | diff --git a/cli/joomla_build.php b/cli/joomla_build.php new file mode 100644 index 0000000..17cbd88 --- /dev/null +++ b/cli/joomla_build.php @@ -0,0 +1,295 @@ +#!/usr/bin/env php + + * + * SPDX-License-Identifier: GPL-3.0-or-later + * + * FILE INFORMATION + * DEFGROUP: moko-platform.CLI + * INGROUP: moko-platform + * REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform + * PATH: /cli/joomla_build.php + * VERSION: 05.00.01 + * BRIEF: Build a Joomla extension ZIP from manifest — all types supported + * NOTE: Called by pre-release and auto-release workflows. + * + * USAGE + * php joomla_build.php --path . --version 02.01.24 + * php joomla_build.php --path . --version 02.01.24 --suffix -dev + * php joomla_build.php --path . --version 02.01.24 --output build --github-output + * + * Supports: plugin, module, component, template, package, library, file + */ + +declare(strict_types=1); + +// ── Argument parsing ──────────────────────────────────────────────────── +$path = '.'; +$version = ''; +$suffix = ''; +$outputDir = 'build'; +$ghOutput = false; + +foreach ($argv as $i => $arg) { + if ($arg === '--path' && isset($argv[$i + 1])) $path = $argv[$i + 1]; + if ($arg === '--version' && isset($argv[$i + 1])) $version = $argv[$i + 1]; + if ($arg === '--suffix' && isset($argv[$i + 1])) $suffix = $argv[$i + 1]; + if ($arg === '--output' && isset($argv[$i + 1])) $outputDir = $argv[$i + 1]; + if ($arg === '--github-output') $ghOutput = true; +} + +if ($version === '') { + fwrite(STDERR, "::error::--version is required\n"); + exit(1); +} + +$path = realpath($path) ?: $path; + +// ── Find source directory ────────────────────────────────────────────── +$srcDir = null; +foreach (['src', 'htdocs'] as $d) { + if (is_dir("{$path}/{$d}")) { $srcDir = "{$path}/{$d}"; break; } +} +if ($srcDir === null) { + fwrite(STDERR, "::error::No src/ or htdocs/ directory in {$path}\n"); + exit(1); +} + +// ── Find manifest ────────────────────────────────────────────────────── +$manifest = findManifest($srcDir); +if ($manifest === null) { + fwrite(STDERR, "::error::No Joomla manifest found in {$srcDir}\n"); + exit(1); +} + +fwrite(STDERR, "Manifest: {$manifest}\n"); + +// ── Parse manifest ───────────────────────────────────────────────────── +$meta = parseManifest($manifest); + +// Resolve language-key names (e.g. PLG_SYSTEM_MOKOWAAS -> "System - Moko WaaS") +if (preg_match('/^[A-Z_]+$/', $meta['name'])) { + $resolved = resolveLanguageKey($srcDir, $meta['name']); + if ($resolved !== null) { $meta['name'] = $resolved; } +} + +$prefix = typePrefix($meta); +$zipName = "{$prefix}{$meta['element']}-{$version}{$suffix}.zip"; +$zipPath = "{$outputDir}/{$zipName}"; + +fwrite(STDERR, "=== Joomla Build: {$meta['type']} — {$meta['element']} {$version}{$suffix} ===\n"); +fwrite(STDERR, " Type: {$meta['type']}\n"); +fwrite(STDERR, " Element: {$meta['element']}\n"); +fwrite(STDERR, " Group: " . ($meta['group'] ?: 'n/a') . "\n"); +fwrite(STDERR, " Name: {$meta['name']}\n"); +fwrite(STDERR, " Output: {$zipName}\n"); + +// ── Build ────────────────────────────────────────────────────────────── +if (!is_dir($outputDir)) { mkdir($outputDir, 0755, true); } + +if ($meta['type'] === 'package') { + buildPackageZip($srcDir, $zipPath); +} else { + buildZip($srcDir, $zipPath); +} + +$sha256 = hash_file('sha256', $zipPath); +$size = filesize($zipPath); + +fwrite(STDERR, "Package: {$zipPath} ({$size} bytes, SHA: " . substr($sha256, 0, 16) . "...)\n"); + +// ── Output variables ─────────────────────────────────────────────────── +$vars = [ + 'zip_name' => $zipName, + 'zip_path' => $zipPath, + 'sha256' => $sha256, + 'ext_type' => $meta['type'], + 'ext_element' => $meta['element'], + 'ext_name' => $meta['name'], + 'ext_group' => $meta['group'], + 'type_prefix' => $prefix, +]; + +if ($ghOutput && ($ghFile = getenv('GITHUB_OUTPUT')) !== false && $ghFile !== '') { + $fh = fopen($ghFile, 'a'); + foreach ($vars as $k => $v) { fwrite($fh, "{$k}={$v}\n"); } + fclose($fh); + fwrite(STDERR, "Wrote " . count($vars) . " outputs to GITHUB_OUTPUT\n"); +} else { + foreach ($vars as $k => $v) { echo "{$k}={$v}\n"; } +} + +exit(0); + +// ═══════════════════════════════════════════════════════════════════════ +// Functions +// ═══════════════════════════════════════════════════════════════════════ + +function findManifest(string $dir): ?string +{ + // Priority: pkg_*.xml (packages), then any *.xml with + foreach (glob("{$dir}/pkg_*.xml") ?: [] as $f) { return $f; } + foreach (glob("{$dir}/*.xml") ?: [] as $f) { + if (str_contains((string) file_get_contents($f), 'isFile() && $item->getExtension() === 'xml') { + if (str_contains((string) file_get_contents($item->getPathname()), 'getPathname(); + } + } + } + return null; +} + +function parseManifest(string $file): array +{ + $xml = simplexml_load_file($file); + $name = (string) ($xml->name ?? ''); + $type = (string) ($xml->attributes()->type ?? 'component'); + $element = (string) ($xml->element ?? ''); + $group = (string) ($xml->attributes()->group ?? ''); + + // Fallback element detection + if ($element === '') { $element = (string) ($xml->attributes()->plugin ?? ''); } + if ($element === '') { $element = (string) ($xml->attributes()->module ?? ''); } + if ($element === '') { + $element = strtolower(basename($file, '.xml')); + if (in_array($element, ['templatedetails', 'manifest'], true)) { + $element = strtolower(basename(dirname($file))); + } + } + if ($name === '') { $name = $element; } + + return compact('name', 'type', 'element', 'group'); +} + +function typePrefix(array $meta): string +{ + return match ($meta['type']) { + 'plugin' => "plg_{$meta['group']}_", + 'module' => 'mod_', + 'component' => 'com_', + 'template' => 'tpl_', + 'package' => 'pkg_', + 'library' => 'lib_', + default => '', + }; +} + +function resolveLanguageKey(string $srcDir, string $key): ?string +{ + $iter = new RecursiveIteratorIterator( + new RecursiveDirectoryIterator($srcDir, FilesystemIterator::SKIP_DOTS) + ); + foreach ($iter as $item) { + if ($item->isFile() && str_ends_with($item->getFilename(), '.sys.ini')) { + foreach (file($item->getPathname()) as $line) { + if (preg_match('/^' . preg_quote($key, '/') . '="(.+)"/', trim($line), $m)) { + return $m[1]; + } + } + } + } + return null; +} + +function isExcluded(string $name): bool +{ + if ($name === '.ftpignore') return true; + if (str_starts_with($name, 'sftp-config')) return true; + if (str_starts_with($name, '.env')) return true; + if (str_starts_with($name, '.build-trigger')) return true; + $ext = pathinfo($name, PATHINFO_EXTENSION); + return in_array($ext, ['ppk', 'pem', 'key', 'local'], true); +} + +function buildZip(string $srcDir, string $outPath): void +{ + $zip = new ZipArchive(); + if ($zip->open($outPath, ZipArchive::CREATE | ZipArchive::OVERWRITE) !== true) { + fwrite(STDERR, "::error::Cannot create ZIP: {$outPath}\n"); + exit(1); + } + $iter = new RecursiveIteratorIterator( + new RecursiveDirectoryIterator($srcDir, FilesystemIterator::SKIP_DOTS), + RecursiveIteratorIterator::SELF_FIRST + ); + foreach ($iter as $file) { + $local = str_replace('\\', '/', substr($file->getPathname(), strlen($srcDir) + 1)); + if (isExcluded(basename($local))) continue; + $file->isDir() ? $zip->addEmptyDir($local) : $zip->addFile($file->getPathname(), $local); + } + $zip->close(); +} + +function buildPackageZip(string $srcDir, string $outPath): void +{ + fwrite(STDERR, "Building Joomla package (multi-extension)...\n"); + $staging = sys_get_temp_dir() . '/moko_pkg_' . uniqid(); + mkdir($staging, 0755, true); + + // 1. Zip each sub-extension in packages/ + $packagesDir = "{$srcDir}/packages"; + if (is_dir($packagesDir)) { + foreach (glob("{$packagesDir}/*", GLOB_ONLYDIR) as $extDir) { + $subManifest = findManifest($extDir); + if ($subManifest) { + $sub = parseManifest($subManifest); + $subPrefix = typePrefix($sub); + $subZipName = "{$subPrefix}{$sub['element']}.zip"; + } else { + $subZipName = basename($extDir) . '.zip'; + } + + fwrite(STDERR, " Sub-extension: {$subZipName}\n"); + buildZip($extDir, "{$staging}/{$subZipName}"); + } + } + + // 2. Copy package-level files (manifest, script, language) + foreach (glob("{$srcDir}/*.xml") ?: [] as $f) copy($f, "{$staging}/" . basename($f)); + foreach (glob("{$srcDir}/*.php") ?: [] as $f) copy($f, "{$staging}/" . basename($f)); + foreach (['language', 'administrator'] as $d) { + if (is_dir("{$srcDir}/{$d}")) { + copyTree("{$srcDir}/{$d}", "{$staging}/{$d}"); + } + } + + // 3. Create outer zip + buildZip($staging, $outPath); + + // Cleanup + rmTree($staging); +} + +function copyTree(string $src, string $dst): void +{ + if (!is_dir($dst)) mkdir($dst, 0755, true); + $iter = new RecursiveIteratorIterator( + new RecursiveDirectoryIterator($src, FilesystemIterator::SKIP_DOTS), + RecursiveIteratorIterator::SELF_FIRST + ); + foreach ($iter as $item) { + $target = "{$dst}/" . $iter->getSubPathname(); + $item->isDir() ? (is_dir($target) || mkdir($target, 0755, true)) : copy($item->getPathname(), $target); + } +} + +function rmTree(string $dir): void +{ + if (!is_dir($dir)) return; + $iter = new RecursiveIteratorIterator( + new RecursiveDirectoryIterator($dir, FilesystemIterator::SKIP_DOTS), + RecursiveIteratorIterator::CHILD_FIRST + ); + foreach ($iter as $item) { + $item->isDir() ? rmdir($item->getPathname()) : unlink($item->getPathname()); + } + rmdir($dir); +} diff --git a/cli/joomla_release.php b/cli/joomla_release.php index 721c221..d27f917 100644 --- a/cli/joomla_release.php +++ b/cli/joomla_release.php @@ -117,14 +117,21 @@ class JoomlaRelease extends CLIApp return 1; } - $zipName = "{$meta['element']}-{$displayVersion}.zip"; - $tarName = "{$meta['element']}-{$displayVersion}.tar.gz"; + $prefix = $this->typePrefix($meta); + $zipName = "{$prefix}{$meta['element']}-{$displayVersion}.zip"; + $tarName = "{$prefix}{$meta['element']}-{$displayVersion}.tar.gz"; $zipPath = sys_get_temp_dir() . "/{$zipName}"; $tarPath = sys_get_temp_dir() . "/{$tarName}"; + $this->log('INFO', "Type: {$meta['type']} | Element: {$meta['element']} | Group: {$meta['group']}"); + $sha256 = 'dry-run'; if (!$dryRun) { - $this->buildZip($srcDir, $zipPath); + if ($meta['type'] === 'package') { + $this->buildPackageZip($srcDir, $zipPath); + } else { + $this->buildZip($srcDir, $zipPath); + } $this->buildTarGz($srcDir, $tarPath); $sha256 = hash_file('sha256', $zipPath); $this->log('SUCCESS', "ZIP: {$zipName} (" . filesize($zipPath) . " bytes)"); @@ -227,6 +234,94 @@ class JoomlaRelease extends CLIApp // ── Package building ───────────────────────────────────────────── + + /** + * Get the Joomla type prefix for ZIP naming. + * + * @param array $meta Parsed manifest metadata + * @return string Prefix like "plg_system_", "mod_", "com_", etc. + */ + private function typePrefix(array $meta): string + { + return match ($meta['type']) { + 'plugin' => "plg_{$meta['group']}_", + 'module' => 'mod_', + 'component' => 'com_', + 'template' => 'tpl_', + 'package' => 'pkg_', + 'library' => 'lib_', + default => '', + }; + } + + /** + * Build a Joomla package ZIP (type="package") with nested sub-extension zips. + * + * @param string $srcDir Source directory containing pkg_*.xml and packages/ + * @param string $outPath Output ZIP path + */ + private function buildPackageZip(string $srcDir, string $outPath): void + { + $staging = sys_get_temp_dir() . '/moko_pkg_' . uniqid(); + mkdir($staging, 0755, true); + + // 1. Zip each sub-extension in packages/ + $packagesDir = $srcDir . '/packages'; + if (is_dir($packagesDir)) { + foreach (glob("{$packagesDir}/*", GLOB_ONLYDIR) as $extDir) { + $subManifest = null; + foreach (glob("{$extDir}/*.xml") as $xml) { + if (str_contains(file_get_contents($xml), 'parseManifest($subManifest); + $prefix = $this->typePrefix($sub); + $subZipName = "{$prefix}{$sub['element']}.zip"; + } else { + $subZipName = basename($extDir) . '.zip'; + } + + $this->log('INFO', " Sub-extension: {$subZipName}"); + $this->buildZip($extDir, "{$staging}/{$subZipName}"); + } + } + + // 2. Copy package-level files (manifest, script, language) + foreach (glob("{$srcDir}/*.xml") as $f) { copy($f, "{$staging}/" . basename($f)); } + foreach (glob("{$srcDir}/*.php") as $f) { copy($f, "{$staging}/" . basename($f)); } + foreach (['language', 'administrator'] as $d) { + if (is_dir("{$srcDir}/{$d}")) { + $this->copyDir("{$srcDir}/{$d}", "{$staging}/{$d}"); + } + } + + // 3. Create the outer zip + $this->buildZip($staging, $outPath); + + // Cleanup + $this->rmdir($staging); + } + + /** + * Recursively copy a directory. + */ + private function copyDir(string $src, string $dst): void + { + if (!is_dir($dst)) { mkdir($dst, 0755, true); } + $iter = new \RecursiveIteratorIterator( + new \RecursiveDirectoryIterator($src, \FilesystemIterator::SKIP_DOTS), + \RecursiveIteratorIterator::SELF_FIRST + ); + foreach ($iter as $item) { + $target = $dst . '/' . $iter->getSubPathname(); + $item->isDir() ? (is_dir($target) || mkdir($target, 0755, true)) : copy($item->getPathname(), $target); + } + } + private function buildZip(string $srcDir, string $outPath): void { $zip = new \ZipArchive(); -- 2.52.0 From a09d880c0ad4d5a906b6c8571d4f260a91b51a12 Mon Sep 17 00:00:00 2001 From: Jonathan Miller Date: Thu, 21 May 2026 16:54:55 -0500 Subject: [PATCH 04/12] refactor: rename .gitea/ to .mokogitea/ in all PHP code and sync engine Update GiteaAdapter.getWorkflowDir() and getMetadataDir() to return .mokogitea paths. All 24 PHP files referencing .gitea/ updated. Bulk sync will now push workflows to .mokogitea/workflows/ in governed repos. Co-Authored-By: Claude Opus 4.6 (1M context) --- .mokogitea/workflows/ci-platform.yml | 2 +- automation/enrich_mokostandards_xml.php | 4 ++-- automation/push_mokostandards_xml.php | 6 +++--- automation/repo_cleanup.php | 2 +- cli/create_project.php | 2 +- cli/manifest_read.php | 11 +++++++---- cli/release.php | 4 ++-- lib/Enterprise/GitPlatformAdapter.php | 4 ++-- lib/Enterprise/GiteaAdapter.php | 6 +++--- lib/Enterprise/MokoStandardsParser.php | 2 +- lib/Enterprise/Plugins/ApiPlugin.php | 6 +++--- lib/Enterprise/Plugins/GenericPlugin.php | 10 +++++----- lib/Enterprise/Plugins/MobilePlugin.php | 6 +++--- lib/Enterprise/Plugins/NodeJsPlugin.php | 6 +++--- lib/Enterprise/Plugins/PythonPlugin.php | 6 +++--- lib/Enterprise/ProjectMetricsCollector.php | 6 +++--- lib/Enterprise/RepositoryHealthChecker.php | 4 ++-- lib/Enterprise/RepositorySynchronizer.php | 8 ++++---- lib/Enterprise/UnifiedValidation.php | 4 ++-- templates/scripts/validate/validate_structure.php | 10 +++++----- tests/Enterprise/GitPlatformAdapterTest.php | 2 +- validate/check_repo_health.php | 2 +- validate/check_structure.php | 2 +- validate/check_version_consistency.php | 2 +- validate/scan_drift.php | 2 +- 25 files changed, 61 insertions(+), 58 deletions(-) diff --git a/.mokogitea/workflows/ci-platform.yml b/.mokogitea/workflows/ci-platform.yml index 14bb85d..aa777f0 100644 --- a/.mokogitea/workflows/ci-platform.yml +++ b/.mokogitea/workflows/ci-platform.yml @@ -348,7 +348,7 @@ jobs: echo "::error file=${file}::Invalid YAML" ERRORS=$((ERRORS + 1)) fi - done < <(find .gitea/workflows/ -name "*.yml" -o -name "*.yaml" 2>/dev/null | tr '\n' '\0') + done < <(find .mokogitea/workflows/ -name "*.yml" -o -name "*.yaml" 2>/dev/null | tr '\n' '\0') { echo "### Template Integrity" diff --git a/automation/enrich_mokostandards_xml.php b/automation/enrich_mokostandards_xml.php index ac75e2d..38d477f 100644 --- a/automation/enrich_mokostandards_xml.php +++ b/automation/enrich_mokostandards_xml.php @@ -276,7 +276,7 @@ foreach ($repos as $repo) { [$ret] = safeExec('git clone --depth 1 --branch ' . escapeshellarg($defaultBranch) . ' ' . escapeshellarg($authedUrl) . ' ' . escapeshellarg($workDir)); if ($ret !== 0) { echo "FAIL (clone)\n"; $stats['failed']++; continue; } - $manifestPath = "{$workDir}/.gitea/.mokostandards"; + $manifestPath = "{$workDir}/.mokogitea/.mokostandards"; if (!file_exists($manifestPath) || !str_contains(file_get_contents($manifestPath), 'extractPlatform(file_get_contents($manifestPath)); @@ -270,7 +270,7 @@ foreach ($repos as $repo) { gitCmd($workDir, 'config', 'user.name', 'gitea-actions[bot]'); gitCmd($workDir, 'config', 'user.email', 'gitea-actions[bot]@git.mokoconsulting.tech'); - gitCmd($workDir, 'add', '.gitea/.mokostandards'); + gitCmd($workDir, 'add', '.mokogitea/.mokostandards'); foreach ($legacyDeleted as $lf) { gitCmd($workDir, 'add', $lf); } diff --git a/automation/repo_cleanup.php b/automation/repo_cleanup.php index e93f8ba..0a522f2 100644 --- a/automation/repo_cleanup.php +++ b/automation/repo_cleanup.php @@ -361,7 +361,7 @@ class RepoCleanup extends CLIApp } catch (\Exception $e) { /* fallback to main */ } // Check both workflow directories for retired workflows (supports dual-platform repos) - $wfDirs = array_unique(['.github/workflows', '.gitea/workflows', $this->adapter->getWorkflowDir()]); + $wfDirs = array_unique(['.github/workflows', '.mokogitea/workflows', $this->adapter->getWorkflowDir()]); foreach (self::RETIRED_WORKFLOWS as $wf) { foreach ($wfDirs as $wfDir) { $path = "{$wfDir}/{$wf}"; diff --git a/cli/create_project.php b/cli/create_project.php index bbe28db..75a5a69 100644 --- a/cli/create_project.php +++ b/cli/create_project.php @@ -162,7 +162,7 @@ function restGet(string $path, string $token, ?\MokoEnterprise\ApiClient $apiCli function detectPlatform(string $org, string $repo, string $token, ?\MokoEnterprise\ApiClient $apiClient = null): string { // Try platform metadata dir first, then root - foreach (['.github/.mokostandards', '.gitea/.mokostandards', '.mokostandards'] as $path) { + foreach (['.github/.mokostandards', '.mokogitea/.mokostandards', '.mokostandards'] as $path) { $data = restGet("repos/{$org}/{$repo}/contents/{$path}", $token, $apiClient); if (!empty($data['content'])) { $content = base64_decode($data['content']); diff --git a/cli/manifest_read.php b/cli/manifest_read.php index 4e8a8ca..19444c9 100644 --- a/cli/manifest_read.php +++ b/cli/manifest_read.php @@ -51,7 +51,7 @@ $manifestFile = null; $candidates = [ "{$root}/.mokogitea/.manifest.xml", "{$root}/.mokogitea/.moko-platform", - "{$root}/.gitea/.mokostandards", // legacy v4 + "{$root}/.mokogitea/.mokostandards", // legacy v4 ]; foreach ($candidates as $candidate) { @@ -76,15 +76,18 @@ if ($xml === false) { $fields = []; if (preg_match('/^platform:\s*(.+)/m', $content, $m)) { $fields['platform'] = trim($m[1], " - \"'"); + +\"'"); } if (preg_match('/^standards_version:\s*(.+)/m', $content, $m)) { $fields['standards-version'] = trim($m[1], " - \"'"); + +\"'"); } if (preg_match('/^governed_repo:\s*(.+)/m', $content, $m)) { $fields['name'] = trim($m[1], " - \"'"); + +\"'"); } } else { // Register namespace for XPath (optional, simple path works without) diff --git a/cli/release.php b/cli/release.php index f92d5bc..63bc23d 100644 --- a/cli/release.php +++ b/cli/release.php @@ -31,8 +31,8 @@ foreach ($argv as $i => $arg) { $repoRoot = dirname(__DIR__, 2); $syncFile = "{$repoRoot}/lib/Enterprise/RepositorySynchronizer.php"; // Check both workflow directories for the bulk-repo-sync workflow -$bulkSyncFile = file_exists("{$repoRoot}/.gitea/workflows/bulk-repo-sync.yml") - ? "{$repoRoot}/.gitea/workflows/bulk-repo-sync.yml" +$bulkSyncFile = file_exists("{$repoRoot}/.mokogitea/workflows/bulk-repo-sync.yml") + ? "{$repoRoot}/.mokogitea/workflows/bulk-repo-sync.yml" : "{$repoRoot}/.github/workflows/bulk-repo-sync.yml"; $cleanupFile = "{$repoRoot}/templates/workflows/shared/repository-cleanup.yml.template"; diff --git a/lib/Enterprise/GitPlatformAdapter.php b/lib/Enterprise/GitPlatformAdapter.php index ddb7f48..39cd340 100644 --- a/lib/Enterprise/GitPlatformAdapter.php +++ b/lib/Enterprise/GitPlatformAdapter.php @@ -50,14 +50,14 @@ interface GitPlatformAdapter /** * Get the workflow directory name for this platform. * - * @return string '.github/workflows' or '.gitea/workflows' + * @return string '.github/workflows' or '.mokogitea/workflows' */ public function getWorkflowDir(): string; /** * Get the platform-specific metadata directory. * - * @return string '.github' or '.gitea' + * @return string '.github' or '.mokogitea' */ public function getMetadataDir(): string; diff --git a/lib/Enterprise/GiteaAdapter.php b/lib/Enterprise/GiteaAdapter.php index a38cbcf..d7e6817 100644 --- a/lib/Enterprise/GiteaAdapter.php +++ b/lib/Enterprise/GiteaAdapter.php @@ -30,7 +30,7 @@ use RuntimeException; * - File ops: POST for create, PUT for update (check existence first) * - Topics: PUT with {"topics": [...]} * - Branch protection: flat API (not rulesets) - * - Workflow dir: .gitea/workflows + * - Workflow dir: .mokogitea/workflows * * @package MokoStandards\Enterprise * @version 04.06.10 @@ -62,12 +62,12 @@ class GiteaAdapter implements GitPlatformAdapter public function getWorkflowDir(): string { - return '.gitea/workflows'; + return '.mokogitea/workflows'; } public function getMetadataDir(): string { - return '.gitea'; + return '.mokogitea'; } public function getRepoWebUrl(string $org, string $repo): string diff --git a/lib/Enterprise/MokoStandardsParser.php b/lib/Enterprise/MokoStandardsParser.php index 9d6374b..4b45b76 100644 --- a/lib/Enterprise/MokoStandardsParser.php +++ b/lib/Enterprise/MokoStandardsParser.php @@ -25,7 +25,7 @@ use SimpleXMLElement; * MokoStandards Parser * * Reads, writes, and validates the .mokostandards repository manifest. - * The file uses XML format (no file extension) and lives at .gitea/.mokostandards. + * The file uses XML format (no file extension) and lives at .mokogitea/.mokostandards. * * @package MokoStandards\Enterprise * @version 04.07.00 diff --git a/lib/Enterprise/Plugins/ApiPlugin.php b/lib/Enterprise/Plugins/ApiPlugin.php index 2c6baf0..7ce74fa 100644 --- a/lib/Enterprise/Plugins/ApiPlugin.php +++ b/lib/Enterprise/Plugins/ApiPlugin.php @@ -287,7 +287,7 @@ class ApiPlugin extends AbstractProjectPlugin 'docker-compose.yml', 'kubernetes/*.yaml', 'tests/ or test/', - '.gitea/workflows/* or .gitea/workflows/* or .gitlab-ci.yml', + '.mokogitea/workflows/* or .gitea/workflows/* or .gitlab-ci.yml', 'middleware/ or middlewares/', ]; } @@ -673,8 +673,8 @@ class ApiPlugin extends AbstractProjectPlugin */ private function hasCICD(string $projectPath): bool { - return $this->fileExists($projectPath, '.gitea/workflows') || - $this->fileExists($projectPath, '.gitea/workflows') || + return $this->fileExists($projectPath, '.mokogitea/workflows') || + $this->fileExists($projectPath, '.mokogitea/workflows') || $this->fileExists($projectPath, '.gitlab-ci.yml') || $this->fileExists($projectPath, 'Jenkinsfile') || $this->fileExists($projectPath, '.circleci'); diff --git a/lib/Enterprise/Plugins/GenericPlugin.php b/lib/Enterprise/Plugins/GenericPlugin.php index 27297b0..aec9b91 100644 --- a/lib/Enterprise/Plugins/GenericPlugin.php +++ b/lib/Enterprise/Plugins/GenericPlugin.php @@ -73,8 +73,8 @@ class GenericPlugin extends AbstractProjectPlugin } // Check for CI/CD configuration - $hasCICD = $this->fileExists($projectPath, '.gitea/workflows') || - $this->fileExists($projectPath, '.gitea/workflows') || + $hasCICD = $this->fileExists($projectPath, '.mokogitea/workflows') || + $this->fileExists($projectPath, '.mokogitea/workflows') || $this->fileExists($projectPath, '.gitlab-ci.yml') || $this->fileExists($projectPath, '.travis.yml') || $this->fileExists($projectPath, 'Jenkinsfile') || @@ -275,7 +275,7 @@ class GenericPlugin extends AbstractProjectPlugin 'CONTRIBUTING.md', 'CODE_OF_CONDUCT.md', 'SECURITY.md', - '.gitea/workflows/* or .gitea/workflows/* or .gitlab-ci.yml', + '.mokogitea/workflows/* or .gitea/workflows/* or .gitlab-ci.yml', 'docs/ or documentation/', 'tests/ or test/', ]; @@ -359,8 +359,8 @@ class GenericPlugin extends AbstractProjectPlugin */ private function hasCICD(string $projectPath): bool { - return $this->fileExists($projectPath, '.gitea/workflows') || - $this->fileExists($projectPath, '.gitea/workflows') || + return $this->fileExists($projectPath, '.mokogitea/workflows') || + $this->fileExists($projectPath, '.mokogitea/workflows') || $this->fileExists($projectPath, '.gitlab-ci.yml') || $this->fileExists($projectPath, '.travis.yml') || $this->fileExists($projectPath, 'Jenkinsfile') || diff --git a/lib/Enterprise/Plugins/MobilePlugin.php b/lib/Enterprise/Plugins/MobilePlugin.php index 87048dd..348ec7e 100644 --- a/lib/Enterprise/Plugins/MobilePlugin.php +++ b/lib/Enterprise/Plugins/MobilePlugin.php @@ -337,7 +337,7 @@ class MobilePlugin extends AbstractProjectPlugin 'App icons for all required sizes', 'Splash screen assets', 'tests/ or __tests__/', - '.gitea/workflows/* or .gitea/workflows/* or fastlane/', + '.mokogitea/workflows/* or .gitea/workflows/* or fastlane/', 'React Native: metro.config.js', 'Flutter: analysis_options.yaml', 'iOS: Podfile', @@ -542,8 +542,8 @@ class MobilePlugin extends AbstractProjectPlugin */ private function hasCICD(string $projectPath): bool { - return $this->fileExists($projectPath, '.gitea/workflows') || - $this->fileExists($projectPath, '.gitea/workflows') || + return $this->fileExists($projectPath, '.mokogitea/workflows') || + $this->fileExists($projectPath, '.mokogitea/workflows') || $this->fileExists($projectPath, '.gitlab-ci.yml') || $this->fileExists($projectPath, 'fastlane') || $this->fileExists($projectPath, '.circleci'); diff --git a/lib/Enterprise/Plugins/NodeJsPlugin.php b/lib/Enterprise/Plugins/NodeJsPlugin.php index 2ee3488..0bd0c28 100644 --- a/lib/Enterprise/Plugins/NodeJsPlugin.php +++ b/lib/Enterprise/Plugins/NodeJsPlugin.php @@ -314,7 +314,7 @@ class NodeJsPlugin extends AbstractProjectPlugin '.nvmrc or .node-version', '.editorconfig', 'jest.config.js or vitest.config.js', - '.gitea/workflows/* or .gitea/workflows/* or .gitlab-ci.yml', + '.mokogitea/workflows/* or .gitea/workflows/* or .gitlab-ci.yml', ]; } @@ -511,8 +511,8 @@ class NodeJsPlugin extends AbstractProjectPlugin */ private function hasCICD(string $projectPath): bool { - return $this->fileExists($projectPath, '.gitea/workflows') || - $this->fileExists($projectPath, '.gitea/workflows') || + return $this->fileExists($projectPath, '.mokogitea/workflows') || + $this->fileExists($projectPath, '.mokogitea/workflows') || $this->fileExists($projectPath, '.gitlab-ci.yml') || $this->fileExists($projectPath, '.travis.yml') || $this->fileExists($projectPath, '.circleci/config.yml'); diff --git a/lib/Enterprise/Plugins/PythonPlugin.php b/lib/Enterprise/Plugins/PythonPlugin.php index c8f6048..e398e36 100644 --- a/lib/Enterprise/Plugins/PythonPlugin.php +++ b/lib/Enterprise/Plugins/PythonPlugin.php @@ -323,7 +323,7 @@ class PythonPlugin extends AbstractProjectPlugin 'pytest.ini or pyproject.toml', '.python-version or .tool-versions', 'Dockerfile', - '.gitea/workflows/* or .gitea/workflows/* or .gitlab-ci.yml', + '.mokogitea/workflows/* or .gitea/workflows/* or .gitlab-ci.yml', ]; } @@ -532,8 +532,8 @@ class PythonPlugin extends AbstractProjectPlugin */ private function hasCICD(string $projectPath): bool { - return $this->fileExists($projectPath, '.gitea/workflows') || - $this->fileExists($projectPath, '.gitea/workflows') || + return $this->fileExists($projectPath, '.mokogitea/workflows') || + $this->fileExists($projectPath, '.mokogitea/workflows') || $this->fileExists($projectPath, '.gitlab-ci.yml') || $this->fileExists($projectPath, '.travis.yml') || $this->fileExists($projectPath, 'tox.ini'); diff --git a/lib/Enterprise/ProjectMetricsCollector.php b/lib/Enterprise/ProjectMetricsCollector.php index fc9ed07..ab2866e 100644 --- a/lib/Enterprise/ProjectMetricsCollector.php +++ b/lib/Enterprise/ProjectMetricsCollector.php @@ -125,13 +125,13 @@ class ProjectMetricsCollector // CI/CD — check both .github/workflows and .gitea/workflows $hasGithubWf = is_dir("{$path}/.github/workflows"); - $hasGiteaWf = is_dir("{$path}/.gitea/workflows"); + $hasGiteaWf = is_dir("{$path}/.mokogitea/workflows"); $this->collectedMetrics['has_ci_workflows'] = ($hasGithubWf || $hasGiteaWf) ? 1 : 0; $this->collectedMetrics['workflow_count'] = $this->countFiles("{$path}/.github/workflows", '*.yml') + $this->countFiles("{$path}/.github/workflows", '*.yaml') + - $this->countFiles("{$path}/.gitea/workflows", '*.yml') + - $this->countFiles("{$path}/.gitea/workflows", '*.yaml'); + $this->countFiles("{$path}/.mokogitea/workflows", '*.yml') + + $this->countFiles("{$path}/.mokogitea/workflows", '*.yaml'); } private function collectNodeJSMetrics(string $path): void diff --git a/lib/Enterprise/RepositoryHealthChecker.php b/lib/Enterprise/RepositoryHealthChecker.php index a82771c..c437354 100644 --- a/lib/Enterprise/RepositoryHealthChecker.php +++ b/lib/Enterprise/RepositoryHealthChecker.php @@ -175,7 +175,7 @@ class RepositoryHealthChecker // Check both .github/workflows and .gitea/workflows $githubDir = "{$path}/.github/workflows"; - $giteaDir = "{$path}/.gitea/workflows"; + $giteaDir = "{$path}/.mokogitea/workflows"; $hasWorkflowDir = is_dir($githubDir) || is_dir($giteaDir); $workflowDir = is_dir($giteaDir) ? $giteaDir : $githubDir; @@ -212,7 +212,7 @@ class RepositoryHealthChecker // Check for security scanning workflow (CodeQL on GitHub, Trivy on Gitea) $githubWf = "{$path}/.github/workflows"; - $giteaWf = "{$path}/.gitea/workflows"; + $giteaWf = "{$path}/.mokogitea/workflows"; $hasSecurityScan = false; if (is_dir($githubWf)) { $hasSecurityScan = !empty(glob("{$githubWf}/*codeql*.yml")) || !empty(glob("{$githubWf}/*codeql*.yaml")); diff --git a/lib/Enterprise/RepositorySynchronizer.php b/lib/Enterprise/RepositorySynchronizer.php index 747fbb8..0bc8320 100644 --- a/lib/Enterprise/RepositorySynchronizer.php +++ b/lib/Enterprise/RepositorySynchronizer.php @@ -424,7 +424,7 @@ HCL; /** * Read the platform slug from the remote .mokostandards manifest. - * Checks .gitea/.mokostandards, .github/.mokostandards, and root .mokostandards. + * Checks .mokogitea/.mokostandards, .github/.mokostandards, and root .mokostandards. * * @return string|null Platform slug or null if not found/parseable */ @@ -435,7 +435,7 @@ HCL; "{$metaDir}/.mokostandards", '.mokostandards', ]; - if ($metaDir === '.gitea') { + if ($metaDir === '.mokogitea') { $paths[] = '.github/.mokostandards'; } @@ -769,7 +769,7 @@ HCL; * and convert legacy YAML-like format to the new XML manifest. * * Handles: - * 1. Location migration: root or .github/ → .gitea/.mokostandards + * 1. Location migration: root or .github/ → .mokogitea/.mokostandards * 2. Format migration: legacy "platform: xxx" → XML manifest * 3. Update existing XML: refresh timestamp */ @@ -786,7 +786,7 @@ HCL; // ── Collect existing files from all legacy locations ───────── $legacySources = ['.mokostandards']; - if ($metaDir === '.gitea') { + if ($metaDir === '.mokogitea') { $legacySources[] = '.github/.mokostandards'; } diff --git a/lib/Enterprise/UnifiedValidation.php b/lib/Enterprise/UnifiedValidation.php index fffe02d..4a60dd0 100644 --- a/lib/Enterprise/UnifiedValidation.php +++ b/lib/Enterprise/UnifiedValidation.php @@ -275,7 +275,7 @@ class WorkflowValidatorPlugin extends ValidationPlugin public function validate(array $context): ValidationResult { $workflowDir = $context['workflow_dir'] - ?? (is_dir('.gitea/workflows') ? '.gitea/workflows' : '.github/workflows'); + ?? (is_dir('.mokogitea/workflows') ? '.mokogitea/workflows' : '.github/workflows'); if (!is_dir($workflowDir)) { return new ValidationResult($this->name, true, 'No workflows directory'); @@ -286,7 +286,7 @@ class WorkflowValidatorPlugin extends ValidationPlugin glob($workflowDir . '/*.yml') ?: [], glob($workflowDir . '/*.yaml') ?: [] ); - $altDir = ($workflowDir === '.gitea/workflows') ? '.github/workflows' : '.gitea/workflows'; + $altDir = ($workflowDir === '.mokogitea/workflows') ? '.github/workflows' : '.mokogitea/workflows'; if (is_dir($altDir)) { $workflows = array_merge($workflows, glob($altDir . '/*.yml') ?: [], diff --git a/templates/scripts/validate/validate_structure.php b/templates/scripts/validate/validate_structure.php index d5da100..20d4627 100644 --- a/templates/scripts/validate/validate_structure.php +++ b/templates/scripts/validate/validate_structure.php @@ -31,7 +31,7 @@ use MokoEnterprise\CliFramework; * - Required root files present (README.md, CHANGELOG.md, LICENSE, CONTRIBUTING.md, * SECURITY.md, .gitignore, .editorconfig, composer.json) * - Required directories present (src/, docs/, tests/) - * - .gitea/.mokostandards XML governance manifest present + * - .mokogitea/.mokostandards XML governance manifest present * - SPDX-License-Identifier header present in all PHP source files * - No tab characters in YAML/JSON config files * - No Windows path separators in PHP source @@ -73,16 +73,16 @@ class ValidateStructure extends CliFramework // ── Governance attachment ───────────────────────────────────────── $this->section('MokoStandards governance'); - $mokoFile = file_exists("{$path}/.gitea/.mokostandards") + $mokoFile = file_exists("{$path}/.mokogitea/.mokostandards") || file_exists("{$path}/.github/.mokostandards") || file_exists("{$path}/.mokostandards"); - $this->status($mokoFile, '.gitea/.mokostandards (XML manifest)'); + $this->status($mokoFile, '.mokogitea/.mokostandards (XML manifest)'); $mokoFile ? $passed++ : $failed++; // Validate XML format if file exists if ($mokoFile) { - $manifestPath = file_exists("{$path}/.gitea/.mokostandards") - ? "{$path}/.gitea/.mokostandards" + $manifestPath = file_exists("{$path}/.mokogitea/.mokostandards") + ? "{$path}/.mokogitea/.mokostandards" : (file_exists("{$path}/.github/.mokostandards") ? "{$path}/.github/.mokostandards" : "{$path}/.mokostandards"); diff --git a/tests/Enterprise/GitPlatformAdapterTest.php b/tests/Enterprise/GitPlatformAdapterTest.php index 7533874..86fe6e0 100644 --- a/tests/Enterprise/GitPlatformAdapterTest.php +++ b/tests/Enterprise/GitPlatformAdapterTest.php @@ -72,7 +72,7 @@ $giteaAdapter = new GiteaAdapter($giteaClient); assert_true($giteaAdapter instanceof GitPlatformAdapter, 'GiteaAdapter implements GitPlatformAdapter'); assert_true($giteaAdapter->getPlatformName() === 'gitea', 'getPlatformName() returns "gitea"'); assert_true($giteaAdapter->getBaseUrl() === 'https://git.mokoconsulting.tech/api/v1', 'getBaseUrl() returns Gitea API URL'); -assert_true($giteaAdapter->getWorkflowDir() === '.gitea/workflows', 'getWorkflowDir() returns .gitea/workflows'); +assert_true($giteaAdapter->getWorkflowDir() === '.mokogitea/workflows', 'getWorkflowDir() returns .gitea/workflows'); assert_true($giteaAdapter->getApiClient() === $giteaClient, 'getApiClient() returns injected client'); echo "\n"; diff --git a/validate/check_repo_health.php b/validate/check_repo_health.php index c66b82d..c3e93ff 100755 --- a/validate/check_repo_health.php +++ b/validate/check_repo_health.php @@ -133,7 +133,7 @@ class RepoHealthChecker extends CliFramework $cat = 'manifest'; $this->initCategory($cat, 'Manifest & Config', 15); - $this->addCheck($cat, '.gitea/.moko-platform manifest', + $this->addCheck($cat, '.mokogitea/.moko-platform manifest', file_exists("{$p}/.gitea/.moko-platform"), 5); $this->addCheck($cat, 'Workflows directory', is_dir("{$p}/.gitea/workflows") || is_dir("{$p}/.github/workflows"), 5); diff --git a/validate/check_structure.php b/validate/check_structure.php index 6c3c171..d2d789f 100644 --- a/validate/check_structure.php +++ b/validate/check_structure.php @@ -30,7 +30,7 @@ class CheckStructure extends CliFramework private const REQUIRED_DIRS = ['docs', 'scripts']; /** @var list At least one of these workflow directories must exist. */ - private const WORKFLOW_DIRS = ['.github/workflows', '.gitea/workflows']; + private const WORKFLOW_DIRS = ['.github/workflows', '.mokogitea/workflows']; /** @var list Required file paths (relative to repo root). */ private const REQUIRED_FILES = ['README.md', 'LICENSE', 'CONTRIBUTING.md', 'SECURITY.md']; diff --git a/validate/check_version_consistency.php b/validate/check_version_consistency.php index bd167b6..5af06c1 100755 --- a/validate/check_version_consistency.php +++ b/validate/check_version_consistency.php @@ -115,7 +115,7 @@ class CheckVersionConsistency extends CliFramework // Check both .github/workflows and .gitea/workflows $workflowFiles = []; - foreach (['.github/workflows', '.gitea/workflows'] as $wfDir) { + foreach (['.github/workflows', '.mokogitea/workflows'] as $wfDir) { $dir = $path . '/' . $wfDir; if (is_dir($dir)) { $workflowFiles = array_merge($workflowFiles, glob($dir . '/*.yml') ?: []); diff --git a/validate/scan_drift.php b/validate/scan_drift.php index 83eb7ca..36f2f17 100755 --- a/validate/scan_drift.php +++ b/validate/scan_drift.php @@ -269,7 +269,7 @@ class DriftScanner extends CliFramework // Check workflows — scan both .github/workflows and .gitea/workflows $drift = $this->checkFileCategory($org, $repo, 'workflows', '.github/workflows', $drift, $protectedFiles, $syncExclusions); - $drift = $this->checkFileCategory($org, $repo, 'workflows_gitea', '.gitea/workflows', $drift, $protectedFiles, $syncExclusions); + $drift = $this->checkFileCategory($org, $repo, 'workflows_gitea', '.mokogitea/workflows', $drift, $protectedFiles, $syncExclusions); // Check GitHub configs $drift = $this->checkFileCategory($org, $repo, 'github', '.github', $drift, $protectedFiles, $syncExclusions); -- 2.52.0 From 6a29fbd99e9ed56d737350677bc228b49203ec42 Mon Sep 17 00:00:00 2001 From: Jonathan Miller Date: Thu, 21 May 2026 17:14:29 -0500 Subject: [PATCH 05/12] refactor: rename GiteaAdapter to MokoGiteaAdapter Rename class, file, and all references across the codebase to align with the moko-platform naming convention. Co-Authored-By: Claude Opus 4.6 (1M context) --- automation/migrate_to_gitea.php | 4 +- cli/bulk_workflow_trigger.php | 638 ++++----- cli/client_inventory.php | 668 +++++----- cli/scaffold_client.php | 500 +++---- deploy/backup-before-deploy.php | 424 +++--- deploy/deploy-dolibarr.php | 602 ++++----- deploy/health-check.php | 454 +++---- deploy/rollback-joomla.php | 460 +++---- deploy/sync-joomla.php | 906 ++++++------- lib/Enterprise/GitPlatformAdapter.php | 2 +- ...{GiteaAdapter.php => MokoGiteaAdapter.php} | 4 +- lib/Enterprise/PlatformAdapterFactory.php | 14 +- lib/Enterprise/RepositorySynchronizer.php | 2 +- tests/Enterprise/GitPlatformAdapterTest.php | 18 +- validate/check_file_integrity.php | 1170 ++++++++--------- 15 files changed, 2933 insertions(+), 2933 deletions(-) rename lib/Enterprise/{GiteaAdapter.php => MokoGiteaAdapter.php} (99%) diff --git a/automation/migrate_to_gitea.php b/automation/migrate_to_gitea.php index 9939a3f..2ef2bca 100644 --- a/automation/migrate_to_gitea.php +++ b/automation/migrate_to_gitea.php @@ -29,7 +29,7 @@ use MokoEnterprise\CliFramework; use MokoEnterprise\Config; use MokoEnterprise\PlatformAdapterFactory; use MokoEnterprise\GitHubAdapter; -use MokoEnterprise\GiteaAdapter; +use MokoEnterprise\MokoGiteaAdapter; /** * Gitea Migration Script @@ -42,7 +42,7 @@ use MokoEnterprise\GiteaAdapter; class MigrateToGitea extends CliFramework { private ?GitHubAdapter $github = null; - private ?GiteaAdapter $gitea = null; + private ?MokoGiteaAdapter $gitea = null; private ?CheckpointManager $checkpoints = null; protected function configure(): void diff --git a/cli/bulk_workflow_trigger.php b/cli/bulk_workflow_trigger.php index 541137d..25b66be 100644 --- a/cli/bulk_workflow_trigger.php +++ b/cli/bulk_workflow_trigger.php @@ -1,319 +1,319 @@ -#!/usr/bin/env php - - * - * This file is part of a Moko Consulting project. - * - * SPDX-License-Identifier: GPL-3.0-or-later - * - * FILE INFORMATION - * DEFGROUP: MokoStandards.Scripts.CLI - * INGROUP: MokoStandards - * REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform - * PATH: /cli/bulk_workflow_trigger.php - * VERSION: 01.00.00 - * BRIEF: Trigger a workflow across multiple repos at once - */ - -declare(strict_types=1); - -final class BulkWorkflowTrigger -{ - private string $giteaUrl = 'https://git.mokoconsulting.tech'; - private string $token = ''; - private string $reposFile = ''; - private string $org = ''; - private string $workflow = ''; - private string $ref = 'main'; - private string $inputs = ''; - private bool $dryRun = false; - - public function run(): int - { - $this->parseArgs(); - - if ($this->token === '') - { - $this->log('ERROR: --token is required.'); - $this->printUsage(); - return 1; - } - - if ($this->workflow === '') - { - $this->log('ERROR: --workflow is required.'); - $this->printUsage(); - return 1; - } - - if ($this->reposFile === '' && $this->org === '') - { - $this->log('ERROR: Either --repos or --org is required.'); - $this->printUsage(); - return 1; - } - - // Build repo list - $repos = $this->buildRepoList(); - - if ($repos === null || count($repos) === 0) - { - $this->log('ERROR: No repos found to process.'); - return 1; - } - - $this->log("Triggering workflow \"{$this->workflow}\" on ref \"{$this->ref}\" across " . count($repos) . " repo(s)."); - $this->log("Gitea URL: {$this->giteaUrl}"); - - if ($this->dryRun) - { - $this->log('[DRY RUN] No requests will be sent.'); - } - - $this->log(''); - - // Parse inputs - $inputsDecoded = null; - - if ($this->inputs !== '') - { - $inputsDecoded = json_decode($this->inputs, true); - - if (!is_array($inputsDecoded)) - { - $this->log('ERROR: --inputs must be valid JSON.'); - return 1; - } - } - - // Print header - $this->log(sprintf('%-40s | %s', 'Repo', 'Status')); - $this->log(str_repeat('-', 60)); - - $failCount = 0; - - foreach ($repos as $repo) - { - $repo = trim($repo); - - if ($repo === '' || strpos($repo, '/') === false) - { - continue; - } - - [$owner, $repoName] = explode('/', $repo, 2); - - if ($this->dryRun) - { - $this->log(sprintf('%-40s | %s', $repo, 'DRY RUN (skipped)')); - continue; - } - - $payload = ['ref' => $this->ref]; - - if ($inputsDecoded !== null) - { - $payload['inputs'] = $inputsDecoded; - } - - $response = $this->apiRequest( - 'POST', - "/api/v1/repos/{$owner}/{$repoName}/actions/workflows/{$this->workflow}/dispatches", - json_encode($payload) - ); - - if ($response['code'] >= 200 && $response['code'] < 300) - { - $status = 'TRIGGERED'; - } - elseif ($response['code'] === 404) - { - $status = 'FAILED (not found)'; - $failCount++; - } - elseif ($response['code'] === 422) - { - $status = 'SKIPPED (unprocessable)'; - } - else - { - $status = "FAILED (HTTP {$response['code']})"; - $failCount++; - } - - $this->log(sprintf('%-40s | %s', $repo, $status)); - } - - $this->log(''); - $this->log('Done. ' . ($failCount > 0 ? "{$failCount} failure(s)." : 'All succeeded.')); - - return $failCount > 0 ? 1 : 0; - } - - private function parseArgs(): void - { - $args = $_SERVER['argv'] ?? []; - $count = count($args); - - for ($i = 1; $i < $count; $i++) - { - switch ($args[$i]) - { - case '--gitea-url': - $this->giteaUrl = rtrim($args[++$i] ?? '', '/'); - break; - case '--token': - $this->token = $args[++$i] ?? ''; - break; - case '--repos': - $this->reposFile = $args[++$i] ?? ''; - break; - case '--org': - $this->org = $args[++$i] ?? ''; - break; - case '--workflow': - $this->workflow = $args[++$i] ?? ''; - break; - case '--ref': - $this->ref = $args[++$i] ?? 'main'; - break; - case '--inputs': - $this->inputs = $args[++$i] ?? ''; - break; - case '--dry-run': - $this->dryRun = true; - break; - case '--help': - case '-h': - $this->printUsage(); - exit(0); - default: - $this->log("WARNING: Unknown argument: {$args[$i]}"); - break; - } - } - } - - private function printUsage(): void - { - $this->log('Usage: bulk_workflow_trigger.php --token --workflow [options]'); - $this->log(''); - $this->log('Options:'); - $this->log(' --gitea-url Gitea URL (default: https://git.mokoconsulting.tech)'); - $this->log(' --token Gitea API token'); - $this->log(' --repos File with newline-separated owner/repo list'); - $this->log(' --org Trigger on all repos in an org'); - $this->log(' --workflow Workflow file (e.g., "sync-servers.yml")'); - $this->log(' --ref Branch ref (default: "main")'); - $this->log(' --inputs Workflow inputs as JSON string'); - $this->log(' --dry-run Show what would be done without triggering'); - $this->log(' --help, -h Show this help'); - } - - private function buildRepoList(): ?array - { - if ($this->reposFile !== '') - { - if (!file_exists($this->reposFile)) - { - $this->log("ERROR: Repos file not found: {$this->reposFile}"); - return null; - } - - $content = file_get_contents($this->reposFile); - $lines = array_filter(array_map('trim', explode("\n", $content)), function (string $line): bool { - return $line !== '' && $line[0] !== '#'; - }); - - return array_values($lines); - } - - // Fetch all repos from org - $this->log("Fetching repos from org: {$this->org}"); - - $page = 1; - $repos = []; - - while (true) - { - $response = $this->apiRequest('GET', "/api/v1/orgs/{$this->org}/repos?limit=50&page={$page}"); - - if ($response['code'] < 200 || $response['code'] >= 300) - { - if ($page === 1) - { - $this->log("ERROR: Could not fetch repos for org (HTTP {$response['code']})."); - return null; - } - - break; - } - - $data = json_decode($response['body'], true); - - if (!is_array($data) || count($data) === 0) - { - break; - } - - foreach ($data as $repo) - { - $fullName = $repo['full_name'] ?? ''; - - if ($fullName !== '') - { - $repos[] = $fullName; - } - } - - $page++; - } - - $this->log('Found ' . count($repos) . " repo(s) in org \"{$this->org}\"."); - - return $repos; - } - - private function apiRequest(string $method, string $endpoint, ?string $body = null): array - { - $url = $this->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 {$this->token}", - ]); - - if ($body !== null) - { - curl_setopt($ch, CURLOPT_POSTFIELDS, $body); - } - - $responseBody = curl_exec($ch); - $httpCode = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE); - - if (curl_errno($ch)) - { - $error = curl_error($ch); - curl_close($ch); - - return ['code' => 0, 'body' => "cURL error: {$error}"]; - } - - curl_close($ch); - - return ['code' => $httpCode, 'body' => $responseBody]; - } - - private function log(string $message): void - { - fwrite(STDERR, $message . PHP_EOL); - } -} - -$app = new BulkWorkflowTrigger(); -exit($app->run()); +#!/usr/bin/env php + + * + * This file is part of a Moko Consulting project. + * + * SPDX-License-Identifier: GPL-3.0-or-later + * + * FILE INFORMATION + * DEFGROUP: MokoStandards.Scripts.CLI + * INGROUP: MokoStandards + * REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform + * PATH: /cli/bulk_workflow_trigger.php + * VERSION: 01.00.00 + * BRIEF: Trigger a workflow across multiple repos at once + */ + +declare(strict_types=1); + +final class BulkWorkflowTrigger +{ + private string $giteaUrl = 'https://git.mokoconsulting.tech'; + private string $token = ''; + private string $reposFile = ''; + private string $org = ''; + private string $workflow = ''; + private string $ref = 'main'; + private string $inputs = ''; + private bool $dryRun = false; + + public function run(): int + { + $this->parseArgs(); + + if ($this->token === '') + { + $this->log('ERROR: --token is required.'); + $this->printUsage(); + return 1; + } + + if ($this->workflow === '') + { + $this->log('ERROR: --workflow is required.'); + $this->printUsage(); + return 1; + } + + if ($this->reposFile === '' && $this->org === '') + { + $this->log('ERROR: Either --repos or --org is required.'); + $this->printUsage(); + return 1; + } + + // Build repo list + $repos = $this->buildRepoList(); + + if ($repos === null || count($repos) === 0) + { + $this->log('ERROR: No repos found to process.'); + return 1; + } + + $this->log("Triggering workflow \"{$this->workflow}\" on ref \"{$this->ref}\" across " . count($repos) . " repo(s)."); + $this->log("Gitea URL: {$this->giteaUrl}"); + + if ($this->dryRun) + { + $this->log('[DRY RUN] No requests will be sent.'); + } + + $this->log(''); + + // Parse inputs + $inputsDecoded = null; + + if ($this->inputs !== '') + { + $inputsDecoded = json_decode($this->inputs, true); + + if (!is_array($inputsDecoded)) + { + $this->log('ERROR: --inputs must be valid JSON.'); + return 1; + } + } + + // Print header + $this->log(sprintf('%-40s | %s', 'Repo', 'Status')); + $this->log(str_repeat('-', 60)); + + $failCount = 0; + + foreach ($repos as $repo) + { + $repo = trim($repo); + + if ($repo === '' || strpos($repo, '/') === false) + { + continue; + } + + [$owner, $repoName] = explode('/', $repo, 2); + + if ($this->dryRun) + { + $this->log(sprintf('%-40s | %s', $repo, 'DRY RUN (skipped)')); + continue; + } + + $payload = ['ref' => $this->ref]; + + if ($inputsDecoded !== null) + { + $payload['inputs'] = $inputsDecoded; + } + + $response = $this->apiRequest( + 'POST', + "/api/v1/repos/{$owner}/{$repoName}/actions/workflows/{$this->workflow}/dispatches", + json_encode($payload) + ); + + if ($response['code'] >= 200 && $response['code'] < 300) + { + $status = 'TRIGGERED'; + } + elseif ($response['code'] === 404) + { + $status = 'FAILED (not found)'; + $failCount++; + } + elseif ($response['code'] === 422) + { + $status = 'SKIPPED (unprocessable)'; + } + else + { + $status = "FAILED (HTTP {$response['code']})"; + $failCount++; + } + + $this->log(sprintf('%-40s | %s', $repo, $status)); + } + + $this->log(''); + $this->log('Done. ' . ($failCount > 0 ? "{$failCount} failure(s)." : 'All succeeded.')); + + return $failCount > 0 ? 1 : 0; + } + + private function parseArgs(): void + { + $args = $_SERVER['argv'] ?? []; + $count = count($args); + + for ($i = 1; $i < $count; $i++) + { + switch ($args[$i]) + { + case '--gitea-url': + $this->giteaUrl = rtrim($args[++$i] ?? '', '/'); + break; + case '--token': + $this->token = $args[++$i] ?? ''; + break; + case '--repos': + $this->reposFile = $args[++$i] ?? ''; + break; + case '--org': + $this->org = $args[++$i] ?? ''; + break; + case '--workflow': + $this->workflow = $args[++$i] ?? ''; + break; + case '--ref': + $this->ref = $args[++$i] ?? 'main'; + break; + case '--inputs': + $this->inputs = $args[++$i] ?? ''; + break; + case '--dry-run': + $this->dryRun = true; + break; + case '--help': + case '-h': + $this->printUsage(); + exit(0); + default: + $this->log("WARNING: Unknown argument: {$args[$i]}"); + break; + } + } + } + + private function printUsage(): void + { + $this->log('Usage: bulk_workflow_trigger.php --token --workflow [options]'); + $this->log(''); + $this->log('Options:'); + $this->log(' --gitea-url Gitea URL (default: https://git.mokoconsulting.tech)'); + $this->log(' --token Gitea API token'); + $this->log(' --repos File with newline-separated owner/repo list'); + $this->log(' --org Trigger on all repos in an org'); + $this->log(' --workflow Workflow file (e.g., "sync-servers.yml")'); + $this->log(' --ref Branch ref (default: "main")'); + $this->log(' --inputs Workflow inputs as JSON string'); + $this->log(' --dry-run Show what would be done without triggering'); + $this->log(' --help, -h Show this help'); + } + + private function buildRepoList(): ?array + { + if ($this->reposFile !== '') + { + if (!file_exists($this->reposFile)) + { + $this->log("ERROR: Repos file not found: {$this->reposFile}"); + return null; + } + + $content = file_get_contents($this->reposFile); + $lines = array_filter(array_map('trim', explode("\n", $content)), function (string $line): bool { + return $line !== '' && $line[0] !== '#'; + }); + + return array_values($lines); + } + + // Fetch all repos from org + $this->log("Fetching repos from org: {$this->org}"); + + $page = 1; + $repos = []; + + while (true) + { + $response = $this->apiRequest('GET', "/api/v1/orgs/{$this->org}/repos?limit=50&page={$page}"); + + if ($response['code'] < 200 || $response['code'] >= 300) + { + if ($page === 1) + { + $this->log("ERROR: Could not fetch repos for org (HTTP {$response['code']})."); + return null; + } + + break; + } + + $data = json_decode($response['body'], true); + + if (!is_array($data) || count($data) === 0) + { + break; + } + + foreach ($data as $repo) + { + $fullName = $repo['full_name'] ?? ''; + + if ($fullName !== '') + { + $repos[] = $fullName; + } + } + + $page++; + } + + $this->log('Found ' . count($repos) . " repo(s) in org \"{$this->org}\"."); + + return $repos; + } + + private function apiRequest(string $method, string $endpoint, ?string $body = null): array + { + $url = $this->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 {$this->token}", + ]); + + if ($body !== null) + { + curl_setopt($ch, CURLOPT_POSTFIELDS, $body); + } + + $responseBody = curl_exec($ch); + $httpCode = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE); + + if (curl_errno($ch)) + { + $error = curl_error($ch); + curl_close($ch); + + return ['code' => 0, 'body' => "cURL error: {$error}"]; + } + + curl_close($ch); + + return ['code' => $httpCode, 'body' => $responseBody]; + } + + private function log(string $message): void + { + fwrite(STDERR, $message . PHP_EOL); + } +} + +$app = new BulkWorkflowTrigger(); +exit($app->run()); diff --git a/cli/client_inventory.php b/cli/client_inventory.php index 28d0bc0..0654464 100644 --- a/cli/client_inventory.php +++ b/cli/client_inventory.php @@ -1,334 +1,334 @@ -#!/usr/bin/env php - - * - * This file is part of a Moko Consulting project. - * - * SPDX-License-Identifier: GPL-3.0-or-later - * - * FILE INFORMATION - * DEFGROUP: MokoStandards.Scripts.CLI - * INGROUP: MokoStandards - * REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform - * PATH: /cli/client_inventory.php - * VERSION: 01.00.00 - * BRIEF: Discover and list all client-waas repos with their server configuration status - */ - -declare(strict_types=1); - -final class ClientInventory -{ - private string $giteaUrl = 'https://git.mokoconsulting.tech'; - private string $token = ''; - private bool $jsonOutput = false; - - public function run(): int - { - $this->parseArgs(); - - if ($this->token === '') - { - $this->log('ERROR: --token is required.'); - $this->printUsage(); - return 1; - } - - $this->log("Scanning Gitea instance: {$this->giteaUrl}"); - - // Step 1: List all orgs - $orgs = $this->fetchOrgs(); - - if ($orgs === null) - { - $this->log('ERROR: Failed to fetch organizations.'); - return 1; - } - - $this->log('Found ' . count($orgs) . ' organization(s).'); - - // Step 2 & 3: For each org, find client-waas repos - $inventory = []; - - foreach ($orgs as $org) - { - $orgName = $org['username'] ?? $org['name'] ?? ''; - - if ($orgName === '') - { - continue; - } - - $repos = $this->fetchOrgRepos($orgName); - - if ($repos === null) - { - $this->log("WARNING: Could not fetch repos for org: {$orgName}"); - continue; - } - - foreach ($repos as $repo) - { - $repoName = $repo['name'] ?? ''; - - if (strpos($repoName, 'client-waas') === false) - { - continue; - } - - $hasDevConfig = $this->checkVariables($orgName, $repoName, ['DEV_SYNC_HOST', 'DEV_SYNC_PATH']); - $hasLiveConfig = $this->checkVariables($orgName, $repoName, ['LIVE_SSH_HOST', 'LIVE_SYNC_PATH']); - - $lastPush = $repo['updated_at'] ?? 'unknown'; - - if ($lastPush !== 'unknown') - { - $lastPush = substr($lastPush, 0, 19); - } - - $status = 'OK'; - - if (!$hasDevConfig && !$hasLiveConfig) - { - $status = 'UNCONFIGURED'; - } - elseif (!$hasDevConfig) - { - $status = 'NO DEV'; - } - elseif (!$hasLiveConfig) - { - $status = 'NO LIVE'; - } - - $inventory[] = [ - 'org' => $orgName, - 'repo' => $repoName, - 'has_dev_config' => $hasDevConfig, - 'has_live_config' => $hasLiveConfig, - 'last_push' => $lastPush, - 'status' => $status, - ]; - } - } - - // Output results - if ($this->jsonOutput) - { - fwrite(STDOUT, json_encode($inventory, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES) . PHP_EOL); - return 0; - } - - if (count($inventory) === 0) - { - $this->log('No client-waas repos found.'); - return 0; - } - - // Print table - $this->log(''); - $this->log(sprintf( - '%-20s | %-35s | %-10s | %-11s | %-19s | %s', - 'Org', 'Repo', 'Dev Config', 'Live Config', 'Last Push', 'Status' - )); - $this->log(str_repeat('-', 120)); - - foreach ($inventory as $entry) - { - $this->log(sprintf( - '%-20s | %-35s | %-10s | %-11s | %-19s | %s', - $entry['org'], - $entry['repo'], - $entry['has_dev_config'] ? 'Yes' : 'No', - $entry['has_live_config'] ? 'Yes' : 'No', - $entry['last_push'], - $entry['status'] - )); - } - - $this->log(''); - $this->log('Total: ' . count($inventory) . ' client-waas repo(s).'); - - return 0; - } - - private function parseArgs(): void - { - $args = $_SERVER['argv'] ?? []; - $count = count($args); - - for ($i = 1; $i < $count; $i++) - { - switch ($args[$i]) - { - case '--gitea-url': - $this->giteaUrl = rtrim($args[++$i] ?? '', '/'); - break; - case '--token': - $this->token = $args[++$i] ?? ''; - break; - case '--json': - $this->jsonOutput = true; - break; - case '--help': - case '-h': - $this->printUsage(); - exit(0); - default: - $this->log("WARNING: Unknown argument: {$args[$i]}"); - break; - } - } - } - - private function printUsage(): void - { - $this->log('Usage: client_inventory.php --token [options]'); - $this->log(''); - $this->log('Options:'); - $this->log(' --gitea-url Gitea URL (default: https://git.mokoconsulting.tech)'); - $this->log(' --token Gitea API token'); - $this->log(' --json Output results as JSON'); - $this->log(' --help, -h Show this help'); - } - - private function fetchOrgs(): ?array - { - // Try admin endpoint first, fall back to user-visible orgs - $response = $this->apiRequest('GET', '/api/v1/admin/orgs?limit=50'); - - if ($response['code'] >= 200 && $response['code'] < 300) - { - $data = json_decode($response['body'], true); - - if (is_array($data)) - { - return $data; - } - } - - $this->log('Admin orgs endpoint unavailable, falling back to user orgs...'); - - $response = $this->apiRequest('GET', '/api/v1/user/orgs?limit=50'); - - if ($response['code'] >= 200 && $response['code'] < 300) - { - $data = json_decode($response['body'], true); - - if (is_array($data)) - { - return $data; - } - } - - return null; - } - - private function fetchOrgRepos(string $org): ?array - { - $page = 1; - $allRepos = []; - - while (true) - { - $response = $this->apiRequest('GET', "/api/v1/orgs/{$org}/repos?limit=50&page={$page}"); - - if ($response['code'] < 200 || $response['code'] >= 300) - { - return $page === 1 ? null : $allRepos; - } - - $data = json_decode($response['body'], true); - - if (!is_array($data) || count($data) === 0) - { - break; - } - - $allRepos = array_merge($allRepos, $data); - $page++; - } - - return $allRepos; - } - - private function checkVariables(string $org, string $repo, array $requiredVars): bool - { - $response = $this->apiRequest('GET', "/api/v1/repos/{$org}/{$repo}/actions/variables"); - - if ($response['code'] < 200 || $response['code'] >= 300) - { - return false; - } - - $data = json_decode($response['body'], true); - - if (!is_array($data)) - { - return false; - } - - $existingVars = []; - - foreach ($data as $variable) - { - if (isset($variable['name'])) - { - $existingVars[] = $variable['name']; - } - } - - foreach ($requiredVars as $var) - { - if (!in_array($var, $existingVars, true)) - { - return false; - } - } - - return true; - } - - private function apiRequest(string $method, string $endpoint, ?string $body = null): array - { - $url = $this->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 {$this->token}", - ]); - - if ($body !== null) - { - curl_setopt($ch, CURLOPT_POSTFIELDS, $body); - } - - $responseBody = curl_exec($ch); - $httpCode = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE); - - if (curl_errno($ch)) - { - $error = curl_error($ch); - curl_close($ch); - - return ['code' => 0, 'body' => "cURL error: {$error}"]; - } - - curl_close($ch); - - return ['code' => $httpCode, 'body' => $responseBody]; - } - - private function log(string $message): void - { - fwrite(STDERR, $message . PHP_EOL); - } -} - -$app = new ClientInventory(); -exit($app->run()); +#!/usr/bin/env php + + * + * This file is part of a Moko Consulting project. + * + * SPDX-License-Identifier: GPL-3.0-or-later + * + * FILE INFORMATION + * DEFGROUP: MokoStandards.Scripts.CLI + * INGROUP: MokoStandards + * REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform + * PATH: /cli/client_inventory.php + * VERSION: 01.00.00 + * BRIEF: Discover and list all client-waas repos with their server configuration status + */ + +declare(strict_types=1); + +final class ClientInventory +{ + private string $giteaUrl = 'https://git.mokoconsulting.tech'; + private string $token = ''; + private bool $jsonOutput = false; + + public function run(): int + { + $this->parseArgs(); + + if ($this->token === '') + { + $this->log('ERROR: --token is required.'); + $this->printUsage(); + return 1; + } + + $this->log("Scanning Gitea instance: {$this->giteaUrl}"); + + // Step 1: List all orgs + $orgs = $this->fetchOrgs(); + + if ($orgs === null) + { + $this->log('ERROR: Failed to fetch organizations.'); + return 1; + } + + $this->log('Found ' . count($orgs) . ' organization(s).'); + + // Step 2 & 3: For each org, find client-waas repos + $inventory = []; + + foreach ($orgs as $org) + { + $orgName = $org['username'] ?? $org['name'] ?? ''; + + if ($orgName === '') + { + continue; + } + + $repos = $this->fetchOrgRepos($orgName); + + if ($repos === null) + { + $this->log("WARNING: Could not fetch repos for org: {$orgName}"); + continue; + } + + foreach ($repos as $repo) + { + $repoName = $repo['name'] ?? ''; + + if (strpos($repoName, 'client-waas') === false) + { + continue; + } + + $hasDevConfig = $this->checkVariables($orgName, $repoName, ['DEV_SYNC_HOST', 'DEV_SYNC_PATH']); + $hasLiveConfig = $this->checkVariables($orgName, $repoName, ['LIVE_SSH_HOST', 'LIVE_SYNC_PATH']); + + $lastPush = $repo['updated_at'] ?? 'unknown'; + + if ($lastPush !== 'unknown') + { + $lastPush = substr($lastPush, 0, 19); + } + + $status = 'OK'; + + if (!$hasDevConfig && !$hasLiveConfig) + { + $status = 'UNCONFIGURED'; + } + elseif (!$hasDevConfig) + { + $status = 'NO DEV'; + } + elseif (!$hasLiveConfig) + { + $status = 'NO LIVE'; + } + + $inventory[] = [ + 'org' => $orgName, + 'repo' => $repoName, + 'has_dev_config' => $hasDevConfig, + 'has_live_config' => $hasLiveConfig, + 'last_push' => $lastPush, + 'status' => $status, + ]; + } + } + + // Output results + if ($this->jsonOutput) + { + fwrite(STDOUT, json_encode($inventory, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES) . PHP_EOL); + return 0; + } + + if (count($inventory) === 0) + { + $this->log('No client-waas repos found.'); + return 0; + } + + // Print table + $this->log(''); + $this->log(sprintf( + '%-20s | %-35s | %-10s | %-11s | %-19s | %s', + 'Org', 'Repo', 'Dev Config', 'Live Config', 'Last Push', 'Status' + )); + $this->log(str_repeat('-', 120)); + + foreach ($inventory as $entry) + { + $this->log(sprintf( + '%-20s | %-35s | %-10s | %-11s | %-19s | %s', + $entry['org'], + $entry['repo'], + $entry['has_dev_config'] ? 'Yes' : 'No', + $entry['has_live_config'] ? 'Yes' : 'No', + $entry['last_push'], + $entry['status'] + )); + } + + $this->log(''); + $this->log('Total: ' . count($inventory) . ' client-waas repo(s).'); + + return 0; + } + + private function parseArgs(): void + { + $args = $_SERVER['argv'] ?? []; + $count = count($args); + + for ($i = 1; $i < $count; $i++) + { + switch ($args[$i]) + { + case '--gitea-url': + $this->giteaUrl = rtrim($args[++$i] ?? '', '/'); + break; + case '--token': + $this->token = $args[++$i] ?? ''; + break; + case '--json': + $this->jsonOutput = true; + break; + case '--help': + case '-h': + $this->printUsage(); + exit(0); + default: + $this->log("WARNING: Unknown argument: {$args[$i]}"); + break; + } + } + } + + private function printUsage(): void + { + $this->log('Usage: client_inventory.php --token [options]'); + $this->log(''); + $this->log('Options:'); + $this->log(' --gitea-url Gitea URL (default: https://git.mokoconsulting.tech)'); + $this->log(' --token Gitea API token'); + $this->log(' --json Output results as JSON'); + $this->log(' --help, -h Show this help'); + } + + private function fetchOrgs(): ?array + { + // Try admin endpoint first, fall back to user-visible orgs + $response = $this->apiRequest('GET', '/api/v1/admin/orgs?limit=50'); + + if ($response['code'] >= 200 && $response['code'] < 300) + { + $data = json_decode($response['body'], true); + + if (is_array($data)) + { + return $data; + } + } + + $this->log('Admin orgs endpoint unavailable, falling back to user orgs...'); + + $response = $this->apiRequest('GET', '/api/v1/user/orgs?limit=50'); + + if ($response['code'] >= 200 && $response['code'] < 300) + { + $data = json_decode($response['body'], true); + + if (is_array($data)) + { + return $data; + } + } + + return null; + } + + private function fetchOrgRepos(string $org): ?array + { + $page = 1; + $allRepos = []; + + while (true) + { + $response = $this->apiRequest('GET', "/api/v1/orgs/{$org}/repos?limit=50&page={$page}"); + + if ($response['code'] < 200 || $response['code'] >= 300) + { + return $page === 1 ? null : $allRepos; + } + + $data = json_decode($response['body'], true); + + if (!is_array($data) || count($data) === 0) + { + break; + } + + $allRepos = array_merge($allRepos, $data); + $page++; + } + + return $allRepos; + } + + private function checkVariables(string $org, string $repo, array $requiredVars): bool + { + $response = $this->apiRequest('GET', "/api/v1/repos/{$org}/{$repo}/actions/variables"); + + if ($response['code'] < 200 || $response['code'] >= 300) + { + return false; + } + + $data = json_decode($response['body'], true); + + if (!is_array($data)) + { + return false; + } + + $existingVars = []; + + foreach ($data as $variable) + { + if (isset($variable['name'])) + { + $existingVars[] = $variable['name']; + } + } + + foreach ($requiredVars as $var) + { + if (!in_array($var, $existingVars, true)) + { + return false; + } + } + + return true; + } + + private function apiRequest(string $method, string $endpoint, ?string $body = null): array + { + $url = $this->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 {$this->token}", + ]); + + if ($body !== null) + { + curl_setopt($ch, CURLOPT_POSTFIELDS, $body); + } + + $responseBody = curl_exec($ch); + $httpCode = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE); + + if (curl_errno($ch)) + { + $error = curl_error($ch); + curl_close($ch); + + return ['code' => 0, 'body' => "cURL error: {$error}"]; + } + + curl_close($ch); + + return ['code' => $httpCode, 'body' => $responseBody]; + } + + private function log(string $message): void + { + fwrite(STDERR, $message . PHP_EOL); + } +} + +$app = new ClientInventory(); +exit($app->run()); diff --git a/cli/scaffold_client.php b/cli/scaffold_client.php index adf16c7..56c4081 100644 --- a/cli/scaffold_client.php +++ b/cli/scaffold_client.php @@ -1,250 +1,250 @@ -#!/usr/bin/env php - - * - * This file is part of a Moko Consulting project. - * - * SPDX-License-Identifier: GPL-3.0-or-later - * - * FILE INFORMATION - * DEFGROUP: MokoStandards.Scripts.CLI - * INGROUP: MokoStandards - * REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform - * PATH: /cli/scaffold_client.php - * VERSION: 01.00.00 - * BRIEF: Scaffold a new client-waas repo from Template-Client-WaaS with pre-configured settings - */ - -declare(strict_types=1); - -final class ScaffoldClient -{ - private string $name = ''; - private string $org = ''; - private string $giteaUrl = 'https://git.mokoconsulting.tech'; - private string $token = ''; - private bool $dryRun = false; - - public function run(): int - { - $this->parseArgs(); - - if ($this->name === '' || $this->org === '' || $this->token === '') - { - $this->log('ERROR: --name, --org, and --token are required.'); - $this->printUsage(); - return 1; - } - - $repoName = 'client-waas-' . $this->name; - - $this->log("Scaffolding client repo: {$this->org}/{$repoName}"); - $this->log("Gitea URL: {$this->giteaUrl}"); - - if ($this->dryRun) - { - $this->log('[DRY RUN] Would create repo from template MokoConsulting/Template-Client-WaaS'); - $this->log("[DRY RUN] Repo: {$this->org}/{$repoName}"); - $this->log("[DRY RUN] Description: \"{$this->name} WaaS site\""); - $this->log('[DRY RUN] Would create dev branch from main'); - $this->printPostSetupInstructions($repoName); - return 0; - } - - // Step 1: Create repo from template - $this->log('Step 1: Creating repo from template...'); - - $createPayload = json_encode([ - 'owner' => $this->org, - 'name' => $repoName, - 'description' => "{$this->name} WaaS site", - 'private' => true, - 'git_content' => true, - 'topics' => true, - 'labels' => true, - ]); - - $response = $this->apiRequest( - 'POST', - "/api/v1/repos/MokoConsulting/Template-Client-WaaS/generate", - $createPayload - ); - - if ($response['code'] < 200 || $response['code'] >= 300) - { - $this->log("ERROR: Failed to create repo (HTTP {$response['code']})."); - $this->log("Response: {$response['body']}"); - return 1; - } - - $this->log("Repo created: {$this->org}/{$repoName}"); - - // Step 2: Set repo description (already set via generate, but confirm) - $this->log('Step 2: Updating repo description...'); - - $updatePayload = json_encode([ - 'description' => "{$this->name} WaaS site", - ]); - - $response = $this->apiRequest( - 'PATCH', - "/api/v1/repos/{$this->org}/{$repoName}", - $updatePayload - ); - - if ($response['code'] >= 200 && $response['code'] < 300) - { - $this->log('Description updated.'); - } - else - { - $this->log("WARNING: Could not update description (HTTP {$response['code']})."); - } - - // Step 3: Create dev branch from main - $this->log('Step 3: Creating dev branch from main...'); - - $branchPayload = json_encode([ - 'new_branch_name' => 'dev', - 'old_branch_name' => 'main', - ]); - - $response = $this->apiRequest( - 'POST', - "/api/v1/repos/{$this->org}/{$repoName}/branches", - $branchPayload - ); - - if ($response['code'] >= 200 && $response['code'] < 300) - { - $this->log('Branch "dev" created from "main".'); - } - else - { - $this->log("WARNING: Could not create dev branch (HTTP {$response['code']})."); - $this->log("Response: {$response['body']}"); - } - - // Step 4: Print post-setup instructions - $this->printPostSetupInstructions($repoName); - - $this->log('Scaffold complete.'); - - return 0; - } - - private function parseArgs(): void - { - $args = $_SERVER['argv'] ?? []; - $count = count($args); - - for ($i = 1; $i < $count; $i++) - { - switch ($args[$i]) - { - case '--name': - $this->name = $args[++$i] ?? ''; - break; - case '--org': - $this->org = $args[++$i] ?? ''; - break; - case '--gitea-url': - $this->giteaUrl = rtrim($args[++$i] ?? '', '/'); - break; - case '--token': - $this->token = $args[++$i] ?? ''; - break; - case '--dry-run': - $this->dryRun = true; - break; - case '--help': - case '-h': - $this->printUsage(); - exit(0); - default: - $this->log("WARNING: Unknown argument: {$args[$i]}"); - break; - } - } - } - - private function printUsage(): void - { - $this->log('Usage: scaffold_client.php --name --org --token [options]'); - $this->log(''); - $this->log('Options:'); - $this->log(' --name Client name (e.g., "clarksvillefurs")'); - $this->log(' --org Gitea organization (e.g., "ClarksvilleFurs")'); - $this->log(' --gitea-url Gitea URL (default: https://git.mokoconsulting.tech)'); - $this->log(' --token Gitea API token'); - $this->log(' --dry-run Show what would be done without making changes'); - $this->log(' --help, -h Show this help'); - } - - private function printPostSetupInstructions(string $repoName): void - { - $this->log(''); - $this->log('=== POST-SETUP INSTRUCTIONS ==='); - $this->log(''); - $this->log("Navigate to: {$this->giteaUrl}/{$this->org}/{$repoName}/settings"); - $this->log(''); - $this->log('Set the following REPO VARIABLES (Settings > Actions > Variables):'); - $this->log(' DEV_SYNC_HOST - Dev server hostname or IP'); - $this->log(' DEV_SYNC_PORT - Dev server SSH port (default: 22)'); - $this->log(' DEV_SYNC_USER - Dev server SSH username'); - $this->log(' DEV_SYNC_PATH - Dev server deploy path'); - $this->log(' LIVE_SSH_HOST - Live server hostname or IP'); - $this->log(' LIVE_SSH_PORT - Live server SSH port (default: 22)'); - $this->log(' LIVE_SSH_USER - Live server SSH username'); - $this->log(' LIVE_SYNC_PATH - Live server deploy path'); - $this->log(''); - $this->log('Set the following REPO SECRETS (Settings > Actions > Secrets):'); - $this->log(' DEV_SYNC_KEY - Private SSH key for dev server'); - $this->log(' LIVE_SSH_KEY - Private SSH key for live server'); - $this->log(''); - $this->log('================================'); - } - - private function apiRequest(string $method, string $endpoint, ?string $body = null): array - { - $url = $this->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 {$this->token}", - ]); - - if ($body !== null) - { - curl_setopt($ch, CURLOPT_POSTFIELDS, $body); - } - - $responseBody = curl_exec($ch); - $httpCode = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE); - - if (curl_errno($ch)) - { - $error = curl_error($ch); - curl_close($ch); - - return ['code' => 0, 'body' => "cURL error: {$error}"]; - } - - curl_close($ch); - - return ['code' => $httpCode, 'body' => $responseBody]; - } - - private function log(string $message): void - { - fwrite(STDERR, $message . PHP_EOL); - } -} - -$app = new ScaffoldClient(); -exit($app->run()); +#!/usr/bin/env php + + * + * This file is part of a Moko Consulting project. + * + * SPDX-License-Identifier: GPL-3.0-or-later + * + * FILE INFORMATION + * DEFGROUP: MokoStandards.Scripts.CLI + * INGROUP: MokoStandards + * REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform + * PATH: /cli/scaffold_client.php + * VERSION: 01.00.00 + * BRIEF: Scaffold a new client-waas repo from Template-Client-WaaS with pre-configured settings + */ + +declare(strict_types=1); + +final class ScaffoldClient +{ + private string $name = ''; + private string $org = ''; + private string $giteaUrl = 'https://git.mokoconsulting.tech'; + private string $token = ''; + private bool $dryRun = false; + + public function run(): int + { + $this->parseArgs(); + + if ($this->name === '' || $this->org === '' || $this->token === '') + { + $this->log('ERROR: --name, --org, and --token are required.'); + $this->printUsage(); + return 1; + } + + $repoName = 'client-waas-' . $this->name; + + $this->log("Scaffolding client repo: {$this->org}/{$repoName}"); + $this->log("Gitea URL: {$this->giteaUrl}"); + + if ($this->dryRun) + { + $this->log('[DRY RUN] Would create repo from template MokoConsulting/Template-Client-WaaS'); + $this->log("[DRY RUN] Repo: {$this->org}/{$repoName}"); + $this->log("[DRY RUN] Description: \"{$this->name} WaaS site\""); + $this->log('[DRY RUN] Would create dev branch from main'); + $this->printPostSetupInstructions($repoName); + return 0; + } + + // Step 1: Create repo from template + $this->log('Step 1: Creating repo from template...'); + + $createPayload = json_encode([ + 'owner' => $this->org, + 'name' => $repoName, + 'description' => "{$this->name} WaaS site", + 'private' => true, + 'git_content' => true, + 'topics' => true, + 'labels' => true, + ]); + + $response = $this->apiRequest( + 'POST', + "/api/v1/repos/MokoConsulting/Template-Client-WaaS/generate", + $createPayload + ); + + if ($response['code'] < 200 || $response['code'] >= 300) + { + $this->log("ERROR: Failed to create repo (HTTP {$response['code']})."); + $this->log("Response: {$response['body']}"); + return 1; + } + + $this->log("Repo created: {$this->org}/{$repoName}"); + + // Step 2: Set repo description (already set via generate, but confirm) + $this->log('Step 2: Updating repo description...'); + + $updatePayload = json_encode([ + 'description' => "{$this->name} WaaS site", + ]); + + $response = $this->apiRequest( + 'PATCH', + "/api/v1/repos/{$this->org}/{$repoName}", + $updatePayload + ); + + if ($response['code'] >= 200 && $response['code'] < 300) + { + $this->log('Description updated.'); + } + else + { + $this->log("WARNING: Could not update description (HTTP {$response['code']})."); + } + + // Step 3: Create dev branch from main + $this->log('Step 3: Creating dev branch from main...'); + + $branchPayload = json_encode([ + 'new_branch_name' => 'dev', + 'old_branch_name' => 'main', + ]); + + $response = $this->apiRequest( + 'POST', + "/api/v1/repos/{$this->org}/{$repoName}/branches", + $branchPayload + ); + + if ($response['code'] >= 200 && $response['code'] < 300) + { + $this->log('Branch "dev" created from "main".'); + } + else + { + $this->log("WARNING: Could not create dev branch (HTTP {$response['code']})."); + $this->log("Response: {$response['body']}"); + } + + // Step 4: Print post-setup instructions + $this->printPostSetupInstructions($repoName); + + $this->log('Scaffold complete.'); + + return 0; + } + + private function parseArgs(): void + { + $args = $_SERVER['argv'] ?? []; + $count = count($args); + + for ($i = 1; $i < $count; $i++) + { + switch ($args[$i]) + { + case '--name': + $this->name = $args[++$i] ?? ''; + break; + case '--org': + $this->org = $args[++$i] ?? ''; + break; + case '--gitea-url': + $this->giteaUrl = rtrim($args[++$i] ?? '', '/'); + break; + case '--token': + $this->token = $args[++$i] ?? ''; + break; + case '--dry-run': + $this->dryRun = true; + break; + case '--help': + case '-h': + $this->printUsage(); + exit(0); + default: + $this->log("WARNING: Unknown argument: {$args[$i]}"); + break; + } + } + } + + private function printUsage(): void + { + $this->log('Usage: scaffold_client.php --name --org --token [options]'); + $this->log(''); + $this->log('Options:'); + $this->log(' --name Client name (e.g., "clarksvillefurs")'); + $this->log(' --org Gitea organization (e.g., "ClarksvilleFurs")'); + $this->log(' --gitea-url Gitea URL (default: https://git.mokoconsulting.tech)'); + $this->log(' --token Gitea API token'); + $this->log(' --dry-run Show what would be done without making changes'); + $this->log(' --help, -h Show this help'); + } + + private function printPostSetupInstructions(string $repoName): void + { + $this->log(''); + $this->log('=== POST-SETUP INSTRUCTIONS ==='); + $this->log(''); + $this->log("Navigate to: {$this->giteaUrl}/{$this->org}/{$repoName}/settings"); + $this->log(''); + $this->log('Set the following REPO VARIABLES (Settings > Actions > Variables):'); + $this->log(' DEV_SYNC_HOST - Dev server hostname or IP'); + $this->log(' DEV_SYNC_PORT - Dev server SSH port (default: 22)'); + $this->log(' DEV_SYNC_USER - Dev server SSH username'); + $this->log(' DEV_SYNC_PATH - Dev server deploy path'); + $this->log(' LIVE_SSH_HOST - Live server hostname or IP'); + $this->log(' LIVE_SSH_PORT - Live server SSH port (default: 22)'); + $this->log(' LIVE_SSH_USER - Live server SSH username'); + $this->log(' LIVE_SYNC_PATH - Live server deploy path'); + $this->log(''); + $this->log('Set the following REPO SECRETS (Settings > Actions > Secrets):'); + $this->log(' DEV_SYNC_KEY - Private SSH key for dev server'); + $this->log(' LIVE_SSH_KEY - Private SSH key for live server'); + $this->log(''); + $this->log('================================'); + } + + private function apiRequest(string $method, string $endpoint, ?string $body = null): array + { + $url = $this->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 {$this->token}", + ]); + + if ($body !== null) + { + curl_setopt($ch, CURLOPT_POSTFIELDS, $body); + } + + $responseBody = curl_exec($ch); + $httpCode = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE); + + if (curl_errno($ch)) + { + $error = curl_error($ch); + curl_close($ch); + + return ['code' => 0, 'body' => "cURL error: {$error}"]; + } + + curl_close($ch); + + return ['code' => $httpCode, 'body' => $responseBody]; + } + + private function log(string $message): void + { + fwrite(STDERR, $message . PHP_EOL); + } +} + +$app = new ScaffoldClient(); +exit($app->run()); diff --git a/deploy/backup-before-deploy.php b/deploy/backup-before-deploy.php index 7139cdf..8d2bb94 100644 --- a/deploy/backup-before-deploy.php +++ b/deploy/backup-before-deploy.php @@ -1,212 +1,212 @@ -#!/usr/bin/env php - - * - * This file is part of a Moko Consulting project. - * - * SPDX-License-Identifier: GPL-3.0-or-later - * - * FILE INFORMATION - * DEFGROUP: MokoStandards.Scripts.Deploy - * INGROUP: MokoStandards - * REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform - * PATH: /deploy/backup-before-deploy.php - * VERSION: 01.00.00 - * BRIEF: Snapshot Joomla directories before deployment for rollback capability - */ - -declare(strict_types=1); - -class BackupBeforeDeploy -{ - private bool $verbose = false; - private string $configPath = ''; - private string $outputDir = ''; - - private const JOOMLA_DIRS = [ - 'administrator/components', - 'administrator/language', - 'administrator/modules', - 'administrator/templates', - 'components', - 'language', - 'layouts', - 'libraries', - 'media', - 'modules', - 'plugins', - 'templates', - ]; - - public function run(): int - { - $this->parseArgs(); - - if ($this->configPath === '') { - $this->log('Usage: backup-before-deploy.php --config [--output ] [--verbose]'); - return 1; - } - - if ($this->outputDir === '') { - $this->outputDir = '/tmp/moko-snapshot-' . date('Ymd-His'); - } - - $config = $this->loadConfig($this->configPath); - if ($config === null) { - return 1; - } - - $host = $config['host'] ?? ''; - $user = $config['user'] ?? ''; - $port = (int) ($config['port'] ?? 22); - $remotePath = rtrim($config['remote_path'] ?? '', '/'); - $sshKey = $config['ssh_key_file'] ?? ''; - - if ($host === '' || $user === '' || $remotePath === '') { - $this->log('ERROR: Config must contain host, user, and remote_path.'); - return 1; - } - - // Create output directory - if (!is_dir($this->outputDir)) { - if (!mkdir($this->outputDir, 0755, true)) { - $this->log("ERROR: Could not create output directory: {$this->outputDir}"); - return 1; - } - } - - $this->log('Starting pre-deploy snapshot...'); - $this->log("Source: {$user}@{$host}:{$remotePath}"); - $this->log("Output: {$this->outputDir}"); - - $failed = 0; - - foreach (self::JOOMLA_DIRS as $dir) { - $remoteSource = "{$remotePath}/{$dir}/"; - $localTarget = rtrim($this->outputDir, '/\\') . '/' . $dir . '/'; - - // Ensure local subdirectory exists - if (!is_dir($localTarget)) { - mkdir($localTarget, 0755, true); - } - - $sshCmd = "ssh -p {$port}"; - if ($sshKey !== '') { - $sshCmd .= " -i " . escapeshellarg($sshKey); - } - - $cmd = $this->buildRsyncCommand( - $sshCmd, - "{$user}@{$host}:{$remoteSource}", - $localTarget - ); - - $this->log("Downloading: {$dir}"); - if ($this->verbose) { - $this->log("CMD: {$cmd}"); - } - - $output = []; - $exitCode = 0; - exec($cmd, $output, $exitCode); - - if ($exitCode !== 0) { - $this->log("ERROR: rsync failed for {$dir} (exit code {$exitCode})"); - foreach ($output as $line) { - $this->log(" {$line}"); - } - $failed++; - } else { - if ($this->verbose) { - foreach ($output as $line) { - $this->log(" {$line}"); - } - } - } - } - - if ($failed > 0) { - $this->log("Snapshot completed with {$failed} error(s)."); - return 1; - } - - $this->log(''); - $this->log('Snapshot completed successfully.'); - $this->log("SNAPSHOT_PATH={$this->outputDir}"); - $this->log(''); - $this->log('To rollback, run:'); - $this->log(" php rollback-joomla.php --config {$this->configPath} --snapshot-dir {$this->outputDir}"); - - return 0; - } - - private function parseArgs(): void - { - $args = $_SERVER['argv'] ?? []; - $count = count($args); - - for ($i = 1; $i < $count; $i++) { - switch ($args[$i]) { - case '--config': - $this->configPath = $args[++$i] ?? ''; - break; - case '--output': - $this->outputDir = $args[++$i] ?? ''; - break; - case '--verbose': - $this->verbose = true; - break; - } - } - } - - private function loadConfig(string $path): ?array - { - if (!is_file($path)) { - $this->log("ERROR: Config file not found: {$path}"); - return null; - } - - $raw = file_get_contents($path); - if ($raw === false) { - $this->log("ERROR: Could not read config file: {$path}"); - return null; - } - - // Strip // comments (sftp-config.json style) - $cleaned = preg_replace('#^\s*//.*$#m', '', $raw); - $config = json_decode($cleaned, true); - - if (!is_array($config)) { - $this->log('ERROR: Invalid JSON in config file.'); - return null; - } - - return $config; - } - - private function buildRsyncCommand(string $sshCmd, string $source, string $dest): string - { - $parts = ['rsync', '-rlptz', '--exclude=configuration.php']; - - if ($this->verbose) { - $parts[] = '-v'; - } - - $parts[] = '-e'; - $parts[] = escapeshellarg($sshCmd); - $parts[] = escapeshellarg($source); - $parts[] = escapeshellarg($dest); - - return implode(' ', $parts); - } - - private function log(string $message): void - { - $timestamp = date('Y-m-d H:i:s'); - fwrite(STDERR, "[{$timestamp}] {$message}" . PHP_EOL); - } -} - -$app = new BackupBeforeDeploy(); -exit($app->run()); +#!/usr/bin/env php + + * + * This file is part of a Moko Consulting project. + * + * SPDX-License-Identifier: GPL-3.0-or-later + * + * FILE INFORMATION + * DEFGROUP: MokoStandards.Scripts.Deploy + * INGROUP: MokoStandards + * REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform + * PATH: /deploy/backup-before-deploy.php + * VERSION: 01.00.00 + * BRIEF: Snapshot Joomla directories before deployment for rollback capability + */ + +declare(strict_types=1); + +class BackupBeforeDeploy +{ + private bool $verbose = false; + private string $configPath = ''; + private string $outputDir = ''; + + private const JOOMLA_DIRS = [ + 'administrator/components', + 'administrator/language', + 'administrator/modules', + 'administrator/templates', + 'components', + 'language', + 'layouts', + 'libraries', + 'media', + 'modules', + 'plugins', + 'templates', + ]; + + public function run(): int + { + $this->parseArgs(); + + if ($this->configPath === '') { + $this->log('Usage: backup-before-deploy.php --config [--output ] [--verbose]'); + return 1; + } + + if ($this->outputDir === '') { + $this->outputDir = '/tmp/moko-snapshot-' . date('Ymd-His'); + } + + $config = $this->loadConfig($this->configPath); + if ($config === null) { + return 1; + } + + $host = $config['host'] ?? ''; + $user = $config['user'] ?? ''; + $port = (int) ($config['port'] ?? 22); + $remotePath = rtrim($config['remote_path'] ?? '', '/'); + $sshKey = $config['ssh_key_file'] ?? ''; + + if ($host === '' || $user === '' || $remotePath === '') { + $this->log('ERROR: Config must contain host, user, and remote_path.'); + return 1; + } + + // Create output directory + if (!is_dir($this->outputDir)) { + if (!mkdir($this->outputDir, 0755, true)) { + $this->log("ERROR: Could not create output directory: {$this->outputDir}"); + return 1; + } + } + + $this->log('Starting pre-deploy snapshot...'); + $this->log("Source: {$user}@{$host}:{$remotePath}"); + $this->log("Output: {$this->outputDir}"); + + $failed = 0; + + foreach (self::JOOMLA_DIRS as $dir) { + $remoteSource = "{$remotePath}/{$dir}/"; + $localTarget = rtrim($this->outputDir, '/\\') . '/' . $dir . '/'; + + // Ensure local subdirectory exists + if (!is_dir($localTarget)) { + mkdir($localTarget, 0755, true); + } + + $sshCmd = "ssh -p {$port}"; + if ($sshKey !== '') { + $sshCmd .= " -i " . escapeshellarg($sshKey); + } + + $cmd = $this->buildRsyncCommand( + $sshCmd, + "{$user}@{$host}:{$remoteSource}", + $localTarget + ); + + $this->log("Downloading: {$dir}"); + if ($this->verbose) { + $this->log("CMD: {$cmd}"); + } + + $output = []; + $exitCode = 0; + exec($cmd, $output, $exitCode); + + if ($exitCode !== 0) { + $this->log("ERROR: rsync failed for {$dir} (exit code {$exitCode})"); + foreach ($output as $line) { + $this->log(" {$line}"); + } + $failed++; + } else { + if ($this->verbose) { + foreach ($output as $line) { + $this->log(" {$line}"); + } + } + } + } + + if ($failed > 0) { + $this->log("Snapshot completed with {$failed} error(s)."); + return 1; + } + + $this->log(''); + $this->log('Snapshot completed successfully.'); + $this->log("SNAPSHOT_PATH={$this->outputDir}"); + $this->log(''); + $this->log('To rollback, run:'); + $this->log(" php rollback-joomla.php --config {$this->configPath} --snapshot-dir {$this->outputDir}"); + + return 0; + } + + private function parseArgs(): void + { + $args = $_SERVER['argv'] ?? []; + $count = count($args); + + for ($i = 1; $i < $count; $i++) { + switch ($args[$i]) { + case '--config': + $this->configPath = $args[++$i] ?? ''; + break; + case '--output': + $this->outputDir = $args[++$i] ?? ''; + break; + case '--verbose': + $this->verbose = true; + break; + } + } + } + + private function loadConfig(string $path): ?array + { + if (!is_file($path)) { + $this->log("ERROR: Config file not found: {$path}"); + return null; + } + + $raw = file_get_contents($path); + if ($raw === false) { + $this->log("ERROR: Could not read config file: {$path}"); + return null; + } + + // Strip // comments (sftp-config.json style) + $cleaned = preg_replace('#^\s*//.*$#m', '', $raw); + $config = json_decode($cleaned, true); + + if (!is_array($config)) { + $this->log('ERROR: Invalid JSON in config file.'); + return null; + } + + return $config; + } + + private function buildRsyncCommand(string $sshCmd, string $source, string $dest): string + { + $parts = ['rsync', '-rlptz', '--exclude=configuration.php']; + + if ($this->verbose) { + $parts[] = '-v'; + } + + $parts[] = '-e'; + $parts[] = escapeshellarg($sshCmd); + $parts[] = escapeshellarg($source); + $parts[] = escapeshellarg($dest); + + return implode(' ', $parts); + } + + private function log(string $message): void + { + $timestamp = date('Y-m-d H:i:s'); + fwrite(STDERR, "[{$timestamp}] {$message}" . PHP_EOL); + } +} + +$app = new BackupBeforeDeploy(); +exit($app->run()); diff --git a/deploy/deploy-dolibarr.php b/deploy/deploy-dolibarr.php index 078b468..4e9aa61 100644 --- a/deploy/deploy-dolibarr.php +++ b/deploy/deploy-dolibarr.php @@ -1,301 +1,301 @@ -#!/usr/bin/env php - - * - * This file is part of a Moko Consulting project. - * - * SPDX-License-Identifier: GPL-3.0-or-later - * - * FILE INFORMATION - * DEFGROUP: MokoStandards.Scripts.Deploy - * INGROUP: MokoStandards - * REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform - * PATH: /deploy/deploy-dolibarr.php - * VERSION: 01.00.00 - * BRIEF: Deploy Dolibarr module files to a remote server via SFTP/rsync - */ - -declare(strict_types=1); - -class DeployDolibarr -{ - private bool $verbose = false; - private bool $dryRun = false; - private string $configPath = ''; - private string $source = ''; - - private const MODULE_DIRS = [ - 'core/modules', - 'class', - 'lib', - 'sql', - 'langs', - 'css', - 'js', - 'img', - ]; - - private const EXCLUDES = [ - '.git/', - 'vendor/', - 'tests/', - 'node_modules/', - ]; - - public function run(): int - { - $this->parseArgs(); - - if ($this->configPath === '' || $this->source === '') { - $this->log('Usage: deploy-dolibarr.php --source --config [--dry-run] [--verbose]'); - return 1; - } - - if (!is_dir($this->source)) { - $this->log("ERROR: Source directory does not exist: {$this->source}"); - return 1; - } - - $moduleName = $this->detectModuleName(); - if ($moduleName === null) { - $this->log('ERROR: Could not auto-detect module name. Expected core/modules/mod*.class.php'); - return 1; - } - - $config = $this->loadConfig($this->configPath); - if ($config === null) { - return 1; - } - - $host = $config['host'] ?? ''; - $user = $config['user'] ?? ''; - $port = (int) ($config['port'] ?? 22); - $remotePath = rtrim($config['remote_path'] ?? '', '/'); - $sshKey = $config['ssh_key_file'] ?? ''; - - if ($host === '' || $user === '' || $remotePath === '') { - $this->log('ERROR: Config must contain host, user, and remote_path.'); - return 1; - } - - $remoteBase = "{$remotePath}/htdocs/custom/{$moduleName}"; - - $this->log("Deploying Dolibarr module: {$moduleName}"); - $this->log("Source: {$this->source}"); - $this->log("Target: {$user}@{$host}:{$remoteBase}"); - - if ($this->dryRun) { - $this->log('*** DRY RUN — no changes will be made ***'); - } - - $failed = 0; - - // Deploy subdirectories - foreach (self::MODULE_DIRS as $dir) { - $localDir = rtrim($this->source, '/\\') . '/' . $dir . '/'; - - if (!is_dir($localDir)) { - if ($this->verbose) { - $this->log("SKIP: {$dir} (not present in source)"); - } - continue; - } - - $remoteTarget = "{$remoteBase}/{$dir}/"; - $result = $this->rsyncDir($localDir, $remoteTarget, $host, $user, $port, $sshKey); - - if (!$result) { - $failed++; - } - } - - // Deploy root PHP files - $rootPhpFiles = glob(rtrim($this->source, '/\\') . '/*.php'); - if (!empty($rootPhpFiles)) { - $this->log('Syncing root PHP files...'); - $sourceRoot = rtrim($this->source, '/\\') . '/'; - $remoteTarget = "{$remoteBase}/"; - - $sshCmd = "ssh -p {$port}"; - if ($sshKey !== '') { - $sshCmd .= " -i " . escapeshellarg($sshKey); - } - - $cmd = $this->buildRsyncCommand( - $sshCmd, - $sourceRoot, - "{$user}@{$host}:{$remoteTarget}", - ['--include=*.php', '--exclude=*/', '--exclude=.*'] - ); - - if ($this->verbose) { - $this->log("CMD: {$cmd}"); - } - - $output = []; - $exitCode = 0; - exec($cmd, $output, $exitCode); - - if ($exitCode !== 0) { - $this->log("ERROR: rsync failed for root PHP files (exit code {$exitCode})"); - foreach ($output as $line) { - $this->log(" {$line}"); - } - $failed++; - } else { - if ($this->verbose) { - foreach ($output as $line) { - $this->log(" {$line}"); - } - } - } - } - - if ($failed > 0) { - $this->log("Deployment completed with {$failed} error(s)."); - return 1; - } - - $this->log('Deployment completed successfully.'); - return 0; - } - - private function parseArgs(): void - { - $args = $_SERVER['argv'] ?? []; - $count = count($args); - - for ($i = 1; $i < $count; $i++) { - switch ($args[$i]) { - case '--source': - $this->source = $args[++$i] ?? ''; - break; - case '--config': - $this->configPath = $args[++$i] ?? ''; - break; - case '--dry-run': - $this->dryRun = true; - break; - case '--verbose': - $this->verbose = true; - break; - } - } - } - - private function detectModuleName(): ?string - { - $pattern = rtrim($this->source, '/\\') . '/core/modules/mod*.class.php'; - $matches = glob($pattern); - - if (empty($matches)) { - return null; - } - - $filename = basename($matches[0]); - // mod{ModuleName}.class.php → extract ModuleName, lowercase it - if (preg_match('/^mod(.+)\.class\.php$/', $filename, $m)) { - return strtolower($m[1]); - } - - return null; - } - - private function loadConfig(string $path): ?array - { - if (!is_file($path)) { - $this->log("ERROR: Config file not found: {$path}"); - return null; - } - - $raw = file_get_contents($path); - if ($raw === false) { - $this->log("ERROR: Could not read config file: {$path}"); - return null; - } - - // Strip // comments (sftp-config.json style) - $cleaned = preg_replace('#^\s*//.*$#m', '', $raw); - $config = json_decode($cleaned, true); - - if (!is_array($config)) { - $this->log('ERROR: Invalid JSON in config file.'); - return null; - } - - return $config; - } - - private function rsyncDir(string $localDir, string $remoteTarget, string $host, string $user, int $port, string $sshKey): bool - { - $dirName = basename(rtrim($localDir, '/')); - $sshCmd = "ssh -p {$port}"; - if ($sshKey !== '') { - $sshCmd .= " -i " . escapeshellarg($sshKey); - } - - $cmd = $this->buildRsyncCommand($sshCmd, $localDir, "{$user}@{$host}:{$remoteTarget}"); - - $this->log("Syncing: {$dirName}"); - if ($this->verbose) { - $this->log("CMD: {$cmd}"); - } - - $output = []; - $exitCode = 0; - exec($cmd, $output, $exitCode); - - if ($exitCode !== 0) { - $this->log("ERROR: rsync failed for {$dirName} (exit code {$exitCode})"); - foreach ($output as $line) { - $this->log(" {$line}"); - } - return false; - } - - if ($this->verbose) { - foreach ($output as $line) { - $this->log(" {$line}"); - } - } - - return true; - } - - private function buildRsyncCommand(string $sshCmd, string $source, string $dest, array $extraArgs = []): string - { - $parts = ['rsync', '-rlptz', '--delete']; - - foreach (self::EXCLUDES as $exclude) { - $parts[] = '--exclude=' . $exclude; - } - - foreach ($extraArgs as $arg) { - $parts[] = $arg; - } - - if ($this->dryRun) { - $parts[] = '--dry-run'; - } - - if ($this->verbose) { - $parts[] = '-v'; - } - - $parts[] = '-e'; - $parts[] = escapeshellarg($sshCmd); - $parts[] = escapeshellarg($source); - $parts[] = escapeshellarg($dest); - - return implode(' ', $parts); - } - - private function log(string $message): void - { - $timestamp = date('Y-m-d H:i:s'); - fwrite(STDERR, "[{$timestamp}] {$message}" . PHP_EOL); - } -} - -$app = new DeployDolibarr(); -exit($app->run()); +#!/usr/bin/env php + + * + * This file is part of a Moko Consulting project. + * + * SPDX-License-Identifier: GPL-3.0-or-later + * + * FILE INFORMATION + * DEFGROUP: MokoStandards.Scripts.Deploy + * INGROUP: MokoStandards + * REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform + * PATH: /deploy/deploy-dolibarr.php + * VERSION: 01.00.00 + * BRIEF: Deploy Dolibarr module files to a remote server via SFTP/rsync + */ + +declare(strict_types=1); + +class DeployDolibarr +{ + private bool $verbose = false; + private bool $dryRun = false; + private string $configPath = ''; + private string $source = ''; + + private const MODULE_DIRS = [ + 'core/modules', + 'class', + 'lib', + 'sql', + 'langs', + 'css', + 'js', + 'img', + ]; + + private const EXCLUDES = [ + '.git/', + 'vendor/', + 'tests/', + 'node_modules/', + ]; + + public function run(): int + { + $this->parseArgs(); + + if ($this->configPath === '' || $this->source === '') { + $this->log('Usage: deploy-dolibarr.php --source --config [--dry-run] [--verbose]'); + return 1; + } + + if (!is_dir($this->source)) { + $this->log("ERROR: Source directory does not exist: {$this->source}"); + return 1; + } + + $moduleName = $this->detectModuleName(); + if ($moduleName === null) { + $this->log('ERROR: Could not auto-detect module name. Expected core/modules/mod*.class.php'); + return 1; + } + + $config = $this->loadConfig($this->configPath); + if ($config === null) { + return 1; + } + + $host = $config['host'] ?? ''; + $user = $config['user'] ?? ''; + $port = (int) ($config['port'] ?? 22); + $remotePath = rtrim($config['remote_path'] ?? '', '/'); + $sshKey = $config['ssh_key_file'] ?? ''; + + if ($host === '' || $user === '' || $remotePath === '') { + $this->log('ERROR: Config must contain host, user, and remote_path.'); + return 1; + } + + $remoteBase = "{$remotePath}/htdocs/custom/{$moduleName}"; + + $this->log("Deploying Dolibarr module: {$moduleName}"); + $this->log("Source: {$this->source}"); + $this->log("Target: {$user}@{$host}:{$remoteBase}"); + + if ($this->dryRun) { + $this->log('*** DRY RUN — no changes will be made ***'); + } + + $failed = 0; + + // Deploy subdirectories + foreach (self::MODULE_DIRS as $dir) { + $localDir = rtrim($this->source, '/\\') . '/' . $dir . '/'; + + if (!is_dir($localDir)) { + if ($this->verbose) { + $this->log("SKIP: {$dir} (not present in source)"); + } + continue; + } + + $remoteTarget = "{$remoteBase}/{$dir}/"; + $result = $this->rsyncDir($localDir, $remoteTarget, $host, $user, $port, $sshKey); + + if (!$result) { + $failed++; + } + } + + // Deploy root PHP files + $rootPhpFiles = glob(rtrim($this->source, '/\\') . '/*.php'); + if (!empty($rootPhpFiles)) { + $this->log('Syncing root PHP files...'); + $sourceRoot = rtrim($this->source, '/\\') . '/'; + $remoteTarget = "{$remoteBase}/"; + + $sshCmd = "ssh -p {$port}"; + if ($sshKey !== '') { + $sshCmd .= " -i " . escapeshellarg($sshKey); + } + + $cmd = $this->buildRsyncCommand( + $sshCmd, + $sourceRoot, + "{$user}@{$host}:{$remoteTarget}", + ['--include=*.php', '--exclude=*/', '--exclude=.*'] + ); + + if ($this->verbose) { + $this->log("CMD: {$cmd}"); + } + + $output = []; + $exitCode = 0; + exec($cmd, $output, $exitCode); + + if ($exitCode !== 0) { + $this->log("ERROR: rsync failed for root PHP files (exit code {$exitCode})"); + foreach ($output as $line) { + $this->log(" {$line}"); + } + $failed++; + } else { + if ($this->verbose) { + foreach ($output as $line) { + $this->log(" {$line}"); + } + } + } + } + + if ($failed > 0) { + $this->log("Deployment completed with {$failed} error(s)."); + return 1; + } + + $this->log('Deployment completed successfully.'); + return 0; + } + + private function parseArgs(): void + { + $args = $_SERVER['argv'] ?? []; + $count = count($args); + + for ($i = 1; $i < $count; $i++) { + switch ($args[$i]) { + case '--source': + $this->source = $args[++$i] ?? ''; + break; + case '--config': + $this->configPath = $args[++$i] ?? ''; + break; + case '--dry-run': + $this->dryRun = true; + break; + case '--verbose': + $this->verbose = true; + break; + } + } + } + + private function detectModuleName(): ?string + { + $pattern = rtrim($this->source, '/\\') . '/core/modules/mod*.class.php'; + $matches = glob($pattern); + + if (empty($matches)) { + return null; + } + + $filename = basename($matches[0]); + // mod{ModuleName}.class.php → extract ModuleName, lowercase it + if (preg_match('/^mod(.+)\.class\.php$/', $filename, $m)) { + return strtolower($m[1]); + } + + return null; + } + + private function loadConfig(string $path): ?array + { + if (!is_file($path)) { + $this->log("ERROR: Config file not found: {$path}"); + return null; + } + + $raw = file_get_contents($path); + if ($raw === false) { + $this->log("ERROR: Could not read config file: {$path}"); + return null; + } + + // Strip // comments (sftp-config.json style) + $cleaned = preg_replace('#^\s*//.*$#m', '', $raw); + $config = json_decode($cleaned, true); + + if (!is_array($config)) { + $this->log('ERROR: Invalid JSON in config file.'); + return null; + } + + return $config; + } + + private function rsyncDir(string $localDir, string $remoteTarget, string $host, string $user, int $port, string $sshKey): bool + { + $dirName = basename(rtrim($localDir, '/')); + $sshCmd = "ssh -p {$port}"; + if ($sshKey !== '') { + $sshCmd .= " -i " . escapeshellarg($sshKey); + } + + $cmd = $this->buildRsyncCommand($sshCmd, $localDir, "{$user}@{$host}:{$remoteTarget}"); + + $this->log("Syncing: {$dirName}"); + if ($this->verbose) { + $this->log("CMD: {$cmd}"); + } + + $output = []; + $exitCode = 0; + exec($cmd, $output, $exitCode); + + if ($exitCode !== 0) { + $this->log("ERROR: rsync failed for {$dirName} (exit code {$exitCode})"); + foreach ($output as $line) { + $this->log(" {$line}"); + } + return false; + } + + if ($this->verbose) { + foreach ($output as $line) { + $this->log(" {$line}"); + } + } + + return true; + } + + private function buildRsyncCommand(string $sshCmd, string $source, string $dest, array $extraArgs = []): string + { + $parts = ['rsync', '-rlptz', '--delete']; + + foreach (self::EXCLUDES as $exclude) { + $parts[] = '--exclude=' . $exclude; + } + + foreach ($extraArgs as $arg) { + $parts[] = $arg; + } + + if ($this->dryRun) { + $parts[] = '--dry-run'; + } + + if ($this->verbose) { + $parts[] = '-v'; + } + + $parts[] = '-e'; + $parts[] = escapeshellarg($sshCmd); + $parts[] = escapeshellarg($source); + $parts[] = escapeshellarg($dest); + + return implode(' ', $parts); + } + + private function log(string $message): void + { + $timestamp = date('Y-m-d H:i:s'); + fwrite(STDERR, "[{$timestamp}] {$message}" . PHP_EOL); + } +} + +$app = new DeployDolibarr(); +exit($app->run()); diff --git a/deploy/health-check.php b/deploy/health-check.php index d251c3d..d9db5b1 100644 --- a/deploy/health-check.php +++ b/deploy/health-check.php @@ -1,227 +1,227 @@ -#!/usr/bin/env php - - * - * This file is part of a Moko Consulting project. - * - * SPDX-License-Identifier: GPL-3.0-or-later - * - * FILE INFORMATION - * DEFGROUP: MokoStandards.Scripts.Deploy - * INGROUP: MokoStandards - * REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform - * PATH: /deploy/health-check.php - * VERSION: 01.00.00 - * BRIEF: Post-deploy health check — verify a Joomla site is responding correctly - */ - -declare(strict_types=1); - -class HealthCheck -{ - private string $url = ''; - private int $timeout = 30; - private array $checks = ['http']; - - private int $passed = 0; - private int $failed = 0; - - public function run(): int - { - $this->parseArgs(); - - if ($this->url === '') { - $this->log('Usage: health-check.php --url [--timeout ] [--checks ]'); - return 1; - } - - $this->url = rtrim($this->url, '/'); - - $this->log("Health check for: {$this->url}"); - $this->log("Timeout: {$this->timeout}s"); - $this->log("Checks: " . implode(', ', $this->checks)); - $this->log(''); - - foreach ($this->checks as $check) { - switch ($check) { - case 'http': - $this->checkHttp(); - break; - case 'admin': - $this->checkAdmin(); - break; - case 'api': - $this->checkApi(); - break; - default: - $this->log("UNKNOWN CHECK: {$check} — skipping"); - break; - } - } - - $this->log(''); - $this->log("Results: {$this->passed} passed, {$this->failed} failed"); - - return $this->failed > 0 ? 1 : 0; - } - - private function parseArgs(): void - { - $args = $_SERVER['argv'] ?? []; - $count = count($args); - - for ($i = 1; $i < $count; $i++) { - switch ($args[$i]) { - case '--url': - $this->url = $args[++$i] ?? ''; - break; - case '--timeout': - $this->timeout = (int) ($args[++$i] ?? 30); - break; - case '--checks': - $raw = $args[++$i] ?? 'http'; - $this->checks = array_map('trim', explode(',', $raw)); - break; - } - } - } - - private function checkHttp(): void - { - $this->log('[http] GET ' . $this->url); - - $result = $this->curlGet($this->url); - - if ($result === null) { - $this->fail('http', 'Request failed — could not connect'); - return; - } - - if ($result['http_code'] !== 200) { - $this->fail('http', "Expected HTTP 200, got {$result['http_code']}"); - return; - } - - if ($this->containsFatalError($result['body'])) { - $this->fail('http', 'Response body contains PHP fatal error'); - return; - } - - $this->pass('http', "HTTP 200 OK ({$result['time_ms']}ms)"); - } - - private function checkAdmin(): void - { - $adminUrl = $this->url . '/administrator/'; - $this->log('[admin] GET ' . $adminUrl); - - $result = $this->curlGet($adminUrl); - - if ($result === null) { - $this->fail('admin', 'Request failed — could not connect'); - return; - } - - if ($result['http_code'] !== 200) { - $this->fail('admin', "Expected HTTP 200, got {$result['http_code']}"); - return; - } - - $this->pass('admin', "HTTP 200 OK ({$result['time_ms']}ms)"); - } - - private function checkApi(): void - { - $apiUrl = $this->url . '/api/index.php/v1'; - $this->log('[api] GET ' . $apiUrl); - - $result = $this->curlGet($apiUrl); - - if ($result === null) { - $this->fail('api', 'Request failed — could not connect'); - return; - } - - if ($result['http_code'] !== 200 && $result['http_code'] !== 401) { - $this->fail('api', "Expected HTTP 200 or 401, got {$result['http_code']}"); - return; - } - - $this->pass('api', "HTTP {$result['http_code']} — API is alive ({$result['time_ms']}ms)"); - } - - private function curlGet(string $url): ?array - { - $ch = curl_init(); - - curl_setopt_array($ch, [ - CURLOPT_URL => $url, - CURLOPT_RETURNTRANSFER => true, - CURLOPT_FOLLOWLOCATION => true, - CURLOPT_MAXREDIRS => 5, - CURLOPT_TIMEOUT => $this->timeout, - CURLOPT_CONNECTTIMEOUT => $this->timeout, - CURLOPT_SSL_VERIFYPEER => true, - CURLOPT_USERAGENT => 'MokoHealthCheck/1.0', - ]); - - $body = curl_exec($ch); - - if (curl_errno($ch)) { - $error = curl_error($ch); - $this->log(" cURL error: {$error}"); - curl_close($ch); - return null; - } - - $httpCode = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE); - $totalTime = curl_getinfo($ch, CURLINFO_TOTAL_TIME); - curl_close($ch); - - return [ - 'http_code' => $httpCode, - 'body' => is_string($body) ? $body : '', - 'time_ms' => (int) round($totalTime * 1000), - ]; - } - - private function containsFatalError(string $body): bool - { - $patterns = [ - 'Fatal error:', - 'Fatal Error', - 'Parse error:', - 'Uncaught Error:', - 'Uncaught Exception:', - ]; - - foreach ($patterns as $pattern) { - if (stripos($body, $pattern) !== false) { - return true; - } - } - - return false; - } - - private function pass(string $check, string $message): void - { - $this->passed++; - $this->log("[{$check}] PASS: {$message}"); - } - - private function fail(string $check, string $message): void - { - $this->failed++; - $this->log("[{$check}] FAIL: {$message}"); - } - - private function log(string $message): void - { - $timestamp = date('Y-m-d H:i:s'); - fwrite(STDERR, "[{$timestamp}] {$message}" . PHP_EOL); - } -} - -$app = new HealthCheck(); -exit($app->run()); +#!/usr/bin/env php + + * + * This file is part of a Moko Consulting project. + * + * SPDX-License-Identifier: GPL-3.0-or-later + * + * FILE INFORMATION + * DEFGROUP: MokoStandards.Scripts.Deploy + * INGROUP: MokoStandards + * REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform + * PATH: /deploy/health-check.php + * VERSION: 01.00.00 + * BRIEF: Post-deploy health check — verify a Joomla site is responding correctly + */ + +declare(strict_types=1); + +class HealthCheck +{ + private string $url = ''; + private int $timeout = 30; + private array $checks = ['http']; + + private int $passed = 0; + private int $failed = 0; + + public function run(): int + { + $this->parseArgs(); + + if ($this->url === '') { + $this->log('Usage: health-check.php --url [--timeout ] [--checks ]'); + return 1; + } + + $this->url = rtrim($this->url, '/'); + + $this->log("Health check for: {$this->url}"); + $this->log("Timeout: {$this->timeout}s"); + $this->log("Checks: " . implode(', ', $this->checks)); + $this->log(''); + + foreach ($this->checks as $check) { + switch ($check) { + case 'http': + $this->checkHttp(); + break; + case 'admin': + $this->checkAdmin(); + break; + case 'api': + $this->checkApi(); + break; + default: + $this->log("UNKNOWN CHECK: {$check} — skipping"); + break; + } + } + + $this->log(''); + $this->log("Results: {$this->passed} passed, {$this->failed} failed"); + + return $this->failed > 0 ? 1 : 0; + } + + private function parseArgs(): void + { + $args = $_SERVER['argv'] ?? []; + $count = count($args); + + for ($i = 1; $i < $count; $i++) { + switch ($args[$i]) { + case '--url': + $this->url = $args[++$i] ?? ''; + break; + case '--timeout': + $this->timeout = (int) ($args[++$i] ?? 30); + break; + case '--checks': + $raw = $args[++$i] ?? 'http'; + $this->checks = array_map('trim', explode(',', $raw)); + break; + } + } + } + + private function checkHttp(): void + { + $this->log('[http] GET ' . $this->url); + + $result = $this->curlGet($this->url); + + if ($result === null) { + $this->fail('http', 'Request failed — could not connect'); + return; + } + + if ($result['http_code'] !== 200) { + $this->fail('http', "Expected HTTP 200, got {$result['http_code']}"); + return; + } + + if ($this->containsFatalError($result['body'])) { + $this->fail('http', 'Response body contains PHP fatal error'); + return; + } + + $this->pass('http', "HTTP 200 OK ({$result['time_ms']}ms)"); + } + + private function checkAdmin(): void + { + $adminUrl = $this->url . '/administrator/'; + $this->log('[admin] GET ' . $adminUrl); + + $result = $this->curlGet($adminUrl); + + if ($result === null) { + $this->fail('admin', 'Request failed — could not connect'); + return; + } + + if ($result['http_code'] !== 200) { + $this->fail('admin', "Expected HTTP 200, got {$result['http_code']}"); + return; + } + + $this->pass('admin', "HTTP 200 OK ({$result['time_ms']}ms)"); + } + + private function checkApi(): void + { + $apiUrl = $this->url . '/api/index.php/v1'; + $this->log('[api] GET ' . $apiUrl); + + $result = $this->curlGet($apiUrl); + + if ($result === null) { + $this->fail('api', 'Request failed — could not connect'); + return; + } + + if ($result['http_code'] !== 200 && $result['http_code'] !== 401) { + $this->fail('api', "Expected HTTP 200 or 401, got {$result['http_code']}"); + return; + } + + $this->pass('api', "HTTP {$result['http_code']} — API is alive ({$result['time_ms']}ms)"); + } + + private function curlGet(string $url): ?array + { + $ch = curl_init(); + + curl_setopt_array($ch, [ + CURLOPT_URL => $url, + CURLOPT_RETURNTRANSFER => true, + CURLOPT_FOLLOWLOCATION => true, + CURLOPT_MAXREDIRS => 5, + CURLOPT_TIMEOUT => $this->timeout, + CURLOPT_CONNECTTIMEOUT => $this->timeout, + CURLOPT_SSL_VERIFYPEER => true, + CURLOPT_USERAGENT => 'MokoHealthCheck/1.0', + ]); + + $body = curl_exec($ch); + + if (curl_errno($ch)) { + $error = curl_error($ch); + $this->log(" cURL error: {$error}"); + curl_close($ch); + return null; + } + + $httpCode = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE); + $totalTime = curl_getinfo($ch, CURLINFO_TOTAL_TIME); + curl_close($ch); + + return [ + 'http_code' => $httpCode, + 'body' => is_string($body) ? $body : '', + 'time_ms' => (int) round($totalTime * 1000), + ]; + } + + private function containsFatalError(string $body): bool + { + $patterns = [ + 'Fatal error:', + 'Fatal Error', + 'Parse error:', + 'Uncaught Error:', + 'Uncaught Exception:', + ]; + + foreach ($patterns as $pattern) { + if (stripos($body, $pattern) !== false) { + return true; + } + } + + return false; + } + + private function pass(string $check, string $message): void + { + $this->passed++; + $this->log("[{$check}] PASS: {$message}"); + } + + private function fail(string $check, string $message): void + { + $this->failed++; + $this->log("[{$check}] FAIL: {$message}"); + } + + private function log(string $message): void + { + $timestamp = date('Y-m-d H:i:s'); + fwrite(STDERR, "[{$timestamp}] {$message}" . PHP_EOL); + } +} + +$app = new HealthCheck(); +exit($app->run()); diff --git a/deploy/rollback-joomla.php b/deploy/rollback-joomla.php index e8d404d..d225361 100644 --- a/deploy/rollback-joomla.php +++ b/deploy/rollback-joomla.php @@ -1,230 +1,230 @@ -#!/usr/bin/env php - - * - * This file is part of a Moko Consulting project. - * - * SPDX-License-Identifier: GPL-3.0-or-later - * - * FILE INFORMATION - * DEFGROUP: MokoStandards.Scripts.Deploy - * INGROUP: MokoStandards - * REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform - * PATH: /deploy/rollback-joomla.php - * VERSION: 01.00.00 - * BRIEF: Rollback a Joomla deployment by restoring from a pre-deploy snapshot - */ - -declare(strict_types=1); - -class RollbackJoomla -{ - private bool $verbose = false; - private bool $dryRun = false; - private string $configPath = ''; - private string $snapshotDir = ''; - - private const JOOMLA_DIRS = [ - 'administrator/components', - 'administrator/language', - 'administrator/modules', - 'administrator/templates', - 'components', - 'language', - 'layouts', - 'libraries', - 'media', - 'modules', - 'plugins', - 'templates', - ]; - - public function run(): int - { - $this->parseArgs(); - - if ($this->configPath === '' || $this->snapshotDir === '') { - $this->log('Usage: rollback-joomla.php --config --snapshot-dir [--dry-run] [--verbose]'); - return 1; - } - - if (!is_dir($this->snapshotDir)) { - $this->log("ERROR: Snapshot directory does not exist: {$this->snapshotDir}"); - return 1; - } - - $config = $this->loadConfig($this->configPath); - if ($config === null) { - return 1; - } - - $host = $config['host'] ?? ''; - $user = $config['user'] ?? ''; - $port = (int) ($config['port'] ?? 22); - $remotePath = rtrim($config['remote_path'] ?? '', '/'); - $sshKey = $config['ssh_key_file'] ?? ''; - - if ($host === '' || $user === '' || $remotePath === '') { - $this->log('ERROR: Config must contain host, user, and remote_path.'); - return 1; - } - - $this->log('Starting Joomla rollback from snapshot...'); - $this->log("Snapshot: {$this->snapshotDir}"); - $this->log("Target: {$user}@{$host}:{$remotePath}"); - - if ($this->dryRun) { - $this->log('*** DRY RUN — no changes will be made ***'); - } - - $failed = 0; - - foreach (self::JOOMLA_DIRS as $dir) { - $localDir = rtrim($this->snapshotDir, '/\\') . '/' . $dir . '/'; - - if (!is_dir($localDir)) { - if ($this->verbose) { - $this->log("SKIP: {$dir} (not present in snapshot)"); - } - continue; - } - - $remoteTarget = "{$remotePath}/{$dir}/"; - $sshCmd = "ssh -p {$port}"; - if ($sshKey !== '') { - $sshCmd .= " -i " . escapeshellarg($sshKey); - } - - $rsyncArgs = [ - 'rsync', - '-rlptz', - '--delete', - '--exclude=configuration.php', - '-e', $sshCmd, - ]; - - if ($this->dryRun) { - $rsyncArgs[] = '--dry-run'; - } - - if ($this->verbose) { - $rsyncArgs[] = '-v'; - } - - $rsyncArgs[] = $localDir; - $rsyncArgs[] = "{$user}@{$host}:{$remoteTarget}"; - - $cmd = implode(' ', array_map('escapeshellarg', $rsyncArgs)); - // rsync -e needs unescaped, rebuild manually - $cmd = $this->buildRsyncCommand($sshCmd, $localDir, "{$user}@{$host}:{$remoteTarget}"); - - $this->log("Restoring: {$dir}"); - if ($this->verbose) { - $this->log("CMD: {$cmd}"); - } - - $output = []; - $exitCode = 0; - exec($cmd, $output, $exitCode); - - if ($exitCode !== 0) { - $this->log("ERROR: rsync failed for {$dir} (exit code {$exitCode})"); - foreach ($output as $line) { - $this->log(" {$line}"); - } - $failed++; - } else { - if ($this->verbose) { - foreach ($output as $line) { - $this->log(" {$line}"); - } - } - } - } - - if ($failed > 0) { - $this->log("Rollback completed with {$failed} error(s)."); - return 1; - } - - $this->log('Rollback completed successfully.'); - return 0; - } - - private function parseArgs(): void - { - $args = $_SERVER['argv'] ?? []; - $count = count($args); - - for ($i = 1; $i < $count; $i++) { - switch ($args[$i]) { - case '--config': - $this->configPath = $args[++$i] ?? ''; - break; - case '--snapshot-dir': - $this->snapshotDir = $args[++$i] ?? ''; - break; - case '--dry-run': - $this->dryRun = true; - break; - case '--verbose': - $this->verbose = true; - break; - } - } - } - - private function loadConfig(string $path): ?array - { - if (!is_file($path)) { - $this->log("ERROR: Config file not found: {$path}"); - return null; - } - - $raw = file_get_contents($path); - if ($raw === false) { - $this->log("ERROR: Could not read config file: {$path}"); - return null; - } - - // Strip // comments (sftp-config.json style) - $cleaned = preg_replace('#^\s*//.*$#m', '', $raw); - $config = json_decode($cleaned, true); - - if (!is_array($config)) { - $this->log('ERROR: Invalid JSON in config file.'); - return null; - } - - return $config; - } - - private function buildRsyncCommand(string $sshCmd, string $source, string $dest): string - { - $parts = ['rsync', '-rlptz', '--delete', '--exclude=configuration.php']; - - if ($this->dryRun) { - $parts[] = '--dry-run'; - } - - if ($this->verbose) { - $parts[] = '-v'; - } - - $parts[] = '-e'; - $parts[] = escapeshellarg($sshCmd); - $parts[] = escapeshellarg($source); - $parts[] = escapeshellarg($dest); - - return implode(' ', $parts); - } - - private function log(string $message): void - { - $timestamp = date('Y-m-d H:i:s'); - fwrite(STDERR, "[{$timestamp}] {$message}" . PHP_EOL); - } -} - -$app = new RollbackJoomla(); -exit($app->run()); +#!/usr/bin/env php + + * + * This file is part of a Moko Consulting project. + * + * SPDX-License-Identifier: GPL-3.0-or-later + * + * FILE INFORMATION + * DEFGROUP: MokoStandards.Scripts.Deploy + * INGROUP: MokoStandards + * REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform + * PATH: /deploy/rollback-joomla.php + * VERSION: 01.00.00 + * BRIEF: Rollback a Joomla deployment by restoring from a pre-deploy snapshot + */ + +declare(strict_types=1); + +class RollbackJoomla +{ + private bool $verbose = false; + private bool $dryRun = false; + private string $configPath = ''; + private string $snapshotDir = ''; + + private const JOOMLA_DIRS = [ + 'administrator/components', + 'administrator/language', + 'administrator/modules', + 'administrator/templates', + 'components', + 'language', + 'layouts', + 'libraries', + 'media', + 'modules', + 'plugins', + 'templates', + ]; + + public function run(): int + { + $this->parseArgs(); + + if ($this->configPath === '' || $this->snapshotDir === '') { + $this->log('Usage: rollback-joomla.php --config --snapshot-dir [--dry-run] [--verbose]'); + return 1; + } + + if (!is_dir($this->snapshotDir)) { + $this->log("ERROR: Snapshot directory does not exist: {$this->snapshotDir}"); + return 1; + } + + $config = $this->loadConfig($this->configPath); + if ($config === null) { + return 1; + } + + $host = $config['host'] ?? ''; + $user = $config['user'] ?? ''; + $port = (int) ($config['port'] ?? 22); + $remotePath = rtrim($config['remote_path'] ?? '', '/'); + $sshKey = $config['ssh_key_file'] ?? ''; + + if ($host === '' || $user === '' || $remotePath === '') { + $this->log('ERROR: Config must contain host, user, and remote_path.'); + return 1; + } + + $this->log('Starting Joomla rollback from snapshot...'); + $this->log("Snapshot: {$this->snapshotDir}"); + $this->log("Target: {$user}@{$host}:{$remotePath}"); + + if ($this->dryRun) { + $this->log('*** DRY RUN — no changes will be made ***'); + } + + $failed = 0; + + foreach (self::JOOMLA_DIRS as $dir) { + $localDir = rtrim($this->snapshotDir, '/\\') . '/' . $dir . '/'; + + if (!is_dir($localDir)) { + if ($this->verbose) { + $this->log("SKIP: {$dir} (not present in snapshot)"); + } + continue; + } + + $remoteTarget = "{$remotePath}/{$dir}/"; + $sshCmd = "ssh -p {$port}"; + if ($sshKey !== '') { + $sshCmd .= " -i " . escapeshellarg($sshKey); + } + + $rsyncArgs = [ + 'rsync', + '-rlptz', + '--delete', + '--exclude=configuration.php', + '-e', $sshCmd, + ]; + + if ($this->dryRun) { + $rsyncArgs[] = '--dry-run'; + } + + if ($this->verbose) { + $rsyncArgs[] = '-v'; + } + + $rsyncArgs[] = $localDir; + $rsyncArgs[] = "{$user}@{$host}:{$remoteTarget}"; + + $cmd = implode(' ', array_map('escapeshellarg', $rsyncArgs)); + // rsync -e needs unescaped, rebuild manually + $cmd = $this->buildRsyncCommand($sshCmd, $localDir, "{$user}@{$host}:{$remoteTarget}"); + + $this->log("Restoring: {$dir}"); + if ($this->verbose) { + $this->log("CMD: {$cmd}"); + } + + $output = []; + $exitCode = 0; + exec($cmd, $output, $exitCode); + + if ($exitCode !== 0) { + $this->log("ERROR: rsync failed for {$dir} (exit code {$exitCode})"); + foreach ($output as $line) { + $this->log(" {$line}"); + } + $failed++; + } else { + if ($this->verbose) { + foreach ($output as $line) { + $this->log(" {$line}"); + } + } + } + } + + if ($failed > 0) { + $this->log("Rollback completed with {$failed} error(s)."); + return 1; + } + + $this->log('Rollback completed successfully.'); + return 0; + } + + private function parseArgs(): void + { + $args = $_SERVER['argv'] ?? []; + $count = count($args); + + for ($i = 1; $i < $count; $i++) { + switch ($args[$i]) { + case '--config': + $this->configPath = $args[++$i] ?? ''; + break; + case '--snapshot-dir': + $this->snapshotDir = $args[++$i] ?? ''; + break; + case '--dry-run': + $this->dryRun = true; + break; + case '--verbose': + $this->verbose = true; + break; + } + } + } + + private function loadConfig(string $path): ?array + { + if (!is_file($path)) { + $this->log("ERROR: Config file not found: {$path}"); + return null; + } + + $raw = file_get_contents($path); + if ($raw === false) { + $this->log("ERROR: Could not read config file: {$path}"); + return null; + } + + // Strip // comments (sftp-config.json style) + $cleaned = preg_replace('#^\s*//.*$#m', '', $raw); + $config = json_decode($cleaned, true); + + if (!is_array($config)) { + $this->log('ERROR: Invalid JSON in config file.'); + return null; + } + + return $config; + } + + private function buildRsyncCommand(string $sshCmd, string $source, string $dest): string + { + $parts = ['rsync', '-rlptz', '--delete', '--exclude=configuration.php']; + + if ($this->dryRun) { + $parts[] = '--dry-run'; + } + + if ($this->verbose) { + $parts[] = '-v'; + } + + $parts[] = '-e'; + $parts[] = escapeshellarg($sshCmd); + $parts[] = escapeshellarg($source); + $parts[] = escapeshellarg($dest); + + return implode(' ', $parts); + } + + private function log(string $message): void + { + $timestamp = date('Y-m-d H:i:s'); + fwrite(STDERR, "[{$timestamp}] {$message}" . PHP_EOL); + } +} + +$app = new RollbackJoomla(); +exit($app->run()); diff --git a/deploy/sync-joomla.php b/deploy/sync-joomla.php index a3d64e2..e8627ad 100644 --- a/deploy/sync-joomla.php +++ b/deploy/sync-joomla.php @@ -1,453 +1,453 @@ -#!/usr/bin/env php - - * - * This file is part of a Moko Consulting project. - * - * SPDX-License-Identifier: GPL-3.0-or-later - * - * FILE INFORMATION - * DEFGROUP: MokoStandards.Scripts.Deploy - * INGROUP: MokoStandards - * REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform - * PATH: /deploy/sync-joomla.php - * VERSION: 01.00.00 - * BRIEF: Sync Joomla site directories between two servers via rsync over SSH - */ - -declare(strict_types=1); - -class SyncJoomla -{ - /** @var string Path to source sftp-config.json */ - private string $sourceConfig = ''; - - /** @var string Path to dest sftp-config.json */ - private string $destConfig = ''; - - /** @var bool Sync standard Joomla directories only */ - private bool $rsyncMode = false; - - /** @var bool Sync everything under remote_path */ - private bool $fullMode = false; - - /** @var bool Dry-run (preview only) */ - private bool $dryRun = false; - - /** @var bool Verbose output */ - private bool $verbose = false; - - /** @var string[] Additional exclude patterns */ - private array $excludes = []; - - /** @var string Local relay directory */ - private string $relayDir = '/tmp/sync/'; - - /** @var string[] Standard Joomla directories to sync */ - private array $joomlaDirs = [ - 'administrator/components', - 'administrator/language', - 'administrator/modules', - 'administrator/templates', - 'components', - 'language', - 'layouts', - 'libraries', - 'media', - 'modules', - 'plugins', - 'templates', - ]; - - /** - * Main entry point. - * - * @return int Exit code - */ - public function run(): int - { - $this->parseArgs(); - - if (!$this->validate()) { - return 1; - } - - $source = $this->loadConfig($this->sourceConfig); - $dest = $this->loadConfig($this->destConfig); - - if ($source === null || $dest === null) { - return 1; - } - - $this->log("Source: {$source['user']}@{$source['host']}:{$source['remote_path']}"); - $this->log("Dest: {$dest['user']}@{$dest['host']}:{$dest['remote_path']}"); - - if ($this->dryRun) { - $this->log('[DRY-RUN] No files will be transferred.'); - } - - $this->prepareRelayDir(); - - $dirs = $this->resolveDirs(); - $totalFiles = 0; - $syncedDirs = 0; - - foreach ($dirs as $dir) { - $this->log("--- Syncing: {$dir}"); - - $pulled = $this->pullFromSource($source, $dir); - if ($pulled === false) { - $this->log(" WARNING: pull failed for {$dir}, skipping."); - continue; - } - - $pushed = $this->pushToDest($dest, $dir); - if ($pushed === false) { - $this->log(" WARNING: push failed for {$dir}, skipping."); - continue; - } - - $totalFiles += $pulled + $pushed; - $syncedDirs++; - } - - $this->cleanup(); - $this->log(''); - $this->log('=== Sync Summary ==='); - $this->log("Directories synced: {$syncedDirs}/" . count($dirs)); - $this->log("Rsync operations: " . ($syncedDirs * 2) . " (pull + push)"); - - if ($this->dryRun) { - $this->log('Mode: dry-run (no files were transferred)'); - } - - return 0; - } - - /** - * Parse command-line arguments. - */ - private function parseArgs(): void - { - global $argv; - - $i = 1; - while ($i < count($argv)) { - switch ($argv[$i]) { - case '--source': - $this->sourceConfig = $argv[++$i] ?? ''; - break; - case '--dest': - $this->destConfig = $argv[++$i] ?? ''; - break; - case '--rsync': - $this->rsyncMode = true; - break; - case '--full': - $this->fullMode = true; - break; - case '--dry-run': - $this->dryRun = true; - break; - case '--verbose': - $this->verbose = true; - break; - case '--exclude': - $this->excludes[] = $argv[++$i] ?? ''; - break; - default: - $this->log("Unknown argument: {$argv[$i]}"); - break; - } - $i++; - } - } - - /** - * Validate required arguments. - * - * @return bool True if valid - */ - private function validate(): bool - { - if ($this->sourceConfig === '' || $this->destConfig === '') { - $this->log('ERROR: --source and --dest are required.'); - $this->printUsage(); - return false; - } - - if (!$this->rsyncMode && !$this->fullMode) { - $this->log('ERROR: Either --rsync or --full must be specified.'); - $this->printUsage(); - return false; - } - - if ($this->rsyncMode && $this->fullMode) { - $this->log('ERROR: --rsync and --full are mutually exclusive.'); - return false; - } - - if (!file_exists($this->sourceConfig)) { - $this->log("ERROR: Source config not found: {$this->sourceConfig}"); - return false; - } - - if (!file_exists($this->destConfig)) { - $this->log("ERROR: Dest config not found: {$this->destConfig}"); - return false; - } - - return true; - } - - /** - * Load and decode an sftp-config.json file. - * - * @param string $path Path to the config file - * @return array|null Parsed config or null on error - */ - private function loadConfig(string $path): ?array - { - $json = file_get_contents($path); - if ($json === false) { - $this->log("ERROR: Cannot read config: {$path}"); - return null; - } - - // Strip // comments (Sublime Text SFTP format) - $json = preg_replace('#^\s*//.*$#m', '', $json); - $json = preg_replace('#,\s*([\]}])#', '$1', $json); - - $config = json_decode($json, true); - if (!is_array($config)) { - $this->log("ERROR: Invalid JSON in config: {$path}"); - return null; - } - - $required = ['host', 'user', 'remote_path', 'ssh_key_file']; - foreach ($required as $key) { - if (empty($config[$key])) { - $this->log("ERROR: Missing '{$key}' in config: {$path}"); - return null; - } - } - - if (!isset($config['port'])) { - $config['port'] = 22; - } - - return $config; - } - - /** - * Resolve the list of directories to sync. - * - * @return string[] Directory paths (relative to remote_path) - */ - private function resolveDirs(): array - { - if ($this->fullMode) { - return ['.']; - } - - return $this->joomlaDirs; - } - - /** - * Prepare the local relay directory. - */ - private function prepareRelayDir(): void - { - if (is_dir($this->relayDir)) { - shell_exec("rm -rf " . escapeshellarg($this->relayDir)); - } - - mkdir($this->relayDir, 0755, true); - $this->log("Relay directory: {$this->relayDir}"); - } - - /** - * Build common rsync exclude flags. - * - * configuration.php is always excluded — it contains per-environment - * database credentials and settings that must never be synced. - * - * @return string Exclude arguments for rsync - */ - private function buildExcludes(): string - { - $excludes = ['configuration.php']; - $excludes = array_merge($excludes, $this->excludes); - - $flags = ''; - foreach ($excludes as $pattern) { - $flags .= ' --exclude=' . escapeshellarg($pattern); - } - - return $flags; - } - - /** - * Build SSH command fragment for rsync. - * - * @param array $config Server config - * @return string The -e flag value for rsync - */ - private function buildSshCmd(array $config): string - { - $keyPath = escapeshellarg($config['ssh_key_file']); - $port = (int) $config['port']; - - return "ssh -i {$keyPath} -p {$port} -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null"; - } - - /** - * Pull a directory from the source server to the local relay. - * - * @param array $config Source server config - * @param string $dir Relative directory to sync - * @return int|false Number of files or false on failure - */ - private function pullFromSource(array $config, string $dir): int|false - { - $remotePath = rtrim($config['remote_path'], '/') . '/' . ltrim($dir, './'); - $localPath = $this->relayDir . ltrim($dir, './'); - - if (!is_dir($localPath)) { - mkdir($localPath, 0755, true); - } - - $sshCmd = $this->buildSshCmd($config); - $excludes = $this->buildExcludes(); - $dryFlag = $this->dryRun ? ' --dry-run' : ''; - $verboseFlag = $this->verbose ? ' -v' : ''; - - $remote = escapeshellarg("{$config['user']}@{$config['host']}:{$remotePath}/"); - $local = escapeshellarg("{$localPath}/"); - - $cmd = "rsync -az --delete" - . $dryFlag - . $verboseFlag - . $excludes - . " -e " . escapeshellarg($sshCmd) - . " {$remote} {$local}" - . " 2>&1"; - - $this->logVerbose(" PULL: {$cmd}"); - - $output = []; - $exitCode = 0; - exec($cmd, $output, $exitCode); - - if ($exitCode !== 0) { - $this->log(" ERROR (exit {$exitCode}): " . implode("\n", $output)); - return false; - } - - $fileCount = count($output); - $this->logVerbose(" Pulled {$fileCount} line(s) of output."); - - return $fileCount; - } - - /** - * Push a directory from the local relay to the destination server. - * - * @param array $config Dest server config - * @param string $dir Relative directory to sync - * @return int|false Number of files or false on failure - */ - private function pushToDest(array $config, string $dir): int|false - { - $remotePath = rtrim($config['remote_path'], '/') . '/' . ltrim($dir, './'); - $localPath = $this->relayDir . ltrim($dir, './'); - - $sshCmd = $this->buildSshCmd($config); - $excludes = $this->buildExcludes(); - $dryFlag = $this->dryRun ? ' --dry-run' : ''; - $verboseFlag = $this->verbose ? ' -v' : ''; - - $local = escapeshellarg("{$localPath}/"); - $remote = escapeshellarg("{$config['user']}@{$config['host']}:{$remotePath}/"); - - $cmd = "rsync -az --delete" - . $dryFlag - . $verboseFlag - . $excludes - . " -e " . escapeshellarg($sshCmd) - . " {$local} {$remote}" - . " 2>&1"; - - $this->logVerbose(" PUSH: {$cmd}"); - - $output = []; - $exitCode = 0; - exec($cmd, $output, $exitCode); - - if ($exitCode !== 0) { - $this->log(" ERROR (exit {$exitCode}): " . implode("\n", $output)); - return false; - } - - $fileCount = count($output); - $this->logVerbose(" Pushed {$fileCount} line(s) of output."); - - return $fileCount; - } - - /** - * Clean up the relay directory. - */ - private function cleanup(): void - { - if (is_dir($this->relayDir)) { - shell_exec("rm -rf " . escapeshellarg($this->relayDir)); - $this->logVerbose("Cleaned up relay directory."); - } - } - - /** - * Print usage information. - */ - private function printUsage(): void - { - $this->log(''); - $this->log('Usage: sync-joomla.php --source --dest [--rsync|--full] [options]'); - $this->log(''); - $this->log('Required:'); - $this->log(' --source sftp-config.json for source server'); - $this->log(' --dest sftp-config.json for dest server'); - $this->log(' --rsync Sync standard Joomla directories'); - $this->log(' --full Sync everything under the remote path'); - $this->log(''); - $this->log('Options:'); - $this->log(' --dry-run Preview only, no files transferred'); - $this->log(' --verbose Verbose output'); - $this->log(' --exclude Additional exclude pattern (repeatable)'); - } - - /** - * Log a message to stdout. - * - * @param string $message Message to log - */ - private function log(string $message): void - { - echo $message . PHP_EOL; - } - - /** - * Log a verbose message (only when --verbose is set). - * - * @param string $message Message to log - */ - private function logVerbose(string $message): void - { - if ($this->verbose) { - $this->log($message); - } - } -} - -$sync = new SyncJoomla(); -exit($sync->run()); +#!/usr/bin/env php + + * + * This file is part of a Moko Consulting project. + * + * SPDX-License-Identifier: GPL-3.0-or-later + * + * FILE INFORMATION + * DEFGROUP: MokoStandards.Scripts.Deploy + * INGROUP: MokoStandards + * REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform + * PATH: /deploy/sync-joomla.php + * VERSION: 01.00.00 + * BRIEF: Sync Joomla site directories between two servers via rsync over SSH + */ + +declare(strict_types=1); + +class SyncJoomla +{ + /** @var string Path to source sftp-config.json */ + private string $sourceConfig = ''; + + /** @var string Path to dest sftp-config.json */ + private string $destConfig = ''; + + /** @var bool Sync standard Joomla directories only */ + private bool $rsyncMode = false; + + /** @var bool Sync everything under remote_path */ + private bool $fullMode = false; + + /** @var bool Dry-run (preview only) */ + private bool $dryRun = false; + + /** @var bool Verbose output */ + private bool $verbose = false; + + /** @var string[] Additional exclude patterns */ + private array $excludes = []; + + /** @var string Local relay directory */ + private string $relayDir = '/tmp/sync/'; + + /** @var string[] Standard Joomla directories to sync */ + private array $joomlaDirs = [ + 'administrator/components', + 'administrator/language', + 'administrator/modules', + 'administrator/templates', + 'components', + 'language', + 'layouts', + 'libraries', + 'media', + 'modules', + 'plugins', + 'templates', + ]; + + /** + * Main entry point. + * + * @return int Exit code + */ + public function run(): int + { + $this->parseArgs(); + + if (!$this->validate()) { + return 1; + } + + $source = $this->loadConfig($this->sourceConfig); + $dest = $this->loadConfig($this->destConfig); + + if ($source === null || $dest === null) { + return 1; + } + + $this->log("Source: {$source['user']}@{$source['host']}:{$source['remote_path']}"); + $this->log("Dest: {$dest['user']}@{$dest['host']}:{$dest['remote_path']}"); + + if ($this->dryRun) { + $this->log('[DRY-RUN] No files will be transferred.'); + } + + $this->prepareRelayDir(); + + $dirs = $this->resolveDirs(); + $totalFiles = 0; + $syncedDirs = 0; + + foreach ($dirs as $dir) { + $this->log("--- Syncing: {$dir}"); + + $pulled = $this->pullFromSource($source, $dir); + if ($pulled === false) { + $this->log(" WARNING: pull failed for {$dir}, skipping."); + continue; + } + + $pushed = $this->pushToDest($dest, $dir); + if ($pushed === false) { + $this->log(" WARNING: push failed for {$dir}, skipping."); + continue; + } + + $totalFiles += $pulled + $pushed; + $syncedDirs++; + } + + $this->cleanup(); + $this->log(''); + $this->log('=== Sync Summary ==='); + $this->log("Directories synced: {$syncedDirs}/" . count($dirs)); + $this->log("Rsync operations: " . ($syncedDirs * 2) . " (pull + push)"); + + if ($this->dryRun) { + $this->log('Mode: dry-run (no files were transferred)'); + } + + return 0; + } + + /** + * Parse command-line arguments. + */ + private function parseArgs(): void + { + global $argv; + + $i = 1; + while ($i < count($argv)) { + switch ($argv[$i]) { + case '--source': + $this->sourceConfig = $argv[++$i] ?? ''; + break; + case '--dest': + $this->destConfig = $argv[++$i] ?? ''; + break; + case '--rsync': + $this->rsyncMode = true; + break; + case '--full': + $this->fullMode = true; + break; + case '--dry-run': + $this->dryRun = true; + break; + case '--verbose': + $this->verbose = true; + break; + case '--exclude': + $this->excludes[] = $argv[++$i] ?? ''; + break; + default: + $this->log("Unknown argument: {$argv[$i]}"); + break; + } + $i++; + } + } + + /** + * Validate required arguments. + * + * @return bool True if valid + */ + private function validate(): bool + { + if ($this->sourceConfig === '' || $this->destConfig === '') { + $this->log('ERROR: --source and --dest are required.'); + $this->printUsage(); + return false; + } + + if (!$this->rsyncMode && !$this->fullMode) { + $this->log('ERROR: Either --rsync or --full must be specified.'); + $this->printUsage(); + return false; + } + + if ($this->rsyncMode && $this->fullMode) { + $this->log('ERROR: --rsync and --full are mutually exclusive.'); + return false; + } + + if (!file_exists($this->sourceConfig)) { + $this->log("ERROR: Source config not found: {$this->sourceConfig}"); + return false; + } + + if (!file_exists($this->destConfig)) { + $this->log("ERROR: Dest config not found: {$this->destConfig}"); + return false; + } + + return true; + } + + /** + * Load and decode an sftp-config.json file. + * + * @param string $path Path to the config file + * @return array|null Parsed config or null on error + */ + private function loadConfig(string $path): ?array + { + $json = file_get_contents($path); + if ($json === false) { + $this->log("ERROR: Cannot read config: {$path}"); + return null; + } + + // Strip // comments (Sublime Text SFTP format) + $json = preg_replace('#^\s*//.*$#m', '', $json); + $json = preg_replace('#,\s*([\]}])#', '$1', $json); + + $config = json_decode($json, true); + if (!is_array($config)) { + $this->log("ERROR: Invalid JSON in config: {$path}"); + return null; + } + + $required = ['host', 'user', 'remote_path', 'ssh_key_file']; + foreach ($required as $key) { + if (empty($config[$key])) { + $this->log("ERROR: Missing '{$key}' in config: {$path}"); + return null; + } + } + + if (!isset($config['port'])) { + $config['port'] = 22; + } + + return $config; + } + + /** + * Resolve the list of directories to sync. + * + * @return string[] Directory paths (relative to remote_path) + */ + private function resolveDirs(): array + { + if ($this->fullMode) { + return ['.']; + } + + return $this->joomlaDirs; + } + + /** + * Prepare the local relay directory. + */ + private function prepareRelayDir(): void + { + if (is_dir($this->relayDir)) { + shell_exec("rm -rf " . escapeshellarg($this->relayDir)); + } + + mkdir($this->relayDir, 0755, true); + $this->log("Relay directory: {$this->relayDir}"); + } + + /** + * Build common rsync exclude flags. + * + * configuration.php is always excluded — it contains per-environment + * database credentials and settings that must never be synced. + * + * @return string Exclude arguments for rsync + */ + private function buildExcludes(): string + { + $excludes = ['configuration.php']; + $excludes = array_merge($excludes, $this->excludes); + + $flags = ''; + foreach ($excludes as $pattern) { + $flags .= ' --exclude=' . escapeshellarg($pattern); + } + + return $flags; + } + + /** + * Build SSH command fragment for rsync. + * + * @param array $config Server config + * @return string The -e flag value for rsync + */ + private function buildSshCmd(array $config): string + { + $keyPath = escapeshellarg($config['ssh_key_file']); + $port = (int) $config['port']; + + return "ssh -i {$keyPath} -p {$port} -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null"; + } + + /** + * Pull a directory from the source server to the local relay. + * + * @param array $config Source server config + * @param string $dir Relative directory to sync + * @return int|false Number of files or false on failure + */ + private function pullFromSource(array $config, string $dir): int|false + { + $remotePath = rtrim($config['remote_path'], '/') . '/' . ltrim($dir, './'); + $localPath = $this->relayDir . ltrim($dir, './'); + + if (!is_dir($localPath)) { + mkdir($localPath, 0755, true); + } + + $sshCmd = $this->buildSshCmd($config); + $excludes = $this->buildExcludes(); + $dryFlag = $this->dryRun ? ' --dry-run' : ''; + $verboseFlag = $this->verbose ? ' -v' : ''; + + $remote = escapeshellarg("{$config['user']}@{$config['host']}:{$remotePath}/"); + $local = escapeshellarg("{$localPath}/"); + + $cmd = "rsync -az --delete" + . $dryFlag + . $verboseFlag + . $excludes + . " -e " . escapeshellarg($sshCmd) + . " {$remote} {$local}" + . " 2>&1"; + + $this->logVerbose(" PULL: {$cmd}"); + + $output = []; + $exitCode = 0; + exec($cmd, $output, $exitCode); + + if ($exitCode !== 0) { + $this->log(" ERROR (exit {$exitCode}): " . implode("\n", $output)); + return false; + } + + $fileCount = count($output); + $this->logVerbose(" Pulled {$fileCount} line(s) of output."); + + return $fileCount; + } + + /** + * Push a directory from the local relay to the destination server. + * + * @param array $config Dest server config + * @param string $dir Relative directory to sync + * @return int|false Number of files or false on failure + */ + private function pushToDest(array $config, string $dir): int|false + { + $remotePath = rtrim($config['remote_path'], '/') . '/' . ltrim($dir, './'); + $localPath = $this->relayDir . ltrim($dir, './'); + + $sshCmd = $this->buildSshCmd($config); + $excludes = $this->buildExcludes(); + $dryFlag = $this->dryRun ? ' --dry-run' : ''; + $verboseFlag = $this->verbose ? ' -v' : ''; + + $local = escapeshellarg("{$localPath}/"); + $remote = escapeshellarg("{$config['user']}@{$config['host']}:{$remotePath}/"); + + $cmd = "rsync -az --delete" + . $dryFlag + . $verboseFlag + . $excludes + . " -e " . escapeshellarg($sshCmd) + . " {$local} {$remote}" + . " 2>&1"; + + $this->logVerbose(" PUSH: {$cmd}"); + + $output = []; + $exitCode = 0; + exec($cmd, $output, $exitCode); + + if ($exitCode !== 0) { + $this->log(" ERROR (exit {$exitCode}): " . implode("\n", $output)); + return false; + } + + $fileCount = count($output); + $this->logVerbose(" Pushed {$fileCount} line(s) of output."); + + return $fileCount; + } + + /** + * Clean up the relay directory. + */ + private function cleanup(): void + { + if (is_dir($this->relayDir)) { + shell_exec("rm -rf " . escapeshellarg($this->relayDir)); + $this->logVerbose("Cleaned up relay directory."); + } + } + + /** + * Print usage information. + */ + private function printUsage(): void + { + $this->log(''); + $this->log('Usage: sync-joomla.php --source --dest [--rsync|--full] [options]'); + $this->log(''); + $this->log('Required:'); + $this->log(' --source sftp-config.json for source server'); + $this->log(' --dest sftp-config.json for dest server'); + $this->log(' --rsync Sync standard Joomla directories'); + $this->log(' --full Sync everything under the remote path'); + $this->log(''); + $this->log('Options:'); + $this->log(' --dry-run Preview only, no files transferred'); + $this->log(' --verbose Verbose output'); + $this->log(' --exclude Additional exclude pattern (repeatable)'); + } + + /** + * Log a message to stdout. + * + * @param string $message Message to log + */ + private function log(string $message): void + { + echo $message . PHP_EOL; + } + + /** + * Log a verbose message (only when --verbose is set). + * + * @param string $message Message to log + */ + private function logVerbose(string $message): void + { + if ($this->verbose) { + $this->log($message); + } + } +} + +$sync = new SyncJoomla(); +exit($sync->run()); diff --git a/lib/Enterprise/GitPlatformAdapter.php b/lib/Enterprise/GitPlatformAdapter.php index 39cd340..b924c93 100644 --- a/lib/Enterprise/GitPlatformAdapter.php +++ b/lib/Enterprise/GitPlatformAdapter.php @@ -21,7 +21,7 @@ namespace MokoEnterprise; * Git Platform Adapter Interface * * Defines all platform operations required by MokoStandards automation. - * Implementations exist for GitHub (GitHubAdapter) and Gitea (GiteaAdapter), + * Implementations exist for GitHub (GitHubAdapter) and Gitea (MokoGiteaAdapter), * allowing scripts to work against either platform transparently. * * @package MokoStandards\Enterprise diff --git a/lib/Enterprise/GiteaAdapter.php b/lib/Enterprise/MokoGiteaAdapter.php similarity index 99% rename from lib/Enterprise/GiteaAdapter.php rename to lib/Enterprise/MokoGiteaAdapter.php index d7e6817..1d02fa6 100644 --- a/lib/Enterprise/GiteaAdapter.php +++ b/lib/Enterprise/MokoGiteaAdapter.php @@ -9,7 +9,7 @@ * DEFGROUP: MokoStandards.Enterprise.Platform * INGROUP: MokoStandards.Enterprise * REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform - * PATH: /lib/Enterprise/GiteaAdapter.php + * PATH: /lib/Enterprise/MokoGiteaAdapter.php * BRIEF: Gitea implementation of GitPlatformAdapter */ @@ -35,7 +35,7 @@ use RuntimeException; * @package MokoStandards\Enterprise * @version 04.06.10 */ -class GiteaAdapter implements GitPlatformAdapter +class MokoGiteaAdapter implements GitPlatformAdapter { private ApiClient $apiClient; private string $baseUrl; diff --git a/lib/Enterprise/PlatformAdapterFactory.php b/lib/Enterprise/PlatformAdapterFactory.php index 9e1d423..f98e2e0 100644 --- a/lib/Enterprise/PlatformAdapterFactory.php +++ b/lib/Enterprise/PlatformAdapterFactory.php @@ -51,7 +51,7 @@ class PlatformAdapterFactory return match ($platform) { 'github' => self::createGitHubAdapter($config), - 'gitea' => self::createGiteaAdapter($config), + 'gitea' => self::createMokoGiteaAdapter($config), default => throw new RuntimeException("Unsupported git platform: {$platform}. Use 'github' or 'gitea'."), }; } @@ -84,13 +84,13 @@ class PlatformAdapterFactory } /** - * Create a GiteaAdapter with configured ApiClient. + * Create a MokoGiteaAdapter with configured ApiClient. * * @param Config $config Configuration instance - * @return GiteaAdapter Configured Gitea adapter + * @return MokoGiteaAdapter Configured Gitea adapter * @throws RuntimeException If Gitea token is not available */ - private static function createGiteaAdapter(Config $config): GiteaAdapter + private static function createMokoGiteaAdapter(Config $config): MokoGiteaAdapter { $token = $config->getString('gitea.token', ''); if (empty($token)) { @@ -110,21 +110,21 @@ class PlatformAdapterFactory authScheme: 'token' ); - return new GiteaAdapter($apiClient, $apiBaseUrl); + return new MokoGiteaAdapter($apiClient, $apiBaseUrl); } /** * Create adapters for both platforms (useful during migration). * * @param Config $config Configuration instance - * @return array{github: GitHubAdapter, gitea: GiteaAdapter} Both adapters + * @return array{github: GitHubAdapter, gitea: MokoGiteaAdapter} Both adapters * @throws RuntimeException If either token is missing */ public static function createBoth(Config $config): array { return [ 'github' => self::createGitHubAdapter($config), - 'gitea' => self::createGiteaAdapter($config), + 'gitea' => self::createMokoGiteaAdapter($config), ]; } diff --git a/lib/Enterprise/RepositorySynchronizer.php b/lib/Enterprise/RepositorySynchronizer.php index 0bc8320..0ceb103 100644 --- a/lib/Enterprise/RepositorySynchronizer.php +++ b/lib/Enterprise/RepositorySynchronizer.php @@ -65,7 +65,7 @@ class RepositorySynchronizer ?GitPlatformAdapter $adapter = null ) { $this->apiClient = $apiClient; - $this->adapter = $adapter ?? new GiteaAdapter($apiClient); + $this->adapter = $adapter ?? new MokoGiteaAdapter($apiClient); $this->logger = $logger; $this->metrics = $metrics; $this->checkpoints = $checkpoints ?? new CheckpointManager('.checkpoints'); diff --git a/tests/Enterprise/GitPlatformAdapterTest.php b/tests/Enterprise/GitPlatformAdapterTest.php index 86fe6e0..f18ec34 100644 --- a/tests/Enterprise/GitPlatformAdapterTest.php +++ b/tests/Enterprise/GitPlatformAdapterTest.php @@ -19,7 +19,7 @@ use MokoEnterprise\ApiClient; use MokoEnterprise\Config; use MokoEnterprise\GitPlatformAdapter; use MokoEnterprise\GitHubAdapter; -use MokoEnterprise\GiteaAdapter; +use MokoEnterprise\MokoGiteaAdapter; use MokoEnterprise\PlatformAdapterFactory; echo "Testing GitPlatformAdapter Interface Compliance\n"; @@ -58,8 +58,8 @@ assert_true($ghAdapter->getWorkflowDir() === '.github/workflows', 'getWorkflowDi assert_true($ghAdapter->getApiClient() === $ghClient, 'getApiClient() returns injected client'); echo "\n"; -// ── Test 2: GiteaAdapter implements GitPlatformAdapter ────────────────── -echo "2. Testing GiteaAdapter interface compliance...\n"; +// ── Test 2: MokoGiteaAdapter implements GitPlatformAdapter ────────────────── +echo "2. Testing MokoGiteaAdapter interface compliance...\n"; $giteaClient = new ApiClient( baseUrl: 'https://git.mokoconsulting.tech/api/v1', @@ -67,9 +67,9 @@ $giteaClient = new ApiClient( enableCaching: false, authScheme: 'token' ); -$giteaAdapter = new GiteaAdapter($giteaClient); +$giteaAdapter = new MokoGiteaAdapter($giteaClient); -assert_true($giteaAdapter instanceof GitPlatformAdapter, 'GiteaAdapter implements GitPlatformAdapter'); +assert_true($giteaAdapter instanceof GitPlatformAdapter, 'MokoGiteaAdapter implements GitPlatformAdapter'); assert_true($giteaAdapter->getPlatformName() === 'gitea', 'getPlatformName() returns "gitea"'); assert_true($giteaAdapter->getBaseUrl() === 'https://git.mokoconsulting.tech/api/v1', 'getBaseUrl() returns Gitea API URL'); assert_true($giteaAdapter->getWorkflowDir() === '.mokogitea/workflows', 'getWorkflowDir() returns .gitea/workflows'); @@ -125,10 +125,10 @@ try { $config->set('gitea.token', 'test-gitea-token'); try { $adapter = PlatformAdapterFactory::create($config, 'gitea'); - assert_true($adapter instanceof GiteaAdapter, 'Factory creates GiteaAdapter for platform=gitea'); + assert_true($adapter instanceof MokoGiteaAdapter, 'Factory creates MokoGiteaAdapter for platform=gitea'); assert_true($adapter->getPlatformName() === 'gitea', 'Created adapter identifies as gitea'); } catch (\Exception $e) { - assert_true(false, 'Factory creates GiteaAdapter: ' . $e->getMessage()); + assert_true(false, 'Factory creates MokoGiteaAdapter: ' . $e->getMessage()); } // Test invalid platform @@ -185,9 +185,9 @@ try { assert_true(true, 'GitHubAdapter.migrateRepository() throws RuntimeException'); } -// GiteaAdapter.migrateRepository() should NOT throw (it calls the API) +// MokoGiteaAdapter.migrateRepository() should NOT throw (it calls the API) // We can't test it without a real server, but verify the method exists -assert_true(method_exists($giteaAdapter, 'migrateRepository'), 'GiteaAdapter.migrateRepository() exists'); +assert_true(method_exists($giteaAdapter, 'migrateRepository'), 'MokoGiteaAdapter.migrateRepository() exists'); echo "\n"; // ── Summary ───────────────────────────────────────────────────────────── diff --git a/validate/check_file_integrity.php b/validate/check_file_integrity.php index 97edfde..d9b1336 100644 --- a/validate/check_file_integrity.php +++ b/validate/check_file_integrity.php @@ -1,585 +1,585 @@ -#!/usr/bin/env php - - * - * This file is part of a Moko Consulting project. - * - * SPDX-License-Identifier: GPL-3.0-or-later - * - * FILE INFORMATION - * DEFGROUP: MokoStandards.Scripts.Validate - * INGROUP: MokoStandards - * REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform - * PATH: /validate/check_file_integrity.php - * VERSION: 01.00.00 - * BRIEF: Compare deployed files on a remote server against the local repository to detect drift - */ - -declare(strict_types=1); - -final class CheckFileIntegrity -{ - private string $configFile = ''; - private string $repoPath = ''; - private bool $verbose = false; - private bool $jsonOutput = false; - - /** @var array{host: string, port: int, user: string, identity: string} */ - private array $sftpConfig = []; - - public function run(): int - { - $this->parseArgs(); - - if ($this->configFile === '') - { - $this->log('ERROR: --config is required.'); - $this->printUsage(); - return 1; - } - - if ($this->repoPath === '') - { - $this->repoPath = getcwd() ?: '.'; - } - - $this->repoPath = rtrim($this->repoPath, '/\\'); - - // Load SFTP config - if (!$this->loadConfig()) - { - return 1; - } - - // Read manifest - $manifest = $this->findManifest(); - - if ($manifest === null) - { - $this->log('ERROR: No Joomla XML manifest found in repo.'); - return 1; - } - - $this->log("Manifest: {$manifest['file']}"); - $this->log("Extension type: {$manifest['type']}"); - $this->log("Extension name: {$manifest['name']}"); - - // Build deploy mappings - $mappings = $this->buildDeployMappings($manifest); - - if (count($mappings) === 0) - { - $this->log('ERROR: No deploy mappings could be determined from manifest.'); - return 1; - } - - if ($this->verbose) - { - $this->log(''); - $this->log('Deploy mappings:'); - - foreach ($mappings as $mapping) - { - $this->log(" Local: {$mapping['local']} -> Remote: {$mapping['remote']}"); - } - - $this->log(''); - } - - // Run rsync dry-run for each mapping - $totalFiles = 0; - $matchCount = 0; - $differCount = 0; - $serverOnly = []; - $repoOnly = []; - $differing = []; - - foreach ($mappings as $mapping) - { - $localPath = $mapping['local']; - $remotePath = $mapping['remote']; - - if (!is_dir($localPath)) - { - if ($this->verbose) - { - $this->log("SKIP: Local path does not exist: {$localPath}"); - } - - continue; - } - - $result = $this->rsyncDryRun($localPath, $remotePath); - - if ($result === null) - { - $this->log("WARNING: rsync failed for mapping {$localPath} -> {$remotePath}"); - continue; - } - - $totalFiles += $result['total']; - $matchCount += $result['match']; - $differCount += $result['differ']; - $serverOnly = array_merge($serverOnly, $result['server_only']); - $repoOnly = array_merge($repoOnly, $result['repo_only']); - $differing = array_merge($differing, $result['differing']); - } - - // Output results - $summary = [ - 'total_files' => $totalFiles, - 'match' => $matchCount, - 'differ' => $differCount, - 'server_only' => count($serverOnly), - 'repo_only' => count($repoOnly), - 'details' => [ - 'server_only_files' => $serverOnly, - 'repo_only_files' => $repoOnly, - 'differing_files' => $differing, - ], - ]; - - if ($this->jsonOutput) - { - fwrite(STDOUT, json_encode($summary, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES) . PHP_EOL); - } - else - { - $this->log(''); - $this->log('=== FILE INTEGRITY REPORT ==='); - $this->log(''); - $this->log(sprintf('Total files checked: %d', $totalFiles)); - $this->log(sprintf('Matching: %d', $matchCount)); - $this->log(sprintf('Differing: %d', $differCount)); - $this->log(sprintf('Server-only: %d', count($serverOnly))); - $this->log(sprintf('Repo-only: %d', count($repoOnly))); - - if ($this->verbose && count($differing) > 0) - { - $this->log(''); - $this->log('Differing files:'); - - foreach ($differing as $f) - { - $this->log(" [CHANGED] {$f}"); - } - } - - if ($this->verbose && count($serverOnly) > 0) - { - $this->log(''); - $this->log('Server-only files (not in repo):'); - - foreach ($serverOnly as $f) - { - $this->log(" [SERVER] {$f}"); - } - } - - if ($this->verbose && count($repoOnly) > 0) - { - $this->log(''); - $this->log('Repo-only files (not on server):'); - - foreach ($repoOnly as $f) - { - $this->log(" [REPO] {$f}"); - } - } - - $this->log(''); - } - - $hasDrift = $differCount > 0 || count($serverOnly) > 0 || count($repoOnly) > 0; - - if ($hasDrift) - { - $this->log('RESULT: Drift detected.'); - return 1; - } - - $this->log('RESULT: Clean. No drift detected.'); - - return 0; - } - - private function parseArgs(): void - { - $args = $_SERVER['argv'] ?? []; - $count = count($args); - - for ($i = 1; $i < $count; $i++) - { - switch ($args[$i]) - { - case '--config': - $this->configFile = $args[++$i] ?? ''; - break; - case '--repo-path': - $this->repoPath = $args[++$i] ?? ''; - break; - case '--verbose': - case '-v': - $this->verbose = true; - break; - case '--json': - $this->jsonOutput = true; - break; - case '--help': - case '-h': - $this->printUsage(); - exit(0); - default: - $this->log("WARNING: Unknown argument: {$args[$i]}"); - break; - } - } - } - - private function printUsage(): void - { - $this->log('Usage: check_file_integrity.php --config [options]'); - $this->log(''); - $this->log('Options:'); - $this->log(' --config SFTP config JSON (host, port, user, identity)'); - $this->log(' --repo-path Local repo path (default: current directory)'); - $this->log(' --verbose, -v Show detailed file-by-file output'); - $this->log(' --json Output results as JSON'); - $this->log(' --help, -h Show this help'); - } - - private function loadConfig(): bool - { - if (!file_exists($this->configFile)) - { - $this->log("ERROR: Config file not found: {$this->configFile}"); - return false; - } - - $content = file_get_contents($this->configFile); - $data = json_decode($content, true); - - if (!is_array($data)) - { - $this->log('ERROR: Config file is not valid JSON.'); - return false; - } - - $host = $data['host'] ?? $data['sftp_host'] ?? ''; - $port = (int) ($data['port'] ?? $data['sftp_port'] ?? 22); - $user = $data['user'] ?? $data['sftp_user'] ?? $data['username'] ?? ''; - $identity = $data['identity'] ?? $data['ssh_key_file'] ?? $data['key'] ?? ''; - - if ($host === '' || $user === '') - { - $this->log('ERROR: Config must contain at least "host" and "user".'); - return false; - } - - $this->sftpConfig = [ - 'host' => $host, - 'port' => $port, - 'user' => $user, - 'identity' => $identity, - ]; - - $this->log("Server: {$user}@{$host}:{$port}"); - - return true; - } - - private function findManifest(): ?array - { - $srcDir = $this->repoPath . '/src'; - $searchDirs = is_dir($srcDir) ? [$srcDir] : [$this->repoPath]; - - foreach ($searchDirs as $dir) - { - $files = glob($dir . '/*.xml'); - - if ($files === false) - { - continue; - } - - foreach ($files as $xmlFile) - { - $content = file_get_contents($xmlFile); - - if ($content === false) - { - continue; - } - - libxml_use_internal_errors(true); - $xml = simplexml_load_string($content); - libxml_clear_errors(); - - if ($xml === false) - { - continue; - } - - $rootName = $xml->getName(); - - if ($rootName !== 'extension') - { - continue; - } - - $type = (string) ($xml['type'] ?? ''); - $extName = (string) ($xml->name ?? basename($xmlFile, '.xml')); - $element = (string) ($xml->element ?? $extName); - - return [ - 'file' => $xmlFile, - 'type' => $type, - 'name' => $extName, - 'element' => $element, - 'xml' => $xml, - ]; - } - } - - return null; - } - - private function buildDeployMappings(array $manifest): array - { - $type = $manifest['type']; - $element = strtolower($manifest['element']); - $xml = $manifest['xml']; - $srcDir = $this->repoPath . '/src'; - - if (!is_dir($srcDir)) - { - $srcDir = $this->repoPath; - } - - $mappings = []; - - switch ($type) - { - case 'template': - $client = (string) ($xml['client'] ?? 'site'); - $basePath = $client === 'administrator' - ? '/administrator/templates/' . $element - : '/templates/' . $element; - - $mappings[] = [ - 'local' => $srcDir, - 'remote' => $basePath, - ]; - break; - - case 'component': - $mappings[] = [ - 'local' => $srcDir . '/admin', - 'remote' => '/administrator/components/' . $element, - ]; - $mappings[] = [ - 'local' => $srcDir . '/site', - 'remote' => '/components/' . $element, - ]; - - if (is_dir($srcDir . '/media')) - { - $mappings[] = [ - 'local' => $srcDir . '/media', - 'remote' => '/media/' . $element, - ]; - } - break; - - case 'plugin': - $group = (string) ($xml['group'] ?? 'system'); - $pluginName = str_replace('plg_' . $group . '_', '', $element); - $mappings[] = [ - 'local' => $srcDir, - 'remote' => '/plugins/' . $group . '/' . $pluginName, - ]; - break; - - case 'module': - $client = (string) ($xml['client'] ?? 'site'); - $basePath = $client === 'administrator' - ? '/administrator/modules/' . $element - : '/modules/' . $element; - - $mappings[] = [ - 'local' => $srcDir, - 'remote' => $basePath, - ]; - break; - - default: - // Generic fallback: src -> extension root - $mappings[] = [ - 'local' => $srcDir, - 'remote' => '/templates/' . $element, - ]; - break; - } - - return $mappings; - } - - /** - * @return array{total: int, match: int, differ: int, server_only: string[], repo_only: string[], differing: string[]}|null - */ - private function rsyncDryRun(string $localPath, string $remotePath): ?array - { - $localPath = rtrim($localPath, '/') . '/'; - $remotePath = rtrim($remotePath, '/') . '/'; - - $sshCmd = "ssh -p {$this->sftpConfig['port']}"; - - if ($this->sftpConfig['identity'] !== '') - { - $sshCmd .= ' -i ' . escapeshellarg($this->sftpConfig['identity']); - } - - $sshCmd .= ' -o StrictHostKeyChecking=no -o BatchMode=yes'; - - $remoteSpec = "{$this->sftpConfig['user']}@{$this->sftpConfig['host']}:{$remotePath}"; - - // Rsync from server to local (dry-run) to detect differences - $cmd = sprintf( - 'rsync -avrc --dry-run --itemize-changes -e %s %s %s 2>&1', - escapeshellarg($sshCmd), - escapeshellarg($remoteSpec), - escapeshellarg($localPath) - ); - - if ($this->verbose) - { - $this->log("Running: {$cmd}"); - } - - $output = []; - $exitCode = 0; - exec($cmd, $output, $exitCode); - - // Also run in reverse to find repo-only files - $cmdReverse = sprintf( - 'rsync -avrc --dry-run --itemize-changes -e %s %s %s 2>&1', - escapeshellarg($sshCmd), - escapeshellarg($localPath), - escapeshellarg($remoteSpec) - ); - - $outputReverse = []; - $exitCodeReverse = 0; - exec($cmdReverse, $outputReverse, $exitCodeReverse); - - // Parse itemize-changes output - $serverOnly = []; - $differing = []; - $repoOnly = []; - $totalTracked = 0; - - foreach ($output as $line) - { - $line = trim($line); - - // Itemize format: YXcstpoguax filename - if (strlen($line) < 12 || $line[0] === ' ') - { - continue; - } - - // Skip summary lines - if (preg_match('/^(sending|receiving|sent|total|$)/', $line)) - { - continue; - } - - if (!preg_match('/^([<>ch.*][fdLDS][\.\+\?cstTpoguax]{9})\s+(.+)$/', $line, $matches)) - { - continue; - } - - $flags = $matches[1]; - $filename = $matches[2]; - - // Skip directories - if ($flags[1] === 'd') - { - continue; - } - - $totalTracked++; - - $updateType = $flags[0]; - - if ($updateType === '<' || $updateType === '>') - { - // File exists on source but differs or is new - if ($flags[2] === '+') - { - // New file (only on server side for forward rsync) - $serverOnly[] = $filename; - } - else - { - $differing[] = $filename; - } - } - elseif ($updateType === 'c') - { - $differing[] = $filename; - } - } - - // Parse reverse output for repo-only files - foreach ($outputReverse as $line) - { - $line = trim($line); - - if (!preg_match('/^([<>ch.*][fdLDS][\.\+\?cstTpoguax]{9})\s+(.+)$/', $line, $matches)) - { - continue; - } - - $flags = $matches[1]; - $filename = $matches[2]; - - if ($flags[1] === 'd') - { - continue; - } - - if ($flags[2] === '+') - { - $repoOnly[] = $filename; - } - } - - // Deduplicate - $differing = array_unique($differing); - $serverOnly = array_unique($serverOnly); - $repoOnly = array_unique($repoOnly); - - $differCount = count($differing); - $serverOnlyCount = count($serverOnly); - $repoOnlyCount = count($repoOnly); - $matchCount = max(0, $totalTracked - $differCount - $serverOnlyCount); - - return [ - 'total' => $totalTracked, - 'match' => $matchCount, - 'differ' => $differCount, - 'server_only' => $serverOnly, - 'repo_only' => $repoOnly, - 'differing' => $differing, - ]; - } - - private function log(string $message): void - { - fwrite(STDERR, $message . PHP_EOL); - } -} - -$app = new CheckFileIntegrity(); -exit($app->run()); +#!/usr/bin/env php + + * + * This file is part of a Moko Consulting project. + * + * SPDX-License-Identifier: GPL-3.0-or-later + * + * FILE INFORMATION + * DEFGROUP: MokoStandards.Scripts.Validate + * INGROUP: MokoStandards + * REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform + * PATH: /validate/check_file_integrity.php + * VERSION: 01.00.00 + * BRIEF: Compare deployed files on a remote server against the local repository to detect drift + */ + +declare(strict_types=1); + +final class CheckFileIntegrity +{ + private string $configFile = ''; + private string $repoPath = ''; + private bool $verbose = false; + private bool $jsonOutput = false; + + /** @var array{host: string, port: int, user: string, identity: string} */ + private array $sftpConfig = []; + + public function run(): int + { + $this->parseArgs(); + + if ($this->configFile === '') + { + $this->log('ERROR: --config is required.'); + $this->printUsage(); + return 1; + } + + if ($this->repoPath === '') + { + $this->repoPath = getcwd() ?: '.'; + } + + $this->repoPath = rtrim($this->repoPath, '/\\'); + + // Load SFTP config + if (!$this->loadConfig()) + { + return 1; + } + + // Read manifest + $manifest = $this->findManifest(); + + if ($manifest === null) + { + $this->log('ERROR: No Joomla XML manifest found in repo.'); + return 1; + } + + $this->log("Manifest: {$manifest['file']}"); + $this->log("Extension type: {$manifest['type']}"); + $this->log("Extension name: {$manifest['name']}"); + + // Build deploy mappings + $mappings = $this->buildDeployMappings($manifest); + + if (count($mappings) === 0) + { + $this->log('ERROR: No deploy mappings could be determined from manifest.'); + return 1; + } + + if ($this->verbose) + { + $this->log(''); + $this->log('Deploy mappings:'); + + foreach ($mappings as $mapping) + { + $this->log(" Local: {$mapping['local']} -> Remote: {$mapping['remote']}"); + } + + $this->log(''); + } + + // Run rsync dry-run for each mapping + $totalFiles = 0; + $matchCount = 0; + $differCount = 0; + $serverOnly = []; + $repoOnly = []; + $differing = []; + + foreach ($mappings as $mapping) + { + $localPath = $mapping['local']; + $remotePath = $mapping['remote']; + + if (!is_dir($localPath)) + { + if ($this->verbose) + { + $this->log("SKIP: Local path does not exist: {$localPath}"); + } + + continue; + } + + $result = $this->rsyncDryRun($localPath, $remotePath); + + if ($result === null) + { + $this->log("WARNING: rsync failed for mapping {$localPath} -> {$remotePath}"); + continue; + } + + $totalFiles += $result['total']; + $matchCount += $result['match']; + $differCount += $result['differ']; + $serverOnly = array_merge($serverOnly, $result['server_only']); + $repoOnly = array_merge($repoOnly, $result['repo_only']); + $differing = array_merge($differing, $result['differing']); + } + + // Output results + $summary = [ + 'total_files' => $totalFiles, + 'match' => $matchCount, + 'differ' => $differCount, + 'server_only' => count($serverOnly), + 'repo_only' => count($repoOnly), + 'details' => [ + 'server_only_files' => $serverOnly, + 'repo_only_files' => $repoOnly, + 'differing_files' => $differing, + ], + ]; + + if ($this->jsonOutput) + { + fwrite(STDOUT, json_encode($summary, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES) . PHP_EOL); + } + else + { + $this->log(''); + $this->log('=== FILE INTEGRITY REPORT ==='); + $this->log(''); + $this->log(sprintf('Total files checked: %d', $totalFiles)); + $this->log(sprintf('Matching: %d', $matchCount)); + $this->log(sprintf('Differing: %d', $differCount)); + $this->log(sprintf('Server-only: %d', count($serverOnly))); + $this->log(sprintf('Repo-only: %d', count($repoOnly))); + + if ($this->verbose && count($differing) > 0) + { + $this->log(''); + $this->log('Differing files:'); + + foreach ($differing as $f) + { + $this->log(" [CHANGED] {$f}"); + } + } + + if ($this->verbose && count($serverOnly) > 0) + { + $this->log(''); + $this->log('Server-only files (not in repo):'); + + foreach ($serverOnly as $f) + { + $this->log(" [SERVER] {$f}"); + } + } + + if ($this->verbose && count($repoOnly) > 0) + { + $this->log(''); + $this->log('Repo-only files (not on server):'); + + foreach ($repoOnly as $f) + { + $this->log(" [REPO] {$f}"); + } + } + + $this->log(''); + } + + $hasDrift = $differCount > 0 || count($serverOnly) > 0 || count($repoOnly) > 0; + + if ($hasDrift) + { + $this->log('RESULT: Drift detected.'); + return 1; + } + + $this->log('RESULT: Clean. No drift detected.'); + + return 0; + } + + private function parseArgs(): void + { + $args = $_SERVER['argv'] ?? []; + $count = count($args); + + for ($i = 1; $i < $count; $i++) + { + switch ($args[$i]) + { + case '--config': + $this->configFile = $args[++$i] ?? ''; + break; + case '--repo-path': + $this->repoPath = $args[++$i] ?? ''; + break; + case '--verbose': + case '-v': + $this->verbose = true; + break; + case '--json': + $this->jsonOutput = true; + break; + case '--help': + case '-h': + $this->printUsage(); + exit(0); + default: + $this->log("WARNING: Unknown argument: {$args[$i]}"); + break; + } + } + } + + private function printUsage(): void + { + $this->log('Usage: check_file_integrity.php --config [options]'); + $this->log(''); + $this->log('Options:'); + $this->log(' --config SFTP config JSON (host, port, user, identity)'); + $this->log(' --repo-path Local repo path (default: current directory)'); + $this->log(' --verbose, -v Show detailed file-by-file output'); + $this->log(' --json Output results as JSON'); + $this->log(' --help, -h Show this help'); + } + + private function loadConfig(): bool + { + if (!file_exists($this->configFile)) + { + $this->log("ERROR: Config file not found: {$this->configFile}"); + return false; + } + + $content = file_get_contents($this->configFile); + $data = json_decode($content, true); + + if (!is_array($data)) + { + $this->log('ERROR: Config file is not valid JSON.'); + return false; + } + + $host = $data['host'] ?? $data['sftp_host'] ?? ''; + $port = (int) ($data['port'] ?? $data['sftp_port'] ?? 22); + $user = $data['user'] ?? $data['sftp_user'] ?? $data['username'] ?? ''; + $identity = $data['identity'] ?? $data['ssh_key_file'] ?? $data['key'] ?? ''; + + if ($host === '' || $user === '') + { + $this->log('ERROR: Config must contain at least "host" and "user".'); + return false; + } + + $this->sftpConfig = [ + 'host' => $host, + 'port' => $port, + 'user' => $user, + 'identity' => $identity, + ]; + + $this->log("Server: {$user}@{$host}:{$port}"); + + return true; + } + + private function findManifest(): ?array + { + $srcDir = $this->repoPath . '/src'; + $searchDirs = is_dir($srcDir) ? [$srcDir] : [$this->repoPath]; + + foreach ($searchDirs as $dir) + { + $files = glob($dir . '/*.xml'); + + if ($files === false) + { + continue; + } + + foreach ($files as $xmlFile) + { + $content = file_get_contents($xmlFile); + + if ($content === false) + { + continue; + } + + libxml_use_internal_errors(true); + $xml = simplexml_load_string($content); + libxml_clear_errors(); + + if ($xml === false) + { + continue; + } + + $rootName = $xml->getName(); + + if ($rootName !== 'extension') + { + continue; + } + + $type = (string) ($xml['type'] ?? ''); + $extName = (string) ($xml->name ?? basename($xmlFile, '.xml')); + $element = (string) ($xml->element ?? $extName); + + return [ + 'file' => $xmlFile, + 'type' => $type, + 'name' => $extName, + 'element' => $element, + 'xml' => $xml, + ]; + } + } + + return null; + } + + private function buildDeployMappings(array $manifest): array + { + $type = $manifest['type']; + $element = strtolower($manifest['element']); + $xml = $manifest['xml']; + $srcDir = $this->repoPath . '/src'; + + if (!is_dir($srcDir)) + { + $srcDir = $this->repoPath; + } + + $mappings = []; + + switch ($type) + { + case 'template': + $client = (string) ($xml['client'] ?? 'site'); + $basePath = $client === 'administrator' + ? '/administrator/templates/' . $element + : '/templates/' . $element; + + $mappings[] = [ + 'local' => $srcDir, + 'remote' => $basePath, + ]; + break; + + case 'component': + $mappings[] = [ + 'local' => $srcDir . '/admin', + 'remote' => '/administrator/components/' . $element, + ]; + $mappings[] = [ + 'local' => $srcDir . '/site', + 'remote' => '/components/' . $element, + ]; + + if (is_dir($srcDir . '/media')) + { + $mappings[] = [ + 'local' => $srcDir . '/media', + 'remote' => '/media/' . $element, + ]; + } + break; + + case 'plugin': + $group = (string) ($xml['group'] ?? 'system'); + $pluginName = str_replace('plg_' . $group . '_', '', $element); + $mappings[] = [ + 'local' => $srcDir, + 'remote' => '/plugins/' . $group . '/' . $pluginName, + ]; + break; + + case 'module': + $client = (string) ($xml['client'] ?? 'site'); + $basePath = $client === 'administrator' + ? '/administrator/modules/' . $element + : '/modules/' . $element; + + $mappings[] = [ + 'local' => $srcDir, + 'remote' => $basePath, + ]; + break; + + default: + // Generic fallback: src -> extension root + $mappings[] = [ + 'local' => $srcDir, + 'remote' => '/templates/' . $element, + ]; + break; + } + + return $mappings; + } + + /** + * @return array{total: int, match: int, differ: int, server_only: string[], repo_only: string[], differing: string[]}|null + */ + private function rsyncDryRun(string $localPath, string $remotePath): ?array + { + $localPath = rtrim($localPath, '/') . '/'; + $remotePath = rtrim($remotePath, '/') . '/'; + + $sshCmd = "ssh -p {$this->sftpConfig['port']}"; + + if ($this->sftpConfig['identity'] !== '') + { + $sshCmd .= ' -i ' . escapeshellarg($this->sftpConfig['identity']); + } + + $sshCmd .= ' -o StrictHostKeyChecking=no -o BatchMode=yes'; + + $remoteSpec = "{$this->sftpConfig['user']}@{$this->sftpConfig['host']}:{$remotePath}"; + + // Rsync from server to local (dry-run) to detect differences + $cmd = sprintf( + 'rsync -avrc --dry-run --itemize-changes -e %s %s %s 2>&1', + escapeshellarg($sshCmd), + escapeshellarg($remoteSpec), + escapeshellarg($localPath) + ); + + if ($this->verbose) + { + $this->log("Running: {$cmd}"); + } + + $output = []; + $exitCode = 0; + exec($cmd, $output, $exitCode); + + // Also run in reverse to find repo-only files + $cmdReverse = sprintf( + 'rsync -avrc --dry-run --itemize-changes -e %s %s %s 2>&1', + escapeshellarg($sshCmd), + escapeshellarg($localPath), + escapeshellarg($remoteSpec) + ); + + $outputReverse = []; + $exitCodeReverse = 0; + exec($cmdReverse, $outputReverse, $exitCodeReverse); + + // Parse itemize-changes output + $serverOnly = []; + $differing = []; + $repoOnly = []; + $totalTracked = 0; + + foreach ($output as $line) + { + $line = trim($line); + + // Itemize format: YXcstpoguax filename + if (strlen($line) < 12 || $line[0] === ' ') + { + continue; + } + + // Skip summary lines + if (preg_match('/^(sending|receiving|sent|total|$)/', $line)) + { + continue; + } + + if (!preg_match('/^([<>ch.*][fdLDS][\.\+\?cstTpoguax]{9})\s+(.+)$/', $line, $matches)) + { + continue; + } + + $flags = $matches[1]; + $filename = $matches[2]; + + // Skip directories + if ($flags[1] === 'd') + { + continue; + } + + $totalTracked++; + + $updateType = $flags[0]; + + if ($updateType === '<' || $updateType === '>') + { + // File exists on source but differs or is new + if ($flags[2] === '+') + { + // New file (only on server side for forward rsync) + $serverOnly[] = $filename; + } + else + { + $differing[] = $filename; + } + } + elseif ($updateType === 'c') + { + $differing[] = $filename; + } + } + + // Parse reverse output for repo-only files + foreach ($outputReverse as $line) + { + $line = trim($line); + + if (!preg_match('/^([<>ch.*][fdLDS][\.\+\?cstTpoguax]{9})\s+(.+)$/', $line, $matches)) + { + continue; + } + + $flags = $matches[1]; + $filename = $matches[2]; + + if ($flags[1] === 'd') + { + continue; + } + + if ($flags[2] === '+') + { + $repoOnly[] = $filename; + } + } + + // Deduplicate + $differing = array_unique($differing); + $serverOnly = array_unique($serverOnly); + $repoOnly = array_unique($repoOnly); + + $differCount = count($differing); + $serverOnlyCount = count($serverOnly); + $repoOnlyCount = count($repoOnly); + $matchCount = max(0, $totalTracked - $differCount - $serverOnlyCount); + + return [ + 'total' => $totalTracked, + 'match' => $matchCount, + 'differ' => $differCount, + 'server_only' => $serverOnly, + 'repo_only' => $repoOnly, + 'differing' => $differing, + ]; + } + + private function log(string $message): void + { + fwrite(STDERR, $message . PHP_EOL); + } +} + +$app = new CheckFileIntegrity(); +exit($app->run()); -- 2.52.0 From d37b547e874f385aac7b489e6f181ab965358bbd Mon Sep 17 00:00:00 2001 From: Jonathan Miller Date: Thu, 21 May 2026 17:18:03 -0500 Subject: [PATCH 06/12] refactor: manifest_read.php uses manifest.xml as primary lookup Priority order: manifest.xml > .manifest.xml (legacy) > .moko-platform (v4) Co-Authored-By: Claude Opus 4.6 (1M context) --- cli/manifest_read.php | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/cli/manifest_read.php b/cli/manifest_read.php index 19444c9..d5837f9 100644 --- a/cli/manifest_read.php +++ b/cli/manifest_read.php @@ -47,11 +47,11 @@ foreach ($argv as $i => $arg) { $root = realpath($path) ?: $path; $manifestFile = null; -// Priority: .manifest.xml > .moko-platform (backward compat) +// Priority: manifest.xml (current standard) $candidates = [ - "{$root}/.mokogitea/.manifest.xml", - "{$root}/.mokogitea/.moko-platform", - "{$root}/.mokogitea/.mokostandards", // legacy v4 + "{$root}/.mokogitea/manifest.xml", + "{$root}/.mokogitea/.manifest.xml", // legacy (dot-prefixed) + "{$root}/.mokogitea/.moko-platform", // legacy v4 ]; foreach ($candidates as $candidate) { -- 2.52.0 From 3d1af376a047ca3f9e0d6a80d8c768b66c9c8c5c Mon Sep 17 00:00:00 2001 From: Jonathan Miller Date: Thu, 21 May 2026 21:25:04 -0500 Subject: [PATCH 07/12] feat(cli): add release automation CLI scripts New CLI scripts to replace inline bash in CI workflows: - changelog_promote.php: promote [Unreleased] to versioned entry - badge_update.php: update [VERSION: XX.XX.XX] in markdown files - updates_xml_build.php: generate Joomla updates.xml with stability suffixes - package_build.php: build ZIP/tar.gz install packages - release_manage.php: create/update/delete Gitea releases + upload assets - release_cascade.php: delete lesser pre-release channels Updated version_set_platform.php with --stability flag to append -dev/-alpha/-beta/-rc suffixes to non-stable versions. Authored-by: Moko Consulting Co-Authored-By: Claude Opus 4.6 (1M context) --- .gitignore | 1 + cli/badge_update.php | 68 +++++++ cli/changelog_promote.php | 82 +++++++++ cli/package_build.php | 288 ++++++++++++++++++++++++++++++ cli/release_cascade.php | 116 ++++++++++++ cli/release_manage.php | 239 +++++++++++++++++++++++++ cli/updates_xml_build.php | 334 +++++++++++++++++++++++++++++++++++ cli/version_set_platform.php | 28 ++- 8 files changed, 1155 insertions(+), 1 deletion(-) create mode 100644 cli/badge_update.php create mode 100644 cli/changelog_promote.php create mode 100644 cli/package_build.php create mode 100644 cli/release_cascade.php create mode 100644 cli/release_manage.php create mode 100644 cli/updates_xml_build.php diff --git a/.gitignore b/.gitignore index 6a600a0..9af5194 100644 --- a/.gitignore +++ b/.gitignore @@ -687,6 +687,7 @@ modulebuilder.txt !/bin/moko /cache/* /cli/* +!/cli/*.php /components/com_ajax/* /components/com_banners/* /components/com_config/* diff --git a/cli/badge_update.php b/cli/badge_update.php new file mode 100644 index 0000000..87a1e8b --- /dev/null +++ b/cli/badge_update.php @@ -0,0 +1,68 @@ +#!/usr/bin/env php + + * + * SPDX-License-Identifier: GPL-3.0-or-later + * + * FILE INFORMATION + * DEFGROUP: MokoStandards.CLI + * INGROUP: MokoStandards + * REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform + * PATH: /cli/badge_update.php + * BRIEF: Update [VERSION: XX.XX.XX] badges in all markdown files + * + * Usage: + * php badge_update.php --path /repo --version 04.01.00 + */ + +declare(strict_types=1); + +$path = '.'; +$version = null; + +foreach ($argv as $i => $arg) { + if ($arg === '--path' && isset($argv[$i + 1])) $path = $argv[$i + 1]; + if ($arg === '--version' && isset($argv[$i + 1])) $version = $argv[$i + 1]; +} + +if ($version === null) { + fwrite(STDERR, "Usage: badge_update.php --path . --version XX.YY.ZZ\n"); + exit(1); +} + +$root = realpath($path) ?: $path; +$pattern = '/\[VERSION:\s*\d{2}\.\d{2}\.\d{2}\]/'; +$replacement = "[VERSION: {$version}]"; +$updated = 0; + +$iterator = new RecursiveIteratorIterator( + new RecursiveDirectoryIterator($root, RecursiveDirectoryIterator::SKIP_DOTS) +); + +foreach ($iterator as $file) { + $filePath = $file->getPathname(); + + // Skip .git and vendor directories + if (preg_match('#[/\\\\](\.git|vendor)[/\\\\]#', $filePath)) { + continue; + } + + // Only process markdown files + if (!preg_match('/\.md$/i', $filePath)) { + continue; + } + + $content = file_get_contents($filePath); + if (preg_match($pattern, $content)) { + $newContent = preg_replace($pattern, $replacement, $content); + if ($newContent !== $content) { + file_put_contents($filePath, $newContent); + $relative = str_replace($root . DIRECTORY_SEPARATOR, '', $filePath); + echo "Updated: {$relative}\n"; + $updated++; + } + } +} + +echo "Updated {$updated} file(s) to {$replacement}\n"; +exit(0); diff --git a/cli/changelog_promote.php b/cli/changelog_promote.php new file mode 100644 index 0000000..1419a15 --- /dev/null +++ b/cli/changelog_promote.php @@ -0,0 +1,82 @@ +#!/usr/bin/env php + + * + * SPDX-License-Identifier: GPL-3.0-or-later + * + * FILE INFORMATION + * DEFGROUP: MokoStandards.CLI + * INGROUP: MokoStandards + * REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform + * PATH: /cli/changelog_promote.php + * BRIEF: Promote [Unreleased] section in CHANGELOG.md to a versioned entry + * + * Usage: + * php changelog_promote.php --path /repo --version 04.01.00 + * php changelog_promote.php --path /repo --version 04.01.00 --date 2026-05-21 + */ + +declare(strict_types=1); + +$path = '.'; +$version = null; +$date = date('Y-m-d'); + +foreach ($argv as $i => $arg) { + if ($arg === '--path' && isset($argv[$i + 1])) $path = $argv[$i + 1]; + if ($arg === '--version' && isset($argv[$i + 1])) $version = $argv[$i + 1]; + if ($arg === '--date' && isset($argv[$i + 1])) $date = $argv[$i + 1]; +} + +if ($version === null) { + fwrite(STDERR, "Usage: changelog_promote.php --path . --version XX.YY.ZZ [--date YYYY-MM-DD]\n"); + exit(1); +} + +$changelog = realpath($path) . '/CHANGELOG.md'; +if (!file_exists($changelog)) { + fwrite(STDERR, "No CHANGELOG.md found at {$path}\n"); + exit(1); +} + +$content = file_get_contents($changelog); + +// Check if [Unreleased] section exists +if (!preg_match('/## \[?Unreleased\]?/i', $content)) { + fwrite(STDERR, "No [Unreleased] section found in CHANGELOG.md\n"); + exit(1); +} + +// Replace [Unreleased] with versioned entry +$content = preg_replace( + '/## \[Unreleased\]/i', + "## [{$version}] --- {$date}", + $content, + 1 +); +$content = preg_replace( + '/## Unreleased/i', + "## [{$version}] --- {$date}", + $content, + 1 +); + +// Insert new [Unreleased] section after the first heading line (# Changelog) +$lines = explode("\n", $content); +$inserted = false; +$result = []; + +foreach ($lines as $line) { + $result[] = $line; + if (!$inserted && preg_match('/^# /', $line)) { + $result[] = ''; + $result[] = '## [Unreleased]'; + $result[] = ''; + $inserted = true; + } +} + +$content = implode("\n", $result); +file_put_contents($changelog, $content); +echo "CHANGELOG promoted: [Unreleased] -> [{$version}] --- {$date}\n"; +exit(0); diff --git a/cli/package_build.php b/cli/package_build.php new file mode 100644 index 0000000..5b3f519 --- /dev/null +++ b/cli/package_build.php @@ -0,0 +1,288 @@ +#!/usr/bin/env php + + * + * SPDX-License-Identifier: GPL-3.0-or-later + * + * FILE INFORMATION + * DEFGROUP: MokoStandards.CLI + * INGROUP: MokoStandards + * REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform + * PATH: /cli/package_build.php + * BRIEF: Build ZIP and tar.gz install packages for Joomla/Dolibarr/generic projects + * + * Usage: + * php package_build.php --path /repo --version 04.01.00 + * php package_build.php --path /repo --version 04.01.00 --output-dir /tmp + * php package_build.php --path /repo --version 04.01.00 --github-output + * + * Options: + * --path Repository root (default: .) + * --version Version string (required) + * --output-dir Directory for built packages (default: /tmp) + * --type-prefix Override type prefix (e.g. plg_system_) + * --element Override element name + * --github-output Export zip_name, tar_name, sha256_zip, sha256_tar to $GITHUB_OUTPUT + * + * NOTE: Uses PHP exec() with escapeshellarg() for tar — all arguments are escaped. + */ + +declare(strict_types=1); + +$path = '.'; +$version = null; +$outputDir = '/tmp'; +$typePrefixOverride = null; +$elementOverride = null; +$githubOutput = false; + +foreach ($argv as $i => $arg) { + if ($arg === '--path' && isset($argv[$i + 1])) $path = $argv[$i + 1]; + if ($arg === '--version' && isset($argv[$i + 1])) $version = $argv[$i + 1]; + if ($arg === '--output-dir' && isset($argv[$i + 1])) $outputDir = $argv[$i + 1]; + if ($arg === '--type-prefix' && isset($argv[$i + 1])) $typePrefixOverride = $argv[$i + 1]; + if ($arg === '--element' && isset($argv[$i + 1])) $elementOverride = $argv[$i + 1]; + if ($arg === '--github-output') $githubOutput = true; +} + +if ($version === null) { + fwrite(STDERR, "Usage: package_build.php --path . --version XX.YY.ZZ [--output-dir /tmp]\n"); + exit(1); +} + +$root = realpath($path) ?: $path; + +// -- Determine source directory ----------------------------------------------- +$sourceDir = null; +foreach (['src', 'htdocs'] as $candidate) { + if (is_dir("{$root}/{$candidate}")) { + $sourceDir = "{$root}/{$candidate}"; + break; + } +} + +if ($sourceDir === null) { + fwrite(STDERR, "No src/ or htdocs/ directory found in {$root}\n"); + exit(1); +} + +// -- Determine element and type prefix from manifest -------------------------- +$extElement = $elementOverride; +$typePrefix = $typePrefixOverride ?? ''; +$extType = ''; +$isPackage = false; + +if ($extElement === null || $typePrefixOverride === null) { + // Find manifest + $manifest = null; + foreach (glob("{$sourceDir}/pkg_*.xml") ?: [] as $f) { + if (strpos(file_get_contents($f), '([^<]+)<\/element>/', $xml, $m)) $extElement = $m[1]; + elseif (preg_match('/plugin="([^"]+)"/', $xml, $m)) $extElement = $m[1]; + elseif (preg_match('/module="([^"]+)"/', $xml, $m)) $extElement = $m[1]; + else $extElement = strtolower(pathinfo($manifest, PATHINFO_FILENAME)); + } + + if (preg_match('/]*type="([^"]+)"/', $xml, $m)) $extType = $m[1]; + $extFolder = ''; + if (preg_match('/]*group="([^"]+)"/', $xml, $m)) $extFolder = $m[1]; + + if ($typePrefixOverride === null) { + switch ($extType) { + case 'plugin': $typePrefix = "plg_{$extFolder}_"; break; + case 'module': $typePrefix = 'mod_'; break; + case 'component': $typePrefix = 'com_'; break; + case 'template': $typePrefix = 'tpl_'; break; + case 'library': $typePrefix = 'lib_'; break; + case 'package': $typePrefix = 'pkg_'; break; + } + } + + $isPackage = ($extType === 'package' && is_dir("{$sourceDir}/packages")); + } +} + +if ($extElement === null) { + $extElement = strtolower(basename($root)); +} + +$zipName = "{$typePrefix}{$extElement}-{$version}.zip"; +$tarName = "{$typePrefix}{$extElement}-{$version}.tar.gz"; +$zipPath = "{$outputDir}/{$zipName}"; +$tarPath = "{$outputDir}/{$tarName}"; + +// -- Exclude patterns --------------------------------------------------------- +$excludePatterns = [ + '.ftpignore', + 'sftp-config*', + '*.ppk', + '*.pem', + '*.key', + '.env*', +]; + +// -- Build packages ----------------------------------------------------------- +if ($isPackage) { + echo "=== Building Joomla PACKAGE (multi-extension) ===\n"; + + $stagingDir = sys_get_temp_dir() . '/moko-pkg-' . uniqid(); + mkdir($stagingDir, 0755, true); + + // ZIP each sub-extension + foreach (glob("{$sourceDir}/packages/*/") ?: [] as $extDir) { + $subName = basename($extDir); + echo " Packaging sub-extension: {$subName}\n"; + + $subZip = new ZipArchive(); + $subZipPath = "{$stagingDir}/{$subName}.zip"; + if ($subZip->open($subZipPath, ZipArchive::CREATE | ZipArchive::OVERWRITE) !== true) { + fwrite(STDERR, "Failed to create ZIP for {$subName}\n"); + continue; + } + + addDirectoryToZip($subZip, $extDir, '', $excludePatterns); + $subZip->close(); + } + + // Copy package-level files + foreach (array_merge(glob("{$sourceDir}/*.xml") ?: [], glob("{$sourceDir}/*.php") ?: []) as $f) { + copy($f, "{$stagingDir}/" . basename($f)); + } + + // Create ZIP from staging + $zip = new ZipArchive(); + if ($zip->open($zipPath, ZipArchive::CREATE | ZipArchive::OVERWRITE) !== true) { + fwrite(STDERR, "Failed to create ZIP: {$zipPath}\n"); + exit(1); + } + addDirectoryToZip($zip, $stagingDir, '', []); + $zip->close(); + + // Create tar.gz — all arguments are escaped via escapeshellarg() + $tarCmd = sprintf( + 'tar -czf %s -C %s .', + escapeshellarg($tarPath), + escapeshellarg($stagingDir) + ); + passthru($tarCmd, $tarReturn); + + // Cleanup staging + $cleanCmd = sprintf('rm -rf %s', escapeshellarg($stagingDir)); + passthru($cleanCmd); + +} else { + echo "=== Building standard extension package ===\n"; + + // ZIP + $zip = new ZipArchive(); + if ($zip->open($zipPath, ZipArchive::CREATE | ZipArchive::OVERWRITE) !== true) { + fwrite(STDERR, "Failed to create ZIP: {$zipPath}\n"); + exit(1); + } + addDirectoryToZip($zip, $sourceDir, '', $excludePatterns); + $zip->close(); + + // tar.gz — all arguments are escaped via escapeshellarg() + $excludeArgs = ''; + foreach ($excludePatterns as $pattern) { + $excludeArgs .= ' --exclude=' . escapeshellarg($pattern); + } + $tarCmd = sprintf( + 'tar -czf %s -C %s%s .', + escapeshellarg($tarPath), + escapeshellarg($sourceDir), + $excludeArgs + ); + passthru($tarCmd, $tarReturn); +} + +// -- Calculate SHA-256 -------------------------------------------------------- +$sha256Zip = hash_file('sha256', $zipPath); +$sha256Tar = file_exists($tarPath) ? hash_file('sha256', $tarPath) : ''; + +$zipSize = filesize($zipPath); +$tarSize = file_exists($tarPath) ? filesize($tarPath) : 0; + +echo "\n"; +echo "ZIP: {$zipName} ({$zipSize} bytes)\n"; +echo " SHA-256: {$sha256Zip}\n"; +if ($tarSize > 0) { + echo "TAR: {$tarName} ({$tarSize} bytes)\n"; + echo " SHA-256: {$sha256Tar}\n"; +} + +// -- Export to GITHUB_OUTPUT -------------------------------------------------- +if ($githubOutput) { + $ghOutput = getenv('GITHUB_OUTPUT'); + $lines = [ + "zip_name={$zipName}", + "tar_name={$tarName}", + "zip_path={$zipPath}", + "tar_path={$tarPath}", + "sha256_zip={$sha256Zip}", + "sha256_tar={$sha256Tar}", + "type_prefix={$typePrefix}", + "ext_element={$extElement}", + ]; + if ($ghOutput) { + file_put_contents($ghOutput, implode("\n", $lines) . "\n", FILE_APPEND); + fwrite(STDERR, "Exported " . count($lines) . " fields to GITHUB_OUTPUT\n"); + } else { + foreach ($lines as $line) echo "{$line}\n"; + } +} + +exit(0); + +// ============================================================================= +// Helper: recursively add directory contents to a ZipArchive +// ============================================================================= +function addDirectoryToZip(ZipArchive $zip, string $dir, string $prefix, array $excludes): void +{ + $iterator = new RecursiveIteratorIterator( + new RecursiveDirectoryIterator($dir, RecursiveDirectoryIterator::SKIP_DOTS), + RecursiveIteratorIterator::SELF_FIRST + ); + + foreach ($iterator as $file) { + $filePath = $file->getPathname(); + $relativePath = $prefix . substr($filePath, strlen($dir) + 1); + + // Check excludes + $basename = basename($filePath); + $skip = false; + foreach ($excludes as $pattern) { + if (fnmatch($pattern, $basename)) { + $skip = true; + break; + } + } + if ($skip) continue; + + // Normalize path separators for ZIP + $relativePath = str_replace('\\', '/', $relativePath); + + if ($file->isDir()) { + $zip->addEmptyDir($relativePath); + } else { + $zip->addFile($filePath, $relativePath); + } + } +} diff --git a/cli/release_cascade.php b/cli/release_cascade.php new file mode 100644 index 0000000..5f7670a --- /dev/null +++ b/cli/release_cascade.php @@ -0,0 +1,116 @@ +#!/usr/bin/env php + + * + * SPDX-License-Identifier: GPL-3.0-or-later + * + * FILE INFORMATION + * DEFGROUP: MokoStandards.CLI + * INGROUP: MokoStandards + * REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform + * PATH: /cli/release_cascade.php + * BRIEF: Delete lesser pre-release channels from Gitea when promoting stability + * + * Usage: + * php release_cascade.php --stability stable --token TOKEN --api-base URL + * php release_cascade.php --stability rc --token TOKEN --api-base URL + * + * Cascade rules: + * stable -> deletes development, alpha, beta, release-candidate + * rc -> deletes development, alpha, beta + * beta -> deletes development, alpha + * alpha -> deletes development + */ + +declare(strict_types=1); + +$stability = null; +$token = null; +$apiBase = null; + +foreach ($argv as $i => $arg) { + if ($arg === '--stability' && isset($argv[$i + 1])) $stability = $argv[$i + 1]; + if ($arg === '--token' && isset($argv[$i + 1])) $token = $argv[$i + 1]; + if ($arg === '--api-base' && isset($argv[$i + 1])) $apiBase = $argv[$i + 1]; +} + +// Allow token from environment +if ($token === null) { + $token = getenv('GA_TOKEN') ?: getenv('GITEA_TOKEN') ?: null; +} + +if ($stability === null || $token === null || $apiBase === null) { + fwrite(STDERR, "Usage: release_cascade.php --stability [stable|rc|beta|alpha] --token TOKEN --api-base URL\n"); + fwrite(STDERR, " --api-base: e.g. https://git.mokoconsulting.tech/api/v1/repos/Org/Repo\n"); + fwrite(STDERR, " Token can also be set via GA_TOKEN or GITEA_TOKEN env var\n"); + exit(1); +} + +// Define cascade hierarchy +$cascadeMap = [ + 'stable' => ['development', 'alpha', 'beta', 'release-candidate'], + 'rc' => ['development', 'alpha', 'beta'], + 'beta' => ['development', 'alpha'], + 'alpha' => ['development'], +]; + +if (!isset($cascadeMap[$stability])) { + fwrite(STDERR, "Unknown stability level: {$stability}\n"); + fwrite(STDERR, "Valid options: stable, rc, beta, alpha\n"); + exit(1); +} + +$tagsToDelete = $cascadeMap[$stability]; +$deleted = 0; + +foreach ($tagsToDelete as $tag) { + // Get release by tag + $ch = curl_init("{$apiBase}/releases/tags/{$tag}"); + curl_setopt_array($ch, [ + CURLOPT_RETURNTRANSFER => true, + CURLOPT_HTTPHEADER => ["Authorization: token {$token}"], + CURLOPT_TIMEOUT => 30, + ]); + $response = curl_exec($ch); + $httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE); + curl_close($ch); + + if ($httpCode !== 200 || empty($response)) { + continue; + } + + $data = json_decode($response, true); + $releaseId = $data['id'] ?? null; + + if ($releaseId === null) { + continue; + } + + // Delete release + $ch = curl_init("{$apiBase}/releases/{$releaseId}"); + curl_setopt_array($ch, [ + CURLOPT_CUSTOMREQUEST => 'DELETE', + CURLOPT_RETURNTRANSFER => true, + CURLOPT_HTTPHEADER => ["Authorization: token {$token}"], + CURLOPT_TIMEOUT => 30, + ]); + curl_exec($ch); + curl_close($ch); + + // Delete tag + $ch = curl_init("{$apiBase}/tags/{$tag}"); + curl_setopt_array($ch, [ + CURLOPT_CUSTOMREQUEST => 'DELETE', + CURLOPT_RETURNTRANSFER => true, + CURLOPT_HTTPHEADER => ["Authorization: token {$token}"], + CURLOPT_TIMEOUT => 30, + ]); + curl_exec($ch); + curl_close($ch); + + echo "Deleted: {$tag} (release id: {$releaseId})\n"; + $deleted++; +} + +echo "Cleaned up {$deleted} pre-release channel(s)\n"; +exit(0); diff --git a/cli/release_manage.php b/cli/release_manage.php new file mode 100644 index 0000000..1d9eb00 --- /dev/null +++ b/cli/release_manage.php @@ -0,0 +1,239 @@ +#!/usr/bin/env php + + * + * SPDX-License-Identifier: GPL-3.0-or-later + * + * FILE INFORMATION + * DEFGROUP: MokoStandards.CLI + * INGROUP: MokoStandards + * REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform + * PATH: /cli/release_manage.php + * BRIEF: Create/update Gitea releases, upload assets, update release body + * + * Usage: + * # Create a release + * php release_manage.php --action create --tag stable --name "My Plugin 04.01.00" \ + * --body "Release notes" --target main --token TOKEN --api-base URL + * + * # Upload assets to a release + * php release_manage.php --action upload --tag stable --files "/tmp/pkg.zip,/tmp/pkg.tar.gz" \ + * --token TOKEN --api-base URL + * + * # Update release body (e.g. add SHA checksums) + * php release_manage.php --action update-body --tag stable --body "New body" \ + * --token TOKEN --api-base URL + * + * # Delete a release and its tag + * php release_manage.php --action delete --tag stable --token TOKEN --api-base URL + * + * Options: + * --action create | upload | update-body | delete (required) + * --tag Release tag name (required) + * --name Release name/title (for create) + * --body Release body/description (for create, update-body) + * --body-file Read body from file instead of --body + * --target Target branch/commitish (for create, default: main) + * --files Comma-separated file paths to upload (for upload) + * --token Gitea API token (or GA_TOKEN/GITEA_TOKEN env var) + * --api-base Gitea API base URL (e.g. https://git.mokoconsulting.tech/api/v1/repos/Org/Repo) + * + * NOTE: This script uses PHP curl for all HTTP operations (no shell calls). + */ + +declare(strict_types=1); + +$action = null; +$tag = null; +$name = null; +$body = null; +$bodyFile = null; +$target = 'main'; +$files = []; +$token = null; +$apiBase = null; + +foreach ($argv as $i => $arg) { + if ($arg === '--action' && isset($argv[$i + 1])) $action = $argv[$i + 1]; + if ($arg === '--tag' && isset($argv[$i + 1])) $tag = $argv[$i + 1]; + if ($arg === '--name' && isset($argv[$i + 1])) $name = $argv[$i + 1]; + if ($arg === '--body' && isset($argv[$i + 1])) $body = $argv[$i + 1]; + if ($arg === '--body-file' && isset($argv[$i + 1])) $bodyFile = $argv[$i + 1]; + if ($arg === '--target' && isset($argv[$i + 1])) $target = $argv[$i + 1]; + if ($arg === '--files' && isset($argv[$i + 1])) $files = array_filter(explode(',', $argv[$i + 1])); + if ($arg === '--token' && isset($argv[$i + 1])) $token = $argv[$i + 1]; + if ($arg === '--api-base' && isset($argv[$i + 1])) $apiBase = $argv[$i + 1]; +} + +// Allow token from environment +if ($token === null) { + $token = getenv('GA_TOKEN') ?: getenv('GITEA_TOKEN') ?: null; +} + +// Read body from file if specified +if ($bodyFile !== null && file_exists($bodyFile)) { + $body = file_get_contents($bodyFile); +} + +if ($action === null || $tag === null || $token === null || $apiBase === null) { + fwrite(STDERR, "Usage: release_manage.php --action [create|upload|update-body|delete] --tag TAG --token TOKEN --api-base URL\n"); + exit(1); +} + +/** + * Make a Gitea API request using curl + */ +function giteaApi(string $url, string $method, string $token, ?string $jsonBody = null, ?string $filePath = null): array +{ + $ch = curl_init($url); + $headers = ["Authorization: token {$token}"]; + + $opts = [ + CURLOPT_RETURNTRANSFER => true, + CURLOPT_TIMEOUT => 60, + CURLOPT_CUSTOMREQUEST => $method, + ]; + + if ($jsonBody !== null) { + $headers[] = 'Content-Type: application/json'; + $opts[CURLOPT_POSTFIELDS] = $jsonBody; + } elseif ($filePath !== null) { + $headers[] = 'Content-Type: application/octet-stream'; + $opts[CURLOPT_POSTFIELDS] = file_get_contents($filePath); + } + + $opts[CURLOPT_HTTPHEADER] = $headers; + curl_setopt_array($ch, $opts); + + $response = curl_exec($ch); + $httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE); + curl_close($ch); + + $data = json_decode($response ?: '{}', true) ?: []; + return ['code' => $httpCode, 'data' => $data]; +} + +/** + * Get release by tag + */ +function getReleaseByTag(string $apiBase, string $tag, string $token): ?array +{ + $result = giteaApi("{$apiBase}/releases/tags/{$tag}", 'GET', $token); + if ($result['code'] === 200 && isset($result['data']['id'])) { + return $result['data']; + } + return null; +} + +// -- Action dispatch ---------------------------------------------------------- +switch ($action) { + case 'create': + // Delete existing release if present + $existing = getReleaseByTag($apiBase, $tag, $token); + if ($existing !== null) { + $existingId = $existing['id']; + giteaApi("{$apiBase}/releases/{$existingId}", 'DELETE', $token); + giteaApi("{$apiBase}/tags/{$tag}", 'DELETE', $token); + echo "Deleted previous release: {$tag} (id: {$existingId})\n"; + } + + $payload = json_encode([ + 'tag_name' => $tag, + 'name' => $name ?? $tag, + 'body' => $body ?? '', + 'target_commitish' => $target, + ]); + + $result = giteaApi("{$apiBase}/releases", 'POST', $token, $payload); + if ($result['code'] >= 200 && $result['code'] < 300) { + $releaseId = $result['data']['id'] ?? 'unknown'; + echo "Release created: {$name} (tag: {$tag}, id: {$releaseId})\n"; + } else { + fwrite(STDERR, "Failed to create release: HTTP {$result['code']}\n"); + fwrite(STDERR, json_encode($result['data']) . "\n"); + exit(1); + } + break; + + case 'upload': + if (empty($files)) { + fwrite(STDERR, "No files specified. Use --files /path/to/file1,/path/to/file2\n"); + exit(1); + } + + $release = getReleaseByTag($apiBase, $tag, $token); + if ($release === null) { + fwrite(STDERR, "No release found for tag: {$tag}\n"); + exit(1); + } + $releaseId = $release['id']; + + // Get existing assets to avoid duplicates + $assetsResult = giteaApi("{$apiBase}/releases/{$releaseId}/assets", 'GET', $token); + $existingAssets = $assetsResult['data'] ?? []; + + foreach ($files as $filePath) { + $filePath = trim($filePath); + if (!file_exists($filePath)) { + fwrite(STDERR, "File not found: {$filePath}\n"); + continue; + } + + $fileName = basename($filePath); + + // Delete existing asset with same name + foreach ($existingAssets as $asset) { + if (($asset['name'] ?? '') === $fileName) { + giteaApi("{$apiBase}/releases/{$releaseId}/assets/{$asset['id']}", 'DELETE', $token); + echo "Deleted existing asset: {$fileName}\n"; + break; + } + } + + // Upload + $uploadUrl = "{$apiBase}/releases/{$releaseId}/assets?name=" . urlencode($fileName); + $result = giteaApi($uploadUrl, 'POST', $token, null, $filePath); + if ($result['code'] >= 200 && $result['code'] < 300) { + echo "Uploaded: {$fileName}\n"; + } else { + fwrite(STDERR, "Failed to upload {$fileName}: HTTP {$result['code']}\n"); + } + } + break; + + case 'update-body': + $release = getReleaseByTag($apiBase, $tag, $token); + if ($release === null) { + fwrite(STDERR, "No release found for tag: {$tag}\n"); + exit(1); + } + $releaseId = $release['id']; + + $payload = json_encode(['body' => $body ?? '']); + $result = giteaApi("{$apiBase}/releases/{$releaseId}", 'PATCH', $token, $payload); + if ($result['code'] >= 200 && $result['code'] < 300) { + echo "Release body updated for tag: {$tag}\n"; + } else { + fwrite(STDERR, "Failed to update body: HTTP {$result['code']}\n"); + exit(1); + } + break; + + case 'delete': + $existing = getReleaseByTag($apiBase, $tag, $token); + if ($existing !== null) { + giteaApi("{$apiBase}/releases/{$existing['id']}", 'DELETE', $token); + giteaApi("{$apiBase}/tags/{$tag}", 'DELETE', $token); + echo "Deleted: {$tag} (id: {$existing['id']})\n"; + } else { + echo "No release found for tag: {$tag}\n"; + } + break; + + default: + fwrite(STDERR, "Unknown action: {$action}\n"); + fwrite(STDERR, "Valid actions: create, upload, update-body, delete\n"); + exit(1); +} + +exit(0); diff --git a/cli/updates_xml_build.php b/cli/updates_xml_build.php new file mode 100644 index 0000000..8217a6e --- /dev/null +++ b/cli/updates_xml_build.php @@ -0,0 +1,334 @@ +#!/usr/bin/env php + + * + * SPDX-License-Identifier: GPL-3.0-or-later + * + * FILE INFORMATION + * DEFGROUP: MokoStandards.CLI + * INGROUP: MokoStandards + * REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform + * PATH: /cli/updates_xml_build.php + * BRIEF: Generate Joomla updates.xml from extension manifest metadata + * + * Usage: + * php updates_xml_build.php --path /repo --version 04.01.00 --stability stable + * php updates_xml_build.php --path /repo --version 04.01.00 --stability stable --sha SHA256 + * php updates_xml_build.php --path /repo --version 04.01.00 --stability stable --github-output + * + * Options: + * --path Repository root (default: .) + * --version Version string (required) + * --stability One of: stable, rc, beta, alpha, development (default: stable) + * --sha SHA-256 hash of the ZIP package (optional) + * --gitea-url Gitea instance URL (default: env GITEA_URL or https://git.mokoconsulting.tech) + * --org Organization (default: env GITEA_ORG) + * --repo Repository name (default: env GITEA_REPO) + * --output Output file path (default: updates.xml in --path) + * --github-output Export ext_element, ext_name, ext_type, ext_folder to $GITHUB_OUTPUT + */ + +declare(strict_types=1); + +// -- Argument parsing --------------------------------------------------------- +$path = '.'; +$version = null; +$stability = 'stable'; +$sha = null; +$giteaUrl = getenv('GITEA_URL') ?: 'https://git.mokoconsulting.tech'; +$org = getenv('GITEA_ORG') ?: ''; +$repo = getenv('GITEA_REPO') ?: ''; +$outputFile = null; +$githubOutput = false; + +foreach ($argv as $i => $arg) { + if ($arg === '--path' && isset($argv[$i + 1])) $path = $argv[$i + 1]; + if ($arg === '--version' && isset($argv[$i + 1])) $version = $argv[$i + 1]; + if ($arg === '--stability' && isset($argv[$i + 1])) $stability = $argv[$i + 1]; + if ($arg === '--sha' && isset($argv[$i + 1])) $sha = $argv[$i + 1]; + if ($arg === '--gitea-url' && isset($argv[$i + 1])) $giteaUrl = $argv[$i + 1]; + if ($arg === '--org' && isset($argv[$i + 1])) $org = $argv[$i + 1]; + if ($arg === '--repo' && isset($argv[$i + 1])) $repo = $argv[$i + 1]; + if ($arg === '--output' && isset($argv[$i + 1])) $outputFile = $argv[$i + 1]; + if ($arg === '--github-output') $githubOutput = true; +} + +if ($version === null) { + fwrite(STDERR, "Usage: updates_xml_build.php --path . --version XX.YY.ZZ [--stability stable] [--sha SHA]\n"); + exit(1); +} + +$root = realpath($path) ?: $path; + +// -- Locate Joomla manifest --------------------------------------------------- +$manifest = null; + +// Priority: pkg_*.xml in src/ > any extension XML in src/ > any in root +$candidates = glob("{$root}/src/pkg_*.xml") ?: []; +foreach ($candidates as $f) { + if (strpos(file_get_contents($f), '([^<]+)<\/name>/', $xml, $m)) $extName = $m[1]; + +$extType = ''; +if (preg_match('/]*type="([^"]+)"/', $xml, $m)) $extType = $m[1]; + +$extElement = ''; +if (preg_match('/([^<]+)<\/element>/', $xml, $m)) $extElement = $m[1]; +if (empty($extElement) && preg_match('/plugin="([^"]+)"/', $xml, $m)) $extElement = $m[1]; +if (empty($extElement) && preg_match('/module="([^"]+)"/', $xml, $m)) $extElement = $m[1]; +if (empty($extElement)) { + $fname = strtolower(pathinfo($manifest, PATHINFO_FILENAME)); + if (in_array($fname, ['templatedetails', 'manifest'])) { + $extElement = strtolower(str_replace([' ', '-'], '', $repo ?: basename($root))); + } else { + $extElement = $fname; + } +} + +$extClient = ''; +if (preg_match('/]*client="([^"]+)"/', $xml, $m)) $extClient = $m[1]; + +$extFolder = ''; +if (preg_match('/]*group="([^"]+)"/', $xml, $m)) $extFolder = $m[1]; + +$targetPlatform = ''; +if (preg_match('/()/', $xml, $m)) $targetPlatform = $m[1]; +if (empty($targetPlatform)) { + $targetPlatform = ''; +} + +$phpMinimum = ''; +if (preg_match('/([^<]+)<\/php_minimum>/', $xml, $m)) $phpMinimum = $m[1]; + +// Resolve language key names (e.g. PLG_SYSTEM_MOKOJOOMTOS) +if (preg_match('/^[A-Z_]+$/', $extName)) { + $iniFiles = []; + $iterator = new RecursiveIteratorIterator( + new RecursiveDirectoryIterator($root, RecursiveDirectoryIterator::SKIP_DOTS) + ); + foreach ($iterator as $file) { + if (preg_match('/\.sys\.ini$/i', $file->getFilename())) { + $iniFiles[] = $file->getPathname(); + } + } + foreach ($iniFiles as $ini) { + $content = file_get_contents($ini); + if (preg_match('/^' . preg_quote($extName, '/') . '="([^"]+)"/m', $content, $m)) { + $extName = $m[1]; + break; + } + } +} + +// Fallbacks +if (empty($extName)) $extName = $repo ?: basename($root); +if (empty($extType)) $extType = 'component'; + +// -- Build type prefix -------------------------------------------------------- +$typePrefix = ''; +switch ($extType) { + case 'plugin': $typePrefix = "plg_{$extFolder}_"; break; + case 'module': $typePrefix = 'mod_'; break; + case 'component': $typePrefix = 'com_'; break; + case 'template': $typePrefix = 'tpl_'; break; + case 'library': $typePrefix = 'lib_'; break; + case 'package': $typePrefix = 'pkg_'; break; +} + +// -- Export to GITHUB_OUTPUT if requested ------------------------------------- +if ($githubOutput) { + $ghOutput = getenv('GITHUB_OUTPUT'); + $lines = [ + "ext_element={$extElement}", + "ext_name={$extName}", + "ext_type={$extType}", + "ext_folder={$extFolder}", + "type_prefix={$typePrefix}", + ]; + if ($ghOutput) { + file_put_contents($ghOutput, implode("\n", $lines) . "\n", FILE_APPEND); + fwrite(STDERR, "Exported " . count($lines) . " fields to GITHUB_OUTPUT\n"); + } else { + foreach ($lines as $line) echo "{$line}\n"; + } +} + +// -- Stability suffix map ----------------------------------------------------- +$stabilitySuffixMap = [ + 'stable' => '', + 'rc' => '-rc', + 'beta' => '-beta', + 'alpha' => '-alpha', + 'development' => '-dev', +]; + +$stabilityTagMap = [ + 'stable' => 'stable', + 'rc' => 'rc', + 'beta' => 'beta', + 'alpha' => 'alpha', + 'development' => 'dev', +]; + +// -- Build update entries ----------------------------------------------------- +$releaseTag = $stabilityTagMap[$stability] ?? $stability; + +// For the primary entry: apply suffix if not stable +$primarySuffix = $stabilitySuffixMap[$stability] ?? ''; +$primaryVersion = $version . $primarySuffix; + +$downloadUrl = "{$giteaUrl}/{$org}/{$repo}/releases/download/{$releaseTag}/{$typePrefix}{$extElement}-{$primaryVersion}.zip"; +$infoUrl = "{$giteaUrl}/{$org}/{$repo}/releases/tag/{$releaseTag}"; + +// Build client tag +$clientTag = ''; +if (!empty($extClient)) { + $clientTag = " {$extClient}"; +} elseif ($extType === 'module' || $extType === 'plugin') { + $clientTag = ' site'; +} + +// Build folder tag +$folderTag = ''; +if (!empty($extFolder) && $extType === 'plugin') { + $folderTag = " {$extFolder}"; +} + +// PHP minimum tag +$phpTag = ''; +if (!empty($phpMinimum)) { + $phpTag = " {$phpMinimum}"; +} + +// SHA tag +$shaTag = ''; +if (!empty($sha)) { + $shaTag = " {$sha}"; +} + +/** + * Build a single entry for a given stability tag + */ +function buildEntry( + string $tagName, + string $entryVersion, + string $entryDownloadUrl, + string $extName, + string $extElement, + string $extType, + string $clientTag, + string $folderTag, + string $infoUrl, + string $targetPlatform, + string $phpTag, + string $shaTag +): string { + $lines = []; + $lines[] = ' '; + $lines[] = " {$extName}"; + $lines[] = " {$extName} update"; + $lines[] = " {$extElement}"; + $lines[] = " {$extType}"; + $lines[] = " {$entryVersion}"; + if (!empty($clientTag)) $lines[] = $clientTag; + if (!empty($folderTag)) $lines[] = $folderTag; + $lines[] = " {$tagName}"; + $lines[] = " {$infoUrl}"; + $lines[] = ' '; + $lines[] = " {$entryDownloadUrl}"; + $lines[] = ' '; + if (!empty($shaTag)) $lines[] = $shaTag; + $lines[] = " {$targetPlatform}"; + if (!empty($phpTag)) $lines[] = $phpTag; + $lines[] = ' Moko Consulting'; + $lines[] = ' https://mokoconsulting.tech'; + $lines[] = ' '; + return implode("\n", $lines); +} + +// -- Determine which channels to write ---------------------------------------- +// Stable cascades to all channels; pre-releases only write their level and below +// Each channel gets its own suffixed version: +// development -> 04.01.00-dev +// alpha -> 04.01.00-alpha +// beta -> 04.01.00-beta +// rc -> 04.01.00-rc +// stable -> 04.01.00 +$allChannels = ['development', 'alpha', 'beta', 'rc', 'stable']; +$stabilityIndex = array_search($stability === 'development' ? 'development' : $stability, $allChannels); +if ($stabilityIndex === false) $stabilityIndex = 4; // default to stable + +// Write entries for this stability and all below it +$entries = []; +for ($i = 0; $i <= $stabilityIndex; $i++) { + $channelName = $allChannels[$i]; + $channelSuffix = $stabilitySuffixMap[$channelName] ?? ''; + $channelVersion = $version . $channelSuffix; + $channelTag = $stabilityTagMap[$channelName] ?? $channelName; + $channelDownloadUrl = "{$giteaUrl}/{$org}/{$repo}/releases/download/{$channelTag}/{$typePrefix}{$extElement}-{$channelVersion}.zip"; + $channelInfoUrl = "{$giteaUrl}/{$org}/{$repo}/releases/tag/{$channelTag}"; + + $entries[] = buildEntry( + $channelName, + $channelVersion, + $channelDownloadUrl, + $extName, + $extElement, + $extType, + $clientTag, + $folderTag, + $channelInfoUrl, + $targetPlatform, + $phpTag, + $shaTag + ); +} + +// -- Write updates.xml -------------------------------------------------------- +$year = date('Y'); +$output = << + + + +XML; +$output .= "\n" . implode("\n", $entries) . "\n\n"; + +$dest = $outputFile ?? "{$root}/updates.xml"; +file_put_contents($dest, $output); + +$channelCount = count($entries); +echo "updates.xml: {$primaryVersion} ({$channelCount} channel(s), stability={$stability})\n"; +echo "Output: {$dest}\n"; +exit(0); diff --git a/cli/version_set_platform.php b/cli/version_set_platform.php index ea3c32a..852a669 100644 --- a/cli/version_set_platform.php +++ b/cli/version_set_platform.php @@ -10,6 +10,13 @@ * REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform * PATH: /cli/version_set_platform.php * BRIEF: Set version in platform-specific files (Dolibarr $this->version, Joomla ) + * + * Usage: + * php version_set_platform.php --path . --version 04.01.00 + * php version_set_platform.php --path . --version 04.01.00 --stability alpha + * + * When --stability is set to anything other than "stable", the suffix is + * appended to the version (e.g. 04.01.00-dev, 04.01.00-alpha, 04.01.00-rc). */ declare(strict_types=1); @@ -17,10 +24,13 @@ declare(strict_types=1); $path = '.'; $version = null; $branch = null; +$stability = 'stable'; + foreach ($argv as $i => $arg) { if ($arg === '--path' && isset($argv[$i + 1])) $path = $argv[$i + 1]; if ($arg === '--version' && isset($argv[$i + 1])) $version = $argv[$i + 1]; if ($arg === '--branch' && isset($argv[$i + 1])) $branch = $argv[$i + 1]; + if ($arg === '--stability' && isset($argv[$i + 1])) $stability = $argv[$i + 1]; } // Auto-detect branch from git or GitHub env @@ -32,10 +42,26 @@ if ($branch === null) { } if ($version === null) { - fwrite(STDERR, "Usage: version_set_platform.php --path . --version development\n"); + fwrite(STDERR, "Usage: version_set_platform.php --path . --version 04.01.00 [--stability dev]\n"); exit(1); } +// Append stability suffix for non-stable releases +$stabilitySuffixMap = [ + 'stable' => '', + 'development' => '-dev', + 'dev' => '-dev', + 'alpha' => '-alpha', + 'beta' => '-beta', + 'rc' => '-rc', + 'release-candidate' => '-rc', +]; +$suffix = $stabilitySuffixMap[$stability] ?? ''; +if ($suffix !== '' && !str_ends_with($version, $suffix)) { + $version .= $suffix; + echo "Version with stability suffix: {$version}\n"; +} + $root = realpath($path) ?: $path; // Detect platform -- 2.52.0 From 03801ff9256e33d6454e35e87d6f2b1e65ca9fa9 Mon Sep 17 00:00:00 2001 From: Jonathan Miller Date: Thu, 21 May 2026 21:34:18 -0500 Subject: [PATCH 08/12] fix(ci): sync updates.xml via API instead of git checkout (#34) The git checkout approach fails when dev and main have diverged. Use Gitea Contents API (PUT) to update updates.xml on target branches directly, matching the pattern already used in auto-release.yml. Fixes #34 Co-Authored-By: Claude Opus 4.6 (1M context) --- .mokogitea/workflows/pre-release.yml | 27 +++++++++++++++------------ 1 file changed, 15 insertions(+), 12 deletions(-) diff --git a/.mokogitea/workflows/pre-release.yml b/.mokogitea/workflows/pre-release.yml index 1ec5d77..623bb57 100644 --- a/.mokogitea/workflows/pre-release.yml +++ b/.mokogitea/workflows/pre-release.yml @@ -336,23 +336,26 @@ jobs: if: steps.platform.outputs.platform == 'joomla' run: | CURRENT_BRANCH="${{ github.ref_name }}" - git config --local user.email "gitea-actions[bot]@mokoconsulting.tech" - git config --local user.name "gitea-actions[bot]" + TOKEN="${{ secrets.GA_TOKEN }}" + API="${GITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}" + VERSION="${{ steps.meta.outputs.version }}" - # Sync updates.xml to main and dev (whichever isn't current) + # Sync updates.xml to main and dev via API (avoids git checkout conflicts) for BRANCH in main dev; do [ "$BRANCH" = "$CURRENT_BRANCH" ] && continue - echo "Syncing updates.xml → ${BRANCH}" - git fetch origin "${BRANCH}" 2>/dev/null || continue - git checkout "origin/${BRANCH}" -- . 2>/dev/null || continue - git checkout "${CURRENT_BRANCH}" -- updates.xml - if ! git diff --quiet updates.xml 2>/dev/null; then - git add updates.xml - git commit -m "chore: sync updates.xml from ${CURRENT_BRANCH} [skip ci]" - git push origin HEAD:refs/heads/${BRANCH} 2>&1 || echo "WARNING: push to ${BRANCH} failed" + echo "Syncing updates.xml -> ${BRANCH}" + + FILE_SHA=$(curl -sf -H "Authorization: token ${TOKEN}" "${API}/contents/updates.xml?ref=${BRANCH}" | jq -r '.sha // empty' 2>/dev/null || true) + + if [ -z "$FILE_SHA" ]; then + echo " WARNING: could not get updates.xml SHA from ${BRANCH}" + continue fi - git checkout "${CURRENT_BRANCH}" 2>/dev/null + + CONTENT=$(base64 -w0 updates.xml) + curl -sf -X PUT -H "Authorization: token ${TOKEN}" -H "Content-Type: application/json" "${API}/contents/updates.xml" -d "$(jq -n --arg content \"$CONTENT\" --arg sha \"$FILE_SHA\" --arg msg \"chore: sync updates.xml ${VERSION} from ${CURRENT_BRANCH} [skip ci]\" --arg branch \"$BRANCH\" '{content: $content, sha: $sha, message: $msg, branch: $branch}' + )" > /dev/null 2>&1 && echo " Synced to ${BRANCH}" || echo " WARNING: push to ${BRANCH} failed" done - name: "Delete lesser pre-release channels (cascade)" -- 2.52.0 From c6da98289baaf6ad46378a862e8821e9f830c256 Mon Sep 17 00:00:00 2001 From: Jonathan Miller Date: Thu, 21 May 2026 21:33:52 -0500 Subject: [PATCH 09/12] chore: rename MokoStandards to moko-platform in CLI headers Update DEFGROUP and INGROUP fields across all CLI scripts to reflect the repo rename from MokoStandards to moko-platform. Co-Authored-By: Claude Opus 4.6 (1M context) --- cli/archive_repo.php | 4 ++-- cli/badge_update.php | 4 ++-- cli/bulk_workflow_trigger.php | 4 ++-- cli/changelog_promote.php | 4 ++-- cli/client_inventory.php | 4 ++-- cli/create_project.php | 4 ++-- cli/create_repo.php | 6 +++--- cli/joomla_release.php | 4 ++-- cli/manifest_read.php | 4 ++-- cli/package_build.php | 4 ++-- cli/platform_detect.php | 4 ++-- cli/release.php | 4 ++-- cli/release_cascade.php | 4 ++-- cli/release_manage.php | 4 ++-- cli/release_notes.php | 4 ++-- cli/scaffold_client.php | 4 ++-- cli/sync_rulesets.php | 4 ++-- cli/updates_xml_build.php | 4 ++-- cli/version_bump.php | 4 ++-- cli/version_read.php | 4 ++-- cli/version_set_platform.php | 4 ++-- 21 files changed, 43 insertions(+), 43 deletions(-) diff --git a/cli/archive_repo.php b/cli/archive_repo.php index 376f6e9..d71fe33 100644 --- a/cli/archive_repo.php +++ b/cli/archive_repo.php @@ -7,8 +7,8 @@ * SPDX-License-Identifier: GPL-3.0-or-later * * FILE INFORMATION - * DEFGROUP: MokoStandards.CLI - * INGROUP: MokoStandards + * DEFGROUP: moko-platform.CLI + * INGROUP: moko-platform * REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform * PATH: /cli/archive_repo.php * BRIEF: Gracefully retire a governed repository — archive, close issues/PRs, remove sync def diff --git a/cli/badge_update.php b/cli/badge_update.php index 87a1e8b..7470de5 100644 --- a/cli/badge_update.php +++ b/cli/badge_update.php @@ -5,8 +5,8 @@ * SPDX-License-Identifier: GPL-3.0-or-later * * FILE INFORMATION - * DEFGROUP: MokoStandards.CLI - * INGROUP: MokoStandards + * DEFGROUP: moko-platform.CLI + * INGROUP: moko-platform * REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform * PATH: /cli/badge_update.php * BRIEF: Update [VERSION: XX.XX.XX] badges in all markdown files diff --git a/cli/bulk_workflow_trigger.php b/cli/bulk_workflow_trigger.php index 25b66be..2c2fa48 100644 --- a/cli/bulk_workflow_trigger.php +++ b/cli/bulk_workflow_trigger.php @@ -7,8 +7,8 @@ * SPDX-License-Identifier: GPL-3.0-or-later * * FILE INFORMATION - * DEFGROUP: MokoStandards.Scripts.CLI - * INGROUP: MokoStandards + * DEFGROUP: moko-platform.CLI + * INGROUP: moko-platform * REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform * PATH: /cli/bulk_workflow_trigger.php * VERSION: 01.00.00 diff --git a/cli/changelog_promote.php b/cli/changelog_promote.php index 1419a15..c1c7fa2 100644 --- a/cli/changelog_promote.php +++ b/cli/changelog_promote.php @@ -5,8 +5,8 @@ * SPDX-License-Identifier: GPL-3.0-or-later * * FILE INFORMATION - * DEFGROUP: MokoStandards.CLI - * INGROUP: MokoStandards + * DEFGROUP: moko-platform.CLI + * INGROUP: moko-platform * REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform * PATH: /cli/changelog_promote.php * BRIEF: Promote [Unreleased] section in CHANGELOG.md to a versioned entry diff --git a/cli/client_inventory.php b/cli/client_inventory.php index 0654464..0151aa7 100644 --- a/cli/client_inventory.php +++ b/cli/client_inventory.php @@ -7,8 +7,8 @@ * SPDX-License-Identifier: GPL-3.0-or-later * * FILE INFORMATION - * DEFGROUP: MokoStandards.Scripts.CLI - * INGROUP: MokoStandards + * DEFGROUP: moko-platform.CLI + * INGROUP: moko-platform * REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform * PATH: /cli/client_inventory.php * VERSION: 01.00.00 diff --git a/cli/create_project.php b/cli/create_project.php index 75a5a69..15e4c19 100644 --- a/cli/create_project.php +++ b/cli/create_project.php @@ -7,8 +7,8 @@ * SPDX-License-Identifier: GPL-3.0-or-later * * FILE INFORMATION - * DEFGROUP: MokoStandards.CLI - * INGROUP: MokoStandards + * DEFGROUP: moko-platform.CLI + * INGROUP: moko-platform * REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform * PATH: /cli/create_project.php * BRIEF: Create baseline GitHub Projects for repositories with standard fields and views diff --git a/cli/create_repo.php b/cli/create_repo.php index 1e3314f..16003b0 100644 --- a/cli/create_repo.php +++ b/cli/create_repo.php @@ -7,8 +7,8 @@ * SPDX-License-Identifier: GPL-3.0-or-later * * FILE INFORMATION - * DEFGROUP: MokoStandards.CLI - * INGROUP: MokoStandards + * DEFGROUP: moko-platform.CLI + * INGROUP: moko-platform * REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform * PATH: /cli/create_repo.php * BRIEF: Scaffold a new governed repository with full MokoStandards baseline @@ -158,7 +158,7 @@ SPDX-License-Identifier: GPL-3.0-or-later # FILE INFORMATION DEFGROUP: {$name} -INGROUP: MokoStandards +INGROUP: moko-platform REPO: {$repoUrl} PATH: /README.md BRIEF: {$description} diff --git a/cli/joomla_release.php b/cli/joomla_release.php index d27f917..7429bf8 100644 --- a/cli/joomla_release.php +++ b/cli/joomla_release.php @@ -7,8 +7,8 @@ * SPDX-License-Identifier: GPL-3.0-or-later * * FILE INFORMATION - * DEFGROUP: MokoStandards.CLI - * INGROUP: MokoStandards + * DEFGROUP: moko-platform.CLI + * INGROUP: moko-platform * REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform * PATH: /cli/joomla_release.php * BRIEF: Joomla release pipeline — build ZIP+tar.gz, upload to GitHub Release, update updates.xml diff --git a/cli/manifest_read.php b/cli/manifest_read.php index d5837f9..5f8196e 100644 --- a/cli/manifest_read.php +++ b/cli/manifest_read.php @@ -5,8 +5,8 @@ * SPDX-License-Identifier: GPL-3.0-or-later * * FILE INFORMATION - * DEFGROUP: MokoStandards.CLI - * INGROUP: MokoStandards + * DEFGROUP: moko-platform.CLI + * INGROUP: moko-platform * REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform * PATH: /cli/manifest_read.php * VERSION: 04.09.00 diff --git a/cli/package_build.php b/cli/package_build.php index 5b3f519..1d18182 100644 --- a/cli/package_build.php +++ b/cli/package_build.php @@ -5,8 +5,8 @@ * SPDX-License-Identifier: GPL-3.0-or-later * * FILE INFORMATION - * DEFGROUP: MokoStandards.CLI - * INGROUP: MokoStandards + * DEFGROUP: moko-platform.CLI + * INGROUP: moko-platform * REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform * PATH: /cli/package_build.php * BRIEF: Build ZIP and tar.gz install packages for Joomla/Dolibarr/generic projects diff --git a/cli/platform_detect.php b/cli/platform_detect.php index c295acc..50a08f7 100644 --- a/cli/platform_detect.php +++ b/cli/platform_detect.php @@ -5,8 +5,8 @@ * SPDX-License-Identifier: GPL-3.0-or-later * * FILE INFORMATION - * DEFGROUP: MokoStandards.CLI - * INGROUP: MokoStandards + * DEFGROUP: moko-platform.CLI + * INGROUP: moko-platform * REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform * PATH: /cli/platform_detect.php * BRIEF: Detect platform from .mokostandards file — outputs platform string diff --git a/cli/release.php b/cli/release.php index 63bc23d..c725032 100644 --- a/cli/release.php +++ b/cli/release.php @@ -5,8 +5,8 @@ * SPDX-License-Identifier: GPL-3.0-or-later * * FILE INFORMATION - * DEFGROUP: MokoStandards.CLI - * INGROUP: MokoStandards + * DEFGROUP: moko-platform.CLI + * INGROUP: moko-platform * REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform * PATH: /cli/release.php * BRIEF: Automate the MokoStandards version branch release flow diff --git a/cli/release_cascade.php b/cli/release_cascade.php index 5f7670a..ad71d26 100644 --- a/cli/release_cascade.php +++ b/cli/release_cascade.php @@ -5,8 +5,8 @@ * SPDX-License-Identifier: GPL-3.0-or-later * * FILE INFORMATION - * DEFGROUP: MokoStandards.CLI - * INGROUP: MokoStandards + * DEFGROUP: moko-platform.CLI + * INGROUP: moko-platform * REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform * PATH: /cli/release_cascade.php * BRIEF: Delete lesser pre-release channels from Gitea when promoting stability diff --git a/cli/release_manage.php b/cli/release_manage.php index 1d9eb00..32789e0 100644 --- a/cli/release_manage.php +++ b/cli/release_manage.php @@ -5,8 +5,8 @@ * SPDX-License-Identifier: GPL-3.0-or-later * * FILE INFORMATION - * DEFGROUP: MokoStandards.CLI - * INGROUP: MokoStandards + * DEFGROUP: moko-platform.CLI + * INGROUP: moko-platform * REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform * PATH: /cli/release_manage.php * BRIEF: Create/update Gitea releases, upload assets, update release body diff --git a/cli/release_notes.php b/cli/release_notes.php index be91fe2..643000d 100644 --- a/cli/release_notes.php +++ b/cli/release_notes.php @@ -5,8 +5,8 @@ * SPDX-License-Identifier: GPL-3.0-or-later * * FILE INFORMATION - * DEFGROUP: MokoStandards.CLI - * INGROUP: MokoStandards + * DEFGROUP: moko-platform.CLI + * INGROUP: moko-platform * REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform * PATH: /cli/release_notes.php * BRIEF: Extract release notes from CHANGELOG.md for a given version diff --git a/cli/scaffold_client.php b/cli/scaffold_client.php index 56c4081..d5a60e3 100644 --- a/cli/scaffold_client.php +++ b/cli/scaffold_client.php @@ -7,8 +7,8 @@ * SPDX-License-Identifier: GPL-3.0-or-later * * FILE INFORMATION - * DEFGROUP: MokoStandards.Scripts.CLI - * INGROUP: MokoStandards + * DEFGROUP: moko-platform.CLI + * INGROUP: moko-platform * REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform * PATH: /cli/scaffold_client.php * VERSION: 01.00.00 diff --git a/cli/sync_rulesets.php b/cli/sync_rulesets.php index 807be66..8b2361e 100644 --- a/cli/sync_rulesets.php +++ b/cli/sync_rulesets.php @@ -7,8 +7,8 @@ * SPDX-License-Identifier: GPL-3.0-or-later * * FILE INFORMATION - * DEFGROUP: MokoStandards.CLI - * INGROUP: MokoStandards + * DEFGROUP: moko-platform.CLI + * INGROUP: moko-platform * REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform * PATH: /cli/sync_rulesets.php * BRIEF: Apply branch protection rules to all repos via platform adapter diff --git a/cli/updates_xml_build.php b/cli/updates_xml_build.php index 8217a6e..6122167 100644 --- a/cli/updates_xml_build.php +++ b/cli/updates_xml_build.php @@ -5,8 +5,8 @@ * SPDX-License-Identifier: GPL-3.0-or-later * * FILE INFORMATION - * DEFGROUP: MokoStandards.CLI - * INGROUP: MokoStandards + * DEFGROUP: moko-platform.CLI + * INGROUP: moko-platform * REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform * PATH: /cli/updates_xml_build.php * BRIEF: Generate Joomla updates.xml from extension manifest metadata diff --git a/cli/version_bump.php b/cli/version_bump.php index d330ff7..a7d155f 100644 --- a/cli/version_bump.php +++ b/cli/version_bump.php @@ -5,8 +5,8 @@ * SPDX-License-Identifier: GPL-3.0-or-later * * FILE INFORMATION - * DEFGROUP: MokoStandards.CLI - * INGROUP: MokoStandards + * DEFGROUP: moko-platform.CLI + * INGROUP: moko-platform * REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform * PATH: /cli/version_bump.php * BRIEF: Auto-increment patch version in README.md — outputs old → new diff --git a/cli/version_read.php b/cli/version_read.php index 13aa29f..0c0063b 100644 --- a/cli/version_read.php +++ b/cli/version_read.php @@ -5,8 +5,8 @@ * SPDX-License-Identifier: GPL-3.0-or-later * * FILE INFORMATION - * DEFGROUP: MokoStandards.CLI - * INGROUP: MokoStandards + * DEFGROUP: moko-platform.CLI + * INGROUP: moko-platform * REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform * PATH: /cli/version_read.php * BRIEF: Read VERSION from README.md — outputs just the version string diff --git a/cli/version_set_platform.php b/cli/version_set_platform.php index 852a669..8e9a4c7 100644 --- a/cli/version_set_platform.php +++ b/cli/version_set_platform.php @@ -5,8 +5,8 @@ * SPDX-License-Identifier: GPL-3.0-or-later * * FILE INFORMATION - * DEFGROUP: MokoStandards.CLI - * INGROUP: MokoStandards + * DEFGROUP: moko-platform.CLI + * INGROUP: moko-platform * REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform * PATH: /cli/version_set_platform.php * BRIEF: Set version in platform-specific files (Dolibarr $this->version, Joomla ) -- 2.52.0 From 4dfbcf4fd2068cf14ba8b5289fefd4f1cf5a22df Mon Sep 17 00:00:00 2001 From: Jonathan Miller Date: Thu, 21 May 2026 21:38:54 -0500 Subject: [PATCH 10/12] =?UTF-8?q?feat(cli):=20add=20updates=5Fxml=5Fsync.p?= =?UTF-8?q?hp=20=E2=80=94=20replaces=20inline=20workflow=20sync=20(#34)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New CLI tool syncs updates.xml to target branches via Gitea API. Pre-release workflow now calls the CLI instead of inline bash. Co-Authored-By: Claude Opus 4.6 (1M context) --- .mokogitea/workflows/pre-release.yml | 23 +--- cli/updates_xml_sync.php | 169 +++++++++++++++++++++++++++ 2 files changed, 170 insertions(+), 22 deletions(-) create mode 100644 cli/updates_xml_sync.php diff --git a/.mokogitea/workflows/pre-release.yml b/.mokogitea/workflows/pre-release.yml index 623bb57..c872554 100644 --- a/.mokogitea/workflows/pre-release.yml +++ b/.mokogitea/workflows/pre-release.yml @@ -335,28 +335,7 @@ jobs: - name: "Sync updates.xml to all branches" if: steps.platform.outputs.platform == 'joomla' run: | - CURRENT_BRANCH="${{ github.ref_name }}" - TOKEN="${{ secrets.GA_TOKEN }}" - API="${GITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}" - VERSION="${{ steps.meta.outputs.version }}" - - # Sync updates.xml to main and dev via API (avoids git checkout conflicts) - for BRANCH in main dev; do - [ "$BRANCH" = "$CURRENT_BRANCH" ] && continue - - echo "Syncing updates.xml -> ${BRANCH}" - - FILE_SHA=$(curl -sf -H "Authorization: token ${TOKEN}" "${API}/contents/updates.xml?ref=${BRANCH}" | jq -r '.sha // empty' 2>/dev/null || true) - - if [ -z "$FILE_SHA" ]; then - echo " WARNING: could not get updates.xml SHA from ${BRANCH}" - continue - fi - - CONTENT=$(base64 -w0 updates.xml) - curl -sf -X PUT -H "Authorization: token ${TOKEN}" -H "Content-Type: application/json" "${API}/contents/updates.xml" -d "$(jq -n --arg content \"$CONTENT\" --arg sha \"$FILE_SHA\" --arg msg \"chore: sync updates.xml ${VERSION} from ${CURRENT_BRANCH} [skip ci]\" --arg branch \"$BRANCH\" '{content: $content, sha: $sha, message: $msg, branch: $branch}' - )" > /dev/null 2>&1 && echo " Synced to ${BRANCH}" || echo " WARNING: push to ${BRANCH} failed" - done + php /tmp/moko-platform-api/cli/updates_xml_sync.php --path . --current "${{ github.ref_name }}" --branches main,dev --version "${{ steps.meta.outputs.version }}" --token "${{ secrets.GA_TOKEN }}" --org "${GITEA_ORG}" --repo "${GITEA_REPO}" --gitea-url "${GITEA_URL}" - name: "Delete lesser pre-release channels (cascade)" continue-on-error: true diff --git a/cli/updates_xml_sync.php b/cli/updates_xml_sync.php new file mode 100644 index 0000000..46efbc1 --- /dev/null +++ b/cli/updates_xml_sync.php @@ -0,0 +1,169 @@ +#!/usr/bin/env php + + * + * SPDX-License-Identifier: GPL-3.0-or-later + * + * FILE INFORMATION + * DEFGROUP: moko-platform.CLI + * INGROUP: moko-platform + * REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform + * PATH: /cli/updates_xml_sync.php + * VERSION: 05.00.01 + * BRIEF: Sync updates.xml to target branches via Gitea API + * NOTE: Called by pre-release and auto-release workflows after updates.xml + * is modified on the current branch. Pushes the file to other branches + * without requiring a git checkout (avoids merge conflicts). + * + * Usage: + * php updates_xml_sync.php --path /repo --branches main,dev --current dev + * php updates_xml_sync.php --path /repo --branches main --current dev --version 02.01.27 + * + * Options: + * --path Repository root containing updates.xml (default: .) + * --branches Comma-separated target branches to sync to (default: main,dev) + * --current Current branch to skip (required) + * --version Version string for commit message (optional) + * --token Gitea API token (default: env GA_TOKEN) + * --gitea-url Gitea instance URL (default: env GITEA_URL or https://git.mokoconsulting.tech) + * --org Organization (default: env GITEA_ORG) + * --repo Repository name (default: env GITEA_REPO) + */ + +declare(strict_types=1); + +// ── Argument parsing ──────────────────────────────────────────────────── +$path = '.'; +$branches = 'main,dev'; +$current = ''; +$version = ''; +$token = getenv('GA_TOKEN') ?: ''; +$giteaUrl = getenv('GITEA_URL') ?: 'https://git.mokoconsulting.tech'; +$org = getenv('GITEA_ORG') ?: ''; +$repo = getenv('GITEA_REPO') ?: ''; + +foreach ($argv as $i => $arg) { + if ($arg === '--path' && isset($argv[$i + 1])) $path = $argv[$i + 1]; + if ($arg === '--branches' && isset($argv[$i + 1])) $branches = $argv[$i + 1]; + if ($arg === '--current' && isset($argv[$i + 1])) $current = $argv[$i + 1]; + if ($arg === '--version' && isset($argv[$i + 1])) $version = $argv[$i + 1]; + if ($arg === '--token' && isset($argv[$i + 1])) $token = $argv[$i + 1]; + if ($arg === '--gitea-url' && isset($argv[$i + 1])) $giteaUrl = $argv[$i + 1]; + if ($arg === '--org' && isset($argv[$i + 1])) $org = $argv[$i + 1]; + if ($arg === '--repo' && isset($argv[$i + 1])) $repo = $argv[$i + 1]; +} + +if ($current === '') { + fwrite(STDERR, "Error: --current is required\n"); + exit(1); +} + +if ($token === '') { + fwrite(STDERR, "Error: --token or GA_TOKEN env is required\n"); + exit(1); +} + +if ($org === '' || $repo === '') { + fwrite(STDERR, "Error: --org and --repo (or GITEA_ORG/GITEA_REPO env) are required\n"); + exit(1); +} + +$updatesFile = rtrim($path, '/') . '/updates.xml'; +if (!file_exists($updatesFile)) { + fwrite(STDERR, "No updates.xml found at {$updatesFile}\n"); + exit(0); +} + +$content = file_get_contents($updatesFile); +$encoded = base64_encode($content); +$giteaUrl = rtrim($giteaUrl, '/'); +$apiBase = "{$giteaUrl}/api/v1/repos/{$org}/{$repo}"; +$vLabel = $version !== '' ? " {$version}" : ''; + +$targets = array_filter( + array_map('trim', explode(',', $branches)), + fn($b) => $b !== '' && $b !== $current +); + +if (empty($targets)) { + fwrite(STDERR, "No target branches to sync to (current: {$current})\n"); + exit(0); +} + +$synced = 0; +$failed = 0; + +foreach ($targets as $branch) { + fwrite(STDERR, "Syncing updates.xml -> {$branch}...\n"); + + $sha = getFileSha($apiBase, $token, $branch); + + if ($sha === null) { + fwrite(STDERR, " WARNING: could not get SHA from {$branch}\n"); + $failed++; + continue; + } + + $ok = putFile($apiBase, $token, $branch, $encoded, $sha, + "chore: sync updates.xml{$vLabel} from {$current} [skip ci]"); + + if ($ok) { + fwrite(STDERR, " Synced to {$branch}\n"); + $synced++; + } else { + fwrite(STDERR, " WARNING: push to {$branch} failed\n"); + $failed++; + } +} + +fwrite(STDERR, "Done: {$synced} synced, {$failed} failed\n"); +exit($failed > 0 ? 1 : 0); + +// ═══════════════════════════════════════════════════════════════════════ + +function getFileSha(string $apiBase, string $token, string $branch): ?string +{ + $resp = apiCall('GET', "{$apiBase}/contents/updates.xml?ref={$branch}", $token); + return $resp['sha'] ?? null; +} + +function putFile(string $apiBase, string $token, string $branch, + string $encoded, string $sha, string $msg): bool +{ + $resp = apiCall('PUT', "{$apiBase}/contents/updates.xml", $token, [ + 'content' => $encoded, + 'sha' => $sha, + 'message' => $msg, + 'branch' => $branch, + ]); + return $resp !== null; +} + +function apiCall(string $method, string $url, string $token, ?array $data = null): ?array +{ + $headers = [ + "Authorization: token {$token}", + 'Content-Type: application/json', + 'Accept: application/json', + ]; + + $ch = curl_init($url); + curl_setopt($ch, CURLOPT_CUSTOMREQUEST, $method); + curl_setopt($ch, CURLOPT_HTTPHEADER, $headers); + curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); + curl_setopt($ch, CURLOPT_TIMEOUT, 30); + curl_setopt($ch, CURLOPT_FOLLOWLOCATION, true); + + if ($data !== null) { + curl_setopt($ch, CURLOPT_POSTFIELDS, + json_encode($data, JSON_UNESCAPED_SLASHES)); + } + + $body = curl_exec($ch); + $code = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE); + curl_close($ch); + + return ($code >= 200 && $code < 300) + ? (json_decode($body, true) ?: []) + : null; +} -- 2.52.0 From 3c11006aae1c3952d735c17d8298d75a90deec63 Mon Sep 17 00:00:00 2001 From: Jonathan Miller Date: Thu, 21 May 2026 21:48:12 -0500 Subject: [PATCH 11/12] refactor(ci): pre-release uses CLI tools for detect/version/build Replace inline bash with moko-platform CLI calls: - manifest_read.php --github-output for platform detection - version_bump.php for patch version increment - version_set_platform.php for manifest version update - joomla_build.php for type-aware package building - updates_xml_sync.php for cross-branch sync Reduces inline bash from ~160 lines to ~85 lines. Co-Authored-By: Claude Opus 4.6 (1M context) --- .mokogitea/workflows/pre-release.yml | 152 +++++++-------------------- 1 file changed, 40 insertions(+), 112 deletions(-) diff --git a/.mokogitea/workflows/pre-release.yml b/.mokogitea/workflows/pre-release.yml index c872554..026d4db 100644 --- a/.mokogitea/workflows/pre-release.yml +++ b/.mokogitea/workflows/pre-release.yml @@ -62,20 +62,13 @@ jobs: - name: Detect platform id: platform run: | - # Read platform from XML manifest ( tag) or plain text fallback - PLATFORM=$(sed -n 's/.*\([^<]*\)<\/platform>.*/\1/p' .mokogitea/manifest.xml 2>/dev/null | head -1) - [ -z "$PLATFORM" ] && PLATFORM=$(cat .mokogitea/manifest.xml 2>/dev/null | tr -d '[:space:]') - [ -z "$PLATFORM" ] && PLATFORM="generic" - echo "platform=$PLATFORM" >> "$GITHUB_OUTPUT" - MANIFEST=$(find . -maxdepth 3 -name "*.xml" ! -path "./.git/*" -exec grep -l '/dev/null | head -1 || true) - MOD_FILE=$(find . -maxdepth 4 -name "mod*.class.php" ! -path "./.git/*" -exec grep -l 'extends DolibarrModules' {} \; 2>/dev/null | head -1 || true) - echo "manifest=${MANIFEST}" >> "$GITHUB_OUTPUT" - echo "mod_file=${MOD_FILE}" >> "$GITHUB_OUTPUT" + php /tmp/moko-platform-api/cli/manifest_read.php --path . --github-output - name: Resolve metadata id: meta run: | STABILITY="${{ inputs.stability }}" + MOKO_API="/tmp/moko-platform-api/cli" case "$STABILITY" in development) SUFFIX="-dev"; TAG="development" ;; @@ -84,55 +77,14 @@ jobs: release-candidate) SUFFIX="-rc"; TAG="release-candidate" ;; esac - # Read and bump patch version (with rollover) - CURRENT=$(sed -n 's/.*VERSION:[[:space:]]*\([0-9][0-9]\.[0-9][0-9]\.[0-9][0-9]\).*/\1/p' README.md 2>/dev/null | head -1) - [ -z "$CURRENT" ] && CURRENT="00.00.00" - - MAJOR=$(echo "$CURRENT" | cut -d. -f1) - MINOR=$(echo "$CURRENT" | cut -d. -f2) - PATCH=$(echo "$CURRENT" | cut -d. -f3) - - # Patch bump with rollover: ZZ=99 → bump minor, YY=99 → bump major - NEW_PATCH=$((10#$PATCH + 1)) - NEW_MINOR=$((10#$MINOR)) - NEW_MAJOR=$((10#$MAJOR)) - - if [ $NEW_PATCH -gt 99 ]; then - NEW_PATCH=0 - NEW_MINOR=$((NEW_MINOR + 1)) - fi - if [ $NEW_MINOR -gt 99 ]; then - NEW_MINOR=0 - NEW_MAJOR=$((NEW_MAJOR + 1)) - fi - - VERSION=$(printf "%02d.%02d.%02d" $NEW_MAJOR $NEW_MINOR $NEW_PATCH) - TODAY=$(date +%Y-%m-%d) - - echo "Bumping: ${CURRENT} → ${VERSION} (patch)" - - # Update README.md - sed -i "s/VERSION:[[:space:]]*${CURRENT}/VERSION: ${VERSION}/" README.md + # Bump patch version + BUMP_OUTPUT=$(php ${MOKO_API}/version_bump.php --path .) + VERSION=$(echo "$BUMP_OUTPUT" | grep -oP '\d{2}\.\d{2}\.\d{2}$' || true) + [ -z "$VERSION" ] && VERSION=$(php ${MOKO_API}/version_read.php --path .) + echo "Version: ${VERSION}" # Update platform-specific manifest - PLATFORM="${{ steps.platform.outputs.platform }}" - MANIFEST="${{ steps.platform.outputs.manifest }}" - MOD_FILE="${{ steps.platform.outputs.mod_file }}" - case "$PLATFORM" in - joomla) - if [ -n "$MANIFEST" ]; then - MANIFEST_VER=$(sed -n 's/.*\([^<]*\)<\/version>.*/\1/p' "$MANIFEST" | head -1) - sed -i "s|${MANIFEST_VER}|${VERSION}|" "$MANIFEST" - sed -i "s|[^<]*|${TODAY}|" "$MANIFEST" - fi - ;; - dolibarr) - if [ -n "$MOD_FILE" ]; then - sed -i "s/\$this->version = '[^']*'/\$this->version = '${VERSION}'/" "$MOD_FILE" - fi - ;; - *) ;; - esac + php ${MOKO_API}/version_set_platform.php --path . --version "${VERSION}" # Commit version bump git config --local user.email "gitea-actions[bot]@mokoconsulting.tech" @@ -140,40 +92,22 @@ jobs: git remote set-url origin "https://jmiller:${{ secrets.GA_TOKEN }}@git.mokoconsulting.tech/${{ github.repository }}.git" git add -A git diff --cached --quiet || { - git commit -m "chore(version): bump ${CURRENT} → ${VERSION} [skip ci]" + git commit -m "chore(version): bump to ${VERSION} [skip ci]" git push origin HEAD 2>&1 } - # Auto-detect element (platform-aware) - case "$PLATFORM" in - joomla) - MANIFEST="${{ steps.platform.outputs.manifest }}" - EXT_ELEMENT="" - if [ -n "$MANIFEST" ]; then - EXT_ELEMENT=$(sed -n 's/.*\([^<]*\)<\/element>.*/\1/p' "$MANIFEST" 2>/dev/null | head -1) - if [ -z "$EXT_ELEMENT" ]; then - EXT_ELEMENT=$(basename "$MANIFEST" .xml | tr '[:upper:]' '[:lower:]') - case "$EXT_ELEMENT" in - templatedetails|manifest) EXT_ELEMENT=$(echo "${GITEA_REPO}" | tr '[:upper:]' '[:lower:]' | tr -d ' -') ;; - esac - fi - else - EXT_ELEMENT=$(echo "${GITEA_REPO}" | tr '[:upper:]' '[:lower:]' | tr -d ' -') - fi - ;; - dolibarr) - MOD_FILE="${{ steps.platform.outputs.mod_file }}" - if [ -n "$MOD_FILE" ]; then - MOD_BASENAME=$(basename "$MOD_FILE" .class.php) - EXT_ELEMENT=$(echo "$MOD_BASENAME" | sed 's/^mod//' | tr '[:upper:]' '[:lower:]') - else - EXT_ELEMENT=$(echo "${GITEA_REPO}" | tr '[:upper:]' '[:lower:]' | tr -d ' -') - fi - ;; - *) - EXT_ELEMENT=$(echo "${GITEA_REPO}" | tr '[:upper:]' '[:lower:]' | tr -d ' -') - ;; - esac + # Detect element from Joomla/Dolibarr manifest + PLATFORM="${{ steps.platform.outputs.platform }}" + EXT_ELEMENT=$(php ${MOKO_API}/manifest_read.php --path . --field name 2>/dev/null | tr -d ' ' | tr '[:upper:]' '[:lower:]' || true) + # For Joomla, prefer tag + if [ "$PLATFORM" = "joomla" ]; then + MANIFEST=$(find . -maxdepth 3 -name "*.xml" ! -path "./.git/*" -exec grep -l '/dev/null | head -1 || true) + if [ -n "$MANIFEST" ]; then + ELEM=$(sed -n 's/.*\([^<]*\)<\/element>.*//p' "$MANIFEST" | head -1) + [ -n "$ELEM" ] && EXT_ELEMENT="$ELEM" + fi + fi + [ -z "$EXT_ELEMENT" ] && EXT_ELEMENT=$(echo "${GITEA_REPO}" | tr '[:upper:]' '[:lower:]' | tr -d ' -') ZIP_NAME="${EXT_ELEMENT}-${VERSION}${SUFFIX}.zip" @@ -183,38 +117,32 @@ jobs: echo "tag=${TAG}" >> "$GITHUB_OUTPUT" echo "zip_name=${ZIP_NAME}" >> "$GITHUB_OUTPUT" echo "ext_element=${EXT_ELEMENT}" >> "$GITHUB_OUTPUT" - echo "manifest=${MANIFEST}" >> "$GITHUB_OUTPUT" echo "=== Pre-Release: ${EXT_ELEMENT} ${VERSION}${SUFFIX} ===" - name: Build package - run: | - SOURCE_DIR="src" - [ ! -d "$SOURCE_DIR" ] && SOURCE_DIR="htdocs" - if [ ! -d "$SOURCE_DIR" ]; then - echo "::error::No src/ or htdocs/ directory" - exit 1 - fi - - mkdir -p build/package - # Use cp instead of rsync (not always available in runner containers) - cp -a "${SOURCE_DIR}/." build/package/ - # Remove excluded files - cd build/package - rm -f sftp-config* .ftpignore *.ppk *.pem *.key .env* *.local .build-trigger - cd "$OLDPWD" - - - name: Create ZIP id: zip run: | - ZIP_NAME="${{ steps.meta.outputs.zip_name }}" - cd build/package - zip -r "../${ZIP_NAME}" . - cd .. + VERSION="${{ steps.meta.outputs.version }}" + SUFFIX="${{ steps.meta.outputs.suffix }}" + PLATFORM="${{ steps.platform.outputs.platform }}" - SHA256=$(sha256sum "${ZIP_NAME}" | cut -d' ' -f1) - echo "sha256=${SHA256}" >> "$GITHUB_OUTPUT" - echo "ZIP: ${ZIP_NAME} (SHA: ${SHA256:0:16}...)" + if [ "$PLATFORM" = "joomla" ]; then + php /tmp/moko-platform-api/cli/joomla_build.php --path . --version "${VERSION}" --suffix "${SUFFIX}" --output build --github-output + else + # Generic build: zip src/ directory + SOURCE_DIR="src" + [ ! -d "$SOURCE_DIR" ] && SOURCE_DIR="htdocs" + [ ! -d "$SOURCE_DIR" ] && { echo "::error::No src/ or htdocs/"; exit 1; } + EXT_ELEMENT="${{ steps.meta.outputs.ext_element }}" + ZIP_NAME="${EXT_ELEMENT}-${VERSION}${SUFFIX}.zip" + mkdir -p build + cd "$SOURCE_DIR" && zip -r "../build/${ZIP_NAME}" . && cd .. + SHA256=$(sha256sum "build/${ZIP_NAME}" | cut -d' ' -f1) + echo "zip_name=${ZIP_NAME}" >> "$GITHUB_OUTPUT" + echo "zip_path=build/${ZIP_NAME}" >> "$GITHUB_OUTPUT" + echo "sha256=${SHA256}" >> "$GITHUB_OUTPUT" + fi - name: Create or replace Gitea release id: release -- 2.52.0 From 9b456da7e5ff9b8d01f3a7c4046c18a68ef19c65 Mon Sep 17 00:00:00 2001 From: Jonathan Miller Date: Thu, 21 May 2026 21:54:51 -0500 Subject: [PATCH 12/12] refactor(ci): auto-release uses CLI tools for 6 major steps (#45) Replace inline bash with moko-platform CLI calls: - manifest_read.php for platform detection - version_read.php + version_bump.php for version management - badge_update.php for README badges - updates_xml_build.php for Joomla update stream - release_cascade.php for pre-release cleanup Reduces auto-release.yml from 1006 to 762 lines (-244). Co-Authored-By: Claude Opus 4.6 (1M context) --- .mokogitea/workflows/auto-release.yml | 304 +++----------------------- 1 file changed, 30 insertions(+), 274 deletions(-) diff --git a/.mokogitea/workflows/auto-release.yml b/.mokogitea/workflows/auto-release.yml index 91f0b06..1b26bd1 100644 --- a/.mokogitea/workflows/auto-release.yml +++ b/.mokogitea/workflows/auto-release.yml @@ -79,118 +79,36 @@ jobs: - name: Detect platform id: platform run: | - # Read platform from XML manifest ( tag) or plain text fallback - PLATFORM=$(sed -n 's/.*\([^<]*\)<\/platform>.*/\1/p' .mokogitea/manifest.xml 2>/dev/null | head -1) - [ -z "$PLATFORM" ] && PLATFORM=$(cat .mokogitea/manifest.xml 2>/dev/null | tr -d '[:space:]') - [ -z "$PLATFORM" ] && PLATFORM="generic" - echo "platform=$PLATFORM" >> "$GITHUB_OUTPUT" - echo "Platform detected: ${PLATFORM}" + php /tmp/moko-platform-api/cli/manifest_read.php --path . --github-output MANIFEST=$(find . -maxdepth 3 -name "*.xml" ! -path "./.git/*" -exec grep -l '/dev/null | head -1 || true) MOD_FILE=$(find . -maxdepth 4 -name "mod*.class.php" ! -path "./.git/*" -exec grep -l 'extends DolibarrModules' {} \; 2>/dev/null | head -1 || true) echo "manifest=${MANIFEST}" >> "$GITHUB_OUTPUT" echo "mod_file=${MOD_FILE}" >> "$GITHUB_OUTPUT" - # -- STEP 1: Read version ----------------------------------------------- - - name: "Step 1: Read version from README.md" + - name: "Step 1: Read version" id: version run: | - VERSION=$(php /tmp/moko-platform-api/cli/version_read.php --path . 2>/dev/null) + VERSION=$(php /tmp/moko-platform-api/cli/version_read.php --path .) if [ -z "$VERSION" ]; then - echo "No VERSION in README.md — skipping release" + echo "::error::No VERSION in README.md" echo "skip=true" >> "$GITHUB_OUTPUT" exit 0 fi - # Derive major.minor for branch naming (patches update existing branch) - MINOR=$(echo "$VERSION" | awk -F. '{printf "%s.%s", $1, $2}') - PATCH=$(echo "$VERSION" | awk -F. '{print $3}') - - MAJOR=$(echo "$VERSION" | awk -F. '{print $1}') - MINOR_NUM=$(echo "$VERSION" | awk -F. '{print $2}') - - echo "version=$VERSION" >> "$GITHUB_OUTPUT" - echo "branch=version/${MAJOR}" >> "$GITHUB_OUTPUT" - echo "minor=$MINOR" >> "$GITHUB_OUTPUT" - echo "major=$MAJOR" >> "$GITHUB_OUTPUT" - echo "release_tag=stable" >> "$GITHUB_OUTPUT" - echo "stability=stable" >> "$GITHUB_OUTPUT" - echo "skip=false" >> "$GITHUB_OUTPUT" - if [ "$PATCH" = "00" ] || [ "$PATCH" = "01" ]; then - echo "is_minor=true" >> "$GITHUB_OUTPUT" - echo "Version: $VERSION (first release for this minor — full pipeline)" - else - echo "is_minor=false" >> "$GITHUB_OUTPUT" - echo "Version: $VERSION (patch — platform version + badges only)" - fi - - # -- STEP 1b: Bump minor version (stable = minor bump, reset patch) ------ - - name: "Step 1b: Bump minor version for stable release" - if: steps.version.outputs.skip != 'true' - id: bump - run: | - CURRENT=$(sed -n 's/.*VERSION:[[:space:]]*\([0-9][0-9]\.[0-9][0-9]\.[0-9][0-9]\).*/\1/p' README.md 2>/dev/null | head -1) - [ -z "$CURRENT" ] && { echo "skip=true" >> "$GITHUB_OUTPUT"; exit 0; } - - MAJOR=$((10#$(echo "$CURRENT" | cut -d. -f1))) - MINOR=$((10#$(echo "$CURRENT" | cut -d. -f2))) - - # Minor bump, reset patch. Rollover if minor > 99 - MINOR=$((MINOR + 1)) - if [ $MINOR -gt 99 ]; then - MINOR=0 - MAJOR=$((MAJOR + 1)) - fi - - VERSION=$(printf "%02d.%02d.00" $MAJOR $MINOR) - TODAY=$(date +%Y-%m-%d) - - echo "Stable bump: ${CURRENT} → ${VERSION} (minor)" - - # Update README.md - sed -i "s/VERSION:[[:space:]]*${CURRENT}/VERSION: ${VERSION}/" README.md - - # Update platform-specific manifest - PLATFORM="${{ steps.platform.outputs.platform }}" - MANIFEST="${{ steps.platform.outputs.manifest }}" - MOD_FILE="${{ steps.platform.outputs.mod_file }}" - case "$PLATFORM" in - joomla) - if [ -n "$MANIFEST" ]; then - MANIFEST_VER=$(sed -n 's/.*\([^<]*\)<\/version>.*/\1/p' "$MANIFEST" | head -1) - [ -n "$MANIFEST_VER" ] && sed -i "s|${MANIFEST_VER}|${VERSION}|" "$MANIFEST" - sed -i "s|[^<]*|${TODAY}|" "$MANIFEST" - fi - ;; - dolibarr) - if [ -n "$MOD_FILE" ]; then - sed -i "s/\$this->version = '[^']*'/\$this->version = '${VERSION}'/" "$MOD_FILE" - fi - echo "${VERSION}" > update.txt - ;; - *) ;; - esac - - # Promote [Unreleased] section in CHANGELOG.md to new version - if [ -f "CHANGELOG.md" ] && grep -qi "Unreleased" CHANGELOG.md; then - sed -i "s|## \[Unreleased\]|## [${VERSION}] --- ${TODAY}|" CHANGELOG.md - sed -i "s|## Unreleased|## [${VERSION}] --- ${TODAY}|" CHANGELOG.md - sed -i "2i ## [Unreleased]" CHANGELOG.md - sed -i "3i \\ " CHANGELOG.md - echo "CHANGELOG promoted to [${VERSION}]" - fi - - # Commit and push - git config --local user.email "gitea-actions[bot]@mokoconsulting.tech" - git config --local user.name "gitea-actions[bot]" - git remote set-url origin "https://jmiller:${{ secrets.GA_TOKEN }}@git.mokoconsulting.tech/${{ github.repository }}.git" - git add -A - git diff --cached --quiet || { - git commit -m "chore(version): bump ${CURRENT} → ${VERSION} [skip ci]" - git push origin HEAD:main 2>&1 - } - - # Override version output for rest of pipeline + MAJOR=$(echo "$VERSION" | cut -d. -f1) echo "version=${VERSION}" >> "$GITHUB_OUTPUT" - echo "major=$(printf "%02d" $MAJOR)" >> "$GITHUB_OUTPUT" + echo "release_tag=v${MAJOR}" >> "$GITHUB_OUTPUT" + echo "skip=false" >> "$GITHUB_OUTPUT" + + - name: "Step 1b: Bump version" + id: bump + if: steps.version.outputs.skip != 'true' + run: | + MOKO_API="/tmp/moko-platform-api/cli" + BUMP=$(php ${MOKO_API}/version_bump.php --path . --minor) + VERSION=$(echo "$BUMP" | grep -oP '\d{2}\.\d{2}\.\d{2}$' || true) + [ -z "$VERSION" ] && VERSION=$(php ${MOKO_API}/version_read.php --path .) + echo "version=${VERSION}" >> "$GITHUB_OUTPUT" + echo "Bumped to: ${VERSION}" - name: Check if already released if: steps.version.outputs.skip != 'true' @@ -340,165 +258,22 @@ jobs: # -- STEP 4: Update version badges ---------------------------------------- - name: "Step 4: Update version badges" - if: >- - steps.version.outputs.skip != 'true' && - steps.check.outputs.already_released != 'true' + if: steps.version.outputs.skip != 'true' run: | VERSION="${{ steps.bump.outputs.version || steps.version.outputs.version }}" - find . -name "*.md" ! -path "./.git/*" ! -path "./vendor/*" | while read -r f; do - if grep -q '\[VERSION:' "$f" 2>/dev/null; then - sed -i "s/\[VERSION:[[:space:]]*[0-9]\{2\}\.[0-9]\{2\}\.[0-9]\{2\}\]/[VERSION: ${VERSION}]/" "$f" - fi - done + php /tmp/moko-platform-api/cli/badge_update.php --path . --version "${VERSION}" 2>/dev/null || true - # -- STEP 5: Write updates.xml (Joomla update server) --------------------- - name: "Step 5: Write update stream" - id: updates if: >- steps.version.outputs.skip != 'true' && - steps.check.outputs.already_released != 'true' + steps.platform.outputs.platform == 'joomla' run: | VERSION="${{ steps.bump.outputs.version || steps.version.outputs.version }}" - REPO="${{ github.repository }}" + php /tmp/moko-platform-api/cli/updates_xml_build.php \ + --path . --version "${VERSION}" --stability stable \ + --gitea-url "${GITEA_URL}" --org "${GITEA_ORG}" --repo "${GITEA_REPO}" \ + --github-output - # -- Parse extension metadata from XML manifest ---------------- - MANIFEST=$(find . -maxdepth 2 -name "*.xml" -exec grep -l '/dev/null | head -1 || true) - if [ -z "$MANIFEST" ]; then - echo "Warning: No Joomla XML manifest found — skipping updates.xml" >> $GITHUB_STEP_SUMMARY - exit 0 - fi - - # Extract fields using sed (portable — no grep -P) - EXT_NAME=$(sed -n 's/.*\([^<]*\)<\/name>.*/\1/p' "$MANIFEST" | head -1) - EXT_TYPE=$(sed -n 's/.*]*type="\([^"]*\)".*/\1/p' "$MANIFEST" | head -1) - EXT_ELEMENT=$(sed -n 's/.*\([^<]*\)<\/element>.*/\1/p' "$MANIFEST" | head -1) - EXT_CLIENT=$(sed -n 's/.*]*client="\([^"]*\)".*/\1/p' "$MANIFEST" | head -1) - EXT_FOLDER=$(sed -n 's/.*]*group="\([^"]*\)".*/\1/p' "$MANIFEST" | head -1) - TARGET_PLATFORM=$(sed -n 's/.*\(\).*/\1/p' "$MANIFEST" | head -1) - PHP_MINIMUM=$(sed -n 's/.*\([^<]*\)<\/php_minimum>.*/\1/p' "$MANIFEST" | head -1) - - # If EXT_NAME is a language key (e.g. PLG_SYSTEM_MOKOJGDPC), resolve from .ini - if echo "$EXT_NAME" | grep -qE '^[A-Z_]+$'; then - INI_NAME=$(find . -name "*.sys.ini" -path "*/en-GB/*" -exec grep -h "^${EXT_NAME}=" {} \; 2>/dev/null | head -1 | cut -d'"' -f2) - [ -z "$INI_NAME" ] && INI_NAME=$(find . -name "*.sys.ini" -exec grep -h "^${EXT_NAME}=" {} \; 2>/dev/null | head -1 | cut -d'"' -f2) - [ -n "$INI_NAME" ] && EXT_NAME="$INI_NAME" - fi - - # Fallbacks - [ -z "$EXT_NAME" ] && EXT_NAME="${{ github.event.repository.name }}" - [ -z "$EXT_TYPE" ] && EXT_TYPE="component" - - # Derive element if not in manifest: - # 1. plugin="xxx" attribute (plugins) - # 2. module="xxx" attribute (modules) - # 3. XML filename (components, packages) - # 4. Repo name fallback (templates, anything else) - if [ -z "$EXT_ELEMENT" ]; then - EXT_ELEMENT=$(sed -n 's/.*plugin="\([^"]*\)".*/\1/p' "$MANIFEST" | head -1) - fi - if [ -z "$EXT_ELEMENT" ]; then - EXT_ELEMENT=$(sed -n 's/.*module="\([^"]*\)".*/\1/p' "$MANIFEST" | head -1) - fi - if [ -z "$EXT_ELEMENT" ]; then - FNAME=$(basename "$MANIFEST" .xml | tr '[:upper:]' '[:lower:]') - # If filename is generic (templateDetails, manifest), use repo name - case "$FNAME" in - templatedetails|manifest) EXT_ELEMENT=$(echo "${{ github.event.repository.name }}" | tr '[:upper:]' '[:lower:]' | tr -d ' -') ;; - *) EXT_ELEMENT="$FNAME" ;; - esac - fi - # Final fallback - [ -z "$EXT_ELEMENT" ] && EXT_ELEMENT=$(echo "${{ github.event.repository.name }}" | tr '[:upper:]' '[:lower:]' | tr -d ' -') - - # Save for Steps 7, 8, 8b - echo "ext_element=${EXT_ELEMENT}" >> "$GITHUB_OUTPUT" - echo "ext_name=${EXT_NAME}" >> "$GITHUB_OUTPUT" - echo "ext_type=${EXT_TYPE}" >> "$GITHUB_OUTPUT" - echo "ext_folder=${EXT_FOLDER}" >> "$GITHUB_OUTPUT" - - # Build client tag: plugins and frontend modules need site - CLIENT_TAG="" - if [ -n "$EXT_CLIENT" ]; then - CLIENT_TAG="${EXT_CLIENT}" - elif [ "$EXT_TYPE" = "module" ] || [ "$EXT_TYPE" = "plugin" ]; then - CLIENT_TAG="site" - fi - - # Build folder tag for plugins (required for Joomla to match the update) - FOLDER_TAG="" - if [ -n "$EXT_FOLDER" ] && [ "$EXT_TYPE" = "plugin" ]; then - FOLDER_TAG="${EXT_FOLDER}" - fi - - # Build targetplatform (fallback to Joomla 5 if not in manifest) - if [ -z "$TARGET_PLATFORM" ]; then - TARGET_PLATFORM=$(printf '' "/") - fi - - # Build php_minimum tag - PHP_TAG="" - if [ -n "$PHP_MINIMUM" ]; then - PHP_TAG="${PHP_MINIMUM}" - fi - - # Build TYPE_PREFIX for download URL - TYPE_PREFIX="" - case "${EXT_TYPE}" in - plugin) TYPE_PREFIX="plg_${EXT_FOLDER}_" ;; - module) TYPE_PREFIX="mod_" ;; - component) TYPE_PREFIX="com_" ;; - template) TYPE_PREFIX="tpl_" ;; - library) TYPE_PREFIX="lib_" ;; - package) TYPE_PREFIX="pkg_" ;; - esac - - DOWNLOAD_URL="${GITEA_URL}/${GITEA_ORG}/${GITEA_REPO}/releases/download/stable/${TYPE_PREFIX}${EXT_ELEMENT}-${VERSION}.zip" - INFO_URL="${GITEA_URL}/${GITEA_ORG}/${GITEA_REPO}/releases/tag/stable" - - # -- Build update entry for a given stability tag - build_entry() { - local TAG_NAME="$1" - printf '%s\n' ' ' - printf '%s\n' " ${EXT_NAME}" - printf '%s\n' " ${EXT_NAME} update" - printf '%s\n' " ${EXT_ELEMENT}" - printf '%s\n' " ${EXT_TYPE}" - printf '%s\n' " ${VERSION}" - [ -n "$CLIENT_TAG" ] && printf '%s\n' " ${CLIENT_TAG}" - [ -n "$FOLDER_TAG" ] && printf '%s\n' " ${FOLDER_TAG}" - printf '%s\n' " ${TAG_NAME}" - printf '%s\n' " ${INFO_URL}" - printf '%s\n' ' ' - printf '%s\n' " ${DOWNLOAD_URL}" - printf '%s\n' ' ' - printf '%s\n' " ${TARGET_PLATFORM}" - [ -n "$PHP_TAG" ] && printf '%s\n' " ${PHP_TAG}" - printf '%s\n' ' Moko Consulting' - printf '%s\n' ' https://mokoconsulting.tech' - printf '%s\n' ' ' - } - - # -- Write updates.xml with cascading channels - # Stable release updates ALL channels (development, alpha, beta, rc, stable) - { - printf '%s\n' "" - printf '%s\n' "" - printf '%s\n' "" - printf '%s\n' '' - build_entry "development" - build_entry "alpha" - build_entry "beta" - build_entry "rc" - build_entry "stable" - printf '%s\n' '' - } > updates.xml - - echo "updates.xml: ${VERSION} (all channels updated to stable)" >> $GITHUB_STEP_SUMMARY - - # -- Commit all changes --------------------------------------------------- - name: Commit release changes if: >- steps.version.outputs.skip != 'true' && @@ -912,33 +687,14 @@ jobs: # -- Clean up lesser pre-releases (cascade) --------------------------------- # stable → deletes all | rc → beta,alpha,dev | beta → alpha,dev | alpha → dev - name: "Delete lesser pre-release channels" - if: steps.version.outputs.skip != 'true' continue-on-error: true run: | - API_BASE="${GITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}" - TOKEN="${{ secrets.GA_TOKEN }}" + php /tmp/moko-platform-api/cli/release_cascade.php \ + --stability stable \ + --token "${{ secrets.GA_TOKEN }}" \ + --org "${GITEA_ORG}" --repo "${GITEA_REPO}" \ + --gitea-url "${GITEA_URL}" 2>/dev/null || true - # Stable deletes all pre-release channels - TAGS_TO_DELETE="development alpha beta release-candidate" - - DELETED=0 - for TAG in $TAGS_TO_DELETE; do - RELEASE_ID=$(curl -sS -H "Authorization: token ${TOKEN}" \ - "${API_BASE}/releases/tags/${TAG}" 2>/dev/null | \ - python3 -c "import sys,json; print(json.load(sys.stdin).get('id',''))" 2>/dev/null || true) - - if [ -n "$RELEASE_ID" ] && [ "$RELEASE_ID" != "None" ]; then - curl -sS -X DELETE -H "Authorization: token ${TOKEN}" \ - "${API_BASE}/releases/${RELEASE_ID}" 2>/dev/null || true - curl -sS -X DELETE -H "Authorization: token ${TOKEN}" \ - "${API_BASE}/tags/${TAG}" 2>/dev/null || true - echo "Deleted: ${TAG} (id: ${RELEASE_ID})" - DELETED=$((DELETED + 1)) - fi - done - echo "Cleaned up ${DELETED} pre-release channel(s)" >> $GITHUB_STEP_SUMMARY - - # -- STEP 11: Reset dev branch from main ------------------------------------ - name: "Step 11: Delete and recreate dev branch from main" if: steps.version.outputs.skip != 'true' continue-on-error: true -- 2.52.0