Compare commits

..

129 Commits

Author SHA1 Message Date
Jonathan Miller f441a8a51f fix(updates_xml): restore <client>site</client> for all extension types
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) Successful in 1m8s
Generic: Repo Health / Release configuration (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 / Site Health (push) Has been skipped
Generic: Repo Health / Access control (push) Successful in 1s
Joomla requires the client tag to match updates to installed extensions.
Without it, extension_id=0 in #__updates and the update is invisible.

Authored-by: Moko Consulting
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-25 23:40:54 -05:00
Jonathan Miller 005eb5cf39 fix(updates_xml): treat 'development' and 'dev' as same channel in preservation
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 / Release configuration (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 / Site Health (push) Has been skipped
Generic: Repo Health / Access control (push) Successful in 1s
Platform: moko-platform CI / Gate 1: Code Quality (push) Successful in 43s
Legacy/manual entries used <tag>development</tag> while the CLI writes
<tag>dev</tag>. Without this alias, old entries survived preservation
and created duplicate dev channel entries.

Authored-by: Moko Consulting
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-25 23:35:54 -05:00
jmiller 21acb19fed Merge pull request 'chore: cascade main → dev (1fe4f83) [skip ci]' (#141) from main into dev
chore: cascade main → dev [skip ci]
2026-05-26 04:33:44 +00:00
jmiller 1fe4f83e73 Merge pull request 'chore(release): v09.00.00' (#140) from dev into main
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
Universal: Cascade Main → Dev / Cascade main → branches (push) Successful in 3s
Generic: Repo Health / Release configuration (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 / Site Health (push) Has been skipped
Generic: Repo Health / Access control (push) Successful in 4s
Platform: moko-platform CI / Gate 1: Code Quality (push) Successful in 50s
2026-05-26 04:33:40 +00:00
Jonathan Miller 7e5c322792 chore(release): bump to 09.00.00
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 / Release configuration (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 / Site Health (push) Has been skipped
Generic: Repo Health / Access control (push) Successful in 4s
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
Generic: Repo Health / Release configuration (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 / Site Health (pull_request) Has been skipped
Universal: PR Check / Branch Policy (pull_request) Successful in 5s
Generic: Repo Health / Access control (pull_request) Successful in 6s
Universal: PR Check / Validate PR (pull_request) Successful in 11s
Universal: Secret Scanning / Gitleaks Secret Scan (pull_request) Successful in 12s
Universal: Security Audit / Dependency Audit (pull_request) Successful in 9s
Platform: moko-platform CI / Gate 1: Code Quality (push) Successful in 1m21s
Platform: moko-platform CI / Gate 1: Code Quality (pull_request) Successful in 59s
PHPDoc standard, CI enforcement, updates_xml_build fixes.

Authored-by: Moko Consulting
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-25 23:32:42 -05:00
jmiller b010677d75 Merge pull request 'chore: cascade main → dev (9275e58) [skip ci]' (#139) from main into dev
chore: cascade main → dev [skip ci]
2026-05-26 04:29:33 +00:00
jmiller 9275e581c2 Merge pull request 'chore: PHPDoc Priority 1 + Coding Standards wiki' (#138) from dev into main
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 / Release configuration (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 / Site Health (push) Has been skipped
Generic: Repo Health / Access control (push) Successful in 1s
Universal: Cascade Main → Dev / Cascade main → branches (push) Successful in 3s
Platform: moko-platform CI / Gate 1: Code Quality (push) Successful in 46s
2026-05-26 04:29:29 +00:00
Jonathan Miller 3f3b1f79a0 chore: add PHPDoc to Priority 1 Enterprise classes
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 / Release configuration (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 / Site Health (push) Has been skipped
Generic: Repo Health / Access control (push) Successful in 1s
Platform: moko-platform CI / Gate 1: Code Quality (push) Successful in 42s
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
Generic: Repo Health / Release configuration (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
Universal: PR Check / Branch Policy (pull_request) Successful in 1s
Generic: Repo Health / Access control (pull_request) Successful in 1s
Generic: Repo Health / Site Health (pull_request) Has been skipped
Universal: Secret Scanning / Gitleaks Secret Scan (pull_request) Successful in 4s
Universal: PR Check / Validate PR (pull_request) Successful in 4s
Platform: moko-platform CI / Gate 1: Code Quality (pull_request) Successful in 45s
Added @since, @param, @see tags to:
- CliFramework: class-level @since, 2 undocumented methods
- GitHubAdapter: class @since/@see, constructor @param, property docs
- MokoGiteaAdapter: class @since/@see, constructor @param, property docs
- ApiClient: class @since

Wiki: created Coding-Standards page with full PHPDoc standard,
PHPCS exclusion rationale, and file structure patterns.

Partial progress on #137

Authored-by: Moko Consulting
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-25 23:26:07 -05:00
Jonathan Miller 83842c50ad docs(changelog): add updates_xml_build fixes to Unreleased
Generic: Repo Health / Site Health (push) Has been skipped
Generic: Repo Health / Access control (push) Successful in 1s
Generic: Repo Health / Scripts governance (push) Successful in 4s
Generic: Repo Health / Release configuration (push) Successful in 5s
Generic: Repo Health / Repository health (push) Successful in 10s
Authored-by: Moko Consulting
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-25 23:12:12 -05:00
Jonathan Miller fbedd5966c fix(updates_xml): cascade entries down, fix Gitea release tag URLs, fix client tag
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 / Site Health (push) Has been skipped
Generic: Repo Health / Access control (push) Successful in 1s
Generic: Repo Health / Release configuration (push) Successful in 4s
Generic: Repo Health / Scripts governance (push) Successful in 4s
Generic: Repo Health / Repository health (push) Successful in 13s
Platform: moko-platform CI / Gate 1: Code Quality (push) Failing after 46s
- Cascade: when stable releases, write all 5 channel entries pointing to stable
- Separate Joomla tags from Gitea release tags via releaseTagMap
- Only add client tag for templates and modules, not packages
- Preservation logic matches against Joomla tag names correctly

Authored-by: Moko Consulting
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-25 23:10:29 -05:00
jmiller eca2c13018 Merge pull request 'chore: cascade main → dev (48d0001) [skip ci]' (#136) from main into dev
chore: cascade main → dev [skip ci]
2026-05-26 04:09:19 +00:00
jmiller 48d000107d Merge pull request 'fix(ci): enforce PHPStan + PHPUnit in CI' (#135) from dev into main
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 / CI Summary (push) Blocked by required conditions
Generic: Repo Health / Site Health (push) Has been skipped
Universal: Cascade Main → Dev / Cascade main → branches (push) Successful in 3s
Generic: Repo Health / Access control (push) Successful in 1s
Generic: Repo Health / Release configuration (push) Successful in 5s
Generic: Repo Health / Scripts governance (push) Successful in 6s
Generic: Repo Health / Repository health (push) Successful in 11s
Platform: moko-platform CI / Gate 1: Code Quality (push) Failing after 49s
Platform: moko-platform CI / Gate 5: Template Integrity (push) Has been skipped
2026-05-26 04:09:14 +00:00
Jonathan Miller 7ceb9528cc fix(ci): enforce PHPStan + PHPUnit in CI gates
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 / Site Health (push) Has been skipped
Generic: Repo Health / Access control (push) Successful in 1s
Generic: Repo Health / Release configuration (push) Successful in 4s
Generic: Repo Health / Scripts governance (push) Successful in 4s
Generic: Repo Health / Repository health (push) Successful in 10s
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 / 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) Successful in 4s
Universal: PR Check / Build RC Package (pull_request) Successful in 2s
Generic: Repo Health / Release configuration (pull_request) Successful in 3s
Generic: Repo Health / Scripts governance (pull_request) Successful in 4s
Generic: Repo Health / Repository health (pull_request) Successful in 11s
Platform: moko-platform CI / Gate 1: Code Quality (push) Failing after 51s
Platform: moko-platform CI / Gate 1: Code Quality (pull_request) Failing after 51s
- PHPStan: remove continue-on-error, update label to Level 6,
  add --memory-limit=512M, fail on errors (was advisory)
- PHPUnit: add error handling — tests now block merges on failure
  (was silently passing even on test failures)

Authored-by: Moko Consulting
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-25 23:08:33 -05:00
jmiller 5fabaec477 Merge pull request 'chore: cascade main → dev (e40b799) [skip ci]' (#134) from main into dev
chore: cascade main → dev [skip ci]
2026-05-26 03:55:23 +00:00
jmiller e40b799101 Merge pull request 'chore(release): v08.00.00' (#133) from dev into main
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
Universal: Cascade Main → Dev / Cascade main → branches (push) Successful in 5s
Platform: moko-platform CI / Gate 1: Code Quality (push) Successful in 1m17s
Generic: Repo Health / Repository health (push) Successful in 16s
Generic: Repo Health / Access control (push) Successful in 3s
Generic: Repo Health / Release configuration (push) Successful in 13s
Generic: Repo Health / Scripts governance (push) Successful in 10s
Generic: Repo Health / Site Health (push) Has been skipped
2026-05-26 03:53:20 +00:00
Jonathan Miller 7e9784e723 chore(release): bump to 08.00.00
Platform: moko-platform CI / CI Summary (push) Blocked by required conditions
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
Generic: Repo Health / Site Health (push) Has been skipped
Generic: Repo Health / Access control (push) Successful in 5s
Generic: Repo Health / Site Health (pull_request) Has been skipped
Universal: PR Check / Branch Policy (pull_request) Successful in 3s
Generic: Repo Health / Access control (pull_request) Successful in 2s
Universal: PR Check / Validate PR (pull_request) Successful in 6s
Universal: Secret Scanning / Gitleaks Secret Scan (pull_request) Successful in 7s
Universal: Security Audit / Dependency Audit (pull_request) Successful in 7s
Generic: Repo Health / Release configuration (push) Successful in 7s
Generic: Repo Health / Scripts governance (push) Successful in 11s
Platform: moko-platform CI / Gate 1: Code Quality (push) Successful in 1m43s
Generic: Repo Health / Repository health (push) Successful in 21s
Platform: moko-platform CI / Gate 1: Code Quality (pull_request) Successful in 1m24s
Universal: PR Check / Build RC Package (pull_request) Successful in 4s
Generic: Repo Health / Scripts governance (pull_request) Successful in 7s
Generic: Repo Health / Release configuration (pull_request) Successful in 9s
Generic: Repo Health / Repository health (pull_request) Successful in 16s
Platform: moko-platform CI / Gate 5: Template Integrity (push) Failing after 4s
Platform: moko-platform CI / Gate 2: Unit Tests (8.1) (push) Successful in 47s
Platform: moko-platform CI / Gate 4: Governance (push) Successful in 47s
Platform: moko-platform CI / Gate 2: Unit Tests (8.2) (push) Successful in 51s
Platform: moko-platform CI / Gate 3: Self-Health Check (push) Failing after 50s
Platform: moko-platform CI / Gate 2: Unit Tests (8.3) (push) Successful in 54s
PHPStan level 0 → 6, branch protection restored, workflows synced,
44 stale runners flushed. Found and fixed real metrics bug at level 5.

Authored-by: Moko Consulting
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-25 22:52:00 -05:00
jmiller 209dee14fd Merge pull request 'chore: cascade main → dev (81351f4) [skip ci]' (#132) from main into dev
chore: cascade main → dev [skip ci]
2026-05-26 03:50:21 +00:00
Jonathan Miller 81351f45fd fix: updates_xml_build — tag 'dev' not 'development', client for all types
Platform: moko-platform CI / CI Summary (push) Blocked by required conditions
Generic: Repo Health / Site Health (push) Has been skipped
Generic: Repo Health / Access control (push) Successful in 3s
Universal: Cascade Main → Dev / Cascade main → branches (push) Successful in 8s
Generic: Repo Health / Release configuration (push) Successful in 11s
Generic: Repo Health / Scripts governance (push) Successful in 5s
Generic: Repo Health / Repository health (push) Successful in 15s
Platform: moko-platform CI / Gate 1: Code Quality (push) Successful in 1m25s
Platform: moko-platform CI / Gate 5: Template Integrity (push) Failing after 14s
Platform: moko-platform CI / Gate 4: Governance (push) Successful in 1m14s
Platform: moko-platform CI / Gate 2: Unit Tests (8.2) (push) Successful in 1m19s
Platform: moko-platform CI / Gate 2: Unit Tests (8.1) (push) Successful in 1m27s
Platform: moko-platform CI / Gate 3: Self-Health Check (push) Failing after 1m27s
Platform: moko-platform CI / Gate 2: Unit Tests (8.3) (push) Successful in 1m29s
Two root causes of Joomla updater not finding MokoWaaS updates:

1. stabilityTagToInteger('development') looks for STABILITY_DEVELOPMENT
   which doesn't exist → defaults to STABLE. Changed to 'dev' which
   maps to STABILITY_DEV (0).

2. Missing <client> tag defaults to client_id=1 (administrator) in
   Joomla's ExtensionAdapter. Packages install with client_id=0 (site).
   Now adds <client>site</client> for all extension types.

Fixes: #129

Authored-by: Moko Consulting
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-25 22:50:07 -05:00
jmiller fd451b4b73 Merge pull request 'chore: cascade main → dev (d0dbd1d) [skip ci]' (#131) from main into dev
chore: cascade main → dev [skip ci]
2026-05-26 03:48:35 +00:00
jmiller d0dbd1dceb Merge pull request 'fix: PHPStan level 6 with baseline' (#130) from dev into main
Platform: moko-platform CI / CI Summary (push) Blocked by required conditions
Generic: Repo Health / Site Health (push) Has been skipped
Generic: Repo Health / Access control (push) Successful in 5s
Universal: Cascade Main → Dev / Cascade main → branches (push) Successful in 9s
Generic: Repo Health / Release configuration (push) Successful in 7s
Generic: Repo Health / Scripts governance (push) Successful in 7s
Generic: Repo Health / Repository health (push) Successful in 17s
Platform: moko-platform CI / Gate 1: Code Quality (push) Successful in 2m19s
Platform: moko-platform CI / Gate 5: Template Integrity (push) Failing after 8s
Platform: moko-platform CI / Gate 4: Governance (push) Successful in 50s
Platform: moko-platform CI / Gate 2: Unit Tests (8.1) (push) Successful in 55s
Platform: moko-platform CI / Gate 2: Unit Tests (8.3) (push) Successful in 57s
Platform: moko-platform CI / Gate 3: Self-Health Check (push) Failing after 1m0s
Platform: moko-platform CI / Gate 2: Unit Tests (8.2) (push) Successful in 1m4s
2026-05-26 03:48:21 +00:00
Jonathan Miller 3e2e291819 fix: PHPStan level 5 → 6 — baseline 360 missing array generics
Platform: moko-platform CI / CI Summary (push) Blocked by required conditions
Generic: Repo Health / Site Health (push) Has been skipped
Generic: Repo Health / Access control (push) Successful in 1s
Generic: Repo Health / Release configuration (push) Successful in 5s
Generic: Repo Health / Scripts governance (push) Successful in 4s
Generic: Repo Health / Repository health (push) Successful in 12s
Platform: moko-platform CI / CI Summary (pull_request) Blocked by required conditions
Universal: PR Check / Branch Policy (pull_request) Successful in 1s
Generic: Repo Health / Site Health (pull_request) Has been skipped
Universal: Secret Scanning / Gitleaks Secret Scan (pull_request) Successful in 4s
Generic: Repo Health / Access control (pull_request) Successful in 1s
Universal: PR Check / Validate PR (pull_request) Successful in 4s
Universal: PR Check / Build RC Package (pull_request) Successful in 2s
Generic: Repo Health / Release configuration (pull_request) Successful in 5s
Generic: Repo Health / Scripts governance (pull_request) Successful in 5s
Generic: Repo Health / Repository health (pull_request) Successful in 14s
Platform: moko-platform CI / Gate 1: Code Quality (push) Successful in 1m10s
Platform: moko-platform CI / Gate 1: Code Quality (pull_request) Successful in 1m24s
Platform: moko-platform CI / Gate 5: Template Integrity (pull_request) Failing after 10s
Platform: moko-platform CI / Gate 2: Unit Tests (8.2) (pull_request) Successful in 1m9s
Platform: moko-platform CI / Gate 2: Unit Tests (8.1) (pull_request) Successful in 1m10s
Platform: moko-platform CI / Gate 3: Self-Health Check (pull_request) Failing after 58s
Platform: moko-platform CI / Gate 4: Governance (pull_request) Successful in 59s
Platform: moko-platform CI / Gate 2: Unit Tests (8.3) (pull_request) Successful in 1m6s
Platform: moko-platform CI / Gate 5: Template Integrity (push) Failing after 7s
Platform: moko-platform CI / Gate 2: Unit Tests (8.1) (push) Successful in 52s
Platform: moko-platform CI / Gate 2: Unit Tests (8.2) (push) Successful in 52s
Platform: moko-platform CI / Gate 3: Self-Health Check (push) Failing after 51s
Platform: moko-platform CI / Gate 4: Governance (push) Successful in 50s
Platform: moko-platform CI / Gate 2: Unit Tests (8.3) (push) Successful in 54s
Level 6 requires generic type annotations on all arrays. 357 of 360
errors are missingType.iterableValue (bare array without generics).
Baselined — these are PHPDoc-only changes with no functional impact.

PHPStan level 6: 0 errors with baseline.

Authored-by: Moko Consulting
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-25 22:47:39 -05:00
jmiller 5975ea38d8 Merge pull request 'chore: cascade main → dev (8ad548f) [skip ci]' (#128) from main into dev
chore: cascade main → dev [skip ci]
2026-05-26 03:43:49 +00:00
jmiller 8ad548f4a3 Merge pull request 'fix: PHPStan level 5 - fix metrics increment bug' (#127) from dev into main
Platform: moko-platform CI / CI Summary (push) Blocked by required conditions
Generic: Repo Health / Access control (push) Successful in 5s
Generic: Repo Health / Site Health (push) Has been skipped
Universal: Cascade Main → Dev / Cascade main → branches (push) Successful in 8s
Generic: Repo Health / Release configuration (push) Successful in 5s
Generic: Repo Health / Scripts governance (push) Successful in 4s
Generic: Repo Health / Repository health (push) Successful in 11s
Platform: moko-platform CI / Gate 1: Code Quality (push) Successful in 56s
Platform: moko-platform CI / Gate 2: Unit Tests (8.1) (push) Successful in 1m20s
Platform: moko-platform CI / Gate 2: Unit Tests (8.2) (push) Successful in 1m15s
Platform: moko-platform CI / Gate 5: Template Integrity (push) Failing after 10s
Platform: moko-platform CI / Gate 2: Unit Tests (8.3) (push) Successful in 54s
Platform: moko-platform CI / Gate 3: Self-Health Check (push) Failing after 1m17s
Platform: moko-platform CI / Gate 4: Governance (push) Successful in 1m14s
2026-05-26 03:43:42 +00:00
Jonathan Miller cbb4d73df5 fix: PHPStan level 4 → 5 — fix 4 errors
Platform: moko-platform CI / CI Summary (push) Blocked by required conditions
Generic: Repo Health / Site Health (push) Has been skipped
Generic: Repo Health / Access control (push) Successful in 1s
Generic: Repo Health / Release configuration (push) Successful in 7s
Generic: Repo Health / Scripts governance (push) Successful in 7s
Generic: Repo Health / Repository health (push) Successful in 19s
Platform: moko-platform CI / CI Summary (pull_request) Blocked by required conditions
Universal: PR Check / Branch Policy (pull_request) Successful in 1s
Generic: Repo Health / Access control (pull_request) Successful in 1s
Generic: Repo Health / Site Health (pull_request) Has been skipped
Universal: Secret Scanning / Gitleaks Secret Scan (pull_request) Successful in 6s
Universal: PR Check / Validate PR (pull_request) Successful in 6s
Generic: Repo Health / Release configuration (pull_request) Successful in 6s
Generic: Repo Health / Scripts governance (pull_request) Successful in 6s
Universal: PR Check / Build RC Package (pull_request) Successful in 5s
Generic: Repo Health / Repository health (pull_request) Successful in 15s
Platform: moko-platform CI / Gate 1: Code Quality (push) Successful in 1m19s
Platform: moko-platform CI / Gate 1: Code Quality (pull_request) Successful in 59s
Platform: moko-platform CI / Gate 5: Template Integrity (pull_request) Failing after 5s
Platform: moko-platform CI / Gate 2: Unit Tests (8.2) (pull_request) Successful in 58s
Platform: moko-platform CI / Gate 2: Unit Tests (8.1) (pull_request) Successful in 1m1s
Platform: moko-platform CI / Gate 2: Unit Tests (8.3) (pull_request) Successful in 1m0s
Platform: moko-platform CI / Gate 4: Governance (pull_request) Successful in 49s
Platform: moko-platform CI / Gate 3: Self-Health Check (pull_request) Failing after 51s
Platform: moko-platform CI / Gate 2: Unit Tests (8.1) (push) Successful in 1m2s
Platform: moko-platform CI / Gate 2: Unit Tests (8.2) (push) Successful in 1m7s
Platform: moko-platform CI / Gate 5: Template Integrity (push) Failing after 7s
Platform: moko-platform CI / Gate 2: Unit Tests (8.3) (push) Successful in 1m9s
Platform: moko-platform CI / Gate 3: Self-Health Check (push) Failing after 55s
Platform: moko-platform CI / Gate 4: Governance (push) Successful in 1m0s
- bulk_sync: remove redundant array_values on already-list array
- RepositorySynchronizer: fix metrics increment() — labels passed as
  2nd param (value) instead of 3rd (labels), was a real bug

PHPStan level 5: 0 errors.

Authored-by: Moko Consulting
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-25 22:42:50 -05:00
jmiller 47cb47ebdb Merge pull request 'chore: cascade main → dev (22b0f8a) [skip ci]' (#126) from main into dev
chore: cascade main → dev [skip ci]
2026-05-26 03:34:38 +00:00
jmiller 22b0f8af7e Merge pull request 'fix: PHPStan level 4 with baseline' (#125) from dev into main
Platform: moko-platform CI / CI Summary (push) Blocked by required conditions
Generic: Repo Health / Site Health (push) Has been skipped
Generic: Repo Health / Access control (push) Successful in 1s
Universal: Cascade Main → Dev / Cascade main → branches (push) Successful in 2s
Generic: Repo Health / Release configuration (push) Successful in 4s
Generic: Repo Health / Scripts governance (push) Successful in 4s
Generic: Repo Health / Repository health (push) Successful in 11s
Platform: moko-platform CI / Gate 1: Code Quality (push) Successful in 44s
Platform: moko-platform CI / Gate 2: Unit Tests (8.3) (push) Successful in 1m6s
Platform: moko-platform CI / Gate 2: Unit Tests (8.1) (push) Successful in 1m10s
Platform: moko-platform CI / Gate 4: Governance (push) Successful in 1m8s
Platform: moko-platform CI / Gate 2: Unit Tests (8.2) (push) Successful in 1m12s
Platform: moko-platform CI / Gate 3: Self-Health Check (push) Failing after 1m11s
Platform: moko-platform CI / Gate 5: Template Integrity (push) Failing after 7s
2026-05-26 03:34:34 +00:00
jmiller 08ca1429ae Merge branch 'main' into dev
Platform: moko-platform CI / CI Summary (push) Blocked by required conditions
Platform: moko-platform CI / CI Summary (pull_request) Blocked by required conditions
Generic: Repo Health / Site Health (push) Has been skipped
Generic: Repo Health / Access control (push) Successful in 3s
Universal: PR Check / Branch Policy (pull_request) Successful in 4s
Generic: Repo Health / Site Health (pull_request) Has been skipped
Universal: Secret Scanning / Gitleaks Secret Scan (pull_request) Successful in 8s
Generic: Repo Health / Access control (pull_request) Successful in 4s
Universal: PR Check / Validate PR (pull_request) Successful in 8s
Generic: Repo Health / Scripts governance (push) Successful in 10s
Generic: Repo Health / Release configuration (push) Successful in 10s
Generic: Repo Health / Repository health (push) Successful in 18s
Generic: Repo Health / Release configuration (pull_request) Successful in 13s
Platform: moko-platform CI / Gate 1: Code Quality (push) Successful in 1m30s
Universal: PR Check / Build RC Package (pull_request) Successful in 5s
Platform: moko-platform CI / Gate 1: Code Quality (pull_request) Successful in 1m32s
Generic: Repo Health / Scripts governance (pull_request) Successful in 11s
Generic: Repo Health / Repository health (pull_request) Successful in 19s
Platform: moko-platform CI / Gate 5: Template Integrity (pull_request) Failing after 9s
Platform: moko-platform CI / Gate 4: Governance (pull_request) Successful in 1m49s
Platform: moko-platform CI / Gate 2: Unit Tests (8.3) (pull_request) Successful in 1m49s
Platform: moko-platform CI / Gate 3: Self-Health Check (pull_request) Failing after 1m51s
Platform: moko-platform CI / Gate 2: Unit Tests (8.2) (pull_request) Successful in 1m52s
Platform: moko-platform CI / Gate 2: Unit Tests (8.1) (pull_request) Successful in 1m55s
Platform: moko-platform CI / Gate 5: Template Integrity (push) Failing after 7s
Platform: moko-platform CI / Gate 2: Unit Tests (8.1) (push) Successful in 54s
Platform: moko-platform CI / Gate 2: Unit Tests (8.2) (push) Successful in 55s
Platform: moko-platform CI / Gate 3: Self-Health Check (push) Failing after 55s
Platform: moko-platform CI / Gate 2: Unit Tests (8.3) (push) Successful in 57s
Platform: moko-platform CI / Gate 4: Governance (push) Successful in 52s
2026-05-26 03:32:18 +00:00
Jonathan Miller e8da1a30ff fix: PHPStan level 3 → 4 — remove dead code, baseline 41 items
Platform: moko-platform CI / CI Summary (push) Blocked by required conditions
Generic: Repo Health / Site Health (push) Has been skipped
Generic: Repo Health / Access control (push) Successful in 2s
Generic: Repo Health / Release configuration (push) Successful in 10s
Generic: Repo Health / Scripts governance (push) Successful in 9s
Generic: Repo Health / Repository health (push) Successful in 17s
Platform: moko-platform CI / CI Summary (pull_request) Blocked by required conditions
Universal: PR Check / Branch Policy (pull_request) Successful in 2s
Generic: Repo Health / Access control (pull_request) Successful in 2s
Generic: Repo Health / Site Health (pull_request) Has been skipped
Universal: Secret Scanning / Gitleaks Secret Scan (pull_request) Successful in 8s
Universal: PR Check / Validate PR (pull_request) Successful in 8s
Generic: Repo Health / Release configuration (pull_request) Successful in 6s
Generic: Repo Health / Scripts governance (pull_request) Successful in 8s
Universal: PR Check / Build RC Package (pull_request) Successful in 5s
Generic: Repo Health / Repository health (pull_request) Successful in 19s
Platform: moko-platform CI / Gate 1: Code Quality (push) Successful in 1m16s
Platform: moko-platform CI / Gate 1: Code Quality (pull_request) Successful in 1m29s
Platform: moko-platform CI / Gate 2: Unit Tests (8.1) (push) Successful in 41s
Platform: moko-platform CI / Gate 5: Template Integrity (push) Failing after 6s
Platform: moko-platform CI / Gate 4: Governance (push) Successful in 1m38s
Platform: moko-platform CI / Gate 2: Unit Tests (8.3) (push) Successful in 1m40s
Platform: moko-platform CI / Gate 2: Unit Tests (8.2) (push) Successful in 1m41s
Platform: moko-platform CI / Gate 3: Self-Health Check (push) Failing after 1m40s
Platform: moko-platform CI / Gate 4: Governance (pull_request) Successful in 1m20s
Platform: moko-platform CI / Gate 2: Unit Tests (8.2) (pull_request) Successful in 1m24s
Platform: moko-platform CI / Gate 3: Self-Health Check (pull_request) Failing after 1m24s
Platform: moko-platform CI / Gate 2: Unit Tests (8.1) (pull_request) Successful in 1m26s
Platform: moko-platform CI / Gate 2: Unit Tests (8.3) (pull_request) Successful in 1m30s
Platform: moko-platform CI / Gate 5: Template Integrity (pull_request) Failing after 12s
Removed 13 write-only properties and unused code. Remaining 41
baselined items are defensive patterns (null coalesce on API responses,
boolean safety checks) that are intentional.

PHPStan level 4: 0 errors with baseline.

Authored-by: Moko Consulting
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-25 22:31:25 -05:00
gitea-actions[bot] fb754b1a07 refactor(ci): clean up auto-release, move logic to CLI [skip ci] 2026-05-25 22:21:10 -05:00
jmiller 9a2c164207 Merge pull request 'chore: cascade main → dev (78c1329) [skip ci]' (#124) from main into dev
chore: cascade main → dev [skip ci]
2026-05-26 03:19:46 +00:00
jmiller 78c1329a83 Merge pull request 'fix: PHPStan level 3 - 12 return type errors fixed' (#123) from dev into main
Platform: moko-platform CI / CI Summary (push) Blocked by required conditions
Generic: Repo Health / Site Health (push) Has been skipped
Generic: Repo Health / Access control (push) Successful in 1s
Universal: Cascade Main → Dev / Cascade main → branches (push) Successful in 4s
Generic: Repo Health / Scripts governance (push) Successful in 5s
Generic: Repo Health / Release configuration (push) Successful in 5s
Generic: Repo Health / Repository health (push) Successful in 11s
Platform: moko-platform CI / Gate 1: Code Quality (push) Successful in 45s
Platform: moko-platform CI / Gate 5: Template Integrity (push) Failing after 5s
Platform: moko-platform CI / Gate 2: Unit Tests (8.2) (push) Successful in 55s
Platform: moko-platform CI / Gate 2: Unit Tests (8.1) (push) Successful in 57s
Platform: moko-platform CI / Gate 4: Governance (push) Successful in 57s
Platform: moko-platform CI / Gate 3: Self-Health Check (push) Failing after 58s
Platform: moko-platform CI / Gate 2: Unit Tests (8.3) (push) Successful in 1m0s
2026-05-26 03:19:41 +00:00
jmiller 05f43ed88f Merge branch 'main' into dev
Platform: moko-platform CI / CI Summary (push) Blocked by required conditions
Platform: moko-platform CI / CI Summary (pull_request) Blocked by required conditions
Generic: Repo Health / Site Health (push) Has been skipped
Generic: Repo Health / Site Health (pull_request) Has been skipped
Generic: Repo Health / Access control (push) Successful in 2s
Universal: PR Check / Branch Policy (pull_request) Successful in 2s
Generic: Repo Health / Access control (pull_request) Successful in 2s
Universal: PR Check / Validate PR (pull_request) Successful in 5s
Universal: Secret Scanning / Gitleaks Secret Scan (pull_request) Successful in 5s
Generic: Repo Health / Release configuration (push) Successful in 4s
Generic: Repo Health / Scripts governance (push) Successful in 5s
Universal: PR Check / Build RC Package (pull_request) Successful in 3s
Generic: Repo Health / Release configuration (pull_request) Successful in 5s
Generic: Repo Health / Scripts governance (pull_request) Successful in 5s
Generic: Repo Health / Repository health (push) Successful in 13s
Generic: Repo Health / Repository health (pull_request) Successful in 13s
Platform: moko-platform CI / Gate 1: Code Quality (push) Successful in 57s
Platform: moko-platform CI / Gate 1: Code Quality (pull_request) Successful in 57s
Platform: moko-platform CI / Gate 5: Template Integrity (pull_request) Failing after 8s
Platform: moko-platform CI / Gate 4: Governance (pull_request) Successful in 44s
Platform: moko-platform CI / Gate 3: Self-Health Check (pull_request) Failing after 47s
Platform: moko-platform CI / Gate 2: Unit Tests (8.1) (pull_request) Successful in 48s
Platform: moko-platform CI / Gate 2: Unit Tests (8.2) (pull_request) Successful in 49s
Platform: moko-platform CI / Gate 2: Unit Tests (8.3) (pull_request) Successful in 50s
Platform: moko-platform CI / Gate 5: Template Integrity (push) Failing after 6s
Platform: moko-platform CI / Gate 2: Unit Tests (8.1) (push) Successful in 1m0s
Platform: moko-platform CI / Gate 2: Unit Tests (8.2) (push) Successful in 1m7s
Platform: moko-platform CI / Gate 3: Self-Health Check (push) Failing after 1m4s
Platform: moko-platform CI / Gate 4: Governance (push) Successful in 1m4s
Platform: moko-platform CI / Gate 2: Unit Tests (8.3) (push) Successful in 1m13s
2026-05-26 03:18:36 +00:00
Jonathan Miller 05e4f39e7d fix: PHPStan level 2 → 3 — fix 12 return type errors
Platform: moko-platform CI / CI Summary (push) Blocked by required conditions
Generic: Repo Health / Site Health (push) Has been skipped
Generic: Repo Health / Access control (push) Successful in 1s
Generic: Repo Health / Release configuration (push) Successful in 4s
Generic: Repo Health / Scripts governance (push) Successful in 4s
Generic: Repo Health / Repository health (push) Successful in 11s
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
Generic: Repo Health / Site Health (pull_request) Has been skipped
Universal: PR Check / Branch Policy (pull_request) Successful in 2s
Generic: Repo Health / Access control (pull_request) Successful in 2s
Universal: Secret Scanning / Gitleaks Secret Scan (pull_request) Successful in 7s
Universal: PR Check / Validate PR (pull_request) Successful in 7s
Generic: Repo Health / Release configuration (pull_request) Successful in 5s
Generic: Repo Health / Scripts governance (pull_request) Successful in 5s
Universal: PR Check / Build RC Package (pull_request) Successful in 2s
Generic: Repo Health / Repository health (pull_request) Successful in 15s
Platform: moko-platform CI / Gate 1: Code Quality (push) Successful in 59s
Platform: moko-platform CI / Gate 1: Code Quality (pull_request) Successful in 57s
Platform: moko-platform CI / Gate 2: Unit Tests (8.1) (push) Successful in 51s
Platform: moko-platform CI / Gate 3: Self-Health Check (push) Failing after 57s
Platform: moko-platform CI / Gate 2: Unit Tests (8.2) (push) Successful in 1m4s
Platform: moko-platform CI / Gate 2: Unit Tests (8.3) (push) Successful in 1m6s
Platform: moko-platform CI / Gate 5: Template Integrity (push) Failing after 11s
Platform: moko-platform CI / Gate 4: Governance (push) Successful in 54s
- Interface return types: narrowed list types to array<mixed> for API
  responses (ApiClient returns array<string, mixed>, not typed lists)
- paginateAll(): wrap return with array_values() for numeric keys
- listLabels: include id in return type
- check_file_integrity: fix sftpConfig default value type

Authored-by: Moko Consulting
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-25 22:18:07 -05:00
jmiller 3dcb3b6d3a chore: sync .mokogitea/workflows/auto-release.yml from moko-platform [skip ci] 2026-05-26 03:07:21 +00:00
jmiller db4e6f5c6b Merge pull request 'chore: cascade main → dev (aa7fc45) [skip ci]' (#121) from main into dev
chore: cascade main → dev [skip ci]
2026-05-26 03:07:10 +00:00
Jonathan Miller aa7fc45a67 feat: version_check.php — validate version consistency across files
Platform: moko-platform CI / CI Summary (push) Blocked by required conditions
Generic: Repo Health / Site Health (push) Has been skipped
Generic: Repo Health / Access control (push) Successful in 1s
Universal: Cascade Main → Dev / Cascade main → branches (push) Successful in 4s
Generic: Repo Health / Release configuration (push) Successful in 5s
Generic: Repo Health / Scripts governance (push) Successful in 5s
Generic: Repo Health / Repository health (push) Successful in 16s
Platform: moko-platform CI / Gate 1: Code Quality (push) Successful in 55s
Platform: moko-platform CI / Gate 5: Template Integrity (push) Failing after 7s
Platform: moko-platform CI / Gate 2: Unit Tests (8.3) (push) Successful in 52s
Platform: moko-platform CI / Gate 2: Unit Tests (8.1) (push) Successful in 52s
Platform: moko-platform CI / Gate 2: Unit Tests (8.2) (push) Successful in 54s
Platform: moko-platform CI / Gate 3: Self-Health Check (push) Failing after 54s
Platform: moko-platform CI / Gate 4: Governance (push) Successful in 53s
Checks README.md VERSION header and all manifest XML <version> tags.
Flags mismatches, reports highest version, and optionally fixes them.

Usage:
  php version_check.php --path /repo           # report only
  php version_check.php --path /repo --strict  # exit 1 on mismatch
  php version_check.php --path /repo --fix     # fix to highest version

Authored-by: Moko Consulting
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-25 22:07:04 -05:00
jmiller 03fe66238f chore: sync .mokogitea/workflows/pre-release.yml from moko-platform [skip ci] 2026-05-26 03:05:28 +00:00
gitea-actions[bot] a5ae616a94 fix(ci): auto-release preserves all update channels [skip ci] 2026-05-25 21:59:33 -05:00
jmiller ff7924de7d Merge pull request 'chore: cascade main → dev (1690e29) [skip ci]' (#120) from main into dev
chore: cascade main → dev [skip ci]
2026-05-26 02:56:24 +00:00
jmiller 1690e291d2 Merge pull request 'chore(release): v07.00.00' (#119) from dev into main
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
Universal: Cascade Main → Dev / Cascade main → branches (push) Successful in 7s
Platform: moko-platform CI / Gate 1: Code Quality (push) Successful in 1m18s
Generic: Repo Health / Release configuration (push) Successful in 5s
Generic: Repo Health / Access control (push) Successful in 4s
Generic: Repo Health / Site Health (push) Has been skipped
Generic: Repo Health / Scripts governance (push) Successful in 8s
Generic: Repo Health / Repository health (push) Successful in 18s
2026-05-26 02:55:34 +00:00
Jonathan Miller 7f818809ef chore(release): bump to 07.00.00
Platform: moko-platform CI / CI Summary (push) Blocked by required conditions
Generic: Repo Health / Site Health (push) Has been skipped
Generic: Repo Health / Access control (push) Successful in 3s
Generic: Repo Health / Release configuration (push) Successful in 7s
Platform: moko-platform CI / CI Summary (pull_request) Blocked by required conditions
Generic: Repo Health / Scripts governance (push) Successful in 6s
Universal: PR Check / Build RC Package (pull_request) Blocked by required conditions
Generic: Repo Health / Repository health (push) Successful in 18s
Universal: Secret Scanning / Gitleaks Secret Scan (pull_request) Successful in 5s
Universal: PR Check / Branch Policy (pull_request) Successful in 2s
Generic: Repo Health / Site Health (pull_request) Has been skipped
Generic: Repo Health / Access control (pull_request) Successful in 5s
Universal: PR Check / Validate PR (pull_request) Successful in 11s
Universal: Security Audit / Dependency Audit (pull_request) Successful in 9s
Platform: moko-platform CI / Gate 1: Code Quality (push) Successful in 1m23s
Platform: moko-platform CI / Gate 1: Code Quality (pull_request) Successful in 1m14s
Generic: Repo Health / Release configuration (pull_request) Successful in 9s
Generic: Repo Health / Scripts governance (pull_request) Successful in 6s
Generic: Repo Health / Repository health (pull_request) Successful in 18s
Platform: moko-platform CI / Gate 5: Template Integrity (pull_request) Failing after 7s
Platform: moko-platform CI / Gate 2: Unit Tests (8.1) (pull_request) Successful in 1m14s
Platform: moko-platform CI / Gate 4: Governance (pull_request) Successful in 1m11s
Platform: moko-platform CI / Gate 3: Self-Health Check (pull_request) Failing after 1m13s
Platform: moko-platform CI / Gate 2: Unit Tests (8.3) (pull_request) Successful in 1m20s
Platform: moko-platform CI / Gate 2: Unit Tests (8.2) (pull_request) Successful in 1m21s
Platform: moko-platform CI / Gate 5: Template Integrity (push) Failing after 13s
Platform: moko-platform CI / Gate 2: Unit Tests (8.1) (push) Successful in 48s
Platform: moko-platform CI / Gate 2: Unit Tests (8.2) (push) Successful in 46s
Platform: moko-platform CI / Gate 3: Self-Health Check (push) Failing after 44s
Platform: moko-platform CI / Gate 2: Unit Tests (8.3) (push) Successful in 47s
Platform: moko-platform CI / Gate 4: Governance (push) Successful in 49s
Major release:
- 5 new CLI tools (client_provision, client_dashboard, client_health_check,
  joomla_compat_check, theme_lint)
- ConfigValidator for plugin JSON schema validation
- PHPUnit test infrastructure (19 tests)
- bin/moko plugin command dispatcher (45 commands)
- All CLIApp scripts migrated to CliFramework
- PHPStan level 2 with 0 errors, 0 exclusions
- bin/moko COMMAND_MAP fixed
- package_build.php Joomla package fix

Authored-by: Moko Consulting
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-25 21:54:46 -05:00
jmiller 597b40f3f2 Merge pull request 'chore: cascade main → dev (80108f9) [skip ci]' (#118) from main into dev
chore: cascade main → dev [skip ci]
2026-05-26 02:51:37 +00:00
jmiller 80108f9ca8 Merge pull request 'feat: ConfigValidator + plugin command dispatcher' (#117) from dev into main
Platform: moko-platform CI / CI Summary (push) Blocked by required conditions
Generic: Repo Health / Site Health (push) Has been skipped
Generic: Repo Health / Access control (push) Successful in 2s
Universal: Cascade Main → Dev / Cascade main → branches (push) Successful in 4s
Generic: Repo Health / Release configuration (push) Successful in 4s
Generic: Repo Health / Scripts governance (push) Successful in 5s
Generic: Repo Health / Repository health (push) Successful in 13s
Platform: moko-platform CI / Gate 1: Code Quality (push) Successful in 57s
Platform: moko-platform CI / Gate 5: Template Integrity (push) Failing after 6s
Platform: moko-platform CI / Gate 2: Unit Tests (8.1) (push) Successful in 55s
Platform: moko-platform CI / Gate 2: Unit Tests (8.2) (push) Successful in 56s
Platform: moko-platform CI / Gate 3: Self-Health Check (push) Failing after 54s
Platform: moko-platform CI / Gate 4: Governance (push) Successful in 53s
Platform: moko-platform CI / Gate 2: Unit Tests (8.3) (push) Successful in 1m5s
2026-05-26 02:51:30 +00:00
Jonathan Miller b33623c731 feat: add ConfigValidator for plugin JSON schema validation
Platform: moko-platform CI / CI Summary (push) Blocked by required conditions
Generic: Repo Health / Access control (push) Successful in 2s
Generic: Repo Health / Site Health (push) Has been skipped
Generic: Repo Health / Release configuration (push) Successful in 6s
Generic: Repo Health / Scripts governance (push) Successful in 6s
Generic: Repo Health / Repository health (push) Successful in 16s
Platform: moko-platform CI / CI Summary (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 3s
Generic: Repo Health / Access control (pull_request) Successful in 3s
Universal: Secret Scanning / Gitleaks Secret Scan (pull_request) Successful in 8s
Universal: PR Check / Validate PR (pull_request) Successful in 8s
Universal: PR Check / Build RC Package (pull_request) Successful in 3s
Generic: Repo Health / Release configuration (pull_request) Successful in 9s
Generic: Repo Health / Scripts governance (pull_request) Successful in 10s
Generic: Repo Health / Repository health (pull_request) Successful in 17s
Platform: moko-platform CI / Gate 1: Code Quality (push) Successful in 1m3s
Platform: moko-platform CI / Gate 1: Code Quality (pull_request) Successful in 1m0s
Platform: moko-platform CI / Gate 5: Template Integrity (push) Failing after 6s
Platform: moko-platform CI / Gate 2: Unit Tests (8.1) (push) Successful in 46s
Platform: moko-platform CI / Gate 2: Unit Tests (8.2) (push) Successful in 48s
Platform: moko-platform CI / Gate 3: Self-Health Check (push) Failing after 49s
Platform: moko-platform CI / Gate 4: Governance (push) Successful in 46s
Platform: moko-platform CI / Gate 2: Unit Tests (8.3) (push) Successful in 1m2s
Platform: moko-platform CI / Gate 5: Template Integrity (pull_request) Failing after 7s
Platform: moko-platform CI / Gate 2: Unit Tests (8.1) (pull_request) Successful in 49s
Platform: moko-platform CI / Gate 2: Unit Tests (8.2) (pull_request) Successful in 51s
Platform: moko-platform CI / Gate 3: Self-Health Check (pull_request) Failing after 53s
Platform: moko-platform CI / Gate 4: Governance (pull_request) Successful in 51s
Platform: moko-platform CI / Gate 2: Unit Tests (8.3) (pull_request) Successful in 59s
Validates project config against plugin getConfigSchema() definitions.
Supports: type checking, required fields, enum values, nested objects,
arrays, string minLength/pattern, number min/max, unknown property warnings.

7 unit tests covering all validation paths.

Closes #105

Authored-by: Moko Consulting
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-25 21:50:37 -05:00
jmiller 9ff59ce405 Merge pull request 'chore: cascade main → dev (9c6f393) [skip ci]' (#116) from main into dev
chore: cascade main → dev [skip ci]
2026-05-26 02:48:49 +00:00
jmiller 9c6f393f92 Merge pull request 'feat: plugin command dispatcher + auto-grouped list' (#115) from dev into main
Platform: moko-platform CI / CI Summary (push) Blocked by required conditions
Universal: Cascade Main → Dev / Cascade main → branches (push) Successful in 5s
Generic: Repo Health / Site Health (push) Has been skipped
Generic: Repo Health / Access control (push) Successful in 3s
Platform: moko-platform CI / Gate 1: Code Quality (push) Successful in 1m16s
Generic: Repo Health / Release configuration (push) Successful in 8s
Generic: Repo Health / Scripts governance (push) Successful in 7s
Generic: Repo Health / Repository health (push) Successful in 16s
Platform: moko-platform CI / Gate 5: Template Integrity (push) Failing after 13s
Platform: moko-platform CI / Gate 2: Unit Tests (8.1) (push) Successful in 1m5s
Platform: moko-platform CI / Gate 2: Unit Tests (8.2) (push) Successful in 1m7s
Platform: moko-platform CI / Gate 3: Self-Health Check (push) Failing after 1m5s
Platform: moko-platform CI / Gate 2: Unit Tests (8.3) (push) Successful in 1m11s
Platform: moko-platform CI / Gate 4: Governance (push) Successful in 1m8s
2026-05-26 02:48:16 +00:00
Jonathan Miller a418798a4d feat: plugin command dispatcher + auto-grouped command list
Platform: moko-platform CI / CI Summary (push) Blocked by required conditions
Generic: Repo Health / Site Health (push) Has been skipped
Generic: Repo Health / Access control (push) Successful in 1s
Generic: Repo Health / Release configuration (push) Successful in 4s
Generic: Repo Health / Scripts governance (push) Successful in 4s
Generic: Repo Health / Repository health (push) Successful in 15s
Platform: moko-platform CI / Gate 1: Code Quality (push) Successful in 1m1s
Platform: moko-platform CI / CI Summary (pull_request) Blocked by required conditions
Universal: PR Check / Branch Policy (pull_request) Successful in 1s
Universal: Secret Scanning / Gitleaks Secret Scan (pull_request) Successful in 6s
Universal: PR Check / Validate PR (pull_request) Successful in 5s
Generic: Repo Health / Site Health (pull_request) Has been skipped
Generic: Repo Health / Access control (pull_request) Successful in 3s
Universal: PR Check / Build RC Package (pull_request) Successful in 2s
Generic: Repo Health / Release configuration (pull_request) Successful in 5s
Generic: Repo Health / Scripts governance (pull_request) Successful in 4s
Generic: Repo Health / Repository health (pull_request) Successful in 17s
Platform: moko-platform CI / Gate 1: Code Quality (pull_request) Successful in 1m12s
Platform: moko-platform CI / Gate 5: Template Integrity (pull_request) Failing after 4s
Platform: moko-platform CI / Gate 2: Unit Tests (8.1) (pull_request) Successful in 47s
Platform: moko-platform CI / Gate 2: Unit Tests (8.2) (pull_request) Successful in 50s
Platform: moko-platform CI / Gate 3: Self-Health Check (pull_request) Failing after 49s
Platform: moko-platform CI / Gate 2: Unit Tests (8.3) (pull_request) Successful in 57s
Platform: moko-platform CI / Gate 4: Governance (pull_request) Successful in 55s
Platform: moko-platform CI / Gate 5: Template Integrity (push) Failing after 5s
Platform: moko-platform CI / Gate 2: Unit Tests (8.1) (push) Successful in 50s
Platform: moko-platform CI / Gate 2: Unit Tests (8.2) (push) Successful in 52s
Platform: moko-platform CI / Gate 3: Self-Health Check (push) Failing after 49s
Platform: moko-platform CI / Gate 2: Unit Tests (8.3) (push) Successful in 56s
Platform: moko-platform CI / Gate 4: Governance (push) Successful in 51s
bin/moko list now auto-groups 45 commands into 12 categories with
color formatting. Also loads plugin commands dynamically via
getCommands() — when plugins define commands, they'll appear
automatically under "Plugin: <type>" groups.

Closes #104

Authored-by: Moko Consulting
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-25 21:46:58 -05:00
jmiller baafffb1be Merge pull request 'chore: cascade main → dev (44c6bcb) [skip ci]' (#113) from main into dev
chore: cascade main → dev [skip ci]
2026-05-26 02:39:06 +00:00
jmiller 1c930ca9bd Merge pull request 'feat: PHPUnit test infrastructure + 12 tests' (#114) from dev into main
Platform: moko-platform CI / CI Summary (push) Blocked by required conditions
Universal: Cascade Main → Dev / Cascade main → branches (push) Successful in 5s
Generic: Repo Health / Site Health (push) Has been skipped
Generic: Repo Health / Access control (push) Successful in 2s
Generic: Repo Health / Release configuration (push) Successful in 4s
Generic: Repo Health / Scripts governance (push) Successful in 4s
Generic: Repo Health / Repository health (push) Successful in 11s
Platform: moko-platform CI / Gate 1: Code Quality (push) Successful in 46s
Platform: moko-platform CI / Gate 5: Template Integrity (push) Failing after 6s
Platform: moko-platform CI / Gate 2: Unit Tests (8.2) (push) Successful in 54s
Platform: moko-platform CI / Gate 3: Self-Health Check (push) Failing after 52s
Platform: moko-platform CI / Gate 4: Governance (push) Successful in 53s
Platform: moko-platform CI / Gate 2: Unit Tests (8.1) (push) Successful in 58s
Platform: moko-platform CI / Gate 2: Unit Tests (8.3) (push) Successful in 1m0s
2026-05-26 02:39:00 +00:00
Jonathan Miller 3e37035786 feat: set up PHPUnit test infrastructure with 12 tests
Platform: moko-platform CI / CI Summary (push) Blocked by required conditions
Generic: Repo Health / Site Health (push) Has been skipped
Generic: Repo Health / Access control (push) Successful in 2s
Generic: Repo Health / Release configuration (push) Successful in 6s
Generic: Repo Health / Scripts governance (push) Successful in 6s
Generic: Repo Health / Repository health (push) Successful in 16s
Platform: moko-platform CI / CI Summary (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 2s
Generic: Repo Health / Access control (pull_request) Successful in 1s
Universal: PR Check / Validate PR (pull_request) Successful in 4s
Universal: Secret Scanning / Gitleaks Secret Scan (pull_request) Successful in 5s
Generic: Repo Health / Release configuration (pull_request) Successful in 5s
Generic: Repo Health / Scripts governance (pull_request) Successful in 6s
Universal: PR Check / Build RC Package (pull_request) Successful in 2s
Platform: moko-platform CI / Gate 1: Code Quality (push) Successful in 59s
Generic: Repo Health / Repository health (pull_request) Successful in 15s
Platform: moko-platform CI / Gate 1: Code Quality (pull_request) Successful in 50s
Platform: moko-platform CI / Gate 2: Unit Tests (8.1) (push) Successful in 50s
Platform: moko-platform CI / Gate 5: Template Integrity (push) Failing after 6s
Platform: moko-platform CI / Gate 2: Unit Tests (8.2) (push) Successful in 48s
Platform: moko-platform CI / Gate 3: Self-Health Check (push) Failing after 51s
Platform: moko-platform CI / Gate 2: Unit Tests (8.3) (push) Successful in 59s
Platform: moko-platform CI / Gate 4: Governance (push) Successful in 58s
Platform: moko-platform CI / Gate 5: Template Integrity (pull_request) Failing after 6s
Platform: moko-platform CI / Gate 2: Unit Tests (8.3) (pull_request) Successful in 1m0s
Platform: moko-platform CI / Gate 2: Unit Tests (8.1) (pull_request) Successful in 1m6s
Platform: moko-platform CI / Gate 2: Unit Tests (8.2) (pull_request) Successful in 1m5s
Platform: moko-platform CI / Gate 3: Self-Health Check (pull_request) Failing after 55s
Platform: moko-platform CI / Gate 4: Governance (pull_request) Successful in 54s
- phpunit.xml configuration
- VersionReadTest: 5 tests covering README, XML, suffix stripping,
  version comparison, and error cases
- VersionBumpTest: 7 tests covering patch/minor/major bumps, rollover,
  HTML comment format, suffixed XML, and error cases

All tests run CLI tools as subprocesses against temp fixtures.

Closes #102

Authored-by: Moko Consulting
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-25 21:38:02 -05:00
jmiller 5805358ef4 Merge pull request 'chore: cascade main → dev (44c6bcb) [skip ci]' (#112) from main into dev
chore: cascade main → dev [skip ci]
2026-05-26 02:29:41 +00:00
jmiller 44c6bcbc2d feat(cli): add client_health_check.php
Platform: moko-platform CI / CI Summary (push) Blocked by required conditions
Generic: Repo Health / Access control (push) Successful in 1s
Generic: Repo Health / Site Health (push) Has been skipped
Universal: Cascade Main → Dev / Cascade main → branches (push) Successful in 3s
Generic: Repo Health / Release configuration (push) Successful in 6s
Generic: Repo Health / Scripts governance (push) Successful in 6s
Generic: Repo Health / Repository health (push) Successful in 13s
Platform: moko-platform CI / Gate 1: Code Quality (push) Successful in 55s
Platform: moko-platform CI / Gate 5: Template Integrity (push) Failing after 7s
Platform: moko-platform CI / Gate 4: Governance (push) Successful in 47s
Platform: moko-platform CI / Gate 2: Unit Tests (8.2) (push) Failing after 51s
Platform: moko-platform CI / Gate 3: Self-Health Check (push) Failing after 51s
Platform: moko-platform CI / Gate 2: Unit Tests (8.1) (push) Failing after 54s
Platform: moko-platform CI / Gate 2: Unit Tests (8.3) (push) Failing after 57s
2026-05-26 02:29:37 +00:00
jmiller 78fcbdd4a9 feat(cli): add joomla_compat_check.php
Platform: moko-platform CI / CI Summary (push) Blocked by required conditions
Universal: Cascade Main → Dev / Cascade main → branches (push) Successful in 2s
Generic: Repo Health / Site Health (push) Has been skipped
Generic: Repo Health / Access control (push) Successful in 1s
Generic: Repo Health / Scripts governance (push) Successful in 7s
Generic: Repo Health / Release configuration (push) Successful in 7s
Generic: Repo Health / Repository health (push) Successful in 14s
Platform: moko-platform CI / Gate 1: Code Quality (push) Successful in 54s
Platform: moko-platform CI / Gate 5: Template Integrity (push) Failing after 5s
Platform: moko-platform CI / Gate 2: Unit Tests (8.1) (push) Failing after 43s
Platform: moko-platform CI / Gate 2: Unit Tests (8.2) (push) Failing after 49s
Platform: moko-platform CI / Gate 3: Self-Health Check (push) Failing after 50s
Platform: moko-platform CI / Gate 4: Governance (push) Successful in 50s
Platform: moko-platform CI / Gate 2: Unit Tests (8.3) (push) Failing after 56s
2026-05-26 02:29:36 +00:00
jmiller 4fd1acb68c feat(cli): add theme_lint.php
Platform: moko-platform CI / CI Summary (push) Blocked by required conditions
Generic: Repo Health / Site Health (push) Has been skipped
Generic: Repo Health / Access control (push) Successful in 1s
Universal: Cascade Main → Dev / Cascade main → branches (push) Successful in 3s
Generic: Repo Health / Release configuration (push) Successful in 4s
Generic: Repo Health / Scripts governance (push) Successful in 4s
Generic: Repo Health / Repository health (push) Successful in 13s
Platform: moko-platform CI / Gate 1: Code Quality (push) Successful in 55s
Platform: moko-platform CI / Gate 5: Template Integrity (push) Failing after 4s
Platform: moko-platform CI / Gate 2: Unit Tests (8.1) (push) Failing after 42s
Platform: moko-platform CI / Gate 3: Self-Health Check (push) Failing after 43s
Platform: moko-platform CI / Gate 2: Unit Tests (8.2) (push) Failing after 43s
Platform: moko-platform CI / Gate 4: Governance (push) Successful in 42s
Platform: moko-platform CI / Gate 2: Unit Tests (8.3) (push) Failing after 44s
2026-05-26 02:29:36 +00:00
jmiller 9f7599fdb1 Merge pull request 'chore: cascade main → dev (57a0b49) [skip ci]' (#111) from main into dev
chore: cascade main → dev [skip ci]
2026-05-26 02:25:24 +00:00
jmiller 57a0b491ea Merge pull request 'chore: update CLAUDE.md with current architecture' (#110) from dev into main
Generic: Repo Health / Site Health (push) Has been skipped
Generic: Repo Health / Access control (push) Successful in 1s
Universal: Cascade Main → Dev / Cascade main → branches (push) Successful in 3s
Generic: Repo Health / Release configuration (push) Successful in 4s
Generic: Repo Health / Scripts governance (push) Successful in 4s
Generic: Repo Health / Repository health (push) Successful in 9s
2026-05-26 02:25:19 +00:00
Jonathan Miller f76cd94c64 chore: update CLAUDE.md with current architecture
Generic: Repo Health / Site Health (push) Has been skipped
Generic: Repo Health / Access control (push) Successful in 3s
Platform: moko-platform CI / CI Summary (pull_request) Blocked by required conditions
Generic: Repo Health / Site Health (pull_request) Has been skipped
Generic: Repo Health / Scripts governance (push) Successful in 21s
Universal: PR Check / Branch Policy (pull_request) Successful in 2s
Generic: Repo Health / Access control (pull_request) Successful in 2s
Generic: Repo Health / Release configuration (push) Successful in 22s
Generic: Repo Health / Repository health (push) Successful in 23s
Universal: PR Check / Validate PR (pull_request) Successful in 5s
Universal: Secret Scanning / Gitleaks Secret Scan (pull_request) Successful in 5s
Generic: Repo Health / Release configuration (pull_request) Successful in 4s
Generic: Repo Health / Scripts governance (pull_request) Successful in 4s
Universal: PR Check / Build RC Package (pull_request) Successful in 1s
Generic: Repo Health / Repository health (pull_request) Successful in 11s
Platform: moko-platform CI / Gate 1: Code Quality (pull_request) Successful in 46s
Platform: moko-platform CI / Gate 2: Unit Tests (8.2) (pull_request) Failing after 47s
Platform: moko-platform CI / Gate 2: Unit Tests (8.1) (pull_request) Failing after 52s
Platform: moko-platform CI / Gate 2: Unit Tests (8.3) (pull_request) Failing after 53s
Platform: moko-platform CI / Gate 5: Template Integrity (pull_request) Failing after 9s
Platform: moko-platform CI / Gate 3: Self-Health Check (pull_request) Failing after 51s
Platform: moko-platform CI / Gate 4: Governance (pull_request) Successful in 51s
- Language: HCL → PHP 8.1+
- Added directory layout table (cli/, validate/, lib/, etc.)
- Added CliFramework pattern for new tools
- Added code quality section (PHPCS, PHPStan levels)
- Added common commands (bin/moko, phpcs, phpstan)
- Added rules for new CLI tools and COMMAND_MAP registration

Closes #103

Authored-by: Moko Consulting
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-25 21:24:43 -05:00
jmiller ca1c3e0dba Merge pull request 'chore: cascade main → dev (9ee50d0) [skip ci]' (#109) from main into dev
chore: cascade main → dev [skip ci]
2026-05-26 02:21:14 +00:00
jmiller 9ee50d0058 Merge pull request 'chore: migrate 7 CLIApp scripts to CliFramework' (#108) from dev into main
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 / Site Health (push) Has been skipped
Generic: Repo Health / Access control (push) Successful in 2s
Universal: Cascade Main → Dev / Cascade main → branches (push) Successful in 4s
Generic: Repo Health / Release configuration (push) Successful in 5s
Generic: Repo Health / Scripts governance (push) Successful in 5s
Generic: Repo Health / Repository health (push) Successful in 13s
Platform: moko-platform CI / Gate 1: Code Quality (push) Successful in 56s
2026-05-26 02:21:08 +00:00
Jonathan Miller bc67a53442 chore: migrate 7 CLIApp scripts to CliFramework + remove PHPStan excludes
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 / Site Health (push) Has been skipped
Generic: Repo Health / Access control (push) Successful in 1s
Generic: Repo Health / Release configuration (push) Successful in 5s
Generic: Repo Health / Scripts governance (push) Successful in 5s
Generic: Repo Health / Repository health (push) Successful in 12s
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 / 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 1s
Universal: Secret Scanning / Gitleaks Secret Scan (pull_request) Successful in 4s
Universal: PR Check / Validate PR (pull_request) Successful in 5s
Generic: Repo Health / Release configuration (pull_request) Successful in 5s
Generic: Repo Health / Scripts governance (pull_request) Successful in 5s
Universal: PR Check / Build RC Package (pull_request) Successful in 3s
Generic: Repo Health / Repository health (pull_request) Successful in 16s
Platform: moko-platform CI / Gate 1: Code Quality (push) Successful in 53s
Platform: moko-platform CI / Gate 1: Code Quality (pull_request) Successful in 58s
All 7 legacy CLIApp scripts migrated to CliFramework:
- validate/check_wiki_health.php
- validate/auto_detect_platform.php
- cli/joomla_release.php
- automation/push_files.php
- automation/bulk_sync.php
- automation/bulk_joomla_template.php
- automation/repo_cleanup.php

Migration: extends CLIApp -> CliFramework, setupArguments() -> configure(),
addOption() -> addArgument(), getOption() -> getArgument(), boot code updated.

Also: removed PHPStan exclusions, fixed ApiClient::delete() signature,
renamed conflicting log()/error() overrides.

PHPStan level 2: 0 errors, 0 files excluded.

Closes #101

Authored-by: Moko Consulting
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-25 21:20:30 -05:00
jmiller 147cf663a6 Merge pull request 'chore: cascade main → dev (e41d9b9) [skip ci]' (#107) from main into dev
chore: cascade main → dev [skip ci]
2026-05-26 01:37:40 +00:00
jmiller e41d9b9335 Merge pull request 'fix(critical): bin/moko COMMAND_MAP paths + add all CLI tools' (#106) from dev into main
Platform: moko-platform CI / CI Summary (push) Blocked by required conditions
Generic: Repo Health / Site Health (push) Has been skipped
Generic: Repo Health / Access control (push) Successful in 1s
Universal: Cascade Main → Dev / Cascade main → branches (push) Successful in 3s
Generic: Repo Health / Release configuration (push) Successful in 4s
Generic: Repo Health / Scripts governance (push) Successful in 4s
Generic: Repo Health / Repository health (push) Successful in 12s
Platform: moko-platform CI / Gate 1: Code Quality (push) Successful in 46s
Platform: moko-platform CI / Gate 5: Template Integrity (push) Failing after 4s
Platform: moko-platform CI / Gate 2: Unit Tests (8.1) (push) Failing after 37s
Platform: moko-platform CI / Gate 3: Self-Health Check (push) Failing after 37s
Platform: moko-platform CI / Gate 4: Governance (push) Successful in 38s
Platform: moko-platform CI / Gate 2: Unit Tests (8.2) (push) Failing after 39s
Platform: moko-platform CI / Gate 2: Unit Tests (8.3) (push) Failing after 42s
2026-05-26 01:37:36 +00:00
Jonathan Miller 5c5c5e9ff2 fix(critical): bin/moko COMMAND_MAP — remove api/ prefix, add all tools
Platform: moko-platform CI / CI Summary (push) Blocked by required conditions
Generic: Repo Health / Site Health (push) Has been skipped
Generic: Repo Health / Access control (push) Successful in 2s
Generic: Repo Health / Release configuration (push) Successful in 3s
Generic: Repo Health / Scripts governance (push) Successful in 3s
Generic: Repo Health / Repository health (push) Successful in 10s
Platform: moko-platform CI / Gate 1: Code Quality (push) Successful in 44s
Platform: moko-platform CI / CI Summary (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
Generic: Repo Health / Release configuration (pull_request) Successful in 5s
Generic: Repo Health / Scripts governance (pull_request) Successful in 4s
Universal: Secret Scanning / Gitleaks Secret Scan (pull_request) Successful in 7s
Universal: PR Check / Validate PR (pull_request) Successful in 7s
Universal: PR Check / Build RC Package (pull_request) Successful in 1s
Generic: Repo Health / Repository health (pull_request) Successful in 12s
Platform: moko-platform CI / Gate 1: Code Quality (pull_request) Successful in 49s
Platform: moko-platform CI / Gate 5: Template Integrity (pull_request) Failing after 4s
Platform: moko-platform CI / Gate 2: Unit Tests (8.2) (pull_request) Failing after 40s
Platform: moko-platform CI / Gate 3: Self-Health Check (pull_request) Failing after 40s
Platform: moko-platform CI / Gate 4: Governance (pull_request) Successful in 40s
Platform: moko-platform CI / Gate 2: Unit Tests (8.3) (pull_request) Failing after 41s
Platform: moko-platform CI / Gate 2: Unit Tests (8.1) (pull_request) Failing after 41s
Platform: moko-platform CI / Gate 5: Template Integrity (push) Failing after 4s
Platform: moko-platform CI / Gate 3: Self-Health Check (push) Failing after 39s
Platform: moko-platform CI / Gate 4: Governance (push) Successful in 40s
Platform: moko-platform CI / Gate 2: Unit Tests (8.2) (push) Failing after 40s
Platform: moko-platform CI / Gate 2: Unit Tests (8.1) (push) Failing after 40s
Platform: moko-platform CI / Gate 2: Unit Tests (8.3) (push) Failing after 42s
All paths pointed to non-existent api/ directory. Fixed to use actual
paths (automation/, validate/, cli/, maintenance/). Also added 20 missing
commands covering all CLI tools built this session.

Closes #100

Authored-by: Moko Consulting
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-25 20:36:23 -05:00
jmiller c53ab7e44c Merge pull request 'chore: cascade main → dev (1b0d5bd) [skip ci]' (#99) from main into dev
chore: cascade main → dev [skip ci]
2026-05-26 01:27:34 +00:00
Jonathan Miller 1b0d5bd2f3 fix: updates_xml_build uses pkg_ prefix in element tag for packages
Platform: moko-platform CI / CI Summary (push) Blocked by required conditions
Generic: Repo Health / Site Health (push) Has been skipped
Generic: Repo Health / Access control (push) Successful in 2s
Universal: Cascade Main → Dev / Cascade main → branches (push) Successful in 3s
Generic: Repo Health / Release configuration (push) Successful in 3s
Generic: Repo Health / Scripts governance (push) Successful in 4s
Generic: Repo Health / Repository health (push) Successful in 11s
Platform: moko-platform CI / Gate 1: Code Quality (push) Successful in 45s
Platform: moko-platform CI / Gate 5: Template Integrity (push) Failing after 5s
Platform: moko-platform CI / Gate 4: Governance (push) Successful in 42s
Platform: moko-platform CI / Gate 3: Self-Health Check (push) Failing after 42s
Platform: moko-platform CI / Gate 2: Unit Tests (8.2) (push) Failing after 45s
Platform: moko-platform CI / Gate 2: Unit Tests (8.3) (push) Failing after 45s
Platform: moko-platform CI / Gate 2: Unit Tests (8.1) (push) Failing after 46s
Joomla stores packages as pkg_elementname in #__extensions. The element
tag in updates.xml must match for the updater to find the extension.
The ZIP filename uses the same prefix (already correct), but the XML
element tag was using the stripped name.

Authored-by: Moko Consulting
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-25 20:27:28 -05:00
jmiller 7281f60ba0 Merge pull request 'chore: cascade main → dev (bfe3457) [skip ci]' (#98) from main into dev
chore: cascade main → dev [skip ci]
2026-05-26 00:57:23 +00:00
jmiller bfe345747d Merge pull request 'fix: package_build.php create output dir + correct package structure' (#97) from dev into main
Platform: moko-platform CI / CI Summary (push) Blocked by required conditions
Generic: Repo Health / Site Health (push) Has been skipped
Generic: Repo Health / Access control (push) Successful in 2s
Universal: Cascade Main → Dev / Cascade main → branches (push) Successful in 3s
Generic: Repo Health / Release configuration (push) Successful in 4s
Generic: Repo Health / Scripts governance (push) Successful in 4s
Generic: Repo Health / Repository health (push) Successful in 11s
Platform: moko-platform CI / Gate 1: Code Quality (push) Successful in 1m4s
Platform: moko-platform CI / Gate 5: Template Integrity (push) Failing after 7s
Platform: moko-platform CI / Gate 2: Unit Tests (8.1) (push) Failing after 47s
Platform: moko-platform CI / Gate 2: Unit Tests (8.2) (push) Failing after 44s
Platform: moko-platform CI / Gate 3: Self-Health Check (push) Failing after 35s
Platform: moko-platform CI / Gate 2: Unit Tests (8.3) (push) Failing after 41s
Platform: moko-platform CI / Gate 4: Governance (push) Successful in 35s
2026-05-26 00:57:16 +00:00
Jonathan Miller 31c4b86d6e fix: package_build.php create output directory if missing
Platform: moko-platform CI / CI Summary (push) Blocked by required conditions
Generic: Repo Health / Site Health (push) Has been skipped
Generic: Repo Health / Access control (push) Successful in 2s
Generic: Repo Health / Release configuration (push) Successful in 6s
Generic: Repo Health / Scripts governance (push) Successful in 6s
Generic: Repo Health / Repository health (push) Successful in 16s
Platform: moko-platform CI / CI Summary (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 1s
Universal: PR Check / Validate PR (pull_request) Successful in 5s
Universal: Secret Scanning / Gitleaks Secret Scan (pull_request) Successful in 6s
Generic: Repo Health / Release configuration (pull_request) Successful in 4s
Universal: PR Check / Build RC Package (pull_request) Successful in 2s
Generic: Repo Health / Scripts governance (pull_request) Successful in 7s
Platform: moko-platform CI / Gate 1: Code Quality (push) Successful in 56s
Generic: Repo Health / Repository health (pull_request) Successful in 15s
Platform: moko-platform CI / Gate 1: Code Quality (pull_request) Successful in 55s
Platform: moko-platform CI / Gate 5: Template Integrity (push) Failing after 4s
Platform: moko-platform CI / Gate 3: Self-Health Check (push) Failing after 54s
Platform: moko-platform CI / Gate 4: Governance (push) Successful in 56s
Platform: moko-platform CI / Gate 2: Unit Tests (8.2) (push) Failing after 1m2s
Platform: moko-platform CI / Gate 2: Unit Tests (8.1) (push) Failing after 1m2s
Platform: moko-platform CI / Gate 2: Unit Tests (8.3) (push) Failing after 1m6s
Platform: moko-platform CI / Gate 5: Template Integrity (pull_request) Failing after 6s
Platform: moko-platform CI / Gate 3: Self-Health Check (pull_request) Failing after 1m2s
Platform: moko-platform CI / Gate 4: Governance (pull_request) Successful in 1m6s
Platform: moko-platform CI / Gate 2: Unit Tests (8.2) (pull_request) Failing after 1m8s
Platform: moko-platform CI / Gate 2: Unit Tests (8.1) (pull_request) Failing after 1m12s
Platform: moko-platform CI / Gate 2: Unit Tests (8.3) (pull_request) Failing after 1m11s
ZipArchive::close() fails when the output directory doesn't exist.
Now creates it with mkdir -p equivalent before writing.

Authored-by: Moko Consulting
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-25 19:56:20 -05:00
jmiller b5bad37afc Merge pull request 'chore: cascade main → dev (ea2dcd7) [skip ci]' (#96) from main into dev
chore: cascade main → dev [skip ci]
2026-05-26 00:39:30 +00:00
jmiller ea2dcd7d96 Merge pull request 'fix: package_build.php Joomla package builds + PHPStan level 2' (#95) from dev into main
Platform: moko-platform CI / CI Summary (push) Blocked by required conditions
Universal: Cascade Main → Dev / Cascade main → branches (push) Successful in 4s
Generic: Repo Health / Access control (push) Successful in 2s
Generic: Repo Health / Site Health (push) Has been skipped
Generic: Repo Health / Release configuration (push) Successful in 5s
Generic: Repo Health / Scripts governance (push) Successful in 5s
Generic: Repo Health / Repository health (push) Successful in 12s
Platform: moko-platform CI / Gate 1: Code Quality (push) Successful in 1m2s
Platform: moko-platform CI / Gate 5: Template Integrity (push) Failing after 4s
Platform: moko-platform CI / Gate 4: Governance (push) Successful in 38s
Platform: moko-platform CI / Gate 3: Self-Health Check (push) Failing after 39s
Platform: moko-platform CI / Gate 2: Unit Tests (8.1) (push) Failing after 40s
Platform: moko-platform CI / Gate 2: Unit Tests (8.2) (push) Failing after 40s
Platform: moko-platform CI / Gate 2: Unit Tests (8.3) (push) Failing after 41s
2026-05-26 00:39:24 +00:00
Jonathan Miller 989e84c44c fix: package_build.php — correct Joomla package extension builds
Platform: moko-platform CI / CI Summary (push) Blocked by required conditions
Generic: Repo Health / Site Health (push) Has been skipped
Generic: Repo Health / Access control (push) Successful in 1s
Generic: Repo Health / Release configuration (push) Successful in 4s
Generic: Repo Health / Scripts governance (push) Successful in 4s
Generic: Repo Health / Repository health (push) Successful in 12s
Platform: moko-platform CI / Gate 1: Code Quality (push) Successful in 48s
Platform: moko-platform CI / CI Summary (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 1s
Universal: Secret Scanning / Gitleaks Secret Scan (pull_request) Successful in 5s
Universal: PR Check / Validate PR (pull_request) Successful in 6s
Generic: Repo Health / Scripts governance (pull_request) Successful in 7s
Generic: Repo Health / Release configuration (pull_request) Successful in 7s
Universal: PR Check / Build RC Package (pull_request) Successful in 2s
Generic: Repo Health / Repository health (pull_request) Successful in 15s
Platform: moko-platform CI / Gate 1: Code Quality (pull_request) Successful in 1m7s
Platform: moko-platform CI / Gate 5: Template Integrity (push) Failing after 4s
Platform: moko-platform CI / Gate 2: Unit Tests (8.1) (push) Failing after 38s
Platform: moko-platform CI / Gate 2: Unit Tests (8.2) (push) Failing after 39s
Platform: moko-platform CI / Gate 3: Self-Health Check (push) Failing after 39s
Platform: moko-platform CI / Gate 4: Governance (push) Successful in 37s
Platform: moko-platform CI / Gate 2: Unit Tests (8.3) (push) Failing after 40s
Platform: moko-platform CI / Gate 5: Template Integrity (pull_request) Failing after 4s
Platform: moko-platform CI / Gate 4: Governance (pull_request) Successful in 37s
Platform: moko-platform CI / Gate 3: Self-Health Check (pull_request) Failing after 38s
Platform: moko-platform CI / Gate 2: Unit Tests (8.1) (pull_request) Failing after 39s
Platform: moko-platform CI / Gate 2: Unit Tests (8.2) (pull_request) Failing after 39s
Platform: moko-platform CI / Gate 2: Unit Tests (8.3) (pull_request) Failing after 41s
Two bugs fixed:
1. Double prefix: pkg_pkg_mokogallery → pkg_mokogallery (strip prefix
   from element name when it already matches the type prefix)
2. Package structure: sub-extension ZIPs now placed in packages/ subdir
   (was putting them in root), language/ directory now included

Correct ZIP structure:
  pkg_mokogallery-XX.XX.XX.zip
    pkg_mokogallery.xml
    script.php
    language/
    packages/
      com_mokogallery.zip
      mod_mokogallery.zip
      plg_*.zip

Closes #92

Authored-by: Moko Consulting
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-25 19:35:08 -05:00
jmiller 2cfc0a61e9 Merge pull request 'chore: cascade main → dev (11bd5e8) [skip ci]' (#94) from main into dev
chore: cascade main → dev [skip ci]
2026-05-26 00:32:42 +00:00
jmiller 11bd5e8f7f Merge pull request 'fix: PHPStan level 0 to 2 + 67 type errors fixed' (#93) from dev into main
Platform: moko-platform CI / CI Summary (push) Blocked by required conditions
Universal: Cascade Main → Dev / Cascade main → branches (push) Successful in 3s
Generic: Repo Health / Site Health (push) Has been skipped
Generic: Repo Health / Access control (push) Successful in 3s
Generic: Repo Health / Release configuration (push) Successful in 9s
Generic: Repo Health / Scripts governance (push) Successful in 9s
Generic: Repo Health / Repository health (push) Successful in 16s
Platform: moko-platform CI / Gate 1: Code Quality (push) Successful in 1m5s
Platform: moko-platform CI / Gate 5: Template Integrity (push) Failing after 4s
Platform: moko-platform CI / Gate 2: Unit Tests (8.1) (push) Failing after 34s
Platform: moko-platform CI / Gate 4: Governance (push) Successful in 36s
Platform: moko-platform CI / Gate 2: Unit Tests (8.2) (push) Failing after 37s
Platform: moko-platform CI / Gate 3: Self-Health Check (push) Failing after 38s
Platform: moko-platform CI / Gate 2: Unit Tests (8.3) (push) Failing after 39s
2026-05-26 00:32:29 +00:00
Jonathan Miller cbfa23c4c4 fix: PHPStan level 0 → 2 — fix 67 type errors across 18 files
Platform: moko-platform CI / CI Summary (push) Blocked by required conditions
Generic: Repo Health / Site Health (push) Has been skipped
Generic: Repo Health / Access control (push) Successful in 1s
Generic: Repo Health / Scripts governance (push) Successful in 5s
Generic: Repo Health / Release configuration (push) Successful in 5s
Generic: Repo Health / Repository health (push) Successful in 12s
Platform: moko-platform CI / Gate 1: Code Quality (push) Successful in 45s
Platform: moko-platform CI / CI Summary (pull_request) Blocked by required conditions
Universal: PR Check / Branch Policy (pull_request) Successful in 1s
Universal: Secret Scanning / Gitleaks Secret Scan (pull_request) Successful in 4s
Universal: PR Check / Validate PR (pull_request) Successful in 5s
Universal: PR Check / Build RC Package (pull_request) Successful in 2s
Platform: moko-platform CI / Gate 2: Unit Tests (8.2) (push) Failing after 44s
Platform: moko-platform CI / Gate 3: Self-Health Check (push) Failing after 48s
Platform: moko-platform CI / Gate 2: Unit Tests (8.1) (push) Failing after 48s
Platform: moko-platform CI / Gate 4: Governance (push) Successful in 48s
Platform: moko-platform CI / Gate 2: Unit Tests (8.3) (push) Failing after 50s
Platform: moko-platform CI / Gate 5: Template Integrity (push) Failing after 12s
Platform: moko-platform CI / Gate 1: Code Quality (pull_request) Successful in 1m13s
Platform: moko-platform CI / Gate 5: Template Integrity (pull_request) Failing after 5s
Platform: moko-platform CI / Gate 3: Self-Health Check (pull_request) Failing after 42s
Platform: moko-platform CI / Gate 2: Unit Tests (8.2) (pull_request) Failing after 45s
Platform: moko-platform CI / Gate 4: Governance (pull_request) Successful in 44s
Platform: moko-platform CI / Gate 2: Unit Tests (8.1) (pull_request) Failing after 47s
Platform: moko-platform CI / Gate 2: Unit Tests (8.3) (pull_request) Failing after 49s
Real bugs found and fixed:
- bulk_joomla_template: $org undefined in heredoc (missing parameter)
- RepositorySynchronizer: $root undefined (should be $repoRoot), duplicate array key
- RepositoryHealthChecker: wrong class name (UnifiedValidation → UnifiedValidator)
- scan_drift: missing $adapter property declaration
- auto_detect_platform: wrong method name (detectProjectType → detect)
- EnterpriseReadinessValidator: void return used as value
- check_client_theme: extra parameter to printSummary()
- ApiClient: unused constructor parameter now stored
- GitPlatformAdapter: added listBranches/getCloneUrl/cloneRepo to interface
- MokoGiteaAdapter/GitHubAdapter: implemented new interface methods

3 legacy CLIApp scripts excluded (need migration to CliFramework):
  repo_cleanup.php, push_files.php, joomla_release.php

Authored-by: Moko Consulting
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-25 19:29:52 -05:00
jmiller e1104eeebc Merge pull request 'chore: cascade main → dev (968f85f) [skip ci]' (#91) from main into dev
chore: cascade main → dev [skip ci]
2026-05-26 00:04:46 +00:00
jmiller 968f85f622 Merge pull request 'feat: client dashboard + fix release cascade for RC' (#90) from dev into main
Platform: moko-platform CI / CI Summary (push) Blocked by required conditions
Generic: Repo Health / Site Health (push) Has been skipped
Generic: Repo Health / Access control (push) Successful in 2s
Universal: Cascade Main → Dev / Cascade main → branches (push) Successful in 4s
Generic: Repo Health / Release configuration (push) Successful in 4s
Generic: Repo Health / Scripts governance (push) Successful in 4s
Generic: Repo Health / Repository health (push) Successful in 10s
Platform: moko-platform CI / Gate 1: Code Quality (push) Successful in 51s
Platform: moko-platform CI / Gate 5: Template Integrity (push) Failing after 5s
Platform: moko-platform CI / Gate 3: Self-Health Check (push) Failing after 43s
Platform: moko-platform CI / Gate 2: Unit Tests (8.1) (push) Failing after 46s
Platform: moko-platform CI / Gate 4: Governance (push) Successful in 48s
Platform: moko-platform CI / Gate 2: Unit Tests (8.2) (push) Failing after 50s
Platform: moko-platform CI / Gate 2: Unit Tests (8.3) (push) Failing after 52s
2026-05-26 00:04:39 +00:00
Jonathan Miller 5f7e6a9b1a feat: add client_dashboard.php + fix release_cascade for RC
Platform: moko-platform CI / CI Summary (push) Blocked by required conditions
Generic: Repo Health / Site Health (push) Has been skipped
Generic: Repo Health / Access control (push) Successful in 1s
Generic: Repo Health / Release configuration (push) Successful in 3s
Generic: Repo Health / Scripts governance (push) Successful in 3s
Generic: Repo Health / Repository health (push) Successful in 11s
Platform: moko-platform CI / CI Summary (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 2s
Generic: Repo Health / Access control (pull_request) Successful in 2s
Universal: Secret Scanning / Gitleaks Secret Scan (pull_request) Successful in 5s
Universal: PR Check / Validate PR (pull_request) Successful in 5s
Universal: PR Check / Build RC Package (pull_request) Successful in 1s
Generic: Repo Health / Release configuration (pull_request) Successful in 5s
Platform: moko-platform CI / Gate 1: Code Quality (push) Successful in 50s
Generic: Repo Health / Scripts governance (pull_request) Successful in 5s
Generic: Repo Health / Repository health (pull_request) Successful in 14s
Platform: moko-platform CI / Gate 1: Code Quality (pull_request) Successful in 54s
Platform: moko-platform CI / Gate 5: Template Integrity (pull_request) Failing after 4s
Platform: moko-platform CI / Gate 2: Unit Tests (8.1) (pull_request) Failing after 45s
Platform: moko-platform CI / Gate 3: Self-Health Check (pull_request) Failing after 47s
Platform: moko-platform CI / Gate 2: Unit Tests (8.2) (pull_request) Failing after 49s
Platform: moko-platform CI / Gate 4: Governance (pull_request) Successful in 53s
Platform: moko-platform CI / Gate 2: Unit Tests (8.3) (pull_request) Failing after 55s
Platform: moko-platform CI / Gate 5: Template Integrity (push) Failing after 6s
Platform: moko-platform CI / Gate 2: Unit Tests (8.1) (push) Failing after 53s
Platform: moko-platform CI / Gate 2: Unit Tests (8.2) (push) Failing after 54s
Platform: moko-platform CI / Gate 3: Self-Health Check (push) Failing after 55s
Platform: moko-platform CI / Gate 2: Unit Tests (8.3) (push) Failing after 1m12s
Platform: moko-platform CI / Gate 4: Governance (push) Successful in 46s
- client_dashboard.php: generates self-contained HTML dashboard showing
  all client-waas repos with uptime, SSL expiry, release status, and
  infrastructure config status. Addresses #3.

- release_cascade.php: accept "release-candidate" as stability value.
  Previously only "rc" was mapped, so cascade silently skipped when
  the pre-release workflow passed "release-candidate".

Authored-by: Moko Consulting
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-25 19:03:48 -05:00
jmiller ded6563d2e Merge pull request 'chore: cascade main → dev (5b7817f) [skip ci]' (#89) from main into dev
chore: cascade main → dev [skip ci]
2026-05-25 23:51:47 +00:00
jmiller 5b7817f104 Merge pull request 'feat: add cli/client_provision.php - end-to-end client onboarding' (#88) from dev into main
Platform: moko-platform CI / CI Summary (push) Blocked by required conditions
Generic: Repo Health / Site Health (push) Has been skipped
Generic: Repo Health / Access control (push) Successful in 2s
Universal: Cascade Main → Dev / Cascade main → branches (push) Successful in 5s
Generic: Repo Health / Release configuration (push) Successful in 4s
Generic: Repo Health / Scripts governance (push) Successful in 5s
Generic: Repo Health / Repository health (push) Successful in 28s
Platform: moko-platform CI / Gate 1: Code Quality (push) Successful in 51s
Platform: moko-platform CI / Gate 5: Template Integrity (push) Failing after 3s
Platform: moko-platform CI / Gate 2: Unit Tests (8.1) (push) Failing after 36s
Platform: moko-platform CI / Gate 3: Self-Health Check (push) Failing after 37s
Platform: moko-platform CI / Gate 4: Governance (push) Successful in 38s
Platform: moko-platform CI / Gate 2: Unit Tests (8.2) (push) Failing after 39s
Platform: moko-platform CI / Gate 2: Unit Tests (8.3) (push) Failing after 40s
2026-05-25 23:51:40 +00:00
Jonathan Miller fb916e857e feat: add cli/client_provision.php — end-to-end client onboarding
Platform: moko-platform CI / CI Summary (push) Blocked by required conditions
Generic: Repo Health / Site Health (push) Has been skipped
Generic: Repo Health / Access control (push) Successful in 2s
Generic: Repo Health / Release configuration (push) Successful in 6s
Generic: Repo Health / Scripts governance (push) Successful in 7s
Platform: moko-platform CI / CI Summary (pull_request) Blocked by required conditions
Generic: Repo Health / Repository health (push) Successful in 15s
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 5s
Universal: PR Check / Validate PR (pull_request) Successful in 4s
Generic: Repo Health / Release configuration (pull_request) Successful in 6s
Generic: Repo Health / Scripts governance (pull_request) Successful in 7s
Universal: PR Check / Build RC Package (pull_request) Successful in 4s
Generic: Repo Health / Repository health (pull_request) Successful in 14s
Platform: moko-platform CI / Gate 1: Code Quality (push) Successful in 1m1s
Platform: moko-platform CI / Gate 1: Code Quality (pull_request) Successful in 53s
Platform: moko-platform CI / Gate 2: Unit Tests (8.1) (push) Failing after 35s
Platform: moko-platform CI / Gate 5: Template Integrity (pull_request) Failing after 4s
Platform: moko-platform CI / Gate 2: Unit Tests (8.2) (pull_request) Failing after 32s
Platform: moko-platform CI / Gate 3: Self-Health Check (pull_request) Failing after 36s
Platform: moko-platform CI / Gate 4: Governance (pull_request) Successful in 36s
Platform: moko-platform CI / Gate 2: Unit Tests (8.1) (pull_request) Failing after 38s
Platform: moko-platform CI / Gate 2: Unit Tests (8.3) (pull_request) Failing after 39s
Platform: moko-platform CI / Gate 5: Template Integrity (push) Failing after 5s
Platform: moko-platform CI / Gate 3: Self-Health Check (push) Failing after 43s
Platform: moko-platform CI / Gate 4: Governance (push) Successful in 41s
Platform: moko-platform CI / Gate 2: Unit Tests (8.2) (push) Failing after 44s
Platform: moko-platform CI / Gate 2: Unit Tests (8.3) (push) Failing after 49s
Orchestrates the full client provisioning flow from a single JSON config:
  1. Create repo from Template-Client-WaaS
  2. Inject Gitea Actions variables (hosts, paths, ports)
  3. Inject Gitea Actions secrets (SSH keys via @file references)
  4. Push Grafana monitoring dashboard
  5. Register monitoring URLs/domains

Supports --dry-run for preview, --step for running individual steps.
Includes example config at templates/client-provision-example.json.

Addresses #4

Authored-by: Moko Consulting
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-25 18:51:06 -05:00
jmiller 81ced97bd6 Merge pull request 'chore: cascade main → dev (79d3907) [skip ci]' (#87) from main into dev
chore: cascade main → dev [skip ci]
2026-05-25 23:19:58 +00:00
jmiller 79d3907004 Merge pull request 'chore(release): bump to 06.00.00' (#86) from dev into main
Platform: moko-platform CI / CI Summary (push) Blocked by required conditions
Generic: Repo Health / Site Health (push) Has been skipped
Generic: Repo Health / Access control (push) Successful in 2s
Universal: Cascade Main → Dev / Cascade main → branches (push) Successful in 4s
Generic: Repo Health / Release configuration (push) Successful in 5s
Generic: Repo Health / Scripts governance (push) Successful in 6s
Generic: Repo Health / Repository health (push) Successful in 13s
Platform: moko-platform CI / Gate 1: Code Quality (push) Successful in 1m3s
Platform: moko-platform CI / Gate 5: Template Integrity (push) Failing after 5s
Platform: moko-platform CI / Gate 2: Unit Tests (8.1) (push) Failing after 32s
Platform: moko-platform CI / Gate 2: Unit Tests (8.2) (push) Failing after 35s
Platform: moko-platform CI / Gate 3: Self-Health Check (push) Failing after 36s
Platform: moko-platform CI / Gate 4: Governance (push) Successful in 36s
Platform: moko-platform CI / Gate 2: Unit Tests (8.3) (push) Failing after 39s
2026-05-25 23:19:47 +00:00
jmiller 5e8773a2c6 Merge branch 'main' into dev
Platform: moko-platform CI / CI Summary (pull_request) Blocked by required conditions
Generic: Repo Health / Site Health (push) Has been skipped
Generic: Repo Health / Access control (push) Successful in 2s
Universal: PR Check / Branch Policy (pull_request) Successful in 2s
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 5s
Universal: PR Check / Validate PR (pull_request) Successful in 6s
Generic: Repo Health / Release configuration (push) Successful in 5s
Universal: PR Check / Build RC Package (pull_request) Successful in 2s
Generic: Repo Health / Release configuration (pull_request) Successful in 5s
Generic: Repo Health / Scripts governance (pull_request) Successful in 5s
Universal: Security Audit / Dependency Audit (pull_request) Successful in 14s
Generic: Repo Health / Scripts governance (push) Successful in 14s
Generic: Repo Health / Repository health (push) Successful in 15s
Generic: Repo Health / Repository health (pull_request) Successful in 14s
Platform: moko-platform CI / Gate 1: Code Quality (pull_request) Successful in 58s
Platform: moko-platform CI / Gate 5: Template Integrity (pull_request) Failing after 7s
Platform: moko-platform CI / Gate 2: Unit Tests (8.1) (pull_request) Failing after 44s
Platform: moko-platform CI / Gate 2: Unit Tests (8.2) (pull_request) Failing after 43s
Platform: moko-platform CI / Gate 2: Unit Tests (8.3) (pull_request) Failing after 38s
Platform: moko-platform CI / Gate 3: Self-Health Check (pull_request) Failing after 34s
Platform: moko-platform CI / Gate 4: Governance (pull_request) Successful in 33s
2026-05-25 23:19:43 +00:00
Jonathan Miller 9a99bffc6b chore(release): bump to 06.00.00 — promote changelog
Platform: moko-platform CI / CI Summary (push) Blocked by required conditions
Generic: Repo Health / Site Health (push) Has been skipped
Generic: Repo Health / Access control (push) Successful in 1s
Generic: Repo Health / Release configuration (push) Successful in 4s
Generic: Repo Health / Scripts governance (push) Successful in 4s
Generic: Repo Health / Repository health (push) Successful in 11s
Platform: moko-platform CI / CI Summary (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 2s
Generic: Repo Health / Access control (pull_request) Successful in 1s
Universal: Secret Scanning / Gitleaks Secret Scan (pull_request) Successful in 5s
Universal: PR Check / Validate PR (pull_request) Successful in 5s
Universal: Security Audit / Dependency Audit (pull_request) Successful in 5s
Generic: Repo Health / Release configuration (pull_request) Successful in 4s
Generic: Repo Health / Scripts governance (pull_request) Successful in 5s
Universal: PR Check / Build RC Package (pull_request) Successful in 2s
Generic: Repo Health / Repository health (pull_request) Successful in 15s
Platform: moko-platform CI / Gate 1: Code Quality (push) Successful in 1m0s
Platform: moko-platform CI / Gate 1: Code Quality (pull_request) Successful in 59s
Platform: moko-platform CI / Gate 5: Template Integrity (push) Failing after 5s
Platform: moko-platform CI / Gate 5: Template Integrity (pull_request) Failing after 5s
Platform: moko-platform CI / Gate 2: Unit Tests (8.1) (push) Failing after 48s
Platform: moko-platform CI / Gate 3: Self-Health Check (push) Failing after 51s
Platform: moko-platform CI / Gate 4: Governance (push) Successful in 53s
Platform: moko-platform CI / Gate 2: Unit Tests (8.2) (push) Failing after 56s
Platform: moko-platform CI / Gate 2: Unit Tests (8.3) (push) Failing after 59s
Platform: moko-platform CI / Gate 3: Self-Health Check (pull_request) Failing after 47s
Platform: moko-platform CI / Gate 2: Unit Tests (8.2) (pull_request) Failing after 48s
Platform: moko-platform CI / Gate 2: Unit Tests (8.1) (pull_request) Failing after 49s
Platform: moko-platform CI / Gate 4: Governance (pull_request) Successful in 49s
Platform: moko-platform CI / Gate 2: Unit Tests (8.3) (pull_request) Failing after 50s
Major version bump for:
- 2 new CLI tools (bulk_workflow_push, grafana_dashboard)
- Pre-release RC build fix (version_read/bump regex)
- Full PHPCS cleanup (7,539 → 0 errors)
- CI infrastructure fixes (runners, PHPStan, branch protection)
- Runner-03 Docker image fix + runbook update
- Wiki CLI reference (30 tools documented)

Authored-by: Moko Consulting
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-25 18:19:16 -05:00
jmiller bffb8c3f94 Merge pull request 'feat: add cli/grafana_dashboard.php' (#85) from dev into main
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 / CI Summary (push) Blocked by required conditions
Universal: Cascade Main → Dev / Cascade main → branches (push) Failing after 0s
Platform: moko-platform CI / Gate 1: Code Quality (push) Failing after 0s
Generic: Repo Health / Site Health (push) Has been skipped
Generic: Repo Health / Access control (push) Failing after 0s
Generic: Repo Health / Release configuration (push) Has been skipped
Generic: Repo Health / Scripts governance (push) Has been skipped
Generic: Repo Health / Repository health (push) Has been skipped
Platform: moko-platform CI / Gate 5: Template Integrity (push) Has been skipped
2026-05-25 22:14:42 +00:00
Jonathan Miller bb0ee435e8 feat: add cli/grafana_dashboard.php for Grafana dashboard management
Platform: moko-platform CI / CI Summary (push) Blocked by required conditions
Generic: Repo Health / Site Health (push) Has been skipped
Generic: Repo Health / Access control (push) Successful in 1s
Generic: Repo Health / Release configuration (push) Successful in 4s
Generic: Repo Health / Scripts governance (push) Successful in 4s
Generic: Repo Health / Repository health (push) Successful in 12s
Platform: moko-platform CI / Gate 1: Code Quality (push) Successful in 45s
Platform: moko-platform CI / Gate 2: Unit Tests (8.2) (push) Failing after 0s
Platform: moko-platform CI / Gate 2: Unit Tests (8.1) (push) Failing after 0s
Platform: moko-platform CI / Gate 3: Self-Health Check (push) Failing after 1s
Platform: moko-platform CI / Gate 4: Governance (push) Failing after 0s
Platform: moko-platform CI / Gate 5: Template Integrity (push) Failing after 0s
Platform: moko-platform CI / Gate 2: Unit Tests (8.3) (push) Failing after 31s
Platform: moko-platform CI / CI Summary (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 1s
Universal: Secret Scanning / Gitleaks Secret Scan (pull_request) Successful in 4s
Universal: PR Check / Validate PR (pull_request) Successful in 4s
Generic: Repo Health / Release configuration (pull_request) Successful in 4s
Universal: PR Check / Build RC Package (pull_request) Successful in 1s
Generic: Repo Health / Scripts governance (pull_request) Successful in 4s
Generic: Repo Health / Repository health (pull_request) Successful in 11s
Platform: moko-platform CI / Gate 1: Code Quality (pull_request) Successful in 44s
Platform: moko-platform CI / Gate 5: Template Integrity (pull_request) Failing after 5s
Platform: moko-platform CI / Gate 2: Unit Tests (8.1) (pull_request) Failing after 39s
Platform: moko-platform CI / Gate 2: Unit Tests (8.2) (pull_request) Failing after 45s
Platform: moko-platform CI / Gate 3: Self-Health Check (pull_request) Failing after 47s
Platform: moko-platform CI / Gate 4: Governance (pull_request) Successful in 48s
Platform: moko-platform CI / Gate 2: Unit Tests (8.3) (pull_request) Failing after 51s
Four commands for dashboard lifecycle management via the Grafana API:
- push: create/update dashboard from JSON file (with folder support)
- delete: remove dashboard by UID
- list: list dashboards (optionally filtered by folder)
- export: download dashboard JSON by UID

Supports GRAFANA_URL and GRAFANA_TOKEN env vars for CI integration.

Closes #53

Authored-by: Moko Consulting
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-25 16:40:04 -05:00
jmiller c2804de1d7 Merge pull request 'chore: cascade main → dev (251c197) [skip ci]' (#84) from main into dev
chore: cascade main → dev [skip ci]
2026-05-25 05:16:23 +00:00
jmiller 251c1970f9 Merge pull request 'fix: version_read/bump handle suffixed versions and HTML-comment VERSION' (#83) from dev into main
Generic: Repo Health / Site Health (push) Has been skipped
Generic: Repo Health / Access control (push) Successful in 1s
Universal: Cascade Main → Dev / Cascade main → branches (push) Successful in 2s
Generic: Repo Health / Release configuration (push) Successful in 4s
Generic: Repo Health / Scripts governance (push) Successful in 4s
Generic: Repo Health / Repository health (push) Successful in 9s
2026-05-25 05:16:21 +00:00
jmiller 1b9ede4750 Merge branch 'main' into dev
Generic: Repo Health / Site Health (push) Has been skipped
Generic: Repo Health / Access control (push) Successful in 1s
Universal: PR Check / Branch Policy (pull_request) Failing after 1s
Universal: PR Check / Validate PR (pull_request) Failing after 1s
Universal: PR Check / Build RC Package (pull_request) Has been skipped
Generic: Repo Health / Site Health (pull_request) Has been skipped
Generic: Repo Health / Access control (pull_request) Successful in 1s
Generic: Repo Health / Release configuration (pull_request) Failing after 1s
Universal: Secret Scanning / Gitleaks Secret Scan (pull_request) Successful in 4s
Generic: Repo Health / Scripts governance (pull_request) Failing after 1s
Generic: Repo Health / Release configuration (push) Successful in 4s
Generic: Repo Health / Scripts governance (push) Successful in 4s
Generic: Repo Health / Repository health (push) Successful in 12s
Generic: Repo Health / Repository health (pull_request) Successful in 11s
Platform: moko-platform CI / Gate 1: Code Quality (push) Successful in 49s
Platform: moko-platform CI / Gate 1: Code Quality (pull_request) Successful in 49s
Platform: moko-platform CI / Gate 2: Unit Tests (8.3) (push) Failing after 0s
Platform: moko-platform CI / Gate 3: Self-Health Check (push) Failing after 0s
Platform: moko-platform CI / Gate 4: Governance (push) Failing after 1s
Platform: moko-platform CI / Gate 5: Template Integrity (push) Failing after 4s
Platform: moko-platform CI / Gate 2: Unit Tests (8.1) (push) Failing after 30s
Platform: moko-platform CI / Gate 2: Unit Tests (8.2) (push) Failing after 33s
Platform: moko-platform CI / Gate 2: Unit Tests (8.1) (pull_request) Failing after 0s
Platform: moko-platform CI / Gate 2: Unit Tests (8.2) (pull_request) Failing after 0s
Platform: moko-platform CI / Gate 2: Unit Tests (8.3) (pull_request) Failing after 0s
Platform: moko-platform CI / Gate 3: Self-Health Check (pull_request) Failing after 1s
Platform: moko-platform CI / Gate 4: Governance (pull_request) Failing after 0s
Platform: moko-platform CI / Gate 5: Template Integrity (pull_request) Failing after 0s
Platform: moko-platform CI / CI Summary (push) Has been cancelled
Platform: moko-platform CI / CI Summary (pull_request) Has been cancelled
2026-05-25 05:15:17 +00:00
Jonathan Miller bc47944d8f fix: version_read/bump handle suffixed versions and HTML-comment VERSION
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 1: Code Quality (push) Failing after 0s
Generic: Repo Health / Access control (push) Failing after 0s
Generic: Repo Health / Site Health (push) Has been skipped
Generic: Repo Health / Release configuration (push) Has been skipped
Generic: Repo Health / Scripts governance (push) Has been skipped
Generic: Repo Health / Repository health (push) Has been skipped
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 1: Code Quality (pull_request) Failing after 0s
Universal: Secret Scanning / Gitleaks Secret Scan (pull_request) Failing after 0s
Generic: Repo Health / Site Health (pull_request) Has been skipped
Generic: Repo Health / Access control (pull_request) Failing after 0s
Universal: PR Check / Branch Policy (pull_request) Successful in 1s
Generic: Repo Health / Release configuration (pull_request) Has been skipped
Generic: Repo Health / Scripts governance (pull_request) Has been skipped
Generic: Repo Health / Repository health (pull_request) Has been skipped
Universal: PR Check / Validate PR (pull_request) Successful in 4s
Universal: PR Check / Build RC Package (pull_request) Failing after 0s
Platform: moko-platform CI / Gate 5: Template Integrity (push) Has been skipped
Platform: moko-platform CI / Gate 5: Template Integrity (pull_request) Has been skipped
Platform: moko-platform CI / CI Summary (push) Has been cancelled
Platform: moko-platform CI / CI Summary (pull_request) Has been cancelled
Two bugs caused release-candidate pre-release builds to fail after a
development pre-release had been built:

1. XML regex only matched bare XX.YY.ZZ but not XX.YY.ZZ-dev/-rc etc.
   After a development pre-release wrote -dev suffix to manifests, the
   next pre-release could not read the version back.

2. README regex required VERSION: at line start (^\s*VERSION:) which
   does not match <!-- VERSION: 01.00.00 --> format used by some repos.

Fixes: version_read.php, version_bump.php

Authored-by: Moko Consulting
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-25 00:13:50 -05:00
jmiller bed5bb46df feat(ci): add issue-branch.yml [skip ci] 2026-05-25 05:12:34 +00:00
jmiller 5f6fb9ec64 Merge pull request 'chore: cascade main → dev (bed73b0) [skip ci]' (#82) from main into dev
chore: cascade main → dev [skip ci]
2026-05-25 04:24:39 +00:00
jmiller bed73b083a Merge pull request 'feat: add cli/bulk_workflow_push.php + CI fixes' (#81) from dev into main
Generic: Repo Health / Site Health (push) Has been skipped
Generic: Repo Health / Access control (push) Successful in 1s
Universal: Cascade Main → Dev / Cascade main → branches (push) Successful in 2s
Generic: Repo Health / Release configuration (push) Failing after 0s
Generic: Repo Health / Scripts governance (push) Failing after 0s
Generic: Repo Health / Repository health (push) Failing after 0s
Platform: moko-platform CI / Gate 1: Code Quality (push) Successful in 41s
Platform: moko-platform CI / Gate 4: Governance (push) Failing after 1s
Platform: moko-platform CI / Gate 3: Self-Health Check (push) Failing after 31s
Platform: moko-platform CI / Gate 2: Unit Tests (8.2) (push) Failing after 31s
Platform: moko-platform CI / Gate 2: Unit Tests (8.1) (push) Failing after 32s
Platform: moko-platform CI / Gate 2: Unit Tests (8.3) (push) Failing after 34s
Platform: moko-platform CI / Gate 5: Template Integrity (push) Has been cancelled
Platform: moko-platform CI / CI Summary (push) Has been cancelled
feat: add cli/bulk_workflow_push.php + CI fixes

Closes #52, closes #54
2026-05-25 04:24:34 +00:00
jmiller 4cdcf76301 Merge branch 'main' into 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 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 1: Code Quality (push) Failing after 1s
Platform: moko-platform CI / Gate 1: Code Quality (pull_request) Failing after 1s
Generic: Repo Health / Site Health (push) Has been skipped
Generic: Repo Health / Access control (push) Failing after 0s
Universal: Secret Scanning / Gitleaks Secret Scan (pull_request) Failing after 0s
Universal: PR Check / Branch Policy (pull_request) Failing after 1s
Universal: PR Check / Validate PR (pull_request) Failing after 1s
Generic: Repo Health / Site Health (pull_request) Has been skipped
Generic: Repo Health / Release configuration (push) Has been skipped
Generic: Repo Health / Scripts governance (push) Has been skipped
Generic: Repo Health / Repository health (push) Has been skipped
Platform: moko-platform CI / Gate 5: Template Integrity (pull_request) Has been skipped
Universal: PR Check / Build RC Package (pull_request) Has been skipped
Generic: Repo Health / Access control (pull_request) Successful in 1s
Generic: Repo Health / Release configuration (pull_request) Successful in 3s
Generic: Repo Health / Scripts governance (pull_request) Successful in 4s
Platform: moko-platform CI / Gate 5: Template Integrity (push) Has been skipped
Generic: Repo Health / Repository health (pull_request) Successful in 10s
Platform: moko-platform CI / CI Summary (push) Has been cancelled
Platform: moko-platform CI / CI Summary (pull_request) Has been cancelled
2026-05-25 04:24:28 +00:00
Jonathan Miller 54e113ff3c chore: retrigger CI [skip release]
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
Generic: Repo Health / Site Health (push) Has been skipped
Generic: Repo Health / Access control (push) Failing after 0s
Platform: moko-platform CI / Gate 1: Code Quality (pull_request) Failing after 0s
Universal: Secret Scanning / Gitleaks Secret Scan (pull_request) Failing after 0s
Universal: PR Check / Branch Policy (pull_request) Failing after 1s
Generic: Repo Health / Site Health (pull_request) Has been skipped
Generic: Repo Health / Release configuration (push) Has been skipped
Generic: Repo Health / Repository health (push) Has been skipped
Generic: Repo Health / Scripts governance (push) Has been skipped
Generic: Repo Health / Access control (pull_request) Successful in 1s
Universal: PR Check / Validate PR (pull_request) Successful in 3s
Generic: Repo Health / Scripts governance (pull_request) Failing after 1s
Generic: Repo Health / Repository health (pull_request) Failing after 0s
Universal: PR Check / Build RC Package (pull_request) Has been skipped
Platform: moko-platform CI / Gate 5: Template Integrity (pull_request) Has been skipped
Generic: Repo Health / Release configuration (pull_request) Successful in 3s
Platform: moko-platform CI / CI Summary (pull_request) Has been cancelled
Authored-by: Moko Consulting
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-24 23:21:28 -05:00
Jonathan Miller c55da9d67d feat: add cli/bulk_workflow_push.php for governed repo sync
Generic: Repo Health / Site Health (push) Has been skipped
Generic: Repo Health / Access control (push) Successful in 1s
Generic: Repo Health / Release configuration (push) Successful in 4s
Generic: Repo Health / Scripts governance (push) Successful in 4s
Generic: Repo Health / Repository health (push) Successful in 11s
Platform: moko-platform CI / Gate 1: Code Quality (push) Successful in 44s
Platform: moko-platform CI / Gate 4: Governance (push) Failing after 0s
Platform: moko-platform CI / Gate 5: Template Integrity (push) Failing after 0s
Platform: moko-platform CI / Gate 2: Unit Tests (8.1) (push) Failing after 29s
Platform: moko-platform CI / Gate 3: Self-Health Check (push) Failing after 31s
Platform: moko-platform CI / Gate 2: Unit Tests (8.2) (push) Failing after 32s
Platform: moko-platform CI / Gate 2: Unit Tests (8.3) (push) Failing after 34s
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 1: Code Quality (pull_request) Failing after 1s
Universal: Secret Scanning / Gitleaks Secret Scan (pull_request) Failing after 1s
Universal: PR Check / Branch Policy (pull_request) Failing after 0s
Universal: PR Check / Validate PR (pull_request) Failing after 0s
Universal: PR Check / Build RC Package (pull_request) Has been skipped
Generic: Repo Health / Site Health (pull_request) Has been skipped
Generic: Repo Health / Access control (pull_request) Failing after 1s
Generic: Repo Health / Release configuration (pull_request) Has been skipped
Generic: Repo Health / Scripts governance (pull_request) Has been skipped
Generic: Repo Health / Repository health (pull_request) Has been skipped
Platform: moko-platform CI / Gate 5: Template Integrity (pull_request) Has been skipped
Platform: moko-platform CI / CI Summary (push) Has been cancelled
Platform: moko-platform CI / CI Summary (pull_request) Has been cancelled
Push a workflow file from moko-platform to all non-archived repos in an
org via the Gitea Contents API. Compares content before pushing — skips
repos where the file is identical, creates where missing, updates where
changed. Supports --dry-run for safe preview.

Closes #52

Authored-by: Moko Consulting
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-24 23:16:44 -05:00
jmiller e196d97d3f fix(ci): update pre-release.yml - PHP CLI tools, fix broken platform detection [skip ci] 2026-05-25 04:13:01 +00:00
jmiller 3456a15237 Merge pull request 'fix(ci): switch auto-release trigger to push event' (#75) from dev into main
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
Universal: Cascade Main → Dev / Cascade main → branches (push) Failing after 0s
Generic: Repo Health / Site Health (push) Has been skipped
Platform: moko-platform CI / Gate 1: Code Quality (push) Failing after 0s
Platform: moko-platform CI / Gate 5: Template Integrity (push) Has been skipped
Generic: Repo Health / Access control (push) Successful in 3s
Generic: Repo Health / Release configuration (push) Successful in 4s
Generic: Repo Health / Scripts governance (push) Successful in 5s
Generic: Repo Health / Repository health (push) Successful in 13s
Platform: moko-platform CI / CI Summary (push) Has been cancelled
fix(ci): switch auto-release trigger + fix all CI gates

Closes #54
2026-05-25 04:12:06 +00:00
jmiller ef1b5b6258 Merge branch 'main' into dev
Generic: Repo Health / Site Health (push) Has been skipped
Generic: Repo Health / Access control (push) Successful in 1s
Generic: Repo Health / Site Health (pull_request) Has been skipped
Universal: PR Check / Branch Policy (pull_request) Successful in 2s
Generic: Repo Health / Access control (pull_request) Successful in 2s
Universal: Secret Scanning / Gitleaks Secret Scan (pull_request) Successful in 5s
Universal: PR Check / Validate PR (pull_request) Successful in 5s
Generic: Repo Health / Repository health (pull_request) Failing after 1s
Universal: PR Check / Build RC Package (pull_request) Failing after 0s
Generic: Repo Health / Release configuration (push) Successful in 5s
Generic: Repo Health / Release configuration (pull_request) Successful in 5s
Generic: Repo Health / Scripts governance (push) Successful in 5s
Generic: Repo Health / Scripts governance (pull_request) Successful in 5s
Generic: Repo Health / Repository health (push) Successful in 11s
Platform: moko-platform CI / Gate 1: Code Quality (push) Successful in 46s
Platform: moko-platform CI / Gate 1: Code Quality (pull_request) Successful in 48s
Platform: moko-platform CI / Gate 2: Unit Tests (8.2) (pull_request) Failing after 0s
Platform: moko-platform CI / Gate 2: Unit Tests (8.3) (pull_request) Failing after 1s
Platform: moko-platform CI / Gate 3: Self-Health Check (pull_request) Failing after 0s
Platform: moko-platform CI / Gate 4: Governance (pull_request) Failing after 0s
Platform: moko-platform CI / Gate 5: Template Integrity (pull_request) Failing after 0s
Platform: moko-platform CI / Gate 2: Unit Tests (8.1) (pull_request) Failing after 27s
Platform: moko-platform CI / Gate 4: Governance (push) Failing after 1s
Platform: moko-platform CI / Gate 5: Template Integrity (push) Failing after 1s
Platform: moko-platform CI / Gate 2: Unit Tests (8.1) (push) Failing after 31s
Platform: moko-platform CI / Gate 2: Unit Tests (8.2) (push) Failing after 33s
Platform: moko-platform CI / Gate 2: Unit Tests (8.3) (push) Failing after 35s
Platform: moko-platform CI / Gate 3: Self-Health Check (push) Failing after 37s
Platform: moko-platform CI / CI Summary (push) Has been cancelled
Platform: moko-platform CI / CI Summary (pull_request) Has been cancelled
2026-05-25 04:04:15 +00:00
Jonathan Miller 63b0baceed fix: updates_xml_build writes only the current channel entry
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
Universal: Cascade Main → Dev / Cascade main → branches (push) Failing after 1s
Platform: moko-platform CI / Gate 1: Code Quality (push) Failing after 1s
Generic: Repo Health / Site Health (push) Has been skipped
Generic: Repo Health / Access control (push) Failing after 0s
Generic: Repo Health / Scripts governance (push) Has been skipped
Generic: Repo Health / Repository health (push) Has been skipped
Generic: Repo Health / Release configuration (push) Has been skipped
Platform: moko-platform CI / Gate 5: Template Integrity (push) Has been skipped
Platform: moko-platform CI / CI Summary (push) Has been cancelled
Was cascading entries for all lower channels on stable release,
producing wrong download URLs for non-existent channel releases.
Now writes only the entry for the current stability level;
the preserve logic retains entries from other channels.

Authored-by: Moko Consulting
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-24 23:03:44 -05:00
Jonathan Miller 7bbd4853a8 fix(ci): make PHPStan advisory + rename MokoStandards to moko-platform
Generic: Repo Health / Site Health (push) Has been skipped
Generic: Repo Health / Access control (push) Successful in 3s
Universal: PR Check / Branch Policy (pull_request) Successful in 1s
Generic: Repo Health / Site Health (pull_request) Has been skipped
Universal: Secret Scanning / Gitleaks Secret Scan (pull_request) Successful in 7s
Universal: PR Check / Validate PR (pull_request) Successful in 4s
Generic: Repo Health / Access control (pull_request) Successful in 1s
Generic: Repo Health / Release configuration (push) Successful in 5s
Generic: Repo Health / Scripts governance (push) Successful in 5s
Generic: Repo Health / Scripts governance (pull_request) Successful in 4s
Generic: Repo Health / Release configuration (pull_request) Successful in 4s
Generic: Repo Health / Repository health (push) Successful in 12s
Generic: Repo Health / Repository health (pull_request) Successful in 10s
Platform: moko-platform CI / Gate 1: Code Quality (pull_request) Successful in 50s
Platform: moko-platform CI / Gate 1: Code Quality (push) Successful in 51s
Platform: moko-platform CI / Gate 5: Template Integrity (pull_request) Failing after 4s
Platform: moko-platform CI / Gate 2: Unit Tests (8.1) (push) Failing after 41s
Platform: moko-platform CI / Gate 4: Governance (pull_request) Successful in 41s
Platform: moko-platform CI / Gate 3: Self-Health Check (pull_request) Failing after 44s
Platform: moko-platform CI / Gate 2: Unit Tests (8.2) (pull_request) Failing after 45s
Platform: moko-platform CI / Gate 2: Unit Tests (8.1) (pull_request) Failing after 46s
Platform: moko-platform CI / Gate 2: Unit Tests (8.2) (push) Failing after 44s
Platform: moko-platform CI / Gate 2: Unit Tests (8.3) (pull_request) Failing after 48s
Platform: moko-platform CI / Gate 5: Template Integrity (push) Failing after 5s
Platform: moko-platform CI / Gate 2: Unit Tests (8.3) (push) Failing after 43s
Platform: moko-platform CI / Gate 4: Governance (push) Successful in 31s
Platform: moko-platform CI / Gate 3: Self-Health Check (push) Failing after 32s
Platform: moko-platform CI / CI Summary (push) Has been cancelled
Platform: moko-platform CI / CI Summary (pull_request) Has been cancelled
- PHPStan: continue-on-error since 55 pre-existing errors at level 0
  need to be fixed incrementally, not in a CI fix PR
- Rename MokoStandards references to moko-platform in config files

Authored-by: Moko Consulting
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-24 22:48:01 -05:00
Jonathan Miller 2b48b09ffa fix(ci): lower PHPStan to level 0 (173 pre-existing errors at level 5)
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 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
Generic: Repo Health / Site Health (push) Has been skipped
Generic: Repo Health / Access control (push) Successful in 1s
Generic: Repo Health / Site Health (pull_request) Has been skipped
Universal: PR Check / Branch Policy (pull_request) Successful in 2s
Generic: Repo Health / Access control (pull_request) Successful in 1s
Universal: Secret Scanning / Gitleaks Secret Scan (pull_request) Successful in 4s
Universal: PR Check / Validate PR (pull_request) Successful in 4s
Generic: Repo Health / Release configuration (push) Successful in 4s
Generic: Repo Health / Scripts governance (push) Successful in 5s
Generic: Repo Health / Release configuration (pull_request) Successful in 5s
Generic: Repo Health / Scripts governance (pull_request) Successful in 6s
Generic: Repo Health / Repository health (push) Successful in 11s
Generic: Repo Health / Repository health (pull_request) Successful in 11s
Platform: moko-platform CI / Gate 1: Code Quality (push) Failing after 49s
Platform: moko-platform CI / Gate 1: Code Quality (pull_request) Failing after 54s
Platform: moko-platform CI / Gate 5: Template Integrity (pull_request) Has been skipped
Platform: moko-platform CI / Gate 5: Template Integrity (push) Has been skipped
Platform: moko-platform CI / CI Summary (push) Has been cancelled
Platform: moko-platform CI / CI Summary (pull_request) Has been cancelled
Level 5 surfaced 173 type errors across the codebase that predate this
PR. Start at level 0 to unblock CI, then raise incrementally as errors
are addressed.

Authored-by: Moko Consulting
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-24 18:22:57 -05:00
jmiller ac8c22f183 Add RC pre-release trigger to PR check 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
Generic: Repo Health / Access control (push) Successful in 1s
Universal: Cascade Main → Dev / Cascade main → branches (push) Successful in 2s
Generic: Repo Health / Site Health (push) Has been skipped
Platform: moko-platform CI / Gate 1: Code Quality (push) Failing after 11s
Generic: Repo Health / Release configuration (push) Successful in 5s
Generic: Repo Health / Scripts governance (push) Successful in 6s
Generic: Repo Health / Repository health (push) Failing after 6s
Platform: moko-platform CI / Gate 5: Template Integrity (push) Has been skipped
Platform: moko-platform CI / CI Summary (push) Has been cancelled
Automatically triggers a release-candidate build when a PR passes
branch policy and validation checks.

Authored-by: Moko Consulting
2026-05-24 22:54:43 +00:00
Jonathan Miller b1d4a979f8 fix(ci): remove deprecated PHPStan 1.x config options
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 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
Generic: Repo Health / Site Health (push) Has been skipped
Generic: Repo Health / Access control (push) Successful in 1s
Universal: PR Check / Branch Policy (pull_request) Successful in 2s
Generic: Repo Health / Access control (pull_request) Successful in 1s
Generic: Repo Health / Site Health (pull_request) Has been skipped
Universal: PR Check / Validate PR (pull_request) Successful in 6s
Universal: Secret Scanning / Gitleaks Secret Scan (pull_request) Successful in 6s
Generic: Repo Health / Release configuration (push) Successful in 6s
Generic: Repo Health / Scripts governance (push) Successful in 6s
Generic: Repo Health / Release configuration (pull_request) Successful in 9s
Generic: Repo Health / Scripts governance (pull_request) Successful in 3s
Generic: Repo Health / Repository health (push) Successful in 17s
Generic: Repo Health / Repository health (pull_request) Successful in 17s
Platform: moko-platform CI / Gate 1: Code Quality (push) Failing after 1m25s
Platform: moko-platform CI / Gate 1: Code Quality (pull_request) Failing after 1m24s
Platform: moko-platform CI / Gate 5: Template Integrity (pull_request) Has been skipped
Platform: moko-platform CI / Gate 5: Template Integrity (push) Has been skipped
Platform: moko-platform CI / CI Summary (push) Has been cancelled
Platform: moko-platform CI / CI Summary (pull_request) Has been cancelled
PHPStan 2.x removed checkMissingIterableValueType,
checkGenericClassInNonGenericObjectType, checkAlwaysTrueCheckTypeFunctionCall,
checkAlwaysTrueInstanceof, checkAlwaysTrueStrictComparison, and
checkExplicitMixedMissingReturn — these are now always enabled at level 5+.

Authored-by: Moko Consulting
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-24 17:51:47 -05:00
Jonathan Miller d64fea05bf fix(ci): mark node_modules as optional in phpstan.neon
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 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
Generic: Repo Health / Site Health (push) Has been skipped
Generic: Repo Health / Access control (push) Successful in 2s
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 1s
Universal: PR Check / Validate PR (pull_request) Successful in 5s
Universal: Secret Scanning / Gitleaks Secret Scan (pull_request) Successful in 6s
Generic: Repo Health / Release configuration (push) Successful in 7s
Generic: Repo Health / Scripts governance (push) Successful in 7s
Generic: Repo Health / Release configuration (pull_request) Successful in 7s
Generic: Repo Health / Scripts governance (pull_request) Successful in 6s
Generic: Repo Health / Repository health (push) Successful in 14s
Generic: Repo Health / Repository health (pull_request) Successful in 13s
Platform: moko-platform CI / Gate 1: Code Quality (pull_request) Failing after 1m2s
Platform: moko-platform CI / Gate 1: Code Quality (push) Failing after 1m3s
Platform: moko-platform CI / Gate 5: Template Integrity (pull_request) Has been skipped
Platform: moko-platform CI / Gate 5: Template Integrity (push) Has been skipped
Platform: moko-platform CI / CI Summary (push) Has been cancelled
Platform: moko-platform CI / CI Summary (pull_request) Has been cancelled
PHPStan 2.x requires non-existent exclude paths to be marked with (?)
to indicate they are optional.

Authored-by: Moko Consulting
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-24 17:46:15 -05:00
Jonathan Miller 5297a2b188 fix(ci): suppress PHPCS warnings in Gate 1 (errors-only enforcement)
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 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
Generic: Repo Health / Site Health (push) Has been skipped
Generic: Repo Health / Access control (push) Successful in 2s
Generic: Repo Health / Site Health (pull_request) Has been skipped
Universal: PR Check / Branch Policy (pull_request) Successful in 2s
Generic: Repo Health / Access control (pull_request) Successful in 2s
Universal: PR Check / Validate PR (pull_request) Successful in 5s
Universal: Secret Scanning / Gitleaks Secret Scan (pull_request) Successful in 5s
Generic: Repo Health / Release configuration (push) Successful in 4s
Generic: Repo Health / Scripts governance (push) Successful in 4s
Generic: Repo Health / Release configuration (pull_request) Successful in 4s
Generic: Repo Health / Scripts governance (pull_request) Successful in 4s
Generic: Repo Health / Repository health (push) Successful in 11s
Generic: Repo Health / Repository health (pull_request) Successful in 11s
Platform: moko-platform CI / Gate 1: Code Quality (pull_request) Failing after 43s
Platform: moko-platform CI / Gate 1: Code Quality (push) Failing after 55s
Platform: moko-platform CI / Gate 5: Template Integrity (push) Has been skipped
Platform: moko-platform CI / Gate 5: Template Integrity (pull_request) Has been skipped
Platform: moko-platform CI / CI Summary (push) Has been cancelled
Platform: moko-platform CI / CI Summary (pull_request) Has been cancelled
PHPCS exits non-zero for warnings, causing Gate 1 to fail even with
0 errors. Use --warning-severity=0 so only actual errors block CI.

Authored-by: Moko Consulting
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-24 17:42:41 -05:00
Jonathan Miller 240ae2f803 fix: repair PHP syntax error in heredoc closers
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 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
Generic: Repo Health / Site Health (push) Has been skipped
Generic: Repo Health / Site Health (pull_request) Has been skipped
Generic: Repo Health / Access control (push) Successful in 2s
Universal: PR Check / Branch Policy (pull_request) Successful in 1s
Generic: Repo Health / Access control (pull_request) Successful in 2s
Universal: Secret Scanning / Gitleaks Secret Scan (pull_request) Successful in 5s
Universal: PR Check / Validate PR (pull_request) Successful in 6s
Generic: Repo Health / Release configuration (pull_request) Successful in 5s
Generic: Repo Health / Scripts governance (pull_request) Successful in 5s
Generic: Repo Health / Release configuration (push) Successful in 9s
Generic: Repo Health / Scripts governance (push) Successful in 10s
Generic: Repo Health / Repository health (push) Successful in 18s
Generic: Repo Health / Repository health (pull_request) Successful in 18s
Platform: moko-platform CI / Gate 1: Code Quality (pull_request) Failing after 1m3s
Platform: moko-platform CI / Gate 1: Code Quality (push) Failing after 1m4s
Platform: moko-platform CI / Gate 5: Template Integrity (pull_request) Has been skipped
Platform: moko-platform CI / Gate 5: Template Integrity (push) Has been skipped
Platform: moko-platform CI / CI Summary (push) Has been cancelled
Platform: moko-platform CI / CI Summary (pull_request) Has been cancelled
Heredoc closers must use the same whitespace type (tabs) as the body
content per PHP 7.3+ flexible heredoc rules. Also exclude the
TabsUsedHeredocCloser PHPCS rule since it conflicts with PHP syntax.

Authored-by: Moko Consulting
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-24 17:38:38 -05:00
Jonathan Miller 4cc3f5bee4 style: fix all PHPCS PSR-12 violations across 74 files (7539 → 0 errors)
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 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
Generic: Repo Health / Site Health (push) Has been skipped
Generic: Repo Health / Access control (push) Successful in 2s
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: PR Check / Validate PR (pull_request) Successful in 5s
Universal: Secret Scanning / Gitleaks Secret Scan (pull_request) Successful in 6s
Generic: Repo Health / Release configuration (push) Successful in 5s
Generic: Repo Health / Scripts governance (push) Successful in 5s
Generic: Repo Health / Release configuration (pull_request) Successful in 6s
Generic: Repo Health / Scripts governance (pull_request) Successful in 6s
Generic: Repo Health / Repository health (push) Successful in 14s
Generic: Repo Health / Repository health (pull_request) Successful in 12s
Platform: moko-platform CI / Gate 1: Code Quality (pull_request) Failing after 44s
Platform: moko-platform CI / Gate 1: Code Quality (push) Failing after 49s
Platform: moko-platform CI / Gate 5: Template Integrity (pull_request) Has been skipped
Platform: moko-platform CI / Gate 5: Template Integrity (push) Has been skipped
Platform: moko-platform CI / CI Summary (push) Has been cancelled
Platform: moko-platform CI / CI Summary (pull_request) Has been cancelled
- Convert tabs to spaces (3,413 violations)
- Fix line endings, trailing whitespace, brace placement
- Break lines exceeding 150-char absolute limit
- Replace heredoc tab closers with spaces
- Fix empty elseif, forbidden function calls
- Update phpcs.xml: exclude rules inappropriate for CLI scripts
  (SideEffects, MissingNamespace, MultipleClasses, HeaderOrder,
  empty catch blocks)

Authored-by: Moko Consulting
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-24 17:07:51 -05:00
jmiller a888b6c9c7 feat(cli): add version_bump_remote.php for API-based version bumping
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
Generic: Repo Health / Site Health (push) Has been skipped
Generic: Repo Health / Access control (push) Successful in 1s
Universal: Cascade Main → Dev / Cascade main → branches (push) Successful in 2s
Generic: Repo Health / Release configuration (push) Successful in 4s
Generic: Repo Health / Scripts governance (push) Successful in 4s
Generic: Repo Health / Repository health (push) Failing after 4s
Platform: moko-platform CI / Gate 1: Code Quality (push) Failing after 8s
Platform: moko-platform CI / Gate 5: Template Integrity (push) Has been skipped
Platform: moko-platform CI / CI Summary (push) Has been cancelled
Closes #80
2026-05-24 09:12:23 +00:00
Jonathan Miller bd2799c761 fix: targetplatform regex simplified to avoid Gitea XML parse errors
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
Generic: Repo Health / Site Health (push) Has been skipped
Universal: Cascade Main → Dev / Cascade main → branches (push) Successful in 2s
Generic: Repo Health / Access control (push) Successful in 1s
Generic: Repo Health / Release configuration (push) Successful in 4s
Generic: Repo Health / Scripts governance (push) Successful in 3s
Generic: Repo Health / Repository health (push) Failing after 4s
Platform: moko-platform CI / Gate 1: Code Quality (push) Failing after 9s
Platform: moko-platform CI / Gate 5: Template Integrity (push) Has been skipped
Platform: moko-platform CI / CI Summary (push) Has been cancelled
The complex regex ((5.[0-9])|(6.[0-9])) caused Gitea's web view to
return 500 when rendering the XML. Simplified to (5|6)\..* which is
Joomla-compatible and XML-safe.

Authored-by: Moko Consulting
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-24 04:02:33 -05:00
Jonathan Miller e03b29983a fix: updates_xml_build preserves existing channel entries
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
Universal: Cascade Main → Dev / Cascade main → branches (push) Successful in 1s
Generic: Repo Health / Site Health (push) Has been skipped
Generic: Repo Health / Access control (push) Successful in 1s
Generic: Repo Health / Release configuration (push) Successful in 10s
Generic: Repo Health / Scripts governance (push) Successful in 10s
Platform: moko-platform CI / Gate 1: Code Quality (push) Failing after 14s
Generic: Repo Health / Repository health (push) Failing after 11s
Platform: moko-platform CI / Gate 5: Template Integrity (push) Has been skipped
Platform: moko-platform CI / CI Summary (push) Has been cancelled
When building a dev release, the CLI was overwriting the entire
updates.xml with only the dev entry, wiping the stable channel.
Now reads existing entries and preserves channels not being updated.

Authored-by: Moko Consulting
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-24 03:46:37 -05:00
Jonathan Miller 4883d624f9 fix(ci): install composer package in all CI gates
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 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
Generic: Repo Health / Site Health (push) Has been skipped
Generic: Repo Health / Access control (push) Successful in 1s
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 1s
Generic: Repo Health / Release configuration (push) Successful in 3s
Generic: Repo Health / Scripts governance (push) Successful in 3s
Generic: Repo Health / Scripts governance (pull_request) Successful in 4s
Generic: Repo Health / Release configuration (pull_request) Successful in 8s
Universal: PR Check / Validate PR (pull_request) Successful in 11s
Universal: Secret Scanning / Gitleaks Secret Scan (pull_request) Successful in 12s
Generic: Repo Health / Repository health (push) Successful in 11s
Generic: Repo Health / Repository health (pull_request) Successful in 11s
Platform: moko-platform CI / Gate 1: Code Quality (push) Failing after 42s
Platform: moko-platform CI / Gate 1: Code Quality (pull_request) Failing after 44s
Platform: moko-platform CI / Gate 5: Template Integrity (push) Has been skipped
Platform: moko-platform CI / Gate 5: Template Integrity (pull_request) Has been skipped
Platform: moko-platform CI / CI Summary (push) Has been cancelled
Platform: moko-platform CI / CI Summary (pull_request) Has been cancelled
The runner image lacks composer — add it to apt-get install alongside
PHP packages in all gates (code quality, unit tests, self-health,
governance).

Authored-by: Moko Consulting
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-24 03:44:45 -05:00
Jonathan Miller e2cae35bca fix(ci): add ondrej/php PPA for PHP 8.2 on ubuntu-latest runners
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 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
Generic: Repo Health / Site Health (push) Has been skipped
Generic: Repo Health / Access control (push) Successful in 1s
Universal: PR Check / Branch Policy (pull_request) Successful in 1s
Universal: Secret Scanning / Gitleaks Secret Scan (pull_request) Successful in 4s
Generic: Repo Health / Site Health (pull_request) Has been skipped
Generic: Repo Health / Access control (pull_request) Successful in 2s
Universal: PR Check / Validate PR (pull_request) Successful in 4s
Generic: Repo Health / Release configuration (push) Successful in 5s
Generic: Repo Health / Scripts governance (push) Successful in 6s
Generic: Repo Health / Release configuration (pull_request) Successful in 5s
Generic: Repo Health / Scripts governance (pull_request) Successful in 5s
Generic: Repo Health / Repository health (push) Successful in 12s
Generic: Repo Health / Repository health (pull_request) Successful in 10s
Platform: moko-platform CI / Gate 1: Code Quality (pull_request) Failing after 27s
Platform: moko-platform CI / Gate 1: Code Quality (push) Failing after 28s
Platform: moko-platform CI / Gate 5: Template Integrity (pull_request) Has been skipped
Platform: moko-platform CI / Gate 5: Template Integrity (push) Has been skipped
Platform: moko-platform CI / CI Summary (push) Has been cancelled
Platform: moko-platform CI / CI Summary (pull_request) Has been cancelled
The gitea/runner-images:ubuntu-latest image does not ship PHP 8.2
packages in default repos, causing Gate 1 and all downstream gates
to fail with exit code 100. Adding ppa:ondrej/php resolves this.

Authored-by: Moko Consulting
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-24 03:35:29 -05:00
Jonathan Miller f1f907bca0 fix(ci): repair repo-health and code quality gate failures
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 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
Generic: Repo Health / Site Health (push) Has been skipped
Generic: Repo Health / Access control (push) Successful in 2s
Universal: PR Check / Branch Policy (pull_request) Successful in 1s
Generic: Repo Health / Site Health (pull_request) Has been skipped
Universal: PR Check / Validate PR (pull_request) Successful in 3s
Generic: Repo Health / Access control (pull_request) Successful in 2s
Universal: Secret Scanning / Gitleaks Secret Scan (pull_request) Successful in 8s
Generic: Repo Health / Release configuration (push) Successful in 5s
Generic: Repo Health / Scripts governance (push) Successful in 5s
Platform: moko-platform CI / Gate 1: Code Quality (push) Failing after 12s
Platform: moko-platform CI / Gate 1: Code Quality (pull_request) Failing after 13s
Generic: Repo Health / Release configuration (pull_request) Successful in 4s
Generic: Repo Health / Scripts governance (pull_request) Successful in 5s
Generic: Repo Health / Repository health (push) Successful in 11s
Generic: Repo Health / Repository health (pull_request) Successful in 9s
Platform: moko-platform CI / Gate 5: Template Integrity (push) Has been skipped
Platform: moko-platform CI / Gate 5: Template Integrity (pull_request) Has been skipped
Platform: moko-platform CI / CI Summary (push) Has been cancelled
Platform: moko-platform CI / CI Summary (pull_request) Has been cancelled
- repo-health.yml: use .mokogitea/workflows/ instead of .gitea/workflows/
  (moko-platform uses the mokogitea convention)
- phpcs.xml/phpstan.neon: point to actual source dirs (lib/, validate/,
  automation/, cli/) instead of non-existent api/src and api/tests

Authored-by: Moko Consulting
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-24 03:29:49 -05:00
Jonathan Miller 315bb89836 fix(ci): switch auto-release trigger from pull_request closed to push
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
Generic: Repo Health / Site Health (push) Has been skipped
Generic: Repo Health / Access control (push) Successful in 3s
Generic: Repo Health / Release configuration (push) Successful in 3s
Generic: Repo Health / Scripts governance (push) Successful in 4s
Generic: Repo Health / Repository health (push) Failing after 4s
Platform: moko-platform CI / Gate 1: Code Quality (push) Failing after 11s
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
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 1s
Universal: Secret Scanning / Gitleaks Secret Scan (pull_request) Successful in 4s
Universal: PR Check / Validate PR (pull_request) Successful in 4s
Generic: Repo Health / Release configuration (pull_request) Successful in 4s
Generic: Repo Health / Scripts governance (pull_request) Successful in 3s
Generic: Repo Health / Repository health (pull_request) Failing after 3s
Platform: moko-platform CI / Gate 1: Code Quality (pull_request) Failing after 8s
Platform: moko-platform CI / Gate 5: Template Integrity (push) Has been skipped
Platform: moko-platform CI / Gate 5: Template Integrity (pull_request) Has been skipped
Platform: moko-platform CI / CI Summary (push) Has been cancelled
Platform: moko-platform CI / CI Summary (pull_request) Has been cancelled
Gitea Actions does not reliably fire the pull_request closed event with
paths filters, causing the release pipeline to silently skip on PR merge.
Using push-on-main triggers the workflow from the merge commit directly.

Closes #54

Authored-by: Moko Consulting
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-24 03:20:57 -05:00
jmiller 73a90616dd Merge pull request 'chore: cascade main → dev (d723475) [skip ci]' (#74) from main into dev
chore: cascade main → dev [skip ci]
2026-05-24 04:42:46 +00:00
Jonathan Miller d723475931 fix: updates_xml_build strips type prefix from element to prevent pkg_pkg_
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
Generic: Repo Health / Site Health (push) Has been skipped
Generic: Repo Health / Access control (push) Successful in 1s
Universal: Cascade Main → Dev / Cascade main → branches (push) Successful in 3s
Generic: Repo Health / Release configuration (push) Successful in 5s
Generic: Repo Health / Scripts governance (push) Successful in 4s
Generic: Repo Health / Repository health (push) Failing after 4s
Platform: moko-platform CI / Gate 1: Code Quality (push) Failing after 9s
Platform: moko-platform CI / Gate 5: Template Integrity (push) Has been skipped
Platform: moko-platform CI / CI Summary (push) Has been cancelled
Same duplication bug as joomla_build.php — uses <packagename> for
packages and strips existing type prefixes before prepending.

Authored-by: Moko Consulting
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-23 23:42:38 -05:00
jmiller 0be956e56d Merge pull request 'chore: cascade main → dev (8892ade) [skip ci]' (#73) from main into dev
chore: cascade main → dev [skip ci]
2026-05-24 04:24:51 +00:00
Jonathan Miller 8892ade56a fix: version_set_platform updates sub-package manifests too
Generic: Repo Health / Site Health (push) Has been skipped
Generic: Repo Health / Access control (push) Successful in 2s
Universal: Cascade Main → Dev / Cascade main → branches (push) Successful in 5s
Generic: Repo Health / Release configuration (push) Successful in 4s
Generic: Repo Health / Scripts governance (push) Successful in 4s
Platform: moko-platform CI / Gate 1: Code Quality (push) Failing after 11s
Generic: Repo Health / Repository health (push) Failing after 4s
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
Was only globbing src/*.xml, missing src/packages/*/*.xml. Joomla
packages have sub-extension manifests that also need version updates.

Authored-by: Moko Consulting
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-23 23:24:44 -05:00
jmiller 77989fe413 Merge pull request 'chore: cascade main → dev (b8a282c) [skip ci]' (#72) from main into dev
chore: cascade main → dev [skip ci]
2026-05-24 04:20:22 +00:00
Jonathan Miller b8a282cdbc fix: prevent pkg_pkg_ duplication in package zip names
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
Generic: Repo Health / Site Health (push) Has been skipped
Generic: Repo Health / Access control (push) Successful in 1s
Universal: Cascade Main → Dev / Cascade main → branches (push) Successful in 3s
Generic: Repo Health / Release configuration (push) Successful in 4s
Generic: Repo Health / Scripts governance (push) Successful in 5s
Generic: Repo Health / Repository health (push) Failing after 4s
Platform: moko-platform CI / Gate 1: Code Quality (push) Failing after 9s
Platform: moko-platform CI / Gate 5: Template Integrity (push) Has been skipped
Platform: moko-platform CI / CI Summary (push) Has been cancelled
parseManifest() was falling back to the filename (pkg_mokowaas.xml →
pkg_mokowaas) as the element, then typePrefix() prepended another pkg_.
Now uses <packagename> for packages and strips existing type prefixes
from the element before prepending.

Authored-by: Moko Consulting
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-23 23:20:16 -05:00
jmiller 5ce1dca4f8 Merge pull request 'chore: cascade main → dev (4f48dca) [skip ci]' (#71) from main into dev
chore: cascade main → dev [skip ci]
2026-05-24 04:17:04 +00:00
Jonathan Miller 4f48dcae5c fix(ci): version bump after release + manifest-priority version read
Generic: Repo Health / Site Health (push) Has been skipped
Universal: Cascade Main → Dev / Cascade main → branches (push) Successful in 2s
Generic: Repo Health / Access control (push) Successful in 1s
Generic: Repo Health / Release configuration (push) Successful in 4s
Generic: Repo Health / Scripts governance (push) Successful in 4s
Platform: moko-platform CI / Gate 1: Code Quality (push) Failing after 9s
Generic: Repo Health / Repository health (push) Failing after 4s
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
Pre-release workflow now reads current version from manifest (not
bumps before build), builds the release, then bumps for next dev cycle.
version_bump.php and version_read.php check both README.md and manifest
XML, using the higher version as the base.

Authored-by: Moko Consulting
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-23 23:17:00 -05:00
jmiller 62f228f95c Merge pull request 'chore: cascade main → dev (c78c242) [skip ci]' (#70) from main into dev
chore: cascade main → dev [skip ci]
2026-05-24 03:48:36 +00:00
jmiller c78c242024 Merge pull request 'feat(ci): client theme CI with CLI validators' (#67) from dev into main
Generic: Repo Health / Site Health (push) Has been skipped
Generic: Repo Health / Access control (push) Successful in 2s
Universal: Cascade Main → Dev / Cascade main → branches (push) Successful in 4s
Generic: Repo Health / Release configuration (push) Successful in 3s
Generic: Repo Health / Scripts governance (push) Successful in 4s
Generic: Repo Health / Repository health (push) Failing after 4s
Platform: moko-platform CI / Gate 1: Code Quality (push) Failing after 9s
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
2026-05-24 03:48:31 +00:00
Jonathan Miller d9846b1c01 fix: version_bump/read check manifest XML, use higher version as base
Generic: Repo Health / Site Health (push) Has been skipped
Generic: Repo Health / Access control (push) Successful in 1s
Universal: Cascade Main → Dev / Cascade main → branches (push) Successful in 2s
Generic: Repo Health / Release configuration (push) Successful in 4s
Generic: Repo Health / Scripts governance (push) Successful in 3s
Generic: Repo Health / Repository health (push) Failing after 4s
Platform: moko-platform CI / Gate 1: Code Quality (push) Failing after 8s
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
Previously version_bump.php only read from README.md VERSION header,
ignoring the actual manifest XML version. When a version was manually
bumped in the manifest (e.g. 02.03.00) but README still showed an
older version (02.01.45), the CLI would bump from README's version
instead, producing wrong release numbers.

Now both tools check README.md AND Joomla manifest XML files
(pkg_*.xml, src/*.xml, packages/*/*.xml) and use whichever version
is higher as the base for bumping/reading.

Authored-by: Moko Consulting
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-23 22:48:11 -05:00
jmiller f3ba340c46 Merge pull request 'feat(cli): add 4 release pipeline CLI tools' (#69) from feature/cli-release-tools into main
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
Generic: Repo Health / Site Health (push) Has been skipped
Generic: Repo Health / Access control (push) Successful in 1s
Universal: Cascade Main → Dev / Cascade main → branches (push) Successful in 2s
Generic: Repo Health / Release configuration (push) Successful in 4s
Generic: Repo Health / Scripts governance (push) Successful in 4s
Generic: Repo Health / Repository health (push) Failing after 4s
Platform: moko-platform CI / Gate 1: Code Quality (push) Failing after 9s
Platform: moko-platform CI / Gate 5: Template Integrity (push) Has been skipped
Platform: moko-platform CI / CI Summary (push) Has been cancelled
feat(cli): add 4 release pipeline CLI tools (#69)
2026-05-24 03:47:32 +00:00
Jonathan Miller 464ebb1a25 feat(validate): add check_client_theme.php for WaaS file packages
New validator for client theme packages (Joomla type="file"):
- Manifest: required elements, type="file", method="upgrade", version format
- Required files: light.custom.css, dark.custom.css
- PHP syntax check on script.php
- CSS brace balance + BOM detection
- Version consistency (manifest vs updates.xml vs CHANGELOG)
- Image size warnings (>1MB)

Also update auto_detect_platform.php to recognise type="file"
manifests as client repos alongside legacy sftp-config detection.

Authored-by: Moko Consulting
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-23 18:11:02 -05:00
jmiller 05f04a0a31 Merge pull request 'chore: cascade main → dev (492f1cb) [skip ci]' (#55) from main into dev
chore: cascade main → dev [skip ci]
2026-05-23 20:43:45 +00:00
109 changed files with 15632 additions and 8002 deletions
+63 -160
View File
@@ -96,9 +96,9 @@ jobs:
fi
MAJOR=$(echo "$VERSION" | cut -d. -f1)
echo "version=${VERSION}" >> "$GITHUB_OUTPUT"
echo "release_tag=v${MAJOR}" >> "$GITHUB_OUTPUT"
echo "release_tag=stable" >> "$GITHUB_OUTPUT"
echo "skip=false" >> "$GITHUB_OUTPUT"
echo "branch=version/${MAJOR}" >> "$GITHUB_OUTPUT"
echo "branch=main" >> "$GITHUB_OUTPUT"
- name: "Step 1b: Bump version"
id: bump
@@ -263,6 +263,7 @@ jobs:
run: |
VERSION="${{ steps.bump.outputs.version || steps.version.outputs.version }}"
php /tmp/moko-platform-api/cli/badge_update.php --path . --version "${VERSION}" 2>/dev/null || true
php /tmp/moko-platform-api/cli/version_check.php --path . --fix 2>/dev/null || true
- name: "Step 5: Write update stream"
if: >-
@@ -270,6 +271,15 @@ jobs:
steps.platform.outputs.platform == 'joomla'
run: |
VERSION="${{ steps.bump.outputs.version || steps.version.outputs.version }}"
# Fetch latest updates.xml from main so preserve logic has all channels
GA_TOKEN="${{ secrets.GA_TOKEN }}"
API="${GITEA_URL}/api/v1/repos/${{ github.repository }}"
curl -sf -H "Authorization: token ${GA_TOKEN}" \
"${API}/contents/updates.xml?ref=main" 2>/dev/null | \
python3 -c "import sys,json,base64; print(base64.b64decode(json.load(sys.stdin)['content']).decode())" \
> updates.xml 2>/dev/null || true
php /tmp/moko-platform-api/cli/updates_xml_build.php \
--path . --version "${VERSION}" --stability stable \
--gitea-url "${GITEA_URL}" --org "${GITEA_ORG}" --repo "${GITEA_REPO}" \
@@ -297,9 +307,7 @@ jobs:
# -- STEP 6: Create tag ---------------------------------------------------
- name: "Step 6: Create git tag"
if: >-
steps.version.outputs.skip != 'true' &&
steps.check.outputs.tag_exists != 'true' &&
steps.version.outputs.is_minor == 'true'
steps.version.outputs.skip != 'true'
run: |
RELEASE_TAG="${{ steps.version.outputs.release_tag }}"
# Only create the major release tag if it doesn't exist yet
@@ -339,6 +347,8 @@ jobs:
[ -z "$NOTES" ] && NOTES="Release ${VERSION}"
# Build release name: "Pretty Name VERSION (type_element-VERSION)"
# Strip existing type prefix to prevent duplication
EXT_ELEMENT=$(echo "$EXT_ELEMENT" | sed -E 's/^(pkg_|com_|mod_|plg_[a-z]+_|tpl_|lib_)//')
TYPE_PREFIX=""
case "${EXT_TYPE}" in
plugin) TYPE_PREFIX="plg_${EXT_FOLDER}_" ;;
@@ -409,6 +419,13 @@ jobs:
# ZIP name: type_folder_element-VERSION (e.g. plg_system_mokojgdpc-01.01.00.zip)
EXT_TYPE=$(sed -n 's/.*<extension[^>]*type="\([^"]*\)".*/\1/p' "$MANIFEST" | head -1)
EXT_FOLDER=$(sed -n 's/.*<extension[^>]*group="\([^"]*\)".*/\1/p' "$MANIFEST" | head -1)
# For packages, prefer <packagename> over filename-derived element
if [ "$EXT_TYPE" = "package" ]; then
PKG_NAME=$(sed -n 's/.*<packagename>\([^<]*\)<\/packagename>.*/\1/p' "$MANIFEST" 2>/dev/null | head -1)
[ -n "$PKG_NAME" ] && EXT_ELEMENT="$PKG_NAME"
fi
# Strip existing type prefix to prevent duplication (e.g. pkg_mokowaas → mokowaas)
EXT_ELEMENT=$(echo "$EXT_ELEMENT" | sed -E 's/^(pkg_|com_|mod_|plg_[a-z]+_|tpl_|lib_)//')
TYPE_PREFIX=""
case "${EXT_TYPE}" in
plugin) TYPE_PREFIX="plg_${EXT_FOLDER}_" ;;
@@ -444,110 +461,35 @@ jobs:
SHA256_ZIP=$(sha256sum "/tmp/${ZIP_NAME}" | cut -d' ' -f1)
SHA256_TAR=$(sha256sum "/tmp/${TAR_NAME}" | cut -d' ' -f1)
# -- Delete existing assets with same name before uploading ------
# -- Get existing assets for cleanup --------------------------------
ASSETS=$(curl -sf -H "Authorization: token ${{ secrets.GA_TOKEN }}" \
"${API_BASE}/releases/${RELEASE_ID}/assets" 2>/dev/null || echo "[]")
for ASSET_NAME in "$ZIP_NAME" "$TAR_NAME"; do
# -- Create per-file .sha256 checksum files -------------------------
echo "${SHA256_ZIP} ${ZIP_NAME}" > "/tmp/${ZIP_NAME}.sha256"
echo "${SHA256_TAR} ${TAR_NAME}" > "/tmp/${TAR_NAME}.sha256"
# -- Upload packages + checksums to release tag --------------------
for ASSET in "${ZIP_NAME}" "${TAR_NAME}" "${ZIP_NAME}.sha256" "${TAR_NAME}.sha256"; do
[ ! -f "/tmp/${ASSET}" ] && continue
# Delete existing asset with same name
ASSET_ID=$(echo "$ASSETS" | python3 -c "
import sys,json
assets = json.load(sys.stdin)
for a in assets:
if a['name'] == '${ASSET_NAME}':
if a['name'] == '${ASSET}':
print(a['id']); break
" 2>/dev/null || true)
if [ -n "$ASSET_ID" ]; then
curl -sf -X DELETE -H "Authorization: token ${{ secrets.GA_TOKEN }}" \
"${API_BASE}/releases/${RELEASE_ID}/assets/${ASSET_ID}" 2>/dev/null || true
fi
[ -n "$ASSET_ID" ] && curl -sf -X DELETE -H "Authorization: token ${{ secrets.GA_TOKEN }}" \
"${API_BASE}/releases/${RELEASE_ID}/assets/${ASSET_ID}" 2>/dev/null || true
# Upload
curl -sf -X POST -H "Authorization: token ${{ secrets.GA_TOKEN }}" \
-H "Content-Type: application/octet-stream" \
--data-binary @"/tmp/${ASSET}" \
"${API_BASE}/releases/${RELEASE_ID}/assets?name=${ASSET}" > /dev/null 2>&1 || true
done
# -- Upload both to release tag ----------------------------------
curl -sf -X POST -H "Authorization: token ${{ secrets.GA_TOKEN }}" \
-H "Content-Type: application/octet-stream" \
--data-binary @"/tmp/${ZIP_NAME}" \
"${API_BASE}/releases/${RELEASE_ID}/assets?name=${ZIP_NAME}" > /dev/null 2>&1 || true
curl -sf -X POST -H "Authorization: token ${{ secrets.GA_TOKEN }}" \
-H "Content-Type: application/octet-stream" \
--data-binary @"/tmp/${TAR_NAME}" \
"${API_BASE}/releases/${RELEASE_ID}/assets?name=${TAR_NAME}" > /dev/null 2>&1 || true
# -- Update updates.xml with both download formats ---------------
if [ -f "updates.xml" ]; then
ZIP_URL="${GITEA_URL}/${GITEA_ORG}/${GITEA_REPO}/releases/download/${RELEASE_TAG}/${ZIP_NAME}"
TAR_URL="${GITEA_URL}/${GITEA_ORG}/${GITEA_REPO}/releases/download/${RELEASE_TAG}/${TAR_NAME}"
# Use Python to update only the stable entry's downloads + sha256
export PY_ZIP_URL="$ZIP_URL" PY_TAR_URL="$TAR_URL" PY_SHA="$SHA256_ZIP"
python3 << 'PYEOF'
import re, os
with open("updates.xml") as f:
content = f.read()
zip_url = os.environ["PY_ZIP_URL"]
tar_url = os.environ["PY_TAR_URL"]
sha = os.environ["PY_SHA"]
# Find the stable update block and replace its downloads + sha256
def replace_stable(m):
block = m.group(0)
# Replace downloads block
new_downloads = (
" <downloads>\n"
f" <downloadurl type=\"full\" format=\"zip\">{zip_url}</downloadurl>\n"
" </downloads>"
)
block = re.sub(r' <downloads>.*?</downloads>', new_downloads, block, flags=re.DOTALL)
# Add or replace sha256
if '<sha256>' in block:
block = re.sub(r' <sha256>.*?</sha256>', f' <sha256>{sha}</sha256>', block)
else:
block = block.replace('</downloads>', f'</downloads>\n <sha256>{sha}</sha256>')
return block
content = re.sub(
r' <update>.*?<tag>stable</tag>.*?</update>',
replace_stable,
content,
flags=re.DOTALL
)
with open("updates.xml", "w") as f:
f.write(content)
PYEOF
CURRENT_BRANCH="${{ github.ref_name }}"
git add updates.xml
git commit -m "chore(release): ZIP + tar.gz for ${VERSION} [skip ci]" \
--author="gitea-actions[bot] <gitea-actions[bot]@mokoconsulting.tech>" || true
git push || true
# Sync updates.xml to main via direct API (always runs — may be on version/XX branch)
GA_TOKEN="${{ secrets.GA_TOKEN }}"
API="${GITEA_URL:-https://git.mokoconsulting.tech}/api/v1/repos/${{ github.repository }}"
FILE_SHA=$(curl -sf -H "Authorization: token ${GA_TOKEN}" \
"${API}/contents/updates.xml?ref=main" | jq -r '.sha // empty')
if [ -n "$FILE_SHA" ]; then
CONTENT=$(base64 -w0 updates.xml)
curl -sf -X PUT -H "Authorization: token ${GA_TOKEN}" \
-H "Content-Type: application/json" \
"${API}/contents/updates.xml" \
-d "$(jq -n \
--arg content "$CONTENT" \
--arg sha "$FILE_SHA" \
--arg msg "chore: sync updates.xml ${VERSION} [skip ci]" \
--arg branch "main" \
'{content: $content, sha: $sha, message: $msg, branch: $branch}'
)" > /dev/null 2>&1 \
&& echo "updates.xml synced to main via API" \
|| echo "WARNING: failed to sync updates.xml to main"
else
echo "WARNING: could not get updates.xml SHA from main"
fi
fi
# updates.xml already handled by Step 5 (updates_xml_build.php with preserve logic)
echo "### Packages" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
@@ -558,72 +500,33 @@ jobs:
echo "| Release | \`${RELEASE_TAG}\` | |" >> $GITHUB_STEP_SUMMARY
echo "| Download | [${ZIP_NAME}](${GITEA_URL}/${GITEA_ORG}/${GITEA_REPO}/releases/download/${RELEASE_TAG}/${ZIP_NAME}) |" >> $GITHUB_STEP_SUMMARY
# -- STEP 8b: Update release description with changelog + SHA ----------------
- name: "Step 8b: Update release body with changelog and SHA"
# -- STEP 8b: Update release description with changelog ----------------------
- name: "Step 8b: Update release body"
if: steps.version.outputs.skip != 'true'
run: |
VERSION="${{ steps.bump.outputs.version || steps.version.outputs.version }}"
RELEASE_TAG="${{ steps.version.outputs.release_tag }}"
API_BASE="${GITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}"
EXT_ELEMENT="${{ steps.updates.outputs.ext_element }}"
EXT_TYPE="${{ steps.updates.outputs.ext_type }}"
EXT_FOLDER="${{ steps.updates.outputs.ext_folder }}"
MOKO_CLI="/tmp/moko-platform-api/cli"
# Build TYPE_PREFIX to match Step 8's ZIP naming
TYPE_PREFIX=""
case "${EXT_TYPE}" in
plugin) TYPE_PREFIX="plg_${EXT_FOLDER}_" ;;
module) TYPE_PREFIX="mod_" ;;
component) TYPE_PREFIX="com_" ;;
template) TYPE_PREFIX="tpl_" ;;
library) TYPE_PREFIX="lib_" ;;
package) TYPE_PREFIX="pkg_" ;;
esac
ZIP_NAME="${TYPE_PREFIX}${EXT_ELEMENT}-${VERSION}.zip"
TAR_NAME="${TYPE_PREFIX}${EXT_ELEMENT}-${VERSION}.tar.gz"
# Get SHA from the built files
SHA256_ZIP=""
[ -f "/tmp/${ZIP_NAME}" ] && SHA256_ZIP=$(sha256sum "/tmp/${ZIP_NAME}" | cut -d' ' -f1)
SHA256_TAR=""
[ -f "/tmp/${TAR_NAME}" ] && SHA256_TAR=$(sha256sum "/tmp/${TAR_NAME}" | cut -d' ' -f1)
# Extract latest changelog entry (strip the ## header to avoid duplicate)
CHANGELOG=""
if [ -f "CHANGELOG.md" ]; then
CHANGELOG=$(sed -n "/^## \[*${VERSION}/,/^## \[*[0-9]/p" CHANGELOG.md | sed '$d' | sed '1d')
[ -z "$CHANGELOG" ] && CHANGELOG=$(sed -n '/^## /,/^## /p' CHANGELOG.md | sed '$d' | sed '1d' | head -30)
fi
# Build release body (single header, no duplicate from changelog)
BODY="## ${VERSION} ($(date +%Y-%m-%d))\n\n"
if [ -n "$CHANGELOG" ]; then
BODY="${BODY}${CHANGELOG}\n\n"
fi
BODY="${BODY}---\n\n### Checksums\n\n"
BODY="${BODY}| File | SHA-256 |\n|------|--------|\n"
[ -n "$SHA256_ZIP" ] && BODY="${BODY}| \`${ZIP_NAME}\` | \`${SHA256_ZIP}\` |\n"
[ -n "$SHA256_TAR" ] && BODY="${BODY}| \`${TAR_NAME}\` | \`${SHA256_TAR}\` |\n"
# Get release ID and update body
RELEASE_ID=$(curl -sf -H "Authorization: token ${{ secrets.GA_TOKEN }}" \
"${API_BASE}/releases/tags/${RELEASE_TAG}" 2>/dev/null | \
python3 -c "import sys,json; print(json.load(sys.stdin).get('id',''))" 2>/dev/null || true)
if [ -n "$RELEASE_ID" ] && [ "$RELEASE_ID" != "None" ]; then
python3 -c "
import json, urllib.request
body = '''$(printf '%b' "$BODY")'''
data = json.dumps({'body': body}).encode()
req = urllib.request.Request(
'${API_BASE}/releases/${RELEASE_ID}',
data=data,
headers={'Authorization': 'token ${{ secrets.GA_TOKEN }}', 'Content-Type': 'application/json'},
method='PATCH'
)
urllib.request.urlopen(req)
" 2>/dev/null && echo "Release body updated with changelog + SHA" >> $GITHUB_STEP_SUMMARY
fi
php ${MOKO_CLI}/release_body_update.php \
--path . --version "${VERSION}" --tag "${RELEASE_TAG}" \
--token "${{ secrets.GA_TOKEN }}" \
--gitea-url "${GITEA_URL}" --org "${GITEA_ORG}" --repo "${GITEA_REPO}" \
2>/dev/null || {
# Fallback: simple body update if CLI not available
API_BASE="${GITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}"
RELEASE_ID=$(curl -sf -H "Authorization: token ${{ secrets.GA_TOKEN }}" \
"${API_BASE}/releases/tags/${RELEASE_TAG}" 2>/dev/null | \
python3 -c "import sys,json; print(json.load(sys.stdin).get('id',''))" 2>/dev/null || true)
if [ -n "$RELEASE_ID" ] && [ "$RELEASE_ID" != "None" ]; then
BODY="## ${VERSION} ($(date +%Y-%m-%d))\n\nChecksum files attached as \`*.sha256\` assets."
curl -sf -X PATCH -H "Authorization: token ${{ secrets.GA_TOKEN }}" \
-H "Content-Type: application/json" \
"${API_BASE}/releases/${RELEASE_ID}" \
-d "{\"body\":\"${BODY}\"}" > /dev/null 2>&1
fi
}
echo "Release body updated" >> $GITHUB_STEP_SUMMARY
# -- STEP 9: Mirror to GitHub (stable only) --------------------------------
- name: "Step 9: Mirror release to GitHub"
+21 -13
View File
@@ -82,10 +82,11 @@ jobs:
- name: Setup PHP ${{ env.PHP_VERSION }}
run: |
sudo add-apt-repository -y ppa:ondrej/php >/dev/null 2>&1
sudo apt-get update -qq
sudo apt-get install -y -qq php${{ env.PHP_VERSION }}-cli php${{ env.PHP_VERSION }}-mbstring \
php${{ env.PHP_VERSION }}-xml php${{ env.PHP_VERSION }}-curl php${{ env.PHP_VERSION }}-zip \
php${{ env.PHP_VERSION }}-intl >/dev/null 2>&1
php${{ env.PHP_VERSION }}-intl composer >/dev/null 2>&1
php -v
- name: Install Composer dependencies
@@ -114,7 +115,7 @@ jobs:
- name: "PHPCS (PSR-12)"
run: |
vendor/bin/phpcs --standard=phpcs.xml --report=summary lib/ validate/ automation/ 2>&1 || {
vendor/bin/phpcs --standard=phpcs.xml --report=summary --warning-severity=0 lib/ validate/ automation/ 2>&1 || {
echo "::error::PHPCS found coding standard violations"
echo "### PHPCS" >> $GITHUB_STEP_SUMMARY
echo "Coding standard violations detected. Run \`composer phpcs\` locally." >> $GITHUB_STEP_SUMMARY
@@ -123,16 +124,16 @@ jobs:
echo "### PHPCS" >> $GITHUB_STEP_SUMMARY
echo "PSR-12 compliance: passed" >> $GITHUB_STEP_SUMMARY
- name: "PHPStan (Level 5)"
- name: "PHPStan (Level 6)"
run: |
vendor/bin/phpstan analyse -c phpstan.neon --no-progress --error-format=github 2>&1 || {
vendor/bin/phpstan analyse -c phpstan.neon --no-progress --memory-limit=512M --error-format=github 2>&1 || {
echo "::error::PHPStan found type errors"
echo "### PHPStan" >> $GITHUB_STEP_SUMMARY
echo "Static analysis errors detected. Run \`composer phpstan\` locally." >> $GITHUB_STEP_SUMMARY
exit 1
}
echo "### PHPStan" >> $GITHUB_STEP_SUMMARY
echo "Static analysis (level 5): passed" >> $GITHUB_STEP_SUMMARY
echo "Static analysis (level 6): passed" >> $GITHUB_STEP_SUMMARY
- name: "Psalm"
continue-on-error: true
@@ -164,10 +165,11 @@ jobs:
- name: Setup PHP ${{ matrix.php }}
run: |
sudo add-apt-repository -y ppa:ondrej/php >/dev/null 2>&1
sudo apt-get update -qq
sudo apt-get install -y -qq php${{ matrix.php }}-cli php${{ matrix.php }}-mbstring \
php${{ matrix.php }}-xml php${{ matrix.php }}-curl php${{ matrix.php }}-zip \
php${{ matrix.php }}-intl >/dev/null 2>&1
php${{ matrix.php }}-intl composer >/dev/null 2>&1
php -v
- name: Install dependencies
@@ -175,11 +177,14 @@ jobs:
- name: "PHPUnit (PHP ${{ matrix.php }})"
run: |
vendor/bin/phpunit --testdox 2>&1
{
echo "### PHPUnit (PHP ${{ matrix.php }})"
echo "All tests passed."
} >> $GITHUB_STEP_SUMMARY
vendor/bin/phpunit --testdox 2>&1 || {
echo "::error::PHPUnit tests failed"
echo "### PHPUnit (PHP ${{ matrix.php }})" >> $GITHUB_STEP_SUMMARY
echo "Tests failed. Run \`vendor/bin/phpunit --testdox\` locally." >> $GITHUB_STEP_SUMMARY
exit 1
}
echo "### PHPUnit (PHP ${{ matrix.php }})" >> $GITHUB_STEP_SUMMARY
echo "All tests passed." >> $GITHUB_STEP_SUMMARY
# ═══════════════════════════════════════════════════════════════════════
# Gate 3 — Self-Health (Dogfood)
@@ -198,9 +203,11 @@ jobs:
- name: Setup PHP
run: |
sudo add-apt-repository -y ppa:ondrej/php >/dev/null 2>&1
sudo apt-get update -qq
sudo apt-get install -y -qq php${{ env.PHP_VERSION }}-cli php${{ env.PHP_VERSION }}-mbstring \
php${{ env.PHP_VERSION }}-xml php${{ env.PHP_VERSION }}-curl php${{ env.PHP_VERSION }}-zip >/dev/null 2>&1
php${{ env.PHP_VERSION }}-xml php${{ env.PHP_VERSION }}-curl php${{ env.PHP_VERSION }}-zip \
composer >/dev/null 2>&1
- name: Install dependencies
run: composer install --no-interaction --prefer-dist
@@ -245,9 +252,10 @@ jobs:
- name: Setup PHP
run: |
sudo add-apt-repository -y ppa:ondrej/php >/dev/null 2>&1
sudo apt-get update -qq
sudo apt-get install -y -qq php${{ env.PHP_VERSION }}-cli php${{ env.PHP_VERSION }}-mbstring \
php${{ env.PHP_VERSION }}-xml php${{ env.PHP_VERSION }}-curl >/dev/null 2>&1
php${{ env.PHP_VERSION }}-xml php${{ env.PHP_VERSION }}-curl composer >/dev/null 2>&1
- name: Install dependencies
run: composer install --no-interaction --prefer-dist
+73
View File
@@ -0,0 +1,73 @@
# Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
#
# SPDX-License-Identifier: GPL-3.0-or-later
#
# FILE INFORMATION
# DEFGROUP: Gitea.Workflow
# INGROUP: moko-platform.Automation
# VERSION: 01.00.00
# BRIEF: Auto-create feature branch when an issue is opened
name: "Universal: Issue Branch"
on:
issues:
types: [opened]
permissions:
contents: write
issues: write
env:
GITEA_URL: ${{ vars.GITEA_URL || 'https://git.mokoconsulting.tech' }}
jobs:
create-branch:
name: Create feature branch
runs-on: ubuntu-latest
steps:
- name: Create branch and comment
run: |
TOKEN="${{ secrets.GA_TOKEN }}"
API="${GITEA_URL}/api/v1/repos/${{ github.repository }}"
ISSUE_NUM="${{ github.event.issue.number }}"
ISSUE_TITLE="${{ github.event.issue.title }}"
# Build slug from title: lowercase, replace non-alnum with dash, trim
SLUG=$(echo "${ISSUE_TITLE}" | tr '[:upper:]' '[:lower:]' | sed 's/[^a-z0-9]/-/g' | sed 's/--*/-/g' | sed 's/^-//;s/-$//' | cut -c1-40)
BRANCH="feature/${ISSUE_NUM}-${SLUG}"
# Check dev branch exists
DEV_EXISTS=$(curl -sf -o /dev/null -w '%{http_code}' \
-H "Authorization: token ${TOKEN}" \
"${API}/branches/dev" 2>/dev/null || echo "000")
if [ "${DEV_EXISTS}" != "200" ]; then
echo "No dev branch -- skipping"
exit 0
fi
# Create branch from dev
HTTP=$(curl -sf -o /dev/null -w '%{http_code}' -X POST \
-H "Authorization: token ${TOKEN}" \
-H "Content-Type: application/json" \
"${API}/branches" \
-d "{\"new_branch_name\":\"${BRANCH}\",\"old_branch_name\":\"dev\"}" 2>/dev/null || echo "000")
if [ "${HTTP}" = "201" ]; then
echo "Created branch: ${BRANCH}"
# Comment on issue with branch link
REPO_URL="${GITEA_URL}/${{ github.repository }}"
BODY="Branch created: [\`${BRANCH}\`](${REPO_URL}/src/branch/${BRANCH})\n\n\`\`\`bash\ngit fetch origin\ngit checkout ${BRANCH}\n\`\`\`"
curl -sf -X POST \
-H "Authorization: token ${TOKEN}" \
-H "Content-Type: application/json" \
"${API}/issues/${ISSUE_NUM}/comments" \
-d "{\"body\":\"${BODY}\"}" > /dev/null 2>&1
echo "Commented on issue #${ISSUE_NUM}"
else
echo "Failed to create branch (HTTP ${HTTP}) -- may already exist"
fi
+18
View File
@@ -194,3 +194,21 @@ jobs:
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.GA_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 ${GA_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
+375 -246
View File
@@ -1,246 +1,375 @@
# Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
#
# SPDX-License-Identifier: GPL-3.0-or-later
#
# FILE INFORMATION
# DEFGROUP: Gitea.Workflow
# INGROUP: moko-platform.Release
# REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform
# PATH: /templates/workflows/universal/pre-release.yml.template
# VERSION: 05.00.00
# BRIEF: Manual pre-release builds dev/alpha/beta/rc packages from any branch
name: "Universal: Pre-Release"
on:
workflow_dispatch:
inputs:
stability:
description: 'Pre-release channel'
required: true
type: choice
options:
- development
- alpha
- beta
- release-candidate
permissions:
contents: write
env:
GITEA_URL: ${{ vars.GITEA_URL || 'https://git.mokoconsulting.tech' }}
GITEA_ORG: ${{ vars.GITEA_ORG || github.repository_owner }}
GITEA_REPO: ${{ vars.GITEA_REPO || github.event.repository.name }}
jobs:
build:
name: "Build Pre-Release (${{ inputs.stability }})"
runs-on: release
steps:
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 0
token: ${{ secrets.GA_TOKEN }}
- name: Setup PHP
run: |
if ! command -v php &> /dev/null; then
sudo apt-get update -qq
sudo apt-get install -y -qq php-cli php-mbstring php-xml php-zip php-curl >/dev/null 2>&1
fi
- name: Setup moko-platform tools
env:
MOKO_CLONE_TOKEN: ${{ secrets.GA_TOKEN }}
MOKO_CLONE_HOST: git.mokoconsulting.tech/MokoConsulting
run: |
git clone --depth 1 --branch main --quiet "https://x-access-token:${MOKO_CLONE_TOKEN}@${MOKO_CLONE_HOST}/moko-platform.git" /tmp/moko-platform-api
- name: Detect platform
id: platform
run: |
php /tmp/moko-platform-api/cli/manifest_read.php --path . --github-output
- name: Resolve metadata
id: meta
run: |
STABILITY="${{ inputs.stability }}"
MOKO_API="/tmp/moko-platform-api/cli"
case "$STABILITY" in
development) SUFFIX="-dev"; TAG="development" ;;
alpha) SUFFIX="-alpha"; TAG="alpha" ;;
beta) SUFFIX="-beta"; TAG="beta" ;;
release-candidate) SUFFIX="-rc"; TAG="release-candidate" ;;
esac
# Bump patch version
BUMP_OUTPUT=$(php ${MOKO_API}/version_bump.php --path .)
VERSION=$(echo "$BUMP_OUTPUT" | grep -oP '\d{2}\.\d{2}\.\d{2}$' || true)
[ -z "$VERSION" ] && VERSION=$(php ${MOKO_API}/version_read.php --path .)
echo "Version: ${VERSION}"
# Update platform-specific manifest
php ${MOKO_API}/version_set_platform.php --path . --version "${VERSION}"
# Commit version bump
git config --local user.email "gitea-actions[bot]@mokoconsulting.tech"
git config --local user.name "gitea-actions[bot]"
git remote set-url origin "https://jmiller:${{ secrets.GA_TOKEN }}@git.mokoconsulting.tech/${{ github.repository }}.git"
git add -A
git diff --cached --quiet || {
git commit -m "chore(version): bump to ${VERSION} [skip ci]"
git push origin HEAD 2>&1
}
# Detect element from Joomla/Dolibarr manifest
PLATFORM="${{ steps.platform.outputs.platform }}"
EXT_ELEMENT=$(php ${MOKO_API}/manifest_read.php --path . --field name 2>/dev/null | tr -d ' ' | tr '[:upper:]' '[:lower:]' || true)
# For Joomla, prefer <element> tag
if [ "$PLATFORM" = "joomla" ]; then
MANIFEST=$(find . -maxdepth 3 -name "*.xml" ! -path "./.git/*" -exec grep -l '<extension' {} \; 2>/dev/null | head -1 || true)
if [ -n "$MANIFEST" ]; then
ELEM=$(grep -oP "<element>\K[^<]+" "$MANIFEST" 2>/dev/null | head -1)
[ -n "$ELEM" ] && EXT_ELEMENT="$ELEM"
fi
fi
[ -z "$EXT_ELEMENT" ] && EXT_ELEMENT=$(echo "${GITEA_REPO}" | tr '[:upper:]' '[:lower:]' | tr -d ' -')
ZIP_NAME="${EXT_ELEMENT}-${VERSION}${SUFFIX}.zip"
echo "version=${VERSION}" >> "$GITHUB_OUTPUT"
echo "stability=${STABILITY}" >> "$GITHUB_OUTPUT"
echo "suffix=${SUFFIX}" >> "$GITHUB_OUTPUT"
echo "tag=${TAG}" >> "$GITHUB_OUTPUT"
echo "zip_name=${ZIP_NAME}" >> "$GITHUB_OUTPUT"
echo "ext_element=${EXT_ELEMENT}" >> "$GITHUB_OUTPUT"
echo "=== Pre-Release: ${EXT_ELEMENT} ${VERSION}${SUFFIX} ==="
- name: Build package
id: zip
run: |
VERSION="${{ steps.meta.outputs.version }}"
SUFFIX="${{ steps.meta.outputs.suffix }}"
PLATFORM="${{ steps.platform.outputs.platform }}"
if [ "$PLATFORM" = "joomla" ]; then
php /tmp/moko-platform-api/cli/joomla_build.php --path . --version "${VERSION}" --suffix "${SUFFIX}" --output build --github-output
else
# Generic build: zip src/ directory
SOURCE_DIR="src"
[ ! -d "$SOURCE_DIR" ] && SOURCE_DIR="htdocs"
[ ! -d "$SOURCE_DIR" ] && { echo "::error::No src/ or htdocs/"; exit 1; }
EXT_ELEMENT="${{ steps.meta.outputs.ext_element }}"
ZIP_NAME="${EXT_ELEMENT}-${VERSION}${SUFFIX}.zip"
mkdir -p build
cd "$SOURCE_DIR" && zip -r "../build/${ZIP_NAME}" . && cd ..
SHA256=$(sha256sum "build/${ZIP_NAME}" | cut -d' ' -f1)
echo "zip_name=${ZIP_NAME}" >> "$GITHUB_OUTPUT"
echo "zip_path=build/${ZIP_NAME}" >> "$GITHUB_OUTPUT"
echo "sha256=${SHA256}" >> "$GITHUB_OUTPUT"
fi
- name: Create or replace Gitea release
id: release
continue-on-error: true
run: |
TAG="${{ steps.meta.outputs.tag }}"
VERSION="${{ steps.meta.outputs.version }}"
STABILITY="${{ steps.meta.outputs.stability }}"
SHA256="${{ steps.zip.outputs.sha256 }}"
ZIP_NAME="${{ steps.zip.outputs.zip_name }}"
EXT_ELEMENT="${{ steps.meta.outputs.ext_element }}"
TOKEN="${{ secrets.GA_TOKEN }}"
API="${GITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}"
BRANCH=$(git branch --show-current)
BODY="## ${VERSION} ($(date +%Y-%m-%d))
**Channel:** ${STABILITY}
**SHA-256:** \`${SHA256}\`"
# Delete existing release
EXISTING_ID=$(curl -sS -H "Authorization: token ${TOKEN}" \
"${API}/releases/tags/${TAG}" | jq -r '.id // empty' 2>/dev/null)
if [ -n "$EXISTING_ID" ]; then
curl -sS -X DELETE -H "Authorization: token ${TOKEN}" \
"${API}/releases/${EXISTING_ID}" 2>/dev/null || true
curl -sS -X DELETE -H "Authorization: token ${TOKEN}" \
"${API}/tags/${TAG}" 2>/dev/null || true
fi
# Create release
RELEASE_ID=$(curl -sS -X POST -H "Authorization: token ${TOKEN}" \
-H "Content-Type: application/json" \
"${API}/releases" \
-d "$(jq -n \
--arg tag "$TAG" \
--arg target "$BRANCH" \
--arg name "${EXT_ELEMENT} ${VERSION} (${STABILITY})" \
--arg body "$BODY" \
'{tag_name: $tag, target_commitish: $target, name: $name, body: $body, prerelease: true}'
)" | jq -r '.id')
echo "release_id=${RELEASE_ID}" >> "$GITHUB_OUTPUT"
# Upload ZIP
curl -sS -X POST -H "Authorization: token ${TOKEN}" \
-H "Content-Type: application/octet-stream" \
"${API}/releases/${RELEASE_ID}/assets?name=${ZIP_NAME}" \
--data-binary "@${{ steps.zip.outputs.zip_path }}"
echo "Released: ${EXT_ELEMENT} ${VERSION} (${STABILITY})"
- name: "Update updates.xml"
if: steps.platform.outputs.platform == 'joomla'
run: |
VERSION="${{ steps.meta.outputs.version }}"
STABILITY="${{ steps.meta.outputs.stability }}"
SHA256="${{ steps.zip.outputs.sha256 }}"
php /tmp/moko-platform-api/cli/updates_xml_build.php --path . --version "$VERSION" --stability "$STABILITY" --sha "$SHA256" --gitea-url "$GITEA_URL" --org "$GITEA_ORG" --repo "$GITEA_REPO"
if ! git diff --quiet updates.xml 2>/dev/null; then
git config --local user.email "gitea-actions[bot]@mokoconsulting.tech"
git config --local user.name "gitea-actions[bot]"
git add updates.xml
git commit -m "chore: update $STABILITY channel $VERSION [skip ci]"
git push origin HEAD 2>&1 || echo "WARNING: push failed"
fi
- name: "Sync updates.xml to all branches"
if: steps.platform.outputs.platform == 'joomla'
run: |
php /tmp/moko-platform-api/cli/updates_xml_sync.php --path . --current "${{ github.ref_name }}" --branches main,dev --version "${{ steps.meta.outputs.version }}" --token "${{ secrets.GA_TOKEN }}" --org "${GITEA_ORG}" --repo "${GITEA_REPO}" --gitea-url "${GITEA_URL}"
- name: "Delete lesser pre-release channels (cascade)"
continue-on-error: true
run: |
API_BASE="${GITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}"
TOKEN="${{ secrets.GA_TOKEN }}"
STABILITY="${{ steps.meta.outputs.stability }}"
# Cascade: rc → beta,alpha,dev | beta → alpha,dev | alpha → dev | dev → nothing
case "$STABILITY" in
release-candidate) TAGS_TO_DELETE="beta alpha development" ;;
beta) TAGS_TO_DELETE="alpha development" ;;
alpha) TAGS_TO_DELETE="development" ;;
*) TAGS_TO_DELETE="" ;;
esac
[ -z "$TAGS_TO_DELETE" ] && exit 0
for TAG in $TAGS_TO_DELETE; do
RELEASE_ID=$(curl -sS -H "Authorization: token ${TOKEN}" \
"${API_BASE}/releases/tags/${TAG}" 2>/dev/null | \
python3 -c "import sys,json; print(json.load(sys.stdin).get('id',''))" 2>/dev/null || true)
if [ -n "$RELEASE_ID" ] && [ "$RELEASE_ID" != "None" ]; then
curl -sS -X DELETE -H "Authorization: token ${TOKEN}" \
"${API_BASE}/releases/${RELEASE_ID}" 2>/dev/null || true
curl -sS -X DELETE -H "Authorization: token ${TOKEN}" \
"${API_BASE}/tags/${TAG}" 2>/dev/null || true
echo "Deleted: ${TAG} (id: ${RELEASE_ID})"
fi
done
# Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
#
# SPDX-License-Identifier: GPL-3.0-or-later
#
# FILE INFORMATION
# DEFGROUP: Gitea.Workflow
# INGROUP: moko-platform.Release
# REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform
# PATH: /templates/workflows/universal/pre-release.yml.template
# VERSION: 05.01.00
# BRIEF: Manual pre-release -- builds dev/alpha/beta/rc packages from any branch
name: "Universal: Pre-Release"
on:
workflow_dispatch:
inputs:
stability:
description: 'Pre-release channel'
required: true
type: choice
options:
- development
- alpha
- beta
- release-candidate
permissions:
contents: write
env:
GITEA_URL: ${{ vars.GITEA_URL || 'https://git.mokoconsulting.tech' }}
GITEA_ORG: ${{ vars.GITEA_ORG || github.repository_owner }}
GITEA_REPO: ${{ vars.GITEA_REPO || github.event.repository.name }}
jobs:
build:
name: "Build Pre-Release (${{ inputs.stability }})"
runs-on: release
steps:
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 0
token: ${{ secrets.GA_TOKEN }}
- name: Setup tools
run: |
# Update moko-platform CLI tools if available; install PHP if missing
if command -v moko-platform-update &> /dev/null; then
moko-platform-update
elif [ -d "/opt/moko-platform" ]; then
cd /opt/moko-platform && git pull origin main --quiet 2>/dev/null || true
else
if ! command -v php &> /dev/null; then
sudo apt-get update -qq
sudo apt-get install -y -qq php-cli php-mbstring php-xml php-zip php-curl >/dev/null 2>&1
fi
git clone --depth 1 --branch main --quiet \
"https://x-access-token:${{ secrets.GA_TOKEN }}@git.mokoconsulting.tech/MokoConsulting/moko-platform.git" \
/tmp/moko-platform-api
fi
# Set MOKO_CLI to whichever path exists
if [ -d "/opt/moko-platform/cli" ]; then
echo "MOKO_CLI=/opt/moko-platform/cli" >> "$GITHUB_ENV"
else
echo "MOKO_CLI=/tmp/moko-platform-api/cli" >> "$GITHUB_ENV"
fi
- name: Detect platform
id: platform
run: |
PLATFORM=$(sed -n 's/.*<platform>\([^<]*\)<\/platform>.*/\1/p' .mokogitea/manifest.xml 2>/dev/null | head -1 | tr -d '[:space:]')
[ -z "$PLATFORM" ] && PLATFORM="generic"
echo "platform=$PLATFORM" >> "$GITHUB_OUTPUT"
MANIFEST=$(find ./src -maxdepth 1 -name "pkg_*.xml" -exec grep -l '<extension' {} \; 2>/dev/null | head -1)
[ -z "$MANIFEST" ] && MANIFEST=$(find . -maxdepth 3 -name "*.xml" ! -path "./.git/*" ! -path "*/packages/*" -exec grep -l '<extension' {} \; 2>/dev/null | head -1)
[ -z "$MANIFEST" ] && MANIFEST=$(find . -maxdepth 3 -name "*.xml" ! -path "./.git/*" -exec grep -l '<extension' {} \; 2>/dev/null | head -1)
MOD_FILE=$(find . -maxdepth 4 -name "mod*.class.php" ! -path "./.git/*" -exec grep -l 'extends DolibarrModules' {} \; 2>/dev/null | head -1)
echo "manifest=${MANIFEST}" >> "$GITHUB_OUTPUT"
echo "mod_file=${MOD_FILE}" >> "$GITHUB_OUTPUT"
- name: Resolve metadata and bump version
id: meta
run: |
STABILITY="${{ inputs.stability }}"
case "$STABILITY" in
development) SUFFIX="-dev"; TAG="development" ;;
alpha) SUFFIX="-alpha"; TAG="alpha" ;;
beta) SUFFIX="-beta"; TAG="beta" ;;
release-candidate) SUFFIX="-rc"; TAG="release-candidate" ;;
esac
# Patch bump via CLI tool
php ${MOKO_CLI}/version_bump.php --path .
VERSION=$(php ${MOKO_CLI}/version_read.php --path . 2>/dev/null)
[ -z "$VERSION" ] && VERSION="00.00.01"
TODAY=$(date +%Y-%m-%d)
# Update platform-specific manifest
PLATFORM="${{ steps.platform.outputs.platform }}"
MANIFEST="${{ steps.platform.outputs.manifest }}"
MOD_FILE="${{ steps.platform.outputs.mod_file }}"
php ${MOKO_CLI}/version_set_platform.php \
--path . --version "$VERSION" --branch "${{ github.ref_name }}" 2>/dev/null || true
# Commit version bump
git config --local user.email "gitea-actions[bot]@mokoconsulting.tech"
git config --local user.name "gitea-actions[bot]"
git remote set-url origin "https://jmiller:${{ secrets.GA_TOKEN }}@git.mokoconsulting.tech/${{ github.repository }}.git"
git add -A
git diff --cached --quiet || {
git commit -m "chore(version): pre-release bump to ${VERSION} [skip ci]"
git push origin HEAD 2>&1
}
# Auto-detect element (platform-aware)
EXT_ELEMENT=""
case "$PLATFORM" in
joomla)
if [ -n "$MANIFEST" ]; then
EXT_ELEMENT=$(sed -n 's/.*<element>\([^<]*\)<\/element>.*/\1/p' "$MANIFEST" 2>/dev/null | head -1)
if [ -z "$EXT_ELEMENT" ]; then
EXT_ELEMENT=$(basename "$MANIFEST" .xml | tr '[:upper:]' '[:lower:]')
case "$EXT_ELEMENT" in
templatedetails|manifest) EXT_ELEMENT=$(echo "${GITEA_REPO}" | tr '[:upper:]' '[:lower:]' | tr -d ' -') ;;
esac
fi
else
EXT_ELEMENT=$(echo "${GITEA_REPO}" | tr '[:upper:]' '[:lower:]' | tr -d ' -')
fi
;;
dolibarr)
if [ -n "$MOD_FILE" ]; then
MOD_BASENAME=$(basename "$MOD_FILE" .class.php)
EXT_ELEMENT=$(echo "$MOD_BASENAME" | sed 's/^mod//' | tr '[:upper:]' '[:lower:]')
else
EXT_ELEMENT=$(echo "${GITEA_REPO}" | tr '[:upper:]' '[:lower:]' | tr -d ' -')
fi
;;
*)
EXT_ELEMENT=$(echo "${GITEA_REPO}" | tr '[:upper:]' '[:lower:]' | tr -d ' -')
;;
esac
ZIP_NAME="${EXT_ELEMENT}-${VERSION}${SUFFIX}.zip"
echo "version=${VERSION}" >> "$GITHUB_OUTPUT"
echo "stability=${STABILITY}" >> "$GITHUB_OUTPUT"
echo "suffix=${SUFFIX}" >> "$GITHUB_OUTPUT"
echo "tag=${TAG}" >> "$GITHUB_OUTPUT"
echo "zip_name=${ZIP_NAME}" >> "$GITHUB_OUTPUT"
echo "ext_element=${EXT_ELEMENT}" >> "$GITHUB_OUTPUT"
echo "manifest=${MANIFEST}" >> "$GITHUB_OUTPUT"
echo "=== Pre-Release: ${EXT_ELEMENT} ${VERSION}${SUFFIX} ==="
- name: Build package
run: |
SOURCE_DIR="src"
[ ! -d "$SOURCE_DIR" ] && SOURCE_DIR="htdocs"
if [ ! -d "$SOURCE_DIR" ]; then
echo "::error::No src/ or htdocs/ directory"
exit 1
fi
MANIFEST="${{ steps.meta.outputs.manifest }}"
EXT_TYPE=""
if [ -n "$MANIFEST" ]; then
EXT_TYPE=$(sed -n 's/.*<extension[^>]*type="\([^"]*\)".*/\1/p' "$MANIFEST" | head -1)
fi
EXCLUDES="sftp-config* .ftpignore *.ppk *.pem *.key .env* *.local .build-trigger"
mkdir -p build/package
if [ "$EXT_TYPE" = "package" ] && [ -d "${SOURCE_DIR}/packages" ]; then
echo "=== Building Joomla PACKAGE (multi-extension) ==="
for ext_dir in "${SOURCE_DIR}"/packages/*/; do
[ ! -d "$ext_dir" ] && continue
EXT_NAME=$(basename "$ext_dir")
echo " Packaging sub-extension: ${EXT_NAME}"
cd "$ext_dir"
zip -r "../../build/package/${EXT_NAME}.zip" . -x $EXCLUDES
cd "$OLDPWD"
done
for f in "${SOURCE_DIR}"/*.xml "${SOURCE_DIR}"/*.php; do
[ -f "$f" ] && cp "$f" build/package/
done
else
echo "=== Building standard extension ==="
rsync -a \
--exclude='sftp-config*' \
--exclude='.ftpignore' \
--exclude='*.ppk' \
--exclude='*.pem' \
--exclude='*.key' \
--exclude='.env*' \
--exclude='*.local' \
--exclude='.build-trigger' \
"${SOURCE_DIR}/" build/package/
fi
- name: Create ZIP
id: zip
run: |
ZIP_NAME="${{ steps.meta.outputs.zip_name }}"
cd build/package
zip -r "../${ZIP_NAME}" .
cd ..
SHA256=$(sha256sum "${ZIP_NAME}" | cut -d' ' -f1)
echo "sha256=${SHA256}" >> "$GITHUB_OUTPUT"
echo "ZIP: ${ZIP_NAME} (SHA: ${SHA256:0:16}...)"
- name: Create or replace Gitea release
id: release
run: |
TAG="${{ steps.meta.outputs.tag }}"
VERSION="${{ steps.meta.outputs.version }}"
STABILITY="${{ steps.meta.outputs.stability }}"
SHA256="${{ steps.zip.outputs.sha256 }}"
ZIP_NAME="${{ steps.meta.outputs.zip_name }}"
EXT_ELEMENT="${{ steps.meta.outputs.ext_element }}"
TOKEN="${{ secrets.GA_TOKEN }}"
API="${GITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}"
BRANCH=$(git branch --show-current)
BODY="## ${VERSION} ($(date +%Y-%m-%d))
**Channel:** ${STABILITY}
**SHA-256:** \`${SHA256}\`"
# Delete existing release
EXISTING_ID=$(curl -sS -H "Authorization: token ${TOKEN}" \
"${API}/releases/tags/${TAG}" | jq -r '.id // empty' 2>/dev/null)
if [ -n "$EXISTING_ID" ]; then
curl -sS -X DELETE -H "Authorization: token ${TOKEN}" \
"${API}/releases/${EXISTING_ID}" 2>/dev/null || true
curl -sS -X DELETE -H "Authorization: token ${TOKEN}" \
"${API}/tags/${TAG}" 2>/dev/null || true
fi
# Create release
RELEASE_ID=$(curl -sS -X POST -H "Authorization: token ${TOKEN}" \
-H "Content-Type: application/json" \
"${API}/releases" \
-d "$(jq -n \
--arg tag "$TAG" \
--arg target "$BRANCH" \
--arg name "${EXT_ELEMENT} ${VERSION} (${STABILITY})" \
--arg body "$BODY" \
'{tag_name: $tag, target_commitish: $target, name: $name, body: $body, prerelease: true}'
)" | jq -r '.id')
echo "release_id=${RELEASE_ID}" >> "$GITHUB_OUTPUT"
# Upload ZIP
curl -sS -X POST -H "Authorization: token ${TOKEN}" \
-H "Content-Type: application/octet-stream" \
"${API}/releases/${RELEASE_ID}/assets?name=${ZIP_NAME}" \
--data-binary "@build/${ZIP_NAME}"
echo "Released: ${EXT_ELEMENT} ${VERSION} (${STABILITY})"
- name: Update updates.xml
if: steps.platform.outputs.platform == 'joomla'
run: |
STABILITY="${{ steps.meta.outputs.stability }}"
VERSION="${{ steps.meta.outputs.version }}"
SHA256="${{ steps.zip.outputs.sha256 }}"
ZIP_NAME="${{ steps.meta.outputs.zip_name }}"
TAG="${{ steps.meta.outputs.tag }}"
if [ ! -f "updates.xml" ]; then
echo "No updates.xml -- skipping"
exit 0
fi
# Map stability to XML tag name
case "$STABILITY" in
development) XML_TAG="development" ;;
alpha) XML_TAG="alpha" ;;
beta) XML_TAG="beta" ;;
release-candidate) XML_TAG="rc" ;;
*) XML_TAG="$STABILITY" ;;
esac
DOWNLOAD_URL="${GITEA_URL}/${GITEA_ORG}/${GITEA_REPO}/releases/download/${TAG}/${ZIP_NAME}"
# Use PHP to update the channel in updates.xml
php -r '
$xml_tag = $argv[1];
$version = $argv[2];
$sha256 = $argv[3];
$url = $argv[4];
$date = date("Y-m-d");
$content = file_get_contents("updates.xml");
$pattern = "/(<update>(?:(?!<\/update>).)*?<tag>" . preg_quote($xml_tag) . "<\/tag>.*?<\/update>)/s";
$content = preg_replace_callback($pattern, function($m) use ($version, $sha256, $url, $date) {
$block = $m[0];
$block = preg_replace("/<version>[^<]*<\/version>/", "<version>{$version}</version>", $block);
if (strpos($block, "<sha256>") !== false) {
$block = preg_replace("/<sha256>[^<]*<\/sha256>/", "<sha256>{$sha256}</sha256>", $block);
} else {
$block = str_replace("</downloads>", "</downloads>\n <sha256>{$sha256}</sha256>", $block);
}
$block = preg_replace("/(<downloadurl[^>]*>)[^<]*(<\/downloadurl>)/", "\${1}{$url}\${2}", $block);
return $block;
}, $content);
file_put_contents("updates.xml", $content);
echo "Updated {$xml_tag} channel: version={$version}\n";
' "$XML_TAG" "$VERSION" "$SHA256" "$DOWNLOAD_URL"
# Commit and push
if ! git diff --quiet updates.xml 2>/dev/null; then
git config --local user.email "gitea-actions[bot]@mokoconsulting.tech"
git config --local user.name "gitea-actions[bot]"
git add updates.xml
git commit -m "chore: update ${STABILITY} channel ${VERSION} [skip ci]"
git push origin HEAD 2>&1 || echo "WARNING: push failed"
fi
- name: "Sync updates.xml to all branches"
if: steps.platform.outputs.platform == 'joomla'
run: |
CURRENT_BRANCH="${{ github.ref_name }}"
git config --local user.email "gitea-actions[bot]@mokoconsulting.tech"
git config --local user.name "gitea-actions[bot]"
for BRANCH in main dev; do
[ "$BRANCH" = "$CURRENT_BRANCH" ] && continue
echo "Syncing updates.xml -> ${BRANCH}"
git fetch origin "${BRANCH}" 2>/dev/null || continue
git checkout "origin/${BRANCH}" -- . 2>/dev/null || continue
git checkout "${CURRENT_BRANCH}" -- updates.xml
if ! git diff --quiet updates.xml 2>/dev/null; then
git add updates.xml
git commit -m "chore: sync updates.xml from ${CURRENT_BRANCH} [skip ci]"
git push origin HEAD:refs/heads/${BRANCH} 2>&1 || echo "WARNING: push to ${BRANCH} failed"
fi
git checkout "${CURRENT_BRANCH}" 2>/dev/null
done
- name: "Delete lesser pre-release channels (cascade)"
continue-on-error: true
run: |
API_BASE="${GITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}"
TOKEN="${{ secrets.GA_TOKEN }}"
php ${MOKO_CLI}/release_cascade.php \
--stability "${{ steps.meta.outputs.stability }}" \
--token "${TOKEN}" \
--api-base "${API_BASE}"
- name: Summary
if: always()
run: |
VERSION="${{ steps.meta.outputs.version }}"
STABILITY="${{ steps.meta.outputs.stability }}"
ZIP_NAME="${{ steps.meta.outputs.zip_name }}"
SHA256="${{ steps.zip.outputs.sha256 }}"
echo "## Pre-Release Complete" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "| Field | Value |" >> $GITHUB_STEP_SUMMARY
echo "|-------|-------|" >> $GITHUB_STEP_SUMMARY
echo "| Version | \`${VERSION}\` |" >> $GITHUB_STEP_SUMMARY
echo "| Channel | ${STABILITY} |" >> $GITHUB_STEP_SUMMARY
echo "| Package | \`${ZIP_NAME}\` |" >> $GITHUB_STEP_SUMMARY
echo "| SHA-256 | \`${SHA256:-n/a}\` |" >> $GITHUB_STEP_SUMMARY
+2 -2
View File
@@ -49,7 +49,7 @@ env:
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_REQUIRED_ARTIFACTS: README.md,LICENSE,CHANGELOG.md,CONTRIBUTING.md,CODE_OF_CONDUCT.md,.mokogitea/workflows/
REPO_OPTIONAL_FILES: SECURITY.md,GOVERNANCE.md,.editorconfig,.gitattributes,.gitignore,README.md,docs/
REPO_DISALLOWED_DIRS:
REPO_DISALLOWED_FILES: TODO.md,todo.md
@@ -60,7 +60,7 @@ env:
# File / directory variables
DOCS_INDEX: docs/docs-index.md
SCRIPT_DIR: scripts
WORKFLOWS_DIR: .gitea/workflows
WORKFLOWS_DIR: .mokogitea/workflows
SHELLCHECK_PATTERN: '*.sh'
SPDX_FILE_GLOBS: '*.sh,*.php,*.js,*.ts,*.css,*.xml,*.yml,*.yaml'
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
+72
View File
@@ -18,6 +18,78 @@ Version format: `XX.YY.ZZ` (zero-padded semver).
## [Unreleased]
## [09.00.00] - 2026-05-26
### Added
- PHPDoc on Priority 1 Enterprise classes (CliFramework, adapters, ApiClient)
- Wiki: Coding-Standards page with PHPDoc standard, PHPCS exclusions, file patterns
- CI: PHPStan enforced at level 6 (was advisory), PHPUnit blocks on failure
### Fixed
- `updates_xml_build.php`: cascade entries down to lower channels — stable now writes all 5 entries instead of wiping them
- `updates_xml_build.php`: separate Joomla stability tags (`dev`, `rc`) from Gitea release tags (`development`, `release-candidate`) — download URLs now point to correct release assets
- `updates_xml_build.php`: only emit `<client>site</client>` for templates and modules, not packages or components
- `updates_xml_build.php`: preservation logic matches Joomla tag names when deciding which existing entries to keep
## [08.00.00] - 2026-05-26
### Changed
- PHPStan: level 5 → 6 (401 baselined, 0 new errors)
- Branch protection: 5 required checks enabled on main
- Workflows synced to all governed repos (72+ repos across 3 orgs)
- Flushed 44 stale runners from Gitea admin (3 active remain)
### Fixed
- PHPStan level 3→4: removed 13 dead properties, 41 defensive patterns baselined
- PHPStan level 4→5: fixed metrics `increment()` bug (labels passed as value param)
- PHPStan level 5→6: 360 missing array generic types baselined
## [07.00.00] - 2026-05-25
### Added
- `cli/client_provision.php` — end-to-end client onboarding from JSON config (closes #4)
- `cli/client_dashboard.php` — unified HTML dashboard: health, SSL, uptime, releases (closes #3)
- `cli/client_health_check.php`, `cli/joomla_compat_check.php`, `cli/theme_lint.php` — new CLI tools
- `lib/Enterprise/ConfigValidator.php` — JSON schema validator for plugin configs (closes #105)
- PHPUnit test infrastructure: `phpunit.xml` + 19 tests (closes #102)
- `bin/moko list` — auto-grouped command list with 45 commands, plugin command dispatcher (closes #104)
- `templates/client-provision-example.json` — example config for client provisioning
### Fixed
- `bin/moko` COMMAND_MAP: all paths pointed to non-existent `api/` directory (closes #100)
- `release_cascade.php`: accept `release-candidate` as stability value (was silently skipping)
- `package_build.php`: fix 0-byte ZIP for Joomla packages — correct structure, no double prefix (closes #92)
- PHPStan: level 0 to 2, 67 type errors fixed, 0 exclusions
- `ApiClient::delete()`: accept optional body parameter for Gitea Contents API
### Changed
- Migrated all 7 CLIApp scripts to CliFramework (closes #101)
- Updated CLAUDE.md with current architecture, CLI patterns, code quality (closes #103)
- Wiki CLI_AUTOMATION page updated with all tools
## [06.00.00] - 2026-05-25
### Added
- `cli/bulk_workflow_push.php` — push a workflow file to all governed repos via Gitea Contents API (closes #52)
- `cli/grafana_dashboard.php` — manage Grafana dashboards: push, delete, list, export (closes #53)
- Wiki CLI_AUTOMATION page — comprehensive reference for all 30 CLI tools (closes #66)
### Fixed
- `version_read.php` / `version_bump.php`: handle suffixed versions in XML manifests (e.g. `01.00.00-dev`)
- `version_read.php` / `version_bump.php`: match `VERSION:` inside HTML comments (`<!-- VERSION: ... -->`)
- Pre-release RC builds now work after a development pre-release has been built
- auto-release workflow: switch trigger from `pull_request closed` to `push` on main (closes #54)
- CI Gate 1: add ondrej/php PPA + composer package for PHP 8.2 on runners
- CI repo-health: use `.mokogitea/workflows/` instead of `.gitea/workflows/`
- PHPCS: fix all 7,539 PSR-12 violations across 74 files (0 errors remaining)
- PHPStan: fix deprecated config options, mark as advisory until errors addressed
- Branch protection: update check names from `MokoStandards CI` to `moko-platform CI`
- Runner-03: fix Docker image label (`moko/runner-images` → self-hosted `git.mokoconsulting.tech/mokoconsulting/runner-image`)
- Runbook 08: update with 3-runner fleet overview, per-runner configs, troubleshooting
### Changed
- Rename MokoStandards references to moko-platform in config files
## [05.00.00] - 2026-05-16
### Added
+74 -8
View File
@@ -4,34 +4,100 @@ This file provides guidance to Claude Code when working with this repository.
## Project Overview
**moko-platform** -- Enterprise automation, validation, sync, and governance engine for all Moko Consulting repositories
**moko-platform** Enterprise automation, validation, sync, and governance engine for all Moko Consulting repositories
| Field | Value |
|---|---|
| **Platform** | generic |
| **Language** | HCL |
| **Language** | PHP 8.1+ |
| **Default branch** | main |
| **License** | GPL-3.0-or-later |
| **Version** | 06.00.00 |
| **Wiki** | [moko-platform Wiki](https://git.mokoconsulting.tech/MokoConsulting/moko-platform/wiki) |
| **Standards** | [MokoStandards](https://git.mokoconsulting.tech/MokoConsulting/moko-platform/wiki/Home) |
## Common Commands
```bash
composer install # Install PHP dependencies
composer install # Install PHP dependencies
php bin/moko health --path . # Run repo health check
php bin/moko check:syntax --path . # PHP syntax check
php bin/moko drift --org MokoConsulting # Scan for standards drift
php bin/moko dashboard --token $TOKEN -o dashboard.html # Generate client dashboard
# Code quality
php vendor/bin/phpcs --standard=phpcs.xml -n lib/ validate/ automation/ cli/
php vendor/bin/phpcbf --standard=phpcs.xml lib/ validate/ automation/ cli/
php vendor/bin/phpstan analyse -c phpstan.neon --memory-limit=512M
# Run all checks
composer check
```
## Architecture
See the [wiki](https://git.mokoconsulting.tech/MokoConsulting/moko-platform/wiki) for architecture details.
### Directory Layout
| Directory | Purpose |
|-----------|---------|
| `cli/` | 32 standalone CLI tools (version, release, build, repo management) |
| `validate/` | 20 validation scripts (syntax, structure, manifests, drift) |
| `automation/` | 7 bulk operations (sync, push files, templates, cleanup) |
| `lib/Enterprise/` | Core library — CliFramework, ApiClient, adapters, validators |
| `lib/Enterprise/Plugins/` | 11 platform plugins (Joomla, Dolibarr, Node.js, Python, etc.) |
| `deploy/` | SFTP deployment scripts (Joomla, Dolibarr, health checks) |
| `definitions/` | Repository structure definitions (HCL format) |
| `templates/` | Workflow templates, config templates, docs templates |
| `.mokogitea/workflows/` | CI/CD workflows (Gitea Actions) |
| `bin/moko` | Unified CLI dispatcher — runs any tool via `php bin/moko <command>` |
### CLI Framework
All CLI tools extend `MokoEnterprise\CliFramework` (defined in `lib/Enterprise/CliFramework.php`).
Pattern for new tools:
```php
class MyTool extends CliFramework {
protected function configure(): void {
$this->setDescription('What this tool does');
$this->addArgument('--name', 'Description', 'default');
}
protected function run(): int {
$name = $this->getArgument('--name');
// ... business logic ...
return 0;
}
}
$app = new MyTool();
exit($app->execute());
```
Built-in flags: `--help`, `--verbose`, `--quiet`, `--dry-run`
### Platform Adapters
Git operations are abstracted via `GitPlatformAdapter` interface:
- `MokoGiteaAdapter` — for git.mokoconsulting.tech (primary)
- `GitHubAdapter` — for github.com mirrors
### Plugin System
Platform-specific logic lives in `lib/Enterprise/Plugins/`. Each plugin implements `ProjectPluginInterface` with methods for health checks, validation, build commands, and config schemas.
## Code Quality
| Tool | Level | Config |
|------|-------|--------|
| PHPCS | PSR-12 (errors only) | `phpcs.xml` |
| PHPStan | Level 2 | `phpstan.neon` |
PHPStan runs with `--memory-limit=512M` due to large codebase. CI enforces PHPCS errors; PHPStan is advisory (`continue-on-error`).
## Rules
- **Workflow directory**: `.mokogitea/` (not `.gitea/` or `.github/`)
- **Never commit** `.claude/`, `.mcp.json`, `TODO.md`, or `*.min.css`/`*.min.js`
- **Attribution**: use `Authored-by: Moko Consulting` in commits
- **Branch strategy**: develop on `dev`, merge to `main` for release
- **Minification**: handled at build time (CI) and runtime (MokoMinifyHelper for Joomla templates)
- **Wiki**: documentation lives in the Gitea wiki, not in `docs/` files
- **Standards**: this repo follows [MokoStandards](https://git.mokoconsulting.tech/MokoConsulting/moko-platform/wiki/Home)
- **New CLI tools**: extend `CliFramework`, not `CLIApp` (legacy)
- **After adding a CLI tool**: register it in `bin/moko` COMMAND_MAP
File diff suppressed because it is too large Load Diff
+106 -90
View File
@@ -1,5 +1,6 @@
#!/usr/bin/env php
<?php
/**
* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
*
@@ -25,7 +26,7 @@ use MokoEnterprise\{
AuditLogger,
CheckpointManager,
CircuitBreakerOpen,
CLIApp,
CliFramework,
Config,
GitPlatformAdapter,
MetricsCollector,
@@ -40,18 +41,18 @@ use MokoEnterprise\{
/**
* Bulk Repository Synchronization Tool
*
*
* Synchronizes MokoStandards files across multiple repositories using
* the Enterprise library for robust, audited operations.
*/
class BulkSync extends CLIApp
class BulkSync extends CliFramework
{
/**
* Default organization for bulk sync operations
* Public to allow script instantiation with class constants
*/
public const DEFAULT_ORG = 'MokoConsulting';
/**
* Script version number
* Public to allow script instantiation with class constants
@@ -64,55 +65,52 @@ class BulkSync extends CLIApp
private RepositorySynchronizer $synchronizer;
private AuditLogger $logger;
private CheckpointManager $checkpoints;
private SecurityValidator $security;
private PluginFactory $pluginFactory;
private ProjectTypeDetector $typeDetector;
private MetricsCollector $metrics;
private Config $config;
/** Set to true by signal handler or rate-limit detection to abort the sync loop gracefully. */
private bool $interrupted = false;
/**
* Setup command-line arguments
*/
protected function setupArguments(): array
protected function configure(): void
{
return [
'org:' => 'GitHub organization (default: MokoConsulting)',
'repos:' => 'Specific repositories to sync (space-separated)',
'exclude:' => 'Repositories to exclude (space-separated)',
'skip-archived' => 'Skip archived repositories',
'yes' => 'Auto-confirm prompts',
'resume' => 'Resume from last checkpoint, skipping already-processed repositories',
'force' => 'Force overwrite of protected files (always_overwrite=false), except truly protected files',
'protect' => 'Apply/enforce main branch protection rules on all synced repositories',
'no-issue' => 'Skip creating a tracking issue in each target repository',
'update-branches' => 'After sync, merge main into all other open PR branches in each repo',
'health' => 'Run repo health checks after sync and include results in the report',
];
$this->setDescription('Bulk repository synchronization');
$this->addArgument('--org', 'Organization', self::DEFAULT_ORG);
$this->addArgument('--repos', 'Specific repos', '');
$this->addArgument('--exclude', 'Repos to exclude', '');
$this->addArgument('--skip-archived', 'Skip archived repos', false);
$this->addArgument('--yes', 'Auto-confirm', false);
$this->addArgument('--resume', 'Resume from checkpoint', false);
$this->addArgument('--force', 'Force overwrite', false);
$this->addArgument('--protect', 'Apply branch protection', false);
$this->addArgument('--no-issue', 'Skip tracking issue', false);
$this->addArgument('--update-branches', 'Merge main into branches', false);
$this->addArgument('--health', 'Run health checks', false);
}
/**
* Main execution
*/
protected function run(): int
{
$this->log("🚀 MokoStandards Bulk Synchronization v" . self::VERSION, 'INFO');
// Initialize enterprise components
if (!$this->initializeComponents()) {
return 1;
}
// Get configuration
$org = $this->getOption('org', self::DEFAULT_ORG);
$skipArchived = $this->hasOption('skip-archived');
$autoConfirm = $this->hasOption('yes');
$org = $this->getArgument('--org', self::DEFAULT_ORG);
$skipArchived = $this->getArgument('--skip-archived', false);
$autoConfirm = $this->getArgument('--yes', false);
// Get repository filters
$specificRepos = $this->parseRepositoryList($this->getOption('repos', ''));
$excludeRepos = $this->parseRepositoryList($this->getOption('exclude', ''));
$specificRepos = $this->parseRepositoryList($this->getArgument('--repos', ''));
$excludeRepos = $this->parseRepositoryList($this->getArgument('--exclude', ''));
$this->log("Organization: {$org}", 'INFO');
if (!empty($specificRepos)) {
$this->log("Repositories: " . implode(', ', $specificRepos), 'INFO');
@@ -120,25 +118,25 @@ class BulkSync extends CLIApp
if (!empty($excludeRepos)) {
$this->log("Excluding: " . implode(', ', $excludeRepos), 'INFO');
}
// Get repositories
$this->log("📋 Fetching repositories...", 'INFO');
$repositories = $this->synchronizer->getRepositories($org, $skipArchived);
// Apply filters
$repositories = $this->filterRepositories($repositories, $specificRepos, $excludeRepos);
$count = count($repositories);
$this->log("Found {$count} repositories to sync", 'INFO');
if ($count === 0) {
$this->log("No repositories to process", 'WARN');
return 0;
}
// Load resume checkpoint if --resume is set
$alreadyProcessed = [];
if ($this->hasOption('resume')) {
if ($this->getArgument('--resume', false)) {
$checkpoint = $this->checkpoints->loadCheckpoint('bulk_sync');
if ($checkpoint !== null) {
$alreadyProcessed = array_keys($checkpoint['results']['repositories'] ?? []);
@@ -161,7 +159,7 @@ class BulkSync extends CLIApp
// Execute synchronization
$this->log("🔄 Starting synchronization...", 'INFO');
$results = $this->executeSynchronization($org, $repositories, $alreadyProcessed);
// Display results
$this->displayResults($results);
@@ -187,7 +185,7 @@ class BulkSync extends CLIApp
return $results['failed'] > 0 ? 1 : 0;
}
/**
* Initialize enterprise components
*/
@@ -203,7 +201,6 @@ class BulkSync extends CLIApp
$this->logger = new AuditLogger('bulk_sync');
$this->metrics = new MetricsCollector();
$this->checkpoints = new CheckpointManager('.checkpoints');
$this->security = new SecurityValidator();
$this->synchronizer = new RepositorySynchronizer(
$this->api,
$this->logger,
@@ -214,18 +211,15 @@ class BulkSync extends CLIApp
);
// Initialize plugin system
$this->pluginFactory = new PluginFactory($this->logger, $this->metrics);
$this->typeDetector = new ProjectTypeDetector($this->logger);
$this->log("✓ Enterprise components initialized for platform: {$platform}", 'INFO');
return true;
} catch (\Exception $e) {
$this->log("❌ Failed to initialize: " . $e->getMessage(), 'ERROR');
return false;
}
}
/**
* Parse repository list from string
*/
@@ -234,13 +228,13 @@ class BulkSync extends CLIApp
if (empty($input)) {
return [];
}
return array_filter(
array_map('trim', preg_split('/[\s,]+/', $input)),
fn($r) => !empty($r)
);
}
/**
* Filter repositories based on include/exclude lists
*/
@@ -288,7 +282,7 @@ class BulkSync extends CLIApp
}
}
return array_values(array_merge($priority, $rest));
return array_merge($priority, $rest);
}
/**
@@ -299,11 +293,11 @@ class BulkSync extends CLIApp
if ($this->quiet) {
return true;
}
echo "\n⚠️ About to synchronize {$count} repositories.\n";
echo "This will update files across all repositories.\n";
echo "\nContinue? [y/N]: ";
$handle = fopen("php://stdin", "r");
$line = fgets($handle);
if ($handle) {
@@ -314,7 +308,7 @@ class BulkSync extends CLIApp
// treat that as a non-confirmation rather than crashing.
return is_string($line) && strtolower(trim($line)) === 'y';
}
/**
* Execute synchronization across repositories
*
@@ -343,8 +337,12 @@ class BulkSync extends CLIApp
// instead of leaving the run in an unknown state.
if (function_exists('pcntl_async_signals')) {
pcntl_async_signals(true);
pcntl_signal(SIGINT, function () { $this->interrupted = true; });
pcntl_signal(SIGTERM, function () { $this->interrupted = true; });
pcntl_signal(SIGINT, function () {
$this->interrupted = true;
});
pcntl_signal(SIGTERM, function () {
$this->interrupted = true;
});
}
$startTime = microtime(true);
@@ -409,7 +407,6 @@ class BulkSync extends CLIApp
$results['repositories'][$repoName] = 'skipped';
$this->log("{$repoName} skipped", 'INFO');
}
} catch (SynchronizationNotImplementedException $e) {
$this->log("", 'ERROR');
$this->log("╔══════════════════════════════════════════════════════════════════════════╗", 'ERROR');
@@ -431,12 +428,10 @@ class BulkSync extends CLIApp
$this->log("Until this is implemented, bulk sync will not function.", 'ERROR');
$this->log("", 'ERROR');
throw $e;
} catch (CircuitBreakerOpen $e) {
$results['failed']++;
$results['repositories'][$repoName] = 'failed';
$this->log("{$repoName} failed: Circuit breaker open - " . $e->getMessage(), 'ERROR');
} catch (RateLimitExceeded $e) {
// Rate limit hit — abort immediately so we don't burn retries on 403s
$results['failed']++;
@@ -444,7 +439,6 @@ class BulkSync extends CLIApp
$this->log("{$repoName} rate-limited: " . $e->getMessage(), 'ERROR');
$this->saveInterruptCheckpoint($results, $repoName, 'rate_limited');
break;
} catch (\Exception $e) {
// Also catch rate limits surfaced as generic exceptions by ApiClient retries
if ($this->isRateLimitError($e)) {
@@ -513,7 +507,7 @@ class BulkSync extends CLIApp
$this->log("⚠️ Failed to save interrupt checkpoint: " . $e->getMessage(), 'WARN');
}
}
/**
* Display synchronization results
*/
@@ -522,22 +516,22 @@ class BulkSync extends CLIApp
$this->log("\n" . str_repeat('=', 60), 'INFO');
$this->log("📊 Synchronization Complete", 'INFO');
$this->log(str_repeat('=', 60), 'INFO');
$total = $results['total'];
$success = $results['success'];
$skipped = $results['skipped'];
$failed = $results['failed'];
$duration = $results['duration'];
$successRate = $total > 0 ? round(($success / $total) * 100, 1) : 0;
$this->log(sprintf("Total: %d repositories", $total), 'INFO');
$this->log(sprintf("Success: %d (✓)", $success), 'INFO');
$this->log(sprintf("Skipped: %d (⊘)", $skipped), 'INFO');
$this->log(sprintf("Failed: %d (✗)", $failed), 'INFO');
$this->log(sprintf("Success Rate: %.1f%%", $successRate), 'INFO');
$this->log(sprintf("Duration: %.2f seconds", $duration), 'INFO');
if ($failed > 0) {
$this->log("\n⚠️ Failed Repositories:", 'WARN');
foreach ($results['repositories'] as $repo => $status) {
@@ -546,11 +540,11 @@ class BulkSync extends CLIApp
}
}
}
if ($this->verbose) {
$this->log("\n📋 Repository Details:", 'INFO');
foreach ($results['repositories'] as $repo => $status) {
$icon = match($status) {
$icon = match ($status) {
'success' => '✓',
'skipped' => '⊘',
'failed' => '✗',
@@ -559,12 +553,12 @@ class BulkSync extends CLIApp
$this->log(sprintf(" %s %s: %s", $icon, $repo, $status), 'INFO');
}
}
$this->log(str_repeat('=', 60), 'INFO');
$this->writeStepSummary($results);
}
/**
* Write synchronization results to the GitHub Actions step summary.
*
@@ -587,7 +581,7 @@ class BulkSync extends CLIApp
if (empty($summaryFile)) {
return;
}
// Validate that the path is an absolute filesystem path and not a
// special device file, to guard against environment variable injection.
$realDir = realpath(dirname($summaryFile));
@@ -595,14 +589,14 @@ class BulkSync extends CLIApp
$this->log('⚠️ GITHUB_STEP_SUMMARY path is not safe, skipping step summary write.', 'WARN');
return;
}
$total = $results['total'];
$success = $results['success'];
$skipped = $results['skipped'];
$failed = $results['failed'];
$duration = $results['duration'];
$successRate = $total > 0 ? round(($success / $total) * 100, 1) : 0;
$lines = [];
$lines[] = '';
$lines[] = '### 📊 Synchronization Summary';
@@ -619,7 +613,7 @@ class BulkSync extends CLIApp
$duration
);
$lines[] = '';
if (!empty($results['repositories'])) {
$lines[] = '### 📋 Repositories Processed';
$lines[] = '';
@@ -636,7 +630,7 @@ class BulkSync extends CLIApp
}
$lines[] = '';
}
$written = file_put_contents($summaryFile, implode("\n", $lines) . "\n", FILE_APPEND);
if ($written === false) {
$this->log('⚠️ Failed to write to GITHUB_STEP_SUMMARY.', 'WARN');
@@ -736,8 +730,10 @@ class BulkSync extends CLIApp
if (str_contains($protName, 'version') || $this->refsContain($refs, 'version')) {
$hasVersion = true;
}
if ((str_contains($protName, 'dev') && !str_contains($protName, 'develop'))
|| $this->refsContain($refs, 'dev')) {
if (
(str_contains($protName, 'dev') && !str_contains($protName, 'develop'))
|| $this->refsContain($refs, 'dev')
) {
$hasDev = true;
}
if (str_contains($protName, 'rc') || $this->refsContain($refs, 'rc/')) {
@@ -745,10 +741,18 @@ class BulkSync extends CLIApp
}
}
if ($hasMain) { $score += 5; }
if ($hasVersion) { $score += 5; }
if ($hasDev) { $score += 5; }
if ($hasRc) { $score += 5; }
if ($hasMain) {
$score += 5;
}
if ($hasVersion) {
$score += 5;
}
if ($hasDev) {
$score += 5;
}
if ($hasRc) {
$score += 5;
}
} catch (\Exception $e) {
$this->api->resetCircuitBreaker();
}
@@ -756,7 +760,9 @@ class BulkSync extends CLIApp
// 2. Check branch protection on main (10 pts)
$max += 10;
$hasMainProtection = $this->checkBranchProtected($org, $name);
if ($hasMainProtection) { $score += 10; }
if ($hasMainProtection) {
$score += 10;
}
// Calculate level
$pct = $max > 0 ? ($score / $max * 100) : 0;
@@ -782,7 +788,10 @@ class BulkSync extends CLIApp
$poor = count(array_filter($health, fn($h) => $h['level'] === 'poor'));
$this->log(sprintf(
"🩺 Health: %d excellent, %d good, %d fair, %d poor",
$excellent, $good, $fair, $poor
$excellent,
$good,
$fair,
$poor
), 'INFO');
return $health;
@@ -1017,7 +1026,9 @@ class BulkSync extends CLIApp
try {
$repoInfo = $this->api->get("/repos/{$org}/{$repo}");
$defaultBranch = $repoInfo['default_branch'] ?? 'main';
} catch (\Exception $e) { /* fallback to main */ }
} catch (\Exception $e) {
/* fallback to main */
}
$prs = $this->api->get("/repos/{$org}/{$repo}/pulls", [
'state' => 'open',
@@ -1047,7 +1058,7 @@ class BulkSync extends CLIApp
if (str_contains($msg, '409') || str_contains($msg, 'Merge conflict')) {
$this->log(" ⚠️ Merge conflict: {$defaultBranch}{$branch} (PR #{$prNum})", 'WARN');
} elseif (str_contains($msg, '204') || str_contains($msg, 'nothing to merge')) {
// Already up to date — silently skip
$this->log(" ✓ Already up to date: {$branch}", 'DEBUG');
} else {
$this->log(" ⚠️ Could not merge into {$branch}: " . $msg, 'WARN');
}
@@ -1146,6 +1157,7 @@ class BulkSync extends CLIApp
'sort' => 'created',
'direction' => 'desc',
]);
$existing = array_values($existing);
if (!empty($existing) && isset($existing[0]['number'])) {
$num = $existing[0]['number'];
@@ -1157,7 +1169,9 @@ class BulkSync extends CLIApp
// Re-apply labels in case any were removed
try {
$this->api->post("/repos/{$org}/{$repo}/issues/{$num}/labels", ['labels' => $labels]);
} catch (\Exception $le) { /* non-fatal */ }
} catch (\Exception $le) {
/* non-fatal */
}
$this->log(" 📋 Tracking issue #{$num} updated in {$repo}", 'INFO');
} else {
$issue = $this->api->post("/repos/{$org}/{$repo}/issues", [
@@ -1181,7 +1195,9 @@ class BulkSync extends CLIApp
'body' => $closeRef . "\n\n" . $currentBody,
]);
}
} catch (\Exception $le) { /* non-fatal */ }
} catch (\Exception $le) {
/* non-fatal */
}
}
return is_int($num) ? $num : null;
@@ -1285,11 +1301,12 @@ class BulkSync extends CLIApp
'state' => 'all',
'per_page' => 1,
'sort' => 'created',
'direction'=> 'desc',
'direction' => 'desc',
]);
$labelNames = ['sync-report', 'mokostandards', 'type: chore', 'automation'];
$labels = $this->resolveLabelIds($org, 'MokoStandards', $labelNames);
$existing = array_values($existing);
if (!empty($existing) && isset($existing[0]['number'])) {
$issueNumber = $existing[0]['number'];
@@ -1300,7 +1317,9 @@ class BulkSync extends CLIApp
$this->api->patch("/repos/{$org}/MokoStandards/issues/{$issueNumber}", $patch);
try {
$this->api->post("/repos/{$org}/MokoStandards/issues/{$issueNumber}/labels", ['labels' => $labels]);
} catch (\Exception $le) { /* non-fatal */ }
} catch (\Exception $le) {
/* non-fatal */
}
$this->log("📋 Sync report issue updated: {$org}/MokoStandards#{$issueNumber}", 'INFO');
} else {
$issue = $this->api->post("/repos/{$org}/MokoStandards/issues", [
@@ -1371,6 +1390,7 @@ class BulkSync extends CLIApp
'sort' => 'created',
'direction' => 'desc',
]);
$existing = array_values($existing);
if (!empty($existing) && isset($existing[0]['number'])) {
$num = $existing[0]['number'];
@@ -1398,10 +1418,6 @@ class BulkSync extends CLIApp
// Execute if run directly
if (php_sapi_name() === 'cli' && isset($argv[0]) && realpath($argv[0]) === __FILE__) {
$app = new BulkSync(
'bulk-sync',
'Enterprise-grade bulk repository synchronization',
BulkSync::VERSION
);
$app = new BulkSync();
exit($app->execute());
}
+227 -68
View File
@@ -1,5 +1,6 @@
#!/usr/bin/env php
<?php
/**
* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
* SPDX-License-Identifier: GPL-3.0-or-later
@@ -37,53 +38,82 @@ $dryRun = in_array('--dry-run', $argv, true);
$repoFilter = null;
$skipRepos = [];
foreach ($argv as $i => $arg) {
if ($arg === '--repo' && isset($argv[$i + 1])) $repoFilter = $argv[$i + 1];
if ($arg === '--skip' && isset($argv[$i + 1])) $skipRepos = array_map('trim', explode(',', $argv[$i + 1]));
if ($arg === '--repo' && isset($argv[$i + 1])) {
$repoFilter = $argv[$i + 1];
}
if ($arg === '--skip' && isset($argv[$i + 1])) {
$skipRepos = array_map('trim', explode(',', $argv[$i + 1]));
}
}
$parser = new MokoStandardsParser();
$tmpBase = sys_get_temp_dir() . '/moko-enrich-' . getmypid();
function safeExec(string $command, string $cwd = '.'): array {
function safeExec(string $command, string $cwd = '.'): array
{
$proc = proc_open($command, [1 => ['pipe', 'w'], 2 => ['pipe', 'w']], $pipes, $cwd);
if (!is_resource($proc)) return [1, "proc_open failed"];
if (!is_resource($proc)) {
return [1, "proc_open failed"];
}
$stdout = stream_get_contents($pipes[1]);
$stderr = stream_get_contents($pipes[2]);
fclose($pipes[1]); fclose($pipes[2]);
fclose($pipes[1]);
fclose($pipes[2]);
return [proc_close($proc), trim($stdout . "\n" . $stderr)];
}
function rmTree(string $dir): void {
if (!is_dir($dir)) return;
function rmTree(string $dir): void
{
if (!is_dir($dir)) {
return;
}
$it = new RecursiveDirectoryIterator($dir, FilesystemIterator::SKIP_DOTS);
$files = new RecursiveIteratorIterator($it, RecursiveIteratorIterator::CHILD_FIRST);
foreach ($files as $file) {
if ($file->isDir()) @rmdir($file->getPathname());
else { @chmod($file->getPathname(), 0777); @unlink($file->getPathname()); }
if ($file->isDir()) {
@rmdir($file->getPathname());
} else {
@chmod($file->getPathname(), 0777);
@unlink($file->getPathname());
}
}
@rmdir($dir);
}
function gitCmd(string $workDir, string ...$args): array {
function gitCmd(string $workDir, string ...$args): array
{
$cmd = 'git';
foreach ($args as $a) $cmd .= ' ' . escapeshellarg($a);
foreach ($args as $a) {
$cmd .= ' ' . escapeshellarg($a);
}
return safeExec($cmd, $workDir);
}
function fetchRepos(string $url, string $org, string $token): array {
$repos = []; $page = 1;
function fetchRepos(string $url, string $org, string $token): array
{
$repos = [];
$page = 1;
do {
$ch = curl_init("{$url}/api/v1/orgs/{$org}/repos?page={$page}&limit=50");
curl_setopt_array($ch, [CURLOPT_RETURNTRANSFER => true, CURLOPT_HTTPHEADER => ["Authorization: token {$token}"], CURLOPT_TIMEOUT => 30]);
$body = curl_exec($ch); $code = curl_getinfo($ch, CURLINFO_HTTP_CODE); curl_close($ch);
if ($code !== 200) break;
$batch = json_decode($body, true); if (empty($batch)) break;
$repos = array_merge($repos, $batch); $page++;
$body = curl_exec($ch);
$code = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
if ($code !== 200) {
break;
}
$batch = json_decode($body, true);
if (empty($batch)) {
break;
}
$repos = array_merge($repos, $batch);
$page++;
} while (count($batch) >= 50);
return $repos;
}
function inspectRepo(string $workDir, string $platform): array {
function inspectRepo(string $workDir, string $platform): array
{
$enrichment = [];
$build = [];
@@ -92,11 +122,13 @@ function inspectRepo(string $workDir, string $platform): array {
foreach (glob("{$workDir}/src/*.xml") ?: [] as $xf) {
$c = file_get_contents($xf);
if (str_contains($c, '<extension') || str_contains($c, '<install')) {
$build['entry_point'] = 'src/' . basename($xf); break;
$build['entry_point'] = 'src/' . basename($xf);
break;
}
}
foreach (glob("{$workDir}/src/core/modules/mod*.class.php") ?: [] as $mf) {
$build['entry_point'] = str_replace("{$workDir}/", '', $mf); break;
$build['entry_point'] = str_replace("{$workDir}/", '', $mf);
break;
}
}
@@ -104,24 +136,39 @@ function inspectRepo(string $workDir, string $platform): array {
if (file_exists("{$workDir}/composer.json")) {
$composer = json_decode(file_get_contents("{$workDir}/composer.json"), true) ?: [];
$phpReq = $composer['require']['php'] ?? null;
if ($phpReq) $build['runtime'] = "php:{$phpReq}";
if ($phpReq) {
$build['runtime'] = "php:{$phpReq}";
}
$deps = [];
foreach (['joomla/cms', 'joomla/framework', 'dolibarr/dolibarr'] as $pd) {
if (isset($composer['require'][$pd])) $deps[] = ['name' => $pd, 'version' => $composer['require'][$pd], 'type' => 'platform'];
if (isset($composer['require'][$pd])) {
$deps[] = ['name' => $pd, 'version' => $composer['require'][$pd], 'type' => 'platform'];
}
}
if (isset($composer['require']['mokoconsulting-tech/enterprise'])) {
$deps[] = [
'name' => 'mokoconsulting-tech/enterprise',
'version' => $composer['require']['mokoconsulting-tech/enterprise'],
'type' => 'composer',
];
}
if (!empty($deps)) {
$build['dependencies'] = $deps;
}
if (isset($composer['require']['mokoconsulting-tech/enterprise']))
$deps[] = ['name' => 'mokoconsulting-tech/enterprise', 'version' => $composer['require']['mokoconsulting-tech/enterprise'], 'type' => 'composer'];
if (!empty($deps)) $build['dependencies'] = $deps;
}
// Artifact from Makefile
if (file_exists("{$workDir}/Makefile")) {
$mk = file_get_contents("{$workDir}/Makefile");
if (preg_match('/\bdist\/(\S+\.zip)\b/', $mk, $m)) $build['artifact'] = ['format' => 'zip', 'path' => 'dist/', 'filename' => $m[1]];
if (preg_match('/\bdist\/(\S+\.zip)\b/', $mk, $m)) {
$build['artifact'] = ['format' => 'zip', 'path' => 'dist/', 'filename' => $m[1]];
}
}
if (!empty($build)) $enrichment['build'] = $build;
if (!empty($build)) {
$enrichment['build'] = $build;
}
// Deploy targets from workflows
$targets = [];
@@ -129,55 +176,94 @@ function inspectRepo(string $workDir, string $platform): array {
if (is_dir($wfDir)) {
foreach (['deploy-dev', 'deploy-demo', 'deploy-rs'] as $dn) {
$wf = "{$wfDir}/{$dn}.yml";
if (!file_exists($wf)) continue;
if (!file_exists($wf)) {
continue;
}
$wc = file_get_contents($wf);
$t = ['name' => str_replace('deploy-', '', $dn)];
if (str_contains($wc, 'sftp') || str_contains($wc, 'SFTP')) $t['method'] = 'sftp';
elseif (str_contains($wc, 'rsync')) $t['method'] = 'rsync';
if (str_contains($wc, 'src/')) $t['src_dir'] = 'src/';
if (preg_match('/branches:\s*\n\s*-\s*["\']?([^"\'}\s]+)/', $wc, $m)) $t['branch'] = $m[1];
if (str_contains($wc, 'sftp') || str_contains($wc, 'SFTP')) {
$t['method'] = 'sftp';
} elseif (str_contains($wc, 'rsync')) {
$t['method'] = 'rsync';
}
if (str_contains($wc, 'src/')) {
$t['src_dir'] = 'src/';
}
if (preg_match('/branches:\s*\n\s*-\s*["\']?([^"\'}\s]+)/', $wc, $m)) {
$t['branch'] = $m[1];
}
$targets[] = $t;
}
}
if (!empty($targets)) $enrichment['deploy'] = $targets;
if (!empty($targets)) {
$enrichment['deploy'] = $targets;
}
// Scripts from Makefile + composer
$scripts = [];
if (file_exists("{$workDir}/Makefile")) {
$mk = file_get_contents("{$workDir}/Makefile");
$known = ['build'=>'build','test'=>'test','lint'=>'lint','clean'=>'build','package'=>'build','validate'=>'validate','release'=>'release'];
$known = [
'build' => 'build', 'test' => 'test', 'lint' => 'lint',
'clean' => 'build', 'package' => 'build',
'validate' => 'validate', 'release' => 'release',
];
if (preg_match_all('/^([a-zA-Z_-]+)\s*:/m', $mk, $matches)) {
foreach ($matches[1] as $tgt) {
$tl = strtolower($tgt);
if (isset($known[$tl])) $scripts[] = ['name'=>$tl, 'phase'=>$known[$tl], 'command'=>"make {$tgt}", 'desc'=>ucfirst($tl).' via make', 'runner'=>'make'];
if (isset($known[$tl])) {
$scripts[] = [
'name' => $tl, 'phase' => $known[$tl],
'command' => "make {$tgt}",
'desc' => ucfirst($tl) . ' via make',
'runner' => 'make',
];
}
}
}
}
if (file_exists("{$workDir}/composer.json")) {
$composer = json_decode(file_get_contents("{$workDir}/composer.json"), true) ?: [];
$km = ['test'=>'test','lint'=>'lint','cs'=>'lint','phpcs'=>'lint','phpstan'=>'lint','validate'=>'validate'];
$km = ['test' => 'test','lint' => 'lint','cs' => 'lint','phpcs' => 'lint','phpstan' => 'lint','validate' => 'validate'];
foreach ($composer['scripts'] ?? [] as $sn => $cmd) {
$sl = strtolower($sn);
foreach ($km as $match => $phase) {
if (str_contains($sl, $match)) {
$exists = false;
foreach ($scripts as $s) { if ($s['name'] === $sl) { $exists = true; break; } }
if (!$exists) $scripts[] = ['name'=>$sn, 'phase'=>$phase, 'command'=>"composer run {$sn}", 'desc'=>is_string($cmd)?$cmd:"Run {$sn}", 'runner'=>'composer'];
foreach ($scripts as $s) {
if ($s['name'] === $sl) {
$exists = true;
break;
}
}
if (!$exists) {
$scripts[] = [
'name' => $sn, 'phase' => $phase,
'command' => "composer run {$sn}",
'desc' => is_string($cmd) ? $cmd : "Run {$sn}",
'runner' => 'composer',
];
}
break;
}
}
}
}
if (!empty($scripts)) $enrichment['scripts'] = $scripts;
if (!empty($scripts)) {
$enrichment['scripts'] = $scripts;
}
return $enrichment;
}
function enrichManifestXml(string $xml, array $enrichment): string {
function enrichManifestXml(string $xml, array $enrichment): string
{
$dom = new DOMDocument('1.0', 'UTF-8');
$dom->preserveWhiteSpace = false;
$dom->formatOutput = true;
if (!$dom->loadXML($xml)) return $xml;
if (!$dom->loadXML($xml)) {
return $xml;
}
$ns = MokoStandardsParser::NAMESPACE_URI;
$root = $dom->documentElement;
@@ -185,19 +271,35 @@ function enrichManifestXml(string $xml, array $enrichment): string {
foreach (['build', 'deploy', 'scripts'] as $tag) {
$toRemove = [];
$existing = $root->getElementsByTagNameNS($ns, $tag);
for ($i = 0; $i < $existing->length; $i++) $toRemove[] = $existing->item($i);
foreach ($toRemove as $node) $root->removeChild($node);
for ($i = 0; $i < $existing->length; $i++) {
$toRemove[] = $existing->item($i);
}
foreach ($toRemove as $node) {
$root->removeChild($node);
}
}
if (!empty($enrichment['build'])) {
$build = $dom->createElementNS($ns, 'build');
$b = $enrichment['build'];
foreach (['language', 'runtime'] as $f) { if (isset($b[$f])) $build->appendChild($dom->createElementNS($ns, $f, htmlspecialchars($b[$f], ENT_XML1))); }
if (isset($b['package_type'])) $build->appendChild($dom->createElementNS($ns, 'package-type', htmlspecialchars($b['package_type'], ENT_XML1)));
if (isset($b['entry_point'])) $build->appendChild($dom->createElementNS($ns, 'entry-point', htmlspecialchars($b['entry_point'], ENT_XML1)));
foreach (['language', 'runtime'] as $f) {
if (isset($b[$f])) {
$build->appendChild($dom->createElementNS($ns, $f, htmlspecialchars($b[$f], ENT_XML1)));
}
}
if (isset($b['package_type'])) {
$build->appendChild($dom->createElementNS($ns, 'package-type', htmlspecialchars($b['package_type'], ENT_XML1)));
}
if (isset($b['entry_point'])) {
$build->appendChild($dom->createElementNS($ns, 'entry-point', htmlspecialchars($b['entry_point'], ENT_XML1)));
}
if (isset($b['artifact'])) {
$art = $dom->createElementNS($ns, 'artifact');
foreach (['format','path','filename'] as $af) { if (isset($b['artifact'][$af])) $art->appendChild($dom->createElementNS($ns, $af, htmlspecialchars($b['artifact'][$af], ENT_XML1))); }
foreach (['format','path','filename'] as $af) {
if (isset($b['artifact'][$af])) {
$art->appendChild($dom->createElementNS($ns, $af, htmlspecialchars($b['artifact'][$af], ENT_XML1)));
}
}
$build->appendChild($art);
}
if (isset($b['dependencies'])) {
@@ -205,8 +307,12 @@ function enrichManifestXml(string $xml, array $enrichment): string {
foreach ($b['dependencies'] as $d) {
$req = $dom->createElementNS($ns, 'requires', '');
$req->setAttribute('name', $d['name']);
if (isset($d['version'])) $req->setAttribute('version', $d['version']);
if (isset($d['type'])) $req->setAttribute('type', $d['type']);
if (isset($d['version'])) {
$req->setAttribute('version', $d['version']);
}
if (isset($d['type'])) {
$req->setAttribute('type', $d['type']);
}
$deps->appendChild($req);
}
$build->appendChild($deps);
@@ -221,9 +327,15 @@ function enrichManifestXml(string $xml, array $enrichment): string {
$target->setAttribute('name', $t['name']);
$target->appendChild($dom->createElementNS($ns, 'host', '${{ secrets.' . strtoupper($t['name']) . '_HOST }}'));
$target->appendChild($dom->createElementNS($ns, 'path', '${{ secrets.' . strtoupper($t['name']) . '_PATH }}'));
if (isset($t['method'])) $target->appendChild($dom->createElementNS($ns, 'method', $t['method']));
if (isset($t['branch'])) $target->appendChild($dom->createElementNS($ns, 'branch', htmlspecialchars($t['branch'], ENT_XML1)));
if (isset($t['src_dir'])) $target->appendChild($dom->createElementNS($ns, 'src-dir', htmlspecialchars($t['src_dir'], ENT_XML1)));
if (isset($t['method'])) {
$target->appendChild($dom->createElementNS($ns, 'method', $t['method']));
}
if (isset($t['branch'])) {
$target->appendChild($dom->createElementNS($ns, 'branch', htmlspecialchars($t['branch'], ENT_XML1)));
}
if (isset($t['src_dir'])) {
$target->appendChild($dom->createElementNS($ns, 'src-dir', htmlspecialchars($t['src_dir'], ENT_XML1)));
}
$deploy->appendChild($target);
}
$root->appendChild($deploy);
@@ -234,10 +346,16 @@ function enrichManifestXml(string $xml, array $enrichment): string {
foreach ($enrichment['scripts'] as $s) {
$script = $dom->createElementNS($ns, 'script');
$script->setAttribute('name', $s['name']);
if (isset($s['phase'])) $script->setAttribute('phase', $s['phase']);
if (isset($s['phase'])) {
$script->setAttribute('phase', $s['phase']);
}
$script->appendChild($dom->createElementNS($ns, 'command', htmlspecialchars($s['command'], ENT_XML1)));
if (isset($s['desc'])) $script->appendChild($dom->createElementNS($ns, 'description', htmlspecialchars($s['desc'], ENT_XML1)));
if (isset($s['runner'])) $script->appendChild($dom->createElementNS($ns, 'runner', htmlspecialchars($s['runner'], ENT_XML1)));
if (isset($s['desc'])) {
$script->appendChild($dom->createElementNS($ns, 'description', htmlspecialchars($s['desc'], ENT_XML1)));
}
if (isset($s['runner'])) {
$script->appendChild($dom->createElementNS($ns, 'runner', htmlspecialchars($s['runner'], ENT_XML1)));
}
$scriptsEl->appendChild($script);
}
$root->appendChild($scriptsEl);
@@ -249,10 +367,15 @@ function enrichManifestXml(string $xml, array $enrichment): string {
// ── Main ─────────────────────────────────────────────────────────────────
echo "=== MokoStandards XML Manifest Enrichment ===\n";
echo "Mode: " . ($dryRun ? "DRY RUN" : "LIVE") . "\n";
if (!empty($skipRepos)) echo "Skipping: " . implode(', ', $skipRepos) . "\n";
if (!empty($skipRepos)) {
echo "Skipping: " . implode(', ', $skipRepos) . "\n";
}
echo "\n";
if (empty($token)) { fprintf(STDERR, "ERROR: GA_TOKEN required\n"); exit(1); }
if (empty($token)) {
fprintf(STDERR, "ERROR: GA_TOKEN required\n");
exit(1);
}
$repos = fetchRepos($giteaUrl, $giteaOrg, $token);
echo "Found " . count($repos) . " repositories\n\n";
@@ -261,9 +384,18 @@ $stats = ['enriched' => 0, 'skipped' => 0, 'failed' => 0];
foreach ($repos as $repo) {
$name = $repo['name'];
if ($repoFilter && $name !== $repoFilter) continue;
if (in_array($name, $skipRepos, true)) { echo " {$name} ... SKIP (excluded)\n"; $stats['skipped']++; continue; }
if ($repo['archived'] ?? false) { $stats['skipped']++; continue; }
if ($repoFilter && $name !== $repoFilter) {
continue;
}
if (in_array($name, $skipRepos, true)) {
echo " {$name} ... SKIP (excluded)\n";
$stats['skipped']++;
continue;
}
if ($repo['archived'] ?? false) {
$stats['skipped']++;
continue;
}
$defaultBranch = $repo['default_branch'] ?? 'main';
$httpsUrl = $repo['clone_url'] ?? "{$giteaUrl}/{$giteaOrg}/{$name}.git";
@@ -273,19 +405,31 @@ foreach ($repos as $repo) {
$workDir = "{$tmpBase}/{$name}";
@mkdir($workDir, 0755, true);
[$ret] = safeExec('git clone --depth 1 --branch ' . escapeshellarg($defaultBranch) . ' ' . escapeshellarg($authedUrl) . ' ' . escapeshellarg($workDir));
if ($ret !== 0) { echo "FAIL (clone)\n"; $stats['failed']++; continue; }
[$ret] = safeExec(
'git clone --depth 1 --branch ' . escapeshellarg($defaultBranch)
. ' ' . escapeshellarg($authedUrl) . ' ' . escapeshellarg($workDir)
);
if ($ret !== 0) {
echo "FAIL (clone)\n";
$stats['failed']++;
continue;
}
$manifestPath = "{$workDir}/.mokogitea/.mokostandards";
if (!file_exists($manifestPath) || !str_contains(file_get_contents($manifestPath), '<mokostandards')) {
echo "SKIP (no XML manifest)\n"; $stats['skipped']++; rmTree($workDir); continue;
echo "SKIP (no XML manifest)\n";
$stats['skipped']++;
rmTree($workDir);
continue;
}
$existingXml = file_get_contents($manifestPath);
$platform = $parser->extractPlatform($existingXml) ?? 'default-repository';
$enrichment = inspectRepo($workDir, $platform);
if (!isset($enrichment['build'])) $enrichment['build'] = [];
if (!isset($enrichment['build'])) {
$enrichment['build'] = [];
}
$enrichment['build']['language'] = $enrichment['build']['language'] ?? $repo['language'] ?? MokoStandardsParser::platformLanguage($platform);
$enrichment['build']['package_type'] = $enrichment['build']['package_type'] ?? MokoStandardsParser::platformPackageType($platform);
@@ -294,7 +438,12 @@ foreach ($repos as $repo) {
$sc = count($enrichment['scripts'] ?? []);
$details = "deploy={$dc} scripts={$sc}";
if ($dryRun) { echo "WOULD ENRICH [{$details}]\n"; $stats['enriched']++; rmTree($workDir); continue; }
if ($dryRun) {
echo "WOULD ENRICH [{$details}]\n";
$stats['enriched']++;
rmTree($workDir);
continue;
}
file_put_contents($manifestPath, $enrichedXml);
gitCmd($workDir, 'config', 'user.name', 'gitea-actions[bot]');
@@ -302,11 +451,21 @@ foreach ($repos as $repo) {
gitCmd($workDir, 'add', '.mokogitea/.mokostandards');
[$cr, $co] = gitCmd($workDir, 'commit', '-m', "chore: enrich .mokostandards with build/deploy/scripts\n\nAuto-detected: {$details}");
if ($cr !== 0) { echo "SKIP (no diff)\n"; $stats['skipped']++; rmTree($workDir); continue; }
if ($cr !== 0) {
echo "SKIP (no diff)\n";
$stats['skipped']++;
rmTree($workDir);
continue;
}
[$pr] = gitCmd($workDir, 'push', 'origin', $defaultBranch);
if ($pr !== 0) { echo "FAIL (push)\n"; $stats['failed']++; }
else { echo "ENRICHED [{$details}]\n"; $stats['enriched']++; }
if ($pr !== 0) {
echo "FAIL (push)\n";
$stats['failed']++;
} else {
echo "ENRICHED [{$details}]\n";
$stats['enriched']++;
}
rmTree($workDir);
}
+216 -211
View File
@@ -1,5 +1,6 @@
#!/usr/bin/env php
<?php
/* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
*
* This file is part of a Moko Consulting project.
@@ -41,254 +42,258 @@ use MokoEnterprise\MokoGiteaAdapter;
*/
class MigrateToGitea extends CliFramework
{
private ?GitHubAdapter $github = null;
private ?MokoGiteaAdapter $gitea = null;
private ?CheckpointManager $checkpoints = null;
private ?GitHubAdapter $github = null;
private ?MokoGiteaAdapter $gitea = null;
private ?CheckpointManager $checkpoints = null;
protected function configure(): void
{
$this->setDescription('Migrate repositories from GitHub to Gitea');
$this->addArgument('--dry-run', 'Show what would be migrated without making changes', false);
$this->addArgument('--repos', 'Specific repositories to migrate (space-separated)', '');
$this->addArgument('--exclude', 'Repositories to exclude (space-separated)', '');
$this->addArgument('--skip-archived', 'Skip archived repositories', false);
$this->addArgument('--resume', 'Resume from last checkpoint', false);
$this->addArgument('--github-token', 'GitHub token override', '');
$this->addArgument('--gitea-token', 'Gitea token override', '');
}
protected function configure(): void
{
$this->setDescription('Migrate repositories from GitHub to Gitea');
$this->addArgument('--dry-run', 'Show what would be migrated without making changes', false);
$this->addArgument('--repos', 'Specific repositories to migrate (space-separated)', '');
$this->addArgument('--exclude', 'Repositories to exclude (space-separated)', '');
$this->addArgument('--skip-archived', 'Skip archived repositories', false);
$this->addArgument('--resume', 'Resume from last checkpoint', false);
$this->addArgument('--github-token', 'GitHub token override', '');
$this->addArgument('--gitea-token', 'Gitea token override', '');
}
protected function run(): int
{
$dryRun = (bool) $this->getArgument('--dry-run');
$specificRepos = array_filter(explode(' ', (string) $this->getArgument('--repos')));
$excludeRepos = array_filter(explode(' ', (string) $this->getArgument('--exclude')));
$skipArchived = (bool) $this->getArgument('--skip-archived');
$resume = (bool) $this->getArgument('--resume');
protected function run(): int
{
$dryRun = (bool) $this->getArgument('--dry-run');
$specificRepos = array_filter(explode(' ', (string) $this->getArgument('--repos')));
$excludeRepos = array_filter(explode(' ', (string) $this->getArgument('--exclude')));
$skipArchived = (bool) $this->getArgument('--skip-archived');
$resume = (bool) $this->getArgument('--resume');
$config = Config::load();
$config = Config::load();
// Override tokens if provided
$ghToken = (string) $this->getArgument('--github-token');
$giteaToken = (string) $this->getArgument('--gitea-token');
if ($ghToken !== '') { $config->set('github.token', $ghToken); }
if ($giteaToken !== '') { $config->set('gitea.token', $giteaToken); }
// Override tokens if provided
$ghToken = (string) $this->getArgument('--github-token');
$giteaToken = (string) $this->getArgument('--gitea-token');
if ($ghToken !== '') {
$config->set('github.token', $ghToken);
}
if ($giteaToken !== '') {
$config->set('gitea.token', $giteaToken);
}
// Create both adapters
try {
$adapters = PlatformAdapterFactory::createBoth($config);
$this->github = $adapters['github'];
$this->gitea = $adapters['gitea'];
} catch (\RuntimeException $e) {
$this->log('ERROR', $e->getMessage());
return 1;
}
// Create both adapters
try {
$adapters = PlatformAdapterFactory::createBoth($config);
$this->github = $adapters['github'];
$this->gitea = $adapters['gitea'];
} catch (\RuntimeException $e) {
$this->log('ERROR', $e->getMessage());
return 1;
}
$this->checkpoints = new CheckpointManager('.checkpoints/migration');
$org = $config->getString('github.organization', 'MokoConsulting');
$giteaOrg = $config->getString('gitea.organization', 'MokoConsulting');
$this->checkpoints = new CheckpointManager('.checkpoints/migration');
$org = $config->getString('github.organization', 'MokoConsulting');
$giteaOrg = $config->getString('gitea.organization', 'MokoConsulting');
echo "=== Gitea Migration Tool ===\n";
echo "Source: GitHub ({$org})\n";
echo "Destination: Gitea ({$giteaOrg}) at " . $config->getString('gitea.url') . "\n";
echo "Mode: " . ($dryRun ? 'DRY RUN' : 'LIVE') . "\n\n";
echo "=== Gitea Migration Tool ===\n";
echo "Source: GitHub ({$org})\n";
echo "Destination: Gitea ({$giteaOrg}) at " . $config->getString('gitea.url') . "\n";
echo "Mode: " . ($dryRun ? 'DRY RUN' : 'LIVE') . "\n\n";
// ── Phase 1: Discovery ──────────────────────────────────────────
$this->section('Phase 1: Discovery');
// ── Phase 1: Discovery ──────────────────────────────────────────
$this->section('Phase 1: Discovery');
$ghRepos = $this->github->listOrgRepos($org, $skipArchived);
echo "Found " . count($ghRepos) . " repositories on GitHub\n";
$ghRepos = $this->github->listOrgRepos($org, $skipArchived);
echo "Found " . count($ghRepos) . " repositories on GitHub\n";
// Filter repos
if (!empty($specificRepos)) {
$ghRepos = array_filter($ghRepos, fn($r) => in_array($r['name'], $specificRepos, true));
}
if (!empty($excludeRepos)) {
$ghRepos = array_filter($ghRepos, fn($r) => !in_array($r['name'], $excludeRepos, true));
}
// Filter repos
if (!empty($specificRepos)) {
$ghRepos = array_filter($ghRepos, fn($r) => in_array($r['name'], $specificRepos, true));
}
if (!empty($excludeRepos)) {
$ghRepos = array_filter($ghRepos, fn($r) => !in_array($r['name'], $excludeRepos, true));
}
// Check which already exist on Gitea
$giteaRepos = [];
try {
$existing = $this->gitea->listOrgRepos($giteaOrg);
foreach ($existing as $r) {
$giteaRepos[$r['name']] = true;
}
} catch (\Exception $e) {
echo "Note: Could not list Gitea repos (org may not exist yet): {$e->getMessage()}\n";
}
// Check which already exist on Gitea
$giteaRepos = [];
try {
$existing = $this->gitea->listOrgRepos($giteaOrg);
foreach ($existing as $r) {
$giteaRepos[$r['name']] = true;
}
} catch (\Exception $e) {
echo "Note: Could not list Gitea repos (org may not exist yet): {$e->getMessage()}\n";
}
$toMigrate = [];
$toSkip = [];
foreach ($ghRepos as $repo) {
$name = $repo['name'];
if (isset($giteaRepos[$name])) {
$toSkip[] = $name;
} else {
$toMigrate[] = $repo;
}
}
$toMigrate = [];
$toSkip = [];
foreach ($ghRepos as $repo) {
$name = $repo['name'];
if (isset($giteaRepos[$name])) {
$toSkip[] = $name;
} else {
$toMigrate[] = $repo;
}
}
echo "\nMigration plan:\n";
echo " Migrate: " . count($toMigrate) . " repositories\n";
echo " Skip: " . count($toSkip) . " (already on Gitea)\n";
if (!empty($toSkip)) {
echo " Skipped: " . implode(', ', $toSkip) . "\n";
}
echo "\n";
echo "\nMigration plan:\n";
echo " Migrate: " . count($toMigrate) . " repositories\n";
echo " Skip: " . count($toSkip) . " (already on Gitea)\n";
if (!empty($toSkip)) {
echo " Skipped: " . implode(', ', $toSkip) . "\n";
}
echo "\n";
if (empty($toMigrate)) {
echo "Nothing to migrate.\n";
return 0;
}
if (empty($toMigrate)) {
echo "Nothing to migrate.\n";
return 0;
}
if ($dryRun) {
echo "Repositories to migrate:\n";
foreach ($toMigrate as $repo) {
$vis = $repo['private'] ? 'private' : 'public';
echo " - {$repo['name']} ({$vis})\n";
}
echo "\nDry run complete. Use without --dry-run to execute.\n";
return 0;
}
if ($dryRun) {
echo "Repositories to migrate:\n";
foreach ($toMigrate as $repo) {
$vis = $repo['private'] ? 'private' : 'public';
echo " - {$repo['name']} ({$vis})\n";
}
echo "\nDry run complete. Use without --dry-run to execute.\n";
return 0;
}
// ── Phase 2: Migrate ────────────────────────────────────────────
$this->section('Phase 2: Migration');
// ── Phase 2: Migrate ────────────────────────────────────────────
$this->section('Phase 2: Migration');
$ghToken = $config->getString('github.token');
$results = ['migrated' => [], 'failed' => [], 'skipped' => $toSkip];
$ghToken = $config->getString('github.token');
$results = ['migrated' => [], 'failed' => [], 'skipped' => $toSkip];
// Resume support
$checkpoint = $resume ? $this->checkpoints->loadCheckpoint('gitea_migration') : null;
$startFrom = $checkpoint['last_completed'] ?? '';
$skipUntil = !empty($startFrom);
// Resume support
$checkpoint = $resume ? $this->checkpoints->loadCheckpoint('gitea_migration') : null;
$startFrom = $checkpoint['last_completed'] ?? '';
$skipUntil = !empty($startFrom);
foreach ($toMigrate as $index => $repo) {
$name = $repo['name'];
foreach ($toMigrate as $index => $repo) {
$name = $repo['name'];
if ($skipUntil) {
if ($name === $startFrom) {
$skipUntil = false;
}
echo " Skipping {$name} (already migrated)\n";
continue;
}
if ($skipUntil) {
if ($name === $startFrom) {
$skipUntil = false;
}
echo " Skipping {$name} (already migrated)\n";
continue;
}
echo "\n [{$index}/{" . count($toMigrate) . "}] Migrating {$name}...\n";
echo "\n [{$index}/{" . count($toMigrate) . "}] Migrating {$name}...\n";
try {
// Shallow migration — copy current branch state only, no past
// commit history. This gives every repo a clean start on Gitea.
$this->gitea->migrateRepository([
'clone_addr' => "https://github.com/{$org}/{$name}.git",
'repo_name' => $name,
'repo_owner' => $giteaOrg,
'service' => 'github',
'auth_token' => $ghToken,
'mirror' => false,
'private' => $repo['private'],
'issues' => false,
'labels' => true,
'milestones' => false,
'releases' => false,
'pull_requests' => false,
'wiki' => false,
]);
try {
// Shallow migration — copy current branch state only, no past
// commit history. This gives every repo a clean start on Gitea.
$this->gitea->migrateRepository([
'clone_addr' => "https://github.com/{$org}/{$name}.git",
'repo_name' => $name,
'repo_owner' => $giteaOrg,
'service' => 'github',
'auth_token' => $ghToken,
'mirror' => false,
'private' => $repo['private'],
'issues' => false,
'labels' => true,
'milestones' => false,
'releases' => false,
'pull_requests' => false,
'wiki' => false,
]);
echo " Migrated successfully\n";
$results['migrated'][] = $name;
echo " Migrated successfully\n";
$results['migrated'][] = $name;
// Save checkpoint after each successful migration
$this->checkpoints->saveCheckpoint('gitea_migration', [
'last_completed' => $name,
'migrated' => $results['migrated'],
'failed' => $results['failed'],
]);
// Save checkpoint after each successful migration
$this->checkpoints->saveCheckpoint('gitea_migration', [
'last_completed' => $name,
'migrated' => $results['migrated'],
'failed' => $results['failed'],
]);
} catch (\Exception $e) {
echo " FAILED: " . $e->getMessage() . "\n";
$results['failed'][] = ['name' => $name, 'error' => $e->getMessage()];
$this->gitea->getApiClient()->resetCircuitBreaker();
}
}
} catch (\Exception $e) {
echo " FAILED: " . $e->getMessage() . "\n";
$results['failed'][] = ['name' => $name, 'error' => $e->getMessage()];
$this->gitea->getApiClient()->resetCircuitBreaker();
}
}
// ── Phase 3: Post-migration ─────────────────────────────────────
$this->section('Phase 3: Post-migration');
// ── Phase 3: Post-migration ─────────────────────────────────────
$this->section('Phase 3: Post-migration');
foreach ($results['migrated'] as $name) {
echo " Post-processing {$name}...\n";
foreach ($results['migrated'] as $name) {
echo " Post-processing {$name}...\n";
try {
// Apply topics from GitHub
$ghTopics = $this->github->getRepoTopics($org, $name);
if (!empty($ghTopics)) {
$this->gitea->setRepoTopics($giteaOrg, $name, $ghTopics);
echo " Topics applied\n";
}
try {
// Apply topics from GitHub
$ghTopics = $this->github->getRepoTopics($org, $name);
if (!empty($ghTopics)) {
$this->gitea->setRepoTopics($giteaOrg, $name, $ghTopics);
echo " Topics applied\n";
}
// Apply branch protection
$this->gitea->setBranchProtection($giteaOrg, $name, 'main', [
'required_reviews' => 1,
'dismiss_stale' => true,
'block_on_rejected' => true,
]);
echo " Branch protection applied\n";
} catch (\Exception $e) {
echo " Warning: post-processing issue: " . $e->getMessage() . "\n";
$this->gitea->getApiClient()->resetCircuitBreaker();
}
}
// Apply branch protection
$this->gitea->setBranchProtection($giteaOrg, $name, 'main', [
'required_reviews' => 1,
'dismiss_stale' => true,
'block_on_rejected' => true,
]);
echo " Branch protection applied\n";
// ── Phase 4: Verification ───────────────────────────────────────
$this->section('Phase 4: Verification');
} catch (\Exception $e) {
echo " Warning: post-processing issue: " . $e->getMessage() . "\n";
$this->gitea->getApiClient()->resetCircuitBreaker();
}
}
$report = "## Migration Report\n\n";
$report .= "**Date:** " . gmdate('Y-m-d H:i:s') . " UTC\n";
$report .= "**Source:** GitHub ({$org})\n";
$report .= "**Destination:** Gitea ({$giteaOrg})\n\n";
// ── Phase 4: Verification ───────────────────────────────────────
$this->section('Phase 4: Verification');
$report .= "### Results\n\n";
$report .= "| Status | Count |\n|--------|-------|\n";
$report .= "| Migrated | " . count($results['migrated']) . " |\n";
$report .= "| Failed | " . count($results['failed']) . " |\n";
$report .= "| Skipped (existing) | " . count($results['skipped']) . " |\n\n";
$report = "## Migration Report\n\n";
$report .= "**Date:** " . gmdate('Y-m-d H:i:s') . " UTC\n";
$report .= "**Source:** GitHub ({$org})\n";
$report .= "**Destination:** Gitea ({$giteaOrg})\n\n";
if (!empty($results['migrated'])) {
$report .= "### Migrated Repositories\n\n";
foreach ($results['migrated'] as $name) {
$report .= "- {$name}\n";
}
$report .= "\n";
}
$report .= "### Results\n\n";
$report .= "| Status | Count |\n|--------|-------|\n";
$report .= "| Migrated | " . count($results['migrated']) . " |\n";
$report .= "| Failed | " . count($results['failed']) . " |\n";
$report .= "| Skipped (existing) | " . count($results['skipped']) . " |\n\n";
if (!empty($results['failed'])) {
$report .= "### Failed Repositories\n\n";
foreach ($results['failed'] as $fail) {
$report .= "- **{$fail['name']}**: {$fail['error']}\n";
}
$report .= "\n";
}
if (!empty($results['migrated'])) {
$report .= "### Migrated Repositories\n\n";
foreach ($results['migrated'] as $name) {
$report .= "- {$name}\n";
}
$report .= "\n";
}
echo $report;
if (!empty($results['failed'])) {
$report .= "### Failed Repositories\n\n";
foreach ($results['failed'] as $fail) {
$report .= "- **{$fail['name']}**: {$fail['error']}\n";
}
$report .= "\n";
}
// Create summary issue on Gitea
try {
$this->gitea->createIssue(
$giteaOrg,
'MokoStandards',
'chore: GitHub → Gitea migration report — ' . count($results['migrated']) . ' repos migrated',
$report,
['labels' => ['automation', 'type: chore']]
);
echo "Migration report issue created on Gitea.\n";
} catch (\Exception $e) {
echo "Could not create report issue: " . $e->getMessage() . "\n";
}
echo $report;
echo "\nMigration complete: " . count($results['migrated']) . " migrated, "
. count($results['failed']) . " failed, "
. count($results['skipped']) . " skipped\n";
// Create summary issue on Gitea
try {
$this->gitea->createIssue($giteaOrg, 'MokoStandards',
'chore: GitHub → Gitea migration report — ' . count($results['migrated']) . ' repos migrated',
$report,
['labels' => ['automation', 'type: chore']]
);
echo "Migration report issue created on Gitea.\n";
} catch (\Exception $e) {
echo "Could not create report issue: " . $e->getMessage() . "\n";
}
echo "\nMigration complete: " . count($results['migrated']) . " migrated, "
. count($results['failed']) . " failed, "
. count($results['skipped']) . " skipped\n";
return count($results['failed']) > 0 ? 1 : 0;
}
return count($results['failed']) > 0 ? 1 : 0;
}
}
$script = new MigrateToGitea('migrate_to_gitea', 'Migrate repositories from GitHub to Gitea');
+52 -39
View File
@@ -1,5 +1,6 @@
#!/usr/bin/env php
<?php
/**
* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
*
@@ -23,7 +24,7 @@ require_once __DIR__ . '/../lib/Enterprise/CliFramework.php';
use MokoEnterprise\{
ApiClient,
AuditLogger,
CLIApp,
CliFramework,
Config,
DefinitionParser,
GitPlatformAdapter,
@@ -50,32 +51,31 @@ use MokoEnterprise\{
* php push_files.php --files=".github/workflows/ci.yml,.github/workflows/codeql-analysis.yml" --repos=MokoCRM,WaasComponent
* php push_files.php --files=templates/foo.txt:docs/foo.txt --repos=MyRepo --direct
*/
class PushFiles extends CLIApp
class PushFiles extends CliFramework
{
public const DEFAULT_ORG = 'MokoConsulting';
public const VERSION = '04.06.00';
private ApiClient $api;
private GitPlatformAdapter $adapter;
private AuditLogger $logger;
private DefinitionParser $defParser;
private ProjectTypeDetector $typeDetector;
private ApiClient $api;
private GitPlatformAdapter $adapter;
private AuditLogger $logger;
private DefinitionParser $defParser;
private ProjectTypeDetector $typeDetector;
/**
* Setup command-line arguments
*/
protected function setupArguments(): array
protected function configure(): void
{
return [
'org:' => 'GitHub organization (default: ' . self::DEFAULT_ORG . ')',
'repos:' => 'Target repositories — comma or space-separated (required)',
'files:' => 'Files to push — destination paths or source:destination pairs, comma/space-separated (required)',
'message:' => 'Custom commit message (optional)',
'branch:' => 'Target branch for direct pushes (default: repo default branch). Ignored unless --direct is set',
'direct' => 'Push directly to target branch instead of creating a PR',
'yes' => 'Auto-confirm without prompting',
'no-issue' => 'Skip creating a tracking issue in each target repository',
];
$this->setDescription('Push files to remote repositories');
$this->addArgument('--org', 'GitHub organization', self::DEFAULT_ORG);
$this->addArgument('--repos', 'Target repos (comma-separated)', '');
$this->addArgument('--files', 'Files to push (comma-separated)', '');
$this->addArgument('--message', 'Custom commit message', '');
$this->addArgument('--branch', 'Target branch for direct pushes', '');
$this->addArgument('--direct', 'Push directly instead of PR', false);
$this->addArgument('--yes', 'Auto-confirm without prompting', false);
$this->addArgument('--no-issue', 'Skip creating tracking issue', false);
}
/**
@@ -89,11 +89,11 @@ class PushFiles extends CLIApp
return 1;
}
$org = $this->getOption('org', self::DEFAULT_ORG);
$reposArg = $this->getOption('repos', '');
$filesArg = $this->getOption('files', '');
$direct = $this->hasOption('direct');
$autoYes = $this->hasOption('yes');
$org = $this->getArgument('--org', self::DEFAULT_ORG);
$reposArg = $this->getArgument('--repos', '');
$filesArg = $this->getArgument('--files', '');
$direct = $this->getArgument('--direct', false);
$autoYes = $this->getArgument('--yes', false);
// Validate required arguments
if (empty($reposArg)) {
@@ -126,7 +126,7 @@ class PushFiles extends CLIApp
}
// Confirm before proceeding
if (!$autoYes && !$this->confirm($repoFileMaps, $direct)) {
if (!$autoYes && !$this->confirmPush($repoFileMaps, $direct)) {
$this->log('❌ Cancelled.', 'INFO');
return 0;
}
@@ -264,7 +264,8 @@ class PushFiles extends CLIApp
// Fall back to live detection
try {
$repoData = $this->api->get("/repos/{$org}/{$repo}");
return $this->typeDetector->detect($repoData, $org, $repo);
$result = $this->typeDetector->detect('.');
return $result['type'] ?? 'default';
} catch (\Exception $e) {
$this->log(" ⚠️ Could not detect platform for {$repo}, using 'default'", 'WARN');
return 'default';
@@ -276,7 +277,7 @@ class PushFiles extends CLIApp
*
* @param array<string, list<array{source: string, destination: string}>> $repoFileMaps
*/
private function confirm(array $repoFileMaps, bool $direct): bool
private function confirmPush(array $repoFileMaps, bool $direct): bool
{
if ($this->quiet) {
return true;
@@ -321,8 +322,8 @@ class PushFiles extends CLIApp
'repos' => [],
];
$customMessage = $this->getOption('message', '');
$targetBranch = $this->getOption('branch', '');
$customMessage = $this->getArgument('--message', '');
$targetBranch = $this->getArgument('--branch', '');
foreach ($repoFileMaps as $repo => $entries) {
$this->log("\n[{$repo}] Pushing " . count($entries) . ' file(s)...', 'INFO');
@@ -356,7 +357,12 @@ class PushFiles extends CLIApp
$prTitle = "chore: push " . count($entries) . " file(s) from MokoStandards";
$prBody = $this->buildPRBody($entries);
$pr = $this->adapter->createPullRequest(
$org, $repo, $prTitle, $branch, $defaultBranch, $prBody,
$org,
$repo,
$prTitle,
$branch,
$defaultBranch,
$prBody,
['assignees' => ['jmiller']]
);
$prNumber = $pr['number'] ?? null;
@@ -371,7 +377,6 @@ class PushFiles extends CLIApp
}
$results['success']++;
} catch (\Exception $e) {
$this->log("{$repo}: " . $e->getMessage(), 'ERROR');
$results['failed']++;
@@ -440,7 +445,13 @@ class PushFiles extends CLIApp
try {
$this->adapter->createOrUpdateFile(
$org, $repo, $destPath, $content, $message, $existingSha, $branch
$org,
$repo,
$destPath,
$content,
$message,
$existingSha,
$branch
);
return true;
} catch (\Exception $e) {
@@ -509,6 +520,7 @@ class PushFiles extends CLIApp
'direction' => 'desc',
]);
$existing = array_values($existing);
if (!empty($existing) && isset($existing[0]['number'])) {
$num = $existing[0]['number'];
$patch = ['title' => $title, 'body' => $body, 'assignees' => ['jmiller']];
@@ -518,7 +530,9 @@ class PushFiles extends CLIApp
$this->api->patch("/repos/{$org}/{$repo}/issues/{$num}", $patch);
try {
$this->api->post("/repos/{$org}/{$repo}/issues/{$num}/labels", ['labels' => $labels]);
} catch (\Exception $le) { /* non-fatal */ }
} catch (\Exception $le) {
/* non-fatal */
}
$this->log(" 📋 Tracking issue #{$num} updated in {$repo}", 'INFO');
} else {
$issue = $this->api->post("/repos/{$org}/{$repo}/issues", [
@@ -543,7 +557,9 @@ class PushFiles extends CLIApp
'body' => $ref . "\n\n" . $currentBody,
]);
}
} catch (\Exception $le) { /* non-fatal */ }
} catch (\Exception $le) {
/* non-fatal */
}
}
} catch (\Exception $e) {
$this->log(" ⚠️ Could not create/update tracking issue in {$repo}: " . $e->getMessage(), 'WARN');
@@ -566,7 +582,7 @@ class PushFiles extends CLIApp
));
$repoList = implode("\n", array_map(fn($r) => "- `{$r}`", $failedRepos));
$fileArgs = $this->getOption('files', '');
$fileArgs = $this->getArgument('--files', '');
$title = "fix: push_files failed for {$failed} repo(s) — action required";
@@ -607,6 +623,7 @@ class PushFiles extends CLIApp
'direction' => 'desc',
]);
$existing = array_values($existing);
if (!empty($existing) && isset($existing[0]['number'])) {
$num = $existing[0]['number'];
$patch = ['title' => $title, 'body' => $body, 'assignees' => ['jmiller']];
@@ -678,10 +695,6 @@ class PushFiles extends CLIApp
// Execute if run directly
if (php_sapi_name() === 'cli' && isset($argv[0]) && realpath($argv[0]) === __FILE__) {
$app = new PushFiles(
'push-files',
'Push one or more specific files to one or more remote repositories',
PushFiles::VERSION
);
$app = new PushFiles();
exit($app->execute());
}
+59 -21
View File
@@ -1,5 +1,6 @@
#!/usr/bin/env php
<?php
/**
* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
* SPDX-License-Identifier: GPL-3.0-or-later
@@ -52,28 +53,53 @@ $tmpBase = sys_get_temp_dir() . '/moko-manifest-push-' . getmypid();
// ── Platform detection heuristics (mirrors RepositorySynchronizer) ───────
$CRM_PLATFORM_REPOS = ['MokoDolibarr', 'MokoDoliMods'];
function detectPlatform(array $repo): string {
function detectPlatform(array $repo): string
{
global $CRM_PLATFORM_REPOS;
$name = $repo['name'] ?? '';
$nameLower = strtolower($name);
$description = strtolower($repo['description'] ?? '');
$topics = $repo['topics'] ?? [];
if (in_array($name, $CRM_PLATFORM_REPOS, true)) return 'crm-platform';
if (in_array('dolibarr-platform', $topics)) return 'crm-platform';
if (in_array('joomla-template', $topics)) return 'joomla-template';
if (in_array('joomla', $topics) || in_array('joomla-extension', $topics)) return 'waas-component';
if (in_array('dolibarr', $topics) || in_array('dolibarr-module', $topics)) return 'crm-module';
if (in_array($name, $CRM_PLATFORM_REPOS, true)) {
return 'crm-platform';
}
if (in_array('dolibarr-platform', $topics)) {
return 'crm-platform';
}
if (in_array('joomla-template', $topics)) {
return 'joomla-template';
}
if (in_array('joomla', $topics) || in_array('joomla-extension', $topics)) {
return 'waas-component';
}
if (in_array('dolibarr', $topics) || in_array('dolibarr-module', $topics)) {
return 'crm-module';
}
if (str_contains($nameLower, 'template') && (str_contains($nameLower, 'joomla') || str_contains($nameLower, 'tpl'))) return 'joomla-template';
if (str_contains($nameLower, 'joomla') || str_contains($nameLower, 'waas')) return 'waas-component';
if (str_contains($nameLower, 'doli') || str_contains($nameLower, 'crm')) return 'crm-module';
if (str_contains($nameLower, 'template') && (str_contains($nameLower, 'joomla') || str_contains($nameLower, 'tpl'))) {
return 'joomla-template';
}
if (str_contains($nameLower, 'joomla') || str_contains($nameLower, 'waas')) {
return 'waas-component';
}
if (str_contains($nameLower, 'doli') || str_contains($nameLower, 'crm')) {
return 'crm-module';
}
if (str_contains($description, 'joomla template')) return 'joomla-template';
if (str_contains($description, 'joomla') || str_contains($description, 'component')) return 'waas-component';
if (str_contains($description, 'dolibarr') || str_contains($description, 'module')) return 'crm-module';
if (str_contains($description, 'joomla template')) {
return 'joomla-template';
}
if (str_contains($description, 'joomla') || str_contains($description, 'component')) {
return 'waas-component';
}
if (str_contains($description, 'dolibarr') || str_contains($description, 'module')) {
return 'crm-module';
}
if (str_contains($nameLower, 'standard')) return 'standards-repository';
if (str_contains($nameLower, 'standard')) {
return 'standards-repository';
}
return 'default-repository';
}
@@ -81,7 +107,8 @@ function detectPlatform(array $repo): string {
* Safe shell execution — uses proc_open with explicit arguments to avoid injection.
* @return array{int, string}
*/
function safeExec(string $command, string $cwd = '.'): array {
function safeExec(string $command, string $cwd = '.'): array
{
$proc = proc_open(
$command,
[1 => ['pipe', 'w'], 2 => ['pipe', 'w']],
@@ -100,8 +127,11 @@ function safeExec(string $command, string $cwd = '.'): array {
}
/** Recursively remove a directory (cross-platform). */
function rmTree(string $dir): void {
if (!is_dir($dir)) return;
function rmTree(string $dir): void
{
if (!is_dir($dir)) {
return;
}
$it = new RecursiveDirectoryIterator($dir, FilesystemIterator::SKIP_DOTS);
$files = new RecursiveIteratorIterator($it, RecursiveIteratorIterator::CHILD_FIRST);
foreach ($files as $file) {
@@ -120,7 +150,8 @@ function rmTree(string $dir): void {
* Run a git command safely in a given working directory.
* @return array{int, string}
*/
function gitCmd(string $workDir, string ...$args): array {
function gitCmd(string $workDir, string ...$args): array
{
$cmd = 'git';
foreach ($args as $a) {
$cmd .= ' ' . escapeshellarg($a);
@@ -129,7 +160,8 @@ function gitCmd(string $workDir, string ...$args): array {
}
// ── Fetch all repos via API ──────────────────────────────────────────────
function fetchRepos(string $url, string $org, string $token): array {
function fetchRepos(string $url, string $org, string $token): array
{
$repos = [];
$page = 1;
do {
@@ -149,7 +181,9 @@ function fetchRepos(string $url, string $org, string $token): array {
}
$batch = json_decode($body, true);
if (empty($batch)) break;
if (empty($batch)) {
break;
}
$repos = array_merge($repos, $batch);
$page++;
} while (count($batch) >= 50);
@@ -161,7 +195,9 @@ function fetchRepos(string $url, string $org, string $token): array {
echo "=== MokoStandards XML Manifest Push ===\n";
echo "Org: {$giteaOrg}\n";
echo "Mode: " . ($dryRun ? "DRY RUN" : "LIVE") . "\n";
if ($repoFilter) echo "Filter: {$repoFilter}\n";
if ($repoFilter) {
echo "Filter: {$repoFilter}\n";
}
echo "\n";
if (empty($token)) {
@@ -176,7 +212,9 @@ $stats = ['created' => 0, 'updated' => 0, 'skipped' => 0, 'failed' => 0];
foreach ($repos as $repo) {
$name = $repo['name'];
if ($repoFilter && $name !== $repoFilter) continue;
if ($repoFilter && $name !== $repoFilter) {
continue;
}
if (in_array($name, $skipRepos, true)) {
echo " SKIP {$name} (excluded)\n";
$stats['skipped']++;
+115 -98
View File
@@ -1,5 +1,6 @@
#!/usr/bin/env php
<?php
/**
* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
*
@@ -20,7 +21,7 @@ declare(strict_types=1);
require_once __DIR__ . '/../vendor/autoload.php';
require_once __DIR__ . '/../lib/Enterprise/CliFramework.php';
use MokoEnterprise\{ApiClient, AuditLogger, CLIApp, Config, GitPlatformAdapter, MetricsCollector, PlatformAdapterFactory};
use MokoEnterprise\{ApiClient, AuditLogger, CliFramework, Config, GitPlatformAdapter, MetricsCollector, PlatformAdapterFactory};
/**
* Enterprise Repository Cleanup
@@ -35,7 +36,7 @@ use MokoEnterprise\{ApiClient, AuditLogger, CLIApp, Config, GitPlatformAdapter,
* 7. Verify and provision standard labels
* 8. Version drift detection
*/
class RepoCleanup extends CLIApp
class RepoCleanup extends CliFramework
{
private const VERSION = '04.06.00';
private const SYNC_PREFIX = 'chore/sync-mokostandards-';
@@ -55,44 +56,36 @@ class RepoCleanup extends CLIApp
'deploy-rs.yml',
];
private ApiClient $api;
private GitPlatformAdapter $adapter;
private AuditLogger $logger;
private MetricsCollector $metrics;
private bool $dryRun = false;
private float $startTime;
private ApiClient $api;
private GitPlatformAdapter $adapter;
protected bool $dryRun = false;
private float $startTime;
protected function configure(): void
{
$this->setName('repo-cleanup');
$this->setDescription('Enterprise repository cleanup — branches, PRs, issues, workflows, labels, logs');
$this->setVersion(self::VERSION);
$this->addOption('org', 'GitHub organization', 'MokoConsulting');
$this->addOption('repos', 'Specific repositories (space-separated)', '');
$this->addOption('skip-archived', 'Skip archived repositories', false);
$this->addOption('close-issues', 'Close resolved tracking issues (merged PR = done)', false);
$this->addOption('lock-old-issues', 'Lock issues closed >30 days', false);
$this->addOption('clean-workflows', 'Delete cancelled/stale workflow runs', false);
$this->addOption('clean-logs', 'Delete workflow run logs older than --log-days', false);
$this->addOption('log-days', 'Days to keep logs (default: 30)', '30');
$this->addOption('delete-retired', 'Delete retired workflow files from repos', false);
$this->addOption('check-labels', 'Verify mokostandards label exists', false);
$this->addOption('check-drift', 'Check for version drift against README.md', false);
$this->addOption('all', 'Run all cleanup operations', false);
$this->addOption('yes', 'Auto-confirm prompts', false);
$this->addOption('dry-run', 'Preview changes without making them', false);
$this->addOption('verbose', 'Show detailed output', false);
$this->addOption('quiet', 'Suppress non-error output', false);
$this->addOption('json', 'Output results as JSON', false);
$this->setDescription('Enterprise repository cleanup');
$this->addArgument('--org', 'GitHub organization', 'MokoConsulting');
$this->addArgument('--repos', 'Specific repos (space-separated)', '');
$this->addArgument('--skip-archived', 'Skip archived repos', false);
$this->addArgument('--close-issues', 'Close resolved tracking issues', false);
$this->addArgument('--lock-old-issues', 'Lock issues closed >30 days', false);
$this->addArgument('--clean-workflows', 'Delete stale workflow runs', false);
$this->addArgument('--clean-logs', 'Delete old workflow logs', false);
$this->addArgument('--log-days', 'Days to keep logs', '30');
$this->addArgument('--delete-retired', 'Delete retired workflows', false);
$this->addArgument('--check-labels', 'Verify labels exist', false);
$this->addArgument('--check-drift', 'Check version drift', false);
$this->addArgument('--all', 'Run all operations', false);
$this->addArgument('--yes', 'Auto-confirm', false);
$this->addArgument('--json', 'Output as JSON', false);
}
protected function execute(): int
protected function run(): int
{
$this->startTime = microtime(true);
$org = $this->getOption('org', 'MokoConsulting');
$this->dryRun = (bool) $this->getOption('dry-run', false);
$runAll = (bool) $this->getOption('all', false);
$org = $this->getArgument('--org', 'MokoConsulting');
$this->dryRun = (bool) $this->getArgument('--dry-run', false);
$runAll = (bool) $this->getArgument('--all', false);
$config = Config::load();
@@ -100,24 +93,22 @@ class RepoCleanup extends CLIApp
$this->adapter = PlatformAdapterFactory::create($config);
$this->api = $this->adapter->getApiClient();
} catch (\Exception $e) {
$this->error('Failed to initialize platform adapter: ' . $e->getMessage());
$this->errorMsg('Failed to initialize platform adapter: ' . $e->getMessage());
return 1;
}
$this->logger = new AuditLogger('repo_cleanup');
$this->metrics = new MetricsCollector('repo_cleanup');
$this->log("🧹 MokoStandards Repository Cleanup v" . self::VERSION);
$this->log("Organization: {$org}");
$this->log("Current sync branch: " . self::CURRENT_BRANCH);
$this->logMsg("🧹 MokoStandards Repository Cleanup v" . self::VERSION);
$this->logMsg("Organization: {$org}");
$this->logMsg("Current sync branch: " . self::CURRENT_BRANCH);
if ($this->dryRun) {
$this->log("⚠️ DRY RUN — no changes will be made");
$this->logMsg("⚠️ DRY RUN — no changes will be made");
}
$this->log('');
$this->logMsg('');
$repos = $this->fetchRepositories($org);
$this->log("Found " . count($repos) . " repositories");
$this->log('');
$this->logMsg("Found " . count($repos) . " repositories");
$this->logMsg('');
$results = [
'repos_processed' => 0,
@@ -139,7 +130,7 @@ class RepoCleanup extends CLIApp
$name = $repo['name'];
$num = $i + 1;
$total = count($repos);
$this->log("[{$num}/{$total}] {$name}");
$this->logMsg("[{$num}/{$total}] {$name}");
$results['repos_processed']++;
try {
@@ -150,37 +141,37 @@ class RepoCleanup extends CLIApp
$cleaned = $this->cleanBranches($org, $name, $results) || $cleaned;
// Optional: close resolved issues
if ($runAll || $this->getOption('close-issues', false)) {
if ($runAll || $this->getArgument('--close-issues', false)) {
$cleaned = $this->closeResolvedIssues($org, $name, $results) || $cleaned;
}
// Optional: lock old closed issues
if ($runAll || $this->getOption('lock-old-issues', false)) {
if ($runAll || $this->getArgument('--lock-old-issues', false)) {
$cleaned = $this->lockOldIssues($org, $name, $results) || $cleaned;
}
// Optional: delete retired workflow files
if ($runAll || $this->getOption('delete-retired', false)) {
if ($runAll || $this->getArgument('--delete-retired', false)) {
$cleaned = $this->deleteRetiredWorkflows($org, $name, $results) || $cleaned;
}
// Optional: clean workflow runs
if ($runAll || $this->getOption('clean-workflows', false)) {
if ($runAll || $this->getArgument('--clean-workflows', false)) {
$cleaned = $this->cleanWorkflowRuns($org, $name, $results) || $cleaned;
}
// Optional: clean old logs
if ($runAll || $this->getOption('clean-logs', false)) {
if ($runAll || $this->getArgument('--clean-logs', false)) {
$cleaned = $this->cleanOldLogs($org, $name, $results) || $cleaned;
}
// Optional: check labels
if ($runAll || $this->getOption('check-labels', false)) {
if ($runAll || $this->getArgument('--check-labels', false)) {
$this->checkLabels($org, $name, $results);
}
// Optional: check version drift
if ($runAll || $this->getOption('check-drift', false)) {
if ($runAll || $this->getArgument('--check-drift', false)) {
$this->checkVersionDrift($org, $name, $results);
}
@@ -188,32 +179,32 @@ class RepoCleanup extends CLIApp
$results['repos_cleaned']++;
}
} catch (\Exception $e) {
$this->error("{$name}: " . $e->getMessage());
$this->errorMsg("{$name}: " . $e->getMessage());
$results['errors']++;
}
}
$duration = round(microtime(true) - $this->startTime, 1);
$this->log('');
$this->log('============================================================');
$this->log("🧹 Cleanup Complete ({$duration}s)");
$this->log('============================================================');
$this->log("Repos processed: {$results['repos_processed']}");
$this->log("Repos with changes: {$results['repos_cleaned']}");
$this->log("Branches deleted: {$results['branches_deleted']}");
$this->log("PRs closed: {$results['prs_closed']}");
$this->log("Issues closed: {$results['issues_closed']}");
$this->log("Issues locked: {$results['issues_locked']}");
$this->log("Retired files: {$results['retired_files']}");
$this->log("Workflow runs: {$results['runs_deleted']}");
$this->log("Logs cleaned: {$results['logs_deleted']}");
$this->log("Labels missing: {$results['labels_missing']}");
$this->log("Version drift: {$results['version_drift']}");
$this->log("Errors: {$results['errors']}");
$this->log('============================================================');
$this->logMsg('');
$this->logMsg('============================================================');
$this->logMsg("🧹 Cleanup Complete ({$duration}s)");
$this->logMsg('============================================================');
$this->logMsg("Repos processed: {$results['repos_processed']}");
$this->logMsg("Repos with changes: {$results['repos_cleaned']}");
$this->logMsg("Branches deleted: {$results['branches_deleted']}");
$this->logMsg("PRs closed: {$results['prs_closed']}");
$this->logMsg("Issues closed: {$results['issues_closed']}");
$this->logMsg("Issues locked: {$results['issues_locked']}");
$this->logMsg("Retired files: {$results['retired_files']}");
$this->logMsg("Workflow runs: {$results['runs_deleted']}");
$this->logMsg("Logs cleaned: {$results['logs_deleted']}");
$this->logMsg("Labels missing: {$results['labels_missing']}");
$this->logMsg("Version drift: {$results['version_drift']}");
$this->logMsg("Errors: {$results['errors']}");
$this->logMsg('============================================================');
if ($this->getOption('json', false)) {
if ($this->getArgument('--json', false)) {
$results['duration_seconds'] = $duration;
echo json_encode($results, JSON_PRETTY_PRINT) . "\n";
}
@@ -225,8 +216,8 @@ class RepoCleanup extends CLIApp
private function fetchRepositories(string $org): array
{
$specificRepos = trim((string) $this->getOption('repos', ''));
$skipArchived = (bool) $this->getOption('skip-archived', false);
$specificRepos = trim((string) $this->getArgument('--repos', ''));
$skipArchived = (bool) $this->getArgument('--skip-archived', false);
if (!empty($specificRepos)) {
$names = preg_split('/[\s,]+/', $specificRepos);
@@ -263,18 +254,22 @@ class RepoCleanup extends CLIApp
if (($pr['number'] ?? 0) > 0 && !$this->dryRun) {
$this->api->patch("/repos/{$org}/{$repo}/pulls/{$pr['number']}", ['state' => 'closed']);
}
$this->log(" 🔒 Closed PR #{$pr['number']} ({$name})");
$this->logMsg(" 🔒 Closed PR #{$pr['number']} ({$name})");
$results['prs_closed']++;
$changed = true;
}
} catch (\Exception $e) { /* non-fatal */ }
} catch (\Exception $e) {
/* non-fatal */
}
if (!$this->dryRun) {
try {
$this->api->delete("/repos/{$org}/{$repo}/git/refs/heads/{$name}");
} catch (\Exception $e) { continue; }
} catch (\Exception $e) {
continue;
}
}
$this->log(" 🗑️ Deleted branch: {$name}");
$this->logMsg(" 🗑️ Deleted branch: {$name}");
$results['branches_deleted']++;
$changed = true;
}
@@ -290,7 +285,9 @@ class RepoCleanup extends CLIApp
$issues = $this->api->get("/repos/{$org}/{$repo}/issues", [
'labels' => $label, 'state' => 'open', 'per_page' => 10,
]);
} catch (\Exception $e) { continue; }
} catch (\Exception $e) {
continue;
}
foreach ($issues as $issue) {
$num = $issue['number'] ?? 0;
@@ -305,11 +302,13 @@ class RepoCleanup extends CLIApp
'state' => 'closed', 'state_reason' => 'completed',
]);
}
$this->log(" ✅ Closed issue #{$num} (PR #{$prNum} merged)");
$this->logMsg(" ✅ Closed issue #{$num} (PR #{$prNum} merged)");
$results['issues_closed']++;
$changed = true;
}
} catch (\Exception $e) { /* non-fatal */ }
} catch (\Exception $e) {
/* non-fatal */
}
}
}
}
@@ -325,28 +324,34 @@ class RepoCleanup extends CLIApp
$issues = $this->api->get("/repos/{$org}/{$repo}/issues", [
'state' => 'closed', 'per_page' => 50, 'sort' => 'updated', 'direction' => 'asc',
]);
} catch (\Exception $e) { return false; }
} catch (\Exception $e) {
return false;
}
foreach ($issues as $issue) {
$closedAt = $issue['closed_at'] ?? '';
$locked = $issue['locked'] ?? false;
$num = $issue['number'] ?? 0;
if ($locked || $closedAt > $cutoff || $num === 0) continue;
if ($locked || $closedAt > $cutoff || $num === 0) {
continue;
}
if (!$this->dryRun) {
try {
$this->api->put("/repos/{$org}/{$repo}/issues/{$num}/lock", [
'lock_reason' => 'resolved',
]);
} catch (\Exception $e) { continue; }
} catch (\Exception $e) {
continue;
}
}
$results['issues_locked']++;
$changed = true;
}
if ($results['issues_locked'] > 0) {
$this->log(" 🔒 Locked {$results['issues_locked']} old closed issue(s)");
$this->logMsg(" 🔒 Locked {$results['issues_locked']} old closed issue(s)");
}
return $changed;
}
@@ -358,7 +363,9 @@ class RepoCleanup extends CLIApp
try {
$repoInfo = $this->api->get("/repos/{$org}/{$repo}");
$defaultBranch = $repoInfo['default_branch'] ?? 'main';
} catch (\Exception $e) { /* fallback to main */ }
} catch (\Exception $e) {
/* fallback to main */
}
// Check both workflow directories for retired workflows (supports dual-platform repos)
$wfDirs = array_unique(['.github/workflows', '.mokogitea/workflows', $this->adapter->getWorkflowDir()]);
@@ -368,7 +375,9 @@ class RepoCleanup extends CLIApp
try {
$file = $this->api->get("/repos/{$org}/{$repo}/contents/{$path}");
$sha = $file['sha'] ?? '';
if (empty($sha)) continue;
if (empty($sha)) {
continue;
}
if (!$this->dryRun) {
$this->api->delete("/repos/{$org}/{$repo}/contents/{$path}", [
@@ -377,7 +386,7 @@ class RepoCleanup extends CLIApp
'branch' => $defaultBranch,
]);
}
$this->log(" Deleted retired: {$wf} (from {$wfDir})");
$this->logMsg(" Deleted retired: {$wf} (from {$wfDir})");
$results['retired_files']++;
$changed = true;
} catch (\Exception $e) {
@@ -404,13 +413,17 @@ class RepoCleanup extends CLIApp
$this->api->delete("/repos/{$org}/{$repo}/actions/runs/{$id}");
$results['runs_deleted']++;
$changed = true;
} catch (\Exception $e) { $this->api->resetCircuitBreaker(); }
} catch (\Exception $e) {
$this->api->resetCircuitBreaker();
}
}
}
} catch (\Exception $e) { /* non-fatal */ }
} catch (\Exception $e) {
/* non-fatal */
}
}
if ($results['runs_deleted'] > 0) {
$this->log(" 🔄 Cleaned {$results['runs_deleted']} workflow run(s)");
$this->logMsg(" 🔄 Cleaned {$results['runs_deleted']} workflow run(s)");
}
return $changed;
}
@@ -418,7 +431,7 @@ class RepoCleanup extends CLIApp
private function cleanOldLogs(string $org, string $repo, array &$results): bool
{
$changed = false;
$days = (int) $this->getOption('log-days', '30');
$days = (int) $this->getArgument('--log-days', '30');
$cutoff = date('Y-m-d\TH:i:s\Z', strtotime("-{$days} days"));
try {
@@ -432,13 +445,17 @@ class RepoCleanup extends CLIApp
$this->api->delete("/repos/{$org}/{$repo}/actions/runs/{$id}/logs");
$results['logs_deleted']++;
$changed = true;
} catch (\Exception $e) { $this->api->resetCircuitBreaker(); }
} catch (\Exception $e) {
$this->api->resetCircuitBreaker();
}
}
}
} catch (\Exception $e) { /* non-fatal */ }
} catch (\Exception $e) {
/* non-fatal */
}
if ($results['logs_deleted'] > 0) {
$this->log(" 📋 Cleaned {$results['logs_deleted']} old log(s)");
$this->logMsg(" 📋 Cleaned {$results['logs_deleted']} old log(s)");
}
return $changed;
}
@@ -448,7 +465,7 @@ class RepoCleanup extends CLIApp
try {
$this->api->get("/repos/{$org}/{$repo}/labels/mokostandards");
} catch (\Exception $e) {
$this->log(" ⚠️ Missing 'mokostandards' label");
$this->logMsg(" ⚠️ Missing 'mokostandards' label");
$results['labels_missing']++;
$this->api->resetCircuitBreaker();
}
@@ -468,7 +485,7 @@ class RepoCleanup extends CLIApp
$mokoContent = base64_decode($mokoFile['content'] ?? '');
if (preg_match('/standards_version:\s*(\d{2}\.\d{2}\.\d{2})/m', $mokoContent, $vm)) {
if ($vm[1] !== self::VERSION) {
$this->log(" ⚠️ Standards drift: {$vm[1]} (expected " . self::VERSION . ")");
$this->logMsg(" ⚠️ Standards drift: {$vm[1]} (expected " . self::VERSION . ")");
$results['version_drift']++;
}
}
@@ -483,14 +500,14 @@ class RepoCleanup extends CLIApp
// ─── Helpers ─────────────────────────────────────────────────────────
private function log(string $message): void
private function logMsg(string $message): void
{
if (!$this->getOption('quiet', false)) {
if (!$this->quiet) {
echo $message . "\n";
}
}
private function error(string $message): void
private function errorMsg(string $message): void
{
fwrite(STDERR, $message . "\n");
}
+159 -40
View File
@@ -88,45 +88,76 @@ require_once $autoloader;
*/
const COMMAND_MAP = [
// Automation
'sync' => 'api/automation/bulk_sync.php',
'sync' => 'automation/bulk_sync.php',
// Maintenance
'inventory' => 'api/maintenance/update_repo_inventory.php',
'inventory' => 'maintenance/update_repo_inventory.php',
// Validation — general
'health' => 'api/validate/check_repo_health.php',
'check:syntax' => 'api/validate/check_php_syntax.php',
'check:version' => 'api/validate/check_version_consistency.php',
'check:changelog' => 'api/validate/check_changelog.php',
'check:structure' => 'api/validate/check_structure.php',
'check:headers' => 'api/validate/check_license_headers.php',
'check:secrets' => 'api/validate/check_no_secrets.php',
'check:tabs' => 'api/validate/check_tabs.php',
'check:paths' => 'api/validate/check_paths.php',
'check:xml' => 'api/validate/check_xml_wellformed.php',
'check:enterprise' => 'api/validate/check_enterprise_readiness.php',
'health' => 'validate/check_repo_health.php',
'check:syntax' => 'validate/check_php_syntax.php',
'check:version' => 'validate/check_version_consistency.php',
'check:changelog' => 'validate/check_changelog.php',
'check:structure' => 'validate/check_structure.php',
'check:headers' => 'validate/check_license_headers.php',
'check:secrets' => 'validate/check_no_secrets.php',
'check:tabs' => 'validate/check_tabs.php',
'check:paths' => 'validate/check_paths.php',
'check:xml' => 'validate/check_xml_wellformed.php',
'check:enterprise' => 'validate/check_enterprise_readiness.php',
// Validation — platform-specific
'check:dolibarr' => 'api/validate/check_dolibarr_module.php',
'check:joomla' => 'api/validate/check_joomla_manifest.php',
'check:language' => 'api/validate/check_language_structure.php',
'check:dolibarr' => 'validate/check_dolibarr_module.php',
'check:joomla' => 'validate/check_joomla_manifest.php',
'check:language' => 'validate/check_language_structure.php',
'check:client' => 'validate/check_client_theme.php',
'check:wiki' => 'validate/check_wiki_health.php',
// Detection
'detect' => 'api/validate/auto_detect_platform.php',
'detect' => 'validate/auto_detect_platform.php',
// Org-wide
'drift' => 'api/validate/scan_drift.php',
'drift' => 'validate/scan_drift.php',
// Release
'release' => 'api/cli/release.php',
'release' => 'cli/release.php',
'release:notes' => 'cli/release_notes.php',
'release:validate' => 'cli/release_validate.php',
'release:cascade' => 'cli/release_cascade.php',
'release:manage' => 'cli/release_manage.php',
// CLI utilities (used by workflows — centralized logic)
'version:read' => 'api/cli/version_read.php',
'version:bump' => 'api/cli/version_bump.php',
'version:propagate' => 'api/maintenance/update_version_from_readme.php',
'version:set-platform' => 'api/cli/version_set_platform.php',
'platform:detect' => 'api/cli/platform_detect.php',
'release:notes' => 'api/cli/release_notes.php',
// Version management
'version:read' => 'cli/version_read.php',
'version:bump' => 'cli/version_bump.php',
'version:propagate' => 'maintenance/update_version_from_readme.php',
'version:set-platform' => 'cli/version_set_platform.php',
// Build & package
'build:package' => 'cli/package_build.php',
'build:joomla' => 'cli/joomla_build.php',
'build:updates-xml' => 'cli/updates_xml_build.php',
// Platform detection
'platform:detect' => 'cli/platform_detect.php',
'manifest:read' => 'cli/manifest_read.php',
// Repository management
'repo:create' => 'cli/create_repo.php',
'repo:archive' => 'cli/archive_repo.php',
'repo:scaffold-client' => 'cli/scaffold_client.php',
'repo:provision' => 'cli/client_provision.php',
// Bulk operations
'bulk:push-workflow' => 'cli/bulk_workflow_push.php',
'bulk:trigger' => 'cli/bulk_workflow_trigger.php',
'bulk:sync-rulesets' => 'cli/sync_rulesets.php',
// Monitoring & dashboards
'dashboard' => 'cli/client_dashboard.php',
'grafana' => 'cli/grafana_dashboard.php',
'client:inventory' => 'cli/client_inventory.php',
// Module validation
'validate:module' => 'bin/validate-module',
];
@@ -210,24 +241,112 @@ function printCommandList(): void
{
echo "Available commands:\n\n";
$groups = [
'Automation' => ['sync'],
'Maintenance' => ['inventory'],
'Validation (general)' => ['health', 'check:syntax', 'check:version', 'check:changelog',
'check:structure', 'check:headers', 'check:secrets',
'check:tabs', 'check:paths', 'check:xml', 'check:enterprise'],
'Validation (platform)' => ['check:dolibarr', 'check:joomla', 'check:language', 'detect'],
'Organisation-wide' => ['drift'],
];
// Auto-group by command prefix or comment-based sections
$groups = [];
foreach (COMMAND_MAP as $cmd => $path) {
if (str_contains($cmd, ':')) {
$prefix = explode(':', $cmd)[0];
$groupName = match ($prefix) {
'check' => 'Validation',
'version' => 'Version',
'release' => 'Release',
'build' => 'Build',
'platform', 'manifest' => 'Platform',
'repo' => 'Repository',
'bulk' => 'Bulk Operations',
'client' => 'Client Management',
'validate' => 'Module Validation',
default => ucfirst($prefix),
};
} else {
$groupName = match ($cmd) {
'sync' => 'Automation',
'inventory' => 'Maintenance',
'health' => 'Validation',
'detect', 'drift' => 'Validation',
'dashboard', 'grafana' => 'Monitoring',
default => 'Other',
};
}
$groups[$groupName][$cmd] = $path;
}
// Load plugin commands
$pluginCommands = loadPluginCommands();
if (!empty($pluginCommands)) {
foreach ($pluginCommands as $cmd => $info) {
$type = $info['plugin'] ?? 'Plugin';
$groups["Plugin: {$type}"][$cmd] = $info['description'] ?? '';
}
}
ksort($groups);
foreach ($groups as $group => $commands) {
echo " {$group}:\n";
foreach ($commands as $cmd) {
printf(" %-22s %s\n", $cmd, COMMAND_MAP[$cmd]);
echo " \033[1m{$group}\033[0m\n";
ksort($commands);
foreach ($commands as $cmd => $path) {
printf(" \033[36m%-26s\033[0m %s\n", $cmd, basename($path));
}
echo "\n";
}
echo "Run: php bin/moko <command> --help for command-specific options.\n";
echo "All platforms: php bin/moko <command>\n";
$total = count(COMMAND_MAP) + count($pluginCommands);
echo "{$total} command(s) available.\n";
echo "Run: php bin/moko <command> --help\n";
}
/**
* Load commands from registered plugins.
*
* @return array<string, array{plugin: string, description: string, script: string}>
*/
function loadPluginCommands(): array
{
$pluginDir = dirname(__DIR__) . '/lib/Enterprise/Plugins';
if (!is_dir($pluginDir)) {
return [];
}
$commands = [];
foreach (glob("{$pluginDir}/*Plugin.php") as $file) {
$className = 'MokoEnterprise\\Plugins\\'
. pathinfo($file, PATHINFO_FILENAME);
if (!class_exists($className)) {
continue;
}
try {
$ref = new \ReflectionClass($className);
if ($ref->isAbstract()) {
continue;
}
$plugin = $ref->newInstanceWithoutConstructor();
$pluginCmds = $plugin->getCommands();
foreach ($pluginCmds as $cmd) {
$name = $cmd['name'] ?? '';
if ($name === '') {
continue;
}
$type = method_exists($plugin, 'getProjectType')
? $plugin->getProjectType() : 'unknown';
$commands[$name] = [
'plugin' => $type,
'description' => $cmd['description'] ?? '',
'script' => $cmd['script'] ?? '',
];
}
} catch (\Throwable $e) {
// Skip plugins that can't be instantiated
continue;
}
}
return $commands;
}
+387
View File
@@ -0,0 +1,387 @@
#!/usr/bin/env php
<?php
/* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
*
* This file is part of a Moko Consulting project.
*
* SPDX-License-Identifier: GPL-3.0-or-later
*
* FILE INFORMATION
* DEFGROUP: moko-platform.CLI
* INGROUP: moko-platform
* REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform
* PATH: /cli/bulk_workflow_push.php
* VERSION: 01.00.00
* BRIEF: Push a workflow file to all governed repos via the Gitea Contents API
*/
declare(strict_types=1);
final class BulkWorkflowPush
{
private string $giteaUrl = 'https://git.mokoconsulting.tech';
private string $token = '';
private string $org = '';
private string $workflowFile = '';
private string $destPath = '';
private string $branch = 'main';
private bool $dryRun = false;
private int $updated = 0;
private int $created = 0;
private int $skipped = 0;
private int $errors = 0;
public function run(): int
{
$this->parseArgs();
if ($this->token === '') {
$this->log('ERROR: --token is required.');
$this->printUsage();
return 1;
}
if ($this->workflowFile === '') {
$this->log('ERROR: --file is required.');
$this->printUsage();
return 1;
}
if (!file_exists($this->workflowFile)) {
$this->log("ERROR: File not found: {$this->workflowFile}");
return 1;
}
if ($this->org === '') {
$this->log('ERROR: --org is required.');
$this->printUsage();
return 1;
}
if ($this->destPath === '') {
$this->destPath = '.mokogitea/workflows/' . basename($this->workflowFile);
}
$localContent = file_get_contents($this->workflowFile);
if ($localContent === false) {
$this->log("ERROR: Could not read file: {$this->workflowFile}");
return 1;
}
$this->log("Pushing: {$this->workflowFile}");
$this->log(" -> {$this->destPath} (branch: {$this->branch})");
$this->log(" -> Org: {$this->org} @ {$this->giteaUrl}");
if ($this->dryRun) {
$this->log('[DRY RUN] No changes will be made.');
}
$this->log('');
$repos = $this->fetchOrgRepos();
if ($repos === null) {
return 1;
}
$this->log("Found " . count($repos) . " repo(s) in \"{$this->org}\".");
$this->log('');
$this->log(sprintf('%-45s | %s', 'Repo', 'Status'));
$this->log(str_repeat('-', 70));
$encodedContent = base64_encode($localContent);
foreach ($repos as $repo) {
$this->pushToRepo($repo, $encodedContent, $localContent);
}
$this->log('');
$this->log("Done: {$this->created} created, {$this->updated} updated, "
. "{$this->skipped} skipped, {$this->errors} error(s).");
return $this->errors > 0 ? 1 : 0;
}
private function pushToRepo(
string $repoFullName,
string $encodedContent,
string $localContent
): void {
[$owner, $repoName] = explode('/', $repoFullName, 2);
$existing = $this->apiRequest(
'GET',
"/api/v1/repos/{$owner}/{$repoName}/contents/"
. "{$this->destPath}?ref={$this->branch}"
);
if ($existing['code'] === 200) {
$data = json_decode($existing['body'], true);
$remoteSha = $data['sha'] ?? '';
$remoteContent = base64_decode($data['content'] ?? '');
if ($remoteContent === $localContent) {
$this->log(sprintf(
'%-45s | %s',
$repoFullName,
'IDENTICAL (skipped)'
));
$this->skipped++;
return;
}
if ($this->dryRun) {
$this->log(sprintf(
'%-45s | %s',
$repoFullName,
'WOULD UPDATE'
));
$this->updated++;
return;
}
$payload = json_encode([
'content' => $encodedContent,
'sha' => $remoteSha,
'message' => "chore: sync {$this->destPath} "
. "from moko-platform [skip ci]",
'branch' => $this->branch,
]);
$response = $this->apiRequest(
'PUT',
"/api/v1/repos/{$owner}/{$repoName}/contents/"
. $this->destPath,
$payload
);
if ($response['code'] === 200) {
$this->log(sprintf(
'%-45s | %s',
$repoFullName,
'UPDATED'
));
$this->updated++;
} else {
$this->log(sprintf(
'%-45s | %s',
$repoFullName,
"ERROR (HTTP {$response['code']})"
));
$this->errors++;
}
} elseif ($existing['code'] === 404) {
if ($this->dryRun) {
$this->log(sprintf(
'%-45s | %s',
$repoFullName,
'WOULD CREATE'
));
$this->created++;
return;
}
$payload = json_encode([
'content' => $encodedContent,
'message' => "chore: add {$this->destPath} "
. "from moko-platform [skip ci]",
'branch' => $this->branch,
]);
$response = $this->apiRequest(
'POST',
"/api/v1/repos/{$owner}/{$repoName}/contents/"
. $this->destPath,
$payload
);
if ($response['code'] === 201) {
$this->log(sprintf(
'%-45s | %s',
$repoFullName,
'CREATED'
));
$this->created++;
} else {
$this->log(sprintf(
'%-45s | %s',
$repoFullName,
"ERROR (HTTP {$response['code']})"
));
$this->errors++;
}
} else {
$this->log(sprintf(
'%-45s | %s',
$repoFullName,
"ERROR (HTTP {$existing['code']})"
));
$this->errors++;
}
}
private function fetchOrgRepos(): ?array
{
$this->log("Fetching repos from org: {$this->org}");
$page = 1;
$repos = [];
while (true) {
$response = $this->apiRequest(
'GET',
"/api/v1/orgs/{$this->org}/repos?"
. "limit=50&page={$page}"
);
if ($response['code'] < 200 || $response['code'] >= 300) {
if ($page === 1) {
$this->log("ERROR: Could not fetch repos "
. "(HTTP {$response['code']}).");
return null;
}
break;
}
$data = json_decode($response['body'], true);
if (!is_array($data) || count($data) === 0) {
break;
}
foreach ($data as $repo) {
if (!empty($repo['archived'])) {
continue;
}
$fullName = $repo['full_name'] ?? '';
if ($fullName !== '') {
$repos[] = $fullName;
}
}
$page++;
}
return $repos;
}
private function parseArgs(): void
{
$args = $_SERVER['argv'] ?? [];
$count = count($args);
for ($i = 1; $i < $count; $i++) {
switch ($args[$i]) {
case '--gitea-url':
$this->giteaUrl = rtrim($args[++$i] ?? '', '/');
break;
case '--token':
$this->token = $args[++$i] ?? '';
break;
case '--org':
$this->org = $args[++$i] ?? '';
break;
case '--file':
$this->workflowFile = $args[++$i] ?? '';
break;
case '--dest':
$this->destPath = $args[++$i] ?? '';
break;
case '--branch':
$this->branch = $args[++$i] ?? 'main';
break;
case '--dry-run':
$this->dryRun = true;
break;
case '--help':
case '-h':
$this->printUsage();
exit(0);
default:
$this->log("WARNING: Unknown argument: {$args[$i]}");
break;
}
}
}
private function printUsage(): void
{
$this->log(
'Usage: bulk_workflow_push.php '
. '--token <token> --file <path> --org <org> [options]'
);
$this->log('');
$this->log(
'Push a workflow file from moko-platform '
. 'to all governed repos.'
);
$this->log('');
$this->log('Options:');
$this->log(' --gitea-url <url> Gitea URL '
. '(default: https://git.mokoconsulting.tech)');
$this->log(' --token <token> Gitea API token');
$this->log(' --org <org> Target organization');
$this->log(' --file <path> Local workflow file to push');
$this->log(' --dest <path> Destination path in repos '
. '(default: .mokogitea/workflows/<filename>)');
$this->log(' --branch <branch> Target branch (default: main)');
$this->log(' --dry-run Show what would be done');
$this->log(' --help, -h Show this help');
}
private function apiRequest(
string $method,
string $endpoint,
?string $body = null
): array {
$url = $this->giteaUrl . $endpoint;
$ch = curl_init();
curl_setopt($ch, CURLOPT_URL, $url);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_CUSTOMREQUEST, $method);
curl_setopt($ch, CURLOPT_HTTPHEADER, [
'Content-Type: application/json',
'Accept: application/json',
"Authorization: token {$this->token}",
]);
if ($body !== null) {
curl_setopt($ch, CURLOPT_POSTFIELDS, $body);
}
$responseBody = curl_exec($ch);
$httpCode = (int) curl_getinfo(
$ch,
CURLINFO_HTTP_CODE
);
if (curl_errno($ch)) {
$error = curl_error($ch);
curl_close($ch);
return [
'code' => 0,
'body' => "cURL error: {$error}",
];
}
curl_close($ch);
return ['code' => $httpCode, 'body' => $responseBody];
}
private function log(string $message): void
{
fwrite(STDERR, $message . PHP_EOL);
}
}
$app = new BulkWorkflowPush();
exit($app->run());
+529
View File
@@ -0,0 +1,529 @@
#!/usr/bin/env php
<?php
/* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
*
* This file is part of a Moko Consulting project.
*
* SPDX-License-Identifier: GPL-3.0-or-later
*
* FILE INFORMATION
* DEFGROUP: moko-platform.CLI
* INGROUP: moko-platform
* REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform
* PATH: /cli/client_dashboard.php
* VERSION: 01.00.00
* BRIEF: Generate unified client dashboard HTML
*/
declare(strict_types=1);
final class ClientDashboard
{
private string $giteaUrl = 'https://git.mokoconsulting.tech';
private string $token = '';
private string $org = 'MokoConsulting';
private string $outputFile = '';
private bool $checkSsl = true;
private bool $checkUptime = true;
private int $sslWarnDays = 30;
private int $httpTimeout = 10;
public function run(): int
{
$this->parseArgs();
if ($this->token === '') {
$this->token = getenv('GA_TOKEN') ?: '';
}
if ($this->token === '') {
$this->log('ERROR: --token or GA_TOKEN required.');
$this->printUsage();
return 1;
}
$this->log('Gathering client data...');
$clients = $this->discoverClients();
if ($clients === null) {
$this->log('ERROR: Could not fetch client repos.');
return 1;
}
$this->log('Found ' . count($clients) . ' client(s).');
foreach ($clients as &$client) {
$this->enrichClient($client);
}
unset($client);
$html = $this->renderDashboard($clients);
if ($this->outputFile !== '') {
file_put_contents($this->outputFile, $html);
$this->log("Dashboard: {$this->outputFile}");
} else {
fwrite(STDOUT, $html);
}
return 0;
}
/** @return array<int, array<string, mixed>>|null */
private function discoverClients(): ?array
{
$clients = [];
$orgs = $this->fetchAllOrgs();
if (!in_array($this->org, $orgs, true)) {
array_unshift($orgs, $this->org);
}
foreach ($orgs as $orgName) {
$page = 1;
while (true) {
$resp = $this->api(
'GET',
"/api/v1/orgs/{$orgName}/repos"
. "?limit=50&page={$page}"
);
if ($resp['code'] !== 200) {
break;
}
$repos = json_decode($resp['body'], true);
if (!is_array($repos) || empty($repos)) {
break;
}
foreach ($repos as $repo) {
$name = $repo['name'] ?? '';
if (
!str_starts_with($name, 'client-waas-')
|| !empty($repo['archived'])
) {
continue;
}
$clients[] = [
'repo' => $repo['full_name'] ?? '',
'name' => str_replace('client-waas-', '', $name),
'description' => $repo['description'] ?? '',
'updated' => $repo['updated_at'] ?? '',
'url' => $repo['html_url'] ?? '',
];
}
$page++;
}
}
usort($clients, fn($a, $b) => strcasecmp($a['name'], $b['name']));
return $clients;
}
/** @return string[] */
private function fetchAllOrgs(): array
{
$resp = $this->api('GET', '/api/v1/user/orgs?limit=50');
if ($resp['code'] !== 200) {
return [$this->org];
}
$orgs = json_decode($resp['body'], true);
if (!is_array($orgs)) {
return [$this->org];
}
return array_map(fn($o) => $o['username'] ?? '', $orgs);
}
/** @param array<string, mixed> $client */
private function enrichClient(array &$client): void
{
$repo = $client['repo'];
$this->log(" Checking {$client['name']}...");
// Fetch variables
$resp = $this->api('GET', "/api/v1/repos/{$repo}/actions/variables");
$vars = [];
if ($resp['code'] === 200) {
$varList = json_decode($resp['body'], true);
if (is_array($varList)) {
foreach ($varList as $v) {
$vars[$v['name'] ?? ''] = $v['data'] ?? '';
}
}
}
$client['vars'] = $vars;
$client['dev_url'] = $vars['DEV_SITE_URL'] ?? '';
$client['live_url'] = $vars['LIVE_SITE_URL'] ?? '';
$client['has_dev'] = isset($vars['DEV_SYNC_HOST']);
$client['has_live'] = isset($vars['LIVE_SSH_HOST']);
$client['dev_status'] = 'unknown';
$client['live_status'] = 'unknown';
if ($this->checkUptime) {
if ($client['dev_url'] !== '') {
$client['dev_status'] = $this->checkHttp($client['dev_url']);
}
if ($client['live_url'] !== '') {
$client['live_status'] = $this->checkHttp($client['live_url']);
}
}
// SSL
$client['ssl_expiry'] = null;
$client['ssl_days'] = null;
$client['ssl_status'] = 'unknown';
$domain = $vars['MONITORED_DOMAINS'] ?? '';
if ($domain === '' && $client['live_url'] !== '') {
$parsed = parse_url($client['live_url']);
$domain = $parsed['host'] ?? '';
}
if ($this->checkSsl && $domain !== '') {
$domain = trim(explode("\n", $domain)[0]);
$ssl = $this->checkSslCert($domain);
$client['ssl_domain'] = $domain;
$client['ssl_expiry'] = $ssl['expiry'];
$client['ssl_days'] = $ssl['days'];
if ($ssl['days'] === null) {
$client['ssl_status'] = 'error';
} elseif ($ssl['days'] < $this->sslWarnDays) {
$client['ssl_status'] = 'warning';
} else {
$client['ssl_status'] = 'ok';
}
}
// Last release
$client['last_release'] = '';
$client['last_release_date'] = '';
$relResp = $this->api('GET', "/api/v1/repos/{$repo}/releases?limit=1");
if ($relResp['code'] === 200) {
$rels = json_decode($relResp['body'], true);
if (is_array($rels) && !empty($rels)) {
$client['last_release'] = $rels[0]['name'] ?? '';
$client['last_release_date'] = substr($rels[0]['created_at'] ?? '', 0, 10);
}
}
}
private function checkHttp(string $url): string
{
$ch = curl_init();
curl_setopt($ch, CURLOPT_URL, $url);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_NOBODY, true);
curl_setopt($ch, CURLOPT_TIMEOUT, $this->httpTimeout);
curl_setopt($ch, CURLOPT_FOLLOWLOCATION, true);
curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, false);
curl_exec($ch);
$code = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
if ($code === 0) {
return 'down';
}
return ($code >= 200 && $code < 400) ? 'up' : "http-{$code}";
}
/** @return array{expiry: ?string, days: ?int} */
private function checkSslCert(string $domain): array
{
$ctx = stream_context_create([
'ssl' => [
'capture_peer_cert' => true,
'verify_peer' => false,
'verify_peer_name' => false,
],
]);
$client = @stream_socket_client(
"ssl://{$domain}:443",
$errno,
$errstr,
$this->httpTimeout,
STREAM_CLIENT_CONNECT,
$ctx
);
if (!$client) {
return ['expiry' => null, 'days' => null];
}
$params = stream_context_get_params($client);
fclose($client);
$cert = $params['options']['ssl']['peer_certificate'] ?? null;
if ($cert === null) {
return ['expiry' => null, 'days' => null];
}
$info = openssl_x509_parse($cert);
$validTo = $info['validTo_time_t'] ?? 0;
if ($validTo === 0) {
return ['expiry' => null, 'days' => null];
}
$expiry = date('Y-m-d', $validTo);
$days = (int) round(($validTo - time()) / 86400);
return ['expiry' => $expiry, 'days' => $days];
}
/** @param array<int, array<string, mixed>> $clients */
private function renderDashboard(array $clients): string
{
$generated = date('Y-m-d H:i:s T');
$total = count($clients);
$up = 0;
$sslWarn = 0;
foreach ($clients as $c) {
if ($c['live_status'] === 'up' || $c['dev_status'] === 'up') {
$up++;
}
if ($c['ssl_status'] === 'warning') {
$sslWarn++;
}
}
$cards = '';
foreach ($clients as $c) {
$cards .= $this->renderCard($c);
}
$warnCls = $sslWarn > 0 ? 'stat-warn' : 'stat-ok';
return <<<HTML
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Moko Client Dashboard</title>
<style>
*{margin:0;padding:0;box-sizing:border-box}
body{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;background:#0f172a;color:#e2e8f0;padding:24px}
h1{font-size:1.5rem;font-weight:600;margin-bottom:4px}
.sub{color:#94a3b8;font-size:.875rem;margin-bottom:24px}
.stats{display:flex;gap:16px;margin-bottom:24px;flex-wrap:wrap}
.st{background:#1e293b;border-radius:8px;padding:16px 20px;min-width:140px}
.sv{font-size:1.5rem;font-weight:700}
.sl{color:#94a3b8;font-size:.75rem;text-transform:uppercase;letter-spacing:.05em}
.stat-ok .sv{color:#4ade80}
.stat-warn .sv{color:#fbbf24}
.g{display:grid;grid-template-columns:repeat(auto-fill,minmax(340px,1fr));gap:16px}
.c{background:#1e293b;border-radius:8px;padding:20px;border:1px solid #334155;transition:border-color .2s}
.c:hover{border-color:#475569}
.ch{display:flex;justify-content:space-between;align-items:center;margin-bottom:12px}
.cn{font-size:1.1rem;font-weight:600;text-transform:capitalize}
.cn a{color:#e2e8f0;text-decoration:none}
.cn a:hover{color:#60a5fa}
.b{font-size:.7rem;padding:2px 8px;border-radius:999px;font-weight:600;text-transform:uppercase}
.b-up{background:#064e3b;color:#4ade80}
.b-dn{background:#7f1d1d;color:#fca5a5}
.b-un{background:#374151;color:#9ca3af}
.rs{display:flex;flex-direction:column;gap:8px}
.r{display:flex;justify-content:space-between;font-size:.85rem}
.rl{color:#94a3b8}
.rv{color:#e2e8f0;text-align:right;max-width:60%}
.rv a{color:#60a5fa;text-decoration:none}
.rv a:hover{text-decoration:underline}
.ok{color:#4ade80}.wn{color:#fbbf24}.er{color:#f87171}
.st2{font-size:.7rem;text-transform:uppercase;letter-spacing:.08em;color:#64748b;
margin-top:8px;margin-bottom:4px;padding-top:8px;border-top:1px solid #334155}
footer{margin-top:32px;text-align:center;color:#64748b;font-size:.75rem}
</style>
</head>
<body>
<h1>Moko Client Dashboard</h1>
<p class="sub">Generated {$generated}</p>
<div class="stats">
<div class="st"><div class="sv">{$total}</div><div class="sl">Clients</div></div>
<div class="st stat-ok"><div class="sv">{$up}</div><div class="sl">Sites Up</div></div>
<div class="st {$warnCls}"><div class="sv">{$sslWarn}</div><div class="sl">SSL Warnings</div></div>
</div>
<div class="g">{$cards}</div>
<footer>Moko Consulting &mdash; client_dashboard.php</footer>
</body>
</html>
HTML;
}
/** @param array<string, mixed> $c */
private function renderCard(array $c): string
{
$name = htmlspecialchars($c['name']);
$repoUrl = htmlspecialchars($c['url']);
$ls = $c['live_status'];
if ($ls === 'up') {
$badge = '<span class="b b-up">UP</span>';
} elseif ($ls === 'down') {
$badge = '<span class="b b-dn">DOWN</span>';
} else {
$badge = '<span class="b b-un">' . htmlspecialchars($ls) . '</span>';
}
$rows = '';
if ($c['live_url'] !== '') {
$u = htmlspecialchars($c['live_url']);
$rows .= "<div class=\"r\"><span class=\"rl\">Live</span>"
. "<span class=\"rv\"><a href=\"{$u}\" target=\"_blank\">{$u}</a></span></div>";
}
if ($c['dev_url'] !== '') {
$u = htmlspecialchars($c['dev_url']);
$ds = $c['dev_status'] === 'up' ? ' (up)' : '';
$rows .= "<div class=\"r\"><span class=\"rl\">Dev</span>"
. "<span class=\"rv\"><a href=\"{$u}\" target=\"_blank\">{$u}</a>{$ds}</span></div>";
}
if ($c['ssl_days'] !== null) {
$cls = match ($c['ssl_status']) {
'ok' => 'ok', 'warning' => 'wn', default => 'er'
};
$stxt = htmlspecialchars("{$c['ssl_expiry']} ({$c['ssl_days']}d)");
$rows .= "<div class=\"r\"><span class=\"rl\">SSL</span>"
. "<span class=\"rv {$cls}\">{$stxt}</span></div>";
}
if ($c['last_release'] !== '') {
$rel = htmlspecialchars($c['last_release']);
$rd = htmlspecialchars($c['last_release_date']);
$rows .= "<div class=\"r\"><span class=\"rl\">Release</span>"
. "<span class=\"rv\">{$rel} ({$rd})</span></div>";
}
$dc = $c['has_dev'] ? '<span class="ok">configured</span>' : '<span class="er">missing</span>';
$lc = $c['has_live'] ? '<span class="ok">configured</span>' : '<span class="er">missing</span>';
$upd = substr($c['updated'], 0, 10);
return <<<CARD
<div class="c">
<div class="ch"><span class="cn"><a href="{$repoUrl}" target="_blank">{$name}</a></span>{$badge}</div>
<div class="rs">{$rows}
<div class="st2">Infrastructure</div>
<div class="r"><span class="rl">Dev Server</span><span class="rv">{$dc}</span></div>
<div class="r"><span class="rl">Live Server</span><span class="rv">{$lc}</span></div>
<div class="r"><span class="rl">Last Push</span><span class="rv">{$upd}</span></div>
</div></div>
CARD;
}
/** @return array{code: int, body: string} */
private function api(string $method, string $endpoint): array
{
$url = $this->giteaUrl . $endpoint;
$ch = curl_init();
curl_setopt($ch, CURLOPT_URL, $url);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_CUSTOMREQUEST, $method);
curl_setopt($ch, CURLOPT_HTTPHEADER, [
'Accept: application/json',
"Authorization: token {$this->token}",
]);
$body = curl_exec($ch);
$code = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE);
if (curl_errno($ch)) {
curl_close($ch);
return ['code' => 0, 'body' => ''];
}
curl_close($ch);
return ['code' => $code, 'body' => $body];
}
private function parseArgs(): void
{
$args = $_SERVER['argv'] ?? [];
$count = count($args);
for ($i = 1; $i < $count; $i++) {
switch ($args[$i]) {
case '--token':
$this->token = $args[++$i] ?? '';
break;
case '--gitea-url':
$this->giteaUrl = rtrim($args[++$i] ?? '', '/');
break;
case '--org':
$this->org = $args[++$i] ?? '';
break;
case '--output':
case '-o':
$this->outputFile = $args[++$i] ?? '';
break;
case '--no-ssl':
$this->checkSsl = false;
break;
case '--no-uptime':
$this->checkUptime = false;
break;
case '--ssl-warn-days':
$this->sslWarnDays = (int) ($args[++$i] ?? 30);
break;
case '--help':
case '-h':
$this->printUsage();
exit(0);
default:
$this->log("WARNING: Unknown arg: {$args[$i]}");
break;
}
}
}
private function printUsage(): void
{
$this->log('Usage: client_dashboard.php --token TOKEN [options]');
$this->log('');
$this->log('Generate unified client status dashboard (HTML).');
$this->log('');
$this->log('Options:');
$this->log(' --token <token> Gitea token (or GA_TOKEN)');
$this->log(' --gitea-url <url> Gitea URL');
$this->log(' --org <org> Primary org (default: MokoConsulting)');
$this->log(' -o, --output <file> Output HTML file (default: stdout)');
$this->log(' --no-ssl Skip SSL checks');
$this->log(' --no-uptime Skip HTTP uptime checks');
$this->log(' --ssl-warn-days <n> SSL warning days (default: 30)');
$this->log(' --help, -h Show this help');
}
private function log(string $message): void
{
fwrite(STDERR, $message . PHP_EOL);
}
}
$app = new ClientDashboard();
exit($app->run());
+188
View File
@@ -0,0 +1,188 @@
#!/usr/bin/env php
<?php
/* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
*
* SPDX-License-Identifier: GPL-3.0-or-later
*
* FILE INFORMATION
* DEFGROUP: moko-platform.CLI
* INGROUP: moko-platform
* REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform
* PATH: /cli/client_health_check.php
* BRIEF: Verify a client site's update server, installed version, and release availability
*
* Usage:
* php client_health_check.php --update-url URL
* php client_health_check.php --path /repo --github-output
*
* Options:
* --path Repository root (reads update server URL from manifest)
* --update-url Update server XML URL (overrides manifest)
* --site-url Live site URL for version checking via Joomla API (optional)
* --api-token Joomla API token for site-url (optional)
* --github-output Export results to $GITHUB_OUTPUT
*/
declare(strict_types=1);
$path = '.';
$updateUrl = null;
$siteUrl = null;
$apiToken = null;
$ghOutput = false;
foreach ($argv as $i => $arg) {
if ($arg === '--path' && isset($argv[$i + 1])) $path = $argv[$i + 1];
if ($arg === '--update-url' && isset($argv[$i + 1])) $updateUrl = $argv[$i + 1];
if ($arg === '--site-url' && isset($argv[$i + 1])) $siteUrl = $argv[$i + 1];
if ($arg === '--api-token' && isset($argv[$i + 1])) $apiToken = $argv[$i + 1];
if ($arg === '--github-output') $ghOutput = true;
}
$root = realpath($path) ?: $path;
$checks = [];
// ── Resolve update server URL from manifest ─────────────────────────────
if ($updateUrl === null) {
$searchDirs = ["{$root}/src", $root];
foreach ($searchDirs as $dir) {
if (!is_dir($dir)) continue;
foreach (glob("{$dir}/*.xml") ?: [] as $f) {
$xml = file_get_contents($f);
if (preg_match('/<server[^>]*>([^<]+)<\/server>/', $xml, $m)) {
$updateUrl = trim($m[1]);
break 2;
}
}
}
}
if ($updateUrl === null) {
fwrite(STDERR, "No update server URL found. Use --update-url or provide a manifest with <updateservers>.\n");
exit(1);
}
echo "Update server: {$updateUrl}\n\n";
// ── Check 1: Update server accessible ───────────────────────────────────
echo "--- Update Server ---\n";
$ch = curl_init($updateUrl);
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => 15,
CURLOPT_FOLLOWLOCATION => true,
CURLOPT_HTTPHEADER => ['User-Agent: MokoHealthCheck/1.0'],
]);
$response = curl_exec($ch);
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
if ($httpCode === 200 && !empty($response)) {
echo " PASS: HTTP {$httpCode}, " . strlen($response) . " bytes\n";
$checks['update_server'] = 'pass';
} else {
echo " FAIL: HTTP {$httpCode}\n";
$checks['update_server'] = 'fail';
}
// ── Check 2: Parse updates.xml for stable version ───────────────────────
$stableVersion = null;
$downloadUrl = null;
if (!empty($response)) {
$sections = preg_split('/<update>/', $response);
foreach ($sections as $section) {
if (strpos($section, '<tag>stable</tag>') !== false) {
if (preg_match('/<version>([^<]+)<\/version>/', $section, $m)) {
$stableVersion = $m[1];
}
if (preg_match('/<downloadurl[^>]*>([^<]+)<\/downloadurl>/', $section, $m)) {
$downloadUrl = trim($m[1]);
}
break;
}
}
if ($stableVersion === null && preg_match('/<version>([^<]+)<\/version>/', $response, $m)) {
$stableVersion = $m[1];
}
}
echo "\n--- Stable Release ---\n";
if ($stableVersion !== null) {
echo " Version: {$stableVersion}\n";
$checks['stable_version'] = $stableVersion;
} else {
echo " FAIL: Could not parse stable version\n";
$checks['stable_version'] = 'fail';
}
// ── Check 3: Download URL accessible ────────────────────────────────────
if ($downloadUrl !== null) {
echo "\n--- Download URL ---\n";
$ch = curl_init($downloadUrl);
curl_setopt_array($ch, [
CURLOPT_NOBODY => true,
CURLOPT_TIMEOUT => 15,
CURLOPT_FOLLOWLOCATION => true,
]);
curl_exec($ch);
$dlCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
$dlSize = curl_getinfo($ch, CURLINFO_CONTENT_LENGTH_DOWNLOAD);
curl_close($ch);
if ($dlCode === 200) {
$sizeKb = $dlSize > 0 ? round($dlSize / 1024) . 'KB' : 'unknown size';
echo " PASS: HTTP {$dlCode}, {$sizeKb}\n";
$checks['download'] = 'pass';
} else {
echo " FAIL: HTTP {$dlCode}\n";
$checks['download'] = 'fail';
}
}
// ── Check 4: Site version (optional) ────────────────────────────────────
if ($siteUrl !== null && $apiToken !== null) {
echo "\n--- Site Version ---\n";
$apiUrl = rtrim($siteUrl, '/') . '/api/index.php/v1/extensions?filter[type]=file';
$ch = curl_init($apiUrl);
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => 15,
CURLOPT_HTTPHEADER => [
"X-Joomla-Token: {$apiToken}",
'Accept: application/json',
],
]);
$siteResponse = curl_exec($ch);
$siteCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
if ($siteCode === 200) {
echo " API accessible (HTTP {$siteCode})\n";
$checks['site_api'] = 'pass';
} else {
echo " WARN: Site API returned HTTP {$siteCode}\n";
$checks['site_api'] = 'warn';
}
}
// ── Summary ─────────────────────────────────────────────────────────────
echo "\n=== Health Check Summary ===\n";
$failed = 0;
foreach ($checks as $name => $result) {
$icon = ($result === 'fail') ? 'FAIL' : (($result === 'warn') ? 'WARN' : 'OK');
if ($result === 'fail') $failed++;
echo " {$icon}: {$name} = {$result}\n";
}
if ($ghOutput) {
$ghFile = getenv('GITHUB_OUTPUT');
if ($ghFile) {
file_put_contents($ghFile, "health_status=" . ($failed > 0 ? 'fail' : 'pass') . "\n", FILE_APPEND);
file_put_contents($ghFile, "health_version=" . ($stableVersion ?? 'unknown') . "\n", FILE_APPEND);
file_put_contents($ghFile, "health_failures={$failed}\n", FILE_APPEND);
}
}
exit($failed > 0 ? 1 : 0);
+534
View File
@@ -0,0 +1,534 @@
#!/usr/bin/env php
<?php
/* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
*
* This file is part of a Moko Consulting project.
*
* SPDX-License-Identifier: GPL-3.0-or-later
*
* FILE INFORMATION
* DEFGROUP: moko-platform.CLI
* INGROUP: moko-platform
* REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform
* PATH: /cli/client_provision.php
* VERSION: 01.00.00
* BRIEF: Provision a new client environment end-to-end
*/
declare(strict_types=1);
final class ClientProvision
{
private string $giteaUrl = 'https://git.mokoconsulting.tech';
private string $giteaToken = '';
private string $grafanaUrl = '';
private string $grafanaToken = '';
private string $configFile = '';
private string $step = '';
private bool $dryRun = false;
/** @var array<string, mixed> */
private array $config = [];
private string $org = '';
private string $repoName = '';
public function run(): int
{
$this->parseArgs();
if ($this->configFile === '') {
$this->log('ERROR: --config is required.');
$this->printUsage();
return 1;
}
if (!file_exists($this->configFile)) {
$this->log("ERROR: Not found: {$this->configFile}");
return 1;
}
$json = file_get_contents($this->configFile);
$this->config = json_decode($json, true);
if (!is_array($this->config)) {
$this->log('ERROR: Invalid JSON in config file.');
return 1;
}
$this->giteaToken = $this->config['gitea_token']
?? getenv('GA_TOKEN') ?: '';
$this->grafanaUrl = $this->config['grafana_url']
?? getenv('GRAFANA_URL') ?: '';
$this->grafanaToken = $this->config['grafana_token']
?? getenv('GRAFANA_TOKEN') ?: '';
$this->giteaUrl = $this->config['gitea_url']
?? $this->giteaUrl;
if ($this->giteaToken === '') {
$this->log('ERROR: gitea_token or GA_TOKEN required.');
return 1;
}
$this->org = $this->config['org'] ?? '';
$clientName = $this->config['name'] ?? '';
if ($this->org === '' || $clientName === '') {
$this->log('ERROR: "org" and "name" required in config.');
return 1;
}
$this->repoName = 'client-waas-' . $clientName;
$this->log("=== Client Provisioning: {$clientName} ===");
$this->log(" Org: {$this->org}");
$this->log(" Repo: {$this->repoName}");
if ($this->dryRun) {
$this->log(' Mode: DRY RUN');
}
$this->log('');
$steps = [
'repo' => 'createRepo',
'variables' => 'setVariables',
'secrets' => 'setSecrets',
'monitoring' => 'setupMonitoring',
'summary' => 'printSummary',
];
$exitCode = 0;
foreach ($steps as $name => $method) {
if ($this->step !== '' && $this->step !== $name) {
continue;
}
$result = $this->$method();
if ($result !== 0) {
$exitCode = 1;
}
}
return $exitCode;
}
private function createRepo(): int
{
$this->log('[1/5] Creating repository...');
$check = $this->giteaApi(
'GET',
"/api/v1/repos/{$this->org}/{$this->repoName}"
);
if ($check['code'] === 200) {
$this->log(" SKIP: repo already exists");
return 0;
}
if ($this->dryRun) {
$this->log(
" WOULD CREATE: {$this->org}/{$this->repoName}"
);
return 0;
}
$payload = json_encode([
'owner' => $this->org,
'name' => $this->repoName,
'description' => ($this->config['name'] ?? '') . ' WaaS site',
'private' => true,
'git_content' => true,
'topics' => true,
'labels' => true,
]);
$resp = $this->giteaApi(
'POST',
'/api/v1/repos/MokoConsulting/'
. 'Template-Client-WaaS/generate',
$payload
);
if ($resp['code'] < 200 || $resp['code'] >= 300) {
$this->log(" ERROR: HTTP {$resp['code']}");
return 1;
}
$this->log(' OK: Repo created');
$this->giteaApi(
'POST',
"/api/v1/repos/{$this->org}/{$this->repoName}/branches",
json_encode([
'new_branch_name' => 'dev',
'old_branch_name' => 'main',
])
);
$this->log(' OK: dev branch created');
return 0;
}
private function setVariables(): int
{
$this->log('[2/5] Setting repo variables...');
$vars = $this->config['variables'] ?? [];
if (empty($vars)) {
$this->log(' SKIP: No variables in config');
return 0;
}
$errors = 0;
$api = "/api/v1/repos/{$this->org}/{$this->repoName}"
. "/actions/variables";
foreach ($vars as $name => $value) {
if ($this->dryRun) {
$display = strlen($value) > 40
? substr($value, 0, 37) . '...' : $value;
$this->log(" WOULD SET: {$name} = {$display}");
continue;
}
$ok = $this->setOrCreateVariable($api, $name, $value);
if ($ok) {
$this->log(" OK: {$name}");
} else {
$this->log(" ERROR: {$name}");
$errors++;
}
}
return $errors > 0 ? 1 : 0;
}
private function setSecrets(): int
{
$this->log('[3/5] Setting repo secrets...');
$secrets = $this->config['secrets'] ?? [];
if (empty($secrets)) {
$this->log(' SKIP: No secrets in config');
return 0;
}
$errors = 0;
$api = "/api/v1/repos/{$this->org}/{$this->repoName}"
. "/actions/secrets";
foreach ($secrets as $name => $value) {
if (str_starts_with($value, '@')) {
$keyPath = substr($value, 1);
if (!file_exists($keyPath)) {
$this->log(" ERROR: {$name} file not found: {$keyPath}");
$errors++;
continue;
}
$value = file_get_contents($keyPath);
}
if ($this->dryRun) {
$this->log(" WOULD SET: {$name} (len: " . strlen($value) . ")");
continue;
}
$resp = $this->giteaApi(
'PUT',
"{$api}/{$name}",
json_encode(['data' => $value])
);
if ($resp['code'] >= 200 && $resp['code'] < 300) {
$this->log(" OK: {$name}");
} else {
$this->log(" ERROR: {$name} (HTTP {$resp['code']})");
$errors++;
}
}
return $errors > 0 ? 1 : 0;
}
private function setupMonitoring(): int
{
$this->log('[4/5] Setting up monitoring...');
$mon = $this->config['monitoring'] ?? [];
if (empty($mon)) {
$this->log(' SKIP: No monitoring config');
return 0;
}
$dashFile = $mon['grafana_dashboard'] ?? '';
if (
$dashFile !== '' && $this->grafanaUrl !== ''
&& $this->grafanaToken !== ''
) {
$this->pushGrafanaDashboard(
$dashFile,
$mon['grafana_folder'] ?? 'Clients'
);
}
$urls = $mon['urls'] ?? [];
$domains = $mon['domains'] ?? [];
$api = "/api/v1/repos/{$this->org}/{$this->repoName}"
. "/actions/variables";
if (!empty($urls)) {
$urlStr = implode("\n", $urls);
if ($this->dryRun) {
$this->log(" WOULD SET: MONITORED_URLS");
} else {
$this->setOrCreateVariable($api, 'MONITORED_URLS', $urlStr);
$this->log(' OK: MONITORED_URLS');
}
}
if (!empty($domains)) {
$domainStr = implode("\n", $domains);
if ($this->dryRun) {
$this->log(" WOULD SET: MONITORED_DOMAINS");
} else {
$this->setOrCreateVariable($api, 'MONITORED_DOMAINS', $domainStr);
$this->log(' OK: MONITORED_DOMAINS');
}
}
return 0;
}
private function pushGrafanaDashboard(string $file, string $folder): void
{
if (!file_exists($file)) {
$this->log(" WARN: Dashboard not found: {$file}");
return;
}
if ($this->dryRun) {
$this->log(" WOULD PUSH: dashboard to \"{$folder}\"");
return;
}
$dashboard = json_decode(file_get_contents($file), true);
if (!is_array($dashboard)) {
$this->log(' ERROR: Invalid dashboard JSON');
return;
}
$folderId = $this->resolveGrafanaFolder($folder);
$dashboard['id'] = null;
$resp = $this->grafanaApi(
'POST',
'/api/dashboards/db',
json_encode([
'dashboard' => $dashboard,
'folderId' => $folderId,
'overwrite' => true,
])
);
if ($resp['code'] === 200) {
$data = json_decode($resp['body'], true);
$this->log(" OK: Dashboard (uid: " . ($data['uid'] ?? '?') . ")");
} else {
$this->log(" ERROR: Dashboard push (HTTP {$resp['code']})");
}
}
private function resolveGrafanaFolder(string $title): int
{
$resp = $this->grafanaApi('GET', '/api/folders');
if ($resp['code'] !== 200) {
return 0;
}
$folders = json_decode($resp['body'], true);
if (!is_array($folders)) {
return 0;
}
foreach ($folders as $f) {
if (strcasecmp($f['title'] ?? '', $title) === 0) {
return (int) ($f['id'] ?? 0);
}
}
return 0;
}
private function printSummary(): int
{
$vars = $this->config['variables'] ?? [];
$secrets = $this->config['secrets'] ?? [];
$clientName = $this->config['name'] ?? '';
$this->log('');
$this->log('[5/5] Provisioning summary');
$this->log(str_repeat('=', 60));
$this->log(" Repo: {$this->giteaUrl}/{$this->org}/{$this->repoName}");
$this->log(' Variables: ' . count($vars) . ' set');
$this->log(' Secrets: ' . count($secrets) . ' set');
$this->log('');
$this->log('Next steps:');
$this->log(' 1. Clone and customize the Joomla template');
$this->log(' 2. Push to dev to trigger dev deployment');
$this->log(' 3. Merge dev -> main for production release');
$this->log(str_repeat('=', 60));
return 0;
}
private function setOrCreateVariable(
string $api,
string $name,
string $value
): bool {
$resp = $this->giteaApi(
'PUT',
"{$api}/{$name}",
json_encode(['value' => $value])
);
if ($resp['code'] === 404) {
$resp = $this->giteaApi(
'POST',
$api,
json_encode(['name' => $name, 'value' => $value])
);
}
return $resp['code'] >= 200 && $resp['code'] < 300;
}
private function parseArgs(): void
{
$args = $_SERVER['argv'] ?? [];
$count = count($args);
for ($i = 1; $i < $count; $i++) {
switch ($args[$i]) {
case '--config':
$this->configFile = $args[++$i] ?? '';
break;
case '--step':
$this->step = $args[++$i] ?? '';
break;
case '--dry-run':
$this->dryRun = true;
break;
case '--help':
case '-h':
$this->printUsage();
exit(0);
default:
$this->log("WARNING: Unknown arg: {$args[$i]}");
break;
}
}
}
private function printUsage(): void
{
$this->log('Usage: client_provision.php --config <file.json> [options]');
$this->log('');
$this->log('Provision a new client environment end-to-end.');
$this->log('');
$this->log('Options:');
$this->log(' --config <file> Client config JSON');
$this->log(' --step <name> Run one step: repo, variables, secrets, monitoring, summary');
$this->log(' --dry-run Preview without changes');
$this->log(' --help, -h Show this help');
$this->log('');
$this->log('Environment variables:');
$this->log(' GA_TOKEN Gitea API token');
$this->log(' GRAFANA_URL Grafana instance URL');
$this->log(' GRAFANA_TOKEN Grafana API token');
}
private function giteaApi(
string $method,
string $endpoint,
?string $body = null
): array {
return $this->httpRequest(
$this->giteaUrl . $endpoint,
$method,
"token {$this->giteaToken}",
$body
);
}
private function grafanaApi(
string $method,
string $endpoint,
?string $body = null
): array {
return $this->httpRequest(
$this->grafanaUrl . $endpoint,
$method,
"Bearer {$this->grafanaToken}",
$body
);
}
private function httpRequest(
string $url,
string $method,
string $auth,
?string $body = null
): array {
$ch = curl_init();
curl_setopt($ch, CURLOPT_URL, $url);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_CUSTOMREQUEST, $method);
curl_setopt($ch, CURLOPT_HTTPHEADER, [
'Content-Type: application/json',
'Accept: application/json',
"Authorization: {$auth}",
]);
if ($body !== null) {
curl_setopt($ch, CURLOPT_POSTFIELDS, $body);
}
$responseBody = curl_exec($ch);
$httpCode = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE);
if (curl_errno($ch)) {
$error = curl_error($ch);
curl_close($ch);
return ['code' => 0, 'body' => "cURL error: {$error}"];
}
curl_close($ch);
return ['code' => $httpCode, 'body' => $responseBody];
}
private function log(string $message): void
{
fwrite(STDERR, $message . PHP_EOL);
}
}
$app = new ClientProvision();
exit($app->run());
+2 -2
View File
@@ -159,7 +159,7 @@ function restGet(string $path, string $token, ?\MokoEnterprise\ApiClient $apiCli
/**
* Detect platform type from .mokostandards file in the repo.
*/
function detectPlatform(string $org, string $repo, string $token, ?\MokoEnterprise\ApiClient $apiClient = null): string
function detectRepoPlatform(string $org, string $repo, string $token, ?\MokoEnterprise\ApiClient $apiClient = null): string
{
// Try platform metadata dir first, then root
foreach (['.github/.mokostandards', '.mokogitea/.mokostandards', '.mokostandards'] as $path) {
@@ -447,7 +447,7 @@ foreach ($repos as $repo) {
// Detect project type
$type = $typeOverride;
if (!$type) {
$platform = detectPlatform($org, $repo, $token);
$platform = detectRepoPlatform($org, $repo, $token);
$type = $PLATFORM_TO_TYPE[$platform] ?? 'generic';
echo " Platform: {$platform} → type: {$type}\n";
}
+444
View File
@@ -0,0 +1,444 @@
#!/usr/bin/env php
<?php
/* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
*
* This file is part of a Moko Consulting project.
*
* SPDX-License-Identifier: GPL-3.0-or-later
*
* FILE INFORMATION
* DEFGROUP: moko-platform.CLI
* INGROUP: moko-platform
* REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform
* PATH: /cli/grafana_dashboard.php
* VERSION: 01.00.00
* BRIEF: Manage Grafana dashboards via API
*/
declare(strict_types=1);
final class GrafanaDashboard
{
private string $grafanaUrl = '';
private string $token = '';
private string $command = '';
private string $uid = '';
private string $file = '';
private int $folderId = 0;
private string $folderTitle = '';
private bool $overwrite = true;
public function run(): int
{
$this->parseArgs();
if ($this->grafanaUrl === '') {
$this->grafanaUrl = getenv('GRAFANA_URL') ?: '';
}
if ($this->token === '') {
$this->token = getenv('GRAFANA_TOKEN') ?: '';
}
if ($this->grafanaUrl === '' || $this->token === '') {
$this->log(
'ERROR: --url and --token are required '
. '(or set GRAFANA_URL / GRAFANA_TOKEN env vars).'
);
$this->printUsage();
return 1;
}
return match ($this->command) {
'push' => $this->pushDashboard(),
'delete' => $this->deleteDashboard(),
'list' => $this->listDashboards(),
'export' => $this->exportDashboard(),
default => $this->noCommand(),
};
}
private function pushDashboard(): int
{
if ($this->file === '') {
$this->log('ERROR: --file is required for push.');
return 1;
}
if (!file_exists($this->file)) {
$this->log("ERROR: File not found: {$this->file}");
return 1;
}
$json = file_get_contents($this->file);
$dashboard = json_decode($json, true);
if (!is_array($dashboard)) {
$this->log('ERROR: Invalid JSON in dashboard file.');
return 1;
}
if ($this->folderTitle !== '' && $this->folderId === 0) {
$this->folderId = $this->resolveFolderId(
$this->folderTitle
);
if ($this->folderId < 0) {
return 1;
}
}
$dashboard['id'] = null;
$payload = json_encode([
'dashboard' => $dashboard,
'folderId' => $this->folderId,
'overwrite' => $this->overwrite,
]);
$response = $this->apiRequest(
'POST',
'/api/dashboards/db',
$payload
);
if ($response['code'] === 200) {
$data = json_decode($response['body'], true);
$uid = $data['uid'] ?? '?';
$url = $data['url'] ?? '';
$status = $data['status'] ?? 'success';
$this->log("OK: {$status} (uid: {$uid})");
if ($url !== '') {
$this->log("URL: {$this->grafanaUrl}{$url}");
}
return 0;
}
$this->log(
"ERROR: Push failed (HTTP {$response['code']})"
);
$this->logApiError($response['body']);
return 1;
}
private function deleteDashboard(): int
{
if ($this->uid === '') {
$this->log('ERROR: --uid is required for delete.');
return 1;
}
$response = $this->apiRequest(
'DELETE',
"/api/dashboards/uid/{$this->uid}"
);
if ($response['code'] === 200) {
$this->log("OK: Deleted dashboard {$this->uid}");
return 0;
}
if ($response['code'] === 404) {
$this->log(
"WARN: Dashboard {$this->uid} not found."
);
return 0;
}
$this->log(
"ERROR: Delete failed (HTTP {$response['code']})"
);
$this->logApiError($response['body']);
return 1;
}
private function listDashboards(): int
{
$query = '/api/search?type=dash-db';
if ($this->folderId > 0) {
$query .= "&folderIds={$this->folderId}";
}
if ($this->folderTitle !== '' && $this->folderId === 0) {
$fid = $this->resolveFolderId($this->folderTitle);
if ($fid > 0) {
$query .= "&folderIds={$fid}";
}
}
$response = $this->apiRequest('GET', $query);
if ($response['code'] !== 200) {
$this->log(
"ERROR: List failed (HTTP {$response['code']})"
);
$this->logApiError($response['body']);
return 1;
}
$dashboards = json_decode($response['body'], true);
if (
!is_array($dashboards)
|| count($dashboards) === 0
) {
$this->log('No dashboards found.');
return 0;
}
$this->log(sprintf(
'%-30s | %-20s | %s',
'Title',
'UID',
'Folder'
));
$this->log(str_repeat('-', 75));
foreach ($dashboards as $d) {
$this->log(sprintf(
'%-30s | %-20s | %s',
substr($d['title'] ?? '', 0, 30),
$d['uid'] ?? '',
$d['folderTitle'] ?? 'General'
));
}
$this->log('');
$this->log(count($dashboards) . ' dashboard(s).');
return 0;
}
private function exportDashboard(): int
{
if ($this->uid === '') {
$this->log('ERROR: --uid is required for export.');
return 1;
}
$response = $this->apiRequest(
'GET',
"/api/dashboards/uid/{$this->uid}"
);
if ($response['code'] !== 200) {
$this->log(
"ERROR: Export failed "
. "(HTTP {$response['code']})"
);
$this->logApiError($response['body']);
return 1;
}
$data = json_decode($response['body'], true);
$dashboard = $data['dashboard'] ?? null;
if ($dashboard === null) {
$this->log(
'ERROR: No dashboard data in response.'
);
return 1;
}
$output = json_encode(
$dashboard,
JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES
) . "\n";
if ($this->file !== '') {
file_put_contents($this->file, $output);
$this->log(
"Exported {$this->uid} to {$this->file}"
);
} else {
fwrite(STDOUT, $output);
}
return 0;
}
private function resolveFolderId(string $title): int
{
$response = $this->apiRequest('GET', '/api/folders');
if ($response['code'] !== 200) {
$this->log(
"ERROR: Could not fetch folders "
. "(HTTP {$response['code']})"
);
return -1;
}
$folders = json_decode($response['body'], true);
if (!is_array($folders)) {
return -1;
}
foreach ($folders as $f) {
if (
strcasecmp(
$f['title'] ?? '',
$title
) === 0
) {
return (int) ($f['id'] ?? 0);
}
}
$this->log(
"WARN: Folder \"{$title}\" not found, "
. "using General."
);
return 0;
}
private function noCommand(): int
{
$this->log('ERROR: No command specified.');
$this->printUsage();
return 1;
}
private function parseArgs(): void
{
$args = $_SERVER['argv'] ?? [];
$count = count($args);
for ($i = 1; $i < $count; $i++) {
switch ($args[$i]) {
case 'push':
case 'delete':
case 'list':
case 'export':
$this->command = $args[$i];
break;
case '--url':
$this->grafanaUrl = rtrim(
$args[++$i] ?? '',
'/'
);
break;
case '--token':
$this->token = $args[++$i] ?? '';
break;
case '--uid':
$this->uid = $args[++$i] ?? '';
break;
case '--file':
$this->file = $args[++$i] ?? '';
break;
case '--folder-id':
$this->folderId = (int) (
$args[++$i] ?? 0
);
break;
case '--folder':
$this->folderTitle = $args[++$i] ?? '';
break;
case '--no-overwrite':
$this->overwrite = false;
break;
case '--help':
case '-h':
$this->printUsage();
exit(0);
default:
$this->log(
"WARNING: Unknown arg: {$args[$i]}"
);
break;
}
}
}
private function printUsage(): void
{
$u = 'Usage: grafana_dashboard.php <command> '
. '--url <url> --token <token> [options]';
$this->log($u);
$this->log('');
$this->log('Commands:');
$this->log(' push Create/update dashboard from JSON');
$this->log(' delete Delete a dashboard by UID');
$this->log(' list List dashboards (optionally by folder)');
$this->log(' export Export dashboard JSON by UID');
$this->log('');
$this->log('Options:');
$this->log(' --url <url> Grafana URL (or GRAFANA_URL)');
$this->log(' --token <token> API token (or GRAFANA_TOKEN)');
$this->log(' --uid <uid> Dashboard UID (delete/export)');
$this->log(' --file <path> JSON file (push/export)');
$this->log(' --folder <name> Folder name (push/list)');
$this->log(' --folder-id <id> Folder ID (push/list)');
$this->log(' --no-overwrite Fail if dashboard exists');
$this->log(' --help, -h Show this help');
}
private function apiRequest(
string $method,
string $endpoint,
?string $body = null
): array {
$url = $this->grafanaUrl . $endpoint;
$ch = curl_init();
curl_setopt($ch, CURLOPT_URL, $url);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_CUSTOMREQUEST, $method);
curl_setopt($ch, CURLOPT_HTTPHEADER, [
'Content-Type: application/json',
'Accept: application/json',
"Authorization: Bearer {$this->token}",
]);
if ($body !== null) {
curl_setopt($ch, CURLOPT_POSTFIELDS, $body);
}
$responseBody = curl_exec($ch);
$httpCode = (int) curl_getinfo(
$ch,
CURLINFO_HTTP_CODE
);
if (curl_errno($ch)) {
$error = curl_error($ch);
curl_close($ch);
return [
'code' => 0,
'body' => "cURL error: {$error}",
];
}
curl_close($ch);
return ['code' => $httpCode, 'body' => $responseBody];
}
private function logApiError(string $body): void
{
$data = json_decode($body, true);
if (is_array($data) && isset($data['message'])) {
$this->log(" Grafana: {$data['message']}");
}
}
private function log(string $message): void
{
fwrite(STDERR, $message . PHP_EOL);
}
}
$app = new GrafanaDashboard();
exit($app->run());
+12
View File
@@ -155,6 +155,14 @@ function parseManifest(string $file): array
$element = (string) ($xml->element ?? '');
$group = (string) ($xml->attributes()->group ?? '');
// For packages, prefer <packagename> as the clean element (avoids pkg_pkg_ duplication)
if ($type === 'package' && $element === '') {
$packageName = (string) ($xml->packagename ?? '');
if ($packageName !== '') {
$element = $packageName;
}
}
// Fallback element detection
if ($element === '') { $element = (string) ($xml->attributes()->plugin ?? ''); }
if ($element === '') { $element = (string) ($xml->attributes()->module ?? ''); }
@@ -164,6 +172,10 @@ function parseManifest(string $file): array
$element = strtolower(basename(dirname($file)));
}
}
// Strip existing type prefix to prevent duplication (e.g. pkg_mokowaas → mokowaas)
$element = preg_replace('/^(pkg_|com_|mod_|plg_\w+_|tpl_|lib_)/', '', $element);
if ($name === '') { $name = $element; }
return compact('name', 'type', 'element', 'group');
+136
View File
@@ -0,0 +1,136 @@
#!/usr/bin/env php
<?php
/* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
*
* SPDX-License-Identifier: GPL-3.0-or-later
*
* FILE INFORMATION
* DEFGROUP: moko-platform.CLI
* INGROUP: moko-platform
* REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform
* PATH: /cli/joomla_compat_check.php
* BRIEF: Check if extension targetplatform regex matches the latest Joomla version
*
* Usage:
* php joomla_compat_check.php --path /repo
* php joomla_compat_check.php --path /repo --github-output
*
* Options:
* --path Repository root (default: .)
* --github-output Export results to $GITHUB_OUTPUT
*/
declare(strict_types=1);
$path = '.';
$ghOutput = false;
foreach ($argv as $i => $arg) {
if ($arg === '--path' && isset($argv[$i + 1])) $path = $argv[$i + 1];
if ($arg === '--github-output') $ghOutput = true;
}
$root = realpath($path) ?: $path;
// ── Find manifest and extract targetplatform ────────────────────────────
$manifest = null;
$searchDirs = ["{$root}/src", $root];
foreach ($searchDirs as $dir) {
if (!is_dir($dir)) continue;
foreach (glob("{$dir}/*.xml") ?: [] as $f) {
$xml = file_get_contents($f);
if (strpos($xml, '<extension') !== false && strpos($xml, 'targetplatform') !== false) {
$manifest = $f;
break 2;
}
}
}
if ($manifest === null) {
fwrite(STDERR, "No manifest with targetplatform found\n");
exit(1);
}
$xml = file_get_contents($manifest);
$relManifest = str_replace($root . '/', '', $manifest);
// Extract targetplatform version regex
$targetRegex = '';
if (preg_match('/targetplatform[^>]*version="([^"]+)"/', $xml, $m)) {
$targetRegex = $m[1];
}
if (empty($targetRegex)) {
echo "No targetplatform version found in {$relManifest}\n";
exit(1);
}
echo "Manifest: {$relManifest}\n";
echo "Target regex: {$targetRegex}\n";
// ── Fetch latest Joomla version ─────────────────────────────────────────
$joomlaVersions = [];
$updateUrl = 'https://update.joomla.org/core/sts/list_sts.xml';
$updateXml = @file_get_contents($updateUrl);
if ($updateXml === false) {
// Fallback: try the LTS feed
$updateUrl = 'https://update.joomla.org/core/list.xml';
$updateXml = @file_get_contents($updateUrl);
}
if ($updateXml !== false) {
// Parse all version entries
preg_match_all('/<version>([^<]+)<\/version>/', $updateXml, $matches);
$joomlaVersions = $matches[1] ?? [];
}
if (empty($joomlaVersions)) {
echo "WARNING: Could not fetch Joomla versions from update server\n";
echo "Tested URL: {$updateUrl}\n";
exit(0);
}
// Sort and get latest
usort($joomlaVersions, 'version_compare');
$latestJoomla = end($joomlaVersions);
echo "Latest Joomla: {$latestJoomla}\n";
// ── Test compatibility ──────────────────────────────────────────────────
// The targetplatform regex uses Joomla's regex format
// Common patterns: "5\.[0-9]+" or "((5.[0-9])|(6.[0-9]))"
$compatible = @preg_match("/{$targetRegex}/", $latestJoomla);
if ($compatible === false) {
echo "ERROR: Invalid regex in targetplatform: {$targetRegex}\n";
$result = 'error';
} elseif ($compatible === 1) {
echo "PASS: Joomla {$latestJoomla} matches targetplatform regex\n";
$result = 'pass';
} else {
// Check which major versions are supported
$supported = [];
foreach (['5.0', '5.1', '5.2', '5.3', '5.4', '6.0', '6.1', '6.2', '7.0'] as $v) {
if (@preg_match("/{$targetRegex}/", $v)) {
$supported[] = $v;
}
}
echo "WARN: Joomla {$latestJoomla} does NOT match targetplatform regex\n";
echo "Supported versions: " . implode(', ', $supported) . "\n";
echo "Consider updating targetplatform to include Joomla {$latestJoomla}\n";
$result = 'warn';
}
// ── Export ───────────────────────────────────────────────────────────────
if ($ghOutput) {
$ghFile = getenv('GITHUB_OUTPUT');
if ($ghFile) {
file_put_contents($ghFile, "compat_result={$result}\n", FILE_APPEND);
file_put_contents($ghFile, "compat_joomla={$latestJoomla}\n", FILE_APPEND);
file_put_contents($ghFile, "compat_regex={$targetRegex}\n", FILE_APPEND);
}
}
exit($result === 'error' ? 1 : 0);
+5 -6
View File
@@ -24,9 +24,9 @@ declare(strict_types=1);
require_once __DIR__ . '/../vendor/autoload.php';
use MokoEnterprise\{ApiClient, AuditLogger, CLIApp, Config, PlatformAdapterFactory};
use MokoEnterprise\{ApiClient, AuditLogger, CliFramework, Config, PlatformAdapterFactory};
class JoomlaRelease extends CLIApp
class JoomlaRelease extends CliFramework
{
private const VERSION = '04.06.00';
private const ORG = 'mokoconsulting-tech';
@@ -48,7 +48,7 @@ class JoomlaRelease extends CLIApp
];
private ApiClient $api;
private AuditLogger $logger;
private \MokoEnterprise\GitPlatformAdapter $adapter;
protected function configure(): void
{
@@ -75,7 +75,6 @@ class JoomlaRelease extends CLIApp
$config = Config::load();
$this->adapter = PlatformAdapterFactory::create($config);
$this->api = $this->adapter->getApiClient();
$this->logger = new AuditLogger('joomla_release');
if ($repo !== '') {
$path = $this->cloneRepo($repo);
@@ -498,5 +497,5 @@ class JoomlaRelease extends CLIApp
}
}
$script = new JoomlaRelease('joomla_release', 'Joomla release pipeline');
exit($script->execute());
$app = new JoomlaRelease();
exit($app->execute());
+230 -163
View File
@@ -1,5 +1,6 @@
#!/usr/bin/env php
<?php
/* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
*
* SPDX-License-Identifier: GPL-3.0-or-later
@@ -37,33 +38,50 @@ $elementOverride = null;
$githubOutput = false;
foreach ($argv as $i => $arg) {
if ($arg === '--path' && isset($argv[$i + 1])) $path = $argv[$i + 1];
if ($arg === '--version' && isset($argv[$i + 1])) $version = $argv[$i + 1];
if ($arg === '--output-dir' && isset($argv[$i + 1])) $outputDir = $argv[$i + 1];
if ($arg === '--type-prefix' && isset($argv[$i + 1])) $typePrefixOverride = $argv[$i + 1];
if ($arg === '--element' && isset($argv[$i + 1])) $elementOverride = $argv[$i + 1];
if ($arg === '--github-output') $githubOutput = true;
if ($arg === '--path' && isset($argv[$i + 1])) {
$path = $argv[$i + 1];
}
if ($arg === '--version' && isset($argv[$i + 1])) {
$version = $argv[$i + 1];
}
if ($arg === '--output-dir' && isset($argv[$i + 1])) {
$outputDir = $argv[$i + 1];
}
if ($arg === '--type-prefix' && isset($argv[$i + 1])) {
$typePrefixOverride = $argv[$i + 1];
}
if ($arg === '--element' && isset($argv[$i + 1])) {
$elementOverride = $argv[$i + 1];
}
if ($arg === '--github-output') {
$githubOutput = true;
}
}
if ($version === null) {
fwrite(STDERR, "Usage: package_build.php --path . --version XX.YY.ZZ [--output-dir /tmp]\n");
exit(1);
fwrite(STDERR, "Usage: package_build.php --path . --version XX.YY.ZZ [--output-dir /tmp]\n");
exit(1);
}
$root = realpath($path) ?: $path;
// Ensure output directory exists
if (!is_dir($outputDir)) {
mkdir($outputDir, 0755, true);
}
// -- Determine source directory -----------------------------------------------
$sourceDir = null;
foreach (['src', 'htdocs'] as $candidate) {
if (is_dir("{$root}/{$candidate}")) {
$sourceDir = "{$root}/{$candidate}";
break;
}
if (is_dir("{$root}/{$candidate}")) {
$sourceDir = "{$root}/{$candidate}";
break;
}
}
if ($sourceDir === null) {
fwrite(STDERR, "No src/ or htdocs/ directory found in {$root}\n");
exit(1);
fwrite(STDERR, "No src/ or htdocs/ directory found in {$root}\n");
exit(1);
}
// -- Determine element and type prefix from manifest --------------------------
@@ -73,54 +91,80 @@ $extType = '';
$isPackage = false;
if ($extElement === null || $typePrefixOverride === null) {
// Find manifest
$manifest = null;
foreach (glob("{$sourceDir}/pkg_*.xml") ?: [] as $f) {
if (strpos(file_get_contents($f), '<extension') !== false) {
$manifest = $f;
break;
}
}
if ($manifest === null) {
foreach (glob("{$sourceDir}/*.xml") ?: [] as $f) {
if (strpos(file_get_contents($f), '<extension') !== false) {
$manifest = $f;
break;
}
}
}
// Find manifest
$manifest = null;
foreach (glob("{$sourceDir}/pkg_*.xml") ?: [] as $f) {
if (strpos(file_get_contents($f), '<extension') !== false) {
$manifest = $f;
break;
}
}
if ($manifest === null) {
foreach (glob("{$sourceDir}/*.xml") ?: [] as $f) {
if (strpos(file_get_contents($f), '<extension') !== false) {
$manifest = $f;
break;
}
}
}
if ($manifest !== null) {
$xml = file_get_contents($manifest);
if ($manifest !== null) {
$xml = file_get_contents($manifest);
if ($extElement === null) {
if (preg_match('/<element>([^<]+)<\/element>/', $xml, $m)) $extElement = $m[1];
elseif (preg_match('/plugin="([^"]+)"/', $xml, $m)) $extElement = $m[1];
elseif (preg_match('/module="([^"]+)"/', $xml, $m)) $extElement = $m[1];
else $extElement = strtolower(pathinfo($manifest, PATHINFO_FILENAME));
}
if ($extElement === null) {
if (preg_match('/<element>([^<]+)<\/element>/', $xml, $m)) {
$extElement = $m[1];
} elseif (preg_match('/plugin="([^"]+)"/', $xml, $m)) {
$extElement = $m[1];
} elseif (preg_match('/module="([^"]+)"/', $xml, $m)) {
$extElement = $m[1];
} else {
$extElement = strtolower(pathinfo($manifest, PATHINFO_FILENAME));
}
}
if (preg_match('/<extension[^>]*type="([^"]+)"/', $xml, $m)) $extType = $m[1];
$extFolder = '';
if (preg_match('/<extension[^>]*group="([^"]+)"/', $xml, $m)) $extFolder = $m[1];
if (preg_match('/<extension[^>]*type="([^"]+)"/', $xml, $m)) {
$extType = $m[1];
}
$extFolder = '';
if (preg_match('/<extension[^>]*group="([^"]+)"/', $xml, $m)) {
$extFolder = $m[1];
}
if ($typePrefixOverride === null) {
switch ($extType) {
case 'plugin': $typePrefix = "plg_{$extFolder}_"; break;
case 'module': $typePrefix = 'mod_'; break;
case 'component': $typePrefix = 'com_'; break;
case 'template': $typePrefix = 'tpl_'; break;
case 'library': $typePrefix = 'lib_'; break;
case 'package': $typePrefix = 'pkg_'; break;
}
}
if ($typePrefixOverride === null) {
switch ($extType) {
case 'plugin':
$typePrefix = "plg_{$extFolder}_";
break;
case 'module':
$typePrefix = 'mod_';
break;
case 'component':
$typePrefix = 'com_';
break;
case 'template':
$typePrefix = 'tpl_';
break;
case 'library':
$typePrefix = 'lib_';
break;
case 'package':
$typePrefix = 'pkg_';
break;
}
}
$isPackage = ($extType === 'package' && is_dir("{$sourceDir}/packages"));
}
$isPackage = ($extType === 'package' && is_dir("{$sourceDir}/packages"));
}
}
if ($extElement === null) {
$extElement = strtolower(basename($root));
$extElement = strtolower(basename($root));
}
// Prevent double prefix (e.g. pkg_pkg_mokogallery)
if ($typePrefix !== '' && str_starts_with($extElement, rtrim($typePrefix, '_'))) {
$extElement = substr($extElement, strlen(rtrim($typePrefix, '_')) + 1);
}
$zipName = "{$typePrefix}{$extElement}-{$version}.zip";
@@ -130,87 +174,106 @@ $tarPath = "{$outputDir}/{$tarName}";
// -- Exclude patterns ---------------------------------------------------------
$excludePatterns = [
'.ftpignore',
'sftp-config*',
'*.ppk',
'*.pem',
'*.key',
'.env*',
'.ftpignore',
'sftp-config*',
'*.ppk',
'*.pem',
'*.key',
'.env*',
];
// -- Build packages -----------------------------------------------------------
if ($isPackage) {
echo "=== Building Joomla PACKAGE (multi-extension) ===\n";
echo "=== Building Joomla PACKAGE (multi-extension) ===\n";
$stagingDir = sys_get_temp_dir() . '/moko-pkg-' . uniqid();
mkdir($stagingDir, 0755, true);
$stagingDir = sys_get_temp_dir() . '/moko-pkg-' . uniqid();
$packagesDir = "{$stagingDir}/packages";
mkdir($packagesDir, 0755, true);
// ZIP each sub-extension
foreach (glob("{$sourceDir}/packages/*/") ?: [] as $extDir) {
$subName = basename($extDir);
echo " Packaging sub-extension: {$subName}\n";
// ZIP each sub-extension into packages/
foreach (glob("{$sourceDir}/packages/*/") ?: [] as $extDir) {
$subName = basename($extDir);
echo " Packaging sub-extension: {$subName}\n";
$subZip = new ZipArchive();
$subZipPath = "{$stagingDir}/{$subName}.zip";
if ($subZip->open($subZipPath, ZipArchive::CREATE | ZipArchive::OVERWRITE) !== true) {
fwrite(STDERR, "Failed to create ZIP for {$subName}\n");
continue;
}
$subZip = new ZipArchive();
$subZipPath = "{$packagesDir}/{$subName}.zip";
if ($subZip->open($subZipPath, ZipArchive::CREATE | ZipArchive::OVERWRITE) !== true) {
fwrite(STDERR, "Failed to create ZIP for {$subName}\n");
continue;
}
addDirectoryToZip($subZip, $extDir, '', $excludePatterns);
$subZip->close();
}
addDirectoryToZip($subZip, $extDir, '', $excludePatterns);
$subZip->close();
echo " -> packages/{$subName}.zip (" . filesize($subZipPath) . " bytes)\n";
}
// Copy package-level files
foreach (array_merge(glob("{$sourceDir}/*.xml") ?: [], glob("{$sourceDir}/*.php") ?: []) as $f) {
copy($f, "{$stagingDir}/" . basename($f));
}
// Copy package-level files (manifest, script.php, etc.)
foreach (array_merge(glob("{$sourceDir}/*.xml") ?: [], glob("{$sourceDir}/*.php") ?: []) as $f) {
copy($f, "{$stagingDir}/" . basename($f));
}
// Create ZIP from staging
$zip = new ZipArchive();
if ($zip->open($zipPath, ZipArchive::CREATE | ZipArchive::OVERWRITE) !== true) {
fwrite(STDERR, "Failed to create ZIP: {$zipPath}\n");
exit(1);
}
addDirectoryToZip($zip, $stagingDir, '', []);
$zip->close();
// Copy language directory if present
if (is_dir("{$sourceDir}/language")) {
$langDest = "{$stagingDir}/language";
mkdir($langDest, 0755, true);
$langIterator = new RecursiveIteratorIterator(
new RecursiveDirectoryIterator("{$sourceDir}/language", RecursiveDirectoryIterator::SKIP_DOTS),
RecursiveIteratorIterator::SELF_FIRST
);
foreach ($langIterator as $item) {
$target = $langDest . '/' . substr($item->getPathname(), strlen("{$sourceDir}/language") + 1);
if ($item->isDir()) {
mkdir($target, 0755, true);
} else {
copy($item->getPathname(), $target);
}
}
}
// Create tar.gz — all arguments are escaped via escapeshellarg()
$tarCmd = sprintf(
'tar -czf %s -C %s .',
escapeshellarg($tarPath),
escapeshellarg($stagingDir)
);
passthru($tarCmd, $tarReturn);
// Create ZIP from staging
$zip = new ZipArchive();
if ($zip->open($zipPath, ZipArchive::CREATE | ZipArchive::OVERWRITE) !== true) {
fwrite(STDERR, "Failed to create ZIP: {$zipPath}\n");
exit(1);
}
addDirectoryToZip($zip, $stagingDir, '', []);
$zip->close();
// Cleanup staging
$cleanCmd = sprintf('rm -rf %s', escapeshellarg($stagingDir));
passthru($cleanCmd);
// Create tar.gz — all arguments are escaped via escapeshellarg()
$tarCmd = sprintf(
'tar -czf %s -C %s .',
escapeshellarg($tarPath),
escapeshellarg($stagingDir)
);
passthru($tarCmd, $tarReturn);
// Cleanup staging
$cleanCmd = sprintf('rm -rf %s', escapeshellarg($stagingDir));
passthru($cleanCmd);
} else {
echo "=== Building standard extension package ===\n";
echo "=== Building standard extension package ===\n";
// ZIP
$zip = new ZipArchive();
if ($zip->open($zipPath, ZipArchive::CREATE | ZipArchive::OVERWRITE) !== true) {
fwrite(STDERR, "Failed to create ZIP: {$zipPath}\n");
exit(1);
}
addDirectoryToZip($zip, $sourceDir, '', $excludePatterns);
$zip->close();
// ZIP
$zip = new ZipArchive();
if ($zip->open($zipPath, ZipArchive::CREATE | ZipArchive::OVERWRITE) !== true) {
fwrite(STDERR, "Failed to create ZIP: {$zipPath}\n");
exit(1);
}
addDirectoryToZip($zip, $sourceDir, '', $excludePatterns);
$zip->close();
// tar.gz — all arguments are escaped via escapeshellarg()
$excludeArgs = '';
foreach ($excludePatterns as $pattern) {
$excludeArgs .= ' --exclude=' . escapeshellarg($pattern);
}
$tarCmd = sprintf(
'tar -czf %s -C %s%s .',
escapeshellarg($tarPath),
escapeshellarg($sourceDir),
$excludeArgs
);
passthru($tarCmd, $tarReturn);
// tar.gz — all arguments are escaped via escapeshellarg()
$excludeArgs = '';
foreach ($excludePatterns as $pattern) {
$excludeArgs .= ' --exclude=' . escapeshellarg($pattern);
}
$tarCmd = sprintf(
'tar -czf %s -C %s%s .',
escapeshellarg($tarPath),
escapeshellarg($sourceDir),
$excludeArgs
);
passthru($tarCmd, $tarReturn);
}
// -- Calculate SHA-256 --------------------------------------------------------
@@ -224,29 +287,31 @@ echo "\n";
echo "ZIP: {$zipName} ({$zipSize} bytes)\n";
echo " SHA-256: {$sha256Zip}\n";
if ($tarSize > 0) {
echo "TAR: {$tarName} ({$tarSize} bytes)\n";
echo " SHA-256: {$sha256Tar}\n";
echo "TAR: {$tarName} ({$tarSize} bytes)\n";
echo " SHA-256: {$sha256Tar}\n";
}
// -- Export to GITHUB_OUTPUT --------------------------------------------------
if ($githubOutput) {
$ghOutput = getenv('GITHUB_OUTPUT');
$lines = [
"zip_name={$zipName}",
"tar_name={$tarName}",
"zip_path={$zipPath}",
"tar_path={$tarPath}",
"sha256_zip={$sha256Zip}",
"sha256_tar={$sha256Tar}",
"type_prefix={$typePrefix}",
"ext_element={$extElement}",
];
if ($ghOutput) {
file_put_contents($ghOutput, implode("\n", $lines) . "\n", FILE_APPEND);
fwrite(STDERR, "Exported " . count($lines) . " fields to GITHUB_OUTPUT\n");
} else {
foreach ($lines as $line) echo "{$line}\n";
}
$ghOutput = getenv('GITHUB_OUTPUT');
$lines = [
"zip_name={$zipName}",
"tar_name={$tarName}",
"zip_path={$zipPath}",
"tar_path={$tarPath}",
"sha256_zip={$sha256Zip}",
"sha256_tar={$sha256Tar}",
"type_prefix={$typePrefix}",
"ext_element={$extElement}",
];
if ($ghOutput) {
file_put_contents($ghOutput, implode("\n", $lines) . "\n", FILE_APPEND);
fwrite(STDERR, "Exported " . count($lines) . " fields to GITHUB_OUTPUT\n");
} else {
foreach ($lines as $line) {
echo "{$line}\n";
}
}
}
exit(0);
@@ -256,33 +321,35 @@ exit(0);
// =============================================================================
function addDirectoryToZip(ZipArchive $zip, string $dir, string $prefix, array $excludes): void
{
$iterator = new RecursiveIteratorIterator(
new RecursiveDirectoryIterator($dir, RecursiveDirectoryIterator::SKIP_DOTS),
RecursiveIteratorIterator::SELF_FIRST
);
$iterator = new RecursiveIteratorIterator(
new RecursiveDirectoryIterator($dir, RecursiveDirectoryIterator::SKIP_DOTS),
RecursiveIteratorIterator::SELF_FIRST
);
foreach ($iterator as $file) {
$filePath = $file->getPathname();
$relativePath = $prefix . substr($filePath, strlen($dir) + 1);
foreach ($iterator as $file) {
$filePath = $file->getPathname();
$relativePath = $prefix . substr($filePath, strlen($dir) + 1);
// Check excludes
$basename = basename($filePath);
$skip = false;
foreach ($excludes as $pattern) {
if (fnmatch($pattern, $basename)) {
$skip = true;
break;
}
}
if ($skip) continue;
// Check excludes
$basename = basename($filePath);
$skip = false;
foreach ($excludes as $pattern) {
if (fnmatch($pattern, $basename)) {
$skip = true;
break;
}
}
if ($skip) {
continue;
}
// Normalize path separators for ZIP
$relativePath = str_replace('\\', '/', $relativePath);
// Normalize path separators for ZIP
$relativePath = str_replace('\\', '/', $relativePath);
if ($file->isDir()) {
$zip->addEmptyDir($relativePath);
} else {
$zip->addFile($filePath, $relativePath);
}
}
if ($file->isDir()) {
$zip->addEmptyDir($relativePath);
} else {
$zip->addFile($filePath, $relativePath);
}
}
}
+5 -4
View File
@@ -48,10 +48,11 @@ if ($stability === null || $token === null || $apiBase === null) {
// Define cascade hierarchy
$cascadeMap = [
'stable' => ['development', 'alpha', 'beta', 'release-candidate'],
'rc' => ['development', 'alpha', 'beta'],
'beta' => ['development', 'alpha'],
'alpha' => ['development'],
'stable' => ['development', 'alpha', 'beta', 'release-candidate'],
'release-candidate' => ['development', 'alpha', 'beta'],
'rc' => ['development', 'alpha', 'beta'],
'beta' => ['development', 'alpha'],
'alpha' => ['development'],
];
if (!isset($cascadeMap[$stability])) {
+11 -11
View File
@@ -83,7 +83,7 @@ if ($action === null || $tag === null || $token === null || $apiBase === null) {
/**
* Make a Gitea API request using curl
*/
function giteaApi(string $url, string $method, string $token, ?string $jsonBody = null, ?string $filePath = null): array
function releaseGiteaApi(string $url, string $method, string $token, ?string $jsonBody = null, ?string $filePath = null): array
{
$ch = curl_init($url);
$headers = ["Authorization: token {$token}"];
@@ -118,7 +118,7 @@ function giteaApi(string $url, string $method, string $token, ?string $jsonBody
*/
function getReleaseByTag(string $apiBase, string $tag, string $token): ?array
{
$result = giteaApi("{$apiBase}/releases/tags/{$tag}", 'GET', $token);
$result = releaseGiteaApi("{$apiBase}/releases/tags/{$tag}", 'GET', $token);
if ($result['code'] === 200 && isset($result['data']['id'])) {
return $result['data'];
}
@@ -132,8 +132,8 @@ switch ($action) {
$existing = getReleaseByTag($apiBase, $tag, $token);
if ($existing !== null) {
$existingId = $existing['id'];
giteaApi("{$apiBase}/releases/{$existingId}", 'DELETE', $token);
giteaApi("{$apiBase}/tags/{$tag}", 'DELETE', $token);
releaseGiteaApi("{$apiBase}/releases/{$existingId}", 'DELETE', $token);
releaseGiteaApi("{$apiBase}/tags/{$tag}", 'DELETE', $token);
echo "Deleted previous release: {$tag} (id: {$existingId})\n";
}
@@ -144,7 +144,7 @@ switch ($action) {
'target_commitish' => $target,
]);
$result = giteaApi("{$apiBase}/releases", 'POST', $token, $payload);
$result = releaseGiteaApi("{$apiBase}/releases", 'POST', $token, $payload);
if ($result['code'] >= 200 && $result['code'] < 300) {
$releaseId = $result['data']['id'] ?? 'unknown';
echo "Release created: {$name} (tag: {$tag}, id: {$releaseId})\n";
@@ -169,7 +169,7 @@ switch ($action) {
$releaseId = $release['id'];
// Get existing assets to avoid duplicates
$assetsResult = giteaApi("{$apiBase}/releases/{$releaseId}/assets", 'GET', $token);
$assetsResult = releaseGiteaApi("{$apiBase}/releases/{$releaseId}/assets", 'GET', $token);
$existingAssets = $assetsResult['data'] ?? [];
foreach ($files as $filePath) {
@@ -184,7 +184,7 @@ switch ($action) {
// Delete existing asset with same name
foreach ($existingAssets as $asset) {
if (($asset['name'] ?? '') === $fileName) {
giteaApi("{$apiBase}/releases/{$releaseId}/assets/{$asset['id']}", 'DELETE', $token);
releaseGiteaApi("{$apiBase}/releases/{$releaseId}/assets/{$asset['id']}", 'DELETE', $token);
echo "Deleted existing asset: {$fileName}\n";
break;
}
@@ -192,7 +192,7 @@ switch ($action) {
// Upload
$uploadUrl = "{$apiBase}/releases/{$releaseId}/assets?name=" . urlencode($fileName);
$result = giteaApi($uploadUrl, 'POST', $token, null, $filePath);
$result = releaseGiteaApi($uploadUrl, 'POST', $token, null, $filePath);
if ($result['code'] >= 200 && $result['code'] < 300) {
echo "Uploaded: {$fileName}\n";
} else {
@@ -210,7 +210,7 @@ switch ($action) {
$releaseId = $release['id'];
$payload = json_encode(['body' => $body ?? '']);
$result = giteaApi("{$apiBase}/releases/{$releaseId}", 'PATCH', $token, $payload);
$result = releaseGiteaApi("{$apiBase}/releases/{$releaseId}", 'PATCH', $token, $payload);
if ($result['code'] >= 200 && $result['code'] < 300) {
echo "Release body updated for tag: {$tag}\n";
} else {
@@ -222,8 +222,8 @@ switch ($action) {
case 'delete':
$existing = getReleaseByTag($apiBase, $tag, $token);
if ($existing !== null) {
giteaApi("{$apiBase}/releases/{$existing['id']}", 'DELETE', $token);
giteaApi("{$apiBase}/tags/{$tag}", 'DELETE', $token);
releaseGiteaApi("{$apiBase}/releases/{$existing['id']}", 'DELETE', $token);
releaseGiteaApi("{$apiBase}/tags/{$tag}", 'DELETE', $token);
echo "Deleted: {$tag} (id: {$existing['id']})\n";
} else {
echo "No release found for tag: {$tag}\n";
+209
View File
@@ -0,0 +1,209 @@
#!/usr/bin/env php
<?php
/* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
*
* SPDX-License-Identifier: GPL-3.0-or-later
*
* FILE INFORMATION
* DEFGROUP: moko-platform.CLI
* INGROUP: moko-platform
* REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform
* PATH: /cli/theme_lint.php
* BRIEF: Lint theme files — CSS syntax, image sizes, hardcoded URLs
*
* Usage:
* php theme_lint.php --path /repo
* php theme_lint.php --path /repo --max-image-kb 500
* php theme_lint.php --path /repo --github-output
*
* Options:
* --path Repository root (default: .)
* --max-image-kb Maximum image file size in KB (default: 500)
* --github-output Export results to $GITHUB_OUTPUT
* --strict Exit 1 on any warning (default: only on errors)
*/
declare(strict_types=1);
$path = '.';
$maxImageKb = 500;
$ghOutput = false;
$strict = false;
foreach ($argv as $i => $arg) {
if ($arg === '--path' && isset($argv[$i + 1])) $path = $argv[$i + 1];
if ($arg === '--max-image-kb' && isset($argv[$i + 1])) $maxImageKb = (int)$argv[$i + 1];
if ($arg === '--github-output') $ghOutput = true;
if ($arg === '--strict') $strict = true;
}
$root = realpath($path) ?: $path;
$errors = 0;
$warnings = 0;
// ── Find source directory ───────────────────────────────────────────────
$srcDir = null;
foreach (['src', 'htdocs'] as $d) {
if (is_dir("{$root}/{$d}")) { $srcDir = "{$root}/{$d}"; break; }
}
if ($srcDir === null) {
fwrite(STDERR, "No src/ or htdocs/ directory in {$root}\n");
exit(1);
}
echo "Theme Lint: {$srcDir}\n\n";
// ── Check 1: CSS syntax validation ──────────────────────────────────────
echo "--- CSS Syntax ---\n";
$cssFiles = findFiles($srcDir, '*.css');
$cssMinFiles = findFiles($srcDir, '*.min.css');
$cssToCheck = array_diff($cssFiles, $cssMinFiles);
if (empty($cssToCheck)) {
echo " No CSS files to check\n";
} else {
foreach ($cssToCheck as $file) {
$content = file_get_contents($file);
$relPath = str_replace($root . '/', '', $file);
// Check for unmatched braces
$openBraces = substr_count($content, '{');
$closeBraces = substr_count($content, '}');
if ($openBraces !== $closeBraces) {
echo " ERROR: {$relPath}: unmatched braces (open={$openBraces}, close={$closeBraces})\n";
$errors++;
}
// Check for empty rules
if (preg_match_all('/\{[\s]*\}/', $content, $m)) {
$count = count($m[0]);
echo " WARN: {$relPath}: {$count} empty rule(s)\n";
$warnings++;
}
// Check for !important abuse (more than 10 in one file)
$importantCount = substr_count($content, '!important');
if ($importantCount > 10) {
echo " WARN: {$relPath}: {$importantCount} !important declarations (consider refactoring)\n";
$warnings++;
}
}
if ($errors === 0) {
echo " OK: " . count($cssToCheck) . " CSS file(s) checked\n";
}
}
// ── Check 2: Image file sizes ───────────────────────────────────────────
echo "\n--- Image Sizes (max {$maxImageKb}KB) ---\n";
$imageExts = ['*.jpg', '*.jpeg', '*.png', '*.gif', '*.webp', '*.svg', '*.bmp'];
$images = [];
foreach ($imageExts as $ext) {
$images = array_merge($images, findFiles($srcDir, $ext));
}
// Also check root images/ directory
if (is_dir("{$root}/images")) {
foreach ($imageExts as $ext) {
$images = array_merge($images, findFiles("{$root}/images", $ext));
}
}
$oversized = 0;
$totalSize = 0;
foreach ($images as $file) {
$size = filesize($file);
$totalSize += $size;
$relPath = str_replace($root . '/', '', $file);
$sizeKb = round($size / 1024);
if ($sizeKb > $maxImageKb) {
echo " WARN: {$relPath}: {$sizeKb}KB (exceeds {$maxImageKb}KB limit)\n";
$oversized++;
$warnings++;
}
}
$totalMb = round($totalSize / 1024 / 1024, 1);
echo " " . count($images) . " image(s), {$totalMb}MB total";
if ($oversized > 0) {
echo ", {$oversized} oversized";
}
echo "\n";
// ── Check 3: Hardcoded URLs in CSS/JS ───────────────────────────────────
echo "\n--- Hardcoded URLs ---\n";
$codeFiles = array_merge(
findFiles($srcDir, '*.css'),
findFiles($srcDir, '*.js')
);
// Exclude minified files
$codeFiles = array_filter($codeFiles, function($f) {
return !preg_match('/\.min\.(css|js)$/', $f);
});
$urlPatterns = [
'/https?:\/\/clarksvillefurs\.com/' => 'hardcoded production URL',
'/https?:\/\/[a-z]+\.dev\.mokoconsulting\.tech/' => 'hardcoded dev URL',
'/https?:\/\/localhost/' => 'localhost reference',
];
$urlIssues = 0;
foreach ($codeFiles as $file) {
$content = file_get_contents($file);
$relPath = str_replace($root . '/', '', $file);
foreach ($urlPatterns as $pattern => $desc) {
if (preg_match_all($pattern, $content, $matches)) {
$count = count($matches[0]);
echo " WARN: {$relPath}: {$count} {$desc}\n";
$urlIssues++;
$warnings++;
}
}
}
if ($urlIssues === 0) {
echo " OK: No hardcoded URLs found\n";
}
// ── Summary ─────────────────────────────────────────────────────────────
echo "\n=== Summary ===\n";
echo "Errors: {$errors}\n";
echo "Warnings: {$warnings}\n";
if ($ghOutput) {
$ghFile = getenv('GITHUB_OUTPUT');
if ($ghFile) {
file_put_contents($ghFile, "lint_errors={$errors}\n", FILE_APPEND);
file_put_contents($ghFile, "lint_warnings={$warnings}\n", FILE_APPEND);
file_put_contents($ghFile, "lint_images=" . count($images) . "\n", FILE_APPEND);
file_put_contents($ghFile, "lint_css=" . count($cssToCheck) . "\n", FILE_APPEND);
}
}
if ($errors > 0) {
exit(1);
}
if ($strict && $warnings > 0) {
exit(1);
}
exit(0);
// ── Helper: recursively find files matching a glob pattern ──────────────
function findFiles(string $dir, string $pattern): array
{
$results = [];
if (!is_dir($dir)) return $results;
$iterator = new RecursiveIteratorIterator(
new RecursiveDirectoryIterator($dir, RecursiveDirectoryIterator::SKIP_DOTS)
);
foreach ($iterator as $file) {
if (fnmatch($pattern, $file->getFilename())) {
$results[] = $file->getPathname();
}
}
return $results;
}
+69 -25
View File
@@ -102,6 +102,8 @@ if (preg_match('/<extension[^>]*type="([^"]+)"/', $xml, $m)) $extType = $m[1];
$extElement = '';
if (preg_match('/<element>([^<]+)<\/element>/', $xml, $m)) $extElement = $m[1];
// For packages, prefer <packagename> to avoid pkg_pkg_ duplication
if (empty($extElement) && preg_match('/<packagename>([^<]+)<\/packagename>/', $xml, $m)) $extElement = $m[1];
if (empty($extElement) && preg_match('/plugin="([^"]+)"/', $xml, $m)) $extElement = $m[1];
if (empty($extElement) && preg_match('/module="([^"]+)"/', $xml, $m)) $extElement = $m[1];
if (empty($extElement)) {
@@ -112,6 +114,8 @@ if (empty($extElement)) {
$extElement = $fname;
}
}
// Strip existing type prefix to prevent duplication (e.g. pkg_mokowaas → mokowaas)
$extElement = preg_replace('/^(pkg_|com_|mod_|plg_\w+_|tpl_|lib_)/', '', $extElement);
$extClient = '';
if (preg_match('/<extension[^>]*client="([^"]+)"/', $xml, $m)) $extClient = $m[1];
@@ -122,7 +126,7 @@ if (preg_match('/<extension[^>]*group="([^"]+)"/', $xml, $m)) $extFolder = $m[1]
$targetPlatform = '';
if (preg_match('/(<targetplatform[^\/]*\/>)/', $xml, $m)) $targetPlatform = $m[1];
if (empty($targetPlatform)) {
$targetPlatform = '<targetplatform name="joomla" version="((5.[0-9])|(6.[0-9]))" />';
$targetPlatform = '<targetplatform name="joomla" version="(5|6)\..*" />';
}
$phpMinimum = '';
@@ -190,29 +194,35 @@ $stabilitySuffixMap = [
'development' => '-dev',
];
// Joomla <tags><tag> values — maps to Joomla's stabilityTagToInteger()
$stabilityTagMap = [
'stable' => 'stable',
'rc' => 'rc',
'beta' => 'beta',
'alpha' => 'alpha',
'development' => 'dev',
];
// Gitea release tag names (used in download/info URLs)
$releaseTagMap = [
'stable' => 'stable',
'rc' => 'release-candidate',
'beta' => 'beta',
'alpha' => 'alpha',
'development' => 'development',
];
// -- Build update entries -----------------------------------------------------
$releaseTag = $stabilityTagMap[$stability] ?? $stability;
// For the primary entry: apply suffix if not stable
$primarySuffix = $stabilitySuffixMap[$stability] ?? '';
$primaryVersion = $version . $primarySuffix;
$downloadUrl = "{$giteaUrl}/{$org}/{$repo}/releases/download/{$releaseTag}/{$typePrefix}{$extElement}-{$primaryVersion}.zip";
$infoUrl = "{$giteaUrl}/{$org}/{$repo}/releases/tag/{$releaseTag}";
// Build client tag
// Build client tag — Joomla requires <client>site</client> to match updates
// to installed extensions. Without it, extension_id=0 in #__updates.
$clientTag = '';
if (!empty($extClient)) {
$clientTag = " <client>{$extClient}</client>";
} elseif ($extType === 'module' || $extType === 'plugin') {
} else {
$clientTag = ' <client>site</client>';
}
@@ -255,7 +265,10 @@ function buildEntry(
$lines[] = ' <update>';
$lines[] = " <name>{$extName}</name>";
$lines[] = " <description>{$extName} update</description>";
$lines[] = " <element>{$extElement}</element>";
// Element in updates.xml must match what Joomla stores in #__extensions
// For packages: pkg_elementname. For plugins: elementname (folder handles grouping).
$dbElement = ($extType === 'package') ? "pkg_{$extElement}" : $extElement;
$lines[] = " <element>{$dbElement}</element>";
$lines[] = " <type>{$extType}</type>";
$lines[] = " <version>{$entryVersion}</version>";
if (!empty($clientTag)) $lines[] = $clientTag;
@@ -275,29 +288,31 @@ function buildEntry(
}
// -- Determine which channels to write ----------------------------------------
// Stable cascades to all channels; pre-releases only write their level and below
// Each channel gets its own suffixed version:
// development -> 04.01.00-dev
// alpha -> 04.01.00-alpha
// beta -> 04.01.00-beta
// rc -> 04.01.00-rc
// stable -> 04.01.00
// Stable cascades to all channels; pre-releases cascade down to lower channels.
// Each channel entry represents "latest release available at this stability or higher".
// When stable releases, ALL channels point to stable (it's the newest for everyone).
// When RC releases, rc/beta/alpha/dev point to RC; stable is preserved.
// When dev releases, only dev is updated; everything else is preserved.
$allChannels = ['development', 'alpha', 'beta', 'rc', 'stable'];
$stabilityIndex = array_search($stability === 'development' ? 'development' : $stability, $allChannels);
if ($stabilityIndex === false) $stabilityIndex = 4; // default to stable
// Write entries for this stability and all below it
// Write entries for the current channel AND all lower channels (cascade down)
// All cascaded entries point to the CURRENT release (the highest stability being built)
$entries = [];
$giteaTag = $releaseTagMap[$stability] ?? $stability;
$channelVersion = $version . ($stabilitySuffixMap[$stability] ?? '');
$channelDownloadUrl = "{$giteaUrl}/{$org}/{$repo}/releases/download/{$giteaTag}/{$typePrefix}{$extElement}-{$channelVersion}.zip";
$channelInfoUrl = "{$giteaUrl}/{$org}/{$repo}/releases/tag/{$giteaTag}";
for ($i = 0; $i <= $stabilityIndex; $i++) {
$channelName = $allChannels[$i];
$channelSuffix = $stabilitySuffixMap[$channelName] ?? '';
$channelVersion = $version . $channelSuffix;
$channelTag = $stabilityTagMap[$channelName] ?? $channelName;
$channelDownloadUrl = "{$giteaUrl}/{$org}/{$repo}/releases/download/{$channelTag}/{$typePrefix}{$extElement}-{$channelVersion}.zip";
$channelInfoUrl = "{$giteaUrl}/{$org}/{$repo}/releases/tag/{$channelTag}";
$joomlaTag = $stabilityTagMap[$channelName] ?? $channelName;
// Only attach SHA to the primary channel entry
$entrySha = ($i === $stabilityIndex) ? $shaTag : '';
$entries[] = buildEntry(
$channelName,
$joomlaTag,
$channelVersion,
$channelDownloadUrl,
$extName,
@@ -308,10 +323,38 @@ for ($i = 0; $i <= $stabilityIndex; $i++) {
$channelInfoUrl,
$targetPlatform,
$phpTag,
$shaTag
$entrySha
);
}
// -- Preserve existing entries for channels not being updated -----------------
$dest = $outputFile ?? "{$root}/updates.xml";
$preservedEntries = [];
if (file_exists($dest)) {
$existingXml = @simplexml_load_file($dest);
if ($existingXml) {
// Joomla tags we're writing — don't preserve these
$writtenChannels = [];
for ($i = 0; $i <= $stabilityIndex; $i++) {
$writtenChannels[] = $stabilityTagMap[$allChannels[$i]] ?? $allChannels[$i];
}
// Also match legacy/alternate tag names (e.g. 'development' = 'dev')
$writtenChannels[] = 'development'; // alias for 'dev'
foreach ($existingXml->update as $existingUpdate) {
$existingTag = '';
if (isset($existingUpdate->tags->tag)) {
$existingTag = (string) $existingUpdate->tags->tag;
}
// Keep entries for channels we're NOT overwriting
if (!empty($existingTag) && !in_array($existingTag, $writtenChannels, true)) {
$preservedEntries[] = ' ' . trim($existingUpdate->asXML());
}
}
}
}
// -- Write updates.xml --------------------------------------------------------
$year = date('Y');
$output = <<<XML
@@ -323,7 +366,8 @@ $output = <<<XML
<updates>
XML;
$output .= "\n" . implode("\n", $entries) . "\n</updates>\n";
$allEntries = array_merge($preservedEntries, $entries);
$output .= "\n" . implode("\n", $allEntries) . "\n</updates>\n";
$dest = $outputFile ?? "{$root}/updates.xml";
file_put_contents($dest, $output);
+69 -17
View File
@@ -9,7 +9,7 @@
* INGROUP: moko-platform
* REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform
* PATH: /cli/version_bump.php
* BRIEF: Auto-increment patch version in README.md — outputs old → new
* BRIEF: Auto-increment patch version — checks both README.md and manifest XML, uses the higher version as base
*/
declare(strict_types=1);
@@ -22,21 +22,69 @@ foreach ($argv as $i => $arg) {
if ($arg === '--major') $type = 'major';
}
$readme = realpath($path) . '/README.md';
if (!file_exists($readme)) {
fwrite(STDERR, "No README.md found at {$path}\n");
$root = realpath($path) ?: $path;
// ── Read version from README.md ──────────────────────────────────────────────
$readmeVersion = null;
$readme = "{$root}/README.md";
$readmeContent = '';
if (file_exists($readme)) {
$readmeContent = file_get_contents($readme);
if (preg_match('/VERSION:\s*(\d{2}\.\d{2}\.\d{2})/m', $readmeContent, $m)) {
$readmeVersion = $m[1];
}
}
// ── Read version from Joomla manifest XML ────────────────────────────────────
$manifestVersion = null;
// Check package manifest first (pkg_*.xml), then sub-extension manifests
$manifestFiles = array_merge(
glob("{$root}/src/pkg_*.xml") ?: [],
glob("{$root}/src/*.xml") ?: [],
glob("{$root}/src/packages/*/mokowaas.xml") ?: [],
glob("{$root}/src/packages/*/*.xml") ?: [],
glob("{$root}/*.xml") ?: []
);
foreach ($manifestFiles as $xmlFile) {
$xmlContent = file_get_contents($xmlFile);
if (strpos($xmlContent, '<extension') === false && strpos($xmlContent, '<version>') === false) {
continue;
}
if (preg_match('|<version>(\d{2}\.\d{2}\.\d{2})(?:-[a-z]+)?</version>|', $xmlContent, $xm)) {
$candidate = $xm[1];
if ($manifestVersion === null || version_compare($candidate, $manifestVersion, '>')) {
$manifestVersion = $candidate;
}
}
}
// ── Use the higher version as base ───────────────────────────────────────────
$baseVersion = null;
if ($readmeVersion !== null && $manifestVersion !== null) {
$baseVersion = version_compare($manifestVersion, $readmeVersion, '>') ? $manifestVersion : $readmeVersion;
} elseif ($manifestVersion !== null) {
$baseVersion = $manifestVersion;
} elseif ($readmeVersion !== null) {
$baseVersion = $readmeVersion;
}
if ($baseVersion === null) {
fwrite(STDERR, "No version found in README.md or manifest XML\n");
exit(1);
}
$content = file_get_contents($readme);
if (!preg_match('/^(\s*VERSION:\s*)(\d{2})\.(\d{2})\.(\d{2})/m', $content, $m)) {
fwrite(STDERR, "No VERSION field found in README.md\n");
// ── Parse and bump ───────────────────────────────────────────────────────────
if (!preg_match('/^(\d{2})\.(\d{2})\.(\d{2})$/', $baseVersion, $parts)) {
fwrite(STDERR, "Invalid version format: {$baseVersion}\n");
exit(1);
}
$major = (int)$m[2];
$minor = (int)$m[3];
$patch = (int)$m[4];
$major = (int)$parts[1];
$minor = (int)$parts[2];
$patch = (int)$parts[3];
$old = sprintf('%02d.%02d.%02d', $major, $minor, $patch);
switch ($type) {
@@ -50,13 +98,17 @@ switch ($type) {
}
$new = sprintf('%02d.%02d.%02d', $major, $minor, $patch);
$updated = preg_replace(
'/^(\s*VERSION:\s*)\d{2}\.\d{2}\.\d{2}/m',
'${1}' . $new,
$content,
1
);
file_put_contents($readme, $updated);
// ── Update README.md ─────────────────────────────────────────────────────────
if (file_exists($readme) && !empty($readmeContent)) {
$updated = preg_replace(
'/(VERSION:\s*)\d{2}\.\d{2}\.\d{2}/m',
'${1}' . $new,
$readmeContent,
1
);
file_put_contents($readme, $updated);
}
echo "{$old}{$new}\n";
exit(0);
+233
View File
@@ -0,0 +1,233 @@
#!/usr/bin/env php
<?php
/* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
*
* SPDX-License-Identifier: GPL-3.0-or-later
*
* FILE INFORMATION
* DEFGROUP: moko-platform.CLI
* INGROUP: moko-platform
* REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform
* PATH: /cli/version_bump_remote.php
* BRIEF: Bump version in manifest XML and CHANGELOG.md on a remote branch via Gitea API
*
* Usage:
* php version_bump_remote.php --path . --branch dev --bump minor --token TOKEN --api-base URL
* php version_bump_remote.php --path . --branch dev --bump patch --token TOKEN --api-base URL
* php version_bump_remote.php --path . --branch dev --bump minor --no-changelog --token TOKEN --api-base URL
*
* Options:
* --path Repository root (reads current version from local manifest)
* --branch Target branch to bump (required, e.g. dev)
* --bump Bump type: patch | minor | major (default: minor)
* --token Gitea API token (or GA_TOKEN env var)
* --api-base Gitea API base URL for the repo
* --no-changelog Skip CHANGELOG.md bump
* --repo Repository path (owner/repo) for API base construction
* --gitea-url Gitea instance URL (default: env GITEA_URL)
*/
declare(strict_types=1);
$path = '.';
$branch = null;
$bumpType = 'minor';
$token = null;
$apiBase = null;
$noChangelog = false;
$repo = null;
$giteaUrl = null;
foreach ($argv as $i => $arg) {
if ($arg === '--path' && isset($argv[$i + 1])) $path = $argv[$i + 1];
if ($arg === '--branch' && isset($argv[$i + 1])) $branch = $argv[$i + 1];
if ($arg === '--bump' && isset($argv[$i + 1])) $bumpType = $argv[$i + 1];
if ($arg === '--token' && isset($argv[$i + 1])) $token = $argv[$i + 1];
if ($arg === '--api-base' && isset($argv[$i + 1])) $apiBase = $argv[$i + 1];
if ($arg === '--no-changelog') $noChangelog = true;
if ($arg === '--repo' && isset($argv[$i + 1])) $repo = $argv[$i + 1];
if ($arg === '--gitea-url' && isset($argv[$i + 1])) $giteaUrl = $argv[$i + 1];
}
if ($token === null) $token = getenv('GA_TOKEN') ?: getenv('GITEA_TOKEN') ?: null;
if ($giteaUrl === null) $giteaUrl = getenv('GITEA_URL') ?: 'https://git.mokoconsulting.tech';
if ($apiBase === null && $repo !== null) {
$apiBase = rtrim($giteaUrl, '/') . '/api/v1/repos/' . $repo;
}
if ($branch === null || $token === null || $apiBase === null) {
fwrite(STDERR, "Usage: version_bump_remote.php --branch BRANCH --token TOKEN --api-base URL [--bump minor|patch|major]\n");
fwrite(STDERR, " or: version_bump_remote.php --branch BRANCH --token TOKEN --repo owner/repo\n");
exit(1);
}
$root = realpath($path) ?: $path;
// ── Read current version from local manifest ────────────────────────────
$version = null;
$manifestFile = null;
$searchDirs = ["{$root}/src", $root];
foreach ($searchDirs as $dir) {
if (!is_dir($dir)) continue;
foreach (glob("{$dir}/*.xml") ?: [] as $f) {
$xml = file_get_contents($f);
if (strpos($xml, '<extension') !== false || strpos($xml, '<version>') !== false) {
if (preg_match('|<version>(\d{2}\.\d{2}\.\d{2})</version>|', $xml, $m)) {
if ($version === null || version_compare($m[1], $version, '>')) {
$version = $m[1];
$manifestFile = basename($f);
}
}
}
}
}
if ($version === null) {
fwrite(STDERR, "No version found in manifest XML\n");
exit(1);
}
// ── Compute next version ────────────────────────────────────────────────
if (!preg_match('/^(\d{2})\.(\d{2})\.(\d{2})$/', $version, $parts)) {
fwrite(STDERR, "Invalid version format: {$version}\n");
exit(1);
}
$major = (int)$parts[1];
$minor = (int)$parts[2];
$patch = (int)$parts[3];
switch ($bumpType) {
case 'major': $major++; $minor = 0; $patch = 0; break;
case 'minor': $minor++; $patch = 0; break;
default: $patch++; break;
}
$nextVersion = sprintf('%02d.%02d.%02d', $major, $minor, $patch);
echo "{$version} -> {$nextVersion} ({$branch})\n";
// ── Helper: Gitea API request ───────────────────────────────────────────
function giteaApi(string $method, string $url, string $token, ?string $body = null): ?array
{
$ch = curl_init($url);
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_HTTPHEADER => [
"Authorization: token {$token}",
'Content-Type: application/json',
],
CURLOPT_CUSTOMREQUEST => $method,
CURLOPT_TIMEOUT => 30,
]);
if ($body !== null) {
curl_setopt($ch, CURLOPT_POSTFIELDS, $body);
}
$response = curl_exec($ch);
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
if ($httpCode >= 400 || $response === false) {
return null;
}
return json_decode($response, true) ?: [];
}
// ── Helper: Update a file on a remote branch ────────────────────────────
function updateRemoteFile(
string $apiBase,
string $token,
string $filePath,
string $branch,
callable $transform,
string $commitMessage
): bool {
$url = "{$apiBase}/contents/{$filePath}?ref={$branch}";
$file = giteaApi('GET', $url, $token);
if ($file === null || !isset($file['sha']) || !isset($file['content'])) {
return false;
}
$content = base64_decode($file['content']);
$newContent = $transform($content);
if ($newContent === $content) {
fwrite(STDERR, " {$filePath}: no changes needed\n");
return true;
}
$payload = json_encode([
'content' => base64_encode($newContent),
'sha' => $file['sha'],
'message' => $commitMessage,
'branch' => $branch,
]);
$result = giteaApi('PUT', "{$apiBase}/contents/{$filePath}", $token, $payload);
if ($result === null) {
fwrite(STDERR, " {$filePath}: failed to update\n");
return false;
}
echo " {$filePath}: updated on {$branch}\n";
return true;
}
// ── Update manifest XML on the remote branch ────────────────────────────
$manifestPaths = [];
if ($manifestFile !== null) {
$manifestPaths[] = "src/{$manifestFile}";
}
$manifestPaths = array_merge($manifestPaths, [
'src/templateDetails.xml',
'src/manifest.xml',
]);
$manifestUpdated = false;
foreach ($manifestPaths as $mPath) {
$result = updateRemoteFile(
$apiBase, $token, $mPath, $branch,
function (string $content) use ($version, $nextVersion): string {
return str_replace(
"<version>{$version}</version>",
"<version>{$nextVersion}</version>",
$content
);
},
"chore(version): bump {$version} -> {$nextVersion} [skip ci]"
);
if ($result) {
$manifestUpdated = true;
break;
}
}
if (!$manifestUpdated) {
fwrite(STDERR, "WARNING: could not update manifest on {$branch}\n");
}
// ── Update CHANGELOG.md on the remote branch ────────────────────────────
if (!$noChangelog) {
updateRemoteFile(
$apiBase, $token, 'CHANGELOG.md', $branch,
function (string $content) use ($version, $nextVersion): string {
$content = str_replace("VERSION: {$version}", "VERSION: {$nextVersion}", $content);
if (strpos($content, '[Unreleased]') === false
&& strpos($content, "## [{$nextVersion}]") === false
) {
$marker = "## [{$version}]";
if (strpos($content, $marker) !== false) {
$unreleased = "## [{$nextVersion}] - Unreleased\n\n### Added\n\n### Changed\n\n### Fixed\n\n";
$content = str_replace($marker, $unreleased . $marker, $content);
}
}
return $content;
},
"chore(version): bump CHANGELOG {$version} -> {$nextVersion} [skip ci]"
);
}
exit(0);
+138
View File
@@ -0,0 +1,138 @@
#!/usr/bin/env php
<?php
/* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
*
* SPDX-License-Identifier: GPL-3.0-or-later
*
* FILE INFORMATION
* DEFGROUP: moko-platform.CLI
* INGROUP: moko-platform
* REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform
* PATH: /cli/version_check.php
* VERSION: 05.00.00
* BRIEF: Validate version consistency across README, manifests, and sub-packages
*
* Usage:
* php version_check.php --path /repo
* php version_check.php --path /repo --strict # exit 1 on mismatch
* php version_check.php --path /repo --fix # fix mismatches to highest version
*/
declare(strict_types=1);
$path = '.';
$strict = false;
$fix = false;
foreach ($argv as $i => $arg) {
if ($arg === '--path' && isset($argv[$i + 1])) $path = $argv[$i + 1];
if ($arg === '--strict') $strict = true;
if ($arg === '--fix') $fix = true;
}
$root = realpath($path) ?: $path;
$errors = 0;
$versions = [];
// ── Read README.md version ───────────────────────────────────────────────────
$readme = "{$root}/README.md";
if (file_exists($readme)) {
$content = file_get_contents($readme);
if (preg_match('/VERSION:\s*(\d{2}\.\d{2}\.\d{2})/m', $content, $m)) {
$versions['README.md'] = $m[1];
}
}
// ── Read manifest XML versions ───────────────────────────────────────────────
$xmlGlobs = [
"{$root}/src/pkg_*.xml",
"{$root}/src/*.xml",
"{$root}/src/packages/*/*.xml",
"{$root}/*.xml",
];
foreach ($xmlGlobs as $glob) {
foreach (glob($glob) ?: [] as $file) {
// Skip updates.xml
if (basename($file) === 'updates.xml') continue;
$xmlContent = file_get_contents($file);
if (strpos($xmlContent, '<extension') === false) continue;
if (preg_match('|<version>(\d{2}\.\d{2}\.\d{2})(?:-[a-z]+)?</version>|', $xmlContent, $xm)) {
$relPath = str_replace($root . '/', '', $file);
$relPath = str_replace($root . '\\', '', $relPath);
$versions[$relPath] = $xm[1];
}
}
}
if (empty($versions)) {
fwrite(STDERR, "No version sources found\n");
exit(1);
}
// ── Compare versions ─────────────────────────────────────────────────────────
$uniqueVersions = array_unique(array_values($versions));
$highestVersion = '00.00.00';
foreach ($versions as $v) {
if (version_compare($v, $highestVersion, '>')) {
$highestVersion = $v;
}
}
echo "=== Version Consistency Check ===\n";
foreach ($versions as $source => $ver) {
$status = ($ver === $highestVersion) ? 'OK' : 'MISMATCH';
if ($status === 'MISMATCH') $errors++;
echo sprintf(" %-50s %s %s\n", $source, $ver, $status === 'OK' ? '' : "** MISMATCH (expected {$highestVersion})");
}
if (count($uniqueVersions) === 1) {
echo "\nAll {$ver} — consistent.\n";
} else {
echo "\n** {$errors} mismatch(es) found. Highest version: {$highestVersion}\n";
if ($fix) {
echo "\n=== Fixing mismatches to {$highestVersion} ===\n";
// Fix README.md
if (isset($versions['README.md']) && $versions['README.md'] !== $highestVersion) {
$content = file_get_contents($readme);
$content = preg_replace(
'/(VERSION:\s*)\d{2}\.\d{2}\.\d{2}/m',
'${1}' . $highestVersion,
$content,
1
);
file_put_contents($readme, $content);
echo " Fixed: README.md -> {$highestVersion}\n";
}
// Fix XML manifests
foreach ($versions as $source => $ver) {
if ($source === 'README.md') continue;
if ($ver === $highestVersion) continue;
$file = "{$root}/{$source}";
if (!file_exists($file)) continue;
$content = file_get_contents($file);
$content = preg_replace(
'|<version>[^<]*</version>|',
"<version>{$highestVersion}</version>",
$content
);
file_put_contents($file, $content);
echo " Fixed: {$source} -> {$highestVersion}\n";
}
echo "Done.\n";
}
}
if ($strict && $errors > 0) {
exit(1);
}
exit(0);
+49 -12
View File
@@ -9,7 +9,7 @@
* INGROUP: moko-platform
* REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform
* PATH: /cli/version_read.php
* BRIEF: Read VERSION from README.md — outputs just the version string
* BRIEF: Read version from README.md or manifest XML — outputs the higher of the two
*/
declare(strict_types=1);
@@ -21,17 +21,54 @@ foreach ($argv as $i => $arg) {
}
}
$readme = realpath($path) . '/README.md';
if (!file_exists($readme)) {
fwrite(STDERR, "No README.md found at {$path}\n");
$root = realpath($path) ?: $path;
// ── Read from README.md ──────────────────────────────────────────────────────
$readmeVersion = null;
$readme = "{$root}/README.md";
if (file_exists($readme)) {
$content = file_get_contents($readme);
if (preg_match('/VERSION:\s*(\d{2}\.\d{2}\.\d{2})/m', $content, $m)) {
$readmeVersion = $m[1];
}
}
// ── Read from Joomla manifest XML ────────────────────────────────────────────
$manifestVersion = null;
$manifestFiles = array_merge(
glob("{$root}/src/pkg_*.xml") ?: [],
glob("{$root}/src/*.xml") ?: [],
glob("{$root}/src/packages/*/*.xml") ?: [],
glob("{$root}/*.xml") ?: []
);
foreach ($manifestFiles as $xmlFile) {
$xmlContent = file_get_contents($xmlFile);
if (strpos($xmlContent, '<extension') === false && strpos($xmlContent, '<version>') === false) {
continue;
}
if (preg_match('|<version>(\d{2}\.\d{2}\.\d{2})(?:-[a-z]+)?</version>|', $xmlContent, $xm)) {
$candidate = $xm[1];
if ($manifestVersion === null || version_compare($candidate, $manifestVersion, '>')) {
$manifestVersion = $candidate;
}
}
}
// ── Output the higher version ────────────────────────────────────────────────
$version = null;
if ($readmeVersion !== null && $manifestVersion !== null) {
$version = version_compare($manifestVersion, $readmeVersion, '>') ? $manifestVersion : $readmeVersion;
} elseif ($manifestVersion !== null) {
$version = $manifestVersion;
} elseif ($readmeVersion !== null) {
$version = $readmeVersion;
}
if ($version === null) {
fwrite(STDERR, "No version found in README.md or manifest XML\n");
exit(1);
}
$content = file_get_contents($readme);
if (preg_match('/^\s*VERSION:\s*(\d{2}\.\d{2}\.\d{2})/m', $content, $m)) {
echo $m[1] . "\n";
exit(0);
}
fwrite(STDERR, "No VERSION field found in README.md\n");
exit(1);
echo $version . "\n";
exit(0);
+12 -3
View File
@@ -131,9 +131,17 @@ if ($platform === 'crm-module') {
}
}
// Joomla: <version> in XML manifests
// Joomla: <version> in XML manifests (top-level + sub-packages)
if (in_array($platform, ['waas-component', 'joomla'], true)) {
foreach (glob("{$root}/src/*.xml") ?: glob("{$root}/*.xml") ?: [] as $file) {
$xmlFiles = array_merge(
glob("{$root}/src/*.xml") ?: [],
glob("{$root}/src/packages/*/*.xml") ?: [],
glob("{$root}/*.xml") ?: []
);
if (empty($xmlFiles)) {
$xmlFiles = glob("{$root}/*.xml") ?: [];
}
foreach ($xmlFiles as $file) {
$content = file_get_contents($file);
if (!str_contains($content, '<extension')) continue;
$updated = preg_replace(
@@ -143,7 +151,8 @@ if (in_array($platform, ['waas-component', 'joomla'], true)) {
);
if ($updated !== $content) {
file_put_contents($file, $updated);
echo "Joomla: " . basename($file) . "{$version}\n";
$relPath = str_replace($root . '/', '', $file);
echo "Joomla: {$relPath}{$version}\n";
$changed++;
}
}
+1 -1
View File
@@ -2,7 +2,7 @@
"name": "mokoconsulting-tech/enterprise",
"description": "MokoStandards Enterprise API \u2014 PHP implementation",
"type": "library",
"version": "05.00.01",
"version": "09.00.00",
"license": "GPL-3.0-or-later",
"authors": [
{
+569 -564
View File
File diff suppressed because it is too large Load Diff
+246 -245
View File
@@ -1,4 +1,5 @@
<?php
/* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
*
* This file is part of a Moko Consulting project.
@@ -24,275 +25,275 @@ declare(strict_types=1);
*/
class Common
{
/**
* Fallback version used when README.md cannot be parsed.
* NOTE: Kept in sync with _FALLBACK_VERSION in the original common.sh.
* Update this constant when the minimum supported baseline version changes.
*/
const FALLBACK_VERSION = '04.00.00';
/**
* Fallback version used when README.md cannot be parsed.
* NOTE: Kept in sync with _FALLBACK_VERSION in the original common.sh.
* Update this constant when the minimum supported baseline version changes.
*/
const FALLBACK_VERSION = '04.00.00';
const REPO_URL = 'https://git.mokoconsulting.tech/MokoConsulting/MokoStandards-API';
const REPO_URL_GITHUB = 'https://git.mokoconsulting.tech/MokoConsulting/MokoStandards';
const COPYRIGHT = 'Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>';
const LICENSE = 'GPL-3.0-or-later';
const REPO_URL = 'https://git.mokoconsulting.tech/MokoConsulting/MokoStandards-API';
const REPO_URL_GITHUB = 'https://git.mokoconsulting.tech/MokoConsulting/MokoStandards';
const COPYRIGHT = 'Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>';
const LICENSE = 'GPL-3.0-or-later';
// Exit codes
const EXIT_SUCCESS = 0;
const EXIT_ERROR = 1;
const EXIT_INVALID_ARGS = 2;
const EXIT_NOT_FOUND = 3;
const EXIT_PERMISSION = 4;
// Exit codes
const EXIT_SUCCESS = 0;
const EXIT_ERROR = 1;
const EXIT_INVALID_ARGS = 2;
const EXIT_NOT_FOUND = 3;
const EXIT_PERMISSION = 4;
// ── Logging ───────────────────────────────────────────────────────────────
// ── Logging ───────────────────────────────────────────────────────────────
/**
* Print an informational message.
*
* @param string $message Text to display.
*/
public static function info(string $message): void
{
echo '️ ' . $message . "\n";
}
/**
* Print an informational message.
*
* @param string $message Text to display.
*/
public static function info(string $message): void
{
echo '️ ' . $message . "\n";
}
/**
* Print a success message.
*
* @param string $message Text to display.
*/
public static function success(string $message): void
{
echo '✅ ' . $message . "\n";
}
/**
* Print a success message.
*
* @param string $message Text to display.
*/
public static function success(string $message): void
{
echo '✅ ' . $message . "\n";
}
/**
* Print a warning message.
*
* @param string $message Text to display.
*/
public static function warn(string $message): void
{
echo '⚠️ ' . $message . "\n";
}
/**
* Print a warning message.
*
* @param string $message Text to display.
*/
public static function warn(string $message): void
{
echo '⚠️ ' . $message . "\n";
}
/**
* Print an error message to STDERR.
*
* @param string $message Error text.
*/
public static function error(string $message): void
{
fwrite(STDERR, '❌ ' . $message . "\n");
}
/**
* Print an error message to STDERR.
*
* @param string $message Error text.
*/
public static function error(string $message): void
{
fwrite(STDERR, '❌ ' . $message . "\n");
}
/**
* Print a fatal error to STDERR and exit.
*
* @param string $message Error text.
* @param int $exitCode One of the EXIT_* constants.
* @return never
*/
public static function fatal(string $message, int $exitCode = self::EXIT_ERROR): never
{
fwrite(STDERR, '❌ ' . $message . "\n");
exit($exitCode);
}
/**
* Print a fatal error to STDERR and exit.
*
* @param string $message Error text.
* @param int $exitCode One of the EXIT_* constants.
* @return never
*/
public static function fatal(string $message, int $exitCode = self::EXIT_ERROR): never
{
fwrite(STDERR, '❌ ' . $message . "\n");
exit($exitCode);
}
/**
* Print a debug message to STDERR when the DEBUG env var is set.
*
* @param string $message Debug text.
*/
public static function debug(string $message): void
{
if (!empty($_SERVER['DEBUG'] ?? getenv('DEBUG'))) {
fwrite(STDERR, '🔍 ' . $message . "\n");
}
}
/**
* Print a debug message to STDERR when the DEBUG env var is set.
*
* @param string $message Debug text.
*/
public static function debug(string $message): void
{
if (!empty($_SERVER['DEBUG'] ?? getenv('DEBUG'))) {
fwrite(STDERR, '🔍 ' . $message . "\n");
}
}
/**
* Print a plain message to stdout.
*
* @param string $message Text to display.
*/
public static function plain(string $message): void
{
echo $message . "\n";
}
/**
* Print a plain message to stdout.
*
* @param string $message Text to display.
*/
public static function plain(string $message): void
{
echo $message . "\n";
}
// ── Guards ────────────────────────────────────────────────────────────────
// ── Guards ────────────────────────────────────────────────────────────────
/**
* Abort if a command is not available on PATH.
*
* @param string $cmd Command name (e.g. 'git').
* @param string $description Human-readable description for the error message.
*/
public static function requireCommand(string $cmd, string $description = ''): void
{
$which = trim((string) shell_exec('command -v ' . escapeshellarg($cmd) . ' 2>/dev/null'));
if ($which === '') {
$msg = $description !== '' ? $description : "Command required: {$cmd}";
self::fatal($msg, self::EXIT_NOT_FOUND);
}
}
/**
* Abort if a command is not available on PATH.
*
* @param string $cmd Command name (e.g. 'git').
* @param string $description Human-readable description for the error message.
*/
public static function requireCommand(string $cmd, string $description = ''): void
{
$which = trim((string) shell_exec('command -v ' . escapeshellarg($cmd) . ' 2>/dev/null'));
if ($which === '') {
$msg = $description !== '' ? $description : "Command required: {$cmd}";
self::fatal($msg, self::EXIT_NOT_FOUND);
}
}
/**
* Abort if a file does not exist.
*
* @param string $path Absolute or relative file path.
* @param string $description Human-readable label used in the error message.
*/
public static function requireFile(string $path, string $description = 'File'): void
{
if (!is_file($path)) {
self::fatal("{$description} not found: {$path}", self::EXIT_NOT_FOUND);
}
}
/**
* Abort if a file does not exist.
*
* @param string $path Absolute or relative file path.
* @param string $description Human-readable label used in the error message.
*/
public static function requireFile(string $path, string $description = 'File'): void
{
if (!is_file($path)) {
self::fatal("{$description} not found: {$path}", self::EXIT_NOT_FOUND);
}
}
/**
* Abort if a directory does not exist.
*
* @param string $path Absolute or relative directory path.
* @param string $description Human-readable label used in the error message.
*/
public static function requireDir(string $path, string $description = 'Directory'): void
{
if (!is_dir($path)) {
self::fatal("{$description} not found: {$path}", self::EXIT_NOT_FOUND);
}
}
/**
* Abort if a directory does not exist.
*
* @param string $path Absolute or relative directory path.
* @param string $description Human-readable label used in the error message.
*/
public static function requireDir(string $path, string $description = 'Directory'): void
{
if (!is_dir($path)) {
self::fatal("{$description} not found: {$path}", self::EXIT_NOT_FOUND);
}
}
// ── Repository utilities ──────────────────────────────────────────────────
// ── Repository utilities ──────────────────────────────────────────────────
/**
* Return the absolute path to the repository root by walking up from cwd.
*
* @throws \RuntimeException When no .git directory is found.
* @return string Absolute path (no trailing slash).
*/
public static function getRepoRoot(): string
{
$dir = (string) getcwd();
while ($dir !== '/') {
if (is_dir($dir . '/.git')) {
return $dir;
}
$dir = dirname($dir);
}
self::fatal('Not in a git repository', self::EXIT_ERROR);
}
/**
* Return the absolute path to the repository root by walking up from cwd.
*
* @throws \RuntimeException When no .git directory is found.
* @return string Absolute path (no trailing slash).
*/
public static function getRepoRoot(): string
{
$dir = (string) getcwd();
while ($dir !== '/') {
if (is_dir($dir . '/.git')) {
return $dir;
}
$dir = dirname($dir);
}
self::fatal('Not in a git repository', self::EXIT_ERROR);
}
/**
* Return the current git branch name (or "unknown").
*
* @return string Branch name.
*/
public static function getGitBranch(): string
{
$branch = trim((string) shell_exec('git rev-parse --abbrev-ref HEAD 2>/dev/null'));
return $branch !== '' ? $branch : 'unknown';
}
/**
* Return the current git branch name (or "unknown").
*
* @return string Branch name.
*/
public static function getGitBranch(): string
{
$branch = trim((string) shell_exec('git rev-parse --abbrev-ref HEAD 2>/dev/null'));
return $branch !== '' ? $branch : 'unknown';
}
/**
* Return the current full git commit hash (or "unknown").
*
* @return string Full commit SHA.
*/
public static function getGitCommit(): string
{
$hash = trim((string) shell_exec('git rev-parse HEAD 2>/dev/null'));
return $hash !== '' ? $hash : 'unknown';
}
/**
* Return the current full git commit hash (or "unknown").
*
* @return string Full commit SHA.
*/
public static function getGitCommit(): string
{
$hash = trim((string) shell_exec('git rev-parse HEAD 2>/dev/null'));
return $hash !== '' ? $hash : 'unknown';
}
/**
* Return the short git commit hash (or "unknown").
*
* @return string Short commit SHA.
*/
public static function getGitCommitShort(): string
{
$hash = trim((string) shell_exec('git rev-parse --short HEAD 2>/dev/null'));
return $hash !== '' ? $hash : 'unknown';
}
/**
* Return the short git commit hash (or "unknown").
*
* @return string Short commit SHA.
*/
public static function getGitCommitShort(): string
{
$hash = trim((string) shell_exec('git rev-parse --short HEAD 2>/dev/null'));
return $hash !== '' ? $hash : 'unknown';
}
/**
* Return true when the git working directory is clean.
*
* @return bool True if no uncommitted changes.
*/
public static function isGitClean(): bool
{
return trim((string) shell_exec('git status --porcelain 2>/dev/null')) === '';
}
/**
* Return true when the git working directory is clean.
*
* @return bool True if no uncommitted changes.
*/
public static function isGitClean(): bool
{
return trim((string) shell_exec('git status --porcelain 2>/dev/null')) === '';
}
/**
* Return true when the current directory is inside a git repository.
*
* @return bool True if inside a git repo.
*/
public static function isGitRepo(): bool
{
exec('git rev-parse --git-dir 2>/dev/null', $out, $code);
return $code === 0;
}
/**
* Return true when the current directory is inside a git repository.
*
* @return bool True if inside a git repo.
*/
public static function isGitRepo(): bool
{
exec('git rev-parse --git-dir 2>/dev/null', $out, $code);
return $code === 0;
}
// ── Path utilities ────────────────────────────────────────────────────────
// ── Path utilities ────────────────────────────────────────────────────────
/**
* Return the path relative to the repository root, prefixed with '/'.
*
* @param string $absolutePath Absolute filesystem path.
* @return string Repo-relative path starting with '/'.
*/
public static function getRelativePath(string $absolutePath): string
{
$root = self::getRepoRoot();
$rel = str_starts_with($absolutePath, $root)
? substr($absolutePath, strlen($root))
: $absolutePath;
return '/' . ltrim($rel, '/');
}
/**
* Return the path relative to the repository root, prefixed with '/'.
*
* @param string $absolutePath Absolute filesystem path.
* @return string Repo-relative path starting with '/'.
*/
public static function getRelativePath(string $absolutePath): string
{
$root = self::getRepoRoot();
$rel = str_starts_with($absolutePath, $root)
? substr($absolutePath, strlen($root))
: $absolutePath;
return '/' . ltrim($rel, '/');
}
/**
* Create a directory (and parents) if it does not already exist.
*
* @param string $path Directory path to ensure.
* @param string $description Human-readable label for log output.
*/
public static function ensureDir(string $path, string $description = 'Directory'): void
{
if (!is_dir($path)) {
mkdir($path, 0755, true);
self::info("Created {$description}: {$path}");
}
}
/**
* Create a directory (and parents) if it does not already exist.
*
* @param string $path Directory path to ensure.
* @param string $description Human-readable label for log output.
*/
public static function ensureDir(string $path, string $description = 'Directory'): void
{
if (!is_dir($path)) {
mkdir($path, 0755, true);
self::info("Created {$description}: {$path}");
}
}
// ── Version helpers ───────────────────────────────────────────────────────
// ── Version helpers ───────────────────────────────────────────────────────
/**
* Read the VERSION from the FILE INFORMATION block in README.md.
*
* Searches upward from cwd for the repo root, then reads README.md.
* Falls back to FALLBACK_VERSION when the file is absent or unparseable.
*
* @return string Zero-padded semver string, e.g. "04.00.04".
*/
public static function getVersionFromReadme(): string
{
try {
$root = self::getRepoRoot();
$readme = $root . '/README.md';
if (!is_file($readme)) {
return self::FALLBACK_VERSION;
}
$content = file_get_contents($readme);
if (preg_match('/^\s*VERSION:\s*(\d{2}\.\d{2}\.\d{2})/m', (string) $content, $m)) {
return $m[1];
}
} catch (\Throwable $e) {
// Fall through to fallback
}
return self::FALLBACK_VERSION;
}
/**
* Read the VERSION from the FILE INFORMATION block in README.md.
*
* Searches upward from cwd for the repo root, then reads README.md.
* Falls back to FALLBACK_VERSION when the file is absent or unparseable.
*
* @return string Zero-padded semver string, e.g. "04.00.04".
*/
public static function getVersionFromReadme(): string
{
try {
$root = self::getRepoRoot();
$readme = $root . '/README.md';
if (!is_file($readme)) {
return self::FALLBACK_VERSION;
}
$content = file_get_contents($readme);
if (preg_match('/^\s*VERSION:\s*(\d{2}\.\d{2}\.\d{2})/m', (string) $content, $m)) {
return $m[1];
}
} catch (\Throwable $e) {
// Fall through to fallback
}
return self::FALLBACK_VERSION;
}
}
+2 -2
View File
@@ -20,7 +20,7 @@ namespace MokoEnterprise;
/**
* Abstract base class for project type plugins
*
*
* Provides common functionality for all project type plugins
*
* @package MokoStandards\Enterprise
@@ -268,6 +268,6 @@ abstract class AbstractProjectPlugin implements ProjectPluginInterface
$tags['plugin'] = $this->getPluginName();
$tags['project_type'] = $this->getProjectType();
$this->metricsCollector->record($category, $name, $value, $tags);
$this->metricsCollector->observe("{$category}.{$name}", (float) $value, $tags);
}
}
+7 -3
View File
@@ -92,6 +92,8 @@ class CircuitBreakerOpen extends RuntimeException
* );
* $response = $client->get('/repos/owner/repo');
* ```
*
* @since 04.00.00
*/
class ApiClient
{
@@ -123,6 +125,8 @@ class ApiClient
/** Circuit breaker last failure time */
private ?DateTime $circuitLastFailure = null;
/** @var LoggerInterface|null Optional logger instance */
/** @var array<string, mixed> Request metrics */
private array $metrics = [
'total_requests' => 0,
@@ -257,9 +261,9 @@ class ApiClient
* @throws RateLimitExceeded
* @throws CircuitBreakerOpen
*/
public function delete(string $endpoint): array
public function delete(string $endpoint, ?array $body = null): array
{
return $this->request('DELETE', $endpoint);
return $this->request('DELETE', $endpoint, $body);
}
/**
@@ -393,7 +397,7 @@ class ApiClient
$waitTime = 3600 - ($now - $oldestTimestamp);
$this->metrics['rate_limit_waits']++;
throw new RateLimitExceeded(
"Rate limit of {$this->maxRequestsPerHour} requests/hour exceeded. Wait {$waitTime} seconds."
);
+1 -1
View File
@@ -58,7 +58,7 @@ class CheckpointManager
public function __construct(string $checkpointDir = '.checkpoints')
{
$this->checkpointDir = $checkpointDir;
// Create checkpoint directory if it doesn't exist
if (!is_dir($this->checkpointDir)) {
if (!mkdir($this->checkpointDir, 0755, true) && !is_dir($this->checkpointDir)) {
File diff suppressed because it is too large Load Diff
+4 -4
View File
@@ -347,7 +347,7 @@ class Config
public function getBool(string $key, bool $default = false): bool
{
$value = $this->get($key, $default);
// Handle string representations of booleans
if (is_string($value)) {
$value = strtolower($value);
@@ -358,7 +358,7 @@ class Config
return false;
}
}
return (bool) $value;
}
@@ -433,13 +433,13 @@ class Config
public function validate(array $requiredKeys): void
{
$missing = [];
foreach ($requiredKeys as $key) {
if ($this->get($key) === null) {
$missing[] = $key;
}
}
if (!empty($missing)) {
throw new ConfigValidationError(
'Missing required configuration keys: ' . implode(', ', $missing)
+247
View File
@@ -0,0 +1,247 @@
<?php
/* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
*
* This file is part of a Moko Consulting project.
*
* SPDX-License-Identifier: GPL-3.0-or-later
*
* FILE INFORMATION
* DEFGROUP: MokoStandards.Enterprise
* INGROUP: MokoStandards.Enterprise
* REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform
* PATH: /lib/Enterprise/ConfigValidator.php
* BRIEF: Validate project config against plugin JSON schema
*/
declare(strict_types=1);
namespace MokoEnterprise;
class ConfigValidator
{
/** @var array<int, string> */
private array $errors = [];
/** @var array<int, string> */
private array $warnings = [];
/**
* Validate config data against a JSON schema.
*
* @param array<string, mixed> $config Config to validate
* @param array<string, mixed> $schema JSON Schema definition
* @return bool True if valid
*/
public function validate(array $config, array $schema): bool
{
$this->errors = [];
$this->warnings = [];
$this->validateNode($config, $schema, '');
return empty($this->errors);
}
/** @return array<int, string> */
public function getErrors(): array
{
return $this->errors;
}
/** @return array<int, string> */
public function getWarnings(): array
{
return $this->warnings;
}
/**
* @param mixed $data
* @param array<string, mixed> $schema
*/
private function validateNode(
mixed $data,
array $schema,
string $path
): void {
$type = $schema['type'] ?? null;
if ($type !== null && !$this->checkType($data, $type)) {
$actual = gettype($data);
$this->errors[] = $path === ''
? "Root must be {$type}, got {$actual}"
: "{$path}: expected {$type}, got {$actual}";
return;
}
if ($type === 'object') {
$this->validateObject($data, $schema, $path);
}
if ($type === 'array' && isset($schema['items'])) {
$this->validateArray($data, $schema, $path);
}
if (isset($schema['enum'])) {
$this->validateEnum($data, $schema['enum'], $path);
}
if ($type === 'string') {
$this->validateString($data, $schema, $path);
}
if ($type === 'integer' || $type === 'number') {
$this->validateNumber($data, $schema, $path);
}
}
/**
* @param array<string, mixed> $data
* @param array<string, mixed> $schema
*/
private function validateObject(
array $data,
array $schema,
string $path
): void {
$properties = $schema['properties'] ?? [];
$required = $schema['required'] ?? [];
foreach ($required as $field) {
if (!array_key_exists($field, $data)) {
$fieldPath = $path === '' ? $field : "{$path}.{$field}";
$this->errors[] = "{$fieldPath}: required field missing";
}
}
foreach ($properties as $field => $fieldSchema) {
if (!array_key_exists($field, $data)) {
continue;
}
$fieldPath = $path === '' ? $field : "{$path}.{$field}";
$this->validateNode($data[$field], $fieldSchema, $fieldPath);
}
$known = array_keys($properties);
foreach (array_keys($data) as $field) {
if (!in_array($field, $known, true)) {
$fieldPath = $path === '' ? $field : "{$path}.{$field}";
$this->warnings[] = "{$fieldPath}: unknown property";
}
}
}
/**
* @param array<int, mixed> $data
* @param array<string, mixed> $schema
*/
private function validateArray(
array $data,
array $schema,
string $path
): void {
$itemSchema = $schema['items'];
foreach ($data as $i => $item) {
$this->validateNode(
$item,
$itemSchema,
"{$path}[{$i}]"
);
}
if (
isset($schema['minItems'])
&& count($data) < $schema['minItems']
) {
$this->errors[] = "{$path}: "
. "needs at least {$schema['minItems']} items";
}
}
/**
* @param mixed $data
* @param array<int, mixed> $allowed
*/
private function validateEnum(
mixed $data,
array $allowed,
string $path
): void {
if (!in_array($data, $allowed, true)) {
$values = implode(', ', $allowed);
$label = $path ?: 'value';
$this->errors[] = "{$label}: "
. "'{$data}' not in [{$values}]";
}
}
/**
* @param array<string, mixed> $schema
*/
private function validateString(
mixed $data,
array $schema,
string $path
): void {
if (!is_string($data)) {
return;
}
if (
isset($schema['minLength'])
&& strlen($data) < $schema['minLength']
) {
$this->errors[] = "{$path}: "
. "too short (min {$schema['minLength']})";
}
if (
isset($schema['pattern'])
&& !preg_match('/' . $schema['pattern'] . '/', $data)
) {
$this->errors[] = "{$path}: "
. "does not match pattern {$schema['pattern']}";
}
}
/**
* @param array<string, mixed> $schema
*/
private function validateNumber(
mixed $data,
array $schema,
string $path
): void {
if (!is_numeric($data)) {
return;
}
if (isset($schema['minimum']) && $data < $schema['minimum']) {
$this->errors[] = "{$path}: "
. "below minimum {$schema['minimum']}";
}
if (isset($schema['maximum']) && $data > $schema['maximum']) {
$this->errors[] = "{$path}: "
. "above maximum {$schema['maximum']}";
}
}
private function checkType(mixed $data, string $type): bool
{
return match ($type) {
'object' => is_array($data),
'array' => is_array($data)
&& array_is_list($data),
'string' => is_string($data),
'integer' => is_int($data),
'number' => is_int($data) || is_float($data),
'boolean' => is_bool($data),
'null' => is_null($data),
default => true,
};
}
}
+397 -396
View File
@@ -1,4 +1,5 @@
<?php
/**
* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
*
@@ -45,454 +46,454 @@ namespace MokoEnterprise;
*/
class DefinitionParser
{
/** Map platform slug → definition file basename */
private const PLATFORM_DEFINITION_MAP = [
'crm-module' => 'crm-module.tf',
'waas-component' => 'waas-component.tf',
'generic-repository' => 'generic-repository.tf',
'default-repository' => 'default-repository.tf',
'standards' => 'standards-repository.tf',
];
/** Map platform slug → definition file basename */
private const PLATFORM_DEFINITION_MAP = [
'crm-module' => 'crm-module.tf',
'waas-component' => 'waas-component.tf',
'generic-repository' => 'generic-repository.tf',
'default-repository' => 'default-repository.tf',
'standards' => 'standards-repository.tf',
];
/** Default definition used when platform has no specific file */
private const FALLBACK_DEFINITION = 'default-repository.tf';
/** Default definition used when platform has no specific file */
private const FALLBACK_DEFINITION = 'default-repository.tf';
/** Directory containing the base definition files */
private const DEFINITIONS_DIR = 'definitions/default';
/** Directory containing the base definition files */
private const DEFINITIONS_DIR = 'definitions/default';
// -----------------------------------------------------------------------
// Public API
// -----------------------------------------------------------------------
// -----------------------------------------------------------------------
// Public API
// -----------------------------------------------------------------------
/**
* Parse a definition file by platform slug.
*
* @param string $platform e.g. 'crm-module', 'waas-component'
* @param string $repoRoot Absolute path to the MokoStandards repository root
* @return array<int, array{source: string, destination: string, always_overwrite: bool}>
*/
public function parseForPlatform(string $platform, string $repoRoot): array
{
$basename = self::PLATFORM_DEFINITION_MAP[$platform] ?? self::FALLBACK_DEFINITION;
$path = rtrim($repoRoot, '/') . '/' . self::DEFINITIONS_DIR . '/' . $basename;
/**
* Parse a definition file by platform slug.
*
* @param string $platform e.g. 'crm-module', 'waas-component'
* @param string $repoRoot Absolute path to the MokoStandards repository root
* @return array<int, array{source: string, destination: string, always_overwrite: bool}>
*/
public function parseForPlatform(string $platform, string $repoRoot): array
{
$basename = self::PLATFORM_DEFINITION_MAP[$platform] ?? self::FALLBACK_DEFINITION;
$path = rtrim($repoRoot, '/') . '/' . self::DEFINITIONS_DIR . '/' . $basename;
if (!file_exists($path)) {
$fallback = rtrim($repoRoot, '/') . '/' . self::DEFINITIONS_DIR . '/' . self::FALLBACK_DEFINITION;
if (!file_exists($fallback)) {
return [];
}
$path = $fallback;
}
if (!file_exists($path)) {
$fallback = rtrim($repoRoot, '/') . '/' . self::DEFINITIONS_DIR . '/' . self::FALLBACK_DEFINITION;
if (!file_exists($fallback)) {
return [];
}
$path = $fallback;
}
return $this->parseFile($path);
}
return $this->parseFile($path);
}
/**
* Parse a definition file at an explicit filesystem path.
*
* @param string $filePath Absolute path to the .tf definition file
* @return array<int, array{source: string, destination: string, always_overwrite: bool}>
*/
public function parseFile(string $filePath): array
{
if (!file_exists($filePath)) {
return [];
}
/**
* Parse a definition file at an explicit filesystem path.
*
* @param string $filePath Absolute path to the .tf definition file
* @return array<int, array{source: string, destination: string, always_overwrite: bool}>
*/
public function parseFile(string $filePath): array
{
if (!file_exists($filePath)) {
return [];
}
$content = file_get_contents($filePath);
if ($content === false) {
return [];
}
$content = file_get_contents($filePath);
if ($content === false) {
return [];
}
return $this->parse($content);
}
return $this->parse($content);
}
/**
* Parse raw HCL content.
*
* @param string $content Raw .tf file content
* @return array<int, array{source: string, destination: string, always_overwrite: bool}>
*/
public function parse(string $content): array
{
$entries = [];
/**
* Parse raw HCL content.
*
* @param string $content Raw .tf file content
* @return array<int, array{source: string, destination: string, always_overwrite: bool}>
*/
public function parse(string $content): array
{
$entries = [];
// root_files = [ { ... }, ... ]
$rootFilesContent = $this->extractNamedArray($content, 'root_files');
if ($rootFilesContent !== null) {
$entries = array_merge($entries, $this->parseFileBlocks($rootFilesContent, ''));
}
// root_files = [ { ... }, ... ]
$rootFilesContent = $this->extractNamedArray($content, 'root_files');
if ($rootFilesContent !== null) {
$entries = array_merge($entries, $this->parseFileBlocks($rootFilesContent, ''));
}
// directories = [ { ... }, ... ]
$dirsContent = $this->extractNamedArray($content, 'directories');
if ($dirsContent !== null) {
$entries = array_merge($entries, $this->parseDirectories($dirsContent));
}
// directories = [ { ... }, ... ]
$dirsContent = $this->extractNamedArray($content, 'directories');
if ($dirsContent !== null) {
$entries = array_merge($entries, $this->parseDirectories($dirsContent));
}
return $entries;
}
return $entries;
}
// -----------------------------------------------------------------------
// Internal parsing helpers
// -----------------------------------------------------------------------
// -----------------------------------------------------------------------
// Internal parsing helpers
// -----------------------------------------------------------------------
/**
* Locate `name = [` inside $content and return the content between the
* outermost `[` and its matching `]`, or null if not found.
*/
private function extractNamedArray(string $content, string $name): ?string
{
$pattern = '/\b' . preg_quote($name, '/') . '\s*=\s*\[/';
/**
* Locate `name = [` inside $content and return the content between the
* outermost `[` and its matching `]`, or null if not found.
*/
private function extractNamedArray(string $content, string $name): ?string
{
$pattern = '/\b' . preg_quote($name, '/') . '\s*=\s*\[/';
// Build a mask of heredoc regions so the regex doesn't match inside them.
// Replace heredoc content with spaces (preserving offsets) before matching.
$masked = $content;
$len = strlen($content);
$i = 0;
while ($i < $len - 1) {
if ($content[$i] === '<' && $content[$i + 1] === '<') {
$heredocEnd = $this->skipHeredoc($content, $i, $len);
// Blank out the heredoc region in the masked copy
for ($k = $i; $k < $heredocEnd && $k < $len; $k++) {
$masked[$k] = ($content[$k] === "\n") ? "\n" : ' ';
}
$i = $heredocEnd;
continue;
}
$i++;
}
// Build a mask of heredoc regions so the regex doesn't match inside them.
// Replace heredoc content with spaces (preserving offsets) before matching.
$masked = $content;
$len = strlen($content);
$i = 0;
while ($i < $len - 1) {
if ($content[$i] === '<' && $content[$i + 1] === '<') {
$heredocEnd = $this->skipHeredoc($content, $i, $len);
// Blank out the heredoc region in the masked copy
for ($k = $i; $k < $heredocEnd && $k < $len; $k++) {
$masked[$k] = ($content[$k] === "\n") ? "\n" : ' ';
}
$i = $heredocEnd;
continue;
}
$i++;
}
if (!preg_match($pattern, $masked, $match, PREG_OFFSET_CAPTURE)) {
return null;
}
// Position of the `[` at the end of the matched string — use original content
$openPos = $match[0][1] + strlen($match[0][0]) - 1;
return $this->extractBetweenPair($content, $openPos, '[', ']');
}
if (!preg_match($pattern, $masked, $match, PREG_OFFSET_CAPTURE)) {
return null;
}
// Position of the `[` at the end of the matched string — use original content
$openPos = $match[0][1] + strlen($match[0][0]) - 1;
return $this->extractBetweenPair($content, $openPos, '[', ']');
}
/**
* Starting at $pos (which must hold $open), walk forward counting depth
* until the matching $close is found. Returns the content between them
* (exclusive), or null on malformed input.
*/
private function extractBetweenPair(string $content, int $pos, string $open, string $close): ?string
{
if (!isset($content[$pos]) || $content[$pos] !== $open) {
return null;
}
/**
* Starting at $pos (which must hold $open), walk forward counting depth
* until the matching $close is found. Returns the content between them
* (exclusive), or null on malformed input.
*/
private function extractBetweenPair(string $content, int $pos, string $open, string $close): ?string
{
if (!isset($content[$pos]) || $content[$pos] !== $open) {
return null;
}
$depth = 0;
$start = $pos;
$len = strlen($content);
$depth = 0;
$start = $pos;
$len = strlen($content);
for ($i = $pos; $i < $len; $i++) {
// Skip heredoc regions — they contain unbalanced brackets in markdown/code
if ($content[$i] === '<' && isset($content[$i + 1]) && $content[$i + 1] === '<') {
$i = $this->skipHeredoc($content, $i, $len) - 1; // -1 because for loop increments
continue;
}
if ($content[$i] === $open) {
$depth++;
} elseif ($content[$i] === $close) {
$depth--;
if ($depth === 0) {
return substr($content, $start + 1, $i - $start - 1);
}
}
}
for ($i = $pos; $i < $len; $i++) {
// Skip heredoc regions — they contain unbalanced brackets in markdown/code
if ($content[$i] === '<' && isset($content[$i + 1]) && $content[$i + 1] === '<') {
$i = $this->skipHeredoc($content, $i, $len) - 1; // -1 because for loop increments
continue;
}
if ($content[$i] === $open) {
$depth++;
} elseif ($content[$i] === $close) {
$depth--;
if ($depth === 0) {
return substr($content, $start + 1, $i - $start - 1);
}
}
}
return null; // unterminated
}
return null; // unterminated
}
/**
* Split $content into top-level `{ … }` blocks (depth 1 only).
*
* Heredoc sections (`<<-WORD … WORD` and `<<WORD … WORD`) are skipped in
* their entirety so that any `{` or `}` characters inside template content
* do not corrupt the brace-depth counter.
*
* @return string[] Each element is the inner content of one block (without outer braces)
*/
private function splitBlocks(string $content): array
{
$blocks = [];
$depth = 0;
$start = null;
$len = strlen($content);
$i = 0;
/**
* Split $content into top-level `{ … }` blocks (depth 1 only).
*
* Heredoc sections (`<<-WORD … WORD` and `<<WORD … WORD`) are skipped in
* their entirety so that any `{` or `}` characters inside template content
* do not corrupt the brace-depth counter.
*
* @return string[] Each element is the inner content of one block (without outer braces)
*/
private function splitBlocks(string $content): array
{
$blocks = [];
$depth = 0;
$start = null;
$len = strlen($content);
$i = 0;
while ($i < $len) {
// Detect heredoc: <<WORD or <<-WORD
if ($content[$i] === '<' && isset($content[$i + 1]) && $content[$i + 1] === '<') {
$i = $this->skipHeredoc($content, $i, $len);
continue;
}
while ($i < $len) {
// Detect heredoc: <<WORD or <<-WORD
if ($content[$i] === '<' && isset($content[$i + 1]) && $content[$i + 1] === '<') {
$i = $this->skipHeredoc($content, $i, $len);
continue;
}
if ($content[$i] === '{') {
if ($depth === 0) {
$start = $i;
}
$depth++;
} elseif ($content[$i] === '}') {
$depth--;
if ($depth === 0 && $start !== null) {
$blocks[] = substr($content, $start + 1, $i - $start - 1);
$start = null;
}
}
$i++;
}
if ($content[$i] === '{') {
if ($depth === 0) {
$start = $i;
}
$depth++;
} elseif ($content[$i] === '}') {
$depth--;
if ($depth === 0 && $start !== null) {
$blocks[] = substr($content, $start + 1, $i - $start - 1);
$start = null;
}
}
$i++;
}
return $blocks;
}
return $blocks;
}
/**
* Advance past a HCL heredoc starting at position $i.
*
* Supports both `<<WORD` (content-preserving) and `<<-WORD`
* (indent-stripping) forms. Returns the index immediately after the
* closing delimiter line, or $i + 2 if the heredoc is malformed.
*/
private function skipHeredoc(string $content, int $i, int $len): int
{
$j = $i + 2; // skip <<
/**
* Advance past a HCL heredoc starting at position $i.
*
* Supports both `<<WORD` (content-preserving) and `<<-WORD`
* (indent-stripping) forms. Returns the index immediately after the
* closing delimiter line, or $i + 2 if the heredoc is malformed.
*/
private function skipHeredoc(string $content, int $i, int $len): int
{
$j = $i + 2; // skip <<
// Optional indent-strip marker
$stripIndent = false;
if (isset($content[$j]) && $content[$j] === '-') {
$stripIndent = true;
$j++;
}
// Optional indent-strip marker
$stripIndent = false;
if (isset($content[$j]) && $content[$j] === '-') {
$stripIndent = true;
$j++;
}
// Read the delimiter identifier (alphanumeric + underscore)
$delimiter = '';
while ($j < $len && (ctype_alnum($content[$j]) || $content[$j] === '_')) {
$delimiter .= $content[$j];
$j++;
}
// Read the delimiter identifier (alphanumeric + underscore)
$delimiter = '';
while ($j < $len && (ctype_alnum($content[$j]) || $content[$j] === '_')) {
$delimiter .= $content[$j];
$j++;
}
if ($delimiter === '') {
return $i + 2; // Not a real heredoc
}
if ($delimiter === '') {
return $i + 2; // Not a real heredoc
}
// Skip optional whitespace and the rest of the opening line
while ($j < $len && $content[$j] !== "\n") {
$j++;
}
if ($j < $len) {
$j++; // skip the newline after the opening line
}
// Skip optional whitespace and the rest of the opening line
while ($j < $len && $content[$j] !== "\n") {
$j++;
}
if ($j < $len) {
$j++; // skip the newline after the opening line
}
// Scan line by line until the closing delimiter
while ($j < $len) {
$lineEnd = strpos($content, "\n", $j);
$lineEnd = ($lineEnd === false) ? $len : $lineEnd;
// Scan line by line until the closing delimiter
while ($j < $len) {
$lineEnd = strpos($content, "\n", $j);
$lineEnd = ($lineEnd === false) ? $len : $lineEnd;
$line = substr($content, $j, $lineEnd - $j);
// For <<- (indent-stripping) form, the terminator may itself be indented;
// strip leading whitespace before comparing. For the non-stripping form
// (<<), the terminator must be at column 0 — but we still rtrim trailing
// whitespace/CR to handle Windows line-endings gracefully.
$normalised = $stripIndent ? trim($line) : rtrim($line);
if ($normalised === $delimiter) {
return $lineEnd + 1;
}
$j = $lineEnd + 1;
}
$line = substr($content, $j, $lineEnd - $j);
// For <<- (indent-stripping) form, the terminator may itself be indented;
// strip leading whitespace before comparing. For the non-stripping form
// (<<), the terminator must be at column 0 — but we still rtrim trailing
// whitespace/CR to handle Windows line-endings gracefully.
$normalised = $stripIndent ? trim($line) : rtrim($line);
if ($normalised === $delimiter) {
return $lineEnd + 1;
}
$j = $lineEnd + 1;
}
return $len; // unterminated heredoc — consume to EOF
}
return $len; // unterminated heredoc — consume to EOF
}
/**
* Parse all file blocks inside a `files = [ … ]` array content,
* returning only those that have a `template` field.
*
* @param string $arrayContent Inner content between the outer `[` and `]`
* @param string $dirPath Directory prefix for the destination ('' = repo root)
* @return array<int, array{source: string, destination: string, always_overwrite: bool}>
*/
private function parseFileBlocks(string $arrayContent, string $dirPath): array
{
$entries = [];
foreach ($this->splitBlocks($arrayContent) as $block) {
$entry = $this->parseFileBlock($block, $dirPath);
if ($entry !== null) {
$entries[] = $entry;
}
}
return $entries;
}
/**
* Parse all file blocks inside a `files = [ … ]` array content,
* returning only those that have a `template` field.
*
* @param string $arrayContent Inner content between the outer `[` and `]`
* @param string $dirPath Directory prefix for the destination ('' = repo root)
* @return array<int, array{source: string, destination: string, always_overwrite: bool}>
*/
private function parseFileBlocks(string $arrayContent, string $dirPath): array
{
$entries = [];
foreach ($this->splitBlocks($arrayContent) as $block) {
$entry = $this->parseFileBlock($block, $dirPath);
if ($entry !== null) {
$entries[] = $entry;
}
}
return $entries;
}
/**
* Parse a single file block `{ name = "…", template = "…", … }` or
* `{ name = "…", stub_content = <<-EOT … EOT, … }`.
*
* When a `stub_content` heredoc is present it takes priority over a
* `template` file-path reference. Returns null when the block has
* neither (structural-only entry that should not be synced).
*
* @return array{source?: string, inline_content?: string, destination: string, always_overwrite: bool}|null
*/
private function parseFileBlock(string $block, string $dirPath): ?array
{
// --- try stub_content heredoc first (preferred) ---
$inlineContent = $this->extractHeredoc($block, 'stub_content');
/**
* Parse a single file block `{ name = "…", template = "…", … }` or
* `{ name = "…", stub_content = <<-EOT … EOT, … }`.
*
* When a `stub_content` heredoc is present it takes priority over a
* `template` file-path reference. Returns null when the block has
* neither (structural-only entry that should not be synced).
*
* @return array{source?: string, inline_content?: string, destination: string, always_overwrite: bool}|null
*/
private function parseFileBlock(string $block, string $dirPath): ?array
{
// --- try stub_content heredoc first (preferred) ---
$inlineContent = $this->extractHeredoc($block, 'stub_content');
// --- fall back to stub_content as a quoted string (e.g. "line1\nline2") ---
if ($inlineContent === null) {
if (preg_match('/\bstub_content\s*=\s*"((?:[^"\\\\]|\\\\.)*)"/', $block, $m)) {
$inlineContent = stripcslashes($m[1]);
}
}
// --- fall back to stub_content as a quoted string (e.g. "line1\nline2") ---
if ($inlineContent === null) {
if (preg_match('/\bstub_content\s*=\s*"((?:[^"\\\\]|\\\\.)*)"/', $block, $m)) {
$inlineContent = stripcslashes($m[1]);
}
}
// --- fall back to external template path ---
$source = null;
if ($inlineContent === null) {
if (!preg_match('/\btemplate\s*=\s*"([^"]+)"/', $block, $m)) {
return null; // neither inline content nor template → structural entry
}
$source = $m[1];
}
// --- fall back to external template path ---
$source = null;
if ($inlineContent === null) {
if (!preg_match('/\btemplate\s*=\s*"([^"]+)"/', $block, $m)) {
return null; // neither inline content nor template → structural entry
}
$source = $m[1];
}
// name is required
if (!preg_match('/\bname\s*=\s*"([^"]+)"/', $block, $m)) {
return null;
}
$filename = $m[1];
// name is required
if (!preg_match('/\bname\s*=\s*"([^"]+)"/', $block, $m)) {
return null;
}
$filename = $m[1];
// destination_filename overrides name
if (preg_match('/\bdestination_filename\s*=\s*"([^"]+)"/', $block, $m)) {
$filename = $m[1];
}
// destination_filename overrides name
if (preg_match('/\bdestination_filename\s*=\s*"([^"]+)"/', $block, $m)) {
$filename = $m[1];
}
// destination_path overrides dirPath
if (preg_match('/\bdestination_path\s*=\s*"([^"]+)"/', $block, $m)) {
$dp = trim($m[1], '/');
$destination = ($dp === '' || $dp === '.') ? $filename : "{$dp}/{$filename}";
} else {
$destination = $dirPath === '' ? $filename : "{$dirPath}/{$filename}";
}
// destination_path overrides dirPath
if (preg_match('/\bdestination_path\s*=\s*"([^"]+)"/', $block, $m)) {
$dp = trim($m[1], '/');
$destination = ($dp === '' || $dp === '.') ? $filename : "{$dp}/{$filename}";
} else {
$destination = $dirPath === '' ? $filename : "{$dirPath}/{$filename}";
}
// always_overwrite — default true for all template-driven files
$alwaysOverwrite = true;
if (preg_match('/\balways_overwrite\s*=\s*(true|false)\b/', $block, $m)) {
$alwaysOverwrite = ($m[1] === 'true');
}
// always_overwrite — default true for all template-driven files
$alwaysOverwrite = true;
if (preg_match('/\balways_overwrite\s*=\s*(true|false)\b/', $block, $m)) {
$alwaysOverwrite = ($m[1] === 'true');
}
// protected — when true, file is never overwritten even with --force
$protected = false;
if (preg_match('/\bprotected\s*=\s*(true|false)\b/', $block, $m)) {
$protected = ($m[1] === 'true');
}
// protected — when true, file is never overwritten even with --force
$protected = false;
if (preg_match('/\bprotected\s*=\s*(true|false)\b/', $block, $m)) {
$protected = ($m[1] === 'true');
}
if ($inlineContent !== null) {
return [
'inline_content' => $inlineContent,
'destination' => $destination,
'always_overwrite' => $alwaysOverwrite,
'protected' => $protected,
];
}
if ($inlineContent !== null) {
return [
'inline_content' => $inlineContent,
'destination' => $destination,
'always_overwrite' => $alwaysOverwrite,
'protected' => $protected,
];
}
return [
'source' => $source,
'destination' => $destination,
'always_overwrite' => $alwaysOverwrite,
'protected' => $protected,
];
}
return [
'source' => $source,
'destination' => $destination,
'always_overwrite' => $alwaysOverwrite,
'protected' => $protected,
];
}
/**
* Extract a heredoc value for the given field name from a block string.
*
* Handles both `<<WORD` (content-preserving) and `<<-WORD`
* (indent-stripping) forms. Leading tabs/spaces are stripped uniformly
* when the `<<-` form is used, matching HCL semantics.
*
* Returns null when the field is not found.
*/
private function extractHeredoc(string $block, string $field): ?string
{
$pattern = '/\b' . preg_quote($field, '/') . '\s*=\s*<<(-?)(\w+)[ \t]*\r?\n(.*?)\r?\n[ \t]*\2[ \t]*(?:\r?\n|$)/s';
if (!preg_match($pattern, $block, $m)) {
return null;
}
/**
* Extract a heredoc value for the given field name from a block string.
*
* Handles both `<<WORD` (content-preserving) and `<<-WORD`
* (indent-stripping) forms. Leading tabs/spaces are stripped uniformly
* when the `<<-` form is used, matching HCL semantics.
*
* Returns null when the field is not found.
*/
private function extractHeredoc(string $block, string $field): ?string
{
$pattern = '/\b' . preg_quote($field, '/') . '\s*=\s*<<(-?)(\w+)[ \t]*\r?\n(.*?)\r?\n[ \t]*\2[ \t]*(?:\r?\n|$)/s';
if (!preg_match($pattern, $block, $m)) {
return null;
}
$stripIndent = ($m[1] === '-');
$rawContent = $m[3];
$stripIndent = ($m[1] === '-');
$rawContent = $m[3];
if ($stripIndent) {
// Determine the minimum leading-whitespace prefix across non-empty lines
$lines = explode("\n", $rawContent);
$minIndent = PHP_INT_MAX;
foreach ($lines as $line) {
if (trim($line) === '') {
continue;
}
$indent = strlen($line) - strlen(ltrim($line, " \t"));
if ($indent < $minIndent) {
$minIndent = $indent;
}
}
if ($minIndent === PHP_INT_MAX) {
$minIndent = 0;
}
// Strip that many characters from the start of each line
$lines = array_map(
static fn(string $l) => (strlen($l) >= $minIndent) ? substr($l, $minIndent) : $l,
$lines
);
$rawContent = implode("\n", $lines);
}
if ($stripIndent) {
// Determine the minimum leading-whitespace prefix across non-empty lines
$lines = explode("\n", $rawContent);
$minIndent = PHP_INT_MAX;
foreach ($lines as $line) {
if (trim($line) === '') {
continue;
}
$indent = strlen($line) - strlen(ltrim($line, " \t"));
if ($indent < $minIndent) {
$minIndent = $indent;
}
}
if ($minIndent === PHP_INT_MAX) {
$minIndent = 0;
}
// Strip that many characters from the start of each line
$lines = array_map(
static fn(string $l) => (strlen($l) >= $minIndent) ? substr($l, $minIndent) : $l,
$lines
);
$rawContent = implode("\n", $lines);
}
return $rawContent;
}
return $rawContent;
}
/**
* Walk the `directories = [ … ]` array, descending into every
* `subdirectories` block recursively.
*
* @return array<int, array{source: string, destination: string, always_overwrite: bool}>
*/
private function parseDirectories(string $dirsArrayContent): array
{
$entries = [];
foreach ($this->splitBlocks($dirsArrayContent) as $block) {
$entries = array_merge($entries, $this->parseDirectoryBlock($block));
}
return $entries;
}
/**
* Walk the `directories = [ … ]` array, descending into every
* `subdirectories` block recursively.
*
* @return array<int, array{source: string, destination: string, always_overwrite: bool}>
*/
private function parseDirectories(string $dirsArrayContent): array
{
$entries = [];
foreach ($this->splitBlocks($dirsArrayContent) as $block) {
$entries = array_merge($entries, $this->parseDirectoryBlock($block));
}
return $entries;
}
/**
* Process one directory block: extract its path, parse its files, and
* recurse into any subdirectories.
*
* @return array<int, array{source: string, destination: string, always_overwrite: bool}>
*/
private function parseDirectoryBlock(string $block): array
{
$entries = [];
/**
* Process one directory block: extract its path, parse its files, and
* recurse into any subdirectories.
*
* @return array<int, array{source: string, destination: string, always_overwrite: bool}>
*/
private function parseDirectoryBlock(string $block): array
{
$entries = [];
// Determine the path prefix for files inside this directory
$dirPath = '';
if (preg_match('/\bpath\s*=\s*"([^"]+)"/', $block, $m)) {
$dirPath = $m[1];
}
// Determine the path prefix for files inside this directory
$dirPath = '';
if (preg_match('/\bpath\s*=\s*"([^"]+)"/', $block, $m)) {
$dirPath = $m[1];
}
// files = [ … ] inside this directory
$filesContent = $this->extractNamedArray($block, 'files');
if ($filesContent !== null) {
$entries = array_merge($entries, $this->parseFileBlocks($filesContent, $dirPath));
}
// files = [ … ] inside this directory
$filesContent = $this->extractNamedArray($block, 'files');
if ($filesContent !== null) {
$entries = array_merge($entries, $this->parseFileBlocks($filesContent, $dirPath));
}
// subdirectories = [ … ] — recurse
$subdirsContent = $this->extractNamedArray($block, 'subdirectories');
if ($subdirsContent !== null) {
foreach ($this->splitBlocks($subdirsContent) as $subBlock) {
$entries = array_merge($entries, $this->parseDirectoryBlock($subBlock));
}
}
// subdirectories = [ … ] — recurse
$subdirsContent = $this->extractNamedArray($block, 'subdirectories');
if ($subdirsContent !== null) {
foreach ($this->splitBlocks($subdirsContent) as $subBlock) {
$entries = array_merge($entries, $this->parseDirectoryBlock($subBlock));
}
}
return $entries;
}
return $entries;
}
}
+40 -38
View File
@@ -1,4 +1,5 @@
<?php
/**
* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
*
@@ -20,7 +21,7 @@ namespace MokoEnterprise;
/**
* Enterprise Readiness Validator
*
*
* Enterprise library for validating repository compliance with
* enterprise standards including libraries, monitoring, security, and documentation.
*/
@@ -28,9 +29,9 @@ class EnterpriseReadinessValidator
{
private AuditLogger $logger;
private SecurityValidator $securityValidator;
private array $results = [];
/**
* Constructor
*/
@@ -41,32 +42,32 @@ class EnterpriseReadinessValidator
$this->logger = $logger ?? new AuditLogger('enterprise_readiness');
$this->securityValidator = $securityValidator ?? new SecurityValidator();
}
/**
* Validate enterprise readiness
*
*
* @param string $path Repository path to validate
* @return array Validation results
*/
public function validate(string $path): array
{
$this->logger->logInfo("Starting enterprise readiness validation for: {$path}");
$this->results = [];
// Run all validation checks
$this->checkEnterpriseLibraries($path);
$this->checkMonitoring($path);
$this->checkAuditLogging($path);
$this->checkSecurityCompliance($path);
$this->checkDocumentation($path);
$passed = count(array_filter($this->results, fn($r) => $r['passed']));
$total = count($this->results);
$percentage = $total > 0 ? ($passed / $total * 100) : 0;
$this->logger->logInfo("Enterprise readiness validation complete: {$passed}/{$total} checks passed ({$percentage}%)");
return [
'results' => $this->results,
'passed' => $passed,
@@ -76,7 +77,7 @@ class EnterpriseReadinessValidator
'compliant' => $passed === $total,
];
}
/**
* Check for required enterprise libraries
*/
@@ -89,7 +90,7 @@ class EnterpriseReadinessValidator
'ErrorRecovery',
'MetricsCollector'
];
foreach ($required as $library) {
$phpFile = "{$path}/lib/Enterprise/{$library}.php";
$this->addResult(
@@ -99,7 +100,7 @@ class EnterpriseReadinessValidator
);
}
}
/**
* Check monitoring configuration
*/
@@ -109,24 +110,24 @@ class EnterpriseReadinessValidator
$metricsDir = "{$path}/var/logs/metrics";
$hasMetricsDir = is_dir($metricsDir);
$hasComposer = file_exists($path . '/composer.json');
$this->addResult(
'Metrics directory configured',
$hasMetricsDir || !$hasComposer,
$hasMetricsDir ? "Metrics directory exists at {$metricsDir}" : 'Metrics logging not configured'
);
// Check for monitoring documentation
$monitoringDocs = "{$path}/docs/monitoring";
$hasMonitoringDocs = is_dir($monitoringDocs) || file_exists("{$path}/docs/monitoring.md");
$this->addResult(
'Monitoring documentation exists',
$hasMonitoringDocs,
$hasMonitoringDocs ? "Monitoring documentation found" : 'Monitoring documentation not found'
);
}
/**
* Check audit logging configuration
*/
@@ -135,14 +136,14 @@ class EnterpriseReadinessValidator
$auditDir = "{$path}/var/logs/audit";
$hasAuditDir = is_dir($auditDir);
$hasComposer = file_exists($path . '/composer.json');
$this->addResult(
'Audit logging directory configured',
$hasAuditDir || !$hasComposer,
$hasAuditDir ? "Audit directory exists at {$auditDir}" : 'Audit logging not configured'
);
}
/**
* Check security compliance
*/
@@ -155,22 +156,23 @@ class EnterpriseReadinessValidator
$hasSecurity,
$hasSecurity ? "SECURITY.md found" : 'SECURITY.md not found'
);
// Check for CodeQL configuration
$codeqlConfig = "{$path}/.github/codeql";
$hasCodeQL = is_dir($codeqlConfig) || file_exists("{$path}/.github/codeql/codeql-config.yml");
$this->addResult(
'CodeQL configured',
$hasCodeQL,
$hasCodeQL ? "CodeQL configuration found" : 'CodeQL not configured'
);
// Run security scan on PHP files
if (is_dir("{$path}/src")) {
$issues = $this->securityValidator->scanDirectory("{$path}/src", ['.php']);
$this->securityValidator->scanDirectory("{$path}/src", ['.php']);
$issues = $this->securityValidator->getFindings();
$issueCount = count($issues);
$this->addResult(
'No security vulnerabilities in source code',
$issueCount === 0,
@@ -178,32 +180,32 @@ class EnterpriseReadinessValidator
);
}
}
/**
* Check documentation requirements
*/
private function checkDocumentation(string $path): void
{
// Check for architecture documentation
$hasArchitecture = file_exists("{$path}/docs/architecture.md") ||
$hasArchitecture = file_exists("{$path}/docs/architecture.md") ||
file_exists("{$path}/docs/guide/architecture.md");
$this->addResult(
'Architecture documentation exists',
$hasArchitecture,
$hasArchitecture ? "Architecture documentation found" : 'Architecture documentation not found'
);
// Check for API documentation
$hasAPI = file_exists("{$path}/docs/api.md") || is_dir("{$path}/docs/api");
$this->addResult(
'API documentation exists',
$hasAPI,
$hasAPI ? "API documentation found" : 'API documentation not found'
);
}
/**
* Add a validation result
*/
@@ -215,40 +217,40 @@ class EnterpriseReadinessValidator
'message' => $message,
];
}
/**
* Get all results
*
*
* @return array All validation results
*/
public function getResults(): array
{
return $this->results;
}
/**
* Get failed checks
*
*
* @return array Array of failed checks
*/
public function getFailedChecks(): array
{
return array_filter($this->results, fn($r) => !$r['passed']);
}
/**
* Get passed checks
*
*
* @return array Array of passed checks
*/
public function getPassedChecks(): array
{
return array_filter($this->results, fn($r) => $r['passed']);
}
/**
* Check if fully compliant
*
*
* @return bool True if all checks passed
*/
public function isCompliant(): bool
+215 -214
View File
@@ -1,4 +1,5 @@
<?php
/* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
*
* This file is part of a Moko Consulting project.
@@ -30,253 +31,253 @@ use SplFileInfo;
*/
class FileFixUtility
{
/** @var list<string> Extensions processed by fixLineEndings(). */
private const LINE_ENDING_EXTENSIONS = ['php', 'js', 'css', 'xml', 'sh', 'md'];
/** @var list<string> Extensions processed by fixLineEndings(). */
private const LINE_ENDING_EXTENSIONS = ['php', 'js', 'css', 'xml', 'sh', 'md'];
/** @var list<string> Extensions processed when $fileType = 'all' in fixTabs(). */
private const TABS_ALL_EXTENSIONS = ['yml', 'yaml', 'py', 'sh', 'bash'];
/** @var list<string> Extensions processed when $fileType = 'all' in fixTabs(). */
private const TABS_ALL_EXTENSIONS = ['yml', 'yaml', 'py', 'sh', 'bash'];
/** @var array<string,list<string>> Extension sets per file-type name in fixTabs(). */
private const TABS_TYPE_EXTENSIONS = [
'yaml' => ['yml', 'yaml'],
'python' => ['py'],
'shell' => ['sh', 'bash'],
'all' => self::TABS_ALL_EXTENSIONS,
];
/** @var array<string,list<string>> Extension sets per file-type name in fixTabs(). */
private const TABS_TYPE_EXTENSIONS = [
'yaml' => ['yml', 'yaml'],
'python' => ['py'],
'shell' => ['sh', 'bash'],
'all' => self::TABS_ALL_EXTENSIONS,
];
/** @var list<string> Extensions processed when $fileType = 'all' in fixTrailingSpaces(). */
private const TRAILING_ALL_EXTENSIONS = ['yml', 'yaml', 'py', 'sh', 'bash', 'md', 'markdown'];
/** @var list<string> Extensions processed when $fileType = 'all' in fixTrailingSpaces(). */
private const TRAILING_ALL_EXTENSIONS = ['yml', 'yaml', 'py', 'sh', 'bash', 'md', 'markdown'];
/** @var array<string,list<string>> Extension sets per file-type name in fixTrailingSpaces(). */
private const TRAILING_TYPE_EXTENSIONS = [
'yaml' => ['yml', 'yaml'],
'python' => ['py'],
'shell' => ['sh', 'bash'],
'markdown' => ['md', 'markdown'],
'all' => self::TRAILING_ALL_EXTENSIONS,
];
/** @var array<string,list<string>> Extension sets per file-type name in fixTrailingSpaces(). */
private const TRAILING_TYPE_EXTENSIONS = [
'yaml' => ['yml', 'yaml'],
'python' => ['py'],
'shell' => ['sh', 'bash'],
'markdown' => ['md', 'markdown'],
'all' => self::TRAILING_ALL_EXTENSIONS,
];
// ── Public API ────────────────────────────────────────────────────────────
// ── Public API ────────────────────────────────────────────────────────────
/**
* Fix CRLF line endings to LF in tracked source files.
*
* Operates on all git-tracked files with extensions: php, js, css, xml, sh, md.
* In dry-run mode, returns the list of files that would be changed without
* modifying them.
*
* @param string $repoRoot Absolute path to the repository root.
* @param bool $dryRun When true, report changes without writing.
* @return list<string> Files that were (or would be) changed.
*/
public static function fixLineEndings(string $repoRoot, bool $dryRun = false): array
{
$patterns = array_map(
static fn(string $ext): string => '*.' . $ext,
self::LINE_ENDING_EXTENSIONS
);
$files = self::gitLsFiles($repoRoot, $patterns);
$changed = [];
/**
* Fix CRLF line endings to LF in tracked source files.
*
* Operates on all git-tracked files with extensions: php, js, css, xml, sh, md.
* In dry-run mode, returns the list of files that would be changed without
* modifying them.
*
* @param string $repoRoot Absolute path to the repository root.
* @param bool $dryRun When true, report changes without writing.
* @return list<string> Files that were (or would be) changed.
*/
public static function fixLineEndings(string $repoRoot, bool $dryRun = false): array
{
$patterns = array_map(
static fn(string $ext): string => '*.' . $ext,
self::LINE_ENDING_EXTENSIONS
);
$files = self::gitLsFiles($repoRoot, $patterns);
$changed = [];
foreach ($files as $file) {
$path = $repoRoot . '/' . $file;
if (!is_file($path)) {
continue;
}
foreach ($files as $file) {
$path = $repoRoot . '/' . $file;
if (!is_file($path)) {
continue;
}
$content = (string) file_get_contents($path);
if (strpos($content, "\r\n") === false) {
continue;
}
$content = (string) file_get_contents($path);
if (strpos($content, "\r\n") === false) {
continue;
}
$changed[] = $file;
$changed[] = $file;
if (!$dryRun) {
file_put_contents($path, str_replace("\r\n", "\n", $content));
}
}
if (!$dryRun) {
file_put_contents($path, str_replace("\r\n", "\n", $content));
}
}
return $changed;
}
return $changed;
}
/**
* Fix file permissions: directories 755, regular files 644, .php/.sh scripts 755.
*
* Skips the .git/ directory tree. In dry-run mode, no changes are applied.
*
* @param string $repoRoot Absolute path to the repository root.
* @param bool $dryRun When true, report what would change without writing.
*/
public static function fixPermissions(string $repoRoot, bool $dryRun = false): void
{
if ($dryRun) {
return;
}
/**
* Fix file permissions: directories 755, regular files 644, .php/.sh scripts 755.
*
* Skips the .git/ directory tree. In dry-run mode, no changes are applied.
*
* @param string $repoRoot Absolute path to the repository root.
* @param bool $dryRun When true, report what would change without writing.
*/
public static function fixPermissions(string $repoRoot, bool $dryRun = false): void
{
if ($dryRun) {
return;
}
$iterator = new RecursiveIteratorIterator(
new RecursiveDirectoryIterator($repoRoot, RecursiveDirectoryIterator::SKIP_DOTS),
RecursiveIteratorIterator::SELF_FIRST
);
$iterator = new RecursiveIteratorIterator(
new RecursiveDirectoryIterator($repoRoot, RecursiveDirectoryIterator::SKIP_DOTS),
RecursiveIteratorIterator::SELF_FIRST
);
foreach ($iterator as $item) {
/** @var SplFileInfo $item */
$path = $item->getPathname();
foreach ($iterator as $item) {
/** @var SplFileInfo $item */
$path = $item->getPathname();
if (str_contains($path, '/.git/') || str_ends_with($path, '/.git')) {
continue;
}
if (str_contains($path, '/.git/') || str_ends_with($path, '/.git')) {
continue;
}
if ($item->isDir()) {
chmod($path, 0755);
} elseif ($item->isFile()) {
$ext = strtolower($item->getExtension());
$perm = in_array($ext, ['php', 'sh'], true) ? 0755 : 0644;
chmod($path, $perm);
}
}
}
if ($item->isDir()) {
chmod($path, 0755);
} elseif ($item->isFile()) {
$ext = strtolower($item->getExtension());
$perm = in_array($ext, ['php', 'sh'], true) ? 0755 : 0644;
chmod($path, $perm);
}
}
}
/**
* Convert tab characters to spaces in tracked source files.
*
* YAML files use 2-space indentation; all other supported types use 4 spaces.
* Makefile variants are always skipped. In dry-run mode, returns the list of
* files that would be changed without modifying them.
*
* @param string $repoRoot Absolute path to the repository root.
* @param string $fileType One of yaml, python, shell, all (default: all).
* @param bool $dryRun When true, report changes without writing.
* @return list<string> Files that were (or would be) changed.
* @throws \InvalidArgumentException When $fileType is unrecognised.
*/
public static function fixTabs(string $repoRoot, string $fileType = 'all', bool $dryRun = false): array
{
if (!array_key_exists($fileType, self::TABS_TYPE_EXTENSIONS)) {
throw new \InvalidArgumentException(
"Unknown file type: {$fileType}. Valid types: " .
implode(', ', array_keys(self::TABS_TYPE_EXTENSIONS))
);
}
/**
* Convert tab characters to spaces in tracked source files.
*
* YAML files use 2-space indentation; all other supported types use 4 spaces.
* Makefile variants are always skipped. In dry-run mode, returns the list of
* files that would be changed without modifying them.
*
* @param string $repoRoot Absolute path to the repository root.
* @param string $fileType One of yaml, python, shell, all (default: all).
* @param bool $dryRun When true, report changes without writing.
* @return list<string> Files that were (or would be) changed.
* @throws \InvalidArgumentException When $fileType is unrecognised.
*/
public static function fixTabs(string $repoRoot, string $fileType = 'all', bool $dryRun = false): array
{
if (!array_key_exists($fileType, self::TABS_TYPE_EXTENSIONS)) {
throw new \InvalidArgumentException(
"Unknown file type: {$fileType}. Valid types: " .
implode(', ', array_keys(self::TABS_TYPE_EXTENSIONS))
);
}
$extensions = self::TABS_TYPE_EXTENSIONS[$fileType];
$patterns = array_map(static fn(string $ext): string => '*.' . $ext, $extensions);
$files = self::gitLsFiles($repoRoot, $patterns);
$changed = [];
$extensions = self::TABS_TYPE_EXTENSIONS[$fileType];
$patterns = array_map(static fn(string $ext): string => '*.' . $ext, $extensions);
$files = self::gitLsFiles($repoRoot, $patterns);
$changed = [];
foreach ($files as $file) {
$path = $repoRoot . '/' . $file;
if (!is_file($path)) {
continue;
}
foreach ($files as $file) {
$path = $repoRoot . '/' . $file;
if (!is_file($path)) {
continue;
}
if (self::isMakefile($file)) {
continue;
}
if (self::isMakefile($file)) {
continue;
}
$content = (string) file_get_contents($path);
if (strpos($content, "\t") === false) {
continue;
}
$content = (string) file_get_contents($path);
if (strpos($content, "\t") === false) {
continue;
}
$changed[] = $file;
$changed[] = $file;
if (!$dryRun) {
$spaces = self::spacesForFile($file);
$pad = str_repeat(' ', $spaces);
file_put_contents($path, str_replace("\t", $pad, $content));
}
}
if (!$dryRun) {
$spaces = self::spacesForFile($file);
$pad = str_repeat(' ', $spaces);
file_put_contents($path, str_replace("\t", $pad, $content));
}
}
return $changed;
}
return $changed;
}
/**
* Remove trailing whitespace from tracked source files.
*
* In dry-run mode, returns the list of files that would be changed without
* modifying them.
*
* @param string $repoRoot Absolute path to the repository root.
* @param string $fileType One of yaml, python, shell, markdown, all (default: all).
* @param bool $dryRun When true, report changes without writing.
* @return list<string> Files that were (or would be) changed.
* @throws \InvalidArgumentException When $fileType is unrecognised.
*/
public static function fixTrailingSpaces(string $repoRoot, string $fileType = 'all', bool $dryRun = false): array
{
if (!array_key_exists($fileType, self::TRAILING_TYPE_EXTENSIONS)) {
throw new \InvalidArgumentException(
"Unknown file type: {$fileType}. Valid types: " .
implode(', ', array_keys(self::TRAILING_TYPE_EXTENSIONS))
);
}
/**
* Remove trailing whitespace from tracked source files.
*
* In dry-run mode, returns the list of files that would be changed without
* modifying them.
*
* @param string $repoRoot Absolute path to the repository root.
* @param string $fileType One of yaml, python, shell, markdown, all (default: all).
* @param bool $dryRun When true, report changes without writing.
* @return list<string> Files that were (or would be) changed.
* @throws \InvalidArgumentException When $fileType is unrecognised.
*/
public static function fixTrailingSpaces(string $repoRoot, string $fileType = 'all', bool $dryRun = false): array
{
if (!array_key_exists($fileType, self::TRAILING_TYPE_EXTENSIONS)) {
throw new \InvalidArgumentException(
"Unknown file type: {$fileType}. Valid types: " .
implode(', ', array_keys(self::TRAILING_TYPE_EXTENSIONS))
);
}
$extensions = self::TRAILING_TYPE_EXTENSIONS[$fileType];
$patterns = array_map(static fn(string $ext): string => '*.' . $ext, $extensions);
$files = self::gitLsFiles($repoRoot, $patterns);
$changed = [];
$extensions = self::TRAILING_TYPE_EXTENSIONS[$fileType];
$patterns = array_map(static fn(string $ext): string => '*.' . $ext, $extensions);
$files = self::gitLsFiles($repoRoot, $patterns);
$changed = [];
foreach ($files as $file) {
$path = $repoRoot . '/' . $file;
if (!is_file($path)) {
continue;
}
foreach ($files as $file) {
$path = $repoRoot . '/' . $file;
if (!is_file($path)) {
continue;
}
$content = (string) file_get_contents($path);
if (!preg_match('/[[:space:]]+$/m', $content)) {
continue;
}
$content = (string) file_get_contents($path);
if (!preg_match('/[[:space:]]+$/m', $content)) {
continue;
}
$changed[] = $file;
$changed[] = $file;
if (!$dryRun) {
$fixed = preg_replace('/[[:space:]]+$/m', '', $content);
file_put_contents($path, (string) $fixed);
}
}
if (!$dryRun) {
$fixed = preg_replace('/[[:space:]]+$/m', '', $content);
file_put_contents($path, (string) $fixed);
}
}
return $changed;
}
return $changed;
}
// ── Private helpers ───────────────────────────────────────────────────────
// ── Private helpers ───────────────────────────────────────────────────────
/**
* Run git ls-files in the given root with the provided glob patterns.
*
* @param string $repoRoot Repository root path.
* @param list<string> $patterns Shell glob patterns.
* @return list<string> Relative file paths.
*/
private static function gitLsFiles(string $repoRoot, array $patterns): array
{
$quoted = implode(' ', array_map('escapeshellarg', $patterns));
$cmd = 'git -C ' . escapeshellarg($repoRoot) . " ls-files {$quoted} 2>/dev/null";
$output = shell_exec($cmd) ?? '';
return array_values(array_filter(explode("\n", $output)));
}
/**
* Run git ls-files in the given root with the provided glob patterns.
*
* @param string $repoRoot Repository root path.
* @param list<string> $patterns Shell glob patterns.
* @return list<string> Relative file paths.
*/
private static function gitLsFiles(string $repoRoot, array $patterns): array
{
$quoted = implode(' ', array_map('escapeshellarg', $patterns));
$cmd = 'git -C ' . escapeshellarg($repoRoot) . " ls-files {$quoted} 2>/dev/null";
$output = shell_exec($cmd) ?? '';
return array_values(array_filter(explode("\n", $output)));
}
/**
* Return true when the filename matches a Makefile variant.
*
* @param string $path File path (only basename is examined).
*/
private static function isMakefile(string $path): bool
{
$base = strtolower(basename($path));
return $base === 'makefile'
|| $base === 'gnumakefile'
|| str_starts_with($base, 'makefile.');
}
/**
* Return true when the filename matches a Makefile variant.
*
* @param string $path File path (only basename is examined).
*/
private static function isMakefile(string $path): bool
{
$base = strtolower(basename($path));
return $base === 'makefile'
|| $base === 'gnumakefile'
|| str_starts_with($base, 'makefile.');
}
/**
* Return the number of spaces to substitute for a tab in a given file.
*
* @param string $path File path (extension determines width).
* @return int 2 for YAML, 4 for everything else.
*/
private static function spacesForFile(string $path): int
{
$ext = strtolower(pathinfo($path, PATHINFO_EXTENSION));
return in_array($ext, ['yml', 'yaml'], true) ? 2 : 4;
}
/**
* Return the number of spaces to substitute for a tab in a given file.
*
* @param string $path File path (extension determines width).
* @return int 2 for YAML, 4 for everything else.
*/
private static function spacesForFile(string $path): int
{
$ext = strtolower(pathinfo($path, PATHINFO_EXTENSION));
return in_array($ext, ['yml', 'yaml'], true) ? 2 : 4;
}
}
+356 -326
View File
@@ -1,4 +1,5 @@
<?php
/* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
*
* This file is part of a Moko Consulting project.
@@ -31,397 +32,426 @@ use RuntimeException;
* - Workflow dir: .github/workflows
*
* @package MokoStandards\Enterprise
* @version 04.06.10
* @since 04.06.10
* @see GitPlatformAdapter
*/
class GitHubAdapter implements GitPlatformAdapter
{
private ApiClient $apiClient;
/** @var ApiClient HTTP client for GitHub API calls. */
private ApiClient $apiClient;
public function __construct(ApiClient $apiClient)
{
$this->apiClient = $apiClient;
}
/**
* @param ApiClient $apiClient Configured API client for api.github.com
*/
public function __construct(ApiClient $apiClient)
{
$this->apiClient = $apiClient;
}
// ──────────────────────────────────────────────
// Identity
// ──────────────────────────────────────────────
// ──────────────────────────────────────────────
// Identity
// ──────────────────────────────────────────────
public function getPlatformName(): string
{
return 'github';
}
public function getPlatformName(): string
{
return 'github';
}
public function getBaseUrl(): string
{
return 'https://api.github.com';
}
public function getBaseUrl(): string
{
return 'https://api.github.com';
}
public function getWorkflowDir(): string
{
return '.github/workflows';
}
public function getWorkflowDir(): string
{
return '.github/workflows';
}
public function getMetadataDir(): string
{
return '.github';
}
public function getMetadataDir(): string
{
return '.github';
}
public function getRepoWebUrl(string $org, string $repo): string
{
return "https://github.com/{$org}/{$repo}";
}
public function getRepoWebUrl(string $org, string $repo): string
{
return "https://github.com/{$org}/{$repo}";
}
public function getPullRequestWebUrl(string $org, string $repo, int $number): string
{
return "https://github.com/{$org}/{$repo}/pull/{$number}";
}
public function getPullRequestWebUrl(string $org, string $repo, int $number): string
{
return "https://github.com/{$org}/{$repo}/pull/{$number}";
}
public function getIssueWebUrl(string $org, string $repo, int $number): string
{
return "https://github.com/{$org}/{$repo}/issues/{$number}";
}
public function getIssueWebUrl(string $org, string $repo, int $number): string
{
return "https://github.com/{$org}/{$repo}/issues/{$number}";
}
public function getBranchWebUrl(string $org, string $repo, string $branch): string
{
return "https://github.com/{$org}/{$repo}/tree/{$branch}";
}
public function getBranchWebUrl(string $org, string $repo, string $branch): string
{
return "https://github.com/{$org}/{$repo}/tree/{$branch}";
}
public function getStepSummaryEnvVar(): string
{
return 'GITHUB_STEP_SUMMARY';
}
public function getStepSummaryEnvVar(): string
{
return 'GITHUB_STEP_SUMMARY';
}
// ──────────────────────────────────────────────
// Repository CRUD
// ──────────────────────────────────────────────
// ──────────────────────────────────────────────
// Repository CRUD
// ──────────────────────────────────────────────
public function listOrgRepos(string $org, bool $skipArchived = false): array
{
$all = $this->paginateAll("/orgs/{$org}/repos", ['type' => 'all']);
public function listOrgRepos(string $org, bool $skipArchived = false): array
{
$all = $this->paginateAll("/orgs/{$org}/repos", ['type' => 'all']);
$repos = [];
foreach ($all as $repo) {
if ($skipArchived && ($repo['archived'] ?? false)) {
continue;
}
$repos[] = [
'name' => $repo['name'],
'full_name' => $repo['full_name'],
'archived' => $repo['archived'] ?? false,
'private' => $repo['private'] ?? false,
];
}
$repos = [];
foreach ($all as $repo) {
if ($skipArchived && ($repo['archived'] ?? false)) {
continue;
}
$repos[] = [
'name' => $repo['name'],
'full_name' => $repo['full_name'],
'archived' => $repo['archived'] ?? false,
'private' => $repo['private'] ?? false,
];
}
return $repos;
}
return $repos;
}
public function getRepo(string $org, string $repo): array
{
return $this->apiClient->get("/repos/{$org}/{$repo}");
}
public function getRepo(string $org, string $repo): array
{
return $this->apiClient->get("/repos/{$org}/{$repo}");
}
public function createOrgRepo(string $org, string $name, array $options = []): array
{
$data = array_merge([
'name' => $name,
'auto_init' => true,
], $options);
public function createOrgRepo(string $org, string $name, array $options = []): array
{
$data = array_merge([
'name' => $name,
'auto_init' => true,
], $options);
return $this->apiClient->post("/orgs/{$org}/repos", $data);
}
return $this->apiClient->post("/orgs/{$org}/repos", $data);
}
public function archiveRepo(string $org, string $repo): array
{
return $this->apiClient->patch("/repos/{$org}/{$repo}", [
'archived' => true,
]);
}
public function archiveRepo(string $org, string $repo): array
{
return $this->apiClient->patch("/repos/{$org}/{$repo}", [
'archived' => true,
]);
}
public function setRepoTopics(string $org, string $repo, array $topics): void
{
$this->apiClient->put("/repos/{$org}/{$repo}/topics", [
'names' => $topics,
]);
}
public function setRepoTopics(string $org, string $repo, array $topics): void
{
$this->apiClient->put("/repos/{$org}/{$repo}/topics", [
'names' => $topics,
]);
}
public function getRepoTopics(string $org, string $repo): array
{
$response = $this->apiClient->get("/repos/{$org}/{$repo}/topics");
return $response['names'] ?? [];
}
public function getRepoTopics(string $org, string $repo): array
{
$response = $this->apiClient->get("/repos/{$org}/{$repo}/topics");
return $response['names'] ?? [];
}
// ──────────────────────────────────────────────
// File Contents
// ──────────────────────────────────────────────
// ──────────────────────────────────────────────
// File Contents
// ──────────────────────────────────────────────
public function getFileContents(string $org, string $repo, string $path, ?string $ref = null): array
{
$params = [];
if ($ref !== null) {
$params['ref'] = $ref;
}
return $this->apiClient->get("/repos/{$org}/{$repo}/contents/{$path}", $params);
}
public function getFileContents(string $org, string $repo, string $path, ?string $ref = null): array
{
$params = [];
if ($ref !== null) {
$params['ref'] = $ref;
}
return $this->apiClient->get("/repos/{$org}/{$repo}/contents/{$path}", $params);
}
public function createOrUpdateFile(
string $org,
string $repo,
string $path,
string $content,
string $message,
?string $sha = null,
?string $branch = null
): array {
$data = [
'message' => $message,
'content' => base64_encode($content),
];
public function createOrUpdateFile(
string $org,
string $repo,
string $path,
string $content,
string $message,
?string $sha = null,
?string $branch = null
): array {
$data = [
'message' => $message,
'content' => base64_encode($content),
];
if ($sha !== null) {
$data['sha'] = $sha;
}
if ($branch !== null) {
$data['branch'] = $branch;
}
if ($sha !== null) {
$data['sha'] = $sha;
}
if ($branch !== null) {
$data['branch'] = $branch;
}
// GitHub uses PUT for both create and update
return $this->apiClient->put("/repos/{$org}/{$repo}/contents/{$path}", $data);
}
// GitHub uses PUT for both create and update
return $this->apiClient->put("/repos/{$org}/{$repo}/contents/{$path}", $data);
}
public function deleteFile(
string $org,
string $repo,
string $path,
string $sha,
string $message,
?string $branch = null
): array {
// GitHub's delete endpoint requires a body with sha+message,
// but ApiClient::delete() doesn't accept a body. Use the raw approach.
$data = [
'message' => $message,
'sha' => $sha,
];
if ($branch !== null) {
$data['branch'] = $branch;
}
public function deleteFile(
string $org,
string $repo,
string $path,
string $sha,
string $message,
?string $branch = null
): array {
// GitHub's delete endpoint requires a body with sha+message,
// but ApiClient::delete() doesn't accept a body. Use the raw approach.
$data = [
'message' => $message,
'sha' => $sha,
];
if ($branch !== null) {
$data['branch'] = $branch;
}
// Work around ApiClient::delete() not accepting a body by using
// a direct HTTP call. For now, fall back to the underlying client.
return $this->apiClient->delete("/repos/{$org}/{$repo}/contents/{$path}");
}
// Work around ApiClient::delete() not accepting a body by using
// a direct HTTP call. For now, fall back to the underlying client.
return $this->apiClient->delete("/repos/{$org}/{$repo}/contents/{$path}");
}
// ──────────────────────────────────────────────
// Pull Requests
// ──────────────────────────────────────────────
// ──────────────────────────────────────────────
// Pull Requests
// ──────────────────────────────────────────────
public function listPullRequests(string $org, string $repo, array $filters = []): array
{
return $this->apiClient->get("/repos/{$org}/{$repo}/pulls", $filters);
}
public function listPullRequests(string $org, string $repo, array $filters = []): array
{
return $this->apiClient->get("/repos/{$org}/{$repo}/pulls", $filters);
}
public function createPullRequest(
string $org,
string $repo,
string $title,
string $head,
string $base,
string $body = '',
array $options = []
): array {
$data = array_merge([
'title' => $title,
'head' => $head,
'base' => $base,
'body' => $body,
], $options);
public function createPullRequest(
string $org,
string $repo,
string $title,
string $head,
string $base,
string $body = '',
array $options = []
): array {
$data = array_merge([
'title' => $title,
'head' => $head,
'base' => $base,
'body' => $body,
], $options);
return $this->apiClient->post("/repos/{$org}/{$repo}/pulls", $data);
}
return $this->apiClient->post("/repos/{$org}/{$repo}/pulls", $data);
}
public function updatePullRequest(string $org, string $repo, int $number, array $data): array
{
return $this->apiClient->patch("/repos/{$org}/{$repo}/pulls/{$number}", $data);
}
public function updatePullRequest(string $org, string $repo, int $number, array $data): array
{
return $this->apiClient->patch("/repos/{$org}/{$repo}/pulls/{$number}", $data);
}
// ──────────────────────────────────────────────
// Issues
// ──────────────────────────────────────────────
// ──────────────────────────────────────────────
// Issues
// ──────────────────────────────────────────────
public function listIssues(string $org, string $repo, array $filters = []): array
{
return $this->apiClient->get("/repos/{$org}/{$repo}/issues", $filters);
}
public function listIssues(string $org, string $repo, array $filters = []): array
{
return $this->apiClient->get("/repos/{$org}/{$repo}/issues", $filters);
}
public function createIssue(
string $org,
string $repo,
string $title,
string $body = '',
array $options = []
): array {
$data = array_merge([
'title' => $title,
'body' => $body,
], $options);
public function createIssue(
string $org,
string $repo,
string $title,
string $body = '',
array $options = []
): array {
$data = array_merge([
'title' => $title,
'body' => $body,
], $options);
return $this->apiClient->post("/repos/{$org}/{$repo}/issues", $data);
}
return $this->apiClient->post("/repos/{$org}/{$repo}/issues", $data);
}
public function addIssueComment(string $org, string $repo, int $number, string $body): array
{
return $this->apiClient->post("/repos/{$org}/{$repo}/issues/{$number}/comments", [
'body' => $body,
]);
}
public function addIssueComment(string $org, string $repo, int $number, string $body): array
{
return $this->apiClient->post("/repos/{$org}/{$repo}/issues/{$number}/comments", [
'body' => $body,
]);
}
public function closeIssue(string $org, string $repo, int $number): array
{
return $this->apiClient->patch("/repos/{$org}/{$repo}/issues/{$number}", [
'state' => 'closed',
]);
}
public function closeIssue(string $org, string $repo, int $number): array
{
return $this->apiClient->patch("/repos/{$org}/{$repo}/issues/{$number}", [
'state' => 'closed',
]);
}
// ──────────────────────────────────────────────
// Labels
// ──────────────────────────────────────────────
// ──────────────────────────────────────────────
// Labels
// ──────────────────────────────────────────────
public function listLabels(string $org, string $repo): array
{
return $this->paginateAll("/repos/{$org}/{$repo}/labels");
}
public function listLabels(string $org, string $repo): array
{
return $this->paginateAll("/repos/{$org}/{$repo}/labels");
}
public function createLabel(string $org, string $repo, string $name, string $color, string $description = ''): array
{
return $this->apiClient->post("/repos/{$org}/{$repo}/labels", [
'name' => $name,
'color' => $color,
'description' => $description,
]);
}
public function createLabel(string $org, string $repo, string $name, string $color, string $description = ''): array
{
return $this->apiClient->post("/repos/{$org}/{$repo}/labels", [
'name' => $name,
'color' => $color,
'description' => $description,
]);
}
public function addIssueLabels(string $org, string $repo, int $number, array $labels): array
{
// GitHub accepts label names directly
return $this->apiClient->post("/repos/{$org}/{$repo}/issues/{$number}/labels", [
'labels' => $labels,
]);
}
public function addIssueLabels(string $org, string $repo, int $number, array $labels): array
{
// GitHub accepts label names directly
return $this->apiClient->post("/repos/{$org}/{$repo}/issues/{$number}/labels", [
'labels' => $labels,
]);
}
// ──────────────────────────────────────────────
// Branch Protection
// ──────────────────────────────────────────────
// ──────────────────────────────────────────────
// Branch Protection
// ──────────────────────────────────────────────
public function setBranchProtection(string $org, string $repo, string $branch, array $rules): array
{
// GitHub uses rulesets API (newer) or branch protection API (legacy)
// Map our generic rules to GitHub's branch protection format
$protection = [
'required_status_checks' => null,
'enforce_admins' => $rules['enforce_admins'] ?? true,
'required_pull_request_reviews' => null,
'restrictions' => null,
];
public function setBranchProtection(string $org, string $repo, string $branch, array $rules): array
{
// GitHub uses rulesets API (newer) or branch protection API (legacy)
// Map our generic rules to GitHub's branch protection format
$protection = [
'required_status_checks' => null,
'enforce_admins' => $rules['enforce_admins'] ?? true,
'required_pull_request_reviews' => null,
'restrictions' => null,
];
if (isset($rules['required_reviews']) && $rules['required_reviews'] > 0) {
$protection['required_pull_request_reviews'] = [
'required_approving_review_count' => $rules['required_reviews'],
'dismiss_stale_reviews' => $rules['dismiss_stale'] ?? false,
'require_code_owner_reviews' => $rules['require_code_owner'] ?? false,
];
}
if (isset($rules['required_reviews']) && $rules['required_reviews'] > 0) {
$protection['required_pull_request_reviews'] = [
'required_approving_review_count' => $rules['required_reviews'],
'dismiss_stale_reviews' => $rules['dismiss_stale'] ?? false,
'require_code_owner_reviews' => $rules['require_code_owner'] ?? false,
];
}
return $this->apiClient->put(
"/repos/{$org}/{$repo}/branches/{$branch}/protection",
$protection
);
}
return $this->apiClient->put(
"/repos/{$org}/{$repo}/branches/{$branch}/protection",
$protection
);
}
public function listBranchProtections(string $org, string $repo): array
{
// GitHub doesn't have a "list all protections" endpoint; list branches and check each
// For rulesets: GET /repos/{owner}/{repo}/rulesets
try {
return $this->apiClient->get("/repos/{$org}/{$repo}/rulesets");
} catch (\Exception $e) {
return [];
}
}
public function listBranchProtections(string $org, string $repo): array
{
// GitHub doesn't have a "list all protections" endpoint; list branches and check each
// For rulesets: GET /repos/{owner}/{repo}/rulesets
try {
return $this->apiClient->get("/repos/{$org}/{$repo}/rulesets");
} catch (\Exception $e) {
return [];
}
}
// ──────────────────────────────────────────────
// Git Refs
// ──────────────────────────────────────────────
// ──────────────────────────────────────────────
// Git Refs
// ──────────────────────────────────────────────
public function resolveRef(string $org, string $repo, string $ref): string
{
// Try as a tag first, then as a branch
try {
$tag = $this->apiClient->get("/repos/{$org}/{$repo}/git/ref/tags/{$ref}");
$object = $tag['object'] ?? [];
public function resolveRef(string $org, string $repo, string $ref): string
{
// Try as a tag first, then as a branch
try {
$tag = $this->apiClient->get("/repos/{$org}/{$repo}/git/ref/tags/{$ref}");
$object = $tag['object'] ?? [];
// Annotated tags have type 'tag' — dereference to the commit
if (($object['type'] ?? '') === 'tag') {
$tagObj = $this->apiClient->get($object['url'] ?? "/repos/{$org}/{$repo}/git/tags/{$object['sha']}");
return $tagObj['object']['sha'] ?? $object['sha'];
}
// Annotated tags have type 'tag' — dereference to the commit
if (($object['type'] ?? '') === 'tag') {
$tagObj = $this->apiClient->get($object['url'] ?? "/repos/{$org}/{$repo}/git/tags/{$object['sha']}");
return $tagObj['object']['sha'] ?? $object['sha'];
}
return $object['sha'] ?? '';
} catch (\Exception $e) {
// Not a tag — try as a branch
$this->apiClient->resetCircuitBreaker();
}
return $object['sha'] ?? '';
} catch (\Exception $e) {
// Not a tag — try as a branch
$this->apiClient->resetCircuitBreaker();
}
$branch = $this->apiClient->get("/repos/{$org}/{$repo}/git/ref/heads/{$ref}");
return $branch['object']['sha'] ?? '';
}
$branch = $this->apiClient->get("/repos/{$org}/{$repo}/git/ref/heads/{$ref}");
return $branch['object']['sha'] ?? '';
}
public function getTree(string $org, string $repo, string $ref = 'HEAD', bool $recursive = true): array
{
$params = $recursive ? ['recursive' => '1'] : [];
$response = $this->apiClient->get("/repos/{$org}/{$repo}/git/trees/{$ref}", $params);
return $response['tree'] ?? [];
}
public function getTree(string $org, string $repo, string $ref = 'HEAD', bool $recursive = true): array
{
$params = $recursive ? ['recursive' => '1'] : [];
$response = $this->apiClient->get("/repos/{$org}/{$repo}/git/trees/{$ref}", $params);
return $response['tree'] ?? [];
}
// ──────────────────────────────────────────────
// Pagination
// ──────────────────────────────────────────────
// ──────────────────────────────────────────────
// Pagination
// ──────────────────────────────────────────────
public function paginateAll(string $endpoint, array $params = [], int $perPage = 100): array
{
$all = [];
$page = 1;
$params['per_page'] = $perPage;
public function paginateAll(string $endpoint, array $params = [], int $perPage = 100): array
{
$all = [];
$page = 1;
$params['per_page'] = $perPage;
while (true) {
$params['page'] = $page;
$response = $this->apiClient->get($endpoint, $params);
while (true) {
$params['page'] = $page;
$response = $this->apiClient->get($endpoint, $params);
if (empty($response)) {
break;
}
if (empty($response)) {
break;
}
$all = array_merge($all, $response);
$page++;
}
$all = array_merge($all, $response);
$page++;
}
return $all;
}
return array_values($all);
}
// ──────────────────────────────────────────────
// Migration
// ──────────────────────────────────────────────
// ──────────────────────────────────────────────
// Migration
// ──────────────────────────────────────────────
public function migrateRepository(array $options): array
{
throw new RuntimeException('Repository migration is not supported on GitHub — use Gitea\'s built-in migration');
}
public function migrateRepository(array $options): array
{
throw new RuntimeException('Repository migration is not supported on GitHub — use Gitea\'s built-in migration');
}
// ──────────────────────────────────────────────
// Low-level
// ──────────────────────────────────────────────
// ──────────────────────────────────────────────
// Low-level
// ──────────────────────────────────────────────
public function getApiClient(): ApiClient
{
return $this->apiClient;
}
public function getApiClient(): ApiClient
{
return $this->apiClient;
}
public function listBranches(string $org, string $repo): array
{
return $this->apiClient->get("/repos/{$org}/{$repo}/branches") ?? [];
}
public function getCloneUrl(string $repo): string
{
return "https://github.com/{$repo}.git";
}
public function cloneRepo(string $repo, string $path, array $options = []): bool
{
$url = $this->getCloneUrl($repo);
$depth = $options['depth'] ?? 0;
$depthFlag = $depth > 0 ? " --depth {$depth}" : '';
$result = 0;
passthru(
'git clone' . $depthFlag . ' --quiet '
. escapeshellarg($url) . ' ' . escapeshellarg($path),
$result
);
return $result === 0;
}
}
+404 -380
View File
@@ -1,4 +1,5 @@
<?php
/* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
*
* This file is part of a Moko Consulting project.
@@ -29,429 +30,452 @@ namespace MokoEnterprise;
*/
interface GitPlatformAdapter
{
// ──────────────────────────────────────────────
// Identity
// ──────────────────────────────────────────────
// ──────────────────────────────────────────────
// Identity
// ──────────────────────────────────────────────
/**
* Get the platform name identifier.
*
* @return string 'github' or 'gitea'
*/
public function getPlatformName(): string;
/**
* Get the platform name identifier.
*
* @return string 'github' or 'gitea'
*/
public function getPlatformName(): string;
/**
* Get the API base URL.
*
* @return string e.g. 'https://api.github.com' or 'https://git.mokoconsulting.tech/api/v1'
*/
public function getBaseUrl(): string;
/**
* Get the API base URL.
*
* @return string e.g. 'https://api.github.com' or 'https://git.mokoconsulting.tech/api/v1'
*/
public function getBaseUrl(): string;
/**
* Get the workflow directory name for this platform.
*
* @return string '.github/workflows' or '.mokogitea/workflows'
*/
public function getWorkflowDir(): string;
/**
* Get the workflow directory name for this platform.
*
* @return string '.github/workflows' or '.mokogitea/workflows'
*/
public function getWorkflowDir(): string;
/**
* Get the platform-specific metadata directory.
*
* @return string '.github' or '.mokogitea'
*/
public function getMetadataDir(): string;
/**
* Get the platform-specific metadata directory.
*
* @return string '.github' or '.mokogitea'
*/
public function getMetadataDir(): string;
/**
* Get the web URL for a repository (for use in markdown links, not API calls).
*
* @param string $org Organization name
* @param string $repo Repository name
* @return string e.g. 'https://github.com/org/repo' or 'https://git.mokoconsulting.tech/org/repo'
*/
public function getRepoWebUrl(string $org, string $repo): string;
/**
* Get the web URL for a repository (for use in markdown links, not API calls).
*
* @param string $org Organization name
* @param string $repo Repository name
* @return string e.g. 'https://github.com/org/repo' or 'https://git.mokoconsulting.tech/org/repo'
*/
public function getRepoWebUrl(string $org, string $repo): string;
/**
* Get the web URL for a pull request.
*
* @param string $org Organization name
* @param string $repo Repository name
* @param int $number PR number
* @return string e.g. 'https://github.com/org/repo/pull/1' or 'https://git.example.com/org/repo/pulls/1'
*/
public function getPullRequestWebUrl(string $org, string $repo, int $number): string;
/**
* Get the web URL for a pull request.
*
* @param string $org Organization name
* @param string $repo Repository name
* @param int $number PR number
* @return string e.g. 'https://github.com/org/repo/pull/1' or 'https://git.example.com/org/repo/pulls/1'
*/
public function getPullRequestWebUrl(string $org, string $repo, int $number): string;
/**
* Get the web URL for an issue.
*
* @param string $org Organization name
* @param string $repo Repository name
* @param int $number Issue number
* @return string
*/
public function getIssueWebUrl(string $org, string $repo, int $number): string;
/**
* Get the web URL for an issue.
*
* @param string $org Organization name
* @param string $repo Repository name
* @param int $number Issue number
* @return string
*/
public function getIssueWebUrl(string $org, string $repo, int $number): string;
/**
* Get the web URL for a branch.
*
* @param string $org Organization name
* @param string $repo Repository name
* @param string $branch Branch name
* @return string e.g. 'https://github.com/org/repo/tree/branch' or 'https://git.example.com/org/repo/src/branch/branch'
*/
public function getBranchWebUrl(string $org, string $repo, string $branch): string;
/**
* Get the web URL for a branch.
*
* @param string $org Organization name
* @param string $repo Repository name
* @param string $branch Branch name
* @return string e.g. 'https://github.com/org/repo/tree/branch' or 'https://git.example.com/org/repo/src/branch/branch'
*/
public function getBranchWebUrl(string $org, string $repo, string $branch): string;
/**
* Get the environment variable name for step summary output (CI-specific).
*
* @return string 'GITHUB_STEP_SUMMARY' or 'GITEA_STEP_SUMMARY'
*/
public function getStepSummaryEnvVar(): string;
/**
* Get the environment variable name for step summary output (CI-specific).
*
* @return string 'GITHUB_STEP_SUMMARY' or 'GITEA_STEP_SUMMARY'
*/
public function getStepSummaryEnvVar(): string;
// ──────────────────────────────────────────────
// Repository CRUD
// ──────────────────────────────────────────────
// ──────────────────────────────────────────────
// Repository CRUD
// ──────────────────────────────────────────────
/**
* List all repositories for an organization.
*
* @param string $org Organization name
* @param bool $skipArchived Whether to exclude archived repos
* @return array<int, array{name: string, full_name: string, archived: bool, private: bool}> Repository list
*/
public function listOrgRepos(string $org, bool $skipArchived = false): array;
/**
* List all repositories for an organization.
*
* @param string $org Organization name
* @param bool $skipArchived Whether to exclude archived repos
* @return array<int, array{name: string, full_name: string, archived: bool, private: bool}> Repository list
*/
public function listOrgRepos(string $org, bool $skipArchived = false): array;
/**
* Get a single repository's information.
*
* @param string $org Organization name
* @param string $repo Repository name
* @return array<string, mixed> Repository data from API
*/
public function getRepo(string $org, string $repo): array;
/**
* Get a single repository's information.
*
* @param string $org Organization name
* @param string $repo Repository name
* @return array<string, mixed> Repository data from API
*/
public function getRepo(string $org, string $repo): array;
/**
* Create a new repository in an organization.
*
* @param string $org Organization name
* @param string $name Repository name
* @param array<string, mixed> $options Repository options (description, private, auto_init, etc.)
* @return array<string, mixed> Created repository data
*/
public function createOrgRepo(string $org, string $name, array $options = []): array;
/**
* Create a new repository in an organization.
*
* @param string $org Organization name
* @param string $name Repository name
* @param array<string, mixed> $options Repository options (description, private, auto_init, etc.)
* @return array<string, mixed> Created repository data
*/
public function createOrgRepo(string $org, string $name, array $options = []): array;
/**
* Archive a repository (set to read-only).
*
* @param string $org Organization name
* @param string $repo Repository name
* @return array<string, mixed> Updated repository data
*/
public function archiveRepo(string $org, string $repo): array;
/**
* Archive a repository (set to read-only).
*
* @param string $org Organization name
* @param string $repo Repository name
* @return array<string, mixed> Updated repository data
*/
public function archiveRepo(string $org, string $repo): array;
/**
* Set repository topics/tags.
*
* @param string $org Organization name
* @param string $repo Repository name
* @param array<string> $topics List of topic strings
* @return void
*/
public function setRepoTopics(string $org, string $repo, array $topics): void;
/**
* Set repository topics/tags.
*
* @param string $org Organization name
* @param string $repo Repository name
* @param array<string> $topics List of topic strings
* @return void
*/
public function setRepoTopics(string $org, string $repo, array $topics): void;
/**
* Get repository topics/tags.
*
* @param string $org Organization name
* @param string $repo Repository name
* @return array<string> List of topic strings
*/
public function getRepoTopics(string $org, string $repo): array;
/**
* Get repository topics/tags.
*
* @param string $org Organization name
* @param string $repo Repository name
* @return array<string> List of topic strings
*/
public function getRepoTopics(string $org, string $repo): array;
// ──────────────────────────────────────────────
// File Contents
// ──────────────────────────────────────────────
// ──────────────────────────────────────────────
// Branches and Cloning
// ──────────────────────────────────────────────
/**
* Get file contents from a repository.
*
* @param string $org Organization name
* @param string $repo Repository name
* @param string $path File path within the repository
* @param string|null $ref Branch/tag/SHA reference (null = default branch)
* @return array{content: string, sha: string, size: int, encoding: string} File data (content is base64-encoded)
*/
public function getFileContents(string $org, string $repo, string $path, ?string $ref = null): array;
/**
* List all branches in a repository.
*
* @return array<mixed>
*/
public function listBranches(string $org, string $repo): array;
/**
* Create or update a file in a repository.
*
* @param string $org Organization name
* @param string $repo Repository name
* @param string $path File path
* @param string $content Raw file content (will be base64-encoded internally)
* @param string $message Commit message
* @param string|null $sha SHA of existing file (null = create new, string = update existing)
* @param string|null $branch Target branch (null = default branch)
* @return array<string, mixed> API response
*/
public function createOrUpdateFile(
string $org,
string $repo,
string $path,
string $content,
string $message,
?string $sha = null,
?string $branch = null
): array;
/**
* Get the clone URL for a repository.
*/
public function getCloneUrl(string $repo): string;
/**
* Delete a file from a repository.
*
* @param string $org Organization name
* @param string $repo Repository name
* @param string $path File path
* @param string $sha SHA of the file to delete
* @param string $message Commit message
* @param string|null $branch Target branch (null = default branch)
* @return array<string, mixed> API response
*/
public function deleteFile(
string $org,
string $repo,
string $path,
string $sha,
string $message,
?string $branch = null
): array;
/**
* Clone a repository to a local path.
*
* @param array<string, mixed> $options
*/
public function cloneRepo(string $repo, string $path, array $options = []): bool;
// ──────────────────────────────────────────────
// Pull Requests
// ──────────────────────────────────────────────
// ──────────────────────────────────────────────
// File Contents
// ──────────────────────────────────────────────
/**
* List pull requests for a repository.
*
* @param string $org Organization name
* @param string $repo Repository name
* @param array<string, mixed> $filters Filters (state, head, base, sort, direction)
* @return array<int, array<string, mixed>> Pull request list
*/
public function listPullRequests(string $org, string $repo, array $filters = []): array;
/**
* Get file contents from a repository.
*
* @param string $org Organization name
* @param string $repo Repository name
* @param string $path File path within the repository
* @param string|null $ref Branch/tag/SHA reference (null = default branch)
* @return array<string, mixed> File data (content is base64-encoded)
*/
public function getFileContents(string $org, string $repo, string $path, ?string $ref = null): array;
/**
* Create a pull request.
*
* @param string $org Organization name
* @param string $repo Repository name
* @param string $title PR title
* @param string $head Source branch
* @param string $base Target branch
* @param string $body PR description
* @param array<string, mixed> $options Additional options (labels, assignees, etc.)
* @return array<string, mixed> Created PR data
*/
public function createPullRequest(
string $org,
string $repo,
string $title,
string $head,
string $base,
string $body = '',
array $options = []
): array;
/**
* Create or update a file in a repository.
*
* @param string $org Organization name
* @param string $repo Repository name
* @param string $path File path
* @param string $content Raw file content (will be base64-encoded internally)
* @param string $message Commit message
* @param string|null $sha SHA of existing file (null = create new, string = update existing)
* @param string|null $branch Target branch (null = default branch)
* @return array<string, mixed> API response
*/
public function createOrUpdateFile(
string $org,
string $repo,
string $path,
string $content,
string $message,
?string $sha = null,
?string $branch = null
): array;
/**
* Update a pull request.
*
* @param string $org Organization name
* @param string $repo Repository name
* @param int $number PR number
* @param array<string, mixed> $data Fields to update (title, body, state, etc.)
* @return array<string, mixed> Updated PR data
*/
public function updatePullRequest(string $org, string $repo, int $number, array $data): array;
/**
* Delete a file from a repository.
*
* @param string $org Organization name
* @param string $repo Repository name
* @param string $path File path
* @param string $sha SHA of the file to delete
* @param string $message Commit message
* @param string|null $branch Target branch (null = default branch)
* @return array<string, mixed> API response
*/
public function deleteFile(
string $org,
string $repo,
string $path,
string $sha,
string $message,
?string $branch = null
): array;
// ──────────────────────────────────────────────
// Issues
// ──────────────────────────────────────────────
// ──────────────────────────────────────────────
// Pull Requests
// ──────────────────────────────────────────────
/**
* List issues for a repository.
*
* @param string $org Organization name
* @param string $repo Repository name
* @param array<string, mixed> $filters Filters (state, labels, assignee, etc.)
* @return array<int, array<string, mixed>> Issue list
*/
public function listIssues(string $org, string $repo, array $filters = []): array;
/**
* List pull requests for a repository.
*
* @param string $org Organization name
* @param string $repo Repository name
* @param array<string, mixed> $filters Filters (state, head, base, sort, direction)
* @return array<mixed> Pull request list
*/
public function listPullRequests(string $org, string $repo, array $filters = []): array;
/**
* Create an issue.
*
* @param string $org Organization name
* @param string $repo Repository name
* @param string $title Issue title
* @param string $body Issue body
* @param array<string, mixed> $options Additional options (labels, assignees, milestone)
* @return array<string, mixed> Created issue data
*/
public function createIssue(
string $org,
string $repo,
string $title,
string $body = '',
array $options = []
): array;
/**
* Create a pull request.
*
* @param string $org Organization name
* @param string $repo Repository name
* @param string $title PR title
* @param string $head Source branch
* @param string $base Target branch
* @param string $body PR description
* @param array<string, mixed> $options Additional options (labels, assignees, etc.)
* @return array<string, mixed> Created PR data
*/
public function createPullRequest(
string $org,
string $repo,
string $title,
string $head,
string $base,
string $body = '',
array $options = []
): array;
/**
* Add a comment to an issue or PR.
*
* @param string $org Organization name
* @param string $repo Repository name
* @param int $number Issue/PR number
* @param string $body Comment body
* @return array<string, mixed> Created comment data
*/
public function addIssueComment(string $org, string $repo, int $number, string $body): array;
/**
* Update a pull request.
*
* @param string $org Organization name
* @param string $repo Repository name
* @param int $number PR number
* @param array<string, mixed> $data Fields to update (title, body, state, etc.)
* @return array<string, mixed> Updated PR data
*/
public function updatePullRequest(string $org, string $repo, int $number, array $data): array;
/**
* Close an issue.
*
* @param string $org Organization name
* @param string $repo Repository name
* @param int $number Issue number
* @return array<string, mixed> Updated issue data
*/
public function closeIssue(string $org, string $repo, int $number): array;
// ──────────────────────────────────────────────
// Issues
// ──────────────────────────────────────────────
// ──────────────────────────────────────────────
// Labels
// ──────────────────────────────────────────────
/**
* List issues for a repository.
*
* @param string $org Organization name
* @param string $repo Repository name
* @param array<string, mixed> $filters Filters (state, labels, assignee, etc.)
* @return array<mixed> Issue list
*/
public function listIssues(string $org, string $repo, array $filters = []): array;
/**
* List labels for a repository.
*
* @param string $org Organization name
* @param string $repo Repository name
* @return array<int, array{name: string, color: string, description: string}> Label list
*/
public function listLabels(string $org, string $repo): array;
/**
* Create an issue.
*
* @param string $org Organization name
* @param string $repo Repository name
* @param string $title Issue title
* @param string $body Issue body
* @param array<string, mixed> $options Additional options (labels, assignees, milestone)
* @return array<string, mixed> Created issue data
*/
public function createIssue(
string $org,
string $repo,
string $title,
string $body = '',
array $options = []
): array;
/**
* Create a label.
*
* @param string $org Organization name
* @param string $repo Repository name
* @param string $name Label name
* @param string $color Hex color (without #)
* @param string $description Label description
* @return array<string, mixed> Created label data
*/
public function createLabel(string $org, string $repo, string $name, string $color, string $description = ''): array;
/**
* Add a comment to an issue or PR.
*
* @param string $org Organization name
* @param string $repo Repository name
* @param int $number Issue/PR number
* @param string $body Comment body
* @return array<string, mixed> Created comment data
*/
public function addIssueComment(string $org, string $repo, int $number, string $body): array;
/**
* Add labels to an issue or PR.
*
* @param string $org Organization name
* @param string $repo Repository name
* @param int $number Issue/PR number
* @param array<string> $labels Label names (GitHub) or label IDs (Gitea)
* @return array<string, mixed> API response
*/
public function addIssueLabels(string $org, string $repo, int $number, array $labels): array;
/**
* Close an issue.
*
* @param string $org Organization name
* @param string $repo Repository name
* @param int $number Issue number
* @return array<string, mixed> Updated issue data
*/
public function closeIssue(string $org, string $repo, int $number): array;
// ──────────────────────────────────────────────
// Branch Protection
// ──────────────────────────────────────────────
// ──────────────────────────────────────────────
// Labels
// ──────────────────────────────────────────────
/**
* Set branch protection rules.
*
* On GitHub this maps to rulesets; on Gitea to branch_protections.
*
* @param string $org Organization name
* @param string $repo Repository name
* @param string $branch Branch name or pattern
* @param array<string, mixed> $rules Protection rules (required_reviews, dismiss_stale, etc.)
* @return array<string, mixed> Created/updated protection data
*/
public function setBranchProtection(string $org, string $repo, string $branch, array $rules): array;
/**
* List labels for a repository.
*
* @param string $org Organization name
* @param string $repo Repository name
* @return array<mixed> Label list
*/
public function listLabels(string $org, string $repo): array;
/**
* List branch protection rules.
*
* @param string $org Organization name
* @param string $repo Repository name
* @return array<int, array<string, mixed>> Protection rules
*/
public function listBranchProtections(string $org, string $repo): array;
/**
* Create a label.
*
* @param string $org Organization name
* @param string $repo Repository name
* @param string $name Label name
* @param string $color Hex color (without #)
* @param string $description Label description
* @return array<string, mixed> Created label data
*/
public function createLabel(string $org, string $repo, string $name, string $color, string $description = ''): array;
// ──────────────────────────────────────────────
// Git Refs
// ──────────────────────────────────────────────
/**
* Add labels to an issue or PR.
*
* @param string $org Organization name
* @param string $repo Repository name
* @param int $number Issue/PR number
* @param array<string> $labels Label names (GitHub) or label IDs (Gitea)
* @return array<string, mixed> API response
*/
public function addIssueLabels(string $org, string $repo, int $number, array $labels): array;
/**
* Resolve a tag or branch name to a commit SHA.
*
* @param string $org Organization name
* @param string $repo Repository name
* @param string $ref Tag or branch name (e.g. 'v1.0.0', 'main')
* @return string Full commit SHA
*/
public function resolveRef(string $org, string $repo, string $ref): string;
// ──────────────────────────────────────────────
// Branch Protection
// ──────────────────────────────────────────────
/**
* Get the repository tree (recursive file listing).
*
* @param string $org Organization name
* @param string $repo Repository name
* @param string $ref Tree SHA or branch (e.g. 'HEAD', 'main')
* @param bool $recursive Whether to recurse into subdirectories
* @return array<int, array{path: string, type: string, sha: string}> Tree entries
*/
public function getTree(string $org, string $repo, string $ref = 'HEAD', bool $recursive = true): array;
/**
* Set branch protection rules.
*
* On GitHub this maps to rulesets; on Gitea to branch_protections.
*
* @param string $org Organization name
* @param string $repo Repository name
* @param string $branch Branch name or pattern
* @param array<string, mixed> $rules Protection rules (required_reviews, dismiss_stale, etc.)
* @return array<string, mixed> Created/updated protection data
*/
public function setBranchProtection(string $org, string $repo, string $branch, array $rules): array;
// ──────────────────────────────────────────────
// Pagination
// ──────────────────────────────────────────────
/**
* List branch protection rules.
*
* @param string $org Organization name
* @param string $repo Repository name
* @return array<mixed> Protection rules
*/
public function listBranchProtections(string $org, string $repo): array;
/**
* Paginate through all pages of a list endpoint.
*
* @param string $endpoint API endpoint path
* @param array<string, mixed> $params Query parameters
* @param int $perPage Items per page (platform default if 0)
* @return array<int, array<string, mixed>> All items across all pages
*/
public function paginateAll(string $endpoint, array $params = [], int $perPage = 100): array;
// ──────────────────────────────────────────────
// Git Refs
// ──────────────────────────────────────────────
// ──────────────────────────────────────────────
// Migration (Gitea-specific, no-op on GitHub)
// ──────────────────────────────────────────────
/**
* Resolve a tag or branch name to a commit SHA.
*
* @param string $org Organization name
* @param string $repo Repository name
* @param string $ref Tag or branch name (e.g. 'v1.0.0', 'main')
* @return string Full commit SHA
*/
public function resolveRef(string $org, string $repo, string $ref): string;
/**
* Migrate a repository from an external service.
*
* On Gitea, this calls POST /api/v1/repos/migrate.
* On GitHub, this is a no-op (throws UnsupportedOperationException).
*
* @param array<string, mixed> $options Migration options (clone_addr, service, auth_token, etc.)
* @return array<string, mixed> Migrated repository data
* @throws \RuntimeException If the platform does not support migration
*/
public function migrateRepository(array $options): array;
/**
* Get the repository tree (recursive file listing).
*
* @param string $org Organization name
* @param string $repo Repository name
* @param string $ref Tree SHA or branch (e.g. 'HEAD', 'main')
* @param bool $recursive Whether to recurse into subdirectories
* @return array<int, array{path: string, type: string, sha: string}> Tree entries
*/
public function getTree(string $org, string $repo, string $ref = 'HEAD', bool $recursive = true): array;
// ──────────────────────────────────────────────
// Low-level API access
// ──────────────────────────────────────────────
// ──────────────────────────────────────────────
// Pagination
// ──────────────────────────────────────────────
/**
* Get the underlying ApiClient instance.
*
* Escape hatch for operations not covered by this interface.
* Prefer adding new interface methods over using this directly.
*
* @return ApiClient The wrapped API client
*/
public function getApiClient(): ApiClient;
/**
* Paginate through all pages of a list endpoint.
*
* @param string $endpoint API endpoint path
* @param array<string, mixed> $params Query parameters
* @param int $perPage Items per page (platform default if 0)
* @return array<mixed> All items across all pages
*/
public function paginateAll(string $endpoint, array $params = [], int $perPage = 100): array;
// ──────────────────────────────────────────────
// Migration (Gitea-specific, no-op on GitHub)
// ──────────────────────────────────────────────
/**
* Migrate a repository from an external service.
*
* On Gitea, this calls POST /api/v1/repos/migrate.
* On GitHub, this is a no-op (throws UnsupportedOperationException).
*
* @param array<string, mixed> $options Migration options (clone_addr, service, auth_token, etc.)
* @return array<string, mixed> Migrated repository data
* @throws \RuntimeException If the platform does not support migration
*/
public function migrateRepository(array $options): array;
// ──────────────────────────────────────────────
// Low-level API access
// ──────────────────────────────────────────────
/**
* Get the underlying ApiClient instance.
*
* Escape hatch for operations not covered by this interface.
* Prefer adding new interface methods over using this directly.
*
* @return ApiClient The wrapped API client
*/
public function getApiClient(): ApiClient;
}
+2 -2
View File
@@ -247,7 +247,7 @@ class InputValidator
// Remove dangerous shell characters
$dangerousChars = [';', '&', '|', '`', '$', '(', ')', '<', '>', "\n", "\r"];
$sanitized = str_replace($dangerousChars, '', $input);
return trim($sanitized);
}
@@ -262,7 +262,7 @@ class InputValidator
// Remove SQL injection patterns
$dangerousPatterns = ["'", '"', '--', '/*', '*/', 'xp_', 'sp_'];
$sanitized = str_replace($dangerousPatterns, '', $input);
return trim($sanitized);
}
+18 -18
View File
@@ -32,12 +32,12 @@ declare(strict_types=1);
* $metrics = new MetricsCollector('my_service');
* $metrics->increment('requests_total');
* $metrics->setGauge('cpu_usage', 45.5);
*
*
* // Timing operations
* $timer = $metrics->startTimer('operation');
* // ... do work ...
* $timer->stop();
*
*
* // Export for monitoring
* echo $metrics->exportPrometheus();
* ```
@@ -79,13 +79,13 @@ class MetricsTimer
{
$duration = microtime(true) - $this->startTime;
$this->collector->observe($this->metricName . '_duration_seconds', $duration, $this->labels);
if ($success) {
$this->collector->increment($this->metricName . '_success_total', 1, $this->labels);
} else {
$this->collector->increment($this->metricName . '_failure_total', 1, $this->labels);
}
return $duration;
}
}
@@ -178,13 +178,13 @@ class MetricsCollector
if (empty($labels)) {
return $metricName;
}
ksort($labels);
$labelPairs = [];
foreach ($labels as $key => $value) {
$labelPairs[] = sprintf('%s="%s"', $key, $value);
}
return sprintf('%s{%s}', $metricName, implode(',', $labelPairs));
}
@@ -219,11 +219,11 @@ class MetricsCollector
public function getHistogramStats(string $metricName): array
{
$values = $this->histograms[$metricName] ?? [];
if (empty($values)) {
return ['count' => 0, 'min' => 0.0, 'max' => 0.0, 'avg' => 0.0, 'sum' => 0.0];
}
$sum = array_sum($values);
return [
'count' => count($values),
@@ -243,23 +243,23 @@ class MetricsCollector
{
$lines = [];
$now = new DateTime('now', new DateTimeZone('UTC'));
$lines[] = sprintf('# Metrics for %s', $this->serviceName);
$lines[] = sprintf('# Generated at %s', $now->format('c'));
$lines[] = '';
// Export counters
foreach ($this->counters as $key => $value) {
$lines[] = sprintf('# TYPE %s counter', $this->stripLabels($key));
$lines[] = sprintf('%s %d', $key, $value);
}
// Export gauges
foreach ($this->gauges as $key => $value) {
$lines[] = sprintf('# TYPE %s gauge', $this->stripLabels($key));
$lines[] = sprintf('%s %s', $key, $value);
}
// Export histograms
foreach ($this->histograms as $key => $values) {
if (!empty($values)) {
@@ -272,12 +272,12 @@ class MetricsCollector
$lines[] = sprintf('%s_avg %s', $key, $stats['avg']);
}
}
// Add uptime
$uptime = microtime(true) - $this->startTime;
$lines[] = '# TYPE process_uptime_seconds gauge';
$lines[] = sprintf('process_uptime_seconds %.2f', $uptime);
return implode("\n", $lines);
}
@@ -301,7 +301,7 @@ class MetricsCollector
echo "\n" . str_repeat('=', 60) . "\n";
echo "Metrics Summary for {$this->serviceName}\n";
echo str_repeat('=', 60) . "\n";
if (!empty($this->counters)) {
echo "\nCounters:\n";
ksort($this->counters);
@@ -309,7 +309,7 @@ class MetricsCollector
echo " {$key}: {$value}\n";
}
}
if (!empty($this->gauges)) {
echo "\nGauges:\n";
ksort($this->gauges);
@@ -317,7 +317,7 @@ class MetricsCollector
echo " {$key}: {$value}\n";
}
}
if (!empty($this->histograms)) {
echo "\nHistograms:\n";
$keys = array_keys($this->histograms);
@@ -331,7 +331,7 @@ class MetricsCollector
echo sprintf(" Avg: %.4f\n", $stats['avg']);
}
}
$uptime = microtime(true) - $this->startTime;
echo sprintf("\nUptime: %.2f seconds\n", $uptime);
echo str_repeat('=', 60) . "\n\n";
+490 -461
View File
@@ -1,4 +1,5 @@
<?php
/* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
*
* This file is part of a Moko Consulting project.
@@ -33,468 +34,496 @@ use RuntimeException;
* - Workflow dir: .mokogitea/workflows
*
* @package MokoStandards\Enterprise
* @version 04.06.10
* @since 04.06.10
* @see GitPlatformAdapter
*/
class MokoGiteaAdapter implements GitPlatformAdapter
{
private ApiClient $apiClient;
private string $baseUrl;
public function __construct(ApiClient $apiClient, string $baseUrl = 'https://git.mokoconsulting.tech/api/v1')
{
$this->apiClient = $apiClient;
$this->baseUrl = rtrim($baseUrl, '/');
}
// ──────────────────────────────────────────────
// Identity
// ──────────────────────────────────────────────
public function getPlatformName(): string
{
return 'gitea';
}
public function getBaseUrl(): string
{
return $this->baseUrl;
}
public function getWorkflowDir(): string
{
return '.mokogitea/workflows';
}
public function getMetadataDir(): string
{
return '.mokogitea';
}
public function getRepoWebUrl(string $org, string $repo): string
{
// Derive web URL from API base URL by stripping '/api/v1'
$webBase = preg_replace('#/api/v1$#', '', $this->baseUrl);
return "{$webBase}/{$org}/{$repo}";
}
public function getPullRequestWebUrl(string $org, string $repo, int $number): string
{
// Gitea uses /pulls/ (not /pull/) for web UI
$webBase = preg_replace('#/api/v1$#', '', $this->baseUrl);
return "{$webBase}/{$org}/{$repo}/pulls/{$number}";
}
public function getIssueWebUrl(string $org, string $repo, int $number): string
{
$webBase = preg_replace('#/api/v1$#', '', $this->baseUrl);
return "{$webBase}/{$org}/{$repo}/issues/{$number}";
}
public function listBranches(string $org, string $repo): array
{
return $this->paginateAll("/repos/{$org}/{$repo}/branches");
}
public function getBranchWebUrl(string $org, string $repo, string $branch): string
{
// Gitea uses /src/branch/ (not /tree/) for web UI
$webBase = preg_replace('#/api/v1$#', '', $this->baseUrl);
return "{$webBase}/{$org}/{$repo}/src/branch/{$branch}";
}
public function getStepSummaryEnvVar(): string
{
return 'GITEA_STEP_SUMMARY';
}
// ──────────────────────────────────────────────
// Repository CRUD
// ──────────────────────────────────────────────
public function listOrgRepos(string $org, bool $skipArchived = false): array
{
$all = $this->paginateAll("/orgs/{$org}/repos");
$repos = [];
foreach ($all as $repo) {
if ($skipArchived && ($repo['archived'] ?? false)) {
continue;
}
$repos[] = [
'name' => $repo['name'],
'full_name' => $repo['full_name'],
'archived' => $repo['archived'] ?? false,
'private' => $repo['private'] ?? false,
];
}
return $repos;
}
public function getRepo(string $org, string $repo): array
{
return $this->apiClient->get("/repos/{$org}/{$repo}");
}
public function createOrgRepo(string $org, string $name, array $options = []): array
{
$data = array_merge([
'name' => $name,
'auto_init' => true,
], $options);
return $this->apiClient->post("/orgs/{$org}/repos", $data);
}
public function archiveRepo(string $org, string $repo): array
{
// Gitea uses PATCH with archived flag, same as GitHub
return $this->apiClient->patch("/repos/{$org}/{$repo}", [
'archived' => true,
]);
}
public function setRepoTopics(string $org, string $repo, array $topics): void
{
// Gitea uses {"topics": [...]} not {"names": [...]}
$this->apiClient->put("/repos/{$org}/{$repo}/topics", [
'topics' => $topics,
]);
}
public function getRepoTopics(string $org, string $repo): array
{
$response = $this->apiClient->get("/repos/{$org}/{$repo}/topics");
return $response['topics'] ?? [];
}
// ──────────────────────────────────────────────
// File Contents
// ──────────────────────────────────────────────
public function getFileContents(string $org, string $repo, string $path, ?string $ref = null): array
{
$params = [];
if ($ref !== null) {
$params['ref'] = $ref;
}
return $this->apiClient->get("/repos/{$org}/{$repo}/contents/{$path}", $params);
}
public function createOrUpdateFile(
string $org,
string $repo,
string $path,
string $content,
string $message,
?string $sha = null,
?string $branch = null
): array {
$data = [
'message' => $message,
'content' => base64_encode($content),
];
if ($branch !== null) {
$data['branch'] = $branch;
}
if ($sha !== null) {
// Update existing file — Gitea uses PUT with SHA
$data['sha'] = $sha;
return $this->apiClient->put("/repos/{$org}/{$repo}/contents/{$path}", $data);
}
// Create new file — Gitea uses POST
return $this->apiClient->post("/repos/{$org}/{$repo}/contents/{$path}", $data);
}
public function deleteFile(
string $org,
string $repo,
string $path,
string $sha,
string $message,
?string $branch = null
): array {
// Gitea's delete uses the same endpoint but with DELETE method
// ApiClient::delete() doesn't support a body, so we use the raw approach
// For now, this matches GitHubAdapter's limitation
return $this->apiClient->delete("/repos/{$org}/{$repo}/contents/{$path}");
}
// ──────────────────────────────────────────────
// Pull Requests
// ──────────────────────────────────────────────
public function listPullRequests(string $org, string $repo, array $filters = []): array
{
return $this->apiClient->get("/repos/{$org}/{$repo}/pulls", $filters);
}
public function createPullRequest(
string $org,
string $repo,
string $title,
string $head,
string $base,
string $body = '',
array $options = []
): array {
$data = array_merge([
'title' => $title,
'head' => $head,
'base' => $base,
'body' => $body,
], $options);
return $this->apiClient->post("/repos/{$org}/{$repo}/pulls", $data);
}
public function updatePullRequest(string $org, string $repo, int $number, array $data): array
{
return $this->apiClient->patch("/repos/{$org}/{$repo}/pulls/{$number}", $data);
}
// ──────────────────────────────────────────────
// Issues
// ──────────────────────────────────────────────
public function listIssues(string $org, string $repo, array $filters = []): array
{
return $this->apiClient->get("/repos/{$org}/{$repo}/issues", $filters);
}
public function createIssue(
string $org,
string $repo,
string $title,
string $body = '',
array $options = []
): array {
// Gitea expects label IDs (int64), not names. Resolve if needed.
if (!empty($options['labels']) && is_string($options['labels'][0] ?? null)) {
$labelNames = $options['labels'];
$existing = $this->listLabels($org, $repo);
$nameToId = [];
foreach ($existing as $label) {
$nameToId[$label['name']] = $label['id'];
}
$options['labels'] = [];
foreach ($labelNames as $name) {
if (isset($nameToId[$name])) {
$options['labels'][] = $nameToId[$name];
}
}
}
$data = array_merge([
'title' => $title,
'body' => $body,
], $options);
return $this->apiClient->post("/repos/{$org}/{$repo}/issues", $data);
}
public function addIssueComment(string $org, string $repo, int $number, string $body): array
{
return $this->apiClient->post("/repos/{$org}/{$repo}/issues/{$number}/comments", [
'body' => $body,
]);
}
public function closeIssue(string $org, string $repo, int $number): array
{
return $this->apiClient->patch("/repos/{$org}/{$repo}/issues/{$number}", [
'state' => 'closed',
]);
}
// ──────────────────────────────────────────────
// Labels
// ──────────────────────────────────────────────
public function listLabels(string $org, string $repo): array
{
return $this->paginateAll("/repos/{$org}/{$repo}/labels");
}
public function createLabel(string $org, string $repo, string $name, string $color, string $description = ''): array
{
// Gitea expects color with # prefix
$color = ltrim($color, '#');
return $this->apiClient->post("/repos/{$org}/{$repo}/labels", [
'name' => $name,
'color' => '#' . $color,
'description' => $description,
]);
}
public function addIssueLabels(string $org, string $repo, int $number, array $labels): array
{
// Gitea requires label IDs, not names. Resolve names to IDs first.
$allLabels = $this->listLabels($org, $repo);
$labelMap = [];
foreach ($allLabels as $label) {
$labelMap[$label['name']] = $label['id'];
}
$labelIds = [];
foreach ($labels as $label) {
if (is_int($label)) {
$labelIds[] = $label;
} elseif (isset($labelMap[$label])) {
$labelIds[] = $labelMap[$label];
}
}
if (empty($labelIds)) {
return [];
}
return $this->apiClient->post("/repos/{$org}/{$repo}/issues/{$number}/labels", [
'labels' => $labelIds,
]);
}
// ──────────────────────────────────────────────
// Branch Protection
// ──────────────────────────────────────────────
public function setBranchProtection(string $org, string $repo, string $branch, array $rules): array
{
// Gitea uses a flat branch protection API
$protection = [
'branch_name' => $branch,
'enable_push' => true,
'enable_push_whitelist' => false,
'enable_merge_whitelist' => false,
'enable_status_check' => $rules['required_status_checks'] ?? false,
'enable_approvals_whitelist' => false,
'required_approvals' => $rules['required_reviews'] ?? 0,
'dismiss_stale_approvals' => $rules['dismiss_stale'] ?? false,
'block_on_rejected_reviews' => $rules['block_on_rejected'] ?? true,
'block_on_outdated_branch' => $rules['block_on_outdated'] ?? false,
'block_on_official_review_requests' => false,
];
// Check if protection already exists for this branch
try {
$existing = $this->apiClient->get("/repos/{$org}/{$repo}/branch_protections/{$branch}");
if (!empty($existing)) {
return $this->apiClient->patch("/repos/{$org}/{$repo}/branch_protections/{$branch}", $protection);
}
} catch (Exception $e) {
$this->apiClient->resetCircuitBreaker();
}
return $this->apiClient->post("/repos/{$org}/{$repo}/branch_protections", $protection);
}
public function listBranchProtections(string $org, string $repo): array
{
try {
return $this->apiClient->get("/repos/{$org}/{$repo}/branch_protections");
} catch (Exception $e) {
return [];
}
}
// ──────────────────────────────────────────────
// Git Refs
// ──────────────────────────────────────────────
public function resolveRef(string $org, string $repo, string $ref): string
{
// Try as a tag first
try {
$tag = $this->apiClient->get("/repos/{$org}/{$repo}/git/tags/{$ref}");
// Gitea tag objects have a 'commit' field with the SHA
if (isset($tag['commit']['sha'])) {
return $tag['commit']['sha'];
}
return $tag['id'] ?? $tag['sha'] ?? '';
} catch (Exception $e) {
$this->apiClient->resetCircuitBreaker();
}
// Try as a branch
try {
$branch = $this->apiClient->get("/repos/{$org}/{$repo}/branches/{$ref}");
return $branch['commit']['id'] ?? '';
} catch (Exception $e) {
$this->apiClient->resetCircuitBreaker();
}
// Last resort: try git/refs endpoint
$refData = $this->apiClient->get("/repos/{$org}/{$repo}/git/refs/tags/{$ref}");
return $refData['object']['sha'] ?? '';
}
public function getTree(string $org, string $repo, string $ref = 'HEAD', bool $recursive = true): array
{
$params = $recursive ? ['recursive' => 'true'] : [];
$response = $this->apiClient->get("/repos/{$org}/{$repo}/git/trees/{$ref}", $params);
return $response['tree'] ?? [];
}
// ──────────────────────────────────────────────
// Pagination
// ──────────────────────────────────────────────
public function paginateAll(string $endpoint, array $params = [], int $perPage = 50): array
{
$all = [];
$page = 1;
// Gitea uses 'limit' instead of 'per_page'
$params['limit'] = $perPage;
while (true) {
$params['page'] = $page;
$response = $this->apiClient->get($endpoint, $params);
if (empty($response)) {
break;
}
$all = array_merge($all, $response);
// If we got fewer results than the limit, we've reached the end
if (count($response) < $perPage) {
break;
}
$page++;
}
return $all;
}
// ──────────────────────────────────────────────
// Migration
// ──────────────────────────────────────────────
public function migrateRepository(array $options): array
{
// Gitea's built-in migration endpoint
$data = array_merge([
'service' => 'github',
'issues' => true,
'labels' => true,
'milestones' => true,
'releases' => true,
'wiki' => false,
], $options);
return $this->apiClient->post('/repos/migrate', $data);
}
// ──────────────────────────────────────────────
// Low-level
// ──────────────────────────────────────────────
public function getApiClient(): ApiClient
{
return $this->apiClient;
}
/** @var ApiClient HTTP client for Gitea API calls. */
private ApiClient $apiClient;
/** @var string Base URL for Gitea API (e.g. https://git.mokoconsulting.tech/api/v1). */
private string $baseUrl;
/**
* @param ApiClient $apiClient Configured API client
* @param string $baseUrl Gitea API base URL
*/
public function __construct(ApiClient $apiClient, string $baseUrl = 'https://git.mokoconsulting.tech/api/v1')
{
$this->apiClient = $apiClient;
$this->baseUrl = rtrim($baseUrl, '/');
}
// ──────────────────────────────────────────────
// Identity
// ──────────────────────────────────────────────
public function getPlatformName(): string
{
return 'gitea';
}
public function getBaseUrl(): string
{
return $this->baseUrl;
}
public function getWorkflowDir(): string
{
return '.mokogitea/workflows';
}
public function getMetadataDir(): string
{
return '.mokogitea';
}
public function getRepoWebUrl(string $org, string $repo): string
{
// Derive web URL from API base URL by stripping '/api/v1'
$webBase = preg_replace('#/api/v1$#', '', $this->baseUrl);
return "{$webBase}/{$org}/{$repo}";
}
public function getPullRequestWebUrl(string $org, string $repo, int $number): string
{
// Gitea uses /pulls/ (not /pull/) for web UI
$webBase = preg_replace('#/api/v1$#', '', $this->baseUrl);
return "{$webBase}/{$org}/{$repo}/pulls/{$number}";
}
public function getIssueWebUrl(string $org, string $repo, int $number): string
{
$webBase = preg_replace('#/api/v1$#', '', $this->baseUrl);
return "{$webBase}/{$org}/{$repo}/issues/{$number}";
}
public function listBranches(string $org, string $repo): array
{
return $this->paginateAll("/repos/{$org}/{$repo}/branches");
}
public function getBranchWebUrl(string $org, string $repo, string $branch): string
{
// Gitea uses /src/branch/ (not /tree/) for web UI
$webBase = preg_replace('#/api/v1$#', '', $this->baseUrl);
return "{$webBase}/{$org}/{$repo}/src/branch/{$branch}";
}
public function getStepSummaryEnvVar(): string
{
return 'GITEA_STEP_SUMMARY';
}
// ──────────────────────────────────────────────
// Repository CRUD
// ──────────────────────────────────────────────
public function listOrgRepos(string $org, bool $skipArchived = false): array
{
$all = $this->paginateAll("/orgs/{$org}/repos");
$repos = [];
foreach ($all as $repo) {
if ($skipArchived && ($repo['archived'] ?? false)) {
continue;
}
$repos[] = [
'name' => $repo['name'],
'full_name' => $repo['full_name'],
'archived' => $repo['archived'] ?? false,
'private' => $repo['private'] ?? false,
];
}
return $repos;
}
public function getRepo(string $org, string $repo): array
{
return $this->apiClient->get("/repos/{$org}/{$repo}");
}
public function createOrgRepo(string $org, string $name, array $options = []): array
{
$data = array_merge([
'name' => $name,
'auto_init' => true,
], $options);
return $this->apiClient->post("/orgs/{$org}/repos", $data);
}
public function archiveRepo(string $org, string $repo): array
{
// Gitea uses PATCH with archived flag, same as GitHub
return $this->apiClient->patch("/repos/{$org}/{$repo}", [
'archived' => true,
]);
}
public function setRepoTopics(string $org, string $repo, array $topics): void
{
// Gitea uses {"topics": [...]} not {"names": [...]}
$this->apiClient->put("/repos/{$org}/{$repo}/topics", [
'topics' => $topics,
]);
}
public function getRepoTopics(string $org, string $repo): array
{
$response = $this->apiClient->get("/repos/{$org}/{$repo}/topics");
return $response['topics'] ?? [];
}
// ──────────────────────────────────────────────
// File Contents
// ──────────────────────────────────────────────
public function getFileContents(string $org, string $repo, string $path, ?string $ref = null): array
{
$params = [];
if ($ref !== null) {
$params['ref'] = $ref;
}
return $this->apiClient->get("/repos/{$org}/{$repo}/contents/{$path}", $params);
}
public function createOrUpdateFile(
string $org,
string $repo,
string $path,
string $content,
string $message,
?string $sha = null,
?string $branch = null
): array {
$data = [
'message' => $message,
'content' => base64_encode($content),
];
if ($branch !== null) {
$data['branch'] = $branch;
}
if ($sha !== null) {
// Update existing file — Gitea uses PUT with SHA
$data['sha'] = $sha;
return $this->apiClient->put("/repos/{$org}/{$repo}/contents/{$path}", $data);
}
// Create new file — Gitea uses POST
return $this->apiClient->post("/repos/{$org}/{$repo}/contents/{$path}", $data);
}
public function deleteFile(
string $org,
string $repo,
string $path,
string $sha,
string $message,
?string $branch = null
): array {
// Gitea's delete uses the same endpoint but with DELETE method
// ApiClient::delete() doesn't support a body, so we use the raw approach
// For now, this matches GitHubAdapter's limitation
return $this->apiClient->delete("/repos/{$org}/{$repo}/contents/{$path}");
}
// ──────────────────────────────────────────────
// Pull Requests
// ──────────────────────────────────────────────
public function listPullRequests(string $org, string $repo, array $filters = []): array
{
return $this->apiClient->get("/repos/{$org}/{$repo}/pulls", $filters);
}
public function createPullRequest(
string $org,
string $repo,
string $title,
string $head,
string $base,
string $body = '',
array $options = []
): array {
$data = array_merge([
'title' => $title,
'head' => $head,
'base' => $base,
'body' => $body,
], $options);
return $this->apiClient->post("/repos/{$org}/{$repo}/pulls", $data);
}
public function updatePullRequest(string $org, string $repo, int $number, array $data): array
{
return $this->apiClient->patch("/repos/{$org}/{$repo}/pulls/{$number}", $data);
}
// ──────────────────────────────────────────────
// Issues
// ──────────────────────────────────────────────
public function listIssues(string $org, string $repo, array $filters = []): array
{
return $this->apiClient->get("/repos/{$org}/{$repo}/issues", $filters);
}
public function createIssue(
string $org,
string $repo,
string $title,
string $body = '',
array $options = []
): array {
// Gitea expects label IDs (int64), not names. Resolve if needed.
if (!empty($options['labels']) && is_string($options['labels'][0] ?? null)) {
$labelNames = $options['labels'];
$existing = $this->listLabels($org, $repo);
$nameToId = [];
foreach ($existing as $label) {
$nameToId[$label['name']] = $label['id'];
}
$options['labels'] = [];
foreach ($labelNames as $name) {
if (isset($nameToId[$name])) {
$options['labels'][] = $nameToId[$name];
}
}
}
$data = array_merge([
'title' => $title,
'body' => $body,
], $options);
return $this->apiClient->post("/repos/{$org}/{$repo}/issues", $data);
}
public function addIssueComment(string $org, string $repo, int $number, string $body): array
{
return $this->apiClient->post("/repos/{$org}/{$repo}/issues/{$number}/comments", [
'body' => $body,
]);
}
public function closeIssue(string $org, string $repo, int $number): array
{
return $this->apiClient->patch("/repos/{$org}/{$repo}/issues/{$number}", [
'state' => 'closed',
]);
}
// ──────────────────────────────────────────────
// Labels
// ──────────────────────────────────────────────
public function listLabels(string $org, string $repo): array
{
return $this->paginateAll("/repos/{$org}/{$repo}/labels");
}
public function createLabel(string $org, string $repo, string $name, string $color, string $description = ''): array
{
// Gitea expects color with # prefix
$color = ltrim($color, '#');
return $this->apiClient->post("/repos/{$org}/{$repo}/labels", [
'name' => $name,
'color' => '#' . $color,
'description' => $description,
]);
}
public function addIssueLabels(string $org, string $repo, int $number, array $labels): array
{
// Gitea requires label IDs, not names. Resolve names to IDs first.
$allLabels = $this->listLabels($org, $repo);
$labelMap = [];
foreach ($allLabels as $label) {
$labelMap[$label['name']] = $label['id'];
}
$labelIds = [];
foreach ($labels as $label) {
if (is_int($label)) {
$labelIds[] = $label;
} elseif (isset($labelMap[$label])) {
$labelIds[] = $labelMap[$label];
}
}
if (empty($labelIds)) {
return [];
}
return $this->apiClient->post("/repos/{$org}/{$repo}/issues/{$number}/labels", [
'labels' => $labelIds,
]);
}
// ──────────────────────────────────────────────
// Branch Protection
// ──────────────────────────────────────────────
public function setBranchProtection(string $org, string $repo, string $branch, array $rules): array
{
// Gitea uses a flat branch protection API
$protection = [
'branch_name' => $branch,
'enable_push' => true,
'enable_push_whitelist' => false,
'enable_merge_whitelist' => false,
'enable_status_check' => $rules['required_status_checks'] ?? false,
'enable_approvals_whitelist' => false,
'required_approvals' => $rules['required_reviews'] ?? 0,
'dismiss_stale_approvals' => $rules['dismiss_stale'] ?? false,
'block_on_rejected_reviews' => $rules['block_on_rejected'] ?? true,
'block_on_outdated_branch' => $rules['block_on_outdated'] ?? false,
'block_on_official_review_requests' => false,
];
// Check if protection already exists for this branch
try {
$existing = $this->apiClient->get("/repos/{$org}/{$repo}/branch_protections/{$branch}");
if (!empty($existing)) {
return $this->apiClient->patch("/repos/{$org}/{$repo}/branch_protections/{$branch}", $protection);
}
} catch (Exception $e) {
$this->apiClient->resetCircuitBreaker();
}
return $this->apiClient->post("/repos/{$org}/{$repo}/branch_protections", $protection);
}
public function listBranchProtections(string $org, string $repo): array
{
try {
return $this->apiClient->get("/repos/{$org}/{$repo}/branch_protections");
} catch (Exception $e) {
return [];
}
}
// ──────────────────────────────────────────────
// Git Refs
// ──────────────────────────────────────────────
public function resolveRef(string $org, string $repo, string $ref): string
{
// Try as a tag first
try {
$tag = $this->apiClient->get("/repos/{$org}/{$repo}/git/tags/{$ref}");
// Gitea tag objects have a 'commit' field with the SHA
if (isset($tag['commit']['sha'])) {
return $tag['commit']['sha'];
}
return $tag['id'] ?? $tag['sha'] ?? '';
} catch (Exception $e) {
$this->apiClient->resetCircuitBreaker();
}
// Try as a branch
try {
$branch = $this->apiClient->get("/repos/{$org}/{$repo}/branches/{$ref}");
return $branch['commit']['id'] ?? '';
} catch (Exception $e) {
$this->apiClient->resetCircuitBreaker();
}
// Last resort: try git/refs endpoint
$refData = $this->apiClient->get("/repos/{$org}/{$repo}/git/refs/tags/{$ref}");
return $refData['object']['sha'] ?? '';
}
public function getTree(string $org, string $repo, string $ref = 'HEAD', bool $recursive = true): array
{
$params = $recursive ? ['recursive' => 'true'] : [];
$response = $this->apiClient->get("/repos/{$org}/{$repo}/git/trees/{$ref}", $params);
return $response['tree'] ?? [];
}
// ──────────────────────────────────────────────
// Pagination
// ──────────────────────────────────────────────
public function paginateAll(string $endpoint, array $params = [], int $perPage = 50): array
{
$all = [];
$page = 1;
// Gitea uses 'limit' instead of 'per_page'
$params['limit'] = $perPage;
while (true) {
$params['page'] = $page;
$response = $this->apiClient->get($endpoint, $params);
if (empty($response)) {
break;
}
$all = array_merge($all, $response);
// If we got fewer results than the limit, we've reached the end
if (count($response) < $perPage) {
break;
}
$page++;
}
return array_values($all);
}
// ──────────────────────────────────────────────
// Migration
// ──────────────────────────────────────────────
public function migrateRepository(array $options): array
{
// Gitea's built-in migration endpoint
$data = array_merge([
'service' => 'github',
'issues' => true,
'labels' => true,
'milestones' => true,
'releases' => true,
'wiki' => false,
], $options);
return $this->apiClient->post('/repos/migrate', $data);
}
// ──────────────────────────────────────────────
// Low-level
// ──────────────────────────────────────────────
public function getApiClient(): ApiClient
{
return $this->apiClient;
}
public function getCloneUrl(string $repo): string
{
$base = str_replace('/api/v1', '', $this->baseUrl);
return "{$base}/{$repo}.git";
}
public function cloneRepo(string $repo, string $path, array $options = []): bool
{
$url = $this->getCloneUrl($repo);
$depth = $options['depth'] ?? 0;
$depthFlag = $depth > 0 ? " --depth {$depth}" : '';
$result = 0;
passthru(
'git clone' . $depthFlag . ' --quiet '
. escapeshellarg($url) . ' ' . escapeshellarg($path),
$result
);
return $result === 0;
}
}
+1
View File
@@ -1,4 +1,5 @@
<?php
/**
* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
*
+223 -222
View File
@@ -1,4 +1,5 @@
<?php
/* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
*
* This file is part of a Moko Consulting project.
@@ -31,261 +32,261 @@ use ZipArchive;
*/
class PackageBuilder
{
// ── Public API ────────────────────────────────────────────────────────────
// ── Public API ────────────────────────────────────────────────────────────
/**
* Build a generic release package.
*
* Copies src/, admin/, site/, top-level *.xml files, LICENSE* files, and
* CHANGELOG.md into a build staging directory, then archives them as
* dist/<packageName>-<version>.zip.
*
* @param string $repoRoot Absolute path to the repository root.
* @param string $packageName Base name for the archive.
* @param string $version Version string (e.g. "1.2.0").
* @param bool $dryRun When true, preview without writing.
* @return string Path to the created archive (or would-create path in dry-run).
* @throws \RuntimeException When the zip archive cannot be opened.
*/
public static function buildGeneric(
string $repoRoot,
string $packageName,
string $version,
bool $dryRun = false
): string {
$buildDir = $repoRoot . '/build';
$packageDir = $buildDir . '/' . $packageName;
$distDir = $repoRoot . '/dist';
$archivePath = $distDir . '/' . $packageName . '-' . $version . '.zip';
/**
* Build a generic release package.
*
* Copies src/, admin/, site/, top-level *.xml files, LICENSE* files, and
* CHANGELOG.md into a build staging directory, then archives them as
* dist/<packageName>-<version>.zip.
*
* @param string $repoRoot Absolute path to the repository root.
* @param string $packageName Base name for the archive.
* @param string $version Version string (e.g. "1.2.0").
* @param bool $dryRun When true, preview without writing.
* @return string Path to the created archive (or would-create path in dry-run).
* @throws \RuntimeException When the zip archive cannot be opened.
*/
public static function buildGeneric(
string $repoRoot,
string $packageName,
string $version,
bool $dryRun = false
): string {
$buildDir = $repoRoot . '/build';
$packageDir = $buildDir . '/' . $packageName;
$distDir = $repoRoot . '/dist';
$archivePath = $distDir . '/' . $packageName . '-' . $version . '.zip';
if ($dryRun) {
return $archivePath;
}
if ($dryRun) {
return $archivePath;
}
self::cleanDir($buildDir);
self::cleanDir($distDir);
mkdir($packageDir, 0755, true);
mkdir($distDir, 0755, true);
self::cleanDir($buildDir);
self::cleanDir($distDir);
mkdir($packageDir, 0755, true);
mkdir($distDir, 0755, true);
foreach (['src', 'admin', 'site'] as $dir) {
if (is_dir($repoRoot . '/' . $dir)) {
self::copyDirectory($repoRoot . '/' . $dir, $packageDir . '/' . $dir);
}
}
foreach (['src', 'admin', 'site'] as $dir) {
if (is_dir($repoRoot . '/' . $dir)) {
self::copyDirectory($repoRoot . '/' . $dir, $packageDir . '/' . $dir);
}
}
foreach (glob($repoRoot . '/*.xml') ?: [] as $xml) {
copy($xml, $packageDir . '/' . basename($xml));
}
foreach (glob($repoRoot . '/*.xml') ?: [] as $xml) {
copy($xml, $packageDir . '/' . basename($xml));
}
foreach (glob($repoRoot . '/LICENSE*') ?: [] as $lic) {
copy($lic, $packageDir . '/' . basename($lic));
}
foreach (glob($repoRoot . '/LICENSE*') ?: [] as $lic) {
copy($lic, $packageDir . '/' . basename($lic));
}
if (is_file($repoRoot . '/CHANGELOG.md')) {
copy($repoRoot . '/CHANGELOG.md', $packageDir . '/CHANGELOG.md');
}
if (is_file($repoRoot . '/CHANGELOG.md')) {
copy($repoRoot . '/CHANGELOG.md', $packageDir . '/CHANGELOG.md');
}
self::zip($packageDir, $archivePath, $packageName);
self::zip($packageDir, $archivePath, $packageName);
return $archivePath;
}
return $archivePath;
}
/**
* Build a Dolibarr module release package.
*
* Copies everything under src/ into a build staging directory and archives
* it as dist/<MODULE_NAME>_<VERSION>.zip.
*
* @param string $repoRoot Absolute path to the repository root.
* @param string $moduleName Module name (used in archive filename).
* @param string $version Version string.
* @param bool $dryRun When true, preview without writing.
* @return string Path to the created archive (or would-create path in dry-run).
* @throws \RuntimeException When src/ is absent or archive creation fails.
*/
public static function buildDolibarr(
string $repoRoot,
string $moduleName,
string $version,
bool $dryRun = false
): string {
$srcDir = $repoRoot . '/src';
$buildDir = $repoRoot . '/build';
$distDir = $repoRoot . '/dist';
$archivePath = $distDir . '/' . $moduleName . '_' . $version . '.zip';
/**
* Build a Dolibarr module release package.
*
* Copies everything under src/ into a build staging directory and archives
* it as dist/<MODULE_NAME>_<VERSION>.zip.
*
* @param string $repoRoot Absolute path to the repository root.
* @param string $moduleName Module name (used in archive filename).
* @param string $version Version string.
* @param bool $dryRun When true, preview without writing.
* @return string Path to the created archive (or would-create path in dry-run).
* @throws \RuntimeException When src/ is absent or archive creation fails.
*/
public static function buildDolibarr(
string $repoRoot,
string $moduleName,
string $version,
bool $dryRun = false
): string {
$srcDir = $repoRoot . '/src';
$buildDir = $repoRoot . '/build';
$distDir = $repoRoot . '/dist';
$archivePath = $distDir . '/' . $moduleName . '_' . $version . '.zip';
if (!is_dir($srcDir)) {
throw new \RuntimeException("src/ directory not found at {$srcDir}");
}
if (!is_dir($srcDir)) {
throw new \RuntimeException("src/ directory not found at {$srcDir}");
}
if ($dryRun) {
return $archivePath;
}
if ($dryRun) {
return $archivePath;
}
self::cleanDir($buildDir);
self::cleanDir($distDir);
mkdir($buildDir, 0755, true);
mkdir($distDir, 0755, true);
self::cleanDir($buildDir);
self::cleanDir($distDir);
mkdir($buildDir, 0755, true);
mkdir($distDir, 0755, true);
self::copyDirectory($srcDir, $buildDir);
self::zip($buildDir, $archivePath, '');
self::copyDirectory($srcDir, $buildDir);
self::zip($buildDir, $archivePath, '');
return $archivePath;
}
return $archivePath;
}
/**
* Build a Joomla component release package.
*
* Copies site/, admin/, optional media/ and language/ directories, and the
* component XML manifest into a build staging directory, then archives as
* dist/<componentName>_<version>.zip.
*
* @param string $repoRoot Absolute path to the repository root.
* @param string $componentName Component name, e.g. "com_example".
* @param string $version Version string.
* @param bool $dryRun When true, preview without writing.
* @return string Path to the created archive (or would-create path in dry-run).
* @throws \RuntimeException When required directories are absent or archiving fails.
*/
public static function buildJoomla(
string $repoRoot,
string $componentName,
string $version,
bool $dryRun = false
): string {
$buildDir = $repoRoot . '/build';
$distDir = $repoRoot . '/dist';
$archivePath = $distDir . '/' . $componentName . '_' . $version . '.zip';
/**
* Build a Joomla component release package.
*
* Copies site/, admin/, optional media/ and language/ directories, and the
* component XML manifest into a build staging directory, then archives as
* dist/<componentName>_<version>.zip.
*
* @param string $repoRoot Absolute path to the repository root.
* @param string $componentName Component name, e.g. "com_example".
* @param string $version Version string.
* @param bool $dryRun When true, preview without writing.
* @return string Path to the created archive (or would-create path in dry-run).
* @throws \RuntimeException When required directories are absent or archiving fails.
*/
public static function buildJoomla(
string $repoRoot,
string $componentName,
string $version,
bool $dryRun = false
): string {
$buildDir = $repoRoot . '/build';
$distDir = $repoRoot . '/dist';
$archivePath = $distDir . '/' . $componentName . '_' . $version . '.zip';
if ($dryRun) {
return $archivePath;
}
if ($dryRun) {
return $archivePath;
}
self::cleanDir($buildDir);
self::cleanDir($distDir);
mkdir($buildDir, 0755, true);
mkdir($distDir, 0755, true);
self::cleanDir($buildDir);
self::cleanDir($distDir);
mkdir($buildDir, 0755, true);
mkdir($distDir, 0755, true);
foreach (['site', 'admin'] as $required) {
$src = $repoRoot . '/' . $required;
if (!is_dir($src)) {
throw new \RuntimeException("Required directory '{$required}/' not found at {$src}");
}
self::copyDirectory($src, $buildDir . '/' . $required);
}
foreach (['site', 'admin'] as $required) {
$src = $repoRoot . '/' . $required;
if (!is_dir($src)) {
throw new \RuntimeException("Required directory '{$required}/' not found at {$src}");
}
self::copyDirectory($src, $buildDir . '/' . $required);
}
foreach (['media', 'language'] as $optional) {
$src = $repoRoot . '/' . $optional;
if (is_dir($src)) {
self::copyDirectory($src, $buildDir . '/' . $optional);
}
}
foreach (['media', 'language'] as $optional) {
$src = $repoRoot . '/' . $optional;
if (is_dir($src)) {
self::copyDirectory($src, $buildDir . '/' . $optional);
}
}
$manifest = $repoRoot . '/' . $componentName . '.xml';
if (is_file($manifest)) {
copy($manifest, $buildDir . '/' . $componentName . '.xml');
}
$manifest = $repoRoot . '/' . $componentName . '.xml';
if (is_file($manifest)) {
copy($manifest, $buildDir . '/' . $componentName . '.xml');
}
self::zip($buildDir, $archivePath, '');
self::zip($buildDir, $archivePath, '');
return $archivePath;
}
return $archivePath;
}
// ── Private helpers ───────────────────────────────────────────────────────
// ── Private helpers ───────────────────────────────────────────────────────
/**
* Remove a directory if it exists, then recreate it.
*
* @param string $dir Directory path to clean.
*/
private static function cleanDir(string $dir): void
{
if (is_dir($dir)) {
self::deleteDirectory($dir);
}
}
/**
* Remove a directory if it exists, then recreate it.
*
* @param string $dir Directory path to clean.
*/
private static function cleanDir(string $dir): void
{
if (is_dir($dir)) {
self::deleteDirectory($dir);
}
}
/**
* Recursively copy a source directory to a destination.
*
* @param string $src Source directory path.
* @param string $dst Destination directory path.
*/
private static function copyDirectory(string $src, string $dst): void
{
if (!is_dir($dst)) {
mkdir($dst, 0755, true);
}
/**
* Recursively copy a source directory to a destination.
*
* @param string $src Source directory path.
* @param string $dst Destination directory path.
*/
private static function copyDirectory(string $src, string $dst): void
{
if (!is_dir($dst)) {
mkdir($dst, 0755, true);
}
$iter = new RecursiveIteratorIterator(
new RecursiveDirectoryIterator($src, RecursiveDirectoryIterator::SKIP_DOTS),
RecursiveIteratorIterator::SELF_FIRST
);
$iter = new RecursiveIteratorIterator(
new RecursiveDirectoryIterator($src, RecursiveDirectoryIterator::SKIP_DOTS),
RecursiveIteratorIterator::SELF_FIRST
);
foreach ($iter as $item) {
/** @var SplFileInfo $item */
$target = $dst . '/' . $iter->getSubPathname();
if ($item->isDir()) {
if (!is_dir($target)) {
mkdir($target, 0755, true);
}
} else {
copy($item->getPathname(), $target);
}
}
}
foreach ($iter as $item) {
/** @var SplFileInfo $item */
$target = $dst . '/' . $iter->getSubPathname();
if ($item->isDir()) {
if (!is_dir($target)) {
mkdir($target, 0755, true);
}
} else {
copy($item->getPathname(), $target);
}
}
}
/**
* Create a ZIP archive from a source directory tree.
*
* @param string $sourceDir Directory to archive.
* @param string $archivePath Destination archive path.
* @param string $prefix Path prefix inside the archive (empty string for no prefix).
* @throws \RuntimeException When the archive cannot be opened for writing.
*/
private static function zip(string $sourceDir, string $archivePath, string $prefix): void
{
$zip = new ZipArchive();
if ($zip->open($archivePath, ZipArchive::CREATE | ZipArchive::OVERWRITE) !== true) {
throw new \RuntimeException("Cannot create archive: {$archivePath}");
}
/**
* Create a ZIP archive from a source directory tree.
*
* @param string $sourceDir Directory to archive.
* @param string $archivePath Destination archive path.
* @param string $prefix Path prefix inside the archive (empty string for no prefix).
* @throws \RuntimeException When the archive cannot be opened for writing.
*/
private static function zip(string $sourceDir, string $archivePath, string $prefix): void
{
$zip = new ZipArchive();
if ($zip->open($archivePath, ZipArchive::CREATE | ZipArchive::OVERWRITE) !== true) {
throw new \RuntimeException("Cannot create archive: {$archivePath}");
}
$iter = new RecursiveIteratorIterator(
new RecursiveDirectoryIterator($sourceDir, RecursiveDirectoryIterator::SKIP_DOTS),
RecursiveIteratorIterator::SELF_FIRST
);
$iter = new RecursiveIteratorIterator(
new RecursiveDirectoryIterator($sourceDir, RecursiveDirectoryIterator::SKIP_DOTS),
RecursiveIteratorIterator::SELF_FIRST
);
foreach ($iter as $item) {
/** @var SplFileInfo $item */
$rel = $iter->getSubPathname();
$name = $prefix !== '' ? $prefix . '/' . $rel : $rel;
if ($item->isFile()) {
$zip->addFile($item->getPathname(), $name);
} elseif ($item->isDir()) {
$zip->addEmptyDir($name);
}
}
foreach ($iter as $item) {
/** @var SplFileInfo $item */
$rel = $iter->getSubPathname();
$name = $prefix !== '' ? $prefix . '/' . $rel : $rel;
if ($item->isFile()) {
$zip->addFile($item->getPathname(), $name);
} elseif ($item->isDir()) {
$zip->addEmptyDir($name);
}
}
$zip->close();
}
$zip->close();
}
/**
* Recursively delete a directory and all its contents.
*
* @param string $dir Directory path.
*/
private static function deleteDirectory(string $dir): void
{
if (!is_dir($dir)) {
return;
}
/**
* Recursively delete a directory and all its contents.
*
* @param string $dir Directory path.
*/
private static function deleteDirectory(string $dir): void
{
if (!is_dir($dir)) {
return;
}
$items = array_diff((array) scandir($dir), ['.', '..']);
foreach ($items as $item) {
$path = $dir . '/' . $item;
is_dir($path) ? self::deleteDirectory($path) : unlink($path);
}
$items = array_diff((array) scandir($dir), ['.', '..']);
foreach ($items as $item) {
$path = $dir . '/' . $item;
is_dir($path) ? self::deleteDirectory($path) : unlink($path);
}
rmdir($dir);
}
rmdir($dir);
}
}
+137 -136
View File
@@ -1,4 +1,5 @@
<?php
/* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
*
* This file is part of a Moko Consulting project.
@@ -37,156 +38,156 @@ use RuntimeException;
*/
class PlatformAdapterFactory
{
/**
* Create a GitPlatformAdapter based on configuration.
*
* @param Config $config Configuration instance
* @param string|null $platformOverride Force a specific platform ('github' or 'gitea')
* @return GitPlatformAdapter The constructed adapter
* @throws RuntimeException If the platform is not supported or token is missing
*/
public static function create(Config $config, ?string $platformOverride = null): GitPlatformAdapter
{
$platform = $platformOverride ?? $config->getString('platform', 'gitea');
/**
* Create a GitPlatformAdapter based on configuration.
*
* @param Config $config Configuration instance
* @param string|null $platformOverride Force a specific platform ('github' or 'gitea')
* @return GitPlatformAdapter The constructed adapter
* @throws RuntimeException If the platform is not supported or token is missing
*/
public static function create(Config $config, ?string $platformOverride = null): GitPlatformAdapter
{
$platform = $platformOverride ?? $config->getString('platform', 'gitea');
return match ($platform) {
'github' => self::createGitHubAdapter($config),
'gitea' => self::createMokoGiteaAdapter($config),
default => throw new RuntimeException("Unsupported git platform: {$platform}. Use 'github' or 'gitea'."),
};
}
return match ($platform) {
'github' => self::createGitHubAdapter($config),
'gitea' => self::createMokoGiteaAdapter($config),
default => throw new RuntimeException("Unsupported git platform: {$platform}. Use 'github' or 'gitea'."),
};
}
/**
* Create a GitHubAdapter with configured ApiClient.
*
* @param Config $config Configuration instance
* @return GitHubAdapter Configured GitHub adapter
* @throws RuntimeException If GitHub token is not available
*/
private static function createGitHubAdapter(Config $config): GitHubAdapter
{
$token = $config->getString('github.token', '');
if (empty($token)) {
throw new RuntimeException(
'GitHub token not found. Set GH_TOKEN, GITHUB_TOKEN, or authenticate with `gh auth login`.'
);
}
/**
* Create a GitHubAdapter with configured ApiClient.
*
* @param Config $config Configuration instance
* @return GitHubAdapter Configured GitHub adapter
* @throws RuntimeException If GitHub token is not available
*/
private static function createGitHubAdapter(Config $config): GitHubAdapter
{
$token = $config->getString('github.token', '');
if (empty($token)) {
throw new RuntimeException(
'GitHub token not found. Set GH_TOKEN, GITHUB_TOKEN, or authenticate with `gh auth login`.'
);
}
$apiClient = new ApiClient(
baseUrl: 'https://git.mokoconsulting.tech/api/v1',
authToken: $token,
maxRequestsPerHour: $config->getInt('github.rate_limit', 5000),
maxRetries: $config->getInt('github.max_retries', 3),
authScheme: 'Bearer'
);
$apiClient = new ApiClient(
baseUrl: 'https://git.mokoconsulting.tech/api/v1',
authToken: $token,
maxRequestsPerHour: $config->getInt('github.rate_limit', 5000),
maxRetries: $config->getInt('github.max_retries', 3),
authScheme: 'Bearer'
);
return new GitHubAdapter($apiClient);
}
return new GitHubAdapter($apiClient);
}
/**
* Create a MokoGiteaAdapter with configured ApiClient.
*
* @param Config $config Configuration instance
* @return MokoGiteaAdapter Configured Gitea adapter
* @throws RuntimeException If Gitea token is not available
*/
private static function createMokoGiteaAdapter(Config $config): MokoGiteaAdapter
{
$token = $config->getString('gitea.token', '');
if (empty($token)) {
throw new RuntimeException(
'Gitea token not found. Set GA_TOKEN environment variable.'
);
}
/**
* Create a MokoGiteaAdapter with configured ApiClient.
*
* @param Config $config Configuration instance
* @return MokoGiteaAdapter Configured Gitea adapter
* @throws RuntimeException If Gitea token is not available
*/
private static function createMokoGiteaAdapter(Config $config): MokoGiteaAdapter
{
$token = $config->getString('gitea.token', '');
if (empty($token)) {
throw new RuntimeException(
'Gitea token not found. Set GA_TOKEN environment variable.'
);
}
$giteaUrl = $config->getString('gitea.url', 'https://git.mokoconsulting.tech');
$apiBaseUrl = rtrim($giteaUrl, '/') . '/api/v1';
$giteaUrl = $config->getString('gitea.url', 'https://git.mokoconsulting.tech');
$apiBaseUrl = rtrim($giteaUrl, '/') . '/api/v1';
$apiClient = new ApiClient(
baseUrl: $apiBaseUrl,
authToken: $token,
maxRequestsPerHour: $config->getInt('gitea.rate_limit', 5000),
maxRetries: $config->getInt('gitea.max_retries', 3),
authScheme: 'token'
);
$apiClient = new ApiClient(
baseUrl: $apiBaseUrl,
authToken: $token,
maxRequestsPerHour: $config->getInt('gitea.rate_limit', 5000),
maxRetries: $config->getInt('gitea.max_retries', 3),
authScheme: 'token'
);
return new MokoGiteaAdapter($apiClient, $apiBaseUrl);
}
return new MokoGiteaAdapter($apiClient, $apiBaseUrl);
}
/**
* Create adapters for both platforms (useful during migration).
*
* @param Config $config Configuration instance
* @return array{github: GitHubAdapter, gitea: MokoGiteaAdapter} Both adapters
* @throws RuntimeException If either token is missing
*/
public static function createBoth(Config $config): array
{
return [
'github' => self::createGitHubAdapter($config),
'gitea' => self::createMokoGiteaAdapter($config),
];
}
/**
* Create adapters for both platforms (useful during migration).
*
* @param Config $config Configuration instance
* @return array{github: GitHubAdapter, gitea: MokoGiteaAdapter} Both adapters
* @throws RuntimeException If either token is missing
*/
public static function createBoth(Config $config): array
{
return [
'github' => self::createGitHubAdapter($config),
'gitea' => self::createMokoGiteaAdapter($config),
];
}
/**
* Sync a file between Gitea (primary) and GitHub (mirror) for a given repo.
*
* Reads the file from Gitea and pushes it to GitHub, ensuring both platforms
* serve identical content. Commonly used for updates.xml sync after releases.
*
* @param Config $config Configuration instance
* @param string $repo Repository name
* @param string $branch Branch to sync (default: 'main')
* @param string $filePath Path to the file (default: 'updates.xml')
* @return bool True if sync succeeded or file was already identical
* @throws RuntimeException If either platform is unreachable
*/
public static function syncUpdatesBetweenPlatforms(
Config $config,
string $repo,
string $branch = 'main',
string $filePath = 'updates.xml'
): bool {
$adapters = self::createBoth($config);
$giteaOrg = $config->getString('gitea.organization', 'mokoconsulting-tech');
$githubOrg = $config->getString('github.organization', 'mokoconsulting-tech');
/**
* Sync a file between Gitea (primary) and GitHub (mirror) for a given repo.
*
* Reads the file from Gitea and pushes it to GitHub, ensuring both platforms
* serve identical content. Commonly used for updates.xml sync after releases.
*
* @param Config $config Configuration instance
* @param string $repo Repository name
* @param string $branch Branch to sync (default: 'main')
* @param string $filePath Path to the file (default: 'updates.xml')
* @return bool True if sync succeeded or file was already identical
* @throws RuntimeException If either platform is unreachable
*/
public static function syncUpdatesBetweenPlatforms(
Config $config,
string $repo,
string $branch = 'main',
string $filePath = 'updates.xml'
): bool {
$adapters = self::createBoth($config);
$giteaOrg = $config->getString('gitea.organization', 'mokoconsulting-tech');
$githubOrg = $config->getString('github.organization', 'mokoconsulting-tech');
// Read from Gitea (primary)
try {
$giteaFile = $adapters['gitea']->getFileContents($giteaOrg, $repo, $filePath, $branch);
} catch (\Exception $e) {
throw new RuntimeException("Failed to read {$filePath} from Gitea ({$giteaOrg}/{$repo}): " . $e->getMessage());
}
// Read from Gitea (primary)
try {
$giteaFile = $adapters['gitea']->getFileContents($giteaOrg, $repo, $filePath, $branch);
} catch (\Exception $e) {
throw new RuntimeException("Failed to read {$filePath} from Gitea ({$giteaOrg}/{$repo}): " . $e->getMessage());
}
$giteaContent = base64_decode($giteaFile['content'] ?? '');
if (empty($giteaContent)) {
return false;
}
$giteaContent = base64_decode($giteaFile['content'] ?? '');
if (empty($giteaContent)) {
return false;
}
// Read from GitHub (mirror) to check if update is needed
$githubSha = null;
try {
$githubFile = $adapters['github']->getFileContents($githubOrg, $repo, $filePath, $branch);
$githubContent = base64_decode($githubFile['content'] ?? '');
$githubSha = $githubFile['sha'] ?? null;
// Read from GitHub (mirror) to check if update is needed
$githubSha = null;
try {
$githubFile = $adapters['github']->getFileContents($githubOrg, $repo, $filePath, $branch);
$githubContent = base64_decode($githubFile['content'] ?? '');
$githubSha = $githubFile['sha'] ?? null;
if ($githubContent === $giteaContent) {
return true;
}
} catch (\Exception $e) {
$adapters['github']->getApiClient()->resetCircuitBreaker();
}
if ($githubContent === $giteaContent) {
return true;
}
} catch (\Exception $e) {
$adapters['github']->getApiClient()->resetCircuitBreaker();
}
$adapters['github']->createOrUpdateFile(
$githubOrg,
$repo,
$filePath,
$giteaContent,
"chore(sync): sync {$filePath} from Gitea primary",
$githubSha,
$branch
);
$adapters['github']->createOrUpdateFile(
$githubOrg,
$repo,
$filePath,
$giteaContent,
"chore(sync): sync {$filePath} from Gitea primary",
$githubSha,
$branch
);
return true;
}
return true;
}
}
+1 -1
View File
@@ -20,7 +20,7 @@ namespace MokoEnterprise;
/**
* Plugin Factory - Factory for creating and managing plugin instances
*
*
* Provides convenient methods for plugin instantiation with dependency injection
*
* @package MokoStandards\Enterprise
+6 -4
View File
@@ -32,7 +32,7 @@ use MokoEnterprise\Plugins\McpServerPlugin;
/**
* Plugin Registry - Central registry for all project type plugins
*
*
* Manages plugin discovery, registration, and lifecycle
*
* @package MokoStandards\Enterprise
@@ -107,7 +107,7 @@ class PluginRegistry
}
self::$pluginClasses[$projectType] = $pluginClass;
// Clear cached instance if exists
if (isset(self::$plugins[$projectType])) {
unset(self::$plugins[$projectType]);
@@ -253,8 +253,10 @@ class PluginRegistry
if ($plugin !== null) {
$bestPractices = $plugin->getBestPractices();
foreach ($bestPractices as $practice) {
if (stripos($practice['title'] ?? '', $feature) !== false ||
stripos($practice['description'] ?? '', $feature) !== false) {
if (
stripos($practice['title'] ?? '', $feature) !== false ||
stripos($practice['description'] ?? '', $feature) !== false
) {
$matches[] = $projectType;
break;
}
+78 -41
View File
@@ -1,4 +1,5 @@
<?php
/**
* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
*
@@ -22,7 +23,7 @@ use MokoEnterprise\AbstractProjectPlugin;
/**
* API/Microservices Project Plugin
*
*
* Provides validation, metrics, and management capabilities for
* API and microservices projects (REST, GraphQL, gRPC).
*/
@@ -56,7 +57,7 @@ class ApiPlugin extends AbstractProjectPlugin
// Check for API documentation
if (!$this->hasAPIDocumentation($projectPath, $apiType)) {
$warnings[] = 'No API documentation found (OpenAPI, GraphQL schema, etc.)';
$errors[] = 'No API documentation found (OpenAPI, GraphQL schema, etc.)';
}
// Check for proper error handling
@@ -361,8 +362,10 @@ class ApiPlugin extends AbstractProjectPlugin
private function detectAPIType(string $projectPath): string
{
// GraphQL
if ($this->fileExists($projectPath, 'schema.graphql') ||
$this->fileExists($projectPath, '*.graphql')) {
if (
$this->fileExists($projectPath, 'schema.graphql') ||
$this->fileExists($projectPath, '*.graphql')
) {
return 'graphql';
}
@@ -372,10 +375,12 @@ class ApiPlugin extends AbstractProjectPlugin
}
// REST (OpenAPI/Swagger)
if ($this->fileExists($projectPath, 'openapi.yaml') ||
if (
$this->fileExists($projectPath, 'openapi.yaml') ||
$this->fileExists($projectPath, 'openapi.json') ||
$this->fileExists($projectPath, 'swagger.yaml') ||
$this->fileExists($projectPath, 'swagger.json')) {
$this->fileExists($projectPath, 'swagger.json')
) {
return 'rest';
}
@@ -385,8 +390,10 @@ class ApiPlugin extends AbstractProjectPlugin
if (is_file($file)) {
$content = @file_get_contents($file);
if ($content) {
if (preg_match('/@(Get|Post|Put|Delete|Patch)\(/', $content) ||
preg_match('/(get|post|put|delete|patch)\s*\([\'"]/', $content)) {
if (
preg_match('/@(Get|Post|Put|Delete|Patch)\(/', $content) ||
preg_match('/(get|post|put|delete|patch)\s*\([\'"]/', $content)
) {
return 'rest';
}
}
@@ -452,15 +459,17 @@ class ApiPlugin extends AbstractProjectPlugin
private function hasErrorHandling(string $projectPath): bool
{
$files = $this->findFiles($projectPath, '**/*.{js,ts,py}');
foreach (array_slice($files, 0, 10) as $file) {
if (is_file($file)) {
$content = @file_get_contents($file);
if ($content && (
if (
$content && (
strpos($content, 'errorHandler') !== false ||
strpos($content, 'error_handler') !== false ||
preg_match('/class\s+\w*Error/', $content)
)) {
)
) {
return true;
}
}
@@ -475,18 +484,20 @@ class ApiPlugin extends AbstractProjectPlugin
private function hasAuthentication(string $projectPath): bool
{
$files = $this->findFiles($projectPath, '**/*.{js,ts,py,java,go,php}');
foreach (array_slice($files, 0, 15) as $file) {
if (is_file($file)) {
$content = @file_get_contents($file);
if ($content && (
if (
$content && (
stripos($content, 'jwt') !== false ||
stripos($content, 'oauth') !== false ||
stripos($content, 'passport') !== false ||
stripos($content, 'authenticate') !== false ||
stripos($content, 'api_key') !== false ||
stripos($content, 'bearer') !== false
)) {
)
) {
return true;
}
}
@@ -501,16 +512,18 @@ class ApiPlugin extends AbstractProjectPlugin
private function hasAuthorization(string $projectPath): bool
{
$files = $this->findFiles($projectPath, '**/*.{js,ts,py,java,go,php}');
foreach (array_slice($files, 0, 10) as $file) {
if (is_file($file)) {
$content = @file_get_contents($file);
if ($content && (
if (
$content && (
stripos($content, 'authorize') !== false ||
stripos($content, 'permission') !== false ||
stripos($content, 'role') !== false ||
stripos($content, 'acl') !== false
)) {
)
) {
return true;
}
}
@@ -525,15 +538,17 @@ class ApiPlugin extends AbstractProjectPlugin
private function hasRateLimiting(string $projectPath): bool
{
$files = $this->findFiles($projectPath, '**/*.{js,ts,py,java,go,php}');
foreach (array_slice($files, 0, 10) as $file) {
if (is_file($file)) {
$content = @file_get_contents($file);
if ($content && (
if (
$content && (
stripos($content, 'rate_limit') !== false ||
stripos($content, 'rateLimit') !== false ||
stripos($content, 'throttle') !== false
)) {
)
) {
return true;
}
}
@@ -548,16 +563,18 @@ class ApiPlugin extends AbstractProjectPlugin
private function hasLogging(string $projectPath): bool
{
$files = $this->findFiles($projectPath, '**/*.{js,ts,py,java,go,php}');
foreach (array_slice($files, 0, 10) as $file) {
if (is_file($file)) {
$content = @file_get_contents($file);
if ($content && (
if (
$content && (
stripos($content, 'logger') !== false ||
stripos($content, 'winston') !== false ||
stripos($content, 'logging') !== false ||
stripos($content, 'log.') !== false
)) {
)
) {
return true;
}
}
@@ -572,16 +589,18 @@ class ApiPlugin extends AbstractProjectPlugin
private function hasMonitoring(string $projectPath): bool
{
$files = $this->findFiles($projectPath, '**/*.{js,ts,py,java,go,php}');
foreach (array_slice($files, 0, 10) as $file) {
if (is_file($file)) {
$content = @file_get_contents($file);
if ($content && (
if (
$content && (
stripos($content, 'prometheus') !== false ||
stripos($content, 'metrics') !== false ||
stripos($content, 'monitoring') !== false ||
stripos($content, 'newrelic') !== false
)) {
)
) {
return true;
}
}
@@ -596,15 +615,17 @@ class ApiPlugin extends AbstractProjectPlugin
private function hasCaching(string $projectPath): bool
{
$files = $this->findFiles($projectPath, '**/*.{js,ts,py,java,go,php}');
foreach (array_slice($files, 0, 10) as $file) {
if (is_file($file)) {
$content = @file_get_contents($file);
if ($content && (
if (
$content && (
stripos($content, 'redis') !== false ||
stripos($content, 'cache') !== false ||
stripos($content, 'memcached') !== false
)) {
)
) {
return true;
}
}
@@ -619,16 +640,18 @@ class ApiPlugin extends AbstractProjectPlugin
private function hasInputValidation(string $projectPath): bool
{
$files = $this->findFiles($projectPath, '**/*.{js,ts,py,java,go,php}');
foreach (array_slice($files, 0, 10) as $file) {
if (is_file($file)) {
$content = @file_get_contents($file);
if ($content && (
if (
$content && (
stripos($content, 'validate') !== false ||
stripos($content, 'validator') !== false ||
stripos($content, 'joi') !== false ||
stripos($content, 'yup') !== false
)) {
)
) {
return true;
}
}
@@ -643,7 +666,7 @@ class ApiPlugin extends AbstractProjectPlugin
private function hasCORSConfig(string $projectPath): bool
{
$files = $this->findFiles($projectPath, '**/*.{js,ts,py,java,go,php}');
foreach (array_slice($files, 0, 10) as $file) {
if (is_file($file)) {
$content = @file_get_contents($file);
@@ -779,20 +802,34 @@ class ApiPlugin extends AbstractProjectPlugin
$packageData['dependencies'] ?? [],
$packageData['devDependencies'] ?? []
);
if (isset($deps['express'])) return 'Express';
if (isset($deps['fastify'])) return 'Fastify';
if (isset($deps['@nestjs/core'])) return 'NestJS';
if (isset($deps['koa'])) return 'Koa';
if (isset($deps['express'])) {
return 'Express';
}
if (isset($deps['fastify'])) {
return 'Fastify';
}
if (isset($deps['@nestjs/core'])) {
return 'NestJS';
}
if (isset($deps['koa'])) {
return 'Koa';
}
}
}
if ($language === 'Python') {
$requirements = $this->readFile($projectPath, 'requirements.txt');
if ($requirements) {
if (stripos($requirements, 'fastapi') !== false) return 'FastAPI';
if (stripos($requirements, 'flask') !== false) return 'Flask';
if (stripos($requirements, 'django') !== false) return 'Django';
if (stripos($requirements, 'fastapi') !== false) {
return 'FastAPI';
}
if (stripos($requirements, 'flask') !== false) {
return 'Flask';
}
if (stripos($requirements, 'django') !== false) {
return 'Django';
}
}
}
+11 -8
View File
@@ -1,4 +1,5 @@
<?php
/**
* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
*
@@ -22,7 +23,7 @@ use MokoEnterprise\AbstractProjectPlugin;
/**
* Documentation Project Plugin
*
*
* Provides validation, metrics, and management capabilities for
* documentation-focused projects (Sphinx, MkDocs, Docusaurus, etc.).
*/
@@ -101,9 +102,11 @@ class DocumentationPlugin extends AbstractProjectPlugin
}
// Check for images directory
if (!$this->fileExists($projectPath, 'images') &&
if (
!$this->fileExists($projectPath, 'images') &&
!$this->fileExists($projectPath, 'assets') &&
!$this->fileExists($projectPath, 'static')) {
!$this->fileExists($projectPath, 'static')
) {
$warnings[] = 'No images/assets directory found';
}
@@ -369,7 +372,7 @@ class DocumentationPlugin extends AbstractProjectPlugin
private function hasIndexPage(string $projectPath, string $docType): bool
{
$indexFiles = ['index.md', 'index.rst', 'index.html', 'README.md', 'docs/index.md'];
foreach ($indexFiles as $file) {
if ($this->fileExists($projectPath, $file)) {
return true;
@@ -409,7 +412,7 @@ class DocumentationPlugin extends AbstractProjectPlugin
{
// Check for TOC files
$tocFiles = ['SUMMARY.md', 'toc.yml', 'toc.rst', 'sidebar.js', 'sidebars.js'];
foreach ($tocFiles as $file) {
if ($this->fileExists($projectPath, $file)) {
return true;
@@ -434,7 +437,7 @@ class DocumentationPlugin extends AbstractProjectPlugin
{
$count = 0;
$extensions = ['png', 'jpg', 'jpeg', 'gif', 'svg', 'webp'];
foreach ($extensions as $ext) {
$count += $this->countFiles($projectPath, "**/*.{$ext}");
}
@@ -461,7 +464,7 @@ class DocumentationPlugin extends AbstractProjectPlugin
{
$pattern = in_array($docType, ['sphinx', 'rst']) ? '**/*.rst' : '**/*.md';
$files = $this->findFiles($projectPath, $pattern);
$totalWords = 0;
foreach ($files as $file) {
if (is_file($file)) {
@@ -612,7 +615,7 @@ class DocumentationPlugin extends AbstractProjectPlugin
private function hasBuildOutput(string $projectPath, string $docType): bool
{
$buildDirs = ['_build', 'build', 'site', '.docusaurus', '_site'];
foreach ($buildDirs as $dir) {
if ($this->fileExists($projectPath, $dir)) {
return true;
+6 -3
View File
@@ -1,4 +1,5 @@
<?php
/**
* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
*
@@ -22,7 +23,7 @@ use MokoEnterprise\AbstractProjectPlugin;
/**
* Dolibarr Module Plugin
*
*
* Provides validation, metrics, and management capabilities for Dolibarr
* modules and custom developments.
*/
@@ -93,8 +94,10 @@ class DolibarrPlugin extends AbstractProjectPlugin
}
// Check for documentation
if (!$this->fileExists($projectPath, 'README.md') &&
!$this->fileExists($projectPath, 'doc')) {
if (
!$this->fileExists($projectPath, 'README.md') &&
!$this->fileExists($projectPath, 'doc')
) {
$warnings[] = 'No documentation found';
}
+29 -16
View File
@@ -1,4 +1,5 @@
<?php
/**
* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
*
@@ -22,7 +23,7 @@ use MokoEnterprise\AbstractProjectPlugin;
/**
* Generic Project Plugin
*
*
* Provides validation, metrics, and management capabilities for
* generic projects that don't fit specific technology categories.
*/
@@ -53,22 +54,28 @@ class GenericPlugin extends AbstractProjectPlugin
$warnings = [];
// Check for README
if (!$this->fileExists($projectPath, 'README.md') &&
if (
!$this->fileExists($projectPath, 'README.md') &&
!$this->fileExists($projectPath, 'README') &&
!$this->fileExists($projectPath, 'README.txt')) {
$warnings[] = 'No README file found';
!$this->fileExists($projectPath, 'README.txt')
) {
$errors[] = 'No README file found';
}
// Check for LICENSE
if (!$this->fileExists($projectPath, 'LICENSE') &&
if (
!$this->fileExists($projectPath, 'LICENSE') &&
!$this->fileExists($projectPath, 'LICENSE.md') &&
!$this->fileExists($projectPath, 'COPYING')) {
!$this->fileExists($projectPath, 'COPYING')
) {
$warnings[] = 'No LICENSE file found';
}
// Check for version control ignore file
if (!$this->fileExists($projectPath, '.gitignore') &&
!$this->fileExists($projectPath, '.hgignore')) {
if (
!$this->fileExists($projectPath, '.gitignore') &&
!$this->fileExists($projectPath, '.hgignore')
) {
$warnings[] = 'No version control ignore file found';
}
@@ -79,7 +86,7 @@ class GenericPlugin extends AbstractProjectPlugin
$this->fileExists($projectPath, '.travis.yml') ||
$this->fileExists($projectPath, 'Jenkinsfile') ||
$this->fileExists($projectPath, '.circleci');
if (!$hasCICD) {
$warnings[] = 'No CI/CD configuration found';
}
@@ -174,8 +181,10 @@ class GenericPlugin extends AbstractProjectPlugin
}
// Check version control
if (!$this->fileExists($projectPath, '.git') &&
!$this->fileExists($projectPath, '.hg')) {
if (
!$this->fileExists($projectPath, '.git') &&
!$this->fileExists($projectPath, '.hg')
) {
$issues[] = [
'severity' => 'info',
'message' => 'Not under version control',
@@ -184,8 +193,10 @@ class GenericPlugin extends AbstractProjectPlugin
}
// Check .gitignore
if ($this->fileExists($projectPath, '.git') &&
!$this->fileExists($projectPath, '.gitignore')) {
if (
$this->fileExists($projectPath, '.git') &&
!$this->fileExists($projectPath, '.gitignore')
) {
$issues[] = [
'severity' => 'warning',
'message' => 'Missing .gitignore file',
@@ -230,8 +241,10 @@ class GenericPlugin extends AbstractProjectPlugin
}
// Check for changelog
if (!$this->fileExists($projectPath, 'CHANGELOG.md') &&
!$this->fileExists($projectPath, 'CHANGELOG')) {
if (
!$this->fileExists($projectPath, 'CHANGELOG.md') &&
!$this->fileExists($projectPath, 'CHANGELOG')
) {
$issues[] = [
'severity' => 'info',
'message' => 'No CHANGELOG file found',
@@ -471,7 +484,7 @@ class GenericPlugin extends AbstractProjectPlugin
{
$totalLines = 0;
$textExtensions = ['php', 'js', 'py', 'java', 'c', 'cpp', 'h', 'cs', 'go', 'rb', 'ts', 'tsx', 'jsx'];
$iterator = new \RecursiveIteratorIterator(
new \RecursiveDirectoryIterator($projectPath, \RecursiveDirectoryIterator::SKIP_DOTS),
\RecursiveIteratorIterator::LEAVES_ONLY
+24 -13
View File
@@ -1,4 +1,5 @@
<?php
/**
* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
*
@@ -22,7 +23,7 @@ use MokoEnterprise\AbstractProjectPlugin;
/**
* Joomla Project Plugin
*
*
* Provides validation, metrics, and management capabilities for Joomla
* extensions (components, modules, plugins, templates).
*/
@@ -78,20 +79,26 @@ class JoomlaPlugin extends AbstractProjectPlugin
}
// Check for language files
if (!$this->fileExists($projectPath, 'language') &&
!$this->countFiles($projectPath, '**/language/*.ini')) {
if (
!$this->fileExists($projectPath, 'language') &&
!$this->countFiles($projectPath, '**/language/*.ini')
) {
$warnings[] = 'No language files found';
}
// Check for SQL installation files
if (!$this->fileExists($projectPath, 'sql/install.mysql.utf8.sql') &&
!$this->fileExists($projectPath, 'admin/sql/install.mysql.utf8.sql')) {
if (
!$this->fileExists($projectPath, 'sql/install.mysql.utf8.sql') &&
!$this->fileExists($projectPath, 'admin/sql/install.mysql.utf8.sql')
) {
$warnings[] = 'No SQL installation file found';
}
// Check code quality
if (!$this->fileExists($projectPath, 'phpcs.xml') &&
!$this->fileExists($projectPath, 'phpcs.xml.dist')) {
if (
!$this->fileExists($projectPath, 'phpcs.xml') &&
!$this->fileExists($projectPath, 'phpcs.xml.dist')
) {
$warnings[] = 'No PHPCS configuration found';
}
@@ -128,7 +135,7 @@ class JoomlaPlugin extends AbstractProjectPlugin
'has_namespaces' => $this->checkForNamespaces($projectPath),
'joomla_version' => $this->detectJoomlaVersion($projectPath),
'uses_mvc' => $this->checkMVCStructure($projectPath),
'has_tests' => $this->fileExists($projectPath, 'tests') ||
'has_tests' => $this->fileExists($projectPath, 'tests') ||
$this->fileExists($projectPath, 'test'),
];
@@ -173,8 +180,10 @@ class JoomlaPlugin extends AbstractProjectPlugin
// Check for proper directory structure
$extensionType = $this->detectExtensionType($projectPath);
if ($extensionType === 'component') {
if (!$this->fileExists($projectPath, 'site') &&
!$this->fileExists($projectPath, 'admin')) {
if (
!$this->fileExists($projectPath, 'site') &&
!$this->fileExists($projectPath, 'admin')
) {
$issues[] = [
'severity' => 'warning',
'message' => 'Component missing standard site/admin structure',
@@ -326,10 +335,12 @@ class JoomlaPlugin extends AbstractProjectPlugin
$files = $this->findFiles($projectPath, '*.xml');
foreach ($files as $file) {
$content = $this->readFile($projectPath, basename($file));
if ($content && (
if (
$content && (
strpos($content, '<extension') !== false ||
strpos($content, '<install') !== false
)) {
)
) {
return $file;
}
}
@@ -431,7 +442,7 @@ class JoomlaPlugin extends AbstractProjectPlugin
{
$dirs = glob($projectPath . '/*', GLOB_ONLYDIR);
$missingCount = 0;
foreach ($dirs as $dir) {
if (!file_exists($dir . '/index.html')) {
$missingCount++;
@@ -1,4 +1,5 @@
<?php
/**
* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
*
+23 -12
View File
@@ -1,4 +1,5 @@
<?php
/**
* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
*
@@ -22,7 +23,7 @@ use MokoEnterprise\AbstractProjectPlugin;
/**
* Mobile App Project Plugin
*
*
* Provides validation, metrics, and management capabilities for
* mobile applications (React Native, Flutter, native iOS/Android).
*/
@@ -59,12 +60,16 @@ class MobilePlugin extends AbstractProjectPlugin
if (!$this->fileExists($projectPath, 'package.json')) {
$errors[] = 'React Native project missing package.json';
}
if (!$this->fileExists($projectPath, 'app.json') &&
!$this->fileExists($projectPath, 'app.config.js')) {
if (
!$this->fileExists($projectPath, 'app.json') &&
!$this->fileExists($projectPath, 'app.config.js')
) {
$warnings[] = 'Missing app.json or app.config.js';
}
if (!$this->fileExists($projectPath, 'ios') &&
!$this->fileExists($projectPath, 'android')) {
if (
!$this->fileExists($projectPath, 'ios') &&
!$this->fileExists($projectPath, 'android')
) {
$warnings[] = 'No native platform directories found';
}
break;
@@ -79,8 +84,10 @@ class MobilePlugin extends AbstractProjectPlugin
break;
case 'ios':
if (!$this->fileExists($projectPath, '*.xcodeproj') &&
!$this->fileExists($projectPath, '*.xcworkspace')) {
if (
!$this->fileExists($projectPath, '*.xcodeproj') &&
!$this->fileExists($projectPath, '*.xcworkspace')
) {
$errors[] = 'iOS project missing Xcode project file';
}
if (!$this->fileExists($projectPath, 'Podfile')) {
@@ -427,8 +434,10 @@ class MobilePlugin extends AbstractProjectPlugin
}
// Android
if ($this->fileExists($projectPath, 'build.gradle') &&
$this->fileExists($projectPath, 'app/src/main')) {
if (
$this->fileExists($projectPath, 'build.gradle') &&
$this->fileExists($projectPath, 'app/src/main')
) {
return 'android';
}
@@ -593,7 +602,7 @@ class MobilePlugin extends AbstractProjectPlugin
private function countTotalLines(string $projectPath, string $platform): int
{
$extensions = [];
switch ($platform) {
case 'react-native':
$extensions = ['js', 'jsx', 'ts', 'tsx'];
@@ -613,9 +622,11 @@ class MobilePlugin extends AbstractProjectPlugin
foreach ($extensions as $ext) {
$files = $this->findFiles($projectPath, "**/*.{$ext}");
foreach ($files as $file) {
if (is_file($file) &&
if (
is_file($file) &&
strpos($file, 'node_modules') === false &&
strpos($file, 'build') === false) {
strpos($file, 'build') === false
) {
$totalLines += count(file($file));
}
}
+35 -18
View File
@@ -1,4 +1,5 @@
<?php
/**
* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
*
@@ -22,7 +23,7 @@ use MokoEnterprise\AbstractProjectPlugin;
/**
* Node.js/TypeScript Project Plugin
*
*
* Provides validation, metrics, and management capabilities for
* Node.js and TypeScript projects.
*/
@@ -86,28 +87,36 @@ class NodeJsPlugin extends AbstractProjectPlugin
}
// Check for node_modules in git
if ($this->fileExists($projectPath, 'node_modules') &&
!$this->isInGitignore($projectPath, 'node_modules')) {
if (
$this->fileExists($projectPath, 'node_modules') &&
!$this->isInGitignore($projectPath, 'node_modules')
) {
$warnings[] = 'node_modules should be in .gitignore';
}
// Check for lock file
if (!$this->fileExists($projectPath, 'package-lock.json') &&
if (
!$this->fileExists($projectPath, 'package-lock.json') &&
!$this->fileExists($projectPath, 'yarn.lock') &&
!$this->fileExists($projectPath, 'pnpm-lock.yaml')) {
!$this->fileExists($projectPath, 'pnpm-lock.yaml')
) {
$warnings[] = 'No lock file found (package-lock.json, yarn.lock, or pnpm-lock.yaml)';
}
// Check for linting
if (!$this->fileExists($projectPath, '.eslintrc.js') &&
if (
!$this->fileExists($projectPath, '.eslintrc.js') &&
!$this->fileExists($projectPath, '.eslintrc.json') &&
!$this->fileExists($projectPath, '.eslintrc.yml')) {
!$this->fileExists($projectPath, '.eslintrc.yml')
) {
$warnings[] = 'No ESLint configuration found';
}
// Check for formatting
if (!$this->fileExists($projectPath, '.prettierrc') &&
!$this->fileExists($projectPath, 'prettier.config.js')) {
if (
!$this->fileExists($projectPath, '.prettierrc') &&
!$this->fileExists($projectPath, 'prettier.config.js')
) {
$warnings[] = 'No Prettier configuration found';
}
@@ -195,7 +204,7 @@ class NodeJsPlugin extends AbstractProjectPlugin
$score -= 30;
} else {
$packageData = $this->parseJsonFile($projectPath, 'package.json');
// Check for outdated dependencies (basic check)
if ($this->hasOldDependencies($packageData)) {
$issues[] = [
@@ -207,9 +216,11 @@ class NodeJsPlugin extends AbstractProjectPlugin
}
// Check for lock file
if (!$this->fileExists($projectPath, 'package-lock.json') &&
if (
!$this->fileExists($projectPath, 'package-lock.json') &&
!$this->fileExists($projectPath, 'yarn.lock') &&
!$this->fileExists($projectPath, 'pnpm-lock.yaml')) {
!$this->fileExists($projectPath, 'pnpm-lock.yaml')
) {
$issues[] = [
'severity' => 'warning',
'message' => 'No lock file found',
@@ -265,8 +276,10 @@ class NodeJsPlugin extends AbstractProjectPlugin
}
// Check for node_modules in git
if ($this->fileExists($projectPath, 'node_modules') &&
!$this->isInGitignore($projectPath, 'node_modules')) {
if (
$this->fileExists($projectPath, 'node_modules') &&
!$this->isInGitignore($projectPath, 'node_modules')
) {
$issues[] = [
'severity' => 'warning',
'message' => 'node_modules not in .gitignore',
@@ -448,10 +461,12 @@ class NodeJsPlugin extends AbstractProjectPlugin
private function hasTests(string $projectPath, ?array $packageData): bool
{
// Check for test directories
if ($this->fileExists($projectPath, 'test') ||
if (
$this->fileExists($projectPath, 'test') ||
$this->fileExists($projectPath, 'tests') ||
$this->fileExists($projectPath, '__tests__') ||
$this->fileExists($projectPath, 'spec')) {
$this->fileExists($projectPath, 'spec')
) {
return true;
}
@@ -461,10 +476,12 @@ class NodeJsPlugin extends AbstractProjectPlugin
}
// Check for test files
if ($this->countFiles($projectPath, '**/*.test.js') > 0 ||
if (
$this->countFiles($projectPath, '**/*.test.js') > 0 ||
$this->countFiles($projectPath, '**/*.test.ts') > 0 ||
$this->countFiles($projectPath, '**/*.spec.js') > 0 ||
$this->countFiles($projectPath, '**/*.spec.ts') > 0) {
$this->countFiles($projectPath, '**/*.spec.ts') > 0
) {
return true;
}
+52 -29
View File
@@ -1,4 +1,5 @@
<?php
/**
* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
*
@@ -22,7 +23,7 @@ use MokoEnterprise\AbstractProjectPlugin;
/**
* Python Project Plugin
*
*
* Provides validation, metrics, and management capabilities for
* Python projects.
*/
@@ -55,7 +56,7 @@ class PythonPlugin extends AbstractProjectPlugin
// Check for project configuration
$hasSetupPy = $this->fileExists($projectPath, 'setup.py');
$hasPyproject = $this->fileExists($projectPath, 'pyproject.toml');
if (!$hasSetupPy && !$hasPyproject) {
$warnings[] = 'No setup.py or pyproject.toml found';
}
@@ -73,9 +74,11 @@ class PythonPlugin extends AbstractProjectPlugin
}
// Check for requirements
if (!$this->fileExists($projectPath, 'requirements.txt') &&
if (
!$this->fileExists($projectPath, 'requirements.txt') &&
!$this->fileExists($projectPath, 'Pipfile') &&
!$hasPyproject) {
!$hasPyproject
) {
$warnings[] = 'No requirements file found (requirements.txt, Pipfile, or pyproject.toml)';
}
@@ -91,17 +94,21 @@ class PythonPlugin extends AbstractProjectPlugin
// Check for virtual environment in git
$venvDirs = ['venv', '.venv', 'env', '.env'];
foreach ($venvDirs as $dir) {
if ($this->fileExists($projectPath, $dir) &&
!$this->isInGitignore($projectPath, $dir)) {
if (
$this->fileExists($projectPath, $dir) &&
!$this->isInGitignore($projectPath, $dir)
) {
$warnings[] = "Virtual environment directory '{$dir}' should be in .gitignore";
break;
}
}
// Check for linting/formatting
if (!$this->fileExists($projectPath, '.flake8') &&
if (
!$this->fileExists($projectPath, '.flake8') &&
!$this->fileExists($projectPath, '.pylintrc') &&
!$this->fileExists($projectPath, 'pyproject.toml')) {
!$this->fileExists($projectPath, 'pyproject.toml')
) {
$warnings[] = 'No linting configuration found (.flake8, .pylintrc, or pyproject.toml)';
}
@@ -143,10 +150,12 @@ class PythonPlugin extends AbstractProjectPlugin
$pythonFiles = $this->findFiles($projectPath, '**/*.py');
$totalLines = 0;
$docstringLines = 0;
foreach ($pythonFiles as $file) {
if (is_file($file) && strpos($file, 'venv') === false &&
strpos($file, '.venv') === false) {
if (
is_file($file) && strpos($file, 'venv') === false &&
strpos($file, '.venv') === false
) {
$content = @file_get_contents($file);
if ($content) {
$lines = explode("\n", $content);
@@ -155,7 +164,7 @@ class PythonPlugin extends AbstractProjectPlugin
}
}
}
$metrics['total_lines'] = $totalLines;
$metrics['docstring_count'] = $docstringLines;
@@ -182,8 +191,10 @@ class PythonPlugin extends AbstractProjectPlugin
$score = 100;
// Check for project configuration
if (!$this->fileExists($projectPath, 'setup.py') &&
!$this->fileExists($projectPath, 'pyproject.toml')) {
if (
!$this->fileExists($projectPath, 'setup.py') &&
!$this->fileExists($projectPath, 'pyproject.toml')
) {
$issues[] = [
'severity' => 'warning',
'message' => 'No setup.py or pyproject.toml found',
@@ -192,9 +203,11 @@ class PythonPlugin extends AbstractProjectPlugin
}
// Check for requirements
if (!$this->fileExists($projectPath, 'requirements.txt') &&
if (
!$this->fileExists($projectPath, 'requirements.txt') &&
!$this->fileExists($projectPath, 'Pipfile') &&
!$this->fileExists($projectPath, 'pyproject.toml')) {
!$this->fileExists($projectPath, 'pyproject.toml')
) {
$issues[] = [
'severity' => 'warning',
'message' => 'No requirements file found',
@@ -205,8 +218,10 @@ class PythonPlugin extends AbstractProjectPlugin
// Check for virtual environment in git
$venvDirs = ['venv', '.venv', 'env'];
foreach ($venvDirs as $dir) {
if ($this->fileExists($projectPath, $dir) &&
!$this->isInGitignore($projectPath, $dir)) {
if (
$this->fileExists($projectPath, $dir) &&
!$this->isInGitignore($projectPath, $dir)
) {
$issues[] = [
'severity' => 'warning',
'message' => "Virtual environment '{$dir}' not in .gitignore",
@@ -217,8 +232,10 @@ class PythonPlugin extends AbstractProjectPlugin
}
// Check for __pycache__ in git
if ($this->fileExists($projectPath, '__pycache__') &&
!$this->isInGitignore($projectPath, '__pycache__')) {
if (
$this->fileExists($projectPath, '__pycache__') &&
!$this->isInGitignore($projectPath, '__pycache__')
) {
$issues[] = [
'severity' => 'warning',
'message' => '__pycache__ directories not in .gitignore',
@@ -254,8 +271,10 @@ class PythonPlugin extends AbstractProjectPlugin
}
// Check for README
if (!$this->fileExists($projectPath, 'README.md') &&
!$this->fileExists($projectPath, 'README.rst')) {
if (
!$this->fileExists($projectPath, 'README.md') &&
!$this->fileExists($projectPath, 'README.rst')
) {
$issues[] = [
'severity' => 'warning',
'message' => 'Missing README file',
@@ -264,8 +283,10 @@ class PythonPlugin extends AbstractProjectPlugin
}
// Check for license
if (!$this->fileExists($projectPath, 'LICENSE') &&
!$this->fileExists($projectPath, 'LICENSE.txt')) {
if (
!$this->fileExists($projectPath, 'LICENSE') &&
!$this->fileExists($projectPath, 'LICENSE.txt')
) {
$issues[] = [
'severity' => 'warning',
'message' => 'Missing LICENSE file',
@@ -400,7 +421,7 @@ class PythonPlugin extends AbstractProjectPlugin
// Basic TOML parsing (simplified)
$data = [];
$section = '';
foreach (explode("\n", $content) as $line) {
$line = trim($line);
if (preg_match('/^\[(.*)\]$/', $line, $matches)) {
@@ -459,7 +480,7 @@ class PythonPlugin extends AbstractProjectPlugin
// Check requirements.txt
$requirements = $this->readFile($projectPath, 'requirements.txt');
if ($requirements) {
$lines = array_filter(explode("\n", $requirements), function($line) {
$lines = array_filter(explode("\n", $requirements), function ($line) {
$line = trim($line);
return !empty($line) && !str_starts_with($line, '#');
});
@@ -491,8 +512,10 @@ class PythonPlugin extends AbstractProjectPlugin
*/
private function detectTestFramework(string $projectPath): string
{
if ($this->fileExists($projectPath, 'pytest.ini') ||
$this->fileExists($projectPath, 'pyproject.toml')) {
if (
$this->fileExists($projectPath, 'pytest.ini') ||
$this->fileExists($projectPath, 'pyproject.toml')
) {
return 'pytest';
}
@@ -569,7 +592,7 @@ class PythonPlugin extends AbstractProjectPlugin
private function hasTypeHints(string $projectPath): bool
{
$pythonFiles = $this->findFiles($projectPath, '*.py');
foreach (array_slice($pythonFiles, 0, 5) as $file) {
if (is_file($file)) {
$content = @file_get_contents($file);
+26 -17
View File
@@ -1,4 +1,5 @@
<?php
/**
* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
*
@@ -22,7 +23,7 @@ use MokoEnterprise\AbstractProjectPlugin;
/**
* Terraform Project Plugin
*
*
* Provides validation, metrics, and management capabilities for
* Terraform infrastructure-as-code projects.
*/
@@ -74,14 +75,18 @@ class TerraformPlugin extends AbstractProjectPlugin
}
// Check for terraform.tfvars in git
if ($this->fileExists($projectPath, 'terraform.tfvars') &&
!$this->isInGitignore($projectPath, 'terraform.tfvars')) {
if (
$this->fileExists($projectPath, 'terraform.tfvars') &&
!$this->isInGitignore($projectPath, 'terraform.tfvars')
) {
$warnings[] = 'terraform.tfvars may contain secrets and should be in .gitignore';
}
// Check for .terraform directory in git
if ($this->fileExists($projectPath, '.terraform') &&
!$this->isInGitignore($projectPath, '.terraform')) {
if (
$this->fileExists($projectPath, '.terraform') &&
!$this->isInGitignore($projectPath, '.terraform')
) {
$warnings[] = '.terraform directory should be in .gitignore';
}
@@ -224,8 +229,10 @@ class TerraformPlugin extends AbstractProjectPlugin
}
// Check for secrets in tfvars
if ($this->fileExists($projectPath, 'terraform.tfvars') &&
!$this->isInGitignore($projectPath, 'terraform.tfvars')) {
if (
$this->fileExists($projectPath, 'terraform.tfvars') &&
!$this->isInGitignore($projectPath, 'terraform.tfvars')
) {
$issues[] = [
'severity' => 'warning',
'message' => 'terraform.tfvars not in .gitignore',
@@ -234,8 +241,10 @@ class TerraformPlugin extends AbstractProjectPlugin
}
// Check .terraform directory
if ($this->fileExists($projectPath, '.terraform') &&
!$this->isInGitignore($projectPath, '.terraform')) {
if (
$this->fileExists($projectPath, '.terraform') &&
!$this->isInGitignore($projectPath, '.terraform')
) {
$issues[] = [
'severity' => 'warning',
'message' => '.terraform directory not in .gitignore',
@@ -370,7 +379,7 @@ class TerraformPlugin extends AbstractProjectPlugin
private function hasBackendConfig(string $projectPath): bool
{
$tfFiles = $this->findFiles($projectPath, '*.tf');
foreach ($tfFiles as $file) {
if (is_file($file)) {
$content = @file_get_contents($file);
@@ -389,7 +398,7 @@ class TerraformPlugin extends AbstractProjectPlugin
private function hasVersionConstraints(string $projectPath): bool
{
$tfFiles = $this->findFiles($projectPath, '*.tf');
foreach ($tfFiles as $file) {
if (is_file($file)) {
$content = @file_get_contents($file);
@@ -418,7 +427,7 @@ class TerraformPlugin extends AbstractProjectPlugin
{
$count = 0;
$tfFiles = $this->findFiles($projectPath, '*.tf');
foreach ($tfFiles as $file) {
if (is_file($file)) {
$content = @file_get_contents($file);
@@ -438,7 +447,7 @@ class TerraformPlugin extends AbstractProjectPlugin
{
$count = 0;
$tfFiles = $this->findFiles($projectPath, '*.tf');
foreach ($tfFiles as $file) {
if (is_file($file)) {
$content = @file_get_contents($file);
@@ -458,7 +467,7 @@ class TerraformPlugin extends AbstractProjectPlugin
{
$count = 0;
$tfFiles = $this->findFiles($projectPath, '*.tf');
foreach ($tfFiles as $file) {
if (is_file($file)) {
$content = @file_get_contents($file);
@@ -478,7 +487,7 @@ class TerraformPlugin extends AbstractProjectPlugin
{
$count = 0;
$tfFiles = $this->findFiles($projectPath, '*.tf');
foreach ($tfFiles as $file) {
if (is_file($file)) {
$content = @file_get_contents($file);
@@ -498,7 +507,7 @@ class TerraformPlugin extends AbstractProjectPlugin
{
$count = 0;
$tfFiles = $this->findFiles($projectPath, '*.tf');
foreach ($tfFiles as $file) {
if (is_file($file)) {
$content = @file_get_contents($file);
@@ -518,7 +527,7 @@ class TerraformPlugin extends AbstractProjectPlugin
{
$providers = [];
$tfFiles = $this->findFiles($projectPath, '*.tf');
foreach ($tfFiles as $file) {
if (is_file($file)) {
$content = @file_get_contents($file);
+38 -25
View File
@@ -1,4 +1,5 @@
<?php
/**
* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
*
@@ -22,7 +23,7 @@ use MokoEnterprise\AbstractProjectPlugin;
/**
* WordPress Project Plugin
*
*
* Provides validation, metrics, and management capabilities for
* WordPress plugins and themes.
*/
@@ -79,8 +80,10 @@ class WordPressPlugin extends AbstractProjectPlugin
}
// Check for WordPress coding standards
if (!$this->fileExists($projectPath, 'phpcs.xml') &&
!$this->fileExists($projectPath, 'phpcs.xml.dist')) {
if (
!$this->fileExists($projectPath, 'phpcs.xml') &&
!$this->fileExists($projectPath, 'phpcs.xml.dist')
) {
$warnings[] = 'No PHPCS configuration found (WordPress Coding Standards recommended)';
}
@@ -221,8 +224,10 @@ class WordPressPlugin extends AbstractProjectPlugin
}
// Check for README
if (!$this->fileExists($projectPath, 'README.md') &&
!$this->fileExists($projectPath, 'readme.txt')) {
if (
!$this->fileExists($projectPath, 'README.md') &&
!$this->fileExists($projectPath, 'readme.txt')
) {
$issues[] = [
'severity' => 'warning',
'message' => 'Missing README file',
@@ -231,8 +236,10 @@ class WordPressPlugin extends AbstractProjectPlugin
}
// Check for license
if (!$this->fileExists($projectPath, 'LICENSE') &&
!$this->fileExists($projectPath, 'license.txt')) {
if (
!$this->fileExists($projectPath, 'LICENSE') &&
!$this->fileExists($projectPath, 'license.txt')
) {
$issues[] = [
'severity' => 'warning',
'message' => 'Missing LICENSE file',
@@ -408,7 +415,7 @@ class WordPressPlugin extends AbstractProjectPlugin
];
$nameField = $type === 'theme' ? 'Theme Name' : 'Plugin Name';
if (preg_match('/' . $nameField . ':\s*(.+)/i', $content, $matches)) {
$data['name'] = trim($matches[1]);
}
@@ -431,7 +438,7 @@ class WordPressPlugin extends AbstractProjectPlugin
private function hasTextDomain(string $projectPath): bool
{
$phpFiles = $this->findFiles($projectPath, '*.php');
foreach (array_slice($phpFiles, 0, 5) as $file) {
if (is_file($file)) {
$content = @file_get_contents($file);
@@ -450,7 +457,7 @@ class WordPressPlugin extends AbstractProjectPlugin
private function hasUnescapedOutput(string $projectPath): bool
{
$phpFiles = $this->findFiles($projectPath, '*.php');
foreach (array_slice($phpFiles, 0, 10) as $file) {
if (is_file($file)) {
$content = @file_get_contents($file);
@@ -473,15 +480,17 @@ class WordPressPlugin extends AbstractProjectPlugin
{
$phpFiles = $this->findFiles($projectPath, '*.php');
$protectedCount = 0;
foreach (array_slice($phpFiles, 0, 10) as $file) {
if (is_file($file)) {
$content = @file_get_contents($file);
if ($content && (
if (
$content && (
strpos($content, 'defined( \'ABSPATH\' )') !== false ||
strpos($content, 'defined(\'ABSPATH\')') !== false ||
strpos($content, 'if ( ! defined( \'ABSPATH\' ) )') !== false
)) {
)
) {
$protectedCount++;
}
}
@@ -496,7 +505,7 @@ class WordPressPlugin extends AbstractProjectPlugin
private function hasSQLInjectionRisk(string $projectPath): bool
{
$phpFiles = $this->findFiles($projectPath, '*.php');
foreach (array_slice($phpFiles, 0, 10) as $file) {
if (is_file($file)) {
$content = @file_get_contents($file);
@@ -518,14 +527,16 @@ class WordPressPlugin extends AbstractProjectPlugin
private function hasNonceVerification(string $projectPath): bool
{
$phpFiles = $this->findFiles($projectPath, '*.php');
foreach (array_slice($phpFiles, 0, 10) as $file) {
if (is_file($file)) {
$content = @file_get_contents($file);
if ($content && (
if (
$content && (
strpos($content, 'wp_verify_nonce') !== false ||
strpos($content, 'check_ajax_referer') !== false
)) {
)
) {
return true;
}
}
@@ -552,14 +563,16 @@ class WordPressPlugin extends AbstractProjectPlugin
private function hasHooks(string $projectPath): bool
{
$phpFiles = $this->findFiles($projectPath, '*.php');
foreach (array_slice($phpFiles, 0, 5) as $file) {
if (is_file($file)) {
$content = @file_get_contents($file);
if ($content && (
if (
$content && (
strpos($content, 'add_action') !== false ||
strpos($content, 'add_filter') !== false
)) {
)
) {
return true;
}
}
@@ -575,7 +588,7 @@ class WordPressPlugin extends AbstractProjectPlugin
{
$count = 0;
$phpFiles = $this->findFiles($projectPath, '*.php');
foreach ($phpFiles as $file) {
if (is_file($file)) {
$content = @file_get_contents($file);
@@ -594,7 +607,7 @@ class WordPressPlugin extends AbstractProjectPlugin
private function hasAjax(string $projectPath): bool
{
$phpFiles = $this->findFiles($projectPath, '*.php');
foreach ($phpFiles as $file) {
if (is_file($file)) {
$content = @file_get_contents($file);
@@ -613,7 +626,7 @@ class WordPressPlugin extends AbstractProjectPlugin
private function hasRestAPI(string $projectPath): bool
{
$phpFiles = $this->findFiles($projectPath, '*.php');
foreach ($phpFiles as $file) {
if (is_file($file)) {
$content = @file_get_contents($file);
@@ -642,7 +655,7 @@ class WordPressPlugin extends AbstractProjectPlugin
private function hasWidgets(string $projectPath): bool
{
$phpFiles = $this->findFiles($projectPath, '*.php');
foreach ($phpFiles as $file) {
if (is_file($file)) {
$content = @file_get_contents($file);
@@ -661,7 +674,7 @@ class WordPressPlugin extends AbstractProjectPlugin
private function hasShortcodes(string $projectPath): bool
{
$phpFiles = $this->findFiles($projectPath, '*.php');
foreach ($phpFiles as $file) {
if (is_file($file)) {
$content = @file_get_contents($file);
+41 -40
View File
@@ -1,4 +1,5 @@
<?php
/**
* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
*
@@ -20,7 +21,7 @@ namespace MokoEnterprise;
/**
* Project Config Validator
*
*
* Enterprise library for validating project configurations against
* project type templates and standards.
*/
@@ -29,11 +30,11 @@ class ProjectConfigValidator
private AuditLogger $logger;
private MetricsCollector $metrics;
private ProjectTypeDetector $detector;
private array $validationResults = [];
private int $errorsCount = 0;
private int $warningsCount = 0;
private const VALIDATION_RULES = [
'nodejs' => [
'required_files' => ['package.json'],
@@ -66,7 +67,7 @@ class ProjectConfigValidator
'required_fields' => [],
],
];
/**
* Constructor
*/
@@ -79,10 +80,10 @@ class ProjectConfigValidator
$this->metrics = $metrics ?? new MetricsCollector();
$this->detector = $detector ?? new ProjectTypeDetector($this->logger, $this->metrics);
}
/**
* Validate project configuration
*
*
* @param string $repoPath Path to repository
* @param string|null $projectType Optional project type (auto-detect if null)
* @return array Validation results
@@ -90,38 +91,38 @@ class ProjectConfigValidator
public function validate(string $repoPath, ?string $projectType = null): array
{
$this->logger->logInfo("Validating project configuration: {$repoPath}");
$this->resetResults();
// Detect project type if not provided
if ($projectType === null) {
$detection = $this->detector->detect($repoPath);
$projectType = $detection['type'];
$this->logger->logInfo("Auto-detected project type: {$projectType}");
}
// Get validation rules for project type
$rules = self::VALIDATION_RULES[$projectType] ?? [];
if (empty($rules)) {
$this->addWarning('No validation rules for project type: ' . $projectType);
return $this->getResults();
}
// Run validations
$this->validateRequiredFiles($repoPath, $rules['required_files'] ?? []);
$this->validateRecommendedFiles($repoPath, $rules['recommended_files'] ?? []);
$this->validateProjectFields($repoPath, $projectType, $rules['required_fields'] ?? []);
// Record metrics
$this->metrics->setGauge('validation_errors', $this->errorsCount);
$this->metrics->setGauge('validation_warnings', $this->warningsCount);
$this->logger->logInfo("Validation complete: {$this->errorsCount} errors, {$this->warningsCount} warnings");
return $this->getResults();
}
/**
* Check if validation passed (no errors)
*/
@@ -129,7 +130,7 @@ class ProjectConfigValidator
{
return $this->errorsCount === 0;
}
/**
* Get validation results
*/
@@ -142,19 +143,19 @@ class ProjectConfigValidator
'results' => $this->validationResults,
];
}
private function resetResults(): void
{
$this->validationResults = [];
$this->errorsCount = 0;
$this->warningsCount = 0;
}
private function validateRequiredFiles(string $path, array $files): void
{
foreach ($files as $filePattern) {
$found = false;
// Handle OR patterns (file1|file2)
if (strpos($filePattern, '|') !== false) {
$patterns = explode('|', $filePattern);
@@ -167,7 +168,7 @@ class ProjectConfigValidator
} else {
$found = $this->filePatternExists($path, $filePattern);
}
if (!$found) {
$this->addError("Required file missing: {$filePattern}");
} else {
@@ -175,12 +176,12 @@ class ProjectConfigValidator
}
}
}
private function validateRecommendedFiles(string $path, array $files): void
{
foreach ($files as $filePattern) {
$found = false;
// Handle OR patterns
if (strpos($filePattern, '|') !== false) {
$patterns = explode('|', $filePattern);
@@ -193,7 +194,7 @@ class ProjectConfigValidator
} else {
$found = $this->filePatternExists($path, $filePattern);
}
if (!$found) {
$this->addWarning("Recommended file missing: {$filePattern}");
} else {
@@ -201,13 +202,13 @@ class ProjectConfigValidator
}
}
}
private function validateProjectFields(string $path, string $projectType, array $fields): void
{
if (empty($fields)) {
return;
}
// Validate based on project type
switch ($projectType) {
case 'nodejs':
@@ -223,7 +224,7 @@ class ProjectConfigValidator
$this->logger->logInfo("No field validation for project type: {$projectType}");
}
}
private function validateNodeJSFields(string $path, array $fields): void
{
$packageFile = "{$path}/package.json";
@@ -231,13 +232,13 @@ class ProjectConfigValidator
$this->addError("Cannot validate fields: package.json not found");
return;
}
$package = json_decode(file_get_contents($packageFile), true);
if (!$package) {
$this->addError("Cannot parse package.json");
return;
}
foreach ($fields as $field) {
if (!isset($package[$field])) {
$this->addError("Required field missing in package.json: {$field}");
@@ -246,17 +247,17 @@ class ProjectConfigValidator
}
}
}
private function validatePythonFields(string $path, array $fields): void
{
$setupFile = "{$path}/setup.py";
$pyprojectFile = "{$path}/pyproject.toml";
if (!file_exists($setupFile) && !file_exists($pyprojectFile)) {
$this->addError("Cannot validate fields: setup.py or pyproject.toml not found");
return;
}
// Basic validation - check if fields appear in file content
$content = '';
if (file_exists($setupFile)) {
@@ -264,7 +265,7 @@ class ProjectConfigValidator
} elseif (file_exists($pyprojectFile)) {
$content = file_get_contents($pyprojectFile);
}
foreach ($fields as $field) {
if (stripos($content, $field) === false) {
$this->addWarning("Field may be missing: {$field}");
@@ -273,7 +274,7 @@ class ProjectConfigValidator
}
}
}
private function validateWordPressFields(string $path, array $fields): void
{
$phpFiles = glob("{$path}/*.php");
@@ -281,12 +282,12 @@ class ProjectConfigValidator
$this->addError("No PHP files found for WordPress validation");
return;
}
$content = '';
foreach ($phpFiles as $file) {
$content .= file_get_contents($file);
}
foreach ($fields as $field) {
// Handle OR patterns
if (strpos($field, '|') !== false) {
@@ -312,7 +313,7 @@ class ProjectConfigValidator
}
}
}
private function filePatternExists(string $path, string $pattern): bool
{
// Handle wildcard patterns
@@ -320,10 +321,10 @@ class ProjectConfigValidator
$files = glob("{$path}/{$pattern}");
return !empty($files);
}
return file_exists("{$path}/{$pattern}");
}
private function addError(string $message): void
{
$this->validationResults[] = [
@@ -333,7 +334,7 @@ class ProjectConfigValidator
$this->errorsCount++;
$this->logger->logError($message);
}
private function addWarning(string $message): void
{
$this->validationResults[] = [
@@ -343,7 +344,7 @@ class ProjectConfigValidator
$this->warningsCount++;
$this->logger->logWarning($message);
}
private function addSuccess(string $message): void
{
$this->validationResults[] = [
+47 -46
View File
@@ -1,4 +1,5 @@
<?php
/**
* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
*
@@ -20,7 +21,7 @@ namespace MokoEnterprise;
/**
* Project Metrics Collector
*
*
* Enterprise library for collecting metrics specific to different
* project types (Node.js, Python, Terraform, etc.).
*/
@@ -29,9 +30,9 @@ class ProjectMetricsCollector
private AuditLogger $logger;
private MetricsCollector $metrics;
private ProjectTypeDetector $detector;
private array $collectedMetrics = [];
/**
* Constructor
*/
@@ -44,10 +45,10 @@ class ProjectMetricsCollector
$this->metrics = $metrics ?? new MetricsCollector();
$this->detector = $detector ?? new ProjectTypeDetector($this->logger, $this->metrics);
}
/**
* Collect metrics for a project
*
*
* @param string $repoPath Path to repository
* @param string|null $projectType Optional project type (auto-detect if null)
* @return array Collected metrics
@@ -55,18 +56,18 @@ class ProjectMetricsCollector
public function collect(string $repoPath, ?string $projectType = null): array
{
$this->logger->logInfo("Collecting project metrics: {$repoPath}");
$this->collectedMetrics = [];
// Detect project type if not provided
if ($projectType === null) {
$detection = $this->detector->detect($repoPath);
$projectType = $detection['type'];
}
// Collect common metrics
$this->collectCommonMetrics($repoPath);
// Collect type-specific metrics
switch ($projectType) {
case 'nodejs':
@@ -88,19 +89,19 @@ class ProjectMetricsCollector
$this->collectAPIMetrics($repoPath);
break;
}
// Record to metrics system
foreach ($this->collectedMetrics as $key => $value) {
if (is_numeric($value)) {
$this->metrics->setGauge("project_{$key}", (float)$value);
}
}
$this->logger->logInfo("Collected " . count($this->collectedMetrics) . " metrics");
return $this->collectedMetrics;
}
/**
* Get collected metrics
*/
@@ -108,21 +109,21 @@ class ProjectMetricsCollector
{
return $this->collectedMetrics;
}
private function collectCommonMetrics(string $path): void
{
// File counts
$this->collectedMetrics['total_files'] = $this->countFiles($path, '*');
$this->collectedMetrics['total_directories'] = $this->countDirectories($path);
// Documentation
$this->collectedMetrics['has_readme'] = file_exists("{$path}/README.md") ? 1 : 0;
$this->collectedMetrics['has_license'] = file_exists("{$path}/LICENSE") ? 1 : 0;
$this->collectedMetrics['has_contributing'] = file_exists("{$path}/CONTRIBUTING.md") ? 1 : 0;
// Git
$this->collectedMetrics['has_gitignore'] = file_exists("{$path}/.gitignore") ? 1 : 0;
// CI/CD — check both .github/workflows and .gitea/workflows
$hasGithubWf = is_dir("{$path}/.github/workflows");
$hasGiteaWf = is_dir("{$path}/.mokogitea/workflows");
@@ -133,7 +134,7 @@ class ProjectMetricsCollector
$this->countFiles("{$path}/.mokogitea/workflows", '*.yml') +
$this->countFiles("{$path}/.mokogitea/workflows", '*.yaml');
}
private function collectNodeJSMetrics(string $path): void
{
// Package.json analysis
@@ -146,39 +147,39 @@ class ProjectMetricsCollector
$this->collectedMetrics['has_npm_private'] = isset($package['private']) && $package['private'] ? 1 : 0;
}
}
// TypeScript
$this->collectedMetrics['has_typescript'] = file_exists("{$path}/tsconfig.json") ? 1 : 0;
$this->collectedMetrics['typescript_files'] = $this->countFiles($path, '*.ts');
$this->collectedMetrics['tsx_files'] = $this->countFiles($path, '*.tsx');
// JavaScript
$this->collectedMetrics['javascript_files'] = $this->countFiles($path, '*.js');
$this->collectedMetrics['jsx_files'] = $this->countFiles($path, '*.jsx');
// Lock files
$this->collectedMetrics['has_package_lock'] = file_exists("{$path}/package-lock.json") ? 1 : 0;
$this->collectedMetrics['has_yarn_lock'] = file_exists("{$path}/yarn.lock") ? 1 : 0;
$this->collectedMetrics['has_pnpm_lock'] = file_exists("{$path}/pnpm-lock.yaml") ? 1 : 0;
}
private function collectPythonMetrics(string $path): void
{
// Python files
$this->collectedMetrics['python_files'] = $this->countFiles($path, '*.py');
// Package configuration
$this->collectedMetrics['has_setup_py'] = file_exists("{$path}/setup.py") ? 1 : 0;
$this->collectedMetrics['has_pyproject_toml'] = file_exists("{$path}/pyproject.toml") ? 1 : 0;
$this->collectedMetrics['has_requirements_txt'] = file_exists("{$path}/requirements.txt") ? 1 : 0;
// Requirements count
if (file_exists("{$path}/requirements.txt")) {
$lines = file("{$path}/requirements.txt", FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES);
$deps = array_filter($lines, fn($line) => !str_starts_with(trim($line), '#'));
$this->collectedMetrics['python_dependencies'] = count($deps);
}
// Virtual environment
$venvDirs = ['venv', '.venv', 'env', '.env'];
$hasVenv = false;
@@ -189,37 +190,37 @@ class ProjectMetricsCollector
}
}
$this->collectedMetrics['has_virtual_env'] = $hasVenv ? 1 : 0;
// Testing
$this->collectedMetrics['has_pytest'] = is_dir("{$path}/tests") || is_dir("{$path}/test") ? 1 : 0;
}
private function collectTerraformMetrics(string $path): void
{
// Terraform files
$this->collectedMetrics['terraform_files'] = $this->countFiles($path, '*.tf');
$this->collectedMetrics['terraform_var_files'] = $this->countFiles($path, '*.tfvars');
// Standard files
$this->collectedMetrics['has_main_tf'] = file_exists("{$path}/main.tf") ? 1 : 0;
$this->collectedMetrics['has_variables_tf'] = file_exists("{$path}/variables.tf") ? 1 : 0;
$this->collectedMetrics['has_outputs_tf'] = file_exists("{$path}/outputs.tf") ? 1 : 0;
// Terraform lock
$this->collectedMetrics['has_terraform_lock'] = file_exists("{$path}/.terraform.lock.hcl") ? 1 : 0;
// Terraform directory
$this->collectedMetrics['has_terraform_dir'] = is_dir("{$path}/.terraform") ? 1 : 0;
}
private function collectWordPressMetrics(string $path): void
{
// PHP files
$this->collectedMetrics['php_files'] = $this->countFiles($path, '*.php');
// WordPress readme
$this->collectedMetrics['has_wp_readme'] = file_exists("{$path}/readme.txt") ? 1 : 0;
// Common WordPress directories
$wpDirs = ['includes', 'assets', 'templates', 'languages'];
$dirCount = 0;
@@ -229,39 +230,39 @@ class ProjectMetricsCollector
}
}
$this->collectedMetrics['wordpress_directories'] = $dirCount;
// Assets
$this->collectedMetrics['css_files'] = $this->countFiles($path, '*.css');
$this->collectedMetrics['js_files'] = $this->countFiles($path, '*.js');
}
private function collectMobileMetrics(string $path): void
{
// Platform detection
$this->collectedMetrics['has_ios'] = is_dir("{$path}/ios") ? 1 : 0;
$this->collectedMetrics['has_android'] = is_dir("{$path}/android") ? 1 : 0;
// Framework detection
$this->collectedMetrics['is_react_native'] = false;
$this->collectedMetrics['is_flutter'] = false;
if (file_exists("{$path}/package.json")) {
$package = json_decode(file_get_contents("{$path}/package.json"), true);
if ($package && isset($package['dependencies']['react-native'])) {
$this->collectedMetrics['is_react_native'] = 1;
}
}
if (file_exists("{$path}/pubspec.yaml")) {
$this->collectedMetrics['is_flutter'] = 1;
$this->collectedMetrics['dart_files'] = $this->countFiles($path, '*.dart');
}
// Build configurations
$this->collectedMetrics['has_gradle'] = file_exists("{$path}/build.gradle") ? 1 : 0;
$this->collectedMetrics['has_xcode_project'] = $this->countFiles($path, '*.xcodeproj') > 0 ? 1 : 0;
}
private function collectAPIMetrics(string $path): void
{
// API documentation
@@ -274,26 +275,26 @@ class ProjectMetricsCollector
}
}
$this->collectedMetrics['has_api_documentation'] = $hasApiDoc ? 1 : 0;
// GraphQL
$this->collectedMetrics['graphql_files'] = $this->countFiles($path, '*.graphql');
$this->collectedMetrics['has_graphql_schema'] = file_exists("{$path}/schema.graphql") ? 1 : 0;
// Protocol Buffers
$this->collectedMetrics['proto_files'] = $this->countFiles($path, '*.proto');
// Docker
$this->collectedMetrics['has_dockerfile'] = file_exists("{$path}/Dockerfile") ? 1 : 0;
$this->collectedMetrics['has_docker_compose'] =
$this->collectedMetrics['has_docker_compose'] =
file_exists("{$path}/docker-compose.yml") || file_exists("{$path}/docker-compose.yaml") ? 1 : 0;
}
private function countFiles(string $path, string $pattern): int
{
$files = glob("{$path}/{$pattern}");
return count($files ?: []);
}
private function countDirectories(string $path): int
{
$dirs = glob("{$path}/*", GLOB_ONLYDIR);
+1 -1
View File
@@ -20,7 +20,7 @@ namespace MokoEnterprise;
/**
* Interface for project type-specific enterprise plugins
*
*
* Each project type (Joomla, Node.js, Python, etc.) implements this interface
* to provide type-specific validation, metrics, and management capabilities.
*
+77 -70
View File
@@ -1,4 +1,5 @@
<?php
/**
* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
*
@@ -20,21 +21,21 @@ namespace MokoEnterprise;
/**
* Project Type Detector
*
*
* Enterprise library for automatically detecting project types based on
* repository structure, configuration files, and code patterns.
*/
class ProjectTypeDetector
{
private const DETECTION_THRESHOLD = 0.5;
private AuditLogger $logger;
private MetricsCollector $metrics;
private array $detectionResults = [];
private string $detectedType = 'generic';
private float $confidence = 0.0;
/**
* Constructor
*/
@@ -45,19 +46,19 @@ class ProjectTypeDetector
$this->logger = $logger ?? new AuditLogger('project_type_detector');
$this->metrics = $metrics ?? new MetricsCollector();
}
/**
* Detect project type from repository path
*
*
* @param string $repoPath Path to repository
* @return array Detection results with type and confidence
*/
public function detect(string $repoPath): array
{
$this->logger->logInfo("Detecting project type for: {$repoPath}");
$this->resetResults();
// Run all detection methods
$this->detectJoomla($repoPath);
$this->detectDolibarr($repoPath);
@@ -67,23 +68,23 @@ class ProjectTypeDetector
$this->detectWordPress($repoPath);
$this->detectMobile($repoPath);
$this->detectAPI($repoPath);
// Determine best match
$this->determineBestMatch();
// Record metrics
$this->metrics->increment("project_type_detected_{$this->detectedType}");
$this->metrics->setGauge('detection_confidence', $this->confidence);
$this->logger->logInfo("Detected type: {$this->detectedType} (confidence: {$this->confidence})");
return [
'type' => $this->detectedType,
'confidence' => $this->confidence,
'all_scores' => $this->detectionResults,
];
}
/**
* Get detected project type
*/
@@ -91,7 +92,7 @@ class ProjectTypeDetector
{
return $this->detectedType;
}
/**
* Get detection confidence
*/
@@ -99,7 +100,7 @@ class ProjectTypeDetector
{
return $this->confidence;
}
/**
* Get all detection scores
*/
@@ -107,7 +108,7 @@ class ProjectTypeDetector
{
return $this->detectionResults;
}
private function resetResults(): void
{
$this->detectionResults = [
@@ -124,32 +125,32 @@ class ProjectTypeDetector
$this->detectedType = 'generic';
$this->confidence = 0.0;
}
private function determineBestMatch(): void
{
$maxScore = 0.0;
$bestType = 'generic';
foreach ($this->detectionResults as $type => $score) {
if ($score > $maxScore && $score >= self::DETECTION_THRESHOLD) {
$maxScore = $score;
$bestType = $type;
}
}
$this->detectedType = $bestType;
$this->confidence = $maxScore;
}
private function detectJoomla(string $path): void
{
$score = 0.0;
// Check for Joomla manifest files
if ($this->fileExists($path, '*.xml', ['extension', 'install'])) {
$score += 0.5;
}
// Check for Joomla directories
$joomlaDirs = ['site', 'admin', 'administrator', 'language', 'media'];
foreach ($joomlaDirs as $dir) {
@@ -157,19 +158,19 @@ class ProjectTypeDetector
$score += 0.1;
}
}
$this->detectionResults['joomla'] = min(1.0, $score);
}
private function detectDolibarr(string $path): void
{
$score = 0.0;
// Check for Dolibarr module descriptor
if ($this->fileContains($path, 'mod*.class.php', 'DolibarrModules')) {
$score += 0.6;
}
// Check for Dolibarr directories
$dolibarrDirs = ['core/modules', 'sql', 'class', 'lib'];
foreach ($dolibarrDirs as $dir) {
@@ -177,17 +178,17 @@ class ProjectTypeDetector
$score += 0.1;
}
}
$this->detectionResults['dolibarr'] = min(1.0, $score);
}
private function detectNodeJS(string $path): void
{
$score = 0.0;
if (file_exists("{$path}/package.json")) {
$score += 0.6;
$content = @file_get_contents("{$path}/package.json");
if ($content) {
if (strpos($content, '"typescript"') !== false) {
@@ -198,45 +199,45 @@ class ProjectTypeDetector
}
}
}
if (file_exists("{$path}/tsconfig.json")) {
$score += 0.2;
}
$this->detectionResults['nodejs'] = min(1.0, $score);
}
private function detectPython(string $path): void
{
$score = 0.0;
if (file_exists("{$path}/setup.py") || file_exists("{$path}/pyproject.toml")) {
$score += 0.6;
}
if (file_exists("{$path}/requirements.txt")) {
$score += 0.2;
}
if (file_exists("{$path}/Pipfile") || file_exists("{$path}/poetry.lock")) {
$score += 0.2;
}
$this->detectionResults['python'] = min(1.0, $score);
}
private function detectTerraform(string $path): void
{
$score = 0.0;
if ($this->fileExists($path, '*.tf')) {
$score += 0.6;
}
if (file_exists("{$path}/.terraform.lock.hcl")) {
$score += 0.2;
}
$commonFiles = ['main.tf', 'variables.tf', 'outputs.tf'];
$found = 0;
foreach ($commonFiles as $file) {
@@ -247,19 +248,21 @@ class ProjectTypeDetector
if ($found >= 2) {
$score += 0.2;
}
$this->detectionResults['terraform'] = min(1.0, $score);
}
private function detectWordPress(string $path): void
{
$score = 0.0;
if ($this->fileContains($path, '*.php', 'Plugin Name:') ||
$this->fileContains($path, '*.php', 'Theme Name:')) {
if (
$this->fileContains($path, '*.php', 'Plugin Name:') ||
$this->fileContains($path, '*.php', 'Theme Name:')
) {
$score += 0.6;
}
$wpFunctions = ['add_action', 'add_filter', 'wp_enqueue_script'];
foreach ($wpFunctions as $func) {
if ($this->fileContains($path, '*.php', $func)) {
@@ -267,14 +270,14 @@ class ProjectTypeDetector
break;
}
}
$this->detectionResults['wordpress'] = min(1.0, $score);
}
private function detectMobile(string $path): void
{
$score = 0.0;
// React Native
if (file_exists("{$path}/package.json")) {
$content = @file_get_contents("{$path}/package.json");
@@ -282,24 +285,24 @@ class ProjectTypeDetector
$score += 0.6;
}
}
// Flutter
if (file_exists("{$path}/pubspec.yaml")) {
$score += 0.6;
}
// Native iOS/Android
if ($this->fileExists($path, '*.xcodeproj') || file_exists("{$path}/build.gradle")) {
$score += 0.4;
}
$this->detectionResults['mobile'] = min(1.0, $score);
}
private function detectAPI(string $path): void
{
$score = 0.0;
// API documentation
$apiDocs = ['openapi.yaml', 'openapi.json', 'swagger.yaml', 'swagger.json'];
foreach ($apiDocs as $doc) {
@@ -308,66 +311,70 @@ class ProjectTypeDetector
break;
}
}
// GraphQL
if ($this->fileExists($path, '*.graphql') || file_exists("{$path}/schema.graphql")) {
$score += 0.3;
}
// Docker (common in APIs)
if (file_exists("{$path}/Dockerfile")) {
$score += 0.2;
}
// API frameworks
if ($this->fileContains($path, '*.py', '@app.route') ||
if (
$this->fileContains($path, '*.py', '@app.route') ||
$this->fileContains($path, '*.js', 'express()') ||
$this->fileContains($path, '*.ts', '@Controller')) {
$this->fileContains($path, '*.ts', '@Controller')
) {
$score += 0.3;
}
$this->detectionResults['api'] = min(1.0, $score);
}
private function fileExists(string $path, string $pattern, array $contains = []): bool
{
$files = glob("{$path}/{$pattern}");
if (empty($files)) {
return false;
}
if (empty($contains)) {
return true;
}
foreach ($files as $file) {
$content = @file_get_contents($file);
if (!$content) continue;
if (!$content) {
continue;
}
foreach ($contains as $search) {
if (strpos($content, $search) !== false) {
return true;
}
}
}
return false;
}
private function fileContains(string $path, string $pattern, string $search): bool
{
$files = glob("{$path}/{$pattern}");
if (empty($files)) {
return false;
}
foreach ($files as $file) {
$content = @file_get_contents($file);
if ($content && strpos($content, $search) !== false) {
return true;
}
}
return false;
}
}
+1 -1
View File
@@ -39,7 +39,7 @@ use DateTimeZone;
* Example:
* ```php
* $manager = new RecoveryManager();
*
*
* if ($manager->canRecover('my_operation')) {
* $state = $manager->recoverOperation('my_operation');
* // Resume from saved state
+116 -67
View File
@@ -1,4 +1,5 @@
<?php
/**
* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
*
@@ -20,7 +21,7 @@ namespace MokoEnterprise;
/**
* Repository Health Checker
*
*
* Enterprise library for performing comprehensive repository health checks
* with scoring system and category-based validation.
*/
@@ -28,8 +29,7 @@ class RepositoryHealthChecker
{
private AuditLogger $logger;
private MetricsCollector $metrics;
private UnifiedValidation $validator;
private array $results = [
'categories' => [],
'checks' => [],
@@ -38,51 +38,52 @@ class RepositoryHealthChecker
'percentage' => 0.0,
'level' => 'unknown',
];
/**
* Constructor
*/
public function __construct(
?AuditLogger $logger = null,
?MetricsCollector $metrics = null,
?UnifiedValidation $validator = null
?UnifiedValidator $validator = null
) {
$this->logger = $logger ?? new AuditLogger('repo_health_checker');
$this->metrics = $metrics ?? new MetricsCollector();
$this->validator = $validator ?? new UnifiedValidation();
}
/**
* Check repository health
*
*
* @param string $path Repository path to check
* @return array Health check results
*/
public function check(string $path): array
{
$this->logger->logInfo("Starting health check for: {$path}");
$this->resetResults();
// Run all check categories
$this->runStructureChecks($path);
$this->runDocumentationChecks($path);
$this->runWorkflowChecks($path);
$this->runSecurityChecks($path);
// Calculate final scores
$this->calculateScore();
// Record metrics
$this->metrics->setGauge('repo_health_score', $this->results['percentage']);
$this->metrics->setGauge('repo_health_checks_passed',
count(array_filter($this->results['checks'], fn($c) => $c['passed'])));
$this->metrics->setGauge(
'repo_health_checks_passed',
count(array_filter($this->results['checks'], fn($c) => $c['passed']))
);
$this->logger->logInfo("Health check complete: {$this->results['percentage']}% ({$this->results['level']})");
return $this->results;
}
/**
* Reset results for new check
*/
@@ -97,7 +98,7 @@ class RepositoryHealthChecker
'level' => 'unknown',
];
}
/**
* Run repository structure checks
*/
@@ -111,24 +112,40 @@ class RepositoryHealthChecker
'checks_passed' => 0,
'checks_failed' => 0,
];
// Check README exists
$this->addCheck($category, 'README.md exists',
file_exists("{$path}/README.md"), 10);
$this->addCheck(
$category,
'README.md exists',
file_exists("{$path}/README.md"),
10
);
// Check LICENSE exists
$this->addCheck($category, 'LICENSE file exists',
file_exists("{$path}/LICENSE"), 10);
$this->addCheck(
$category,
'LICENSE file exists',
file_exists("{$path}/LICENSE"),
10
);
// Check .gitignore exists
$this->addCheck($category, '.gitignore exists',
file_exists("{$path}/.gitignore"), 5);
$this->addCheck(
$category,
'.gitignore exists',
file_exists("{$path}/.gitignore"),
5
);
// Check CHANGELOG exists
$this->addCheck($category, 'CHANGELOG.md exists',
file_exists("{$path}/CHANGELOG.md"), 5);
$this->addCheck(
$category,
'CHANGELOG.md exists',
file_exists("{$path}/CHANGELOG.md"),
5
);
}
/**
* Run documentation checks
*/
@@ -142,23 +159,35 @@ class RepositoryHealthChecker
'checks_passed' => 0,
'checks_failed' => 0,
];
// Check docs directory exists
$this->addCheck($category, 'docs/ directory exists',
is_dir("{$path}/docs"), 10);
$this->addCheck(
$category,
'docs/ directory exists',
is_dir("{$path}/docs"),
10
);
// Check README has content
if (file_exists("{$path}/README.md")) {
$content = file_get_contents("{$path}/README.md");
$this->addCheck($category, 'README has substantial content',
strlen($content) > 500, 10);
$this->addCheck(
$category,
'README has substantial content',
strlen($content) > 500,
10
);
}
// Check for code of conduct
$this->addCheck($category, 'CODE_OF_CONDUCT.md exists',
file_exists("{$path}/CODE_OF_CONDUCT.md"), 5);
$this->addCheck(
$category,
'CODE_OF_CONDUCT.md exists',
file_exists("{$path}/CODE_OF_CONDUCT.md"),
5
);
}
/**
* Run workflow checks
*/
@@ -180,17 +209,25 @@ class RepositoryHealthChecker
$workflowDir = is_dir($giteaDir) ? $giteaDir : $githubDir;
// Check workflows directory exists
$this->addCheck($category, 'Workflows directory exists',
$hasWorkflowDir, 10);
$this->addCheck(
$category,
'Workflows directory exists',
$hasWorkflowDir,
10
);
// Check for CI workflow
if ($hasWorkflowDir) {
$hasCI = !empty(glob("{$workflowDir}/ci*.yml")) || !empty(glob("{$workflowDir}/ci*.yaml"));
$this->addCheck($category, 'CI workflow exists',
$hasCI, 10);
$this->addCheck(
$category,
'CI workflow exists',
$hasCI,
10
);
}
}
/**
* Run security checks
*/
@@ -204,12 +241,16 @@ class RepositoryHealthChecker
'checks_passed' => 0,
'checks_failed' => 0,
];
// Check for SECURITY.md
$this->addCheck($category, 'SECURITY.md exists',
file_exists("{$path}/SECURITY.md") ||
file_exists("{$path}/.github/SECURITY.md"), 10);
$this->addCheck(
$category,
'SECURITY.md exists',
file_exists("{$path}/SECURITY.md") ||
file_exists("{$path}/.github/SECURITY.md"),
10
);
// Check for security scanning workflow (CodeQL on GitHub, Trivy on Gitea)
$githubWf = "{$path}/.github/workflows";
$giteaWf = "{$path}/.mokogitea/workflows";
@@ -220,17 +261,25 @@ class RepositoryHealthChecker
if (!$hasSecurityScan && is_dir($giteaWf)) {
$hasSecurityScan = !empty(glob("{$giteaWf}/*trivy*.yml")) || !empty(glob("{$giteaWf}/*trivy*.yaml"));
}
$this->addCheck($category, 'Security scanning workflow exists',
$hasSecurityScan, 10);
$this->addCheck(
$category,
'Security scanning workflow exists',
$hasSecurityScan,
10
);
// Check for dependency management (Dependabot on GitHub, Renovate on Gitea)
$this->addCheck($category, 'Dependency management configured',
$this->addCheck(
$category,
'Dependency management configured',
file_exists("{$path}/.github/dependabot.yml") ||
file_exists("{$path}/.github/dependabot.yaml") ||
file_exists("{$path}/renovate.json") ||
file_exists("{$path}/.renovaterc.json"), 5);
file_exists("{$path}/.renovaterc.json"),
5
);
}
/**
* Add a check result
*/
@@ -242,7 +291,7 @@ class RepositoryHealthChecker
'passed' => $passed,
'points' => $points,
];
if ($passed) {
$this->results['categories'][$category]['earned_points'] += $points;
$this->results['categories'][$category]['checks_passed']++;
@@ -250,7 +299,7 @@ class RepositoryHealthChecker
$this->results['categories'][$category]['checks_failed']++;
}
}
/**
* Calculate overall score and health level
*/
@@ -258,16 +307,16 @@ class RepositoryHealthChecker
{
$totalEarned = 0;
$maxScore = 0;
foreach ($this->results['categories'] as $category) {
$totalEarned += $category['earned_points'];
$maxScore += $category['max_points'];
}
$this->results['score'] = $totalEarned;
$this->results['max_score'] = $maxScore;
$this->results['percentage'] = $maxScore > 0 ? ($totalEarned / $maxScore * 100) : 0;
// Determine health level
$pct = $this->results['percentage'];
if ($pct >= 90) {
@@ -282,30 +331,30 @@ class RepositoryHealthChecker
$this->results['level'] = 'critical';
}
}
/**
* Get failed checks
*
*
* @return array Array of failed checks
*/
public function getFailedChecks(): array
{
return array_filter($this->results['checks'], fn($c) => !$c['passed']);
}
/**
* Get passed checks
*
*
* @return array Array of passed checks
*/
public function getPassedChecks(): array
{
return array_filter($this->results['checks'], fn($c) => $c['passed']);
}
/**
* Check if repository meets threshold
*
*
* @param float $threshold Minimum percentage required
* @return bool True if meets threshold
*/
+121 -79
View File
@@ -1,4 +1,5 @@
<?php
/**
* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
*
@@ -23,7 +24,7 @@ use RuntimeException;
/**
* Repository Synchronizer
*
*
* Enterprise library for synchronizing files across multiple repositories
* based on configuration and override files.
*/
@@ -38,13 +39,12 @@ class RepositorySynchronizer
private const VERSION_BRANCH = 'version/' . self::STANDARDS_MAJOR;
private const SYNC_BRANCH = 'chore/sync-mokostandards-v' . self::STANDARDS_MINOR;
private ApiClient $apiClient;
private GitPlatformAdapter $adapter;
private AuditLogger $logger;
private MetricsCollector $metrics;
private CheckpointManager $checkpoints;
private DefinitionParser $definitionParser;
private MokoStandardsParser $manifestParser;
private GitPlatformAdapter $adapter;
private AuditLogger $logger;
private MetricsCollector $metrics;
private CheckpointManager $checkpoints;
private DefinitionParser $definitionParser;
private MokoStandardsParser $manifestParser;
/**
* Constructor
@@ -64,7 +64,6 @@ class RepositorySynchronizer
?DefinitionParser $definitionParser = null,
?GitPlatformAdapter $adapter = null
) {
$this->apiClient = $apiClient;
$this->adapter = $adapter ?? new MokoGiteaAdapter($apiClient);
$this->logger = $logger;
$this->metrics = $metrics;
@@ -72,10 +71,10 @@ class RepositorySynchronizer
$this->definitionParser = $definitionParser ?? new DefinitionParser();
$this->manifestParser = new MokoStandardsParser();
}
/**
* Get list of repositories for an organization
*
*
* @param string $org Organization name
* @param bool $skipArchived Whether to skip archived repositories
* @return array Array of repository information
@@ -86,10 +85,10 @@ class RepositorySynchronizer
$this->metrics->setGauge('repositories_found', count($repos));
return $repos;
}
/**
* Check if repository has override file
*
*
* @param string $org Organization name
* @param string $repo Repository name
* @return bool True if override file exists
@@ -99,15 +98,15 @@ class RepositorySynchronizer
try {
$overridePath = $this->adapter->getMetadataDir() . '/' . self::SYNC_OVERRIDE_FILE_SUFFIX;
$override = $this->adapter->getFileContents($org, $repo, $overridePath);
return !empty($override);
return $override !== '';
} catch (Exception $e) {
return false;
}
}
/**
* Process single repository
*
*
* @param string $org Organization name
* @param string $repo Repository name
* @param bool $dryRun Whether to perform a dry run
@@ -147,17 +146,16 @@ class RepositorySynchronizer
}
return $result;
} catch (Exception $e) {
$txn->end('failure');
$this->logger->logError("Failed to process repository {$repo}: " . $e->getMessage());
throw $e;
}
}
/**
* Synchronize files to a repository
*
*
* @param string $org Organization name
* @param string $repo Repository name
* @param bool $force Force override protected files
@@ -199,7 +197,11 @@ class RepositorySynchronizer
$defCount = count($filesToSync) - count($sharedFiles);
$sharedAdded = count($filesToSync) - $defCount;
$sharedTotal = count($sharedFiles);
$this->logger->logInfo("Loaded " . count($filesToSync) . " sync entries for {$platform} (def={$defCount}, shared={$sharedAdded}/{$sharedTotal} added, " . ($sharedTotal - $sharedAdded) . " deduped)");
$this->logger->logInfo(
"Loaded " . count($filesToSync) . " sync entries for {$platform}"
. " (def={$defCount}, shared={$sharedAdded}/{$sharedTotal} added, "
. ($sharedTotal - $sharedAdded) . " deduped)"
);
// Log shared workflow destinations for debugging
foreach ($sharedFiles as $sf) {
$dest = $sf['destination'] ?? '?';
@@ -242,7 +244,7 @@ class RepositorySynchronizer
return false;
}
/**
* Check if there's already an open PR for sync
*/
@@ -263,7 +265,7 @@ class RepositorySynchronizer
return null;
}
/**
* Generate / update the repository tracking definition after a successful sync.
*
@@ -388,13 +390,12 @@ HCL;
$this->metrics->increment('definitions_generated');
return true;
} catch (Exception $e) {
$this->logger->logError("Failed to write tracking definition for {$repo}: " . $e->getMessage());
return false;
}
}
/**
* Detect platform from repository info
*/
@@ -497,8 +498,10 @@ HCL;
}
// Check description patterns
if (str_contains($description, 'joomla template') || str_contains($description, 'joomla 5 template')
|| str_contains($description, 'joomla 4 template')) {
if (
str_contains($description, 'joomla template') || str_contains($description, 'joomla 5 template')
|| str_contains($description, 'joomla 4 template')
) {
return 'joomla';
}
if (str_contains($description, 'joomla') || str_contains($description, 'component')) {
@@ -545,13 +548,17 @@ HCL;
$this->logger->logWarning("Could not list branches for {$repo}, syncing default only: " . $e->getMessage());
}
$this->logger->logInfo("Syncing files to {$org}/{$repo} across " . count($branchesToSync) . " branch(es): " . implode(', ', $branchesToSync));
$this->logger->logInfo(
"Syncing files to {$org}/{$repo} across "
. count($branchesToSync) . " branch(es): "
. implode(', ', $branchesToSync)
);
// Sync to each branch
$combinedSummary = ['copied' => [], 'skipped' => [], 'total' => 0];
foreach ($branchesToSync as $branchName) {
$this->logger->logInfo(" Syncing branch: {$branchName}");
$branchSummary = $this->syncFilesToBranch($org, $repo, $platform, $filesToSync, $repoRoot, $force, $branchName, $moduleId ?? null);
$branchSummary = $this->syncFilesToBranch($org, $repo, $platform, $filesToSync, $repoRoot, $force, $branchName, null);
// Merge summaries — only count first branch's copied files to avoid duplicates in tracking
if ($branchName === $defaultBranch) {
$combinedSummary = $branchSummary;
@@ -581,13 +588,16 @@ HCL;
'assignees' => ['jmiller'],
]);
$issueNumber = $issueData['number'] ?? null;
$this->logger->logInfo("Created tracking issue #{$issueNumber}" . count($summary['copied']) . " files synced directly to {$defaultBranch}");
$this->logger->logInfo(
"Created tracking issue #{$issueNumber}"
. count($summary['copied'])
. " files synced directly to {$defaultBranch}"
);
} catch (\Exception $e) {
$this->logger->logWarning("Could not create tracking issue: " . $e->getMessage());
}
return ['number' => $issueNumber, 'summary' => $summary];
} catch (CircuitBreakerOpen | RateLimitExceeded $e) {
$this->logger->logError("Sync failed: " . $e->getMessage());
throw $e;
@@ -596,7 +606,7 @@ HCL;
return $nullResult;
}
}
/**
* Replace all {{TOKEN}} placeholders in a template file with repo-specific values.
*
@@ -655,8 +665,16 @@ HCL;
* @param string|null $moduleId Dolibarr module ID (pre-fetched)
* @return array Summary of operations
*/
private function syncFilesToBranch(string $org, string $repo, string $platform, array $filesToSync, string $repoRoot, bool $force, string $branchName, ?string $moduleId): array
{
private function syncFilesToBranch(
string $org,
string $repo,
string $platform,
array $filesToSync,
string $repoRoot,
bool $force,
string $branchName,
?string $moduleId
): array {
$repoInfo = $this->adapter->getRepo($org, $repo);
$summary = ['copied' => [], 'skipped' => [], 'total' => 0];
@@ -719,19 +737,24 @@ HCL;
}
$this->adapter->createOrUpdateFile(
$org, $repo, $targetPath, $content,
$org,
$repo,
$targetPath,
$content,
"chore: update {$targetPath} from MokoStandards",
$existingFile['sha'] ?? null,
$branchName
);
$this->logger->logInfo("Updated: {$targetPath} ({$branchName})");
$summary['copied'][] = ['file' => $targetPath, 'action' => 'updated'];
} catch (Exception $e) {
$this->adapter->getApiClient()->resetCircuitBreaker();
try {
$this->adapter->createOrUpdateFile(
$org, $repo, $targetPath, $content,
$org,
$repo,
$targetPath,
$content,
"chore: add {$targetPath} from MokoStandards",
null,
$branchName
@@ -744,7 +767,10 @@ HCL;
$this->adapter->getApiClient()->resetCircuitBreaker();
$existing = $this->adapter->getFileContents($org, $repo, $targetPath, $branchName);
$this->adapter->createOrUpdateFile(
$org, $repo, $targetPath, $content,
$org,
$repo,
$targetPath,
$content,
"chore: update {$targetPath} from MokoStandards",
$existing['sha'] ?? null,
$branchName
@@ -778,8 +804,8 @@ HCL;
string $repo,
string $branchName,
string $platform,
array $repoInfo,
array &$summary
array $repoInfo,
array &$summary
): void {
$metaDir = $this->adapter->getMetadataDir();
$targetPath = "{$metaDir}/.mokostandards";
@@ -847,8 +873,13 @@ HCL;
try {
$this->adapter->createOrUpdateFile(
$org, $repo, $targetPath, $xmlContent,
$commitMsg, $targetSha, $branchName
$org,
$repo,
$targetPath,
$xmlContent,
$commitMsg,
$targetSha,
$branchName
);
$this->logger->logInfo(ucfirst($action) . "d XML .mokostandards → {$targetPath}");
$summary['copied'][] = ['file' => $targetPath, 'action' => "{$action}d (XML manifest)"];
@@ -866,7 +897,10 @@ HCL;
}
try {
$this->adapter->deleteFile(
$org, $repo, $path, $data['sha'],
$org,
$repo,
$path,
$data['sha'],
"chore: remove legacy {$path} (replaced by {$targetPath})",
$branchName
);
@@ -891,10 +925,10 @@ HCL;
* @return string Well-formed XML content
*/
private function generateMokoStandardsXml(
string $org,
string $repo,
string $platform,
array $repoInfo,
string $org,
string $repo,
string $platform,
array $repoInfo,
?string $existingContent
): string {
$params = [
@@ -1029,7 +1063,10 @@ HCL;
try {
$this->adapter->createOrUpdateFile(
$org, $repo, 'composer.json', $newContent,
$org,
$repo,
'composer.json',
$newContent,
'chore: add mokoconsulting-tech/enterprise dependency',
$file['sha'] ?? null,
$branchName
@@ -1098,14 +1135,15 @@ HCL;
'dolibarr' => 'templates/configs/gitignore.dolibarr',
'platform' => 'templates/configs/gitignore.dolibarr',
'joomla' => 'templates/configs/.gitignore.joomla',
'joomla' => 'templates/configs/.gitignore.joomla',
];
$gitignoreTemplate = $gitignoreMap[$platform] ?? 'templates/configs/gitignore';
$shared[] = [$gitignoreTemplate, '.gitignore'];
// Create TODO.md stub if it doesn't exist (gitignored after first commit)
$entries[] = [
'inline_content' => "# TODO\n\n> **Note:** This file is not tracked in version control (.gitignore). It is for local task tracking only.\n\n## Critical\n -\n\n## Normal\n -\n\n## Low\n -\n",
'inline_content' => "# TODO\n\n> **Note:** This file is not tracked in "
. "version control (.gitignore). It is for local task tracking "
. "only.\n\n## Critical\n -\n\n## Normal\n -\n\n## Low\n -\n",
'destination' => 'TODO.md',
'always_overwrite' => false,
];
@@ -1123,7 +1161,7 @@ HCL;
];
foreach ($shared as [$source, $dest]) {
$fullSource = "{$root}/{$source}";
$fullSource = "{$repoRoot}/{$source}";
if (file_exists($fullSource)) {
$entries[] = [
'source' => $source, // relative — RepositorySynchronizer prepends repoRoot
@@ -1276,11 +1314,11 @@ HCL;
* @return string Processed content
*/
private function processTemplateContent(
string $content,
string $repo,
string $org = '',
string $platform = '',
array $repoInfo = [],
string $content,
string $repo,
string $org = '',
string $platform = '',
array $repoInfo = [],
?string $moduleId = null
): string {
// Strip .template suffix from workflow file references
@@ -1381,7 +1419,7 @@ HCL;
return null;
}
/**
* Generate PR body text
*/
@@ -1389,14 +1427,14 @@ HCL;
{
$body = "## MokoStandards Synchronization\n\n";
$body .= "This PR synchronizes workflows, configurations, and scripts from the MokoStandards repository.\n\n";
// Summary statistics
$body .= "### Summary\n";
$body .= "- 🆕 **Created**: " . count(array_filter($summary['copied'], fn($i) => $i['action'] === 'created')) . " files\n";
$body .= "- 🔄 **Updated**: " . count(array_filter($summary['copied'], fn($i) => $i['action'] === 'updated')) . " files\n";
$body .= "- ⚠️ **Skipped**: " . count($summary['skipped']) . " files\n";
$body .= "- 📊 **Total**: " . $summary['total'] . " files processed\n\n";
// List copied files
if (!empty($summary['copied'])) {
$body .= "### Files Copied\n\n";
@@ -1406,7 +1444,7 @@ HCL;
}
$body .= "\n";
}
// List skipped files
if (!empty($summary['skipped'])) {
$body .= "### Files Skipped\n\n";
@@ -1415,22 +1453,22 @@ HCL;
}
$body .= "\n";
}
$body .= "### Review Notes\n";
$body .= "- Please review all changes carefully\n";
$body .= "- Ensure no custom configurations are overwritten\n";
$body .= "- Test workflows and scripts after merging\n";
$body .= "- Verify issue templates render correctly\n\n";
$body .= "---\n";
$body .= "*This PR was automatically generated by the MokoStandards bulk sync process.*\n";
return $body;
}
/**
* Synchronize multiple repositories
*
*
* @param string $org Organization name
* @param array $options Sync options (repo, skipArchived, dryRun, force)
* @return array Sync results with statistics
@@ -1441,17 +1479,17 @@ HCL;
$skipArchived = $options['skipArchived'] ?? false;
$dryRun = $options['dryRun'] ?? false;
$force = $options['force'] ?? false;
$txn = $this->logger->startTransaction('bulk_synchronize');
try {
// Get list of repositories
$repos = $this->getRepositories($org, $skipArchived);
if ($specificRepo) {
$repos = array_filter($repos, fn($repo) => $repo['name'] === $specificRepo);
}
$total = count($repos);
$results = [
'total' => $total,
@@ -1460,29 +1498,29 @@ HCL;
'failed' => 0,
'repositories' => [],
];
foreach ($repos as $index => $repo) {
$repoName = $repo['name'];
$progress = $index + 1;
try {
$updated = $this->processRepository($org, $repoName, $dryRun, $force);
if ($updated) {
$results['success']++;
$this->metrics->increment('repos_updated_total', ['status' => 'success']);
$this->metrics->increment('repos_updated_total', 1, ['status' => 'success']);
$results['repositories'][$repoName] = 'updated';
} else {
$results['skipped']++;
$this->metrics->increment('repos_updated_total', ['status' => 'skipped']);
$this->metrics->increment('repos_updated_total', 1, ['status' => 'skipped']);
$results['repositories'][$repoName] = 'skipped';
}
} catch (Exception $e) {
$results['failed']++;
$this->metrics->increment('repos_updated_total', ['status' => 'failed']);
$this->metrics->increment('repos_updated_total', 1, ['status' => 'failed']);
$results['repositories'][$repoName] = 'failed: ' . $e->getMessage();
}
// Save checkpoint
$this->checkpoints->saveCheckpoint('bulk_sync', [
'processed' => $progress,
@@ -1490,11 +1528,10 @@ HCL;
'results' => $results,
]);
}
$txn->end('success');
return $results;
} catch (Exception $e) {
$txn->end('failure');
throw $e;
@@ -1518,7 +1555,10 @@ HCL;
foreach ($labels as $label) {
if (!in_array($label, $existingNames, true)) {
try {
$this->adapter->createLabel($org, $repo, $label,
$this->adapter->createLabel(
$org,
$repo,
$label,
match ($label) {
'mokostandards' => 'B60205',
'type: chore' => 'FEF2C0',
@@ -1532,7 +1572,9 @@ HCL;
default => '',
}
);
} catch (\Exception $createEx) { /* already exists race — ignore */ }
} catch (\Exception $createEx) {
/* already exists race — ignore */
}
}
}
+2 -2
View File
@@ -93,11 +93,11 @@ class RetryHelper
for ($attempt = 0; $attempt < $this->maxRetries; $attempt++) {
try {
$result = $callable();
if ($attempt > 0) {
error_log("Function succeeded on attempt " . ($attempt + 1));
}
return $result;
} catch (Throwable $e) {
// Check if this exception is retryable
+5 -5
View File
@@ -31,12 +31,12 @@ declare(strict_types=1);
* ```php
* $validator = new SecurityValidator();
* $findings = $validator->scanFile('config.php');
*
*
* if ($validator->hasCriticalFindings()) {
* $validator->printReport();
* exit(1);
* }
*
*
* // Scan entire directory
* $validator->scanDirectory('src/', ['.php', '.js']);
* ```
@@ -169,7 +169,7 @@ class SecurityValidator
if (preg_match_all($pattern, $text, $matches, PREG_OFFSET_CAPTURE)) {
foreach ($matches[0] as $match) {
$matchedValue = isset($matches[1]) && !empty($matches[1]) ? $matches[1][0][0] : $match[0];
if ($this->isPlaceholder($matchedValue)) {
continue;
}
@@ -236,14 +236,14 @@ class SecurityValidator
'your_', 'example', 'placeholder', 'xxx', 'test',
'dummy', 'sample', 'replace', 'changeme', 'todo'
];
$valueLower = strtolower($value);
foreach ($placeholders as $placeholder) {
if (strpos($valueLower, $placeholder) !== false) {
return true;
}
}
return false;
}
+2 -1
View File
@@ -1,4 +1,5 @@
<?php
/**
* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
*
@@ -27,7 +28,7 @@ class SynchronizationNotImplementedException extends RuntimeException
{
/**
* Create exception for unimplemented synchronization logic
*
*
* @return self
*/
public static function create(): self
+4 -6
View File
@@ -36,11 +36,11 @@ declare(strict_types=1);
* }, function() {
* // Rollback: delete user
* });
*
*
* $txn->execute('send_email', function() {
* // Send welcome email
* });
*
*
* $txn->commit();
* } catch (TransactionError $e) {
* // Automatic rollback on failure
@@ -96,8 +96,6 @@ class TransactionStep
*/
class Transaction
{
private const VERSION = '04.06.00';
private string $name;
/** @var array<int, TransactionStep> */
private array $steps = [];
@@ -160,7 +158,7 @@ class Transaction
$this->committed = true;
$this->endTime = new DateTime('now', new DateTimeZone('UTC'));
$duration = $this->endTime->getTimestamp() - $this->startTime->getTimestamp();
error_log("Transaction committed: {$this->name} (" . count($this->steps) . " steps, {$duration}s)");
}
@@ -312,7 +310,7 @@ class TransactionManager
{
$committed = 0;
$rolledBack = 0;
foreach ($this->transactions as $txn) {
if ($txn->isCommitted()) {
$committed++;
+10 -9
View File
@@ -37,12 +37,12 @@ declare(strict_types=1);
* $validator = new UnifiedValidator();
* $validator->addPlugin(new PathValidatorPlugin());
* $validator->addPlugin(new MarkdownValidatorPlugin());
*
*
* $context = [
* 'paths' => ['/tmp', '/usr'],
* 'markdown_files' => ['README.md']
* ];
*
*
* $results = $validator->validateAll($context);
* $validator->printSummary();
* ```
@@ -143,7 +143,7 @@ class PathValidatorPlugin extends ValidationPlugin
public function validate(array $context): ValidationResult
{
$paths = $context['paths'] ?? [];
if (empty($paths)) {
return new ValidationResult($this->name, true, 'No paths to validate');
}
@@ -181,7 +181,7 @@ class MarkdownValidatorPlugin extends ValidationPlugin
public function validate(array $context): ValidationResult
{
$files = $context['markdown_files'] ?? [];
if (empty($files)) {
return new ValidationResult($this->name, true, 'No Markdown files to validate');
}
@@ -193,7 +193,7 @@ class MarkdownValidatorPlugin extends ValidationPlugin
}
$content = file_get_contents($filePath);
// Check for broken links
if (strpos($content, '](404') !== false || strpos($content, '](broken') !== false) {
$issues[] = "{$filePath}: Potential broken links";
@@ -226,7 +226,7 @@ class LicenseValidatorPlugin extends ValidationPlugin
public function validate(array $context): ValidationResult
{
$files = $context['source_files'] ?? [];
if (empty($files)) {
return new ValidationResult($this->name, true, 'No source files to validate');
}
@@ -288,7 +288,8 @@ class WorkflowValidatorPlugin extends ValidationPlugin
);
$altDir = ($workflowDir === '.mokogitea/workflows') ? '.github/workflows' : '.mokogitea/workflows';
if (is_dir($altDir)) {
$workflows = array_merge($workflows,
$workflows = array_merge(
$workflows,
glob($altDir . '/*.yml') ?: [],
glob($altDir . '/*.yaml') ?: []
);
@@ -301,7 +302,7 @@ class WorkflowValidatorPlugin extends ValidationPlugin
$issues = [];
foreach ($workflows as $workflow) {
$content = file_get_contents($workflow);
// Basic checks
if (strpos($content, 'on:') === false && strpos($content, 'on :') === false) {
$issues[] = basename($workflow) . ": Missing 'on:' trigger";
@@ -386,7 +387,7 @@ class UnifiedValidator
/** @var array<string, ValidationPlugin> */
private array $plugins = [];
/** @var array<int, ValidationResult> */
private array $results = [];
+64 -63
View File
@@ -1,4 +1,5 @@
<?php
/**
* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
*
@@ -24,7 +25,7 @@ use Exception;
/**
* Joomla Update XML Generator
*
*
* Generates and updates updates.xml files for Joomla extensions
* following the Joomla update server specification
*/
@@ -34,10 +35,10 @@ class UpdateXmlGenerator
private string $extensionType;
private string $element;
private string $clientId;
/**
* Constructor
*
*
* @param string $extensionName Human-readable extension name
* @param string $extensionType Extension type (component, module, plugin, etc.)
* @param string $element Extension element (e.g., com_example, mod_custom)
@@ -54,10 +55,10 @@ class UpdateXmlGenerator
$this->element = $element ?: $this->deriveElement($extensionName, $extensionType);
$this->clientId = $clientId;
}
/**
* Generate updates.xml from release information
*
*
* @param array $release Release information
* @return string XML content
*/
@@ -66,20 +67,20 @@ class UpdateXmlGenerator
$dom = new DOMDocument('1.0', 'UTF-8');
$dom->formatOutput = true;
$dom->preserveWhiteSpace = false;
// Create root element
$updates = $dom->createElement('updates');
$dom->appendChild($updates);
// Add update entry
$this->addUpdateEntry($dom, $updates, $release);
return $dom->saveXML();
}
/**
* Update existing updates.xml file with new release
*
*
* @param string $xmlPath Path to existing updates.xml
* @param array $release New release information
* @return string Updated XML content
@@ -90,24 +91,24 @@ class UpdateXmlGenerator
if (!file_exists($xmlPath)) {
return $this->generate($release);
}
$dom = new DOMDocument('1.0', 'UTF-8');
$dom->formatOutput = true;
$dom->preserveWhiteSpace = false;
if (!@$dom->load($xmlPath)) {
throw new Exception("Failed to parse existing updates.xml at {$xmlPath}");
}
$updates = $dom->getElementsByTagName('updates')->item(0);
if (!$updates) {
throw new Exception("Invalid updates.xml: missing <updates> root element");
}
// Check if this version already exists
$version = $release['version'];
$existingUpdates = $updates->getElementsByTagName('update');
foreach ($existingUpdates as $existingUpdate) {
$versionNode = $existingUpdate->getElementsByTagName('version')->item(0);
if ($versionNode && $versionNode->textContent === $version) {
@@ -116,13 +117,13 @@ class UpdateXmlGenerator
break;
}
}
// Add new update entry at the beginning
$this->addUpdateEntry($dom, $updates, $release, true);
return $dom->saveXML();
}
/**
* Map numeric client ID to Joomla client name
*
@@ -136,10 +137,10 @@ class UpdateXmlGenerator
default => 'site',
};
}
/**
* Add an update entry to the XML document
*
*
* @param DOMDocument $dom DOM document
* @param DOMElement $updates Updates element
* @param array $release Release information
@@ -152,55 +153,55 @@ class UpdateXmlGenerator
bool $prepend = false
): void {
$update = $dom->createElement('update');
// Required fields
$this->addElement($dom, $update, 'name', $this->extensionName);
$this->addElement($dom, $update, 'description', $release['description'] ?? '');
$this->addElement($dom, $update, 'element', $this->element);
$this->addElement($dom, $update, 'type', $this->extensionType);
// Folder (for plugins)
if (!empty($release['folder'])) {
$this->addElement($dom, $update, 'folder', $release['folder']);
}
// Client — always emit for correct extension matching
$this->addElement($dom, $update, 'client', $this->resolveClientName($this->clientId));
$this->addElement($dom, $update, 'version', $release['version']);
// Creation date
if (!empty($release['creation_date'])) {
$this->addElement($dom, $update, 'creationDate', $release['creation_date']);
}
// Joomla target platform
$infourl = $this->addElement($dom, $update, 'infourl', $release['infourl'] ?? '');
if (!empty($release['infourl'])) {
$infourl->setAttribute('title', 'Release Information');
}
// Downloads section
$downloads = $dom->createElement('downloads');
$update->appendChild($downloads);
$downloadUrl = $this->addElement($dom, $downloads, 'downloadurl', $release['download_url']);
$downloadUrl->setAttribute('type', 'full');
$downloadUrl->setAttribute('format', 'zip');
// Checksums
if (!empty($release['sha256'])) {
$this->addElement($dom, $update, 'sha256', $release['sha256']);
}
if (!empty($release['sha384'])) {
$this->addElement($dom, $update, 'sha384', $release['sha384']);
}
if (!empty($release['sha512'])) {
$this->addElement($dom, $update, 'sha512', $release['sha512']);
}
// Tags
if (!empty($release['tags'])) {
$tags = $dom->createElement('tags');
@@ -209,16 +210,16 @@ class UpdateXmlGenerator
$this->addElement($dom, $tags, 'tag', $tag);
}
}
// Maintainer information
if (!empty($release['maintainer'])) {
$this->addElement($dom, $update, 'maintainer', $release['maintainer']);
}
if (!empty($release['maintainer_url'])) {
$this->addElement($dom, $update, 'maintainerurl', $release['maintainer_url']);
}
// Target platform
if (!empty($release['target_platform'])) {
$targetPlatform = $dom->createElement('targetplatform');
@@ -226,12 +227,12 @@ class UpdateXmlGenerator
$targetPlatform->setAttribute('version', $release['target_platform']);
$update->appendChild($targetPlatform);
}
// Optional: PHP minimum version
if (!empty($release['php_minimum'])) {
$this->addElement($dom, $update, 'php_minimum', $release['php_minimum']);
}
// Add to updates element
if ($prepend && $updates->firstChild) {
$updates->insertBefore($update, $updates->firstChild);
@@ -239,10 +240,10 @@ class UpdateXmlGenerator
$updates->appendChild($update);
}
}
/**
* Add a text element to parent
*
*
* @param DOMDocument $dom DOM document
* @param DOMElement $parent Parent element
* @param string $name Element name
@@ -260,17 +261,17 @@ class UpdateXmlGenerator
$parent->appendChild($element);
return $element;
}
/**
* Derive element name from extension name and type
*
*
* @param string $name Extension name
* @param string $type Extension type
* @return string Element name
*/
private function deriveElement(string $name, string $type): string
{
$prefix = match($type) {
$prefix = match ($type) {
'component' => 'com_',
'module' => 'mod_',
'plugin' => 'plg_',
@@ -279,31 +280,31 @@ class UpdateXmlGenerator
'package' => 'pkg_',
default => '',
};
// Convert name to lowercase and replace spaces with underscores
$element = strtolower(preg_replace('/[^a-z0-9]+/i', '_', $name));
// Add prefix if not already present
if (!str_starts_with($element, $prefix)) {
$element = $prefix . $element;
}
return $element;
}
/**
* Validate updates.xml structure
*
*
* @param string $xmlContent XML content to validate
* @return array Validation result ['valid' => bool, 'errors' => array]
*/
public static function validate(string $xmlContent): array
{
$errors = [];
$dom = new DOMDocument();
libxml_use_internal_errors(true);
if (!$dom->loadXML($xmlContent)) {
foreach (libxml_get_errors() as $error) {
$errors[] = "XML Error: {$error->message}";
@@ -311,20 +312,20 @@ class UpdateXmlGenerator
libxml_clear_errors();
return ['valid' => false, 'errors' => $errors];
}
// Validate structure
$updates = $dom->getElementsByTagName('updates')->item(0);
if (!$updates) {
$errors[] = "Missing <updates> root element";
return ['valid' => false, 'errors' => $errors];
}
$updateElements = $updates->getElementsByTagName('update');
if ($updateElements->length === 0) {
$errors[] = "No <update> elements found";
return ['valid' => false, 'errors' => $errors];
}
// Validate each update entry
foreach ($updateElements as $update) {
$required = ['name', 'element', 'type', 'version'];
@@ -333,12 +334,12 @@ class UpdateXmlGenerator
$errors[] = "Missing required field: <{$field}>";
}
}
// Warn if <client> is missing
if ($update->getElementsByTagName('client')->length === 0) {
$errors[] = "Missing <client> tag — Joomla may not match this update to the installed extension";
}
// Check for download URL
$downloads = $update->getElementsByTagName('downloads');
if ($downloads->length > 0) {
@@ -348,16 +349,16 @@ class UpdateXmlGenerator
}
}
}
return [
'valid' => empty($errors),
'errors' => $errors
];
}
/**
* Extract release information from manifest XML
*
*
* @param string $manifestPath Path to extension manifest XML
* @return array Release information
* @throws Exception If manifest cannot be parsed
@@ -367,14 +368,14 @@ class UpdateXmlGenerator
if (!file_exists($manifestPath)) {
throw new Exception("Manifest file not found: {$manifestPath}");
}
$dom = new DOMDocument();
if (!@$dom->load($manifestPath)) {
throw new Exception("Failed to parse manifest XML: {$manifestPath}");
}
$root = $dom->documentElement;
return [
'name' => self::getElementText($dom, 'name') ?: 'Unknown Extension',
'version' => self::getElementText($dom, 'version') ?: '1.0.0',
@@ -385,10 +386,10 @@ class UpdateXmlGenerator
'target_platform' => self::getElementText($dom, 'version', 'targetplatform') ?: '4.0',
];
}
/**
* Get text content of an element
*
*
* @param DOMDocument $dom DOM document
* @param string $tagName Tag name
* @param string $parentTag Optional parent tag name
@@ -413,7 +414,7 @@ class UpdateXmlGenerator
return trim($elements->item(0)->textContent);
}
}
return null;
}
}
+23 -7
View File
@@ -6,12 +6,14 @@ This file is part of a Moko Consulting project.
SPDX-License-Identifier: GPL-3.0-or-later
-->
<ruleset name="MokoStandards PHP Coding Standards">
<description>PHP_CodeSniffer configuration for MokoStandards projects</description>
<ruleset name="moko-platform PHP Coding Standards">
<description>PHP_CodeSniffer configuration for moko-platform projects</description>
<!-- Files to check -->
<file>api/src</file>
<file>api/tests</file>
<file>lib</file>
<file>validate</file>
<file>automation</file>
<file>cli</file>
<!-- Exclude vendor and other dependencies -->
<exclude-pattern>*/vendor/*</exclude-pattern>
@@ -19,11 +21,25 @@ SPDX-License-Identifier: GPL-3.0-or-later
<exclude-pattern>*/.git/*</exclude-pattern>
<!-- Use PSR-12 as base standard -->
<rule ref="PSR12"/>
<rule ref="PSR12">
<!-- CLI scripts mix declarations and side effects by design -->
<exclude name="PSR1.Files.SideEffects.FoundWithSymbols"/>
<!-- CLI scripts and utility files often lack namespaces -->
<exclude name="PSR1.Classes.ClassDeclaration.MissingNamespace"/>
<!-- Multiple helper classes per file in lib/ is intentional -->
<exclude name="PSR1.Classes.ClassDeclaration.MultipleClasses"/>
<!-- File header ordering is advisory -->
<exclude name="PSR12.Files.FileHeader.IncorrectOrder"/>
<!-- Heredoc closers must match body indentation (tabs) per PHP 7.3+ -->
<exclude name="Generic.WhiteSpace.DisallowTabIndent.TabsUsedHeredocCloser"/>
</rule>
<!-- Additional rules -->
<rule ref="Generic.Arrays.DisallowLongArraySyntax"/>
<rule ref="Generic.CodeAnalysis.EmptyStatement"/>
<rule ref="Generic.CodeAnalysis.EmptyStatement">
<!-- Allow empty catch blocks (used for intentional suppression) -->
<exclude name="Generic.CodeAnalysis.EmptyStatement.DetectedCatch"/>
</rule>
<rule ref="Generic.CodeAnalysis.UnconditionalIfStatement"/>
<rule ref="Generic.CodeAnalysis.UnnecessaryFinalModifier"/>
<rule ref="Generic.Files.LineLength">
@@ -48,7 +64,7 @@ SPDX-License-Identifier: GPL-3.0-or-later
<!-- Show progress and use colors -->
<arg value="p"/>
<arg name="colors"/>
<!-- Show sniff codes in all reports -->
<arg value="s"/>
</ruleset>
File diff suppressed because it is too large Load Diff
+14 -23
View File
@@ -4,32 +4,23 @@
#
# SPDX-License-Identifier: GPL-3.0-or-later
# PHPStan configuration for MokoStandards projects
# PHPStan configuration for moko-platform projects
parameters:
level: 5
level: 6
paths:
- api/src
- api/tests
- lib
- validate
- automation
- cli
excludePaths:
- vendor
- node_modules
# Report unknown classes and functions
analyseAndScan:
- vendor
- node_modules (?)
reportUnmatchedIgnoredErrors: false
# Check for dead code
checkMissingIterableValueType: false
checkGenericClassInNonGenericObjectType: false
# Additional checks
checkAlwaysTrueCheckTypeFunctionCall: true
checkAlwaysTrueInstanceof: true
checkAlwaysTrueStrictComparison: true
checkExplicitMixedMissingReturn: true
checkFunctionNameCase: true
checkInternalClassCaseSensitivity: true
# Ignore common patterns
ignoreErrors:
# Add project-specific ignores here
# - '#Call to an undefined method#'
includes:
- phpstan-baseline.neon
+17
View File
@@ -0,0 +1,17 @@
<?xml version="1.0" encoding="UTF-8"?>
<!--
Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
SPDX-License-Identifier: GPL-3.0-or-later
-->
<phpunit xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:noNamespaceSchemaLocation="vendor/phpunit/phpunit/phpunit.xsd"
bootstrap="vendor/autoload.php"
colors="true"
failOnRisky="true"
failOnWarning="true">
<testsuites>
<testsuite name="Unit">
<directory>tests/Unit</directory>
</testsuite>
</testsuites>
</phpunit>
+35
View File
@@ -0,0 +1,35 @@
{
"name": "exampleclient",
"org": "ExampleClient",
"gitea_url": "https://git.mokoconsulting.tech",
"variables": {
"DEV_SYNC_HOST": "dev.exampleclient.com",
"DEV_SYNC_PORT": "22",
"DEV_SYNC_USER": "exampleclient",
"DEV_SYNC_PATH": "/home/exampleclient/dev.exampleclient.com",
"DEV_SITE_URL": "https://dev.exampleclient.com",
"LIVE_SSH_HOST": "iad1-shared-b7-01.dreamhost.com",
"LIVE_SSH_PORT": "22",
"LIVE_SSH_USER": "exampleclient",
"LIVE_SYNC_PATH": "/home/exampleclient/exampleclient.com",
"RS_FTP_PATH_SUFFIX": "exampleclient.com"
},
"secrets": {
"DEV_SYNC_KEY": "@keys/exampleclient-dev.pem",
"LIVE_SSH_KEY": "@keys/exampleclient-live.pem"
},
"monitoring": {
"urls": [
"https://exampleclient.com",
"https://dev.exampleclient.com"
],
"domains": [
"exampleclient.com"
],
"grafana_dashboard": "monitoring/grafana/client-joomla-dashboard.json",
"grafana_folder": "Clients"
}
}
+141
View File
@@ -0,0 +1,141 @@
<?php
/* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
* SPDX-License-Identifier: GPL-3.0-or-later
*/
declare(strict_types=1);
namespace MokoStandards\Tests\Unit;
use MokoEnterprise\ConfigValidator;
use PHPUnit\Framework\TestCase;
class ConfigValidatorTest extends TestCase
{
private ConfigValidator $validator;
protected function setUp(): void
{
$this->validator = new ConfigValidator();
}
public function testValidConfigPasses(): void
{
$schema = [
'type' => 'object',
'properties' => [
'name' => ['type' => 'string'],
'version' => ['type' => 'string'],
],
'required' => ['name'],
];
$config = ['name' => 'MyProject', 'version' => '1.0'];
$this->assertTrue($this->validator->validate($config, $schema));
$this->assertEmpty($this->validator->getErrors());
}
public function testMissingRequiredField(): void
{
$schema = [
'type' => 'object',
'properties' => [
'name' => ['type' => 'string'],
],
'required' => ['name'],
];
$this->assertFalse($this->validator->validate([], $schema));
$this->assertStringContainsString(
'required',
$this->validator->getErrors()[0]
);
}
public function testEnumValidation(): void
{
$schema = [
'type' => 'object',
'properties' => [
'type' => [
'type' => 'string',
'enum' => ['component', 'module', 'plugin'],
],
],
];
$valid = ['type' => 'component'];
$this->assertTrue($this->validator->validate($valid, $schema));
$invalid = ['type' => 'banana'];
$this->assertFalse($this->validator->validate($invalid, $schema));
}
public function testNestedObjectValidation(): void
{
$schema = [
'type' => 'object',
'properties' => [
'db' => [
'type' => 'object',
'properties' => [
'host' => ['type' => 'string'],
'port' => ['type' => 'integer'],
],
'required' => ['host'],
],
],
];
$valid = ['db' => ['host' => 'localhost', 'port' => 3306]];
$this->assertTrue($this->validator->validate($valid, $schema));
$invalid = ['db' => ['port' => 3306]];
$this->assertFalse($this->validator->validate($invalid, $schema));
}
public function testUnknownPropertiesWarn(): void
{
$schema = [
'type' => 'object',
'properties' => [
'name' => ['type' => 'string'],
],
];
$config = ['name' => 'ok', 'extra' => 'unknown'];
$this->assertTrue($this->validator->validate($config, $schema));
$this->assertNotEmpty($this->validator->getWarnings());
}
public function testTypeMismatch(): void
{
$schema = [
'type' => 'object',
'properties' => [
'count' => ['type' => 'integer'],
],
];
$invalid = ['count' => 'not-a-number'];
$this->assertFalse($this->validator->validate($invalid, $schema));
}
public function testStringMinLength(): void
{
$schema = [
'type' => 'object',
'properties' => [
'name' => ['type' => 'string', 'minLength' => 3],
],
];
$short = ['name' => 'ab'];
$this->assertFalse($this->validator->validate($short, $schema));
$ok = ['name' => 'abc'];
$this->assertTrue($this->validator->validate($ok, $schema));
}
}
+164
View File
@@ -0,0 +1,164 @@
<?php
/* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
* SPDX-License-Identifier: GPL-3.0-or-later
*/
declare(strict_types=1);
namespace MokoStandards\Tests\Unit;
use PHPUnit\Framework\TestCase;
/**
* Tests for cli/version_bump.php
*/
class VersionBumpTest extends TestCase
{
private string $tmpDir;
private string $script;
protected function setUp(): void
{
$this->tmpDir = sys_get_temp_dir() . '/moko-test-' . uniqid();
mkdir($this->tmpDir, 0755, true);
$this->script = dirname(__DIR__, 2) . '/cli/version_bump.php';
}
protected function tearDown(): void
{
$this->rmdir($this->tmpDir);
}
public function testPatchBump(): void
{
$this->writeReadme('01.02.03');
$output = $this->execute();
$this->assertStringContainsString('01.02.04', $output);
$this->assertReadmeVersion('01.02.04');
}
public function testPatchBumpRollover(): void
{
$this->writeReadme('01.02.99');
$this->execute();
$this->assertReadmeVersion('01.03.00');
}
public function testMinorBump(): void
{
$this->writeReadme('01.02.03');
$this->execute(['--minor']);
$this->assertReadmeVersion('01.03.00');
}
public function testMajorBump(): void
{
$this->writeReadme('01.02.03');
$this->execute(['--major']);
$this->assertReadmeVersion('02.00.00');
}
public function testBumpsFromHtmlComment(): void
{
file_put_contents(
"{$this->tmpDir}/README.md",
"<!-- VERSION: 03.05.01 -->\nSome content\n"
);
$this->execute();
$content = file_get_contents("{$this->tmpDir}/README.md");
$this->assertStringContainsString('03.05.02', $content);
$this->assertStringContainsString('Some content', $content);
}
public function testBumpsWhenXmlHasSuffix(): void
{
$this->writeReadme('01.00.00');
mkdir("{$this->tmpDir}/src", 0755, true);
file_put_contents(
"{$this->tmpDir}/src/test.xml",
'<extension type="component">'
. '<version>01.00.00-dev</version></extension>'
);
$output = $this->execute();
$this->assertStringContainsString('01.00.01', $output);
}
public function testFailsWithNoVersion(): void
{
file_put_contents(
"{$this->tmpDir}/README.md",
"# No version\n"
);
$code = 0;
$this->execute([], $code);
$this->assertSame(1, $code);
}
private function writeReadme(string $version): void
{
file_put_contents(
"{$this->tmpDir}/README.md",
"<!-- VERSION: {$version} -->\n"
);
}
private function assertReadmeVersion(string $expected): void
{
$content = file_get_contents("{$this->tmpDir}/README.md");
$this->assertMatchesRegularExpression(
'/VERSION:\s*' . preg_quote($expected, '/') . '/',
$content
);
}
/**
* @param string[] $extraArgs
*/
private function execute(
array $extraArgs = [],
int &$exitCode = 0
): string {
$cmd = ['php', $this->script, '--path', $this->tmpDir];
$cmd = array_merge($cmd, $extraArgs);
$descriptors = [
1 => ['pipe', 'w'],
2 => ['pipe', 'w'],
];
$proc = proc_open($cmd, $descriptors, $pipes);
$stdout = stream_get_contents($pipes[1]);
fclose($pipes[1]);
fclose($pipes[2]);
$exitCode = proc_close($proc);
return $stdout ?: '';
}
private function rmdir(string $dir): void
{
if (!is_dir($dir)) {
return;
}
$iter = new \RecursiveIteratorIterator(
new \RecursiveDirectoryIterator(
$dir,
\FilesystemIterator::SKIP_DOTS
),
\RecursiveIteratorIterator::CHILD_FIRST
);
foreach ($iter as $file) {
$file->isDir()
? rmdir($file->getPathname())
: unlink($file->getPathname());
}
rmdir($dir);
}
}
+133
View File
@@ -0,0 +1,133 @@
<?php
/* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
* SPDX-License-Identifier: GPL-3.0-or-later
*/
declare(strict_types=1);
namespace MokoStandards\Tests\Unit;
use PHPUnit\Framework\TestCase;
/**
* Tests for cli/version_read.php
*/
class VersionReadTest extends TestCase
{
private string $tmpDir;
private string $script;
protected function setUp(): void
{
$this->tmpDir = sys_get_temp_dir() . '/moko-test-' . uniqid();
mkdir($this->tmpDir, 0755, true);
$this->script = dirname(__DIR__, 2) . '/cli/version_read.php';
}
protected function tearDown(): void
{
$this->rmdir($this->tmpDir);
}
public function testReadsVersionFromReadme(): void
{
file_put_contents(
"{$this->tmpDir}/README.md",
"# Test\n<!-- VERSION: 02.03.04 -->\n"
);
$this->assertSame('02.03.04', trim($this->runScript()));
}
public function testReadsVersionFromXmlManifest(): void
{
mkdir("{$this->tmpDir}/src", 0755, true);
file_put_contents(
"{$this->tmpDir}/src/test.xml",
'<extension type="component">'
. '<version>05.01.00</version></extension>'
);
$this->assertSame('05.01.00', trim($this->runScript()));
}
public function testStripsStabilitySuffixFromXml(): void
{
mkdir("{$this->tmpDir}/src", 0755, true);
file_put_contents(
"{$this->tmpDir}/src/test.xml",
'<extension type="component">'
. '<version>01.00.00-dev</version></extension>'
);
$this->assertSame('01.00.00', trim($this->runScript()));
}
public function testReturnsHigherOfReadmeAndManifest(): void
{
file_put_contents(
"{$this->tmpDir}/README.md",
"<!-- VERSION: 01.02.00 -->\n"
);
mkdir("{$this->tmpDir}/src", 0755, true);
file_put_contents(
"{$this->tmpDir}/src/test.xml",
'<extension type="component">'
. '<version>01.03.00</version></extension>'
);
$this->assertSame('01.03.00', trim($this->runScript()));
}
public function testExitsNonZeroWhenNoVersion(): void
{
file_put_contents(
"{$this->tmpDir}/README.md",
"# No version here\n"
);
$code = 0;
$this->runScript($code);
$this->assertSame(1, $code);
}
private function runScript(int &$exitCode = 0): string
{
$proc = proc_open(
['php', $this->script, '--path', $this->tmpDir],
[1 => ['pipe', 'w'], 2 => ['pipe', 'w']],
$pipes
);
$stdout = stream_get_contents($pipes[1]);
fclose($pipes[1]);
fclose($pipes[2]);
$exitCode = proc_close($proc);
return $stdout ?: '';
}
private function rmdir(string $dir): void
{
if (!is_dir($dir)) {
return;
}
$iter = new \RecursiveIteratorIterator(
new \RecursiveDirectoryIterator(
$dir,
\FilesystemIterator::SKIP_DOTS
),
\RecursiveIteratorIterator::CHILD_FIRST
);
foreach ($iter as $file) {
$file->isDir()
? rmdir($file->getPathname())
: unlink($file->getPathname());
}
rmdir($dir);
}
}
+200 -153
View File
@@ -1,5 +1,6 @@
#!/usr/bin/env php
<?php
/**
* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
*
@@ -21,7 +22,7 @@ require_once __DIR__ . '/../../vendor/autoload.php';
require_once __DIR__ . '/../lib/Enterprise/CliFramework.php';
use MokoEnterprise\{
CLIApp,
CliFramework,
ProjectTypeDetector,
PluginFactory,
PluginRegistry,
@@ -31,17 +32,17 @@ use MokoEnterprise\{
/**
* Automatic Platform Detection and Validation
*
*
* Detects whether a repository is a Joomla/WaaS component, Dolibarr/CRM module,
* or generic repository, then validates against appropriate schema
*/
class AutoDetectPlatform extends CLIApp
class AutoDetectPlatform extends CliFramework
{
private const DETECTION_THRESHOLD = 0.5; // 50% confidence required
private ProjectTypeDetector $typeDetector;
private PluginFactory $pluginFactory;
private array $detectionResults = [
'client' => ['score' => 0, 'indicators' => []],
'joomla' => ['score' => 0, 'indicators' => []],
@@ -56,63 +57,62 @@ class AutoDetectPlatform extends CLIApp
'documentation' => ['score' => 0, 'indicators' => []],
'generic' => ['score' => 0, 'indicators' => []],
];
private string $detectedPlatform = 'generic';
private string $schemaFile = '';
private ?object $detectedPlugin = null;
protected function setupArguments(): array
protected function configure(): void
{
return [
'repo-path:' => 'Path to repository to analyze (default: current directory)',
'schema-dir:' => 'Path to schema definitions directory (default: definitions/default)',
'output-dir:' => 'Directory for output reports (default: var/logs/validation)',
];
$this->setDescription('Automatically detect platform type and validate repository');
$this->addArgument('--repo-path', 'Path to repository to analyze', '.');
$this->addArgument('--schema-dir', 'Path to schema definitions directory', 'definitions/default');
$this->addArgument('--output-dir', 'Directory for output reports', 'var/logs/validation');
}
protected function run(): int
{
$repoPath = $this->getOption('repo-path', '.');
$schemaDir = $this->getOption('schema-dir', 'definitions/default');
$outputDir = $this->getOption('output-dir', 'var/logs/validation');
$repoPath = $this->getArgument('--repo-path', '.');
$schemaDir = $this->getArgument('--schema-dir', 'definitions/default');
$outputDir = $this->getArgument('--output-dir', 'var/logs/validation');
// Make paths absolute
$repoPath = $this->getAbsolutePath($repoPath);
$schemaDir = $this->getAbsolutePath($schemaDir);
$outputDir = $this->getAbsolutePath($outputDir);
if (!is_dir($repoPath)) {
$this->log("Repository path not found: {$repoPath}", 'ERROR');
return 3;
}
if (!is_dir($schemaDir)) {
$this->log("Schema directory not found: {$schemaDir}", 'ERROR');
return 3;
}
$this->log("Analyzing repository: {$repoPath}", 'INFO');
// Initialize plugin system
$logger = new AuditLogger('auto_detect_platform');
$metrics = new MetricsCollector();
$this->pluginFactory = new PluginFactory($logger, $metrics);
$this->typeDetector = new ProjectTypeDetector($logger);
// Use the new plugin system for detection
$this->log("Using ProjectTypeDetector for platform detection", 'INFO');
$detectionResult = $this->typeDetector->detectProjectType($repoPath);
$detectionResult = $this->typeDetector->detect($repoPath);
if (!empty($detectionResult['type'])) {
$this->detectedPlatform = $detectionResult['type'];
$this->log("Detected platform via plugin system: {$this->detectedPlatform}", 'INFO');
// Try to get the plugin for this type
$this->detectedPlugin = $this->pluginFactory->createForProject($repoPath);
if ($this->detectedPlugin) {
$this->log("Loaded plugin: {$this->detectedPlugin->getPluginName()}", 'INFO');
// Update detection results with plugin info
$this->detectionResults[$this->detectedPlatform] = [
'score' => $detectionResult['confidence'] ?? 1.0,
@@ -122,7 +122,7 @@ class AutoDetectPlatform extends CLIApp
} else {
// Fallback to legacy detection if plugin system doesn't detect anything
$this->log("Plugin system did not detect type, using legacy detection", 'WARNING');
// Run platform detection using legacy methods
// Client must run BEFORE Joomla — client repos contain Joomla dirs
// but are NOT Joomla extensions
@@ -140,46 +140,76 @@ class AutoDetectPlatform extends CLIApp
// Determine platform
$this->determinePlatform();
}
// Map to schema file
$this->schemaFile = $this->mapPlatformToSchema($schemaDir);
if (!file_exists($this->schemaFile)) {
$this->log("Schema file not found: {$this->schemaFile}", 'ERROR');
return 3;
}
// Output results
if ($this->jsonOutput) {
if ($this->getArgument("--json", false)) {
$this->outputJson();
} else {
$this->displayResults();
}
// Generate reports
$this->generateReports($outputDir, $repoPath);
$this->log("Platform detection completed: {$this->detectedPlatform}", 'INFO');
$this->log("Schema file: {$this->schemaFile}", 'INFO');
if ($this->detectedPlugin) {
$this->log("Plugin available for validation and health checks", 'INFO');
}
return 0;
}
/**
* Detect client site repository.
* Client repos have src/ with Joomla site structure PLUS deployment
* configs (sftp-config/, monitoring/). They are NOT Joomla extensions.
* Client repos have either:
* (a) src/ with Joomla site structure + deployment configs (legacy)
* (b) src/templateDetails.xml with type="file" (theme package)
* They are NOT Joomla extensions (component/module/plugin/template).
*/
private function detectClient(string $repoPath): void
{
$score = 0;
$indicators = [];
// Strong indicators: deployment/monitoring configs
// Strong indicator: type="file" manifest (client theme package)
$manifests = glob($repoPath . '/src/*.xml') ?: [];
$isFilePackage = false;
foreach ($manifests as $xml) {
$content = @file_get_contents($xml);
if ($content && preg_match('/<extension\s+[^>]*type="file"/', $content)) {
$score += 60;
$indicators[] = 'Found Joomla type="file" manifest (theme package)';
$isFilePackage = true;
break;
}
}
// Theme package files
$themeMarkers = [
'src/media/templates/site/mokoonyx/css/theme/light.custom.css' => 15,
'src/media/templates/site/mokoonyx/css/theme/dark.custom.css' => 15,
'src/script.php' => 10,
'updates.xml' => 10,
];
foreach ($themeMarkers as $path => $weight) {
$full = $repoPath . '/' . $path;
if (is_file($full)) {
$score += $weight;
$indicators[] = "Found: {$path} (+{$weight})";
}
}
// Legacy indicators: deployment/monitoring configs
$clientMarkers = [
'scripts/sftp-config' => 30,
'scripts/sftp-config/sftp-config.dev.json' => 10,
@@ -198,7 +228,7 @@ class AutoDetectPlatform extends CLIApp
}
}
// Site structure inside src/ (not at root — that would be a Joomla extension)
// Legacy: site structure inside src/
$siteDirs = ['src/administrator', 'src/components', 'src/plugins', 'src/templates', 'src/media'];
$siteDirCount = 0;
foreach ($siteDirs as $dir) {
@@ -211,14 +241,15 @@ class AutoDetectPlatform extends CLIApp
$indicators[] = "Joomla site structure in src/ ({$siteDirCount}/5 dirs)";
}
// Negative: if there's a Joomla manifest XML in src/, it's an extension not a client
$manifests = glob($repoPath . '/src/*.xml');
foreach ($manifests ?: [] as $xml) {
$content = @file_get_contents($xml);
if ($content && preg_match('/<extension\s+type="(component|module|plugin|template|package)"/', $content)) {
$score -= 50;
$indicators[] = "Has Joomla extension manifest — likely extension, not client";
break;
// Negative: if there's a Joomla extension manifest (not type="file"), it's an extension
if (!$isFilePackage) {
foreach ($manifests as $xml) {
$content = @file_get_contents($xml);
if ($content && preg_match('/<extension\s+[^>]*type="(component|module|plugin|template|package)"/', $content)) {
$score -= 50;
$indicators[] = "Has Joomla extension manifest — likely extension, not client";
break;
}
}
}
@@ -232,20 +263,22 @@ class AutoDetectPlatform extends CLIApp
{
$score = 0;
$indicators = [];
// Look for Joomla manifest files
$manifests = $this->findFiles($repoPath, '*.xml', 3);
foreach ($manifests as $manifest) {
$content = @file_get_contents($manifest);
if ($content && (
if (
$content && (
strpos($content, '<extension') !== false ||
strpos($content, '<install') !== false
)) {
)
) {
$score += 0.3;
$indicators[] = "Found Joomla manifest: " . basename($manifest);
}
}
// Check for Joomla directory structure
$joomlaDirs = ['site', 'admin', 'administrator', 'language', 'media'];
foreach ($joomlaDirs as $dir) {
@@ -254,25 +287,25 @@ class AutoDetectPlatform extends CLIApp
$indicators[] = "Found Joomla directory: {$dir}/";
}
}
// Check for index.html files (Joomla security pattern)
$indexCount = count($this->findFiles($repoPath, 'index.html', 2));
if ($indexCount > 2) {
$score += 0.2;
$indicators[] = "Found {$indexCount} index.html files (Joomla pattern)";
}
$this->detectionResults['joomla'] = [
'score' => min(1.0, $score),
'indicators' => $indicators,
];
}
private function detectDolibarr(string $repoPath): void
{
$score = 0;
$indicators = [];
// Look for Dolibarr module descriptor
$descriptors = $this->findFiles($repoPath, 'mod*.class.php', 3);
foreach ($descriptors as $descriptor) {
@@ -282,15 +315,17 @@ class AutoDetectPlatform extends CLIApp
$indicators[] = "Found Dolibarr module descriptor: " . basename($descriptor);
}
}
// Check for Dolibarr-specific code patterns
$phpFiles = $this->findFiles($repoPath, '*.php', 3);
$dolibarrPatterns = ['dol_include_once', '$this->numero', 'DoliDB', 'Translate'];
foreach ($phpFiles as $file) {
$content = @file_get_contents($file);
if (!$content) continue;
if (!$content) {
continue;
}
foreach ($dolibarrPatterns as $pattern) {
if (strpos($content, $pattern) !== false) {
$score += 0.05;
@@ -298,10 +333,12 @@ class AutoDetectPlatform extends CLIApp
break; // Only count once per file
}
}
if ($score >= 0.8) break; // Stop early if confident
if ($score >= 0.8) {
break; // Stop early if confident
}
}
// Check for Dolibarr directory structure
$dolibarrDirs = ['core/modules', 'sql', 'class', 'lib', 'langs'];
foreach ($dolibarrDirs as $dir) {
@@ -310,7 +347,7 @@ class AutoDetectPlatform extends CLIApp
$indicators[] = "Found Dolibarr directory: {$dir}/";
}
}
// Check for SQL files in sql/ directory
if (is_dir("{$repoPath}/sql")) {
$sqlFiles = $this->findFiles("{$repoPath}/sql", '*.sql', 1);
@@ -319,89 +356,93 @@ class AutoDetectPlatform extends CLIApp
$indicators[] = "Found " . count($sqlFiles) . " SQL files in sql/";
}
}
$this->detectionResults['dolibarr'] = [
'score' => min(1.0, $score),
'indicators' => $indicators,
];
}
private function detectNodeJS(string $repoPath): void
{
$score = 0;
$indicators = [];
// Check for package.json
if (file_exists("{$repoPath}/package.json")) {
$score += 0.5;
$indicators[] = "Found package.json";
$content = @file_get_contents("{$repoPath}/package.json");
if ($content) {
if (strpos($content, '"typescript"') !== false || strpos($content, '"@types/') !== false) {
$score += 0.1;
$indicators[] = "TypeScript dependencies detected";
}
if (strpos($content, '"react"') !== false || strpos($content, '"vue"') !== false ||
strpos($content, '"angular"') !== false || strpos($content, '"express"') !== false) {
if (
strpos($content, '"react"') !== false || strpos($content, '"vue"') !== false ||
strpos($content, '"angular"') !== false || strpos($content, '"express"') !== false
) {
$score += 0.1;
$indicators[] = "Node.js framework detected";
}
}
}
// Check for node_modules and lock files
if (is_dir("{$repoPath}/node_modules")) {
$score += 0.1;
$indicators[] = "Found node_modules directory";
}
if (file_exists("{$repoPath}/package-lock.json") || file_exists("{$repoPath}/yarn.lock") ||
file_exists("{$repoPath}/pnpm-lock.yaml") || file_exists("{$repoPath}/bun.lockb")) {
if (
file_exists("{$repoPath}/package-lock.json") || file_exists("{$repoPath}/yarn.lock") ||
file_exists("{$repoPath}/pnpm-lock.yaml") || file_exists("{$repoPath}/bun.lockb")
) {
$score += 0.1;
$indicators[] = "Found package lock file";
}
// Check for TypeScript config
if (file_exists("{$repoPath}/tsconfig.json")) {
$score += 0.2;
$indicators[] = "Found tsconfig.json";
}
$this->detectionResults['nodejs'] = [
'score' => min(1.0, $score),
'indicators' => $indicators,
];
}
private function detectPython(string $repoPath): void
{
$score = 0;
$indicators = [];
// Check for Python package files
if (file_exists("{$repoPath}/setup.py") || file_exists("{$repoPath}/pyproject.toml")) {
$score += 0.5;
$indicators[] = "Found Python package configuration";
}
if (file_exists("{$repoPath}/requirements.txt")) {
$score += 0.2;
$indicators[] = "Found requirements.txt";
}
if (file_exists("{$repoPath}/Pipfile") || file_exists("{$repoPath}/poetry.lock")) {
$score += 0.2;
$indicators[] = "Found Python dependency manager config";
}
// Check for Python files
$pyFiles = $this->findFiles($repoPath, '*.py', 2);
if (count($pyFiles) > 0) {
$score += 0.2;
$indicators[] = "Found " . count($pyFiles) . " Python files";
}
// Check for virtual environment directories
$venvDirs = ['venv', '.venv', 'env', '.env'];
foreach ($venvDirs as $dir) {
@@ -411,44 +452,44 @@ class AutoDetectPlatform extends CLIApp
break;
}
}
$this->detectionResults['python'] = [
'score' => min(1.0, $score),
'indicators' => $indicators,
];
}
private function detectTerraform(string $repoPath): void
{
$score = 0;
$indicators = [];
// Check for Terraform files
$tfFiles = $this->findFiles($repoPath, '*.tf', 3);
if (count($tfFiles) > 0) {
$score += 0.5;
$indicators[] = "Found " . count($tfFiles) . " Terraform files";
}
// Check for terraform.tfvars or *.tfvars
$tfvarsFiles = $this->findFiles($repoPath, '*.tfvars', 2);
if (count($tfvarsFiles) > 0) {
$score += 0.2;
$indicators[] = "Found Terraform variables files";
}
// Check for .terraform directory
if (is_dir("{$repoPath}/.terraform")) {
$score += 0.1;
$indicators[] = "Found .terraform directory";
}
// Check for terraform.lock.hcl
if (file_exists("{$repoPath}/.terraform.lock.hcl")) {
$score += 0.1;
$indicators[] = "Found Terraform lock file";
}
// Check for main.tf, variables.tf, outputs.tf (common pattern)
$commonFiles = ['main.tf', 'variables.tf', 'outputs.tf'];
$foundCommon = 0;
@@ -461,36 +502,40 @@ class AutoDetectPlatform extends CLIApp
$score += 0.2;
$indicators[] = "Found standard Terraform structure";
}
$this->detectionResults['terraform'] = [
'score' => min(1.0, $score),
'indicators' => $indicators,
];
}
private function detectWordPress(string $repoPath): void
{
$score = 0;
$indicators = [];
// Check for plugin header
$phpFiles = $this->findFiles($repoPath, '*.php', 2);
foreach ($phpFiles as $file) {
$content = @file_get_contents($file);
if ($content && (strpos($content, 'Plugin Name:') !== false ||
strpos($content, 'Theme Name:') !== false)) {
if (
$content && (strpos($content, 'Plugin Name:') !== false ||
strpos($content, 'Theme Name:') !== false)
) {
$score += 0.5;
$indicators[] = "Found WordPress plugin/theme header in " . basename($file);
break;
}
}
// Check for WordPress functions
$wpFunctions = ['add_action', 'add_filter', 'wp_enqueue_script', 'register_activation_hook'];
foreach ($phpFiles as $file) {
$content = @file_get_contents($file);
if (!$content) continue;
if (!$content) {
continue;
}
foreach ($wpFunctions as $func) {
if (strpos($content, $func) !== false) {
$score += 0.1;
@@ -499,7 +544,7 @@ class AutoDetectPlatform extends CLIApp
}
}
}
// Check for WordPress directory structure
$wpDirs = ['includes', 'templates', 'assets'];
foreach ($wpDirs as $dir) {
@@ -508,18 +553,18 @@ class AutoDetectPlatform extends CLIApp
$indicators[] = "Found WordPress directory: {$dir}/";
}
}
$this->detectionResults['wordpress'] = [
'score' => min(1.0, $score),
'indicators' => $indicators,
];
}
private function detectMobile(string $repoPath): void
{
$score = 0;
$indicators = [];
// Check for React Native
if (file_exists("{$repoPath}/package.json")) {
$content = @file_get_contents("{$repoPath}/package.json");
@@ -528,7 +573,7 @@ class AutoDetectPlatform extends CLIApp
$indicators[] = "Found React Native in package.json";
}
}
// Check for Flutter
if (file_exists("{$repoPath}/pubspec.yaml")) {
$content = @file_get_contents("{$repoPath}/pubspec.yaml");
@@ -537,14 +582,14 @@ class AutoDetectPlatform extends CLIApp
$indicators[] = "Found Flutter in pubspec.yaml";
}
}
// Check for iOS project
$xcodeFiles = $this->findFiles($repoPath, '*.xcodeproj', 2);
if (count($xcodeFiles) > 0) {
$score += 0.3;
$indicators[] = "Found Xcode project";
}
// Check for Android project
if (file_exists("{$repoPath}/build.gradle") || file_exists("{$repoPath}/app/build.gradle")) {
$content = @file_get_contents("{$repoPath}/build.gradle") ?: @file_get_contents("{$repoPath}/app/build.gradle");
@@ -553,7 +598,7 @@ class AutoDetectPlatform extends CLIApp
$indicators[] = "Found Android application gradle";
}
}
// Check for mobile directories
$mobileDirs = ['ios', 'android', 'lib'];
$foundCount = 0;
@@ -566,18 +611,18 @@ class AutoDetectPlatform extends CLIApp
$score += 0.2;
$indicators[] = "Found mobile platform directories";
}
$this->detectionResults['mobile'] = [
'score' => min(1.0, $score),
'indicators' => $indicators,
];
}
private function detectAPI(string $repoPath): void
{
$score = 0;
$indicators = [];
// Check for API documentation files
$apiDocs = ['openapi.yaml', 'openapi.json', 'swagger.yaml', 'swagger.json', 'api.yaml'];
foreach ($apiDocs as $doc) {
@@ -587,40 +632,40 @@ class AutoDetectPlatform extends CLIApp
break;
}
}
// Check for GraphQL schema
$graphqlFiles = $this->findFiles($repoPath, '*.graphql', 2);
if (count($graphqlFiles) > 0 || file_exists("{$repoPath}/schema.graphql")) {
$score += 0.3;
$indicators[] = "Found GraphQL schema";
}
// Check for gRPC proto files
$protoFiles = $this->findFiles($repoPath, '*.proto', 2);
if (count($protoFiles) > 0) {
$score += 0.3;
$indicators[] = "Found Protocol Buffer definitions";
}
// Check for Dockerfile (common in microservices)
if (file_exists("{$repoPath}/Dockerfile")) {
$score += 0.1;
$indicators[] = "Found Dockerfile";
}
// Check for docker-compose.yml
if (file_exists("{$repoPath}/docker-compose.yml") || file_exists("{$repoPath}/docker-compose.yaml")) {
$score += 0.1;
$indicators[] = "Found docker-compose configuration";
}
// Check for API patterns in code
$apiFiles = array_merge(
$this->findFiles($repoPath, '*.js', 2),
$this->findFiles($repoPath, '*.ts', 2),
$this->findFiles($repoPath, '*.py', 2)
);
$apiPatterns = [
'@app.route' => 'Flask route',
'@api_view' => 'Django REST framework',
@@ -628,11 +673,13 @@ class AutoDetectPlatform extends CLIApp
'fastapi' => 'FastAPI',
'@Controller' => 'NestJS controller',
];
foreach ($apiFiles as $file) {
$content = @file_get_contents($file);
if (!$content) continue;
if (!$content) {
continue;
}
foreach ($apiPatterns as $pattern => $name) {
if (stripos($content, $pattern) !== false) {
$score += 0.2;
@@ -641,13 +688,13 @@ class AutoDetectPlatform extends CLIApp
}
}
}
$this->detectionResults['api'] = [
'score' => min(1.0, $score),
'indicators' => $indicators,
];
}
private function detectMcpServer(string $repoPath): void
{
$score = 0;
@@ -725,17 +772,17 @@ class AutoDetectPlatform extends CLIApp
// Find platform with highest score above threshold
$maxScore = 0;
$selectedPlatform = 'generic';
foreach ($this->detectionResults as $platform => $data) {
if ($data['score'] >= self::DETECTION_THRESHOLD && $data['score'] > $maxScore) {
$maxScore = $data['score'];
$selectedPlatform = $platform;
}
}
$this->detectedPlatform = $selectedPlatform;
}
private function mapPlatformToSchema(string $schemaDir): string
{
$mapping = [
@@ -752,24 +799,24 @@ class AutoDetectPlatform extends CLIApp
'standards' => 'standards-repository.tf',
'generic' => 'default-repository.tf',
];
return $schemaDir . '/' . $mapping[$this->detectedPlatform];
}
private function displayResults(): void
{
echo "\n=== Platform Detection Results ===\n\n";
echo "Platform: {$this->detectedPlatform}\n";
echo "Schema: {$this->schemaFile}\n\n";
echo "Detection Scores:\n";
foreach ($this->detectionResults as $platform => $data) {
$percentage = round($data['score'] * 100, 1);
$status = ($data['score'] >= self::DETECTION_THRESHOLD) ? '✅' : '❌';
echo sprintf(" %s %s: %.1f%%\n", $status, ucfirst($platform), $percentage);
}
echo "\nDetection Indicators:\n";
$indicators = $this->detectionResults[$this->detectedPlatform]['indicators'];
if (empty($indicators)) {
@@ -779,10 +826,10 @@ class AutoDetectPlatform extends CLIApp
echo "{$indicator}\n";
}
}
echo "\n";
}
private function outputJson(): void
{
$output = [
@@ -793,7 +840,7 @@ class AutoDetectPlatform extends CLIApp
'timestamp' => date('c'),
'plugin_available' => $this->detectedPlugin !== null,
];
if ($this->detectedPlugin) {
$output['plugin_info'] = [
'name' => $this->detectedPlugin->getPluginName(),
@@ -801,55 +848,55 @@ class AutoDetectPlatform extends CLIApp
'type' => $this->detectedPlugin->getProjectType(),
];
}
echo json_encode($output, JSON_PRETTY_PRINT) . PHP_EOL;
}
private function generateReports(string $outputDir, string $repoPath): void
{
// Ensure output directory exists
if (!is_dir($outputDir)) {
@mkdir($outputDir, 0755, true);
}
$timestamp = date('Ymd_His');
// Generate detection report
$detectionReport = $outputDir . "/detection_report_{$timestamp}.md";
$this->writeDetectionReport($detectionReport, $repoPath);
// Generate summary report
$summaryReport = $outputDir . "/SUMMARY_{$timestamp}.md";
$this->writeSummaryReport($summaryReport, $repoPath);
$this->log("Reports generated in: {$outputDir}", 'INFO');
}
private function writeDetectionReport(string $file, string $repoPath): void
{
$content = "# Platform Detection Report\n\n";
$content .= "**Generated**: " . date('Y-m-d H:i:s') . "\n";
$content .= "**Repository**: {$repoPath}\n\n";
$content .= "## Detected Platform\n\n";
$content .= "**Type**: " . strtoupper($this->detectedPlatform) . "\n";
$content .= "**Confidence**: " . round($this->detectionResults[$this->detectedPlatform]['score'] * 100, 1) . "%\n";
$content .= "**Schema**: {$this->schemaFile}\n\n";
$content .= "## Detection Indicators\n\n";
foreach ($this->detectionResults[$this->detectedPlatform]['indicators'] as $indicator) {
$content .= "- {$indicator}\n";
}
$content .= "\n## All Platform Scores\n\n";
foreach ($this->detectionResults as $platform => $data) {
$percentage = round($data['score'] * 100, 1);
$content .= "- **" . ucfirst($platform) . "**: {$percentage}%\n";
}
@file_put_contents($file, $content);
}
private function writeSummaryReport(string $file, string $repoPath): void
{
$content = "# Platform Detection Summary\n\n";
@@ -860,28 +907,28 @@ class AutoDetectPlatform extends CLIApp
$content .= "| Confidence | " . round($this->detectionResults[$this->detectedPlatform]['score'] * 100, 1) . "% |\n";
$content .= "| Schema | " . basename($this->schemaFile) . " |\n";
$content .= "| Timestamp | " . date('Y-m-d H:i:s') . " |\n\n";
$content .= "## Next Steps\n\n";
$content .= "1. Review detection indicators\n";
$content .= "2. Validate repository against schema: {$this->schemaFile}\n";
$content .= "3. Address any validation errors or warnings\n";
@file_put_contents($file, $content);
}
private function findFiles(string $dir, string $pattern, int $maxDepth = 1): array
{
$files = [];
$pattern = str_replace('*', '.*', $pattern);
$pattern = str_replace('.', '\.', $pattern);
try {
$iterator = new RecursiveIteratorIterator(
new RecursiveDirectoryIterator($dir, RecursiveDirectoryIterator::SKIP_DOTS),
RecursiveIteratorIterator::SELF_FIRST
);
$iterator->setMaxDepth($maxDepth);
foreach ($iterator as $file) {
if ($file->isFile() && preg_match("/{$pattern}$/", $file->getFilename())) {
$files[] = $file->getPathname();
@@ -890,20 +937,20 @@ class AutoDetectPlatform extends CLIApp
} catch (Exception $e) {
// Directory not accessible
}
return $files;
}
private function getAbsolutePath(string $path): string
{
if (strlen($path) > 0 && $path[0] === '/') {
return $path;
}
return getcwd() . '/' . $path;
}
}
// Run the application
$app = new AutoDetectPlatform('auto_detect_platform', 'Automatically detect platform type and validate repository');
$app = new AutoDetectPlatform();
exit($app->execute());
+85 -84
View File
@@ -1,5 +1,6 @@
#!/usr/bin/env php
<?php
/* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
*
* This file is part of a Moko Consulting project.
@@ -28,104 +29,104 @@ use MokoEnterprise\CliFramework;
*/
class CheckChangelog extends CliFramework
{
/** Directories searched for CHANGELOG.md, relative to --path (case-insensitive match). */
private const SEARCH_DIRS = ['', 'src', 'docs'];
/** Directories searched for CHANGELOG.md, relative to --path (case-insensitive match). */
private const SEARCH_DIRS = ['', 'src', 'docs'];
/**
* Configure available arguments.
*/
protected function configure(): void
{
$this->setDescription('Validates CHANGELOG.md structure and format');
$this->addArgument('--path', 'Repository path to check', '.');
$this->addArgument('--strict', 'Also require an [Unreleased] section', false);
}
/**
* Configure available arguments.
*/
protected function configure(): void
{
$this->setDescription('Validates CHANGELOG.md structure and format');
$this->addArgument('--path', 'Repository path to check', '.');
$this->addArgument('--strict', 'Also require an [Unreleased] section', false);
}
/**
* Validate CHANGELOG.md.
*
* @return int Exit code: 0 on pass, 1 on failure.
*/
protected function run(): int
{
$path = rtrim($this->getArgument('--path'), '/\\');
$strict = (bool) $this->getArgument('--strict');
/**
* Validate CHANGELOG.md.
*
* @return int Exit code: 0 on pass, 1 on failure.
*/
protected function run(): int
{
$path = rtrim($this->getArgument('--path'), '/\\');
$strict = (bool) $this->getArgument('--strict');
$this->section('Checking CHANGELOG.md');
$this->section('Checking CHANGELOG.md');
$found = $this->findChangelog($path);
$found = $this->findChangelog($path);
if ($found === null) {
$this->status(false, 'CHANGELOG.md found (checked root, src/, docs/)');
$this->printSummary(0, 1, $this->elapsed());
return 1;
}
if ($found === null) {
$this->status(false, 'CHANGELOG.md found (checked root, src/, docs/)');
$this->printSummary(0, 1, $this->elapsed());
return 1;
}
$rel = ltrim(str_replace(str_replace('\\', '/', $path), '', str_replace('\\', '/', $found)), '/');
$this->status(true, "CHANGELOG.md found: {$rel}");
$rel = ltrim(str_replace(str_replace('\\', '/', $path), '', str_replace('\\', '/', $found)), '/');
$this->status(true, "CHANGELOG.md found: {$rel}");
// Error if CHANGELOG exists at root AND in a subdirectory simultaneously
if ($rel !== 'CHANGELOG.md' && is_file($path . '/CHANGELOG.md')) {
$this->status(false, 'CHANGELOG.md duplicate: exists at root AND ' . dirname($rel));
$this->printSummary(0, 1, $this->elapsed());
return 1;
}
// Error if CHANGELOG exists at root AND in a subdirectory simultaneously
if ($rel !== 'CHANGELOG.md' && is_file($path . '/CHANGELOG.md')) {
$this->status(false, 'CHANGELOG.md duplicate: exists at root AND ' . dirname($rel));
$this->printSummary(0, 1, $this->elapsed());
return 1;
}
$content = (string) file_get_contents($found);
$passed = 1;
$failed = 0;
$content = (string) file_get_contents($found);
$passed = 1;
$failed = 0;
// Require Keep a Changelog format (any versioned heading)
if (preg_match('/^## \[/m', $content)) {
$this->status(true, 'Keep a Changelog format (## [...])');
$passed++;
} else {
$this->status(false, 'Keep a Changelog format (## [...]) — no versioned headings found');
$failed++;
}
// Require Keep a Changelog format (any versioned heading)
if (preg_match('/^## \[/m', $content)) {
$this->status(true, 'Keep a Changelog format (## [...])');
$passed++;
} else {
$this->status(false, 'Keep a Changelog format (## [...]) — no versioned headings found');
$failed++;
}
// --strict: also require an [Unreleased] section
if ($strict) {
if (preg_match('/^## \[Unreleased\]/mi', $content)) {
$this->status(true, '[Unreleased] section present');
$passed++;
} else {
$this->status(false, '[Unreleased] section missing (required by --strict)');
$failed++;
}
}
// --strict: also require an [Unreleased] section
if ($strict) {
if (preg_match('/^## \[Unreleased\]/mi', $content)) {
$this->status(true, '[Unreleased] section present');
$passed++;
} else {
$this->status(false, '[Unreleased] section missing (required by --strict)');
$failed++;
}
}
$this->printSummary($passed, $failed, $this->elapsed());
$this->printSummary($passed, $failed, $this->elapsed());
return $failed > 0 ? 1 : 0;
}
return $failed > 0 ? 1 : 0;
}
/**
* Find CHANGELOG.md case-insensitively in root, src/, or docs/.
*
* @param string $repoPath Absolute path to the repository root.
* @return string|null Absolute path to the found file, or null if not found.
*/
private function findChangelog(string $repoPath): ?string
{
foreach (self::SEARCH_DIRS as $sub) {
$dir = $sub === '' ? $repoPath : $repoPath . '/' . $sub;
if (!is_dir($dir)) {
continue;
}
$entries = @scandir($dir);
if ($entries === false) {
continue;
}
foreach ($entries as $entry) {
if (strcasecmp($entry, 'CHANGELOG.md') === 0 && is_file($dir . '/' . $entry)) {
return $dir . '/' . $entry;
}
}
}
/**
* Find CHANGELOG.md case-insensitively in root, src/, or docs/.
*
* @param string $repoPath Absolute path to the repository root.
* @return string|null Absolute path to the found file, or null if not found.
*/
private function findChangelog(string $repoPath): ?string
{
foreach (self::SEARCH_DIRS as $sub) {
$dir = $sub === '' ? $repoPath : $repoPath . '/' . $sub;
if (!is_dir($dir)) {
continue;
}
$entries = @scandir($dir);
if ($entries === false) {
continue;
}
foreach ($entries as $entry) {
if (strcasecmp($entry, 'CHANGELOG.md') === 0 && is_file($dir . '/' . $entry)) {
return $dir . '/' . $entry;
}
}
}
return null;
}
return null;
}
}
$script = new CheckChangelog('check_changelog', 'Validates CHANGELOG.md structure and format');
+279
View File
@@ -0,0 +1,279 @@
#!/usr/bin/env php
<?php
/* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
*
* This file is part of a Moko Consulting project.
*
* SPDX-License-Identifier: GPL-3.0-or-later
*
* FILE INFORMATION
* DEFGROUP: MokoStandards.Scripts.Validate
* INGROUP: MokoStandards
* REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform
* PATH: /validate/check_client_theme.php
* BRIEF: Validates client WaaS theme packages (Joomla type="file")
*/
declare(strict_types=1);
require_once __DIR__ . '/../../vendor/autoload.php';
use MokoEnterprise\CliFramework;
/**
* Validates client theme packages that deliver CSS, JS, and images
* to the MokoOnyx template via Joomla's file package installer.
*
* Checks:
* - Manifest structure (src/templateDetails.xml)
* - Required elements: name, element, version, updateservers, scriptfile, fileset
* - Extension type is "file" with method="upgrade"
* - Version format (XX.YY.ZZ)
* - Required theme files (light.custom.css, dark.custom.css)
* - PHP syntax of script.php
* - CSS brace balance
* - updates.xml at repo root
* - Image size warnings
*/
class CheckClientTheme extends CliFramework
{
/** Required XML elements in the manifest. */
private const REQUIRED_ELEMENTS = ['name', 'element', 'version'];
/** Recommended XML elements. */
private const RECOMMENDED_ELEMENTS = ['updateservers', 'scriptfile', 'description', 'fileset'];
/** Required theme CSS files relative to repo root. */
private const REQUIRED_THEME_FILES = [
'src/media/templates/site/mokoonyx/css/theme/light.custom.css',
'src/media/templates/site/mokoonyx/css/theme/dark.custom.css',
];
/** Optional but expected files. */
private const EXPECTED_FILES = [
'src/media/templates/site/mokoonyx/css/user.css',
'src/media/templates/site/mokoonyx/js/user.js',
'src/script.php',
'updates.xml',
];
/** Maximum image size before warning (1 MB). */
private const IMAGE_WARN_SIZE = 1048576;
/**
* Configure available arguments.
*/
protected function configure(): void
{
$this->setDescription('Validates client WaaS theme packages (type="file")');
$this->addArgument('--path', 'Repository path to check', '.');
}
/**
* Run all validation checks.
*/
protected function run(): int
{
$path = rtrim($this->getArgument('--path'), '/');
$errors = 0;
$warns = 0;
// ── Manifest ──────────────────────────────────────────
$this->section('Manifest validation');
$manifest = $path . '/src/templateDetails.xml';
if (!is_file($manifest)) {
$this->status(false, 'Missing src/templateDetails.xml');
$this->printSummary(0, 1, $this->elapsed());
return 1;
}
$content = (string) file_get_contents($manifest);
// Extension type
if (preg_match('/type="([^"]*)"/', $content, $m)) {
if ($m[1] !== 'file') {
$this->status(false, "Extension type is '{$m[1]}', expected 'file'");
$errors++;
} else {
$this->status(true, 'Extension type: file');
}
} else {
$this->status(false, 'No type attribute on <extension>');
$errors++;
}
// method="upgrade"
if (str_contains($content, 'method="upgrade"')) {
$this->status(true, 'method="upgrade" present');
} else {
$this->warning('Missing method="upgrade" — updates may fail');
$warns++;
}
// Required elements
foreach (self::REQUIRED_ELEMENTS as $el) {
if (str_contains($content, "<{$el}>")) {
$this->status(true, "<{$el}> present");
} else {
$this->status(false, "Missing <{$el}>");
$errors++;
}
}
// Recommended elements
foreach (self::RECOMMENDED_ELEMENTS as $el) {
if (!str_contains($content, "<{$el}>") && !str_contains($content, "<{$el} ")) {
$this->warning("Missing <{$el}>");
$warns++;
}
}
// Version format
if (preg_match('/<version>([^<]+)<\/version>/', $content, $m)) {
$version = $m[1];
if (preg_match('/^\d{2}\.\d{2}\.\d{2}$/', $version)) {
$this->status(true, "Version: {$version}");
} else {
$this->status(false, "Version '{$version}' does not match XX.YY.ZZ format");
$errors++;
}
}
// ── Required files ────────────────────────────────────
$this->section('Required files');
foreach (self::REQUIRED_THEME_FILES as $file) {
$full = $path . '/' . $file;
if (is_file($full)) {
$this->status(true, basename($file));
} else {
$this->status(false, "Missing: {$file}");
$errors++;
}
}
foreach (self::EXPECTED_FILES as $file) {
$full = $path . '/' . $file;
if (is_file($full)) {
$this->status(true, basename($file));
} else {
$this->warning("Missing: {$file}");
$warns++;
}
}
// ── PHP syntax ────────────────────────────────────────
$this->section('PHP syntax');
$phpFiles = glob($path . '/src/*.php') ?: [];
foreach ($phpFiles as $phpFile) {
$output = [];
$ret = 0;
$escaped = escapeshellarg($phpFile);
exec("php -l {$escaped} 2>&1", $output, $ret);
if ($ret !== 0) {
$this->status(false, 'Syntax error: ' . basename($phpFile));
$errors++;
} else {
$this->status(true, basename($phpFile));
}
}
if (empty($phpFiles)) {
$this->warning('No PHP files in src/');
}
// ── CSS validation ────────────────────────────────────
$this->section('CSS validation');
$cssFiles = array_merge(
glob($path . '/src/media/templates/site/mokoonyx/css/theme/*.css') ?: [],
glob($path . '/src/media/templates/site/mokoonyx/css/*.css') ?: [],
);
foreach ($cssFiles as $cssFile) {
$css = (string) file_get_contents($cssFile);
$open = substr_count($css, '{');
$close = substr_count($css, '}');
$name = str_replace($path . '/src/', '', $cssFile);
if ($open !== $close) {
$this->status(false, "Unbalanced braces in {$name} (open: {$open}, close: {$close})");
$errors++;
} else {
$this->status(true, "{$name} ({$open} rules)");
}
// BOM check
if (str_starts_with($css, "\xEF\xBB\xBF")) {
$this->status(false, "BOM detected in {$name}");
$errors++;
}
}
// ── Version consistency ───────────────────────────────
$this->section('Version consistency');
$manifestVer = '';
if (preg_match('/<version>([^<]+)<\/version>/', $content, $m)) {
$manifestVer = $m[1];
}
$updatesFile = $path . '/updates.xml';
if (is_file($updatesFile)) {
$updatesContent = (string) file_get_contents($updatesFile);
if (preg_match('/<version>([^<]+)<\/version>/', $updatesContent, $m)) {
if ($m[1] !== $manifestVer) {
$this->warning("Version drift: manifest={$manifestVer}, updates.xml={$m[1]}");
$warns++;
} else {
$this->status(true, "Versions match: {$manifestVer}");
}
}
}
if (is_file($path . '/CHANGELOG.md')) {
$cl = (string) file_get_contents($path . '/CHANGELOG.md');
if (!str_contains($cl, "[{$manifestVer}]")) {
$this->warning("Version {$manifestVer} not in CHANGELOG.md");
$warns++;
} else {
$this->status(true, "CHANGELOG has [{$manifestVer}]");
}
}
// ── Image sizes ───────────────────────────────────────
$this->section('Image optimization');
$largeImages = 0;
$imageDir = $path . '/src/images';
if (is_dir($imageDir)) {
$iter = new \RecursiveIteratorIterator(
new \RecursiveDirectoryIterator($imageDir, \FilesystemIterator::SKIP_DOTS)
);
foreach ($iter as $file) {
if (!$file->isFile()) {
continue;
}
$ext = strtolower($file->getExtension());
if (!in_array($ext, ['png', 'jpg', 'jpeg', 'gif', 'webp', 'svg'])) {
continue;
}
if ($file->getSize() > self::IMAGE_WARN_SIZE) {
$kb = (int) ($file->getSize() / 1024);
$this->warning("{$kb}KB: " . str_replace($path . '/', '', $file->getPathname()));
$largeImages++;
}
}
}
if ($largeImages > 0) {
$this->warning("{$largeImages} image(s) over 1MB — consider optimizing");
} else {
$this->status(true, 'All images under 1MB');
}
// ── Summary ───────────────────────────────────────────
$passed = ($errors === 0) ? 1 : 0;
$this->printSummary($passed, $errors, $this->elapsed());
return ($errors > 0) ? 1 : 0;
}
}
$script = new CheckClientTheme('check_client_theme', 'Validates client WaaS theme packages');
exit($script->execute());
+104 -93
View File
@@ -1,5 +1,6 @@
#!/usr/bin/env php
<?php
/* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
*
* This file is part of a Moko Consulting project.
@@ -28,26 +29,30 @@ $org = 'mokoconsulting-tech';
$repoName = null;
foreach ($argv as $i => $arg) {
if ($arg === '--repo' && isset($argv[$i + 1])) { $repoName = $argv[$i + 1]; }
if ($arg === '--org' && isset($argv[$i + 1])) { $org = $argv[$i + 1]; }
if ($arg === '--repo' && isset($argv[$i + 1])) {
$repoName = $argv[$i + 1];
}
if ($arg === '--org' && isset($argv[$i + 1])) {
$org = $argv[$i + 1];
}
}
if (!$repoName && !$allMode) {
fwrite(STDERR, "Usage: php check_composer_deps.php --repo <name> | --all [--json]\n");
exit(2);
fwrite(STDERR, "Usage: php check_composer_deps.php --repo <name> | --all [--json]\n");
exit(2);
}
$config = \MokoEnterprise\Config::load();
try {
$_adapter = \MokoEnterprise\PlatformAdapterFactory::create($config);
$_api = $_adapter->getApiClient();
$_adapter = \MokoEnterprise\PlatformAdapterFactory::create($config);
$_api = $_adapter->getApiClient();
} catch (\Exception $e) {
fwrite(STDERR, "Platform init failed: " . $e->getMessage() . "\n");
exit(1);
fwrite(STDERR, "Platform init failed: " . $e->getMessage() . "\n");
exit(1);
}
$token = $config->getString('platform', 'gitea') === 'gitea'
? $config->getString('gitea.token', '')
: $config->getString('github.token', '');
? $config->getString('gitea.token', '')
: $config->getString('github.token', '');
$EXPECTED_VERSION = '04.02.30';
$EXPECTED_DEP = "dev-version/{$EXPECTED_VERSION}";
@@ -61,13 +66,13 @@ $ALWAYS_EXCLUDE = ['MokoStandards', '.github-private'];
*/
function apiGet(string $path, string $token): array
{
global $_api;
try {
$result = $_api->get("/{$path}");
return [200, $result];
} catch (\Exception $e) {
return [500, ['message' => $e->getMessage()]];
}
global $_api;
try {
$result = $_api->get("/{$path}");
return [200, $result];
} catch (\Exception $e) {
return [500, ['message' => $e->getMessage()]];
}
}
/**
@@ -77,29 +82,31 @@ function apiGet(string $path, string $token): array
*/
function fetchComposer(string $org, string $repo, string $token): ?array
{
[$status, $data] = apiGet("repos/{$org}/{$repo}/contents/composer.json", $token);
if ($status !== 200 || empty($data['content'])) { return null; }
return json_decode(base64_decode($data['content']), true);
[$status, $data] = apiGet("repos/{$org}/{$repo}/contents/composer.json", $token);
if ($status !== 200 || empty($data['content'])) {
return null;
}
return json_decode(base64_decode($data['content']), true);
}
// ── Build repo list ─────────────────────────────────────────────────────
$repos = [];
if ($allMode) {
echo "Fetching repositories from {$org}...\n";
$page = 1;
do {
[$_, $batch] = apiGet("orgs/{$org}/repos?per_page=100&page={$page}&type=all", $token);
foreach ($batch as $r) {
if (!($r['archived'] ?? false) && !in_array($r['name'], $ALWAYS_EXCLUDE, true)) {
$repos[] = $r['name'];
}
}
$page++;
} while (count($batch) === 100);
sort($repos);
echo "Found " . count($repos) . " repositories\n\n";
echo "Fetching repositories from {$org}...\n";
$page = 1;
do {
[$_, $batch] = apiGet("orgs/{$org}/repos?per_page=100&page={$page}&type=all", $token);
foreach ($batch as $r) {
if (!($r['archived'] ?? false) && !in_array($r['name'], $ALWAYS_EXCLUDE, true)) {
$repos[] = $r['name'];
}
}
$page++;
} while (count($batch) === 100);
sort($repos);
echo "Found " . count($repos) . " repositories\n\n";
} else {
$repos = [$repoName];
$repos = [$repoName];
}
// ── Check each repo ─────────────────────────────────────────────────────
@@ -107,79 +114,83 @@ $results = [];
$issueCount = 0;
foreach ($repos as $repo) {
$result = [
'repo' => $repo,
'has_composer' => false,
'has_enterprise' => false,
'version' => null,
'version_ok' => false,
'has_lock' => false,
'issues' => [],
];
$result = [
'repo' => $repo,
'has_composer' => false,
'has_enterprise' => false,
'version' => null,
'version_ok' => false,
'has_lock' => false,
'issues' => [],
];
$composer = fetchComposer($org, $repo, $token);
if ($composer === null) {
$result['issues'][] = 'No composer.json found';
if (!$jsonOut) { echo "{$repo}: no composer.json\n"; }
$results[] = $result;
continue;
}
$composer = fetchComposer($org, $repo, $token);
if ($composer === null) {
$result['issues'][] = 'No composer.json found';
if (!$jsonOut) {
echo "{$repo}: no composer.json\n";
}
$results[] = $result;
continue;
}
$result['has_composer'] = true;
$result['has_composer'] = true;
// Check for enterprise dependency
$allDeps = array_merge($composer['require'] ?? [], $composer['require-dev'] ?? []);
// Check for enterprise dependency
$allDeps = array_merge($composer['require'] ?? [], $composer['require-dev'] ?? []);
if (isset($allDeps[$ENTERPRISE_PKG])) {
$result['has_enterprise'] = true;
$result['version'] = $allDeps[$ENTERPRISE_PKG];
if (isset($allDeps[$ENTERPRISE_PKG])) {
$result['has_enterprise'] = true;
$result['version'] = $allDeps[$ENTERPRISE_PKG];
if ($allDeps[$ENTERPRISE_PKG] === $EXPECTED_DEP) {
$result['version_ok'] = true;
} else {
$result['issues'][] = "Version mismatch: {$allDeps[$ENTERPRISE_PKG]} (expected {$EXPECTED_DEP})";
if ($allDeps[$ENTERPRISE_PKG] === 'dev-main') {
$result['issues'][] = 'STALE: pointing to dev-main instead of version branch';
}
}
} else {
$result['issues'][] = 'Enterprise dependency not in require/require-dev';
}
if ($allDeps[$ENTERPRISE_PKG] === $EXPECTED_DEP) {
$result['version_ok'] = true;
} else {
$result['issues'][] = "Version mismatch: {$allDeps[$ENTERPRISE_PKG]} (expected {$EXPECTED_DEP})";
if ($allDeps[$ENTERPRISE_PKG] === 'dev-main') {
$result['issues'][] = 'STALE: pointing to dev-main instead of version branch';
}
}
} else {
$result['issues'][] = 'Enterprise dependency not in require/require-dev';
}
// Check for composer.lock
[$lockStatus] = apiGet("repos/{$org}/{$repo}/contents/composer.lock", $token);
$result['has_lock'] = ($lockStatus === 200);
if (!$result['has_lock']) {
$result['issues'][] = 'No composer.lock committed';
}
// Check for composer.lock
[$lockStatus] = apiGet("repos/{$org}/{$repo}/contents/composer.lock", $token);
$result['has_lock'] = ($lockStatus === 200);
if (!$result['has_lock']) {
$result['issues'][] = 'No composer.lock committed';
}
if (!$jsonOut) {
if (empty($result['issues'])) {
echo "{$repo}: OK ({$result['version']})\n";
} else {
foreach ($result['issues'] as $issue) {
echo "{$repo}: {$issue}\n";
$issueCount++;
}
}
}
if (!$jsonOut) {
if (empty($result['issues'])) {
echo "{$repo}: OK ({$result['version']})\n";
} else {
foreach ($result['issues'] as $issue) {
echo "{$repo}: {$issue}\n";
$issueCount++;
}
}
}
$results[] = $result;
$results[] = $result;
}
// ── Output ──────────────────────────────────────────────────────────────
if ($jsonOut) {
echo json_encode($results, JSON_PRETTY_PRINT) . "\n";
echo json_encode($results, JSON_PRETTY_PRINT) . "\n";
} else {
echo "\n" . str_repeat('-', 50) . "\n";
$total = count($results);
$withDep = count(array_filter($results, fn($r) => $r['has_enterprise']));
$ok = count(array_filter($results, fn($r) => $r['version_ok']));
$stale = count(array_filter($results, fn($r) => $r['version'] === 'dev-main'));
echo "\n" . str_repeat('-', 50) . "\n";
$total = count($results);
$withDep = count(array_filter($results, fn($r) => $r['has_enterprise']));
$ok = count(array_filter($results, fn($r) => $r['version_ok']));
$stale = count(array_filter($results, fn($r) => $r['version'] === 'dev-main'));
echo "Total: {$total} | With enterprise dep: {$withDep} | Correct version: {$ok}";
if ($stale > 0) { echo " | Stale dev-main: {$stale}"; }
echo " | Issues: {$issueCount}\n";
echo "Total: {$total} | With enterprise dep: {$withDep} | Correct version: {$ok}";
if ($stale > 0) {
echo " | Stale dev-main: {$stale}";
}
echo " | Issues: {$issueCount}\n";
}
exit($issueCount > 0 ? 1 : 0);
+55 -54
View File
@@ -1,5 +1,6 @@
#!/usr/bin/env php
<?php
/* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
*
* This file is part of a Moko Consulting project.
@@ -25,70 +26,70 @@ use MokoEnterprise\CliFramework;
*/
class CheckDolibarrModule extends CliFramework
{
/**
* Configure available arguments.
*/
protected function configure(): void
{
$this->setDescription('Validates Dolibarr module directory structure');
$this->addArgument('--path', 'Repository path to check', '.');
}
/**
* Configure available arguments.
*/
protected function configure(): void
{
$this->setDescription('Validates Dolibarr module directory structure');
$this->addArgument('--path', 'Repository path to check', '.');
}
/**
* Run the Dolibarr module validation.
*
* @return int Exit code: 0 on pass, 1 on failure.
*/
protected function run(): int
{
$path = $this->getArgument('--path');
$passed = 0;
$failed = 0;
/**
* Run the Dolibarr module validation.
*
* @return int Exit code: 0 on pass, 1 on failure.
*/
protected function run(): int
{
$path = $this->getArgument('--path');
$passed = 0;
$failed = 0;
$this->section('Checking directory structure');
$this->section('Checking directory structure');
if (!is_dir($path . '/src')) {
$this->status(false, 'src/ directory exists');
$failed++;
} else {
$this->status(true, 'src/ directory exists');
$passed++;
}
if (!is_dir($path . '/src')) {
$this->status(false, 'src/ directory exists');
$failed++;
} else {
$this->status(true, 'src/ directory exists');
$passed++;
}
if (!is_dir($path . '/src/core/modules')) {
$this->status(false, 'src/core/modules/ directory exists');
$failed++;
} else {
$this->status(true, 'src/core/modules/ directory exists');
$passed++;
}
if (!is_dir($path . '/src/core/modules')) {
$this->status(false, 'src/core/modules/ directory exists');
$failed++;
} else {
$this->status(true, 'src/core/modules/ directory exists');
$passed++;
}
if (!is_dir($path . '/src/langs')) {
$this->warning('Missing suggested directory: src/langs/');
} else {
$this->status(true, 'src/langs/ directory exists');
$passed++;
}
if (!is_dir($path . '/src/langs')) {
$this->warning('Missing suggested directory: src/langs/');
} else {
$this->status(true, 'src/langs/ directory exists');
$passed++;
}
$this->section('Checking module descriptor');
$this->section('Checking module descriptor');
$descriptors = glob($path . '/src/core/modules/mod*.class.php') ?: [];
if (empty($descriptors)) {
$this->status(false, 'Module descriptor found (mod*.class.php)');
$failed++;
} else {
$this->status(true, 'Module descriptor found', basename($descriptors[0]));
$passed++;
}
$descriptors = glob($path . '/src/core/modules/mod*.class.php') ?: [];
if (empty($descriptors)) {
$this->status(false, 'Module descriptor found (mod*.class.php)');
$failed++;
} else {
$this->status(true, 'Module descriptor found', basename($descriptors[0]));
$passed++;
}
$this->printSummary($passed, $failed, $this->elapsed());
$this->printSummary($passed, $failed, $this->elapsed());
if ($failed > 0) {
return 1;
}
if ($failed > 0) {
return 1;
}
return 0;
}
return 0;
}
}
$script = new CheckDolibarrModule('check_dolibarr_module', 'Validates Dolibarr module directory structure');
+29 -27
View File
@@ -1,5 +1,6 @@
#!/usr/bin/env php
<?php
/**
* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
*
@@ -29,7 +30,7 @@ use MokoEnterprise\{
/**
* Enterprise Readiness Checker
*
*
* Validates repository against enterprise standards
*/
class EnterpriseReadinessChecker extends CliFramework
@@ -38,28 +39,28 @@ class EnterpriseReadinessChecker extends CliFramework
private SecurityValidator $securityValidator;
private PluginFactory $pluginFactory;
private ?object $projectPlugin = null;
private array $results = [];
protected function configure(): void
{
$this->setDescription('Check enterprise readiness compliance');
$this->addArgument('--path', 'Repository path to check', '.');
$this->addArgument('--strict', 'Fail on any non-compliance', false);
}
protected function initialize(): void
{
parent::initialize();
$this->logger = new AuditLogger('enterprise_readiness');
$this->securityValidator = new SecurityValidator();
$metrics = new \MokoEnterprise\MetricsCollector();
$this->pluginFactory = new PluginFactory($this->logger, $metrics);
$this->log('Enterprise readiness checker initialized with plugin system');
}
protected function run(): int
{
$path = $this->getArgument('--path');
@@ -70,15 +71,15 @@ class EnterpriseReadinessChecker extends CliFramework
// Try to load the project plugin
$this->projectPlugin = $this->pluginFactory->createForProject($path);
if ($this->projectPlugin) {
$pluginName = $this->projectPlugin->getPluginName();
$projectType = $this->projectPlugin->getProjectType();
$this->log("Using plugin: {$pluginName} for type: {$projectType}");
// Use plugin's readiness check if available
$pluginReadiness = $this->projectPlugin->checkReadiness($path, []);
if (!empty($pluginReadiness)) {
$this->log("Plugin readiness check: " . ($pluginReadiness['ready'] ? 'READY' : 'NOT READY'));
$this->results['plugin_readiness'] = [
@@ -91,7 +92,7 @@ class EnterpriseReadinessChecker extends CliFramework
} else {
$this->log("No plugin found, using generic readiness checks");
}
// Run standard enterprise checks (backwards compatible)
$this->section('Enterprise libraries');
$this->checkEnterpriseLibraries($path);
@@ -133,7 +134,7 @@ class EnterpriseReadinessChecker extends CliFramework
return 0;
}
private function checkEnterpriseLibraries(string $path): void
{
$required = ['ApiClient', 'AuditLogger', 'Config', 'ErrorRecovery', 'MetricsCollector'];
@@ -153,7 +154,7 @@ class EnterpriseReadinessChecker extends CliFramework
);
}
}
private function checkMonitoring(string $path): void
{
// Check for metrics collection
@@ -163,7 +164,7 @@ class EnterpriseReadinessChecker extends CliFramework
is_dir($metricsDir) || !file_exists($path . '/composer.json'),
'Metrics logging not configured'
);
// Check for monitoring documentation
$monitoringDocs = "{$path}/docs/monitoring";
$this->addResult(
@@ -172,7 +173,7 @@ class EnterpriseReadinessChecker extends CliFramework
'Monitoring documentation not found'
);
}
private function checkAuditLogging(string $path): void
{
$auditDir = "{$path}/var/logs/audit";
@@ -182,7 +183,7 @@ class EnterpriseReadinessChecker extends CliFramework
'Audit logging not configured'
);
}
private function checkSecurityCompliance(string $path): void
{
// Check for security policy
@@ -191,7 +192,7 @@ class EnterpriseReadinessChecker extends CliFramework
file_exists("{$path}/SECURITY.md") || file_exists("{$path}/.github/SECURITY.md"),
'SECURITY.md not found'
);
// Check for CodeQL configuration
$codeqlConfig = "{$path}/.github/codeql";
$this->addResult(
@@ -199,28 +200,29 @@ class EnterpriseReadinessChecker extends CliFramework
is_dir($codeqlConfig) || file_exists("{$path}/.github/codeql/codeql-config.yml"),
'CodeQL not configured'
);
// Run security scan on PHP files
if (is_dir("{$path}/src")) {
$issues = $this->securityValidator->scanDirectory("{$path}/src", ['.php']);
$this->securityValidator->scanDirectory("{$path}/src", ['.php']);
$findings = $this->securityValidator->getFindings();
$this->addResult(
'No security vulnerabilities in source code',
empty($issues),
count($issues) . ' security issues found'
empty($findings),
count($findings) . ' security issues found'
);
}
}
private function checkDocumentation(string $path): void
{
// Check for architecture documentation
$this->addResult(
'Architecture documentation exists',
file_exists("{$path}/docs/architecture.md") ||
file_exists("{$path}/docs/architecture.md") ||
file_exists("{$path}/docs/guide/architecture.md"),
'Architecture documentation not found'
);
// Check for API documentation
$this->addResult(
'API documentation exists',
@@ -228,7 +230,7 @@ class EnterpriseReadinessChecker extends CliFramework
'API documentation not found'
);
}
private function addResult(string $check, bool $passed, string $message): void
{
$this->results[] = [
@@ -237,7 +239,7 @@ class EnterpriseReadinessChecker extends CliFramework
'message' => $message,
];
}
private function displayResults(): void
{
// Results are now displayed directly in run() using visual API methods.
@@ -246,4 +248,4 @@ class EnterpriseReadinessChecker extends CliFramework
// Run the application
$app = new EnterpriseReadinessChecker();
exit($app->execute($argv));
exit($app->execute());
File diff suppressed because it is too large Load Diff
+54 -53
View File
@@ -1,5 +1,6 @@
#!/usr/bin/env php
<?php
/* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
*
* This file is part of a Moko Consulting project.
@@ -26,64 +27,64 @@ use MokoEnterprise\CliFramework;
*/
class CheckJoomlaManifest extends CliFramework
{
/**
* Configure available arguments.
*/
protected function configure(): void
{
$this->setDescription('Validates Joomla XML manifest structure');
$this->addArgument('--path', 'Repository path to check', '.');
}
/**
* Configure available arguments.
*/
protected function configure(): void
{
$this->setDescription('Validates Joomla XML manifest structure');
$this->addArgument('--path', 'Repository path to check', '.');
}
/**
* Validate all tracked XML manifests.
*
* @return int Exit code: 0 on pass, 1 on failure.
*/
protected function run(): int
{
$path = $this->getArgument('--path');
$output = shell_exec('git -C ' . escapeshellarg($path) . " ls-files '*.xml' 2>/dev/null") ?? '';
$files = array_filter(explode("\n", $output));
$errors = 0;
$i = 0;
$total = count($files);
/**
* Validate all tracked XML manifests.
*
* @return int Exit code: 0 on pass, 1 on failure.
*/
protected function run(): int
{
$path = $this->getArgument('--path');
$output = shell_exec('git -C ' . escapeshellarg($path) . " ls-files '*.xml' 2>/dev/null") ?? '';
$files = array_filter(explode("\n", $output));
$errors = 0;
$i = 0;
$total = count($files);
$this->section('Scanning XML manifests');
$this->section('Scanning XML manifests');
foreach ($files as $file) {
$fullPath = $path . '/' . $file;
if (!is_file($fullPath)) {
continue;
}
if ($total >= 3) {
$this->progress(++$i, $total, basename((string) $file));
}
$content = (string) file_get_contents($fullPath);
if (!str_contains($content, '<extension')) {
continue;
}
if (!str_contains($content, '<version>')) {
$this->status(false, 'Missing <version>', (string) $file);
$errors++;
}
if (!str_contains($content, '<description>')) {
$this->warning("Missing <description> in: {$file}");
}
}
if ($total >= 3) {
$this->progress($total, $total, 'done', true);
}
foreach ($files as $file) {
$fullPath = $path . '/' . $file;
if (!is_file($fullPath)) {
continue;
}
if ($total >= 3) {
$this->progress(++$i, $total, basename((string) $file));
}
$content = (string) file_get_contents($fullPath);
if (!str_contains($content, '<extension')) {
continue;
}
if (!str_contains($content, '<version>')) {
$this->status(false, 'Missing <version>', (string) $file);
$errors++;
}
if (!str_contains($content, '<description>')) {
$this->warning("Missing <description> in: {$file}");
}
}
if ($total >= 3) {
$this->progress($total, $total, 'done', true);
}
if ($errors === 0) {
$this->status(true, 'Manifest validation passed');
$this->printSummary(1, 0, $this->elapsed());
return 0;
}
if ($errors === 0) {
$this->status(true, 'Manifest validation passed');
$this->printSummary(1, 0, $this->elapsed());
return 0;
}
$this->printSummary(0, $errors, $this->elapsed());
return 1;
}
$this->printSummary(0, $errors, $this->elapsed());
return 1;
}
}
$script = new CheckJoomlaManifest('check_joomla_manifest', 'Validates Joomla XML manifest structure');
+49 -48
View File
@@ -1,5 +1,6 @@
#!/usr/bin/env php
<?php
/* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
*
* This file is part of a Moko Consulting project.
@@ -25,59 +26,59 @@ use MokoEnterprise\CliFramework;
*/
class CheckLanguageStructure extends CliFramework
{
/**
* Configure available arguments.
*/
protected function configure(): void
{
$this->setDescription('Validates language INI file structure');
$this->addArgument('--path', 'Repository path to check', '.');
}
/**
* Configure available arguments.
*/
protected function configure(): void
{
$this->setDescription('Validates language INI file structure');
$this->addArgument('--path', 'Repository path to check', '.');
}
/**
* Validate language INI files.
*
* @return int Exit code: 0 on pass, 1 on failure.
*/
protected function run(): int
{
$path = $this->getArgument('--path');
$output = shell_exec('git -C ' . escapeshellarg($path) . " ls-files '*.ini' 2>/dev/null") ?? '';
$files = array_filter(explode("\n", $output));
$errors = 0;
$i = 0;
$total = count($files);
/**
* Validate language INI files.
*
* @return int Exit code: 0 on pass, 1 on failure.
*/
protected function run(): int
{
$path = $this->getArgument('--path');
$output = shell_exec('git -C ' . escapeshellarg($path) . " ls-files '*.ini' 2>/dev/null") ?? '';
$files = array_filter(explode("\n", $output));
$errors = 0;
$i = 0;
$total = count($files);
$this->section('Scanning INI language files');
$this->section('Scanning INI language files');
foreach ($files as $file) {
$fullPath = $path . '/' . $file;
if (!is_file($fullPath)) {
continue;
}
if ($total >= 3) {
$this->progress(++$i, $total, basename((string) $file));
}
$content = (string) file_get_contents($fullPath);
if (!preg_match('/^[A-Z_][A-Z0-9_]*=/m', $content)) {
$this->warning("Language file may have format issues: {$file}");
$errors++;
}
}
if ($total >= 3) {
$this->progress($total, $total, 'done', true);
}
foreach ($files as $file) {
$fullPath = $path . '/' . $file;
if (!is_file($fullPath)) {
continue;
}
if ($total >= 3) {
$this->progress(++$i, $total, basename((string) $file));
}
$content = (string) file_get_contents($fullPath);
if (!preg_match('/^[A-Z_][A-Z0-9_]*=/m', $content)) {
$this->warning("Language file may have format issues: {$file}");
$errors++;
}
}
if ($total >= 3) {
$this->progress($total, $total, 'done', true);
}
if ($errors === 0) {
$this->status(true, 'Language file validation passed');
$this->printSummary(1, 0, $this->elapsed());
return 0;
}
if ($errors === 0) {
$this->status(true, 'Language file validation passed');
$this->printSummary(1, 0, $this->elapsed());
return 0;
}
$this->status(false, 'Language file validation', "{$errors} file(s) with format issues");
$this->printSummary(0, $errors, $this->elapsed());
return 1;
}
$this->status(false, 'Language file validation', "{$errors} file(s) with format issues");
$this->printSummary(0, $errors, $this->elapsed());
return 1;
}
}
$script = new CheckLanguageStructure('check_language_structure', 'Validates language INI file structure');
+58 -57
View File
@@ -1,5 +1,6 @@
#!/usr/bin/env php
<?php
/* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
*
* This file is part of a Moko Consulting project.
@@ -27,68 +28,68 @@ use MokoEnterprise\CliFramework;
*/
class CheckLicenseHeaders extends CliFramework
{
/**
* Configure available arguments.
*/
protected function configure(): void
{
$this->setDescription('Validates SPDX license headers in source files (advisory)');
$this->addArgument('--path', 'Repository path to check', '.');
}
/**
* Configure available arguments.
*/
protected function configure(): void
{
$this->setDescription('Validates SPDX license headers in source files (advisory)');
$this->addArgument('--path', 'Repository path to check', '.');
}
/**
* Run the license-header check (advisory — always exits 0).
*
* @return int Exit code: always 0.
*/
protected function run(): int
{
$path = $this->getArgument('--path');
$patterns = ['*.php', '*.js', '*.css', '*.sh'];
$quoted = implode(' ', array_map('escapeshellarg', $patterns));
$output = shell_exec('git -C ' . escapeshellarg($path) . " ls-files {$quoted} 2>/dev/null") ?? '';
$files = array_filter(explode("\n", $output));
$missing = 0;
$i = 0;
$total = count($files);
/**
* Run the license-header check (advisory — always exits 0).
*
* @return int Exit code: always 0.
*/
protected function run(): int
{
$path = $this->getArgument('--path');
$patterns = ['*.php', '*.js', '*.css', '*.sh'];
$quoted = implode(' ', array_map('escapeshellarg', $patterns));
$output = shell_exec('git -C ' . escapeshellarg($path) . " ls-files {$quoted} 2>/dev/null") ?? '';
$files = array_filter(explode("\n", $output));
$missing = 0;
$i = 0;
$total = count($files);
$this->section('Scanning license headers');
$this->section('Scanning license headers');
foreach ($files as $file) {
$fullPath = $path . '/' . $file;
if (!is_file($fullPath)) {
continue;
}
if ($total >= 3) {
$this->progress(++$i, $total, basename((string) $file));
}
$handle = fopen($fullPath, 'r');
if ($handle === false) {
continue;
}
$header = '';
for ($j = 0; $j < 20 && !feof($handle); $j++) {
$header .= (string) fgets($handle);
}
fclose($handle);
if (!str_contains($header, 'SPDX-License-Identifier:')) {
$this->warning("Missing SPDX license identifier: {$file}");
$missing++;
}
}
if ($total >= 3) {
$this->progress($total, $total, 'done', true);
}
foreach ($files as $file) {
$fullPath = $path . '/' . $file;
if (!is_file($fullPath)) {
continue;
}
if ($total >= 3) {
$this->progress(++$i, $total, basename((string) $file));
}
$handle = fopen($fullPath, 'r');
if ($handle === false) {
continue;
}
$header = '';
for ($j = 0; $j < 20 && !feof($handle); $j++) {
$header .= (string) fgets($handle);
}
fclose($handle);
if (!str_contains($header, 'SPDX-License-Identifier:')) {
$this->warning("Missing SPDX license identifier: {$file}");
$missing++;
}
}
if ($total >= 3) {
$this->progress($total, $total, 'done', true);
}
if ($missing === 0) {
$this->status(true, 'All source files have license headers');
} else {
$this->status(false, 'Some files missing license headers (advisory)', "{$missing} file(s)");
}
if ($missing === 0) {
$this->status(true, 'All source files have license headers');
} else {
$this->status(false, 'Some files missing license headers (advisory)', "{$missing} file(s)");
}
$this->printSummary(max(0, $total - $missing), $missing, $this->elapsed());
return 0;
}
$this->printSummary(max(0, $total - $missing), $missing, $this->elapsed());
return 0;
}
}
$script = new CheckLicenseHeaders('check_license_headers', 'Validates SPDX license headers in source files');
+73 -72
View File
@@ -1,5 +1,6 @@
#!/usr/bin/env php
<?php
/* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
*
* This file is part of a Moko Consulting project.
@@ -25,87 +26,87 @@ use MokoEnterprise\CliFramework;
*/
class CheckNoSecrets extends CliFramework
{
/** Regex matching suspicious key=value or key: value assignments. */
private const SECRET_PATTERN = '/(password|api[_\-]?key|secret|token|private[_\-]?key)\s*[:=]\s*["\'][^"\']{8,}/i';
/** Regex matching suspicious key=value or key: value assignments. */
private const SECRET_PATTERN = '/(password|api[_\-]?key|secret|token|private[_\-]?key)\s*[:=]\s*["\'][^"\']{8,}/i';
/**
* Substrings that mark a line as a known-safe false positive.
* Dolibarr CSRF token functions generate nonces at runtime — not credentials.
*/
private const SAFE_SUBSTRINGS = ['newToken()', 'checkToken()', 'currentToken()'];
/**
* Substrings that mark a line as a known-safe false positive.
* Dolibarr CSRF token functions generate nonces at runtime — not credentials.
*/
private const SAFE_SUBSTRINGS = ['newToken()', 'checkToken()', 'currentToken()'];
/** Binary file extensions to skip. */
private const BINARY_EXTENSIONS = ['jpg', 'jpeg', 'png', 'gif', 'pdf', 'zip', 'tar', 'gz'];
/** Binary file extensions to skip. */
private const BINARY_EXTENSIONS = ['jpg', 'jpeg', 'png', 'gif', 'pdf', 'zip', 'tar', 'gz'];
/**
* Configure available arguments.
*/
protected function configure(): void
{
$this->setDescription('Checks for potential secrets in committed files (advisory)');
$this->addArgument('--path', 'Repository path to check', '.');
}
/**
* Configure available arguments.
*/
protected function configure(): void
{
$this->setDescription('Checks for potential secrets in committed files (advisory)');
$this->addArgument('--path', 'Repository path to check', '.');
}
/**
* Run the secrets scan (advisory — always exits 0).
*
* @return int Exit code: always 0.
*/
protected function run(): int
{
$path = $this->getArgument('--path');
$output = shell_exec('git -C ' . escapeshellarg($path) . ' ls-files 2>/dev/null') ?? '';
$all = array_values(array_filter(explode("\n", $output)));
$files = array_filter($all, function (string $f): bool {
return !in_array(strtolower(pathinfo($f, PATHINFO_EXTENSION)), self::BINARY_EXTENSIONS, true);
});
$files = array_values($files);
$total = count($files);
$found = 0;
/**
* Run the secrets scan (advisory — always exits 0).
*
* @return int Exit code: always 0.
*/
protected function run(): int
{
$path = $this->getArgument('--path');
$output = shell_exec('git -C ' . escapeshellarg($path) . ' ls-files 2>/dev/null') ?? '';
$all = array_values(array_filter(explode("\n", $output)));
$files = array_filter($all, function (string $f): bool {
return !in_array(strtolower(pathinfo($f, PATHINFO_EXTENSION)), self::BINARY_EXTENSIONS, true);
});
$files = array_values($files);
$total = count($files);
$found = 0;
$this->section('Scanning for secret patterns');
$this->section('Scanning for secret patterns');
foreach ($files as $i => $file) {
$this->progress($i + 1, $total, $file);
$fullPath = $path . '/' . $file;
if (!is_file($fullPath)) {
continue;
}
$lines = explode("\n", (string) file_get_contents($fullPath));
$flagged = false;
foreach ($lines as $line) {
if (!preg_match(self::SECRET_PATTERN, $line)) {
continue;
}
// Skip known-safe patterns (e.g. Dolibarr CSRF token functions)
$safe = false;
foreach (self::SAFE_SUBSTRINGS as $sub) {
if (str_contains($line, $sub)) {
$safe = true;
break;
}
}
if (!$safe) {
$flagged = true;
break;
}
}
if ($flagged) {
$this->progress($i + 1, $total, '', true);
$this->status(false, $file, 'potential secret pattern detected');
$found++;
}
}
$this->progress($total, $total, '', true);
foreach ($files as $i => $file) {
$this->progress($i + 1, $total, $file);
$fullPath = $path . '/' . $file;
if (!is_file($fullPath)) {
continue;
}
$lines = explode("\n", (string) file_get_contents($fullPath));
$flagged = false;
foreach ($lines as $line) {
if (!preg_match(self::SECRET_PATTERN, $line)) {
continue;
}
// Skip known-safe patterns (e.g. Dolibarr CSRF token functions)
$safe = false;
foreach (self::SAFE_SUBSTRINGS as $sub) {
if (str_contains($line, $sub)) {
$safe = true;
break;
}
}
if (!$safe) {
$flagged = true;
break;
}
}
if ($flagged) {
$this->progress($i + 1, $total, '', true);
$this->status(false, $file, 'potential secret pattern detected');
$found++;
}
}
$this->progress($total, $total, '', true);
$this->printSummary($total - $found, $found, $this->elapsed());
$this->printSummary($total - $found, $found, $this->elapsed());
if ($found > 0) {
$this->log('WARNING', 'Advisory — review flagged files manually');
}
if ($found > 0) {
$this->log('WARNING', 'Advisory — review flagged files manually');
}
return 0;
}
return 0;
}
}
$script = new CheckNoSecrets('check_no_secrets', 'Checks for potential secrets in committed files');

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