Compare commits

...

26 Commits

Author SHA1 Message Date
Jonathan Miller 3d5d581883 Merge branch 'dev'
Platform: moko-platform CI / Gate 2: Unit Tests (8.1) (push) Blocked by required conditions
Platform: moko-platform CI / Gate 2: Unit Tests (8.2) (push) Blocked by required conditions
Platform: moko-platform CI / Gate 2: Unit Tests (8.3) (push) Blocked by required conditions
Platform: moko-platform CI / Gate 3: Self-Health Check (push) Blocked by required conditions
Platform: moko-platform CI / Gate 4: Governance (push) Blocked by required conditions
Platform: moko-platform CI / Gate 5: Template Integrity (push) Blocked by required conditions
Platform: moko-platform CI / CI Summary (push) Blocked by required conditions
Generic: Repo Health / Scripts governance (push) Blocked by required conditions
Generic: Repo Health / Repository health (push) Blocked by required conditions
Generic: Repo Health / Report Issues (push) Blocked by required conditions
Generic: Repo Health / Site Health (push) Has been skipped
Generic: Repo Health / Access control (push) Successful in 1s
Platform: moko-platform CI / Gate 1: Code Quality (push) Failing after 36s
2026-06-06 13:00:10 -05:00
Jonathan Miller 6bd26698c4 fix: check for manifest_element.php in pre-installed tools validation
Platform: moko-platform CI / Gate 2: Unit Tests (8.1) (push) Blocked by required conditions
Platform: moko-platform CI / Gate 2: Unit Tests (8.2) (push) Blocked by required conditions
Platform: moko-platform CI / Gate 2: Unit Tests (8.3) (push) Blocked by required conditions
Platform: moko-platform CI / Gate 3: Self-Health Check (push) Blocked by required conditions
Platform: moko-platform CI / Gate 4: Governance (push) Blocked by required conditions
Platform: moko-platform CI / Gate 5: Template Integrity (push) Blocked by required conditions
Platform: moko-platform CI / CI Summary (push) Blocked by required conditions
Platform: moko-platform CI / Gate 1: Code Quality (push) Failing after 35s
Generic: Repo Health / Scripts governance (push) Blocked by required conditions
Generic: Repo Health / Repository health (push) Blocked by required conditions
Generic: Repo Health / Report Issues (push) Blocked by required conditions
Generic: Repo Health / Site Health (push) Has been skipped
Generic: Repo Health / Access control (push) Successful in 3s
Universal: Auto Version Bump / Version Bump (push) Successful in 7s
Runner image has stale /opt/moko-platform missing manifest_element.php.
Adding it to the existence check forces a fresh clone until the image
is rebuilt.

Authored-by: Moko Consulting
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-06-06 12:53:27 -05:00
Jonathan Miller 19b504526b fix: remove double quotes from shell commands in workflow YAML
Platform: moko-platform CI / Gate 2: Unit Tests (8.1) (push) Blocked by required conditions
Platform: moko-platform CI / Gate 2: Unit Tests (8.2) (push) Blocked by required conditions
Platform: moko-platform CI / Gate 2: Unit Tests (8.3) (push) Blocked by required conditions
Platform: moko-platform CI / Gate 3: Self-Health Check (push) Blocked by required conditions
Platform: moko-platform CI / Gate 4: Governance (push) Blocked by required conditions
Platform: moko-platform CI / Gate 5: Template Integrity (push) Blocked by required conditions
Platform: moko-platform CI / CI Summary (push) Blocked by required conditions
Generic: Repo Health / Scripts governance (push) Blocked by required conditions
Generic: Repo Health / Repository health (push) Blocked by required conditions
Generic: Repo Health / Report Issues (push) Blocked by required conditions
Generic: Repo Health / Site Health (push) Has been skipped
Generic: Repo Health / Access control (push) Successful in 2s
Universal: Auto Version Bump / Version Bump (push) Failing after 3s
Platform: moko-platform CI / Gate 1: Code Quality (push) Failing after 34s
act_runner passes run: | blocks through a shell that treats double
quotes as literal characters in some contexts. Removed all double
quotes from echo, test, and git clone commands. Git clone URL is
now built in a variable to avoid quoting issues with the token.

Fixes pre-release and auto-release workflows failing with:
  fatal: protocol '"https' is not supported

