Compare commits
60 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 17a50851fb | |||
| ef3614d249 | |||
| 3d5d581883 | |||
| 6bd26698c4 | |||
| 19b504526b | |||
| e7bdf7cbc7 | |||
| ff5794d0cc | |||
| bd5f676e0a | |||
| bfba45e8b5 | |||
| 78ea05233b | |||
| ae0d54310d | |||
| 9df59836bf | |||
| 6e40707223 | |||
| ca55e5d2d2 | |||
| 9526d006c4 | |||
| c90a5671bd | |||
| 048a7d71d1 | |||
| 847263dd86 | |||
| 6e540f64c4 | |||
| c57b5724ac | |||
| 78affd37ff | |||
| b3062c6559 | |||
| cf02738930 | |||
| 455d4c8a19 | |||
| 8286d493b9 | |||
| b740152d67 | |||
| 9dab9f1ef6 | |||
| c61d32709c | |||
| a59091e348 | |||
| ee3d58c20f | |||
| 2b137f9041 | |||
| f3f356ae54 | |||
| 85d863be08 | |||
| a83eda5798 | |||
| 54a27c0a8f | |||
| 5754fae5a8 | |||
| ab3c0a3a8d | |||
| eb3689cff6 | |||
| 631b44e1a3 | |||
| 7338a3da2e | |||
| 0a0e1f11e0 | |||
| c3a3ab3f62 | |||
| 79631d77bb | |||
| 556ac85a63 | |||
| c1a145480c | |||
| 4d06e3828e | |||
| ab7b6cfba1 | |||
| e135a0ff8b | |||
| 2d6155d655 | |||
| 65215cdc4c | |||
| 86db53d2ac | |||
| 8a4e1ab60f | |||
| 8c87cf1e74 | |||
| 505013c6f1 | |||
| 2f6845c5c0 | |||
| 45233fb9d2 | |||
| ecf6615383 | |||
| 59d3524615 | |||
| 8058baef95 | |||
| df2efa4838 |
@@ -0,0 +1,76 @@
|
||||
# moko-platform
|
||||
|
||||
Enterprise automation, validation, sync, and governance engine for all Moko Consulting repositories.
|
||||
|
||||
## Quick Reference
|
||||
|
||||
| Field | Value |
|
||||
|---|---|
|
||||
| **Language** | PHP 8.1+ |
|
||||
| **Version** | 09.01.00 |
|
||||
| **Branch** | develop on `dev`, merge to `main` (protected) |
|
||||
| **Wiki** | [moko-platform Wiki](https://git.mokoconsulting.tech/MokoConsulting/moko-platform/wiki) |
|
||||
|
||||
## Commands
|
||||
|
||||
```bash
|
||||
composer install # Install PHP dependencies
|
||||
php bin/moko health --path . # Repo health check
|
||||
php bin/moko check:syntax --path . # PHP syntax check
|
||||
php bin/moko drift --org MokoConsulting # Scan for standards drift
|
||||
php bin/moko dashboard --token $TOKEN -o dashboard.html # Client dashboard
|
||||
|
||||
# Code quality
|
||||
php vendor/bin/phpcs --standard=phpcs.xml -n lib/ validate/ automation/ cli/
|
||||
php vendor/bin/phpcbf --standard=phpcs.xml lib/ validate/ automation/ cli/
|
||||
php vendor/bin/phpstan analyse -c phpstan.neon --memory-limit=512M
|
||||
composer check # Run all checks
|
||||
```
|
||||
|
||||
## Architecture
|
||||
|
||||
| Directory | Purpose |
|
||||
|---|---|
|
||||
| `cli/` | 32 standalone CLI tools (version, release, build, repo management) |
|
||||
| `validate/` | 20 validation scripts (syntax, structure, manifests, drift) |
|
||||
| `automation/` | 7 bulk operations (sync, push files, templates, cleanup) |
|
||||
| `lib/Enterprise/` | Core library — CliFramework, ApiClient, adapters, validators |
|
||||
| `lib/Enterprise/Plugins/` | 11 platform plugins (Joomla, Dolibarr, Node.js, Python, etc.) |
|
||||
| `deploy/` | SFTP deployment scripts (Joomla, Dolibarr, health checks) |
|
||||
| `templates/` | Universal templates, configs, governance schema |
|
||||
| `.mokogitea/workflows/` | CI/CD workflows (Gitea Actions) |
|
||||
| `bin/moko` | Unified CLI dispatcher — `php bin/moko <command>` |
|
||||
| `monitoring/sites.json` | Sites list for mcp_mokomonitor |
|
||||
|
||||
### CLI Framework
|
||||
|
||||
All CLI tools extend `MokoEnterprise\CliFramework` (`lib/Enterprise/CliFramework.php`).
|
||||
Built-in flags: `--help`, `--verbose`, `--quiet`, `--dry-run`.
|
||||
After adding a CLI tool, register it in `bin/moko` COMMAND_MAP.
|
||||
|
||||
### Platform Adapters
|
||||
|
||||
- `MokoGiteaAdapter` — git.mokoconsulting.tech (primary)
|
||||
- `GitHubAdapter` — github.com mirrors
|
||||
|
||||
### Plugin System
|
||||
|
||||
Platform-specific logic in `lib/Enterprise/Plugins/`. Each implements `ProjectPluginInterface` with health checks, validation, build commands, config schemas.
|
||||
|
||||
## Code Quality
|
||||
|
||||
| Tool | Level | Config |
|
||||
|---|---|---|
|
||||
| PHPCS | PSR-12 (errors only) | `phpcs.xml` |
|
||||
| PHPStan | Level 2 (advisory) | `phpstan.neon` |
|
||||
|
||||
PHPStan runs with `--memory-limit=512M`. CI enforces PHPCS errors; PHPStan is `continue-on-error`.
|
||||
|
||||
## Rules
|
||||
|
||||
- **Never commit** `.claude/`, `.mcp.json`, `TODO.md`, `*.min.css`/`*.min.js`
|
||||
- **Attribution**: `Authored-by: Moko Consulting`
|
||||
- **Workflow directory**: `.mokogitea/` (not `.gitea/` or `.github/`)
|
||||
- **Wiki**: documentation lives in the Gitea wiki, not `docs/` files
|
||||
- **New CLI tools**: extend `CliFramework`, not `CLIApp` (legacy)
|
||||
- **Standards**: [MokoStandards](https://git.mokoconsulting.tech/MokoConsulting/moko-platform/wiki/Home)
|
||||
@@ -1,66 +1,68 @@
|
||||
# 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: /.mokogitea/workflows/auto-bump.yml
|
||||
# VERSION: 09.23.00
|
||||
# BRIEF: Auto patch-bump version on every push to dev (skips merge commits)
|
||||
|
||||
name: "Universal: Auto Version Bump"
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- dev
|
||||
- rc
|
||||
- 'feature/**'
|
||||
- 'patch/**'
|
||||
|
||||
env:
|
||||
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
|
||||
GITEA_URL: ${{ vars.GITEA_URL || 'https://git.mokoconsulting.tech' }}
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
jobs:
|
||||
bump:
|
||||
name: Version Bump
|
||||
runs-on: release
|
||||
if: >-
|
||||
!contains(github.event.head_commit.message, '[skip ci]') &&
|
||||
!contains(github.event.head_commit.message, '[skip bump]') &&
|
||||
!startsWith(github.event.head_commit.message, 'Merge pull request')
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
||||
with:
|
||||
token: ${{ secrets.MOKOGITEA_TOKEN }}
|
||||
fetch-depth: 1
|
||||
|
||||
- name: Setup moko-platform tools
|
||||
run: |
|
||||
if ! command -v composer &> /dev/null; then
|
||||
sudo apt-get update -qq && sudo apt-get install -y -qq php-cli php-mbstring php-xml php-zip php-curl composer >/dev/null 2>&1
|
||||
fi
|
||||
if [ -d "/opt/moko-platform/cli" ]; then
|
||||
echo "MOKO_CLI=/opt/moko-platform/cli" >> "$GITHUB_ENV"
|
||||
else
|
||||
git clone --depth 1 --branch main --quiet \
|
||||
"https://x-access-token:${{ secrets.MOKOGITEA_TOKEN }}@git.mokoconsulting.tech/MokoConsulting/moko-platform.git" \
|
||||
/tmp/moko-platform-api
|
||||
cd /tmp/moko-platform-api && composer install --no-dev --no-interaction --quiet
|
||||
echo "MOKO_CLI=/tmp/moko-platform-api/cli" >> "$GITHUB_ENV"
|
||||
fi
|
||||
|
||||
- name: Bump version
|
||||
run: |
|
||||
php ${MOKO_CLI}/version_auto_bump.php \
|
||||
--path . --branch "${GITHUB_REF_NAME}" \
|
||||
--token "${{ secrets.MOKOGITEA_TOKEN }}" \
|
||||
--repo-url "https://x-access-token:${{ secrets.MOKOGITEA_TOKEN }}@git.mokoconsulting.tech/${{ github.repository }}.git"
|
||||
# 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: /.mokogitea/workflows/auto-bump.yml
|
||||
# VERSION: 09.23.00
|
||||
# BRIEF: Auto patch-bump version on every push to dev (skips merge commits)
|
||||
|
||||
name: "Universal: Auto Version Bump"
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- dev
|
||||
- rc
|
||||
- 'feature/**'
|
||||
- 'patch/**'
|
||||
|
||||
env:
|
||||
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
|
||||
GITEA_URL: ${{ vars.GITEA_URL || 'https://git.mokoconsulting.tech' }}
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
jobs:
|
||||
bump:
|
||||
name: Version Bump
|
||||
runs-on: release
|
||||
if: >-
|
||||
!contains(github.event.head_commit.message, '[skip ci]') &&
|
||||
!contains(github.event.head_commit.message, '[skip bump]') &&
|
||||
!startsWith(github.event.head_commit.message, 'Merge pull request')
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
||||
with:
|
||||
token: ${{ secrets.MOKOGITEA_TOKEN }}
|
||||
fetch-depth: 1
|
||||
|
||||
- name: Setup moko-platform tools
|
||||
run: |
|
||||
if [ -f "/opt/moko-platform/cli/version_bump.php" ] && [ -f "/opt/moko-platform/vendor/autoload.php" ]; then
|
||||
echo "Using pre-installed /opt/moko-platform"
|
||||
echo "MOKO_CLI=/opt/moko-platform/cli" >> "$GITHUB_ENV"
|
||||
else
|
||||
if ! command -v composer &> /dev/null; then
|
||||
sudo apt-get update -qq && sudo apt-get install -y -qq php-cli php-mbstring php-xml php-zip php-curl composer >/dev/null 2>&1
|
||||
fi
|
||||
rm -rf /tmp/moko-platform-api
|
||||
git clone --depth 1 --branch main --quiet \
|
||||
"https://x-access-token:${{ secrets.MOKOGITEA_TOKEN }}@git.mokoconsulting.tech/MokoConsulting/moko-platform.git" \
|
||||
/tmp/moko-platform-api
|
||||
cd /tmp/moko-platform-api && composer install --no-dev --no-interaction --quiet
|
||||
echo "MOKO_CLI=/tmp/moko-platform-api/cli" >> "$GITHUB_ENV"
|
||||
fi
|
||||
|
||||
- name: Bump version
|
||||
run: |
|
||||
php ${MOKO_CLI}/version_auto_bump.php \
|
||||
--path . --branch "${GITHUB_REF_NAME}" \
|
||||
--token "${{ secrets.MOKOGITEA_TOKEN }}" \
|
||||
--repo-url "https://x-access-token:${{ secrets.MOKOGITEA_TOKEN }}@git.mokoconsulting.tech/${{ github.repository }}.git"
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
# INGROUP: moko-platform.Release
|
||||
# REPO: https://git.mokoconsulting.tech/mokoconsulting-tech/moko-platform
|
||||
# PATH: /templates/workflows/universal/auto-release.yml.template
|
||||
# VERSION: 09.23.00
|
||||
# VERSION: 05.00.00
|
||||
# BRIEF: Universal build & release � detects platform from manifest.xml
|
||||
#
|
||||
# +========================================================================+
|
||||
@@ -17,7 +17,7 @@
|
||||
# | Reads manifest.xml (joomla|dolibarr|generic) to branch logic. |
|
||||
# | |
|
||||
# | Platform-specific: |
|
||||
# | joomla: XML manifest, updates.xml, type-prefixed packages |
|
||||
# | joomla: XML manifest, type-prefixed packages |
|
||||
# | dolibarr: mod*.class.php, update.txt, dev version reset |
|
||||
# | generic: README-only, no update stream |
|
||||
# | |
|
||||
@@ -71,20 +71,25 @@ jobs:
|
||||
MOKO_CLONE_TOKEN: ${{ secrets.MOKOGITEA_TOKEN }}
|
||||
MOKO_CLONE_HOST: git.mokoconsulting.tech/MokoConsulting
|
||||
run: |
|
||||
if ! command -v composer &> /dev/null; then
|
||||
sudo apt-get update -qq && sudo apt-get install -y -qq php-cli php-mbstring php-xml php-zip php-curl composer >/dev/null 2>&1
|
||||
if [ -f /opt/moko-platform/cli/version_bump.php ] && [ -f /opt/moko-platform/vendor/autoload.php ]; then
|
||||
echo Using pre-installed /opt/moko-platform
|
||||
echo MOKO_CLI=/opt/moko-platform/cli >> $GITHUB_ENV
|
||||
else
|
||||
echo Falling back to fresh clone
|
||||
if ! command -v composer > /dev/null 2>&1; then
|
||||
sudo apt-get update -qq && sudo apt-get install -y -qq php-cli php-mbstring php-xml php-zip php-curl composer > /dev/null 2>&1
|
||||
fi
|
||||
rm -rf /tmp/moko-platform-api
|
||||
CLONE_URL=https://x-access-token:${MOKO_CLONE_TOKEN}@${MOKO_CLONE_HOST}/moko-platform.git
|
||||
git clone --depth 1 --branch main --quiet $CLONE_URL /tmp/moko-platform-api
|
||||
cd /tmp/moko-platform-api
|
||||
composer install --no-dev --no-interaction --quiet
|
||||
echo MOKO_CLI=/tmp/moko-platform-api/cli >> $GITHUB_ENV
|
||||
fi
|
||||
# Always fetch latest CLI tools — never use stale cache from previous runs
|
||||
rm -rf /tmp/moko-platform-api
|
||||
git clone --depth 1 --branch main --quiet \
|
||||
"https://x-access-token:${MOKO_CLONE_TOKEN}@${MOKO_CLONE_HOST}/moko-platform.git" \
|
||||
/tmp/moko-platform-api
|
||||
cd /tmp/moko-platform-api
|
||||
composer install --no-dev --no-interaction --quiet
|
||||
|
||||
- name: Rename branch to rc
|
||||
run: |
|
||||
php /tmp/moko-platform-api/cli/branch_rename.php \
|
||||
php ${MOKO_CLI}/branch_rename.php \
|
||||
--from "${{ github.event.pull_request.head.ref || 'dev' }}" --to rc \
|
||||
--token "${{ secrets.MOKOGITEA_TOKEN }}" \
|
||||
--api-base "${GITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}" \
|
||||
@@ -100,7 +105,7 @@ jobs:
|
||||
|
||||
- name: Publish RC release
|
||||
run: |
|
||||
php /tmp/moko-platform-api/cli/release_publish.php \
|
||||
php ${MOKO_CLI}/release_publish.php \
|
||||
--path . --stability rc --bump minor --branch rc \
|
||||
--token "${{ secrets.MOKOGITEA_TOKEN }}"
|
||||
|
||||
@@ -108,7 +113,7 @@ jobs:
|
||||
if: always()
|
||||
run: |
|
||||
echo "## Promoted to Release Candidate" >> $GITHUB_STEP_SUMMARY
|
||||
echo "Branch renamed to rc, minor bump, RC + lesser stream releases built, updates.xml synced" >> $GITHUB_STEP_SUMMARY
|
||||
echo "Branch renamed to rc, minor bump, RC release built" >> $GITHUB_STEP_SUMMARY
|
||||
|
||||
# ── Merged PR → Build & Release (or promote RC to stable) ────────────────────
|
||||
release:
|
||||
@@ -131,31 +136,80 @@ jobs:
|
||||
git config --local user.name "gitea-actions[bot]"
|
||||
git remote set-url origin "https://x-access-token:${{ secrets.MOKOGITEA_TOKEN }}@git.mokoconsulting.tech/${{ github.repository }}.git"
|
||||
|
||||
- name: Check for merge conflict markers
|
||||
run: |
|
||||
CONFLICTS=$(grep -rn '<<<<<<< \|>>>>>>> \|^=======$' --include='*.php' --include='*.xml' --include='*.css' --include='*.js' --include='*.json' --include='*.md' --include='*.yml' --include='*.yaml' --include='*.ini' --include='*.txt' . 2>/dev/null | grep -v '.git/' || true)
|
||||
if [ -n "$CONFLICTS" ]; then
|
||||
echo "::error::Merge conflict markers found — aborting release"
|
||||
echo "## Release Blocked: Conflict Markers" >> $GITHUB_STEP_SUMMARY
|
||||
echo '```' >> $GITHUB_STEP_SUMMARY
|
||||
echo "$CONFLICTS" >> $GITHUB_STEP_SUMMARY
|
||||
echo '```' >> $GITHUB_STEP_SUMMARY
|
||||
exit 1
|
||||
fi
|
||||
echo "No conflict markers found"
|
||||
|
||||
- name: Setup moko-platform tools
|
||||
env:
|
||||
MOKO_CLONE_TOKEN: ${{ secrets.MOKOGITEA_TOKEN }}
|
||||
MOKO_CLONE_HOST: git.mokoconsulting.tech/MokoConsulting
|
||||
COMPOSER_AUTH: '{"github-oauth":{"github.com":"${{ secrets.GH_MIRROR_TOKEN }}"}}'
|
||||
run: |
|
||||
# Ensure PHP + Composer are available
|
||||
if ! command -v composer &> /dev/null; then
|
||||
sudo apt-get update -qq && sudo apt-get install -y -qq php-cli php-mbstring php-xml php-zip php-curl composer >/dev/null 2>&1
|
||||
if [ -f /opt/moko-platform/cli/version_bump.php ] && [ -f /opt/moko-platform/vendor/autoload.php ]; then
|
||||
echo Using pre-installed /opt/moko-platform
|
||||
echo MOKO_CLI=/opt/moko-platform/cli >> $GITHUB_ENV
|
||||
else
|
||||
echo Falling back to fresh clone
|
||||
if ! command -v composer > /dev/null 2>&1; then
|
||||
sudo apt-get update -qq && sudo apt-get install -y -qq php-cli php-mbstring php-xml php-zip php-curl composer > /dev/null 2>&1
|
||||
fi
|
||||
rm -rf /tmp/moko-platform-api
|
||||
CLONE_URL=https://x-access-token:${MOKO_CLONE_TOKEN}@${MOKO_CLONE_HOST}/moko-platform.git
|
||||
git clone --depth 1 --branch main --quiet $CLONE_URL /tmp/moko-platform-api
|
||||
cd /tmp/moko-platform-api
|
||||
composer install --no-dev --no-interaction --quiet
|
||||
echo MOKO_CLI=/tmp/moko-platform-api/cli >> $GITHUB_ENV
|
||||
fi
|
||||
# Always fetch latest CLI tools — never use stale cache from previous runs
|
||||
rm -rf /tmp/moko-platform-api
|
||||
git clone --depth 1 --branch main --quiet \
|
||||
"https://x-access-token:${MOKO_CLONE_TOKEN}@${MOKO_CLONE_HOST}/moko-platform.git" \
|
||||
/tmp/moko-platform-api
|
||||
cd /tmp/moko-platform-api
|
||||
composer install --no-dev --no-interaction --quiet
|
||||
|
||||
|
||||
- name: "Publish stable release"
|
||||
run: |
|
||||
php /tmp/moko-platform-api/cli/release_publish.php \
|
||||
php ${MOKO_CLI}/release_publish.php \
|
||||
--path . --stability stable --bump minor --branch main \
|
||||
--token "${{ secrets.MOKOGITEA_TOKEN }}"
|
||||
|
||||
- name: Update release notes from CHANGELOG.md
|
||||
run: |
|
||||
API_BASE="${GITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}"
|
||||
|
||||
# Extract [Unreleased] section from changelog
|
||||
if [ -f "CHANGELOG.md" ]; then
|
||||
NOTES=$(awk '/^## \[Unreleased\]/{found=1; next} /^## \[/{if(found) exit} found{print}' CHANGELOG.md)
|
||||
[ -z "$NOTES" ] && NOTES="Stable release"
|
||||
else
|
||||
NOTES="Stable release"
|
||||
fi
|
||||
|
||||
# Update release body via API
|
||||
RELEASE_ID=$(curl -sf -H "Authorization: token ${{ secrets.MOKOGITEA_TOKEN }}" \
|
||||
"${API_BASE}/releases/tags/stable" | python3 -c "import json,sys; print(json.load(sys.stdin).get('id',''))" 2>/dev/null || true)
|
||||
|
||||
if [ -n "$RELEASE_ID" ]; then
|
||||
python3 -c "
|
||||
import json, urllib.request
|
||||
body = open('/dev/stdin').read()
|
||||
payload = json.dumps({'body': body}).encode()
|
||||
req = urllib.request.Request(
|
||||
'${API_BASE}/releases/${RELEASE_ID}',
|
||||
data=payload, method='PATCH',
|
||||
headers={
|
||||
'Authorization': 'token ${{ secrets.MOKOGITEA_TOKEN }}',
|
||||
'Content-Type': 'application/json'
|
||||
})
|
||||
urllib.request.urlopen(req)
|
||||
" <<< "$NOTES"
|
||||
echo "Release notes updated from CHANGELOG.md"
|
||||
fi
|
||||
|
||||
# -- STEP 9: Mirror to GitHub (stable only) --------------------------------
|
||||
- name: "Step 9: Mirror release to GitHub"
|
||||
if: >-
|
||||
@@ -167,7 +221,7 @@ jobs:
|
||||
RELEASE_TAG="${{ steps.version.outputs.release_tag }}"
|
||||
GH_REPO="${{ vars.GH_MIRROR_REPO || github.repository }}"
|
||||
API_BASE="${GITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}"
|
||||
php /tmp/moko-platform-api/cli/release_mirror.php \
|
||||
php ${MOKO_CLI}/release_mirror.php \
|
||||
--version "$VERSION" --tag "$RELEASE_TAG" \
|
||||
--token "${{ secrets.MOKOGITEA_TOKEN }}" --api-base "$API_BASE" \
|
||||
--gh-token "${{ secrets.GH_MIRROR_TOKEN }}" --gh-repo "$GH_REPO" \
|
||||
@@ -241,7 +295,7 @@ jobs:
|
||||
continue-on-error: true
|
||||
run: |
|
||||
API_BASE="${GITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}"
|
||||
php /tmp/moko-platform-api/cli/version_reset_dev.php \
|
||||
php ${MOKO_CLI}/version_reset_dev.php \
|
||||
--token "${{ secrets.MOKOGITEA_TOKEN }}" --api-base "${API_BASE}" \
|
||||
--branch dev --path . 2>&1 || true
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
# DEFGROUP: Gitea.Workflow
|
||||
# INGROUP: moko-platform.CI
|
||||
# REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform
|
||||
# PATH: /.gitea/workflows/ci-platform.yml
|
||||
# PATH: /.mokogitea/workflows/ci-platform.yml
|
||||
# VERSION: 09.23.00
|
||||
# BRIEF: moko-platform CI — the standards engine validates itself
|
||||
#
|
||||
@@ -41,7 +41,7 @@ on:
|
||||
paths-ignore:
|
||||
- '**.md'
|
||||
- 'wiki/**'
|
||||
- '.gitea/ISSUE_TEMPLATE/**'
|
||||
- '.mokogitea/ISSUE_TEMPLATE/**'
|
||||
pull_request:
|
||||
branches:
|
||||
- main
|
||||
@@ -104,7 +104,7 @@ jobs:
|
||||
echo "::error file=${file}::PHP syntax error"
|
||||
ERRORS=$((ERRORS + 1))
|
||||
fi
|
||||
done < <(find lib/ validate/ automation/ cli/ src/ deploy/ -name "*.php" -print0 2>/dev/null)
|
||||
done < <(find lib/ validate/ automation/ cli/ source/ src/ deploy/ -name "*.php" -print0 2>/dev/null)
|
||||
|
||||
{
|
||||
echo "### PHP Syntax"
|
||||
@@ -270,7 +270,7 @@ jobs:
|
||||
echo "::warning file=${file}::Missing SPDX header"
|
||||
MISSING=$((MISSING + 1))
|
||||
fi
|
||||
done < <(find lib/ validate/ cli/ src/ automation/ deploy/ -name "*.php" -print0 2>/dev/null)
|
||||
done < <(find lib/ validate/ cli/ source/ src/ automation/ deploy/ -name "*.php" -print0 2>/dev/null)
|
||||
|
||||
{
|
||||
echo "### License Headers"
|
||||
@@ -289,7 +289,7 @@ jobs:
|
||||
echo "::error file=${file}::Potential hardcoded secret detected"
|
||||
FOUND=$((FOUND + 1))
|
||||
fi
|
||||
done < <(find lib/ validate/ cli/ src/ automation/ deploy/ -name "*.php" -print0 2>/dev/null)
|
||||
done < <(find lib/ validate/ cli/ source/ src/ automation/ deploy/ -name "*.php" -print0 2>/dev/null)
|
||||
|
||||
{
|
||||
echo "### Secret Detection"
|
||||
@@ -412,6 +412,12 @@ jobs:
|
||||
if: always()
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
sparse-checkout: automation/ci-issue-reporter.sh
|
||||
sparse-checkout-cone-mode: false
|
||||
|
||||
- name: Check gate results
|
||||
run: |
|
||||
{
|
||||
@@ -437,3 +443,46 @@ jobs:
|
||||
echo "::error::One or more CI gates failed"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: "File issues for failed gates"
|
||||
if: >-
|
||||
always() &&
|
||||
(needs.code-quality.result == 'failure' ||
|
||||
needs.tests.result == 'failure' ||
|
||||
needs.self-health.result == 'failure' ||
|
||||
needs.governance.result == 'failure' ||
|
||||
needs.templates.result == 'failure')
|
||||
env:
|
||||
GITEA_TOKEN: ${{ secrets.MOKOGITEA_TOKEN }}
|
||||
GITEA_URL: ${{ vars.GITEA_URL || 'https://git.mokoconsulting.tech' }}
|
||||
run: |
|
||||
chmod +x automation/ci-issue-reporter.sh
|
||||
REPORTER="./automation/ci-issue-reporter.sh"
|
||||
WF="Platform CI"
|
||||
|
||||
report_gate() {
|
||||
local gate="$1" result="$2" details="$3"
|
||||
if [ "$result" = "failure" ]; then
|
||||
"$REPORTER" --gate "$gate" --details "$details" --workflow "$WF" --severity error
|
||||
fi
|
||||
}
|
||||
|
||||
report_gate "Code Quality" \
|
||||
"${{ needs.code-quality.result }}" \
|
||||
"PHPCS (PSR-12), PHPStan, or PHP syntax checks failed. Run \`composer check\` locally to reproduce."
|
||||
|
||||
report_gate "Unit Tests" \
|
||||
"${{ needs.tests.result }}" \
|
||||
"PHPUnit tests failed on one or more PHP versions (8.1, 8.2, 8.3). Run \`vendor/bin/phpunit --testdox\` locally."
|
||||
|
||||
report_gate "Self-Health" \
|
||||
"${{ needs.self-health.result }}" \
|
||||
"Self-health score fell below the 80% threshold. Run \`php bin/moko health -- --path .\` locally."
|
||||
|
||||
report_gate "Governance" \
|
||||
"${{ needs.governance.result }}" \
|
||||
"Governance checks failed (license headers, secrets, or version consistency). Check the CI run summary for specifics."
|
||||
|
||||
report_gate "Template Integrity" \
|
||||
"${{ needs.templates.result }}" \
|
||||
"Workflow or gitignore templates failed YAML validation or are missing required entries."
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
# DEFGROUP: Gitea.Workflow
|
||||
# INGROUP: moko-platform.Maintenance
|
||||
# REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform
|
||||
# PATH: /.gitea/workflows/cleanup.yml
|
||||
# PATH: /.mokogitea/workflows/cleanup.yml
|
||||
# VERSION: 09.23.00
|
||||
# BRIEF: Scheduled cleanup — delete merged branches and old workflow runs
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
# FILE INFORMATION
|
||||
# DEFGROUP: Gitea.Workflow
|
||||
# INGROUP: moko-platform.Automation
|
||||
# VERSION: 09.23.00
|
||||
# VERSION: 09.25.00
|
||||
# BRIEF: Auto-create feature branch when an issue is opened
|
||||
|
||||
name: "Universal: Issue Branch"
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
# DEFGROUP: Gitea.Workflow
|
||||
# INGROUP: moko-platform.Notifications
|
||||
# REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform
|
||||
# PATH: /.gitea/workflows/notify.yml
|
||||
# PATH: /.mokogitea/workflows/notify.yml
|
||||
# VERSION: 09.23.00
|
||||
# BRIEF: Push notifications via ntfy on release success or workflow failure
|
||||
|
||||
|
||||
+510
-236
@@ -1,236 +1,510 @@
|
||||
# Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
|
||||
#
|
||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||
#
|
||||
# FILE INFORMATION
|
||||
# DEFGROUP: Gitea.Workflow
|
||||
# INGROUP: moko-platform.CI
|
||||
# REPO: https://git.mokoconsulting.tech/mokoconsulting-tech/moko-platform
|
||||
# PATH: /templates/workflows/universal/pr-check.yml.template
|
||||
# VERSION: 09.23.00
|
||||
# BRIEF: PR gate — branch policy + code validation before merge
|
||||
|
||||
name: "Universal: PR Check"
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
types: [opened, synchronize, reopened, edited]
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: write
|
||||
|
||||
env:
|
||||
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
|
||||
|
||||
jobs:
|
||||
# ── Branch Policy ──────────────────────────────────────────────────────
|
||||
branch-policy:
|
||||
name: Branch Policy
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Check branch merge target
|
||||
run: |
|
||||
HEAD="${{ github.head_ref }}"
|
||||
BASE="${{ github.base_ref }}"
|
||||
|
||||
echo "PR: ${HEAD} → ${BASE}"
|
||||
|
||||
ALLOWED=true
|
||||
REASON=""
|
||||
|
||||
case "$HEAD" in
|
||||
feature/*|feat/*)
|
||||
if [ "$BASE" != "dev" ]; then
|
||||
ALLOWED=false
|
||||
REASON="Feature branches must target 'dev', not '${BASE}'"
|
||||
fi
|
||||
;;
|
||||
fix/*|bugfix/*)
|
||||
if [ "$BASE" != "dev" ]; then
|
||||
ALLOWED=false
|
||||
REASON="Fix branches must target 'dev', not '${BASE}'"
|
||||
fi
|
||||
;;
|
||||
patch/*)
|
||||
if [ "$BASE" != "dev" ] && [ "$BASE" != "rc" ]; then
|
||||
ALLOWED=false
|
||||
REASON="Patch branches must target 'dev' or 'rc', not '${BASE}'"
|
||||
fi
|
||||
;;
|
||||
hotfix/*)
|
||||
if [ "$BASE" != "dev" ] && [ "$BASE" != "main" ]; then
|
||||
ALLOWED=false
|
||||
REASON="Hotfix branches can only target 'dev' or 'main', not '${BASE}'"
|
||||
fi
|
||||
;;
|
||||
rc)
|
||||
if [ "$BASE" != "main" ]; then
|
||||
ALLOWED=false
|
||||
REASON="RC branch can only merge into 'main', not '${BASE}'"
|
||||
fi
|
||||
;;
|
||||
dev)
|
||||
if [ "$BASE" != "main" ]; then
|
||||
ALLOWED=false
|
||||
REASON="Dev branch can only merge into 'main', not '${BASE}'"
|
||||
fi
|
||||
;;
|
||||
esac
|
||||
|
||||
if [ "$ALLOWED" = false ]; then
|
||||
echo "::error::${REASON}"
|
||||
echo "## Branch Policy Violation" >> $GITHUB_STEP_SUMMARY
|
||||
echo "" >> $GITHUB_STEP_SUMMARY
|
||||
echo "${REASON}" >> $GITHUB_STEP_SUMMARY
|
||||
echo "" >> $GITHUB_STEP_SUMMARY
|
||||
echo "### Allowed merge paths:" >> $GITHUB_STEP_SUMMARY
|
||||
echo "- \`feature/*\` → \`dev\`" >> $GITHUB_STEP_SUMMARY
|
||||
echo "- \`fix/*\` → \`dev\`" >> $GITHUB_STEP_SUMMARY
|
||||
echo "- \`hotfix/*\` → \`dev\` or \`main\`" >> $GITHUB_STEP_SUMMARY
|
||||
echo "- \`dev\` → \`main\`" >> $GITHUB_STEP_SUMMARY
|
||||
echo "- \`rc/*\` → \`main\`" >> $GITHUB_STEP_SUMMARY
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "Branch policy: OK (${HEAD} → ${BASE})"
|
||||
echo "## Branch Policy: Passed" >> $GITHUB_STEP_SUMMARY
|
||||
|
||||
# ── Code Validation ────────────────────────────────────────────────────
|
||||
validate:
|
||||
name: Validate PR
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Detect platform
|
||||
id: platform
|
||||
run: |
|
||||
# Read platform from XML manifest (<platform> tag) or plain text fallback
|
||||
PLATFORM=$(sed -n 's/.*<platform>\([^<]*\)<\/platform>.*/\1/p' .mokogitea/manifest.xml 2>/dev/null | head -1)
|
||||
[ -z "$PLATFORM" ] && PLATFORM=$(cat .mokogitea/manifest.xml 2>/dev/null | tr -d '[:space:]')
|
||||
[ -z "$PLATFORM" ] && PLATFORM="generic"
|
||||
echo "platform=$PLATFORM" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Setup PHP
|
||||
if: steps.platform.outputs.platform == 'joomla' || steps.platform.outputs.platform == 'dolibarr'
|
||||
run: |
|
||||
if ! command -v php &> /dev/null; then
|
||||
sudo apt-get update -qq
|
||||
sudo apt-get install -y -qq php-cli php-mbstring php-xml >/dev/null 2>&1
|
||||
fi
|
||||
|
||||
- name: PHP syntax check
|
||||
if: steps.platform.outputs.platform == 'joomla' || steps.platform.outputs.platform == 'dolibarr'
|
||||
run: |
|
||||
ERRORS=0
|
||||
while IFS= read -r -d '' file; do
|
||||
if ! php -l "$file" 2>&1 | grep -q "No syntax errors"; then
|
||||
ERRORS=$((ERRORS + 1))
|
||||
fi
|
||||
done < <(find . -name "*.php" -not -path "./.git/*" -not -path "./vendor/*" -print0)
|
||||
echo "PHP lint: ${ERRORS} error(s)"
|
||||
[ "$ERRORS" -eq 0 ] || { echo "::error::PHP syntax errors found"; exit 1; }
|
||||
|
||||
- name: Validate platform manifest
|
||||
run: |
|
||||
PLATFORM="${{ steps.platform.outputs.platform }}"
|
||||
case "$PLATFORM" in
|
||||
joomla)
|
||||
MANIFEST=$(find . -maxdepth 3 -name "*.xml" ! -path "./.git/*" -exec grep -l '<extension' {} \; 2>/dev/null | head -1)
|
||||
if [ -z "$MANIFEST" ]; then
|
||||
echo "::warning::No Joomla manifest found (WaaS site)"
|
||||
exit 0
|
||||
fi
|
||||
echo "Manifest: ${MANIFEST}"
|
||||
if command -v php &> /dev/null; then
|
||||
php -r "libxml_use_internal_errors(true); \$x = simplexml_load_file('$MANIFEST'); if(!\$x){foreach(libxml_get_errors() as \$e) echo \$e->message; exit(1);}" || { echo "::error::Manifest XML is malformed"; exit 1; }
|
||||
fi
|
||||
for ELEMENT in name version description; do
|
||||
grep -q "<${ELEMENT}>" "$MANIFEST" || { echo "::error::Missing <${ELEMENT}> in manifest"; exit 1; }
|
||||
done
|
||||
echo "Joomla manifest valid"
|
||||
;;
|
||||
dolibarr)
|
||||
MOD_FILE=$(find . -maxdepth 4 -name "mod*.class.php" ! -path "./.git/*" -exec grep -l 'extends DolibarrModules' {} \; 2>/dev/null | head -1)
|
||||
if [ -z "$MOD_FILE" ]; then
|
||||
echo "::error::No mod*.class.php found"
|
||||
exit 1
|
||||
fi
|
||||
echo "Dolibarr module: ${MOD_FILE}"
|
||||
;;
|
||||
*)
|
||||
echo "Generic platform — no manifest validation"
|
||||
;;
|
||||
esac
|
||||
|
||||
- name: Check update stream format
|
||||
run: |
|
||||
PLATFORM="${{ steps.platform.outputs.platform }}"
|
||||
case "$PLATFORM" in
|
||||
joomla)
|
||||
if [ -f "updates.xml" ]; then
|
||||
if command -v php &> /dev/null; then
|
||||
php -r "libxml_use_internal_errors(true); \$x = simplexml_load_file('updates.xml'); if(!\$x){foreach(libxml_get_errors() as \$e) echo \$e->message; exit(1);}" || { echo "::error::updates.xml is malformed"; exit 1; }
|
||||
fi
|
||||
echo "updates.xml valid"
|
||||
fi
|
||||
;;
|
||||
dolibarr)
|
||||
[ -f "update.txt" ] && echo "update.txt present" || echo "::warning::No update.txt"
|
||||
;;
|
||||
esac
|
||||
|
||||
- name: Check changelog has unreleased entry
|
||||
run: |
|
||||
if [ ! -f "CHANGELOG.md" ]; then
|
||||
echo "::warning::No CHANGELOG.md found"
|
||||
exit 0
|
||||
fi
|
||||
# Check for content under [Unreleased] section
|
||||
if ! grep -q "## \[Unreleased\]" CHANGELOG.md; then
|
||||
echo "::error::CHANGELOG.md missing [Unreleased] section"
|
||||
exit 1
|
||||
fi
|
||||
# Check there's at least one entry (Added/Changed/Fixed/Removed) under Unreleased
|
||||
UNRELEASED_CONTENT=$(sed -n '/## \[Unreleased\]/,/## \[/p' CHANGELOG.md | grep -cE '^\s*-\s' || true)
|
||||
if [ "$UNRELEASED_CONTENT" -eq 0 ]; then
|
||||
echo "::error::CHANGELOG.md [Unreleased] section has no entries. Add a changelog entry describing your changes."
|
||||
echo "## Changelog Check: Failed" >> $GITHUB_STEP_SUMMARY
|
||||
echo "The \`[Unreleased]\` section in CHANGELOG.md has no entries." >> $GITHUB_STEP_SUMMARY
|
||||
echo "Add a line like \`- Description of your change\` under a heading (\`### Added\`, \`### Changed\`, \`### Fixed\`, etc.)" >> $GITHUB_STEP_SUMMARY
|
||||
exit 1
|
||||
fi
|
||||
echo "Changelog: ${UNRELEASED_CONTENT} entry/entries in [Unreleased]"
|
||||
|
||||
- name: Verify package source
|
||||
run: |
|
||||
SOURCE_DIR="src"
|
||||
[ ! -d "$SOURCE_DIR" ] && SOURCE_DIR="htdocs"
|
||||
if [ ! -d "$SOURCE_DIR" ]; then
|
||||
echo "::warning::No src/ or htdocs/ directory"
|
||||
exit 0
|
||||
fi
|
||||
FILE_COUNT=$(find "$SOURCE_DIR" -type f | wc -l)
|
||||
echo "Source: ${FILE_COUNT} files"
|
||||
[ "$FILE_COUNT" -gt 0 ] || { echo "::error::Source directory is empty"; exit 1; }
|
||||
|
||||
# ── Pre-Release RC Build ─────────────────────────────────────────────────
|
||||
pre-release:
|
||||
name: Build RC Package
|
||||
runs-on: ubuntu-latest
|
||||
needs: [branch-policy, validate]
|
||||
|
||||
steps:
|
||||
- name: Trigger RC pre-release
|
||||
env:
|
||||
GA_TOKEN: ${{ secrets.MOKOGITEA_TOKEN }}
|
||||
REPO: ${{ github.repository }}
|
||||
BRANCH: ${{ github.head_ref }}
|
||||
GITEA_URL: ${{ vars.GITEA_URL || 'https://git.mokoconsulting.tech' }}
|
||||
run: |
|
||||
curl -s -X POST "${GITEA_URL}/api/v1/repos/${REPO}/actions/workflows/pre-release.yml/dispatches" -H "Authorization: token ${GITEA_TOKEN}" -H "Content-Type: application/json" -d "{\"ref\":\"${BRANCH}\",\"inputs\":{\"stability\":\"release-candidate\"}}"
|
||||
echo "### Pre-Release" >> $GITHUB_STEP_SUMMARY
|
||||
echo "Triggered RC build on branch \`${BRANCH}\`" >> $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.CI
|
||||
# REPO: https://git.mokoconsulting.tech/mokoconsulting-tech/moko-platform
|
||||
# PATH: /templates/workflows/universal/pr-check.yml.template
|
||||
# VERSION: 09.23.00
|
||||
# BRIEF: PR gate — branch policy + code validation before merge
|
||||
|
||||
name: "Universal: PR Check"
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
types: [opened, synchronize, reopened, edited]
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: write
|
||||
|
||||
env:
|
||||
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
|
||||
|
||||
jobs:
|
||||
# ── Branch Policy ──────────────────────────────────────────────────────
|
||||
branch-policy:
|
||||
name: Branch Policy
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Check branch merge target
|
||||
run: |
|
||||
HEAD="${{ github.head_ref }}"
|
||||
BASE="${{ github.base_ref }}"
|
||||
|
||||
echo "PR: ${HEAD} → ${BASE}"
|
||||
|
||||
ALLOWED=true
|
||||
REASON=""
|
||||
|
||||
case "$HEAD" in
|
||||
feature/*|feat/*)
|
||||
if [ "$BASE" != "dev" ]; then
|
||||
ALLOWED=false
|
||||
REASON="Feature branches must target 'dev', not '${BASE}'"
|
||||
fi
|
||||
;;
|
||||
fix/*|bugfix/*)
|
||||
if [ "$BASE" != "dev" ]; then
|
||||
ALLOWED=false
|
||||
REASON="Fix branches must target 'dev', not '${BASE}'"
|
||||
fi
|
||||
;;
|
||||
patch/*)
|
||||
if [ "$BASE" != "dev" ] && [ "$BASE" != "rc" ]; then
|
||||
ALLOWED=false
|
||||
REASON="Patch branches must target 'dev' or 'rc', not '${BASE}'"
|
||||
fi
|
||||
;;
|
||||
hotfix/*)
|
||||
if [ "$BASE" != "dev" ] && [ "$BASE" != "main" ]; then
|
||||
ALLOWED=false
|
||||
REASON="Hotfix branches can only target 'dev' or 'main', not '${BASE}'"
|
||||
fi
|
||||
;;
|
||||
rc)
|
||||
if [ "$BASE" != "main" ]; then
|
||||
ALLOWED=false
|
||||
REASON="RC branch can only merge into 'main', not '${BASE}'"
|
||||
fi
|
||||
;;
|
||||
dev)
|
||||
if [ "$BASE" != "main" ]; then
|
||||
ALLOWED=false
|
||||
REASON="Dev branch can only merge into 'main', not '${BASE}'"
|
||||
fi
|
||||
;;
|
||||
esac
|
||||
|
||||
if [ "$ALLOWED" = false ]; then
|
||||
echo "::error::${REASON}"
|
||||
echo "## Branch Policy Violation" >> $GITHUB_STEP_SUMMARY
|
||||
echo "" >> $GITHUB_STEP_SUMMARY
|
||||
echo "${REASON}" >> $GITHUB_STEP_SUMMARY
|
||||
echo "" >> $GITHUB_STEP_SUMMARY
|
||||
echo "### Allowed merge paths:" >> $GITHUB_STEP_SUMMARY
|
||||
echo "- \`feature/*\` → \`dev\`" >> $GITHUB_STEP_SUMMARY
|
||||
echo "- \`fix/*\` → \`dev\`" >> $GITHUB_STEP_SUMMARY
|
||||
echo "- \`hotfix/*\` → \`dev\` or \`main\`" >> $GITHUB_STEP_SUMMARY
|
||||
echo "- \`dev\` → \`main\`" >> $GITHUB_STEP_SUMMARY
|
||||
echo "- \`rc/*\` → \`main\`" >> $GITHUB_STEP_SUMMARY
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "Branch policy: OK (${HEAD} → ${BASE})"
|
||||
echo "## Branch Policy: Passed" >> $GITHUB_STEP_SUMMARY
|
||||
|
||||
# ── Code Validation ────────────────────────────────────────────────────
|
||||
validate:
|
||||
name: Validate PR
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Check for merge conflict markers
|
||||
run: |
|
||||
CONFLICTS=$(grep -rn '<<<<<<< \|>>>>>>> \|^=======$' --include='*.php' --include='*.xml' --include='*.css' --include='*.js' --include='*.json' --include='*.md' --include='*.yml' --include='*.yaml' --include='*.ini' --include='*.txt' . 2>/dev/null | grep -v '.git/' || true)
|
||||
if [ -n "$CONFLICTS" ]; then
|
||||
echo "::error::Merge conflict markers found in source files"
|
||||
echo "## Conflict Markers Found" >> $GITHUB_STEP_SUMMARY
|
||||
echo '```' >> $GITHUB_STEP_SUMMARY
|
||||
echo "$CONFLICTS" >> $GITHUB_STEP_SUMMARY
|
||||
echo '```' >> $GITHUB_STEP_SUMMARY
|
||||
exit 1
|
||||
fi
|
||||
echo "No conflict markers found"
|
||||
|
||||
- name: Detect platform
|
||||
id: platform
|
||||
run: |
|
||||
# Read platform from XML manifest (<platform> tag) or plain text fallback
|
||||
PLATFORM=$(sed -n 's/.*<platform>\([^<]*\)<\/platform>.*/\1/p' .mokogitea/manifest.xml 2>/dev/null | head -1)
|
||||
[ -z "$PLATFORM" ] && PLATFORM=$(cat .mokogitea/manifest.xml 2>/dev/null | tr -d '[:space:]')
|
||||
[ -z "$PLATFORM" ] && PLATFORM="generic"
|
||||
echo "platform=$PLATFORM" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Setup PHP
|
||||
if: steps.platform.outputs.platform == 'joomla' || steps.platform.outputs.platform == 'dolibarr'
|
||||
run: |
|
||||
if ! command -v php &> /dev/null; then
|
||||
sudo apt-get update -qq
|
||||
sudo apt-get install -y -qq php-cli php-mbstring php-xml >/dev/null 2>&1
|
||||
fi
|
||||
|
||||
- name: PHP syntax check
|
||||
if: steps.platform.outputs.platform == 'joomla' || steps.platform.outputs.platform == 'dolibarr'
|
||||
run: |
|
||||
ERRORS=0
|
||||
while IFS= read -r -d '' file; do
|
||||
if ! php -l "$file" 2>&1 | grep -q "No syntax errors"; then
|
||||
ERRORS=$((ERRORS + 1))
|
||||
fi
|
||||
done < <(find . -name "*.php" -not -path "./.git/*" -not -path "./vendor/*" -print0)
|
||||
echo "PHP lint: ${ERRORS} error(s)"
|
||||
[ "$ERRORS" -eq 0 ] || { echo "::error::PHP syntax errors found"; exit 1; }
|
||||
|
||||
- name: Joomla JEXEC guard check
|
||||
if: steps.platform.outputs.platform == 'joomla'
|
||||
run: |
|
||||
ERRORS=0
|
||||
while IFS= read -r -d '' file; do
|
||||
# Skip vendor, node_modules, and index.html stub files
|
||||
case "$file" in ./vendor/*|./node_modules/*) continue ;; esac
|
||||
# Check first 10 lines for JEXEC or JPATH guard
|
||||
if ! head -20 "$file" | grep -qE "defined\s*\(\s*['\"](_JEXEC|JPATH_BASE|\\\\JPATH_PLATFORM)['\"]"; then
|
||||
echo "::error file=${file}::Missing JEXEC guard: ${file}"
|
||||
ERRORS=$((ERRORS + 1))
|
||||
fi
|
||||
done < <(find . -name "*.php" -path "*/src/*" -not -path "./.git/*" -not -path "./vendor/*" -print0)
|
||||
if [ "$ERRORS" -gt 0 ]; then
|
||||
echo "::error::${ERRORS} PHP file(s) missing defined('_JEXEC') or die guard"
|
||||
echo "## JEXEC Guard Check: Failed" >> $GITHUB_STEP_SUMMARY
|
||||
echo "${ERRORS} file(s) in src/ are missing the Joomla execution guard." >> $GITHUB_STEP_SUMMARY
|
||||
exit 1
|
||||
fi
|
||||
echo "JEXEC guard: OK"
|
||||
|
||||
- name: Joomla directory listing protection
|
||||
if: steps.platform.outputs.platform == 'joomla'
|
||||
run: |
|
||||
MISSING=0
|
||||
SOURCE_DIR="source"
|
||||
[ ! -d "$SOURCE_DIR" ] && SOURCE_DIR="src"
|
||||
[ ! -d "$SOURCE_DIR" ] && exit 0
|
||||
while IFS= read -r dir; do
|
||||
if [ ! -f "${dir}/index.html" ]; then
|
||||
echo "::warning::Missing index.html in ${dir} (directory listing protection)"
|
||||
MISSING=$((MISSING + 1))
|
||||
fi
|
||||
done < <(find "$SOURCE_DIR" -type d -not -path "./.git/*" -not -path "*/vendor/*" -not -path "*/node_modules/*")
|
||||
if [ "$MISSING" -gt 0 ]; then
|
||||
echo "## Directory Protection" >> $GITHUB_STEP_SUMMARY
|
||||
echo "${MISSING} director(ies) missing index.html" >> $GITHUB_STEP_SUMMARY
|
||||
fi
|
||||
echo "Directory protection: ${MISSING} missing (advisory)"
|
||||
|
||||
- name: Joomla script file and asset checks
|
||||
if: steps.platform.outputs.platform == 'joomla'
|
||||
run: |
|
||||
ERRORS=0
|
||||
MANIFEST=$(find . -maxdepth 3 -name "*.xml" ! -path "./.git/*" -exec grep -l '<extension' {} \; 2>/dev/null | head -1)
|
||||
[ -z "$MANIFEST" ] && exit 0
|
||||
MANIFEST_DIR=$(dirname "$MANIFEST")
|
||||
|
||||
# Check scriptfile exists if declared
|
||||
SCRIPTFILE=$(sed -n 's/.*<scriptfile>\([^<]*\)<\/scriptfile>.*/\1/p' "$MANIFEST" 2>/dev/null)
|
||||
if [ -n "$SCRIPTFILE" ]; then
|
||||
if [ ! -f "${MANIFEST_DIR}/${SCRIPTFILE}" ]; then
|
||||
echo "::error::Manifest declares <scriptfile>${SCRIPTFILE}</scriptfile> but file not found at ${MANIFEST_DIR}/${SCRIPTFILE}"
|
||||
ERRORS=$((ERRORS + 1))
|
||||
else
|
||||
echo "Script file: ${MANIFEST_DIR}/${SCRIPTFILE} (OK)"
|
||||
fi
|
||||
fi
|
||||
|
||||
# Require joomla.asset.json and validate it
|
||||
ASSET_JSON=$(find "$MANIFEST_DIR" -name "joomla.asset.json" -not -path "./.git/*" 2>/dev/null | head -1)
|
||||
if [ -z "$ASSET_JSON" ]; then
|
||||
echo "::error::joomla.asset.json not found — Joomla asset system is required"
|
||||
ERRORS=$((ERRORS + 1))
|
||||
else
|
||||
if command -v php &> /dev/null; then
|
||||
php -r "json_decode(file_get_contents('$ASSET_JSON')); if(json_last_error()!==JSON_ERROR_NONE){echo json_last_error_msg();exit(1);}" 2>&1 || {
|
||||
echo "::error::joomla.asset.json is not valid JSON"
|
||||
ERRORS=$((ERRORS + 1))
|
||||
}
|
||||
fi
|
||||
echo "joomla.asset.json: valid"
|
||||
fi
|
||||
|
||||
# Validate all XML files in source/src/ are well-formed
|
||||
XML_ERRORS=0
|
||||
if command -v php &> /dev/null; then
|
||||
while IFS= read -r -d '' xmlfile; do
|
||||
if ! php -r "libxml_use_internal_errors(true); \$x = simplexml_load_file('$xmlfile'); if(!\$x){foreach(libxml_get_errors() as \$e) echo trim(\$e->message) . ' in $xmlfile'; exit(1);}" 2>&1; then
|
||||
XML_ERRORS=$((XML_ERRORS + 1))
|
||||
fi
|
||||
done < <(find "$MANIFEST_DIR" -name "*.xml" -not -path "./.git/*" -print0)
|
||||
fi
|
||||
if [ "$XML_ERRORS" -gt 0 ]; then
|
||||
echo "::error::${XML_ERRORS} XML file(s) are malformed"
|
||||
ERRORS=$((ERRORS + 1))
|
||||
else
|
||||
echo "XML well-formedness: OK"
|
||||
fi
|
||||
|
||||
[ "$ERRORS" -gt 0 ] && exit 1
|
||||
echo "Joomla asset checks: OK"
|
||||
|
||||
- name: Validate platform manifest
|
||||
run: |
|
||||
PLATFORM="${{ steps.platform.outputs.platform }}"
|
||||
case "$PLATFORM" in
|
||||
joomla)
|
||||
MANIFEST=$(find . -maxdepth 3 -name "*.xml" ! -path "./.git/*" -exec grep -l '<extension' {} \; 2>/dev/null | head -1)
|
||||
if [ -z "$MANIFEST" ]; then
|
||||
echo "::warning::No Joomla manifest found (WaaS site)"
|
||||
exit 0
|
||||
fi
|
||||
echo "Manifest: ${MANIFEST}"
|
||||
if command -v php &> /dev/null; then
|
||||
php -r "libxml_use_internal_errors(true); \$x = simplexml_load_file('$MANIFEST'); if(!\$x){foreach(libxml_get_errors() as \$e) echo \$e->message; exit(1);}" || { echo "::error::Manifest XML is malformed"; exit 1; }
|
||||
fi
|
||||
for ELEMENT in name version description; do
|
||||
grep -q "<${ELEMENT}>" "$MANIFEST" || { echo "::error::Missing <${ELEMENT}> in manifest"; exit 1; }
|
||||
done
|
||||
# Block legacy raw/branch update server URLs on MokoGitea
|
||||
RAW_URLS=$(grep -n 'raw/branch' "$MANIFEST" | grep -i 'mokoconsulting\|mokogitea\|git\.mokoconsulting\.tech' || true)
|
||||
if [ -n "$RAW_URLS" ]; then
|
||||
echo "::error::Manifest contains legacy raw/branch update server URL on MokoGitea. Use the Gitea Pages URL instead (e.g. /{REPO}/updates.xml not /{REPO}/raw/branch/main/updates.xml)"
|
||||
echo "$RAW_URLS"
|
||||
exit 1
|
||||
fi
|
||||
echo "Joomla manifest valid"
|
||||
;;
|
||||
dolibarr)
|
||||
MOD_FILE=$(find . -maxdepth 4 -name "mod*.class.php" ! -path "./.git/*" -exec grep -l 'extends DolibarrModules' {} \; 2>/dev/null | head -1)
|
||||
if [ -z "$MOD_FILE" ]; then
|
||||
echo "::error::No mod*.class.php found"
|
||||
exit 1
|
||||
fi
|
||||
echo "Dolibarr module: ${MOD_FILE}"
|
||||
;;
|
||||
*)
|
||||
echo "Generic platform — no manifest validation"
|
||||
;;
|
||||
esac
|
||||
|
||||
- name: Check update stream format
|
||||
run: |
|
||||
PLATFORM="${{ steps.platform.outputs.platform }}"
|
||||
case "$PLATFORM" in
|
||||
joomla)
|
||||
if [ -f "updates.xml" ]; then
|
||||
if command -v php &> /dev/null; then
|
||||
php -r "libxml_use_internal_errors(true); \$x = simplexml_load_file('updates.xml'); if(!\$x){foreach(libxml_get_errors() as \$e) echo \$e->message; exit(1);}" || { echo "::error::updates.xml is malformed"; exit 1; }
|
||||
fi
|
||||
echo "updates.xml valid"
|
||||
fi
|
||||
;;
|
||||
dolibarr)
|
||||
[ -f "update.txt" ] && echo "update.txt present" || echo "::warning::No update.txt"
|
||||
;;
|
||||
esac
|
||||
|
||||
- name: Validate Joomla language files
|
||||
if: steps.platform.outputs.platform == 'joomla'
|
||||
run: |
|
||||
ERRORS=0
|
||||
WARNINGS=0
|
||||
|
||||
# Require both en-GB and en-US language directories
|
||||
LANG_ROOT=$(find . -path "*/language" -type d -not -path "./.git/*" 2>/dev/null | head -1)
|
||||
if [ -z "$LANG_ROOT" ]; then
|
||||
echo "No language/ directory found — skipping"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
if [ ! -d "$LANG_ROOT/en-GB" ]; then
|
||||
echo "::error::Missing en-GB language directory (${LANG_ROOT}/en-GB)"
|
||||
ERRORS=$((ERRORS + 1))
|
||||
fi
|
||||
if [ ! -d "$LANG_ROOT/en-US" ]; then
|
||||
echo "::error::Missing en-US language directory (${LANG_ROOT}/en-US)"
|
||||
ERRORS=$((ERRORS + 1))
|
||||
fi
|
||||
|
||||
# Check that en-GB and en-US have matching .ini files
|
||||
if [ -d "$LANG_ROOT/en-GB" ] && [ -d "$LANG_ROOT/en-US" ]; then
|
||||
for GB_INI in "$LANG_ROOT/en-GB"/*.ini; do
|
||||
[ ! -f "$GB_INI" ] && continue
|
||||
US_INI="$LANG_ROOT/en-US/$(basename "$GB_INI")"
|
||||
if [ ! -f "$US_INI" ]; then
|
||||
echo "::error::$(basename "$GB_INI") exists in en-GB but missing from en-US"
|
||||
ERRORS=$((ERRORS + 1))
|
||||
fi
|
||||
done
|
||||
for US_INI in "$LANG_ROOT/en-US"/*.ini; do
|
||||
[ ! -f "$US_INI" ] && continue
|
||||
GB_INI="$LANG_ROOT/en-GB/$(basename "$US_INI")"
|
||||
if [ ! -f "$GB_INI" ]; then
|
||||
echo "::error::$(basename "$US_INI") exists in en-US but missing from en-GB"
|
||||
ERRORS=$((ERRORS + 1))
|
||||
fi
|
||||
done
|
||||
fi
|
||||
|
||||
# Find all .ini language files
|
||||
INI_FILES=$(find . -path "*/language/*/*.ini" -not -path "./.git/*" 2>/dev/null)
|
||||
if [ -z "$INI_FILES" ]; then
|
||||
echo "No .ini language files found"
|
||||
[ "$ERRORS" -gt 0 ] && exit 1
|
||||
exit 0
|
||||
fi
|
||||
|
||||
echo "Found $(echo "$INI_FILES" | wc -l) language file(s)"
|
||||
|
||||
for FILE in $INI_FILES; do
|
||||
FNAME=$(basename "$FILE")
|
||||
LINENUM=0
|
||||
SEEN_KEYS=""
|
||||
|
||||
while IFS= read -r line || [ -n "$line" ]; do
|
||||
LINENUM=$((LINENUM + 1))
|
||||
|
||||
# Skip empty lines and comments
|
||||
[ -z "$line" ] && continue
|
||||
echo "$line" | grep -qE '^\s*;' && continue
|
||||
echo "$line" | grep -qE '^\s*$' && continue
|
||||
|
||||
# Must match KEY="VALUE" format
|
||||
if ! echo "$line" | grep -qE '^[A-Z_][A-Z0-9_]*=".*"$'; then
|
||||
echo "::error file=${FILE},line=${LINENUM}::Malformed line: ${line}"
|
||||
ERRORS=$((ERRORS + 1))
|
||||
continue
|
||||
fi
|
||||
|
||||
# Extract key and check for duplicates
|
||||
KEY=$(echo "$line" | sed 's/=.*//')
|
||||
if echo "$SEEN_KEYS" | grep -qx "$KEY"; then
|
||||
echo "::error file=${FILE},line=${LINENUM}::Duplicate key: ${KEY}"
|
||||
ERRORS=$((ERRORS + 1))
|
||||
fi
|
||||
SEEN_KEYS="${SEEN_KEYS}
|
||||
${KEY}"
|
||||
done < "$FILE"
|
||||
|
||||
echo " ${FILE}: checked ${LINENUM} lines"
|
||||
done
|
||||
|
||||
# Cross-check en-GB vs en-US key consistency
|
||||
GB_DIR=$(find . -path "*/language/en-GB" -type d -not -path "./.git/*" 2>/dev/null | head -1)
|
||||
US_DIR=$(find . -path "*/language/en-US" -type d -not -path "./.git/*" 2>/dev/null | head -1)
|
||||
|
||||
if [ -n "$GB_DIR" ] && [ -n "$US_DIR" ]; then
|
||||
for GB_FILE in "$GB_DIR"/*.ini; do
|
||||
[ ! -f "$GB_FILE" ] && continue
|
||||
FNAME=$(basename "$GB_FILE")
|
||||
US_FILE="$US_DIR/$FNAME"
|
||||
[ ! -f "$US_FILE" ] && continue
|
||||
|
||||
GB_KEYS=$(grep -oP '^[A-Z_][A-Z0-9_]*(?==)' "$GB_FILE" 2>/dev/null | sort)
|
||||
US_KEYS=$(grep -oP '^[A-Z_][A-Z0-9_]*(?==)' "$US_FILE" 2>/dev/null | sort)
|
||||
|
||||
# Keys in en-GB but not en-US
|
||||
MISSING_US=$(comm -23 <(echo "$GB_KEYS") <(echo "$US_KEYS"))
|
||||
if [ -n "$MISSING_US" ]; then
|
||||
echo "::warning::Keys in en-GB/$FNAME but missing from en-US/$FNAME:"
|
||||
echo "$MISSING_US" | while read -r k; do echo " - $k"; done
|
||||
WARNINGS=$((WARNINGS + 1))
|
||||
fi
|
||||
|
||||
# Keys in en-US but not en-GB
|
||||
MISSING_GB=$(comm -13 <(echo "$GB_KEYS") <(echo "$US_KEYS"))
|
||||
if [ -n "$MISSING_GB" ]; then
|
||||
echo "::warning::Keys in en-US/$FNAME but missing from en-GB/$FNAME:"
|
||||
echo "$MISSING_GB" | while read -r k; do echo " - $k"; done
|
||||
WARNINGS=$((WARNINGS + 1))
|
||||
fi
|
||||
done
|
||||
fi
|
||||
|
||||
{
|
||||
echo "### Language File Validation"
|
||||
echo "| Metric | Count |"
|
||||
echo "|---|---|"
|
||||
echo "| Files checked | $(echo "$INI_FILES" | wc -l) |"
|
||||
echo "| Errors | ${ERRORS} |"
|
||||
echo "| Warnings | ${WARNINGS} |"
|
||||
} >> $GITHUB_STEP_SUMMARY
|
||||
|
||||
if [ "$ERRORS" -gt 0 ]; then
|
||||
echo "::error::Language validation failed with ${ERRORS} error(s)"
|
||||
exit 1
|
||||
fi
|
||||
echo "Language files: OK (${WARNINGS} warning(s))"
|
||||
|
||||
- name: Check changelog has unreleased entry
|
||||
run: |
|
||||
if [ ! -f "CHANGELOG.md" ]; then
|
||||
echo "::warning::No CHANGELOG.md found"
|
||||
exit 0
|
||||
fi
|
||||
# Check for content under [Unreleased] section
|
||||
if ! grep -q "## \[Unreleased\]" CHANGELOG.md; then
|
||||
echo "::error::CHANGELOG.md missing [Unreleased] section"
|
||||
exit 1
|
||||
fi
|
||||
# Check there's at least one entry (Added/Changed/Fixed/Removed) under Unreleased
|
||||
UNRELEASED_CONTENT=$(sed -n '/## \[Unreleased\]/,/## \[/p' CHANGELOG.md | grep -cE '^\s*-\s' || true)
|
||||
if [ "$UNRELEASED_CONTENT" -eq 0 ]; then
|
||||
echo "::error::CHANGELOG.md [Unreleased] section has no entries. Add a changelog entry describing your changes."
|
||||
echo "## Changelog Check: Failed" >> $GITHUB_STEP_SUMMARY
|
||||
echo "The \`[Unreleased]\` section in CHANGELOG.md has no entries." >> $GITHUB_STEP_SUMMARY
|
||||
echo "Add a line like \`- Description of your change\` under a heading (\`### Added\`, \`### Changed\`, \`### Fixed\`, etc.)" >> $GITHUB_STEP_SUMMARY
|
||||
exit 1
|
||||
fi
|
||||
echo "Changelog: ${UNRELEASED_CONTENT} entry/entries in [Unreleased]"
|
||||
|
||||
- name: Verify package source
|
||||
run: |
|
||||
SOURCE_DIR="source"
|
||||
[ ! -d "$SOURCE_DIR" ] && SOURCE_DIR="src"
|
||||
[ ! -d "$SOURCE_DIR" ] && SOURCE_DIR="htdocs"
|
||||
if [ ! -d "$SOURCE_DIR" ]; then
|
||||
echo "::warning::No source/, src/, or htdocs/ directory"
|
||||
exit 0
|
||||
fi
|
||||
FILE_COUNT=$(find "$SOURCE_DIR" -type f | wc -l)
|
||||
echo "Source: ${FILE_COUNT} files"
|
||||
[ "$FILE_COUNT" -gt 0 ] || { echo "::error::Source directory is empty"; exit 1; }
|
||||
|
||||
# ── Pre-Release RC Build ─────────────────────────────────────────────────
|
||||
pre-release:
|
||||
name: Build RC Package
|
||||
runs-on: ubuntu-latest
|
||||
needs: [branch-policy, validate]
|
||||
|
||||
steps:
|
||||
- name: Trigger RC pre-release
|
||||
env:
|
||||
GA_TOKEN: ${{ secrets.MOKOGITEA_TOKEN }}
|
||||
REPO: ${{ github.repository }}
|
||||
BRANCH: ${{ github.head_ref }}
|
||||
GITEA_URL: ${{ vars.GITEA_URL || 'https://git.mokoconsulting.tech' }}
|
||||
run: |
|
||||
curl -s -X POST "${GITEA_URL}/api/v1/repos/${REPO}/actions/workflows/pre-release.yml/dispatches" -H "Authorization: token ${GITEA_TOKEN}" -H "Content-Type: application/json" -d "{\"ref\":\"${BRANCH}\",\"inputs\":{\"stability\":\"release-candidate\"}}"
|
||||
echo "### Pre-Release" >> $GITHUB_STEP_SUMMARY
|
||||
echo "Triggered RC build on branch \`${BRANCH}\`" >> $GITHUB_STEP_SUMMARY
|
||||
|
||||
# ── Issue Reporter ──────────────────────────────────────────────────────
|
||||
report-issues:
|
||||
name: Report Issues
|
||||
runs-on: ubuntu-latest
|
||||
needs: [branch-policy, validate]
|
||||
if: >-
|
||||
always() &&
|
||||
needs.validate.result == 'failure'
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
sparse-checkout: automation/ci-issue-reporter.sh
|
||||
sparse-checkout-cone-mode: false
|
||||
|
||||
- name: "File issue for PR validation failure"
|
||||
env:
|
||||
GITEA_TOKEN: ${{ secrets.MOKOGITEA_TOKEN }}
|
||||
GITEA_URL: ${{ vars.GITEA_URL || 'https://git.mokoconsulting.tech' }}
|
||||
run: |
|
||||
chmod +x automation/ci-issue-reporter.sh
|
||||
./automation/ci-issue-reporter.sh \
|
||||
--gate "PR Validation" \
|
||||
--workflow "PR Check" \
|
||||
--severity error \
|
||||
--details "PR validation failed (syntax, manifest, changelog, or source checks). See the CI run for the specific check that failed."
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
# INGROUP: moko-platform.Release
|
||||
# REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform
|
||||
# PATH: /templates/workflows/universal/pre-release.yml.template
|
||||
# VERSION: 09.23.00
|
||||
# VERSION: 05.01.00
|
||||
# BRIEF: Manual pre-release -- builds dev/alpha/beta/rc packages from any branch
|
||||
|
||||
name: "Universal: Pre-Release"
|
||||
@@ -17,6 +17,10 @@ on:
|
||||
types: [closed]
|
||||
branches:
|
||||
- dev
|
||||
pull_request_target:
|
||||
types: [synchronize, opened, reopened]
|
||||
branches:
|
||||
- main
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
stability:
|
||||
@@ -43,7 +47,8 @@ jobs:
|
||||
runs-on: release
|
||||
if: >-
|
||||
github.event_name == 'workflow_dispatch' ||
|
||||
(github.event.pull_request.merged == true && github.event.pull_request.base.ref == 'dev')
|
||||
(github.event_name == 'pull_request' && github.event.pull_request.merged == true && github.event.pull_request.base.ref == 'dev') ||
|
||||
(github.event_name == 'pull_request_target' && github.event.pull_request.base.ref == 'main')
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
@@ -51,22 +56,28 @@ jobs:
|
||||
with:
|
||||
fetch-depth: 0
|
||||
token: ${{ secrets.MOKOGITEA_TOKEN }}
|
||||
ref: ${{ github.event_name == 'pull_request_target' && github.event.pull_request.head.sha || '' }}
|
||||
|
||||
- name: Setup moko-platform tools
|
||||
env:
|
||||
MOKO_CLONE_TOKEN: ${{ secrets.MOKOGITEA_TOKEN }}
|
||||
MOKO_CLONE_HOST: git.mokoconsulting.tech/MokoConsulting
|
||||
run: |
|
||||
if ! command -v composer &> /dev/null; then
|
||||
sudo apt-get update -qq && sudo apt-get install -y -qq php-cli php-mbstring php-xml php-zip php-curl composer >/dev/null 2>&1
|
||||
# Use pre-installed /opt/moko-platform if available (updated by cron every 6h)
|
||||
if [ -f /opt/moko-platform/cli/version_bump.php ] && [ -f /opt/moko-platform/cli/manifest_element.php ] && [ -f /opt/moko-platform/vendor/autoload.php ]; then
|
||||
echo Using pre-installed /opt/moko-platform
|
||||
echo MOKO_CLI=/opt/moko-platform/cli >> $GITHUB_ENV
|
||||
else
|
||||
echo Falling back to fresh clone
|
||||
if ! command -v composer > /dev/null 2>&1; then
|
||||
sudo apt-get update -qq && sudo apt-get install -y -qq php-cli php-mbstring php-xml php-zip php-curl composer > /dev/null 2>&1
|
||||
fi
|
||||
rm -rf /tmp/moko-platform-api
|
||||
CLONE_URL=https://x-access-token:${MOKO_CLONE_TOKEN}@${MOKO_CLONE_HOST}/moko-platform.git
|
||||
git clone --depth 1 --branch main --quiet $CLONE_URL /tmp/moko-platform-api
|
||||
cd /tmp/moko-platform-api && composer install --no-dev --no-interaction --quiet
|
||||
echo MOKO_CLI=/tmp/moko-platform-api/cli >> $GITHUB_ENV
|
||||
fi
|
||||
# Always fetch latest CLI tools — never use stale cache from previous runs
|
||||
rm -rf /tmp/moko-platform-api
|
||||
git clone --depth 1 --branch main --quiet \
|
||||
"https://x-access-token:${MOKO_CLONE_TOKEN}@${MOKO_CLONE_HOST}/moko-platform.git" \
|
||||
/tmp/moko-platform-api
|
||||
cd /tmp/moko-platform-api && composer install --no-dev --no-interaction --quiet
|
||||
echo "MOKO_CLI=/tmp/moko-platform-api/cli" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Detect platform
|
||||
id: platform
|
||||
@@ -76,24 +87,43 @@ jobs:
|
||||
- name: Resolve metadata and bump version
|
||||
id: meta
|
||||
run: |
|
||||
STABILITY="${{ inputs.stability || 'development' }}"
|
||||
# Auto-detect stability: RC for PRs targeting main, else use input or default to development
|
||||
if [ "${{ github.event_name }}" = "pull_request_target" ] && [ "${{ github.event.pull_request.base.ref }}" = "main" ]; then
|
||||
STABILITY="release-candidate"
|
||||
else
|
||||
STABILITY="${{ inputs.stability || 'development' }}"
|
||||
fi
|
||||
|
||||
case "$STABILITY" in
|
||||
development) TAG="development" ;;
|
||||
alpha) TAG="alpha" ;;
|
||||
beta) TAG="beta" ;;
|
||||
release-candidate) TAG="release-candidate" ;;
|
||||
development) SUFFIX="-dev"; TAG="development" ;;
|
||||
alpha) SUFFIX="-alpha"; TAG="alpha" ;;
|
||||
beta) SUFFIX="-beta"; TAG="beta" ;;
|
||||
release-candidate) SUFFIX="-rc"; TAG="release-candidate" ;;
|
||||
esac
|
||||
|
||||
# Set stability suffix, bump preserves it, fix consistency
|
||||
# Bump version via CLI: patch for dev/alpha/beta, minor for RC
|
||||
case "$STABILITY" in
|
||||
release-candidate) BUMP="minor" ;;
|
||||
*) BUMP="patch" ;;
|
||||
esac
|
||||
|
||||
php ${MOKO_CLI}/version_bump.php --path . $([ "$BUMP" = "minor" ] && echo "--minor") 2>/dev/null || true
|
||||
|
||||
# Set stability suffix and verify consistency
|
||||
VERSION=$(php ${MOKO_CLI}/version_read.php --path . 2>/dev/null || echo "00.00.01")
|
||||
VERSION=$(echo "$VERSION" | sed 's/-\(dev\|alpha\|beta\|rc\)$//')
|
||||
|
||||
php ${MOKO_CLI}/version_set_platform.php \
|
||||
--path . --version "$(php ${MOKO_CLI}/version_read.php --path . 2>/dev/null || echo '00.00.01')" \
|
||||
--branch "${{ github.ref_name }}" --stability "$STABILITY" 2>/dev/null || true
|
||||
--path . --version "$VERSION" --branch "${{ github.ref_name }}" --stability "$STABILITY" 2>/dev/null || true
|
||||
php ${MOKO_CLI}/version_check.php --path . --fix 2>/dev/null || true
|
||||
|
||||
# Read final version (includes suffix, e.g. 01.02.15-dev)
|
||||
VERSION=$(php ${MOKO_CLI}/version_read.php --path . 2>/dev/null)
|
||||
[ -z "$VERSION" ] && VERSION="00.00.01"
|
||||
# Ensure licensing tags (updateservers, dlid) if enabled in manifest.xml
|
||||
php ${MOKO_CLI}/manifest_licensing.php --path . --fix 2>/dev/null || true
|
||||
|
||||
# Append suffix for output
|
||||
if [ -n "$SUFFIX" ]; then
|
||||
VERSION="${VERSION}${SUFFIX}"
|
||||
fi
|
||||
|
||||
# Commit version bump
|
||||
git config --local user.email "gitea-actions[bot]@mokoconsulting.tech"
|
||||
@@ -118,11 +148,12 @@ jobs:
|
||||
|
||||
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 "=== Pre-Release: ${EXT_ELEMENT} ${VERSION} ==="
|
||||
echo "=== Pre-Release: ${EXT_ELEMENT} ${VERSION}${SUFFIX} ==="
|
||||
|
||||
- name: Create release
|
||||
id: release
|
||||
@@ -135,6 +166,41 @@ jobs:
|
||||
--token "${{ secrets.MOKOGITEA_TOKEN }}" --api-base "$API_BASE" \
|
||||
--repo "${GITEA_REPO}" --branch dev --prerelease
|
||||
|
||||
- name: Update release notes from CHANGELOG.md
|
||||
run: |
|
||||
TAG="${{ steps.meta.outputs.tag }}"
|
||||
VERSION="${{ steps.meta.outputs.version }}"
|
||||
API_BASE="${GITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}"
|
||||
|
||||
# Extract [Unreleased] section from changelog (everything between [Unreleased] and next ## heading)
|
||||
if [ -f "CHANGELOG.md" ]; then
|
||||
NOTES=$(awk '/^## \[Unreleased\]/{found=1; next} /^## \[/{if(found) exit} found{print}' CHANGELOG.md)
|
||||
[ -z "$NOTES" ] && NOTES="Release ${VERSION}"
|
||||
else
|
||||
NOTES="Release ${VERSION}"
|
||||
fi
|
||||
|
||||
# Update release body via API
|
||||
RELEASE_ID=$(curl -sf -H "Authorization: token ${{ secrets.MOKOGITEA_TOKEN }}" \
|
||||
"${API_BASE}/releases/tags/${TAG}" | python3 -c "import json,sys; print(json.load(sys.stdin).get('id',''))" 2>/dev/null || true)
|
||||
|
||||
if [ -n "$RELEASE_ID" ]; then
|
||||
python3 -c "
|
||||
import json, urllib.request
|
||||
body = open('/dev/stdin').read()
|
||||
payload = json.dumps({'body': body}).encode()
|
||||
req = urllib.request.Request(
|
||||
'${API_BASE}/releases/${RELEASE_ID}',
|
||||
data=payload, method='PATCH',
|
||||
headers={
|
||||
'Authorization': 'token ${{ secrets.MOKOGITEA_TOKEN }}',
|
||||
'Content-Type': 'application/json'
|
||||
})
|
||||
urllib.request.urlopen(req)
|
||||
" <<< "$NOTES"
|
||||
echo "Release notes updated from CHANGELOG.md"
|
||||
fi
|
||||
|
||||
- name: Build package and upload
|
||||
id: package
|
||||
run: |
|
||||
@@ -146,55 +212,8 @@ jobs:
|
||||
--token "${{ secrets.MOKOGITEA_TOKEN }}" --api-base "$API_BASE" \
|
||||
--repo "${GITEA_REPO}" --output /tmp || true
|
||||
|
||||
- name: Update updates.xml
|
||||
if: steps.platform.outputs.platform == 'joomla'
|
||||
run: |
|
||||
VERSION="${{ steps.meta.outputs.version }}"
|
||||
STABILITY="${{ steps.meta.outputs.stability }}"
|
||||
SHA256="${{ steps.package.outputs.sha256_zip }}"
|
||||
|
||||
if [ ! -f "updates.xml" ]; then
|
||||
echo "No updates.xml -- skipping"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
SHA_FLAG=""
|
||||
[ -n "$SHA256" ] && SHA_FLAG="--sha ${SHA256}"
|
||||
|
||||
php ${MOKO_CLI}/updates_xml_build.php \
|
||||
--path . --version "${VERSION}" --stability "${STABILITY}" \
|
||||
--gitea-url "${GITEA_URL}" --org "${GITEA_ORG}" --repo "${GITEA_REPO}" \
|
||||
${SHA_FLAG}
|
||||
|
||||
# Commit and push
|
||||
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}" -- updates.xml 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
|
||||
# updates.xml is generated dynamically by MokoGitea license server
|
||||
# No need to build, commit, or sync updates.xml from workflows
|
||||
|
||||
- name: "Delete lesser pre-release channels (cascade)"
|
||||
continue-on-error: true
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -6,7 +6,7 @@
|
||||
# DEFGROUP: Gitea.Workflow
|
||||
# INGROUP: moko-platform.Security
|
||||
# REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform
|
||||
# PATH: /.gitea/workflows/security-audit.yml
|
||||
# PATH: /.mokogitea/workflows/security-audit.yml
|
||||
# VERSION: 09.23.00
|
||||
# BRIEF: Dependency vulnerability scanning for composer and npm packages
|
||||
|
||||
|
||||
@@ -1,302 +0,0 @@
|
||||
# Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
|
||||
#
|
||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||
#
|
||||
# FILE INFORMATION
|
||||
# DEFGROUP: Gitea.Workflow
|
||||
# INGROUP: moko-platform.Universal
|
||||
# REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform
|
||||
# PATH: /templates/workflows/update-server.yml
|
||||
# VERSION: 09.23.00
|
||||
# BRIEF: Pre-release build + update server XML for dev/alpha/beta/rc branches
|
||||
#
|
||||
# Thin wrapper around moko-platform CLI tools.
|
||||
# Builds packages, updates updates.xml, and optionally deploys via SFTP.
|
||||
#
|
||||
# Joomla filters update entries by the user's "Minimum Stability" setting.
|
||||
|
||||
name: "Update Server"
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- 'dev'
|
||||
- 'dev/**'
|
||||
- 'alpha/**'
|
||||
- 'beta/**'
|
||||
- 'rc/**'
|
||||
paths:
|
||||
- 'src/**'
|
||||
- 'htdocs/**'
|
||||
pull_request:
|
||||
types: [closed]
|
||||
branches:
|
||||
- 'dev'
|
||||
- 'dev/**'
|
||||
- 'alpha/**'
|
||||
- 'beta/**'
|
||||
- 'rc/**'
|
||||
paths:
|
||||
- 'src/**'
|
||||
- 'htdocs/**'
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
stability:
|
||||
description: 'Stability tag'
|
||||
required: true
|
||||
default: 'development'
|
||||
type: choice
|
||||
options:
|
||||
- development
|
||||
- alpha
|
||||
- beta
|
||||
- rc
|
||||
- stable
|
||||
|
||||
env:
|
||||
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
|
||||
GITEA_URL: ${{ vars.GITEA_URL || 'https://git.mokoconsulting.tech' }}
|
||||
GITEA_ORG: ${{ vars.GITEA_ORG || github.repository_owner }}
|
||||
GITEA_REPO: ${{ vars.GITEA_REPO || github.event.repository.name }}
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
jobs:
|
||||
update-xml:
|
||||
name: Update Server
|
||||
runs-on: release
|
||||
if: >-
|
||||
github.event.pull_request.merged == true || github.event_name == 'workflow_dispatch' || github.event_name == 'push'
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
token: ${{ secrets.MOKOGITEA_TOKEN }}
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Setup moko-platform tools
|
||||
env:
|
||||
MOKO_CLONE_TOKEN: ${{ secrets.MOKOGITEA_TOKEN }}
|
||||
MOKO_CLONE_HOST: git.mokoconsulting.tech/MokoConsulting
|
||||
COMPOSER_AUTH: '{"http-basic":{"git.mokoconsulting.tech":{"username":"token","password":"${{ secrets.MOKOGITEA_TOKEN }}"}}}'
|
||||
run: |
|
||||
if ! command -v composer &> /dev/null; then
|
||||
sudo apt-get update -qq && sudo apt-get install -y -qq php-cli php-mbstring php-xml php-zip php-curl composer >/dev/null 2>&1
|
||||
fi
|
||||
# Always fetch latest CLI tools — never use stale cache from previous runs
|
||||
rm -rf /tmp/moko-platform
|
||||
git clone --depth 1 --branch main --quiet \
|
||||
"https://x-access-token:${MOKO_CLONE_TOKEN}@${MOKO_CLONE_HOST}/moko-platform.git" \
|
||||
/tmp/moko-platform 2>/dev/null || true
|
||||
if [ -d "/tmp/moko-platform" ] && [ -f "/tmp/moko-platform/composer.json" ]; then
|
||||
cd /tmp/moko-platform && composer install --no-dev --no-interaction --quiet 2>/dev/null || true
|
||||
fi
|
||||
echo "MOKO_CLI=/tmp/moko-platform/cli" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Detect platform
|
||||
id: platform
|
||||
run: php ${MOKO_CLI}/manifest_read.php --path . --github-output
|
||||
|
||||
- name: Resolve stability and bump version
|
||||
id: meta
|
||||
run: |
|
||||
BRANCH="${{ github.ref_name }}"
|
||||
|
||||
# Configure git for bot pushes
|
||||
git config --local user.email "gitea-actions[bot]@mokoconsulting.tech"
|
||||
git config --local user.name "gitea-actions[bot]"
|
||||
git remote set-url origin "https://x-access-token:${{ secrets.MOKOGITEA_TOKEN }}@git.mokoconsulting.tech/${{ github.repository }}.git"
|
||||
|
||||
# Determine stability from branch or manual input
|
||||
if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
|
||||
STABILITY="${{ inputs.stability }}"
|
||||
elif [[ "$BRANCH" == rc/* ]]; then
|
||||
STABILITY="rc"
|
||||
elif [[ "$BRANCH" == beta/* ]]; then
|
||||
STABILITY="beta"
|
||||
elif [[ "$BRANCH" == alpha/* ]]; then
|
||||
STABILITY="alpha"
|
||||
else
|
||||
STABILITY="development"
|
||||
fi
|
||||
|
||||
# Gitea release tag per stability
|
||||
case "$STABILITY" in
|
||||
development) TAG="development" ;;
|
||||
alpha) TAG="alpha" ;;
|
||||
beta) TAG="beta" ;;
|
||||
rc) TAG="release-candidate" ;;
|
||||
*) TAG="stable" ;;
|
||||
esac
|
||||
|
||||
# Bump patch, set platform suffix, fix consistency — version_bump preserves suffix
|
||||
php ${MOKO_CLI}/version_set_platform.php \
|
||||
--path . --version "$(php ${MOKO_CLI}/version_read.php --path . 2>/dev/null || echo '00.00.01')" \
|
||||
--branch "$BRANCH" --stability "$STABILITY" 2>/dev/null || true
|
||||
php ${MOKO_CLI}/version_bump.php --path . 2>/dev/null || true
|
||||
php ${MOKO_CLI}/version_check.php --path . --fix 2>/dev/null || true
|
||||
|
||||
# Read final version (includes suffix, e.g. 01.02.15-dev)
|
||||
VERSION=$(php ${MOKO_CLI}/version_read.php --path . 2>/dev/null || echo "00.00.01")
|
||||
|
||||
echo "version=${VERSION}" >> "$GITHUB_OUTPUT"
|
||||
echo "stability=${STABILITY}" >> "$GITHUB_OUTPUT"
|
||||
echo "tag=${TAG}" >> "$GITHUB_OUTPUT"
|
||||
|
||||
# Commit version bump if changed
|
||||
git add -A
|
||||
git diff --cached --quiet || {
|
||||
git commit -m "chore(version): auto-bump ${VERSION} [skip ci]" \
|
||||
--author="gitea-actions[bot] <gitea-actions[bot]@mokoconsulting.tech>"
|
||||
git push
|
||||
}
|
||||
|
||||
- name: Create release and upload package
|
||||
id: package
|
||||
run: |
|
||||
VERSION="${{ steps.meta.outputs.version }}"
|
||||
TAG="${{ steps.meta.outputs.tag }}"
|
||||
API_BASE="${GITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}"
|
||||
|
||||
# Create or update Gitea release
|
||||
php ${MOKO_CLI}/release_create.php \
|
||||
--path . --version "$VERSION" --tag "$TAG" \
|
||||
--token "${{ secrets.MOKOGITEA_TOKEN }}" --api-base "$API_BASE" \
|
||||
--repo "${GITEA_REPO}" --branch "${{ github.ref_name }}" --prerelease
|
||||
|
||||
# Build package and upload
|
||||
php ${MOKO_CLI}/release_package.php \
|
||||
--path . --version "$VERSION" --tag "$TAG" \
|
||||
--token "${{ secrets.MOKOGITEA_TOKEN }}" --api-base "$API_BASE" \
|
||||
--repo "${GITEA_REPO}" --output /tmp || true
|
||||
|
||||
- name: Update updates.xml
|
||||
if: steps.platform.outputs.platform == 'joomla'
|
||||
run: |
|
||||
VERSION="${{ steps.meta.outputs.version }}"
|
||||
STABILITY="${{ steps.meta.outputs.stability }}"
|
||||
SHA256="${{ steps.package.outputs.sha256_zip }}"
|
||||
|
||||
if [ ! -f "updates.xml" ]; then
|
||||
echo "No updates.xml — skipping"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
SHA_FLAG=""
|
||||
[ -n "$SHA256" ] && SHA_FLAG="--sha ${SHA256}"
|
||||
|
||||
php ${MOKO_CLI}/updates_xml_build.php \
|
||||
--path . --version "${VERSION}" --stability "${STABILITY}" \
|
||||
--gitea-url "${GITEA_URL}" --org "${GITEA_ORG}" --repo "${GITEA_REPO}" \
|
||||
${SHA_FLAG}
|
||||
|
||||
# Commit and push updates.xml
|
||||
git add updates.xml
|
||||
git diff --cached --quiet || {
|
||||
git commit -m "chore: update ${STABILITY} channel ${VERSION} [skip ci]"
|
||||
git push
|
||||
}
|
||||
|
||||
- name: Sync updates.xml to main
|
||||
if: github.ref_name != 'main' && steps.platform.outputs.platform == 'joomla'
|
||||
run: |
|
||||
API_BASE="${GITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}"
|
||||
GITEA_TOKEN="${{ secrets.MOKOGITEA_TOKEN }}"
|
||||
|
||||
FILE_SHA=$(curl -sf -H "Authorization: token ${GITEA_TOKEN}" \
|
||||
"${API_BASE}/contents/updates.xml?ref=main" | python3 -c "import sys,json; print(json.load(sys.stdin).get('sha',''))" 2>/dev/null || true)
|
||||
|
||||
if [ -n "$FILE_SHA" ] && [ -f "updates.xml" ]; then
|
||||
python3 -c "
|
||||
import base64, json, urllib.request, sys
|
||||
with open('updates.xml', 'rb') as f:
|
||||
content = base64.b64encode(f.read()).decode()
|
||||
payload = json.dumps({
|
||||
'content': content,
|
||||
'sha': '${FILE_SHA}',
|
||||
'message': 'chore: sync updates.xml from ${{ steps.meta.outputs.stability }} [skip ci]',
|
||||
'branch': 'main'
|
||||
}).encode()
|
||||
req = urllib.request.Request(
|
||||
'${API_BASE}/contents/updates.xml',
|
||||
data=payload, method='PUT',
|
||||
headers={
|
||||
'Authorization': 'token ${GITEA_TOKEN}',
|
||||
'Content-Type': 'application/json'
|
||||
})
|
||||
try:
|
||||
urllib.request.urlopen(req)
|
||||
print('updates.xml synced to main')
|
||||
except Exception as e:
|
||||
print(f'WARNING: sync to main failed: {e}', file=sys.stderr)
|
||||
"
|
||||
fi
|
||||
|
||||
- name: SFTP deploy to dev server
|
||||
if: contains(github.ref, 'dev/') || github.ref == 'refs/heads/dev'
|
||||
env:
|
||||
DEV_HOST: ${{ vars.DEV_FTP_HOST }}
|
||||
DEV_PATH: ${{ vars.DEV_FTP_PATH }}
|
||||
DEV_SUFFIX: ${{ vars.DEV_FTP_SUFFIX }}
|
||||
DEV_USER: ${{ vars.DEV_FTP_USERNAME }}
|
||||
DEV_PORT: ${{ vars.DEV_FTP_PORT }}
|
||||
DEV_KEY: ${{ secrets.DEV_FTP_KEY }}
|
||||
DEV_PASS: ${{ secrets.DEV_FTP_PASSWORD }}
|
||||
run: |
|
||||
# Permission check: admin or maintain role required
|
||||
ACTOR="${{ github.actor }}"
|
||||
API_BASE="${GITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}"
|
||||
|
||||
PERMISSION=$(curl -sf -H "Authorization: token ${{ secrets.MOKOGITEA_TOKEN }}" \
|
||||
"${API_BASE}/collaborators/${ACTOR}/permission" 2>/dev/null | \
|
||||
python3 -c "import sys,json; print(json.load(sys.stdin).get('permission','read'))" 2>/dev/null || echo "read")
|
||||
case "$PERMISSION" in
|
||||
admin|maintain|write) ;;
|
||||
*)
|
||||
echo "Deploy denied: ${ACTOR} has '${PERMISSION}' — requires admin, maintain, or write"
|
||||
exit 0
|
||||
;;
|
||||
esac
|
||||
|
||||
[ -z "$DEV_HOST" ] || [ -z "$DEV_PATH" ] && { echo "DEV FTP not configured — skipping SFTP"; exit 0; }
|
||||
|
||||
SOURCE_DIR="src"
|
||||
[ ! -d "$SOURCE_DIR" ] && SOURCE_DIR="htdocs"
|
||||
[ ! -d "$SOURCE_DIR" ] && exit 0
|
||||
|
||||
PORT="${DEV_PORT:-22}"
|
||||
REMOTE="${DEV_PATH%/}"
|
||||
[ -n "$DEV_SUFFIX" ] && REMOTE="${REMOTE}/${DEV_SUFFIX#/}"
|
||||
|
||||
printf '{"host":"%s","port":%s,"username":"%s","remotePath":"%s"' \
|
||||
"$DEV_HOST" "$PORT" "$DEV_USER" "$REMOTE" > /tmp/sftp-config.json
|
||||
if [ -n "$DEV_KEY" ]; then
|
||||
echo "$DEV_KEY" > /tmp/deploy_key && chmod 600 /tmp/deploy_key
|
||||
printf ',"privateKeyPath":"/tmp/deploy_key"}' >> /tmp/sftp-config.json
|
||||
else
|
||||
printf ',"password":"%s"}' "$DEV_PASS" >> /tmp/sftp-config.json
|
||||
fi
|
||||
|
||||
PLATFORM=$(php ${MOKO_CLI}/platform_detect.php --path . 2>/dev/null || true)
|
||||
if [ "$PLATFORM" = "waas-component" ] && [ -f "${MOKO_CLI}/../deploy/deploy-joomla.php" ]; then
|
||||
php ${MOKO_CLI}/../deploy/deploy-joomla.php --path . --src-dir "$SOURCE_DIR" --config /tmp/sftp-config.json
|
||||
elif [ -f "${MOKO_CLI}/../deploy/deploy-sftp.php" ]; then
|
||||
php ${MOKO_CLI}/../deploy/deploy-sftp.php --path . --src-dir "$SOURCE_DIR" --config /tmp/sftp-config.json
|
||||
fi
|
||||
rm -f /tmp/deploy_key /tmp/sftp-config.json
|
||||
echo "SFTP deploy to dev complete" >> $GITHUB_STEP_SUMMARY
|
||||
|
||||
- name: Summary
|
||||
if: always()
|
||||
run: |
|
||||
VERSION="${{ steps.meta.outputs.version }}"
|
||||
STABILITY="${{ steps.meta.outputs.stability }}"
|
||||
DISPLAY="${VERSION}"
|
||||
echo "## Update Server" >> $GITHUB_STEP_SUMMARY
|
||||
echo "" >> $GITHUB_STEP_SUMMARY
|
||||
echo "| Field | Value |" >> $GITHUB_STEP_SUMMARY
|
||||
echo "|-------|-------|" >> $GITHUB_STEP_SUMMARY
|
||||
echo "| Stability | \`${STABILITY}\` |" >> $GITHUB_STEP_SUMMARY
|
||||
echo "| Version | \`${DISPLAY}\` |" >> $GITHUB_STEP_SUMMARY
|
||||
+6
-11
@@ -10,13 +10,14 @@ BRIEF: Release changelog
|
||||
-->
|
||||
|
||||
# Changelog
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
|
||||
## [09.23.00] --- 2026-05-31
|
||||
## [09.25.00] --- 2026-06-04
|
||||
|
||||
## [09.22.00] --- 2026-05-31
|
||||
## [09.23] --- 2026-05-31
|
||||
|
||||
## [09.22] --- 2026-05-31
|
||||
|
||||
### Changed
|
||||
- **refactor(cli):** migrate 64 legacy scripts to CliFramework (#235) — all tools in cli/, automation/, maintenance/, deploy/, release/ now extend CliFramework with free --help, --verbose, --quiet, --dry-run, --json, banners, and coloured logging
|
||||
@@ -28,12 +29,6 @@ BRIEF: Release changelog
|
||||
- fix: remove lesser stream copies, each stream updates independently
|
||||
- fix: sort updates.xml entries dev first, stable last
|
||||
|
||||
## [09.21.00] --- 2026-05-30
|
||||
## [09.21] --- 2026-05-30
|
||||
|
||||
## [09.20.00] --- 2026-05-30
|
||||
|
||||
## [09.19.00] --- 2026-05-30
|
||||
|
||||
## [09.18.00] --- 2026-05-30
|
||||
|
||||
## [09.17.00] --- 2026-05-30
|
||||
## [09.20] --- 2026-05-30
|
||||
|
||||
@@ -1,102 +0,0 @@
|
||||
# CLAUDE.md
|
||||
|
||||
This file provides guidance to Claude Code when working with this repository.
|
||||
|
||||
## Project Overview
|
||||
|
||||
**moko-platform** — Enterprise automation, validation, sync, and governance engine for all Moko Consulting repositories
|
||||
|
||||
| Field | Value |
|
||||
|---|---|
|
||||
| **Language** | PHP 8.1+ |
|
||||
| **Default branch** | main |
|
||||
| **License** | GPL-3.0-or-later |
|
||||
| **Version** | 09.01.00 |
|
||||
| **Wiki** | [moko-platform Wiki](https://git.mokoconsulting.tech/MokoConsulting/moko-platform/wiki) |
|
||||
|
||||
## Common Commands
|
||||
|
||||
```bash
|
||||
composer install # Install PHP dependencies
|
||||
php bin/moko health --path . # Run repo health check
|
||||
php bin/moko check:syntax --path . # PHP syntax check
|
||||
php bin/moko drift --org MokoConsulting # Scan for standards drift
|
||||
php bin/moko dashboard --token $TOKEN -o dashboard.html # Generate client dashboard
|
||||
|
||||
# Code quality
|
||||
php vendor/bin/phpcs --standard=phpcs.xml -n lib/ validate/ automation/ cli/
|
||||
php vendor/bin/phpcbf --standard=phpcs.xml lib/ validate/ automation/ cli/
|
||||
php vendor/bin/phpstan analyse -c phpstan.neon --memory-limit=512M
|
||||
|
||||
# Run all checks
|
||||
composer check
|
||||
```
|
||||
|
||||
## Architecture
|
||||
|
||||
### Directory Layout
|
||||
|
||||
| Directory | Purpose |
|
||||
|-----------|---------|
|
||||
| `cli/` | 32 standalone CLI tools (version, release, build, repo management) |
|
||||
| `validate/` | 20 validation scripts (syntax, structure, manifests, drift) |
|
||||
| `automation/` | 7 bulk operations (sync, push files, templates, cleanup) |
|
||||
| `lib/Enterprise/` | Core library — CliFramework, ApiClient, adapters, validators |
|
||||
| `lib/Enterprise/Plugins/` | 11 platform plugins (Joomla, Dolibarr, Node.js, Python, etc.) |
|
||||
| `deploy/` | SFTP deployment scripts (Joomla, Dolibarr, health checks) |
|
||||
| `templates/` | Universal templates, configs, governance schema |
|
||||
| `.mokogitea/workflows/` | CI/CD workflows (Gitea Actions) |
|
||||
| `bin/moko` | Unified CLI dispatcher — runs any tool via `php bin/moko <command>` |
|
||||
|
||||
### CLI Framework
|
||||
|
||||
All CLI tools extend `MokoEnterprise\CliFramework` (defined in `lib/Enterprise/CliFramework.php`).
|
||||
|
||||
Pattern for new tools:
|
||||
```php
|
||||
class MyTool extends CliFramework {
|
||||
protected function configure(): void {
|
||||
$this->setDescription('What this tool does');
|
||||
$this->addArgument('--name', 'Description', 'default');
|
||||
}
|
||||
protected function run(): int {
|
||||
$name = $this->getArgument('--name');
|
||||
// ... business logic ...
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
$app = new MyTool();
|
||||
exit($app->execute());
|
||||
```
|
||||
|
||||
Built-in flags: `--help`, `--verbose`, `--quiet`, `--dry-run`
|
||||
|
||||
### Platform Adapters
|
||||
|
||||
Git operations are abstracted via `GitPlatformAdapter` interface:
|
||||
- `MokoGiteaAdapter` — for git.mokoconsulting.tech (primary)
|
||||
- `GitHubAdapter` — for github.com mirrors
|
||||
|
||||
### Plugin System
|
||||
|
||||
Platform-specific logic lives in `lib/Enterprise/Plugins/`. Each plugin implements `ProjectPluginInterface` with methods for health checks, validation, build commands, and config schemas.
|
||||
|
||||
## Code Quality
|
||||
|
||||
| Tool | Level | Config |
|
||||
|------|-------|--------|
|
||||
| PHPCS | PSR-12 (errors only) | `phpcs.xml` |
|
||||
| PHPStan | Level 2 | `phpstan.neon` |
|
||||
|
||||
PHPStan runs with `--memory-limit=512M` due to large codebase. CI enforces PHPCS errors; PHPStan is advisory (`continue-on-error`).
|
||||
|
||||
## Rules
|
||||
|
||||
- **Workflow directory**: `.mokogitea/` (not `.gitea/` or `.github/`)
|
||||
- **Never commit** `.claude/`, `.mcp.json`, `TODO.md`, or `*.min.css`/`*.min.js`
|
||||
- **Attribution**: use `Authored-by: Moko Consulting` in commits
|
||||
- **Branch strategy**: develop on `dev`, merge to `main` for release
|
||||
- **Minification**: handled at build time (CI) and runtime (MokoMinifyHelper for Joomla templates)
|
||||
- **Wiki**: documentation lives in the Gitea wiki, not in `docs/` files
|
||||
- **New CLI tools**: extend `CliFramework`, not `CLIApp` (legacy)
|
||||
- **After adding a CLI tool**: register it in `bin/moko` COMMAND_MAP
|
||||
@@ -6,7 +6,7 @@ DEFGROUP: MokoPlatform.Root
|
||||
INGROUP: MokoPlatform
|
||||
REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform
|
||||
PATH: /README.md
|
||||
VERSION: 09.23.00
|
||||
VERSION: 09.25.00
|
||||
BRIEF: Project overview and documentation
|
||||
-->
|
||||
|
||||
|
||||
@@ -0,0 +1,237 @@
|
||||
#!/usr/bin/env bash
|
||||
# ============================================================================
|
||||
# Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
|
||||
#
|
||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||
#
|
||||
# FILE INFORMATION
|
||||
# DEFGROUP: Automation.CI
|
||||
# INGROUP: moko-platform.Automation
|
||||
# REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform
|
||||
# PATH: /automation/ci-issue-reporter.sh
|
||||
# VERSION: 09.23.00
|
||||
# BRIEF: Creates or updates a Gitea issue when a CI gate fails.
|
||||
# Deduplicates by searching open issues with the "ci-auto" label
|
||||
# whose title matches the gate. If a matching issue exists, a comment
|
||||
# is appended instead of opening a duplicate.
|
||||
# ============================================================================
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
# ── Defaults ────────────────────────────────────────────────────────────────
|
||||
GITEA_URL="${GITEA_URL:-https://git.mokoconsulting.tech}"
|
||||
GITEA_TOKEN="${GITEA_TOKEN:-}"
|
||||
REPO="${GITHUB_REPOSITORY:-}"
|
||||
RUN_URL="${GITHUB_SERVER_URL:-${GITEA_URL}}/${REPO}/actions/runs/${GITHUB_RUN_ID:-0}"
|
||||
LABEL_NAME="ci-auto"
|
||||
LABEL_COLOR="#e11d48"
|
||||
|
||||
GATE=""
|
||||
DETAILS=""
|
||||
SEVERITY="error"
|
||||
WORKFLOW=""
|
||||
|
||||
# ── Parse arguments ─────────────────────────────────────────────────────────
|
||||
usage() {
|
||||
cat <<EOF
|
||||
Usage: ci-issue-reporter.sh --gate NAME --details TEXT [OPTIONS]
|
||||
|
||||
Required:
|
||||
--gate CI gate name (e.g. "Code Quality", "Self-Health")
|
||||
--details Human-readable failure description
|
||||
|
||||
Optional:
|
||||
--severity "error" (default) or "warning"
|
||||
--workflow Workflow name for the issue title
|
||||
--repo owner/repo (default: \$GITHUB_REPOSITORY)
|
||||
--run-url URL to the CI run (auto-detected from env)
|
||||
--token Gitea API token (default: \$GITEA_TOKEN)
|
||||
--url Gitea base URL (default: \$GITEA_URL)
|
||||
EOF
|
||||
exit 1
|
||||
}
|
||||
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--gate) GATE="$2"; shift 2 ;;
|
||||
--details) DETAILS="$2"; shift 2 ;;
|
||||
--severity) SEVERITY="$2"; shift 2 ;;
|
||||
--workflow) WORKFLOW="$2"; shift 2 ;;
|
||||
--repo) REPO="$2"; shift 2 ;;
|
||||
--run-url) RUN_URL="$2"; shift 2 ;;
|
||||
--token) GITEA_TOKEN="$2"; shift 2 ;;
|
||||
--url) GITEA_URL="$2"; shift 2 ;;
|
||||
-h|--help) usage ;;
|
||||
*) echo "Unknown option: $1"; usage ;;
|
||||
esac
|
||||
done
|
||||
|
||||
[[ -z "$GATE" ]] && { echo "ERROR: --gate is required"; usage; }
|
||||
[[ -z "$DETAILS" ]] && { echo "ERROR: --details is required"; usage; }
|
||||
[[ -z "$GITEA_TOKEN" ]] && { echo "ERROR: GITEA_TOKEN not set"; exit 1; }
|
||||
[[ -z "$REPO" ]] && { echo "ERROR: GITHUB_REPOSITORY not set"; exit 1; }
|
||||
|
||||
API="${GITEA_URL}/api/v1/repos/${REPO}"
|
||||
|
||||
# ── Build title ─────────────────────────────────────────────────────────────
|
||||
if [[ -n "$WORKFLOW" ]]; then
|
||||
TITLE="[CI] ${WORKFLOW}: ${GATE} failed"
|
||||
else
|
||||
TITLE="[CI] ${GATE} failed"
|
||||
fi
|
||||
|
||||
# ── Ensure label exists ─────────────────────────────────────────────────────
|
||||
ensure_label() {
|
||||
local exists
|
||||
exists=$(curl -sf -o /dev/null -w '%{http_code}' \
|
||||
-H "Authorization: token ${GITEA_TOKEN}" \
|
||||
"${API}/labels" 2>/dev/null || echo "000")
|
||||
|
||||
if [[ "$exists" == "200" ]]; then
|
||||
# Check if label already exists
|
||||
local found
|
||||
found=$(curl -sf \
|
||||
-H "Authorization: token ${GITEA_TOKEN}" \
|
||||
"${API}/labels" 2>/dev/null \
|
||||
| grep -o "\"name\":\"${LABEL_NAME}\"" || true)
|
||||
|
||||
if [[ -z "$found" ]]; then
|
||||
curl -sf -X POST \
|
||||
-H "Authorization: token ${GITEA_TOKEN}" \
|
||||
-H "Content-Type: application/json" \
|
||||
"${API}/labels" \
|
||||
-d "{\"name\":\"${LABEL_NAME}\",\"color\":\"${LABEL_COLOR}\",\"description\":\"Auto-created by CI issue reporter\"}" \
|
||||
> /dev/null 2>&1 || true
|
||||
fi
|
||||
fi
|
||||
}
|
||||
|
||||
# ── Search for existing open issue ──────────────────────────────────────────
|
||||
find_existing_issue() {
|
||||
# URL-encode the gate name for the query
|
||||
local query
|
||||
query=$(printf '%s' "[CI] ${GATE}" | sed 's/ /%20/g; s/\[/%5B/g; s/\]/%5D/g')
|
||||
|
||||
local response
|
||||
response=$(curl -sf \
|
||||
-H "Authorization: token ${GITEA_TOKEN}" \
|
||||
"${API}/issues?type=issues&state=open&labels=${LABEL_NAME}&q=${query}&limit=5" \
|
||||
2>/dev/null || echo "[]")
|
||||
|
||||
# Extract the first matching issue number
|
||||
echo "$response" \
|
||||
| grep -oP '"number":\s*\K[0-9]+' \
|
||||
| head -1
|
||||
}
|
||||
|
||||
# ── Build issue body ────────────────────────────────────────────────────────
|
||||
build_body() {
|
||||
local severity_badge
|
||||
if [[ "$SEVERITY" == "error" ]]; then
|
||||
severity_badge="**Severity:** Error"
|
||||
else
|
||||
severity_badge="**Severity:** Warning"
|
||||
fi
|
||||
|
||||
cat <<BODY
|
||||
## CI Gate Failure: ${GATE}
|
||||
|
||||
${severity_badge}
|
||||
**Workflow:** ${WORKFLOW:-unknown}
|
||||
**Branch:** ${GITHUB_REF_NAME:-unknown}
|
||||
**Commit:** \`${GITHUB_SHA:0:8}\`
|
||||
**Run:** [View CI run](${RUN_URL})
|
||||
|
||||
### Details
|
||||
|
||||
${DETAILS}
|
||||
|
||||
### Resolution
|
||||
|
||||
Fix the issue described above and push a new commit. This issue will be closed automatically when the gate passes, or can be closed manually.
|
||||
|
||||
---
|
||||
*Auto-created by [ci-issue-reporter](${GITEA_URL}/${REPO}/src/branch/main/automation/ci-issue-reporter.sh)*
|
||||
BODY
|
||||
}
|
||||
|
||||
# ── Build comment body (for existing issues) ────────────────────────────────
|
||||
build_comment() {
|
||||
cat <<COMMENT
|
||||
### CI failure recurrence
|
||||
|
||||
**Branch:** ${GITHUB_REF_NAME:-unknown}
|
||||
**Commit:** \`${GITHUB_SHA:0:8}\`
|
||||
**Run:** [View CI run](${RUN_URL})
|
||||
|
||||
${DETAILS}
|
||||
COMMENT
|
||||
}
|
||||
|
||||
# ── Main ────────────────────────────────────────────────────────────────────
|
||||
ensure_label
|
||||
|
||||
EXISTING=$(find_existing_issue)
|
||||
|
||||
if [[ -n "$EXISTING" ]]; then
|
||||
# Append comment to existing issue
|
||||
COMMENT_BODY=$(build_comment)
|
||||
COMMENT_JSON=$(printf '%s' "$COMMENT_BODY" | python3 -c "
|
||||
import sys, json
|
||||
print(json.dumps({'body': sys.stdin.read()}))" 2>/dev/null)
|
||||
|
||||
HTTP=$(curl -sf -o /dev/null -w '%{http_code}' -X POST \
|
||||
-H "Authorization: token ${GITEA_TOKEN}" \
|
||||
-H "Content-Type: application/json" \
|
||||
"${API}/issues/${EXISTING}/comments" \
|
||||
-d "${COMMENT_JSON}" 2>/dev/null || echo "000")
|
||||
|
||||
if [[ "$HTTP" == "201" ]]; then
|
||||
echo "Commented on existing issue #${EXISTING}"
|
||||
else
|
||||
echo "WARNING: Failed to comment on issue #${EXISTING} (HTTP ${HTTP})"
|
||||
fi
|
||||
else
|
||||
# Create new issue
|
||||
ISSUE_BODY=$(build_body)
|
||||
ISSUE_JSON=$(python3 -c "
|
||||
import sys, json
|
||||
body = sys.stdin.read()
|
||||
print(json.dumps({
|
||||
'title': sys.argv[1],
|
||||
'body': body,
|
||||
'labels': []
|
||||
}))" "$TITLE" <<< "$ISSUE_BODY" 2>/dev/null)
|
||||
|
||||
# Create the issue
|
||||
RESPONSE=$(curl -sf -X POST \
|
||||
-H "Authorization: token ${GITEA_TOKEN}" \
|
||||
-H "Content-Type: application/json" \
|
||||
"${API}/issues" \
|
||||
-d "${ISSUE_JSON}" 2>/dev/null || echo "{}")
|
||||
|
||||
ISSUE_NUM=$(echo "$RESPONSE" | grep -oP '"number":\s*\K[0-9]+' | head -1)
|
||||
|
||||
if [[ -n "$ISSUE_NUM" ]]; then
|
||||
# Apply label (separate call — more reliable across Gitea versions)
|
||||
LABEL_ID=$(curl -sf \
|
||||
-H "Authorization: token ${GITEA_TOKEN}" \
|
||||
"${API}/labels" 2>/dev/null \
|
||||
| grep -oP "\"id\":\s*\K[0-9]+(?=[^}]*\"name\":\s*\"${LABEL_NAME}\")" \
|
||||
| head -1 || true)
|
||||
|
||||
if [[ -n "$LABEL_ID" ]]; then
|
||||
curl -sf -X POST \
|
||||
-H "Authorization: token ${GITEA_TOKEN}" \
|
||||
-H "Content-Type: application/json" \
|
||||
"${API}/issues/${ISSUE_NUM}/labels" \
|
||||
-d "{\"labels\":[${LABEL_ID}]}" \
|
||||
> /dev/null 2>&1 || true
|
||||
fi
|
||||
|
||||
echo "Created issue #${ISSUE_NUM}: ${TITLE}"
|
||||
else
|
||||
echo "WARNING: Failed to create issue"
|
||||
echo "Response: ${RESPONSE}"
|
||||
fi
|
||||
fi
|
||||
@@ -230,7 +230,8 @@ class PushFiles extends CliFramework
|
||||
{
|
||||
// Read platform from repo's .mokogitea/manifest.xml via API
|
||||
try {
|
||||
$manifestData = $this->adapter->getFileContent($org, $repo, '.mokogitea/manifest.xml', 'main');
|
||||
$fileInfo = $this->adapter->getFileContents($org, $repo, '.mokogitea/manifest.xml', 'main');
|
||||
$manifestData = isset($fileInfo['content']) ? base64_decode($fileInfo['content']) : '';
|
||||
if (!empty($manifestData)) {
|
||||
$xml = @simplexml_load_string($manifestData);
|
||||
if ($xml !== false) {
|
||||
|
||||
@@ -1,19 +0,0 @@
|
||||
# Build Index: /api/build
|
||||
|
||||
## Purpose
|
||||
|
||||
This folder contains build system management and compilation scripts.
|
||||
|
||||
## Quick Links
|
||||
|
||||
- [README](./README.md) - Build scripts documentation
|
||||
|
||||
## Scripts
|
||||
|
||||
- [moko-make](./moko-make) - Build system wrapper
|
||||
- [resolve_makefile.py](./resolve_makefile.py) - Makefile resolution
|
||||
|
||||
## Metadata
|
||||
|
||||
- **Document Type:** index
|
||||
- **Auto-generated:** This file is manually maintained for ignored directory
|
||||
-112
@@ -1,112 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
# Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
|
||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||
#
|
||||
# Moko Build Wrapper
|
||||
# Automatically finds and uses appropriate Makefile from MokoStandards
|
||||
|
||||
set -e
|
||||
|
||||
# Colors
|
||||
COLOR_RESET="\033[0m"
|
||||
COLOR_GREEN="\033[32m"
|
||||
COLOR_BLUE="\033[34m"
|
||||
COLOR_RED="\033[31m"
|
||||
|
||||
# Find MokoStandards root
|
||||
find_mokostandards() {
|
||||
# Check environment variable
|
||||
if [ -n "$MOKOSTANDARDS_ROOT" ] && [ -d "$MOKOSTANDARDS_ROOT/templates/build" ]; then
|
||||
echo "$MOKOSTANDARDS_ROOT"
|
||||
return 0
|
||||
fi
|
||||
|
||||
# Check adjacent directories
|
||||
if [ -d "../MokoStandards/templates/build" ]; then
|
||||
echo "$(cd ../MokoStandards && pwd)"
|
||||
return 0
|
||||
fi
|
||||
|
||||
if [ -d "../../MokoStandards/templates/build" ]; then
|
||||
echo "$(cd ../../MokoStandards && pwd)"
|
||||
return 0
|
||||
fi
|
||||
|
||||
# Check home directory
|
||||
if [ -d "$HOME/.mokostandards/templates/build" ]; then
|
||||
echo "$HOME/.mokostandards"
|
||||
return 0
|
||||
fi
|
||||
|
||||
# Check system location
|
||||
if [ -d "/opt/mokostandards/templates/build" ]; then
|
||||
echo "/opt/mokostandards"
|
||||
return 0
|
||||
fi
|
||||
|
||||
return 1
|
||||
}
|
||||
|
||||
# Find appropriate Makefile
|
||||
find_makefile() {
|
||||
# Check for local Makefile
|
||||
if [ -f "Makefile" ]; then
|
||||
echo "Makefile"
|
||||
return 0
|
||||
fi
|
||||
|
||||
# Check for .moko/Makefile
|
||||
if [ -f ".moko/Makefile" ]; then
|
||||
echo ".moko/Makefile"
|
||||
return 0
|
||||
fi
|
||||
|
||||
# Find MokoStandards
|
||||
MOKO_ROOT=$(find_mokostandards)
|
||||
if [ $? -ne 0 ]; then
|
||||
echo -e "${COLOR_RED}✗${COLOR_RESET} MokoStandards repository not found" >&2
|
||||
echo -e "${COLOR_BLUE}Hint:${COLOR_RESET} Set MOKOSTANDARDS_ROOT or clone adjacent" >&2
|
||||
return 1
|
||||
fi
|
||||
|
||||
# Detect project type
|
||||
if [ -d "core/modules" ] && ls core/modules/mod*.class.php >/dev/null 2>&1; then
|
||||
echo "$MOKO_ROOT/templates/build/dolibarr/Makefile"
|
||||
return 0
|
||||
fi
|
||||
|
||||
# Check for Joomla XML files
|
||||
shopt -s nullglob # Prevent glob expansion if no matches
|
||||
for xml in *.xml; do
|
||||
if [ -f "$xml" ]; then
|
||||
if grep -q 'type="component"' "$xml" 2>/dev/null; then
|
||||
echo "$MOKO_ROOT/templates/build/joomla/Makefile.component"
|
||||
return 0
|
||||
elif grep -q 'type="module"' "$xml" 2>/dev/null; then
|
||||
echo "$MOKO_ROOT/templates/build/joomla/Makefile.module"
|
||||
return 0
|
||||
elif grep -q 'type="plugin"' "$xml" 2>/dev/null; then
|
||||
echo "$MOKO_ROOT/templates/build/joomla/Makefile.plugin"
|
||||
return 0
|
||||
fi
|
||||
fi
|
||||
done
|
||||
shopt -u nullglob
|
||||
|
||||
echo -e "${COLOR_RED}✗${COLOR_RESET} Could not detect project type" >&2
|
||||
return 1
|
||||
}
|
||||
|
||||
# Main execution
|
||||
MAKEFILE=$(find_makefile)
|
||||
if [ $? -ne 0 ]; then
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Show which Makefile we're using
|
||||
if [[ "$MAKEFILE" == *"MokoStandards"* ]] || [[ "$MAKEFILE" == *".mokostandards"* ]]; then
|
||||
echo -e "${COLOR_BLUE}ℹ${COLOR_RESET} Using MokoStandards template"
|
||||
fi
|
||||
|
||||
# Run make with the found Makefile
|
||||
exec make -f "$MAKEFILE" "$@"
|
||||
@@ -10,7 +10,7 @@
|
||||
* INGROUP: moko-platform
|
||||
* REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform
|
||||
* PATH: /cli/branch_rename.php
|
||||
* VERSION: 09.23.00
|
||||
* VERSION: 09.25.00
|
||||
* BRIEF: Rename a git branch via Gitea API (create new, update PR, delete old)
|
||||
*/
|
||||
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
* INGROUP: moko-platform
|
||||
* REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform
|
||||
* PATH: /cli/bulk_workflow_push.php
|
||||
* VERSION: 09.23.00
|
||||
* VERSION: 09.25.00
|
||||
* BRIEF: Push a workflow file to all governed repos via the Gitea Contents API
|
||||
*/
|
||||
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
* INGROUP: moko-platform
|
||||
* REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform
|
||||
* PATH: /cli/bulk_workflow_trigger.php
|
||||
* VERSION: 09.23.00
|
||||
* VERSION: 09.25.00
|
||||
* BRIEF: Trigger a workflow across multiple repos at once
|
||||
*/
|
||||
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
* INGROUP: moko-platform
|
||||
* REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform
|
||||
* PATH: /cli/client_dashboard.php
|
||||
* VERSION: 09.23.00
|
||||
* VERSION: 09.25.00
|
||||
* BRIEF: Generate unified client dashboard HTML
|
||||
*/
|
||||
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
* INGROUP: moko-platform
|
||||
* REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform
|
||||
* PATH: /cli/client_inventory.php
|
||||
* VERSION: 09.23.00
|
||||
* VERSION: 09.25.00
|
||||
* BRIEF: Discover and list all client-waas repos with their server configuration status
|
||||
*/
|
||||
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
* INGROUP: moko-platform
|
||||
* REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform
|
||||
* PATH: /cli/client_provision.php
|
||||
* VERSION: 09.23.00
|
||||
* VERSION: 09.25.00
|
||||
* BRIEF: Provision a new client environment end-to-end
|
||||
*/
|
||||
|
||||
|
||||
@@ -31,7 +31,7 @@ require_once __DIR__ . '/../vendor/autoload.php';
|
||||
|
||||
require_once __DIR__ . '/../lib/Enterprise/CliFramework.php';
|
||||
|
||||
use MokoEnterprise\CliFramework;
|
||||
use MokoEnterprise\{CliFramework, SourceResolver};
|
||||
use phpseclib3\Net\SFTP;
|
||||
use phpseclib3\Crypt\PublicKeyLoader;
|
||||
|
||||
@@ -866,11 +866,11 @@ class DeployJoomla extends CliFramework
|
||||
}
|
||||
}
|
||||
|
||||
// 3-5. Fallback chain
|
||||
foreach (['src', 'htdocs'] as $candidate) {
|
||||
if (is_dir("{$repoPath}/{$candidate}")) {
|
||||
return "{$repoPath}/{$candidate}";
|
||||
}
|
||||
// 3-5. Fallback chain (source/ → src/ → htdocs/)
|
||||
$resolved = SourceResolver::resolveAbsolute($repoPath);
|
||||
if ($resolved !== null) {
|
||||
SourceResolver::warnIfLegacy($repoPath);
|
||||
return $resolved;
|
||||
}
|
||||
|
||||
// Last resort: repo root itself
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
* INGROUP: moko-platform
|
||||
* REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform
|
||||
* PATH: /cli/grafana_dashboard.php
|
||||
* VERSION: 09.23.00
|
||||
* VERSION: 09.25.00
|
||||
* BRIEF: Manage Grafana dashboards via API
|
||||
*/
|
||||
|
||||
|
||||
+5
-10
@@ -10,7 +10,7 @@
|
||||
* INGROUP: moko-platform
|
||||
* REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform
|
||||
* PATH: /cli/joomla_build.php
|
||||
* VERSION: 09.23.00
|
||||
* VERSION: 09.25.00
|
||||
* BRIEF: Build a Joomla extension ZIP from manifest — all types supported
|
||||
* NOTE: Called by pre-release and auto-release workflows.
|
||||
*/
|
||||
@@ -19,7 +19,7 @@ declare(strict_types=1);
|
||||
|
||||
require_once __DIR__ . '/../lib/Enterprise/CliFramework.php';
|
||||
|
||||
use MokoEnterprise\CliFramework;
|
||||
use MokoEnterprise\{CliFramework, SourceResolver};
|
||||
|
||||
class JoomlaBuildCli extends CliFramework
|
||||
{
|
||||
@@ -49,17 +49,12 @@ class JoomlaBuildCli extends CliFramework
|
||||
$path = realpath($path) ?: $path;
|
||||
|
||||
// ── Find source directory ──────────────────────────────────────────────
|
||||
$srcDir = null;
|
||||
foreach (['src', 'htdocs'] as $d) {
|
||||
if (is_dir("{$path}/{$d}")) {
|
||||
$srcDir = "{$path}/{$d}";
|
||||
break;
|
||||
}
|
||||
}
|
||||
$srcDir = SourceResolver::resolveAbsolute($path);
|
||||
if ($srcDir === null) {
|
||||
$this->log('ERROR', "::error::No src/ or htdocs/ directory in {$path}");
|
||||
$this->log('ERROR', "::error::No source/ or src/ directory in {$path}");
|
||||
return 1;
|
||||
}
|
||||
SourceResolver::warnIfLegacy($path);
|
||||
|
||||
// ── Find manifest ──────────────────────────────────────────────────────
|
||||
$manifest = $this->findManifest($srcDir);
|
||||
|
||||
@@ -25,7 +25,7 @@ declare(strict_types=1);
|
||||
|
||||
require_once __DIR__ . '/../vendor/autoload.php';
|
||||
|
||||
use MokoEnterprise\{ApiClient, AuditLogger, CliFramework, Config, PlatformAdapterFactory};
|
||||
use MokoEnterprise\{ApiClient, AuditLogger, CliFramework, Config, PlatformAdapterFactory, SourceResolver};
|
||||
|
||||
/**
|
||||
* Joomla Release Manager
|
||||
@@ -121,11 +121,12 @@ class JoomlaRelease extends CliFramework
|
||||
$this->log('INFO', "Version: {$displayVersion} | Release tag: {$releaseTag}");
|
||||
|
||||
// ── Step 3: Build packages ────────────────────────────────────
|
||||
$srcDir = is_dir("{$path}/src") ? "{$path}/src" : (is_dir("{$path}/htdocs") ? "{$path}/htdocs" : null);
|
||||
$srcDir = SourceResolver::resolveAbsolute($path);
|
||||
if ($srcDir === null) {
|
||||
$this->log('ERROR', 'No src/ or htdocs/ directory');
|
||||
$this->log('ERROR', 'No source/ or src/ directory');
|
||||
return 1;
|
||||
}
|
||||
SourceResolver::warnIfLegacy($path);
|
||||
|
||||
$prefix = $this->typePrefix($meta);
|
||||
$zipName = "{$prefix}{$meta['element']}-{$displayVersion}.zip";
|
||||
|
||||
@@ -17,7 +17,7 @@ declare(strict_types=1);
|
||||
|
||||
require_once __DIR__ . '/../lib/Enterprise/CliFramework.php';
|
||||
|
||||
use MokoEnterprise\CliFramework;
|
||||
use MokoEnterprise\{CliFramework, SourceResolver};
|
||||
|
||||
class ManifestElementCli extends CliFramework
|
||||
{
|
||||
@@ -48,7 +48,7 @@ class ManifestElementCli extends CliFramework
|
||||
}
|
||||
}
|
||||
$extManifest = null;
|
||||
$manifestFiles = array_merge(glob("{$root}/src/pkg_*.xml") ?: [], glob("{$root}/src/*.xml") ?: [], glob("{$root}/*.xml") ?: []);
|
||||
$manifestFiles = array_merge(SourceResolver::globSource($root, 'pkg_*.xml'), SourceResolver::globSource($root, '*.xml'), glob("{$root}/*.xml") ?: []);
|
||||
foreach ($manifestFiles as $file) {
|
||||
$c = file_get_contents($file);
|
||||
if (strpos($c, '<extension') !== false) {
|
||||
@@ -58,8 +58,7 @@ class ManifestElementCli extends CliFramework
|
||||
}
|
||||
$modFile = null;
|
||||
$modFiles = array_merge(
|
||||
glob("{$root}/src/core/modules/mod*.class.php") ?: [],
|
||||
glob("{$root}/htdocs/core/modules/mod*.class.php") ?: [],
|
||||
SourceResolver::globSource($root, 'core/modules/mod*.class.php'),
|
||||
glob("{$root}/core/modules/mod*.class.php") ?: []
|
||||
);
|
||||
foreach ($modFiles as $file) {
|
||||
|
||||
@@ -0,0 +1,280 @@
|
||||
#!/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/manifest_licensing.php
|
||||
* VERSION: 01.00.00
|
||||
* BRIEF: Ensure licensing tags (updateservers, dlid) in Joomla extension manifests
|
||||
*/
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
require_once __DIR__ . '/../lib/Enterprise/CliFramework.php';
|
||||
|
||||
use MokoEnterprise\{CliFramework, SourceResolver};
|
||||
|
||||
/**
|
||||
* Reads the <licensing> block from .mokogitea/manifest.xml and ensures that the
|
||||
* Joomla extension manifest contains the correct <updateservers> and <dlid> tags.
|
||||
*
|
||||
* manifest.xml licensing block example:
|
||||
*
|
||||
* <licensing>
|
||||
* <enabled>true</enabled>
|
||||
* <dlid>true</dlid>
|
||||
* <update-server>https://git.mokoconsulting.tech/{org}/{repo}/updates.xml</update-server>
|
||||
* <update-server-name>MyExtension Updates</update-server-name>
|
||||
* </licensing>
|
||||
*
|
||||
* Supports {org} and {repo} placeholders in update-server URL, resolved from
|
||||
* the manifest's <identity> block or git remote.
|
||||
*/
|
||||
class ManifestLicensingCli extends CliFramework
|
||||
{
|
||||
protected function configure(): void
|
||||
{
|
||||
$this->setDescription('Ensure licensing tags (updateservers, dlid) in Joomla extension manifests');
|
||||
$this->addArgument('--path', 'Repository root path', '.');
|
||||
$this->addArgument('--fix', 'Apply fixes (default: dry-run check only)', false);
|
||||
$this->addArgument('--github-output', 'Write results to $GITHUB_OUTPUT', false);
|
||||
}
|
||||
|
||||
protected function run(): int
|
||||
{
|
||||
$root = realpath($this->getArgument('--path')) ?: $this->getArgument('--path');
|
||||
$fix = (bool) $this->getArgument('--fix');
|
||||
$ghOutput = (bool) $this->getArgument('--github-output');
|
||||
|
||||
// ── 1. Read manifest.xml ──────────────────────────────────────────
|
||||
$manifestFile = "{$root}/.mokogitea/manifest.xml";
|
||||
|
||||
if (!file_exists($manifestFile)) {
|
||||
$this->log('WARN', "No manifest.xml found at {$manifestFile}");
|
||||
$this->outputResult($ghOutput, 'skipped', 'No manifest.xml');
|
||||
return 0;
|
||||
}
|
||||
|
||||
$xml = @simplexml_load_file($manifestFile);
|
||||
|
||||
if ($xml === false) {
|
||||
$this->log('ERROR', "Failed to parse {$manifestFile}");
|
||||
return 1;
|
||||
}
|
||||
|
||||
// ── 2. Check if licensing is enabled ──────────────────────────────
|
||||
if (!isset($xml->licensing) || (string) ($xml->licensing->enabled ?? '') !== 'true') {
|
||||
$this->log('INFO', 'Licensing not enabled in manifest.xml — skipping');
|
||||
$this->outputResult($ghOutput, 'skipped', 'Licensing not enabled');
|
||||
return 0;
|
||||
}
|
||||
|
||||
$licensingNode = $xml->licensing;
|
||||
$dlidEnabled = ((string) ($licensingNode->dlid ?? 'true')) === 'true';
|
||||
$updateServerUrl = (string) ($licensingNode->{'update-server'} ?? '');
|
||||
$updateServerName = (string) ($licensingNode->{'update-server-name'} ?? '');
|
||||
|
||||
// ── 3. Resolve placeholders ───────────────────────────────────────
|
||||
$org = (string) ($xml->identity->org ?? '');
|
||||
$repo = (string) ($xml->identity->name ?? '');
|
||||
|
||||
// Fallback to git remote if manifest doesn't have org/name
|
||||
if (empty($org) || empty($repo)) {
|
||||
$remote = trim((string) @shell_exec("cd " . escapeshellarg($root) . " && git remote get-url origin 2>/dev/null"));
|
||||
|
||||
if (preg_match('#[/:]([^/]+)/([^/.]+?)(?:\.git)?$#', $remote, $m)) {
|
||||
if (empty($org)) {
|
||||
$org = $m[1];
|
||||
}
|
||||
if (empty($repo)) {
|
||||
$repo = $m[2];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Default update server URL if not specified
|
||||
if (empty($updateServerUrl) && !empty($org) && !empty($repo)) {
|
||||
$updateServerUrl = "https://git.mokoconsulting.tech/{$org}/{$repo}/updates.xml";
|
||||
}
|
||||
|
||||
// Resolve {org} and {repo} placeholders
|
||||
$updateServerUrl = str_replace(['{org}', '{repo}'], [$org, $repo], $updateServerUrl);
|
||||
|
||||
// Default server name from display-name or repo name
|
||||
if (empty($updateServerName)) {
|
||||
$displayName = (string) ($xml->identity->{'display-name'} ?? $repo);
|
||||
$updateServerName = $displayName . ' Updates';
|
||||
}
|
||||
|
||||
if (empty($updateServerUrl)) {
|
||||
$this->log('ERROR', 'Cannot determine update server URL — set <update-server> in manifest.xml or ensure org/repo are available');
|
||||
return 1;
|
||||
}
|
||||
|
||||
$this->log('INFO', "Licensing enabled — org={$org}, repo={$repo}");
|
||||
$this->log('INFO', "Update server: {$updateServerUrl}");
|
||||
$this->log('INFO', "DLID required: " . ($dlidEnabled ? 'yes' : 'no'));
|
||||
|
||||
// ── 4. Find Joomla extension manifests ────────────────────────────
|
||||
$xmlFiles = array_merge(
|
||||
SourceResolver::globSource($root, '*.xml'),
|
||||
SourceResolver::globSource($root, 'packages/*/*.xml'),
|
||||
glob("{$root}/*.xml") ?: []
|
||||
);
|
||||
|
||||
$packageManifest = null;
|
||||
|
||||
foreach ($xmlFiles as $file) {
|
||||
$content = file_get_contents($file);
|
||||
|
||||
if (!str_contains($content, '<extension')) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Find the package manifest (type="package") or the main extension manifest
|
||||
if (str_contains($content, 'type="package"')) {
|
||||
$packageManifest = $file;
|
||||
break;
|
||||
}
|
||||
|
||||
// Fallback: first extension manifest found
|
||||
if ($packageManifest === null) {
|
||||
$packageManifest = $file;
|
||||
}
|
||||
}
|
||||
|
||||
if ($packageManifest === null) {
|
||||
$this->log('WARN', 'No Joomla extension manifest found');
|
||||
$this->outputResult($ghOutput, 'skipped', 'No extension manifest');
|
||||
return 0;
|
||||
}
|
||||
|
||||
$relPath = str_replace($root . '/', '', str_replace('\\', '/', $packageManifest));
|
||||
$this->log('INFO', "Package manifest: {$relPath}");
|
||||
|
||||
// ── 5. Check and fix the manifest ─────────────────────────────────
|
||||
$content = file_get_contents($packageManifest);
|
||||
$original = $content;
|
||||
$changes = [];
|
||||
|
||||
// --- 5a. Ensure <updateservers> block with correct URL ---
|
||||
if (preg_match('#<updateservers>\s*</updateservers>#s', $content)) {
|
||||
// Empty updateservers block — inject the server
|
||||
$replacement = "<updateservers>\n"
|
||||
. " <server type=\"extension\" name=\"{$updateServerName}\">{$updateServerUrl}</server>\n"
|
||||
. " </updateservers>";
|
||||
$content = preg_replace('#<updateservers>\s*</updateservers>#s', $replacement, $content);
|
||||
$changes[] = 'Added update server URL to empty <updateservers>';
|
||||
} elseif (!str_contains($content, '<updateservers>')) {
|
||||
// No updateservers at all — add before </extension>
|
||||
$serverBlock = "\n <updateservers>\n"
|
||||
. " <server type=\"extension\" name=\"{$updateServerName}\">{$updateServerUrl}</server>\n"
|
||||
. " </updateservers>\n";
|
||||
$content = str_replace('</extension>', $serverBlock . '</extension>', $content);
|
||||
$changes[] = 'Added <updateservers> block';
|
||||
} else {
|
||||
// updateservers exists — verify URL is correct
|
||||
if (preg_match('#<server[^>]*>([^<]+)</server>#', $content, $m)) {
|
||||
if ($m[1] !== $updateServerUrl) {
|
||||
$content = preg_replace(
|
||||
'#(<server[^>]*>)[^<]+(</server>)#',
|
||||
"\${1}{$updateServerUrl}\${2}",
|
||||
$content
|
||||
);
|
||||
$changes[] = "Updated server URL: {$m[1]} → {$updateServerUrl}";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- 5b. Ensure <dlid> tag if required ---
|
||||
if ($dlidEnabled) {
|
||||
if (!str_contains($content, '<dlid')) {
|
||||
// Add before <updateservers> if present, otherwise before </extension>
|
||||
$dlidTag = ' <dlid prefix="dlid=" suffix=""/>' . "\n";
|
||||
|
||||
if (str_contains($content, '<updateservers>')) {
|
||||
$content = str_replace('<updateservers>', $dlidTag . "\n <updateservers>", $content);
|
||||
} else {
|
||||
$content = str_replace('</extension>', $dlidTag . '</extension>', $content);
|
||||
}
|
||||
|
||||
$changes[] = 'Added <dlid> tag';
|
||||
}
|
||||
}
|
||||
|
||||
// --- 5c. Ensure <blockChildUninstall> for packages ---
|
||||
if (str_contains($content, 'type="package"') && !str_contains($content, '<blockChildUninstall>')) {
|
||||
$blockTag = ' <blockChildUninstall>true</blockChildUninstall>' . "\n";
|
||||
|
||||
if (str_contains($content, '<dlid')) {
|
||||
// Add after <dlid>
|
||||
$content = preg_replace(
|
||||
'#(<dlid[^/]*/>\s*\n)#',
|
||||
"\${1}{$blockTag}",
|
||||
$content
|
||||
);
|
||||
} elseif (str_contains($content, '<updateservers>')) {
|
||||
$content = str_replace('<updateservers>', $blockTag . "\n <updateservers>", $content);
|
||||
} else {
|
||||
$content = str_replace('</extension>', $blockTag . '</extension>', $content);
|
||||
}
|
||||
|
||||
$changes[] = 'Added <blockChildUninstall>true</blockChildUninstall>';
|
||||
}
|
||||
|
||||
// ── 6. Report and apply ───────────────────────────────────────────
|
||||
if (empty($changes)) {
|
||||
$this->log('INFO', 'All licensing tags are correct — no changes needed');
|
||||
$this->outputResult($ghOutput, 'ok', 'No changes needed');
|
||||
return 0;
|
||||
}
|
||||
|
||||
foreach ($changes as $change) {
|
||||
$this->log($fix ? 'INFO' : 'WARN', ($fix ? 'Fixed: ' : 'Needs fix: ') . $change);
|
||||
}
|
||||
|
||||
if ($fix) {
|
||||
file_put_contents($packageManifest, $content);
|
||||
$this->log('INFO', "Wrote {$relPath} with " . count($changes) . " change(s)");
|
||||
$this->outputResult($ghOutput, 'fixed', implode('; ', $changes));
|
||||
} else {
|
||||
$this->log('WARN', 'Run with --fix to apply changes');
|
||||
$this->outputResult($ghOutput, 'needs-fix', implode('; ', $changes));
|
||||
return 1;
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Write result to $GITHUB_OUTPUT if requested.
|
||||
*/
|
||||
private function outputResult(bool $ghOutput, string $status, string $detail): void
|
||||
{
|
||||
if (!$ghOutput) {
|
||||
return;
|
||||
}
|
||||
|
||||
$outputFile = getenv('GITHUB_OUTPUT');
|
||||
|
||||
if ($outputFile === false || $outputFile === '') {
|
||||
echo "licensing_status={$status}\n";
|
||||
echo "licensing_detail={$detail}\n";
|
||||
return;
|
||||
}
|
||||
|
||||
$fh = fopen($outputFile, 'a');
|
||||
fwrite($fh, "licensing_status={$status}\n");
|
||||
fwrite($fh, "licensing_detail={$detail}\n");
|
||||
fclose($fh);
|
||||
}
|
||||
}
|
||||
|
||||
$app = new ManifestLicensingCli();
|
||||
exit($app->execute());
|
||||
@@ -10,7 +10,7 @@
|
||||
* INGROUP: moko-platform
|
||||
* REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform
|
||||
* PATH: /cli/manifest_read.php
|
||||
* VERSION: 09.23.00
|
||||
* VERSION: 09.25.00
|
||||
* BRIEF: Parse .manifest.xml and output requested field(s) for CI consumption
|
||||
*/
|
||||
|
||||
|
||||
@@ -19,7 +19,7 @@ declare(strict_types=1);
|
||||
|
||||
require_once __DIR__ . '/../lib/Enterprise/CliFramework.php';
|
||||
|
||||
use MokoEnterprise\CliFramework;
|
||||
use MokoEnterprise\{CliFramework, SourceResolver};
|
||||
|
||||
class PackageBuildCli extends CliFramework
|
||||
{
|
||||
@@ -56,18 +56,13 @@ class PackageBuildCli extends CliFramework
|
||||
}
|
||||
|
||||
// -- Determine source directory -----------------------------------------------
|
||||
$sourceDir = null;
|
||||
foreach (['src', 'htdocs'] as $candidate) {
|
||||
if (is_dir("{$root}/{$candidate}")) {
|
||||
$sourceDir = "{$root}/{$candidate}";
|
||||
break;
|
||||
}
|
||||
}
|
||||
$sourceDir = SourceResolver::resolveAbsolute($root);
|
||||
|
||||
if ($sourceDir === null) {
|
||||
$this->log('ERROR', "No src/ or htdocs/ directory found in {$root}");
|
||||
$this->log('ERROR', "No source/ or src/ directory found in {$root}");
|
||||
return 1;
|
||||
}
|
||||
SourceResolver::warnIfLegacy($root);
|
||||
|
||||
// -- Determine element and type prefix from manifest --------------------------
|
||||
$extElement = $elementOverride;
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
* INGROUP: moko-platform
|
||||
* REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform
|
||||
* PATH: /cli/release_cascade.php
|
||||
* VERSION: 09.23.00
|
||||
* VERSION: 09.25.00
|
||||
* BRIEF: DEPRECATED — cascade behavior removed. Each release stream is independent.
|
||||
*/
|
||||
|
||||
|
||||
@@ -17,7 +17,7 @@ declare(strict_types=1);
|
||||
|
||||
require_once __DIR__ . '/../lib/Enterprise/CliFramework.php';
|
||||
|
||||
use MokoEnterprise\CliFramework;
|
||||
use MokoEnterprise\{CliFramework, SourceResolver};
|
||||
|
||||
class ReleaseCreateCli extends CliFramework
|
||||
{
|
||||
@@ -97,8 +97,8 @@ class ReleaseCreateCli extends CliFramework
|
||||
// Find extension manifest (Joomla XML)
|
||||
$extManifest = null;
|
||||
$manifestFiles = array_merge(
|
||||
glob("{$root}/src/pkg_*.xml") ?: [],
|
||||
glob("{$root}/src/*.xml") ?: [],
|
||||
SourceResolver::globSource($root, 'pkg_*.xml'),
|
||||
SourceResolver::globSource($root, '*.xml'),
|
||||
glob("{$root}/*.xml") ?: []
|
||||
);
|
||||
foreach ($manifestFiles as $file) {
|
||||
@@ -112,8 +112,7 @@ class ReleaseCreateCli extends CliFramework
|
||||
// Find Dolibarr module file
|
||||
$modFile = null;
|
||||
$modFiles = array_merge(
|
||||
glob("{$root}/src/core/modules/mod*.class.php") ?: [],
|
||||
glob("{$root}/htdocs/core/modules/mod*.class.php") ?: [],
|
||||
SourceResolver::globSource($root, 'core/modules/mod*.class.php'),
|
||||
glob("{$root}/core/modules/mod*.class.php") ?: []
|
||||
);
|
||||
foreach ($modFiles as $file) {
|
||||
|
||||
+28
-9
@@ -17,7 +17,7 @@ declare(strict_types=1);
|
||||
|
||||
require_once __DIR__ . '/../lib/Enterprise/CliFramework.php';
|
||||
|
||||
use MokoEnterprise\CliFramework;
|
||||
use MokoEnterprise\{CliFramework, SourceResolver};
|
||||
|
||||
class ReleasePackageCli extends CliFramework
|
||||
{
|
||||
@@ -99,9 +99,10 @@ class ReleasePackageCli extends CliFramework
|
||||
$extFolder = '';
|
||||
$typePrefix = '';
|
||||
|
||||
SourceResolver::warnIfLegacy($root);
|
||||
$manifestFiles = array_merge(
|
||||
glob("{$root}/src/pkg_*.xml") ?: [],
|
||||
glob("{$root}/src/*.xml") ?: [],
|
||||
SourceResolver::globSource($root, 'pkg_*.xml'),
|
||||
SourceResolver::globSource($root, '*.xml'),
|
||||
glob("{$root}/*.xml") ?: []
|
||||
);
|
||||
|
||||
@@ -200,14 +201,12 @@ class ReleasePackageCli extends CliFramework
|
||||
}
|
||||
}
|
||||
|
||||
if ($sourceDir === null && is_dir("{$root}/src")) {
|
||||
$sourceDir = "{$root}/src";
|
||||
} elseif ($sourceDir === null && is_dir("{$root}/htdocs")) {
|
||||
$sourceDir = "{$root}/htdocs";
|
||||
if ($sourceDir === null) {
|
||||
$sourceDir = SourceResolver::resolveAbsolute($root);
|
||||
}
|
||||
|
||||
if ($sourceDir === null) {
|
||||
echo "No src/ or htdocs/ directory found — skipping package build\n";
|
||||
echo "No source/ or src/ directory found — skipping package build\n";
|
||||
return 0;
|
||||
}
|
||||
|
||||
@@ -230,12 +229,32 @@ class ReleasePackageCli extends CliFramework
|
||||
$subName = basename($pkgDir);
|
||||
$subZipPath = "{$outputDir}/{$subName}.zip";
|
||||
|
||||
// If sub-package is a full repo checkout (e.g. git submodule),
|
||||
// look for a source/ or src/ subdirectory containing a Joomla manifest XML
|
||||
// and zip that instead of the repo root.
|
||||
$subSourceDir = $pkgDir;
|
||||
$subSrcAbs = SourceResolver::resolveAbsolute($pkgDir);
|
||||
if ($subSrcAbs !== null) {
|
||||
$srcManifests = array_merge(
|
||||
glob("{$subSrcAbs}/*.xml") ?: [],
|
||||
glob("{$subSrcAbs}/pkg_*.xml") ?: []
|
||||
);
|
||||
foreach ($srcManifests as $mf) {
|
||||
if (strpos(file_get_contents($mf) ?: '', '<extension') !== false) {
|
||||
$subSourceDir = $subSrcAbs;
|
||||
$subSrcName = SourceResolver::resolve($pkgDir);
|
||||
echo " Sub-package {$subName}: using {$subSrcName}/ entry-point\n";
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$subZip = new \ZipArchive();
|
||||
if ($subZip->open($subZipPath, \ZipArchive::CREATE | \ZipArchive::OVERWRITE) !== true) {
|
||||
$this->log('ERROR', "Failed to create sub-package ZIP: {$subZipPath}");
|
||||
continue;
|
||||
}
|
||||
$this->addDirToZip($subZip, $pkgDir, '', $this->excludePatterns);
|
||||
$this->addDirToZip($subZip, $subSourceDir, '', $this->excludePatterns);
|
||||
$subZip->close();
|
||||
|
||||
$zip->addFile($subZipPath, "packages/{$subName}.zip");
|
||||
|
||||
@@ -17,7 +17,7 @@ declare(strict_types=1);
|
||||
|
||||
require_once __DIR__ . '/../lib/Enterprise/CliFramework.php';
|
||||
|
||||
use MokoEnterprise\CliFramework;
|
||||
use MokoEnterprise\{CliFramework, SourceResolver};
|
||||
|
||||
class ReleasePromoteCli extends CliFramework
|
||||
{
|
||||
@@ -109,8 +109,8 @@ class ReleasePromoteCli extends CliFramework
|
||||
if ($to === 'stable') {
|
||||
$root = realpath($path) ?: $path;
|
||||
$manifestFiles = array_merge(
|
||||
glob("{$root}/src/pkg_*.xml") ?: [],
|
||||
glob("{$root}/src/*.xml") ?: [],
|
||||
SourceResolver::globSource($root, 'pkg_*.xml'),
|
||||
SourceResolver::globSource($root, '*.xml'),
|
||||
glob("{$root}/*.xml") ?: []
|
||||
);
|
||||
foreach ($manifestFiles as $xmlFile) {
|
||||
|
||||
+61
-54
@@ -10,7 +10,7 @@
|
||||
* INGROUP: moko-platform
|
||||
* REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform
|
||||
* PATH: /cli/release_publish.php
|
||||
* VERSION: 09.23.00
|
||||
* VERSION: 09.25.00
|
||||
* BRIEF: Publish a release and create copies for all lesser stability streams.
|
||||
*/
|
||||
|
||||
@@ -34,6 +34,7 @@ class ReleasePublishCli extends CliFramework
|
||||
$this->addArgument('--org', 'Organization', '');
|
||||
$this->addArgument('--repo', 'Repository name', '');
|
||||
$this->addArgument('--repo-url', 'Repository URL for git auth', '');
|
||||
$this->addArgument('--skip-update-stream', 'Skip updates.xml generation and sync (managed externally)', false);
|
||||
}
|
||||
|
||||
protected function run(): int
|
||||
@@ -46,7 +47,8 @@ class ReleasePublishCli extends CliFramework
|
||||
$giteaUrl = $this->getArgument('--gitea-url') ?: (getenv('GITEA_URL') ?: 'https://git.mokoconsulting.tech');
|
||||
$org = $this->getArgument('--org') ?: (getenv('GITEA_ORG') ?: '');
|
||||
$repo = $this->getArgument('--repo') ?: (getenv('GITEA_REPO') ?: '');
|
||||
$repoUrl = $this->getArgument('--repo-url');
|
||||
$repoUrl = $this->getArgument('--repo-url');
|
||||
$skipUpdateStream = $this->getArgument('--skip-update-stream');
|
||||
|
||||
if (empty($stability) || empty($token)) {
|
||||
$this->log('ERROR', "Usage: release_publish.php --stability <dev|alpha|beta|rc|stable> --token TOKEN [options]");
|
||||
@@ -295,66 +297,71 @@ class ReleasePublishCli extends CliFramework
|
||||
// -- Step 4: No lesser stream copies --
|
||||
echo "\n--- Step 4: Skipped (no lesser stream copies) ---\n";
|
||||
|
||||
// -- Step 5: Update ONLY this stream in updates.xml --
|
||||
echo "\n--- Step 5: Update {$stability} stream in updates.xml ---\n";
|
||||
$streamsToWrite = [$stability];
|
||||
if ($skipUpdateStream) {
|
||||
echo "\n--- Step 5: Skipped (--skip-update-stream) ---\n";
|
||||
echo "\n--- Step 6: Skipped (--skip-update-stream) ---\n";
|
||||
} else {
|
||||
// -- Step 5: Update ONLY this stream in updates.xml --
|
||||
echo "\n--- Step 5: Update {$stability} stream in updates.xml ---\n";
|
||||
$streamsToWrite = [$stability];
|
||||
|
||||
foreach ($streamsToWrite as $stream) {
|
||||
$streamVersion = $releaseVersion;
|
||||
$shaFlag = !empty($sha256) ? "--sha {$sha256}" : '';
|
||||
foreach ($streamsToWrite as $stream) {
|
||||
$streamVersion = $releaseVersion;
|
||||
$shaFlag = !empty($sha256) ? "--sha {$sha256}" : '';
|
||||
|
||||
echo " Writing {$stream} stream: {$streamVersion}\n";
|
||||
if (!$this->dryRun) {
|
||||
passthru("{$php} {$cli}/updates_xml_build.php --path " . escapeshellarg($path)
|
||||
. " --version " . escapeshellarg($streamVersion)
|
||||
. " --stability " . escapeshellarg($stream)
|
||||
. " --gitea-url " . escapeshellarg($giteaUrl)
|
||||
. " --org " . escapeshellarg($org)
|
||||
. " --repo " . escapeshellarg($repo)
|
||||
. " {$shaFlag} 2>&1");
|
||||
}
|
||||
}
|
||||
|
||||
// -- Step 6: Commit updates.xml and sync to all branches --
|
||||
echo "\n--- Step 6: Commit and sync updates.xml ---\n";
|
||||
$root = realpath($path) ?: $path;
|
||||
|
||||
echo " Writing {$stream} stream: {$streamVersion}\n";
|
||||
if (!$this->dryRun) {
|
||||
passthru("{$php} {$cli}/updates_xml_build.php --path " . escapeshellarg($path)
|
||||
. " --version " . escapeshellarg($streamVersion)
|
||||
. " --stability " . escapeshellarg($stream)
|
||||
$cdX = PHP_OS_FAMILY === 'Windows' ? "cd /d " : "cd ";
|
||||
$cdRt = $cdX . escapeshellarg($root);
|
||||
$diffCheck = trim((string) @shell_exec(
|
||||
$cdRt . " && git diff --quiet updates.xml"
|
||||
. " 2>&1 && echo clean || echo dirty"
|
||||
));
|
||||
if ($diffCheck === 'dirty') {
|
||||
@shell_exec($cdRt . " && git add updates.xml");
|
||||
$chMsg = "chore: update channels for"
|
||||
. " {$releaseVersion} [skip ci]";
|
||||
@shell_exec(
|
||||
$cdRt . " && git commit -m "
|
||||
. escapeshellarg($chMsg)
|
||||
. " --author=\"gitea-actions[bot]"
|
||||
. " <gitea-actions[bot]@mokoconsulting.tech>\""
|
||||
);
|
||||
@shell_exec(
|
||||
$cdRt . " && git push origin "
|
||||
. escapeshellarg($branch) . " 2>&1"
|
||||
);
|
||||
echo " Committed updates.xml\n";
|
||||
}
|
||||
|
||||
// Sync to all branches
|
||||
passthru("{$php} {$cli}/updates_xml_sync.php --path " . escapeshellarg($path)
|
||||
. " --current " . escapeshellarg($branch) . " --all"
|
||||
. " --version " . escapeshellarg($releaseVersion)
|
||||
. " --token " . escapeshellarg($token)
|
||||
. " --gitea-url " . escapeshellarg($giteaUrl)
|
||||
. " --org " . escapeshellarg($org)
|
||||
. " --repo " . escapeshellarg($repo)
|
||||
. " {$shaFlag} 2>&1");
|
||||
. " --repo " . escapeshellarg($repo) . " 2>&1");
|
||||
} else {
|
||||
echo "[DRY-RUN] Would commit updates.xml and sync to all branches\n";
|
||||
}
|
||||
}
|
||||
|
||||
// -- Step 6: Commit updates.xml and sync to all branches --
|
||||
echo "\n--- Step 6: Commit and sync updates.xml ---\n";
|
||||
$root = realpath($path) ?: $path;
|
||||
|
||||
if (!$this->dryRun) {
|
||||
$cdX = PHP_OS_FAMILY === 'Windows' ? "cd /d " : "cd ";
|
||||
$cdRt = $cdX . escapeshellarg($root);
|
||||
$diffCheck = trim((string) @shell_exec(
|
||||
$cdRt . " && git diff --quiet updates.xml"
|
||||
. " 2>&1 && echo clean || echo dirty"
|
||||
));
|
||||
if ($diffCheck === 'dirty') {
|
||||
@shell_exec($cdRt . " && git add updates.xml");
|
||||
$chMsg = "chore: update channels for"
|
||||
. " {$releaseVersion} [skip ci]";
|
||||
@shell_exec(
|
||||
$cdRt . " && git commit -m "
|
||||
. escapeshellarg($chMsg)
|
||||
. " --author=\"gitea-actions[bot]"
|
||||
. " <gitea-actions[bot]@mokoconsulting.tech>\""
|
||||
);
|
||||
@shell_exec(
|
||||
$cdRt . " && git push origin "
|
||||
. escapeshellarg($branch) . " 2>&1"
|
||||
);
|
||||
echo " Committed updates.xml\n";
|
||||
}
|
||||
|
||||
// Sync to all branches
|
||||
passthru("{$php} {$cli}/updates_xml_sync.php --path " . escapeshellarg($path)
|
||||
. " --current " . escapeshellarg($branch) . " --all"
|
||||
. " --version " . escapeshellarg($releaseVersion)
|
||||
. " --token " . escapeshellarg($token)
|
||||
. " --gitea-url " . escapeshellarg($giteaUrl)
|
||||
. " --org " . escapeshellarg($org)
|
||||
. " --repo " . escapeshellarg($repo) . " 2>&1");
|
||||
} else {
|
||||
echo "[DRY-RUN] Would commit updates.xml and sync to all branches\n";
|
||||
}
|
||||
|
||||
echo "\n=== Release published: {$releaseVersion} ===\n";
|
||||
|
||||
// Output for CI
|
||||
|
||||
@@ -17,7 +17,7 @@ declare(strict_types=1);
|
||||
|
||||
require_once __DIR__ . '/../lib/Enterprise/CliFramework.php';
|
||||
|
||||
use MokoEnterprise\CliFramework;
|
||||
use MokoEnterprise\{CliFramework, SourceResolver};
|
||||
|
||||
class ReleaseValidateCli extends CliFramework
|
||||
{
|
||||
@@ -66,8 +66,10 @@ class ReleaseValidateCli extends CliFramework
|
||||
$platform = 'generic';
|
||||
}
|
||||
}
|
||||
$hasSource = is_dir("{$root}/src") || is_dir("{$root}/htdocs");
|
||||
$this->addVResult('Source directory', $hasSource ? 'PASS' : 'WARN', $hasSource ? 'src/ or htdocs/ found' : 'No src/ or htdocs/ directory');
|
||||
$hasSource = SourceResolver::resolveAbsolute($root) !== null;
|
||||
SourceResolver::warnIfLegacy($root);
|
||||
$srcDirName = SourceResolver::resolve($root);
|
||||
$this->addVResult('Source directory', $hasSource ? 'PASS' : 'WARN', $hasSource ? "{$srcDirName}/ found" : 'No source/ or src/ directory');
|
||||
if (!file_exists("{$root}/README.md")) {
|
||||
$this->addVResult('README.md', 'FAIL', 'Not found');
|
||||
} else {
|
||||
@@ -109,7 +111,8 @@ class ReleaseValidateCli extends CliFramework
|
||||
$this->addVResult('LICENSE', $licenseFound ? 'PASS' : 'FAIL', $licenseFound ? 'Found' : 'Not found');
|
||||
if ($platform === 'joomla') {
|
||||
$manifest = null;
|
||||
foreach (["{$root}/src", $root] as $dir) {
|
||||
$srcAbs = SourceResolver::resolveAbsolute($root);
|
||||
foreach (array_filter([$srcAbs, $root]) as $dir) {
|
||||
if (!is_dir($dir)) {
|
||||
continue;
|
||||
} foreach (glob("{$dir}/*.xml") as $xmlFile) {
|
||||
@@ -156,7 +159,7 @@ class ReleaseValidateCli extends CliFramework
|
||||
}
|
||||
} elseif ($platform === 'dolibarr') {
|
||||
$modFile = null;
|
||||
foreach (['src', 'htdocs'] as $sd) {
|
||||
foreach (SourceResolver::getCandidates() as $sd) {
|
||||
$matches = glob("{$root}/{$sd}/mod*.class.php");
|
||||
if (!empty($matches)) {
|
||||
$modFile = $matches[0];
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
* INGROUP: moko-platform
|
||||
* REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform
|
||||
* PATH: /cli/scaffold_client.php
|
||||
* VERSION: 09.23.00
|
||||
* VERSION: 09.25.00
|
||||
* BRIEF: Scaffold a new client-waas repo from Template-Client-WaaS with pre-configured settings
|
||||
*/
|
||||
|
||||
|
||||
+4
-9
@@ -17,7 +17,7 @@ declare(strict_types=1);
|
||||
|
||||
require_once __DIR__ . '/../lib/Enterprise/CliFramework.php';
|
||||
|
||||
use MokoEnterprise\CliFramework;
|
||||
use MokoEnterprise\{CliFramework, SourceResolver};
|
||||
|
||||
class ThemeLintCli extends CliFramework
|
||||
{
|
||||
@@ -41,17 +41,12 @@ class ThemeLintCli extends CliFramework
|
||||
$errors = 0;
|
||||
$warnings = 0;
|
||||
|
||||
$srcDir = null;
|
||||
foreach (['src', 'htdocs'] as $d) {
|
||||
if (is_dir("{$root}/{$d}")) {
|
||||
$srcDir = "{$root}/{$d}";
|
||||
break;
|
||||
}
|
||||
}
|
||||
$srcDir = SourceResolver::resolveAbsolute($root);
|
||||
if ($srcDir === null) {
|
||||
$this->log('ERROR', "No src/ or htdocs/ directory in {$root}");
|
||||
$this->log('ERROR', "No source/ or src/ directory in {$root}");
|
||||
return 1;
|
||||
}
|
||||
SourceResolver::warnIfLegacy($root);
|
||||
|
||||
echo "Theme Lint: {$srcDir}\n\n";
|
||||
|
||||
|
||||
@@ -17,7 +17,7 @@ declare(strict_types=1);
|
||||
|
||||
require_once __DIR__ . '/../lib/Enterprise/CliFramework.php';
|
||||
|
||||
use MokoEnterprise\CliFramework;
|
||||
use MokoEnterprise\{CliFramework, SourceResolver};
|
||||
|
||||
class UpdatesXmlBuildCli extends CliFramework
|
||||
{
|
||||
@@ -109,7 +109,7 @@ class UpdatesXmlBuildCli extends CliFramework
|
||||
// -- Locate Joomla manifest ---------------------------------------------------
|
||||
$manifest = null;
|
||||
|
||||
$candidates = glob("{$root}/src/pkg_*.xml") ?: [];
|
||||
$candidates = SourceResolver::globSource($root, 'pkg_*.xml');
|
||||
foreach ($candidates as $f) {
|
||||
if (strpos(file_get_contents($f), '<extension') !== false) {
|
||||
$manifest = $f;
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
* INGROUP: moko-platform
|
||||
* REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform
|
||||
* PATH: /cli/updates_xml_sync.php
|
||||
* VERSION: 09.23.00
|
||||
* VERSION: 09.25.00
|
||||
* BRIEF: Sync updates.xml to target branches via Gitea API
|
||||
* NOTE: Called by pre-release and auto-release workflows after updates.xml
|
||||
* is modified on the current branch. Pushes the file to other branches
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
* INGROUP: moko-platform
|
||||
* REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform
|
||||
* PATH: /cli/version_auto_bump.php
|
||||
* VERSION: 09.23.00
|
||||
* VERSION: 09.25.00
|
||||
* BRIEF: Auto patch-bump, set stability suffix, and commit — single CLI replacing inline workflow bash
|
||||
*/
|
||||
|
||||
|
||||
@@ -17,7 +17,7 @@ declare(strict_types=1);
|
||||
|
||||
require_once __DIR__ . '/../lib/Enterprise/CliFramework.php';
|
||||
|
||||
use MokoEnterprise\CliFramework;
|
||||
use MokoEnterprise\{CliFramework, SourceResolver};
|
||||
|
||||
class VersionBumpCli extends CliFramework
|
||||
{
|
||||
@@ -61,11 +61,12 @@ class VersionBumpCli extends CliFramework
|
||||
}
|
||||
}
|
||||
$manifestVersion = null;
|
||||
SourceResolver::warnIfLegacy($root);
|
||||
$manifestFiles = array_merge(
|
||||
glob("{$root}/src/pkg_*.xml") ?: [],
|
||||
glob("{$root}/src/*.xml") ?: [],
|
||||
glob("{$root}/src/packages/*/mokowaas.xml") ?: [],
|
||||
glob("{$root}/src/packages/*/*.xml") ?: [],
|
||||
SourceResolver::globSource($root, 'pkg_*.xml'),
|
||||
SourceResolver::globSource($root, '*.xml'),
|
||||
SourceResolver::globSource($root, 'packages/*/mokowaas.xml'),
|
||||
SourceResolver::globSource($root, 'packages/*/*.xml'),
|
||||
glob("{$root}/*.xml") ?: []
|
||||
);
|
||||
foreach ($manifestFiles as $xmlFile) {
|
||||
@@ -141,7 +142,8 @@ class VersionBumpCli extends CliFramework
|
||||
}
|
||||
}
|
||||
$updatedFiles = [];
|
||||
foreach (["{$root}/src/pkg_*.xml", "{$root}/src/*.xml", "{$root}/src/packages/*/*.xml", "{$root}/*.xml"] as $pattern) {
|
||||
$srcName = SourceResolver::resolve($root);
|
||||
foreach (["{$root}/{$srcName}/pkg_*.xml", "{$root}/{$srcName}/*.xml", "{$root}/{$srcName}/packages/*/*.xml", "{$root}/*.xml"] as $pattern) {
|
||||
foreach (glob($pattern) ?: [] as $xmlFile) {
|
||||
$content = file_get_contents($xmlFile);
|
||||
if (strpos($content, '<extension') === false) {
|
||||
|
||||
@@ -17,7 +17,7 @@ declare(strict_types=1);
|
||||
|
||||
require_once __DIR__ . '/../lib/Enterprise/CliFramework.php';
|
||||
|
||||
use MokoEnterprise\CliFramework;
|
||||
use MokoEnterprise\{CliFramework, SourceResolver};
|
||||
|
||||
class VersionBumpRemoteCli extends CliFramework
|
||||
{
|
||||
@@ -104,11 +104,15 @@ class VersionBumpRemoteCli extends CliFramework
|
||||
$nextVersion = sprintf('%02d.%02d.%02d', $major, $minor, $patch);
|
||||
echo "{$version} -> {$nextVersion} ({$branch})\n";
|
||||
|
||||
// Try both source/ and src/ paths for backwards compatibility with remote repos
|
||||
$manifestPaths = [];
|
||||
if ($manifestFile !== null) {
|
||||
$manifestPaths[] = "src/{$manifestFile}";
|
||||
foreach (['source', 'src'] as $srcPrefix) {
|
||||
if ($manifestFile !== null) {
|
||||
$manifestPaths[] = "{$srcPrefix}/{$manifestFile}";
|
||||
}
|
||||
$manifestPaths[] = "{$srcPrefix}/templateDetails.xml";
|
||||
$manifestPaths[] = "{$srcPrefix}/manifest.xml";
|
||||
}
|
||||
$manifestPaths = array_merge($manifestPaths, ['src/templateDetails.xml', 'src/manifest.xml']);
|
||||
$manifestUpdated = false;
|
||||
foreach ($manifestPaths as $mPath) {
|
||||
$result = $this->updateRemoteFile($apiBase, $token, $mPath, $branch, function (string $content) use ($version, $nextVersion): string {
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
* INGROUP: moko-platform
|
||||
* REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform
|
||||
* PATH: /cli/version_check.php
|
||||
* VERSION: 09.23.00
|
||||
* VERSION: 09.25.00
|
||||
* BRIEF: Validate version consistency across README, manifests, and sub-packages
|
||||
*/
|
||||
|
||||
@@ -18,7 +18,7 @@ declare(strict_types=1);
|
||||
|
||||
require_once __DIR__ . '/../lib/Enterprise/CliFramework.php';
|
||||
|
||||
use MokoEnterprise\CliFramework;
|
||||
use MokoEnterprise\{CliFramework, SourceResolver};
|
||||
|
||||
class VersionCheckCli extends CliFramework
|
||||
{
|
||||
@@ -77,7 +77,8 @@ class VersionCheckCli extends CliFramework
|
||||
$versions['pyproject.toml'] = $m[1];
|
||||
}
|
||||
}
|
||||
foreach (["{$root}/src/pkg_*.xml", "{$root}/src/*.xml", "{$root}/src/packages/*/*.xml", "{$root}/*.xml"] as $glob) {
|
||||
$srcName = SourceResolver::resolve($root);
|
||||
foreach (["{$root}/{$srcName}/pkg_*.xml", "{$root}/{$srcName}/*.xml", "{$root}/{$srcName}/packages/*/*.xml", "{$root}/*.xml"] as $glob) {
|
||||
foreach (glob($glob) ?: [] as $file) {
|
||||
if (basename($file) === 'updates.xml') {
|
||||
continue;
|
||||
|
||||
@@ -17,7 +17,7 @@ declare(strict_types=1);
|
||||
|
||||
require_once __DIR__ . '/../lib/Enterprise/CliFramework.php';
|
||||
|
||||
use MokoEnterprise\CliFramework;
|
||||
use MokoEnterprise\{CliFramework, SourceResolver};
|
||||
|
||||
class VersionReadCli extends CliFramework
|
||||
{
|
||||
@@ -64,9 +64,9 @@ class VersionReadCli extends CliFramework
|
||||
// -- 3. Fallback: Joomla manifest XML --
|
||||
$manifestVersion = null;
|
||||
$manifestFiles = array_merge(
|
||||
glob("{$root}/src/pkg_*.xml") ?: [],
|
||||
glob("{$root}/src/*.xml") ?: [],
|
||||
glob("{$root}/src/packages/*/*.xml") ?: [],
|
||||
SourceResolver::globSource($root, 'pkg_*.xml'),
|
||||
SourceResolver::globSource($root, '*.xml'),
|
||||
SourceResolver::globSource($root, 'packages/*/*.xml'),
|
||||
glob("{$root}/*.xml") ?: []
|
||||
);
|
||||
|
||||
|
||||
@@ -17,7 +17,7 @@ declare(strict_types=1);
|
||||
|
||||
require_once __DIR__ . '/../lib/Enterprise/CliFramework.php';
|
||||
|
||||
use MokoEnterprise\CliFramework;
|
||||
use MokoEnterprise\{CliFramework, SourceResolver};
|
||||
|
||||
class VersionSetPlatformCli extends CliFramework
|
||||
{
|
||||
@@ -110,7 +110,8 @@ class VersionSetPlatformCli extends CliFramework
|
||||
|
||||
// Dolibarr: $this->version + $this->url_last_version in mod*.class.php
|
||||
if ($platform === 'crm-module') {
|
||||
$pattern = "{$root}/src/core/modules/mod*.class.php";
|
||||
$srcName = SourceResolver::resolve($root);
|
||||
$pattern = "{$root}/{$srcName}/core/modules/mod*.class.php";
|
||||
foreach (glob($pattern) ?: [] as $file) {
|
||||
$content = file_get_contents($file);
|
||||
|
||||
@@ -146,9 +147,10 @@ class VersionSetPlatformCli extends CliFramework
|
||||
|
||||
// Joomla: <version> in XML manifests (top-level + sub-packages)
|
||||
if (in_array($platform, ['waas-component', 'joomla'], true)) {
|
||||
$srcName = SourceResolver::resolve($root);
|
||||
$xmlFiles = array_merge(
|
||||
glob("{$root}/src/*.xml") ?: [],
|
||||
glob("{$root}/src/packages/*/*.xml") ?: [],
|
||||
glob("{$root}/{$srcName}/*.xml") ?: [],
|
||||
glob("{$root}/{$srcName}/packages/*/*.xml") ?: [],
|
||||
glob("{$root}/*.xml") ?: []
|
||||
);
|
||||
if (empty($xmlFiles)) {
|
||||
|
||||
+1
-1
@@ -10,7 +10,7 @@
|
||||
* INGROUP: moko-platform
|
||||
* REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform
|
||||
* PATH: /cli/wiki_sync.php
|
||||
* VERSION: 09.23.00
|
||||
* VERSION: 09.25.00
|
||||
* BRIEF: Sync select wiki pages from moko-platform to all template repos
|
||||
*/
|
||||
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
* INGROUP: MokoPlatform
|
||||
* REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform
|
||||
* PATH: /deploy/backup-before-deploy.php
|
||||
* VERSION: 09.23.00
|
||||
* VERSION: 09.25.00
|
||||
* BRIEF: Snapshot Joomla directories before deployment for rollback capability
|
||||
*/
|
||||
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
* INGROUP: MokoPlatform
|
||||
* REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform
|
||||
* PATH: /deploy/deploy-dolibarr.php
|
||||
* VERSION: 09.23.00
|
||||
* VERSION: 09.25.00
|
||||
* BRIEF: Deploy Dolibarr module files to a remote server via SFTP/rsync
|
||||
*/
|
||||
|
||||
|
||||
@@ -21,7 +21,7 @@ require_once __DIR__ . '/../vendor/autoload.php';
|
||||
|
||||
require_once __DIR__ . '/../lib/Enterprise/CliFramework.php';
|
||||
|
||||
use MokoEnterprise\CliFramework;
|
||||
use MokoEnterprise\{CliFramework, SourceResolver};
|
||||
use phpseclib3\Net\SFTP;
|
||||
use phpseclib3\Crypt\PublicKeyLoader;
|
||||
|
||||
@@ -51,9 +51,9 @@ class DeploySftp extends CliFramework
|
||||
|
||||
protected function configure(): void
|
||||
{
|
||||
$this->setDescription('Deploy a repository src/ directory to a remote web server via SFTP');
|
||||
$this->setDescription('Deploy a repository source directory to a remote web server via SFTP');
|
||||
$this->addArgument('--path', 'Repository root (default: current directory)', '.');
|
||||
$this->addArgument('--src-dir', 'Source sub-directory to upload (default: src)', 'src');
|
||||
$this->addArgument('--src-dir', 'Source sub-directory to upload (default: auto-detect)', '');
|
||||
$this->addArgument('--env', 'Target environment: dev or rs', '');
|
||||
$this->addArgument('--config', 'Explicit config file path — overrides --env', '');
|
||||
$this->addArgument('--key-passphrase', 'Passphrase for the SSH private key', '');
|
||||
@@ -158,7 +158,8 @@ class DeploySftp extends CliFramework
|
||||
*/
|
||||
private function resolveSrcDir(string $repoPath): string
|
||||
{
|
||||
$sub = $this->getArgument('--src-dir', 'src');
|
||||
$sub = $this->getArgument('--src-dir', '') ?: SourceResolver::resolve($repoPath);
|
||||
SourceResolver::warnIfLegacy($repoPath);
|
||||
$dir = $repoPath . DIRECTORY_SEPARATOR . $sub;
|
||||
|
||||
if (!is_dir($dir)) {
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
* INGROUP: MokoPlatform
|
||||
* REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform
|
||||
* PATH: /deploy/health-check.php
|
||||
* VERSION: 09.23.00
|
||||
* VERSION: 09.25.00
|
||||
* BRIEF: Post-deploy health check — verify a Joomla site is responding correctly
|
||||
*/
|
||||
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
* INGROUP: MokoPlatform
|
||||
* REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform
|
||||
* PATH: /deploy/rollback-joomla.php
|
||||
* VERSION: 09.23.00
|
||||
* VERSION: 09.25.00
|
||||
* BRIEF: Rollback a Joomla deployment by restoring from a pre-deploy snapshot
|
||||
*/
|
||||
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
* INGROUP: MokoPlatform
|
||||
* REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform
|
||||
* PATH: /deploy/sync-joomla.php
|
||||
* VERSION: 09.23.00
|
||||
* VERSION: 09.25.00
|
||||
* BRIEF: Sync Joomla site directories between two servers via rsync over SSH
|
||||
*/
|
||||
|
||||
|
||||
@@ -171,6 +171,13 @@ abstract class CliFramework
|
||||
*/
|
||||
public function __construct(string $name = '', string $version = '04.00.15')
|
||||
{
|
||||
// Load Composer autoloader for Enterprise classes (SourceResolver, etc.)
|
||||
$autoloader = __DIR__ . '/../../vendor/autoload.php';
|
||||
|
||||
if (file_exists($autoloader)) {
|
||||
require_once $autoloader;
|
||||
}
|
||||
|
||||
$this->scriptName = $name ?: basename($_SERVER['argv'][0] ?? 'script', '.php');
|
||||
$this->scriptVersion = $version;
|
||||
$this->startTime = microtime(true);
|
||||
|
||||
@@ -147,31 +147,29 @@ class ManifestReader
|
||||
/**
|
||||
* Get the source/entry-point directory.
|
||||
*
|
||||
* Fallback chain: manifest entry-point → source/ → src/ → htdocs/ → 'source'.
|
||||
* Uses SourceResolver for the directory fallback when no entry-point is set.
|
||||
*
|
||||
* @param string $root Repository root for existence checking
|
||||
* @return string Resolved source directory path (e.g. 'src', 'htdocs')
|
||||
* @return string Resolved source directory path (e.g. 'source', 'src', 'htdocs')
|
||||
*/
|
||||
public function getSourceDir(string $root = ''): string
|
||||
{
|
||||
$entryPoint = $this->get('entry-point', '');
|
||||
if ($entryPoint !== '') {
|
||||
// Strip trailing filename (e.g. src/index.ts → src)
|
||||
// Strip trailing filename (e.g. source/index.ts → source)
|
||||
$dir = rtrim(dirname($entryPoint) === '.' ? $entryPoint : dirname($entryPoint), '/');
|
||||
if ($root === '' || is_dir("{$root}/{$dir}")) {
|
||||
return $dir;
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: check common directories
|
||||
// Fallback: use SourceResolver (source/ → src/ → htdocs/ → default 'source')
|
||||
if ($root !== '') {
|
||||
if (is_dir("{$root}/src")) {
|
||||
return 'src';
|
||||
}
|
||||
if (is_dir("{$root}/htdocs")) {
|
||||
return 'htdocs';
|
||||
}
|
||||
return SourceResolver::resolve($root);
|
||||
}
|
||||
|
||||
return 'src';
|
||||
return 'source';
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -68,7 +68,8 @@ class PackageBuilder
|
||||
mkdir($packageDir, 0755, true);
|
||||
mkdir($distDir, 0755, true);
|
||||
|
||||
foreach (['src', 'admin', 'site'] as $dir) {
|
||||
$srcName = SourceResolver::resolve($repoRoot);
|
||||
foreach ([$srcName, 'admin', 'site'] as $dir) {
|
||||
if (is_dir($repoRoot . '/' . $dir)) {
|
||||
self::copyDirectory($repoRoot . '/' . $dir, $packageDir . '/' . $dir);
|
||||
}
|
||||
@@ -94,15 +95,15 @@ class PackageBuilder
|
||||
/**
|
||||
* Build a Dolibarr module release package.
|
||||
*
|
||||
* Copies everything under src/ into a build staging directory and archives
|
||||
* it as dist/<MODULE_NAME>_<VERSION>.zip.
|
||||
* Copies everything under source/ (or src/) into a build staging directory
|
||||
* and archives it as dist/<MODULE_NAME>_<VERSION>.zip.
|
||||
*
|
||||
* @param string $repoRoot Absolute path to the repository root.
|
||||
* @param string $moduleName Module name (used in archive filename).
|
||||
* @param string $version Version string.
|
||||
* @param bool $dryRun When true, preview without writing.
|
||||
* @return string Path to the created archive (or would-create path in dry-run).
|
||||
* @throws \RuntimeException When src/ is absent or archive creation fails.
|
||||
* @throws \RuntimeException When source directory is absent or archive creation fails.
|
||||
*/
|
||||
public static function buildDolibarr(
|
||||
string $repoRoot,
|
||||
@@ -110,14 +111,15 @@ class PackageBuilder
|
||||
string $version,
|
||||
bool $dryRun = false
|
||||
): string {
|
||||
$srcDir = $repoRoot . '/src';
|
||||
$srcDir = SourceResolver::resolveAbsolute($repoRoot);
|
||||
$buildDir = $repoRoot . '/build';
|
||||
$distDir = $repoRoot . '/dist';
|
||||
$archivePath = $distDir . '/' . $moduleName . '_' . $version . '.zip';
|
||||
|
||||
if (!is_dir($srcDir)) {
|
||||
throw new \RuntimeException("src/ directory not found at {$srcDir}");
|
||||
if ($srcDir === null) {
|
||||
throw new \RuntimeException("source/ or src/ directory not found in {$repoRoot}");
|
||||
}
|
||||
SourceResolver::warnIfLegacy($repoRoot);
|
||||
|
||||
if ($dryRun) {
|
||||
return $archivePath;
|
||||
|
||||
@@ -20,6 +20,7 @@ declare(strict_types=1);
|
||||
namespace MokoEnterprise\Plugins;
|
||||
|
||||
use MokoEnterprise\AbstractProjectPlugin;
|
||||
use MokoEnterprise\SourceResolver;
|
||||
|
||||
/**
|
||||
* MCP Server Project Plugin
|
||||
@@ -55,10 +56,12 @@ class McpServerPlugin extends AbstractProjectPlugin
|
||||
$warnings = [];
|
||||
|
||||
// Check for required source files
|
||||
$requiredSrc = ['src/index.ts', 'src/client.ts', 'src/config.ts', 'src/types.ts'];
|
||||
$srcName = SourceResolver::resolve($projectPath);
|
||||
SourceResolver::warnIfLegacy($projectPath);
|
||||
$requiredSrc = ['index.ts', 'client.ts', 'config.ts', 'types.ts'];
|
||||
foreach ($requiredSrc as $file) {
|
||||
if (!file_exists("{$projectPath}/{$file}")) {
|
||||
$errors[] = "Missing required source file: {$file}";
|
||||
if (SourceResolver::findUnderSource($projectPath, $file) === null) {
|
||||
$errors[] = "Missing required source file: {$srcName}/{$file}";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -82,37 +85,33 @@ class McpServerPlugin extends AbstractProjectPlugin
|
||||
$errors[] = 'Missing tsconfig.json';
|
||||
}
|
||||
|
||||
// Check for setup wizard
|
||||
if (!file_exists("{$projectPath}/scripts/setup.mjs")) {
|
||||
$warnings[] = 'Missing scripts/setup.mjs — interactive setup wizard recommended';
|
||||
}
|
||||
|
||||
// Check for config example
|
||||
if (!file_exists("{$projectPath}/config.example.json")) {
|
||||
$warnings[] = 'Missing config.example.json — example configuration recommended';
|
||||
}
|
||||
|
||||
// Check for shebang in index.ts
|
||||
if (file_exists("{$projectPath}/src/index.ts")) {
|
||||
$content = @file_get_contents("{$projectPath}/src/index.ts");
|
||||
$indexTs = SourceResolver::findUnderSource($projectPath, 'index.ts');
|
||||
if ($indexTs !== null) {
|
||||
$content = @file_get_contents($indexTs);
|
||||
if ($content && strpos($content, '#!/usr/bin/env node') === false) {
|
||||
$warnings[] = 'src/index.ts should start with #!/usr/bin/env node shebang';
|
||||
$warnings[] = "{$srcName}/index.ts should start with #!/usr/bin/env node shebang";
|
||||
}
|
||||
}
|
||||
|
||||
// Check for McpServer usage
|
||||
if (file_exists("{$projectPath}/src/index.ts")) {
|
||||
$content = @file_get_contents("{$projectPath}/src/index.ts");
|
||||
if ($indexTs !== null) {
|
||||
$content = $content ?? @file_get_contents($indexTs);
|
||||
if ($content && strpos($content, 'McpServer') === false) {
|
||||
$errors[] = 'src/index.ts must import and use McpServer from @modelcontextprotocol/sdk';
|
||||
$errors[] = "{$srcName}/index.ts must import and use McpServer from @modelcontextprotocol/sdk";
|
||||
}
|
||||
}
|
||||
|
||||
// Check for StdioServerTransport
|
||||
if (file_exists("{$projectPath}/src/index.ts")) {
|
||||
$content = @file_get_contents("{$projectPath}/src/index.ts");
|
||||
if ($indexTs !== null) {
|
||||
$content = $content ?? @file_get_contents($indexTs);
|
||||
if ($content && strpos($content, 'StdioServerTransport') === false) {
|
||||
$warnings[] = 'src/index.ts should use StdioServerTransport for Claude Code compatibility';
|
||||
$warnings[] = "{$srcName}/index.ts should use StdioServerTransport for Claude Code compatibility";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -190,12 +189,13 @@ class McpServerPlugin extends AbstractProjectPlugin
|
||||
$score = 100;
|
||||
|
||||
// Check for required source files
|
||||
$requiredSrc = ['src/index.ts', 'src/client.ts', 'src/config.ts', 'src/types.ts'];
|
||||
$srcName = SourceResolver::resolve($projectPath);
|
||||
$requiredSrc = ['index.ts', 'client.ts', 'config.ts', 'types.ts'];
|
||||
foreach ($requiredSrc as $file) {
|
||||
if (!file_exists("{$projectPath}/{$file}")) {
|
||||
if (SourceResolver::findUnderSource($projectPath, $file) === null) {
|
||||
$issues[] = [
|
||||
'severity' => 'critical',
|
||||
'message' => "Missing required file: {$file}",
|
||||
'message' => "Missing required file: {$srcName}/{$file}",
|
||||
];
|
||||
$score -= 20;
|
||||
}
|
||||
@@ -214,14 +214,15 @@ class McpServerPlugin extends AbstractProjectPlugin
|
||||
}
|
||||
|
||||
// Check for at least one registered tool
|
||||
if (file_exists("{$projectPath}/src/index.ts")) {
|
||||
$content = @file_get_contents("{$projectPath}/src/index.ts");
|
||||
$indexTs = SourceResolver::findUnderSource($projectPath, 'index.ts');
|
||||
if ($indexTs !== null) {
|
||||
$content = @file_get_contents($indexTs);
|
||||
if ($content) {
|
||||
$toolCount = substr_count($content, 'server.tool(');
|
||||
if ($toolCount === 0) {
|
||||
$issues[] = [
|
||||
'severity' => 'critical',
|
||||
'message' => 'No MCP tools registered in src/index.ts',
|
||||
'message' => "No MCP tools registered in {$srcName}/index.ts",
|
||||
];
|
||||
$score -= 25;
|
||||
} elseif ($toolCount < 5) {
|
||||
|
||||
@@ -173,37 +173,15 @@ class RepositorySynchronizer
|
||||
$platform = $this->detectPlatform($repoInfo);
|
||||
$this->logger->logInfo("Detected platform for {$repo}: {$platform}");
|
||||
|
||||
// Load file list from the Terraform definition for this platform
|
||||
$filesToSync = $this->definitionParser->parseForPlatform($platform, $repoRoot);
|
||||
|
||||
// Append shared workflows — the parser can't extract them from nested
|
||||
// subdirectories blocks due to heredoc interference in .tf files.
|
||||
$sharedFiles = $this->getSharedWorkflows($platform, $repoRoot);
|
||||
|
||||
// Deduplicate by destination — shared workflows take precedence over parser entries
|
||||
$seen = [];
|
||||
foreach ($filesToSync as $f) {
|
||||
$seen[$f['destination']] = true;
|
||||
}
|
||||
foreach ($sharedFiles as $f) {
|
||||
if (!isset($seen[$f['destination']])) {
|
||||
$filesToSync[] = $f;
|
||||
}
|
||||
}
|
||||
|
||||
$defCount = count($filesToSync) - count($sharedFiles);
|
||||
$sharedAdded = count($filesToSync) - $defCount;
|
||||
$sharedTotal = count($sharedFiles);
|
||||
// Load shared workflows and config files for this platform from templates
|
||||
$filesToSync = $this->getSharedWorkflows($platform, $repoRoot);
|
||||
$sharedTotal = count($filesToSync);
|
||||
$this->logger->logInfo(
|
||||
"Loaded " . count($filesToSync) . " sync entries for {$platform}"
|
||||
. " (def={$defCount}, shared={$sharedAdded}/{$sharedTotal} added, "
|
||||
. ($sharedTotal - $sharedAdded) . " deduped)"
|
||||
"Loaded {$sharedTotal} sync entries for {$platform}"
|
||||
);
|
||||
// Log shared workflow destinations for debugging
|
||||
foreach ($sharedFiles as $sf) {
|
||||
foreach ($filesToSync as $sf) {
|
||||
$dest = $sf['destination'] ?? '?';
|
||||
$added = !isset($seen[$dest]) ? 'ADDED' : 'DEDUPED';
|
||||
$this->logger->logInfo(" shared: {$dest} [{$added}]");
|
||||
$this->logger->logInfo(" sync: {$dest}");
|
||||
}
|
||||
|
||||
if (empty($filesToSync)) {
|
||||
@@ -1380,7 +1358,7 @@ class RepositorySynchronizer
|
||||
|
||||
$descriptors = array_values(array_filter(
|
||||
$paths,
|
||||
static fn(string $p): bool => (bool) preg_match('#src/core/modules/mod\w+\.class\.php$#', $p)
|
||||
static fn(string $p): bool => (bool) preg_match('#(?:source|src)/core/modules/mod\w+\.class\.php$#', $p)
|
||||
));
|
||||
|
||||
if (empty($descriptors)) {
|
||||
|
||||
@@ -0,0 +1,187 @@
|
||||
<?php
|
||||
|
||||
/* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
|
||||
*
|
||||
* This file is part of a Moko Consulting project.
|
||||
*
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*
|
||||
* FILE INFORMATION
|
||||
* DEFGROUP: MokoPlatform.Enterprise
|
||||
* INGROUP: MokoPlatform.Lib
|
||||
* REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform
|
||||
* PATH: /lib/Enterprise/SourceResolver.php
|
||||
* BRIEF: Resolve the root-level source directory across repos (source/, src/, htdocs/)
|
||||
*/
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace MokoEnterprise;
|
||||
|
||||
/**
|
||||
* Source Directory Resolver
|
||||
*
|
||||
* Provides a single, consistent fallback chain for locating the root-level
|
||||
* source directory in any MokoStandards repository. The preferred directory
|
||||
* is `source/`, with legacy `src/` and `htdocs/` as fallbacks.
|
||||
*
|
||||
* This class exists because Joomla extensions use `src/` for namespace
|
||||
* autoloading (e.g. administrator/components/com_foo/src/). Renaming our
|
||||
* root-level source directory to `source/` avoids that collision. During
|
||||
* the transition period, repos may still use `src/`, so all tooling must
|
||||
* check both.
|
||||
*
|
||||
* Usage:
|
||||
* $dir = SourceResolver::resolve($repoRoot); // 'source', 'src', or 'htdocs'
|
||||
* $abs = SourceResolver::resolveAbsolute($repoRoot); // full path or null
|
||||
* $xmls = SourceResolver::globSource($repoRoot, '*.xml'); // glob under first match
|
||||
* $path = SourceResolver::findUnderSource($repoRoot, 'core/modules'); // subpath lookup
|
||||
*
|
||||
* @since 09.02.00
|
||||
*/
|
||||
class SourceResolver
|
||||
{
|
||||
/**
|
||||
* Ordered candidate directories. source/ is preferred, src/ is legacy fallback.
|
||||
*
|
||||
* When the migration is complete and all repos use source/, the 'src'
|
||||
* entry can be removed from this list.
|
||||
*
|
||||
* @var string[]
|
||||
*/
|
||||
private const CANDIDATES = ['source', 'src', 'htdocs'];
|
||||
|
||||
/**
|
||||
* Resolve the source directory name for a repository root.
|
||||
*
|
||||
* Returns the first candidate directory that exists, or 'source' as the
|
||||
* default when no candidate is found (e.g. for new repos being scaffolded).
|
||||
*
|
||||
* @param string $root Absolute path to the repository root.
|
||||
* @return string Directory name (e.g. 'source', 'src', 'htdocs').
|
||||
*/
|
||||
public static function resolve(string $root): string
|
||||
{
|
||||
foreach (self::CANDIDATES as $candidate) {
|
||||
if (is_dir("{$root}/{$candidate}")) {
|
||||
return $candidate;
|
||||
}
|
||||
}
|
||||
|
||||
return 'source';
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the source directory as an absolute path.
|
||||
*
|
||||
* @param string $root Absolute path to the repository root.
|
||||
* @return string|null Absolute path to the source directory, or null if none exists.
|
||||
*/
|
||||
public static function resolveAbsolute(string $root): ?string
|
||||
{
|
||||
foreach (self::CANDIDATES as $candidate) {
|
||||
$path = "{$root}/{$candidate}";
|
||||
if (is_dir($path)) {
|
||||
return $path;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Glob for files under the source directory.
|
||||
*
|
||||
* Checks each candidate directory in order and returns matches from the
|
||||
* first candidate that produces results. This replaces patterns like:
|
||||
*
|
||||
* glob("{$root}/src/*.xml")
|
||||
*
|
||||
* With the backwards-compatible:
|
||||
*
|
||||
* SourceResolver::globSource($root, '*.xml')
|
||||
*
|
||||
* @param string $root Absolute path to the repository root.
|
||||
* @param string $pattern Glob pattern relative to the source directory.
|
||||
* @return string[] Matched file paths (may be empty).
|
||||
*/
|
||||
public static function globSource(string $root, string $pattern): array
|
||||
{
|
||||
foreach (self::CANDIDATES as $candidate) {
|
||||
$dir = "{$root}/{$candidate}";
|
||||
if (!is_dir($dir)) {
|
||||
continue;
|
||||
}
|
||||
$matches = glob("{$dir}/{$pattern}") ?: [];
|
||||
if ($matches !== []) {
|
||||
return $matches;
|
||||
}
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Find a subpath under any source directory candidate.
|
||||
*
|
||||
* Useful for locating platform-specific subdirectories like
|
||||
* `core/modules/` (Dolibarr) or `media/templates/` (Joomla client themes)
|
||||
* regardless of whether the repo uses `source/` or `src/`.
|
||||
*
|
||||
* @param string $root Absolute path to the repository root.
|
||||
* @param string $subpath Relative path to look for (e.g. 'core/modules', 'index.ts').
|
||||
* @return string|null Absolute path if found, null otherwise.
|
||||
*/
|
||||
public static function findUnderSource(string $root, string $subpath): ?string
|
||||
{
|
||||
foreach (self::CANDIDATES as $candidate) {
|
||||
$full = "{$root}/{$candidate}/{$subpath}";
|
||||
if (file_exists($full) || is_dir($full)) {
|
||||
return $full;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the ordered list of candidate directory names.
|
||||
*
|
||||
* Useful for workflows or scripts that need to iterate candidates
|
||||
* themselves (e.g. building find/grep patterns).
|
||||
*
|
||||
* @return string[]
|
||||
*/
|
||||
public static function getCandidates(): array
|
||||
{
|
||||
return self::CANDIDATES;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check whether the resolved source directory is a legacy name (src/).
|
||||
*
|
||||
* @param string $root Absolute path to the repository root.
|
||||
* @return bool True if the repo uses src/ instead of source/.
|
||||
*/
|
||||
public static function isLegacy(string $root): bool
|
||||
{
|
||||
$resolved = self::resolve($root);
|
||||
|
||||
return $resolved === 'src';
|
||||
}
|
||||
|
||||
/**
|
||||
* Emit a deprecation warning to stderr if the repo still uses src/.
|
||||
*
|
||||
* CLI tools should call this after resolving the source directory so
|
||||
* that maintainers know to rename src/ → source/.
|
||||
*
|
||||
* @param string $root Absolute path to the repository root.
|
||||
*/
|
||||
public static function warnIfLegacy(string $root): void
|
||||
{
|
||||
if (self::isLegacy($root)) {
|
||||
fwrite(STDERR, "⚠ WARNING: This repo uses src/ which is deprecated. Rename to source/ per MokoStandards.\n");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
# mcp_mokobackup
|
||||
|
||||
MCP server for database and file backups across Dolibarr, Joomla/Akeeba, Gitea, and file-based environments.
|
||||
|
||||
## Quick Reference
|
||||
|
||||
| Field | Value |
|
||||
|---|---|
|
||||
| **Package** | `@mokoconsulting/backup-mcp` |
|
||||
| **Entry** | `dist/index.js` |
|
||||
| **Config** | `~/.mcp_mokobackup.json` (override: `BACKUP_MCP_CONFIG` env var) |
|
||||
| **Language** | TypeScript |
|
||||
| **Branch** | develop on `dev`, merge to `main` (protected) |
|
||||
|
||||
## Commands
|
||||
|
||||
```bash
|
||||
npm install # Install dependencies
|
||||
npm run build # Compile TypeScript → dist/
|
||||
npm run dev # Watch mode
|
||||
```
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
src/
|
||||
├── index.ts # MCP server entry, tool registration
|
||||
├── config.ts # Loads ~/.mcp_mokobackup.json, resolves targets
|
||||
├── client.ts # Backup execution logic
|
||||
├── akeeba.ts # Akeeba Backup API integration (Joomla sites)
|
||||
├── mokobackup.ts # MokoJoomBackup REST API integration
|
||||
└── types.ts # BackupConfig, BackupTarget types
|
||||
```
|
||||
|
||||
- Config defines **targets** — each target has a type (akeeba, dolibarr, mysql, files, gitea-db, gitea-files)
|
||||
- Client-specific targets go in client repo configs, not global
|
||||
- Dolibarr backups read `conf.php` via SSH to get DB credentials
|
||||
|
||||
## Config
|
||||
|
||||
Default config at `~/.mcp_mokobackup.json`. Client repos override via `BACKUP_MCP_CONFIG` env var pointing to their own config file (e.g. `A:/client-clarksvillefurs/.mcp_mokobackup.json`).
|
||||
|
||||
## Rules
|
||||
|
||||
- **Never commit** `.claude/`, `.mcp.json`, `TODO.md`, `*.min.css`/`*.min.js`
|
||||
- **Attribution**: `Authored-by: Moko Consulting`
|
||||
- **Workflow directory**: `.mokogitea/` (not `.gitea/` or `.github/`)
|
||||
- **Wiki**: documentation lives in the Gitea wiki, not `docs/` files
|
||||
- **Standards**: [MokoStandards](https://git.mokoconsulting.tech/MokoConsulting/moko-platform/wiki/Home)
|
||||
@@ -0,0 +1,236 @@
|
||||
# Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
|
||||
#
|
||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||
#
|
||||
# FILE INFORMATION
|
||||
# DEFGROUP: Gitea.Workflow
|
||||
# INGROUP: moko-platform.CI
|
||||
# REPO: https://git.mokoconsulting.tech/mokoconsulting-tech/moko-platform
|
||||
# PATH: /templates/workflows/universal/pr-check.yml.template
|
||||
# VERSION: 05.00.00
|
||||
# BRIEF: PR gate — branch policy + code validation before merge
|
||||
|
||||
name: "Universal: PR Check"
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
types: [opened, synchronize, reopened, edited]
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: write
|
||||
|
||||
env:
|
||||
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
|
||||
|
||||
jobs:
|
||||
# ── Branch Policy ──────────────────────────────────────────────────────
|
||||
branch-policy:
|
||||
name: Branch Policy
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Check branch merge target
|
||||
run: |
|
||||
HEAD="${{ github.head_ref }}"
|
||||
BASE="${{ github.base_ref }}"
|
||||
|
||||
echo "PR: ${HEAD} → ${BASE}"
|
||||
|
||||
ALLOWED=true
|
||||
REASON=""
|
||||
|
||||
case "$HEAD" in
|
||||
feature/*|feat/*)
|
||||
if [ "$BASE" != "dev" ]; then
|
||||
ALLOWED=false
|
||||
REASON="Feature branches must target 'dev', not '${BASE}'"
|
||||
fi
|
||||
;;
|
||||
fix/*|bugfix/*)
|
||||
if [ "$BASE" != "dev" ]; then
|
||||
ALLOWED=false
|
||||
REASON="Fix branches must target 'dev', not '${BASE}'"
|
||||
fi
|
||||
;;
|
||||
patch/*)
|
||||
if [ "$BASE" != "dev" ] && [ "$BASE" != "rc" ]; then
|
||||
ALLOWED=false
|
||||
REASON="Patch branches must target 'dev' or 'rc', not '${BASE}'"
|
||||
fi
|
||||
;;
|
||||
hotfix/*)
|
||||
if [ "$BASE" != "dev" ] && [ "$BASE" != "main" ]; then
|
||||
ALLOWED=false
|
||||
REASON="Hotfix branches can only target 'dev' or 'main', not '${BASE}'"
|
||||
fi
|
||||
;;
|
||||
rc)
|
||||
if [ "$BASE" != "main" ]; then
|
||||
ALLOWED=false
|
||||
REASON="RC branch can only merge into 'main', not '${BASE}'"
|
||||
fi
|
||||
;;
|
||||
dev)
|
||||
if [ "$BASE" != "main" ]; then
|
||||
ALLOWED=false
|
||||
REASON="Dev branch can only merge into 'main', not '${BASE}'"
|
||||
fi
|
||||
;;
|
||||
esac
|
||||
|
||||
if [ "$ALLOWED" = false ]; then
|
||||
echo "::error::${REASON}"
|
||||
echo "## Branch Policy Violation" >> $GITHUB_STEP_SUMMARY
|
||||
echo "" >> $GITHUB_STEP_SUMMARY
|
||||
echo "${REASON}" >> $GITHUB_STEP_SUMMARY
|
||||
echo "" >> $GITHUB_STEP_SUMMARY
|
||||
echo "### Allowed merge paths:" >> $GITHUB_STEP_SUMMARY
|
||||
echo "- \`feature/*\` → \`dev\`" >> $GITHUB_STEP_SUMMARY
|
||||
echo "- \`fix/*\` → \`dev\`" >> $GITHUB_STEP_SUMMARY
|
||||
echo "- \`hotfix/*\` → \`dev\` or \`main\`" >> $GITHUB_STEP_SUMMARY
|
||||
echo "- \`dev\` → \`main\`" >> $GITHUB_STEP_SUMMARY
|
||||
echo "- \`rc/*\` → \`main\`" >> $GITHUB_STEP_SUMMARY
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "Branch policy: OK (${HEAD} → ${BASE})"
|
||||
echo "## Branch Policy: Passed" >> $GITHUB_STEP_SUMMARY
|
||||
|
||||
# ── Code Validation ────────────────────────────────────────────────────
|
||||
validate:
|
||||
name: Validate PR
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Detect platform
|
||||
id: platform
|
||||
run: |
|
||||
# Read platform from XML manifest (<platform> tag) or plain text fallback
|
||||
PLATFORM=$(sed -n 's/.*<platform>\([^<]*\)<\/platform>.*/\1/p' .mokogitea/manifest.xml 2>/dev/null | head -1)
|
||||
[ -z "$PLATFORM" ] && PLATFORM=$(cat .mokogitea/manifest.xml 2>/dev/null | tr -d '[:space:]')
|
||||
[ -z "$PLATFORM" ] && PLATFORM="generic"
|
||||
echo "platform=$PLATFORM" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Setup PHP
|
||||
if: steps.platform.outputs.platform == 'joomla' || steps.platform.outputs.platform == 'dolibarr'
|
||||
run: |
|
||||
if ! command -v php &> /dev/null; then
|
||||
sudo apt-get update -qq
|
||||
sudo apt-get install -y -qq php-cli php-mbstring php-xml >/dev/null 2>&1
|
||||
fi
|
||||
|
||||
- name: PHP syntax check
|
||||
if: steps.platform.outputs.platform == 'joomla' || steps.platform.outputs.platform == 'dolibarr'
|
||||
run: |
|
||||
ERRORS=0
|
||||
while IFS= read -r -d '' file; do
|
||||
if ! php -l "$file" 2>&1 | grep -q "No syntax errors"; then
|
||||
ERRORS=$((ERRORS + 1))
|
||||
fi
|
||||
done < <(find . -name "*.php" -not -path "./.git/*" -not -path "./vendor/*" -print0)
|
||||
echo "PHP lint: ${ERRORS} error(s)"
|
||||
[ "$ERRORS" -eq 0 ] || { echo "::error::PHP syntax errors found"; exit 1; }
|
||||
|
||||
- name: Validate platform manifest
|
||||
run: |
|
||||
PLATFORM="${{ steps.platform.outputs.platform }}"
|
||||
case "$PLATFORM" in
|
||||
joomla)
|
||||
MANIFEST=$(find . -maxdepth 3 -name "*.xml" ! -path "./.git/*" -exec grep -l '<extension' {} \; 2>/dev/null | head -1)
|
||||
if [ -z "$MANIFEST" ]; then
|
||||
echo "::warning::No Joomla manifest found (WaaS site)"
|
||||
exit 0
|
||||
fi
|
||||
echo "Manifest: ${MANIFEST}"
|
||||
if command -v php &> /dev/null; then
|
||||
php -r "libxml_use_internal_errors(true); \$x = simplexml_load_file('$MANIFEST'); if(!\$x){foreach(libxml_get_errors() as \$e) echo \$e->message; exit(1);}" || { echo "::error::Manifest XML is malformed"; exit 1; }
|
||||
fi
|
||||
for ELEMENT in name version description; do
|
||||
grep -q "<${ELEMENT}>" "$MANIFEST" || { echo "::error::Missing <${ELEMENT}> in manifest"; exit 1; }
|
||||
done
|
||||
echo "Joomla manifest valid"
|
||||
;;
|
||||
dolibarr)
|
||||
MOD_FILE=$(find . -maxdepth 4 -name "mod*.class.php" ! -path "./.git/*" -exec grep -l 'extends DolibarrModules' {} \; 2>/dev/null | head -1)
|
||||
if [ -z "$MOD_FILE" ]; then
|
||||
echo "::error::No mod*.class.php found"
|
||||
exit 1
|
||||
fi
|
||||
echo "Dolibarr module: ${MOD_FILE}"
|
||||
;;
|
||||
*)
|
||||
echo "Generic platform — no manifest validation"
|
||||
;;
|
||||
esac
|
||||
|
||||
- name: Check update stream format
|
||||
run: |
|
||||
PLATFORM="${{ steps.platform.outputs.platform }}"
|
||||
case "$PLATFORM" in
|
||||
joomla)
|
||||
if [ -f "updates.xml" ]; then
|
||||
if command -v php &> /dev/null; then
|
||||
php -r "libxml_use_internal_errors(true); \$x = simplexml_load_file('updates.xml'); if(!\$x){foreach(libxml_get_errors() as \$e) echo \$e->message; exit(1);}" || { echo "::error::updates.xml is malformed"; exit 1; }
|
||||
fi
|
||||
echo "updates.xml valid"
|
||||
fi
|
||||
;;
|
||||
dolibarr)
|
||||
[ -f "update.txt" ] && echo "update.txt present" || echo "::warning::No update.txt"
|
||||
;;
|
||||
esac
|
||||
|
||||
- name: Check changelog has unreleased entry
|
||||
run: |
|
||||
if [ ! -f "CHANGELOG.md" ]; then
|
||||
echo "::warning::No CHANGELOG.md found"
|
||||
exit 0
|
||||
fi
|
||||
# Check for content under [Unreleased] section
|
||||
if ! grep -q "## \[Unreleased\]" CHANGELOG.md; then
|
||||
echo "::error::CHANGELOG.md missing [Unreleased] section"
|
||||
exit 1
|
||||
fi
|
||||
# Check there's at least one entry (Added/Changed/Fixed/Removed) under Unreleased
|
||||
UNRELEASED_CONTENT=$(sed -n '/## \[Unreleased\]/,/## \[/p' CHANGELOG.md | grep -cE '^\s*-\s' || true)
|
||||
if [ "$UNRELEASED_CONTENT" -eq 0 ]; then
|
||||
echo "::error::CHANGELOG.md [Unreleased] section has no entries. Add a changelog entry describing your changes."
|
||||
echo "## Changelog Check: Failed" >> $GITHUB_STEP_SUMMARY
|
||||
echo "The \`[Unreleased]\` section in CHANGELOG.md has no entries." >> $GITHUB_STEP_SUMMARY
|
||||
echo "Add a line like \`- Description of your change\` under a heading (\`### Added\`, \`### Changed\`, \`### Fixed\`, etc.)" >> $GITHUB_STEP_SUMMARY
|
||||
exit 1
|
||||
fi
|
||||
echo "Changelog: ${UNRELEASED_CONTENT} entry/entries in [Unreleased]"
|
||||
|
||||
- name: Verify package source
|
||||
run: |
|
||||
SOURCE_DIR="src"
|
||||
[ ! -d "$SOURCE_DIR" ] && SOURCE_DIR="htdocs"
|
||||
if [ ! -d "$SOURCE_DIR" ]; then
|
||||
echo "::warning::No src/ or htdocs/ directory"
|
||||
exit 0
|
||||
fi
|
||||
FILE_COUNT=$(find "$SOURCE_DIR" -type f | wc -l)
|
||||
echo "Source: ${FILE_COUNT} files"
|
||||
[ "$FILE_COUNT" -gt 0 ] || { echo "::error::Source directory is empty"; exit 1; }
|
||||
|
||||
# ── Pre-Release RC Build ─────────────────────────────────────────────────
|
||||
pre-release:
|
||||
name: Build RC Package
|
||||
runs-on: ubuntu-latest
|
||||
needs: [branch-policy, validate]
|
||||
|
||||
steps:
|
||||
- name: Trigger RC pre-release
|
||||
env:
|
||||
GA_TOKEN: ${{ secrets.MOKOGITEA_TOKEN }}
|
||||
REPO: ${{ github.repository }}
|
||||
BRANCH: ${{ github.head_ref }}
|
||||
GITEA_URL: ${{ vars.GITEA_URL || 'https://git.mokoconsulting.tech' }}
|
||||
run: |
|
||||
curl -s -X POST "${GITEA_URL}/api/v1/repos/${REPO}/actions/workflows/pre-release.yml/dispatches" -H "Authorization: token ${GITEA_TOKEN}" -H "Content-Type: application/json" -d "{\"ref\":\"${BRANCH}\",\"inputs\":{\"stability\":\"release-candidate\"}}"
|
||||
echo "### Pre-Release" >> $GITHUB_STEP_SUMMARY
|
||||
echo "Triggered RC build on branch \`${BRANCH}\`" >> $GITHUB_STEP_SUMMARY
|
||||
@@ -0,0 +1,6 @@
|
||||
# Changelog
|
||||
|
||||
## [1.0] — 2026-05-08
|
||||
|
||||
### Added
|
||||
- Initial release
|
||||
@@ -0,0 +1,3 @@
|
||||
# Contributing
|
||||
|
||||
See [standards](https://git.mokoconsulting.tech/MokoConsulting/moko-platform/wiki).
|
||||
@@ -0,0 +1,76 @@
|
||||
# backup-mcp
|
||||
|
||||
MCP server for database and file backups across Dolibarr and Joomla environments
|
||||
|
||||
  
|
||||
|
||||
|
||||
Model Context Protocol server for database dumps, file backups, and Akeeba Backup integration on Joomla sites.
|
||||
|
||||
---
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| **Type** | MCP Server |
|
||||
| **Language** | Node.js |
|
||||
| **Tools** | 11 tools (6 SSH-based + 5 Akeeba API) |
|
||||
| **License** | GPL-3.0-or-later |
|
||||
| **Platform** | [Gitea](https://git.mokoconsulting.tech/MokoConsulting/backup-mcp) (primary) |
|
||||
|
||||
---
|
||||
|
||||
## Overview
|
||||
|
||||
backup-mcp provides two backup strategies through a single MCP server:
|
||||
|
||||
| Strategy | Method | Tools |
|
||||
|----------|--------|-------|
|
||||
| **SSH Backups** | MySQL/PostgreSQL dumps and tar archives via SSH | `backup_database`, `backup_files`, `backup_list`, `backup_prune`, `backup_status`, `backup_list_targets` |
|
||||
| **Akeeba Backups** | Joomla Web Services API (`/api/index.php/v1/akeebabackup/*`) | `akeeba_backup`, `akeeba_list`, `akeeba_download`, `akeeba_delete`, `akeeba_profiles` |
|
||||
|
||||
Each client repo has its own `.backup-mcp.json` scoped via the `BACKUP_MCP_CONFIG` env var in `.mcp.json`.
|
||||
|
||||
---
|
||||
|
||||
## Wiki Pages
|
||||
|
||||
### Reference
|
||||
|
||||
- [Tools Reference](https://git.mokoconsulting.tech/MokoConsulting/backup-mcp/wiki/Tools-Reference) -- all 11 tools with descriptions
|
||||
- [Akeeba Integration](https://git.mokoconsulting.tech/MokoConsulting/backup-mcp/wiki/Akeeba-Integration) -- Akeeba Backup Pro setup, requirements, per-client workspace config
|
||||
|
||||
---
|
||||
|
||||
## Related Wikis
|
||||
|
||||
| Repo | Purpose |
|
||||
|------|---------|
|
||||
| [ssh-mcp](https://git.mokoconsulting.tech/MokoConsulting/ssh-mcp/wiki) | SSH server management (used for SSH-based backups) |
|
||||
| [joomla-api-mcp](https://git.mokoconsulting.tech/MokoConsulting/joomla-api-mcp/wiki) | Joomla Web Services API MCP |
|
||||
| [deploy-mcp](https://git.mokoconsulting.tech/MokoConsulting/deploy-mcp/wiki) | Git-based deployment MCP |
|
||||
|
||||
---
|
||||
|
||||
> **[MokoStandards](https://git.mokoconsulting.tech/MokoConsulting/moko-platform/wiki)** -- central standards hub for all Moko Consulting projects.
|
||||
|
||||
---
|
||||
|
||||
|
||||
|
||||
---
|
||||
|
||||
## Documentation
|
||||
|
||||
Full documentation is available on the [Wiki](https://git.mokoconsulting.tech/MokoConsulting/backup-mcp/wiki).
|
||||
|
||||
## Contributing
|
||||
|
||||
See the wiki for development guidelines and contribution instructions.
|
||||
|
||||
## License
|
||||
|
||||
This project is licensed under the GNU General Public License v3.0 or later -- see the [LICENSE](LICENSE) file.
|
||||
|
||||
---
|
||||
|
||||
*[Moko Consulting](https://mokoconsulting.tech) -- [MokoStandards](https://git.mokoconsulting.tech/MokoConsulting/moko-platform/wiki/Home)*
|
||||
@@ -0,0 +1,3 @@
|
||||
# Security
|
||||
|
||||
Report to hello@mokoconsulting.tech.
|
||||
@@ -0,0 +1,28 @@
|
||||
{
|
||||
"defaultTarget": "dolibarr-db",
|
||||
"targets": {
|
||||
"dolibarr-db": {
|
||||
"name": "dolibarr",
|
||||
"type": "mysql",
|
||||
"sshHost": "crm.mokoconsulting.tech",
|
||||
"sshUser": "mokoconsulting",
|
||||
"sshKeyPath": "~/.ssh/id_ed25519",
|
||||
"database": "dolibarr",
|
||||
"dbUser": "dolibarr",
|
||||
"dbPassword": "your-db-password",
|
||||
"localBackupDir": "~/backups/dolibarr"
|
||||
},
|
||||
"joomla-db": {
|
||||
"name": "joomla",
|
||||
"type": "mysql",
|
||||
"sshHost": "waas.mokoconsulting.tech",
|
||||
"sshUser": "mokoconsulting",
|
||||
"sshKeyPath": "~/.ssh/id_ed25519",
|
||||
"database": "joomla",
|
||||
"dbUser": "joomla",
|
||||
"dbPassword": "your-db-password",
|
||||
"remotePaths": ["/var/www/html/images", "/var/www/html/media"],
|
||||
"localBackupDir": "~/backups/joomla"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"name": "@mokoconsulting/backup-mcp",
|
||||
"version": "1.0.0",
|
||||
"description": "MCP server for database and file backups across Dolibarr and Joomla environments",
|
||||
"type": "module",
|
||||
"main": "dist/index.js",
|
||||
"bin": { "backup-mcp": "dist/index.js" },
|
||||
"scripts": {
|
||||
"build": "tsc",
|
||||
"dev": "tsc --watch",
|
||||
"start": "node dist/index.js",
|
||||
"clean": "rm -rf dist/"
|
||||
},
|
||||
"dependencies": {
|
||||
"@modelcontextprotocol/sdk": "^1.12.1",
|
||||
"zod": "^3.24.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^22.15.3",
|
||||
"typescript": "^5.8.3"
|
||||
},
|
||||
"engines": { "node": ">=20.0.0" },
|
||||
"license": "GPL-3.0-or-later",
|
||||
"author": "Moko Consulting <hello@mokoconsulting.tech>"
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
import * as https from 'node:https';
|
||||
import * as http from 'node:http';
|
||||
import { mkdirSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import type { BackupTarget, BackupResult, AkeebaBackupRecord } from './types.js';
|
||||
|
||||
const TIMEOUT_MS = 300_000; // 5 min for backup operations
|
||||
|
||||
/**
|
||||
* Akeeba Backup client using Joomla Web Services API
|
||||
* Endpoint: /api/index.php/v1/akeebabackup/*
|
||||
* Auth: Bearer token (Joomla API token)
|
||||
*/
|
||||
export class AkeebaClient {
|
||||
private readonly target: BackupTarget;
|
||||
private readonly baseUrl: string;
|
||||
private readonly headers: Record<string, string>;
|
||||
|
||||
constructor(target: BackupTarget) {
|
||||
this.target = target;
|
||||
const site = (target.siteUrl ?? '').replace(/\/+$/, '');
|
||||
this.baseUrl = `${site}/api/index.php/v1/akeebabackup`;
|
||||
this.headers = {
|
||||
'Authorization': `Bearer ${target.secretWord}`,
|
||||
'Content-Type': 'application/json',
|
||||
'Accept': 'application/vnd.api+json',
|
||||
};
|
||||
}
|
||||
|
||||
private request(url: string, method: string, body?: unknown): Promise<{ status: number; data: unknown }> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const parsed = new URL(url);
|
||||
const mod = parsed.protocol === 'https:' ? https : http;
|
||||
const payload = body ? JSON.stringify(body) : undefined;
|
||||
const opts: http.RequestOptions = {
|
||||
hostname: parsed.hostname,
|
||||
port: parsed.port || (parsed.protocol === 'https:' ? 443 : 80),
|
||||
path: parsed.pathname + parsed.search,
|
||||
method,
|
||||
headers: { ...this.headers, ...(payload ? { 'Content-Length': Buffer.byteLength(payload) } : {}) },
|
||||
timeout: TIMEOUT_MS,
|
||||
};
|
||||
|
||||
const req = mod.request(opts, (res) => {
|
||||
const chunks: Buffer[] = [];
|
||||
res.on('data', (c: Buffer) => chunks.push(c));
|
||||
res.on('end', () => {
|
||||
const raw = Buffer.concat(chunks).toString();
|
||||
let data: unknown;
|
||||
try { data = JSON.parse(raw); } catch { data = raw; }
|
||||
resolve({ status: res.statusCode ?? 0, data });
|
||||
});
|
||||
});
|
||||
req.on('error', reject);
|
||||
req.on('timeout', () => { req.destroy(); reject(new Error('timeout')); });
|
||||
if (payload) req.write(payload);
|
||||
req.end();
|
||||
});
|
||||
}
|
||||
|
||||
async startBackup(profileId?: number, description?: string): Promise<BackupResult> {
|
||||
try {
|
||||
const profile = profileId ?? this.target.profileId ?? 1;
|
||||
const desc = description ?? `MCP backup ${new Date().toISOString()}`;
|
||||
const res = await this.request(`${this.baseUrl}/backup`, 'POST', {
|
||||
profile: profile,
|
||||
description: desc,
|
||||
});
|
||||
|
||||
if (res.status >= 400) {
|
||||
return { success: false, message: `Akeeba backup failed: ${JSON.stringify(res.data)}` };
|
||||
}
|
||||
|
||||
return { success: true, message: `Akeeba backup started (profile ${profile}): ${desc}` };
|
||||
} catch (err) {
|
||||
return { success: false, message: `Akeeba backup failed: ${err}` };
|
||||
}
|
||||
}
|
||||
|
||||
async listBackups(limit = 20): Promise<AkeebaBackupRecord[]> {
|
||||
try {
|
||||
const res = await this.request(`${this.baseUrl}/backups?page[limit]=${limit}`, 'GET');
|
||||
if (res.status >= 400) return [];
|
||||
const body = res.data as { data?: Array<{ attributes: AkeebaBackupRecord }> };
|
||||
return (body.data ?? []).map(d => d.attributes);
|
||||
} catch { return []; }
|
||||
}
|
||||
|
||||
async deleteBackup(id: string): Promise<BackupResult> {
|
||||
try {
|
||||
const res = await this.request(`${this.baseUrl}/backup/${id}`, 'DELETE');
|
||||
if (res.status >= 400) return { success: false, message: `Delete failed: ${JSON.stringify(res.data)}` };
|
||||
return { success: true, message: `Deleted Akeeba backup ${id}` };
|
||||
} catch (err) {
|
||||
return { success: false, message: `Delete failed: ${err}` };
|
||||
}
|
||||
}
|
||||
|
||||
async downloadBackup(id: string): Promise<BackupResult> {
|
||||
try {
|
||||
mkdirSync(this.target.localBackupDir, { recursive: true });
|
||||
const res = await this.request(`${this.baseUrl}/backup/${id}/download`, 'GET');
|
||||
|
||||
if (res.status >= 400) {
|
||||
return { success: false, message: `Download failed: ${JSON.stringify(res.data)}` };
|
||||
}
|
||||
|
||||
const filename = `${this.target.name}-akeeba-${id}-${new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19)}.jpa`;
|
||||
const localFile = join(this.target.localBackupDir, filename);
|
||||
const fs = await import('node:fs/promises');
|
||||
|
||||
if (typeof res.data === 'string') {
|
||||
const buffer = Buffer.from(res.data, 'base64');
|
||||
await fs.writeFile(localFile, buffer);
|
||||
return { success: true, message: `Downloaded backup ${id}`, filePath: localFile, sizeBytes: buffer.length };
|
||||
}
|
||||
|
||||
await fs.writeFile(localFile, JSON.stringify(res.data));
|
||||
return { success: true, message: `Downloaded backup ${id}`, filePath: localFile };
|
||||
} catch (err) {
|
||||
return { success: false, message: `Download failed: ${err}` };
|
||||
}
|
||||
}
|
||||
|
||||
async getProfiles(): Promise<unknown> {
|
||||
const res = await this.request(`${this.baseUrl}/profiles`, 'GET');
|
||||
return res.data;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
// Uses execFile (safe, no shell interpolation) for all SSH/command execution
|
||||
import { execFile as execFileCb } from 'node:child_process';
|
||||
import { promisify } from 'node:util';
|
||||
import { mkdirSync, readdirSync, statSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import type { BackupTarget, BackupResult } from './types.js';
|
||||
|
||||
const execFile = promisify(execFileCb);
|
||||
const TIMEOUT = 300_000; // 5 min for large backups
|
||||
|
||||
export class BackupClient {
|
||||
private readonly target: BackupTarget;
|
||||
|
||||
constructor(target: BackupTarget) {
|
||||
this.target = target;
|
||||
mkdirSync(target.localBackupDir, { recursive: true });
|
||||
}
|
||||
|
||||
private sshArgs(cmd: string): string[] {
|
||||
const args = ['-o', 'StrictHostKeyChecking=accept-new', '-o', 'BatchMode=yes'];
|
||||
if (this.target.sshPort) args.push('-p', String(this.target.sshPort));
|
||||
if (this.target.sshKeyPath) args.push('-i', this.target.sshKeyPath);
|
||||
args.push(`${this.target.sshUser}@${this.target.sshHost}`, cmd);
|
||||
return args;
|
||||
}
|
||||
|
||||
private timestamp(): string {
|
||||
return new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19);
|
||||
}
|
||||
|
||||
async dumpDatabase(): Promise<BackupResult> {
|
||||
const ts = this.timestamp();
|
||||
const filename = `${this.target.name}-db-${ts}.sql.gz`;
|
||||
const localFile = join(this.target.localBackupDir, filename);
|
||||
const dumpCmd = this.target.type === 'mysql'
|
||||
? `mysqldump -u ${this.target.dbUser} -p'${this.target.dbPassword}' ${this.target.database} | gzip`
|
||||
: `PGPASSWORD='${this.target.dbPassword}' pg_dump -U ${this.target.dbUser} ${this.target.database} | gzip`;
|
||||
|
||||
try {
|
||||
const { stdout } = await execFile('ssh', this.sshArgs(dumpCmd), { timeout: TIMEOUT, maxBuffer: 500 * 1024 * 1024 });
|
||||
const fs = await import('node:fs/promises');
|
||||
await fs.writeFile(localFile, stdout, 'binary');
|
||||
const stat = statSync(localFile);
|
||||
return { success: true, message: `Database backup: ${filename}`, filePath: localFile, sizeBytes: stat.size };
|
||||
} catch (err) {
|
||||
return { success: false, message: `Database backup failed: ${err}` };
|
||||
}
|
||||
}
|
||||
|
||||
async backupFiles(): Promise<BackupResult> {
|
||||
const ts = this.timestamp();
|
||||
const filename = `${this.target.name}-files-${ts}.tar.gz`;
|
||||
const localFile = join(this.target.localBackupDir, filename);
|
||||
const paths = (this.target.remotePaths ?? []).join(' ');
|
||||
const tarCmd = `tar czf - ${paths}`;
|
||||
|
||||
try {
|
||||
const { stdout } = await execFile('ssh', this.sshArgs(tarCmd), { timeout: TIMEOUT, maxBuffer: 500 * 1024 * 1024 });
|
||||
const fs = await import('node:fs/promises');
|
||||
await fs.writeFile(localFile, stdout, 'binary');
|
||||
const stat = statSync(localFile);
|
||||
return { success: true, message: `File backup: ${filename}`, filePath: localFile, sizeBytes: stat.size };
|
||||
} catch (err) {
|
||||
return { success: false, message: `File backup failed: ${err}` };
|
||||
}
|
||||
}
|
||||
|
||||
listBackups(): { name: string; size: number; date: Date }[] {
|
||||
try {
|
||||
return readdirSync(this.target.localBackupDir)
|
||||
.filter(f => f.startsWith(this.target.name))
|
||||
.map(f => {
|
||||
const stat = statSync(join(this.target.localBackupDir, f));
|
||||
return { name: f, size: stat.size, date: stat.mtime };
|
||||
})
|
||||
.sort((a, b) => b.date.getTime() - a.date.getTime());
|
||||
} catch { return []; }
|
||||
}
|
||||
|
||||
async parseDolibarrConf(): Promise<{ dbHost: string; dbName: string; dbUser: string; dbPass: string; dataRoot: string }> {
|
||||
const confPath = this.target.confPath ?? '/htdocs/conf/conf.php';
|
||||
const cmd = `cat ${confPath}`;
|
||||
const { stdout } = await execFile('ssh', this.sshArgs(cmd), { timeout: TIMEOUT });
|
||||
const get = (key: string) => {
|
||||
const m = stdout.match(new RegExp(`\\$${key}\\s*=\\s*['"]([^'"]*)`));
|
||||
if (!m) throw new Error(`Could not find $${key} in ${confPath}`);
|
||||
return m[1];
|
||||
};
|
||||
return {
|
||||
dbHost: get('dolibarr_main_db_host'),
|
||||
dbName: get('dolibarr_main_db_name'),
|
||||
dbUser: get('dolibarr_main_db_user'),
|
||||
dbPass: get('dolibarr_main_db_pass'),
|
||||
dataRoot: get('dolibarr_main_data_root'),
|
||||
};
|
||||
}
|
||||
|
||||
async dumpDolibarr(): Promise<BackupResult> {
|
||||
const ts = this.timestamp();
|
||||
const conf = await this.parseDolibarrConf();
|
||||
|
||||
// 1. Database dump
|
||||
const dbFilename = `${this.target.name}-db-${ts}.sql.gz`;
|
||||
const dbLocalFile = join(this.target.localBackupDir, dbFilename);
|
||||
const dumpCmd = `mysqldump -h ${conf.dbHost} -u ${conf.dbUser} -p'${conf.dbPass}' ${conf.dbName} | gzip`;
|
||||
try {
|
||||
const { stdout: dbOut } = await execFile('ssh', this.sshArgs(dumpCmd), { timeout: TIMEOUT, maxBuffer: 500 * 1024 * 1024 });
|
||||
const fs = await import('node:fs/promises');
|
||||
await fs.writeFile(dbLocalFile, dbOut, 'binary');
|
||||
|
||||
// 2. Documents + custom directories
|
||||
const filesFilename = `${this.target.name}-files-${ts}.tar.gz`;
|
||||
const filesLocalFile = join(this.target.localBackupDir, filesFilename);
|
||||
const customDir = conf.dataRoot.replace(/\/documents\/?$/, '/custom');
|
||||
const tarCmd = `tar czf - ${conf.dataRoot} ${customDir} 2>/dev/null`;
|
||||
const { stdout: tarOut } = await execFile('ssh', this.sshArgs(tarCmd), { timeout: TIMEOUT, maxBuffer: 500 * 1024 * 1024 });
|
||||
await fs.writeFile(filesLocalFile, tarOut, 'binary');
|
||||
|
||||
const dbStat = statSync(dbLocalFile);
|
||||
const filesStat = statSync(filesLocalFile);
|
||||
return {
|
||||
success: true,
|
||||
message: `Dolibarr backup complete:\n DB: ${dbFilename} (${(dbStat.size / 1024 / 1024).toFixed(1)} MB)\n Files: ${filesFilename} (${(filesStat.size / 1024 / 1024).toFixed(1)} MB)`,
|
||||
filePath: dbLocalFile,
|
||||
sizeBytes: dbStat.size + filesStat.size,
|
||||
};
|
||||
} catch (err) {
|
||||
return { success: false, message: `Dolibarr backup failed: ${err}` };
|
||||
}
|
||||
}
|
||||
|
||||
async pruneBackups(olderThanDays: number): Promise<BackupResult> {
|
||||
const cutoff = Date.now() - olderThanDays * 86400000;
|
||||
const fs = await import('node:fs/promises');
|
||||
const backups = this.listBackups().filter(b => b.date.getTime() < cutoff);
|
||||
for (const b of backups) {
|
||||
await fs.unlink(join(this.target.localBackupDir, b.name));
|
||||
}
|
||||
return { success: true, message: `Pruned ${backups.length} backups older than ${olderThanDays} days` };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import { readFile } from 'node:fs/promises';
|
||||
import { resolve } from 'node:path';
|
||||
import { homedir } from 'node:os';
|
||||
import type { BackupConfig, BackupTarget } from './types.js';
|
||||
|
||||
const CONFIG_FILENAME = '.mcp_mokobackup.json';
|
||||
|
||||
export async function loadConfig(): Promise<BackupConfig> {
|
||||
const configPath = process.env.BACKUP_MCP_CONFIG
|
||||
? resolve(process.env.BACKUP_MCP_CONFIG)
|
||||
: resolve(homedir(), CONFIG_FILENAME);
|
||||
|
||||
const raw = await readFile(configPath, 'utf-8');
|
||||
const parsed = JSON.parse(raw) as Partial<BackupConfig>;
|
||||
if (!parsed.targets || Object.keys(parsed.targets).length === 0) {
|
||||
throw new Error(`No targets in ${configPath}`);
|
||||
}
|
||||
return {
|
||||
targets: parsed.targets,
|
||||
defaultTarget: parsed.defaultTarget ?? Object.keys(parsed.targets)[0],
|
||||
};
|
||||
}
|
||||
|
||||
export function getTarget(config: BackupConfig, name?: string): BackupTarget {
|
||||
const key = name ?? config.defaultTarget;
|
||||
const target = config.targets[key];
|
||||
if (!target) throw new Error(`Target "${key}" not found. Available: ${Object.keys(config.targets).join(', ')}`);
|
||||
return target;
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
#!/usr/bin/env node
|
||||
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
||||
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
|
||||
import { z } from 'zod';
|
||||
import { loadConfig, getTarget } from './config.js';
|
||||
import { BackupClient } from './client.js';
|
||||
import { AkeebaClient } from './akeeba.js';
|
||||
import { MokoBackupClient } from './mokobackup.js';
|
||||
import type { BackupConfig } from './types.js';
|
||||
|
||||
let config: BackupConfig;
|
||||
|
||||
function clientFor(t?: string): BackupClient { return new BackupClient(getTarget(config, t)); }
|
||||
|
||||
/**
|
||||
* Return the appropriate Joomla backup client based on target type.
|
||||
* MokoBackup and Akeeba have the same interface — auto-detect which to use.
|
||||
*/
|
||||
function joomlaBackupFor(t?: string): AkeebaClient | MokoBackupClient {
|
||||
const target = getTarget(config, t);
|
||||
if (target.type === 'mokobackup') return new MokoBackupClient(target);
|
||||
return new AkeebaClient(target);
|
||||
}
|
||||
|
||||
// Keep legacy function for backwards compatibility
|
||||
function akeebaFor(t?: string): AkeebaClient | MokoBackupClient { return joomlaBackupFor(t); }
|
||||
function text(s: string) { return { content: [{ type: 'text' as const, text: s }] }; }
|
||||
|
||||
const T = { target: z.string().optional().describe('Backup target name') };
|
||||
|
||||
const server = new McpServer({ name: 'backup-mcp', version: '1.0.0' });
|
||||
|
||||
// ── SSH-based backups ────────────────────────────────────────────────
|
||||
|
||||
server.tool('backup_database', 'Dump database (MySQL/PostgreSQL) to local backup via SSH', { ...T },
|
||||
async ({ target }) => { const r = await clientFor(target).dumpDatabase(); return text(JSON.stringify(r, null, 2)); });
|
||||
|
||||
server.tool('backup_files', 'Backup remote directories to local tar.gz via SSH', { ...T },
|
||||
async ({ target }) => { const r = await clientFor(target).backupFiles(); return text(JSON.stringify(r, null, 2)); });
|
||||
|
||||
server.tool('backup_list', 'List available local backups with sizes and dates', { ...T },
|
||||
async ({ target }) => {
|
||||
const backups = clientFor(target).listBackups();
|
||||
if (backups.length === 0) return text('No backups found');
|
||||
return text(backups.map(b => `${b.name} ${(b.size / 1024 / 1024).toFixed(1)} MB ${b.date.toISOString()}`).join('\n'));
|
||||
});
|
||||
|
||||
server.tool('backup_prune', 'Delete local backups older than specified days', {
|
||||
...T, days: z.number().describe('Delete backups older than this many days'),
|
||||
}, async ({ target, days }) => { const r = await clientFor(target).pruneBackups(days); return text(r.message); });
|
||||
|
||||
server.tool('backup_status', 'Show backup disk usage and last backup info', { ...T },
|
||||
async ({ target }) => {
|
||||
const backups = clientFor(target).listBackups();
|
||||
const totalMB = backups.reduce((s, b) => s + b.size, 0) / 1024 / 1024;
|
||||
const last = backups[0];
|
||||
return text(`Backups: ${backups.length}\nTotal size: ${totalMB.toFixed(1)} MB\nLast backup: ${last ? `${last.name} (${last.date.toISOString()})` : 'none'}`);
|
||||
});
|
||||
|
||||
// ── Akeeba Backup (Joomla sites) ─────────────────────────────────────
|
||||
|
||||
server.tool('akeeba_backup', 'Start a backup on a Joomla site (Akeeba or MokoBackup — auto-detected by target type)', {
|
||||
...T, description: z.string().optional().describe('Backup description'),
|
||||
}, async ({ target, description }) => {
|
||||
const r = await akeebaFor(target).startBackup(undefined, description);
|
||||
return text(JSON.stringify(r, null, 2));
|
||||
});
|
||||
|
||||
server.tool('akeeba_list', 'List backup records on a Joomla site (Akeeba or MokoBackup)', {
|
||||
...T, limit: z.number().optional().describe('Number of records (default 20)'),
|
||||
}, async ({ target, limit }) => {
|
||||
const records = await akeebaFor(target).listBackups(limit ?? 20);
|
||||
if (records.length === 0) return text('No backups found');
|
||||
return text(records.map(r =>
|
||||
`#${r.id} ${r.status} ${r.description} ${r.backupstart} ${r.archivename} ${(r.total_size / 1024 / 1024).toFixed(1)} MB`
|
||||
).join('\n'));
|
||||
});
|
||||
|
||||
server.tool('akeeba_download', 'Download a backup archive from a Joomla site (Akeeba or MokoBackup)', {
|
||||
...T, backup_id: z.string().describe('Backup record ID'),
|
||||
}, async ({ target, backup_id }) => {
|
||||
const r = await akeebaFor(target).downloadBackup(backup_id);
|
||||
return text(JSON.stringify(r, null, 2));
|
||||
});
|
||||
|
||||
server.tool('akeeba_delete', 'Delete a backup record from a Joomla site (Akeeba or MokoBackup)', {
|
||||
...T, backup_id: z.string().describe('Backup record ID'),
|
||||
}, async ({ target, backup_id }) => {
|
||||
const r = await akeebaFor(target).deleteBackup(backup_id);
|
||||
return text(r.message);
|
||||
});
|
||||
|
||||
server.tool('akeeba_profiles', 'List backup profiles on a Joomla site (Akeeba or MokoBackup)', { ...T },
|
||||
async ({ target }) => {
|
||||
const profiles = await akeebaFor(target).getProfiles();
|
||||
return text(JSON.stringify(profiles, null, 2));
|
||||
});
|
||||
|
||||
|
||||
// ── Dolibarr Backup ─────────────────────────────────────────────────
|
||||
|
||||
server.tool('dolibarr_backup', 'Backup a Dolibarr instance (DB + documents + custom) by reading conf.php via SSH', {
|
||||
...T,
|
||||
}, async ({ target }) => {
|
||||
const r = await clientFor(target).dumpDolibarr();
|
||||
return text(JSON.stringify(r, null, 2));
|
||||
});
|
||||
|
||||
server.tool('dolibarr_conf', 'Read and display Dolibarr database settings from conf.php via SSH (no passwords shown)', {
|
||||
...T,
|
||||
}, async ({ target }) => {
|
||||
const conf = await clientFor(target).parseDolibarrConf();
|
||||
return text(`DB Host: ${conf.dbHost}\nDB Name: ${conf.dbName}\nDB User: ${conf.dbUser}\nData Root: ${conf.dataRoot}`);
|
||||
});
|
||||
|
||||
// ── General ──────────────────────────────────────────────────────────
|
||||
|
||||
server.tool('backup_list_targets', 'List all configured backup targets', {},
|
||||
async () => text(Object.entries(config.targets).map(([k, v]) => {
|
||||
const loc = (v.type === 'akeeba' || v.type === 'mokobackup') ? v.siteUrl : `${v.sshUser}@${v.sshHost}`;
|
||||
return `${k}${k === config.defaultTarget ? ' (default)' : ''}: ${v.type} @ ${loc} → ${v.localBackupDir}`;
|
||||
}).join('\n')));
|
||||
|
||||
async function main() {
|
||||
config = await loadConfig();
|
||||
await server.connect(new StdioServerTransport());
|
||||
}
|
||||
main().catch(err => { console.error(err); process.exit(1); });
|
||||
@@ -0,0 +1,144 @@
|
||||
import * as https from 'node:https';
|
||||
import * as http from 'node:http';
|
||||
import { mkdirSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import type { BackupTarget, BackupResult, AkeebaBackupRecord } from './types.js';
|
||||
|
||||
const TIMEOUT_MS = 300_000; // 5 min for backup operations
|
||||
|
||||
/**
|
||||
* MokoJoomBackup client using Joomla Web Services API
|
||||
* Endpoint: /api/index.php/v1/mokobackup/*
|
||||
* Auth: Bearer token (Joomla API token)
|
||||
*
|
||||
* Wire-compatible with AkeebaClient — same interface, different base URL.
|
||||
* The existing akeeba_* MCP tools work with both backends.
|
||||
*/
|
||||
export class MokoBackupClient {
|
||||
private readonly target: BackupTarget;
|
||||
private readonly baseUrl: string;
|
||||
private readonly headers: Record<string, string>;
|
||||
|
||||
constructor(target: BackupTarget) {
|
||||
this.target = target;
|
||||
const site = (target.siteUrl ?? '').replace(/\/+$/, '');
|
||||
this.baseUrl = `${site}/api/index.php/v1/mokobackup`;
|
||||
this.headers = {
|
||||
'Authorization': `Bearer ${target.secretWord}`,
|
||||
'Content-Type': 'application/json',
|
||||
'Accept': 'application/vnd.api+json',
|
||||
};
|
||||
}
|
||||
|
||||
private request(url: string, method: string, body?: unknown): Promise<{ status: number; data: unknown }> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const parsed = new URL(url);
|
||||
const mod = parsed.protocol === 'https:' ? https : http;
|
||||
const payload = body ? JSON.stringify(body) : undefined;
|
||||
const opts: http.RequestOptions = {
|
||||
hostname: parsed.hostname,
|
||||
port: parsed.port || (parsed.protocol === 'https:' ? 443 : 80),
|
||||
path: parsed.pathname + parsed.search,
|
||||
method,
|
||||
headers: { ...this.headers, ...(payload ? { 'Content-Length': Buffer.byteLength(payload) } : {}) },
|
||||
timeout: TIMEOUT_MS,
|
||||
};
|
||||
|
||||
const req = mod.request(opts, (res) => {
|
||||
const chunks: Buffer[] = [];
|
||||
res.on('data', (c: Buffer) => chunks.push(c));
|
||||
res.on('end', () => {
|
||||
const raw = Buffer.concat(chunks).toString();
|
||||
let data: unknown;
|
||||
try { data = JSON.parse(raw); } catch { data = raw; }
|
||||
resolve({ status: res.statusCode ?? 0, data });
|
||||
});
|
||||
});
|
||||
req.on('error', reject);
|
||||
req.on('timeout', () => { req.destroy(); reject(new Error('timeout')); });
|
||||
if (payload) req.write(payload);
|
||||
req.end();
|
||||
});
|
||||
}
|
||||
|
||||
async startBackup(profileId?: number, description?: string): Promise<BackupResult> {
|
||||
try {
|
||||
const profile = profileId ?? this.target.profileId ?? 1;
|
||||
const desc = description ?? `MCP backup ${new Date().toISOString()}`;
|
||||
const res = await this.request(`${this.baseUrl}/backup`, 'POST', {
|
||||
profile: profile,
|
||||
description: desc,
|
||||
});
|
||||
|
||||
if (res.status >= 400) {
|
||||
return { success: false, message: `MokoBackup failed: ${JSON.stringify(res.data)}` };
|
||||
}
|
||||
|
||||
// MokoBackup returns {data: {success, message, record_id}}
|
||||
const body = res.data as { data?: { success: boolean; message: string } };
|
||||
const msg = body?.data?.message ?? `Backup started (profile ${profile}): ${desc}`;
|
||||
|
||||
return { success: true, message: msg };
|
||||
} catch (err) {
|
||||
return { success: false, message: `MokoBackup failed: ${err}` };
|
||||
}
|
||||
}
|
||||
|
||||
async listBackups(limit = 20): Promise<AkeebaBackupRecord[]> {
|
||||
try {
|
||||
const res = await this.request(`${this.baseUrl}/backups?page[limit]=${limit}`, 'GET');
|
||||
if (res.status >= 400) return [];
|
||||
const body = res.data as { data?: Array<{ attributes: AkeebaBackupRecord }> };
|
||||
return (body.data ?? []).map(d => d.attributes);
|
||||
} catch { return []; }
|
||||
}
|
||||
|
||||
async deleteBackup(id: string): Promise<BackupResult> {
|
||||
try {
|
||||
const res = await this.request(`${this.baseUrl}/backup/${id}`, 'DELETE');
|
||||
if (res.status >= 400) return { success: false, message: `Delete failed: ${JSON.stringify(res.data)}` };
|
||||
return { success: true, message: `Deleted MokoBackup record ${id}` };
|
||||
} catch (err) {
|
||||
return { success: false, message: `Delete failed: ${err}` };
|
||||
}
|
||||
}
|
||||
|
||||
async downloadBackup(id: string): Promise<BackupResult> {
|
||||
try {
|
||||
mkdirSync(this.target.localBackupDir, { recursive: true });
|
||||
const res = await this.request(`${this.baseUrl}/backup/${id}/download`, 'GET');
|
||||
|
||||
if (res.status >= 400) {
|
||||
return { success: false, message: `Download failed: ${JSON.stringify(res.data)}` };
|
||||
}
|
||||
|
||||
const filename = `${this.target.name}-mokobackup-${id}-${new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19)}.zip`;
|
||||
const localFile = join(this.target.localBackupDir, filename);
|
||||
const fs = await import('node:fs/promises');
|
||||
|
||||
if (typeof res.data === 'string') {
|
||||
const buffer = Buffer.from(res.data, 'base64');
|
||||
await fs.writeFile(localFile, buffer);
|
||||
return { success: true, message: `Downloaded backup ${id}`, filePath: localFile, sizeBytes: buffer.length };
|
||||
}
|
||||
|
||||
// Handle JSON-wrapped response
|
||||
const body = res.data as { data?: string };
|
||||
if (body?.data && typeof body.data === 'string') {
|
||||
const buffer = Buffer.from(body.data, 'base64');
|
||||
await fs.writeFile(localFile, buffer);
|
||||
return { success: true, message: `Downloaded backup ${id}`, filePath: localFile, sizeBytes: buffer.length };
|
||||
}
|
||||
|
||||
await fs.writeFile(localFile, JSON.stringify(res.data));
|
||||
return { success: true, message: `Downloaded backup ${id}`, filePath: localFile };
|
||||
} catch (err) {
|
||||
return { success: false, message: `Download failed: ${err}` };
|
||||
}
|
||||
}
|
||||
|
||||
async getProfiles(): Promise<unknown> {
|
||||
const res = await this.request(`${this.baseUrl}/profiles`, 'GET');
|
||||
return res.data;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
export interface BackupTarget {
|
||||
name: string;
|
||||
type: 'mysql' | 'postgres' | 'files' | 'akeeba' | 'mokobackup' | 'dolibarr';
|
||||
sshHost?: string;
|
||||
sshPort?: number;
|
||||
sshUser?: string;
|
||||
sshKeyPath?: string;
|
||||
database?: string;
|
||||
dbUser?: string;
|
||||
dbPassword?: string;
|
||||
remotePaths?: string[];
|
||||
localBackupDir: string;
|
||||
/** Akeeba/MokoBackup: site base URL (e.g. https://clarksvillefurs.com) */
|
||||
siteUrl?: string;
|
||||
/** Akeeba/MokoBackup: Joomla API token (Bearer auth) */
|
||||
secretWord?: string;
|
||||
/** Akeeba/MokoBackup: backup profile ID (default 1) */
|
||||
profileId?: number;
|
||||
/** Dolibarr-specific: path to conf/conf.php on the remote server */
|
||||
confPath?: string;
|
||||
}
|
||||
|
||||
export interface BackupConfig {
|
||||
targets: Record<string, BackupTarget>;
|
||||
defaultTarget: string;
|
||||
}
|
||||
|
||||
export interface BackupResult {
|
||||
success: boolean;
|
||||
message: string;
|
||||
filePath?: string;
|
||||
sizeBytes?: number;
|
||||
}
|
||||
|
||||
export interface AkeebaBackupRecord {
|
||||
id: string;
|
||||
description: string;
|
||||
status: string;
|
||||
origin: string;
|
||||
type: string;
|
||||
profile_id: number;
|
||||
archivename: string;
|
||||
absolute_path: string;
|
||||
multipart: number;
|
||||
tag: string;
|
||||
backupstart: string;
|
||||
backupend: string;
|
||||
filesexist: number;
|
||||
remote_filename: string;
|
||||
total_size: number;
|
||||
}
|
||||
|
||||
export interface AkeebaApiResponse {
|
||||
body: {
|
||||
status: number;
|
||||
data: unknown;
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "Node16",
|
||||
"moduleResolution": "Node16",
|
||||
"outDir": "./dist",
|
||||
"rootDir": "./src",
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"resolveJsonModule": true,
|
||||
"declaration": true,
|
||||
"declarationMap": true,
|
||||
"sourceMap": true
|
||||
},
|
||||
"include": ["src/**/*"],
|
||||
"exclude": ["node_modules", "dist"]
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
# mcp_mokocrm
|
||||
|
||||
MCP server for Dolibarr ERP/CRM REST API operations — third parties, invoices, proposals, projects, tasks, contacts, and business management.
|
||||
|
||||
## Quick Reference
|
||||
|
||||
| Field | Value |
|
||||
|---|---|
|
||||
| **Package** | `@mokoconsulting/mcp-mokocrm-api` |
|
||||
| **Entry** | `dist/index.js` |
|
||||
| **Config** | `~/.mcp_mokocrm.json` (override: `DOLIBARR_API_MCP_CONFIG` env var) |
|
||||
| **Language** | TypeScript |
|
||||
| **Branch** | develop on `dev`, merge to `main` (protected) |
|
||||
|
||||
## Commands
|
||||
|
||||
```bash
|
||||
npm install # Install dependencies
|
||||
npm run build # Compile TypeScript → dist/
|
||||
npm run dev # Watch mode
|
||||
```
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
src/
|
||||
├── index.ts # MCP server entry, tool registration
|
||||
├── config.ts # Loads ~/.mcp_mokocrm.json, resolves connections
|
||||
├── client.ts # Dolibarr REST API client wrapper
|
||||
├── tools/ # Individual tool implementations
|
||||
└── types.ts # DolibarrConfig, DolibarrConnection types
|
||||
```
|
||||
|
||||
- Config defines **connections** — each is a Dolibarr instance with URL + API key
|
||||
- Default connections: production (crm.mokoconsulting.tech), dev (crm.dev.mokoconsulting.tech)
|
||||
- No demo environment for CRM
|
||||
|
||||
## Rules
|
||||
|
||||
- **Never commit** `.claude/`, `.mcp.json`, `TODO.md`, `*.min.css`/`*.min.js`
|
||||
- **Attribution**: `Authored-by: Moko Consulting`
|
||||
- **Workflow directory**: `.mokogitea/` (not `.gitea/` or `.github/`)
|
||||
- **Wiki**: documentation lives in the Gitea wiki, not `docs/` files
|
||||
- **Standards**: [MokoStandards](https://git.mokoconsulting.tech/MokoConsulting/moko-platform/wiki/Home)
|
||||
@@ -0,0 +1,62 @@
|
||||
<!-- 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
|
||||
|
||||
This program is free software; you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation; either version 3 of the License, or (at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the IMPLIED WARRANTY of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU General Public License (./LICENSE).
|
||||
|
||||
# FILE INFORMATION
|
||||
DEFGROUP: dolibarr-api-mcp.Documentation
|
||||
INGROUP: dolibarr-api-mcp
|
||||
REPO: https://git.mokoconsulting.tech/MokoConsulting/dolibarr-api-mcp
|
||||
VERSION: 0.0.0
|
||||
PATH: ./CHANGELOG.md
|
||||
BRIEF: Version history and change log
|
||||
-->
|
||||
|
||||
# Changelog
|
||||
|
||||
All notable changes to this project will be documented in this file.
|
||||
|
||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
||||
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [0.0] - 2026-05-07
|
||||
|
||||
### Added
|
||||
- Initial MCP server with Dolibarr REST API tools
|
||||
- Third party management (list, get, create, update, delete)
|
||||
- Invoice management (list, get, create, add lines, validate, set paid)
|
||||
- Commercial proposal management (list, get, create, add lines, validate, close)
|
||||
- Customer order management (list, get, create, validate)
|
||||
- Product and service catalog (list, get, create, update, stock levels)
|
||||
- Contact/address management (list, get)
|
||||
- Project management (list, get, create)
|
||||
- Task management (list, get)
|
||||
- User management (list, get)
|
||||
- Category management (list by type)
|
||||
- Bank account listing
|
||||
- Supplier invoice listing
|
||||
- Supplier order listing
|
||||
- Warehouse listing
|
||||
- Company setup and system status endpoints
|
||||
- Raw API passthrough for any Dolibarr endpoint
|
||||
- Multi-connection support with named connections
|
||||
- Interactive setup wizard (`npm run setup`)
|
||||
- SQL filter builder (`buildSqlFilter`, `searchFilter`) for safe query construction
|
||||
- Full documentation: README, INSTALLATION, ARCHITECTURE, API reference
|
||||
- MokoStandards-compliant project structure
|
||||
- 12 Gitea Actions CI/CD workflows
|
||||
|
||||
## Revision History
|
||||
|
||||
| Date | Version | Author | Notes |
|
||||
| --- | --- | --- | --- |
|
||||
| 2026-05-07 | 0.0.1 | jmiller | Initial release |
|
||||
@@ -0,0 +1,161 @@
|
||||
<!-- 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
|
||||
|
||||
This program is free software; you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation; either version 3 of the License, or (at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the IMPLIED WARRANTY of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU General Public License (./LICENSE).
|
||||
|
||||
# FILE INFORMATION
|
||||
DEFGROUP: dolibarr-api-mcp.Documentation
|
||||
INGROUP: dolibarr-api-mcp
|
||||
REPO: https://git.mokoconsulting.tech/MokoConsulting/dolibarr-api-mcp
|
||||
VERSION: 01.00.00
|
||||
PATH: ./CONTRIBUTING.md
|
||||
BRIEF: Contribution guidelines for the project
|
||||
-->
|
||||
|
||||
# Contributing to dolibarr-api-mcp
|
||||
|
||||
We appreciate your interest in contributing to this project! This document provides guidelines for contributing.
|
||||
|
||||
## Table of Contents
|
||||
|
||||
- [Code of Conduct](#code-of-conduct)
|
||||
- [Getting Started](#getting-started)
|
||||
- [How to Contribute](#how-to-contribute)
|
||||
- [Development Workflow](#development-workflow)
|
||||
- [Commit Messages](#commit-messages)
|
||||
- [Pull Request Process](#pull-request-process)
|
||||
|
||||
## Code of Conduct
|
||||
|
||||
This project adheres to the Contributor Covenant Code of Conduct. By participating, you are expected to uphold this code. Please report unacceptable behavior to hello@mokoconsulting.tech.
|
||||
|
||||
## Getting Started
|
||||
|
||||
1. Fork the repository
|
||||
2. Clone your fork locally
|
||||
3. Install dependencies: `npm install`
|
||||
4. Build: `npm run build`
|
||||
5. Create a new branch for your work
|
||||
|
||||
## How to Contribute
|
||||
|
||||
### Reporting Bugs
|
||||
|
||||
- Use the Gitea issue tracker
|
||||
- Describe the bug clearly with steps to reproduce
|
||||
- Include the Dolibarr version you're connecting to
|
||||
- Include relevant logs or error messages
|
||||
|
||||
### Adding New Tools
|
||||
|
||||
If you want to add support for a Dolibarr API endpoint not yet covered:
|
||||
|
||||
1. Check the [Dolibarr API Explorer](https://your-dolibarr.com/api/index.php/explorer) for endpoint details
|
||||
2. Add the tool registration in `src/index.ts` following the existing patterns
|
||||
3. Update `docs/API.md` with the new tool's parameter table
|
||||
4. Update `README.md` tool listing
|
||||
5. Update `CHANGELOG.md`
|
||||
|
||||
### Contributing Code
|
||||
|
||||
- Pick an issue or create one
|
||||
- Fork the repository and create a branch
|
||||
- Make your changes following the project conventions
|
||||
- Submit a pull request
|
||||
|
||||
## Development Workflow
|
||||
|
||||
1. Ensure your fork is up to date with the main repository
|
||||
2. Create a feature branch from `main`
|
||||
3. Make your changes
|
||||
4. Test against a Dolibarr instance (use `npm run setup` to configure a dev connection)
|
||||
5. Build with `npm run build` to catch TypeScript errors
|
||||
6. Commit your changes with clear messages
|
||||
7. Push to your fork
|
||||
8. Create a pull request
|
||||
|
||||
## Commit Messages
|
||||
|
||||
Follow the conventional commit format:
|
||||
|
||||
```
|
||||
<type>(<scope>): <subject>
|
||||
|
||||
<body>
|
||||
|
||||
<footer>
|
||||
```
|
||||
|
||||
Types: `feat`, `fix`, `docs`, `style`, `refactor`, `test`, `chore`, `ci`, `build`, `perf`, `revert`
|
||||
|
||||
Example:
|
||||
```
|
||||
feat(tools): add shipment management tools
|
||||
|
||||
Add dolibarr_shipments_list, dolibarr_shipment_get, and
|
||||
dolibarr_shipment_validate tools for the /shipments API endpoint.
|
||||
```
|
||||
|
||||
## Pull Request Process
|
||||
|
||||
1. Update documentation for any new tools
|
||||
2. Follow the project's coding style and conventions
|
||||
3. Ensure `npm run build` succeeds without errors
|
||||
4. Update the CHANGELOG.md with your changes
|
||||
5. Request review from maintainers
|
||||
6. Address any feedback promptly
|
||||
7. Once approved, your PR will be merged
|
||||
|
||||
## Style Guidelines
|
||||
|
||||
- Use tabs for indentation
|
||||
- All source files must include the Moko Consulting copyright header
|
||||
- Use `snake_case` for local variables (matching Dolibarr API field names)
|
||||
- Use Zod for all tool parameter validation
|
||||
- Follow the `formatResponse()` pattern for consistent error handling
|
||||
|
||||
## Infrastructure Standards
|
||||
|
||||
All repositories in the MokoConsulting org follow these conventions:
|
||||
|
||||
### Release Tags
|
||||
|
||||
Every repo maintains 5 standard release channel tags:
|
||||
|
||||
- `development` - Active development builds
|
||||
- `alpha` - Early internal testing
|
||||
- `beta` - Broader testing / client UAT
|
||||
- `release-candidate` - Final QA before production
|
||||
- `stable` - Production release
|
||||
|
||||
### Branch Protection
|
||||
|
||||
- `main` is protected; only `jmiller` can push directly
|
||||
- All other contributors must use pull requests
|
||||
- PRs are automatically reviewed by Claude Code
|
||||
|
||||
### CI/CD
|
||||
|
||||
- Gitea Actions runs all CI workflows
|
||||
- Workflows live in `.gitea/workflows/`
|
||||
|
||||
### Secrets
|
||||
|
||||
All repos have `GA_TOKEN` and `GH_TOKEN` as Actions secrets for API access.
|
||||
|
||||
## Questions?
|
||||
|
||||
If you have questions about contributing, feel free to open an issue or contact the maintainers at hello@mokoconsulting.tech.
|
||||
|
||||
## Revision History
|
||||
|
||||
| Date | Version | Author | Notes |
|
||||
| --- | --- | --- | --- |
|
||||
| 2026-05-07 | 0.0.1 | jmiller | Initial contributing guidelines |
|
||||
@@ -0,0 +1,370 @@
|
||||
# dolibarr-api-mcp
|
||||
|
||||
MCP server for Dolibarr ERP/CRM REST API operations
|
||||
|
||||
    
|
||||
|
||||
| Field | Value |
|
||||
|---|---|
|
||||
| **Language** | TypeScript |
|
||||
| **License** | GPL-3.0-or-later |
|
||||
| **Platform** | [Gitea](https://git.mokoconsulting.tech/MokoConsulting/dolibarr-api-mcp) |
|
||||
| **Node.js** | >= 20.0.0 |
|
||||
| **MCP SDK** | @modelcontextprotocol/sdk ^1.12.1 |
|
||||
|
||||
A [Model Context Protocol](https://modelcontextprotocol.io/) (MCP) server that bridges AI assistants (Claude Code, Cursor, etc.) with Dolibarr's built-in REST API. Manage invoices, proposals, orders, products, third parties, projects, and more -- directly from your AI assistant.
|
||||
|
||||
---
|
||||
|
||||
## Quick Start
|
||||
|
||||
```sh
|
||||
git clone https://git.mokoconsulting.tech/MokoConsulting/dolibarr-api-mcp.git
|
||||
cd dolibarr-api-mcp
|
||||
npm install
|
||||
npm run build
|
||||
npm run setup
|
||||
```
|
||||
|
||||
The interactive setup wizard will prompt for your Dolibarr instance URL, API key, and TLS settings.
|
||||
|
||||
Register with Claude Code (`~/.claude.json`):
|
||||
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"dolibarr-api": {
|
||||
"type": "stdio",
|
||||
"command": "node",
|
||||
"args": ["/path/to/dolibarr-api-mcp/dist/index.js"]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Verify with: `dolibarr_status` -- returns the Dolibarr version and server info.
|
||||
|
||||
---
|
||||
|
||||
## Tools (85)
|
||||
|
||||
Every tool accepts an optional `connection` parameter to target a specific named Dolibarr instance (defaults to the configured default).
|
||||
|
||||
### Third Parties (5)
|
||||
|
||||
| Tool | Description |
|
||||
|---|---|
|
||||
| `dolibarr_thirdparties_list` | List/search third parties with pagination and SQL filters |
|
||||
| `dolibarr_thirdparty_get` | Get a third party by ID |
|
||||
| `dolibarr_thirdparty_create` | Create a new third party (customer, supplier, or prospect) |
|
||||
| `dolibarr_thirdparty_update` | Update an existing third party |
|
||||
| `dolibarr_thirdparty_delete` | Delete a third party |
|
||||
|
||||
### Contacts (5)
|
||||
|
||||
| Tool | Description |
|
||||
|---|---|
|
||||
| `dolibarr_contacts_list` | List/search contacts with pagination |
|
||||
| `dolibarr_contact_get` | Get a contact by ID |
|
||||
| `dolibarr_contact_create` | Create a new contact linked to a third party |
|
||||
| `dolibarr_contact_update` | Update an existing contact |
|
||||
| `dolibarr_contact_delete` | Delete a contact |
|
||||
|
||||
### Invoices (7)
|
||||
|
||||
| Tool | Description |
|
||||
|---|---|
|
||||
| `dolibarr_invoices_list` | List/search invoices with status and date filters |
|
||||
| `dolibarr_invoice_get` | Get an invoice by ID |
|
||||
| `dolibarr_invoice_create` | Create a new invoice for a third party |
|
||||
| `dolibarr_invoice_add_line` | Add a line item to an invoice |
|
||||
| `dolibarr_invoice_validate` | Validate a draft invoice |
|
||||
| `dolibarr_invoice_set_paid` | Mark an invoice as paid |
|
||||
| `dolibarr_invoice_add_payment` | Record a payment against an invoice |
|
||||
| `dolibarr_invoice_payments` | List payments for an invoice |
|
||||
|
||||
### Proposals / Quotes (7)
|
||||
|
||||
| Tool | Description |
|
||||
|---|---|
|
||||
| `dolibarr_proposals_list` | List/search proposals with pagination |
|
||||
| `dolibarr_proposal_get` | Get a proposal by ID |
|
||||
| `dolibarr_proposal_create` | Create a new proposal for a third party |
|
||||
| `dolibarr_proposal_add_line` | Add a line item to a proposal |
|
||||
| `dolibarr_proposal_validate` | Validate a draft proposal |
|
||||
| `dolibarr_proposal_close` | Close a proposal (signed or refused) |
|
||||
|
||||
### Orders (5)
|
||||
|
||||
| Tool | Description |
|
||||
|---|---|
|
||||
| `dolibarr_orders_list` | List/search orders with pagination |
|
||||
| `dolibarr_order_get` | Get an order by ID |
|
||||
| `dolibarr_order_create` | Create a new order for a third party |
|
||||
| `dolibarr_order_add_line` | Add a line item to an order |
|
||||
| `dolibarr_order_validate` | Validate a draft order |
|
||||
|
||||
### Products & Services (5)
|
||||
|
||||
| Tool | Description |
|
||||
|---|---|
|
||||
| `dolibarr_products_list` | List/search products and services |
|
||||
| `dolibarr_product_get` | Get a product by ID |
|
||||
| `dolibarr_product_create` | Create a new product or service |
|
||||
| `dolibarr_product_update` | Update an existing product |
|
||||
| `dolibarr_product_stock` | Get stock levels for a product |
|
||||
|
||||
### Projects (4)
|
||||
|
||||
| Tool | Description |
|
||||
|---|---|
|
||||
| `dolibarr_projects_list` | List/search projects |
|
||||
| `dolibarr_project_get` | Get a project by ID |
|
||||
| `dolibarr_project_create` | Create a new project |
|
||||
| `dolibarr_project_update` | Update an existing project |
|
||||
|
||||
### Tasks (6)
|
||||
|
||||
| Tool | Description |
|
||||
|---|---|
|
||||
| `dolibarr_tasks_list` | List tasks (optionally filtered by project) |
|
||||
| `dolibarr_task_get` | Get a task by ID |
|
||||
| `dolibarr_task_create` | Create a new task within a project |
|
||||
| `dolibarr_task_update` | Update an existing task |
|
||||
| `dolibarr_task_timespent_list` | List time entries for a task |
|
||||
| `dolibarr_task_timespent_add` | Add a time entry to a task |
|
||||
|
||||
### Contracts (4)
|
||||
|
||||
| Tool | Description |
|
||||
|---|---|
|
||||
| `dolibarr_contracts_list` | List/search contracts |
|
||||
| `dolibarr_contract_get` | Get a contract by ID |
|
||||
| `dolibarr_contract_create` | Create a new contract |
|
||||
| `dolibarr_contract_validate` | Validate a draft contract |
|
||||
|
||||
### Shipments (5)
|
||||
|
||||
| Tool | Description |
|
||||
|---|---|
|
||||
| `dolibarr_shipments_list` | List/search shipments |
|
||||
| `dolibarr_shipment_get` | Get a shipment by ID |
|
||||
| `dolibarr_shipment_create` | Create a new shipment from an order |
|
||||
| `dolibarr_shipment_validate` | Validate a draft shipment |
|
||||
| `dolibarr_shipment_close` | Close a shipment |
|
||||
|
||||
### Agenda Events (4)
|
||||
|
||||
| Tool | Description |
|
||||
|---|---|
|
||||
| `dolibarr_agendaevents_list` | List/search agenda events |
|
||||
| `dolibarr_agendaevent_get` | Get an agenda event by ID |
|
||||
| `dolibarr_agendaevent_create` | Create a new agenda event |
|
||||
| `dolibarr_agendaevent_update` | Update an existing agenda event |
|
||||
|
||||
### Tickets (3)
|
||||
|
||||
| Tool | Description |
|
||||
|---|---|
|
||||
| `dolibarr_tickets_list` | List/search tickets |
|
||||
| `dolibarr_ticket_get` | Get a ticket by ID |
|
||||
| `dolibarr_ticket_create` | Create a new support ticket |
|
||||
|
||||
### Members (2)
|
||||
|
||||
| Tool | Description |
|
||||
|---|---|
|
||||
| `dolibarr_members_list` | List/search members (foundation/association module) |
|
||||
| `dolibarr_member_get` | Get a member by ID |
|
||||
|
||||
### Users (3)
|
||||
|
||||
| Tool | Description |
|
||||
|---|---|
|
||||
| `dolibarr_users_list` | List Dolibarr users |
|
||||
| `dolibarr_user_get` | Get a user by ID |
|
||||
| `dolibarr_user_create` | Create a new Dolibarr user |
|
||||
|
||||
### Expense Reports (3)
|
||||
|
||||
| Tool | Description |
|
||||
|---|---|
|
||||
| `dolibarr_expensereports_list` | List/search expense reports |
|
||||
| `dolibarr_expensereport_get` | Get an expense report by ID |
|
||||
| `dolibarr_expensereport_create` | Create a new expense report |
|
||||
|
||||
### Interventions (2)
|
||||
|
||||
| Tool | Description |
|
||||
|---|---|
|
||||
| `dolibarr_interventions_list` | List/search interventions |
|
||||
| `dolibarr_intervention_get` | Get an intervention by ID |
|
||||
|
||||
### Documents (3)
|
||||
|
||||
| Tool | Description |
|
||||
|---|---|
|
||||
| `dolibarr_documents_list` | List documents attached to a module element |
|
||||
| `dolibarr_document_download` | Download a document file |
|
||||
| `dolibarr_document_builddoc` | Generate a PDF document for an element |
|
||||
|
||||
### Stock & Warehouses (3)
|
||||
|
||||
| Tool | Description |
|
||||
|---|---|
|
||||
| `dolibarr_warehouses_list` | List warehouses |
|
||||
| `dolibarr_stockmovements_list` | List stock movements |
|
||||
| `dolibarr_stockmovement_create` | Create a stock movement |
|
||||
|
||||
### Bank Accounts (2)
|
||||
|
||||
| Tool | Description |
|
||||
|---|---|
|
||||
| `dolibarr_bankaccounts_list` | List bank accounts |
|
||||
| `dolibarr_bankaccount_lines` | List transaction lines for a bank account |
|
||||
|
||||
### Categories (3)
|
||||
|
||||
| Tool | Description |
|
||||
|---|---|
|
||||
| `dolibarr_categories_list` | List categories by type |
|
||||
| `dolibarr_category_get` | Get a category by ID |
|
||||
| `dolibarr_category_create` | Create a new category |
|
||||
|
||||
### Supplier Invoices & Orders (2)
|
||||
|
||||
| Tool | Description |
|
||||
|---|---|
|
||||
| `dolibarr_supplier_invoices_list` | List supplier (vendor) invoices |
|
||||
| `dolibarr_supplier_orders_list` | List supplier (vendor) orders |
|
||||
|
||||
### Setup & System (5)
|
||||
|
||||
| Tool | Description |
|
||||
|---|---|
|
||||
| `dolibarr_status` | Check Dolibarr instance status and version |
|
||||
| `dolibarr_setup_company` | Get company/organization setup info |
|
||||
| `dolibarr_setup_modules` | List enabled Dolibarr modules |
|
||||
| `dolibarr_setup_dictionary` | Query Dolibarr dictionary tables (countries, currencies, etc.) |
|
||||
| `dolibarr_list_connections` | List all configured Dolibarr connections |
|
||||
|
||||
### Generic (1)
|
||||
|
||||
| Tool | Description |
|
||||
|---|---|
|
||||
| `dolibarr_api_request` | Make a raw API request to any Dolibarr endpoint |
|
||||
|
||||
---
|
||||
|
||||
## Configuration
|
||||
|
||||
The config file is stored at `~/.dolibarr-api-mcp.json` (or set `DOLIBARR_API_MCP_CONFIG` for a custom path):
|
||||
|
||||
```json
|
||||
{
|
||||
"defaultConnection": "production",
|
||||
"connections": {
|
||||
"production": {
|
||||
"baseUrl": "https://erp.example.com",
|
||||
"apiKey": "your-api-key"
|
||||
},
|
||||
"staging": {
|
||||
"baseUrl": "https://erp-staging.example.com",
|
||||
"apiKey": "your-staging-key",
|
||||
"insecure": true
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
| Field | Required | Description |
|
||||
|---|---|---|
|
||||
| `defaultConnection` | Yes | Name of the default connection |
|
||||
| `connections` | Yes | Map of named connections |
|
||||
| `baseUrl` | Yes | Dolibarr instance URL (no trailing slash) |
|
||||
| `apiKey` | Yes | Dolibarr API key (`DOLAPIKEY` header auth) |
|
||||
| `insecure` | No | Set `true` to skip TLS verification (self-signed certs) |
|
||||
|
||||
---
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
AI Assistant <--> MCP (stdio) <--> DolibarrClient <--> Dolibarr REST API
|
||||
/api/index.php
|
||||
```
|
||||
|
||||
- **Transport**: stdio (standard input/output)
|
||||
- **Auth**: `DOLAPIKEY` HTTP header (Dolibarr's native per-user API key)
|
||||
- **HTTP**: Uses `node:https`/`node:http` (not `fetch`) for reliable self-signed TLS support on Node.js 24+
|
||||
- **Validation**: Zod schemas for all tool inputs
|
||||
- **Filtering**: `buildSqlFilter()` helper for Dolibarr's `sqlfilters` parameter with injection-safe escaping
|
||||
|
||||
### Source Layout
|
||||
|
||||
| File | Purpose |
|
||||
|---|---|
|
||||
| `src/index.ts` | Server entry point -- registers all MCP tools with Zod schemas |
|
||||
| `src/client.ts` | `DolibarrClient` HTTP class (GET/POST/PUT/DELETE) |
|
||||
| `src/config.ts` | Configuration loader for multi-instance connections |
|
||||
| `src/types.ts` | TypeScript interfaces (`DolibarrConnection`, `DolibarrConfig`, `ApiResponse`) |
|
||||
| `scripts/setup.mjs` | Interactive setup wizard for creating the config file |
|
||||
| `config.example.json` | Example configuration with multiple connections |
|
||||
|
||||
---
|
||||
|
||||
## Examples
|
||||
|
||||
**List all customers:**
|
||||
```
|
||||
dolibarr_thirdparties_list with search="acme", limit=10
|
||||
```
|
||||
|
||||
**Create an invoice and add a line:**
|
||||
```
|
||||
dolibarr_invoice_create with socid=42
|
||||
dolibarr_invoice_add_line with id=<invoice_id>, desc="Consulting services", subprice=150.00, qty=8
|
||||
dolibarr_invoice_validate with id=<invoice_id>
|
||||
```
|
||||
|
||||
**Check a specific Dolibarr instance:**
|
||||
```
|
||||
dolibarr_status with connection="staging"
|
||||
```
|
||||
|
||||
**Raw API request for unsupported endpoints:**
|
||||
```
|
||||
dolibarr_api_request with method="GET", endpoint="/categories", params={"type": "product"}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Guides
|
||||
|
||||
| Page | Description |
|
||||
|---|---|
|
||||
| [INSTALLATION](https://git.mokoconsulting.tech/MokoConsulting/dolibarr-api-mcp/wiki/INSTALLATION) | Prerequisites, install steps, Claude Code registration, troubleshooting |
|
||||
|
||||
## Reference
|
||||
|
||||
| Page | Description |
|
||||
|---|---|
|
||||
| [ARCHITECTURE](https://git.mokoconsulting.tech/MokoConsulting/dolibarr-api-mcp/wiki/ARCHITECTURE) | Component overview, design decisions, data flow, API module coverage |
|
||||
|
||||
---
|
||||
|
||||
## Documentation
|
||||
|
||||
Full documentation is available on the [Wiki](https://git.mokoconsulting.tech/MokoConsulting/dolibarr-api-mcp/wiki).
|
||||
|
||||
## Contributing
|
||||
|
||||
See [CONTRIBUTING.md](CONTRIBUTING.md) for development guidelines and contribution instructions.
|
||||
|
||||
## License
|
||||
|
||||
This project is licensed under the GNU General Public License v3.0 or later -- see the [LICENSE](LICENSE) file.
|
||||
|
||||
---
|
||||
|
||||
*[Moko Consulting](https://mokoconsulting.tech) -- [MokoStandards](https://git.mokoconsulting.tech/MokoConsulting/moko-platform/wiki/Home)*
|
||||
@@ -0,0 +1,114 @@
|
||||
<!--
|
||||
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: dolibarr-api-mcp.Documentation
|
||||
INGROUP: dolibarr-api-mcp
|
||||
REPO: https://git.mokoconsulting.tech/MokoConsulting/dolibarr-api-mcp
|
||||
PATH: /SECURITY.md
|
||||
VERSION: 01.00.00
|
||||
BRIEF: Security vulnerability reporting and handling policy
|
||||
-->
|
||||
|
||||
# Security Policy
|
||||
|
||||
## Purpose and Scope
|
||||
|
||||
This document defines the security vulnerability reporting, response, and disclosure policy for dolibarr-api-mcp and all repositories governed by MokoStandards.
|
||||
|
||||
## Supported Versions
|
||||
|
||||
| Version | Supported |
|
||||
| ------- | ------------------ |
|
||||
| 1.x.x | :white_check_mark: |
|
||||
| < 1.0 | :x: |
|
||||
|
||||
Only the current major version receives security updates.
|
||||
|
||||
## Reporting a Vulnerability
|
||||
|
||||
Report security vulnerabilities via Gitea issue (preferred):
|
||||
https://git.mokoconsulting.tech/MokoConsulting/dolibarr-api-mcp/issues/new?template=security.yaml
|
||||
|
||||
Or email: hello@mokoconsulting.tech
|
||||
|
||||
### Where to Report
|
||||
|
||||
**DO NOT** create public issues for security vulnerabilities.
|
||||
|
||||
Report security vulnerabilities privately to:
|
||||
|
||||
**Email**: `hello@mokoconsulting.tech`
|
||||
|
||||
**Subject Line**: `[SECURITY] Brief Description`
|
||||
|
||||
### What to Include
|
||||
|
||||
1. **Description**: Clear explanation of the vulnerability
|
||||
2. **Impact**: Potential security impact and severity assessment
|
||||
3. **Affected Versions**: Which versions are vulnerable
|
||||
4. **Reproduction Steps**: Detailed steps to reproduce the issue
|
||||
5. **Proof of Concept**: Code or demonstration (if applicable)
|
||||
6. **Suggested Fix**: Proposed remediation (if known)
|
||||
|
||||
### Response Timeline
|
||||
|
||||
* **Initial Response**: Within 3 business days
|
||||
* **Assessment Complete**: Within 7 business days
|
||||
* **Fix Timeline**: Depends on severity (see below)
|
||||
|
||||
## Severity Classification
|
||||
|
||||
### Critical
|
||||
* API key exposure or leakage
|
||||
* Remote code execution via API parameters
|
||||
* Authentication bypass
|
||||
* **Fix Timeline**: 7 days
|
||||
|
||||
### High
|
||||
* SQL injection via sqlfilters parameter
|
||||
* Unauthorized access to Dolibarr data
|
||||
* **Fix Timeline**: 14 days
|
||||
|
||||
### Medium
|
||||
* Information disclosure (limited scope)
|
||||
* Configuration file exposure
|
||||
* **Fix Timeline**: 30 days
|
||||
|
||||
### Low
|
||||
* Security best practice violations
|
||||
* Minor information leaks
|
||||
* **Fix Timeline**: 60 days or next release
|
||||
|
||||
## Security Considerations
|
||||
|
||||
### API Key Storage
|
||||
|
||||
- API keys are stored in `~/.dolibarr-api-mcp.json` with user-only file permissions
|
||||
- Never commit API keys to version control
|
||||
- The `.gitignore` excludes `.mcp.json` and environment files
|
||||
|
||||
### SQL Filter Safety
|
||||
|
||||
- The `buildSqlFilter()` helper escapes single quotes to prevent SQL injection via the `sqlfilters` parameter
|
||||
- All user-provided search terms are wrapped with the helper before being sent to Dolibarr
|
||||
|
||||
### TLS Verification
|
||||
|
||||
- The `insecure` connection option disables TLS certificate verification
|
||||
- This should only be used for local development with self-signed certificates
|
||||
- Production connections should always use valid TLS certificates
|
||||
|
||||
## Attribution and Recognition
|
||||
|
||||
We acknowledge and appreciate responsible disclosure. With your permission, we will credit you in security advisories and release notes.
|
||||
|
||||
## Revision History
|
||||
|
||||
| Date | Version | Author | Notes |
|
||||
| --- | --- | --- | --- |
|
||||
| 2026-05-07 | 0.0.1 | jmiller | Initial security policy |
|
||||
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"defaultConnection": "production",
|
||||
"connections": {
|
||||
"local-dev": {
|
||||
"baseUrl": "https://localhost:8080",
|
||||
"apiKey": "your-dolibarr-api-key-here",
|
||||
"insecure": true
|
||||
},
|
||||
"production": {
|
||||
"baseUrl": "https://erp.example.com",
|
||||
"apiKey": "your-production-api-key"
|
||||
},
|
||||
"staging": {
|
||||
"baseUrl": "https://erp-staging.example.com",
|
||||
"apiKey": "your-staging-api-key"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
{
|
||||
"name": "@mokoconsulting/mcp-mokocrm-api",
|
||||
"version": "1.0.0",
|
||||
"description": "MCP server for Dolibarr ERP/CRM REST API operations",
|
||||
"type": "module",
|
||||
"main": "dist/index.js",
|
||||
"bin": {
|
||||
"dolibarr-api-mcp": "dist/index.js"
|
||||
},
|
||||
"scripts": {
|
||||
"build": "tsc",
|
||||
"dev": "tsc --watch",
|
||||
"start": "node dist/index.js",
|
||||
"lint": "eslint src/",
|
||||
"setup": "node scripts/setup.mjs",
|
||||
"clean": "rm -rf dist/"
|
||||
},
|
||||
"dependencies": {
|
||||
"@modelcontextprotocol/sdk": "^1.12.1",
|
||||
"zod": "^3.24.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^22.15.3",
|
||||
"typescript": "^5.8.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.0.0"
|
||||
},
|
||||
"license": "GPL-3.0-or-later",
|
||||
"author": "Moko Consulting <hello@mokoconsulting.tech>",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://git.mokoconsulting.tech/MokoConsulting/dolibarr-api-mcp.git"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
#!/usr/bin/env node
|
||||
/* 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: dolibarr-api-mcp.Scripts
|
||||
* INGROUP: dolibarr-api-mcp
|
||||
* REPO: https://git.mokoconsulting.tech/MokoConsulting/dolibarr-api-mcp
|
||||
* PATH: /scripts/setup.mjs
|
||||
* VERSION: 01.00.00
|
||||
* BRIEF: Interactive setup — prompts for Dolibarr API connection details and writes config
|
||||
*/
|
||||
|
||||
import { createInterface } from 'node:readline/promises';
|
||||
import { readFile, writeFile } from 'node:fs/promises';
|
||||
import { resolve } from 'node:path';
|
||||
import { homedir } from 'node:os';
|
||||
|
||||
const CONFIG_PATH = resolve(homedir(), '.dolibarr-api-mcp.json');
|
||||
|
||||
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
||||
|
||||
async function prompt(question, defaultValue) {
|
||||
const suffix = defaultValue ? ` [${defaultValue}]` : '';
|
||||
const answer = await rl.question(`${question}${suffix}: `);
|
||||
return answer.trim() || defaultValue || '';
|
||||
}
|
||||
|
||||
async function promptRequired(question) {
|
||||
let answer = '';
|
||||
while (!answer) {
|
||||
answer = (await rl.question(`${question}: `)).trim();
|
||||
if (!answer) {
|
||||
console.log(' This field is required.');
|
||||
}
|
||||
}
|
||||
return answer;
|
||||
}
|
||||
|
||||
async function main() {
|
||||
console.log('');
|
||||
console.log('=== dolibarr-api-mcp Setup ===');
|
||||
console.log('');
|
||||
console.log('This will create your configuration file at:');
|
||||
console.log(` ${CONFIG_PATH}`);
|
||||
console.log('');
|
||||
|
||||
// Check for existing config
|
||||
let existing = null;
|
||||
try {
|
||||
const raw = await readFile(CONFIG_PATH, 'utf-8');
|
||||
existing = JSON.parse(raw);
|
||||
console.log('Existing config found. You can add a new connection or overwrite.');
|
||||
console.log(` Current connections: ${Object.keys(existing.connections).join(', ')}`);
|
||||
console.log('');
|
||||
} catch {
|
||||
// No existing config
|
||||
}
|
||||
|
||||
const connectionName = await prompt('Connection name', 'production');
|
||||
const baseUrl = await promptRequired('Dolibarr URL (e.g. https://erp.example.com)');
|
||||
const apiKey = await promptRequired('Dolibarr API key (from user settings or Setup > Security)');
|
||||
|
||||
const cleanUrl = baseUrl.replace(/\/+$/, '');
|
||||
|
||||
const insecureAnswer = await prompt('Skip TLS verification for self-signed certs? (y/N)', 'N');
|
||||
const insecure = insecureAnswer.toLowerCase() === 'y';
|
||||
|
||||
const connection = { baseUrl: cleanUrl, apiKey };
|
||||
if (insecure) {
|
||||
connection.insecure = true;
|
||||
}
|
||||
|
||||
let config;
|
||||
if (existing) {
|
||||
config = existing;
|
||||
config.connections[connectionName] = connection;
|
||||
const setDefault = await prompt(`Set "${connectionName}" as default connection? (y/N)`, 'N');
|
||||
if (setDefault.toLowerCase() === 'y') {
|
||||
config.defaultConnection = connectionName;
|
||||
}
|
||||
} else {
|
||||
config = {
|
||||
defaultConnection: connectionName,
|
||||
connections: {
|
||||
[connectionName]: connection,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
await writeFile(CONFIG_PATH, JSON.stringify(config, null, '\t') + '\n', 'utf-8');
|
||||
|
||||
console.log('');
|
||||
console.log(`Config written to ${CONFIG_PATH}`);
|
||||
console.log(` Connection "${connectionName}" configured for ${cleanUrl}`);
|
||||
console.log('');
|
||||
|
||||
const addAnother = await prompt('Add another connection? (y/N)', 'N');
|
||||
if (addAnother.toLowerCase() === 'y') {
|
||||
rl.close();
|
||||
// Re-run to add another
|
||||
const { execFileSync } = await import('node:child_process');
|
||||
execFileSync('node', [new URL(import.meta.url).pathname], { stdio: 'inherit' });
|
||||
return;
|
||||
}
|
||||
|
||||
console.log('Setup complete. You can now use the MCP server.');
|
||||
console.log('');
|
||||
rl.close();
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
console.error(`Setup failed: ${err.message}`);
|
||||
rl.close();
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -0,0 +1,122 @@
|
||||
/* 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: dolibarr-api-mcp.Client
|
||||
* INGROUP: dolibarr-api-mcp
|
||||
* REPO: https://git.mokoconsulting.tech/MokoConsulting/dolibarr-api-mcp
|
||||
* PATH: /src/client.ts
|
||||
* VERSION: 01.00.00
|
||||
* BRIEF: HTTP client for Dolibarr REST API
|
||||
*/
|
||||
|
||||
import * as https from 'node:https';
|
||||
import * as http from 'node:http';
|
||||
import type { DolibarrConnection, ApiResponse } from './types.js';
|
||||
|
||||
const API_PREFIX = '/api/index.php';
|
||||
const TIMEOUT_MS = 30_000;
|
||||
|
||||
export class DolibarrClient {
|
||||
private readonly base_url: string;
|
||||
private readonly headers: Record<string, string>;
|
||||
private readonly insecure: boolean;
|
||||
|
||||
constructor(conn: DolibarrConnection) {
|
||||
this.base_url = conn.baseUrl.replace(/\/+$/, '') + API_PREFIX;
|
||||
this.headers = {
|
||||
'DOLAPIKEY': conn.apiKey,
|
||||
'Content-Type': 'application/json',
|
||||
'Accept': 'application/json',
|
||||
};
|
||||
this.insecure = conn.insecure ?? false;
|
||||
}
|
||||
|
||||
async get(endpoint: string, params?: Record<string, string>): Promise<ApiResponse> {
|
||||
const url = this.buildUrl(endpoint, params);
|
||||
return this.request(url, 'GET');
|
||||
}
|
||||
|
||||
async post(endpoint: string, body?: unknown): Promise<ApiResponse> {
|
||||
const url = this.buildUrl(endpoint);
|
||||
return this.request(url, 'POST', body);
|
||||
}
|
||||
|
||||
async put(endpoint: string, body: unknown): Promise<ApiResponse> {
|
||||
const url = this.buildUrl(endpoint);
|
||||
return this.request(url, 'PUT', body);
|
||||
}
|
||||
|
||||
async delete(endpoint: string): Promise<ApiResponse> {
|
||||
const url = this.buildUrl(endpoint);
|
||||
return this.request(url, 'DELETE');
|
||||
}
|
||||
|
||||
private buildUrl(endpoint: string, params?: Record<string, string>): string {
|
||||
const path = endpoint.startsWith('/') ? endpoint : `/${endpoint}`;
|
||||
const url = new URL(`${this.base_url}${path}`);
|
||||
if (params) {
|
||||
for (const [key, value] of Object.entries(params)) {
|
||||
url.searchParams.set(key, value);
|
||||
}
|
||||
}
|
||||
return url.toString();
|
||||
}
|
||||
|
||||
private request(url: string, method: string, body?: unknown): Promise<ApiResponse> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const parsed = new URL(url);
|
||||
const is_https = parsed.protocol === 'https:';
|
||||
const transport = is_https ? https : http;
|
||||
|
||||
const options: https.RequestOptions = {
|
||||
hostname: parsed.hostname,
|
||||
port: parsed.port || (is_https ? 443 : 80),
|
||||
path: parsed.pathname + parsed.search,
|
||||
method,
|
||||
headers: { ...this.headers },
|
||||
timeout: TIMEOUT_MS,
|
||||
};
|
||||
|
||||
if (this.insecure && is_https) {
|
||||
options.rejectUnauthorized = false;
|
||||
}
|
||||
|
||||
const payload = body !== undefined ? JSON.stringify(body) : undefined;
|
||||
if (payload) {
|
||||
(options.headers as Record<string, string>)['Content-Length'] = Buffer.byteLength(payload).toString();
|
||||
}
|
||||
|
||||
const req = transport.request(options, (res) => {
|
||||
const chunks: Buffer[] = [];
|
||||
res.on('data', (chunk: Buffer) => chunks.push(chunk));
|
||||
res.on('end', () => {
|
||||
const raw = Buffer.concat(chunks).toString('utf-8');
|
||||
let data: unknown;
|
||||
|
||||
try {
|
||||
data = JSON.parse(raw);
|
||||
} catch {
|
||||
data = raw;
|
||||
}
|
||||
|
||||
resolve({ status: res.statusCode ?? 0, data });
|
||||
});
|
||||
});
|
||||
|
||||
req.on('error', (err) => reject(err));
|
||||
req.on('timeout', () => {
|
||||
req.destroy();
|
||||
reject(new Error('Request timed out'));
|
||||
});
|
||||
|
||||
if (payload) {
|
||||
req.write(payload);
|
||||
}
|
||||
req.end();
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
/* 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: dolibarr-api-mcp.Config
|
||||
* INGROUP: dolibarr-api-mcp
|
||||
* REPO: https://git.mokoconsulting.tech/MokoConsulting/dolibarr-api-mcp
|
||||
* PATH: /src/config.ts
|
||||
* VERSION: 01.00.00
|
||||
* BRIEF: Configuration loader for Dolibarr API MCP connections
|
||||
*/
|
||||
|
||||
import { readFile } from 'node:fs/promises';
|
||||
import { resolve } from 'node:path';
|
||||
import { homedir } from 'node:os';
|
||||
import type { DolibarrConfig, DolibarrConnection } from './types.js';
|
||||
|
||||
const CONFIG_FILENAME = '.mcp_mokocrm.json';
|
||||
|
||||
export async function loadConfig(): Promise<DolibarrConfig> {
|
||||
const config_path = process.env.DOLIBARR_API_MCP_CONFIG
|
||||
? resolve(process.env.DOLIBARR_API_MCP_CONFIG)
|
||||
: resolve(homedir(), CONFIG_FILENAME);
|
||||
|
||||
try {
|
||||
const raw = await readFile(config_path, 'utf-8');
|
||||
const parsed = JSON.parse(raw) as Partial<DolibarrConfig>;
|
||||
|
||||
if (!parsed.connections || Object.keys(parsed.connections).length === 0) {
|
||||
throw new Error('No connections defined in config');
|
||||
}
|
||||
|
||||
return {
|
||||
connections: parsed.connections,
|
||||
defaultConnection: parsed.defaultConnection ?? Object.keys(parsed.connections)[0],
|
||||
};
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
throw new Error(
|
||||
`Failed to load config from ${config_path}: ${message}\n` +
|
||||
`Create ${config_path} — see config.example.json for format.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export function getConnection(config: DolibarrConfig, name?: string): DolibarrConnection {
|
||||
const key = name ?? config.defaultConnection;
|
||||
const conn = config.connections[key];
|
||||
if (!conn) {
|
||||
throw new Error(
|
||||
`Connection "${key}" not found. Available: ${Object.keys(config.connections).join(', ')}`,
|
||||
);
|
||||
}
|
||||
return conn;
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,31 @@
|
||||
/* 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: dolibarr-api-mcp.Types
|
||||
* INGROUP: dolibarr-api-mcp
|
||||
* REPO: https://git.mokoconsulting.tech/MokoConsulting/dolibarr-api-mcp
|
||||
* PATH: /src/types.ts
|
||||
* VERSION: 01.00.00
|
||||
* BRIEF: TypeScript type definitions for Dolibarr API MCP server
|
||||
*/
|
||||
|
||||
export interface DolibarrConnection {
|
||||
baseUrl: string;
|
||||
apiKey: string;
|
||||
/** Skip TLS certificate verification (self-signed certs) */
|
||||
insecure?: boolean;
|
||||
}
|
||||
|
||||
export interface DolibarrConfig {
|
||||
connections: Record<string, DolibarrConnection>;
|
||||
defaultConnection: string;
|
||||
}
|
||||
|
||||
export interface ApiResponse {
|
||||
status: number;
|
||||
data: unknown;
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "Node16",
|
||||
"moduleResolution": "Node16",
|
||||
"outDir": "./dist",
|
||||
"rootDir": "./src",
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"resolveJsonModule": true,
|
||||
"declaration": true,
|
||||
"declarationMap": true,
|
||||
"sourceMap": true
|
||||
},
|
||||
"include": ["src/**/*"],
|
||||
"exclude": ["node_modules", "dist"]
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
# mcp_mokodreamhost
|
||||
|
||||
MCP server for DreamHost API — DNS records, hosting, and domain management.
|
||||
|
||||
## Quick Reference
|
||||
|
||||
| Field | Value |
|
||||
|---|---|
|
||||
| **Package** | `@mokoconsulting/dreamhost-mcp` |
|
||||
| **Entry** | `dist/index.js` |
|
||||
| **Config** | `~/.mcp_mokodreamhost.json` (override: `DREAMHOST_MCP_CONFIG` env var) |
|
||||
| **Language** | TypeScript |
|
||||
| **Branch** | develop on `dev`, merge to `main` (protected) |
|
||||
|
||||
## Commands
|
||||
|
||||
```bash
|
||||
npm install # Install dependencies
|
||||
npm run build # Compile TypeScript → dist/
|
||||
npm run dev # Watch mode
|
||||
```
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
src/
|
||||
├── index.ts # MCP server entry, tool registration
|
||||
├── config.ts # Loads ~/.mcp_mokodreamhost.json (just apiKey)
|
||||
├── client.ts # DreamHost API client wrapper
|
||||
└── types.ts # DreamHostConfig type
|
||||
```
|
||||
|
||||
- Simple config — just an API key
|
||||
- Manages DNS records, domains, and hosting for all DreamHost-hosted sites
|
||||
|
||||
## Rules
|
||||
|
||||
- **Never commit** `.claude/`, `.mcp.json`, `TODO.md`, `*.min.css`/`*.min.js`
|
||||
- **Attribution**: `Authored-by: Moko Consulting`
|
||||
- **Workflow directory**: `.mokogitea/` (not `.gitea/` or `.github/`)
|
||||
- **Wiki**: documentation lives in the Gitea wiki, not `docs/` files
|
||||
- **Standards**: [MokoStandards](https://git.mokoconsulting.tech/MokoConsulting/moko-platform/wiki/Home)
|
||||
@@ -0,0 +1,6 @@
|
||||
# Changelog
|
||||
|
||||
## [1.0] — 2026-05-08
|
||||
|
||||
### Added
|
||||
- Initial release
|
||||
@@ -0,0 +1,3 @@
|
||||
# Contributing
|
||||
|
||||
See [standards](https://git.mokoconsulting.tech/MokoConsulting/moko-platform/wiki).
|
||||
@@ -0,0 +1,159 @@
|
||||
# dreamhost-mcp
|
||||
|
||||
[](LICENSE)
|
||||
[](https://nodejs.org)
|
||||
[](https://modelcontextprotocol.io)
|
||||
[](https://www.typescriptlang.org)
|
||||
|
||||
MCP server for the [DreamHost API](https://help.dreamhost.com/hc/en-us/articles/217560167-API-overview) -- DNS records, domains, hosting accounts, MySQL databases, and email management.
|
||||
|
||||
Part of [Moko Consulting](https://mokoconsulting.tech) infrastructure.
|
||||
|
||||
---
|
||||
|
||||
## Tools
|
||||
|
||||
| Tool | Description |
|
||||
|---|---|
|
||||
| `dreamhost_dns_list` | List all DNS records (optionally filter by domain) |
|
||||
| `dreamhost_dns_add` | Add a DNS record (A, AAAA, CNAME, MX, TXT, SRV) |
|
||||
| `dreamhost_dns_remove` | Remove a DNS record (must match record, type, and value exactly) |
|
||||
| `dreamhost_dns_check` | Check if a DNS record exists for a domain (optional type filter) |
|
||||
| `dreamhost_domain_list` | List all hosted domains |
|
||||
| `dreamhost_domain_registrations` | List domain registrations with expiry dates |
|
||||
| `dreamhost_user_list` | List hosting users and accounts |
|
||||
| `dreamhost_account_status` | Get account status and usage |
|
||||
| `dreamhost_api_commands` | List available API commands for the configured key |
|
||||
| `dreamhost_mysql_list` | List MySQL databases |
|
||||
| `dreamhost_mysql_users` | List MySQL database users |
|
||||
| `dreamhost_mail_list` | List email addresses (optionally filter by domain) |
|
||||
| `dreamhost_rewards_referrals` | List referral rewards |
|
||||
|
||||
---
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- **Node.js** >= 20
|
||||
- A **DreamHost API key** -- generate one at [DreamHost Panel > Web Panel API](https://panel.dreamhost.com/?tree=home.api)
|
||||
|
||||
---
|
||||
|
||||
## Installation
|
||||
|
||||
```bash
|
||||
git clone https://git.mokoconsulting.tech/MokoConsulting/dreamhost-mcp.git
|
||||
cd dreamhost-mcp
|
||||
npm install && npm run build
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Configuration
|
||||
|
||||
Create `~/.dreamhost-mcp.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
"apiKey": "your-dreamhost-api-key"
|
||||
}
|
||||
```
|
||||
|
||||
Override the config path with the `DREAMHOST_MCP_CONFIG` environment variable:
|
||||
|
||||
```bash
|
||||
DREAMHOST_MCP_CONFIG=/path/to/config.json node dist/index.js
|
||||
```
|
||||
|
||||
### Claude Code (`.mcp.json`)
|
||||
|
||||
Add to your project or global `.mcp.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"dreamhost": {
|
||||
"command": "node",
|
||||
"args": ["/path/to/dreamhost-mcp/dist/index.js"]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Usage Examples
|
||||
|
||||
Once connected via MCP, the tools are available to the AI agent directly.
|
||||
|
||||
**List all DNS records for a domain:**
|
||||
|
||||
```
|
||||
dreamhost_dns_list(domain: "example.com")
|
||||
```
|
||||
|
||||
**Add an A record:**
|
||||
|
||||
```
|
||||
dreamhost_dns_add(record: "sub.example.com", type: "A", value: "1.2.3.4", comment: "staging server")
|
||||
```
|
||||
|
||||
**Remove a DNS record:**
|
||||
|
||||
```
|
||||
dreamhost_dns_remove(record: "sub.example.com", type: "A", value: "1.2.3.4")
|
||||
```
|
||||
|
||||
**Check if a CNAME exists:**
|
||||
|
||||
```
|
||||
dreamhost_dns_check(domain: "sub.example.com", type: "CNAME")
|
||||
```
|
||||
|
||||
**List domain registrations and expiry dates:**
|
||||
|
||||
```
|
||||
dreamhost_domain_registrations()
|
||||
```
|
||||
|
||||
**List MySQL databases:**
|
||||
|
||||
```
|
||||
dreamhost_mysql_list()
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Project Structure
|
||||
|
||||
```
|
||||
src/
|
||||
index.ts # MCP server entry point and tool definitions
|
||||
client.ts # DreamHost API HTTP client (HTTPS, 30s timeout)
|
||||
config.ts # Config file loader (~/.dreamhost-mcp.json)
|
||||
types.ts # TypeScript interfaces (DreamHostConfig, DnsRecord, ApiResponse)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Development
|
||||
|
||||
```bash
|
||||
npm run dev # watch mode (tsc --watch)
|
||||
npm run build # compile TypeScript to dist/
|
||||
npm run start # run the compiled server
|
||||
npm run clean # remove dist/
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Documentation
|
||||
|
||||
Full documentation is available on the [Wiki](https://git.mokoconsulting.tech/MokoConsulting/dreamhost-mcp/wiki).
|
||||
|
||||
## License
|
||||
|
||||
[GPL-3.0-or-later](LICENSE) -- Copyright (C) 2026 Moko Consulting
|
||||
|
||||
---
|
||||
|
||||
*[Moko Consulting](https://mokoconsulting.tech) -- [MokoStandards](https://git.mokoconsulting.tech/MokoConsulting/moko-platform/wiki/Home)*
|
||||
@@ -0,0 +1,3 @@
|
||||
# Security
|
||||
|
||||
Report to hello@mokoconsulting.tech.
|
||||
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"apiKey": "your-dreamhost-api-key"
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"name": "@mokoconsulting/dreamhost-mcp",
|
||||
"version": "1.0.0",
|
||||
"description": "MCP server for DreamHost API — DNS records, hosting, and domain management",
|
||||
"type": "module",
|
||||
"main": "dist/index.js",
|
||||
"bin": { "dreamhost-mcp": "dist/index.js" },
|
||||
"scripts": {
|
||||
"build": "tsc",
|
||||
"dev": "tsc --watch",
|
||||
"start": "node dist/index.js",
|
||||
"clean": "rm -rf dist/"
|
||||
},
|
||||
"dependencies": {
|
||||
"@modelcontextprotocol/sdk": "^1.12.1",
|
||||
"zod": "^3.24.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^22.15.3",
|
||||
"typescript": "^5.8.3"
|
||||
},
|
||||
"engines": { "node": ">=20.0.0" },
|
||||
"license": "GPL-3.0-or-later",
|
||||
"author": "Moko Consulting <hello@mokoconsulting.tech>"
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
import * as https from 'node:https';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import type { ApiResponse } from './types.js';
|
||||
|
||||
const API_HOST = 'api.dreamhost.com';
|
||||
const TIMEOUT_MS = 30_000;
|
||||
|
||||
export class DreamHostClient {
|
||||
private readonly apiKey: string;
|
||||
|
||||
constructor(apiKey: string) {
|
||||
this.apiKey = apiKey;
|
||||
}
|
||||
|
||||
async callApi(cmd: string, params: Record<string, string> = {}): Promise<ApiResponse> {
|
||||
const query = new URLSearchParams({
|
||||
key: this.apiKey,
|
||||
cmd,
|
||||
format: 'json',
|
||||
unique_id: randomUUID(),
|
||||
...params,
|
||||
});
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const opts: https.RequestOptions = {
|
||||
hostname: API_HOST,
|
||||
port: 443,
|
||||
path: `/?${query.toString()}`,
|
||||
method: 'GET',
|
||||
timeout: TIMEOUT_MS,
|
||||
};
|
||||
|
||||
const req = https.request(opts, (res) => {
|
||||
const chunks: Buffer[] = [];
|
||||
res.on('data', (c: Buffer) => chunks.push(c));
|
||||
res.on('end', () => {
|
||||
const raw = Buffer.concat(chunks).toString();
|
||||
try {
|
||||
resolve(JSON.parse(raw) as ApiResponse);
|
||||
} catch {
|
||||
resolve({ result: 'error', reason: raw });
|
||||
}
|
||||
});
|
||||
});
|
||||
req.on('error', reject);
|
||||
req.on('timeout', () => { req.destroy(); reject(new Error('Request timeout')); });
|
||||
req.end();
|
||||
});
|
||||
}
|
||||
|
||||
async listDnsRecords(): Promise<ApiResponse> {
|
||||
return this.callApi('dns-list_records');
|
||||
}
|
||||
|
||||
async addDnsRecord(record: string, type: string, value: string, comment?: string): Promise<ApiResponse> {
|
||||
const params: Record<string, string> = { record, type, value };
|
||||
if (comment) params.comment = comment;
|
||||
return this.callApi('dns-add_record', params);
|
||||
}
|
||||
|
||||
async removeDnsRecord(record: string, type: string, value: string): Promise<ApiResponse> {
|
||||
return this.callApi('dns-remove_record', { record, type, value });
|
||||
}
|
||||
|
||||
async listDomains(): Promise<ApiResponse> {
|
||||
return this.callApi('domain-list_domains');
|
||||
}
|
||||
|
||||
async listRegistrations(): Promise<ApiResponse> {
|
||||
return this.callApi('domain-list_registrations');
|
||||
}
|
||||
|
||||
async listUsers(): Promise<ApiResponse> {
|
||||
return this.callApi('account-list_accounts');
|
||||
}
|
||||
|
||||
async accountStatus(): Promise<ApiResponse> {
|
||||
return this.callApi('account-status');
|
||||
}
|
||||
|
||||
async listCommands(): Promise<ApiResponse> {
|
||||
return this.callApi('api-list_accessible_cmds');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import { readFile } from 'node:fs/promises';
|
||||
import { resolve } from 'node:path';
|
||||
import { homedir } from 'node:os';
|
||||
import type { DreamHostConfig } from './types.js';
|
||||
|
||||
const CONFIG_FILENAME = '.mcp_mokodreamhost.json';
|
||||
|
||||
export async function loadConfig(): Promise<DreamHostConfig> {
|
||||
const configPath = process.env.DREAMHOST_MCP_CONFIG
|
||||
? resolve(process.env.DREAMHOST_MCP_CONFIG)
|
||||
: resolve(homedir(), CONFIG_FILENAME);
|
||||
|
||||
const raw = await readFile(configPath, 'utf-8');
|
||||
const parsed = JSON.parse(raw) as Partial<DreamHostConfig>;
|
||||
if (!parsed.apiKey) {
|
||||
throw new Error(`No apiKey in ${configPath}`);
|
||||
}
|
||||
return { apiKey: parsed.apiKey };
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user