Compare commits
60 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 1fe4f83e73 | |||
| 7e5c322792 | |||
| b010677d75 | |||
| 9275e581c2 | |||
| 3f3b1f79a0 | |||
| 83842c50ad | |||
| fbedd5966c | |||
| eca2c13018 | |||
| 48d000107d | |||
| 7ceb9528cc | |||
| 5fabaec477 | |||
| e40b799101 | |||
| 7e9784e723 | |||
| 209dee14fd | |||
| 81351f45fd | |||
| fd451b4b73 | |||
| d0dbd1dceb | |||
| 3e2e291819 | |||
| 5975ea38d8 | |||
| 8ad548f4a3 | |||
| cbb4d73df5 | |||
| 47cb47ebdb | |||
| 22b0f8af7e | |||
| 08ca1429ae | |||
| e8da1a30ff | |||
| fb754b1a07 | |||
| 9a2c164207 | |||
| 78c1329a83 | |||
| 05f43ed88f | |||
| 05e4f39e7d | |||
| 3dcb3b6d3a | |||
| db4e6f5c6b | |||
| aa7fc45a67 | |||
| 03fe66238f | |||
| a5ae616a94 | |||
| ff7924de7d | |||
| 1690e291d2 | |||
| 7f818809ef | |||
| 597b40f3f2 | |||
| 80108f9ca8 | |||
| b33623c731 | |||
| 9ff59ce405 | |||
| 9c6f393f92 | |||
| a418798a4d | |||
| baafffb1be | |||
| 1c930ca9bd | |||
| 3e37035786 | |||
| 5805358ef4 | |||
| 44c6bcbc2d | |||
| 78fcbdd4a9 | |||
| 4fd1acb68c | |||
| 9f7599fdb1 | |||
| 57a0b491ea | |||
| f76cd94c64 | |||
| ca1c3e0dba | |||
| 9ee50d0058 | |||
| bc67a53442 | |||
| 147cf663a6 | |||
| e41d9b9335 | |||
| 5c5c5e9ff2 |
@@ -26,7 +26,8 @@
|
||||
name: "Universal: Build & Release"
|
||||
|
||||
on:
|
||||
push:
|
||||
pull_request:
|
||||
types: [closed]
|
||||
branches:
|
||||
- main
|
||||
paths:
|
||||
@@ -47,7 +48,8 @@ jobs:
|
||||
release:
|
||||
name: Build & Release Pipeline
|
||||
runs-on: release
|
||||
if: github.event_name == 'push' || github.event_name == 'workflow_dispatch'
|
||||
if: >-
|
||||
github.event.pull_request.merged == true || github.event_name == 'workflow_dispatch'
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
@@ -94,9 +96,9 @@ jobs:
|
||||
fi
|
||||
MAJOR=$(echo "$VERSION" | cut -d. -f1)
|
||||
echo "version=${VERSION}" >> "$GITHUB_OUTPUT"
|
||||
echo "release_tag=v${MAJOR}" >> "$GITHUB_OUTPUT"
|
||||
echo "release_tag=stable" >> "$GITHUB_OUTPUT"
|
||||
echo "skip=false" >> "$GITHUB_OUTPUT"
|
||||
echo "branch=version/${MAJOR}" >> "$GITHUB_OUTPUT"
|
||||
echo "branch=main" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: "Step 1b: Bump version"
|
||||
id: bump
|
||||
@@ -261,6 +263,7 @@ jobs:
|
||||
run: |
|
||||
VERSION="${{ steps.bump.outputs.version || steps.version.outputs.version }}"
|
||||
php /tmp/moko-platform-api/cli/badge_update.php --path . --version "${VERSION}" 2>/dev/null || true
|
||||
php /tmp/moko-platform-api/cli/version_check.php --path . --fix 2>/dev/null || true
|
||||
|
||||
- name: "Step 5: Write update stream"
|
||||
if: >-
|
||||
@@ -268,6 +271,15 @@ jobs:
|
||||
steps.platform.outputs.platform == 'joomla'
|
||||
run: |
|
||||
VERSION="${{ steps.bump.outputs.version || steps.version.outputs.version }}"
|
||||
|
||||
# Fetch latest updates.xml from main so preserve logic has all channels
|
||||
GA_TOKEN="${{ secrets.GA_TOKEN }}"
|
||||
API="${GITEA_URL}/api/v1/repos/${{ github.repository }}"
|
||||
curl -sf -H "Authorization: token ${GA_TOKEN}" \
|
||||
"${API}/contents/updates.xml?ref=main" 2>/dev/null | \
|
||||
python3 -c "import sys,json,base64; print(base64.b64decode(json.load(sys.stdin)['content']).decode())" \
|
||||
> updates.xml 2>/dev/null || true
|
||||
|
||||
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}" \
|
||||
@@ -295,9 +307,7 @@ jobs:
|
||||
# -- STEP 6: Create tag ---------------------------------------------------
|
||||
- name: "Step 6: Create git tag"
|
||||
if: >-
|
||||
steps.version.outputs.skip != 'true' &&
|
||||
steps.check.outputs.tag_exists != 'true' &&
|
||||
steps.version.outputs.is_minor == 'true'
|
||||
steps.version.outputs.skip != 'true'
|
||||
run: |
|
||||
RELEASE_TAG="${{ steps.version.outputs.release_tag }}"
|
||||
# Only create the major release tag if it doesn't exist yet
|
||||
@@ -337,6 +347,8 @@ jobs:
|
||||
[ -z "$NOTES" ] && NOTES="Release ${VERSION}"
|
||||
|
||||
# Build release name: "Pretty Name VERSION (type_element-VERSION)"
|
||||
# Strip existing type prefix to prevent duplication
|
||||
EXT_ELEMENT=$(echo "$EXT_ELEMENT" | sed -E 's/^(pkg_|com_|mod_|plg_[a-z]+_|tpl_|lib_)//')
|
||||
TYPE_PREFIX=""
|
||||
case "${EXT_TYPE}" in
|
||||
plugin) TYPE_PREFIX="plg_${EXT_FOLDER}_" ;;
|
||||
@@ -407,6 +419,13 @@ jobs:
|
||||
# ZIP name: type_folder_element-VERSION (e.g. plg_system_mokojgdpc-01.01.00.zip)
|
||||
EXT_TYPE=$(sed -n 's/.*<extension[^>]*type="\([^"]*\)".*/\1/p' "$MANIFEST" | head -1)
|
||||
EXT_FOLDER=$(sed -n 's/.*<extension[^>]*group="\([^"]*\)".*/\1/p' "$MANIFEST" | head -1)
|
||||
# For packages, prefer <packagename> over filename-derived element
|
||||
if [ "$EXT_TYPE" = "package" ]; then
|
||||
PKG_NAME=$(sed -n 's/.*<packagename>\([^<]*\)<\/packagename>.*/\1/p' "$MANIFEST" 2>/dev/null | head -1)
|
||||
[ -n "$PKG_NAME" ] && EXT_ELEMENT="$PKG_NAME"
|
||||
fi
|
||||
# Strip existing type prefix to prevent duplication (e.g. pkg_mokowaas → mokowaas)
|
||||
EXT_ELEMENT=$(echo "$EXT_ELEMENT" | sed -E 's/^(pkg_|com_|mod_|plg_[a-z]+_|tpl_|lib_)//')
|
||||
TYPE_PREFIX=""
|
||||
case "${EXT_TYPE}" in
|
||||
plugin) TYPE_PREFIX="plg_${EXT_FOLDER}_" ;;
|
||||
@@ -442,110 +461,35 @@ jobs:
|
||||
SHA256_ZIP=$(sha256sum "/tmp/${ZIP_NAME}" | cut -d' ' -f1)
|
||||
SHA256_TAR=$(sha256sum "/tmp/${TAR_NAME}" | cut -d' ' -f1)
|
||||
|
||||
# -- Delete existing assets with same name before uploading ------
|
||||
# -- Get existing assets for cleanup --------------------------------
|
||||
ASSETS=$(curl -sf -H "Authorization: token ${{ secrets.GA_TOKEN }}" \
|
||||
"${API_BASE}/releases/${RELEASE_ID}/assets" 2>/dev/null || echo "[]")
|
||||
for ASSET_NAME in "$ZIP_NAME" "$TAR_NAME"; do
|
||||
|
||||
# -- Create per-file .sha256 checksum files -------------------------
|
||||
echo "${SHA256_ZIP} ${ZIP_NAME}" > "/tmp/${ZIP_NAME}.sha256"
|
||||
echo "${SHA256_TAR} ${TAR_NAME}" > "/tmp/${TAR_NAME}.sha256"
|
||||
|
||||
# -- Upload packages + checksums to release tag --------------------
|
||||
for ASSET in "${ZIP_NAME}" "${TAR_NAME}" "${ZIP_NAME}.sha256" "${TAR_NAME}.sha256"; do
|
||||
[ ! -f "/tmp/${ASSET}" ] && continue
|
||||
# Delete existing asset with same name
|
||||
ASSET_ID=$(echo "$ASSETS" | python3 -c "
|
||||
import sys,json
|
||||
assets = json.load(sys.stdin)
|
||||
for a in assets:
|
||||
if a['name'] == '${ASSET_NAME}':
|
||||
if a['name'] == '${ASSET}':
|
||||
print(a['id']); break
|
||||
" 2>/dev/null || true)
|
||||
if [ -n "$ASSET_ID" ]; then
|
||||
curl -sf -X DELETE -H "Authorization: token ${{ secrets.GA_TOKEN }}" \
|
||||
"${API_BASE}/releases/${RELEASE_ID}/assets/${ASSET_ID}" 2>/dev/null || true
|
||||
fi
|
||||
[ -n "$ASSET_ID" ] && curl -sf -X DELETE -H "Authorization: token ${{ secrets.GA_TOKEN }}" \
|
||||
"${API_BASE}/releases/${RELEASE_ID}/assets/${ASSET_ID}" 2>/dev/null || true
|
||||
# Upload
|
||||
curl -sf -X POST -H "Authorization: token ${{ secrets.GA_TOKEN }}" \
|
||||
-H "Content-Type: application/octet-stream" \
|
||||
--data-binary @"/tmp/${ASSET}" \
|
||||
"${API_BASE}/releases/${RELEASE_ID}/assets?name=${ASSET}" > /dev/null 2>&1 || true
|
||||
done
|
||||
|
||||
# -- Upload both to release tag ----------------------------------
|
||||
curl -sf -X POST -H "Authorization: token ${{ secrets.GA_TOKEN }}" \
|
||||
-H "Content-Type: application/octet-stream" \
|
||||
--data-binary @"/tmp/${ZIP_NAME}" \
|
||||
"${API_BASE}/releases/${RELEASE_ID}/assets?name=${ZIP_NAME}" > /dev/null 2>&1 || true
|
||||
|
||||
curl -sf -X POST -H "Authorization: token ${{ secrets.GA_TOKEN }}" \
|
||||
-H "Content-Type: application/octet-stream" \
|
||||
--data-binary @"/tmp/${TAR_NAME}" \
|
||||
"${API_BASE}/releases/${RELEASE_ID}/assets?name=${TAR_NAME}" > /dev/null 2>&1 || true
|
||||
|
||||
# -- Update updates.xml with both download formats ---------------
|
||||
if [ -f "updates.xml" ]; then
|
||||
ZIP_URL="${GITEA_URL}/${GITEA_ORG}/${GITEA_REPO}/releases/download/${RELEASE_TAG}/${ZIP_NAME}"
|
||||
TAR_URL="${GITEA_URL}/${GITEA_ORG}/${GITEA_REPO}/releases/download/${RELEASE_TAG}/${TAR_NAME}"
|
||||
|
||||
# Use Python to update only the stable entry's downloads + sha256
|
||||
export PY_ZIP_URL="$ZIP_URL" PY_TAR_URL="$TAR_URL" PY_SHA="$SHA256_ZIP"
|
||||
python3 << 'PYEOF'
|
||||
import re, os
|
||||
|
||||
with open("updates.xml") as f:
|
||||
content = f.read()
|
||||
|
||||
zip_url = os.environ["PY_ZIP_URL"]
|
||||
tar_url = os.environ["PY_TAR_URL"]
|
||||
sha = os.environ["PY_SHA"]
|
||||
|
||||
# Find the stable update block and replace its downloads + sha256
|
||||
def replace_stable(m):
|
||||
block = m.group(0)
|
||||
# Replace downloads block
|
||||
new_downloads = (
|
||||
" <downloads>\n"
|
||||
f" <downloadurl type=\"full\" format=\"zip\">{zip_url}</downloadurl>\n"
|
||||
" </downloads>"
|
||||
)
|
||||
block = re.sub(r' <downloads>.*?</downloads>', new_downloads, block, flags=re.DOTALL)
|
||||
# Add or replace sha256
|
||||
if '<sha256>' in block:
|
||||
block = re.sub(r' <sha256>.*?</sha256>', f' <sha256>{sha}</sha256>', block)
|
||||
else:
|
||||
block = block.replace('</downloads>', f'</downloads>\n <sha256>{sha}</sha256>')
|
||||
return block
|
||||
|
||||
content = re.sub(
|
||||
r' <update>.*?<tag>stable</tag>.*?</update>',
|
||||
replace_stable,
|
||||
content,
|
||||
flags=re.DOTALL
|
||||
)
|
||||
|
||||
with open("updates.xml", "w") as f:
|
||||
f.write(content)
|
||||
PYEOF
|
||||
|
||||
CURRENT_BRANCH="${{ github.ref_name }}"
|
||||
git add updates.xml
|
||||
git commit -m "chore(release): ZIP + tar.gz for ${VERSION} [skip ci]" \
|
||||
--author="gitea-actions[bot] <gitea-actions[bot]@mokoconsulting.tech>" || true
|
||||
git push || true
|
||||
|
||||
# Sync updates.xml to main via direct API (always runs — may be on version/XX branch)
|
||||
GA_TOKEN="${{ secrets.GA_TOKEN }}"
|
||||
API="${GITEA_URL:-https://git.mokoconsulting.tech}/api/v1/repos/${{ github.repository }}"
|
||||
|
||||
FILE_SHA=$(curl -sf -H "Authorization: token ${GA_TOKEN}" \
|
||||
"${API}/contents/updates.xml?ref=main" | jq -r '.sha // empty')
|
||||
|
||||
if [ -n "$FILE_SHA" ]; then
|
||||
CONTENT=$(base64 -w0 updates.xml)
|
||||
curl -sf -X PUT -H "Authorization: token ${GA_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} [skip ci]" \
|
||||
--arg branch "main" \
|
||||
'{content: $content, sha: $sha, message: $msg, branch: $branch}'
|
||||
)" > /dev/null 2>&1 \
|
||||
&& echo "updates.xml synced to main via API" \
|
||||
|| echo "WARNING: failed to sync updates.xml to main"
|
||||
else
|
||||
echo "WARNING: could not get updates.xml SHA from main"
|
||||
fi
|
||||
fi
|
||||
# updates.xml already handled by Step 5 (updates_xml_build.php with preserve logic)
|
||||
|
||||
echo "### Packages" >> $GITHUB_STEP_SUMMARY
|
||||
echo "" >> $GITHUB_STEP_SUMMARY
|
||||
@@ -556,72 +500,33 @@ jobs:
|
||||
echo "| Release | \`${RELEASE_TAG}\` | |" >> $GITHUB_STEP_SUMMARY
|
||||
echo "| Download | [${ZIP_NAME}](${GITEA_URL}/${GITEA_ORG}/${GITEA_REPO}/releases/download/${RELEASE_TAG}/${ZIP_NAME}) |" >> $GITHUB_STEP_SUMMARY
|
||||
|
||||
# -- STEP 8b: Update release description with changelog + SHA ----------------
|
||||
- name: "Step 8b: Update release body with changelog and SHA"
|
||||
# -- STEP 8b: Update release description with changelog ----------------------
|
||||
- name: "Step 8b: Update release body"
|
||||
if: steps.version.outputs.skip != 'true'
|
||||
run: |
|
||||
VERSION="${{ steps.bump.outputs.version || steps.version.outputs.version }}"
|
||||
RELEASE_TAG="${{ steps.version.outputs.release_tag }}"
|
||||
API_BASE="${GITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}"
|
||||
EXT_ELEMENT="${{ steps.updates.outputs.ext_element }}"
|
||||
EXT_TYPE="${{ steps.updates.outputs.ext_type }}"
|
||||
EXT_FOLDER="${{ steps.updates.outputs.ext_folder }}"
|
||||
MOKO_CLI="/tmp/moko-platform-api/cli"
|
||||
|
||||
# Build TYPE_PREFIX to match Step 8's ZIP naming
|
||||
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
|
||||
ZIP_NAME="${TYPE_PREFIX}${EXT_ELEMENT}-${VERSION}.zip"
|
||||
TAR_NAME="${TYPE_PREFIX}${EXT_ELEMENT}-${VERSION}.tar.gz"
|
||||
|
||||
# Get SHA from the built files
|
||||
SHA256_ZIP=""
|
||||
[ -f "/tmp/${ZIP_NAME}" ] && SHA256_ZIP=$(sha256sum "/tmp/${ZIP_NAME}" | cut -d' ' -f1)
|
||||
SHA256_TAR=""
|
||||
[ -f "/tmp/${TAR_NAME}" ] && SHA256_TAR=$(sha256sum "/tmp/${TAR_NAME}" | cut -d' ' -f1)
|
||||
|
||||
# Extract latest changelog entry (strip the ## header to avoid duplicate)
|
||||
CHANGELOG=""
|
||||
if [ -f "CHANGELOG.md" ]; then
|
||||
CHANGELOG=$(sed -n "/^## \[*${VERSION}/,/^## \[*[0-9]/p" CHANGELOG.md | sed '$d' | sed '1d')
|
||||
[ -z "$CHANGELOG" ] && CHANGELOG=$(sed -n '/^## /,/^## /p' CHANGELOG.md | sed '$d' | sed '1d' | head -30)
|
||||
fi
|
||||
|
||||
# Build release body (single header, no duplicate from changelog)
|
||||
BODY="## ${VERSION} ($(date +%Y-%m-%d))\n\n"
|
||||
if [ -n "$CHANGELOG" ]; then
|
||||
BODY="${BODY}${CHANGELOG}\n\n"
|
||||
fi
|
||||
BODY="${BODY}---\n\n### Checksums\n\n"
|
||||
BODY="${BODY}| File | SHA-256 |\n|------|--------|\n"
|
||||
[ -n "$SHA256_ZIP" ] && BODY="${BODY}| \`${ZIP_NAME}\` | \`${SHA256_ZIP}\` |\n"
|
||||
[ -n "$SHA256_TAR" ] && BODY="${BODY}| \`${TAR_NAME}\` | \`${SHA256_TAR}\` |\n"
|
||||
|
||||
# Get release ID and update body
|
||||
RELEASE_ID=$(curl -sf -H "Authorization: token ${{ secrets.GA_TOKEN }}" \
|
||||
"${API_BASE}/releases/tags/${RELEASE_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
|
||||
python3 -c "
|
||||
import json, urllib.request
|
||||
body = '''$(printf '%b' "$BODY")'''
|
||||
data = json.dumps({'body': body}).encode()
|
||||
req = urllib.request.Request(
|
||||
'${API_BASE}/releases/${RELEASE_ID}',
|
||||
data=data,
|
||||
headers={'Authorization': 'token ${{ secrets.GA_TOKEN }}', 'Content-Type': 'application/json'},
|
||||
method='PATCH'
|
||||
)
|
||||
urllib.request.urlopen(req)
|
||||
" 2>/dev/null && echo "Release body updated with changelog + SHA" >> $GITHUB_STEP_SUMMARY
|
||||
fi
|
||||
php ${MOKO_CLI}/release_body_update.php \
|
||||
--path . --version "${VERSION}" --tag "${RELEASE_TAG}" \
|
||||
--token "${{ secrets.GA_TOKEN }}" \
|
||||
--gitea-url "${GITEA_URL}" --org "${GITEA_ORG}" --repo "${GITEA_REPO}" \
|
||||
2>/dev/null || {
|
||||
# Fallback: simple body update if CLI not available
|
||||
API_BASE="${GITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}"
|
||||
RELEASE_ID=$(curl -sf -H "Authorization: token ${{ secrets.GA_TOKEN }}" \
|
||||
"${API_BASE}/releases/tags/${RELEASE_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
|
||||
BODY="## ${VERSION} ($(date +%Y-%m-%d))\n\nChecksum files attached as \`*.sha256\` assets."
|
||||
curl -sf -X PATCH -H "Authorization: token ${{ secrets.GA_TOKEN }}" \
|
||||
-H "Content-Type: application/json" \
|
||||
"${API_BASE}/releases/${RELEASE_ID}" \
|
||||
-d "{\"body\":\"${BODY}\"}" > /dev/null 2>&1
|
||||
fi
|
||||
}
|
||||
echo "Release body updated" >> $GITHUB_STEP_SUMMARY
|
||||
|
||||
# -- STEP 9: Mirror to GitHub (stable only) --------------------------------
|
||||
- name: "Step 9: Mirror release to GitHub"
|
||||
|
||||
@@ -124,16 +124,16 @@ jobs:
|
||||
echo "### PHPCS" >> $GITHUB_STEP_SUMMARY
|
||||
echo "PSR-12 compliance: passed" >> $GITHUB_STEP_SUMMARY
|
||||
|
||||
- name: "PHPStan (Level 2)"
|
||||
continue-on-error: true
|
||||
- name: "PHPStan (Level 6)"
|
||||
run: |
|
||||
vendor/bin/phpstan analyse -c phpstan.neon --no-progress --error-format=github 2>&1 || {
|
||||
echo "::warning::PHPStan found type errors (advisory)"
|
||||
vendor/bin/phpstan analyse -c phpstan.neon --no-progress --memory-limit=512M --error-format=github 2>&1 || {
|
||||
echo "::error::PHPStan found type errors"
|
||||
echo "### PHPStan" >> $GITHUB_STEP_SUMMARY
|
||||
echo "Static analysis errors detected. Run \`composer phpstan\` locally." >> $GITHUB_STEP_SUMMARY
|
||||
exit 1
|
||||
}
|
||||
echo "### PHPStan" >> $GITHUB_STEP_SUMMARY
|
||||
echo "Static analysis: advisory (level 0)" >> $GITHUB_STEP_SUMMARY
|
||||
echo "Static analysis (level 6): passed" >> $GITHUB_STEP_SUMMARY
|
||||
|
||||
- name: "Psalm"
|
||||
continue-on-error: true
|
||||
@@ -177,11 +177,14 @@ jobs:
|
||||
|
||||
- name: "PHPUnit (PHP ${{ matrix.php }})"
|
||||
run: |
|
||||
vendor/bin/phpunit --testdox 2>&1
|
||||
{
|
||||
echo "### PHPUnit (PHP ${{ matrix.php }})"
|
||||
echo "All tests passed."
|
||||
} >> $GITHUB_STEP_SUMMARY
|
||||
vendor/bin/phpunit --testdox 2>&1 || {
|
||||
echo "::error::PHPUnit tests failed"
|
||||
echo "### PHPUnit (PHP ${{ matrix.php }})" >> $GITHUB_STEP_SUMMARY
|
||||
echo "Tests failed. Run \`vendor/bin/phpunit --testdox\` locally." >> $GITHUB_STEP_SUMMARY
|
||||
exit 1
|
||||
}
|
||||
echo "### PHPUnit (PHP ${{ matrix.php }})" >> $GITHUB_STEP_SUMMARY
|
||||
echo "All tests passed." >> $GITHUB_STEP_SUMMARY
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════════
|
||||
# Gate 3 — Self-Health (Dogfood)
|
||||
|
||||
@@ -1,375 +1,375 @@
|
||||
# Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
|
||||
#
|
||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||
#
|
||||
# FILE INFORMATION
|
||||
# DEFGROUP: Gitea.Workflow
|
||||
# INGROUP: moko-platform.Release
|
||||
# REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform
|
||||
# PATH: /templates/workflows/universal/pre-release.yml.template
|
||||
# VERSION: 05.01.00
|
||||
# BRIEF: Manual pre-release -- builds dev/alpha/beta/rc packages from any branch
|
||||
|
||||
name: "Universal: Pre-Release"
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
stability:
|
||||
description: 'Pre-release channel'
|
||||
required: true
|
||||
type: choice
|
||||
options:
|
||||
- development
|
||||
- alpha
|
||||
- beta
|
||||
- release-candidate
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
env:
|
||||
GITEA_URL: ${{ vars.GITEA_URL || 'https://git.mokoconsulting.tech' }}
|
||||
GITEA_ORG: ${{ vars.GITEA_ORG || github.repository_owner }}
|
||||
GITEA_REPO: ${{ vars.GITEA_REPO || github.event.repository.name }}
|
||||
|
||||
jobs:
|
||||
build:
|
||||
name: "Build Pre-Release (${{ inputs.stability }})"
|
||||
runs-on: release
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
token: ${{ secrets.GA_TOKEN }}
|
||||
|
||||
- name: Setup tools
|
||||
run: |
|
||||
# Update moko-platform CLI tools if available; install PHP if missing
|
||||
if command -v moko-platform-update &> /dev/null; then
|
||||
moko-platform-update
|
||||
elif [ -d "/opt/moko-platform" ]; then
|
||||
cd /opt/moko-platform && git pull origin main --quiet 2>/dev/null || true
|
||||
else
|
||||
if ! command -v php &> /dev/null; then
|
||||
sudo apt-get update -qq
|
||||
sudo apt-get install -y -qq php-cli php-mbstring php-xml php-zip php-curl >/dev/null 2>&1
|
||||
fi
|
||||
git clone --depth 1 --branch main --quiet \
|
||||
"https://x-access-token:${{ secrets.GA_TOKEN }}@git.mokoconsulting.tech/MokoConsulting/moko-platform.git" \
|
||||
/tmp/moko-platform-api
|
||||
fi
|
||||
# Set MOKO_CLI to whichever path exists
|
||||
if [ -d "/opt/moko-platform/cli" ]; then
|
||||
echo "MOKO_CLI=/opt/moko-platform/cli" >> "$GITHUB_ENV"
|
||||
else
|
||||
echo "MOKO_CLI=/tmp/moko-platform-api/cli" >> "$GITHUB_ENV"
|
||||
fi
|
||||
|
||||
- name: Detect platform
|
||||
id: platform
|
||||
run: |
|
||||
PLATFORM=$(sed -n 's/.*<platform>\([^<]*\)<\/platform>.*/\1/p' .mokogitea/manifest.xml 2>/dev/null | head -1 | tr -d '[:space:]')
|
||||
[ -z "$PLATFORM" ] && PLATFORM="generic"
|
||||
echo "platform=$PLATFORM" >> "$GITHUB_OUTPUT"
|
||||
MANIFEST=$(find ./src -maxdepth 1 -name "pkg_*.xml" -exec grep -l '<extension' {} \; 2>/dev/null | head -1)
|
||||
[ -z "$MANIFEST" ] && MANIFEST=$(find . -maxdepth 3 -name "*.xml" ! -path "./.git/*" ! -path "*/packages/*" -exec grep -l '<extension' {} \; 2>/dev/null | head -1)
|
||||
[ -z "$MANIFEST" ] && MANIFEST=$(find . -maxdepth 3 -name "*.xml" ! -path "./.git/*" -exec grep -l '<extension' {} \; 2>/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)
|
||||
echo "manifest=${MANIFEST}" >> "$GITHUB_OUTPUT"
|
||||
echo "mod_file=${MOD_FILE}" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Resolve metadata and bump version
|
||||
id: meta
|
||||
run: |
|
||||
STABILITY="${{ inputs.stability }}"
|
||||
|
||||
case "$STABILITY" in
|
||||
development) SUFFIX="-dev"; TAG="development" ;;
|
||||
alpha) SUFFIX="-alpha"; TAG="alpha" ;;
|
||||
beta) SUFFIX="-beta"; TAG="beta" ;;
|
||||
release-candidate) SUFFIX="-rc"; TAG="release-candidate" ;;
|
||||
esac
|
||||
|
||||
# Patch bump via CLI tool
|
||||
php ${MOKO_CLI}/version_bump.php --path .
|
||||
VERSION=$(php ${MOKO_CLI}/version_read.php --path . 2>/dev/null)
|
||||
[ -z "$VERSION" ] && VERSION="00.00.01"
|
||||
TODAY=$(date +%Y-%m-%d)
|
||||
|
||||
# Update platform-specific manifest
|
||||
PLATFORM="${{ steps.platform.outputs.platform }}"
|
||||
MANIFEST="${{ steps.platform.outputs.manifest }}"
|
||||
MOD_FILE="${{ steps.platform.outputs.mod_file }}"
|
||||
|
||||
php ${MOKO_CLI}/version_set_platform.php \
|
||||
--path . --version "$VERSION" --branch "${{ github.ref_name }}" 2>/dev/null || true
|
||||
|
||||
# Commit version bump
|
||||
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): pre-release bump to ${VERSION} [skip ci]"
|
||||
git push origin HEAD 2>&1
|
||||
}
|
||||
|
||||
# Auto-detect element (platform-aware)
|
||||
EXT_ELEMENT=""
|
||||
case "$PLATFORM" in
|
||||
joomla)
|
||||
if [ -n "$MANIFEST" ]; then
|
||||
EXT_ELEMENT=$(sed -n 's/.*<element>\([^<]*\)<\/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)
|
||||
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
|
||||
|
||||
ZIP_NAME="${EXT_ELEMENT}-${VERSION}${SUFFIX}.zip"
|
||||
|
||||
echo "version=${VERSION}" >> "$GITHUB_OUTPUT"
|
||||
echo "stability=${STABILITY}" >> "$GITHUB_OUTPUT"
|
||||
echo "suffix=${SUFFIX}" >> "$GITHUB_OUTPUT"
|
||||
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
|
||||
|
||||
MANIFEST="${{ steps.meta.outputs.manifest }}"
|
||||
EXT_TYPE=""
|
||||
if [ -n "$MANIFEST" ]; then
|
||||
EXT_TYPE=$(sed -n 's/.*<extension[^>]*type="\([^"]*\)".*/\1/p' "$MANIFEST" | head -1)
|
||||
fi
|
||||
|
||||
EXCLUDES="sftp-config* .ftpignore *.ppk *.pem *.key .env* *.local .build-trigger"
|
||||
|
||||
mkdir -p build/package
|
||||
|
||||
if [ "$EXT_TYPE" = "package" ] && [ -d "${SOURCE_DIR}/packages" ]; then
|
||||
echo "=== Building Joomla PACKAGE (multi-extension) ==="
|
||||
for ext_dir in "${SOURCE_DIR}"/packages/*/; do
|
||||
[ ! -d "$ext_dir" ] && continue
|
||||
EXT_NAME=$(basename "$ext_dir")
|
||||
echo " Packaging sub-extension: ${EXT_NAME}"
|
||||
cd "$ext_dir"
|
||||
zip -r "../../build/package/${EXT_NAME}.zip" . -x $EXCLUDES
|
||||
cd "$OLDPWD"
|
||||
done
|
||||
for f in "${SOURCE_DIR}"/*.xml "${SOURCE_DIR}"/*.php; do
|
||||
[ -f "$f" ] && cp "$f" build/package/
|
||||
done
|
||||
else
|
||||
echo "=== Building standard extension ==="
|
||||
rsync -a \
|
||||
--exclude='sftp-config*' \
|
||||
--exclude='.ftpignore' \
|
||||
--exclude='*.ppk' \
|
||||
--exclude='*.pem' \
|
||||
--exclude='*.key' \
|
||||
--exclude='.env*' \
|
||||
--exclude='*.local' \
|
||||
--exclude='.build-trigger' \
|
||||
"${SOURCE_DIR}/" build/package/
|
||||
fi
|
||||
|
||||
- name: Create ZIP
|
||||
id: zip
|
||||
run: |
|
||||
ZIP_NAME="${{ steps.meta.outputs.zip_name }}"
|
||||
cd build/package
|
||||
zip -r "../${ZIP_NAME}" .
|
||||
cd ..
|
||||
|
||||
SHA256=$(sha256sum "${ZIP_NAME}" | cut -d' ' -f1)
|
||||
echo "sha256=${SHA256}" >> "$GITHUB_OUTPUT"
|
||||
echo "ZIP: ${ZIP_NAME} (SHA: ${SHA256:0:16}...)"
|
||||
|
||||
- name: Create or replace Gitea release
|
||||
id: release
|
||||
run: |
|
||||
TAG="${{ steps.meta.outputs.tag }}"
|
||||
VERSION="${{ steps.meta.outputs.version }}"
|
||||
STABILITY="${{ steps.meta.outputs.stability }}"
|
||||
SHA256="${{ steps.zip.outputs.sha256 }}"
|
||||
ZIP_NAME="${{ steps.meta.outputs.zip_name }}"
|
||||
EXT_ELEMENT="${{ steps.meta.outputs.ext_element }}"
|
||||
TOKEN="${{ secrets.GA_TOKEN }}"
|
||||
API="${GITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}"
|
||||
BRANCH=$(git branch --show-current)
|
||||
|
||||
BODY="## ${VERSION} ($(date +%Y-%m-%d))
|
||||
**Channel:** ${STABILITY}
|
||||
**SHA-256:** \`${SHA256}\`"
|
||||
|
||||
# Delete existing release
|
||||
EXISTING_ID=$(curl -sS -H "Authorization: token ${TOKEN}" \
|
||||
"${API}/releases/tags/${TAG}" | jq -r '.id // empty' 2>/dev/null)
|
||||
if [ -n "$EXISTING_ID" ]; then
|
||||
curl -sS -X DELETE -H "Authorization: token ${TOKEN}" \
|
||||
"${API}/releases/${EXISTING_ID}" 2>/dev/null || true
|
||||
curl -sS -X DELETE -H "Authorization: token ${TOKEN}" \
|
||||
"${API}/tags/${TAG}" 2>/dev/null || true
|
||||
fi
|
||||
|
||||
# Create release
|
||||
RELEASE_ID=$(curl -sS -X POST -H "Authorization: token ${TOKEN}" \
|
||||
-H "Content-Type: application/json" \
|
||||
"${API}/releases" \
|
||||
-d "$(jq -n \
|
||||
--arg tag "$TAG" \
|
||||
--arg target "$BRANCH" \
|
||||
--arg name "${EXT_ELEMENT} ${VERSION} (${STABILITY})" \
|
||||
--arg body "$BODY" \
|
||||
'{tag_name: $tag, target_commitish: $target, name: $name, body: $body, prerelease: true}'
|
||||
)" | jq -r '.id')
|
||||
|
||||
echo "release_id=${RELEASE_ID}" >> "$GITHUB_OUTPUT"
|
||||
|
||||
# Upload ZIP
|
||||
curl -sS -X POST -H "Authorization: token ${TOKEN}" \
|
||||
-H "Content-Type: application/octet-stream" \
|
||||
"${API}/releases/${RELEASE_ID}/assets?name=${ZIP_NAME}" \
|
||||
--data-binary "@build/${ZIP_NAME}"
|
||||
|
||||
echo "Released: ${EXT_ELEMENT} ${VERSION} (${STABILITY})"
|
||||
|
||||
- name: Update updates.xml
|
||||
if: steps.platform.outputs.platform == 'joomla'
|
||||
run: |
|
||||
STABILITY="${{ steps.meta.outputs.stability }}"
|
||||
VERSION="${{ steps.meta.outputs.version }}"
|
||||
SHA256="${{ steps.zip.outputs.sha256 }}"
|
||||
ZIP_NAME="${{ steps.meta.outputs.zip_name }}"
|
||||
TAG="${{ steps.meta.outputs.tag }}"
|
||||
|
||||
if [ ! -f "updates.xml" ]; then
|
||||
echo "No updates.xml -- skipping"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Map stability to XML tag name
|
||||
case "$STABILITY" in
|
||||
development) XML_TAG="development" ;;
|
||||
alpha) XML_TAG="alpha" ;;
|
||||
beta) XML_TAG="beta" ;;
|
||||
release-candidate) XML_TAG="rc" ;;
|
||||
*) XML_TAG="$STABILITY" ;;
|
||||
esac
|
||||
|
||||
DOWNLOAD_URL="${GITEA_URL}/${GITEA_ORG}/${GITEA_REPO}/releases/download/${TAG}/${ZIP_NAME}"
|
||||
|
||||
# Use PHP to update the channel in updates.xml
|
||||
php -r '
|
||||
$xml_tag = $argv[1];
|
||||
$version = $argv[2];
|
||||
$sha256 = $argv[3];
|
||||
$url = $argv[4];
|
||||
$date = date("Y-m-d");
|
||||
|
||||
$content = file_get_contents("updates.xml");
|
||||
$pattern = "/(<update>(?:(?!<\/update>).)*?<tag>" . preg_quote($xml_tag) . "<\/tag>.*?<\/update>)/s";
|
||||
|
||||
$content = preg_replace_callback($pattern, function($m) use ($version, $sha256, $url, $date) {
|
||||
$block = $m[0];
|
||||
$block = preg_replace("/<version>[^<]*<\/version>/", "<version>{$version}</version>", $block);
|
||||
if (strpos($block, "<sha256>") !== false) {
|
||||
$block = preg_replace("/<sha256>[^<]*<\/sha256>/", "<sha256>{$sha256}</sha256>", $block);
|
||||
} else {
|
||||
$block = str_replace("</downloads>", "</downloads>\n <sha256>{$sha256}</sha256>", $block);
|
||||
}
|
||||
$block = preg_replace("/(<downloadurl[^>]*>)[^<]*(<\/downloadurl>)/", "\${1}{$url}\${2}", $block);
|
||||
return $block;
|
||||
}, $content);
|
||||
|
||||
file_put_contents("updates.xml", $content);
|
||||
echo "Updated {$xml_tag} channel: version={$version}\n";
|
||||
' "$XML_TAG" "$VERSION" "$SHA256" "$DOWNLOAD_URL"
|
||||
|
||||
# Commit and push
|
||||
if ! git diff --quiet updates.xml 2>/dev/null; then
|
||||
git config --local user.email "gitea-actions[bot]@mokoconsulting.tech"
|
||||
git config --local user.name "gitea-actions[bot]"
|
||||
git add updates.xml
|
||||
git commit -m "chore: update ${STABILITY} channel ${VERSION} [skip ci]"
|
||||
git push origin HEAD 2>&1 || echo "WARNING: push failed"
|
||||
fi
|
||||
|
||||
- name: "Sync updates.xml to all branches"
|
||||
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]"
|
||||
|
||||
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"
|
||||
fi
|
||||
git checkout "${CURRENT_BRANCH}" 2>/dev/null
|
||||
done
|
||||
|
||||
- name: "Delete lesser pre-release channels (cascade)"
|
||||
continue-on-error: true
|
||||
run: |
|
||||
API_BASE="${GITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}"
|
||||
TOKEN="${{ secrets.GA_TOKEN }}"
|
||||
|
||||
php ${MOKO_CLI}/release_cascade.php \
|
||||
--stability "${{ steps.meta.outputs.stability }}" \
|
||||
--token "${TOKEN}" \
|
||||
--api-base "${API_BASE}"
|
||||
|
||||
- name: Summary
|
||||
if: always()
|
||||
run: |
|
||||
VERSION="${{ steps.meta.outputs.version }}"
|
||||
STABILITY="${{ steps.meta.outputs.stability }}"
|
||||
ZIP_NAME="${{ steps.meta.outputs.zip_name }}"
|
||||
SHA256="${{ steps.zip.outputs.sha256 }}"
|
||||
echo "## Pre-Release Complete" >> $GITHUB_STEP_SUMMARY
|
||||
echo "" >> $GITHUB_STEP_SUMMARY
|
||||
echo "| Field | Value |" >> $GITHUB_STEP_SUMMARY
|
||||
echo "|-------|-------|" >> $GITHUB_STEP_SUMMARY
|
||||
echo "| Version | \`${VERSION}\` |" >> $GITHUB_STEP_SUMMARY
|
||||
echo "| Channel | ${STABILITY} |" >> $GITHUB_STEP_SUMMARY
|
||||
echo "| Package | \`${ZIP_NAME}\` |" >> $GITHUB_STEP_SUMMARY
|
||||
echo "| SHA-256 | \`${SHA256:-n/a}\` |" >> $GITHUB_STEP_SUMMARY
|
||||
# Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
|
||||
#
|
||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||
#
|
||||
# FILE INFORMATION
|
||||
# DEFGROUP: Gitea.Workflow
|
||||
# INGROUP: moko-platform.Release
|
||||
# REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform
|
||||
# PATH: /templates/workflows/universal/pre-release.yml.template
|
||||
# VERSION: 05.01.00
|
||||
# BRIEF: Manual pre-release -- builds dev/alpha/beta/rc packages from any branch
|
||||
|
||||
name: "Universal: Pre-Release"
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
stability:
|
||||
description: 'Pre-release channel'
|
||||
required: true
|
||||
type: choice
|
||||
options:
|
||||
- development
|
||||
- alpha
|
||||
- beta
|
||||
- release-candidate
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
env:
|
||||
GITEA_URL: ${{ vars.GITEA_URL || 'https://git.mokoconsulting.tech' }}
|
||||
GITEA_ORG: ${{ vars.GITEA_ORG || github.repository_owner }}
|
||||
GITEA_REPO: ${{ vars.GITEA_REPO || github.event.repository.name }}
|
||||
|
||||
jobs:
|
||||
build:
|
||||
name: "Build Pre-Release (${{ inputs.stability }})"
|
||||
runs-on: release
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
token: ${{ secrets.GA_TOKEN }}
|
||||
|
||||
- name: Setup tools
|
||||
run: |
|
||||
# Update moko-platform CLI tools if available; install PHP if missing
|
||||
if command -v moko-platform-update &> /dev/null; then
|
||||
moko-platform-update
|
||||
elif [ -d "/opt/moko-platform" ]; then
|
||||
cd /opt/moko-platform && git pull origin main --quiet 2>/dev/null || true
|
||||
else
|
||||
if ! command -v php &> /dev/null; then
|
||||
sudo apt-get update -qq
|
||||
sudo apt-get install -y -qq php-cli php-mbstring php-xml php-zip php-curl >/dev/null 2>&1
|
||||
fi
|
||||
git clone --depth 1 --branch main --quiet \
|
||||
"https://x-access-token:${{ secrets.GA_TOKEN }}@git.mokoconsulting.tech/MokoConsulting/moko-platform.git" \
|
||||
/tmp/moko-platform-api
|
||||
fi
|
||||
# Set MOKO_CLI to whichever path exists
|
||||
if [ -d "/opt/moko-platform/cli" ]; then
|
||||
echo "MOKO_CLI=/opt/moko-platform/cli" >> "$GITHUB_ENV"
|
||||
else
|
||||
echo "MOKO_CLI=/tmp/moko-platform-api/cli" >> "$GITHUB_ENV"
|
||||
fi
|
||||
|
||||
- name: Detect platform
|
||||
id: platform
|
||||
run: |
|
||||
PLATFORM=$(sed -n 's/.*<platform>\([^<]*\)<\/platform>.*/\1/p' .mokogitea/manifest.xml 2>/dev/null | head -1 | tr -d '[:space:]')
|
||||
[ -z "$PLATFORM" ] && PLATFORM="generic"
|
||||
echo "platform=$PLATFORM" >> "$GITHUB_OUTPUT"
|
||||
MANIFEST=$(find ./src -maxdepth 1 -name "pkg_*.xml" -exec grep -l '<extension' {} \; 2>/dev/null | head -1)
|
||||
[ -z "$MANIFEST" ] && MANIFEST=$(find . -maxdepth 3 -name "*.xml" ! -path "./.git/*" ! -path "*/packages/*" -exec grep -l '<extension' {} \; 2>/dev/null | head -1)
|
||||
[ -z "$MANIFEST" ] && MANIFEST=$(find . -maxdepth 3 -name "*.xml" ! -path "./.git/*" -exec grep -l '<extension' {} \; 2>/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)
|
||||
echo "manifest=${MANIFEST}" >> "$GITHUB_OUTPUT"
|
||||
echo "mod_file=${MOD_FILE}" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Resolve metadata and bump version
|
||||
id: meta
|
||||
run: |
|
||||
STABILITY="${{ inputs.stability }}"
|
||||
|
||||
case "$STABILITY" in
|
||||
development) SUFFIX="-dev"; TAG="development" ;;
|
||||
alpha) SUFFIX="-alpha"; TAG="alpha" ;;
|
||||
beta) SUFFIX="-beta"; TAG="beta" ;;
|
||||
release-candidate) SUFFIX="-rc"; TAG="release-candidate" ;;
|
||||
esac
|
||||
|
||||
# Patch bump via CLI tool
|
||||
php ${MOKO_CLI}/version_bump.php --path .
|
||||
VERSION=$(php ${MOKO_CLI}/version_read.php --path . 2>/dev/null)
|
||||
[ -z "$VERSION" ] && VERSION="00.00.01"
|
||||
TODAY=$(date +%Y-%m-%d)
|
||||
|
||||
# Update platform-specific manifest
|
||||
PLATFORM="${{ steps.platform.outputs.platform }}"
|
||||
MANIFEST="${{ steps.platform.outputs.manifest }}"
|
||||
MOD_FILE="${{ steps.platform.outputs.mod_file }}"
|
||||
|
||||
php ${MOKO_CLI}/version_set_platform.php \
|
||||
--path . --version "$VERSION" --branch "${{ github.ref_name }}" 2>/dev/null || true
|
||||
|
||||
# Commit version bump
|
||||
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): pre-release bump to ${VERSION} [skip ci]"
|
||||
git push origin HEAD 2>&1
|
||||
}
|
||||
|
||||
# Auto-detect element (platform-aware)
|
||||
EXT_ELEMENT=""
|
||||
case "$PLATFORM" in
|
||||
joomla)
|
||||
if [ -n "$MANIFEST" ]; then
|
||||
EXT_ELEMENT=$(sed -n 's/.*<element>\([^<]*\)<\/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)
|
||||
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
|
||||
|
||||
ZIP_NAME="${EXT_ELEMENT}-${VERSION}${SUFFIX}.zip"
|
||||
|
||||
echo "version=${VERSION}" >> "$GITHUB_OUTPUT"
|
||||
echo "stability=${STABILITY}" >> "$GITHUB_OUTPUT"
|
||||
echo "suffix=${SUFFIX}" >> "$GITHUB_OUTPUT"
|
||||
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
|
||||
|
||||
MANIFEST="${{ steps.meta.outputs.manifest }}"
|
||||
EXT_TYPE=""
|
||||
if [ -n "$MANIFEST" ]; then
|
||||
EXT_TYPE=$(sed -n 's/.*<extension[^>]*type="\([^"]*\)".*/\1/p' "$MANIFEST" | head -1)
|
||||
fi
|
||||
|
||||
EXCLUDES="sftp-config* .ftpignore *.ppk *.pem *.key .env* *.local .build-trigger"
|
||||
|
||||
mkdir -p build/package
|
||||
|
||||
if [ "$EXT_TYPE" = "package" ] && [ -d "${SOURCE_DIR}/packages" ]; then
|
||||
echo "=== Building Joomla PACKAGE (multi-extension) ==="
|
||||
for ext_dir in "${SOURCE_DIR}"/packages/*/; do
|
||||
[ ! -d "$ext_dir" ] && continue
|
||||
EXT_NAME=$(basename "$ext_dir")
|
||||
echo " Packaging sub-extension: ${EXT_NAME}"
|
||||
cd "$ext_dir"
|
||||
zip -r "../../build/package/${EXT_NAME}.zip" . -x $EXCLUDES
|
||||
cd "$OLDPWD"
|
||||
done
|
||||
for f in "${SOURCE_DIR}"/*.xml "${SOURCE_DIR}"/*.php; do
|
||||
[ -f "$f" ] && cp "$f" build/package/
|
||||
done
|
||||
else
|
||||
echo "=== Building standard extension ==="
|
||||
rsync -a \
|
||||
--exclude='sftp-config*' \
|
||||
--exclude='.ftpignore' \
|
||||
--exclude='*.ppk' \
|
||||
--exclude='*.pem' \
|
||||
--exclude='*.key' \
|
||||
--exclude='.env*' \
|
||||
--exclude='*.local' \
|
||||
--exclude='.build-trigger' \
|
||||
"${SOURCE_DIR}/" build/package/
|
||||
fi
|
||||
|
||||
- name: Create ZIP
|
||||
id: zip
|
||||
run: |
|
||||
ZIP_NAME="${{ steps.meta.outputs.zip_name }}"
|
||||
cd build/package
|
||||
zip -r "../${ZIP_NAME}" .
|
||||
cd ..
|
||||
|
||||
SHA256=$(sha256sum "${ZIP_NAME}" | cut -d' ' -f1)
|
||||
echo "sha256=${SHA256}" >> "$GITHUB_OUTPUT"
|
||||
echo "ZIP: ${ZIP_NAME} (SHA: ${SHA256:0:16}...)"
|
||||
|
||||
- name: Create or replace Gitea release
|
||||
id: release
|
||||
run: |
|
||||
TAG="${{ steps.meta.outputs.tag }}"
|
||||
VERSION="${{ steps.meta.outputs.version }}"
|
||||
STABILITY="${{ steps.meta.outputs.stability }}"
|
||||
SHA256="${{ steps.zip.outputs.sha256 }}"
|
||||
ZIP_NAME="${{ steps.meta.outputs.zip_name }}"
|
||||
EXT_ELEMENT="${{ steps.meta.outputs.ext_element }}"
|
||||
TOKEN="${{ secrets.GA_TOKEN }}"
|
||||
API="${GITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}"
|
||||
BRANCH=$(git branch --show-current)
|
||||
|
||||
BODY="## ${VERSION} ($(date +%Y-%m-%d))
|
||||
**Channel:** ${STABILITY}
|
||||
**SHA-256:** \`${SHA256}\`"
|
||||
|
||||
# Delete existing release
|
||||
EXISTING_ID=$(curl -sS -H "Authorization: token ${TOKEN}" \
|
||||
"${API}/releases/tags/${TAG}" | jq -r '.id // empty' 2>/dev/null)
|
||||
if [ -n "$EXISTING_ID" ]; then
|
||||
curl -sS -X DELETE -H "Authorization: token ${TOKEN}" \
|
||||
"${API}/releases/${EXISTING_ID}" 2>/dev/null || true
|
||||
curl -sS -X DELETE -H "Authorization: token ${TOKEN}" \
|
||||
"${API}/tags/${TAG}" 2>/dev/null || true
|
||||
fi
|
||||
|
||||
# Create release
|
||||
RELEASE_ID=$(curl -sS -X POST -H "Authorization: token ${TOKEN}" \
|
||||
-H "Content-Type: application/json" \
|
||||
"${API}/releases" \
|
||||
-d "$(jq -n \
|
||||
--arg tag "$TAG" \
|
||||
--arg target "$BRANCH" \
|
||||
--arg name "${EXT_ELEMENT} ${VERSION} (${STABILITY})" \
|
||||
--arg body "$BODY" \
|
||||
'{tag_name: $tag, target_commitish: $target, name: $name, body: $body, prerelease: true}'
|
||||
)" | jq -r '.id')
|
||||
|
||||
echo "release_id=${RELEASE_ID}" >> "$GITHUB_OUTPUT"
|
||||
|
||||
# Upload ZIP
|
||||
curl -sS -X POST -H "Authorization: token ${TOKEN}" \
|
||||
-H "Content-Type: application/octet-stream" \
|
||||
"${API}/releases/${RELEASE_ID}/assets?name=${ZIP_NAME}" \
|
||||
--data-binary "@build/${ZIP_NAME}"
|
||||
|
||||
echo "Released: ${EXT_ELEMENT} ${VERSION} (${STABILITY})"
|
||||
|
||||
- name: Update updates.xml
|
||||
if: steps.platform.outputs.platform == 'joomla'
|
||||
run: |
|
||||
STABILITY="${{ steps.meta.outputs.stability }}"
|
||||
VERSION="${{ steps.meta.outputs.version }}"
|
||||
SHA256="${{ steps.zip.outputs.sha256 }}"
|
||||
ZIP_NAME="${{ steps.meta.outputs.zip_name }}"
|
||||
TAG="${{ steps.meta.outputs.tag }}"
|
||||
|
||||
if [ ! -f "updates.xml" ]; then
|
||||
echo "No updates.xml -- skipping"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Map stability to XML tag name
|
||||
case "$STABILITY" in
|
||||
development) XML_TAG="development" ;;
|
||||
alpha) XML_TAG="alpha" ;;
|
||||
beta) XML_TAG="beta" ;;
|
||||
release-candidate) XML_TAG="rc" ;;
|
||||
*) XML_TAG="$STABILITY" ;;
|
||||
esac
|
||||
|
||||
DOWNLOAD_URL="${GITEA_URL}/${GITEA_ORG}/${GITEA_REPO}/releases/download/${TAG}/${ZIP_NAME}"
|
||||
|
||||
# Use PHP to update the channel in updates.xml
|
||||
php -r '
|
||||
$xml_tag = $argv[1];
|
||||
$version = $argv[2];
|
||||
$sha256 = $argv[3];
|
||||
$url = $argv[4];
|
||||
$date = date("Y-m-d");
|
||||
|
||||
$content = file_get_contents("updates.xml");
|
||||
$pattern = "/(<update>(?:(?!<\/update>).)*?<tag>" . preg_quote($xml_tag) . "<\/tag>.*?<\/update>)/s";
|
||||
|
||||
$content = preg_replace_callback($pattern, function($m) use ($version, $sha256, $url, $date) {
|
||||
$block = $m[0];
|
||||
$block = preg_replace("/<version>[^<]*<\/version>/", "<version>{$version}</version>", $block);
|
||||
if (strpos($block, "<sha256>") !== false) {
|
||||
$block = preg_replace("/<sha256>[^<]*<\/sha256>/", "<sha256>{$sha256}</sha256>", $block);
|
||||
} else {
|
||||
$block = str_replace("</downloads>", "</downloads>\n <sha256>{$sha256}</sha256>", $block);
|
||||
}
|
||||
$block = preg_replace("/(<downloadurl[^>]*>)[^<]*(<\/downloadurl>)/", "\${1}{$url}\${2}", $block);
|
||||
return $block;
|
||||
}, $content);
|
||||
|
||||
file_put_contents("updates.xml", $content);
|
||||
echo "Updated {$xml_tag} channel: version={$version}\n";
|
||||
' "$XML_TAG" "$VERSION" "$SHA256" "$DOWNLOAD_URL"
|
||||
|
||||
# Commit and push
|
||||
if ! git diff --quiet updates.xml 2>/dev/null; then
|
||||
git config --local user.email "gitea-actions[bot]@mokoconsulting.tech"
|
||||
git config --local user.name "gitea-actions[bot]"
|
||||
git add updates.xml
|
||||
git commit -m "chore: update ${STABILITY} channel ${VERSION} [skip ci]"
|
||||
git push origin HEAD 2>&1 || echo "WARNING: push failed"
|
||||
fi
|
||||
|
||||
- name: "Sync updates.xml to all branches"
|
||||
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]"
|
||||
|
||||
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"
|
||||
fi
|
||||
git checkout "${CURRENT_BRANCH}" 2>/dev/null
|
||||
done
|
||||
|
||||
- name: "Delete lesser pre-release channels (cascade)"
|
||||
continue-on-error: true
|
||||
run: |
|
||||
API_BASE="${GITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}"
|
||||
TOKEN="${{ secrets.GA_TOKEN }}"
|
||||
|
||||
php ${MOKO_CLI}/release_cascade.php \
|
||||
--stability "${{ steps.meta.outputs.stability }}" \
|
||||
--token "${TOKEN}" \
|
||||
--api-base "${API_BASE}"
|
||||
|
||||
- name: Summary
|
||||
if: always()
|
||||
run: |
|
||||
VERSION="${{ steps.meta.outputs.version }}"
|
||||
STABILITY="${{ steps.meta.outputs.stability }}"
|
||||
ZIP_NAME="${{ steps.meta.outputs.zip_name }}"
|
||||
SHA256="${{ steps.zip.outputs.sha256 }}"
|
||||
echo "## Pre-Release Complete" >> $GITHUB_STEP_SUMMARY
|
||||
echo "" >> $GITHUB_STEP_SUMMARY
|
||||
echo "| Field | Value |" >> $GITHUB_STEP_SUMMARY
|
||||
echo "|-------|-------|" >> $GITHUB_STEP_SUMMARY
|
||||
echo "| Version | \`${VERSION}\` |" >> $GITHUB_STEP_SUMMARY
|
||||
echo "| Channel | ${STABILITY} |" >> $GITHUB_STEP_SUMMARY
|
||||
echo "| Package | \`${ZIP_NAME}\` |" >> $GITHUB_STEP_SUMMARY
|
||||
echo "| SHA-256 | \`${SHA256:-n/a}\` |" >> $GITHUB_STEP_SUMMARY
|
||||
|
||||
+44
-5
@@ -18,15 +18,54 @@ Version format: `XX.YY.ZZ` (zero-padded semver).
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [09.00.00] - 2026-05-26
|
||||
|
||||
### Added
|
||||
- `cli/client_provision.php` — end-to-end client onboarding (addresses #4)
|
||||
- `cli/client_dashboard.php` — unified client dashboard: health, SSL, uptime, releases (closes #3)
|
||||
- PHPDoc on Priority 1 Enterprise classes (CliFramework, adapters, ApiClient)
|
||||
- Wiki: Coding-Standards page with PHPDoc standard, PHPCS exclusions, file patterns
|
||||
- CI: PHPStan enforced at level 6 (was advisory), PHPUnit blocks on failure
|
||||
|
||||
### Fixed
|
||||
- `updates_xml_build.php`: cascade entries down to lower channels — stable now writes all 5 entries instead of wiping them
|
||||
- `updates_xml_build.php`: separate Joomla stability tags (`dev`, `rc`) from Gitea release tags (`development`, `release-candidate`) — download URLs now point to correct release assets
|
||||
- `updates_xml_build.php`: only emit `<client>site</client>` for templates and modules, not packages or components
|
||||
- `updates_xml_build.php`: preservation logic matches Joomla tag names when deciding which existing entries to keep
|
||||
|
||||
## [08.00.00] - 2026-05-26
|
||||
|
||||
### Changed
|
||||
- PHPStan: level 5 → 6 (401 baselined, 0 new errors)
|
||||
- Branch protection: 5 required checks enabled on main
|
||||
- Workflows synced to all governed repos (72+ repos across 3 orgs)
|
||||
- Flushed 44 stale runners from Gitea admin (3 active remain)
|
||||
|
||||
### Fixed
|
||||
- PHPStan level 3→4: removed 13 dead properties, 41 defensive patterns baselined
|
||||
- PHPStan level 4→5: fixed metrics `increment()` bug (labels passed as value param)
|
||||
- PHPStan level 5→6: 360 missing array generic types baselined
|
||||
|
||||
## [07.00.00] - 2026-05-25
|
||||
|
||||
### Added
|
||||
- `cli/client_provision.php` — end-to-end client onboarding from JSON config (closes #4)
|
||||
- `cli/client_dashboard.php` — unified HTML dashboard: health, SSL, uptime, releases (closes #3)
|
||||
- `cli/client_health_check.php`, `cli/joomla_compat_check.php`, `cli/theme_lint.php` — new CLI tools
|
||||
- `lib/Enterprise/ConfigValidator.php` — JSON schema validator for plugin configs (closes #105)
|
||||
- PHPUnit test infrastructure: `phpunit.xml` + 19 tests (closes #102)
|
||||
- `bin/moko list` — auto-grouped command list with 45 commands, plugin command dispatcher (closes #104)
|
||||
- `templates/client-provision-example.json` — example config for client provisioning
|
||||
|
||||
### Fixed
|
||||
- `release_cascade.php`: accept `release-candidate` as stability value (was only accepting `rc`, causing cascade to silently skip)
|
||||
- PHPStan bumped from level 0 to level 2 — fixed 67 type errors (undefined variables, missing methods, wrong signatures, dead code)
|
||||
- `package_build.php`: fix 0-byte ZIP for Joomla package extensions — sub-zips now in `packages/` subdir, no double `pkg_pkg_` prefix, includes `language/` dir (closes #92)
|
||||
- `bin/moko` COMMAND_MAP: all paths pointed to non-existent `api/` directory (closes #100)
|
||||
- `release_cascade.php`: accept `release-candidate` as stability value (was silently skipping)
|
||||
- `package_build.php`: fix 0-byte ZIP for Joomla packages — correct structure, no double prefix (closes #92)
|
||||
- PHPStan: level 0 to 2, 67 type errors fixed, 0 exclusions
|
||||
- `ApiClient::delete()`: accept optional body parameter for Gitea Contents API
|
||||
|
||||
### Changed
|
||||
- Migrated all 7 CLIApp scripts to CliFramework (closes #101)
|
||||
- Updated CLAUDE.md with current architecture, CLI patterns, code quality (closes #103)
|
||||
- Wiki CLI_AUTOMATION page updated with all tools
|
||||
|
||||
## [06.00.00] - 2026-05-25
|
||||
|
||||
|
||||
@@ -4,34 +4,100 @@ This file provides guidance to Claude Code when working with this repository.
|
||||
|
||||
## Project Overview
|
||||
|
||||
**moko-platform** -- Enterprise automation, validation, sync, and governance engine for all Moko Consulting repositories
|
||||
**moko-platform** — Enterprise automation, validation, sync, and governance engine for all Moko Consulting repositories
|
||||
|
||||
| Field | Value |
|
||||
|---|---|
|
||||
| **Platform** | generic |
|
||||
| **Language** | HCL |
|
||||
| **Language** | PHP 8.1+ |
|
||||
| **Default branch** | main |
|
||||
| **License** | GPL-3.0-or-later |
|
||||
| **Version** | 06.00.00 |
|
||||
| **Wiki** | [moko-platform Wiki](https://git.mokoconsulting.tech/MokoConsulting/moko-platform/wiki) |
|
||||
| **Standards** | [MokoStandards](https://git.mokoconsulting.tech/MokoConsulting/moko-platform/wiki/Home) |
|
||||
|
||||
## Common Commands
|
||||
|
||||
```bash
|
||||
composer install # Install PHP dependencies
|
||||
composer install # Install PHP dependencies
|
||||
php bin/moko health --path . # Run repo health check
|
||||
php bin/moko check:syntax --path . # PHP syntax check
|
||||
php bin/moko drift --org MokoConsulting # Scan for standards drift
|
||||
php bin/moko dashboard --token $TOKEN -o dashboard.html # Generate client dashboard
|
||||
|
||||
# Code quality
|
||||
php vendor/bin/phpcs --standard=phpcs.xml -n lib/ validate/ automation/ cli/
|
||||
php vendor/bin/phpcbf --standard=phpcs.xml lib/ validate/ automation/ cli/
|
||||
php vendor/bin/phpstan analyse -c phpstan.neon --memory-limit=512M
|
||||
|
||||
# Run all checks
|
||||
composer check
|
||||
```
|
||||
|
||||
## Architecture
|
||||
|
||||
See the [wiki](https://git.mokoconsulting.tech/MokoConsulting/moko-platform/wiki) for architecture details.
|
||||
### Directory Layout
|
||||
|
||||
| Directory | Purpose |
|
||||
|-----------|---------|
|
||||
| `cli/` | 32 standalone CLI tools (version, release, build, repo management) |
|
||||
| `validate/` | 20 validation scripts (syntax, structure, manifests, drift) |
|
||||
| `automation/` | 7 bulk operations (sync, push files, templates, cleanup) |
|
||||
| `lib/Enterprise/` | Core library — CliFramework, ApiClient, adapters, validators |
|
||||
| `lib/Enterprise/Plugins/` | 11 platform plugins (Joomla, Dolibarr, Node.js, Python, etc.) |
|
||||
| `deploy/` | SFTP deployment scripts (Joomla, Dolibarr, health checks) |
|
||||
| `definitions/` | Repository structure definitions (HCL format) |
|
||||
| `templates/` | Workflow templates, config templates, docs templates |
|
||||
| `.mokogitea/workflows/` | CI/CD workflows (Gitea Actions) |
|
||||
| `bin/moko` | Unified CLI dispatcher — runs any tool via `php bin/moko <command>` |
|
||||
|
||||
### CLI Framework
|
||||
|
||||
All CLI tools extend `MokoEnterprise\CliFramework` (defined in `lib/Enterprise/CliFramework.php`).
|
||||
|
||||
Pattern for new tools:
|
||||
```php
|
||||
class MyTool extends CliFramework {
|
||||
protected function configure(): void {
|
||||
$this->setDescription('What this tool does');
|
||||
$this->addArgument('--name', 'Description', 'default');
|
||||
}
|
||||
protected function run(): int {
|
||||
$name = $this->getArgument('--name');
|
||||
// ... business logic ...
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
$app = new MyTool();
|
||||
exit($app->execute());
|
||||
```
|
||||
|
||||
Built-in flags: `--help`, `--verbose`, `--quiet`, `--dry-run`
|
||||
|
||||
### Platform Adapters
|
||||
|
||||
Git operations are abstracted via `GitPlatformAdapter` interface:
|
||||
- `MokoGiteaAdapter` — for git.mokoconsulting.tech (primary)
|
||||
- `GitHubAdapter` — for github.com mirrors
|
||||
|
||||
### Plugin System
|
||||
|
||||
Platform-specific logic lives in `lib/Enterprise/Plugins/`. Each plugin implements `ProjectPluginInterface` with methods for health checks, validation, build commands, and config schemas.
|
||||
|
||||
## Code Quality
|
||||
|
||||
| Tool | Level | Config |
|
||||
|------|-------|--------|
|
||||
| PHPCS | PSR-12 (errors only) | `phpcs.xml` |
|
||||
| PHPStan | Level 2 | `phpstan.neon` |
|
||||
|
||||
PHPStan runs with `--memory-limit=512M` due to large codebase. CI enforces PHPCS errors; PHPStan is advisory (`continue-on-error`).
|
||||
|
||||
## Rules
|
||||
|
||||
- **Workflow directory**: `.mokogitea/` (not `.gitea/` or `.github/`)
|
||||
|
||||
- **Never commit** `.claude/`, `.mcp.json`, `TODO.md`, or `*.min.css`/`*.min.js`
|
||||
- **Attribution**: use `Authored-by: Moko Consulting` in commits
|
||||
- **Branch strategy**: develop on `dev`, merge to `main` for release
|
||||
- **Minification**: handled at build time (CI) and runtime (MokoMinifyHelper for Joomla templates)
|
||||
- **Wiki**: documentation lives in the Gitea wiki, not in `docs/` files
|
||||
- **Standards**: this repo follows [MokoStandards](https://git.mokoconsulting.tech/MokoConsulting/moko-platform/wiki/Home)
|
||||
- **New CLI tools**: extend `CliFramework`, not `CLIApp` (legacy)
|
||||
- **After adding a CLI tool**: register it in `bin/moko` COMMAND_MAP
|
||||
|
||||
@@ -30,7 +30,7 @@ require_once __DIR__ . '/../lib/Enterprise/CliFramework.php';
|
||||
|
||||
use MokoEnterprise\{
|
||||
AuditLogger,
|
||||
CLIApp,
|
||||
CliFramework,
|
||||
Config,
|
||||
GitPlatformAdapter,
|
||||
MetricsCollector,
|
||||
@@ -47,31 +47,28 @@ use MokoEnterprise\{
|
||||
*
|
||||
* Works with both GitHub and Gitea via the PlatformAdapterFactory.
|
||||
*/
|
||||
class BulkJoomlaTemplate extends CLIApp
|
||||
class BulkJoomlaTemplate extends CliFramework
|
||||
{
|
||||
public const DEFAULT_ORG = 'MokoConsulting';
|
||||
public const VERSION = '04.06.10';
|
||||
|
||||
private GitPlatformAdapter $adapter;
|
||||
private AuditLogger $logger;
|
||||
private Config $config;
|
||||
|
||||
protected function setupArguments(): array
|
||||
protected function configure(): void
|
||||
{
|
||||
return [
|
||||
'org:' => 'Organization (default: ' . self::DEFAULT_ORG . ')',
|
||||
'scaffold' => 'Create a new Joomla template repository',
|
||||
'sync' => 'Sync MokoStandards files to existing template repos',
|
||||
'list' => 'List all joomla-template repositories',
|
||||
'name:' => 'Template name for --scaffold (e.g. MokoTheme)',
|
||||
'client:' => 'Joomla client: site (default) or administrator',
|
||||
'repos:' => 'Target repositories for --sync (comma-separated, or use --all)',
|
||||
'all' => 'Sync all repos tagged joomla-template',
|
||||
'sync-updates' => 'Sync updates.xml between Gitea and GitHub for Joomla repos',
|
||||
'private' => 'Create as private repository (--scaffold)',
|
||||
'dry-run' => 'Preview changes without making them',
|
||||
'yes' => 'Auto-confirm prompts',
|
||||
];
|
||||
$this->setDescription('Bulk Joomla template management');
|
||||
$this->addArgument('--org', 'Organization', self::DEFAULT_ORG);
|
||||
$this->addArgument('--scaffold', 'Create new template repo', false);
|
||||
$this->addArgument('--sync', 'Sync files to template repos', false);
|
||||
$this->addArgument('--list', 'List template repos', false);
|
||||
$this->addArgument('--name', 'Template name for scaffold', '');
|
||||
$this->addArgument('--client', 'Joomla client: site or admin', 'site');
|
||||
$this->addArgument('--repos', 'Target repos (comma-separated)', '');
|
||||
$this->addArgument('--all', 'Sync all tagged repos', false);
|
||||
$this->addArgument('--sync-updates', 'Sync updates.xml', false);
|
||||
$this->addArgument('--private', 'Create as private repo', false);
|
||||
$this->addArgument('--yes', 'Auto-confirm', false);
|
||||
}
|
||||
|
||||
protected function run(): int
|
||||
@@ -87,24 +84,23 @@ class BulkJoomlaTemplate extends CLIApp
|
||||
return 1;
|
||||
}
|
||||
|
||||
$this->logger = new AuditLogger('joomla_template');
|
||||
$org = $this->getOption('org', self::DEFAULT_ORG);
|
||||
$org = $this->getArgument('--org', self::DEFAULT_ORG);
|
||||
$platform = $this->adapter->getPlatformName();
|
||||
$this->log("Platform: {$platform} | Organization: {$org}", 'INFO');
|
||||
|
||||
if ($this->hasOption('list')) {
|
||||
if ($this->getArgument('--list', false)) {
|
||||
return $this->listTemplateRepos($org);
|
||||
}
|
||||
|
||||
if ($this->hasOption('scaffold')) {
|
||||
if ($this->getArgument('--scaffold', false)) {
|
||||
return $this->scaffoldTemplate($org);
|
||||
}
|
||||
|
||||
if ($this->hasOption('sync')) {
|
||||
if ($this->getArgument('--sync', false)) {
|
||||
return $this->syncTemplates($org);
|
||||
}
|
||||
|
||||
if ($this->hasOption('sync-updates')) {
|
||||
if ($this->getArgument('--sync-updates', false)) {
|
||||
return $this->syncUpdatesBetweenPlatforms($org);
|
||||
}
|
||||
|
||||
@@ -138,9 +134,9 @@ class BulkJoomlaTemplate extends CLIApp
|
||||
|
||||
private function scaffoldTemplate(string $org): int
|
||||
{
|
||||
$name = $this->getOption('name', '');
|
||||
$client = $this->getOption('client', 'site');
|
||||
$dryRun = $this->hasOption('dry-run');
|
||||
$name = $this->getArgument('--name', '');
|
||||
$client = $this->getArgument('--client', 'site');
|
||||
$dryRun = $this->dryRun;
|
||||
|
||||
if (empty($name)) {
|
||||
$this->log("❌ --name is required for --scaffold", 'ERROR');
|
||||
@@ -176,7 +172,7 @@ class BulkJoomlaTemplate extends CLIApp
|
||||
}
|
||||
|
||||
// Confirm
|
||||
if (!$this->hasOption('yes')) {
|
||||
if (!$this->getArgument('--yes', false)) {
|
||||
echo "\nCreate repository {$org}/{$name}? [y/N]: ";
|
||||
$handle = fopen('php://stdin', 'r');
|
||||
$line = fgets($handle);
|
||||
@@ -192,7 +188,7 @@ class BulkJoomlaTemplate extends CLIApp
|
||||
// Create repository
|
||||
$this->log("\nCreating repository...", 'INFO');
|
||||
try {
|
||||
$isPrivate = $this->hasOption('private');
|
||||
$isPrivate = $this->getArgument('--private', false);
|
||||
$this->adapter->createOrgRepo($org, $name, [
|
||||
'description' => "Joomla {$client} template — {$name}",
|
||||
'private' => $isPrivate,
|
||||
@@ -263,10 +259,10 @@ class BulkJoomlaTemplate extends CLIApp
|
||||
{
|
||||
$repos = [];
|
||||
|
||||
if ($this->hasOption('all')) {
|
||||
if ($this->getArgument('--all', false)) {
|
||||
$repos = $this->findTemplateRepos($org);
|
||||
} else {
|
||||
$reposArg = $this->getOption('repos', '');
|
||||
$reposArg = $this->getArgument('--repos', '');
|
||||
if (empty($reposArg)) {
|
||||
$this->log("❌ --repos or --all required for --sync", 'ERROR');
|
||||
return 1;
|
||||
@@ -284,7 +280,7 @@ class BulkJoomlaTemplate extends CLIApp
|
||||
|
||||
$this->log("\nSyncing " . count($repos) . " template repo(s)...", 'INFO');
|
||||
|
||||
$dryRun = $this->hasOption('dry-run');
|
||||
$dryRun = $this->dryRun;
|
||||
$success = 0;
|
||||
$failed = 0;
|
||||
|
||||
@@ -741,7 +737,7 @@ class BulkJoomlaTemplate extends CLIApp
|
||||
{
|
||||
$repos = [];
|
||||
|
||||
if ($this->hasOption('all')) {
|
||||
if ($this->getArgument('--all', false)) {
|
||||
$repos = $this->findTemplateRepos($org);
|
||||
// Also include waas-component repos
|
||||
$allRepos = $this->adapter->listOrgRepos($org, true);
|
||||
@@ -765,7 +761,7 @@ class BulkJoomlaTemplate extends CLIApp
|
||||
return true;
|
||||
});
|
||||
} else {
|
||||
$reposArg = $this->getOption('repos', '');
|
||||
$reposArg = $this->getArgument('--repos', '');
|
||||
if (empty($reposArg)) {
|
||||
$this->log("❌ --repos or --all required for --sync-updates", 'ERROR');
|
||||
return 1;
|
||||
@@ -791,7 +787,7 @@ class BulkJoomlaTemplate extends CLIApp
|
||||
|
||||
$gitea = $adapters['gitea'];
|
||||
$github = $adapters['github'];
|
||||
$dryRun = $this->hasOption('dry-run');
|
||||
$dryRun = $this->dryRun;
|
||||
|
||||
$this->log("\nSyncing updates.xml across Gitea <-> GitHub for " . count($repos) . " repo(s)...", 'INFO');
|
||||
|
||||
@@ -936,10 +932,6 @@ class BulkJoomlaTemplate extends CLIApp
|
||||
|
||||
// Execute if run directly
|
||||
if (php_sapi_name() === 'cli' && isset($argv[0]) && realpath($argv[0]) === __FILE__) {
|
||||
$app = new BulkJoomlaTemplate(
|
||||
'joomla-template',
|
||||
'Bulk scaffold and sync Joomla template repositories',
|
||||
BulkJoomlaTemplate::VERSION
|
||||
);
|
||||
$app = new BulkJoomlaTemplate();
|
||||
exit($app->execute());
|
||||
}
|
||||
|
||||
+24
-34
@@ -26,7 +26,7 @@ use MokoEnterprise\{
|
||||
AuditLogger,
|
||||
CheckpointManager,
|
||||
CircuitBreakerOpen,
|
||||
CLIApp,
|
||||
CliFramework,
|
||||
Config,
|
||||
GitPlatformAdapter,
|
||||
MetricsCollector,
|
||||
@@ -45,7 +45,7 @@ use MokoEnterprise\{
|
||||
* Synchronizes MokoStandards files across multiple repositories using
|
||||
* the Enterprise library for robust, audited operations.
|
||||
*/
|
||||
class BulkSync extends CLIApp
|
||||
class BulkSync extends CliFramework
|
||||
{
|
||||
/**
|
||||
* Default organization for bulk sync operations
|
||||
@@ -65,9 +65,7 @@ class BulkSync extends CLIApp
|
||||
private RepositorySynchronizer $synchronizer;
|
||||
private AuditLogger $logger;
|
||||
private CheckpointManager $checkpoints;
|
||||
private SecurityValidator $security;
|
||||
private PluginFactory $pluginFactory;
|
||||
private ProjectTypeDetector $typeDetector;
|
||||
private MetricsCollector $metrics;
|
||||
private Config $config;
|
||||
|
||||
/** Set to true by signal handler or rate-limit detection to abort the sync loop gracefully. */
|
||||
@@ -76,21 +74,20 @@ class BulkSync extends CLIApp
|
||||
/**
|
||||
* Setup command-line arguments
|
||||
*/
|
||||
protected function setupArguments(): array
|
||||
protected function configure(): void
|
||||
{
|
||||
return [
|
||||
'org:' => 'GitHub organization (default: MokoConsulting)',
|
||||
'repos:' => 'Specific repositories to sync (space-separated)',
|
||||
'exclude:' => 'Repositories to exclude (space-separated)',
|
||||
'skip-archived' => 'Skip archived repositories',
|
||||
'yes' => 'Auto-confirm prompts',
|
||||
'resume' => 'Resume from last checkpoint, skipping already-processed repositories',
|
||||
'force' => 'Force overwrite of protected files (always_overwrite=false), except truly protected files',
|
||||
'protect' => 'Apply/enforce main branch protection rules on all synced repositories',
|
||||
'no-issue' => 'Skip creating a tracking issue in each target repository',
|
||||
'update-branches' => 'After sync, merge main into all other open PR branches in each repo',
|
||||
'health' => 'Run repo health checks after sync and include results in the report',
|
||||
];
|
||||
$this->setDescription('Bulk repository synchronization');
|
||||
$this->addArgument('--org', 'Organization', self::DEFAULT_ORG);
|
||||
$this->addArgument('--repos', 'Specific repos', '');
|
||||
$this->addArgument('--exclude', 'Repos to exclude', '');
|
||||
$this->addArgument('--skip-archived', 'Skip archived repos', false);
|
||||
$this->addArgument('--yes', 'Auto-confirm', false);
|
||||
$this->addArgument('--resume', 'Resume from checkpoint', false);
|
||||
$this->addArgument('--force', 'Force overwrite', false);
|
||||
$this->addArgument('--protect', 'Apply branch protection', false);
|
||||
$this->addArgument('--no-issue', 'Skip tracking issue', false);
|
||||
$this->addArgument('--update-branches', 'Merge main into branches', false);
|
||||
$this->addArgument('--health', 'Run health checks', false);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -106,13 +103,13 @@ class BulkSync extends CLIApp
|
||||
}
|
||||
|
||||
// Get configuration
|
||||
$org = $this->getOption('org', self::DEFAULT_ORG);
|
||||
$skipArchived = $this->hasOption('skip-archived');
|
||||
$autoConfirm = $this->hasOption('yes');
|
||||
$org = $this->getArgument('--org', self::DEFAULT_ORG);
|
||||
$skipArchived = $this->getArgument('--skip-archived', false);
|
||||
$autoConfirm = $this->getArgument('--yes', false);
|
||||
|
||||
// Get repository filters
|
||||
$specificRepos = $this->parseRepositoryList($this->getOption('repos', ''));
|
||||
$excludeRepos = $this->parseRepositoryList($this->getOption('exclude', ''));
|
||||
$specificRepos = $this->parseRepositoryList($this->getArgument('--repos', ''));
|
||||
$excludeRepos = $this->parseRepositoryList($this->getArgument('--exclude', ''));
|
||||
|
||||
$this->log("Organization: {$org}", 'INFO');
|
||||
if (!empty($specificRepos)) {
|
||||
@@ -139,7 +136,7 @@ class BulkSync extends CLIApp
|
||||
|
||||
// Load resume checkpoint if --resume is set
|
||||
$alreadyProcessed = [];
|
||||
if ($this->hasOption('resume')) {
|
||||
if ($this->getArgument('--resume', false)) {
|
||||
$checkpoint = $this->checkpoints->loadCheckpoint('bulk_sync');
|
||||
if ($checkpoint !== null) {
|
||||
$alreadyProcessed = array_keys($checkpoint['results']['repositories'] ?? []);
|
||||
@@ -204,7 +201,6 @@ class BulkSync extends CLIApp
|
||||
$this->logger = new AuditLogger('bulk_sync');
|
||||
$this->metrics = new MetricsCollector();
|
||||
$this->checkpoints = new CheckpointManager('.checkpoints');
|
||||
$this->security = new SecurityValidator();
|
||||
$this->synchronizer = new RepositorySynchronizer(
|
||||
$this->api,
|
||||
$this->logger,
|
||||
@@ -215,8 +211,6 @@ class BulkSync extends CLIApp
|
||||
);
|
||||
|
||||
// Initialize plugin system
|
||||
$this->pluginFactory = new PluginFactory($this->logger, $this->metrics);
|
||||
$this->typeDetector = new ProjectTypeDetector($this->logger);
|
||||
|
||||
$this->log("✓ Enterprise components initialized for platform: {$platform}", 'INFO');
|
||||
return true;
|
||||
@@ -288,7 +282,7 @@ class BulkSync extends CLIApp
|
||||
}
|
||||
}
|
||||
|
||||
return array_values(array_merge($priority, $rest));
|
||||
return array_merge($priority, $rest);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -1424,10 +1418,6 @@ class BulkSync extends CLIApp
|
||||
|
||||
// Execute if run directly
|
||||
if (php_sapi_name() === 'cli' && isset($argv[0]) && realpath($argv[0]) === __FILE__) {
|
||||
$app = new BulkSync(
|
||||
'bulk-sync',
|
||||
'Enterprise-grade bulk repository synchronization',
|
||||
BulkSync::VERSION
|
||||
);
|
||||
$app = new BulkSync();
|
||||
exit($app->execute());
|
||||
}
|
||||
|
||||
+27
-29
@@ -24,7 +24,7 @@ require_once __DIR__ . '/../lib/Enterprise/CliFramework.php';
|
||||
use MokoEnterprise\{
|
||||
ApiClient,
|
||||
AuditLogger,
|
||||
CLIApp,
|
||||
CliFramework,
|
||||
Config,
|
||||
DefinitionParser,
|
||||
GitPlatformAdapter,
|
||||
@@ -51,7 +51,7 @@ use MokoEnterprise\{
|
||||
* php push_files.php --files=".github/workflows/ci.yml,.github/workflows/codeql-analysis.yml" --repos=MokoCRM,WaasComponent
|
||||
* php push_files.php --files=templates/foo.txt:docs/foo.txt --repos=MyRepo --direct
|
||||
*/
|
||||
class PushFiles extends CLIApp
|
||||
class PushFiles extends CliFramework
|
||||
{
|
||||
public const DEFAULT_ORG = 'MokoConsulting';
|
||||
public const VERSION = '04.06.00';
|
||||
@@ -65,18 +65,17 @@ class PushFiles extends CLIApp
|
||||
/**
|
||||
* Setup command-line arguments
|
||||
*/
|
||||
protected function setupArguments(): array
|
||||
protected function configure(): void
|
||||
{
|
||||
return [
|
||||
'org:' => 'GitHub organization (default: ' . self::DEFAULT_ORG . ')',
|
||||
'repos:' => 'Target repositories — comma or space-separated (required)',
|
||||
'files:' => 'Files to push — destination paths or source:destination pairs, comma/space-separated (required)',
|
||||
'message:' => 'Custom commit message (optional)',
|
||||
'branch:' => 'Target branch for direct pushes (default: repo default branch). Ignored unless --direct is set',
|
||||
'direct' => 'Push directly to target branch instead of creating a PR',
|
||||
'yes' => 'Auto-confirm without prompting',
|
||||
'no-issue' => 'Skip creating a tracking issue in each target repository',
|
||||
];
|
||||
$this->setDescription('Push files to remote repositories');
|
||||
$this->addArgument('--org', 'GitHub organization', self::DEFAULT_ORG);
|
||||
$this->addArgument('--repos', 'Target repos (comma-separated)', '');
|
||||
$this->addArgument('--files', 'Files to push (comma-separated)', '');
|
||||
$this->addArgument('--message', 'Custom commit message', '');
|
||||
$this->addArgument('--branch', 'Target branch for direct pushes', '');
|
||||
$this->addArgument('--direct', 'Push directly instead of PR', false);
|
||||
$this->addArgument('--yes', 'Auto-confirm without prompting', false);
|
||||
$this->addArgument('--no-issue', 'Skip creating tracking issue', false);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -90,11 +89,11 @@ class PushFiles extends CLIApp
|
||||
return 1;
|
||||
}
|
||||
|
||||
$org = $this->getOption('org', self::DEFAULT_ORG);
|
||||
$reposArg = $this->getOption('repos', '');
|
||||
$filesArg = $this->getOption('files', '');
|
||||
$direct = $this->hasOption('direct');
|
||||
$autoYes = $this->hasOption('yes');
|
||||
$org = $this->getArgument('--org', self::DEFAULT_ORG);
|
||||
$reposArg = $this->getArgument('--repos', '');
|
||||
$filesArg = $this->getArgument('--files', '');
|
||||
$direct = $this->getArgument('--direct', false);
|
||||
$autoYes = $this->getArgument('--yes', false);
|
||||
|
||||
// Validate required arguments
|
||||
if (empty($reposArg)) {
|
||||
@@ -127,7 +126,7 @@ class PushFiles extends CLIApp
|
||||
}
|
||||
|
||||
// Confirm before proceeding
|
||||
if (!$autoYes && !$this->confirm($repoFileMaps, $direct)) {
|
||||
if (!$autoYes && !$this->confirmPush($repoFileMaps, $direct)) {
|
||||
$this->log('❌ Cancelled.', 'INFO');
|
||||
return 0;
|
||||
}
|
||||
@@ -265,7 +264,8 @@ class PushFiles extends CLIApp
|
||||
// Fall back to live detection
|
||||
try {
|
||||
$repoData = $this->api->get("/repos/{$org}/{$repo}");
|
||||
return $this->typeDetector->detect($repoData, $org, $repo);
|
||||
$result = $this->typeDetector->detect('.');
|
||||
return $result['type'] ?? 'default';
|
||||
} catch (\Exception $e) {
|
||||
$this->log(" ⚠️ Could not detect platform for {$repo}, using 'default'", 'WARN');
|
||||
return 'default';
|
||||
@@ -277,7 +277,7 @@ class PushFiles extends CLIApp
|
||||
*
|
||||
* @param array<string, list<array{source: string, destination: string}>> $repoFileMaps
|
||||
*/
|
||||
private function confirm(array $repoFileMaps, bool $direct): bool
|
||||
private function confirmPush(array $repoFileMaps, bool $direct): bool
|
||||
{
|
||||
if ($this->quiet) {
|
||||
return true;
|
||||
@@ -322,8 +322,8 @@ class PushFiles extends CLIApp
|
||||
'repos' => [],
|
||||
];
|
||||
|
||||
$customMessage = $this->getOption('message', '');
|
||||
$targetBranch = $this->getOption('branch', '');
|
||||
$customMessage = $this->getArgument('--message', '');
|
||||
$targetBranch = $this->getArgument('--branch', '');
|
||||
|
||||
foreach ($repoFileMaps as $repo => $entries) {
|
||||
$this->log("\n[{$repo}] Pushing " . count($entries) . ' file(s)...', 'INFO');
|
||||
@@ -520,6 +520,7 @@ class PushFiles extends CLIApp
|
||||
'direction' => 'desc',
|
||||
]);
|
||||
|
||||
$existing = array_values($existing);
|
||||
if (!empty($existing) && isset($existing[0]['number'])) {
|
||||
$num = $existing[0]['number'];
|
||||
$patch = ['title' => $title, 'body' => $body, 'assignees' => ['jmiller']];
|
||||
@@ -581,7 +582,7 @@ class PushFiles extends CLIApp
|
||||
));
|
||||
|
||||
$repoList = implode("\n", array_map(fn($r) => "- `{$r}`", $failedRepos));
|
||||
$fileArgs = $this->getOption('files', '');
|
||||
$fileArgs = $this->getArgument('--files', '');
|
||||
|
||||
$title = "fix: push_files failed for {$failed} repo(s) — action required";
|
||||
|
||||
@@ -622,6 +623,7 @@ class PushFiles extends CLIApp
|
||||
'direction' => 'desc',
|
||||
]);
|
||||
|
||||
$existing = array_values($existing);
|
||||
if (!empty($existing) && isset($existing[0]['number'])) {
|
||||
$num = $existing[0]['number'];
|
||||
$patch = ['title' => $title, 'body' => $body, 'assignees' => ['jmiller']];
|
||||
@@ -693,10 +695,6 @@ class PushFiles extends CLIApp
|
||||
|
||||
// Execute if run directly
|
||||
if (php_sapi_name() === 'cli' && isset($argv[0]) && realpath($argv[0]) === __FILE__) {
|
||||
$app = new PushFiles(
|
||||
'push-files',
|
||||
'Push one or more specific files to one or more remote repositories',
|
||||
PushFiles::VERSION
|
||||
);
|
||||
$app = new PushFiles();
|
||||
exit($app->execute());
|
||||
}
|
||||
|
||||
+72
-82
@@ -21,7 +21,7 @@ declare(strict_types=1);
|
||||
require_once __DIR__ . '/../vendor/autoload.php';
|
||||
require_once __DIR__ . '/../lib/Enterprise/CliFramework.php';
|
||||
|
||||
use MokoEnterprise\{ApiClient, AuditLogger, CLIApp, Config, GitPlatformAdapter, MetricsCollector, PlatformAdapterFactory};
|
||||
use MokoEnterprise\{ApiClient, AuditLogger, CliFramework, Config, GitPlatformAdapter, MetricsCollector, PlatformAdapterFactory};
|
||||
|
||||
/**
|
||||
* Enterprise Repository Cleanup
|
||||
@@ -36,7 +36,7 @@ use MokoEnterprise\{ApiClient, AuditLogger, CLIApp, Config, GitPlatformAdapter,
|
||||
* 7. Verify and provision standard labels
|
||||
* 8. Version drift detection
|
||||
*/
|
||||
class RepoCleanup extends CLIApp
|
||||
class RepoCleanup extends CliFramework
|
||||
{
|
||||
private const VERSION = '04.06.00';
|
||||
private const SYNC_PREFIX = 'chore/sync-mokostandards-';
|
||||
@@ -58,42 +58,34 @@ class RepoCleanup extends CLIApp
|
||||
|
||||
private ApiClient $api;
|
||||
private GitPlatformAdapter $adapter;
|
||||
private AuditLogger $logger;
|
||||
private MetricsCollector $metrics;
|
||||
private bool $dryRun = false;
|
||||
protected bool $dryRun = false;
|
||||
private float $startTime;
|
||||
|
||||
protected function configure(): void
|
||||
{
|
||||
$this->setName('repo-cleanup');
|
||||
$this->setDescription('Enterprise repository cleanup — branches, PRs, issues, workflows, labels, logs');
|
||||
$this->setVersion(self::VERSION);
|
||||
|
||||
$this->addOption('org', 'GitHub organization', 'MokoConsulting');
|
||||
$this->addOption('repos', 'Specific repositories (space-separated)', '');
|
||||
$this->addOption('skip-archived', 'Skip archived repositories', false);
|
||||
$this->addOption('close-issues', 'Close resolved tracking issues (merged PR = done)', false);
|
||||
$this->addOption('lock-old-issues', 'Lock issues closed >30 days', false);
|
||||
$this->addOption('clean-workflows', 'Delete cancelled/stale workflow runs', false);
|
||||
$this->addOption('clean-logs', 'Delete workflow run logs older than --log-days', false);
|
||||
$this->addOption('log-days', 'Days to keep logs (default: 30)', '30');
|
||||
$this->addOption('delete-retired', 'Delete retired workflow files from repos', false);
|
||||
$this->addOption('check-labels', 'Verify mokostandards label exists', false);
|
||||
$this->addOption('check-drift', 'Check for version drift against README.md', false);
|
||||
$this->addOption('all', 'Run all cleanup operations', false);
|
||||
$this->addOption('yes', 'Auto-confirm prompts', false);
|
||||
$this->addOption('dry-run', 'Preview changes without making them', false);
|
||||
$this->addOption('verbose', 'Show detailed output', false);
|
||||
$this->addOption('quiet', 'Suppress non-error output', false);
|
||||
$this->addOption('json', 'Output results as JSON', false);
|
||||
$this->setDescription('Enterprise repository cleanup');
|
||||
$this->addArgument('--org', 'GitHub organization', 'MokoConsulting');
|
||||
$this->addArgument('--repos', 'Specific repos (space-separated)', '');
|
||||
$this->addArgument('--skip-archived', 'Skip archived repos', false);
|
||||
$this->addArgument('--close-issues', 'Close resolved tracking issues', false);
|
||||
$this->addArgument('--lock-old-issues', 'Lock issues closed >30 days', false);
|
||||
$this->addArgument('--clean-workflows', 'Delete stale workflow runs', false);
|
||||
$this->addArgument('--clean-logs', 'Delete old workflow logs', false);
|
||||
$this->addArgument('--log-days', 'Days to keep logs', '30');
|
||||
$this->addArgument('--delete-retired', 'Delete retired workflows', false);
|
||||
$this->addArgument('--check-labels', 'Verify labels exist', false);
|
||||
$this->addArgument('--check-drift', 'Check version drift', false);
|
||||
$this->addArgument('--all', 'Run all operations', false);
|
||||
$this->addArgument('--yes', 'Auto-confirm', false);
|
||||
$this->addArgument('--json', 'Output as JSON', false);
|
||||
}
|
||||
|
||||
protected function execute(): int
|
||||
protected function run(): int
|
||||
{
|
||||
$this->startTime = microtime(true);
|
||||
$org = $this->getOption('org', 'MokoConsulting');
|
||||
$this->dryRun = (bool) $this->getOption('dry-run', false);
|
||||
$runAll = (bool) $this->getOption('all', false);
|
||||
$org = $this->getArgument('--org', 'MokoConsulting');
|
||||
$this->dryRun = (bool) $this->getArgument('--dry-run', false);
|
||||
$runAll = (bool) $this->getArgument('--all', false);
|
||||
|
||||
$config = Config::load();
|
||||
|
||||
@@ -101,24 +93,22 @@ class RepoCleanup extends CLIApp
|
||||
$this->adapter = PlatformAdapterFactory::create($config);
|
||||
$this->api = $this->adapter->getApiClient();
|
||||
} catch (\Exception $e) {
|
||||
$this->error('Failed to initialize platform adapter: ' . $e->getMessage());
|
||||
$this->errorMsg('Failed to initialize platform adapter: ' . $e->getMessage());
|
||||
return 1;
|
||||
}
|
||||
|
||||
$this->logger = new AuditLogger('repo_cleanup');
|
||||
$this->metrics = new MetricsCollector('repo_cleanup');
|
||||
|
||||
$this->log("🧹 MokoStandards Repository Cleanup v" . self::VERSION);
|
||||
$this->log("Organization: {$org}");
|
||||
$this->log("Current sync branch: " . self::CURRENT_BRANCH);
|
||||
$this->logMsg("🧹 MokoStandards Repository Cleanup v" . self::VERSION);
|
||||
$this->logMsg("Organization: {$org}");
|
||||
$this->logMsg("Current sync branch: " . self::CURRENT_BRANCH);
|
||||
if ($this->dryRun) {
|
||||
$this->log("⚠️ DRY RUN — no changes will be made");
|
||||
$this->logMsg("⚠️ DRY RUN — no changes will be made");
|
||||
}
|
||||
$this->log('');
|
||||
$this->logMsg('');
|
||||
|
||||
$repos = $this->fetchRepositories($org);
|
||||
$this->log("Found " . count($repos) . " repositories");
|
||||
$this->log('');
|
||||
$this->logMsg("Found " . count($repos) . " repositories");
|
||||
$this->logMsg('');
|
||||
|
||||
$results = [
|
||||
'repos_processed' => 0,
|
||||
@@ -140,7 +130,7 @@ class RepoCleanup extends CLIApp
|
||||
$name = $repo['name'];
|
||||
$num = $i + 1;
|
||||
$total = count($repos);
|
||||
$this->log("[{$num}/{$total}] {$name}");
|
||||
$this->logMsg("[{$num}/{$total}] {$name}");
|
||||
$results['repos_processed']++;
|
||||
|
||||
try {
|
||||
@@ -151,37 +141,37 @@ class RepoCleanup extends CLIApp
|
||||
$cleaned = $this->cleanBranches($org, $name, $results) || $cleaned;
|
||||
|
||||
// Optional: close resolved issues
|
||||
if ($runAll || $this->getOption('close-issues', false)) {
|
||||
if ($runAll || $this->getArgument('--close-issues', false)) {
|
||||
$cleaned = $this->closeResolvedIssues($org, $name, $results) || $cleaned;
|
||||
}
|
||||
|
||||
// Optional: lock old closed issues
|
||||
if ($runAll || $this->getOption('lock-old-issues', false)) {
|
||||
if ($runAll || $this->getArgument('--lock-old-issues', false)) {
|
||||
$cleaned = $this->lockOldIssues($org, $name, $results) || $cleaned;
|
||||
}
|
||||
|
||||
// Optional: delete retired workflow files
|
||||
if ($runAll || $this->getOption('delete-retired', false)) {
|
||||
if ($runAll || $this->getArgument('--delete-retired', false)) {
|
||||
$cleaned = $this->deleteRetiredWorkflows($org, $name, $results) || $cleaned;
|
||||
}
|
||||
|
||||
// Optional: clean workflow runs
|
||||
if ($runAll || $this->getOption('clean-workflows', false)) {
|
||||
if ($runAll || $this->getArgument('--clean-workflows', false)) {
|
||||
$cleaned = $this->cleanWorkflowRuns($org, $name, $results) || $cleaned;
|
||||
}
|
||||
|
||||
// Optional: clean old logs
|
||||
if ($runAll || $this->getOption('clean-logs', false)) {
|
||||
if ($runAll || $this->getArgument('--clean-logs', false)) {
|
||||
$cleaned = $this->cleanOldLogs($org, $name, $results) || $cleaned;
|
||||
}
|
||||
|
||||
// Optional: check labels
|
||||
if ($runAll || $this->getOption('check-labels', false)) {
|
||||
if ($runAll || $this->getArgument('--check-labels', false)) {
|
||||
$this->checkLabels($org, $name, $results);
|
||||
}
|
||||
|
||||
// Optional: check version drift
|
||||
if ($runAll || $this->getOption('check-drift', false)) {
|
||||
if ($runAll || $this->getArgument('--check-drift', false)) {
|
||||
$this->checkVersionDrift($org, $name, $results);
|
||||
}
|
||||
|
||||
@@ -189,32 +179,32 @@ class RepoCleanup extends CLIApp
|
||||
$results['repos_cleaned']++;
|
||||
}
|
||||
} catch (\Exception $e) {
|
||||
$this->error(" ✗ {$name}: " . $e->getMessage());
|
||||
$this->errorMsg(" ✗ {$name}: " . $e->getMessage());
|
||||
$results['errors']++;
|
||||
}
|
||||
}
|
||||
|
||||
$duration = round(microtime(true) - $this->startTime, 1);
|
||||
|
||||
$this->log('');
|
||||
$this->log('============================================================');
|
||||
$this->log("🧹 Cleanup Complete ({$duration}s)");
|
||||
$this->log('============================================================');
|
||||
$this->log("Repos processed: {$results['repos_processed']}");
|
||||
$this->log("Repos with changes: {$results['repos_cleaned']}");
|
||||
$this->log("Branches deleted: {$results['branches_deleted']}");
|
||||
$this->log("PRs closed: {$results['prs_closed']}");
|
||||
$this->log("Issues closed: {$results['issues_closed']}");
|
||||
$this->log("Issues locked: {$results['issues_locked']}");
|
||||
$this->log("Retired files: {$results['retired_files']}");
|
||||
$this->log("Workflow runs: {$results['runs_deleted']}");
|
||||
$this->log("Logs cleaned: {$results['logs_deleted']}");
|
||||
$this->log("Labels missing: {$results['labels_missing']}");
|
||||
$this->log("Version drift: {$results['version_drift']}");
|
||||
$this->log("Errors: {$results['errors']}");
|
||||
$this->log('============================================================');
|
||||
$this->logMsg('');
|
||||
$this->logMsg('============================================================');
|
||||
$this->logMsg("🧹 Cleanup Complete ({$duration}s)");
|
||||
$this->logMsg('============================================================');
|
||||
$this->logMsg("Repos processed: {$results['repos_processed']}");
|
||||
$this->logMsg("Repos with changes: {$results['repos_cleaned']}");
|
||||
$this->logMsg("Branches deleted: {$results['branches_deleted']}");
|
||||
$this->logMsg("PRs closed: {$results['prs_closed']}");
|
||||
$this->logMsg("Issues closed: {$results['issues_closed']}");
|
||||
$this->logMsg("Issues locked: {$results['issues_locked']}");
|
||||
$this->logMsg("Retired files: {$results['retired_files']}");
|
||||
$this->logMsg("Workflow runs: {$results['runs_deleted']}");
|
||||
$this->logMsg("Logs cleaned: {$results['logs_deleted']}");
|
||||
$this->logMsg("Labels missing: {$results['labels_missing']}");
|
||||
$this->logMsg("Version drift: {$results['version_drift']}");
|
||||
$this->logMsg("Errors: {$results['errors']}");
|
||||
$this->logMsg('============================================================');
|
||||
|
||||
if ($this->getOption('json', false)) {
|
||||
if ($this->getArgument('--json', false)) {
|
||||
$results['duration_seconds'] = $duration;
|
||||
echo json_encode($results, JSON_PRETTY_PRINT) . "\n";
|
||||
}
|
||||
@@ -226,8 +216,8 @@ class RepoCleanup extends CLIApp
|
||||
|
||||
private function fetchRepositories(string $org): array
|
||||
{
|
||||
$specificRepos = trim((string) $this->getOption('repos', ''));
|
||||
$skipArchived = (bool) $this->getOption('skip-archived', false);
|
||||
$specificRepos = trim((string) $this->getArgument('--repos', ''));
|
||||
$skipArchived = (bool) $this->getArgument('--skip-archived', false);
|
||||
|
||||
if (!empty($specificRepos)) {
|
||||
$names = preg_split('/[\s,]+/', $specificRepos);
|
||||
@@ -264,7 +254,7 @@ class RepoCleanup extends CLIApp
|
||||
if (($pr['number'] ?? 0) > 0 && !$this->dryRun) {
|
||||
$this->api->patch("/repos/{$org}/{$repo}/pulls/{$pr['number']}", ['state' => 'closed']);
|
||||
}
|
||||
$this->log(" 🔒 Closed PR #{$pr['number']} ({$name})");
|
||||
$this->logMsg(" 🔒 Closed PR #{$pr['number']} ({$name})");
|
||||
$results['prs_closed']++;
|
||||
$changed = true;
|
||||
}
|
||||
@@ -279,7 +269,7 @@ class RepoCleanup extends CLIApp
|
||||
continue;
|
||||
}
|
||||
}
|
||||
$this->log(" 🗑️ Deleted branch: {$name}");
|
||||
$this->logMsg(" 🗑️ Deleted branch: {$name}");
|
||||
$results['branches_deleted']++;
|
||||
$changed = true;
|
||||
}
|
||||
@@ -312,7 +302,7 @@ class RepoCleanup extends CLIApp
|
||||
'state' => 'closed', 'state_reason' => 'completed',
|
||||
]);
|
||||
}
|
||||
$this->log(" ✅ Closed issue #{$num} (PR #{$prNum} merged)");
|
||||
$this->logMsg(" ✅ Closed issue #{$num} (PR #{$prNum} merged)");
|
||||
$results['issues_closed']++;
|
||||
$changed = true;
|
||||
}
|
||||
@@ -361,7 +351,7 @@ class RepoCleanup extends CLIApp
|
||||
}
|
||||
|
||||
if ($results['issues_locked'] > 0) {
|
||||
$this->log(" 🔒 Locked {$results['issues_locked']} old closed issue(s)");
|
||||
$this->logMsg(" 🔒 Locked {$results['issues_locked']} old closed issue(s)");
|
||||
}
|
||||
return $changed;
|
||||
}
|
||||
@@ -396,7 +386,7 @@ class RepoCleanup extends CLIApp
|
||||
'branch' => $defaultBranch,
|
||||
]);
|
||||
}
|
||||
$this->log(" Deleted retired: {$wf} (from {$wfDir})");
|
||||
$this->logMsg(" Deleted retired: {$wf} (from {$wfDir})");
|
||||
$results['retired_files']++;
|
||||
$changed = true;
|
||||
} catch (\Exception $e) {
|
||||
@@ -433,7 +423,7 @@ class RepoCleanup extends CLIApp
|
||||
}
|
||||
}
|
||||
if ($results['runs_deleted'] > 0) {
|
||||
$this->log(" 🔄 Cleaned {$results['runs_deleted']} workflow run(s)");
|
||||
$this->logMsg(" 🔄 Cleaned {$results['runs_deleted']} workflow run(s)");
|
||||
}
|
||||
return $changed;
|
||||
}
|
||||
@@ -441,7 +431,7 @@ class RepoCleanup extends CLIApp
|
||||
private function cleanOldLogs(string $org, string $repo, array &$results): bool
|
||||
{
|
||||
$changed = false;
|
||||
$days = (int) $this->getOption('log-days', '30');
|
||||
$days = (int) $this->getArgument('--log-days', '30');
|
||||
$cutoff = date('Y-m-d\TH:i:s\Z', strtotime("-{$days} days"));
|
||||
|
||||
try {
|
||||
@@ -465,7 +455,7 @@ class RepoCleanup extends CLIApp
|
||||
}
|
||||
|
||||
if ($results['logs_deleted'] > 0) {
|
||||
$this->log(" 📋 Cleaned {$results['logs_deleted']} old log(s)");
|
||||
$this->logMsg(" 📋 Cleaned {$results['logs_deleted']} old log(s)");
|
||||
}
|
||||
return $changed;
|
||||
}
|
||||
@@ -475,7 +465,7 @@ class RepoCleanup extends CLIApp
|
||||
try {
|
||||
$this->api->get("/repos/{$org}/{$repo}/labels/mokostandards");
|
||||
} catch (\Exception $e) {
|
||||
$this->log(" ⚠️ Missing 'mokostandards' label");
|
||||
$this->logMsg(" ⚠️ Missing 'mokostandards' label");
|
||||
$results['labels_missing']++;
|
||||
$this->api->resetCircuitBreaker();
|
||||
}
|
||||
@@ -495,7 +485,7 @@ class RepoCleanup extends CLIApp
|
||||
$mokoContent = base64_decode($mokoFile['content'] ?? '');
|
||||
if (preg_match('/standards_version:\s*(\d{2}\.\d{2}\.\d{2})/m', $mokoContent, $vm)) {
|
||||
if ($vm[1] !== self::VERSION) {
|
||||
$this->log(" ⚠️ Standards drift: {$vm[1]} (expected " . self::VERSION . ")");
|
||||
$this->logMsg(" ⚠️ Standards drift: {$vm[1]} (expected " . self::VERSION . ")");
|
||||
$results['version_drift']++;
|
||||
}
|
||||
}
|
||||
@@ -510,14 +500,14 @@ class RepoCleanup extends CLIApp
|
||||
|
||||
// ─── Helpers ─────────────────────────────────────────────────────────
|
||||
|
||||
private function log(string $message): void
|
||||
private function logMsg(string $message): void
|
||||
{
|
||||
if (!$this->getOption('quiet', false)) {
|
||||
if (!$this->quiet) {
|
||||
echo $message . "\n";
|
||||
}
|
||||
}
|
||||
|
||||
private function error(string $message): void
|
||||
private function errorMsg(string $message): void
|
||||
{
|
||||
fwrite(STDERR, $message . "\n");
|
||||
}
|
||||
|
||||
@@ -88,45 +88,76 @@ require_once $autoloader;
|
||||
*/
|
||||
const COMMAND_MAP = [
|
||||
// Automation
|
||||
'sync' => 'api/automation/bulk_sync.php',
|
||||
'sync' => 'automation/bulk_sync.php',
|
||||
|
||||
// Maintenance
|
||||
'inventory' => 'api/maintenance/update_repo_inventory.php',
|
||||
'inventory' => 'maintenance/update_repo_inventory.php',
|
||||
|
||||
// Validation — general
|
||||
'health' => 'api/validate/check_repo_health.php',
|
||||
'check:syntax' => 'api/validate/check_php_syntax.php',
|
||||
'check:version' => 'api/validate/check_version_consistency.php',
|
||||
'check:changelog' => 'api/validate/check_changelog.php',
|
||||
'check:structure' => 'api/validate/check_structure.php',
|
||||
'check:headers' => 'api/validate/check_license_headers.php',
|
||||
'check:secrets' => 'api/validate/check_no_secrets.php',
|
||||
'check:tabs' => 'api/validate/check_tabs.php',
|
||||
'check:paths' => 'api/validate/check_paths.php',
|
||||
'check:xml' => 'api/validate/check_xml_wellformed.php',
|
||||
'check:enterprise' => 'api/validate/check_enterprise_readiness.php',
|
||||
'health' => 'validate/check_repo_health.php',
|
||||
'check:syntax' => 'validate/check_php_syntax.php',
|
||||
'check:version' => 'validate/check_version_consistency.php',
|
||||
'check:changelog' => 'validate/check_changelog.php',
|
||||
'check:structure' => 'validate/check_structure.php',
|
||||
'check:headers' => 'validate/check_license_headers.php',
|
||||
'check:secrets' => 'validate/check_no_secrets.php',
|
||||
'check:tabs' => 'validate/check_tabs.php',
|
||||
'check:paths' => 'validate/check_paths.php',
|
||||
'check:xml' => 'validate/check_xml_wellformed.php',
|
||||
'check:enterprise' => 'validate/check_enterprise_readiness.php',
|
||||
|
||||
// Validation — platform-specific
|
||||
'check:dolibarr' => 'api/validate/check_dolibarr_module.php',
|
||||
'check:joomla' => 'api/validate/check_joomla_manifest.php',
|
||||
'check:language' => 'api/validate/check_language_structure.php',
|
||||
'check:dolibarr' => 'validate/check_dolibarr_module.php',
|
||||
'check:joomla' => 'validate/check_joomla_manifest.php',
|
||||
'check:language' => 'validate/check_language_structure.php',
|
||||
'check:client' => 'validate/check_client_theme.php',
|
||||
'check:wiki' => 'validate/check_wiki_health.php',
|
||||
|
||||
// Detection
|
||||
'detect' => 'api/validate/auto_detect_platform.php',
|
||||
'detect' => 'validate/auto_detect_platform.php',
|
||||
|
||||
// Org-wide
|
||||
'drift' => 'api/validate/scan_drift.php',
|
||||
'drift' => 'validate/scan_drift.php',
|
||||
|
||||
// Release
|
||||
'release' => 'api/cli/release.php',
|
||||
'release' => 'cli/release.php',
|
||||
'release:notes' => 'cli/release_notes.php',
|
||||
'release:validate' => 'cli/release_validate.php',
|
||||
'release:cascade' => 'cli/release_cascade.php',
|
||||
'release:manage' => 'cli/release_manage.php',
|
||||
|
||||
// CLI utilities (used by workflows — centralized logic)
|
||||
'version:read' => 'api/cli/version_read.php',
|
||||
'version:bump' => 'api/cli/version_bump.php',
|
||||
'version:propagate' => 'api/maintenance/update_version_from_readme.php',
|
||||
'version:set-platform' => 'api/cli/version_set_platform.php',
|
||||
'platform:detect' => 'api/cli/platform_detect.php',
|
||||
'release:notes' => 'api/cli/release_notes.php',
|
||||
// Version management
|
||||
'version:read' => 'cli/version_read.php',
|
||||
'version:bump' => 'cli/version_bump.php',
|
||||
'version:propagate' => 'maintenance/update_version_from_readme.php',
|
||||
'version:set-platform' => 'cli/version_set_platform.php',
|
||||
|
||||
// Build & package
|
||||
'build:package' => 'cli/package_build.php',
|
||||
'build:joomla' => 'cli/joomla_build.php',
|
||||
'build:updates-xml' => 'cli/updates_xml_build.php',
|
||||
|
||||
// Platform detection
|
||||
'platform:detect' => 'cli/platform_detect.php',
|
||||
'manifest:read' => 'cli/manifest_read.php',
|
||||
|
||||
// Repository management
|
||||
'repo:create' => 'cli/create_repo.php',
|
||||
'repo:archive' => 'cli/archive_repo.php',
|
||||
'repo:scaffold-client' => 'cli/scaffold_client.php',
|
||||
'repo:provision' => 'cli/client_provision.php',
|
||||
|
||||
// Bulk operations
|
||||
'bulk:push-workflow' => 'cli/bulk_workflow_push.php',
|
||||
'bulk:trigger' => 'cli/bulk_workflow_trigger.php',
|
||||
'bulk:sync-rulesets' => 'cli/sync_rulesets.php',
|
||||
|
||||
// Monitoring & dashboards
|
||||
'dashboard' => 'cli/client_dashboard.php',
|
||||
'grafana' => 'cli/grafana_dashboard.php',
|
||||
'client:inventory' => 'cli/client_inventory.php',
|
||||
|
||||
// Module validation
|
||||
'validate:module' => 'bin/validate-module',
|
||||
];
|
||||
|
||||
@@ -210,24 +241,112 @@ function printCommandList(): void
|
||||
{
|
||||
echo "Available commands:\n\n";
|
||||
|
||||
$groups = [
|
||||
'Automation' => ['sync'],
|
||||
'Maintenance' => ['inventory'],
|
||||
'Validation (general)' => ['health', 'check:syntax', 'check:version', 'check:changelog',
|
||||
'check:structure', 'check:headers', 'check:secrets',
|
||||
'check:tabs', 'check:paths', 'check:xml', 'check:enterprise'],
|
||||
'Validation (platform)' => ['check:dolibarr', 'check:joomla', 'check:language', 'detect'],
|
||||
'Organisation-wide' => ['drift'],
|
||||
];
|
||||
// Auto-group by command prefix or comment-based sections
|
||||
$groups = [];
|
||||
foreach (COMMAND_MAP as $cmd => $path) {
|
||||
if (str_contains($cmd, ':')) {
|
||||
$prefix = explode(':', $cmd)[0];
|
||||
$groupName = match ($prefix) {
|
||||
'check' => 'Validation',
|
||||
'version' => 'Version',
|
||||
'release' => 'Release',
|
||||
'build' => 'Build',
|
||||
'platform', 'manifest' => 'Platform',
|
||||
'repo' => 'Repository',
|
||||
'bulk' => 'Bulk Operations',
|
||||
'client' => 'Client Management',
|
||||
'validate' => 'Module Validation',
|
||||
default => ucfirst($prefix),
|
||||
};
|
||||
} else {
|
||||
$groupName = match ($cmd) {
|
||||
'sync' => 'Automation',
|
||||
'inventory' => 'Maintenance',
|
||||
'health' => 'Validation',
|
||||
'detect', 'drift' => 'Validation',
|
||||
'dashboard', 'grafana' => 'Monitoring',
|
||||
default => 'Other',
|
||||
};
|
||||
}
|
||||
$groups[$groupName][$cmd] = $path;
|
||||
}
|
||||
|
||||
// Load plugin commands
|
||||
$pluginCommands = loadPluginCommands();
|
||||
if (!empty($pluginCommands)) {
|
||||
foreach ($pluginCommands as $cmd => $info) {
|
||||
$type = $info['plugin'] ?? 'Plugin';
|
||||
$groups["Plugin: {$type}"][$cmd] = $info['description'] ?? '';
|
||||
}
|
||||
}
|
||||
|
||||
ksort($groups);
|
||||
|
||||
foreach ($groups as $group => $commands) {
|
||||
echo " {$group}:\n";
|
||||
foreach ($commands as $cmd) {
|
||||
printf(" %-22s %s\n", $cmd, COMMAND_MAP[$cmd]);
|
||||
echo " \033[1m{$group}\033[0m\n";
|
||||
ksort($commands);
|
||||
foreach ($commands as $cmd => $path) {
|
||||
printf(" \033[36m%-26s\033[0m %s\n", $cmd, basename($path));
|
||||
}
|
||||
echo "\n";
|
||||
}
|
||||
|
||||
echo "Run: php bin/moko <command> --help for command-specific options.\n";
|
||||
echo "All platforms: php bin/moko <command>\n";
|
||||
$total = count(COMMAND_MAP) + count($pluginCommands);
|
||||
echo "{$total} command(s) available.\n";
|
||||
echo "Run: php bin/moko <command> --help\n";
|
||||
}
|
||||
|
||||
/**
|
||||
* Load commands from registered plugins.
|
||||
*
|
||||
* @return array<string, array{plugin: string, description: string, script: string}>
|
||||
*/
|
||||
function loadPluginCommands(): array
|
||||
{
|
||||
$pluginDir = dirname(__DIR__) . '/lib/Enterprise/Plugins';
|
||||
if (!is_dir($pluginDir)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$commands = [];
|
||||
|
||||
foreach (glob("{$pluginDir}/*Plugin.php") as $file) {
|
||||
$className = 'MokoEnterprise\\Plugins\\'
|
||||
. pathinfo($file, PATHINFO_FILENAME);
|
||||
|
||||
if (!class_exists($className)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
$ref = new \ReflectionClass($className);
|
||||
if ($ref->isAbstract()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$plugin = $ref->newInstanceWithoutConstructor();
|
||||
$pluginCmds = $plugin->getCommands();
|
||||
|
||||
foreach ($pluginCmds as $cmd) {
|
||||
$name = $cmd['name'] ?? '';
|
||||
if ($name === '') {
|
||||
continue;
|
||||
}
|
||||
|
||||
$type = method_exists($plugin, 'getProjectType')
|
||||
? $plugin->getProjectType() : 'unknown';
|
||||
|
||||
$commands[$name] = [
|
||||
'plugin' => $type,
|
||||
'description' => $cmd['description'] ?? '',
|
||||
'script' => $cmd['script'] ?? '',
|
||||
];
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
// Skip plugins that can't be instantiated
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
return $commands;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,188 @@
|
||||
#!/usr/bin/env php
|
||||
<?php
|
||||
/* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
|
||||
*
|
||||
* 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/client_health_check.php
|
||||
* BRIEF: Verify a client site's update server, installed version, and release availability
|
||||
*
|
||||
* Usage:
|
||||
* php client_health_check.php --update-url URL
|
||||
* php client_health_check.php --path /repo --github-output
|
||||
*
|
||||
* Options:
|
||||
* --path Repository root (reads update server URL from manifest)
|
||||
* --update-url Update server XML URL (overrides manifest)
|
||||
* --site-url Live site URL for version checking via Joomla API (optional)
|
||||
* --api-token Joomla API token for site-url (optional)
|
||||
* --github-output Export results to $GITHUB_OUTPUT
|
||||
*/
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
$path = '.';
|
||||
$updateUrl = null;
|
||||
$siteUrl = null;
|
||||
$apiToken = null;
|
||||
$ghOutput = false;
|
||||
|
||||
foreach ($argv as $i => $arg) {
|
||||
if ($arg === '--path' && isset($argv[$i + 1])) $path = $argv[$i + 1];
|
||||
if ($arg === '--update-url' && isset($argv[$i + 1])) $updateUrl = $argv[$i + 1];
|
||||
if ($arg === '--site-url' && isset($argv[$i + 1])) $siteUrl = $argv[$i + 1];
|
||||
if ($arg === '--api-token' && isset($argv[$i + 1])) $apiToken = $argv[$i + 1];
|
||||
if ($arg === '--github-output') $ghOutput = true;
|
||||
}
|
||||
|
||||
$root = realpath($path) ?: $path;
|
||||
$checks = [];
|
||||
|
||||
// ── Resolve update server URL from manifest ─────────────────────────────
|
||||
if ($updateUrl === null) {
|
||||
$searchDirs = ["{$root}/src", $root];
|
||||
foreach ($searchDirs as $dir) {
|
||||
if (!is_dir($dir)) continue;
|
||||
foreach (glob("{$dir}/*.xml") ?: [] as $f) {
|
||||
$xml = file_get_contents($f);
|
||||
if (preg_match('/<server[^>]*>([^<]+)<\/server>/', $xml, $m)) {
|
||||
$updateUrl = trim($m[1]);
|
||||
break 2;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if ($updateUrl === null) {
|
||||
fwrite(STDERR, "No update server URL found. Use --update-url or provide a manifest with <updateservers>.\n");
|
||||
exit(1);
|
||||
}
|
||||
|
||||
echo "Update server: {$updateUrl}\n\n";
|
||||
|
||||
// ── Check 1: Update server accessible ───────────────────────────────────
|
||||
echo "--- Update Server ---\n";
|
||||
$ch = curl_init($updateUrl);
|
||||
curl_setopt_array($ch, [
|
||||
CURLOPT_RETURNTRANSFER => true,
|
||||
CURLOPT_TIMEOUT => 15,
|
||||
CURLOPT_FOLLOWLOCATION => true,
|
||||
CURLOPT_HTTPHEADER => ['User-Agent: MokoHealthCheck/1.0'],
|
||||
]);
|
||||
$response = curl_exec($ch);
|
||||
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||||
curl_close($ch);
|
||||
|
||||
if ($httpCode === 200 && !empty($response)) {
|
||||
echo " PASS: HTTP {$httpCode}, " . strlen($response) . " bytes\n";
|
||||
$checks['update_server'] = 'pass';
|
||||
} else {
|
||||
echo " FAIL: HTTP {$httpCode}\n";
|
||||
$checks['update_server'] = 'fail';
|
||||
}
|
||||
|
||||
// ── Check 2: Parse updates.xml for stable version ───────────────────────
|
||||
$stableVersion = null;
|
||||
$downloadUrl = null;
|
||||
|
||||
if (!empty($response)) {
|
||||
$sections = preg_split('/<update>/', $response);
|
||||
foreach ($sections as $section) {
|
||||
if (strpos($section, '<tag>stable</tag>') !== false) {
|
||||
if (preg_match('/<version>([^<]+)<\/version>/', $section, $m)) {
|
||||
$stableVersion = $m[1];
|
||||
}
|
||||
if (preg_match('/<downloadurl[^>]*>([^<]+)<\/downloadurl>/', $section, $m)) {
|
||||
$downloadUrl = trim($m[1]);
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if ($stableVersion === null && preg_match('/<version>([^<]+)<\/version>/', $response, $m)) {
|
||||
$stableVersion = $m[1];
|
||||
}
|
||||
}
|
||||
|
||||
echo "\n--- Stable Release ---\n";
|
||||
if ($stableVersion !== null) {
|
||||
echo " Version: {$stableVersion}\n";
|
||||
$checks['stable_version'] = $stableVersion;
|
||||
} else {
|
||||
echo " FAIL: Could not parse stable version\n";
|
||||
$checks['stable_version'] = 'fail';
|
||||
}
|
||||
|
||||
// ── Check 3: Download URL accessible ────────────────────────────────────
|
||||
if ($downloadUrl !== null) {
|
||||
echo "\n--- Download URL ---\n";
|
||||
$ch = curl_init($downloadUrl);
|
||||
curl_setopt_array($ch, [
|
||||
CURLOPT_NOBODY => true,
|
||||
CURLOPT_TIMEOUT => 15,
|
||||
CURLOPT_FOLLOWLOCATION => true,
|
||||
]);
|
||||
curl_exec($ch);
|
||||
$dlCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||||
$dlSize = curl_getinfo($ch, CURLINFO_CONTENT_LENGTH_DOWNLOAD);
|
||||
curl_close($ch);
|
||||
|
||||
if ($dlCode === 200) {
|
||||
$sizeKb = $dlSize > 0 ? round($dlSize / 1024) . 'KB' : 'unknown size';
|
||||
echo " PASS: HTTP {$dlCode}, {$sizeKb}\n";
|
||||
$checks['download'] = 'pass';
|
||||
} else {
|
||||
echo " FAIL: HTTP {$dlCode}\n";
|
||||
$checks['download'] = 'fail';
|
||||
}
|
||||
}
|
||||
|
||||
// ── Check 4: Site version (optional) ────────────────────────────────────
|
||||
if ($siteUrl !== null && $apiToken !== null) {
|
||||
echo "\n--- Site Version ---\n";
|
||||
$apiUrl = rtrim($siteUrl, '/') . '/api/index.php/v1/extensions?filter[type]=file';
|
||||
$ch = curl_init($apiUrl);
|
||||
curl_setopt_array($ch, [
|
||||
CURLOPT_RETURNTRANSFER => true,
|
||||
CURLOPT_TIMEOUT => 15,
|
||||
CURLOPT_HTTPHEADER => [
|
||||
"X-Joomla-Token: {$apiToken}",
|
||||
'Accept: application/json',
|
||||
],
|
||||
]);
|
||||
$siteResponse = curl_exec($ch);
|
||||
$siteCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||||
curl_close($ch);
|
||||
|
||||
if ($siteCode === 200) {
|
||||
echo " API accessible (HTTP {$siteCode})\n";
|
||||
$checks['site_api'] = 'pass';
|
||||
} else {
|
||||
echo " WARN: Site API returned HTTP {$siteCode}\n";
|
||||
$checks['site_api'] = 'warn';
|
||||
}
|
||||
}
|
||||
|
||||
// ── Summary ─────────────────────────────────────────────────────────────
|
||||
echo "\n=== Health Check Summary ===\n";
|
||||
$failed = 0;
|
||||
foreach ($checks as $name => $result) {
|
||||
$icon = ($result === 'fail') ? 'FAIL' : (($result === 'warn') ? 'WARN' : 'OK');
|
||||
if ($result === 'fail') $failed++;
|
||||
echo " {$icon}: {$name} = {$result}\n";
|
||||
}
|
||||
|
||||
if ($ghOutput) {
|
||||
$ghFile = getenv('GITHUB_OUTPUT');
|
||||
if ($ghFile) {
|
||||
file_put_contents($ghFile, "health_status=" . ($failed > 0 ? 'fail' : 'pass') . "\n", FILE_APPEND);
|
||||
file_put_contents($ghFile, "health_version=" . ($stableVersion ?? 'unknown') . "\n", FILE_APPEND);
|
||||
file_put_contents($ghFile, "health_failures={$failed}\n", FILE_APPEND);
|
||||
}
|
||||
}
|
||||
|
||||
exit($failed > 0 ? 1 : 0);
|
||||
@@ -0,0 +1,136 @@
|
||||
#!/usr/bin/env php
|
||||
<?php
|
||||
/* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
|
||||
*
|
||||
* 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_compat_check.php
|
||||
* BRIEF: Check if extension targetplatform regex matches the latest Joomla version
|
||||
*
|
||||
* Usage:
|
||||
* php joomla_compat_check.php --path /repo
|
||||
* php joomla_compat_check.php --path /repo --github-output
|
||||
*
|
||||
* Options:
|
||||
* --path Repository root (default: .)
|
||||
* --github-output Export results to $GITHUB_OUTPUT
|
||||
*/
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
$path = '.';
|
||||
$ghOutput = false;
|
||||
|
||||
foreach ($argv as $i => $arg) {
|
||||
if ($arg === '--path' && isset($argv[$i + 1])) $path = $argv[$i + 1];
|
||||
if ($arg === '--github-output') $ghOutput = true;
|
||||
}
|
||||
|
||||
$root = realpath($path) ?: $path;
|
||||
|
||||
// ── Find manifest and extract targetplatform ────────────────────────────
|
||||
$manifest = null;
|
||||
$searchDirs = ["{$root}/src", $root];
|
||||
foreach ($searchDirs as $dir) {
|
||||
if (!is_dir($dir)) continue;
|
||||
foreach (glob("{$dir}/*.xml") ?: [] as $f) {
|
||||
$xml = file_get_contents($f);
|
||||
if (strpos($xml, '<extension') !== false && strpos($xml, 'targetplatform') !== false) {
|
||||
$manifest = $f;
|
||||
break 2;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if ($manifest === null) {
|
||||
fwrite(STDERR, "No manifest with targetplatform found\n");
|
||||
exit(1);
|
||||
}
|
||||
|
||||
$xml = file_get_contents($manifest);
|
||||
$relManifest = str_replace($root . '/', '', $manifest);
|
||||
|
||||
// Extract targetplatform version regex
|
||||
$targetRegex = '';
|
||||
if (preg_match('/targetplatform[^>]*version="([^"]+)"/', $xml, $m)) {
|
||||
$targetRegex = $m[1];
|
||||
}
|
||||
|
||||
if (empty($targetRegex)) {
|
||||
echo "No targetplatform version found in {$relManifest}\n";
|
||||
exit(1);
|
||||
}
|
||||
|
||||
echo "Manifest: {$relManifest}\n";
|
||||
echo "Target regex: {$targetRegex}\n";
|
||||
|
||||
// ── Fetch latest Joomla version ─────────────────────────────────────────
|
||||
$joomlaVersions = [];
|
||||
$updateUrl = 'https://update.joomla.org/core/sts/list_sts.xml';
|
||||
$updateXml = @file_get_contents($updateUrl);
|
||||
|
||||
if ($updateXml === false) {
|
||||
// Fallback: try the LTS feed
|
||||
$updateUrl = 'https://update.joomla.org/core/list.xml';
|
||||
$updateXml = @file_get_contents($updateUrl);
|
||||
}
|
||||
|
||||
if ($updateXml !== false) {
|
||||
// Parse all version entries
|
||||
preg_match_all('/<version>([^<]+)<\/version>/', $updateXml, $matches);
|
||||
$joomlaVersions = $matches[1] ?? [];
|
||||
}
|
||||
|
||||
if (empty($joomlaVersions)) {
|
||||
echo "WARNING: Could not fetch Joomla versions from update server\n";
|
||||
echo "Tested URL: {$updateUrl}\n";
|
||||
exit(0);
|
||||
}
|
||||
|
||||
// Sort and get latest
|
||||
usort($joomlaVersions, 'version_compare');
|
||||
$latestJoomla = end($joomlaVersions);
|
||||
|
||||
echo "Latest Joomla: {$latestJoomla}\n";
|
||||
|
||||
// ── Test compatibility ──────────────────────────────────────────────────
|
||||
// The targetplatform regex uses Joomla's regex format
|
||||
// Common patterns: "5\.[0-9]+" or "((5.[0-9])|(6.[0-9]))"
|
||||
$compatible = @preg_match("/{$targetRegex}/", $latestJoomla);
|
||||
|
||||
if ($compatible === false) {
|
||||
echo "ERROR: Invalid regex in targetplatform: {$targetRegex}\n";
|
||||
$result = 'error';
|
||||
} elseif ($compatible === 1) {
|
||||
echo "PASS: Joomla {$latestJoomla} matches targetplatform regex\n";
|
||||
$result = 'pass';
|
||||
} else {
|
||||
// Check which major versions are supported
|
||||
$supported = [];
|
||||
foreach (['5.0', '5.1', '5.2', '5.3', '5.4', '6.0', '6.1', '6.2', '7.0'] as $v) {
|
||||
if (@preg_match("/{$targetRegex}/", $v)) {
|
||||
$supported[] = $v;
|
||||
}
|
||||
}
|
||||
|
||||
echo "WARN: Joomla {$latestJoomla} does NOT match targetplatform regex\n";
|
||||
echo "Supported versions: " . implode(', ', $supported) . "\n";
|
||||
echo "Consider updating targetplatform to include Joomla {$latestJoomla}\n";
|
||||
$result = 'warn';
|
||||
}
|
||||
|
||||
// ── Export ───────────────────────────────────────────────────────────────
|
||||
if ($ghOutput) {
|
||||
$ghFile = getenv('GITHUB_OUTPUT');
|
||||
if ($ghFile) {
|
||||
file_put_contents($ghFile, "compat_result={$result}\n", FILE_APPEND);
|
||||
file_put_contents($ghFile, "compat_joomla={$latestJoomla}\n", FILE_APPEND);
|
||||
file_put_contents($ghFile, "compat_regex={$targetRegex}\n", FILE_APPEND);
|
||||
}
|
||||
}
|
||||
|
||||
exit($result === 'error' ? 1 : 0);
|
||||
@@ -24,9 +24,9 @@ declare(strict_types=1);
|
||||
|
||||
require_once __DIR__ . '/../vendor/autoload.php';
|
||||
|
||||
use MokoEnterprise\{ApiClient, AuditLogger, CLIApp, Config, PlatformAdapterFactory};
|
||||
use MokoEnterprise\{ApiClient, AuditLogger, CliFramework, Config, PlatformAdapterFactory};
|
||||
|
||||
class JoomlaRelease extends CLIApp
|
||||
class JoomlaRelease extends CliFramework
|
||||
{
|
||||
private const VERSION = '04.06.00';
|
||||
private const ORG = 'mokoconsulting-tech';
|
||||
@@ -48,7 +48,7 @@ class JoomlaRelease extends CLIApp
|
||||
];
|
||||
|
||||
private ApiClient $api;
|
||||
private AuditLogger $logger;
|
||||
private \MokoEnterprise\GitPlatformAdapter $adapter;
|
||||
|
||||
protected function configure(): void
|
||||
{
|
||||
@@ -75,7 +75,6 @@ class JoomlaRelease extends CLIApp
|
||||
$config = Config::load();
|
||||
$this->adapter = PlatformAdapterFactory::create($config);
|
||||
$this->api = $this->adapter->getApiClient();
|
||||
$this->logger = new AuditLogger('joomla_release');
|
||||
|
||||
if ($repo !== '') {
|
||||
$path = $this->cloneRepo($repo);
|
||||
@@ -498,5 +497,5 @@ class JoomlaRelease extends CLIApp
|
||||
}
|
||||
}
|
||||
|
||||
$script = new JoomlaRelease('joomla_release', 'Joomla release pipeline');
|
||||
exit($script->execute());
|
||||
$app = new JoomlaRelease();
|
||||
exit($app->execute());
|
||||
|
||||
@@ -0,0 +1,209 @@
|
||||
#!/usr/bin/env php
|
||||
<?php
|
||||
/* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
|
||||
*
|
||||
* 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/theme_lint.php
|
||||
* BRIEF: Lint theme files — CSS syntax, image sizes, hardcoded URLs
|
||||
*
|
||||
* Usage:
|
||||
* php theme_lint.php --path /repo
|
||||
* php theme_lint.php --path /repo --max-image-kb 500
|
||||
* php theme_lint.php --path /repo --github-output
|
||||
*
|
||||
* Options:
|
||||
* --path Repository root (default: .)
|
||||
* --max-image-kb Maximum image file size in KB (default: 500)
|
||||
* --github-output Export results to $GITHUB_OUTPUT
|
||||
* --strict Exit 1 on any warning (default: only on errors)
|
||||
*/
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
$path = '.';
|
||||
$maxImageKb = 500;
|
||||
$ghOutput = false;
|
||||
$strict = false;
|
||||
|
||||
foreach ($argv as $i => $arg) {
|
||||
if ($arg === '--path' && isset($argv[$i + 1])) $path = $argv[$i + 1];
|
||||
if ($arg === '--max-image-kb' && isset($argv[$i + 1])) $maxImageKb = (int)$argv[$i + 1];
|
||||
if ($arg === '--github-output') $ghOutput = true;
|
||||
if ($arg === '--strict') $strict = true;
|
||||
}
|
||||
|
||||
$root = realpath($path) ?: $path;
|
||||
$errors = 0;
|
||||
$warnings = 0;
|
||||
|
||||
// ── Find source directory ───────────────────────────────────────────────
|
||||
$srcDir = null;
|
||||
foreach (['src', 'htdocs'] as $d) {
|
||||
if (is_dir("{$root}/{$d}")) { $srcDir = "{$root}/{$d}"; break; }
|
||||
}
|
||||
if ($srcDir === null) {
|
||||
fwrite(STDERR, "No src/ or htdocs/ directory in {$root}\n");
|
||||
exit(1);
|
||||
}
|
||||
|
||||
echo "Theme Lint: {$srcDir}\n\n";
|
||||
|
||||
// ── Check 1: CSS syntax validation ──────────────────────────────────────
|
||||
echo "--- CSS Syntax ---\n";
|
||||
$cssFiles = findFiles($srcDir, '*.css');
|
||||
$cssMinFiles = findFiles($srcDir, '*.min.css');
|
||||
$cssToCheck = array_diff($cssFiles, $cssMinFiles);
|
||||
|
||||
if (empty($cssToCheck)) {
|
||||
echo " No CSS files to check\n";
|
||||
} else {
|
||||
foreach ($cssToCheck as $file) {
|
||||
$content = file_get_contents($file);
|
||||
$relPath = str_replace($root . '/', '', $file);
|
||||
|
||||
// Check for unmatched braces
|
||||
$openBraces = substr_count($content, '{');
|
||||
$closeBraces = substr_count($content, '}');
|
||||
if ($openBraces !== $closeBraces) {
|
||||
echo " ERROR: {$relPath}: unmatched braces (open={$openBraces}, close={$closeBraces})\n";
|
||||
$errors++;
|
||||
}
|
||||
|
||||
// Check for empty rules
|
||||
if (preg_match_all('/\{[\s]*\}/', $content, $m)) {
|
||||
$count = count($m[0]);
|
||||
echo " WARN: {$relPath}: {$count} empty rule(s)\n";
|
||||
$warnings++;
|
||||
}
|
||||
|
||||
// Check for !important abuse (more than 10 in one file)
|
||||
$importantCount = substr_count($content, '!important');
|
||||
if ($importantCount > 10) {
|
||||
echo " WARN: {$relPath}: {$importantCount} !important declarations (consider refactoring)\n";
|
||||
$warnings++;
|
||||
}
|
||||
}
|
||||
|
||||
if ($errors === 0) {
|
||||
echo " OK: " . count($cssToCheck) . " CSS file(s) checked\n";
|
||||
}
|
||||
}
|
||||
|
||||
// ── Check 2: Image file sizes ───────────────────────────────────────────
|
||||
echo "\n--- Image Sizes (max {$maxImageKb}KB) ---\n";
|
||||
$imageExts = ['*.jpg', '*.jpeg', '*.png', '*.gif', '*.webp', '*.svg', '*.bmp'];
|
||||
$images = [];
|
||||
foreach ($imageExts as $ext) {
|
||||
$images = array_merge($images, findFiles($srcDir, $ext));
|
||||
}
|
||||
// Also check root images/ directory
|
||||
if (is_dir("{$root}/images")) {
|
||||
foreach ($imageExts as $ext) {
|
||||
$images = array_merge($images, findFiles("{$root}/images", $ext));
|
||||
}
|
||||
}
|
||||
|
||||
$oversized = 0;
|
||||
$totalSize = 0;
|
||||
foreach ($images as $file) {
|
||||
$size = filesize($file);
|
||||
$totalSize += $size;
|
||||
$relPath = str_replace($root . '/', '', $file);
|
||||
$sizeKb = round($size / 1024);
|
||||
|
||||
if ($sizeKb > $maxImageKb) {
|
||||
echo " WARN: {$relPath}: {$sizeKb}KB (exceeds {$maxImageKb}KB limit)\n";
|
||||
$oversized++;
|
||||
$warnings++;
|
||||
}
|
||||
}
|
||||
|
||||
$totalMb = round($totalSize / 1024 / 1024, 1);
|
||||
echo " " . count($images) . " image(s), {$totalMb}MB total";
|
||||
if ($oversized > 0) {
|
||||
echo ", {$oversized} oversized";
|
||||
}
|
||||
echo "\n";
|
||||
|
||||
// ── Check 3: Hardcoded URLs in CSS/JS ───────────────────────────────────
|
||||
echo "\n--- Hardcoded URLs ---\n";
|
||||
$codeFiles = array_merge(
|
||||
findFiles($srcDir, '*.css'),
|
||||
findFiles($srcDir, '*.js')
|
||||
);
|
||||
// Exclude minified files
|
||||
$codeFiles = array_filter($codeFiles, function($f) {
|
||||
return !preg_match('/\.min\.(css|js)$/', $f);
|
||||
});
|
||||
|
||||
$urlPatterns = [
|
||||
'/https?:\/\/clarksvillefurs\.com/' => 'hardcoded production URL',
|
||||
'/https?:\/\/[a-z]+\.dev\.mokoconsulting\.tech/' => 'hardcoded dev URL',
|
||||
'/https?:\/\/localhost/' => 'localhost reference',
|
||||
];
|
||||
|
||||
$urlIssues = 0;
|
||||
foreach ($codeFiles as $file) {
|
||||
$content = file_get_contents($file);
|
||||
$relPath = str_replace($root . '/', '', $file);
|
||||
|
||||
foreach ($urlPatterns as $pattern => $desc) {
|
||||
if (preg_match_all($pattern, $content, $matches)) {
|
||||
$count = count($matches[0]);
|
||||
echo " WARN: {$relPath}: {$count} {$desc}\n";
|
||||
$urlIssues++;
|
||||
$warnings++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if ($urlIssues === 0) {
|
||||
echo " OK: No hardcoded URLs found\n";
|
||||
}
|
||||
|
||||
// ── Summary ─────────────────────────────────────────────────────────────
|
||||
echo "\n=== Summary ===\n";
|
||||
echo "Errors: {$errors}\n";
|
||||
echo "Warnings: {$warnings}\n";
|
||||
|
||||
if ($ghOutput) {
|
||||
$ghFile = getenv('GITHUB_OUTPUT');
|
||||
if ($ghFile) {
|
||||
file_put_contents($ghFile, "lint_errors={$errors}\n", FILE_APPEND);
|
||||
file_put_contents($ghFile, "lint_warnings={$warnings}\n", FILE_APPEND);
|
||||
file_put_contents($ghFile, "lint_images=" . count($images) . "\n", FILE_APPEND);
|
||||
file_put_contents($ghFile, "lint_css=" . count($cssToCheck) . "\n", FILE_APPEND);
|
||||
}
|
||||
}
|
||||
|
||||
if ($errors > 0) {
|
||||
exit(1);
|
||||
}
|
||||
if ($strict && $warnings > 0) {
|
||||
exit(1);
|
||||
}
|
||||
exit(0);
|
||||
|
||||
// ── Helper: recursively find files matching a glob pattern ──────────────
|
||||
function findFiles(string $dir, string $pattern): array
|
||||
{
|
||||
$results = [];
|
||||
if (!is_dir($dir)) return $results;
|
||||
|
||||
$iterator = new RecursiveIteratorIterator(
|
||||
new RecursiveDirectoryIterator($dir, RecursiveDirectoryIterator::SKIP_DOTS)
|
||||
);
|
||||
|
||||
foreach ($iterator as $file) {
|
||||
if (fnmatch($pattern, $file->getFilename())) {
|
||||
$results[] = $file->getPathname();
|
||||
}
|
||||
}
|
||||
|
||||
return $results;
|
||||
}
|
||||
+47
-38
@@ -194,29 +194,35 @@ $stabilitySuffixMap = [
|
||||
'development' => '-dev',
|
||||
];
|
||||
|
||||
// Joomla <tags><tag> values — maps to Joomla's stabilityTagToInteger()
|
||||
$stabilityTagMap = [
|
||||
'stable' => 'stable',
|
||||
'rc' => 'rc',
|
||||
'beta' => 'beta',
|
||||
'alpha' => 'alpha',
|
||||
'development' => 'dev',
|
||||
];
|
||||
|
||||
// Gitea release tag names (used in download/info URLs)
|
||||
$releaseTagMap = [
|
||||
'stable' => 'stable',
|
||||
'rc' => 'release-candidate',
|
||||
'beta' => 'beta',
|
||||
'alpha' => 'alpha',
|
||||
'development' => 'development',
|
||||
];
|
||||
|
||||
// -- 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
|
||||
// Build client tag — only needed for templates and modules (site vs admin).
|
||||
// Packages and components don't use client; plugins use folder instead.
|
||||
$clientTag = '';
|
||||
if (!empty($extClient)) {
|
||||
$clientTag = " <client>{$extClient}</client>";
|
||||
} elseif ($extType === 'module' || $extType === 'plugin') {
|
||||
} elseif (in_array($extType, ['template', 'module'])) {
|
||||
$clientTag = ' <client>site</client>';
|
||||
}
|
||||
|
||||
@@ -282,41 +288,44 @@ function buildEntry(
|
||||
}
|
||||
|
||||
// -- 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
|
||||
// Stable cascades to all channels; pre-releases cascade down to lower channels.
|
||||
// Each channel entry represents "latest release available at this stability or higher".
|
||||
// When stable releases, ALL channels point to stable (it's the newest for everyone).
|
||||
// When RC releases, rc/beta/alpha/dev point to RC; stable is preserved.
|
||||
// When dev releases, only dev is updated; everything else is preserved.
|
||||
$allChannels = ['development', 'alpha', 'beta', 'rc', 'stable'];
|
||||
$stabilityIndex = array_search($stability === 'development' ? 'development' : $stability, $allChannels);
|
||||
if ($stabilityIndex === false) $stabilityIndex = 4; // default to stable
|
||||
|
||||
// Write only the current channel entry (not cascade)
|
||||
// Each channel release only creates its own entry; preserved entries handle other channels
|
||||
// Write entries for the current channel AND all lower channels (cascade down)
|
||||
// All cascaded entries point to the CURRENT release (the highest stability being built)
|
||||
$entries = [];
|
||||
$channelName = $allChannels[$stabilityIndex];
|
||||
$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}";
|
||||
$giteaTag = $releaseTagMap[$stability] ?? $stability;
|
||||
$channelVersion = $version . ($stabilitySuffixMap[$stability] ?? '');
|
||||
$channelDownloadUrl = "{$giteaUrl}/{$org}/{$repo}/releases/download/{$giteaTag}/{$typePrefix}{$extElement}-{$channelVersion}.zip";
|
||||
$channelInfoUrl = "{$giteaUrl}/{$org}/{$repo}/releases/tag/{$giteaTag}";
|
||||
|
||||
$entries[] = buildEntry(
|
||||
$channelName,
|
||||
$channelVersion,
|
||||
$channelDownloadUrl,
|
||||
$extName,
|
||||
$extElement,
|
||||
$extType,
|
||||
$clientTag,
|
||||
$folderTag,
|
||||
$channelInfoUrl,
|
||||
$targetPlatform,
|
||||
$phpTag,
|
||||
$shaTag
|
||||
);
|
||||
for ($i = 0; $i <= $stabilityIndex; $i++) {
|
||||
$channelName = $allChannels[$i];
|
||||
$joomlaTag = $stabilityTagMap[$channelName] ?? $channelName;
|
||||
// Only attach SHA to the primary channel entry
|
||||
$entrySha = ($i === $stabilityIndex) ? $shaTag : '';
|
||||
|
||||
$entries[] = buildEntry(
|
||||
$joomlaTag,
|
||||
$channelVersion,
|
||||
$channelDownloadUrl,
|
||||
$extName,
|
||||
$extElement,
|
||||
$extType,
|
||||
$clientTag,
|
||||
$folderTag,
|
||||
$channelInfoUrl,
|
||||
$targetPlatform,
|
||||
$phpTag,
|
||||
$entrySha
|
||||
);
|
||||
}
|
||||
|
||||
// -- Preserve existing entries for channels not being updated -----------------
|
||||
$dest = $outputFile ?? "{$root}/updates.xml";
|
||||
@@ -325,10 +334,10 @@ $preservedEntries = [];
|
||||
if (file_exists($dest)) {
|
||||
$existingXml = @simplexml_load_file($dest);
|
||||
if ($existingXml) {
|
||||
// Channels we're writing — don't preserve these
|
||||
// Joomla tags we're writing — don't preserve these
|
||||
$writtenChannels = [];
|
||||
for ($i = 0; $i <= $stabilityIndex; $i++) {
|
||||
$writtenChannels[] = $allChannels[$i];
|
||||
$writtenChannels[] = $stabilityTagMap[$allChannels[$i]] ?? $allChannels[$i];
|
||||
}
|
||||
|
||||
foreach ($existingXml->update as $existingUpdate) {
|
||||
|
||||
@@ -0,0 +1,138 @@
|
||||
#!/usr/bin/env php
|
||||
<?php
|
||||
/* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
|
||||
*
|
||||
* 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/version_check.php
|
||||
* VERSION: 05.00.00
|
||||
* BRIEF: Validate version consistency across README, manifests, and sub-packages
|
||||
*
|
||||
* Usage:
|
||||
* php version_check.php --path /repo
|
||||
* php version_check.php --path /repo --strict # exit 1 on mismatch
|
||||
* php version_check.php --path /repo --fix # fix mismatches to highest version
|
||||
*/
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
$path = '.';
|
||||
$strict = false;
|
||||
$fix = false;
|
||||
|
||||
foreach ($argv as $i => $arg) {
|
||||
if ($arg === '--path' && isset($argv[$i + 1])) $path = $argv[$i + 1];
|
||||
if ($arg === '--strict') $strict = true;
|
||||
if ($arg === '--fix') $fix = true;
|
||||
}
|
||||
|
||||
$root = realpath($path) ?: $path;
|
||||
$errors = 0;
|
||||
$versions = [];
|
||||
|
||||
// ── Read README.md version ───────────────────────────────────────────────────
|
||||
$readme = "{$root}/README.md";
|
||||
if (file_exists($readme)) {
|
||||
$content = file_get_contents($readme);
|
||||
if (preg_match('/VERSION:\s*(\d{2}\.\d{2}\.\d{2})/m', $content, $m)) {
|
||||
$versions['README.md'] = $m[1];
|
||||
}
|
||||
}
|
||||
|
||||
// ── Read manifest XML versions ───────────────────────────────────────────────
|
||||
$xmlGlobs = [
|
||||
"{$root}/src/pkg_*.xml",
|
||||
"{$root}/src/*.xml",
|
||||
"{$root}/src/packages/*/*.xml",
|
||||
"{$root}/*.xml",
|
||||
];
|
||||
|
||||
foreach ($xmlGlobs as $glob) {
|
||||
foreach (glob($glob) ?: [] as $file) {
|
||||
// Skip updates.xml
|
||||
if (basename($file) === 'updates.xml') continue;
|
||||
|
||||
$xmlContent = file_get_contents($file);
|
||||
if (strpos($xmlContent, '<extension') === false) continue;
|
||||
|
||||
if (preg_match('|<version>(\d{2}\.\d{2}\.\d{2})(?:-[a-z]+)?</version>|', $xmlContent, $xm)) {
|
||||
$relPath = str_replace($root . '/', '', $file);
|
||||
$relPath = str_replace($root . '\\', '', $relPath);
|
||||
$versions[$relPath] = $xm[1];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (empty($versions)) {
|
||||
fwrite(STDERR, "No version sources found\n");
|
||||
exit(1);
|
||||
}
|
||||
|
||||
// ── Compare versions ─────────────────────────────────────────────────────────
|
||||
$uniqueVersions = array_unique(array_values($versions));
|
||||
$highestVersion = '00.00.00';
|
||||
foreach ($versions as $v) {
|
||||
if (version_compare($v, $highestVersion, '>')) {
|
||||
$highestVersion = $v;
|
||||
}
|
||||
}
|
||||
|
||||
echo "=== Version Consistency Check ===\n";
|
||||
foreach ($versions as $source => $ver) {
|
||||
$status = ($ver === $highestVersion) ? 'OK' : 'MISMATCH';
|
||||
if ($status === 'MISMATCH') $errors++;
|
||||
echo sprintf(" %-50s %s %s\n", $source, $ver, $status === 'OK' ? '' : "** MISMATCH (expected {$highestVersion})");
|
||||
}
|
||||
|
||||
if (count($uniqueVersions) === 1) {
|
||||
echo "\nAll {$ver} — consistent.\n";
|
||||
} else {
|
||||
echo "\n** {$errors} mismatch(es) found. Highest version: {$highestVersion}\n";
|
||||
|
||||
if ($fix) {
|
||||
echo "\n=== Fixing mismatches to {$highestVersion} ===\n";
|
||||
|
||||
// Fix README.md
|
||||
if (isset($versions['README.md']) && $versions['README.md'] !== $highestVersion) {
|
||||
$content = file_get_contents($readme);
|
||||
$content = preg_replace(
|
||||
'/(VERSION:\s*)\d{2}\.\d{2}\.\d{2}/m',
|
||||
'${1}' . $highestVersion,
|
||||
$content,
|
||||
1
|
||||
);
|
||||
file_put_contents($readme, $content);
|
||||
echo " Fixed: README.md -> {$highestVersion}\n";
|
||||
}
|
||||
|
||||
// Fix XML manifests
|
||||
foreach ($versions as $source => $ver) {
|
||||
if ($source === 'README.md') continue;
|
||||
if ($ver === $highestVersion) continue;
|
||||
|
||||
$file = "{$root}/{$source}";
|
||||
if (!file_exists($file)) continue;
|
||||
|
||||
$content = file_get_contents($file);
|
||||
$content = preg_replace(
|
||||
'|<version>[^<]*</version>|',
|
||||
"<version>{$highestVersion}</version>",
|
||||
$content
|
||||
);
|
||||
file_put_contents($file, $content);
|
||||
echo " Fixed: {$source} -> {$highestVersion}\n";
|
||||
}
|
||||
|
||||
echo "Done.\n";
|
||||
}
|
||||
}
|
||||
|
||||
if ($strict && $errors > 0) {
|
||||
exit(1);
|
||||
}
|
||||
|
||||
exit(0);
|
||||
+1
-1
@@ -2,7 +2,7 @@
|
||||
"name": "mokoconsulting-tech/enterprise",
|
||||
"description": "MokoStandards Enterprise API \u2014 PHP implementation",
|
||||
"type": "library",
|
||||
"version": "06.00.00",
|
||||
"version": "09.00.00",
|
||||
"license": "GPL-3.0-or-later",
|
||||
"authors": [
|
||||
{
|
||||
|
||||
@@ -92,6 +92,8 @@ class CircuitBreakerOpen extends RuntimeException
|
||||
* );
|
||||
* $response = $client->get('/repos/owner/repo');
|
||||
* ```
|
||||
*
|
||||
* @since 04.00.00
|
||||
*/
|
||||
class ApiClient
|
||||
{
|
||||
@@ -124,7 +126,6 @@ class ApiClient
|
||||
private ?DateTime $circuitLastFailure = null;
|
||||
|
||||
/** @var LoggerInterface|null Optional logger instance */
|
||||
private ?LoggerInterface $logger = null;
|
||||
|
||||
/** @var array<string, mixed> Request metrics */
|
||||
private array $metrics = [
|
||||
@@ -179,7 +180,6 @@ class ApiClient
|
||||
$this->circuitBreakerTimeout = $circuitBreakerTimeout;
|
||||
$this->enableCaching = $enableCaching;
|
||||
$this->userAgent = $userAgent;
|
||||
$this->logger = $logger;
|
||||
$this->authScheme = $authScheme;
|
||||
|
||||
// Initialize HTTP client
|
||||
@@ -261,9 +261,9 @@ class ApiClient
|
||||
* @throws RateLimitExceeded
|
||||
* @throws CircuitBreakerOpen
|
||||
*/
|
||||
public function delete(string $endpoint): array
|
||||
public function delete(string $endpoint, ?array $body = null): array
|
||||
{
|
||||
return $this->request('DELETE', $endpoint);
|
||||
return $this->request('DELETE', $endpoint, $body);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -716,6 +716,9 @@ class ValidationCLI extends CLIApp
|
||||
* Lifecycle: configure() -> parseArguments() -> printBanner() -> initialize() -> run()
|
||||
*
|
||||
* All new scripts must extend CliFramework and implement configure() + run().
|
||||
*
|
||||
* @since 04.00.15
|
||||
* @see CLIApp Legacy base class (deprecated)
|
||||
*/
|
||||
abstract class CliFramework
|
||||
{
|
||||
@@ -932,6 +935,11 @@ abstract class CliFramework
|
||||
// Argument parsing (internal)
|
||||
// =========================================================================
|
||||
|
||||
/**
|
||||
* Parse CLI arguments from $_SERVER['argv'] into registered argument definitions.
|
||||
*
|
||||
* @since 04.00.15
|
||||
*/
|
||||
private function parseArguments(): void
|
||||
{
|
||||
$argv = array_slice($_SERVER['argv'] ?? [], 1);
|
||||
@@ -970,6 +978,11 @@ abstract class CliFramework
|
||||
// Help screen
|
||||
// =========================================================================
|
||||
|
||||
/**
|
||||
* Print auto-generated help screen from registered arguments.
|
||||
*
|
||||
* @since 04.00.15
|
||||
*/
|
||||
protected function printHelp(): void
|
||||
{
|
||||
$w = $this->termWidth();
|
||||
|
||||
@@ -0,0 +1,247 @@
|
||||
<?php
|
||||
|
||||
/* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
|
||||
*
|
||||
* This file is part of a Moko Consulting project.
|
||||
*
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*
|
||||
* FILE INFORMATION
|
||||
* DEFGROUP: MokoStandards.Enterprise
|
||||
* INGROUP: MokoStandards.Enterprise
|
||||
* REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform
|
||||
* PATH: /lib/Enterprise/ConfigValidator.php
|
||||
* BRIEF: Validate project config against plugin JSON schema
|
||||
*/
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace MokoEnterprise;
|
||||
|
||||
class ConfigValidator
|
||||
{
|
||||
/** @var array<int, string> */
|
||||
private array $errors = [];
|
||||
|
||||
/** @var array<int, string> */
|
||||
private array $warnings = [];
|
||||
|
||||
/**
|
||||
* Validate config data against a JSON schema.
|
||||
*
|
||||
* @param array<string, mixed> $config Config to validate
|
||||
* @param array<string, mixed> $schema JSON Schema definition
|
||||
* @return bool True if valid
|
||||
*/
|
||||
public function validate(array $config, array $schema): bool
|
||||
{
|
||||
$this->errors = [];
|
||||
$this->warnings = [];
|
||||
|
||||
$this->validateNode($config, $schema, '');
|
||||
|
||||
return empty($this->errors);
|
||||
}
|
||||
|
||||
/** @return array<int, string> */
|
||||
public function getErrors(): array
|
||||
{
|
||||
return $this->errors;
|
||||
}
|
||||
|
||||
/** @return array<int, string> */
|
||||
public function getWarnings(): array
|
||||
{
|
||||
return $this->warnings;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param mixed $data
|
||||
* @param array<string, mixed> $schema
|
||||
*/
|
||||
private function validateNode(
|
||||
mixed $data,
|
||||
array $schema,
|
||||
string $path
|
||||
): void {
|
||||
$type = $schema['type'] ?? null;
|
||||
|
||||
if ($type !== null && !$this->checkType($data, $type)) {
|
||||
$actual = gettype($data);
|
||||
$this->errors[] = $path === ''
|
||||
? "Root must be {$type}, got {$actual}"
|
||||
: "{$path}: expected {$type}, got {$actual}";
|
||||
return;
|
||||
}
|
||||
|
||||
if ($type === 'object') {
|
||||
$this->validateObject($data, $schema, $path);
|
||||
}
|
||||
|
||||
if ($type === 'array' && isset($schema['items'])) {
|
||||
$this->validateArray($data, $schema, $path);
|
||||
}
|
||||
|
||||
if (isset($schema['enum'])) {
|
||||
$this->validateEnum($data, $schema['enum'], $path);
|
||||
}
|
||||
|
||||
if ($type === 'string') {
|
||||
$this->validateString($data, $schema, $path);
|
||||
}
|
||||
|
||||
if ($type === 'integer' || $type === 'number') {
|
||||
$this->validateNumber($data, $schema, $path);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $data
|
||||
* @param array<string, mixed> $schema
|
||||
*/
|
||||
private function validateObject(
|
||||
array $data,
|
||||
array $schema,
|
||||
string $path
|
||||
): void {
|
||||
$properties = $schema['properties'] ?? [];
|
||||
$required = $schema['required'] ?? [];
|
||||
|
||||
foreach ($required as $field) {
|
||||
if (!array_key_exists($field, $data)) {
|
||||
$fieldPath = $path === '' ? $field : "{$path}.{$field}";
|
||||
$this->errors[] = "{$fieldPath}: required field missing";
|
||||
}
|
||||
}
|
||||
|
||||
foreach ($properties as $field => $fieldSchema) {
|
||||
if (!array_key_exists($field, $data)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$fieldPath = $path === '' ? $field : "{$path}.{$field}";
|
||||
$this->validateNode($data[$field], $fieldSchema, $fieldPath);
|
||||
}
|
||||
|
||||
$known = array_keys($properties);
|
||||
|
||||
foreach (array_keys($data) as $field) {
|
||||
if (!in_array($field, $known, true)) {
|
||||
$fieldPath = $path === '' ? $field : "{$path}.{$field}";
|
||||
$this->warnings[] = "{$fieldPath}: unknown property";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int, mixed> $data
|
||||
* @param array<string, mixed> $schema
|
||||
*/
|
||||
private function validateArray(
|
||||
array $data,
|
||||
array $schema,
|
||||
string $path
|
||||
): void {
|
||||
$itemSchema = $schema['items'];
|
||||
|
||||
foreach ($data as $i => $item) {
|
||||
$this->validateNode(
|
||||
$item,
|
||||
$itemSchema,
|
||||
"{$path}[{$i}]"
|
||||
);
|
||||
}
|
||||
|
||||
if (
|
||||
isset($schema['minItems'])
|
||||
&& count($data) < $schema['minItems']
|
||||
) {
|
||||
$this->errors[] = "{$path}: "
|
||||
. "needs at least {$schema['minItems']} items";
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param mixed $data
|
||||
* @param array<int, mixed> $allowed
|
||||
*/
|
||||
private function validateEnum(
|
||||
mixed $data,
|
||||
array $allowed,
|
||||
string $path
|
||||
): void {
|
||||
if (!in_array($data, $allowed, true)) {
|
||||
$values = implode(', ', $allowed);
|
||||
$label = $path ?: 'value';
|
||||
$this->errors[] = "{$label}: "
|
||||
. "'{$data}' not in [{$values}]";
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $schema
|
||||
*/
|
||||
private function validateString(
|
||||
mixed $data,
|
||||
array $schema,
|
||||
string $path
|
||||
): void {
|
||||
if (!is_string($data)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
isset($schema['minLength'])
|
||||
&& strlen($data) < $schema['minLength']
|
||||
) {
|
||||
$this->errors[] = "{$path}: "
|
||||
. "too short (min {$schema['minLength']})";
|
||||
}
|
||||
|
||||
if (
|
||||
isset($schema['pattern'])
|
||||
&& !preg_match('/' . $schema['pattern'] . '/', $data)
|
||||
) {
|
||||
$this->errors[] = "{$path}: "
|
||||
. "does not match pattern {$schema['pattern']}";
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $schema
|
||||
*/
|
||||
private function validateNumber(
|
||||
mixed $data,
|
||||
array $schema,
|
||||
string $path
|
||||
): void {
|
||||
if (!is_numeric($data)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (isset($schema['minimum']) && $data < $schema['minimum']) {
|
||||
$this->errors[] = "{$path}: "
|
||||
. "below minimum {$schema['minimum']}";
|
||||
}
|
||||
|
||||
if (isset($schema['maximum']) && $data > $schema['maximum']) {
|
||||
$this->errors[] = "{$path}: "
|
||||
. "above maximum {$schema['maximum']}";
|
||||
}
|
||||
}
|
||||
|
||||
private function checkType(mixed $data, string $type): bool
|
||||
{
|
||||
return match ($type) {
|
||||
'object' => is_array($data),
|
||||
'array' => is_array($data)
|
||||
&& array_is_list($data),
|
||||
'string' => is_string($data),
|
||||
'integer' => is_int($data),
|
||||
'number' => is_int($data) || is_float($data),
|
||||
'boolean' => is_bool($data),
|
||||
'null' => is_null($data),
|
||||
default => true,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -32,12 +32,17 @@ use RuntimeException;
|
||||
* - Workflow dir: .github/workflows
|
||||
*
|
||||
* @package MokoStandards\Enterprise
|
||||
* @version 04.06.10
|
||||
* @since 04.06.10
|
||||
* @see GitPlatformAdapter
|
||||
*/
|
||||
class GitHubAdapter implements GitPlatformAdapter
|
||||
{
|
||||
/** @var ApiClient HTTP client for GitHub API calls. */
|
||||
private ApiClient $apiClient;
|
||||
|
||||
/**
|
||||
* @param ApiClient $apiClient Configured API client for api.github.com
|
||||
*/
|
||||
public function __construct(ApiClient $apiClient)
|
||||
{
|
||||
$this->apiClient = $apiClient;
|
||||
@@ -405,7 +410,7 @@ class GitHubAdapter implements GitPlatformAdapter
|
||||
$page++;
|
||||
}
|
||||
|
||||
return $all;
|
||||
return array_values($all);
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────
|
||||
|
||||
@@ -175,7 +175,7 @@ interface GitPlatformAdapter
|
||||
/**
|
||||
* List all branches in a repository.
|
||||
*
|
||||
* @return array<int, array<string, mixed>>
|
||||
* @return array<mixed>
|
||||
*/
|
||||
public function listBranches(string $org, string $repo): array;
|
||||
|
||||
@@ -202,7 +202,7 @@ interface GitPlatformAdapter
|
||||
* @param string $repo Repository name
|
||||
* @param string $path File path within the repository
|
||||
* @param string|null $ref Branch/tag/SHA reference (null = default branch)
|
||||
* @return array{content: string, sha: string, size: int, encoding: string} File data (content is base64-encoded)
|
||||
* @return array<string, mixed> File data (content is base64-encoded)
|
||||
*/
|
||||
public function getFileContents(string $org, string $repo, string $path, ?string $ref = null): array;
|
||||
|
||||
@@ -258,7 +258,7 @@ interface GitPlatformAdapter
|
||||
* @param string $org Organization name
|
||||
* @param string $repo Repository name
|
||||
* @param array<string, mixed> $filters Filters (state, head, base, sort, direction)
|
||||
* @return array<int, array<string, mixed>> Pull request list
|
||||
* @return array<mixed> Pull request list
|
||||
*/
|
||||
public function listPullRequests(string $org, string $repo, array $filters = []): array;
|
||||
|
||||
@@ -305,7 +305,7 @@ interface GitPlatformAdapter
|
||||
* @param string $org Organization name
|
||||
* @param string $repo Repository name
|
||||
* @param array<string, mixed> $filters Filters (state, labels, assignee, etc.)
|
||||
* @return array<int, array<string, mixed>> Issue list
|
||||
* @return array<mixed> Issue list
|
||||
*/
|
||||
public function listIssues(string $org, string $repo, array $filters = []): array;
|
||||
|
||||
@@ -357,7 +357,7 @@ interface GitPlatformAdapter
|
||||
*
|
||||
* @param string $org Organization name
|
||||
* @param string $repo Repository name
|
||||
* @return array<int, array{name: string, color: string, description: string}> Label list
|
||||
* @return array<mixed> Label list
|
||||
*/
|
||||
public function listLabels(string $org, string $repo): array;
|
||||
|
||||
@@ -406,7 +406,7 @@ interface GitPlatformAdapter
|
||||
*
|
||||
* @param string $org Organization name
|
||||
* @param string $repo Repository name
|
||||
* @return array<int, array<string, mixed>> Protection rules
|
||||
* @return array<mixed> Protection rules
|
||||
*/
|
||||
public function listBranchProtections(string $org, string $repo): array;
|
||||
|
||||
@@ -445,7 +445,7 @@ interface GitPlatformAdapter
|
||||
* @param string $endpoint API endpoint path
|
||||
* @param array<string, mixed> $params Query parameters
|
||||
* @param int $perPage Items per page (platform default if 0)
|
||||
* @return array<int, array<string, mixed>> All items across all pages
|
||||
* @return array<mixed> All items across all pages
|
||||
*/
|
||||
public function paginateAll(string $endpoint, array $params = [], int $perPage = 100): array;
|
||||
|
||||
|
||||
@@ -34,13 +34,21 @@ use RuntimeException;
|
||||
* - Workflow dir: .mokogitea/workflows
|
||||
*
|
||||
* @package MokoStandards\Enterprise
|
||||
* @version 04.06.10
|
||||
* @since 04.06.10
|
||||
* @see GitPlatformAdapter
|
||||
*/
|
||||
class MokoGiteaAdapter implements GitPlatformAdapter
|
||||
{
|
||||
/** @var ApiClient HTTP client for Gitea API calls. */
|
||||
private ApiClient $apiClient;
|
||||
|
||||
/** @var string Base URL for Gitea API (e.g. https://git.mokoconsulting.tech/api/v1). */
|
||||
private string $baseUrl;
|
||||
|
||||
/**
|
||||
* @param ApiClient $apiClient Configured API client
|
||||
* @param string $baseUrl Gitea API base URL
|
||||
*/
|
||||
public function __construct(ApiClient $apiClient, string $baseUrl = 'https://git.mokoconsulting.tech/api/v1')
|
||||
{
|
||||
$this->apiClient = $apiClient;
|
||||
@@ -468,7 +476,7 @@ class MokoGiteaAdapter implements GitPlatformAdapter
|
||||
$page++;
|
||||
}
|
||||
|
||||
return $all;
|
||||
return array_values($all);
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────
|
||||
|
||||
@@ -29,7 +29,6 @@ class RepositoryHealthChecker
|
||||
{
|
||||
private AuditLogger $logger;
|
||||
private MetricsCollector $metrics;
|
||||
private UnifiedValidator $validator;
|
||||
|
||||
private array $results = [
|
||||
'categories' => [],
|
||||
@@ -50,7 +49,6 @@ class RepositoryHealthChecker
|
||||
) {
|
||||
$this->logger = $logger ?? new AuditLogger('repo_health_checker');
|
||||
$this->metrics = $metrics ?? new MetricsCollector();
|
||||
$this->validator = $validator ?? new UnifiedValidator();
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -39,7 +39,6 @@ class RepositorySynchronizer
|
||||
private const VERSION_BRANCH = 'version/' . self::STANDARDS_MAJOR;
|
||||
private const SYNC_BRANCH = 'chore/sync-mokostandards-v' . self::STANDARDS_MINOR;
|
||||
|
||||
private ApiClient $apiClient;
|
||||
private GitPlatformAdapter $adapter;
|
||||
private AuditLogger $logger;
|
||||
private MetricsCollector $metrics;
|
||||
@@ -65,7 +64,6 @@ class RepositorySynchronizer
|
||||
?DefinitionParser $definitionParser = null,
|
||||
?GitPlatformAdapter $adapter = null
|
||||
) {
|
||||
$this->apiClient = $apiClient;
|
||||
$this->adapter = $adapter ?? new MokoGiteaAdapter($apiClient);
|
||||
$this->logger = $logger;
|
||||
$this->metrics = $metrics;
|
||||
@@ -1510,16 +1508,16 @@ HCL;
|
||||
|
||||
if ($updated) {
|
||||
$results['success']++;
|
||||
$this->metrics->increment('repos_updated_total', ['status' => 'success']);
|
||||
$this->metrics->increment('repos_updated_total', 1, ['status' => 'success']);
|
||||
$results['repositories'][$repoName] = 'updated';
|
||||
} else {
|
||||
$results['skipped']++;
|
||||
$this->metrics->increment('repos_updated_total', ['status' => 'skipped']);
|
||||
$this->metrics->increment('repos_updated_total', 1, ['status' => 'skipped']);
|
||||
$results['repositories'][$repoName] = 'skipped';
|
||||
}
|
||||
} catch (Exception $e) {
|
||||
$results['failed']++;
|
||||
$this->metrics->increment('repos_updated_total', ['status' => 'failed']);
|
||||
$this->metrics->increment('repos_updated_total', 1, ['status' => 'failed']);
|
||||
$results['repositories'][$repoName] = 'failed: ' . $e->getMessage();
|
||||
}
|
||||
|
||||
|
||||
@@ -96,8 +96,6 @@ class TransactionStep
|
||||
*/
|
||||
class Transaction
|
||||
{
|
||||
private const VERSION = '04.06.00';
|
||||
|
||||
private string $name;
|
||||
/** @var array<int, TransactionStep> */
|
||||
private array $steps = [];
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
+4
-5
@@ -6,7 +6,7 @@
|
||||
|
||||
# PHPStan configuration for moko-platform projects
|
||||
parameters:
|
||||
level: 2
|
||||
level: 6
|
||||
paths:
|
||||
- lib
|
||||
- validate
|
||||
@@ -16,12 +16,11 @@ parameters:
|
||||
analyseAndScan:
|
||||
- vendor
|
||||
- node_modules (?)
|
||||
# Legacy CLIApp scripts — need migration to CliFramework
|
||||
- automation/repo_cleanup.php
|
||||
- automation/push_files.php
|
||||
- cli/joomla_release.php
|
||||
|
||||
reportUnmatchedIgnoredErrors: false
|
||||
|
||||
checkFunctionNameCase: true
|
||||
checkInternalClassCaseSensitivity: true
|
||||
|
||||
includes:
|
||||
- phpstan-baseline.neon
|
||||
|
||||
+17
@@ -0,0 +1,17 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!--
|
||||
Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
|
||||
SPDX-License-Identifier: GPL-3.0-or-later
|
||||
-->
|
||||
<phpunit xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||
xsi:noNamespaceSchemaLocation="vendor/phpunit/phpunit/phpunit.xsd"
|
||||
bootstrap="vendor/autoload.php"
|
||||
colors="true"
|
||||
failOnRisky="true"
|
||||
failOnWarning="true">
|
||||
<testsuites>
|
||||
<testsuite name="Unit">
|
||||
<directory>tests/Unit</directory>
|
||||
</testsuite>
|
||||
</testsuites>
|
||||
</phpunit>
|
||||
@@ -0,0 +1,141 @@
|
||||
<?php
|
||||
|
||||
/* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace MokoStandards\Tests\Unit;
|
||||
|
||||
use MokoEnterprise\ConfigValidator;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
class ConfigValidatorTest extends TestCase
|
||||
{
|
||||
private ConfigValidator $validator;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
$this->validator = new ConfigValidator();
|
||||
}
|
||||
|
||||
public function testValidConfigPasses(): void
|
||||
{
|
||||
$schema = [
|
||||
'type' => 'object',
|
||||
'properties' => [
|
||||
'name' => ['type' => 'string'],
|
||||
'version' => ['type' => 'string'],
|
||||
],
|
||||
'required' => ['name'],
|
||||
];
|
||||
|
||||
$config = ['name' => 'MyProject', 'version' => '1.0'];
|
||||
|
||||
$this->assertTrue($this->validator->validate($config, $schema));
|
||||
$this->assertEmpty($this->validator->getErrors());
|
||||
}
|
||||
|
||||
public function testMissingRequiredField(): void
|
||||
{
|
||||
$schema = [
|
||||
'type' => 'object',
|
||||
'properties' => [
|
||||
'name' => ['type' => 'string'],
|
||||
],
|
||||
'required' => ['name'],
|
||||
];
|
||||
|
||||
$this->assertFalse($this->validator->validate([], $schema));
|
||||
$this->assertStringContainsString(
|
||||
'required',
|
||||
$this->validator->getErrors()[0]
|
||||
);
|
||||
}
|
||||
|
||||
public function testEnumValidation(): void
|
||||
{
|
||||
$schema = [
|
||||
'type' => 'object',
|
||||
'properties' => [
|
||||
'type' => [
|
||||
'type' => 'string',
|
||||
'enum' => ['component', 'module', 'plugin'],
|
||||
],
|
||||
],
|
||||
];
|
||||
|
||||
$valid = ['type' => 'component'];
|
||||
$this->assertTrue($this->validator->validate($valid, $schema));
|
||||
|
||||
$invalid = ['type' => 'banana'];
|
||||
$this->assertFalse($this->validator->validate($invalid, $schema));
|
||||
}
|
||||
|
||||
public function testNestedObjectValidation(): void
|
||||
{
|
||||
$schema = [
|
||||
'type' => 'object',
|
||||
'properties' => [
|
||||
'db' => [
|
||||
'type' => 'object',
|
||||
'properties' => [
|
||||
'host' => ['type' => 'string'],
|
||||
'port' => ['type' => 'integer'],
|
||||
],
|
||||
'required' => ['host'],
|
||||
],
|
||||
],
|
||||
];
|
||||
|
||||
$valid = ['db' => ['host' => 'localhost', 'port' => 3306]];
|
||||
$this->assertTrue($this->validator->validate($valid, $schema));
|
||||
|
||||
$invalid = ['db' => ['port' => 3306]];
|
||||
$this->assertFalse($this->validator->validate($invalid, $schema));
|
||||
}
|
||||
|
||||
public function testUnknownPropertiesWarn(): void
|
||||
{
|
||||
$schema = [
|
||||
'type' => 'object',
|
||||
'properties' => [
|
||||
'name' => ['type' => 'string'],
|
||||
],
|
||||
];
|
||||
|
||||
$config = ['name' => 'ok', 'extra' => 'unknown'];
|
||||
$this->assertTrue($this->validator->validate($config, $schema));
|
||||
$this->assertNotEmpty($this->validator->getWarnings());
|
||||
}
|
||||
|
||||
public function testTypeMismatch(): void
|
||||
{
|
||||
$schema = [
|
||||
'type' => 'object',
|
||||
'properties' => [
|
||||
'count' => ['type' => 'integer'],
|
||||
],
|
||||
];
|
||||
|
||||
$invalid = ['count' => 'not-a-number'];
|
||||
$this->assertFalse($this->validator->validate($invalid, $schema));
|
||||
}
|
||||
|
||||
public function testStringMinLength(): void
|
||||
{
|
||||
$schema = [
|
||||
'type' => 'object',
|
||||
'properties' => [
|
||||
'name' => ['type' => 'string', 'minLength' => 3],
|
||||
],
|
||||
];
|
||||
|
||||
$short = ['name' => 'ab'];
|
||||
$this->assertFalse($this->validator->validate($short, $schema));
|
||||
|
||||
$ok = ['name' => 'abc'];
|
||||
$this->assertTrue($this->validator->validate($ok, $schema));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,164 @@
|
||||
<?php
|
||||
|
||||
/* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace MokoStandards\Tests\Unit;
|
||||
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
/**
|
||||
* Tests for cli/version_bump.php
|
||||
*/
|
||||
class VersionBumpTest extends TestCase
|
||||
{
|
||||
private string $tmpDir;
|
||||
private string $script;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
$this->tmpDir = sys_get_temp_dir() . '/moko-test-' . uniqid();
|
||||
mkdir($this->tmpDir, 0755, true);
|
||||
$this->script = dirname(__DIR__, 2) . '/cli/version_bump.php';
|
||||
}
|
||||
|
||||
protected function tearDown(): void
|
||||
{
|
||||
$this->rmdir($this->tmpDir);
|
||||
}
|
||||
|
||||
public function testPatchBump(): void
|
||||
{
|
||||
$this->writeReadme('01.02.03');
|
||||
$output = $this->execute();
|
||||
$this->assertStringContainsString('01.02.04', $output);
|
||||
$this->assertReadmeVersion('01.02.04');
|
||||
}
|
||||
|
||||
public function testPatchBumpRollover(): void
|
||||
{
|
||||
$this->writeReadme('01.02.99');
|
||||
$this->execute();
|
||||
$this->assertReadmeVersion('01.03.00');
|
||||
}
|
||||
|
||||
public function testMinorBump(): void
|
||||
{
|
||||
$this->writeReadme('01.02.03');
|
||||
$this->execute(['--minor']);
|
||||
$this->assertReadmeVersion('01.03.00');
|
||||
}
|
||||
|
||||
public function testMajorBump(): void
|
||||
{
|
||||
$this->writeReadme('01.02.03');
|
||||
$this->execute(['--major']);
|
||||
$this->assertReadmeVersion('02.00.00');
|
||||
}
|
||||
|
||||
public function testBumpsFromHtmlComment(): void
|
||||
{
|
||||
file_put_contents(
|
||||
"{$this->tmpDir}/README.md",
|
||||
"<!-- VERSION: 03.05.01 -->\nSome content\n"
|
||||
);
|
||||
|
||||
$this->execute();
|
||||
$content = file_get_contents("{$this->tmpDir}/README.md");
|
||||
$this->assertStringContainsString('03.05.02', $content);
|
||||
$this->assertStringContainsString('Some content', $content);
|
||||
}
|
||||
|
||||
public function testBumpsWhenXmlHasSuffix(): void
|
||||
{
|
||||
$this->writeReadme('01.00.00');
|
||||
mkdir("{$this->tmpDir}/src", 0755, true);
|
||||
file_put_contents(
|
||||
"{$this->tmpDir}/src/test.xml",
|
||||
'<extension type="component">'
|
||||
. '<version>01.00.00-dev</version></extension>'
|
||||
);
|
||||
|
||||
$output = $this->execute();
|
||||
$this->assertStringContainsString('01.00.01', $output);
|
||||
}
|
||||
|
||||
public function testFailsWithNoVersion(): void
|
||||
{
|
||||
file_put_contents(
|
||||
"{$this->tmpDir}/README.md",
|
||||
"# No version\n"
|
||||
);
|
||||
|
||||
$code = 0;
|
||||
$this->execute([], $code);
|
||||
$this->assertSame(1, $code);
|
||||
}
|
||||
|
||||
private function writeReadme(string $version): void
|
||||
{
|
||||
file_put_contents(
|
||||
"{$this->tmpDir}/README.md",
|
||||
"<!-- VERSION: {$version} -->\n"
|
||||
);
|
||||
}
|
||||
|
||||
private function assertReadmeVersion(string $expected): void
|
||||
{
|
||||
$content = file_get_contents("{$this->tmpDir}/README.md");
|
||||
$this->assertMatchesRegularExpression(
|
||||
'/VERSION:\s*' . preg_quote($expected, '/') . '/',
|
||||
$content
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param string[] $extraArgs
|
||||
*/
|
||||
private function execute(
|
||||
array $extraArgs = [],
|
||||
int &$exitCode = 0
|
||||
): string {
|
||||
$cmd = ['php', $this->script, '--path', $this->tmpDir];
|
||||
$cmd = array_merge($cmd, $extraArgs);
|
||||
|
||||
$descriptors = [
|
||||
1 => ['pipe', 'w'],
|
||||
2 => ['pipe', 'w'],
|
||||
];
|
||||
|
||||
$proc = proc_open($cmd, $descriptors, $pipes);
|
||||
$stdout = stream_get_contents($pipes[1]);
|
||||
fclose($pipes[1]);
|
||||
fclose($pipes[2]);
|
||||
$exitCode = proc_close($proc);
|
||||
|
||||
return $stdout ?: '';
|
||||
}
|
||||
|
||||
private function rmdir(string $dir): void
|
||||
{
|
||||
if (!is_dir($dir)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$iter = new \RecursiveIteratorIterator(
|
||||
new \RecursiveDirectoryIterator(
|
||||
$dir,
|
||||
\FilesystemIterator::SKIP_DOTS
|
||||
),
|
||||
\RecursiveIteratorIterator::CHILD_FIRST
|
||||
);
|
||||
|
||||
foreach ($iter as $file) {
|
||||
$file->isDir()
|
||||
? rmdir($file->getPathname())
|
||||
: unlink($file->getPathname());
|
||||
}
|
||||
|
||||
rmdir($dir);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
<?php
|
||||
|
||||
/* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace MokoStandards\Tests\Unit;
|
||||
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
/**
|
||||
* Tests for cli/version_read.php
|
||||
*/
|
||||
class VersionReadTest extends TestCase
|
||||
{
|
||||
private string $tmpDir;
|
||||
private string $script;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
$this->tmpDir = sys_get_temp_dir() . '/moko-test-' . uniqid();
|
||||
mkdir($this->tmpDir, 0755, true);
|
||||
$this->script = dirname(__DIR__, 2) . '/cli/version_read.php';
|
||||
}
|
||||
|
||||
protected function tearDown(): void
|
||||
{
|
||||
$this->rmdir($this->tmpDir);
|
||||
}
|
||||
|
||||
public function testReadsVersionFromReadme(): void
|
||||
{
|
||||
file_put_contents(
|
||||
"{$this->tmpDir}/README.md",
|
||||
"# Test\n<!-- VERSION: 02.03.04 -->\n"
|
||||
);
|
||||
|
||||
$this->assertSame('02.03.04', trim($this->runScript()));
|
||||
}
|
||||
|
||||
public function testReadsVersionFromXmlManifest(): void
|
||||
{
|
||||
mkdir("{$this->tmpDir}/src", 0755, true);
|
||||
file_put_contents(
|
||||
"{$this->tmpDir}/src/test.xml",
|
||||
'<extension type="component">'
|
||||
. '<version>05.01.00</version></extension>'
|
||||
);
|
||||
|
||||
$this->assertSame('05.01.00', trim($this->runScript()));
|
||||
}
|
||||
|
||||
public function testStripsStabilitySuffixFromXml(): void
|
||||
{
|
||||
mkdir("{$this->tmpDir}/src", 0755, true);
|
||||
file_put_contents(
|
||||
"{$this->tmpDir}/src/test.xml",
|
||||
'<extension type="component">'
|
||||
. '<version>01.00.00-dev</version></extension>'
|
||||
);
|
||||
|
||||
$this->assertSame('01.00.00', trim($this->runScript()));
|
||||
}
|
||||
|
||||
public function testReturnsHigherOfReadmeAndManifest(): void
|
||||
{
|
||||
file_put_contents(
|
||||
"{$this->tmpDir}/README.md",
|
||||
"<!-- VERSION: 01.02.00 -->\n"
|
||||
);
|
||||
mkdir("{$this->tmpDir}/src", 0755, true);
|
||||
file_put_contents(
|
||||
"{$this->tmpDir}/src/test.xml",
|
||||
'<extension type="component">'
|
||||
. '<version>01.03.00</version></extension>'
|
||||
);
|
||||
|
||||
$this->assertSame('01.03.00', trim($this->runScript()));
|
||||
}
|
||||
|
||||
public function testExitsNonZeroWhenNoVersion(): void
|
||||
{
|
||||
file_put_contents(
|
||||
"{$this->tmpDir}/README.md",
|
||||
"# No version here\n"
|
||||
);
|
||||
|
||||
$code = 0;
|
||||
$this->runScript($code);
|
||||
$this->assertSame(1, $code);
|
||||
}
|
||||
|
||||
private function runScript(int &$exitCode = 0): string
|
||||
{
|
||||
$proc = proc_open(
|
||||
['php', $this->script, '--path', $this->tmpDir],
|
||||
[1 => ['pipe', 'w'], 2 => ['pipe', 'w']],
|
||||
$pipes
|
||||
);
|
||||
|
||||
$stdout = stream_get_contents($pipes[1]);
|
||||
fclose($pipes[1]);
|
||||
fclose($pipes[2]);
|
||||
$exitCode = proc_close($proc);
|
||||
|
||||
return $stdout ?: '';
|
||||
}
|
||||
|
||||
private function rmdir(string $dir): void
|
||||
{
|
||||
if (!is_dir($dir)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$iter = new \RecursiveIteratorIterator(
|
||||
new \RecursiveDirectoryIterator(
|
||||
$dir,
|
||||
\FilesystemIterator::SKIP_DOTS
|
||||
),
|
||||
\RecursiveIteratorIterator::CHILD_FIRST
|
||||
);
|
||||
|
||||
foreach ($iter as $file) {
|
||||
$file->isDir()
|
||||
? rmdir($file->getPathname())
|
||||
: unlink($file->getPathname());
|
||||
}
|
||||
|
||||
rmdir($dir);
|
||||
}
|
||||
}
|
||||
@@ -22,7 +22,7 @@ require_once __DIR__ . '/../../vendor/autoload.php';
|
||||
require_once __DIR__ . '/../lib/Enterprise/CliFramework.php';
|
||||
|
||||
use MokoEnterprise\{
|
||||
CLIApp,
|
||||
CliFramework,
|
||||
ProjectTypeDetector,
|
||||
PluginFactory,
|
||||
PluginRegistry,
|
||||
@@ -36,7 +36,7 @@ use MokoEnterprise\{
|
||||
* Detects whether a repository is a Joomla/WaaS component, Dolibarr/CRM module,
|
||||
* or generic repository, then validates against appropriate schema
|
||||
*/
|
||||
class AutoDetectPlatform extends CLIApp
|
||||
class AutoDetectPlatform extends CliFramework
|
||||
{
|
||||
private const DETECTION_THRESHOLD = 0.5; // 50% confidence required
|
||||
|
||||
@@ -62,20 +62,19 @@ class AutoDetectPlatform extends CLIApp
|
||||
private string $schemaFile = '';
|
||||
private ?object $detectedPlugin = null;
|
||||
|
||||
protected function setupArguments(): array
|
||||
protected function configure(): void
|
||||
{
|
||||
return [
|
||||
'repo-path:' => 'Path to repository to analyze (default: current directory)',
|
||||
'schema-dir:' => 'Path to schema definitions directory (default: definitions/default)',
|
||||
'output-dir:' => 'Directory for output reports (default: var/logs/validation)',
|
||||
];
|
||||
$this->setDescription('Automatically detect platform type and validate repository');
|
||||
$this->addArgument('--repo-path', 'Path to repository to analyze', '.');
|
||||
$this->addArgument('--schema-dir', 'Path to schema definitions directory', 'definitions/default');
|
||||
$this->addArgument('--output-dir', 'Directory for output reports', 'var/logs/validation');
|
||||
}
|
||||
|
||||
protected function run(): int
|
||||
{
|
||||
$repoPath = $this->getOption('repo-path', '.');
|
||||
$schemaDir = $this->getOption('schema-dir', 'definitions/default');
|
||||
$outputDir = $this->getOption('output-dir', 'var/logs/validation');
|
||||
$repoPath = $this->getArgument('--repo-path', '.');
|
||||
$schemaDir = $this->getArgument('--schema-dir', 'definitions/default');
|
||||
$outputDir = $this->getArgument('--output-dir', 'var/logs/validation');
|
||||
|
||||
// Make paths absolute
|
||||
$repoPath = $this->getAbsolutePath($repoPath);
|
||||
@@ -151,7 +150,7 @@ class AutoDetectPlatform extends CLIApp
|
||||
}
|
||||
|
||||
// Output results
|
||||
if ($this->jsonOutput) {
|
||||
if ($this->getArgument("--json", false)) {
|
||||
$this->outputJson();
|
||||
} else {
|
||||
$this->displayResults();
|
||||
@@ -953,5 +952,5 @@ class AutoDetectPlatform extends CLIApp
|
||||
}
|
||||
|
||||
// Run the application
|
||||
$app = new AutoDetectPlatform('auto_detect_platform', 'Automatically detect platform type and validate repository');
|
||||
$app = new AutoDetectPlatform();
|
||||
exit($app->execute());
|
||||
|
||||
@@ -25,7 +25,7 @@ final class CheckFileIntegrity
|
||||
private bool $verbose = false;
|
||||
private bool $jsonOutput = false;
|
||||
|
||||
/** @var array{host: string, port: int, user: string, identity: string} */
|
||||
/** @var array<string, mixed> */
|
||||
private array $sftpConfig = [];
|
||||
|
||||
public function run(): int
|
||||
|
||||
@@ -35,10 +35,8 @@ use MokoEnterprise\{AuditLogger, CliFramework, MetricsCollector, PluginFactory};
|
||||
|
||||
class RepoHealthChecker extends CliFramework
|
||||
{
|
||||
private const DEFAULT_THRESHOLD = 70.0;
|
||||
private AuditLogger $logger;
|
||||
private MetricsCollector $metrics;
|
||||
private PluginFactory $pluginFactory;
|
||||
private string $apiBaseUrl = 'https://git.mokoconsulting.tech/api/v1';
|
||||
|
||||
private array $results = [
|
||||
@@ -61,7 +59,6 @@ class RepoHealthChecker extends CliFramework
|
||||
parent::initialize();
|
||||
$this->logger = new AuditLogger('repo_health_checker');
|
||||
$this->metrics = new MetricsCollector();
|
||||
$this->pluginFactory = new PluginFactory($this->logger, $this->metrics);
|
||||
$config = \MokoEnterprise\Config::load();
|
||||
$this->apiBaseUrl = rtrim($config->getString('gitea.url', 'https://git.mokoconsulting.tech'), '/') . '/api/v1';
|
||||
}
|
||||
|
||||
@@ -17,29 +17,24 @@ declare(strict_types=1);
|
||||
|
||||
require_once __DIR__ . '/../vendor/autoload.php';
|
||||
|
||||
use MokoEnterprise\CLIApp;
|
||||
use MokoEnterprise\CliFramework;
|
||||
|
||||
class CheckWikiHealth extends CLIApp
|
||||
class CheckWikiHealth extends CliFramework
|
||||
{
|
||||
public function __construct()
|
||||
protected function configure(): void
|
||||
{
|
||||
parent::__construct('check-wiki-health', 'Validate wiki health for a repository', '01.00.00');
|
||||
}
|
||||
|
||||
protected function setupArguments(): array
|
||||
{
|
||||
return [
|
||||
'path:' => 'Repository path (default: current directory)',
|
||||
'gitea-url:' => 'Gitea base URL (default: https://git.mokoconsulting.tech)',
|
||||
'token:' => 'Gitea API token (or set GITEA_TOKEN env var)',
|
||||
];
|
||||
$this->setDescription('Validate wiki health for a repository');
|
||||
$this->addArgument('--path', 'Repository path (default: current directory)', '.');
|
||||
$this->addArgument('--gitea-url', 'Gitea base URL', 'https://git.mokoconsulting.tech');
|
||||
$this->addArgument('--token', 'Gitea API token (or set GITEA_TOKEN env var)', '');
|
||||
$this->addArgument('--json', 'Output as JSON', false);
|
||||
}
|
||||
|
||||
protected function run(): int
|
||||
{
|
||||
$repoPath = realpath($this->getOption('path', '.')) ?: '.';
|
||||
$giteaUrl = $this->getOption('gitea-url', 'https://git.mokoconsulting.tech');
|
||||
$token = $this->getOption('token', getenv('GITEA_TOKEN') ?: '');
|
||||
$repoPath = realpath($this->getArgument('--path', '.')) ?: '.';
|
||||
$giteaUrl = $this->getArgument('--gitea-url', 'https://git.mokoconsulting.tech');
|
||||
$token = $this->getArgument('--token', getenv('GITEA_TOKEN') ?: '');
|
||||
|
||||
// Detect repo owner/name from git config
|
||||
$configFile = $repoPath . '/.git/config';
|
||||
@@ -76,7 +71,7 @@ class CheckWikiHealth extends CLIApp
|
||||
if ($pages === null) {
|
||||
$this->log(' No wiki found or API error', 'WARNING');
|
||||
$issues++;
|
||||
if ($this->jsonOutput) {
|
||||
if ($this->getArgument("--json", false)) {
|
||||
echo json_encode(['status' => 'no_wiki', 'issues' => $issues]);
|
||||
}
|
||||
return 0;
|
||||
@@ -118,7 +113,7 @@ class CheckWikiHealth extends CLIApp
|
||||
}
|
||||
}
|
||||
|
||||
if ($this->jsonOutput) {
|
||||
if ($this->getArgument("--json", false)) {
|
||||
echo json_encode([
|
||||
'repo' => "{$owner}/{$repo}",
|
||||
'pages' => $pageCount,
|
||||
@@ -153,4 +148,5 @@ class CheckWikiHealth extends CLIApp
|
||||
}
|
||||
}
|
||||
|
||||
(new CheckWikiHealth())->execute();
|
||||
$app = new CheckWikiHealth();
|
||||
exit($app->execute());
|
||||
|
||||
@@ -38,7 +38,6 @@ class DriftScanner extends CliFramework
|
||||
private const DEFAULT_ORG = 'mokoconsulting-tech';
|
||||
|
||||
private ApiClient $apiClient;
|
||||
private AuditLogger $logger;
|
||||
private MetricsCollector $metrics;
|
||||
private \MokoEnterprise\GitPlatformAdapter $adapter;
|
||||
|
||||
@@ -60,7 +59,6 @@ class DriftScanner extends CliFramework
|
||||
{
|
||||
parent::initialize();
|
||||
|
||||
$this->logger = new AuditLogger('drift_scanner');
|
||||
$this->metrics = new MetricsCollector();
|
||||
|
||||
// Initialize API client via platform adapter
|
||||
|
||||
Reference in New Issue
Block a user