Authored-by: Moko Consulting
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-06-06 12:44:44 -05:00
Jonathan Miller e7bdf7cbc7 fix: load Composer autoloader in CliFramework constructor (#248)
Platform: moko-platform CI / Gate 2: Unit Tests (8.1) (push) Blocked by required conditions
Platform: moko-platform CI / Gate 2: Unit Tests (8.2) (push) Blocked by required conditions
Platform: moko-platform CI / Gate 2: Unit Tests (8.3) (push) Blocked by required conditions
Platform: moko-platform CI / Gate 3: Self-Health Check (push) Blocked by required conditions
Platform: moko-platform CI / Gate 4: Governance (push) Blocked by required conditions
Platform: moko-platform CI / Gate 5: Template Integrity (push) Blocked by required conditions
Platform: moko-platform CI / CI Summary (push) Blocked by required conditions
Generic: Repo Health / Scripts governance (push) Blocked by required conditions
Generic: Repo Health / Repository health (push) Blocked by required conditions
Generic: Repo Health / Report Issues (push) Blocked by required conditions
Generic: Repo Health / Site Health (push) Has been skipped
Generic: Repo Health / Access control (push) Successful in 1s
Universal: Auto Version Bump / Version Bump (push) Failing after 5s
Platform: moko-platform CI / Gate 1: Code Quality (push) Failing after 39s
CLI tools failed with "Class MokoEnterprise\SourceResolver not found"
because the Composer autoloader was never loaded. The require_once
for CliFramework.php loaded the framework but not the PSR-4 autoloader
that maps MokoEnterprise\ to lib/Enterprise/.

Adding require_once for vendor/autoload.php in the constructor ensures
all Enterprise classes (SourceResolver, etc.) are available to every
CLI tool that extends CliFramework.

Closes #248

Authored-by: Moko Consulting
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-06-06 12:34:33 -05:00
Jonathan Miller ff5794d0cc fix: remove dead definitionParser reference in RepositorySynchronizer
Platform: moko-platform CI / Gate 2: Unit Tests (8.1) (push) Blocked by required conditions
Platform: moko-platform CI / Gate 2: Unit Tests (8.2) (push) Blocked by required conditions
Platform: moko-platform CI / Gate 2: Unit Tests (8.3) (push) Blocked by required conditions
Platform: moko-platform CI / Gate 3: Self-Health Check (push) Blocked by required conditions
Platform: moko-platform CI / Gate 4: Governance (push) Blocked by required conditions
Platform: moko-platform CI / Gate 5: Template Integrity (push) Blocked by required conditions
Platform: moko-platform CI / CI Summary (push) Blocked by required conditions
Platform: moko-platform CI / Gate 1: Code Quality (push) Failing after 37s
Generic: Repo Health / Scripts governance (push) Blocked by required conditions
Generic: Repo Health / Repository health (push) Blocked by required conditions
Generic: Repo Health / Report Issues (push) Blocked by required conditions
Generic: Repo Health / Site Health (push) Has been skipped
Generic: Repo Health / Access control (push) Successful in 1s
Universal: Auto Version Bump / Version Bump (push) Failing after 3s
The definitionParser property was never initialized or implemented.
synchronizeRepository() crashed with "Call to a member function
parseForPlatform() on null". Replaced with direct use of
getSharedWorkflows() which provides all files to sync.
2026-06-06 12:09:14 -05:00
Jonathan Miller bd5f676e0a Merge remote-tracking branch 'origin/dev'
Platform: moko-platform CI / Gate 2: Unit Tests (8.1) (push) Blocked by required conditions
Platform: moko-platform CI / Gate 2: Unit Tests (8.2) (push) Blocked by required conditions
Platform: moko-platform CI / Gate 2: Unit Tests (8.3) (push) Blocked by required conditions
Platform: moko-platform CI / Gate 3: Self-Health Check (push) Blocked by required conditions
Platform: moko-platform CI / Gate 4: Governance (push) Blocked by required conditions
Platform: moko-platform CI / Gate 5: Template Integrity (push) Blocked by required conditions
Platform: moko-platform CI / CI Summary (push) Blocked by required conditions
Generic: Repo Health / Scripts governance (push) Blocked by required conditions
Generic: Repo Health / Repository health (push) Blocked by required conditions
Generic: Repo Health / Report Issues (push) Blocked by required conditions
Generic: Repo Health / Site Health (push) Has been skipped
Generic: Repo Health / Access control (push) Successful in 1s
Platform: moko-platform CI / Gate 1: Code Quality (push) Failing after 38s
# Conflicts:
#	cli/manifest_licensing.php
2026-06-06 11:49:30 -05:00
Jonathan Miller bfba45e8b5 chore: remove deprecated updates.xml build/sync from workflows
Platform: moko-platform CI / Gate 2: Unit Tests (8.1) (push) Blocked by required conditions
Platform: moko-platform CI / Gate 2: Unit Tests (8.2) (push) Blocked by required conditions
Platform: moko-platform CI / Gate 2: Unit Tests (8.3) (push) Blocked by required conditions
Platform: moko-platform CI / Gate 3: Self-Health Check (push) Blocked by required conditions
Platform: moko-platform CI / Gate 4: Governance (push) Blocked by required conditions
Platform: moko-platform CI / Gate 5: Template Integrity (push) Blocked by required conditions
Platform: moko-platform CI / CI Summary (push) Blocked by required conditions
Generic: Repo Health / Scripts governance (push) Blocked by required conditions
Generic: Repo Health / Repository health (push) Blocked by required conditions
Generic: Repo Health / Report Issues (push) Blocked by required conditions
Generic: Repo Health / Site Health (push) Has been skipped
Generic: Repo Health / Access control (push) Successful in 2s
Universal: Auto Version Bump / Version Bump (push) Failing after 5s
Platform: moko-platform CI / Gate 1: Code Quality (push) Failing after 37s
Platform: moko-platform CI / Gate 2: Unit Tests (8.1) (pull_request) Blocked by required conditions
Platform: moko-platform CI / Gate 2: Unit Tests (8.2) (pull_request) Blocked by required conditions
Platform: moko-platform CI / Gate 2: Unit Tests (8.3) (pull_request) Blocked by required conditions
Platform: moko-platform CI / Gate 3: Self-Health Check (pull_request) Blocked by required conditions
Platform: moko-platform CI / Gate 4: Governance (pull_request) Blocked by required conditions
Platform: moko-platform CI / Gate 5: Template Integrity (pull_request) Blocked by required conditions
Platform: moko-platform CI / CI Summary (pull_request) Blocked by required conditions
Universal: PR Check / Build RC Package (pull_request) Blocked by required conditions
Universal: PR Check / Report Issues (pull_request) Blocked by required conditions
Generic: Repo Health / Scripts governance (pull_request) Blocked by required conditions
Generic: Repo Health / Repository health (pull_request) Blocked by required conditions
Generic: Repo Health / Report Issues (pull_request) Blocked by required conditions
Generic: Repo Health / Site Health (pull_request) Has been skipped
Universal: PR Check / Branch Policy (pull_request) Successful in 1s
Generic: Repo Health / Access control (pull_request) Successful in 2s
Universal: PR Check / Validate PR (pull_request) Failing after 5s
Universal: Pre-Release / Build Pre-Release (${{ inputs.stability || 'development' }}) (pull_request_target) Failing after 5s
Universal: Secret Scanning / Gitleaks Secret Scan (pull_request) Successful in 6s
Platform: moko-platform CI / Gate 1: Code Quality (pull_request) Failing after 51s
Branch Cleanup / Delete merged branch (pull_request) Has been skipped
Universal: Build & Release / Promote to RC (pull_request) Has been skipped
Universal: Build & Release / Build & Release Pipeline (pull_request) Has been skipped
MokoGitea generates update feeds dynamically from releases.
Static updates.xml is no longer needed.
2026-06-06 11:24:03 -05:00
Jonathan Miller 78ea05233b fix: update workflow path comments and token references
Platform: moko-platform CI / Gate 2: Unit Tests (8.1) (push) Blocked by required conditions
Platform: moko-platform CI / Gate 2: Unit Tests (8.2) (push) Blocked by required conditions
Platform: moko-platform CI / Gate 2: Unit Tests (8.3) (push) Blocked by required conditions
Platform: moko-platform CI / Gate 3: Self-Health Check (push) Blocked by required conditions
Platform: moko-platform CI / Gate 4: Governance (push) Blocked by required conditions
Platform: moko-platform CI / Gate 5: Template Integrity (push) Blocked by required conditions
Platform: moko-platform CI / CI Summary (push) Blocked by required conditions
Generic: Repo Health / Scripts governance (push) Blocked by required conditions
Generic: Repo Health / Repository health (push) Blocked by required conditions
Generic: Repo Health / Report Issues (push) Blocked by required conditions
Generic: Repo Health / Site Health (push) Has been skipped
Generic: Repo Health / Access control (push) Successful in 1s
Universal: Auto Version Bump / Version Bump (push) Failing after 4s
Platform: moko-platform CI / Gate 1: Code Quality (push) Failing after 33s
- Replace .gitea/ with .mokogitea/ in PATH comments
- Standardize token names to MOKOGITEA_TOKEN
- Remove github.token fallback patterns
2026-06-06 11:11:10 -05:00
Jonathan Miller ae0d54310d fix: replace smart quotes with ASCII in pre-release.yml (#245)
Platform: moko-platform CI / Gate 2: Unit Tests (8.1) (push) Blocked by required conditions
Platform: moko-platform CI / Gate 2: Unit Tests (8.2) (push) Blocked by required conditions
Platform: moko-platform CI / Gate 2: Unit Tests (8.3) (push) Blocked by required conditions
Platform: moko-platform CI / Gate 3: Self-Health Check (push) Blocked by required conditions
Platform: moko-platform CI / Gate 4: Governance (push) Blocked by required conditions
Platform: moko-platform CI / Gate 5: Template Integrity (push) Blocked by required conditions
Platform: moko-platform CI / CI Summary (push) Blocked by required conditions
Generic: Repo Health / Scripts governance (push) Blocked by required conditions
Generic: Repo Health / Repository health (push) Blocked by required conditions
Generic: Repo Health / Report Issues (push) Blocked by required conditions
Generic: Repo Health / Site Health (push) Has been skipped
Generic: Repo Health / Access control (push) Successful in 1s
Universal: Auto Version Bump / Version Bump (push) Failing after 3s
Platform: moko-platform CI / Gate 1: Code Quality (push) Failing after 35s
Unicode smart quotes (U+201C/U+201D) in the Setup moko-platform tools
step caused `fatal: protocol '"https' is not supported` during git clone.

Closes #245

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-06-06 10:23:42 -05:00
Jonathan Miller 9df59836bf chore: update CLAUDE.md template with first-run setup and focused format
Replace verbose boilerplate with platform-specific scaffold including
first-run setup checklist and placeholder tokens for new repos.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-06-06 10:23:42 -05:00
Jonathan Miller 6e40707223 chore: move CLAUDE.md to .mokogitea/ directory
Relocate CLAUDE.md from repo root to .mokogitea/ per project convention.
Content updated with focused, repo-specific architecture and rules.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-06-06 10:23:41 -05:00
Jonathan Miller ca55e5d2d2 feat(core): add SourceResolver for backwards-compatible src/ → source/ migration
Introduces SourceResolver utility class with source/ → src/ → htdocs/
fallback chain, replacing hardcoded src/ references across 28 files.
This enables renaming root-level src/ to source/ in all repos while
maintaining backwards compatibility during the transition.

Phase 1: New lib/Enterprise/SourceResolver.php with resolve(),
resolveAbsolute(), globSource(), findUnderSource(), warnIfLegacy()
Phase 2: Updated 19 CLI/deploy tools to use SourceResolver
Phase 3: Updated 7 validator/lib files (McpServerPlugin,
PackageBuilder, RepositorySynchronizer, auto_detect_platform,
check_dolibarr_module, check_client_theme, check_structure)

Authored-by: Moko Consulting
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-06-06 10:23:41 -05:00
jmiller 9526d006c4 feat(ci): add manifest_licensing step to pre-release workflow
Ensures updateservers, dlid, and blockChildUninstall tags are
present in Joomla extension manifests when licensing is enabled.

Authored-by: Moko Consulting
2026-06-06 10:23:41 -05:00
Jonathan Miller c90a5671bd feat(cli): add manifest_licensing.php for update server and dlid management
New CLI tool that reads <licensing> from manifest.xml and ensures
Joomla extension manifests have correct updateservers, dlid, and
blockChildUninstall tags. Supports dry-run and --fix modes.

Authored-by: Moko Consulting
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-06-06 10:23:40 -05:00
gitea-actions[bot] 048a7d71d1 chore(release): build 09.25.00 [skip ci] 2026-06-06 10:23:40 -05:00
jmiller 847263dd86 feat(ci): add manifest_licensing step to pre-release workflow
Platform: moko-platform CI / Gate 2: Unit Tests (8.1) (push) Blocked by required conditions
Platform: moko-platform CI / Gate 2: Unit Tests (8.2) (push) Blocked by required conditions
Platform: moko-platform CI / Gate 2: Unit Tests (8.3) (push) Blocked by required conditions
Platform: moko-platform CI / Gate 3: Self-Health Check (push) Blocked by required conditions
Platform: moko-platform CI / Gate 4: Governance (push) Blocked by required conditions
Platform: moko-platform CI / Gate 5: Template Integrity (push) Blocked by required conditions
Platform: moko-platform CI / CI Summary (push) Blocked by required conditions
Generic: Repo Health / Scripts governance (push) Blocked by required conditions
Generic: Repo Health / Repository health (push) Blocked by required conditions
Generic: Repo Health / Report Issues (push) Blocked by required conditions
Generic: Repo Health / Site Health (push) Has been skipped
Generic: Repo Health / Access control (push) Successful in 1s
Platform: moko-platform CI / Gate 1: Code Quality (push) Failing after 36s
Ensures updateservers, dlid, and blockChildUninstall tags are
present in Joomla extension manifests when licensing is enabled.

Authored-by: Moko Consulting
2026-06-06 12:27:19 +00:00
Jonathan Miller 6e540f64c4 feat(cli): add manifest_licensing.php for update server and dlid management
Platform: moko-platform CI / Gate 2: Unit Tests (8.1) (push) Blocked by required conditions
Platform: moko-platform CI / Gate 2: Unit Tests (8.2) (push) Blocked by required conditions
Platform: moko-platform CI / Gate 2: Unit Tests (8.3) (push) Blocked by required conditions
Platform: moko-platform CI / Gate 3: Self-Health Check (push) Blocked by required conditions
Platform: moko-platform CI / Gate 4: Governance (push) Blocked by required conditions
Platform: moko-platform CI / Gate 5: Template Integrity (push) Blocked by required conditions
Platform: moko-platform CI / CI Summary (push) Blocked by required conditions
Generic: Repo Health / Scripts governance (push) Blocked by required conditions
Generic: Repo Health / Repository health (push) Blocked by required conditions
Generic: Repo Health / Report Issues (push) Blocked by required conditions
Generic: Repo Health / Site Health (push) Has been skipped
Generic: Repo Health / Access control (push) Successful in 1s
Platform: moko-platform CI / Gate 1: Code Quality (push) Failing after 35s
New CLI tool that reads <licensing> from manifest.xml and ensures
Joomla extension manifests have correct updateservers, dlid, and
blockChildUninstall tags. Supports dry-run and --fix modes.

Authored-by: Moko Consulting
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-06-06 07:19:19 -05:00
jmiller c57b5724ac chore: remove update-server docs [skip ci] 2026-06-05 00:55:13 +00:00
jmiller 78affd37ff chore: remove update-server docs [skip ci] 2026-06-05 00:55:12 +00:00
jmiller b3062c6559 chore: remove update-server docs [skip ci] 2026-06-05 00:55:11 +00:00
jmiller cf02738930 chore: remove update-server docs [skip ci] 2026-06-05 00:55:10 +00:00
jmiller 455d4c8a19 chore: remove update-server docs [skip ci] 2026-06-05 00:55:09 +00:00
jmiller 8286d493b9 chore: remove update-server docs [skip ci] 2026-06-05 00:55:08 +00:00
jmiller b740152d67 Merge pull request 'ci: pre-installed /opt/moko-platform on runner' (#244) from dev into main
Generic: Repo Health / Site Health (push) Has been skipped
Generic: Repo Health / Access control (push) Successful in 1s
Generic: Repo Health / Scripts governance (push) Has been cancelled
Generic: Repo Health / Repository health (push) Has been cancelled
Generic: Repo Health / Report Issues (push) Has been cancelled
2026-06-04 23:43:57 +00:00
Jonathan Miller 9dab9f1ef6 ci: use pre-installed /opt/moko-platform on runner, fallback to clone
Generic: Repo Health / Site Health (push) Has been skipped
Generic: Repo Health / Access control (push) Successful in 1s
Universal: Auto Version Bump / Version Bump (push) Failing after 3s
Universal: PR Check / Branch Policy (pull_request) Successful in 1s
Generic: Repo Health / Site Health (pull_request) Has been skipped
Generic: Repo Health / Access control (pull_request) Successful in 2s
Universal: Secret Scanning / Gitleaks Secret Scan (pull_request) Successful in 4s
Universal: PR Check / Validate PR (pull_request) Failing after 5s
Branch Cleanup / Delete merged branch (pull_request) Has been skipped
Universal: Build & Release / Promote to RC (pull_request) Has been skipped
Platform: moko-platform CI / Gate 1: Code Quality (push) Failing after 37s
Universal: Build & Release / Build & Release Pipeline (pull_request) Failing after 5s
Platform: moko-platform CI / Gate 1: Code Quality (pull_request) Failing after 38s
Platform: moko-platform CI / Gate 2: Unit Tests (8.1) (push) Has been cancelled
Platform: moko-platform CI / Gate 2: Unit Tests (8.2) (push) Has been cancelled
Platform: moko-platform CI / Gate 2: Unit Tests (8.3) (push) Has been cancelled
Platform: moko-platform CI / Gate 3: Self-Health Check (push) Has been cancelled
Platform: moko-platform CI / Gate 4: Governance (push) Has been cancelled
Platform: moko-platform CI / Gate 5: Template Integrity (push) Has been cancelled
Platform: moko-platform CI / CI Summary (push) Has been cancelled
Generic: Repo Health / Scripts governance (push) Has been cancelled
Generic: Repo Health / Repository health (push) Has been cancelled
Generic: Repo Health / Report Issues (push) Has been cancelled
Platform: moko-platform CI / Gate 2: Unit Tests (8.1) (pull_request) Has been cancelled
Platform: moko-platform CI / Gate 2: Unit Tests (8.2) (pull_request) Has been cancelled
Platform: moko-platform CI / Gate 2: Unit Tests (8.3) (pull_request) Has been cancelled
Platform: moko-platform CI / Gate 3: Self-Health Check (pull_request) Has been cancelled
Platform: moko-platform CI / Gate 4: Governance (pull_request) Has been cancelled
Platform: moko-platform CI / Gate 5: Template Integrity (pull_request) Has been cancelled
Platform: moko-platform CI / CI Summary (pull_request) Has been cancelled
Universal: PR Check / Build RC Package (pull_request) Has been cancelled
Universal: PR Check / Report Issues (pull_request) Has been cancelled
Generic: Repo Health / Scripts governance (pull_request) Has been cancelled
Generic: Repo Health / Repository health (pull_request) Has been cancelled
Generic: Repo Health / Report Issues (pull_request) Has been cancelled
All workflows check for /opt/moko-platform first (updated by cron
every 6h). Falls back to fresh clone if not available.

Authored-by: Moko Consulting
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-06-04 18:43:03 -05:00
Jonathan Miller c61d32709c chore: remove deprecated update-server.yml workflow [skip ci]
Authored-by: Moko Consulting
2026-06-04 18:42:33 -05:00
690 changed files with 113846 additions and 1368 deletions
+76
View File
@@ -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)
+6 -4
View File
@@ -45,12 +45,14 @@ jobs:
- name: Setup moko-platform tools - name: Setup moko-platform tools
run: | run: |
if ! command -v composer &> /dev/null; then if [ -f "/opt/moko-platform/cli/version_bump.php" ] && [ -f "/opt/moko-platform/vendor/autoload.php" ]; then
sudo apt-get update -qq && sudo apt-get install -y -qq php-cli php-mbstring php-xml php-zip php-curl composer >/dev/null 2>&1 echo "Using pre-installed /opt/moko-platform"
fi
if [ -d "/opt/moko-platform/cli" ]; then
echo "MOKO_CLI=/opt/moko-platform/cli" >> "$GITHUB_ENV" echo "MOKO_CLI=/opt/moko-platform/cli" >> "$GITHUB_ENV"
else 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 \ git clone --depth 1 --branch main --quiet \
"https://x-access-token:${{ secrets.MOKOGITEA_TOKEN }}@git.mokoconsulting.tech/MokoConsulting/moko-platform.git" \ "https://x-access-token:${{ secrets.MOKOGITEA_TOKEN }}@git.mokoconsulting.tech/MokoConsulting/moko-platform.git" \
/tmp/moko-platform-api /tmp/moko-platform-api
+70 -31
View File
@@ -17,7 +17,7 @@
# | Reads manifest.xml (joomla|dolibarr|generic) to branch logic. | # | Reads manifest.xml (joomla|dolibarr|generic) to branch logic. |
# | | # | |
# | Platform-specific: | # | Platform-specific: |
# | joomla: XML manifest, updates.xml, type-prefixed packages | # | joomla: XML manifest, type-prefixed packages |
# | dolibarr: mod*.class.php, update.txt, dev version reset | # | dolibarr: mod*.class.php, update.txt, dev version reset |
# | generic: README-only, no update stream | # | generic: README-only, no update stream |
# | | # | |
@@ -71,20 +71,25 @@ jobs:
MOKO_CLONE_TOKEN: ${{ secrets.MOKOGITEA_TOKEN }} MOKO_CLONE_TOKEN: ${{ secrets.MOKOGITEA_TOKEN }}
MOKO_CLONE_HOST: git.mokoconsulting.tech/MokoConsulting MOKO_CLONE_HOST: git.mokoconsulting.tech/MokoConsulting
run: | run: |
if ! command -v composer &> /dev/null; then if [ -f /opt/moko-platform/cli/version_bump.php ] && [ -f /opt/moko-platform/vendor/autoload.php ]; then
sudo apt-get update -qq && sudo apt-get install -y -qq php-cli php-mbstring php-xml php-zip php-curl composer >/dev/null 2>&1 echo Using pre-installed /opt/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 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 - name: Rename branch to rc
run: | 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 \ --from "${{ github.event.pull_request.head.ref || 'dev' }}" --to rc \
--token "${{ secrets.MOKOGITEA_TOKEN }}" \ --token "${{ secrets.MOKOGITEA_TOKEN }}" \
--api-base "${GITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}" \ --api-base "${GITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}" \
@@ -100,16 +105,15 @@ jobs:
- name: Publish RC release - name: Publish RC release
run: | run: |
php /tmp/moko-platform-api/cli/release_publish.php \ php ${MOKO_CLI}/release_publish.php \
--path . --stability rc --bump minor --branch rc \ --path . --stability rc --bump minor --branch rc \
--token "${{ secrets.MOKOGITEA_TOKEN }}" \ --token "${{ secrets.MOKOGITEA_TOKEN }}"
--skip-update-stream
- name: Summary - name: Summary
if: always() if: always()
run: | run: |
echo "## Promoted to Release Candidate" >> $GITHUB_STEP_SUMMARY echo "## Promoted to Release Candidate" >> $GITHUB_STEP_SUMMARY
echo "Branch renamed to rc, minor bump, RC release built (updates.xml managed by Gitea Pages)" >> $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) ──────────────────── # ── Merged PR → Build & Release (or promote RC to stable) ────────────────────
release: release:
@@ -151,25 +155,60 @@ jobs:
MOKO_CLONE_HOST: git.mokoconsulting.tech/MokoConsulting MOKO_CLONE_HOST: git.mokoconsulting.tech/MokoConsulting
COMPOSER_AUTH: '{"github-oauth":{"github.com":"${{ secrets.GH_MIRROR_TOKEN }}"}}' COMPOSER_AUTH: '{"github-oauth":{"github.com":"${{ secrets.GH_MIRROR_TOKEN }}"}}'
run: | run: |
# Ensure PHP + Composer are available if [ -f /opt/moko-platform/cli/version_bump.php ] && [ -f /opt/moko-platform/vendor/autoload.php ]; then
if ! command -v composer &> /dev/null; then echo Using pre-installed /opt/moko-platform
sudo apt-get update -qq && sudo apt-get install -y -qq php-cli php-mbstring php-xml php-zip php-curl composer >/dev/null 2>&1 echo 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 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" - name: "Publish stable release"
run: | run: |
php /tmp/moko-platform-api/cli/release_publish.php \ php ${MOKO_CLI}/release_publish.php \
--path . --stability stable --bump minor --branch main \ --path . --stability stable --bump minor --branch main \
--token "${{ secrets.MOKOGITEA_TOKEN }}" \ --token "${{ secrets.MOKOGITEA_TOKEN }}"
--skip-update-stream
- 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) -------------------------------- # -- STEP 9: Mirror to GitHub (stable only) --------------------------------
- name: "Step 9: Mirror release to GitHub" - name: "Step 9: Mirror release to GitHub"
@@ -182,7 +221,7 @@ jobs:
RELEASE_TAG="${{ steps.version.outputs.release_tag }}" RELEASE_TAG="${{ steps.version.outputs.release_tag }}"
GH_REPO="${{ vars.GH_MIRROR_REPO || github.repository }}" GH_REPO="${{ vars.GH_MIRROR_REPO || github.repository }}"
API_BASE="${GITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}" 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" \ --version "$VERSION" --tag "$RELEASE_TAG" \
--token "${{ secrets.MOKOGITEA_TOKEN }}" --api-base "$API_BASE" \ --token "${{ secrets.MOKOGITEA_TOKEN }}" --api-base "$API_BASE" \
--gh-token "${{ secrets.GH_MIRROR_TOKEN }}" --gh-repo "$GH_REPO" \ --gh-token "${{ secrets.GH_MIRROR_TOKEN }}" --gh-repo "$GH_REPO" \
@@ -256,7 +295,7 @@ jobs:
continue-on-error: true continue-on-error: true
run: | run: |
API_BASE="${GITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}" API_BASE="${GITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}"
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}" \ --token "${{ secrets.MOKOGITEA_TOKEN }}" --api-base "${API_BASE}" \
--branch dev --path . 2>&1 || true --branch dev --path . 2>&1 || true
+5 -5
View File
@@ -6,7 +6,7 @@
# DEFGROUP: Gitea.Workflow # DEFGROUP: Gitea.Workflow
# INGROUP: moko-platform.CI # INGROUP: moko-platform.CI
# REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform # REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform
# PATH: /.gitea/workflows/ci-platform.yml # PATH: /.mokogitea/workflows/ci-platform.yml
# VERSION: 09.23.00 # VERSION: 09.23.00
# BRIEF: moko-platform CI — the standards engine validates itself # BRIEF: moko-platform CI — the standards engine validates itself
# #
@@ -41,7 +41,7 @@ on:
paths-ignore: paths-ignore:
- '**.md' - '**.md'
- 'wiki/**' - 'wiki/**'
- '.gitea/ISSUE_TEMPLATE/**' - '.mokogitea/ISSUE_TEMPLATE/**'
pull_request: pull_request:
branches: branches:
- main - main
@@ -104,7 +104,7 @@ jobs:
echo "::error file=${file}::PHP syntax error" echo "::error file=${file}::PHP syntax error"
ERRORS=$((ERRORS + 1)) ERRORS=$((ERRORS + 1))
fi fi
done < <(find lib/ validate/ automation/ cli/ 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" echo "### PHP Syntax"
@@ -270,7 +270,7 @@ jobs:
echo "::warning file=${file}::Missing SPDX header" echo "::warning file=${file}::Missing SPDX header"
MISSING=$((MISSING + 1)) MISSING=$((MISSING + 1))
fi fi
done < <(find lib/ validate/ cli/ 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" echo "### License Headers"
@@ -289,7 +289,7 @@ jobs:
echo "::error file=${file}::Potential hardcoded secret detected" echo "::error file=${file}::Potential hardcoded secret detected"
FOUND=$((FOUND + 1)) FOUND=$((FOUND + 1))
fi fi
done < <(find lib/ validate/ cli/ 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" echo "### Secret Detection"
+1 -1
View File
@@ -6,7 +6,7 @@
# DEFGROUP: Gitea.Workflow # DEFGROUP: Gitea.Workflow
# INGROUP: moko-platform.Maintenance # INGROUP: moko-platform.Maintenance
# REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform # REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform
# PATH: /.gitea/workflows/cleanup.yml # PATH: /.mokogitea/workflows/cleanup.yml
# VERSION: 09.23.00 # VERSION: 09.23.00
# BRIEF: Scheduled cleanup — delete merged branches and old workflow runs # BRIEF: Scheduled cleanup — delete merged branches and old workflow runs
+1 -1
View File
@@ -6,7 +6,7 @@
# DEFGROUP: Gitea.Workflow # DEFGROUP: Gitea.Workflow
# INGROUP: moko-platform.Notifications # INGROUP: moko-platform.Notifications
# REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform # REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform
# PATH: /.gitea/workflows/notify.yml # PATH: /.mokogitea/workflows/notify.yml
# VERSION: 09.23.00 # VERSION: 09.23.00
# BRIEF: Push notifications via ntfy on release success or workflow failure # BRIEF: Push notifications via ntfy on release success or workflow failure
+6 -4
View File
@@ -172,7 +172,8 @@ jobs:
if: steps.platform.outputs.platform == 'joomla' if: steps.platform.outputs.platform == 'joomla'
run: | run: |
MISSING=0 MISSING=0
SOURCE_DIR="src" SOURCE_DIR="source"
[ ! -d "$SOURCE_DIR" ] && SOURCE_DIR="src"
[ ! -d "$SOURCE_DIR" ] && exit 0 [ ! -d "$SOURCE_DIR" ] && exit 0
while IFS= read -r dir; do while IFS= read -r dir; do
if [ ! -f "${dir}/index.html" ]; then if [ ! -f "${dir}/index.html" ]; then
@@ -220,7 +221,7 @@ jobs:
echo "joomla.asset.json: valid" echo "joomla.asset.json: valid"
fi fi
# Validate all XML files in src/ are well-formed # Validate all XML files in source/src/ are well-formed
XML_ERRORS=0 XML_ERRORS=0
if command -v php &> /dev/null; then if command -v php &> /dev/null; then
while IFS= read -r -d '' xmlfile; do while IFS= read -r -d '' xmlfile; do
@@ -451,10 +452,11 @@ jobs:
- name: Verify package source - name: Verify package source
run: | run: |
SOURCE_DIR="src" SOURCE_DIR="source"
[ ! -d "$SOURCE_DIR" ] && SOURCE_DIR="src"
[ ! -d "$SOURCE_DIR" ] && SOURCE_DIR="htdocs" [ ! -d "$SOURCE_DIR" ] && SOURCE_DIR="htdocs"
if [ ! -d "$SOURCE_DIR" ]; then if [ ! -d "$SOURCE_DIR" ]; then
echo "::warning::No src/ or htdocs/ directory" echo "::warning::No source/, src/, or htdocs/ directory"
exit 0 exit 0
fi fi
FILE_COUNT=$(find "$SOURCE_DIR" -type f | wc -l) FILE_COUNT=$(find "$SOURCE_DIR" -type f | wc -l)
+84 -25
View File
@@ -7,7 +7,7 @@
# INGROUP: moko-platform.Release # INGROUP: moko-platform.Release
# REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform # REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform
# PATH: /templates/workflows/universal/pre-release.yml.template # PATH: /templates/workflows/universal/pre-release.yml.template
# VERSION: 09.23.00 # VERSION: 05.01.00
# BRIEF: Manual pre-release -- builds dev/alpha/beta/rc packages from any branch # BRIEF: Manual pre-release -- builds dev/alpha/beta/rc packages from any branch
name: "Universal: Pre-Release" name: "Universal: Pre-Release"
@@ -17,6 +17,10 @@ on:
types: [closed] types: [closed]
branches: branches:
- dev - dev
pull_request_target:
types: [synchronize, opened, reopened]
branches:
- main
workflow_dispatch: workflow_dispatch:
inputs: inputs:
stability: stability:
@@ -43,7 +47,8 @@ jobs:
runs-on: release runs-on: release
if: >- if: >-
github.event_name == 'workflow_dispatch' || 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: steps:
- name: Checkout - name: Checkout
@@ -51,22 +56,28 @@ jobs:
with: with:
fetch-depth: 0 fetch-depth: 0
token: ${{ secrets.MOKOGITEA_TOKEN }} token: ${{ secrets.MOKOGITEA_TOKEN }}
ref: ${{ github.event_name == 'pull_request_target' && github.event.pull_request.head.sha || '' }}
- name: Setup moko-platform tools - name: Setup moko-platform tools
env: env:
MOKO_CLONE_TOKEN: ${{ secrets.MOKOGITEA_TOKEN }} MOKO_CLONE_TOKEN: ${{ secrets.MOKOGITEA_TOKEN }}
MOKO_CLONE_HOST: git.mokoconsulting.tech/MokoConsulting MOKO_CLONE_HOST: git.mokoconsulting.tech/MokoConsulting
run: | run: |
if ! command -v composer &> /dev/null; then # Use pre-installed /opt/moko-platform if available (updated by cron every 6h)
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/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 fi
# Always fetch latest CLI tools — never use stale cache from previous runs
rm -rf /tmp/moko-platform-api
git clone --depth 1 --branch main --quiet \
"https://x-access-token:${MOKO_CLONE_TOKEN}@${MOKO_CLONE_HOST}/moko-platform.git" \
/tmp/moko-platform-api
cd /tmp/moko-platform-api && composer install --no-dev --no-interaction --quiet
echo "MOKO_CLI=/tmp/moko-platform-api/cli" >> "$GITHUB_ENV"
- name: Detect platform - name: Detect platform
id: platform id: platform
@@ -76,31 +87,43 @@ jobs:
- name: Resolve metadata and bump version - name: Resolve metadata and bump version
id: meta id: meta
run: | 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 case "$STABILITY" in
development) TAG="development" ;; development) SUFFIX="-dev"; TAG="development" ;;
alpha) TAG="alpha" ;; alpha) SUFFIX="-alpha"; TAG="alpha" ;;
beta) TAG="beta" ;; beta) SUFFIX="-beta"; TAG="beta" ;;
release-candidate) TAG="release-candidate" ;; release-candidate) SUFFIX="-rc"; TAG="release-candidate" ;;
esac esac
# Bump version: patch for dev/alpha/beta, minor for RC # Bump version via CLI: patch for dev/alpha/beta, minor for RC
case "$STABILITY" in case "$STABILITY" in
release-candidate) php ${MOKO_CLI}/version_bump.php --path . --minor 2>/dev/null || true ;; release-candidate) BUMP="minor" ;;
*) php ${MOKO_CLI}/version_bump.php --path . 2>/dev/null || true ;; *) BUMP="patch" ;;
esac esac
# Set stability suffix and fix consistency php ${MOKO_CLI}/version_bump.php --path . $([ "$BUMP" = "minor" ] && echo "--minor") 2>/dev/null || true
VERSION=$(php ${MOKO_CLI}/version_read.php --path . 2>/dev/null || echo '00.00.01')
# Set stability suffix and verify consistency
VERSION=$(php ${MOKO_CLI}/version_read.php --path . 2>/dev/null || echo "00.00.01")
VERSION=$(echo "$VERSION" | sed 's/-\(dev\|alpha\|beta\|rc\)$//') VERSION=$(echo "$VERSION" | sed 's/-\(dev\|alpha\|beta\|rc\)$//')
php ${MOKO_CLI}/version_set_platform.php \ php ${MOKO_CLI}/version_set_platform.php \
--path . --version "$VERSION" --branch "${{ github.ref_name }}" --stability "$STABILITY" 2>/dev/null || true --path . --version "$VERSION" --branch "${{ github.ref_name }}" --stability "$STABILITY" 2>/dev/null || true
php ${MOKO_CLI}/version_check.php --path . --fix 2>/dev/null || true php ${MOKO_CLI}/version_check.php --path . --fix 2>/dev/null || true
# Read final version with suffix # Ensure licensing tags (updateservers, dlid) if enabled in manifest.xml
VERSION=$(php ${MOKO_CLI}/version_read.php --path . 2>/dev/null) php ${MOKO_CLI}/manifest_licensing.php --path . --fix 2>/dev/null || true
[ -z "$VERSION" ] && VERSION="00.00.01"
# Append suffix for output
if [ -n "$SUFFIX" ]; then
VERSION="${VERSION}${SUFFIX}"
fi
# Commit version bump # Commit version bump
git config --local user.email "gitea-actions[bot]@mokoconsulting.tech" git config --local user.email "gitea-actions[bot]@mokoconsulting.tech"
@@ -125,11 +148,12 @@ jobs:
echo "version=${VERSION}" >> "$GITHUB_OUTPUT" echo "version=${VERSION}" >> "$GITHUB_OUTPUT"
echo "stability=${STABILITY}" >> "$GITHUB_OUTPUT" echo "stability=${STABILITY}" >> "$GITHUB_OUTPUT"
echo "suffix=${SUFFIX}" >> "$GITHUB_OUTPUT"
echo "tag=${TAG}" >> "$GITHUB_OUTPUT" echo "tag=${TAG}" >> "$GITHUB_OUTPUT"
echo "zip_name=${ZIP_NAME}" >> "$GITHUB_OUTPUT" echo "zip_name=${ZIP_NAME}" >> "$GITHUB_OUTPUT"
echo "ext_element=${EXT_ELEMENT}" >> "$GITHUB_OUTPUT" echo "ext_element=${EXT_ELEMENT}" >> "$GITHUB_OUTPUT"
echo "=== Pre-Release: ${EXT_ELEMENT} ${VERSION} ===" echo "=== Pre-Release: ${EXT_ELEMENT} ${VERSION}${SUFFIX} ==="
- name: Create release - name: Create release
id: release id: release
@@ -142,6 +166,41 @@ jobs:
--token "${{ secrets.MOKOGITEA_TOKEN }}" --api-base "$API_BASE" \ --token "${{ secrets.MOKOGITEA_TOKEN }}" --api-base "$API_BASE" \
--repo "${GITEA_REPO}" --branch dev --prerelease --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 - name: Build package and upload
id: package id: package
run: | run: |
+6 -4
View File
@@ -296,17 +296,19 @@ jobs:
missing_required=() missing_required=()
missing_optional=() missing_optional=()
# Source directory: src/ or htdocs/ (either is valid for extension repos) # Source directory: source/, src/, or htdocs/ (any is valid for extension repos)
SOURCE_DIR="" SOURCE_DIR=""
if [ -d "src" ]; then if [ -d "source" ]; then
SOURCE_DIR="source"
elif [ -d "src" ]; then
SOURCE_DIR="src" SOURCE_DIR="src"
elif [ -d "htdocs" ]; then elif [ -d "htdocs" ]; then
SOURCE_DIR="htdocs" SOURCE_DIR="htdocs"
elif [ -d "deploy" ] || [ -d "cli" ] || [ -d "monitoring" ]; then elif [ -d "deploy" ] || [ -d "cli" ] || [ -d "monitoring" ]; then
# Platform/tooling repos don't need src/ # Platform/tooling repos don't need source/
SOURCE_DIR="" SOURCE_DIR=""
else else
missing_required+=("src/ or htdocs/ (source directory required)") missing_required+=("source/ or src/ or htdocs/ (source directory required)")
fi fi
for item in "${required_artifacts[@]}"; do for item in "${required_artifacts[@]}"; do
+1 -1
View File
@@ -6,7 +6,7 @@
# DEFGROUP: Gitea.Workflow # DEFGROUP: Gitea.Workflow
# INGROUP: moko-platform.Security # INGROUP: moko-platform.Security
# REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform # REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform
# PATH: /.gitea/workflows/security-audit.yml # PATH: /.mokogitea/workflows/security-audit.yml
# VERSION: 09.23.00 # VERSION: 09.23.00
# BRIEF: Dependency vulnerability scanning for composer and npm packages # BRIEF: Dependency vulnerability scanning for composer and npm packages
-302
View File
@@ -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
-102
View File
@@ -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 -6
View File
@@ -31,7 +31,7 @@ require_once __DIR__ . '/../vendor/autoload.php';
require_once __DIR__ . '/../lib/Enterprise/CliFramework.php'; require_once __DIR__ . '/../lib/Enterprise/CliFramework.php';
use MokoEnterprise\CliFramework; use MokoEnterprise\{CliFramework, SourceResolver};
use phpseclib3\Net\SFTP; use phpseclib3\Net\SFTP;
use phpseclib3\Crypt\PublicKeyLoader; use phpseclib3\Crypt\PublicKeyLoader;
@@ -866,11 +866,11 @@ class DeployJoomla extends CliFramework
} }
} }
// 3-5. Fallback chain // 3-5. Fallback chain (source/ → src/ → htdocs/)
foreach (['src', 'htdocs'] as $candidate) { $resolved = SourceResolver::resolveAbsolute($repoPath);
if (is_dir("{$repoPath}/{$candidate}")) { if ($resolved !== null) {
return "{$repoPath}/{$candidate}"; SourceResolver::warnIfLegacy($repoPath);
} return $resolved;
} }
// Last resort: repo root itself // Last resort: repo root itself
+4 -9
View File
@@ -19,7 +19,7 @@ declare(strict_types=1);
require_once __DIR__ . '/../lib/Enterprise/CliFramework.php'; require_once __DIR__ . '/../lib/Enterprise/CliFramework.php';
use MokoEnterprise\CliFramework; use MokoEnterprise\{CliFramework, SourceResolver};
class JoomlaBuildCli extends CliFramework class JoomlaBuildCli extends CliFramework
{ {
@@ -49,17 +49,12 @@ class JoomlaBuildCli extends CliFramework
$path = realpath($path) ?: $path; $path = realpath($path) ?: $path;
// ── Find source directory ────────────────────────────────────────────── // ── Find source directory ──────────────────────────────────────────────
$srcDir = null; $srcDir = SourceResolver::resolveAbsolute($path);
foreach (['src', 'htdocs'] as $d) {
if (is_dir("{$path}/{$d}")) {
$srcDir = "{$path}/{$d}";
break;
}
}
if ($srcDir === null) { if ($srcDir === null) {
$this->log('ERROR', "::error::No src/ or htdocs/ directory in {$path}"); $this->log('ERROR', "::error::No source/ or src/ directory in {$path}");
return 1; return 1;
} }
SourceResolver::warnIfLegacy($path);
// ── Find manifest ────────────────────────────────────────────────────── // ── Find manifest ──────────────────────────────────────────────────────
$manifest = $this->findManifest($srcDir); $manifest = $this->findManifest($srcDir);
+4 -3
View File
@@ -25,7 +25,7 @@ declare(strict_types=1);
require_once __DIR__ . '/../vendor/autoload.php'; require_once __DIR__ . '/../vendor/autoload.php';
use MokoEnterprise\{ApiClient, AuditLogger, CliFramework, Config, PlatformAdapterFactory}; use MokoEnterprise\{ApiClient, AuditLogger, CliFramework, Config, PlatformAdapterFactory, SourceResolver};
/** /**
* Joomla Release Manager * Joomla Release Manager
@@ -121,11 +121,12 @@ class JoomlaRelease extends CliFramework
$this->log('INFO', "Version: {$displayVersion} | Release tag: {$releaseTag}"); $this->log('INFO', "Version: {$displayVersion} | Release tag: {$releaseTag}");
// ── Step 3: Build packages ──────────────────────────────────── // ── Step 3: Build packages ────────────────────────────────────
$srcDir = is_dir("{$path}/src") ? "{$path}/src" : (is_dir("{$path}/htdocs") ? "{$path}/htdocs" : null); $srcDir = SourceResolver::resolveAbsolute($path);
if ($srcDir === null) { if ($srcDir === null) {
$this->log('ERROR', 'No src/ or htdocs/ directory'); $this->log('ERROR', 'No source/ or src/ directory');
return 1; return 1;
} }
SourceResolver::warnIfLegacy($path);
$prefix = $this->typePrefix($meta); $prefix = $this->typePrefix($meta);
$zipName = "{$prefix}{$meta['element']}-{$displayVersion}.zip"; $zipName = "{$prefix}{$meta['element']}-{$displayVersion}.zip";
+3 -4
View File
@@ -17,7 +17,7 @@ declare(strict_types=1);
require_once __DIR__ . '/../lib/Enterprise/CliFramework.php'; require_once __DIR__ . '/../lib/Enterprise/CliFramework.php';
use MokoEnterprise\CliFramework; use MokoEnterprise\{CliFramework, SourceResolver};
class ManifestElementCli extends CliFramework class ManifestElementCli extends CliFramework
{ {
@@ -48,7 +48,7 @@ class ManifestElementCli extends CliFramework
} }
} }
$extManifest = null; $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) { foreach ($manifestFiles as $file) {
$c = file_get_contents($file); $c = file_get_contents($file);
if (strpos($c, '<extension') !== false) { if (strpos($c, '<extension') !== false) {
@@ -58,8 +58,7 @@ class ManifestElementCli extends CliFramework
} }
$modFile = null; $modFile = null;
$modFiles = array_merge( $modFiles = array_merge(
glob("{$root}/src/core/modules/mod*.class.php") ?: [], SourceResolver::globSource($root, 'core/modules/mod*.class.php'),
glob("{$root}/htdocs/core/modules/mod*.class.php") ?: [],
glob("{$root}/core/modules/mod*.class.php") ?: [] glob("{$root}/core/modules/mod*.class.php") ?: []
); );
foreach ($modFiles as $file) { foreach ($modFiles as $file) {
+280
View 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());
+4 -9
View File
@@ -19,7 +19,7 @@ declare(strict_types=1);
require_once __DIR__ . '/../lib/Enterprise/CliFramework.php'; require_once __DIR__ . '/../lib/Enterprise/CliFramework.php';
use MokoEnterprise\CliFramework; use MokoEnterprise\{CliFramework, SourceResolver};
class PackageBuildCli extends CliFramework class PackageBuildCli extends CliFramework
{ {
@@ -56,18 +56,13 @@ class PackageBuildCli extends CliFramework
} }
// -- Determine source directory ----------------------------------------------- // -- Determine source directory -----------------------------------------------
$sourceDir = null; $sourceDir = SourceResolver::resolveAbsolute($root);
foreach (['src', 'htdocs'] as $candidate) {
if (is_dir("{$root}/{$candidate}")) {
$sourceDir = "{$root}/{$candidate}";
break;
}
}
if ($sourceDir === null) { 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; return 1;
} }
SourceResolver::warnIfLegacy($root);
// -- Determine element and type prefix from manifest -------------------------- // -- Determine element and type prefix from manifest --------------------------
$extElement = $elementOverride; $extElement = $elementOverride;
+4 -5
View File
@@ -17,7 +17,7 @@ declare(strict_types=1);
require_once __DIR__ . '/../lib/Enterprise/CliFramework.php'; require_once __DIR__ . '/../lib/Enterprise/CliFramework.php';
use MokoEnterprise\CliFramework; use MokoEnterprise\{CliFramework, SourceResolver};
class ReleaseCreateCli extends CliFramework class ReleaseCreateCli extends CliFramework
{ {
@@ -97,8 +97,8 @@ class ReleaseCreateCli extends CliFramework
// Find extension manifest (Joomla XML) // Find extension manifest (Joomla XML)
$extManifest = null; $extManifest = null;
$manifestFiles = array_merge( $manifestFiles = array_merge(
glob("{$root}/src/pkg_*.xml") ?: [], SourceResolver::globSource($root, 'pkg_*.xml'),
glob("{$root}/src/*.xml") ?: [], SourceResolver::globSource($root, '*.xml'),
glob("{$root}/*.xml") ?: [] glob("{$root}/*.xml") ?: []
); );
foreach ($manifestFiles as $file) { foreach ($manifestFiles as $file) {
@@ -112,8 +112,7 @@ class ReleaseCreateCli extends CliFramework
// Find Dolibarr module file // Find Dolibarr module file
$modFile = null; $modFile = null;
$modFiles = array_merge( $modFiles = array_merge(
glob("{$root}/src/core/modules/mod*.class.php") ?: [], SourceResolver::globSource($root, 'core/modules/mod*.class.php'),
glob("{$root}/htdocs/core/modules/mod*.class.php") ?: [],
glob("{$root}/core/modules/mod*.class.php") ?: [] glob("{$root}/core/modules/mod*.class.php") ?: []
); );
foreach ($modFiles as $file) { foreach ($modFiles as $file) {
+15 -15
View File
@@ -17,7 +17,7 @@ declare(strict_types=1);
require_once __DIR__ . '/../lib/Enterprise/CliFramework.php'; require_once __DIR__ . '/../lib/Enterprise/CliFramework.php';
use MokoEnterprise\CliFramework; use MokoEnterprise\{CliFramework, SourceResolver};
class ReleasePackageCli extends CliFramework class ReleasePackageCli extends CliFramework
{ {
@@ -99,9 +99,10 @@ class ReleasePackageCli extends CliFramework
$extFolder = ''; $extFolder = '';
$typePrefix = ''; $typePrefix = '';
SourceResolver::warnIfLegacy($root);
$manifestFiles = array_merge( $manifestFiles = array_merge(
glob("{$root}/src/pkg_*.xml") ?: [], SourceResolver::globSource($root, 'pkg_*.xml'),
glob("{$root}/src/*.xml") ?: [], SourceResolver::globSource($root, '*.xml'),
glob("{$root}/*.xml") ?: [] glob("{$root}/*.xml") ?: []
); );
@@ -200,14 +201,12 @@ class ReleasePackageCli extends CliFramework
} }
} }
if ($sourceDir === null && is_dir("{$root}/src")) { if ($sourceDir === null) {
$sourceDir = "{$root}/src"; $sourceDir = SourceResolver::resolveAbsolute($root);
} elseif ($sourceDir === null && is_dir("{$root}/htdocs")) {
$sourceDir = "{$root}/htdocs";
} }
if ($sourceDir === null) { 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; return 0;
} }
@@ -231,19 +230,20 @@ class ReleasePackageCli extends CliFramework
$subZipPath = "{$outputDir}/{$subName}.zip"; $subZipPath = "{$outputDir}/{$subName}.zip";
// If sub-package is a full repo checkout (e.g. git submodule), // If sub-package is a full repo checkout (e.g. git submodule),
// look for a src/ subdirectory containing a Joomla manifest XML // look for a source/ or src/ subdirectory containing a Joomla manifest XML
// and zip that instead of the repo root. // and zip that instead of the repo root.
$subSourceDir = $pkgDir; $subSourceDir = $pkgDir;
$srcCandidate = "{$pkgDir}/src"; $subSrcAbs = SourceResolver::resolveAbsolute($pkgDir);
if (is_dir($srcCandidate)) { if ($subSrcAbs !== null) {
$srcManifests = array_merge( $srcManifests = array_merge(
glob("{$srcCandidate}/*.xml") ?: [], glob("{$subSrcAbs}/*.xml") ?: [],
glob("{$srcCandidate}/pkg_*.xml") ?: [] glob("{$subSrcAbs}/pkg_*.xml") ?: []
); );
foreach ($srcManifests as $mf) { foreach ($srcManifests as $mf) {
if (strpos(file_get_contents($mf) ?: '', '<extension') !== false) { if (strpos(file_get_contents($mf) ?: '', '<extension') !== false) {
$subSourceDir = $srcCandidate; $subSourceDir = $subSrcAbs;
echo " Sub-package {$subName}: using src/ entry-point\n"; $subSrcName = SourceResolver::resolve($pkgDir);
echo " Sub-package {$subName}: using {$subSrcName}/ entry-point\n";
break; break;
} }
} }
+3 -3
View File
@@ -17,7 +17,7 @@ declare(strict_types=1);
require_once __DIR__ . '/../lib/Enterprise/CliFramework.php'; require_once __DIR__ . '/../lib/Enterprise/CliFramework.php';
use MokoEnterprise\CliFramework; use MokoEnterprise\{CliFramework, SourceResolver};
class ReleasePromoteCli extends CliFramework class ReleasePromoteCli extends CliFramework
{ {
@@ -109,8 +109,8 @@ class ReleasePromoteCli extends CliFramework
if ($to === 'stable') { if ($to === 'stable') {
$root = realpath($path) ?: $path; $root = realpath($path) ?: $path;
$manifestFiles = array_merge( $manifestFiles = array_merge(
glob("{$root}/src/pkg_*.xml") ?: [], SourceResolver::globSource($root, 'pkg_*.xml'),
glob("{$root}/src/*.xml") ?: [], SourceResolver::globSource($root, '*.xml'),
glob("{$root}/*.xml") ?: [] glob("{$root}/*.xml") ?: []
); );
foreach ($manifestFiles as $xmlFile) { foreach ($manifestFiles as $xmlFile) {
+8 -5
View File
@@ -17,7 +17,7 @@ declare(strict_types=1);
require_once __DIR__ . '/../lib/Enterprise/CliFramework.php'; require_once __DIR__ . '/../lib/Enterprise/CliFramework.php';
use MokoEnterprise\CliFramework; use MokoEnterprise\{CliFramework, SourceResolver};
class ReleaseValidateCli extends CliFramework class ReleaseValidateCli extends CliFramework
{ {
@@ -66,8 +66,10 @@ class ReleaseValidateCli extends CliFramework
$platform = 'generic'; $platform = 'generic';
} }
} }
$hasSource = is_dir("{$root}/src") || is_dir("{$root}/htdocs"); $hasSource = SourceResolver::resolveAbsolute($root) !== null;
$this->addVResult('Source directory', $hasSource ? 'PASS' : 'WARN', $hasSource ? 'src/ or htdocs/ found' : 'No src/ or htdocs/ directory'); 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")) { if (!file_exists("{$root}/README.md")) {
$this->addVResult('README.md', 'FAIL', 'Not found'); $this->addVResult('README.md', 'FAIL', 'Not found');
} else { } else {
@@ -109,7 +111,8 @@ class ReleaseValidateCli extends CliFramework
$this->addVResult('LICENSE', $licenseFound ? 'PASS' : 'FAIL', $licenseFound ? 'Found' : 'Not found'); $this->addVResult('LICENSE', $licenseFound ? 'PASS' : 'FAIL', $licenseFound ? 'Found' : 'Not found');
if ($platform === 'joomla') { if ($platform === 'joomla') {
$manifest = null; $manifest = null;
foreach (["{$root}/src", $root] as $dir) { $srcAbs = SourceResolver::resolveAbsolute($root);
foreach (array_filter([$srcAbs, $root]) as $dir) {
if (!is_dir($dir)) { if (!is_dir($dir)) {
continue; continue;
} foreach (glob("{$dir}/*.xml") as $xmlFile) { } foreach (glob("{$dir}/*.xml") as $xmlFile) {
@@ -156,7 +159,7 @@ class ReleaseValidateCli extends CliFramework
} }
} elseif ($platform === 'dolibarr') { } elseif ($platform === 'dolibarr') {
$modFile = null; $modFile = null;
foreach (['src', 'htdocs'] as $sd) { foreach (SourceResolver::getCandidates() as $sd) {
$matches = glob("{$root}/{$sd}/mod*.class.php"); $matches = glob("{$root}/{$sd}/mod*.class.php");
if (!empty($matches)) { if (!empty($matches)) {
$modFile = $matches[0]; $modFile = $matches[0];
+4 -9
View File
@@ -17,7 +17,7 @@ declare(strict_types=1);
require_once __DIR__ . '/../lib/Enterprise/CliFramework.php'; require_once __DIR__ . '/../lib/Enterprise/CliFramework.php';
use MokoEnterprise\CliFramework; use MokoEnterprise\{CliFramework, SourceResolver};
class ThemeLintCli extends CliFramework class ThemeLintCli extends CliFramework
{ {
@@ -41,17 +41,12 @@ class ThemeLintCli extends CliFramework
$errors = 0; $errors = 0;
$warnings = 0; $warnings = 0;
$srcDir = null; $srcDir = SourceResolver::resolveAbsolute($root);
foreach (['src', 'htdocs'] as $d) {
if (is_dir("{$root}/{$d}")) {
$srcDir = "{$root}/{$d}";
break;
}
}
if ($srcDir === null) { 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; return 1;
} }
SourceResolver::warnIfLegacy($root);
echo "Theme Lint: {$srcDir}\n\n"; echo "Theme Lint: {$srcDir}\n\n";
+2 -2
View File
@@ -17,7 +17,7 @@ declare(strict_types=1);
require_once __DIR__ . '/../lib/Enterprise/CliFramework.php'; require_once __DIR__ . '/../lib/Enterprise/CliFramework.php';
use MokoEnterprise\CliFramework; use MokoEnterprise\{CliFramework, SourceResolver};
class UpdatesXmlBuildCli extends CliFramework class UpdatesXmlBuildCli extends CliFramework
{ {
@@ -109,7 +109,7 @@ class UpdatesXmlBuildCli extends CliFramework
// -- Locate Joomla manifest --------------------------------------------------- // -- Locate Joomla manifest ---------------------------------------------------
$manifest = null; $manifest = null;
$candidates = glob("{$root}/src/pkg_*.xml") ?: []; $candidates = SourceResolver::globSource($root, 'pkg_*.xml');
foreach ($candidates as $f) { foreach ($candidates as $f) {
if (strpos(file_get_contents($f), '<extension') !== false) { if (strpos(file_get_contents($f), '<extension') !== false) {
$manifest = $f; $manifest = $f;
+8 -6
View File
@@ -17,7 +17,7 @@ declare(strict_types=1);
require_once __DIR__ . '/../lib/Enterprise/CliFramework.php'; require_once __DIR__ . '/../lib/Enterprise/CliFramework.php';
use MokoEnterprise\CliFramework; use MokoEnterprise\{CliFramework, SourceResolver};
class VersionBumpCli extends CliFramework class VersionBumpCli extends CliFramework
{ {
@@ -61,11 +61,12 @@ class VersionBumpCli extends CliFramework
} }
} }
$manifestVersion = null; $manifestVersion = null;
SourceResolver::warnIfLegacy($root);
$manifestFiles = array_merge( $manifestFiles = array_merge(
glob("{$root}/src/pkg_*.xml") ?: [], SourceResolver::globSource($root, 'pkg_*.xml'),
glob("{$root}/src/*.xml") ?: [], SourceResolver::globSource($root, '*.xml'),
glob("{$root}/src/packages/*/mokowaas.xml") ?: [], SourceResolver::globSource($root, 'packages/*/mokowaas.xml'),
glob("{$root}/src/packages/*/*.xml") ?: [], SourceResolver::globSource($root, 'packages/*/*.xml'),
glob("{$root}/*.xml") ?: [] glob("{$root}/*.xml") ?: []
); );
foreach ($manifestFiles as $xmlFile) { foreach ($manifestFiles as $xmlFile) {
@@ -141,7 +142,8 @@ class VersionBumpCli extends CliFramework
} }
} }
$updatedFiles = []; $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) { foreach (glob($pattern) ?: [] as $xmlFile) {
$content = file_get_contents($xmlFile); $content = file_get_contents($xmlFile);
if (strpos($content, '<extension') === false) { if (strpos($content, '<extension') === false) {
+8 -4
View File
@@ -17,7 +17,7 @@ declare(strict_types=1);
require_once __DIR__ . '/../lib/Enterprise/CliFramework.php'; require_once __DIR__ . '/../lib/Enterprise/CliFramework.php';
use MokoEnterprise\CliFramework; use MokoEnterprise\{CliFramework, SourceResolver};
class VersionBumpRemoteCli extends CliFramework class VersionBumpRemoteCli extends CliFramework
{ {
@@ -104,11 +104,15 @@ class VersionBumpRemoteCli extends CliFramework
$nextVersion = sprintf('%02d.%02d.%02d', $major, $minor, $patch); $nextVersion = sprintf('%02d.%02d.%02d', $major, $minor, $patch);
echo "{$version} -> {$nextVersion} ({$branch})\n"; echo "{$version} -> {$nextVersion} ({$branch})\n";
// Try both source/ and src/ paths for backwards compatibility with remote repos
$manifestPaths = []; $manifestPaths = [];
if ($manifestFile !== null) { foreach (['source', 'src'] as $srcPrefix) {
$manifestPaths[] = "src/{$manifestFile}"; 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; $manifestUpdated = false;
foreach ($manifestPaths as $mPath) { foreach ($manifestPaths as $mPath) {
$result = $this->updateRemoteFile($apiBase, $token, $mPath, $branch, function (string $content) use ($version, $nextVersion): string { $result = $this->updateRemoteFile($apiBase, $token, $mPath, $branch, function (string $content) use ($version, $nextVersion): string {
+3 -2
View File
@@ -18,7 +18,7 @@ declare(strict_types=1);
require_once __DIR__ . '/../lib/Enterprise/CliFramework.php'; require_once __DIR__ . '/../lib/Enterprise/CliFramework.php';
use MokoEnterprise\CliFramework; use MokoEnterprise\{CliFramework, SourceResolver};
class VersionCheckCli extends CliFramework class VersionCheckCli extends CliFramework
{ {
@@ -77,7 +77,8 @@ class VersionCheckCli extends CliFramework
$versions['pyproject.toml'] = $m[1]; $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) { foreach (glob($glob) ?: [] as $file) {
if (basename($file) === 'updates.xml') { if (basename($file) === 'updates.xml') {
continue; continue;
+4 -4
View File
@@ -17,7 +17,7 @@ declare(strict_types=1);
require_once __DIR__ . '/../lib/Enterprise/CliFramework.php'; require_once __DIR__ . '/../lib/Enterprise/CliFramework.php';
use MokoEnterprise\CliFramework; use MokoEnterprise\{CliFramework, SourceResolver};
class VersionReadCli extends CliFramework class VersionReadCli extends CliFramework
{ {
@@ -64,9 +64,9 @@ class VersionReadCli extends CliFramework
// -- 3. Fallback: Joomla manifest XML -- // -- 3. Fallback: Joomla manifest XML --
$manifestVersion = null; $manifestVersion = null;
$manifestFiles = array_merge( $manifestFiles = array_merge(
glob("{$root}/src/pkg_*.xml") ?: [], SourceResolver::globSource($root, 'pkg_*.xml'),
glob("{$root}/src/*.xml") ?: [], SourceResolver::globSource($root, '*.xml'),
glob("{$root}/src/packages/*/*.xml") ?: [], SourceResolver::globSource($root, 'packages/*/*.xml'),
glob("{$root}/*.xml") ?: [] glob("{$root}/*.xml") ?: []
); );
+6 -4
View File
@@ -17,7 +17,7 @@ declare(strict_types=1);
require_once __DIR__ . '/../lib/Enterprise/CliFramework.php'; require_once __DIR__ . '/../lib/Enterprise/CliFramework.php';
use MokoEnterprise\CliFramework; use MokoEnterprise\{CliFramework, SourceResolver};
class VersionSetPlatformCli extends CliFramework class VersionSetPlatformCli extends CliFramework
{ {
@@ -110,7 +110,8 @@ class VersionSetPlatformCli extends CliFramework
// Dolibarr: $this->version + $this->url_last_version in mod*.class.php // Dolibarr: $this->version + $this->url_last_version in mod*.class.php
if ($platform === 'crm-module') { 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) { foreach (glob($pattern) ?: [] as $file) {
$content = file_get_contents($file); $content = file_get_contents($file);
@@ -146,9 +147,10 @@ class VersionSetPlatformCli extends CliFramework
// Joomla: <version> in XML manifests (top-level + sub-packages) // Joomla: <version> in XML manifests (top-level + sub-packages)
if (in_array($platform, ['waas-component', 'joomla'], true)) { if (in_array($platform, ['waas-component', 'joomla'], true)) {
$srcName = SourceResolver::resolve($root);
$xmlFiles = array_merge( $xmlFiles = array_merge(
glob("{$root}/src/*.xml") ?: [], glob("{$root}/{$srcName}/*.xml") ?: [],
glob("{$root}/src/packages/*/*.xml") ?: [], glob("{$root}/{$srcName}/packages/*/*.xml") ?: [],
glob("{$root}/*.xml") ?: [] glob("{$root}/*.xml") ?: []
); );
if (empty($xmlFiles)) { if (empty($xmlFiles)) {
+5 -4
View File
@@ -21,7 +21,7 @@ require_once __DIR__ . '/../vendor/autoload.php';
require_once __DIR__ . '/../lib/Enterprise/CliFramework.php'; require_once __DIR__ . '/../lib/Enterprise/CliFramework.php';
use MokoEnterprise\CliFramework; use MokoEnterprise\{CliFramework, SourceResolver};
use phpseclib3\Net\SFTP; use phpseclib3\Net\SFTP;
use phpseclib3\Crypt\PublicKeyLoader; use phpseclib3\Crypt\PublicKeyLoader;
@@ -51,9 +51,9 @@ class DeploySftp extends CliFramework
protected function configure(): void 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('--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('--env', 'Target environment: dev or rs', '');
$this->addArgument('--config', 'Explicit config file path — overrides --env', ''); $this->addArgument('--config', 'Explicit config file path — overrides --env', '');
$this->addArgument('--key-passphrase', 'Passphrase for the SSH private key', ''); $this->addArgument('--key-passphrase', 'Passphrase for the SSH private key', '');
@@ -158,7 +158,8 @@ class DeploySftp extends CliFramework
*/ */
private function resolveSrcDir(string $repoPath): string 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; $dir = $repoPath . DIRECTORY_SEPARATOR . $sub;
if (!is_dir($dir)) { if (!is_dir($dir)) {
+7
View File
@@ -171,6 +171,13 @@ abstract class CliFramework
*/ */
public function __construct(string $name = '', string $version = '04.00.15') 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->scriptName = $name ?: basename($_SERVER['argv'][0] ?? 'script', '.php');
$this->scriptVersion = $version; $this->scriptVersion = $version;
$this->startTime = microtime(true); $this->startTime = microtime(true);
+8 -10
View File
@@ -147,31 +147,29 @@ class ManifestReader
/** /**
* Get the source/entry-point directory. * 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 * @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 public function getSourceDir(string $root = ''): string
{ {
$entryPoint = $this->get('entry-point', ''); $entryPoint = $this->get('entry-point', '');
if ($entryPoint !== '') { 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), '/'); $dir = rtrim(dirname($entryPoint) === '.' ? $entryPoint : dirname($entryPoint), '/');
if ($root === '' || is_dir("{$root}/{$dir}")) { if ($root === '' || is_dir("{$root}/{$dir}")) {
return $dir; return $dir;
} }
} }
// Fallback: check common directories // Fallback: use SourceResolver (source/ → src/ → htdocs/ → default 'source')
if ($root !== '') { if ($root !== '') {
if (is_dir("{$root}/src")) { return SourceResolver::resolve($root);
return 'src';
}
if (is_dir("{$root}/htdocs")) {
return 'htdocs';
}
} }
return 'src'; return 'source';
} }
/** /**
+9 -7
View File
@@ -68,7 +68,8 @@ class PackageBuilder
mkdir($packageDir, 0755, true); mkdir($packageDir, 0755, true);
mkdir($distDir, 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)) { if (is_dir($repoRoot . '/' . $dir)) {
self::copyDirectory($repoRoot . '/' . $dir, $packageDir . '/' . $dir); self::copyDirectory($repoRoot . '/' . $dir, $packageDir . '/' . $dir);
} }
@@ -94,15 +95,15 @@ class PackageBuilder
/** /**
* Build a Dolibarr module release package. * Build a Dolibarr module release package.
* *
* Copies everything under src/ into a build staging directory and archives * Copies everything under source/ (or src/) into a build staging directory
* it as dist/<MODULE_NAME>_<VERSION>.zip. * and archives it as dist/<MODULE_NAME>_<VERSION>.zip.
* *
* @param string $repoRoot Absolute path to the repository root. * @param string $repoRoot Absolute path to the repository root.
* @param string $moduleName Module name (used in archive filename). * @param string $moduleName Module name (used in archive filename).
* @param string $version Version string. * @param string $version Version string.
* @param bool $dryRun When true, preview without writing. * @param bool $dryRun When true, preview without writing.
* @return string Path to the created archive (or would-create path in dry-run). * @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( public static function buildDolibarr(
string $repoRoot, string $repoRoot,
@@ -110,14 +111,15 @@ class PackageBuilder
string $version, string $version,
bool $dryRun = false bool $dryRun = false
): string { ): string {
$srcDir = $repoRoot . '/src'; $srcDir = SourceResolver::resolveAbsolute($repoRoot);
$buildDir = $repoRoot . '/build'; $buildDir = $repoRoot . '/build';
$distDir = $repoRoot . '/dist'; $distDir = $repoRoot . '/dist';
$archivePath = $distDir . '/' . $moduleName . '_' . $version . '.zip'; $archivePath = $distDir . '/' . $moduleName . '_' . $version . '.zip';
if (!is_dir($srcDir)) { if ($srcDir === null) {
throw new \RuntimeException("src/ directory not found at {$srcDir}"); throw new \RuntimeException("source/ or src/ directory not found in {$repoRoot}");
} }
SourceResolver::warnIfLegacy($repoRoot);
if ($dryRun) { if ($dryRun) {
return $archivePath; return $archivePath;
+24 -23
View File
@@ -20,6 +20,7 @@ declare(strict_types=1);
namespace MokoEnterprise\Plugins; namespace MokoEnterprise\Plugins;
use MokoEnterprise\AbstractProjectPlugin; use MokoEnterprise\AbstractProjectPlugin;
use MokoEnterprise\SourceResolver;
/** /**
* MCP Server Project Plugin * MCP Server Project Plugin
@@ -55,10 +56,12 @@ class McpServerPlugin extends AbstractProjectPlugin
$warnings = []; $warnings = [];
// Check for required source files // 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) { foreach ($requiredSrc as $file) {
if (!file_exists("{$projectPath}/{$file}")) { if (SourceResolver::findUnderSource($projectPath, $file) === null) {
$errors[] = "Missing required source file: {$file}"; $errors[] = "Missing required source file: {$srcName}/{$file}";
} }
} }
@@ -82,37 +85,33 @@ class McpServerPlugin extends AbstractProjectPlugin
$errors[] = 'Missing tsconfig.json'; $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 // Check for config example
if (!file_exists("{$projectPath}/config.example.json")) { if (!file_exists("{$projectPath}/config.example.json")) {
$warnings[] = 'Missing config.example.json — example configuration recommended'; $warnings[] = 'Missing config.example.json — example configuration recommended';
} }
// Check for shebang in index.ts // Check for shebang in index.ts
if (file_exists("{$projectPath}/src/index.ts")) { $indexTs = SourceResolver::findUnderSource($projectPath, 'index.ts');
$content = @file_get_contents("{$projectPath}/src/index.ts"); if ($indexTs !== null) {
$content = @file_get_contents($indexTs);
if ($content && strpos($content, '#!/usr/bin/env node') === false) { 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 // Check for McpServer usage
if (file_exists("{$projectPath}/src/index.ts")) { if ($indexTs !== null) {
$content = @file_get_contents("{$projectPath}/src/index.ts"); $content = $content ?? @file_get_contents($indexTs);
if ($content && strpos($content, 'McpServer') === false) { 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 // Check for StdioServerTransport
if (file_exists("{$projectPath}/src/index.ts")) { if ($indexTs !== null) {
$content = @file_get_contents("{$projectPath}/src/index.ts"); $content = $content ?? @file_get_contents($indexTs);
if ($content && strpos($content, 'StdioServerTransport') === false) { 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; $score = 100;
// Check for required source files // 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) { foreach ($requiredSrc as $file) {
if (!file_exists("{$projectPath}/{$file}")) { if (SourceResolver::findUnderSource($projectPath, $file) === null) {
$issues[] = [ $issues[] = [
'severity' => 'critical', 'severity' => 'critical',
'message' => "Missing required file: {$file}", 'message' => "Missing required file: {$srcName}/{$file}",
]; ];
$score -= 20; $score -= 20;
} }
@@ -214,14 +214,15 @@ class McpServerPlugin extends AbstractProjectPlugin
} }
// Check for at least one registered tool // Check for at least one registered tool
if (file_exists("{$projectPath}/src/index.ts")) { $indexTs = SourceResolver::findUnderSource($projectPath, 'index.ts');
$content = @file_get_contents("{$projectPath}/src/index.ts"); if ($indexTs !== null) {
$content = @file_get_contents($indexTs);
if ($content) { if ($content) {
$toolCount = substr_count($content, 'server.tool('); $toolCount = substr_count($content, 'server.tool(');
if ($toolCount === 0) { if ($toolCount === 0) {
$issues[] = [ $issues[] = [
'severity' => 'critical', 'severity' => 'critical',
'message' => 'No MCP tools registered in src/index.ts', 'message' => "No MCP tools registered in {$srcName}/index.ts",
]; ];
$score -= 25; $score -= 25;
} elseif ($toolCount < 5) { } elseif ($toolCount < 5) {
+7 -29
View File
@@ -173,37 +173,15 @@ class RepositorySynchronizer
$platform = $this->detectPlatform($repoInfo); $platform = $this->detectPlatform($repoInfo);
$this->logger->logInfo("Detected platform for {$repo}: {$platform}"); $this->logger->logInfo("Detected platform for {$repo}: {$platform}");
// Load file list from the Terraform definition for this platform // Load shared workflows and config files for this platform from templates
$filesToSync = $this->definitionParser->parseForPlatform($platform, $repoRoot); $filesToSync = $this->getSharedWorkflows($platform, $repoRoot);
$sharedTotal = count($filesToSync);
// 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);
$this->logger->logInfo( $this->logger->logInfo(
"Loaded " . count($filesToSync) . " sync entries for {$platform}" "Loaded {$sharedTotal} sync entries for {$platform}"
. " (def={$defCount}, shared={$sharedAdded}/{$sharedTotal} added, "
. ($sharedTotal - $sharedAdded) . " deduped)"
); );
// Log shared workflow destinations for debugging foreach ($filesToSync as $sf) {
foreach ($sharedFiles as $sf) {
$dest = $sf['destination'] ?? '?'; $dest = $sf['destination'] ?? '?';
$added = !isset($seen[$dest]) ? 'ADDED' : 'DEDUPED'; $this->logger->logInfo(" sync: {$dest}");
$this->logger->logInfo(" shared: {$dest} [{$added}]");
} }
if (empty($filesToSync)) { if (empty($filesToSync)) {
@@ -1380,7 +1358,7 @@ class RepositorySynchronizer
$descriptors = array_values(array_filter( $descriptors = array_values(array_filter(
$paths, $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)) { if (empty($descriptors)) {
+187
View File
@@ -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
+6
View File
@@ -0,0 +1,6 @@
# Changelog
## [1.0] — 2026-05-08
### Added
- Initial release
+3
View File
@@ -0,0 +1,3 @@
# Contributing
See [standards](https://git.mokoconsulting.tech/MokoConsulting/moko-platform/wiki).
+76
View File
@@ -0,0 +1,76 @@
# backup-mcp
MCP server for database and file backups across Dolibarr and Joomla environments
![Language](https://img.shields.io/badge/TypeScript-3178C6?style=flat-square&logo=typescript&logoColor=white) ![License](https://img.shields.io/badge/license-GPL--3.0--or--later-green?style=flat-square) ![Wiki](https://img.shields.io/badge/wiki-backup-mcp-blue?style=flat-square)
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)*
+3
View File
@@ -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"
}
}
}
+25
View File
@@ -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>"
}
+129
View File
@@ -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;
}
}
+141
View File
@@ -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` };
}
}
+29
View File
@@ -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;
}
+128
View File
@@ -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); });
+144
View File
@@ -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;
}
}
+58
View File
@@ -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;
};
}
+19
View File
@@ -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)
+62
View File
@@ -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 |
+161
View File
@@ -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 |
+370
View File
@@ -0,0 +1,370 @@
# dolibarr-api-mcp
MCP server for Dolibarr ERP/CRM REST API operations
![Language](https://img.shields.io/badge/TypeScript-3178C6?style=flat-square&logo=typescript&logoColor=white) ![License](https://img.shields.io/badge/license-GPL--3.0--or--later-green?style=flat-square) ![Node](https://img.shields.io/badge/node-%3E%3D20.0.0-brightgreen?style=flat-square&logo=node.js&logoColor=white) ![MCP](https://img.shields.io/badge/MCP-1.12+-blue?style=flat-square) ![Wiki](https://img.shields.io/badge/wiki-dolibarr--api--mcp-blue?style=flat-square)
| 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)*
+114
View File
@@ -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"
}
}
}
+35
View File
@@ -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"
}
}
+119
View File
@@ -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);
});
+122
View File
@@ -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();
});
}
}
+58
View File
@@ -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
+31
View File
@@ -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;
}
+19
View File
@@ -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)
+6
View File
@@ -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).
+159
View File
@@ -0,0 +1,159 @@
# dreamhost-mcp
[![License: GPL-3.0-or-later](https://img.shields.io/badge/License-GPL--3.0--or--later-blue.svg)](LICENSE)
[![Node: >=20](https://img.shields.io/badge/Node-%3E%3D20-green.svg)](https://nodejs.org)
[![MCP SDK](https://img.shields.io/badge/MCP_SDK-%5E1.12.1-purple.svg)](https://modelcontextprotocol.io)
[![TypeScript](https://img.shields.io/badge/TypeScript-5.x-3178C6.svg)](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)*
+3
View File
@@ -0,0 +1,3 @@
# Security
Report to hello@mokoconsulting.tech.
@@ -0,0 +1,3 @@
{
"apiKey": "your-dreamhost-api-key"
}
+25
View File
@@ -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>"
}
+84
View File
@@ -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');
}
}
+19
View File
@@ -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 };
}
+94
View File
@@ -0,0 +1,94 @@
#!/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 } from './config.js';
import { DreamHostClient } from './client.js';
import type { DreamHostConfig, ApiResponse } from './types.js';
let client: DreamHostClient;
function fmt(res: ApiResponse) {
if (res.result === 'error') return { content: [{ type: 'text' as const, text: `Error: ${res.reason ?? JSON.stringify(res)}` }] };
return { content: [{ type: 'text' as const, text: JSON.stringify(res.data ?? res, null, 2) }] };
}
const server = new McpServer({ name: 'dreamhost-mcp', version: '1.0.0' });
server.tool('dreamhost_dns_list', 'List all DNS records (optionally filter by domain)', {
domain: z.string().optional().describe('Filter by domain name'),
}, async ({ domain }) => {
const res = await client.listDnsRecords();
if (res.result === 'error') return fmt(res);
if (domain && Array.isArray(res.data)) {
res.data = (res.data as Array<{ record: string }>).filter(r => r.record.includes(domain));
}
return fmt(res);
});
server.tool('dreamhost_dns_add', 'Add a DNS record', {
record: z.string().describe('DNS record name (e.g. sub.example.com)'),
type: z.string().describe('Record type (A, AAAA, CNAME, MX, TXT, SRV)'),
value: z.string().describe('Record value'),
comment: z.string().optional().describe('Optional comment'),
}, async ({ record, type, value, comment }) => fmt(await client.addDnsRecord(record, type, value, comment)));
server.tool('dreamhost_dns_remove', 'Remove a DNS record', {
record: z.string().describe('DNS record name'),
type: z.string().describe('Record type'),
value: z.string().describe('Record value (must match exactly)'),
}, async ({ record, type, value }) => fmt(await client.removeDnsRecord(record, type, value)));
server.tool('dreamhost_domain_list', 'List all hosted domains', {},
async () => fmt(await client.listDomains()));
server.tool('dreamhost_domain_registrations', 'List domain registrations with expiry dates', {},
async () => fmt(await client.listRegistrations()));
server.tool('dreamhost_user_list', 'List hosting users/accounts', {},
async () => fmt(await client.listUsers()));
server.tool('dreamhost_account_status', 'Get account status and usage', {},
async () => fmt(await client.accountStatus()));
server.tool('dreamhost_api_commands', 'List available API commands', {},
async () => fmt(await client.listCommands()));
server.tool('dreamhost_mysql_list', 'List MySQL databases', {},
async () => fmt(await client.callApi('mysql-list_dbs')));
server.tool('dreamhost_mysql_users', 'List MySQL database users', {},
async () => fmt(await client.callApi('mysql-list_users')));
server.tool('dreamhost_mail_list', 'List email addresses for a domain', {
domain: z.string().optional().describe('Filter by domain'),
}, async ({ domain }) => {
const res = await client.callApi('mail-list_filters');
if (domain && res.result !== 'error' && Array.isArray(res.data)) {
res.data = (res.data as Array<{ account: string }>).filter(a => a.account?.includes(domain));
}
return fmt(res);
});
server.tool('dreamhost_dns_check', 'Check if a DNS record exists for a domain', {
domain: z.string().describe('Domain to check'),
type: z.string().optional().describe('Record type to filter (A, CNAME, MX, TXT)'),
}, async ({ domain, type }) => {
const res = await client.listDnsRecords();
if (res.result === 'error') return fmt(res);
let records = res.data as Array<{ record: string; type: string; value: string }>;
records = records.filter(r => r.record.includes(domain));
if (type) records = records.filter(r => r.type === type);
if (records.length === 0) return { content: [{ type: 'text' as const, text: `No ${type ?? ''} records found for ${domain}` }] };
return { content: [{ type: 'text' as const, text: records.map(r => `${r.record} ${r.type} ${r.value}`).join('\n') }] };
});
server.tool('dreamhost_rewards_referrals', 'List referral rewards', {},
async () => fmt(await client.callApi('rewards-list_referrals')));
async function main() {
const config = await loadConfig();
client = new DreamHostClient(config.apiKey);
await server.connect(new StdioServerTransport());
}
main().catch(err => { console.error(err); process.exit(1); });
+26
View File
@@ -0,0 +1,26 @@
export interface DreamHostConfig {
apiKey: string;
}
export interface DnsRecord {
record: string;
type: string;
value: string;
comment?: string;
zone: string;
editable: string;
account_id: string;
}
export interface DomainInfo {
domain: string;
type: string;
home: string;
hosting_type: string;
}
export interface ApiResponse {
result: string;
data?: unknown;
reason?: string;
}
+19
View File
@@ -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"]
}
Submodule mcp/servers/mokogitea_api added at 44e1259c3e
@@ -0,0 +1,49 @@
---
name: deploy
description: Build and deploy extension to a target server via SFTP/SSH
allowed-tools: Bash, Read, Glob, Grep
---
# Deploy Extension
Build the current project and deploy to a target server.
## Context
- Current directory: !`pwd`
- Git status: !`git status --short`
- Platform manifest: !`cat .mokogitea/manifest.xml 2>/dev/null | head -10`
## Target Servers
| Name | Host | Joomla Path | Dolibarr Path |
|---|---|---|---|
| WAAS_DEV | waas.dev.mokoconsulting.tech | /home/mokoconsulting_dev/waas.dev.mokoconsulting.tech/ | — |
| WAAS_DEMO | waas.demo.mokoconsulting.tech | /home/mokoconsulting_demo/waas.demo.mokoconsulting.tech/ | — |
| WAAS_LIVE | mokoconsulting.tech | /home/mokoconsulting/mokoconsulting.tech/ | — |
| CRM_DEV | waas.dev.mokoconsulting.tech | — | /home/mokoconsulting_dev/crm.dev.mokoconsulting.tech/htdocs/custom/ |
| CRM_LIVE | crm.mokoconsulting.tech | — | /home/mokoconsulting_crm/crm.mokoconsulting.tech/htdocs/custom/ |
## Instructions
1. **Identify platform** from `.mokogitea/manifest.xml`:
- `joomla` → build ZIP, install via Joomla API or SFTP
- `dolibarr` → SFTP module files to `htdocs/custom/`
- `mcp-server` → npm build, no remote deploy needed
2. **Ask target** if not specified: dev, demo, or live?
- Default to dev for safety
3. **Build:**
- Run `make build` or `make release` depending on platform
- Verify build output exists
4. **Deploy:**
- **Joomla**: Use `mcp_mokowaas` tool to install ZIP via Joomla API, OR use `mcp_mokossh` to SFTP + run CLI installer
- **Dolibarr**: Use `mcp_mokossh` to rsync/scp module directory to `htdocs/custom/`
5. **Post-deploy:**
- Clear Joomla cache if applicable (`php cli/joomla.php cache:clean`)
- Verify extension is active
6. **NEVER deploy to LIVE without explicit user confirmation.**
@@ -0,0 +1,37 @@
---
name: mcp-rebuild
description: Rebuild one or all MCP servers — npm install + npm run build
allowed-tools: Bash, Read, Glob, Grep
---
# MCP Server Rebuild
Rebuild MCP server(s) by running `npm install` and `npm run build`.
## Context
- Current directory: !`pwd`
- MCP servers live at `A:/MCP/mcp_moko*/`
## MCP Server List
| Server | Path | Build? |
|---|---|---|
| mcp_mokobackup | A:/MCP/mcp_mokobackup | Yes (TypeScript) |
| mcp_mokocrm | A:/MCP/mcp_mokocrm | Yes (TypeScript) |
| mcp_mokodreamhost | A:/MCP/mcp_mokodreamhost | Yes (TypeScript) |
| mcp_mokogitea_api | A:/MCP/mcp_mokogitea_api | Yes (TypeScript) |
| mcp_mokomonitor | A:/MCP/mcp_mokomonitor | Yes (TypeScript) |
| mcp_mokossh | A:/MCP/mcp_mokossh | No (plain JS) |
| mcp_mokowaas | A:/MCP/mcp_mokowaas | Yes (TypeScript) |
| mcp_windows | A:/MCP/mcp_windows | Yes (TypeScript) |
## Instructions
1. If the user specified a server name, rebuild only that one. If "all", rebuild all TypeScript MCPs (skip mcp_mokossh).
2. If no server specified and we're inside an MCP directory, rebuild that one.
3. For each server:
- Run `npm install` in the server directory
- Run `npm run build` (skip for mcp_mokossh — it's plain JS)
- Report success/failure
4. After rebuilding, remind the user they need to restart Claude Code for MCP changes to take effect.
@@ -0,0 +1,61 @@
---
name: new-client
description: Scaffold a new WaaS client — Gitea org, theme repo, MCP config, wiki, SSH key
allowed-tools: Bash, Read, Write, Edit, Glob, Grep
---
# New WaaS Client Setup
Scaffold everything needed for a new WaaS client deployment.
## Context
- Current directory: !`pwd`
- Template repo: A:/Templates/Template-Client-WaaS/
## Instructions
Ask the user for:
1. **Client name** (e.g. "Acme Corp")
2. **Client slug** (e.g. "acmecorp" — lowercase, no spaces, used in org/repo names)
3. **Domain** (e.g. "acmecorp.com")
Then execute these steps:
### 1. Create Gitea Organization
- Use `mcp_mokogitea_api` tool `gitea_org_create`
- Org name: PascalCase of slug (e.g. "AcmeCorp")
- Description: "WaaS client: {Client name}"
### 2. Create Client Repository
- Use `mcp_mokogitea_api` tool `gitea_repo_create` in the new org
- Repo name: `client-waas-{slug}`
- Initialize from Template-Client-WaaS if possible, otherwise create empty and copy files
### 3. Clone and Scaffold Locally
- Clone to `A:/client-{slug}/`
- Copy template files from `A:/Templates/Template-Client-WaaS/`
- Update `src/*.xml` manifest:
- `<name>MokoOnyx Theme — {Client Name}</name>`
- `<element>file_mokoonyx_{slug}</element>`
- Create `.mokogitea/CLAUDE.md` with client-specific content
- Create initial `CHANGELOG.md`
- Commit and push
### 4. Create SSH Key
- Generate key pair at `C:/Users/jmill/OneDrive/Documents/Keys/repos/client-{slug}`
- Add public key as deploy key on the Gitea repo
### 5. Setup GitHub Mirror
- Use `gitea_repo_mirror_setup_github_backup_full` to create GitHub backup mirror
### 6. Initialize Wiki
- Create Home page with client info using `mcp_mokogitea_wiki` tools
### 7. Apply Labels
- Apply standard label set using `mcp_mokogitea_api` tools
- Labels use colon-style (e.g. "priority: critical")
### 8. Report
- Show all created resources: org URL, repo URL, GitHub mirror, local path
- Remind about: DNS setup, Joomla site creation, theme customization
@@ -0,0 +1,60 @@
---
name: release
description: Create a release — build ZIP, tag, update updates.xml, create Gitea release
allowed-tools: Bash, Read, Write, Edit, Glob, Grep
---
# Release Workflow
Create a release for the current repository.
## Context
- Current directory: !`pwd`
- Git status: !`git status --short`
- Current branch: !`git rev-parse --abbrev-ref HEAD`
- Recent tags: !`git tag --sort=-creatordate | head -5`
- Platform manifest: !`cat .mokogitea/manifest.xml 2>/dev/null | head -10`
## Instructions
1. **Pre-flight checks:**
- Ensure working tree is clean (no uncommitted changes)
- Ensure we're on `main` or `dev` branch
- Read the current version from the manifest XML or package.json
2. **Determine release type:**
- If user specified a version, use it
- Otherwise, ask: patch, minor, or major bump?
3. **Platform-specific build:**
- **Joomla** (`make build` or `make release`): builds ZIP in `dist/` or `build/`
- **MCP/Node** (`npm run build`): compiles TypeScript
- **Dolibarr** (`make build`): packages module
4. **Update version files:**
- Joomla: update `<version>` in manifest XML, update `updates.xml` in repo root
- Node: update `version` in `package.json`
- Update CHANGELOG.md with release date
5. **Commit, tag, push:**
- Commit version bump: `chore(release): bump to vX.Y.Z`
- Create annotated tag: `git tag -a vX.Y.Z -m "Release X.Y.Z"`
- Push commits and tags: `git push origin && git push origin --tags`
6. **Create Gitea release:**
- Use `mcp_mokogitea_api` tool `gitea_release_create` with the tag
- Upload the built ZIP as a release asset via `gitea_release_upload_asset`
7. **Joomla-specific: update updates.xml:**
- Prepend new `<update>` block with version, download URL pointing to Gitea release asset
- ZIP filename follows Joomla convention from feedback_joomla_release_naming memory
- Commit and push the updates.xml change
8. **Report:** show the release URL and confirm success.
## Important Rules
- Release ZIP filenames follow Joomla conventions (see feedback_joomla_release_naming)
- updates.xml goes in repo root, not src/
- GitHub mirrors sync automatically — do NOT push to GitHub
@@ -0,0 +1,55 @@
---
name: server-health
description: Run health checks across all MokoGitea infrastructure servers
allowed-tools: Bash, Read, Glob, Grep
---
# Server Health Check
Run health checks across all infrastructure servers using SSH.
## Context
- Current time: !`date`
## Instructions
Use `mcp_mokossh` MCP tools to run health checks on each server:
### 1. GIT Server (git.mokoconsulting.tech)
- `systemctl status gitea` — is Gitea running?
- `systemctl status act_runner` — is the Actions runner running?
- `df -h /` — disk space
- `du -sh /var/lib/gitea/repositories/ /var/lib/gitea/data/ /var/lib/gitea/log/` — Gitea storage breakdown
- `uptime` — load average
- `free -h` — memory usage
- `certbot certificates 2>/dev/null | grep -E "Expiry|Domains"` — SSL cert expiry
- `fail2ban-client status sshd 2>/dev/null | grep "Total banned"` — banned IPs
### 2. WAAS_DEV (waas.dev.mokoconsulting.tech)
- `df -h /` — disk space
- `uptime` — load average
- `free -h` — memory
- `php -v | head -1` — PHP version
- Check Joomla health endpoint if available
### 3. WAAS_DEMO (waas.demo.mokoconsulting.tech)
- Same checks as WAAS_DEV
### 4. WAAS_LIVE (mokoconsulting.tech)
- Same checks as WAAS_DEV
- Extra: check Apache/Nginx status
### 5. CRM_LIVE (crm.mokoconsulting.tech)
- `df -h /` — disk space
- `uptime` — load average
- `free -h` — memory
### Output Format
Present results as a summary table:
| Server | Status | Disk | Memory | Load | SSL Expiry | Issues |
|---|---|---|---|---|---|---|
Flag any issues in red (disk > 85%, high load, expiring SSL, stopped services).
@@ -0,0 +1,190 @@
---
name: mokogitea
description: "MokoGitea server infrastructure, SSH commands, deployment, backup, and administration. Use when the user mentions: gitea, mokogitea, git server, deploy, deployment, restart gitea, ssh into, server status, backup gitea, restore, mirror, github backup, update server, gitea actions, CI/CD, runner, waas server, crm server, server health, disk space, systemctl, nginx, certbot, fail2ban, firewall, server logs, gitea logs, or any remote server operation."
when_to_use: "Auto-trigger when discussing: server management, SSH operations, Gitea administration, deployments, backups, mirrors, CI runners, or any infrastructure task involving git.mokoconsulting.tech or the MokoGitea instance."
---
# MokoGitea Infrastructure Reference
You are helping with MokoGitea server infrastructure. Use the `mcp_mokossh` MCP tools for SSH commands and `mcp_mokogitea_api` tools for Gitea API operations.
## Server Map
| Name | Host | User | Port | Purpose |
|---|---|---|---|---|
| GIT | git.mokoconsulting.tech | mokoconsulting | 2918 | MokoGitea instance (Gitea fork) |
| WAAS_DEV | waas.dev.mokoconsulting.tech | mokoconsulting_dev | 22 | WaaS dev (Joomla + Dolibarr) |
| WAAS_DEMO | waas.demo.mokoconsulting.tech | mokoconsulting_demo | 22 | WaaS demo |
| WAAS_LIVE | mokoconsulting.tech | mokoconsulting | 22 | WaaS production |
| CRM_DEV | waas.dev.mokoconsulting.tech | mokoconsulting_dev | 22 | CRM dev (shared host with WAAS_DEV) |
| CRM_LIVE | crm.mokoconsulting.tech | mokoconsulting_crm | 22 | CRM production |
SSH key: `jmiller_private.openssh` (all MCP connections)
## Common SSH Commands
### Gitea Server (GIT)
```bash
# Service management
sudo systemctl status gitea
sudo systemctl restart gitea
sudo systemctl stop gitea
# Logs
sudo journalctl -u gitea -f # Follow live logs
sudo journalctl -u gitea --since "1 hour ago" # Recent logs
# Gitea CLI (run as git user)
sudo -u git /usr/local/bin/gitea admin user list
sudo -u git /usr/local/bin/gitea admin auth list
sudo -u git /usr/local/bin/gitea doctor check
# Gitea Actions runner
sudo systemctl status act_runner
sudo systemctl restart act_runner
sudo journalctl -u act_runner -f
# Config
cat /etc/gitea/app.ini # Main config
sudo -u git /usr/local/bin/gitea admin config # Dump effective config
# Database
sudo mysql -u root gitea -e "SELECT name FROM repository ORDER BY updated_unix DESC LIMIT 10;"
# Disk space
df -h
du -sh /var/lib/gitea/repositories/
du -sh /var/lib/gitea/data/
du -sh /var/lib/gitea/log/
# Nginx
sudo nginx -t # Test config
sudo systemctl reload nginx
cat /etc/nginx/sites-enabled/gitea.conf
# SSL
sudo certbot certificates
sudo certbot renew --dry-run
# Firewall
sudo ufw status
sudo fail2ban-client status
sudo fail2ban-client status sshd
```
### WaaS Servers (WAAS_DEV / WAAS_DEMO / WAAS_LIVE)
```bash
# Joomla paths
# Dev: /home/mokoconsulting_dev/waas.dev.mokoconsulting.tech/
# Demo: /home/mokoconsulting_demo/waas.demo.mokoconsulting.tech/
# Live: /home/mokoconsulting/mokoconsulting.tech/
# Check Joomla version
php cli/joomla.php core:check-updates
# Clear Joomla cache
php cli/joomla.php cache:clean
# Run scheduled tasks
php cli/joomla.php scheduler:run
# Check PHP version
php -v
# Apache/Nginx status
sudo systemctl status apache2 # or nginx
sudo apachectl -S # List virtual hosts
# Database
mysql -u root -e "SHOW DATABASES;"
# Disk
df -h
du -sh /home/*/
```
### CRM Servers (CRM_DEV / CRM_LIVE)
```bash
# Dolibarr paths
# Dev: /home/mokoconsulting_dev/crm.dev.mokoconsulting.tech/htdocs/
# Live: /home/mokoconsulting_crm/crm.mokoconsulting.tech/htdocs/
# Check Dolibarr version
grep 'DOL_VERSION' htdocs/filefunc.inc.php
# Dolibarr conf
cat htdocs/conf/conf.php | grep -E "^\\$dolibarr_main_(db|url)"
# Custom modules
ls htdocs/custom/
# Database
mysql -u root dolibarr -e "SELECT name, value FROM llx_const WHERE name LIKE '%VERSION%';"
```
## Gitea API Operations
Use `mcp_mokogitea_api` MCP tools for API operations:
- **Repos**: `gitea_repo_list`, `gitea_repo_create`, `gitea_repo_delete`, `gitea_repo_get`
- **Issues**: `gitea_issue_create`, `gitea_issue_list`, `gitea_issue_comment`
- **PRs**: `gitea_pr_create`, `gitea_pr_list`, `gitea_pr_merge`
- **Releases**: `gitea_release_create`, `gitea_release_list`, `gitea_release_upload_asset`
- **Mirrors**: `gitea_repo_mirror_create`, `gitea_repo_mirror_setup_github_backup`, `gitea_repo_mirror_setup_github_backup_full`
- **Actions**: `gitea_actions_list_runs`, `gitea_actions_get_run`
- **Orgs**: `gitea_org_list`, `gitea_org_create`, `gitea_org_get`
- **Wiki**: use `mcp_mokogitea_wiki` tools for wiki CRUD
## Backup Operations
Use `mcp_mokobackup` MCP tools:
| Target | Type | What it backs up |
|---|---|---|
| gitea-db | mysql | Gitea MySQL database |
| gitea-files | files | `/var/lib/gitea/` (repos, data, avatars) |
| waas-dev | akeeba | Joomla dev site via Akeeba API |
| waas-demo | akeeba | Joomla demo site |
| waas-live | akeeba | Joomla production site |
| crm-live | dolibarr | Dolibarr production (DB + documents + custom) |
| crm-dev | dolibarr | Dolibarr dev |
## Deployment
Deployments are handled by **Gitea Actions workflows** (not manual SSH):
- Push to `dev` → CI runs (lint, build, validate)
- Merge PR to `main` → release workflow builds ZIP, creates release, deploys via SFTP
- Client sites: `client-release.yml` workflow handles theme package deployment
- Dolibarr modules: manual SFTP to `htdocs/custom/` via `mcp_mokossh`
## Mirror / GitHub Backup
All repos mirror to GitHub (mokoconsulting-tech org) as backup:
- Code + wiki mirrors, synced every 8 hours + on commit
- Use `gitea_repo_mirror_setup_github_backup_full` for new repos
- GitHub is **backup only** — never create PRs or enable Actions on GitHub
## Key Paths on GIT Server
| Path | Contents |
|---|---|
| `/usr/local/bin/gitea` | Gitea binary |
| `/etc/gitea/app.ini` | Main configuration |
| `/var/lib/gitea/` | All Gitea data |
| `/var/lib/gitea/repositories/` | Git bare repos |
| `/var/lib/gitea/data/` | Attachments, avatars, LFS |
| `/var/lib/gitea/log/` | Gitea logs |
| `/var/lib/gitea/custom/` | Custom templates, public files |
| `/etc/nginx/sites-enabled/gitea.conf` | Nginx reverse proxy config |
| `/home/git/.act_runner/` | Actions runner config |
## Important Notes
- Port 2918 on GIT server is **shell SSH** (not just git protocol) — full command execution
- Gitea repo names on server use **hyphens** (e.g. `mcp-mokobackup`), local dirs use **underscores**
- `moko-platform` CLI tools handle CI checks — don't inline bash in workflows
- All infra docs live in **mokogitea-private wiki**, not public repos
- Two master SSH keys (jmiller + moko) on all servers
@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="UTF-8"?>
<mokoplatform schema-version="1.0">
<identity><name>monitor-mcp</name><org>MokoConsulting</org></identity>
<governance><standards-version>05.00.00</standards-version></governance>
</mokoplatform>
@@ -0,0 +1,44 @@
# mcp_mokomonitor
MCP server for server health monitoring, uptime checks, Grafana dashboards, and log tailing.
## Quick Reference
| Field | Value |
|---|---|
| **Package** | `@mokoconsulting/mcp-mokomonitor` |
| **Entry** | `dist/index.js` |
| **Config** | `~/.mcp_mokomonitor.json` (override: `MONITOR_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_mokomonitor.json, resolves connections + Grafana + sites.json
├── client.ts # HTTP health check client
├── grafana.ts # Grafana API integration (dashboards, panels, alerts)
└── types.ts # MonitorConfig, MonitorConnection, SitesConfig types
```
- Config defines **connections** for health checks + optional **Grafana** config
- Sites list at `A:/moko-platform/monitoring/sites.json` for bulk monitoring
- Grafana at bench.mokoconsulting.tech — WaaS dashboard for uptime/performance
## 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,48 @@
---
name: API Integration Request
about: Request integration with a new REST API or service
title: '[API] '
labels: 'enhancement, api-integration'
assignees: ''
---
## API Integration Request
### Target API
- **Service Name**: [e.g., Akeeba Backup, Joomla Web Services]
- **API Documentation**: [URL to API docs]
- **API Type**: [REST / GraphQL / SOAP]
- **Authentication**: [API Key / OAuth / Bearer Token / Basic Auth]
### Proposed Tools
List the MCP tools this integration would provide:
| Tool Name | HTTP Method | Endpoint | Description |
|---|---|---|---|
| `service_list` | GET | `/api/items` | List all items |
| `service_get` | GET | `/api/items/{id}` | Get single item |
| `service_create` | POST | `/api/items` | Create item |
### Multi-Connection
- [ ] Single instance only
- [ ] Multiple instances (production, staging, dev)
- [ ] Multi-tenant (one connection per client)
### Use Case
Describe the workflow this integration enables for AI assistants.
### Priority
- [ ] Critical — blocking current work
- [ ] High — needed soon
- [ ] Medium — would improve workflow
- [ ] Low — nice to have
### Existing Alternatives
Are there other ways to accomplish this today? If so, why is an MCP integration better?
### Checklist
- [ ] API documentation is available and accessible
- [ ] API supports the required authentication method
- [ ] I have tested the API endpoints manually
- [ ] The integration follows the Template-MCP architecture pattern
@@ -0,0 +1,67 @@
---
name: MCP Connection Issue
about: Report a connection, authentication, or API communication issue
title: '[CONNECTION] '
labels: 'bug, mcp-connection'
assignees: ''
---
## Connection Issue
### Issue Type
- [ ] Authentication failure (401/403)
- [ ] Connection refused / timeout
- [ ] TLS / SSL certificate error
- [ ] Wrong connection used (wrong environment)
- [ ] Config file not found / parse error
- [ ] API response error (4xx / 5xx)
### MCP Server
- **Server Name**: [e.g., mcp_mokowaas]
- **Server Version**: [e.g., 1.0.0]
- **Node.js Version**: [e.g., 20.x]
### Connection Details
- **Connection Name**: [e.g., production, staging, default]
- **API Base URL**: [e.g., https://api.example.com] *(do not include API keys)*
- **Insecure Mode**: [Yes / No]
### Error Message
```
Paste the exact error message here
```
### Steps to Reproduce
1. Configure connection with `npm run setup`
2. Call tool `...` with parameters `...`
3. See error
### Expected Behavior
What should have happened.
### Debugging Attempted
- [ ] Tested API directly with curl
- [ ] Verified API key is valid
- [ ] Checked config file exists and is valid JSON
- [ ] Tested with `list_connections` tool
- [ ] Ran server manually: `node dist/index.js 2> debug.log`
### Config File
```json
{
"defaultConnection": "...",
"connections": {
"connection_name": {
"baseUrl": "https://...",
"apiKey": "REDACTED"
}
}
}
```
*(Redact all API keys and tokens)*
### Environment
- **OS**: [e.g., macOS 14, Ubuntu 22.04, Windows 11]
- **Claude Code Version**: [e.g., latest]
- **Registration**: [.mcp.json / ~/.claude.json]
@@ -0,0 +1,49 @@
---
name: New MCP Tool Request
about: Request a new tool to be added to this MCP server
title: '[TOOL] '
labels: 'enhancement, mcp-tool'
assignees: ''
---
## Tool Request
### Tool Name
Proposed tool name (snake_case): `resource_action`
### Description
What should this tool do? What API endpoint(s) does it map to?
### API Endpoint(s)
- **Method**: [GET / POST / PUT / PATCH / DELETE]
- **Endpoint**: `/api/v1/...`
- **Auth**: [API Key / Token / None]
### Parameters
| Parameter | Type | Required | Description |
|---|---|---|---|
| `id` | number | Yes | Resource ID |
| `search` | string | No | Search filter |
### Expected Response
```json
{
"id": 1,
"name": "Example"
}
```
### Use Case
Describe when and why someone would use this tool from Claude or another AI assistant.
### Connection Scope
- [ ] Works with all connections
- [ ] Specific to certain API versions
- [ ] Requires additional permissions
### Checklist
- [ ] I have checked this tool does not already exist
- [ ] I have verified the API endpoint exists and is documented
- [ ] The proposed name follows the `resource_action` convention
@@ -0,0 +1,19 @@
name: Manual Deploy
on:
workflow_dispatch:
inputs:
environment:
description: 'Target environment'
required: true
type: choice
options: [dev, demo, production]
permissions:
contents: read
jobs:
deploy:
name: Deploy
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Deploy
run: echo "Deploy to ${{ inputs.environment }} — configure in deploy-mcp"
@@ -0,0 +1,96 @@
# Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
#
# SPDX-License-Identifier: GPL-3.0-or-later
#
# FILE INFORMATION
# DEFGROUP: Gitea.Workflow
# INGROUP: MokoStandards.Security
# REPO: https://git.mokoconsulting.tech/mokoconsulting-tech/MokoStandards-API
# PATH: /templates/workflows/gitleaks.yml.template
# VERSION: 01.00.00
# BRIEF: Secret scanning — detect leaked credentials, API keys, and tokens
#
# +========================================================================+
# | SECRET SCANNING |
# +========================================================================+
# | |
# | Scans commits for leaked secrets using Gitleaks. |
# | |
# | - PR scan: only new commits in the PR |
# | - Scheduled: full repo scan weekly |
# | - Alerts via ntfy on findings |
# | |
# +========================================================================+
name: Secret Scanning
on:
pull_request:
branches:
- main
- 'dev/**'
schedule:
- cron: '0 5 * * 1' # Weekly Monday 05:00 UTC
workflow_dispatch:
permissions:
contents: read
env:
NTFY_URL: ${{ vars.NTFY_URL || 'https://ntfy.mokoconsulting.tech' }}
NTFY_TOPIC: ${{ vars.NTFY_TOPIC || 'gitea-security' }}
jobs:
gitleaks:
name: Gitleaks Secret Scan
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Install Gitleaks
run: |
GITLEAKS_VERSION="8.21.2"
curl -sSL "https://github.com/gitleaks/gitleaks/releases/download/v${GITLEAKS_VERSION}/gitleaks_${GITLEAKS_VERSION}_linux_x64.tar.gz" \
| tar -xz -C /usr/local/bin gitleaks
gitleaks version
- name: Scan for secrets
id: scan
run: |
echo "### Secret Scanning" >> $GITHUB_STEP_SUMMARY
ARGS="--source . --verbose --report-format json --report-path /tmp/gitleaks-report.json"
if [ "${{ github.event_name }}" = "pull_request" ]; then
# Scan only PR commits
ARGS="$ARGS --log-opts=${{ github.event.pull_request.base.sha }}..${{ github.event.pull_request.head.sha }}"
echo "Scanning PR commits only" >> $GITHUB_STEP_SUMMARY
else
echo "Full repository scan" >> $GITHUB_STEP_SUMMARY
fi
if gitleaks detect $ARGS 2>&1; then
echo "result=clean" >> "$GITHUB_OUTPUT"
echo "**No secrets detected.**" >> $GITHUB_STEP_SUMMARY
else
echo "result=found" >> "$GITHUB_OUTPUT"
FINDINGS=$(jq length /tmp/gitleaks-report.json 2>/dev/null || echo "unknown")
echo "**${FINDINGS} potential secret(s) detected.**" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "Review the findings and rotate any exposed credentials immediately." >> $GITHUB_STEP_SUMMARY
exit 1
fi
- name: Notify on findings
if: failure() && steps.scan.outputs.result == 'found'
run: |
REPO="${{ github.event.repository.name }}"
curl -sS \
-H "Title: ${REPO} — secrets detected in code" \
-H "Tags: rotating_light,key" \
-H "Priority: urgent" \
-d "Gitleaks found potential secrets. Review and rotate credentials immediately." \
"${NTFY_URL}/${NTFY_TOPIC}" || true
@@ -0,0 +1,90 @@
# Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
# SPDX-License-Identifier: GPL-3.0-or-later
#
# Enforces branch merge policy:
# feature/* → dev only
# fix/* → dev only
# hotfix/* → dev or main (emergency)
# dev → main only
# alpha/* → dev only
# beta/* → dev only
# rc/* → main only
name: Branch Policy Check
on:
pull_request:
types: [opened, synchronize, reopened, edited]
jobs:
check-target:
name: Verify merge target
runs-on: ubuntu-latest
steps:
- name: Check branch policy
run: |
HEAD="${{ github.head_ref }}"
BASE="${{ github.base_ref }}"
echo "PR: ${HEAD} → ${BASE}"
ALLOWED=true
REASON=""
case "$HEAD" in
feature/*|feat/*)
if [ "$BASE" != "dev" ]; then
ALLOWED=false
REASON="Feature branches must target 'dev', not '${BASE}'"
fi
;;
fix/*|bugfix/*)
if [ "$BASE" != "dev" ]; then
ALLOWED=false
REASON="Fix branches must target 'dev', not '${BASE}'"
fi
;;
hotfix/*)
if [ "$BASE" != "dev" ] && [ "$BASE" != "main" ]; then
ALLOWED=false
REASON="Hotfix branches can only target 'dev' or 'main', not '${BASE}'"
fi
;;
alpha/*|beta/*)
if [ "$BASE" != "dev" ]; then
ALLOWED=false
REASON="Pre-release branches must target 'dev', not '${BASE}'"
fi
;;
rc/*)
if [ "$BASE" != "main" ]; then
ALLOWED=false
REASON="Release candidate branches must target 'main', not '${BASE}'"
fi
;;
dev)
if [ "$BASE" != "main" ]; then
ALLOWED=false
REASON="Dev branch can only merge into 'main', not '${BASE}'"
fi
;;
esac
if [ "$ALLOWED" = false ]; then
echo "::error::${REASON}"
echo ""
echo "## Branch Policy Violation" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "${REASON}" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "### Allowed merge paths:" >> $GITHUB_STEP_SUMMARY
echo "- \`feature/*\` → \`dev\`" >> $GITHUB_STEP_SUMMARY
echo "- \`fix/*\` → \`dev\`" >> $GITHUB_STEP_SUMMARY
echo "- \`hotfix/*\` → \`dev\` or \`main\`" >> $GITHUB_STEP_SUMMARY
echo "- \`dev\` → \`main\`" >> $GITHUB_STEP_SUMMARY
echo "- \`rc/*\` → \`main\`" >> $GITHUB_STEP_SUMMARY
exit 1
fi
echo "Branch policy: OK (${HEAD} → ${BASE})"
echo "## Branch Policy: Passed" >> $GITHUB_STEP_SUMMARY
@@ -0,0 +1,765 @@
# ============================================================================
# Copyright (C) 2025 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: Gitea.Workflow
# INGROUP: MokoStandards.Validation
# REPO: https://git.mokoconsulting.tech/mokoconsulting-tech/MokoStandards-API
# PATH: /templates/workflows/joomla/repo_health.yml.template
# VERSION: 04.06.00
# BRIEF: Enforces repository guardrails by validating release configuration, scripts governance, tooling availability, and core repository health artifacts.
# ============================================================================
name: Repo Health
concurrency:
group: repo-health-${{ github.repository }}-${{ github.ref }}
cancel-in-progress: true
defaults:
run:
shell: bash
on:
workflow_dispatch:
inputs:
profile:
description: 'Validation profile: all, release, scripts, or repo'
required: true
default: all
type: choice
options:
- all
- release
- scripts
- repo
pull_request:
push:
permissions:
contents: read
env:
# Release policy - Repository Variables Only
RELEASE_REQUIRED_REPO_VARS: RS_FTP_PATH_SUFFIX
RELEASE_OPTIONAL_REPO_VARS: DEV_FTP_SUFFIX
# Scripts governance policy
SCRIPTS_REQUIRED_DIRS:
SCRIPTS_ALLOWED_DIRS: scripts,scripts/fix,scripts/lib,scripts/release,scripts/run,scripts/validate
# Repo health policy
REPO_REQUIRED_ARTIFACTS: README.md,LICENSE,CHANGELOG.md,CONTRIBUTING.md,CODE_OF_CONDUCT.md,.gitea/workflows/
REPO_OPTIONAL_FILES: SECURITY.md,GOVERNANCE.md,.editorconfig,.gitattributes,.gitignore,README.md
REPO_DISALLOWED_DIRS:
REPO_DISALLOWED_FILES: TODO.md,todo.md
# Extended checks toggles
EXTENDED_CHECKS: "true"
# File / directory variables
DOCS_INDEX: ""
SCRIPT_DIR: scripts
WORKFLOWS_DIR: .gitea/workflows
SHELLCHECK_PATTERN: '*.sh'
SPDX_FILE_GLOBS: '*.sh,*.php,*.js,*.ts,*.css,*.xml,*.yml,*.yaml'
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
jobs:
access_check:
name: Access control
runs-on: ubuntu-latest
timeout-minutes: 10
permissions:
contents: read
outputs:
allowed: ${{ steps.perm.outputs.allowed }}
permission: ${{ steps.perm.outputs.permission }}
steps:
- name: Check actor permission (admin only)
id: perm
env:
TOKEN: ${{ secrets.GA_TOKEN || secrets.GA_TOKEN || github.token }}
REPO: ${{ github.repository }}
ACTOR: ${{ github.actor }}
run: |
set -euo pipefail
ALLOWED=false
PERMISSION=unknown
METHOD=""
# Hardcoded authorized users — always allowed
case "$ACTOR" in
jmiller|gitea-actions[bot])
ALLOWED=true
PERMISSION=admin
METHOD="hardcoded allowlist"
;;
*)
# Detect platform and check permissions via API
API_BASE="${GITHUB_API_URL:-${GITEA_API_URL:-https://api.github.com}}"
RESP=$(curl -sf -H "Authorization: token ${TOKEN}" \
"${API_BASE}/repos/${REPO}/collaborators/${ACTOR}/permission" 2>/dev/null || echo '{}')
PERMISSION=$(echo "$RESP" | grep -oP '"permission"\s*:\s*"\K[^"]+' || echo "unknown")
if [ "$PERMISSION" = "admin" ] || [ "$PERMISSION" = "maintain" ] || [ "$PERMISSION" = "owner" ]; then
ALLOWED=true
fi
METHOD="collaborator API"
;;
esac
echo "permission=${PERMISSION}" >> "$GITHUB_OUTPUT"
echo "allowed=${ALLOWED}" >> "$GITHUB_OUTPUT"
{
echo "## Access Authorization"
echo ""
echo "| Field | Value |"
echo "|-------|-------|"
echo "| **Actor** | \`${ACTOR}\` |"
echo "| **Repository** | \`${REPO}\` |"
echo "| **Permission** | \`${PERMISSION}\` |"
echo "| **Method** | ${METHOD} |"
echo "| **Authorized** | ${ALLOWED} |"
echo ""
if [ "$ALLOWED" = "true" ]; then
echo "${ACTOR} authorized (${METHOD})"
else
echo "${ACTOR} is NOT authorized. Requires admin or maintain role."
fi
} >> "${GITHUB_STEP_SUMMARY}"
- name: Deny execution when not permitted
if: ${{ steps.perm.outputs.allowed != 'true' }}
run: |
set -euo pipefail
printf '%s\n' 'ERROR: Access denied. Admin permission required.' >> "${GITHUB_STEP_SUMMARY}"
exit 1
release_config:
name: Release configuration
needs: access_check
if: ${{ needs.access_check.outputs.allowed == 'true' }}
runs-on: ubuntu-latest
timeout-minutes: 20
permissions:
contents: read
steps:
- name: Checkout
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
with:
fetch-depth: 0
- name: Guardrails release vars
env:
PROFILE_RAW: ${{ github.event.inputs.profile }}
RS_FTP_PATH_SUFFIX: ${{ vars.RS_FTP_PATH_SUFFIX }}
DEV_FTP_SUFFIX: ${{ vars.DEV_FTP_SUFFIX }}
run: |
set -euo pipefail
profile="${PROFILE_RAW:-all}"
case "${profile}" in
all|release|scripts|repo) ;;
*)
printf '%s\n' "ERROR: Unknown profile: ${profile}" >> "${GITHUB_STEP_SUMMARY}"
exit 1
;;
esac
if [ "${profile}" = 'scripts' ] || [ "${profile}" = 'repo' ]; then
{
printf '%s\n' '### Release configuration (Repository Variables)'
printf '%s\n' "Profile: ${profile}"
printf '%s\n' 'Status: SKIPPED'
printf '%s\n' 'Reason: profile excludes release validation'
printf '\n'
} >> "${GITHUB_STEP_SUMMARY}"
exit 0
fi
IFS=',' read -r -a required <<< "${RELEASE_REQUIRED_REPO_VARS}"
IFS=',' read -r -a optional <<< "${RELEASE_OPTIONAL_REPO_VARS}"
missing=()
missing_optional=()
for k in "${required[@]}"; do
v="${!k:-}"
[ -z "${v}" ] && missing+=("${k}")
done
for k in "${optional[@]}"; do
v="${!k:-}"
[ -z "${v}" ] && missing_optional+=("${k}")
done
{
printf '%s\n' '### Release configuration (Repository Variables)'
printf '%s\n' "Profile: ${profile}"
printf '%s\n' '| Variable | Status |'
printf '%s\n' '|---|---|'
printf '%s\n' "| RS_FTP_PATH_SUFFIX | ${RS_FTP_PATH_SUFFIX:-NOT SET} |"
printf '%s\n' "| DEV_FTP_SUFFIX | ${DEV_FTP_SUFFIX:-NOT SET} |"
printf '\n'
} >> "${GITHUB_STEP_SUMMARY}"
if [ "${#missing_optional[@]}" -gt 0 ]; then
{
printf '%s\n' '### Missing optional repository variables'
for m in "${missing_optional[@]}"; do printf '%s\n' "- ${m}"; done
printf '\n'
} >> "${GITHUB_STEP_SUMMARY}"
fi
if [ "${#missing[@]}" -gt 0 ]; then
{
printf '%s\n' '### Missing required repository variables'
for m in "${missing[@]}"; do printf '%s\n' "- ${m}"; done
printf '%s\n' 'ERROR: Guardrails failed. Missing required repository variables.'
} >> "${GITHUB_STEP_SUMMARY}"
exit 1
fi
{
printf '%s\n' '### Repository variables validation result'
printf '%s\n' 'Status: OK'
printf '%s\n' 'All required repository variables present.'
printf '%s\n' ''
printf '%s\n' '**Note**: Organization secrets (RS_FTP_HOST, RS_FTP_USER, etc.) are validated at deployment time, not in repository health checks.'
printf '\n'
} >> "${GITHUB_STEP_SUMMARY}"
scripts_governance:
name: Scripts governance
needs: access_check
if: ${{ needs.access_check.outputs.allowed == 'true' }}
runs-on: ubuntu-latest
timeout-minutes: 15
permissions:
contents: read
steps:
- name: Checkout
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
with:
fetch-depth: 0
- name: Scripts folder checks
env:
PROFILE_RAW: ${{ github.event.inputs.profile }}
run: |
set -euo pipefail
profile="${PROFILE_RAW:-all}"
case "${profile}" in
all|release|scripts|repo) ;;
*)
printf '%s\n' "ERROR: Unknown profile: ${profile}" >> "${GITHUB_STEP_SUMMARY}"
exit 1
;;
esac
if [ "${profile}" = 'release' ] || [ "${profile}" = 'repo' ]; then
{
printf '%s\n' '### Scripts governance'
printf '%s\n' "Profile: ${profile}"
printf '%s\n' 'Status: SKIPPED'
printf '%s\n' 'Reason: profile excludes scripts governance'
printf '\n'
} >> "${GITHUB_STEP_SUMMARY}"
exit 0
fi
if [ ! -d "${SCRIPT_DIR}" ]; then
{
printf '%s\n' '### Scripts governance'
printf '%s\n' 'Status: OK (advisory)'
printf '%s\n' 'scripts/ directory not present. No scripts governance enforced.'
printf '\n'
} >> "${GITHUB_STEP_SUMMARY}"
exit 0
fi
IFS=',' read -r -a required_dirs <<< "${SCRIPTS_REQUIRED_DIRS}"
IFS=',' read -r -a allowed_dirs <<< "${SCRIPTS_ALLOWED_DIRS}"
missing_dirs=()
unapproved_dirs=()
for d in "${required_dirs[@]}"; do
req="${d%/}"
[ ! -d "${req}" ] && missing_dirs+=("${req}/")
done
while IFS= read -r d; do
allowed=false
for a in "${allowed_dirs[@]}"; do
a_norm="${a%/}"
[ "${d%/}" = "${a_norm}" ] && allowed=true
done
[ "${allowed}" = false ] && unapproved_dirs+=("${d%/}/")
done < <(find "${SCRIPT_DIR}" -maxdepth 1 -mindepth 1 -type d 2>/dev/null | sed 's#^\./##')
{
printf '%s\n' '### Scripts governance'
printf '%s\n' "Profile: ${profile}"
printf '%s\n' '| Area | Status | Notes |'
printf '%s\n' '|---|---|---|'
if [ "${#missing_dirs[@]}" -gt 0 ]; then
printf '%s\n' '| Required directories | Warning | Missing required subfolders |'
else
printf '%s\n' '| Required directories | OK | All required subfolders present |'
fi
if [ "${#unapproved_dirs[@]}" -gt 0 ]; then
printf '%s\n' '| Directory policy | Warning | Unapproved directories detected |'
else
printf '%s\n' '| Directory policy | OK | No unapproved directories |'
fi
printf '%s\n' '| Enforcement mode | Advisory | scripts folder is optional |'
printf '\n'
if [ "${#missing_dirs[@]}" -gt 0 ]; then
printf '%s\n' 'Missing required script directories:'
for m in "${missing_dirs[@]}"; do printf '%s\n' "- ${m}"; done
printf '\n'
else
printf '%s\n' 'Missing required script directories: none.'
printf '\n'
fi
if [ "${#unapproved_dirs[@]}" -gt 0 ]; then
printf '%s\n' 'Unapproved script directories detected:'
for m in "${unapproved_dirs[@]}"; do printf '%s\n' "- ${m}"; done
printf '\n'
else
printf '%s\n' 'Unapproved script directories detected: none.'
printf '\n'
fi
printf '%s\n' 'Scripts governance completed in advisory mode.'
printf '\n'
} >> "${GITHUB_STEP_SUMMARY}"
repo_health:
name: Repository health
needs: access_check
if: ${{ needs.access_check.outputs.allowed == 'true' }}
runs-on: ubuntu-latest
timeout-minutes: 20
permissions:
contents: read
steps:
- name: Checkout
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
with:
fetch-depth: 0
- name: Repository health checks
env:
PROFILE_RAW: ${{ github.event.inputs.profile }}
run: |
set -euo pipefail
profile="${PROFILE_RAW:-all}"
case "${profile}" in
all|release|scripts|repo) ;;
*)
printf '%s\n' "ERROR: Unknown profile: ${profile}" >> "${GITHUB_STEP_SUMMARY}"
exit 1
;;
esac
if [ "${profile}" = 'release' ] || [ "${profile}" = 'scripts' ]; then
{
printf '%s\n' '### Repository health'
printf '%s\n' "Profile: ${profile}"
printf '%s\n' 'Status: SKIPPED'
printf '%s\n' 'Reason: profile excludes repository health'
printf '\n'
} >> "${GITHUB_STEP_SUMMARY}"
exit 0
fi
# Source directory: src/ or htdocs/ (either is valid)
if [ -d "src" ]; then
SOURCE_DIR="src"
elif [ -d "htdocs" ]; then
SOURCE_DIR="htdocs"
else
missing_required+=("src/ or htdocs/ (source directory required)")
fi
IFS=',' read -r -a required_artifacts <<< "${REPO_REQUIRED_ARTIFACTS}"
IFS=',' read -r -a optional_files <<< "${REPO_OPTIONAL_FILES}"
IFS=',' read -r -a disallowed_dirs <<< "${REPO_DISALLOWED_DIRS}"
IFS=',' read -r -a disallowed_files <<< "${REPO_DISALLOWED_FILES}"
missing_required=()
missing_optional=()
for item in "${required_artifacts[@]}"; do
if printf '%s' "${item}" | grep -q '/$'; then
d="${item%/}"
[ ! -d "${d}" ] && missing_required+=("${item}")
else
[ ! -f "${item}" ] && missing_required+=("${item}")
fi
done
for f in "${optional_files[@]}"; do
if printf '%s' "${f}" | grep -q '/$'; then
d="${f%/}"
[ ! -d "${d}" ] && missing_optional+=("${f}")
else
[ ! -f "${f}" ] && missing_optional+=("${f}")
fi
done
for d in "${disallowed_dirs[@]}"; do
d_norm="${d%/}"
[ -d "${d_norm}" ] && missing_required+=("${d_norm}/ (disallowed)")
done
for f in "${disallowed_files[@]}"; do
[ -f "${f}" ] && missing_required+=("${f} (disallowed)")
done
git fetch origin --prune
dev_paths=()
dev_branches=()
while IFS= read -r b; do
name="${b#origin/}"
if [ "${name}" = 'dev' ]; then
dev_branches+=("${name}")
else
dev_paths+=("${name}")
fi
done < <(git branch -r --list 'origin/dev*' | sed 's/^ *//')
if [ "${#dev_paths[@]}" -eq 0 ] && [ "${#dev_branches[@]}" -eq 0 ]; then
missing_required+=("dev branch")
fi
if [ "${#dev_branches[@]}" -gt 0 ]; then
fi
content_warnings=()
if [ -f 'CHANGELOG.md' ] && ! grep -Eq '^# Changelog' CHANGELOG.md; then
content_warnings+=("CHANGELOG.md missing '# Changelog' header")
fi
if [ -f 'CHANGELOG.md' ] && grep -Eq '^[# ]*Unreleased' CHANGELOG.md; then
content_warnings+=("CHANGELOG.md contains Unreleased section (review release readiness)")
fi
if [ -f 'LICENSE' ] && ! grep -qiE 'GNU GENERAL PUBLIC LICENSE|GPL' LICENSE; then
content_warnings+=("LICENSE does not look like a GPL text")
fi
if [ -f 'README.md' ] && ! grep -qiE 'moko|Moko' README.md; then
content_warnings+=("README.md missing expected brand keyword")
fi
export PROFILE_RAW="${profile}"
export MISSING_REQUIRED="$(printf '%s\n' "${missing_required[@]:-}")"
export MISSING_OPTIONAL="$(printf '%s\n' "${missing_optional[@]:-}")"
export CONTENT_WARNINGS="$(printf '%s\n' "${content_warnings[@]:-}")"
report_json="$(python3 - <<'PY'
import json
import os
profile = os.environ.get('PROFILE_RAW') or 'all'
missing_required = os.environ.get('MISSING_REQUIRED', '').splitlines() if os.environ.get('MISSING_REQUIRED') else []
missing_optional = os.environ.get('MISSING_OPTIONAL', '').splitlines() if os.environ.get('MISSING_OPTIONAL') else []
content_warnings = os.environ.get('CONTENT_WARNINGS', '').splitlines() if os.environ.get('CONTENT_WARNINGS') else []
out = {
'profile': profile,
'missing_required': [x for x in missing_required if x],
'missing_optional': [x for x in missing_optional if x],
'content_warnings': [x for x in content_warnings if x],
}
print(json.dumps(out, indent=2))
PY
)"
{
printf '%s\n' '### Repository health'
printf '%s\n' "Profile: ${profile}"
printf '%s\n' '| Metric | Value |'
printf '%s\n' '|---|---|'
printf '%s\n' "| Missing required | ${#missing_required[@]} |"
printf '%s\n' "| Missing optional | ${#missing_optional[@]} |"
printf '%s\n' "| Content warnings | ${#content_warnings[@]} |"
printf '\n'
printf '%s\n' '### Guardrails report (JSON)'
printf '%s\n' '```json'
printf '%s\n' "${report_json}"
printf '%s\n' '```'
printf '\n'
} >> "${GITHUB_STEP_SUMMARY}"
if [ "${#missing_required[@]}" -gt 0 ]; then
{
printf '%s\n' '### Missing required repo artifacts'
for m in "${missing_required[@]}"; do printf '%s\n' "- ${m}"; done
printf '%s\n' 'ERROR: Guardrails failed. Missing required repository artifacts.'
printf '\n'
} >> "${GITHUB_STEP_SUMMARY}"
exit 1
fi
if [ "${#missing_optional[@]}" -gt 0 ]; then
{
printf '%s\n' '### Missing optional repo artifacts'
for m in "${missing_optional[@]}"; do printf '%s\n' "- ${m}"; done
printf '\n'
} >> "${GITHUB_STEP_SUMMARY}"
fi
if [ "${#content_warnings[@]}" -gt 0 ]; then
{
printf '%s\n' '### Repo content warnings'
for m in "${content_warnings[@]}"; do printf '%s\n' "- ${m}"; done
printf '\n'
} >> "${GITHUB_STEP_SUMMARY}"
fi
# -- Joomla-specific checks --
joomla_findings=()
MANIFEST="$(find . -maxdepth 2 -name '*.xml' -exec grep -l '<extension' {} \; 2>/dev/null | head -1 || true)"
if [ -z "${MANIFEST}" ]; then
joomla_findings+=("Joomla XML manifest not found (no *.xml with <extension> tag)")
else
if ! grep -qP '<version>' "${MANIFEST}"; then
joomla_findings+=("XML manifest: <version> tag missing")
fi
if ! grep -qP 'type="(component|module|plugin|library|package|template|language)"' "${MANIFEST}"; then
joomla_findings+=("XML manifest: type attribute missing or invalid")
fi
if ! grep -qP '<name>' "${MANIFEST}"; then
joomla_findings+=("XML manifest: <name> tag missing")
fi
if ! grep -qP '<author>' "${MANIFEST}"; then
joomla_findings+=("XML manifest: <author> tag missing")
fi
if ! grep -qP '<namespace' "${MANIFEST}"; then
joomla_findings+=("XML manifest: <namespace> missing (required for Joomla 5+)")
fi
fi
INI_COUNT="$(find . -name '*.ini' -type f 2>/dev/null | wc -l)"
if [ "${INI_COUNT}" -eq 0 ]; then
joomla_findings+=("No .ini language files found")
fi
if [ ! -f 'updates.xml' ]; then
joomla_findings+=("updates.xml missing in root (required for Joomla update server)")
fi
INDEX_DIRS=("${SOURCE_DIR}" "${SOURCE_DIR}/admin" "${SOURCE_DIR}/site")
for dir in "${INDEX_DIRS[@]}"; do
if [ -d "${dir}" ] && [ ! -f "${dir}/index.html" ]; then
joomla_findings+=("${dir}/index.html missing (directory listing protection)")
fi
done
if [ "${#joomla_findings[@]}" -gt 0 ]; then
{
printf '%s\n' '### Joomla extension checks'
printf '%s\n' '| Check | Status |'
printf '%s\n' '|---|---|'
for f in "${joomla_findings[@]}"; do
printf '%s\n' "| ${f} | Warning |"
done
printf '\n'
} >> "${GITHUB_STEP_SUMMARY}"
else
{
printf '%s\n' '### Joomla extension checks'
printf '%s\n' 'All Joomla-specific checks passed.'
printf '\n'
} >> "${GITHUB_STEP_SUMMARY}"
fi
extended_enabled="${EXTENDED_CHECKS:-true}"
extended_findings=()
if [ "${extended_enabled}" = 'true' ]; then
if [ -f '.github/CODEOWNERS' ] || [ -f 'CODEOWNERS' ] || [ -f 'docs/CODEOWNERS' ]; then
:
else
extended_findings+=("CODEOWNERS not found (.github/CODEOWNERS preferred)")
fi
if ls "${WORKFLOWS_DIR}"/*.yml >/dev/null 2>&1 || ls "${WORKFLOWS_DIR}"/*.yaml >/dev/null 2>&1; then
bad_refs="$(grep -RIn --include='*.yml' --include='*.yaml' -E '^[[:space:]]*uses:[[:space:]]*[^#]+@(main|master)\b' "${WORKFLOWS_DIR}" 2>/dev/null || true)"
if [ -n "${bad_refs}" ]; then
extended_findings+=("Workflows reference actions @main/@master (pin versions): see log excerpt")
{
printf '%s\n' '### Workflow pinning advisory'
printf '%s\n' 'Found uses: entries pinned to main/master:'
printf '%s\n' '```'
printf '%s\n' "${bad_refs}"
printf '%s\n' '```'
printf '\n'
} >> "${GITHUB_STEP_SUMMARY}"
fi
fi
if [ -f "${DOCS_INDEX}" ]; then
missing_links="$(python3 - <<'PY'
import os
import re
idx = os.environ.get('DOCS_INDEX', 'docs/docs-index.md')
base = os.getcwd()
bad = []
pat = re.compile(r'\[[^\]]+\]\(([^)]+)\)')
with open(idx, 'r', encoding='utf-8') as f:
for line in f:
for m in pat.findall(line):
link = m.strip()
if link.startswith('http://') or link.startswith('https://') or link.startswith('#') or link.startswith('mailto:'):
continue
if link.startswith('/'):
rel = link.lstrip('/')
else:
rel = os.path.normpath(os.path.join(os.path.dirname(idx), link))
rel = rel.split('#', 1)[0]
rel = rel.split('?', 1)[0]
if not rel:
continue
p = os.path.join(base, rel)
if not os.path.exists(p):
bad.append(rel)
print('\n'.join(sorted(set(bad))))
PY
)"
if [ -n "${missing_links}" ]; then
extended_findings+=("docs/docs-index.md contains broken relative links")
{
printf '%s\n' '### Docs index link integrity'
printf '%s\n' 'Broken relative links:'
while IFS= read -r l; do [ -n "${l}" ] && printf '%s\n' "- ${l}"; done <<< "${missing_links}"
printf '\n'
} >> "${GITHUB_STEP_SUMMARY}"
fi
fi
if [ -d "${SCRIPT_DIR}" ]; then
if ! command -v shellcheck >/dev/null 2>&1; then
sudo apt-get update -qq
sudo apt-get install -y shellcheck >/dev/null
fi
sc_out=''
while IFS= read -r shf; do
[ -z "${shf}" ] && continue
out_one="$(shellcheck -S warning -x "${shf}" 2>/dev/null || true)"
if [ -n "${out_one}" ]; then
sc_out="${sc_out}${out_one}\n"
fi
done < <(find "${SCRIPT_DIR}" -type f -name "${SHELLCHECK_PATTERN}" 2>/dev/null | sort)
if [ -n "${sc_out}" ]; then
extended_findings+=("ShellCheck warnings detected (advisory)")
sc_head="$(printf '%s' "${sc_out}" | head -n 200)"
{
printf '%s\n' '### ShellCheck (advisory)'
printf '%s\n' '```'
printf '%s\n' "${sc_head}"
printf '%s\n' '```'
printf '\n'
} >> "${GITHUB_STEP_SUMMARY}"
fi
fi
spdx_missing=()
IFS=',' read -r -a spdx_globs <<< "${SPDX_FILE_GLOBS}"
spdx_args=()
for g in "${spdx_globs[@]}"; do spdx_args+=("${g}"); done
while IFS= read -r f; do
[ -z "${f}" ] && continue
if ! head -n 40 "${f}" | grep -q 'SPDX-License-Identifier:'; then
spdx_missing+=("${f}")
fi
done < <(git ls-files "${spdx_args[@]}" 2>/dev/null || true)
if [ "${#spdx_missing[@]}" -gt 0 ]; then
extended_findings+=("SPDX header missing in some tracked files (advisory)")
{
printf '%s\n' '### SPDX header advisory'
printf '%s\n' 'Files missing SPDX-License-Identifier (first 40 lines scan):'
for f in "${spdx_missing[@]}"; do printf '%s\n' "- ${f}"; done
printf '\n'
} >> "${GITHUB_STEP_SUMMARY}"
fi
stale_cutoff_days=180
stale_branches="$(git for-each-ref --format='%(refname:short) %(committerdate:unix)' refs/remotes/origin 2>/dev/null | awk -v now="$(date +%s)" -v days="${stale_cutoff_days}" '{if (now-$2 > days*86400) print $1}' | head -50)"
if [ -n "${stale_branches}" ]; then
extended_findings+=("Stale remote branches detected (advisory)")
{
printf '%s\n' '### Git hygiene advisory'
printf '%s\n' "Branches with last commit older than ${stale_cutoff_days} days (sample up to 50):"
while IFS= read -r b; do [ -n "${b}" ] && printf '%s\n' "- ${b}"; done <<< "${stale_branches}"
printf '\n'
} >> "${GITHUB_STEP_SUMMARY}"
fi
fi
{
printf '%s\n' '### Guardrails coverage matrix'
printf '%s\n' '| Domain | Status | Notes |'
printf '%s\n' '|---|---|---|'
printf '%s\n' '| Access control | OK | Admin-only execution gate |'
printf '%s\n' '| Release variables | OK | Repository variables validation |'
printf '%s\n' '| Scripts governance | OK | Directory policy and advisory reporting |'
printf '%s\n' '| Repo required artifacts | OK | Required, optional, disallowed enforcement |'
printf '%s\n' '| Repo content heuristics | OK | Brand, license, changelog structure |'
if [ "${extended_enabled}" = 'true' ]; then
if [ "${#extended_findings[@]}" -gt 0 ]; then
printf '%s\n' '| Extended checks | Warning | See extended findings below |'
else
printf '%s\n' '| Extended checks | OK | No findings |'
fi
else
printf '%s\n' '| Extended checks | SKIPPED | EXTENDED_CHECKS disabled |'
fi
printf '\n'
} >> "${GITHUB_STEP_SUMMARY}"
if [ "${extended_enabled}" = 'true' ] && [ "${#extended_findings[@]}" -gt 0 ]; then
{
printf '%s\n' '### Extended findings (advisory)'
for f in "${extended_findings[@]}"; do printf '%s\n' "- ${f}"; done
printf '\n'
} >> "${GITHUB_STEP_SUMMARY}"
fi
printf '%s\n' 'Repository health guardrails passed.' >> "${GITHUB_STEP_SUMMARY}"
+6
View File
@@ -0,0 +1,6 @@
# Changelog
## [1.0] — 2026-05-08
### Added
- Initial release
+3
View File
@@ -0,0 +1,3 @@
# Contributing
See [standards](https://git.mokoconsulting.tech/MokoConsulting/moko-platform/wiki).
+73
View File
@@ -0,0 +1,73 @@
# monitor-mcp
MCP server for server health monitoring, uptime, and log tailing
![Language](https://img.shields.io/badge/JavaScript-F7DF1E?style=flat-square&logo=javascript&logoColor=white) ![License](https://img.shields.io/badge/license-GPL--3.0--or--later-green?style=flat-square) ![Wiki](https://img.shields.io/badge/wiki-monitor-mcp-blue?style=flat-square)
MCP server for infrastructure monitoring -- server health, Grafana dashboards, and site uptime checks.
---
| | |
|---|---|
| **Type** | MCP Server |
| **Language** | Node.js |
| **License** | GPL-3.0-or-later |
| **Config** | `~/.monitor-mcp.json` |
| **Platform** | [Gitea](https://git.mokoconsulting.tech/MokoConsulting/monitor-mcp) (primary) |
---
## Overview
monitor-mcp provides MCP tools for monitoring server infrastructure and Grafana dashboards. It enables Claude Code to check server health, query metrics, inspect alerts, and verify site uptime.
---
## Wiki Pages
### Guides
- [Grafana Integration](https://git.mokoconsulting.tech/MokoConsulting/monitor-mcp/wiki/Grafana-Integration) -- connecting to Grafana dashboards and alerts
- [Sites Monitoring](https://git.mokoconsulting.tech/MokoConsulting/monitor-mcp/wiki/Sites-Monitoring) -- uptime and site health monitoring
### Reference
- [Tools Reference](https://git.mokoconsulting.tech/MokoConsulting/monitor-mcp/wiki/Tools-Reference) -- full list of available MCP tools
---
## Related Wikis
| Repo | Purpose |
|------|---------|
| [ssh-mcp](https://git.mokoconsulting.tech/MokoConsulting/ssh-mcp/wiki) | SSH server management MCP |
| [deploy-mcp](https://git.mokoconsulting.tech/MokoConsulting/deploy-mcp/wiki) | Git-based deployment MCP |
| [backup-mcp](https://git.mokoconsulting.tech/MokoConsulting/backup-mcp/wiki) | Backup MCP with Akeeba integration |
---
> **[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/monitor-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)*
+3
View File
@@ -0,0 +1,3 @@
# Security
Report to hello@mokoconsulting.tech.
@@ -0,0 +1,11 @@
{
"defaultConnection": "git",
"connections": {
"git": {
"host": "git.mokoconsulting.tech",
"port": 2918,
"username": "mokoconsulting",
"keyPath": "~/.ssh/id_ed25519"
}
}
}
@@ -0,0 +1,8 @@
node_modules/
dist/
.env
*.log
# Local config (contains secrets)
config.json
.env
@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="UTF-8"?>
<mokoplatform schema-version="1.0">
<identity><name>monitor-mcp</name><org>MokoConsulting</org></identity>
<governance><standards-version>05.00.00</standards-version></governance>
</mokoplatform>

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