38 Commits

Author SHA1 Message Date
gitea-actions[bot] 082c01fc46 chore(release): build 09.24.00-rc [skip ci] 2026-06-04 22:59:27 +00:00
Jonathan Miller f3f356ae54 ci: remove updates.xml steps from pre-release, use CLI version_bump
Generic: Repo Health / Site Health (push) Has been cancelled
Generic: Repo Health / Access control (push) Has been cancelled
Universal: Auto Version Bump / Version Bump (push) Has been cancelled
Universal: Build & Release / Build & Release Pipeline (pull_request) Has been cancelled
Generic: Repo Health / Site Health (pull_request) Has been cancelled
Universal: PR Check / Branch Policy (pull_request) Has been cancelled
Generic: Repo Health / Access control (pull_request) Has been cancelled
Universal: Secret Scanning / Gitleaks Secret Scan (pull_request) Has been cancelled
Universal: PR Check / Validate PR (pull_request) Has been cancelled
Universal: Build & Release / Promote to RC (pull_request) Has been cancelled
Platform: moko-platform CI / Gate 1: Code Quality (push) Has been cancelled
Platform: moko-platform CI / Gate 1: Code Quality (pull_request) Has been cancelled
Universal: Pre-Release / Build Pre-Release (${{ inputs.stability || 'development' }}) (pull_request_target) Has been cancelled
Platform: moko-platform CI / Gate 2: Unit Tests (8.1) (push) Has been cancelled
Platform: moko-platform CI / Gate 2: Unit Tests (8.2) (push) Has been cancelled
Platform: moko-platform CI / Gate 2: Unit Tests (8.3) (push) Has been cancelled
Platform: moko-platform CI / Gate 3: Self-Health Check (push) Has been cancelled
Platform: moko-platform CI / Gate 4: Governance (push) Has been cancelled
Platform: moko-platform CI / Gate 5: Template Integrity (push) Has been cancelled
Platform: moko-platform CI / CI Summary (push) Has been cancelled
Generic: Repo Health / Scripts governance (push) Has been cancelled
Generic: Repo Health / Repository health (push) Has been cancelled
Generic: Repo Health / Report Issues (push) Has been cancelled
Platform: moko-platform CI / Gate 2: Unit Tests (8.1) (pull_request) Has been cancelled
Platform: moko-platform CI / Gate 2: Unit Tests (8.2) (pull_request) Has been cancelled
Platform: moko-platform CI / Gate 2: Unit Tests (8.3) (pull_request) Has been cancelled
Platform: moko-platform CI / Gate 3: Self-Health Check (pull_request) Has been cancelled
Platform: moko-platform CI / Gate 4: Governance (pull_request) Has been cancelled
Platform: moko-platform CI / Gate 5: Template Integrity (pull_request) Has been cancelled
Platform: moko-platform CI / CI Summary (pull_request) Has been cancelled
Universal: PR Check / Build RC Package (pull_request) Has been cancelled
Universal: PR Check / Report Issues (pull_request) Has been cancelled
Generic: Repo Health / Scripts governance (pull_request) Has been cancelled
Generic: Repo Health / Repository health (pull_request) Has been cancelled
Generic: Repo Health / Report Issues (pull_request) Has been cancelled
- Remove updates_xml_build.php, updates.xml commit/push, branch sync steps
- MokoGitea license server generates updates.xml dynamically
- Pre-release now uses version_bump.php (patch for dev, --minor for RC)

Authored-by: Moko Consulting
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-06-04 17:58:43 -05:00
Jonathan Miller 85d863be08 fix: pre-release bumps version via CLI (patch for dev, minor for RC)
Generic: Repo Health / Site Health (push) Has been cancelled
Generic: Repo Health / Access control (push) Has been cancelled
Universal: Auto Version Bump / Version Bump (push) Has been cancelled
Platform: moko-platform CI / Gate 1: Code Quality (push) Has been cancelled
Platform: moko-platform CI / Gate 2: Unit Tests (8.1) (push) Has been cancelled
Platform: moko-platform CI / Gate 2: Unit Tests (8.2) (push) Has been cancelled
Platform: moko-platform CI / Gate 2: Unit Tests (8.3) (push) Has been cancelled
Platform: moko-platform CI / Gate 3: Self-Health Check (push) Has been cancelled
Platform: moko-platform CI / Gate 4: Governance (push) Has been cancelled
Platform: moko-platform CI / Gate 5: Template Integrity (push) Has been cancelled
Platform: moko-platform CI / CI Summary (push) Has been cancelled
Generic: Repo Health / Scripts governance (push) Has been cancelled
Generic: Repo Health / Repository health (push) Has been cancelled
Generic: Repo Health / Report Issues (push) Has been cancelled
Each dev/alpha/beta pre-release now bumps patch so Joomla sees a
higher version and offers the update. RC bumps minor to consolidate
dev patches into a clean release version.

Uses version_bump.php from CLI — no inline shell math in workflows.

Authored-by: Moko Consulting
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-06-04 16:51:44 -05:00
Jonathan Miller a83eda5798 chore: remove build/ directory from tracking
Generic: Repo Health / Site Health (push) Has been cancelled
Generic: Repo Health / Access control (push) Has been cancelled
Universal: Auto Version Bump / Version Bump (push) Has been cancelled
Platform: moko-platform CI / Gate 1: Code Quality (push) Has been cancelled
Platform: moko-platform CI / Gate 2: Unit Tests (8.1) (push) Has been cancelled
Platform: moko-platform CI / Gate 2: Unit Tests (8.2) (push) Has been cancelled
Platform: moko-platform CI / Gate 2: Unit Tests (8.3) (push) Has been cancelled
Platform: moko-platform CI / Gate 3: Self-Health Check (push) Has been cancelled
Platform: moko-platform CI / Gate 4: Governance (push) Has been cancelled
Platform: moko-platform CI / Gate 5: Template Integrity (push) Has been cancelled
Platform: moko-platform CI / CI Summary (push) Has been cancelled
Generic: Repo Health / Scripts governance (push) Has been cancelled
Generic: Repo Health / Repository health (push) Has been cancelled
Generic: Repo Health / Report Issues (push) Has been cancelled
Build artifacts are created by CI, not tracked in source.

Authored-by: Moko Consulting
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-06-04 13:25:50 -05:00
jmiller 631b44e1a3 chore: sync .mokogitea/workflows/pr-check.yml from moko-platform [skip ci]
Branch Cleanup / Delete merged branch (pull_request) Has been cancelled
Universal: Build & Release / Promote to RC (pull_request) Has been cancelled
Universal: Build & Release / Build & Release Pipeline (pull_request) Has been cancelled
2026-06-04 15:56:16 +00:00
jmiller 79631d77bb chore: sync .mokogitea/workflows/pr-check.yml from moko-platform [skip ci] 2026-06-04 15:38:25 +00:00
jmiller 4d06e3828e chore: sync .mokogitea/workflows/pr-check.yml from moko-platform [skip ci] 2026-06-04 15:28:53 +00:00
jmiller e135a0ff8b chore: sync .mokogitea/workflows/pr-check.yml from moko-platform [skip ci] 2026-06-04 15:13:23 +00:00
jmiller 86db53d2ac chore: sync .mokogitea/workflows/auto-release.yml from moko-platform [skip ci] 2026-06-04 14:20:21 +00:00
Jonathan Miller 8a4e1ab60f feat(cli): add --skip-update-stream flag to release_publish
Generic: Repo Health / Site Health (push) Has been cancelled
Generic: Repo Health / Access control (push) Has been cancelled
Universal: PR Check / Branch Policy (pull_request) Has been cancelled
Generic: Repo Health / Site Health (pull_request) Has been cancelled
Generic: Repo Health / Access control (pull_request) Has been cancelled
Universal: Auto Version Bump / Version Bump (push) Has been cancelled
Universal: Secret Scanning / Gitleaks Secret Scan (pull_request) Has been cancelled
Universal: PR Check / Validate PR (pull_request) Has been cancelled
Platform: moko-platform CI / Gate 1: Code Quality (pull_request) Has been cancelled
Platform: moko-platform CI / Gate 1: Code Quality (push) Has been cancelled
Platform: moko-platform CI / Gate 2: Unit Tests (8.1) (pull_request) Has been cancelled
Platform: moko-platform CI / Gate 2: Unit Tests (8.2) (pull_request) Has been cancelled
Platform: moko-platform CI / Gate 2: Unit Tests (8.3) (pull_request) Has been cancelled
Platform: moko-platform CI / Gate 3: Self-Health Check (pull_request) Has been cancelled
Platform: moko-platform CI / Gate 4: Governance (pull_request) Has been cancelled
Platform: moko-platform CI / Gate 5: Template Integrity (pull_request) Has been cancelled
Platform: moko-platform CI / CI Summary (pull_request) Has been cancelled
Platform: moko-platform CI / Gate 2: Unit Tests (8.1) (push) Has been cancelled
Platform: moko-platform CI / Gate 2: Unit Tests (8.2) (push) Has been cancelled
Platform: moko-platform CI / Gate 2: Unit Tests (8.3) (push) Has been cancelled
Platform: moko-platform CI / Gate 3: Self-Health Check (push) Has been cancelled
Platform: moko-platform CI / Gate 4: Governance (push) Has been cancelled
Platform: moko-platform CI / Gate 5: Template Integrity (push) Has been cancelled
Platform: moko-platform CI / CI Summary (push) Has been cancelled
Generic: Repo Health / Scripts governance (push) Has been cancelled
Generic: Repo Health / Repository health (push) Has been cancelled
Generic: Repo Health / Report Issues (push) Has been cancelled
Universal: PR Check / Build RC Package (pull_request) Has been cancelled
Universal: PR Check / Report Issues (pull_request) Has been cancelled
Generic: Repo Health / Scripts governance (pull_request) Has been cancelled
Generic: Repo Health / Repository health (pull_request) Has been cancelled
Generic: Repo Health / Report Issues (pull_request) Has been cancelled
Allows release workflows to skip updates.xml generation and sync.
This decouples release creation from update stream management,
enabling updates.xml to be managed externally (e.g. Gitea Pages).

Authored-by: Moko Consulting
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-06-04 09:16:42 -05:00
jmiller 505013c6f1 chore: sync .mokogitea/workflows/repo-health.yml from moko-platform [skip ci] 2026-06-03 09:36:54 +00:00
jmiller 2f6845c5c0 chore: sync .mokogitea/workflows/repo-health.yml from moko-platform [skip ci] 2026-06-03 03:10:37 +00:00
jmiller 45233fb9d2 chore: sync .mokogitea/workflows/auto-release.yml from moko-platform [skip ci] 2026-06-02 23:47:13 +00:00
jmiller ecf6615383 chore: sync .mokogitea/workflows/pr-check.yml from moko-platform [skip ci] 2026-06-02 21:51:30 +00:00
Jonathan Miller 11eb1e2649 chore(release): bump to 09.23.00 — plugin commands, audit query, version fix
Generic: Repo Health / Site Health (push) Has been cancelled
Generic: Repo Health / Site Health (pull_request) Has been cancelled
Generic: Repo Health / Access control (push) Has been cancelled
Universal: PR Check / Branch Policy (pull_request) Has been cancelled
Generic: Repo Health / Access control (pull_request) Has been cancelled
Universal: Auto Version Bump / Version Bump (push) Has been cancelled
Universal: Secret Scanning / Gitleaks Secret Scan (pull_request) Has been cancelled
Universal: PR Check / Validate PR (pull_request) Has been cancelled
Platform: moko-platform CI / Gate 1: Code Quality (push) Has been cancelled
Platform: moko-platform CI / Gate 1: Code Quality (pull_request) Has been cancelled
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
Platform: moko-platform CI / Gate 2: Unit Tests (8.1) (pull_request) Has been cancelled
Platform: moko-platform CI / Gate 2: Unit Tests (8.2) (pull_request) Has been cancelled
Platform: moko-platform CI / Gate 2: Unit Tests (8.3) (pull_request) Has been cancelled
Platform: moko-platform CI / Gate 3: Self-Health Check (pull_request) Has been cancelled
Platform: moko-platform CI / Gate 4: Governance (pull_request) Has been cancelled
Platform: moko-platform CI / Gate 5: Template Integrity (pull_request) Has been cancelled
Platform: moko-platform CI / CI Summary (pull_request) Has been cancelled
Generic: Repo Health / Release configuration (push) Has been cancelled
Generic: Repo Health / Scripts governance (push) Has been cancelled
Generic: Repo Health / Repository health (push) Has been cancelled
Universal: PR Check / Build RC Package (pull_request) Has been cancelled
Generic: Repo Health / Release configuration (pull_request) Has been cancelled
Generic: Repo Health / Scripts governance (pull_request) Has been cancelled
Generic: Repo Health / Repository health (pull_request) Has been cancelled
Authored-by: Moko Consulting
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-31 14:18:26 -05:00
Jonathan Miller cb2debc437 feat(cli): populate plugin commands and add audit:query tool (#148, #144)
Generic: Repo Health / Site Health (push) Has been cancelled
Generic: Repo Health / Access control (push) Has been cancelled
Universal: PR Check / Branch Policy (pull_request) Has been cancelled
Generic: Repo Health / Site Health (pull_request) Has been cancelled
Generic: Repo Health / Access control (pull_request) Has been cancelled
Universal: Auto Version Bump / Version Bump (push) Has been cancelled
Universal: Secret Scanning / Gitleaks Secret Scan (pull_request) Has been cancelled
Universal: PR Check / Validate PR (pull_request) Has been cancelled
Platform: moko-platform CI / Gate 1: Code Quality (push) Has been cancelled
Platform: moko-platform CI / Gate 1: Code Quality (pull_request) Has been cancelled
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
Platform: moko-platform CI / Gate 2: Unit Tests (8.1) (pull_request) Has been cancelled
Platform: moko-platform CI / Gate 2: Unit Tests (8.2) (pull_request) Has been cancelled
Platform: moko-platform CI / Gate 2: Unit Tests (8.3) (pull_request) Has been cancelled
Platform: moko-platform CI / Gate 3: Self-Health Check (pull_request) Has been cancelled
Platform: moko-platform CI / Gate 4: Governance (pull_request) Has been cancelled
Platform: moko-platform CI / Gate 5: Template Integrity (pull_request) Has been cancelled
Platform: moko-platform CI / CI Summary (pull_request) Has been cancelled
Generic: Repo Health / Release configuration (push) Has been cancelled
Generic: Repo Health / Scripts governance (push) Has been cancelled
Generic: Repo Health / Repository health (push) Has been cancelled
Universal: PR Check / Build RC Package (pull_request) Has been cancelled
Generic: Repo Health / Release configuration (pull_request) Has been cancelled
Generic: Repo Health / Scripts governance (pull_request) Has been cancelled
Generic: Repo Health / Repository health (pull_request) Has been cancelled
#148: Override getCommands() in 5 plugins — JoomlaPlugin (5 commands),
DolibarrPlugin (3), NodeJsPlugin (2), PythonPlugin (2), WordPressPlugin
(3). All 15 commands appear in `php bin/moko list` and resolve to
existing validation/build/deploy scripts.

#144: New cli/audit_query.php — search, filter, and export JSONL audit
logs with --service, --user, --event, --level, --since, --until filters.
Supports table, json, jsonl output formats and --stats summary mode.

Authored-by: Moko Consulting
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-31 14:12:38 -05:00
Jonathan Miller 1ecd9239ed fix(version): preserve suffix on bump, simplify workflow version logic (#191)
Generic: Repo Health / Site Health (push) Has been cancelled
Generic: Repo Health / Access control (push) Has been cancelled
Universal: PR Check / Branch Policy (pull_request) Has been cancelled
Generic: Repo Health / Site Health (pull_request) Has been cancelled
Generic: Repo Health / Access control (pull_request) Has been cancelled
Universal: Secret Scanning / Gitleaks Secret Scan (pull_request) Has been cancelled
Universal: Auto Version Bump / Version Bump (push) Has been cancelled
Universal: PR Check / Validate PR (pull_request) Has been cancelled
Platform: moko-platform CI / Gate 1: Code Quality (push) Has been cancelled
Platform: moko-platform CI / Gate 1: Code Quality (pull_request) Has been cancelled
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
Platform: moko-platform CI / Gate 2: Unit Tests (8.1) (pull_request) Has been cancelled
Platform: moko-platform CI / Gate 2: Unit Tests (8.2) (pull_request) Has been cancelled
Platform: moko-platform CI / Gate 2: Unit Tests (8.3) (pull_request) Has been cancelled
Platform: moko-platform CI / Gate 3: Self-Health Check (pull_request) Has been cancelled
Platform: moko-platform CI / Gate 4: Governance (pull_request) Has been cancelled
Platform: moko-platform CI / Gate 5: Template Integrity (pull_request) Has been cancelled
Platform: moko-platform CI / CI Summary (pull_request) Has been cancelled
Generic: Repo Health / Release configuration (push) Has been cancelled
Generic: Repo Health / Scripts governance (push) Has been cancelled
Generic: Repo Health / Repository health (push) Has been cancelled
Universal: PR Check / Build RC Package (pull_request) Has been cancelled
Generic: Repo Health / Release configuration (pull_request) Has been cancelled
Generic: Repo Health / Scripts governance (pull_request) Has been cancelled
Generic: Repo Health / Repository health (pull_request) Has been cancelled
version_bump.php now captures the existing suffix (e.g. -dev) from
manifest.xml and re-applies it after incrementing the base version.
This lets workflows read the version as-is instead of stripping and
re-applying suffixes based on branch names.

Simplified update-server.yml and pre-release.yml by removing the
strip→map→re-apply suffix dance. Removed DISPLAY_VERSION and SUFFIX
output variables — VERSION now carries the full suffixed string.

Authored-by: Moko Consulting
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-31 14:01:34 -05:00
Jonathan Miller 66e728b078 style: fix PHPCS violations across migrated CLI scripts
Generic: Repo Health / Access control (push) Has been cancelled
Generic: Repo Health / Site Health (push) Has been cancelled
Universal: PR Check / Branch Policy (pull_request) Has been cancelled
Universal: Auto Version Bump / Version Bump (push) Has been cancelled
Universal: Secret Scanning / Gitleaks Secret Scan (pull_request) Has been cancelled
Universal: PR Check / Validate PR (pull_request) Has been cancelled
Generic: Repo Health / Site Health (pull_request) Has been cancelled
Generic: Repo Health / Access control (pull_request) Has been cancelled
Platform: moko-platform CI / Gate 1: Code Quality (pull_request) Has been cancelled
Platform: moko-platform CI / Gate 1: Code Quality (push) Has been cancelled
Platform: moko-platform CI / Gate 2: Unit Tests (8.1) (pull_request) Has been cancelled
Platform: moko-platform CI / Gate 2: Unit Tests (8.2) (pull_request) Has been cancelled
Platform: moko-platform CI / Gate 2: Unit Tests (8.3) (pull_request) Has been cancelled
Platform: moko-platform CI / Gate 3: Self-Health Check (pull_request) Has been cancelled
Platform: moko-platform CI / Gate 4: Governance (pull_request) Has been cancelled
Platform: moko-platform CI / Gate 5: Template Integrity (pull_request) Has been cancelled
Platform: moko-platform CI / CI Summary (pull_request) Has been cancelled
Platform: moko-platform CI / Gate 2: Unit Tests (8.1) (push) Has been cancelled
Platform: moko-platform CI / Gate 2: Unit Tests (8.2) (push) Has been cancelled
Platform: moko-platform CI / Gate 2: Unit Tests (8.3) (push) Has been cancelled
Platform: moko-platform CI / Gate 3: Self-Health Check (push) Has been cancelled
Platform: moko-platform CI / Gate 4: Governance (push) Has been cancelled
Platform: moko-platform CI / Gate 5: Template Integrity (push) Has been cancelled
Platform: moko-platform CI / CI Summary (push) Has been cancelled
Generic: Repo Health / Release configuration (push) Has been cancelled
Generic: Repo Health / Scripts governance (push) Has been cancelled
Generic: Repo Health / Repository health (push) Has been cancelled
Universal: PR Check / Build RC Package (pull_request) Has been cancelled
Generic: Repo Health / Release configuration (pull_request) Has been cancelled
Generic: Repo Health / Scripts governance (pull_request) Has been cancelled
Generic: Repo Health / Repository health (pull_request) Has been cancelled
Auto-fixed 5006 tab-indent and line-ending errors via phpcbf, then
manually broke 100 lines exceeding 150-char limit. All 74 files in
cli/, automation/, maintenance/, deploy/ now pass PHPCS PSR-12 clean.

Authored-by: Moko Consulting
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-31 13:36:05 -05:00
Jonathan Miller ae2860c3b5 chore(release): bump to 09.22.00 — CliFramework migration
Generic: Repo Health / Site Health (push) Has been cancelled
Generic: Repo Health / Access control (push) Has been cancelled
Universal: PR Check / Branch Policy (pull_request) Has been cancelled
Universal: PR Check / Validate PR (pull_request) Has been cancelled
Universal: Secret Scanning / Gitleaks Secret Scan (pull_request) Has been cancelled
Universal: Auto Version Bump / Version Bump (push) Has been cancelled
Platform: moko-platform CI / Gate 1: Code Quality (push) Has been cancelled
Platform: moko-platform CI / Gate 1: Code Quality (pull_request) Has been cancelled
Platform: moko-platform CI / Gate 2: Unit Tests (8.1) (pull_request) Has been cancelled
Platform: moko-platform CI / Gate 2: Unit Tests (8.2) (pull_request) Has been cancelled
Platform: moko-platform CI / Gate 2: Unit Tests (8.3) (pull_request) Has been cancelled
Platform: moko-platform CI / Gate 3: Self-Health Check (pull_request) Has been cancelled
Platform: moko-platform CI / Gate 4: Governance (pull_request) Has been cancelled
Platform: moko-platform CI / Gate 5: Template Integrity (pull_request) Has been cancelled
Platform: moko-platform CI / CI Summary (pull_request) Has been cancelled
Platform: moko-platform CI / Gate 2: Unit Tests (8.1) (push) Has been cancelled
Platform: moko-platform CI / Gate 2: Unit Tests (8.2) (push) Has been cancelled
Platform: moko-platform CI / Gate 2: Unit Tests (8.3) (push) Has been cancelled
Platform: moko-platform CI / Gate 3: Self-Health Check (push) Has been cancelled
Platform: moko-platform CI / Gate 4: Governance (push) Has been cancelled
Platform: moko-platform CI / Gate 5: Template Integrity (push) Has been cancelled
Platform: moko-platform CI / CI Summary (push) Has been cancelled
Generic: Repo Health / Release configuration (push) Has been cancelled
Generic: Repo Health / Scripts governance (push) Has been cancelled
Generic: Repo Health / Repository health (push) Has been cancelled
Universal: PR Check / Build RC Package (pull_request) Has been cancelled
Authored-by: Moko Consulting
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-31 12:14:34 -05:00
Jonathan Miller 34ab5c43ee docs: update CHANGELOG with CliFramework migration and recent fixes
Generic: Repo Health / Site Health (push) Has been cancelled
Generic: Repo Health / Access control (push) Has been cancelled
Generic: Repo Health / Release configuration (push) Has been cancelled
Generic: Repo Health / Scripts governance (push) Has been cancelled
Generic: Repo Health / Repository health (push) Has been cancelled
Authored-by: Moko Consulting
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-31 12:07:59 -05:00
Jonathan Miller 154d6911f9 Merge branch 'main' into dev
Universal: PR Check / Branch Policy (pull_request) Has been cancelled
Generic: Repo Health / Site Health (pull_request) Has been cancelled
Generic: Repo Health / Access control (pull_request) Has been cancelled
Universal: Secret Scanning / Gitleaks Secret Scan (pull_request) Has been cancelled
Universal: PR Check / Validate PR (pull_request) Has been cancelled
Universal: Auto Version Bump / Version Bump (push) Has been cancelled
Platform: moko-platform CI / Gate 1: Code Quality (push) Has been cancelled
Platform: moko-platform CI / Gate 1: Code Quality (pull_request) Has been cancelled
Generic: Repo Health / Site Health (push) Has been cancelled
Generic: Repo Health / Access control (push) Has been cancelled
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
Platform: moko-platform CI / Gate 2: Unit Tests (8.1) (pull_request) Has been cancelled
Platform: moko-platform CI / Gate 2: Unit Tests (8.2) (pull_request) Has been cancelled
Platform: moko-platform CI / Gate 2: Unit Tests (8.3) (pull_request) Has been cancelled
Platform: moko-platform CI / Gate 3: Self-Health Check (pull_request) Has been cancelled
Platform: moko-platform CI / Gate 4: Governance (pull_request) Has been cancelled
Platform: moko-platform CI / Gate 5: Template Integrity (pull_request) Has been cancelled
Platform: moko-platform CI / CI Summary (pull_request) Has been cancelled
Universal: PR Check / Build RC Package (pull_request) Has been cancelled
Generic: Repo Health / Release configuration (pull_request) Has been cancelled
Generic: Repo Health / Scripts governance (pull_request) Has been cancelled
Generic: Repo Health / Repository health (pull_request) Has been cancelled
Generic: Repo Health / Release configuration (push) Has been cancelled
Generic: Repo Health / Scripts governance (push) Has been cancelled
Generic: Repo Health / Repository health (push) Has been cancelled
# Conflicts:
#	cli/release_create.php
#	cli/release_publish.php
#	cli/updates_xml_build.php
2026-05-31 11:40:18 -05:00
Jonathan Miller b3d9ee8255 refactor(cli): migrate 64 legacy scripts to CliFramework (#235)
Generic: Repo Health / Site Health (push) Has been cancelled
Generic: Repo Health / Access control (push) Has been cancelled
Platform: moko-platform CI / Gate 1: Code Quality (push) Has been cancelled
Platform: moko-platform CI / Gate 2: Unit Tests (8.1) (push) Has been cancelled
Platform: moko-platform CI / Gate 2: Unit Tests (8.2) (push) Has been cancelled
Platform: moko-platform CI / Gate 2: Unit Tests (8.3) (push) Has been cancelled
Platform: moko-platform CI / Gate 3: Self-Health Check (push) Has been cancelled
Platform: moko-platform CI / Gate 4: Governance (push) Has been cancelled
Platform: moko-platform CI / Gate 5: Template Integrity (push) Has been cancelled
Platform: moko-platform CI / CI Summary (push) Has been cancelled
Generic: Repo Health / Release configuration (push) Has been cancelled
Generic: Repo Health / Scripts governance (push) Has been cancelled
Generic: Repo Health / Repository health (push) Has been cancelled
Wrap all CLI tools in cli/, automation/, maintenance/, deploy/, and
release/ in classes extending CliFramework. Replaces manual $argv
parsing with configure()/addArgument(), moves logic into run(): int,
and converts fwrite(STDERR,...) to $this->log(). Two CLIApp subclasses
(generate_dolibarr_version_txt, generate_joomla_update_xml) converted
to extend CliFramework directly.

Every script now gets free --help, --verbose, --quiet, --dry-run,
--json, --no-color, banners, coloured logging, and progress bars.

Authored-by: Moko Consulting
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-31 11:39:10 -05:00
Jonathan Miller 4da7ecd38e fix: restore hyphen in version suffixes
Generic: Repo Health / Site Health (push) Has been cancelled
Universal: Auto Version Bump / Version Bump (push) Has been cancelled
Generic: Repo Health / Access control (push) Has been cancelled
Universal: PR Check / Branch Policy (pull_request) Has been cancelled
Generic: Repo Health / Site Health (pull_request) Has been cancelled
Universal: Secret Scanning / Gitleaks Secret Scan (pull_request) Has been cancelled
Generic: Repo Health / Access control (pull_request) Has been cancelled
Universal: PR Check / Validate PR (pull_request) Has been cancelled
Platform: moko-platform CI / Gate 1: Code Quality (pull_request) Has been cancelled
Platform: moko-platform CI / Gate 1: Code Quality (push) Has been cancelled
Platform: moko-platform CI / Gate 2: Unit Tests (8.1) (pull_request) Has been cancelled
Platform: moko-platform CI / Gate 2: Unit Tests (8.2) (pull_request) Has been cancelled
Platform: moko-platform CI / Gate 2: Unit Tests (8.3) (pull_request) Has been cancelled
Platform: moko-platform CI / Gate 3: Self-Health Check (pull_request) Has been cancelled
Platform: moko-platform CI / Gate 4: Governance (pull_request) Has been cancelled
Platform: moko-platform CI / Gate 5: Template Integrity (pull_request) Has been cancelled
Platform: moko-platform CI / CI Summary (pull_request) Has been cancelled
Platform: moko-platform CI / Gate 2: Unit Tests (8.1) (push) Has been cancelled
Platform: moko-platform CI / Gate 2: Unit Tests (8.2) (push) Has been cancelled
Platform: moko-platform CI / Gate 2: Unit Tests (8.3) (push) Has been cancelled
Platform: moko-platform CI / Gate 3: Self-Health Check (push) Has been cancelled
Platform: moko-platform CI / Gate 4: Governance (push) Has been cancelled
Platform: moko-platform CI / Gate 5: Template Integrity (push) Has been cancelled
Platform: moko-platform CI / CI Summary (push) Has been cancelled
Generic: Repo Health / Release configuration (push) Has been cancelled
Generic: Repo Health / Scripts governance (push) Has been cancelled
Generic: Repo Health / Repository health (push) Has been cancelled
Universal: PR Check / Build RC Package (pull_request) Has been cancelled
Generic: Repo Health / Release configuration (pull_request) Has been cancelled
Generic: Repo Health / Scripts governance (pull_request) Has been cancelled
Generic: Repo Health / Repository health (pull_request) Has been cancelled
Reverts suffix change — keeps hyphen: 02.30.00-dev, 02.30.00-rc, etc.

Authored-by: Moko Consulting
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-31 11:15:09 -05:00
Jonathan Miller 2e1eb9b8f9 fix: remove hyphen from version suffixes in release names
Version suffixes are now appended without a hyphen:
  02.30.00dev instead of 02.30.00-dev
  02.30.00rc instead of 02.30.00-rc

This matches the standard release name format:
  Package - MokoWaaS (VERSION: 02.30.00dev)

Authored-by: Moko Consulting
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-31 11:15:09 -05:00
Jonathan Miller 59720f1533 fix: release names use standardized format
Changed release name from:
  "Package - MokoWaaS 02.29.04 (pkg_mokowaas-02.29.04)"
To:
  "Package - MokoWaaS (VERSION: 02.29.04)"

Closes #242

Authored-by: Moko Consulting
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-31 11:15:08 -05:00
Jonathan Miller 930776a6ff fix(cli): auto-detect org/repo in updates_xml_build from manifest and git remote
When --org/--repo are not provided and env vars are unset, the tool now
reads <org> and <name> from .mokogitea/manifest.xml, with a final
fallback to parsing the git remote URL. Fixes broken URLs with empty
org/repo segments in generated updates.xml.

Authored-by: Moko Consulting
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-31 11:15:08 -05:00
jmiller 4453a2e127 chore: sync CONTRIBUTING.md from moko-platform [skip ci] 2026-05-31 11:15:08 -05:00
Jonathan Miller 0363597c85 fix: remove lesser stream copies, each stream updates independently
Joomla picks the HIGHEST version via version_compare() across ALL
entries. Lesser stream copies (02.17.00-dev) at the same base version
as stable (02.17.00) would never be selected because dev < stable.

Each stream now updates only when its own release happens:
- stable: on merge to main
- dev: on auto-bump after dev recreated from main
- rc: on promote-rc

The dev stream naturally gets 02.17.01-dev (higher than 02.17.00)
because auto-bump runs on the recreated dev branch.

Also sorts updates.xml entries dev→stable (dev first).

Authored-by: Moko Consulting
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-31 11:15:08 -05:00
Jonathan Miller 547fc5ead8 fix: sort updates.xml entries dev first, stable last [skip ci]
Joomla reads entries top-down. Sorting dev→alpha→beta→rc→stable
ensures proper display ordering in the Joomla update manager.

Authored-by: Moko Consulting
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-31 11:15:07 -05:00
Jonathan Miller af77e9d361 fix: restore hyphen in version suffixes
Generic: Repo Health / Site Health (push) Has been cancelled
Generic: Repo Health / Access control (push) Has been cancelled
Platform: moko-platform CI / Gate 1: Code Quality (push) Has been cancelled
Platform: moko-platform CI / Gate 2: Unit Tests (8.1) (push) Has been cancelled
Platform: moko-platform CI / Gate 2: Unit Tests (8.2) (push) Has been cancelled
Platform: moko-platform CI / Gate 2: Unit Tests (8.3) (push) Has been cancelled
Platform: moko-platform CI / Gate 3: Self-Health Check (push) Has been cancelled
Platform: moko-platform CI / Gate 4: Governance (push) Has been cancelled
Platform: moko-platform CI / Gate 5: Template Integrity (push) Has been cancelled
Platform: moko-platform CI / CI Summary (push) Has been cancelled
Generic: Repo Health / Release configuration (push) Has been cancelled
Generic: Repo Health / Scripts governance (push) Has been cancelled
Generic: Repo Health / Repository health (push) Has been cancelled
Reverts suffix change — keeps hyphen: 02.30.00-dev, 02.30.00-rc, etc.

Authored-by: Moko Consulting
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-31 11:12:42 -05:00
Jonathan Miller 7565ef2171 fix: remove hyphen from version suffixes in release names
Generic: Repo Health / Site Health (push) Has been cancelled
Generic: Repo Health / Access control (push) Has been cancelled
Platform: moko-platform CI / Gate 1: Code Quality (push) Has been cancelled
Platform: moko-platform CI / Gate 2: Unit Tests (8.1) (push) Has been cancelled
Platform: moko-platform CI / Gate 2: Unit Tests (8.2) (push) Has been cancelled
Platform: moko-platform CI / Gate 2: Unit Tests (8.3) (push) Has been cancelled
Platform: moko-platform CI / Gate 3: Self-Health Check (push) Has been cancelled
Platform: moko-platform CI / Gate 4: Governance (push) Has been cancelled
Platform: moko-platform CI / Gate 5: Template Integrity (push) Has been cancelled
Platform: moko-platform CI / CI Summary (push) Has been cancelled
Generic: Repo Health / Release configuration (push) Has been cancelled
Generic: Repo Health / Scripts governance (push) Has been cancelled
Generic: Repo Health / Repository health (push) Has been cancelled
Version suffixes are now appended without a hyphen:
  02.30.00dev instead of 02.30.00-dev
  02.30.00rc instead of 02.30.00-rc

This matches the standard release name format:
  Package - MokoWaaS (VERSION: 02.30.00dev)

Authored-by: Moko Consulting
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-31 11:10:08 -05:00
Jonathan Miller f724eaa26e fix: release names use standardized format
Generic: Repo Health / Site Health (push) Has been cancelled
Generic: Repo Health / Access control (push) Has been cancelled
Platform: moko-platform CI / Gate 1: Code Quality (push) Has been cancelled
Platform: moko-platform CI / Gate 2: Unit Tests (8.1) (push) Has been cancelled
Platform: moko-platform CI / Gate 2: Unit Tests (8.2) (push) Has been cancelled
Platform: moko-platform CI / Gate 2: Unit Tests (8.3) (push) Has been cancelled
Platform: moko-platform CI / Gate 3: Self-Health Check (push) Has been cancelled
Platform: moko-platform CI / Gate 4: Governance (push) Has been cancelled
Platform: moko-platform CI / Gate 5: Template Integrity (push) Has been cancelled
Platform: moko-platform CI / CI Summary (push) Has been cancelled
Generic: Repo Health / Release configuration (push) Has been cancelled
Generic: Repo Health / Scripts governance (push) Has been cancelled
Generic: Repo Health / Repository health (push) Has been cancelled
Changed release name from:
  "Package - MokoWaaS 02.29.04 (pkg_mokowaas-02.29.04)"
To:
  "Package - MokoWaaS (VERSION: 02.29.04)"

Closes #242

Authored-by: Moko Consulting
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-31 11:05:41 -05:00
Jonathan Miller 7f1307bf05 fix(cli): auto-detect org/repo in updates_xml_build from manifest and git remote
Generic: Repo Health / Site Health (push) Has been cancelled
Generic: Repo Health / Access control (push) Has been cancelled
Platform: moko-platform CI / Gate 1: Code Quality (push) Has been cancelled
Platform: moko-platform CI / Gate 2: Unit Tests (8.1) (push) Has been cancelled
Platform: moko-platform CI / Gate 2: Unit Tests (8.2) (push) Has been cancelled
Platform: moko-platform CI / Gate 2: Unit Tests (8.3) (push) Has been cancelled
Platform: moko-platform CI / Gate 3: Self-Health Check (push) Has been cancelled
Platform: moko-platform CI / Gate 4: Governance (push) Has been cancelled
Platform: moko-platform CI / Gate 5: Template Integrity (push) Has been cancelled
Platform: moko-platform CI / CI Summary (push) Has been cancelled
Generic: Repo Health / Release configuration (push) Has been cancelled
Generic: Repo Health / Scripts governance (push) Has been cancelled
Generic: Repo Health / Repository health (push) Has been cancelled
When --org/--repo are not provided and env vars are unset, the tool now
reads <org> and <name> from .mokogitea/manifest.xml, with a final
fallback to parsing the git remote URL. Fixes broken URLs with empty
org/repo segments in generated updates.xml.

Authored-by: Moko Consulting
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-31 08:03:04 -05:00
jmiller d0d778fae8 chore: sync .mokogitea/workflows/cascade-dev.yml from moko-platform [skip ci] 2026-05-31 01:45:19 +00:00
jmiller c4aaf5bd2c chore: sync CONTRIBUTING.md from moko-platform [skip ci] 2026-05-31 01:09:57 +00:00
Jonathan Miller d96c5ac420 fix: remove lesser stream copies, each stream updates independently
Generic: Repo Health / Site Health (push) Has been cancelled
Generic: Repo Health / Access control (push) Has been cancelled
Platform: moko-platform CI / Gate 1: Code Quality (push) Has been cancelled
Platform: moko-platform CI / Gate 2: Unit Tests (8.1) (push) Has been cancelled
Platform: moko-platform CI / Gate 2: Unit Tests (8.2) (push) Has been cancelled
Platform: moko-platform CI / Gate 2: Unit Tests (8.3) (push) Has been cancelled
Platform: moko-platform CI / Gate 3: Self-Health Check (push) Has been cancelled
Platform: moko-platform CI / Gate 4: Governance (push) Has been cancelled
Platform: moko-platform CI / Gate 5: Template Integrity (push) Has been cancelled
Platform: moko-platform CI / CI Summary (push) Has been cancelled
Generic: Repo Health / Release configuration (push) Has been cancelled
Generic: Repo Health / Scripts governance (push) Has been cancelled
Generic: Repo Health / Repository health (push) Has been cancelled
Joomla picks the HIGHEST version via version_compare() across ALL
entries. Lesser stream copies (02.17.00-dev) at the same base version
as stable (02.17.00) would never be selected because dev < stable.

Each stream now updates only when its own release happens:
- stable: on merge to main
- dev: on auto-bump after dev recreated from main
- rc: on promote-rc

The dev stream naturally gets 02.17.01-dev (higher than 02.17.00)
because auto-bump runs on the recreated dev branch.

Also sorts updates.xml entries dev→stable (dev first).

Authored-by: Moko Consulting
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-30 19:53:47 -05:00
Jonathan Miller a8ef5f1090 fix: sort updates.xml entries dev first, stable last [skip ci]
Joomla reads entries top-down. Sorting dev→alpha→beta→rc→stable
ensures proper display ordering in the Joomla update manager.

Authored-by: Moko Consulting
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-30 19:50:11 -05:00
Jonathan Miller a9147bb7a4 fix: disable cascade-dev [skip ci] 2026-05-30 18:08:53 -05:00
298 changed files with 19317 additions and 17043 deletions
+1 -1
View File
@@ -42,7 +42,7 @@ Suggested text here
<!-- Add any other context, screenshots, or references --> <!-- Add any other context, screenshots, or references -->
## Standards Alignment ## Standards Alignment
- [ ] Follows MokoStandards documentation guidelines - [ ] Follows moko-platform documentation guidelines
- [ ] Uses en_US/en_GB localization - [ ] Uses en_US/en_GB localization
- [ ] Includes proper SPDX headers where applicable - [ ] Includes proper SPDX headers where applicable
+1 -1
View File
@@ -37,7 +37,7 @@ If you have ideas about how this could be implemented, share them here:
Add any other context, mockups, or screenshots about the feature request here. Add any other context, mockups, or screenshots about the feature request here.
## Relevant Standards ## Relevant Standards
Does this relate to any standards in [MokoStandards](https://git.mokoconsulting.tech/MokoConsulting/moko-platform)? Does this relate to any standards in [moko-platform](https://git.mokoconsulting.tech/MokoConsulting/moko-platform)?
- [ ] Accessibility (WCAG 2.1 AA) - [ ] Accessibility (WCAG 2.1 AA)
- [ ] Localization (en_US/en_GB) - [ ] Localization (en_US/en_GB)
- [ ] Security best practices - [ ] Security best practices
+1 -1
View File
@@ -35,7 +35,7 @@ Use this template only for:
<!-- Describe how this could be addressed --> <!-- Describe how this could be addressed -->
## Standards Reference ## Standards Reference
Does this relate to security standards in [MokoStandards](https://git.mokoconsulting.tech/MokoConsulting/moko-platform)? Does this relate to security standards in [moko-platform](https://git.mokoconsulting.tech/MokoConsulting/moko-platform)?
- [ ] SPDX license identifiers - [ ] SPDX license identifiers
- [ ] Secret management - [ ] Secret management
- [ ] Dependency security - [ ] Dependency security
+1 -1
View File
@@ -62,7 +62,7 @@ jobs:
API="${GITEA_URL}/api/v1" API="${GITEA_URL}/api/v1"
# Platform/standards/infra repos to exclude # Platform/standards/infra repos to exclude
EXCLUDE="gitea-org-config org-profile gitea-private .mokogitea-private MokoStandards moko-platform MokoTesting" EXCLUDE="gitea-org-config org-profile gitea-private .mokogitea-private moko-platform MokoTesting"
EXCLUDE="$EXCLUDE MokoStandards-Template-Client MokoStandards-Template-Dolibarr MokoStandards-Template-Generic MokoStandards-Template-Joomla MokoDoliProjTemplate" EXCLUDE="$EXCLUDE MokoStandards-Template-Client MokoStandards-Template-Dolibarr MokoStandards-Template-Generic MokoStandards-Template-Joomla MokoDoliProjTemplate"
if [ -n "${{ inputs.repos }}" ]; then if [ -n "${{ inputs.repos }}" ]; then
+1 -1
View File
@@ -61,7 +61,7 @@ jobs:
run: | run: |
API="${GITEA_URL}/api/v1" API="${GITEA_URL}/api/v1"
EXCLUDE="gitea-org-config org-profile gitea-private .mokogitea-private MokoStandards moko-platform MokoTesting" EXCLUDE="gitea-org-config org-profile gitea-private .mokogitea-private moko-platform MokoTesting"
EXCLUDE="$EXCLUDE MokoStandards-Template-Client MokoStandards-Template-Dolibarr MokoStandards-Template-Generic MokoStandards-Template-Joomla MokoDoliProjTemplate" EXCLUDE="$EXCLUDE MokoStandards-Template-Client MokoStandards-Template-Dolibarr MokoStandards-Template-Generic MokoStandards-Template-Joomla MokoDoliProjTemplate"
if [ -n "${{ inputs.repos }}" ]; then if [ -n "${{ inputs.repos }}" ]; then
+1 -1
View File
@@ -7,7 +7,7 @@
# INGROUP: moko-platform.Release # INGROUP: moko-platform.Release
# REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform # REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform
# PATH: /.mokogitea/workflows/auto-bump.yml # PATH: /.mokogitea/workflows/auto-bump.yml
# VERSION: 09.02.00 # VERSION: 09.23.00
# BRIEF: Auto patch-bump version on every push to dev (skips merge commits) # BRIEF: Auto patch-bump version on every push to dev (skips merge commits)
name: "Universal: Auto Version Bump" name: "Universal: Auto Version Bump"
+18 -3
View File
@@ -102,13 +102,14 @@ jobs:
run: | run: |
php /tmp/moko-platform-api/cli/release_publish.php \ php /tmp/moko-platform-api/cli/release_publish.php \
--path . --stability rc --bump minor --branch rc \ --path . --stability rc --bump minor --branch rc \
--token "${{ secrets.MOKOGITEA_TOKEN }}" --token "${{ secrets.MOKOGITEA_TOKEN }}" \
--skip-update-stream
- name: Summary - name: Summary
if: always() if: always()
run: | run: |
echo "## Promoted to Release Candidate" >> $GITHUB_STEP_SUMMARY echo "## Promoted to Release Candidate" >> $GITHUB_STEP_SUMMARY
echo "Branch renamed to rc, minor bump, RC + lesser stream releases built, updates.xml synced" >> $GITHUB_STEP_SUMMARY echo "Branch renamed to rc, minor bump, RC release built (updates.xml managed by Gitea Pages)" >> $GITHUB_STEP_SUMMARY
# ── Merged PR → Build & Release (or promote RC to stable) ──────────────────── # ── Merged PR → Build & Release (or promote RC to stable) ────────────────────
release: release:
@@ -131,6 +132,19 @@ jobs:
git config --local user.name "gitea-actions[bot]" git config --local user.name "gitea-actions[bot]"
git remote set-url origin "https://x-access-token:${{ secrets.MOKOGITEA_TOKEN }}@git.mokoconsulting.tech/${{ github.repository }}.git" git remote set-url origin "https://x-access-token:${{ secrets.MOKOGITEA_TOKEN }}@git.mokoconsulting.tech/${{ github.repository }}.git"
- name: Check for merge conflict markers
run: |
CONFLICTS=$(grep -rn '<<<<<<< \|>>>>>>> \|^=======$' --include='*.php' --include='*.xml' --include='*.css' --include='*.js' --include='*.json' --include='*.md' --include='*.yml' --include='*.yaml' --include='*.ini' --include='*.txt' . 2>/dev/null | grep -v '.git/' || true)
if [ -n "$CONFLICTS" ]; then
echo "::error::Merge conflict markers found — aborting release"
echo "## Release Blocked: Conflict Markers" >> $GITHUB_STEP_SUMMARY
echo '```' >> $GITHUB_STEP_SUMMARY
echo "$CONFLICTS" >> $GITHUB_STEP_SUMMARY
echo '```' >> $GITHUB_STEP_SUMMARY
exit 1
fi
echo "No conflict markers found"
- name: Setup moko-platform tools - name: Setup moko-platform tools
env: env:
MOKO_CLONE_TOKEN: ${{ secrets.MOKOGITEA_TOKEN }} MOKO_CLONE_TOKEN: ${{ secrets.MOKOGITEA_TOKEN }}
@@ -154,7 +168,8 @@ jobs:
run: | run: |
php /tmp/moko-platform-api/cli/release_publish.php \ php /tmp/moko-platform-api/cli/release_publish.php \
--path . --stability stable --bump minor --branch main \ --path . --stability stable --bump minor --branch main \
--token "${{ secrets.MOKOGITEA_TOKEN }}" --token "${{ secrets.MOKOGITEA_TOKEN }}" \
--skip-update-stream
# -- STEP 9: Mirror to GitHub (stable only) -------------------------------- # -- STEP 9: Mirror to GitHub (stable only) --------------------------------
- name: "Step 9: Mirror release to GitHub" - name: "Step 9: Mirror release to GitHub"
+2 -2
View File
@@ -4,10 +4,10 @@
# #
# FILE INFORMATION # FILE INFORMATION
# DEFGROUP: Gitea.Workflow # DEFGROUP: Gitea.Workflow
# INGROUP: MokoStandards.Universal # INGROUP: MokoPlatform.Universal
# REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform # REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform
# PATH: /.mokogitea/workflows/branch-cleanup.yml # PATH: /.mokogitea/workflows/branch-cleanup.yml
# VERSION: 01.00.00 # VERSION: 09.23.00
# BRIEF: Delete feature branches after PR merge # BRIEF: Delete feature branches after PR merge
name: "Branch Cleanup" name: "Branch Cleanup"
+7 -210
View File
@@ -1,213 +1,10 @@
# Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech> # DISABLED — auto-release Step 11 recreates dev from main after every release.
# # Cascade-dev is redundant and causes version conflicts when both main and dev
# SPDX-License-Identifier: GPL-3.0-or-later # have different version numbers in templateDetails.xml / manifest.xml.
# name: "Cascade Main → Dev (DISABLED)"
# FILE INFORMATION on: workflow_dispatch
# DEFGROUP: Gitea.Workflow
# INGROUP: moko-platform.Maintenance
# REPO: https://git.mokoconsulting.tech/mokoconsulting-tech/moko-platform
# PATH: /templates/workflows/cascade-dev.yml.template
# VERSION: 02.00.00
# BRIEF: Forward-merge main → all open branches after every push to main
#
# +========================================================================+
# | CASCADE MAIN → ALL BRANCHES |
# +========================================================================+
# | |
# | Triggers on every push to main (PR merges, bot commits, etc.) |
# | |
# | 1. List all branches matching: dev, rc/*, beta/*, alpha/* |
# | 2. For each: create PR (main → branch), auto-merge if clean |
# | 3. On conflict: leave PR open for manual resolution |
# | |
# +========================================================================+
name: "Universal: Cascade Main → Dev"
on:
push:
branches:
- main
workflow_dispatch:
env:
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
GITEA_URL: ${{ vars.GITEA_URL || 'https://git.mokoconsulting.tech' }}
GITEA_ORG: ${{ vars.GITEA_ORG || github.repository_owner }}
GITEA_REPO: ${{ vars.GITEA_REPO || github.event.repository.name }}
permissions:
contents: write
pull-requests: write
jobs: jobs:
cascade: noop:
name: Cascade main → branches
runs-on: ubuntu-latest runs-on: ubuntu-latest
if: >-
!contains(github.event.head_commit.message, '[skip ci]') &&
!contains(github.event.head_commit.message, '[skip cascade]')
steps: steps:
- name: Discover target branches - run: echo "Cascade disabled — auto-release handles dev recreation"
id: branches
env:
GA_TOKEN: ${{ secrets.MOKOGITEA_TOKEN }}
run: |
API="${GITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}"
# Fetch all branches (paginated)
PAGE=1
ALL_BRANCHES=""
while true; do
BATCH=$(curl -sS \
-H "Authorization: token ${GITEA_TOKEN}" \
"${API}/branches?page=${PAGE}&limit=50" \
| jq -r '.[].name // empty')
[ -z "$BATCH" ] && break
ALL_BRANCHES="$ALL_BRANCHES $BATCH"
PAGE=$((PAGE + 1))
done
# Filter to cascade targets: dev, dev/*, rc/*, beta/*, alpha/*
TARGETS=""
for BRANCH in $ALL_BRANCHES; do
case "$BRANCH" in
dev|dev/*|rc/*|beta/*|alpha/*)
TARGETS="$TARGETS $BRANCH"
;;
esac
done
TARGETS=$(echo "$TARGETS" | xargs) # trim whitespace
if [ -z "$TARGETS" ]; then
echo "targets=" >> "$GITHUB_OUTPUT"
echo "️ No cascade target branches found"
else
echo "targets=$TARGETS" >> "$GITHUB_OUTPUT"
COUNT=$(echo "$TARGETS" | wc -w)
echo "📋 Found ${COUNT} target branch(es): ${TARGETS}"
fi
- name: Cascade to all target branches
if: steps.branches.outputs.targets != ''
env:
GA_TOKEN: ${{ secrets.MOKOGITEA_TOKEN }}
run: |
API="${GITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}"
SHORT_SHA="${GITHUB_SHA:0:7}"
TARGETS="${{ steps.branches.outputs.targets }}"
SUCCESS=0
CONFLICTS=0
SKIPPED=0
FAILED=0
for BRANCH in $TARGETS; do
echo ""
echo "═══ main → ${BRANCH} ═══"
# Check if branch is already up to date
ENCODED_BRANCH=$(echo "$BRANCH" | sed 's|/|%2F|g')
RESPONSE=$(curl -sS \
-H "Authorization: token ${GITEA_TOKEN}" \
"${API}/compare/${ENCODED_BRANCH}...main")
AHEAD=$(echo "$RESPONSE" | jq '.total_commits // 0')
if [ "$AHEAD" -eq 0 ]; then
echo " ✅ Already up to date"
SKIPPED=$((SKIPPED + 1))
continue
fi
echo " ️ main is ${AHEAD} commit(s) ahead"
# Check for existing cascade PR
EXISTING=$(curl -sS \
-H "Authorization: token ${GITEA_TOKEN}" \
"${API}/pulls?state=open&head=${GITEA_ORG}:main&base=${ENCODED_BRANCH}&limit=1")
EXISTING_COUNT=$(echo "$EXISTING" | jq 'length')
PR_NUMBER=""
if [ "$EXISTING_COUNT" -gt 0 ]; then
PR_NUMBER=$(echo "$EXISTING" | jq -r '.[0].number')
echo " ️ Reusing existing PR #${PR_NUMBER}"
else
# Create cascade PR
PR_RESPONSE=$(curl -sS -w "\n%{http_code}" \
-X POST \
-H "Authorization: token ${GITEA_TOKEN}" \
-H "Content-Type: application/json" \
-d "{
\"title\": \"chore: cascade main → ${BRANCH} (${SHORT_SHA}) [skip ci]\",
\"body\": \"## Automatic cascade\\n\\nForward-merging \`main\` (${SHORT_SHA}) into \`${BRANCH}\`.\\n\\nIf conflicts exist, resolve manually and merge.\\n\\n> Auto-created by **Cascade Main → Dev**.\",
\"head\": \"main\",
\"base\": \"${BRANCH}\"
}" \
"${API}/pulls")
HTTP_CODE=$(echo "$PR_RESPONSE" | tail -1)
BODY=$(echo "$PR_RESPONSE" | sed '$d')
PR_NUMBER=$(echo "$BODY" | jq -r '.number // empty')
if [ "$HTTP_CODE" != "201" ] || [ -z "$PR_NUMBER" ]; then
MSG=$(echo "$BODY" | jq -r '.message // .' 2>/dev/null | head -1)
echo " ❌ Failed to create PR (HTTP ${HTTP_CODE}): ${MSG}"
FAILED=$((FAILED + 1))
continue
fi
echo " ✅ Created PR #${PR_NUMBER}"
fi
# Try auto-merge
PR_DATA=$(curl -sS \
-H "Authorization: token ${GITEA_TOKEN}" \
"${API}/pulls/${PR_NUMBER}")
MERGEABLE=$(echo "$PR_DATA" | jq -r '.mergeable // false')
if [ "$MERGEABLE" != "true" ]; then
echo " ⚠️ Conflicts — PR #${PR_NUMBER} left open"
CONFLICTS=$((CONFLICTS + 1))
continue
fi
MERGE_RESPONSE=$(curl -sS -w "\n%{http_code}" \
-X POST \
-H "Authorization: token ${GITEA_TOKEN}" \
-H "Content-Type: application/json" \
-d "{
\"Do\": \"merge\",
\"merge_message_field\": \"chore: cascade main → ${BRANCH} [skip ci]\",
\"delete_branch_after_merge\": false
}" \
"${API}/pulls/${PR_NUMBER}/merge")
MERGE_HTTP=$(echo "$MERGE_RESPONSE" | tail -1)
if [ "$MERGE_HTTP" = "200" ] || [ "$MERGE_HTTP" = "204" ]; then
echo " ✅ Merged — ${BRANCH} is in sync"
SUCCESS=$((SUCCESS + 1))
else
MERGE_BODY=$(echo "$MERGE_RESPONSE" | sed '$d')
echo " ⚠️ Merge failed (HTTP ${MERGE_HTTP}) — PR #${PR_NUMBER} left open"
CONFLICTS=$((CONFLICTS + 1))
fi
done
# Summary
echo ""
echo "════════════════════════════════════════"
echo " ✅ Merged: ${SUCCESS}"
echo " ⚠️ Conflicts: ${CONFLICTS}"
echo " ⏭️ Up to date: ${SKIPPED}"
echo " ❌ Failed: ${FAILED}"
echo "════════════════════════════════════════"
if [ "$FAILED" -gt 0 ]; then
exit 1
fi
+2 -2
View File
@@ -7,11 +7,11 @@
# INGROUP: moko-platform.CI # INGROUP: moko-platform.CI
# REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform # REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform
# PATH: /.gitea/workflows/ci-platform.yml # PATH: /.gitea/workflows/ci-platform.yml
# VERSION: 01.00.00 # VERSION: 09.23.00
# BRIEF: moko-platform CI — the standards engine validates itself # BRIEF: moko-platform CI — the standards engine validates itself
# #
# +========================================================================+ # +========================================================================+
# | MOKOSTANDARDS PLATFORM CI | # | MOKO-PLATFORM CI |
# +========================================================================+ # +========================================================================+
# | | # | |
# | This is NOT a generic CI workflow. This is the self-validation | # | This is NOT a generic CI workflow. This is the self-validation |
+1 -1
View File
@@ -7,7 +7,7 @@
# INGROUP: moko-platform.Maintenance # INGROUP: moko-platform.Maintenance
# REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform # REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform
# PATH: /.gitea/workflows/cleanup.yml # PATH: /.gitea/workflows/cleanup.yml
# VERSION: 01.00.00 # VERSION: 09.23.00
# BRIEF: Scheduled cleanup — delete merged branches and old workflow runs # BRIEF: Scheduled cleanup — delete merged branches and old workflow runs
name: "Universal: Repository Cleanup" name: "Universal: Repository Cleanup"
+1 -1
View File
@@ -7,7 +7,7 @@
# INGROUP: moko-platform.Security # INGROUP: moko-platform.Security
# REPO: https://git.mokoconsulting.tech/mokoconsulting-tech/moko-platform # REPO: https://git.mokoconsulting.tech/mokoconsulting-tech/moko-platform
# PATH: /templates/workflows/gitleaks.yml.template # PATH: /templates/workflows/gitleaks.yml.template
# VERSION: 01.00.00 # VERSION: 09.23.00
# BRIEF: Secret scanning — detect leaked credentials, API keys, and tokens # BRIEF: Secret scanning — detect leaked credentials, API keys, and tokens
# #
# +========================================================================+ # +========================================================================+
+1 -1
View File
@@ -5,7 +5,7 @@
# FILE INFORMATION # FILE INFORMATION
# DEFGROUP: Gitea.Workflow # DEFGROUP: Gitea.Workflow
# INGROUP: moko-platform.Automation # INGROUP: moko-platform.Automation
# VERSION: 09.21.00 # VERSION: 09.24.00
# BRIEF: Auto-create feature branch when an issue is opened # BRIEF: Auto-create feature branch when an issue is opened
name: "Universal: Issue Branch" name: "Universal: Issue Branch"
+1 -1
View File
@@ -7,7 +7,7 @@
# INGROUP: moko-platform.Notifications # INGROUP: moko-platform.Notifications
# REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform # REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform
# PATH: /.gitea/workflows/notify.yml # PATH: /.gitea/workflows/notify.yml
# VERSION: 01.00.00 # VERSION: 09.23.00
# BRIEF: Push notifications via ntfy on release success or workflow failure # BRIEF: Push notifications via ntfy on release success or workflow failure
name: "Universal: Notifications" name: "Universal: Notifications"
+273 -1
View File
@@ -7,7 +7,7 @@
# INGROUP: moko-platform.CI # INGROUP: moko-platform.CI
# REPO: https://git.mokoconsulting.tech/mokoconsulting-tech/moko-platform # REPO: https://git.mokoconsulting.tech/mokoconsulting-tech/moko-platform
# PATH: /templates/workflows/universal/pr-check.yml.template # PATH: /templates/workflows/universal/pr-check.yml.template
# VERSION: 05.00.00 # VERSION: 09.23.00
# BRIEF: PR gate — branch policy + code validation before merge # BRIEF: PR gate — branch policy + code validation before merge
name: "Universal: PR Check" name: "Universal: PR Check"
@@ -105,6 +105,19 @@ jobs:
- name: Checkout - name: Checkout
uses: actions/checkout@v4 uses: actions/checkout@v4
- name: Check for merge conflict markers
run: |
CONFLICTS=$(grep -rn '<<<<<<< \|>>>>>>> \|^=======$' --include='*.php' --include='*.xml' --include='*.css' --include='*.js' --include='*.json' --include='*.md' --include='*.yml' --include='*.yaml' --include='*.ini' --include='*.txt' . 2>/dev/null | grep -v '.git/' || true)
if [ -n "$CONFLICTS" ]; then
echo "::error::Merge conflict markers found in source files"
echo "## Conflict Markers Found" >> $GITHUB_STEP_SUMMARY
echo '```' >> $GITHUB_STEP_SUMMARY
echo "$CONFLICTS" >> $GITHUB_STEP_SUMMARY
echo '```' >> $GITHUB_STEP_SUMMARY
exit 1
fi
echo "No conflict markers found"
- name: Detect platform - name: Detect platform
id: platform id: platform
run: | run: |
@@ -134,6 +147,98 @@ jobs:
echo "PHP lint: ${ERRORS} error(s)" echo "PHP lint: ${ERRORS} error(s)"
[ "$ERRORS" -eq 0 ] || { echo "::error::PHP syntax errors found"; exit 1; } [ "$ERRORS" -eq 0 ] || { echo "::error::PHP syntax errors found"; exit 1; }
- name: Joomla JEXEC guard check
if: steps.platform.outputs.platform == 'joomla'
run: |
ERRORS=0
while IFS= read -r -d '' file; do
# Skip vendor, node_modules, and index.html stub files
case "$file" in ./vendor/*|./node_modules/*) continue ;; esac
# Check first 10 lines for JEXEC or JPATH guard
if ! head -20 "$file" | grep -qE "defined\s*\(\s*['\"](_JEXEC|JPATH_BASE|\\\\JPATH_PLATFORM)['\"]"; then
echo "::error file=${file}::Missing JEXEC guard: ${file}"
ERRORS=$((ERRORS + 1))
fi
done < <(find . -name "*.php" -path "*/src/*" -not -path "./.git/*" -not -path "./vendor/*" -print0)
if [ "$ERRORS" -gt 0 ]; then
echo "::error::${ERRORS} PHP file(s) missing defined('_JEXEC') or die guard"
echo "## JEXEC Guard Check: Failed" >> $GITHUB_STEP_SUMMARY
echo "${ERRORS} file(s) in src/ are missing the Joomla execution guard." >> $GITHUB_STEP_SUMMARY
exit 1
fi
echo "JEXEC guard: OK"
- name: Joomla directory listing protection
if: steps.platform.outputs.platform == 'joomla'
run: |
MISSING=0
SOURCE_DIR="src"
[ ! -d "$SOURCE_DIR" ] && exit 0
while IFS= read -r dir; do
if [ ! -f "${dir}/index.html" ]; then
echo "::warning::Missing index.html in ${dir} (directory listing protection)"
MISSING=$((MISSING + 1))
fi
done < <(find "$SOURCE_DIR" -type d -not -path "./.git/*" -not -path "*/vendor/*" -not -path "*/node_modules/*")
if [ "$MISSING" -gt 0 ]; then
echo "## Directory Protection" >> $GITHUB_STEP_SUMMARY
echo "${MISSING} director(ies) missing index.html" >> $GITHUB_STEP_SUMMARY
fi
echo "Directory protection: ${MISSING} missing (advisory)"
- name: Joomla script file and asset checks
if: steps.platform.outputs.platform == 'joomla'
run: |
ERRORS=0
MANIFEST=$(find . -maxdepth 3 -name "*.xml" ! -path "./.git/*" -exec grep -l '<extension' {} \; 2>/dev/null | head -1)
[ -z "$MANIFEST" ] && exit 0
MANIFEST_DIR=$(dirname "$MANIFEST")
# Check scriptfile exists if declared
SCRIPTFILE=$(sed -n 's/.*<scriptfile>\([^<]*\)<\/scriptfile>.*/\1/p' "$MANIFEST" 2>/dev/null)
if [ -n "$SCRIPTFILE" ]; then
if [ ! -f "${MANIFEST_DIR}/${SCRIPTFILE}" ]; then
echo "::error::Manifest declares <scriptfile>${SCRIPTFILE}</scriptfile> but file not found at ${MANIFEST_DIR}/${SCRIPTFILE}"
ERRORS=$((ERRORS + 1))
else
echo "Script file: ${MANIFEST_DIR}/${SCRIPTFILE} (OK)"
fi
fi
# Require joomla.asset.json and validate it
ASSET_JSON=$(find "$MANIFEST_DIR" -name "joomla.asset.json" -not -path "./.git/*" 2>/dev/null | head -1)
if [ -z "$ASSET_JSON" ]; then
echo "::error::joomla.asset.json not found — Joomla asset system is required"
ERRORS=$((ERRORS + 1))
else
if command -v php &> /dev/null; then
php -r "json_decode(file_get_contents('$ASSET_JSON')); if(json_last_error()!==JSON_ERROR_NONE){echo json_last_error_msg();exit(1);}" 2>&1 || {
echo "::error::joomla.asset.json is not valid JSON"
ERRORS=$((ERRORS + 1))
}
fi
echo "joomla.asset.json: valid"
fi
# Validate all XML files in src/ are well-formed
XML_ERRORS=0
if command -v php &> /dev/null; then
while IFS= read -r -d '' xmlfile; do
if ! php -r "libxml_use_internal_errors(true); \$x = simplexml_load_file('$xmlfile'); if(!\$x){foreach(libxml_get_errors() as \$e) echo trim(\$e->message) . ' in $xmlfile'; exit(1);}" 2>&1; then
XML_ERRORS=$((XML_ERRORS + 1))
fi
done < <(find "$MANIFEST_DIR" -name "*.xml" -not -path "./.git/*" -print0)
fi
if [ "$XML_ERRORS" -gt 0 ]; then
echo "::error::${XML_ERRORS} XML file(s) are malformed"
ERRORS=$((ERRORS + 1))
else
echo "XML well-formedness: OK"
fi
[ "$ERRORS" -gt 0 ] && exit 1
echo "Joomla asset checks: OK"
- name: Validate platform manifest - name: Validate platform manifest
run: | run: |
PLATFORM="${{ steps.platform.outputs.platform }}" PLATFORM="${{ steps.platform.outputs.platform }}"
@@ -151,6 +256,13 @@ jobs:
for ELEMENT in name version description; do for ELEMENT in name version description; do
grep -q "<${ELEMENT}>" "$MANIFEST" || { echo "::error::Missing <${ELEMENT}> in manifest"; exit 1; } grep -q "<${ELEMENT}>" "$MANIFEST" || { echo "::error::Missing <${ELEMENT}> in manifest"; exit 1; }
done done
# Block legacy raw/branch update server URLs on MokoGitea
RAW_URLS=$(grep -n 'raw/branch' "$MANIFEST" | grep -i 'mokoconsulting\|mokogitea\|git\.mokoconsulting\.tech' || true)
if [ -n "$RAW_URLS" ]; then
echo "::error::Manifest contains legacy raw/branch update server URL on MokoGitea. Use the Gitea Pages URL instead (e.g. /{REPO}/updates.xml not /{REPO}/raw/branch/main/updates.xml)"
echo "$RAW_URLS"
exit 1
fi
echo "Joomla manifest valid" echo "Joomla manifest valid"
;; ;;
dolibarr) dolibarr)
@@ -183,6 +295,138 @@ jobs:
;; ;;
esac esac
- name: Validate Joomla language files
if: steps.platform.outputs.platform == 'joomla'
run: |
ERRORS=0
WARNINGS=0
# Require both en-GB and en-US language directories
LANG_ROOT=$(find . -path "*/language" -type d -not -path "./.git/*" 2>/dev/null | head -1)
if [ -z "$LANG_ROOT" ]; then
echo "No language/ directory found — skipping"
exit 0
fi
if [ ! -d "$LANG_ROOT/en-GB" ]; then
echo "::error::Missing en-GB language directory (${LANG_ROOT}/en-GB)"
ERRORS=$((ERRORS + 1))
fi
if [ ! -d "$LANG_ROOT/en-US" ]; then
echo "::error::Missing en-US language directory (${LANG_ROOT}/en-US)"
ERRORS=$((ERRORS + 1))
fi
# Check that en-GB and en-US have matching .ini files
if [ -d "$LANG_ROOT/en-GB" ] && [ -d "$LANG_ROOT/en-US" ]; then
for GB_INI in "$LANG_ROOT/en-GB"/*.ini; do
[ ! -f "$GB_INI" ] && continue
US_INI="$LANG_ROOT/en-US/$(basename "$GB_INI")"
if [ ! -f "$US_INI" ]; then
echo "::error::$(basename "$GB_INI") exists in en-GB but missing from en-US"
ERRORS=$((ERRORS + 1))
fi
done
for US_INI in "$LANG_ROOT/en-US"/*.ini; do
[ ! -f "$US_INI" ] && continue
GB_INI="$LANG_ROOT/en-GB/$(basename "$US_INI")"
if [ ! -f "$GB_INI" ]; then
echo "::error::$(basename "$US_INI") exists in en-US but missing from en-GB"
ERRORS=$((ERRORS + 1))
fi
done
fi
# Find all .ini language files
INI_FILES=$(find . -path "*/language/*/*.ini" -not -path "./.git/*" 2>/dev/null)
if [ -z "$INI_FILES" ]; then
echo "No .ini language files found"
[ "$ERRORS" -gt 0 ] && exit 1
exit 0
fi
echo "Found $(echo "$INI_FILES" | wc -l) language file(s)"
for FILE in $INI_FILES; do
FNAME=$(basename "$FILE")
LINENUM=0
SEEN_KEYS=""
while IFS= read -r line || [ -n "$line" ]; do
LINENUM=$((LINENUM + 1))
# Skip empty lines and comments
[ -z "$line" ] && continue
echo "$line" | grep -qE '^\s*;' && continue
echo "$line" | grep -qE '^\s*$' && continue
# Must match KEY="VALUE" format
if ! echo "$line" | grep -qE '^[A-Z_][A-Z0-9_]*=".*"$'; then
echo "::error file=${FILE},line=${LINENUM}::Malformed line: ${line}"
ERRORS=$((ERRORS + 1))
continue
fi
# Extract key and check for duplicates
KEY=$(echo "$line" | sed 's/=.*//')
if echo "$SEEN_KEYS" | grep -qx "$KEY"; then
echo "::error file=${FILE},line=${LINENUM}::Duplicate key: ${KEY}"
ERRORS=$((ERRORS + 1))
fi
SEEN_KEYS="${SEEN_KEYS}
${KEY}"
done < "$FILE"
echo " ${FILE}: checked ${LINENUM} lines"
done
# Cross-check en-GB vs en-US key consistency
GB_DIR=$(find . -path "*/language/en-GB" -type d -not -path "./.git/*" 2>/dev/null | head -1)
US_DIR=$(find . -path "*/language/en-US" -type d -not -path "./.git/*" 2>/dev/null | head -1)
if [ -n "$GB_DIR" ] && [ -n "$US_DIR" ]; then
for GB_FILE in "$GB_DIR"/*.ini; do
[ ! -f "$GB_FILE" ] && continue
FNAME=$(basename "$GB_FILE")
US_FILE="$US_DIR/$FNAME"
[ ! -f "$US_FILE" ] && continue
GB_KEYS=$(grep -oP '^[A-Z_][A-Z0-9_]*(?==)' "$GB_FILE" 2>/dev/null | sort)
US_KEYS=$(grep -oP '^[A-Z_][A-Z0-9_]*(?==)' "$US_FILE" 2>/dev/null | sort)
# Keys in en-GB but not en-US
MISSING_US=$(comm -23 <(echo "$GB_KEYS") <(echo "$US_KEYS"))
if [ -n "$MISSING_US" ]; then
echo "::warning::Keys in en-GB/$FNAME but missing from en-US/$FNAME:"
echo "$MISSING_US" | while read -r k; do echo " - $k"; done
WARNINGS=$((WARNINGS + 1))
fi
# Keys in en-US but not en-GB
MISSING_GB=$(comm -13 <(echo "$GB_KEYS") <(echo "$US_KEYS"))
if [ -n "$MISSING_GB" ]; then
echo "::warning::Keys in en-US/$FNAME but missing from en-GB/$FNAME:"
echo "$MISSING_GB" | while read -r k; do echo " - $k"; done
WARNINGS=$((WARNINGS + 1))
fi
done
fi
{
echo "### Language File Validation"
echo "| Metric | Count |"
echo "|---|---|"
echo "| Files checked | $(echo "$INI_FILES" | wc -l) |"
echo "| Errors | ${ERRORS} |"
echo "| Warnings | ${WARNINGS} |"
} >> $GITHUB_STEP_SUMMARY
if [ "$ERRORS" -gt 0 ]; then
echo "::error::Language validation failed with ${ERRORS} error(s)"
exit 1
fi
echo "Language files: OK (${WARNINGS} warning(s))"
- name: Check changelog has unreleased entry - name: Check changelog has unreleased entry
run: | run: |
if [ ! -f "CHANGELOG.md" ]; then if [ ! -f "CHANGELOG.md" ]; then
@@ -234,3 +478,31 @@ jobs:
curl -s -X POST "${GITEA_URL}/api/v1/repos/${REPO}/actions/workflows/pre-release.yml/dispatches" -H "Authorization: token ${GITEA_TOKEN}" -H "Content-Type: application/json" -d "{\"ref\":\"${BRANCH}\",\"inputs\":{\"stability\":\"release-candidate\"}}" curl -s -X POST "${GITEA_URL}/api/v1/repos/${REPO}/actions/workflows/pre-release.yml/dispatches" -H "Authorization: token ${GITEA_TOKEN}" -H "Content-Type: application/json" -d "{\"ref\":\"${BRANCH}\",\"inputs\":{\"stability\":\"release-candidate\"}}"
echo "### Pre-Release" >> $GITHUB_STEP_SUMMARY echo "### Pre-Release" >> $GITHUB_STEP_SUMMARY
echo "Triggered RC build on branch \`${BRANCH}\`" >> $GITHUB_STEP_SUMMARY echo "Triggered RC build on branch \`${BRANCH}\`" >> $GITHUB_STEP_SUMMARY
# ── Issue Reporter ──────────────────────────────────────────────────────
report-issues:
name: Report Issues
runs-on: ubuntu-latest
needs: [branch-policy, validate]
if: >-
always() &&
needs.validate.result == 'failure'
steps:
- name: Checkout
uses: actions/checkout@v4
with:
sparse-checkout: automation/ci-issue-reporter.sh
sparse-checkout-cone-mode: false
- name: "File issue for PR validation failure"
env:
GITEA_TOKEN: ${{ secrets.MOKOGITEA_TOKEN }}
GITEA_URL: ${{ vars.GITEA_URL || 'https://git.mokoconsulting.tech' }}
run: |
chmod +x automation/ci-issue-reporter.sh
./automation/ci-issue-reporter.sh \
--gate "PR Validation" \
--workflow "PR Check" \
--severity error \
--details "PR validation failed (syntax, manifest, changelog, or source checks). See the CI run for the specific check that failed."
+18 -67
View File
@@ -7,7 +7,7 @@
# INGROUP: moko-platform.Release # INGROUP: moko-platform.Release
# REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform # REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform
# PATH: /templates/workflows/universal/pre-release.yml.template # PATH: /templates/workflows/universal/pre-release.yml.template
# VERSION: 05.01.00 # VERSION: 09.23.00
# BRIEF: Manual pre-release -- builds dev/alpha/beta/rc packages from any branch # BRIEF: Manual pre-release -- builds dev/alpha/beta/rc packages from any branch
name: "Universal: Pre-Release" name: "Universal: Pre-Release"
@@ -79,29 +79,28 @@ jobs:
STABILITY="${{ inputs.stability || 'development' }}" STABILITY="${{ inputs.stability || 'development' }}"
case "$STABILITY" in case "$STABILITY" in
development) SUFFIX="-dev"; TAG="development" ;; development) TAG="development" ;;
alpha) SUFFIX="-alpha"; TAG="alpha" ;; alpha) TAG="alpha" ;;
beta) SUFFIX="-beta"; TAG="beta" ;; beta) TAG="beta" ;;
release-candidate) SUFFIX="-rc"; TAG="release-candidate" ;; release-candidate) TAG="release-candidate" ;;
esac esac
# Read current version (bump already handled by push workflow) # Bump version: patch for dev/alpha/beta, minor for RC
VERSION=$(php ${MOKO_CLI}/version_read.php --path . 2>/dev/null) case "$STABILITY" in
[ -z "$VERSION" ] && VERSION="00.00.01" release-candidate) php ${MOKO_CLI}/version_bump.php --path . --minor 2>/dev/null || true ;;
*) php ${MOKO_CLI}/version_bump.php --path . 2>/dev/null || true ;;
esac
# Strip any existing suffix from version before applying stability # Set stability suffix and fix consistency
VERSION=$(php ${MOKO_CLI}/version_read.php --path . 2>/dev/null || echo '00.00.01')
VERSION=$(echo "$VERSION" | sed 's/-\(dev\|alpha\|beta\|rc\)$//') VERSION=$(echo "$VERSION" | sed 's/-\(dev\|alpha\|beta\|rc\)$//')
php ${MOKO_CLI}/version_set_platform.php \ php ${MOKO_CLI}/version_set_platform.php \
--path . --version "$VERSION" --branch "${{ github.ref_name }}" --stability "$STABILITY" 2>/dev/null || true --path . --version "$VERSION" --branch "${{ github.ref_name }}" --stability "$STABILITY" 2>/dev/null || true
# Verify version consistency across all files
php ${MOKO_CLI}/version_check.php --path . --fix 2>/dev/null || true php ${MOKO_CLI}/version_check.php --path . --fix 2>/dev/null || true
# Update VERSION variable with suffix # Read final version with suffix
if [ -n "$SUFFIX" ]; then VERSION=$(php ${MOKO_CLI}/version_read.php --path . 2>/dev/null)
VERSION="${VERSION}${SUFFIX}" [ -z "$VERSION" ] && VERSION="00.00.01"
fi
# Commit version bump # Commit version bump
git config --local user.email "gitea-actions[bot]@mokoconsulting.tech" git config --local user.email "gitea-actions[bot]@mokoconsulting.tech"
@@ -126,12 +125,11 @@ jobs:
echo "version=${VERSION}" >> "$GITHUB_OUTPUT" echo "version=${VERSION}" >> "$GITHUB_OUTPUT"
echo "stability=${STABILITY}" >> "$GITHUB_OUTPUT" echo "stability=${STABILITY}" >> "$GITHUB_OUTPUT"
echo "suffix=${SUFFIX}" >> "$GITHUB_OUTPUT"
echo "tag=${TAG}" >> "$GITHUB_OUTPUT" echo "tag=${TAG}" >> "$GITHUB_OUTPUT"
echo "zip_name=${ZIP_NAME}" >> "$GITHUB_OUTPUT" echo "zip_name=${ZIP_NAME}" >> "$GITHUB_OUTPUT"
echo "ext_element=${EXT_ELEMENT}" >> "$GITHUB_OUTPUT" echo "ext_element=${EXT_ELEMENT}" >> "$GITHUB_OUTPUT"
echo "=== Pre-Release: ${EXT_ELEMENT} ${VERSION}${SUFFIX} ===" echo "=== Pre-Release: ${EXT_ELEMENT} ${VERSION} ==="
- name: Create release - name: Create release
id: release id: release
@@ -155,55 +153,8 @@ jobs:
--token "${{ secrets.MOKOGITEA_TOKEN }}" --api-base "$API_BASE" \ --token "${{ secrets.MOKOGITEA_TOKEN }}" --api-base "$API_BASE" \
--repo "${GITEA_REPO}" --output /tmp || true --repo "${GITEA_REPO}" --output /tmp || true
- name: Update updates.xml # updates.xml is generated dynamically by MokoGitea license server
if: steps.platform.outputs.platform == 'joomla' # No need to build, commit, or sync updates.xml from workflows
run: |
VERSION="${{ steps.meta.outputs.version }}"
STABILITY="${{ steps.meta.outputs.stability }}"
SHA256="${{ steps.package.outputs.sha256_zip }}"
if [ ! -f "updates.xml" ]; then
echo "No updates.xml -- skipping"
exit 0
fi
SHA_FLAG=""
[ -n "$SHA256" ] && SHA_FLAG="--sha ${SHA256}"
php ${MOKO_CLI}/updates_xml_build.php \
--path . --version "${VERSION}" --stability "${STABILITY}" \
--gitea-url "${GITEA_URL}" --org "${GITEA_ORG}" --repo "${GITEA_REPO}" \
${SHA_FLAG}
# Commit and push
if ! git diff --quiet updates.xml 2>/dev/null; then
git config --local user.email "gitea-actions[bot]@mokoconsulting.tech"
git config --local user.name "gitea-actions[bot]"
git add updates.xml
git commit -m "chore: update ${STABILITY} channel ${VERSION} [skip ci]"
git push origin HEAD 2>&1 || echo "WARNING: push failed"
fi
- name: "Sync updates.xml to all branches"
if: steps.platform.outputs.platform == 'joomla'
run: |
CURRENT_BRANCH="${{ github.ref_name }}"
git config --local user.email "gitea-actions[bot]@mokoconsulting.tech"
git config --local user.name "gitea-actions[bot]"
for BRANCH in main dev; do
[ "$BRANCH" = "$CURRENT_BRANCH" ] && continue
echo "Syncing updates.xml -> ${BRANCH}"
git fetch origin "${BRANCH}" 2>/dev/null || continue
git checkout "origin/${BRANCH}" -- updates.xml 2>/dev/null || continue
git checkout "${CURRENT_BRANCH}" -- updates.xml
if ! git diff --quiet updates.xml 2>/dev/null; then
git add updates.xml
git commit -m "chore: sync updates.xml from ${CURRENT_BRANCH} [skip ci]"
git push origin HEAD:refs/heads/${BRANCH} 2>&1 || echo "WARNING: push to ${BRANCH} failed"
fi
git checkout "${CURRENT_BRANCH}" 2>/dev/null
done
- name: "Delete lesser pre-release channels (cascade)" - name: "Delete lesser pre-release channels (cascade)"
continue-on-error: true continue-on-error: true
+50 -108
View File
@@ -10,8 +10,8 @@
# INGROUP: moko-platform.Validation # INGROUP: moko-platform.Validation
# REPO: https://git.mokoconsulting.tech/mokoconsulting-tech/moko-platform # REPO: https://git.mokoconsulting.tech/mokoconsulting-tech/moko-platform
# PATH: /templates/workflows/joomla/repo_health.yml.template # PATH: /templates/workflows/joomla/repo_health.yml.template
# VERSION: 04.06.00 # VERSION: 09.23.00
# BRIEF: Enforces repository guardrails by validating release configuration, scripts governance, tooling availability, and core repository health artifacts. # BRIEF: Enforces repository guardrails by validating scripts governance, tooling availability, and core repository health artifacts.
# ============================================================================ # ============================================================================
name: "Generic: Repo Health" name: "Generic: Repo Health"
@@ -24,13 +24,12 @@ on:
workflow_dispatch: workflow_dispatch:
inputs: inputs:
profile: profile:
description: 'Validation profile: all, release, scripts, or repo' description: 'Validation profile: all, scripts, or repo'
required: true required: true
default: all default: all
type: choice type: choice
options: options:
- all - all
- release
- scripts - scripts
- repo - repo
pull_request: pull_request:
@@ -40,10 +39,6 @@ permissions:
contents: read contents: read
env: env:
# Release policy - Repository Variables Only
RELEASE_REQUIRED_REPO_VARS: RS_FTP_PATH_SUFFIX
RELEASE_OPTIONAL_REPO_VARS: DEV_FTP_SUFFIX
# Scripts governance policy # Scripts governance policy
SCRIPTS_REQUIRED_DIRS: SCRIPTS_REQUIRED_DIRS:
SCRIPTS_ALLOWED_DIRS: scripts,scripts/fix,scripts/lib,scripts/release,scripts/run,scripts/validate SCRIPTS_ALLOWED_DIRS: scripts,scripts/fix,scripts/lib,scripts/release,scripts/run,scripts/validate
@@ -138,101 +133,6 @@ jobs:
printf '%s\n' 'ERROR: Access denied. Admin permission required.' >> "${GITHUB_STEP_SUMMARY}" printf '%s\n' 'ERROR: Access denied. Admin permission required.' >> "${GITHUB_STEP_SUMMARY}"
exit 1 exit 1
release_config:
name: Release configuration
needs: access_check
if: ${{ needs.access_check.outputs.allowed == 'true' }}
runs-on: ubuntu-latest
timeout-minutes: 20
permissions:
contents: read
steps:
- name: Checkout
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
with:
fetch-depth: 0
- name: Guardrails release vars
env:
PROFILE_RAW: ${{ github.event.inputs.profile }}
RS_FTP_PATH_SUFFIX: ${{ vars.RS_FTP_PATH_SUFFIX }}
DEV_FTP_SUFFIX: ${{ vars.DEV_FTP_SUFFIX }}
run: |
set -euo pipefail
profile="${PROFILE_RAW:-all}"
case "${profile}" in
all|release|scripts|repo) ;;
*)
printf '%s\n' "ERROR: Unknown profile: ${profile}" >> "${GITHUB_STEP_SUMMARY}"
exit 1
;;
esac
if [ "${profile}" = 'scripts' ] || [ "${profile}" = 'repo' ]; then
{
printf '%s\n' '### Release configuration (Repository Variables)'
printf '%s\n' "Profile: ${profile}"
printf '%s\n' 'Status: SKIPPED'
printf '%s\n' 'Reason: profile excludes release validation'
printf '\n'
} >> "${GITHUB_STEP_SUMMARY}"
exit 0
fi
IFS=',' read -r -a required <<< "${RELEASE_REQUIRED_REPO_VARS}"
IFS=',' read -r -a optional <<< "${RELEASE_OPTIONAL_REPO_VARS}"
missing=()
missing_optional=()
for k in "${required[@]}"; do
v="${!k:-}"
[ -z "${v}" ] && missing+=("${k}")
done
for k in "${optional[@]}"; do
v="${!k:-}"
[ -z "${v}" ] && missing_optional+=("${k}")
done
{
printf '%s\n' '### Release configuration (Repository Variables)'
printf '%s\n' "Profile: ${profile}"
printf '%s\n' '| Variable | Status |'
printf '%s\n' '|---|---|'
printf '%s\n' "| RS_FTP_PATH_SUFFIX | ${RS_FTP_PATH_SUFFIX:-NOT SET} |"
printf '%s\n' "| DEV_FTP_SUFFIX | ${DEV_FTP_SUFFIX:-NOT SET} |"
printf '\n'
} >> "${GITHUB_STEP_SUMMARY}"
if [ "${#missing_optional[@]}" -gt 0 ]; then
{
printf '%s\n' '### Missing optional repository variables'
for m in "${missing_optional[@]}"; do printf '%s\n' "- ${m}"; done
printf '\n'
} >> "${GITHUB_STEP_SUMMARY}"
fi
if [ "${#missing[@]}" -gt 0 ]; then
{
printf '%s\n' '### Missing required repository variables'
for m in "${missing[@]}"; do printf '%s\n' "- ${m}"; done
printf '%s\n' 'ERROR: Guardrails failed. Missing required repository variables.'
} >> "${GITHUB_STEP_SUMMARY}"
exit 1
fi
{
printf '%s\n' '### Repository variables validation result'
printf '%s\n' 'Status: OK'
printf '%s\n' 'All required repository variables present.'
printf '%s\n' ''
printf '%s\n' '**Note**: Organization secrets (RS_FTP_HOST, RS_FTP_USER, etc.) are validated at deployment time, not in repository health checks.'
printf '\n'
} >> "${GITHUB_STEP_SUMMARY}"
scripts_governance: scripts_governance:
name: Scripts governance name: Scripts governance
needs: access_check needs: access_check
@@ -256,14 +156,14 @@ jobs:
profile="${PROFILE_RAW:-all}" profile="${PROFILE_RAW:-all}"
case "${profile}" in case "${profile}" in
all|release|scripts|repo) ;; all|scripts|repo) ;;
*) *)
printf '%s\n' "ERROR: Unknown profile: ${profile}" >> "${GITHUB_STEP_SUMMARY}" printf '%s\n' "ERROR: Unknown profile: ${profile}" >> "${GITHUB_STEP_SUMMARY}"
exit 1 exit 1
;; ;;
esac esac
if [ "${profile}" = 'release' ] || [ "${profile}" = 'repo' ]; then if [ "${profile}" = 'repo' ]; then
{ {
printf '%s\n' '### Scripts governance' printf '%s\n' '### Scripts governance'
printf '%s\n' "Profile: ${profile}" printf '%s\n' "Profile: ${profile}"
@@ -370,14 +270,14 @@ jobs:
profile="${PROFILE_RAW:-all}" profile="${PROFILE_RAW:-all}"
case "${profile}" in case "${profile}" in
all|release|scripts|repo) ;; all|scripts|repo) ;;
*) *)
printf '%s\n' "ERROR: Unknown profile: ${profile}" >> "${GITHUB_STEP_SUMMARY}" printf '%s\n' "ERROR: Unknown profile: ${profile}" >> "${GITHUB_STEP_SUMMARY}"
exit 1 exit 1
;; ;;
esac esac
if [ "${profile}" = 'release' ] || [ "${profile}" = 'scripts' ]; then if [ "${profile}" = 'scripts' ]; then
{ {
printf '%s\n' '### Repository health' printf '%s\n' '### Repository health'
printf '%s\n' "Profile: ${profile}" printf '%s\n' "Profile: ${profile}"
@@ -704,7 +604,7 @@ jobs:
printf '%s\n' '| Domain | Status | Notes |' printf '%s\n' '| Domain | Status | Notes |'
printf '%s\n' '|---|---|---|' printf '%s\n' '|---|---|---|'
printf '%s\n' '| Access control | OK | Admin-only execution gate |' printf '%s\n' '| Access control | OK | Admin-only execution gate |'
printf '%s\n' '| Release variables | OK | Repository variables validation |' printf '%s\n' '| Release policy | N/A | Releases handled by MokoGitea |'
printf '%s\n' '| Scripts governance | OK | Directory policy and advisory reporting |' printf '%s\n' '| Scripts governance | OK | Directory policy and advisory reporting |'
printf '%s\n' '| Repo required artifacts | OK | Required, optional, disallowed enforcement |' printf '%s\n' '| Repo required artifacts | OK | Required, optional, disallowed enforcement |'
printf '%s\n' '| Repo content heuristics | OK | Brand, license, changelog structure |' printf '%s\n' '| Repo content heuristics | OK | Brand, license, changelog structure |'
@@ -767,3 +667,45 @@ jobs:
echo "### Site Health" >> $GITHUB_STEP_SUMMARY echo "### Site Health" >> $GITHUB_STEP_SUMMARY
echo "Uptime and SSL checks completed." >> $GITHUB_STEP_SUMMARY echo "Uptime and SSL checks completed." >> $GITHUB_STEP_SUMMARY
# ═══════════════════════════════════════════════════════════════════════
# Issue Reporter — file issues for failed gates
# ═══════════════════════════════════════════════════════════════════════
report-issues:
name: "Report Issues"
runs-on: ubuntu-latest
needs: [access_check, scripts_governance, repo_health]
if: >-
always() &&
(needs.scripts_governance.result == 'failure' ||
needs.repo_health.result == 'failure')
steps:
- name: Checkout
uses: actions/checkout@v4
with:
sparse-checkout: automation/ci-issue-reporter.sh
sparse-checkout-cone-mode: false
- name: "File issues for failed gates"
env:
GITEA_TOKEN: ${{ secrets.MOKOGITEA_TOKEN }}
GITEA_URL: ${{ vars.GITEA_URL || 'https://git.mokoconsulting.tech' }}
run: |
chmod +x automation/ci-issue-reporter.sh
REPORTER="./automation/ci-issue-reporter.sh"
WF="Repo Health"
report_gate() {
local gate="$1" result="$2" details="$3"
if [ "$result" = "failure" ]; then
"$REPORTER" --gate "$gate" --details "$details" --workflow "$WF" --severity error
fi
}
report_gate "Scripts Governance" \
"${{ needs.scripts_governance.result }}" \
"Scripts directory policy violations detected. Review required and allowed directories."
report_gate "Repository Health" \
"${{ needs.repo_health.result }}" \
"Repository health checks failed — missing required artifacts, disallowed files, or content warnings. Check the CI run summary."
+1 -1
View File
@@ -7,7 +7,7 @@
# INGROUP: moko-platform.Security # INGROUP: moko-platform.Security
# REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform # REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform
# PATH: /.gitea/workflows/security-audit.yml # PATH: /.gitea/workflows/security-audit.yml
# VERSION: 01.00.00 # VERSION: 09.23.00
# BRIEF: Dependency vulnerability scanning for composer and npm packages # BRIEF: Dependency vulnerability scanning for composer and npm packages
name: "Universal: Security Audit" name: "Universal: Security Audit"
+14 -24
View File
@@ -7,7 +7,7 @@
# INGROUP: moko-platform.Universal # INGROUP: moko-platform.Universal
# REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform # REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform
# PATH: /templates/workflows/update-server.yml # PATH: /templates/workflows/update-server.yml
# VERSION: 05.00.00 # VERSION: 09.23.00
# BRIEF: Pre-release build + update server XML for dev/alpha/beta/rc branches # BRIEF: Pre-release build + update server XML for dev/alpha/beta/rc branches
# #
# Thin wrapper around moko-platform CLI tools. # Thin wrapper around moko-platform CLI tools.
@@ -109,14 +109,6 @@ jobs:
git config --local user.name "gitea-actions[bot]" git config --local user.name "gitea-actions[bot]"
git remote set-url origin "https://x-access-token:${{ secrets.MOKOGITEA_TOKEN }}@git.mokoconsulting.tech/${{ github.repository }}.git" git remote set-url origin "https://x-access-token:${{ secrets.MOKOGITEA_TOKEN }}@git.mokoconsulting.tech/${{ github.repository }}.git"
# Auto-bump patch version
php ${MOKO_CLI}/version_bump.php --path . 2>/dev/null || true
VERSION=$(php ${MOKO_CLI}/version_read.php --path . 2>/dev/null || echo "0.0.0")
# Strip any existing suffix before applying stability
VERSION=$(echo "$VERSION" | sed 's/-\(dev\|alpha\|beta\|rc\)$//')
# Determine stability from branch or manual input # Determine stability from branch or manual input
if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
STABILITY="${{ inputs.stability }}" STABILITY="${{ inputs.stability }}"
@@ -130,30 +122,28 @@ jobs:
STABILITY="development" STABILITY="development"
fi fi
# Version suffix per stability stream # Gitea release tag per stability
case "$STABILITY" in case "$STABILITY" in
development) SUFFIX="-dev"; TAG="development" ;; development) TAG="development" ;;
alpha) SUFFIX="-alpha"; TAG="alpha" ;; alpha) TAG="alpha" ;;
beta) SUFFIX="-beta"; TAG="beta" ;; beta) TAG="beta" ;;
rc) SUFFIX="-rc"; TAG="release-candidate" ;; rc) TAG="release-candidate" ;;
*) SUFFIX=""; TAG="stable" ;; *) TAG="stable" ;;
esac esac
# Propagate version with stability suffix to all manifest files # Bump patch, set platform suffix, fix consistency — version_bump preserves suffix
php ${MOKO_CLI}/version_set_platform.php \ php ${MOKO_CLI}/version_set_platform.php \
--path . --version "$VERSION" --branch "$BRANCH" --stability "$STABILITY" 2>/dev/null || true --path . --version "$(php ${MOKO_CLI}/version_read.php --path . 2>/dev/null || echo '00.00.01')" \
--branch "$BRANCH" --stability "$STABILITY" 2>/dev/null || true
php ${MOKO_CLI}/version_bump.php --path . 2>/dev/null || true
php ${MOKO_CLI}/version_check.php --path . --fix 2>/dev/null || true php ${MOKO_CLI}/version_check.php --path . --fix 2>/dev/null || true
# Re-read version (now includes suffix from version_set_platform) # Read final version (includes suffix, e.g. 01.02.15-dev)
if [ -n "$SUFFIX" ]; then VERSION=$(php ${MOKO_CLI}/version_read.php --path . 2>/dev/null || echo "00.00.01")
VERSION="${VERSION}${SUFFIX}"
fi
echo "version=${VERSION}" >> "$GITHUB_OUTPUT" echo "version=${VERSION}" >> "$GITHUB_OUTPUT"
echo "stability=${STABILITY}" >> "$GITHUB_OUTPUT" echo "stability=${STABILITY}" >> "$GITHUB_OUTPUT"
echo "suffix=${SUFFIX}" >> "$GITHUB_OUTPUT"
echo "tag=${TAG}" >> "$GITHUB_OUTPUT" echo "tag=${TAG}" >> "$GITHUB_OUTPUT"
echo "display_version=${VERSION}" >> "$GITHUB_OUTPUT"
# Commit version bump if changed # Commit version bump if changed
git add -A git add -A
@@ -303,7 +293,7 @@ jobs:
run: | run: |
VERSION="${{ steps.meta.outputs.version }}" VERSION="${{ steps.meta.outputs.version }}"
STABILITY="${{ steps.meta.outputs.stability }}" STABILITY="${{ steps.meta.outputs.stability }}"
DISPLAY="${{ steps.meta.outputs.display_version }}" DISPLAY="${VERSION}"
echo "## Update Server" >> $GITHUB_STEP_SUMMARY echo "## Update Server" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY echo "" >> $GITHUB_STEP_SUMMARY
echo "| Field | Value |" >> $GITHUB_STEP_SUMMARY echo "| Field | Value |" >> $GITHUB_STEP_SUMMARY
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"metadata": { "metadata": {
"generated_at": "2026-03-10T19:51:42.238134Z", "generated_at": "2026-03-10T19:51:42.238134Z",
"repository": "mokoconsulting-tech/MokoStandards", "repository": "MokoConsulting/moko-platform",
"version": "1.0.0" "version": "1.0.0"
}, },
"scripts": [ "scripts": [
+16 -5
View File
@@ -12,12 +12,23 @@ BRIEF: Release changelog
# Changelog # Changelog
## [Unreleased] ## [Unreleased]
## [09.21.00] --- 2026-05-30
## [09.20.00] --- 2026-05-30 ## [09.24.00] --- 2026-06-04
## [09.19.00] --- 2026-05-30 ## [09.23] --- 2026-05-31
## [09.18.00] --- 2026-05-30 ## [09.22] --- 2026-05-31
## [09.17.00] --- 2026-05-30 ### Changed
- **refactor(cli):** migrate 64 legacy scripts to CliFramework (#235) — all tools in cli/, automation/, maintenance/, deploy/, release/ now extend CliFramework with free --help, --verbose, --quiet, --dry-run, --json, banners, and coloured logging
### Fixed
- fix: auto-detect org/repo in updates_xml_build from manifest and git remote
- fix: restore hyphen in version suffixes
- fix: release names use standardized format
- fix: remove lesser stream copies, each stream updates independently
- fix: sort updates.xml entries dev first, stable last
## [09.21] --- 2026-05-30
## [09.20] --- 2026-05-30
+3 -3
View File
@@ -2,8 +2,8 @@
Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech> Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
SPDX-License-Identifier: GPL-3.0-or-later SPDX-License-Identifier: GPL-3.0-or-later
FILE INFORMATION FILE INFORMATION
DEFGROUP: MokoStandards.Root DEFGROUP: MokoPlatform.Root
INGROUP: MokoStandards INGROUP: MokoPlatform
REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform
PATH: /PLUGIN_SCRIPTS.md PATH: /PLUGIN_SCRIPTS.md
BRIEF: Plugin system CLI documentation BRIEF: Plugin system CLI documentation
@@ -11,7 +11,7 @@ BRIEF: Plugin system CLI documentation
# Plugin System CLI Scripts # Plugin System CLI Scripts
Command-line scripts for validating, health checking, and managing projects using the MokoStandards plugin system. Command-line scripts for validating, health checking, and managing projects using the moko-platform plugin system.
## Available Scripts ## Available Scripts
+5 -5
View File
@@ -2,19 +2,19 @@
Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech> Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
SPDX-License-Identifier: GPL-3.0-or-later SPDX-License-Identifier: GPL-3.0-or-later
FILE INFORMATION FILE INFORMATION
DEFGROUP: MokoStandards.Root DEFGROUP: MokoPlatform.Root
INGROUP: MokoStandards INGROUP: MokoPlatform
REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform
PATH: /README.md PATH: /README.md
VERSION: 09.21.00 VERSION: 09.24.00
BRIEF: Project overview and documentation BRIEF: Project overview and documentation
--> -->
# MokoStandards Enterprise API # moko-platform Enterprise API
![Version](https://img.shields.io/badge/version-09.01.00-blue) ![PHP](https://img.shields.io/badge/PHP-8.1%2B-777BB4) ![License](https://img.shields.io/badge/license-GPL--3.0--or--later-green) ![Version](https://img.shields.io/badge/version-09.01.00-blue) ![PHP](https://img.shields.io/badge/PHP-8.1%2B-777BB4) ![License](https://img.shields.io/badge/license-GPL--3.0--or--later-green)
PHP implementation of MokoStandards — enterprise standards, automation framework, workflow templates, and bulk sync tooling. PHP implementation of moko-platform — enterprise standards, automation framework, workflow templates, and bulk sync tooling.
> **Primary platform**: [Gitea — git.mokoconsulting.tech](https://git.mokoconsulting.tech/MokoConsulting/MokoStandards-API) > **Primary platform**: [Gitea — git.mokoconsulting.tech](https://git.mokoconsulting.tech/MokoConsulting/MokoStandards-API)
> **Backup mirror**: [GitHub](https://github.com/MokoConsulting/MokoStandards-API) *(read-only mirror)* > **Backup mirror**: [GitHub](https://github.com/MokoConsulting/MokoStandards-API) *(read-only mirror)*
+2 -2
View File
@@ -2,8 +2,8 @@
Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech> Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
SPDX-License-Identifier: GPL-3.0-or-later SPDX-License-Identifier: GPL-3.0-or-later
FILE INFORMATION FILE INFORMATION
DEFGROUP: MokoStandards.Index DEFGROUP: MokoPlatform.Index
INGROUP: MokoStandards.Analysis INGROUP: MokoPlatform.Analysis
REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform
PATH: /analysis/index.md PATH: /analysis/index.md
BRIEF: Analysis directory index BRIEF: Analysis directory index
+5 -5
View File
@@ -9,8 +9,8 @@
* SPDX-License-Identifier: GPL-3.0-or-later * SPDX-License-Identifier: GPL-3.0-or-later
* *
* FILE INFORMATION * FILE INFORMATION
* DEFGROUP: MokoStandards.Automation * DEFGROUP: MokoPlatform.Automation
* INGROUP: MokoStandards.Scripts * INGROUP: MokoPlatform.Scripts
* REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform * REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform
* PATH: /automation/bulk_joomla_template.php * PATH: /automation/bulk_joomla_template.php
* BRIEF: Bulk scaffold and sync Joomla template repositories * BRIEF: Bulk scaffold and sync Joomla template repositories
@@ -42,7 +42,7 @@ use MokoEnterprise\{
* *
* Provides three operations for Joomla template projects: * Provides three operations for Joomla template projects:
* --scaffold: Create a new template repository with the full directory structure * --scaffold: Create a new template repository with the full directory structure
* --sync: Push MokoStandards files to existing template repositories * --sync: Push moko-platform files to existing template repositories
* --list: List all repositories tagged as joomla-template * --list: List all repositories tagged as joomla-template
* *
* Works with both GitHub and Gitea via the PlatformAdapterFactory. * Works with both GitHub and Gitea via the PlatformAdapterFactory.
@@ -50,7 +50,7 @@ use MokoEnterprise\{
class BulkJoomlaTemplate extends CliFramework class BulkJoomlaTemplate extends CliFramework
{ {
public const DEFAULT_ORG = 'MokoConsulting'; public const DEFAULT_ORG = 'MokoConsulting';
public const VERSION = '04.06.10'; public const VERSION = '09.23.00';
private GitPlatformAdapter $adapter; private GitPlatformAdapter $adapter;
private Config $config; private Config $config;
@@ -318,7 +318,7 @@ class BulkJoomlaTemplate extends CliFramework
$name, $name,
$path, $path,
$content, $content,
"chore: update {$path} from MokoStandards", "chore: update {$path} from moko-platform",
$existingSha, $existingSha,
$branch $branch
); );
+42 -42
View File
@@ -9,8 +9,8 @@
* SPDX-License-Identifier: GPL-3.0-or-later * SPDX-License-Identifier: GPL-3.0-or-later
* *
* FILE INFORMATION * FILE INFORMATION
* DEFGROUP: MokoStandards.Automation * DEFGROUP: MokoPlatform.Automation
* INGROUP: MokoStandards.Scripts * INGROUP: MokoPlatform.Scripts
* REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform * REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform
* PATH: /automation/bulk_sync.php * PATH: /automation/bulk_sync.php
* BRIEF: Enterprise-grade bulk repository synchronization * BRIEF: Enterprise-grade bulk repository synchronization
@@ -42,7 +42,7 @@ use MokoEnterprise\{
/** /**
* Bulk Repository Synchronization Tool * Bulk Repository Synchronization Tool
* *
* Synchronizes MokoStandards files across multiple repositories using * Synchronizes moko-platform files across multiple repositories using
* the Enterprise library for robust, audited operations. * the Enterprise library for robust, audited operations.
*/ */
class BulkSync extends CliFramework class BulkSync extends CliFramework
@@ -57,7 +57,7 @@ class BulkSync extends CliFramework
* Script version number * Script version number
* Public to allow script instantiation with class constants * Public to allow script instantiation with class constants
*/ */
public const VERSION = '04.06.00'; public const VERSION = '09.23.00';
public const VERSION_MINOR = '04.05'; public const VERSION_MINOR = '04.05';
private ApiClient $api; private ApiClient $api;
@@ -95,7 +95,7 @@ class BulkSync extends CliFramework
*/ */
protected function run(): int protected function run(): int
{ {
$this->log("🚀 MokoStandards Bulk Synchronization v" . self::VERSION, 'INFO'); $this->log("🚀 moko-platform Bulk Synchronization v" . self::VERSION, 'INFO');
// Initialize enterprise components // Initialize enterprise components
if (!$this->initializeComponents()) { if (!$this->initializeComponents()) {
@@ -180,7 +180,7 @@ class BulkSync extends CliFramework
$results['health'] = $this->runHealthChecksAll($org, $repositories); $results['health'] = $this->runHealthChecksAll($org, $repositories);
} }
// Create/update tracking issue in MokoStandards // Create/update tracking issue in moko-platform
$this->createSyncIssue($org, $results); $this->createSyncIssue($org, $results);
// Create/update a failure issue when any repos failed // Create/update a failure issue when any repos failed
@@ -244,7 +244,7 @@ class BulkSync extends CliFramework
* Filter repositories based on include/exclude lists * Filter repositories based on include/exclude lists
*/ */
/** Repositories that are permanently excluded from bulk sync. */ /** Repositories that are permanently excluded from bulk sync. */
private const ALWAYS_EXCLUDE = ['MokoStandards', '.github-private']; private const ALWAYS_EXCLUDE = ['moko-platform', '.github-private'];
private function filterRepositories(array $repositories, array $include, array $exclude): array private function filterRepositories(array $repositories, array $include, array $exclude): array
{ {
@@ -426,7 +426,7 @@ class BulkSync extends CliFramework
$this->log("", 'ERROR'); $this->log("", 'ERROR');
$this->log("Required Implementation:", 'ERROR'); $this->log("Required Implementation:", 'ERROR');
$this->log(" 1. Clone/fetch target repository", 'ERROR'); $this->log(" 1. Clone/fetch target repository", 'ERROR');
$this->log(" 2. Apply file updates based on MokoStandards configuration", 'ERROR'); $this->log(" 2. Apply file updates based on moko-platform configuration", 'ERROR');
$this->log(" 3. Create pull request with changes", 'ERROR'); $this->log(" 3. Create pull request with changes", 'ERROR');
$this->log(" 4. Handle merge conflicts and validation", 'ERROR'); $this->log(" 4. Handle merge conflicts and validation", 'ERROR');
$this->log("", 'ERROR'); $this->log("", 'ERROR');
@@ -837,7 +837,7 @@ class BulkSync extends CliFramework
} }
/** /**
* Ensure all standard MokoStandards labels exist on a target repository. * Ensure all standard moko-platform labels exist on a target repository.
* *
* Fetches existing labels first (GET) and only POSTs the ones that are * Fetches existing labels first (GET) and only POSTs the ones that are
* missing. This avoids the 422 "already exists" responses that would * missing. This avoids the 422 "already exists" responses that would
@@ -872,7 +872,7 @@ class BulkSync extends CliFramework
// Workflow / Process // Workflow / Process
['automation', '8B4513', 'Automated processes or scripts'], ['automation', '8B4513', 'Automated processes or scripts'],
['mokostandards', 'B60205', 'MokoStandards compliance'], ['moko-platform', 'B60205', 'moko-platform compliance'],
['needs-review', 'FBCA04', 'Awaiting code review'], ['needs-review', 'FBCA04', 'Awaiting code review'],
['work-in-progress', 'D93F0B', 'Work in progress, not ready for merge'], ['work-in-progress', 'D93F0B', 'Work in progress, not ready for merge'],
['breaking-change', 'D73A4A', 'Breaking API or functionality change'], ['breaking-change', 'D73A4A', 'Breaking API or functionality change'],
@@ -912,8 +912,8 @@ class BulkSync extends CliFramework
['health: poor', 'FF6B6B', 'Health score below 50'], ['health: poor', 'FF6B6B', 'Health score below 50'],
// Sync / Automation (used by bulk_sync, scan_drift, check_repo_health) // Sync / Automation (used by bulk_sync, scan_drift, check_repo_health)
['standards-update', 'B60205', 'MokoStandards sync update'], ['standards-update', 'B60205', 'moko-platform sync update'],
['standards-drift', 'FBCA04', 'Repository drifted from MokoStandards'], ['standards-drift', 'FBCA04', 'Repository drifted from moko-platform'],
['sync-report', '0075CA', 'Bulk sync run report'], ['sync-report', '0075CA', 'Bulk sync run report'],
['sync-failure', 'D73A4A', 'Bulk sync failure requiring attention'], ['sync-failure', 'D73A4A', 'Bulk sync failure requiring attention'],
['push-failure', 'D73A4A', 'File push failure requiring attention'], ['push-failure', 'D73A4A', 'File push failure requiring attention'],
@@ -925,10 +925,10 @@ class BulkSync extends CliFramework
['type: version', '0E8A16', 'Version-related change'], ['type: version', '0E8A16', 'Version-related change'],
]; ];
// Quick check: if the repo already has the 'mokostandards' label, it was // Quick check: if the repo already has the 'moko-platform' label, it was
// provisioned previously — skip the expensive full label provisioning. // provisioned previously — skip the expensive full label provisioning.
try { try {
$probe = $this->api->get("/repos/{$org}/{$repo}/labels/mokostandards"); $probe = $this->api->get("/repos/{$org}/{$repo}/labels/moko-platform");
if (!empty($probe['name'])) { if (!empty($probe['name'])) {
return; // already provisioned return; // already provisioned
} }
@@ -1024,7 +1024,7 @@ class BulkSync extends CliFramework
*/ */
private function updateOpenBranches(string $org, string $repo): void private function updateOpenBranches(string $org, string $repo): void
{ {
$syncBranchPrefix = 'chore/sync-mokostandards-'; $syncBranchPrefix = 'chore/sync-moko-platform-';
try { try {
$defaultBranch = 'main'; $defaultBranch = 'main';
@@ -1055,7 +1055,7 @@ class BulkSync extends CliFramework
$this->api->post("/repos/{$org}/{$repo}/merges", [ $this->api->post("/repos/{$org}/{$repo}/merges", [
'base' => $branch, 'base' => $branch,
'head' => $defaultBranch, 'head' => $defaultBranch,
'commit_message' => "chore: merge {$defaultBranch} into {$branch} (MokoStandards sync)", 'commit_message' => "chore: merge {$defaultBranch} into {$branch} (moko-platform sync)",
]); ]);
$this->log(" 🔀 Merged {$defaultBranch}{$branch} (PR #{$prNum})", 'INFO'); $this->log(" 🔀 Merged {$defaultBranch}{$branch} (PR #{$prNum})", 'INFO');
} catch (\Exception $e) { } catch (\Exception $e) {
@@ -1076,7 +1076,7 @@ class BulkSync extends CliFramework
/** /**
* Records which sync run touched the repo, the PR number, and the * Records which sync run touched the repo, the PR number, and the
* MokoStandards version that was applied — giving each repo a clear audit * moko-platform version that was applied — giving each repo a clear audit
* trail of what was changed and why. * trail of what was changed and why.
*/ */
/** /**
@@ -1119,16 +1119,16 @@ class BulkSync extends CliFramework
$minor = self::VERSION_MINOR; $minor = self::VERSION_MINOR;
$force = isset($this->options['force']) ? ' *(--force)*' : ''; $force = isset($this->options['force']) ? ' *(--force)*' : '';
$prLink = $this->adapter->getPullRequestWebUrl($org, $repo, $prNumber); $prLink = $this->adapter->getPullRequestWebUrl($org, $repo, $prNumber);
$source = $this->adapter->getRepoWebUrl($org, 'MokoStandards'); $source = $this->adapter->getRepoWebUrl($org, 'moko-platform');
$branchName = 'chore/sync-mokostandards-v' . $minor; $branchName = 'chore/sync-moko-platform-v' . $minor;
$branchLink = $this->adapter->getBranchWebUrl($org, $repo, $branchName); $branchLink = $this->adapter->getBranchWebUrl($org, $repo, $branchName);
$title = "chore: MokoStandards v{$minor} sync tracking"; $title = "chore: moko-platform v{$minor} sync tracking";
$body = <<<MD $body = <<<MD
## MokoStandards Sync Applied ## moko-platform Sync Applied
A MokoStandards bulk sync run has updated files in this repository. A moko-platform bulk sync run has updated files in this repository.
| Field | Value | | Field | Value |
|-------|-------| |-------|-------|
@@ -1144,13 +1144,13 @@ class BulkSync extends CliFramework
Protected files (README, CHANGELOG, GOVERNANCE, etc.) were not overwritten. Protected files (README, CHANGELOG, GOVERNANCE, etc.) were not overwritten.
--- ---
*Updated automatically by [MokoStandards]({$source}) `bulk_sync.php`* *Updated automatically by [moko-platform]({$source}) `bulk_sync.php`*
MD; MD;
// Dedent heredoc // Dedent heredoc
$body = preg_replace('/^ /m', '', $body); $body = preg_replace('/^ /m', '', $body);
$labelNames = ['standards-update', 'mokostandards', 'type: chore', 'automation']; $labelNames = ['standards-update', 'moko-platform', 'type: chore', 'automation'];
$labels = $this->resolveLabelIds($org, $repo, $labelNames); $labels = $this->resolveLabelIds($org, $repo, $labelNames);
try { try {
@@ -1213,7 +1213,7 @@ class BulkSync extends CliFramework
} }
/** /**
* Create a tracking issue in MokoStandards for this sync run. * Create a tracking issue in moko-platform for this sync run.
*/ */
private function createSyncIssue(string $org, array $results): void private function createSyncIssue(string $org, array $results): void
{ {
@@ -1232,7 +1232,7 @@ class BulkSync extends CliFramework
$issues = $results['issues'] ?? []; $issues = $results['issues'] ?? [];
// Stable title — no timestamp so repeated runs update a single issue // Stable title — no timestamp so repeated runs update a single issue
$title = "sync: MokoStandards v" . self::VERSION_MINOR . " bulk sync report"; $title = "sync: moko-platform v" . self::VERSION_MINOR . " bulk sync report";
$protection = $results['protection'] ?? []; $protection = $results['protection'] ?? [];
$hasProtect = !empty($protection); $hasProtect = !empty($protection);
@@ -1281,7 +1281,7 @@ class BulkSync extends CliFramework
: "|---|---|---|---|"; : "|---|---|---|---|";
$body = <<<MD $body = <<<MD
## MokoStandards Bulk Sync Report ## moko-platform Bulk Sync Report
**Organisation:** `{$org}` **Organisation:** `{$org}`
**Triggered:** {$now}{$force} **Triggered:** {$now}{$force}
@@ -1301,7 +1301,7 @@ class BulkSync extends CliFramework
try { try {
// Search for existing issue by label — any state so we can reopen closed ones // Search for existing issue by label — any state so we can reopen closed ones
$existing = $this->api->get("/repos/{$org}/MokoStandards/issues", [ $existing = $this->api->get("/repos/{$org}/moko-platform/issues", [
'labels' => 'sync-report', 'labels' => 'sync-report',
'state' => 'all', 'state' => 'all',
'per_page' => 1, 'per_page' => 1,
@@ -1309,8 +1309,8 @@ class BulkSync extends CliFramework
'direction' => 'desc', 'direction' => 'desc',
]); ]);
$labelNames = ['sync-report', 'mokostandards', 'type: chore', 'automation']; $labelNames = ['sync-report', 'moko-platform', 'type: chore', 'automation'];
$labels = $this->resolveLabelIds($org, 'MokoStandards', $labelNames); $labels = $this->resolveLabelIds($org, 'moko-platform', $labelNames);
$existing = array_values($existing); $existing = array_values($existing);
if (!empty($existing) && isset($existing[0]['number'])) { if (!empty($existing) && isset($existing[0]['number'])) {
@@ -1319,22 +1319,22 @@ class BulkSync extends CliFramework
if (($existing[0]['state'] ?? 'open') === 'closed') { if (($existing[0]['state'] ?? 'open') === 'closed') {
$patch['state'] = 'open'; $patch['state'] = 'open';
} }
$this->api->patch("/repos/{$org}/MokoStandards/issues/{$issueNumber}", $patch); $this->api->patch("/repos/{$org}/moko-platform/issues/{$issueNumber}", $patch);
try { try {
$this->api->post("/repos/{$org}/MokoStandards/issues/{$issueNumber}/labels", ['labels' => $labels]); $this->api->post("/repos/{$org}/moko-platform/issues/{$issueNumber}/labels", ['labels' => $labels]);
} catch (\Exception $le) { } catch (\Exception $le) {
/* non-fatal */ /* non-fatal */
} }
$this->log("📋 Sync report issue updated: {$org}/MokoStandards#{$issueNumber}", 'INFO'); $this->log("📋 Sync report issue updated: {$org}/moko-platform#{$issueNumber}", 'INFO');
} else { } else {
$issue = $this->api->post("/repos/{$org}/MokoStandards/issues", [ $issue = $this->api->post("/repos/{$org}/moko-platform/issues", [
'title' => $title, 'title' => $title,
'body' => $body, 'body' => $body,
'labels' => $labels, 'labels' => $labels,
'assignees' => ['jmiller'], 'assignees' => ['jmiller'],
]); ]);
$issueNumber = $issue['number'] ?? '?'; $issueNumber = $issue['number'] ?? '?';
$this->log("📋 Sync report issue created: {$org}/MokoStandards#{$issueNumber}", 'INFO'); $this->log("📋 Sync report issue created: {$org}/moko-platform#{$issueNumber}", 'INFO');
} }
} catch (\Exception $e) { } catch (\Exception $e) {
$this->log("⚠️ Failed to create/update sync report issue: " . $e->getMessage(), 'WARN'); $this->log("⚠️ Failed to create/update sync report issue: " . $e->getMessage(), 'WARN');
@@ -1342,7 +1342,7 @@ class BulkSync extends CliFramework
} }
/** /**
* Create or update a failure issue in MokoStandards when repos fail to sync. * Create or update a failure issue in moko-platform when repos fail to sync.
* Uses the 'sync-failure' label so it is distinct from the run-report issue. * Uses the 'sync-failure' label so it is distinct from the run-report issue.
* Reopens a closed issue rather than creating a duplicate. * Reopens a closed issue rather than creating a duplicate.
*/ */
@@ -1388,7 +1388,7 @@ class BulkSync extends CliFramework
$body = preg_replace('/^ /m', '', $body); $body = preg_replace('/^ /m', '', $body);
try { try {
$existing = $this->api->get("/repos/{$org}/MokoStandards/issues", [ $existing = $this->api->get("/repos/{$org}/moko-platform/issues", [
'labels' => 'sync-failure', 'labels' => 'sync-failure',
'state' => 'all', 'state' => 'all',
'per_page' => 1, 'per_page' => 1,
@@ -1403,17 +1403,17 @@ class BulkSync extends CliFramework
if (($existing[0]['state'] ?? 'open') === 'closed') { if (($existing[0]['state'] ?? 'open') === 'closed') {
$patch['state'] = 'open'; $patch['state'] = 'open';
} }
$this->api->patch("/repos/{$org}/MokoStandards/issues/{$num}", $patch); $this->api->patch("/repos/{$org}/moko-platform/issues/{$num}", $patch);
$this->log("🚨 Failure issue #{$num} updated: {$org}/MokoStandards#{$num}", 'WARN'); $this->log("🚨 Failure issue #{$num} updated: {$org}/moko-platform#{$num}", 'WARN');
} else { } else {
$issue = $this->api->post("/repos/{$org}/MokoStandards/issues", [ $issue = $this->api->post("/repos/{$org}/moko-platform/issues", [
'title' => $title, 'title' => $title,
'body' => $body, 'body' => $body,
'labels' => $this->resolveLabelIds($org, 'MokoStandards', ['sync-failure']), 'labels' => $this->resolveLabelIds($org, 'moko-platform', ['sync-failure']),
'assignees' => ['jmiller'], 'assignees' => ['jmiller'],
]); ]);
$num = $issue['number'] ?? '?'; $num = $issue['number'] ?? '?';
$this->log("🚨 Failure issue created: {$org}/MokoStandards#{$num}", 'WARN'); $this->log("🚨 Failure issue created: {$org}/moko-platform#{$num}", 'WARN');
} }
} catch (\Exception $e) { } catch (\Exception $e) {
$this->log("⚠️ Could not create/update failure issue: " . $e->getMessage(), 'WARN'); $this->log("⚠️ Could not create/update failure issue: " . $e->getMessage(), 'WARN');
+481
View File
@@ -0,0 +1,481 @@
#!/usr/bin/env php
<?php
/**
* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
* SPDX-License-Identifier: GPL-3.0-or-later
*
* FILE INFORMATION
* DEFGROUP: MokoPlatform.Automation
* INGROUP: MokoPlatform
* REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform
* PATH: /automation/enrich_manifest_xml.php
* BRIEF: Enrich XML manifests with repo-specific build and deploy details
*
* Note: This script uses proc_open for shell commands. All arguments are escaped
* via escapeshellarg(). No user-supplied input reaches the shell unescaped.
*/
declare(strict_types=1);
require_once __DIR__ . '/../vendor/autoload.php';
require_once __DIR__ . '/../lib/Enterprise/CliFramework.php';
use MokoEnterprise\CliFramework;
use MokoEnterprise\MokoStandardsParser;
class EnrichManifestXmlCli extends CliFramework
{
protected function configure(): void
{
$this->setDescription('Enrich XML manifests with repo-specific build and deploy details');
$this->addArgument('--repo', 'Filter to a single repo name', '');
$this->addArgument('--skip', 'Comma-separated list of repos to skip', '');
}
protected function run(): int
{
$giteaUrl = rtrim(getenv('GITEA_URL') ?: 'https://git.mokoconsulting.tech', '/');
$giteaOrg = getenv('GITEA_ORG') ?: 'MokoConsulting';
$token = getenv('GA_TOKEN') ?: getenv('GH_TOKEN') ?: '';
$repoFilter = $this->getArgument('--repo') ?: null;
$skipStr = $this->getArgument('--skip');
$skipRepos = $skipStr !== '' ? array_map('trim', explode(',', $skipStr)) : [];
$parser = new MokoStandardsParser();
$tmpBase = sys_get_temp_dir() . '/moko-enrich-' . getmypid();
echo "=== moko-platform XML Manifest Enrichment ===\n";
echo "Mode: " . ($this->dryRun ? "DRY RUN" : "LIVE") . "\n";
if (!empty($skipRepos)) {
echo "Skipping: " . implode(', ', $skipRepos) . "\n";
}
echo "\n";
if (empty($token)) {
$this->log('ERROR', 'GA_TOKEN required');
return 1;
}
$repos = $this->fetchRepos($giteaUrl, $giteaOrg, $token);
echo "Found " . count($repos) . " repositories\n\n";
$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;
}
$defaultBranch = $repo['default_branch'] ?? 'main';
$httpsUrl = $repo['clone_url'] ?? "{$giteaUrl}/{$giteaOrg}/{$name}.git";
$authedUrl = preg_replace('#^https://#', "https://gitea-actions:{$token}@", $httpsUrl);
echo " {$name} ... ";
$workDir = "{$tmpBase}/{$name}";
@mkdir($workDir, 0755, true);
[$ret] = $this->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/manifest.xml";
if (!file_exists($manifestPath) || !str_contains(file_get_contents($manifestPath), '<moko-platform')) {
echo "SKIP (no XML manifest)\n";
$stats['skipped']++;
$this->rmTree($workDir);
continue;
}
$existingXml = file_get_contents($manifestPath);
$platform = $parser->extractPlatform($existingXml) ?? 'default-repository';
$enrichment = $this->inspectRepo($workDir, $platform);
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);
$enrichedXml = $this->enrichManifestXml($existingXml, $enrichment);
$dc = count($enrichment['deploy'] ?? []);
$sc = count($enrichment['scripts'] ?? []);
$details = "deploy={$dc} scripts={$sc}";
if ($this->dryRun) {
echo "WOULD ENRICH [{$details}]\n";
$stats['enriched']++;
$this->rmTree($workDir);
continue;
}
file_put_contents($manifestPath, $enrichedXml);
$this->gitCmd($workDir, 'config', 'user.name', 'gitea-actions[bot]');
$this->gitCmd($workDir, 'config', 'user.email', 'gitea-actions[bot]@git.mokoconsulting.tech');
$this->gitCmd($workDir, 'add', '.mokogitea/manifest.xml');
[$cr, $co] = $this->gitCmd($workDir, 'commit', '-m', "chore: enrich manifest.xml with build/deploy/scripts\n\nAuto-detected: {$details}");
if ($cr !== 0) {
echo "SKIP (no diff)\n";
$stats['skipped']++;
$this->rmTree($workDir);
continue;
}
[$pr] = $this->gitCmd($workDir, 'push', 'origin', $defaultBranch);
if ($pr !== 0) {
echo "FAIL (push)\n";
$stats['failed']++;
} else {
echo "ENRICHED [{$details}]\n";
$stats['enriched']++;
}
$this->rmTree($workDir);
}
@rmdir($tmpBase);
echo "\n=== Summary ===\nEnriched: {$stats['enriched']}\nSkipped: {$stats['skipped']}\nFailed: {$stats['failed']}\n";
return 0;
}
private function inspectRepo(string $workDir, string $platform): array
{
$enrichment = [];
$build = [];
// Detect entry point
if (is_dir("{$workDir}/src")) {
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;
}
}
foreach (glob("{$workDir}/src/core/modules/mod*.class.php") ?: [] as $mf) {
$build['entry_point'] = str_replace("{$workDir}/", '', $mf);
break;
}
}
// composer.json
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}";
}
$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']['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 (!empty($build)) {
$enrichment['build'] = $build;
}
// Deploy targets from workflows
$targets = [];
$wfDir = is_dir("{$workDir}/.gitea/workflows") ? "{$workDir}/.gitea/workflows" : "{$workDir}/.github/workflows";
if (is_dir($wfDir)) {
foreach (['deploy-dev', 'deploy-demo', 'deploy-rs'] as $dn) {
$wf = "{$wfDir}/{$dn}.yml";
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];
}
$targets[] = $t;
}
}
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',
];
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 (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'];
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',
];
}
break;
}
}
}
}
if (!empty($scripts)) {
$enrichment['scripts'] = $scripts;
}
return $enrichment;
}
private 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;
}
$ns = MokoStandardsParser::NAMESPACE_URI;
$root = $dom->documentElement;
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);
}
}
if (!empty($enrichment['build'])) {
$buildEl = $dom->createElementNS($ns, 'build');
$b = $enrichment['build'];
foreach (['language', 'runtime'] as $f) {
if (isset($b[$f])) {
$buildEl->appendChild($dom->createElementNS($ns, $f, htmlspecialchars($b[$f], ENT_XML1)));
}
}
if (isset($b['package_type'])) {
$buildEl->appendChild($dom->createElementNS($ns, 'package-type', htmlspecialchars($b['package_type'], ENT_XML1)));
}
if (isset($b['entry_point'])) {
$buildEl->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)));
}
}
$buildEl->appendChild($art);
}
if (isset($b['dependencies'])) {
$deps = $dom->createElementNS($ns, 'dependencies');
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']);
}
$deps->appendChild($req);
}
$buildEl->appendChild($deps);
}
$root->appendChild($buildEl);
}
if (!empty($enrichment['deploy'])) {
$deploy = $dom->createElementNS($ns, 'deploy');
foreach ($enrichment['deploy'] as $t) {
$target = $dom->createElementNS($ns, 'target');
$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)));
}
$deploy->appendChild($target);
}
$root->appendChild($deploy);
}
if (!empty($enrichment['scripts'])) {
$scriptsEl = $dom->createElementNS($ns, 'scripts');
foreach ($enrichment['scripts'] as $s) {
$script = $dom->createElementNS($ns, 'script');
$script->setAttribute('name', $s['name']);
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)));
}
$scriptsEl->appendChild($script);
}
$root->appendChild($scriptsEl);
}
return $dom->saveXML();
}
/** @return array{int, string} */
private 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"];
}
$stdout = stream_get_contents($pipes[1]);
$stderr = stream_get_contents($pipes[2]);
fclose($pipes[1]);
fclose($pipes[2]);
return [proc_close($proc), trim($stdout . "\n" . $stderr)];
}
private 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());
}
}
@rmdir($dir);
}
/** @return array{int, string} */
private function gitCmd(string $workDir, string ...$args): array
{
$cmd = 'git';
foreach ($args as $a) {
$cmd .= ' ' . escapeshellarg($a);
}
return $this->safeExec($cmd, $workDir);
}
private 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++;
} while (count($batch) >= 50);
return $repos;
}
}
$app = new EnrichManifestXmlCli();
exit($app->execute());
+210 -200
View File
@@ -6,20 +6,12 @@
* SPDX-License-Identifier: GPL-3.0-or-later * SPDX-License-Identifier: GPL-3.0-or-later
* *
* FILE INFORMATION * FILE INFORMATION
* DEFGROUP: MokoStandards.Automation * DEFGROUP: MokoPlatform.Automation
* INGROUP: MokoStandards * INGROUP: MokoPlatform
* REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform * REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform
* PATH: /automation/enrich_mokostandards_xml.php * PATH: /automation/enrich_mokostandards_xml.php
* BRIEF: Enrich XML manifests with repo-specific build and deploy details * BRIEF: Enrich XML manifests with repo-specific build and deploy details
* *
* Enrich XML .mokostandards manifests with repo-specific build, deploy, and script details.
*
* Runs AFTER push_mokostandards_xml.php. Clones each repo, inspects its contents,
* and updates the manifest with discovered build/deploy/scripts config.
*
* Usage:
* php automation/enrich_mokostandards_xml.php [--dry-run] [--repo NAME] [--skip NAME,NAME]
*
* Note: This script uses proc_open for shell commands. All arguments are escaped * Note: This script uses proc_open for shell commands. All arguments are escaped
* via escapeshellarg(). No user-supplied input reaches the shell unescaped. * via escapeshellarg(). No user-supplied input reaches the shell unescaped.
*/ */
@@ -27,97 +19,159 @@
declare(strict_types=1); declare(strict_types=1);
require_once __DIR__ . '/../vendor/autoload.php'; require_once __DIR__ . '/../vendor/autoload.php';
require_once __DIR__ . '/../lib/Enterprise/CliFramework.php';
use MokoEnterprise\CliFramework;
use MokoEnterprise\MokoStandardsParser; use MokoEnterprise\MokoStandardsParser;
$giteaUrl = rtrim(getenv('GITEA_URL') ?: 'https://git.mokoconsulting.tech', '/'); class EnrichMokostandardsXmlCli extends CliFramework
$giteaOrg = getenv('GITEA_ORG') ?: 'MokoConsulting';
$token = getenv('GA_TOKEN') ?: getenv('GH_TOKEN') ?: '';
$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]));
}
}
$parser = new MokoStandardsParser();
$tmpBase = sys_get_temp_dir() . '/moko-enrich-' . getmypid();
function safeExec(string $command, string $cwd = '.'): array
{ {
$proc = proc_open($command, [1 => ['pipe', 'w'], 2 => ['pipe', 'w']], $pipes, $cwd); protected function configure(): void
if (!is_resource($proc)) { {
return [1, "proc_open failed"]; $this->setDescription('Enrich XML manifests with repo-specific build and deploy details');
$this->addArgument('--repo', 'Filter to a single repo name', '');
$this->addArgument('--skip', 'Comma-separated list of repos to skip', '');
} }
$stdout = stream_get_contents($pipes[1]);
$stderr = stream_get_contents($pipes[2]);
fclose($pipes[1]);
fclose($pipes[2]);
return [proc_close($proc), trim($stdout . "\n" . $stderr)];
}
function rmTree(string $dir): void protected function run(): int
{ {
if (!is_dir($dir)) { $giteaUrl = rtrim(getenv('GITEA_URL') ?: 'https://git.mokoconsulting.tech', '/');
return; $giteaOrg = getenv('GITEA_ORG') ?: 'MokoConsulting';
$token = getenv('GA_TOKEN') ?: getenv('GH_TOKEN') ?: '';
$repoFilter = $this->getArgument('--repo') ?: null;
$skipStr = $this->getArgument('--skip');
$skipRepos = $skipStr !== '' ? array_map('trim', explode(',', $skipStr)) : [];
$parser = new MokoStandardsParser();
$tmpBase = sys_get_temp_dir() . '/moko-enrich-' . getmypid();
echo "=== moko-platform XML Manifest Enrichment ===\n";
echo "Mode: " . ($this->dryRun ? "DRY RUN" : "LIVE") . "\n";
if (!empty($skipRepos)) {
echo "Skipping: " . implode(', ', $skipRepos) . "\n";
} }
$it = new RecursiveDirectoryIterator($dir, FilesystemIterator::SKIP_DOTS); echo "\n";
$files = new RecursiveIteratorIterator($it, RecursiveIteratorIterator::CHILD_FIRST);
foreach ($files as $file) { if (empty($token)) {
if ($file->isDir()) { $this->log('ERROR', 'GA_TOKEN required');
@rmdir($file->getPathname()); return 1;
}
$repos = $this->fetchRepos($giteaUrl, $giteaOrg, $token);
echo "Found " . count($repos) . " repositories\n\n";
$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;
}
$defaultBranch = $repo['default_branch'] ?? 'main';
$httpsUrl = $repo['clone_url'] ?? "{$giteaUrl}/{$giteaOrg}/{$name}.git";
$authedUrl = preg_replace('#^https://#', "https://gitea-actions:{$token}@", $httpsUrl);
echo " {$name} ... ";
$workDir = "{$tmpBase}/{$name}";
@mkdir($workDir, 0755, true);
[$ret] = $this->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/manifest.xml";
if (!file_exists($manifestPath) || !str_contains(file_get_contents($manifestPath), '<moko-platform')) {
echo "SKIP (no XML manifest)\n";
$stats['skipped']++;
$this->rmTree($workDir);
continue;
}
$existingXml = file_get_contents($manifestPath);
$platform = $parser->extractPlatform($existingXml) ?? 'default-repository';
$enrichment = $this->inspectRepo($workDir, $platform);
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);
$enrichedXml = $this->enrichManifestXml($existingXml, $enrichment);
$dc = count($enrichment['deploy'] ?? []);
$sc = count($enrichment['scripts'] ?? []);
$details = "deploy={$dc} scripts={$sc}";
if ($this->dryRun) {
echo "WOULD ENRICH [{$details}]\n";
$stats['enriched']++;
$this->rmTree($workDir);
continue;
}
file_put_contents($manifestPath, $enrichedXml);
$this->gitCmd($workDir, 'config', 'user.name', 'gitea-actions[bot]');
$this->gitCmd($workDir, 'config', 'user.email', 'gitea-actions[bot]@git.mokoconsulting.tech');
$this->gitCmd($workDir, 'add', '.mokogitea/manifest.xml');
$commitMsg = "chore: enrich .mokostandards"
. " with build/deploy/scripts\n\n"
. "Auto-detected: {$details}";
[$cr, $co] = $this->gitCmd(
$workDir,
'commit',
'-m',
$commitMsg
);
if ($cr !== 0) {
echo "SKIP (no diff)\n";
$stats['skipped']++;
$this->rmTree($workDir);
continue;
}
[$pr] = $this->gitCmd($workDir, 'push', 'origin', $defaultBranch);
if ($pr !== 0) {
echo "FAIL (push)\n";
$stats['failed']++;
} else { } else {
@chmod($file->getPathname(), 0777); echo "ENRICHED [{$details}]\n";
@unlink($file->getPathname()); $stats['enriched']++;
} }
}
@rmdir($dir);
}
function gitCmd(string $workDir, string ...$args): array $this->rmTree($workDir);
{
$cmd = 'git';
foreach ($args as $a) {
$cmd .= ' ' . escapeshellarg($a);
} }
return safeExec($cmd, $workDir);
}
function fetchRepos(string $url, string $org, string $token): array @rmdir($tmpBase);
{ echo "\n=== Summary ===\nEnriched: {$stats['enriched']}\nSkipped: {$stats['skipped']}\nFailed: {$stats['failed']}\n";
$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++;
} while (count($batch) >= 50);
return $repos;
}
function inspectRepo(string $workDir, string $platform): array return 0;
{ }
private function inspectRepo(string $workDir, string $platform): array
{
$enrichment = []; $enrichment = [];
$build = []; $build = [];
// Detect entry point
if (is_dir("{$workDir}/src")) { if (is_dir("{$workDir}/src")) {
foreach (glob("{$workDir}/src/*.xml") ?: [] as $xf) { foreach (glob("{$workDir}/src/*.xml") ?: [] as $xf) {
$c = file_get_contents($xf); $c = file_get_contents($xf);
@@ -132,7 +186,6 @@ function inspectRepo(string $workDir, string $platform): array
} }
} }
// composer.json
if (file_exists("{$workDir}/composer.json")) { if (file_exists("{$workDir}/composer.json")) {
$composer = json_decode(file_get_contents("{$workDir}/composer.json"), true) ?: []; $composer = json_decode(file_get_contents("{$workDir}/composer.json"), true) ?: [];
$phpReq = $composer['require']['php'] ?? null; $phpReq = $composer['require']['php'] ?? null;
@@ -158,7 +211,6 @@ function inspectRepo(string $workDir, string $platform): array
} }
} }
// Artifact from Makefile
if (file_exists("{$workDir}/Makefile")) { if (file_exists("{$workDir}/Makefile")) {
$mk = file_get_contents("{$workDir}/Makefile"); $mk = file_get_contents("{$workDir}/Makefile");
if (preg_match('/\bdist\/(\S+\.zip)\b/', $mk, $m)) { if (preg_match('/\bdist\/(\S+\.zip)\b/', $mk, $m)) {
@@ -170,7 +222,6 @@ function inspectRepo(string $workDir, string $platform): array
$enrichment['build'] = $build; $enrichment['build'] = $build;
} }
// Deploy targets from workflows
$targets = []; $targets = [];
$wfDir = is_dir("{$workDir}/.gitea/workflows") ? "{$workDir}/.gitea/workflows" : "{$workDir}/.github/workflows"; $wfDir = is_dir("{$workDir}/.gitea/workflows") ? "{$workDir}/.gitea/workflows" : "{$workDir}/.github/workflows";
if (is_dir($wfDir)) { if (is_dir($wfDir)) {
@@ -199,7 +250,6 @@ function inspectRepo(string $workDir, string $platform): array
$enrichment['deploy'] = $targets; $enrichment['deploy'] = $targets;
} }
// Scripts from Makefile + composer
$scripts = []; $scripts = [];
if (file_exists("{$workDir}/Makefile")) { if (file_exists("{$workDir}/Makefile")) {
$mk = file_get_contents("{$workDir}/Makefile"); $mk = file_get_contents("{$workDir}/Makefile");
@@ -254,11 +304,11 @@ function inspectRepo(string $workDir, string $platform): array
} }
return $enrichment; return $enrichment;
} }
function enrichManifestXml(string $xml, array $enrichment): string private function enrichManifestXml(string $xml, array $enrichment): string
{ {
$dom = new DOMDocument('1.0', 'UTF-8'); $dom = new \DOMDocument('1.0', 'UTF-8');
$dom->preserveWhiteSpace = false; $dom->preserveWhiteSpace = false;
$dom->formatOutput = true; $dom->formatOutput = true;
if (!$dom->loadXML($xml)) { if (!$dom->loadXML($xml)) {
@@ -280,18 +330,18 @@ function enrichManifestXml(string $xml, array $enrichment): string
} }
if (!empty($enrichment['build'])) { if (!empty($enrichment['build'])) {
$build = $dom->createElementNS($ns, 'build'); $buildEl = $dom->createElementNS($ns, 'build');
$b = $enrichment['build']; $b = $enrichment['build'];
foreach (['language', 'runtime'] as $f) { foreach (['language', 'runtime'] as $f) {
if (isset($b[$f])) { if (isset($b[$f])) {
$build->appendChild($dom->createElementNS($ns, $f, htmlspecialchars($b[$f], ENT_XML1))); $buildEl->appendChild($dom->createElementNS($ns, $f, htmlspecialchars($b[$f], ENT_XML1)));
} }
} }
if (isset($b['package_type'])) { if (isset($b['package_type'])) {
$build->appendChild($dom->createElementNS($ns, 'package-type', htmlspecialchars($b['package_type'], ENT_XML1))); $buildEl->appendChild($dom->createElementNS($ns, 'package-type', htmlspecialchars($b['package_type'], ENT_XML1)));
} }
if (isset($b['entry_point'])) { if (isset($b['entry_point'])) {
$build->appendChild($dom->createElementNS($ns, 'entry-point', htmlspecialchars($b['entry_point'], ENT_XML1))); $buildEl->appendChild($dom->createElementNS($ns, 'entry-point', htmlspecialchars($b['entry_point'], ENT_XML1)));
} }
if (isset($b['artifact'])) { if (isset($b['artifact'])) {
$art = $dom->createElementNS($ns, 'artifact'); $art = $dom->createElementNS($ns, 'artifact');
@@ -300,7 +350,7 @@ function enrichManifestXml(string $xml, array $enrichment): string
$art->appendChild($dom->createElementNS($ns, $af, htmlspecialchars($b['artifact'][$af], ENT_XML1))); $art->appendChild($dom->createElementNS($ns, $af, htmlspecialchars($b['artifact'][$af], ENT_XML1)));
} }
} }
$build->appendChild($art); $buildEl->appendChild($art);
} }
if (isset($b['dependencies'])) { if (isset($b['dependencies'])) {
$deps = $dom->createElementNS($ns, 'dependencies'); $deps = $dom->createElementNS($ns, 'dependencies');
@@ -315,9 +365,9 @@ function enrichManifestXml(string $xml, array $enrichment): string
} }
$deps->appendChild($req); $deps->appendChild($req);
} }
$build->appendChild($deps); $buildEl->appendChild($deps);
} }
$root->appendChild($build); $root->appendChild($buildEl);
} }
if (!empty($enrichment['deploy'])) { if (!empty($enrichment['deploy'])) {
@@ -362,113 +412,73 @@ function enrichManifestXml(string $xml, array $enrichment): string
} }
return $dom->saveXML(); return $dom->saveXML();
}
// ── Main ─────────────────────────────────────────────────────────────────
echo "=== MokoStandards XML Manifest Enrichment ===\n";
echo "Mode: " . ($dryRun ? "DRY RUN" : "LIVE") . "\n";
if (!empty($skipRepos)) {
echo "Skipping: " . implode(', ', $skipRepos) . "\n";
}
echo "\n";
if (empty($token)) {
fprintf(STDERR, "ERROR: GA_TOKEN required\n");
exit(1);
}
$repos = fetchRepos($giteaUrl, $giteaOrg, $token);
echo "Found " . count($repos) . " repositories\n\n";
$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;
} }
$defaultBranch = $repo['default_branch'] ?? 'main'; /** @return array{int, string} */
$httpsUrl = $repo['clone_url'] ?? "{$giteaUrl}/{$giteaOrg}/{$name}.git"; private function safeExec(string $command, string $cwd = '.'): array
$authedUrl = preg_replace('#^https://#', "https://gitea-actions:{$token}@", $httpsUrl); {
$proc = proc_open($command, [1 => ['pipe', 'w'], 2 => ['pipe', 'w']], $pipes, $cwd);
echo " {$name} ... "; if (!is_resource($proc)) {
return [1, "proc_open failed"];
$workDir = "{$tmpBase}/{$name}"; }
@mkdir($workDir, 0755, true); $stdout = stream_get_contents($pipes[1]);
[$ret] = safeExec( $stderr = stream_get_contents($pipes[2]);
'git clone --depth 1 --branch ' . escapeshellarg($defaultBranch) fclose($pipes[1]);
. ' ' . escapeshellarg($authedUrl) . ' ' . escapeshellarg($workDir) fclose($pipes[2]);
); return [proc_close($proc), trim($stdout . "\n" . $stderr)];
if ($ret !== 0) {
echo "FAIL (clone)\n";
$stats['failed']++;
continue;
} }
$manifestPath = "{$workDir}/.mokogitea/.mokostandards"; private function rmTree(string $dir): void
if (!file_exists($manifestPath) || !str_contains(file_get_contents($manifestPath), '<mokostandards')) { {
echo "SKIP (no XML manifest)\n"; if (!is_dir($dir)) {
$stats['skipped']++; return;
rmTree($workDir);
continue;
} }
$it = new \RecursiveDirectoryIterator($dir, \FilesystemIterator::SKIP_DOTS);
$existingXml = file_get_contents($manifestPath); $files = new \RecursiveIteratorIterator($it, \RecursiveIteratorIterator::CHILD_FIRST);
$platform = $parser->extractPlatform($existingXml) ?? 'default-repository'; foreach ($files as $file) {
$enrichment = inspectRepo($workDir, $platform); if ($file->isDir()) {
@rmdir($file->getPathname());
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);
$enrichedXml = enrichManifestXml($existingXml, $enrichment);
$dc = count($enrichment['deploy'] ?? []);
$sc = count($enrichment['scripts'] ?? []);
$details = "deploy={$dc} scripts={$sc}";
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]');
gitCmd($workDir, 'config', 'user.email', 'gitea-actions[bot]@git.mokoconsulting.tech');
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;
}
[$pr] = gitCmd($workDir, 'push', 'origin', $defaultBranch);
if ($pr !== 0) {
echo "FAIL (push)\n";
$stats['failed']++;
} else { } else {
echo "ENRICHED [{$details}]\n"; @chmod($file->getPathname(), 0777);
$stats['enriched']++; @unlink($file->getPathname());
}
}
@rmdir($dir);
} }
rmTree($workDir); /** @return array{int, string} */
private function gitCmd(string $workDir, string ...$args): array
{
$cmd = 'git';
foreach ($args as $a) {
$cmd .= ' ' . escapeshellarg($a);
}
return $this->safeExec($cmd, $workDir);
}
private 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++;
} while (count($batch) >= 50);
return $repos;
}
} }
@rmdir($tmpBase); $app = new EnrichMokostandardsXmlCli();
echo "\n=== Summary ===\nEnriched: {$stats['enriched']}\nSkipped: {$stats['skipped']}\nFailed: {$stats['failed']}\n"; exit($app->execute());
+2 -2
View File
@@ -2,8 +2,8 @@
Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech> Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
SPDX-License-Identifier: GPL-3.0-or-later SPDX-License-Identifier: GPL-3.0-or-later
FILE INFORMATION FILE INFORMATION
DEFGROUP: MokoStandards.Index DEFGROUP: MokoPlatform.Index
INGROUP: MokoStandards.Automation INGROUP: MokoPlatform.Automation
REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform
PATH: /automation/index.md PATH: /automation/index.md
BRIEF: Automation directory index BRIEF: Automation directory index
+4 -4
View File
@@ -8,8 +8,8 @@
* SPDX-License-Identifier: GPL-3.0-or-later * SPDX-License-Identifier: GPL-3.0-or-later
* *
* FILE INFORMATION * FILE INFORMATION
* DEFGROUP: MokoStandards.Automation * DEFGROUP: MokoPlatform.Automation
* INGROUP: MokoStandards * INGROUP: MokoPlatform
* REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform * REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform
* PATH: /automation/migrate_to_gitea.php * PATH: /automation/migrate_to_gitea.php
* BRIEF: Migrate repositories from GitHub to self-hosted Gitea instance * BRIEF: Migrate repositories from GitHub to self-hosted Gitea instance
@@ -17,7 +17,7 @@
* USAGE * USAGE
* php automation/migrate_to_gitea.php --dry-run * php automation/migrate_to_gitea.php --dry-run
* php automation/migrate_to_gitea.php --repos MokoCRM MokoDoliMods * php automation/migrate_to_gitea.php --repos MokoCRM MokoDoliMods
* php automation/migrate_to_gitea.php --exclude MokoStandards --skip-archived * php automation/migrate_to_gitea.php --exclude moko-platform --skip-archived
* php automation/migrate_to_gitea.php --resume * php automation/migrate_to_gitea.php --resume
*/ */
@@ -278,7 +278,7 @@ class MigrateToGitea extends CliFramework
try { try {
$this->gitea->createIssue( $this->gitea->createIssue(
$giteaOrg, $giteaOrg,
'MokoStandards', 'moko-platform',
'chore: GitHub → Gitea migration report — ' . count($results['migrated']) . ' repos migrated', 'chore: GitHub → Gitea migration report — ' . count($results['migrated']) . ' repos migrated',
$report, $report,
['labels' => ['automation', 'type: chore']] ['labels' => ['automation', 'type: chore']]
+22 -22
View File
@@ -9,8 +9,8 @@
* SPDX-License-Identifier: GPL-3.0-or-later * SPDX-License-Identifier: GPL-3.0-or-later
* *
* FILE INFORMATION * FILE INFORMATION
* DEFGROUP: MokoStandards.Automation * DEFGROUP: MokoPlatform.Automation
* INGROUP: MokoStandards.Scripts * INGROUP: MokoPlatform.Scripts
* REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform * REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform
* PATH: /automation/push_files.php * PATH: /automation/push_files.php
* BRIEF: Push one or more specific files to one or more remote repositories * BRIEF: Push one or more specific files to one or more remote repositories
@@ -35,7 +35,7 @@ use MokoEnterprise\{
/** /**
* Targeted File Push Tool * Targeted File Push Tool
* *
* Pushes one or more specific files from MokoStandards templates to one or * Pushes one or more specific files from moko-platform templates to one or
* more remote repositories — without running a full sync. * more remote repositories — without running a full sync.
* *
* Files are specified by their destination path as they appear in the target * Files are specified by their destination path as they appear in the target
@@ -53,7 +53,7 @@ use MokoEnterprise\{
class PushFiles extends CliFramework class PushFiles extends CliFramework
{ {
public const DEFAULT_ORG = 'MokoConsulting'; public const DEFAULT_ORG = 'MokoConsulting';
public const VERSION = '04.06.00'; public const VERSION = '09.23.00';
private ApiClient $api; private ApiClient $api;
private GitPlatformAdapter $adapter; private GitPlatformAdapter $adapter;
@@ -81,7 +81,7 @@ class PushFiles extends CliFramework
*/ */
protected function run(): int protected function run(): int
{ {
$this->log('📦 MokoStandards File Push v' . self::VERSION, 'INFO'); $this->log('📦 moko-platform File Push v' . self::VERSION, 'INFO');
if (!$this->initializeComponents()) { if (!$this->initializeComponents()) {
return 1; return 1;
@@ -336,7 +336,7 @@ class PushFiles extends CliFramework
$prNumber = null; $prNumber = null;
if (!$direct) { if (!$direct) {
$prTitle = "chore: push " . count($entries) . " file(s) from MokoStandards"; $prTitle = "chore: push " . count($entries) . " file(s) from moko-platform";
$prBody = $this->buildPRBody($entries); $prBody = $this->buildPRBody($entries);
$pr = $this->adapter->createPullRequest( $pr = $this->adapter->createPullRequest(
$org, $org,
@@ -413,7 +413,7 @@ class PushFiles extends CliFramework
$message = !empty($customMessage) $message = !empty($customMessage)
? $customMessage ? $customMessage
: "chore: update {$destPath} from MokoStandards"; : "chore: update {$destPath} from moko-platform";
// Fetch existing file SHA (needed for updates) // Fetch existing file SHA (needed for updates)
$existingSha = null; $existingSha = null;
@@ -456,9 +456,9 @@ class PushFiles extends CliFramework
): void { ): void {
$now = gmdate('Y-m-d H:i:s') . ' UTC'; $now = gmdate('Y-m-d H:i:s') . ' UTC';
$version = self::VERSION; $version = self::VERSION;
$source = $this->adapter->getRepoWebUrl($org, 'MokoStandards'); $source = $this->adapter->getRepoWebUrl($org, 'moko-platform');
$title = "chore: MokoStandards file push tracking"; $title = "chore: moko-platform file push tracking";
$deliveryLine = $prNumber !== null $deliveryLine = $prNumber !== null
? "| **Pull request** | [#{$prNumber}](" . $this->adapter->getPullRequestWebUrl($org, $repo, $prNumber) . ") |" ? "| **Pull request** | [#{$prNumber}](" . $this->adapter->getPullRequestWebUrl($org, $repo, $prNumber) . ") |"
@@ -470,9 +470,9 @@ class PushFiles extends CliFramework
)); ));
$body = <<<MD $body = <<<MD
## MokoStandards File Push ## moko-platform File Push
One or more files were pushed to this repository from MokoStandards. One or more files were pushed to this repository from moko-platform.
| Field | Value | | Field | Value |
|-------|-------| |-------|-------|
@@ -486,12 +486,12 @@ class PushFiles extends CliFramework
{$fileRows} {$fileRows}
--- ---
*Generated automatically by [MokoStandards]({$source}) `push_files.php`* *Generated automatically by [moko-platform]({$source}) `push_files.php`*
MD; MD;
$body = preg_replace('/^ /m', '', $body); $body = preg_replace('/^ /m', '', $body);
$labels = ['standards-update', 'mokostandards', 'type: chore', 'automation']; $labels = ['standards-update', 'moko-platform', 'type: chore', 'automation'];
try { try {
$existing = $this->api->get("/repos/{$org}/{$repo}/issues", [ $existing = $this->api->get("/repos/{$org}/{$repo}/issues", [
@@ -549,7 +549,7 @@ class PushFiles extends CliFramework
} }
/** /**
* Create or update a failure issue in MokoStandards when repos fail to receive files. * Create or update a failure issue in moko-platform when repos fail to receive files.
* Uses the 'push-failure' label. Reopens a closed issue rather than creating a duplicate. * Uses the 'push-failure' label. Reopens a closed issue rather than creating a duplicate.
*/ */
private function createFailureIssue(string $org, array $results): void private function createFailureIssue(string $org, array $results): void
@@ -597,7 +597,7 @@ class PushFiles extends CliFramework
$body = preg_replace('/^ /m', '', $body); $body = preg_replace('/^ /m', '', $body);
try { try {
$existing = $this->api->get("/repos/{$org}/MokoStandards/issues", [ $existing = $this->api->get("/repos/{$org}/moko-platform/issues", [
'labels' => 'push-failure', 'labels' => 'push-failure',
'state' => 'all', 'state' => 'all',
'per_page' => 1, 'per_page' => 1,
@@ -612,17 +612,17 @@ class PushFiles extends CliFramework
if (($existing[0]['state'] ?? 'open') === 'closed') { if (($existing[0]['state'] ?? 'open') === 'closed') {
$patch['state'] = 'open'; $patch['state'] = 'open';
} }
$this->api->patch("/repos/{$org}/MokoStandards/issues/{$num}", $patch); $this->api->patch("/repos/{$org}/moko-platform/issues/{$num}", $patch);
$this->log("🚨 Failure issue #{$num} updated: {$org}/MokoStandards#{$num}", 'WARN'); $this->log("🚨 Failure issue #{$num} updated: {$org}/moko-platform#{$num}", 'WARN');
} else { } else {
$issue = $this->api->post("/repos/{$org}/MokoStandards/issues", [ $issue = $this->api->post("/repos/{$org}/moko-platform/issues", [
'title' => $title, 'title' => $title,
'body' => $body, 'body' => $body,
'labels' => ['push-failure'], 'labels' => ['push-failure'],
'assignees' => ['jmiller'], 'assignees' => ['jmiller'],
]); ]);
$num = $issue['number'] ?? '?'; $num = $issue['number'] ?? '?';
$this->log("🚨 Failure issue created: {$org}/MokoStandards#{$num}", 'WARN'); $this->log("🚨 Failure issue created: {$org}/moko-platform#{$num}", 'WARN');
} }
} catch (\Exception $e) { } catch (\Exception $e) {
$this->log("⚠️ Could not create/update failure issue: " . $e->getMessage(), 'WARN'); $this->log("⚠️ Could not create/update failure issue: " . $e->getMessage(), 'WARN');
@@ -637,14 +637,14 @@ class PushFiles extends CliFramework
private function buildPRBody(array $entries): string private function buildPRBody(array $entries): string
{ {
$now = gmdate('Y-m-d H:i:s') . ' UTC'; $now = gmdate('Y-m-d H:i:s') . ' UTC';
$lines = ["## MokoStandards File Push\n", "**Pushed:** {$now}\n", '### Files\n']; $lines = ["## moko-platform File Push\n", "**Pushed:** {$now}\n", '### Files\n'];
foreach ($entries as $entry) { foreach ($entries as $entry) {
$lines[] = "- `{$entry['destination']}`"; $lines[] = "- `{$entry['destination']}`";
} }
$sourceUrl = $this->adapter->getRepoWebUrl(self::DEFAULT_ORG, 'MokoStandards'); $sourceUrl = $this->adapter->getRepoWebUrl(self::DEFAULT_ORG, 'moko-platform');
$lines[] = "\n---\n*Generated by [MokoStandards]({$sourceUrl}) `push_files.php`*"; $lines[] = "\n---\n*Generated by [moko-platform]({$sourceUrl}) `push_files.php`*";
return implode("\n", $lines); return implode("\n", $lines);
} }
+345
View File
@@ -0,0 +1,345 @@
#!/usr/bin/env php
<?php
/**
* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
* SPDX-License-Identifier: GPL-3.0-or-later
*
* FILE INFORMATION
* DEFGROUP: MokoPlatform.Automation
* INGROUP: MokoPlatform
* REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform
* PATH: /automation/push_manifest_xml.php
* BRIEF: Push XML manifests to all governed repositories
*/
declare(strict_types=1);
require_once __DIR__ . '/../vendor/autoload.php';
require_once __DIR__ . '/../lib/Enterprise/CliFramework.php';
use MokoEnterprise\CliFramework;
use MokoEnterprise\MokoStandardsParser;
class PushManifestXmlCli extends CliFramework
{
private const CRM_PLATFORM_REPOS = ['MokoDolibarr', 'MokoDoliMods'];
protected function configure(): void
{
$this->setDescription('Push XML manifest.xml to all governed repositories');
$this->addArgument('--repo', 'Filter to a single repo name', '');
$this->addArgument('--skip', 'Comma-separated list of repos to skip', '');
$this->addArgument('--force', 'Force overwrite even if already XML', false);
}
protected function run(): int
{
$giteaUrl = rtrim(getenv('GITEA_URL') ?: 'https://git.mokoconsulting.tech', '/');
$giteaOrg = getenv('GITEA_ORG') ?: 'MokoConsulting';
$token = getenv('GA_TOKEN') ?: getenv('GH_TOKEN') ?: '';
$force = $this->getArgument('--force');
$repoFilter = $this->getArgument('--repo') ?: null;
$skipStr = $this->getArgument('--skip');
$skipRepos = $skipStr !== '' ? array_map('trim', explode(',', $skipStr)) : [];
$parser = new MokoStandardsParser();
$tmpBase = sys_get_temp_dir() . '/moko-manifest-push-' . getmypid();
echo "=== moko-platform XML Manifest Push ===\n";
echo "Org: {$giteaOrg}\n";
echo "Mode: " . ($this->dryRun ? "DRY RUN" : "LIVE") . "\n";
if ($repoFilter) {
echo "Filter: {$repoFilter}\n";
}
echo "\n";
if (empty($token)) {
$this->log('ERROR', 'GA_TOKEN or GH_TOKEN environment variable required');
return 1;
}
$repos = $this->fetchRepos($giteaUrl, $giteaOrg, $token);
echo "Found " . count($repos) . " repositories\n\n";
$stats = ['created' => 0, 'updated' => 0, 'skipped' => 0, 'failed' => 0];
foreach ($repos as $repo) {
$name = $repo['name'];
if ($repoFilter && $name !== $repoFilter) {
continue;
}
if (in_array($name, $skipRepos, true)) {
echo " SKIP {$name} (excluded)\n";
$stats['skipped']++;
continue;
}
if ($repo['archived'] ?? false) {
echo " SKIP {$name} (archived)\n";
$stats['skipped']++;
continue;
}
$platform = $this->detectPlatform($repo);
$defaultBranch = $repo['default_branch'] ?? 'main';
$httpsUrl = $repo['clone_url'] ?? "{$giteaUrl}/{$giteaOrg}/{$name}.git";
$authedUrl = preg_replace('#^https://#', "https://gitea-actions:{$token}@", $httpsUrl);
echo " {$name} [{$platform}] ... ";
// Generate XML manifest
$xmlContent = $parser->generate([
'name' => $name,
'org' => $giteaOrg,
'platform' => $platform,
'standards_version' => '04.07.00',
'description' => $repo['description'] ?? '',
'license' => 'GPL-3.0-or-later',
'topics' => $repo['topics'] ?? [],
'language' => $repo['language'] ?? MokoStandardsParser::platformLanguage($platform),
'package_type' => MokoStandardsParser::platformPackageType($platform),
'last_synced' => date('c'),
]);
if ($this->dryRun) {
echo "WOULD WRITE ({$platform})\n";
$stats['created']++;
continue;
}
// Clone shallow via HTTPS (token-authed)
$workDir = "{$tmpBase}/{$name}";
@mkdir($workDir, 0755, true);
[$ret, $out] = $this->safeExec(
'git clone --depth 1 --branch ' . escapeshellarg($defaultBranch) . ' '
. escapeshellarg($authedUrl) . ' ' . escapeshellarg($workDir)
);
if ($ret !== 0) {
echo "FAIL (clone)\n";
fprintf(STDERR, " %s\n", $out);
$stats['failed']++;
continue;
}
// Check if already XML and up-to-date
$manifestPath = "{$workDir}/.mokogitea/manifest.xml";
$existingIsXml = file_exists($manifestPath) && str_contains(file_get_contents($manifestPath), '<moko-platform');
if ($existingIsXml && !$force) {
$existingPlatform = $parser->extractPlatform(file_get_contents($manifestPath));
if ($existingPlatform === $platform) {
echo "SKIP (already XML)\n";
$stats['skipped']++;
$this->rmTree($workDir);
continue;
}
}
// Write manifest
@mkdir("{$workDir}/.gitea", 0755, true);
file_put_contents($manifestPath, $xmlContent);
// Delete legacy files if present
$legacyDeleted = [];
foreach (['.mokostandards', '.github/.mokostandards', '.gitea/.mokostandards', '.mokogitea/.mokostandards'] as $legacy) {
$legacyPath = "{$workDir}/{$legacy}";
if (file_exists($legacyPath)) {
unlink($legacyPath);
$legacyDeleted[] = $legacy;
}
}
// Commit
$isNew = !$existingIsXml;
$commitMsg = $isNew
? 'chore: add XML manifest.xml'
: 'chore: update manifest.xml';
if (!empty($legacyDeleted)) {
$commitMsg .= "\n\nRemoved legacy: " . implode(', ', $legacyDeleted);
}
$this->gitCmd($workDir, 'config', 'user.name', 'gitea-actions[bot]');
$this->gitCmd($workDir, 'config', 'user.email', 'gitea-actions[bot]@git.mokoconsulting.tech');
$this->gitCmd($workDir, 'add', '.mokogitea/manifest.xml');
foreach ($legacyDeleted as $lf) {
$this->gitCmd($workDir, 'add', $lf);
}
[$commitRet, $commitOut] = $this->gitCmd($workDir, 'commit', '-m', $commitMsg);
if ($commitRet !== 0 && str_contains($commitOut, 'nothing to commit')) {
echo "SKIP (no changes)\n";
$stats['skipped']++;
$this->rmTree($workDir);
continue;
}
if ($commitRet !== 0) {
echo "FAIL (commit)\n";
fprintf(STDERR, " %s\n", $commitOut);
$stats['failed']++;
$this->rmTree($workDir);
continue;
}
[$pushRet, $pushOut] = $this->gitCmd($workDir, 'push', 'origin', $defaultBranch);
if ($pushRet !== 0) {
echo "FAIL (push)\n";
fprintf(STDERR, " %s\n", $pushOut);
$stats['failed']++;
} else {
$action = $isNew ? 'CREATED' : 'UPDATED';
echo "{$action}\n";
$stats[$isNew ? 'created' : 'updated']++;
}
// Cleanup
$this->rmTree($workDir);
}
// Cleanup tmp base
@rmdir($tmpBase);
echo "\n=== Summary ===\n";
echo "Created: {$stats['created']}\n";
echo "Updated: {$stats['updated']}\n";
echo "Skipped: {$stats['skipped']}\n";
echo "Failed: {$stats['failed']}\n";
return 0;
}
private function detectPlatform(array $repo): string
{
$name = $repo['name'] ?? '';
$nameLower = strtolower($name);
$description = strtolower($repo['description'] ?? '');
$topics = $repo['topics'] ?? [];
if (in_array($name, self::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($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';
}
return 'default-repository';
}
/**
* @return array{int, string}
*/
private 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 for: {$command}"];
}
$stdout = stream_get_contents($pipes[1]);
$stderr = stream_get_contents($pipes[2]);
fclose($pipes[1]);
fclose($pipes[2]);
$code = proc_close($proc);
return [$code, trim($stdout . "\n" . $stderr)];
}
private 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());
}
}
@rmdir($dir);
}
/**
* @return array{int, string}
*/
private function gitCmd(string $workDir, string ...$args): array
{
$cmd = 'git';
foreach ($args as $a) {
$cmd .= ' ' . escapeshellarg($a);
}
return $this->safeExec($cmd, $workDir);
}
private 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) {
$this->log('ERROR', "API error (HTTP {$code}) fetching repos page {$page}");
break;
}
$batch = json_decode($body, true);
if (empty($batch)) {
break;
}
$repos = array_merge($repos, $batch);
$page++;
} while (count($batch) >= 50);
return $repos;
}
}
$app = new PushManifestXmlCli();
exit($app->execute());
+214 -222
View File
@@ -6,62 +6,216 @@
* SPDX-License-Identifier: GPL-3.0-or-later * SPDX-License-Identifier: GPL-3.0-or-later
* *
* FILE INFORMATION * FILE INFORMATION
* DEFGROUP: MokoStandards.Automation * DEFGROUP: MokoPlatform.Automation
* INGROUP: MokoStandards * INGROUP: MokoPlatform
* REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform * REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform
* PATH: /automation/push_mokostandards_xml.php * PATH: /automation/push_mokostandards_xml.php
* BRIEF: Push XML manifests to all governed repositories * BRIEF: Push XML manifests to all governed repositories
*
* Push XML .mokostandards manifest to all governed repositories.
*
* Uses git SSH to bypass the Gitea reverse-proxy WAF that blocks
* API requests to paths containing ".mokogitea".
*
* Usage:
* php automation/push_mokostandards_xml.php [--dry-run] [--repo NAME] [--force]
*/ */
declare(strict_types=1); declare(strict_types=1);
require_once __DIR__ . '/../vendor/autoload.php'; require_once __DIR__ . '/../vendor/autoload.php';
require_once __DIR__ . '/../lib/Enterprise/CliFramework.php';
use MokoEnterprise\CliFramework;
use MokoEnterprise\MokoStandardsParser; use MokoEnterprise\MokoStandardsParser;
// ── Configuration ──────────────────────────────────────────────────────── class PushMokostandardsXmlCli extends CliFramework
$giteaUrl = rtrim(getenv('GITEA_URL') ?: 'https://git.mokoconsulting.tech', '/');
$giteaOrg = getenv('GITEA_ORG') ?: 'MokoConsulting';
$token = getenv('GA_TOKEN') ?: getenv('GH_TOKEN') ?: '';
$sshBase = 'ssh://gitea@git.mokoconsulting.tech:2222';
// ── CLI args ─────────────────────────────────────────────────────────────
$dryRun = in_array('--dry-run', $argv, true);
$force = in_array('--force', $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]));
}
}
$parser = new MokoStandardsParser();
$tmpBase = sys_get_temp_dir() . '/moko-manifest-push-' . getmypid();
// ── Platform detection heuristics (mirrors RepositorySynchronizer) ───────
$CRM_PLATFORM_REPOS = ['MokoDolibarr', 'MokoDoliMods'];
function detectPlatform(array $repo): string
{ {
global $CRM_PLATFORM_REPOS; private const CRM_PLATFORM_REPOS = ['MokoDolibarr', 'MokoDoliMods'];
protected function configure(): void
{
$this->setDescription('Push XML manifests to all governed repositories');
$this->addArgument('--repo', 'Filter to a single repo name', '');
$this->addArgument('--skip', 'Comma-separated list of repos to skip', '');
$this->addArgument('--force', 'Force overwrite even if already XML', false);
}
protected function run(): int
{
$giteaUrl = rtrim(getenv('GITEA_URL') ?: 'https://git.mokoconsulting.tech', '/');
$giteaOrg = getenv('GITEA_ORG') ?: 'MokoConsulting';
$token = getenv('GA_TOKEN') ?: getenv('GH_TOKEN') ?: '';
$force = $this->getArgument('--force');
$repoFilter = $this->getArgument('--repo') ?: null;
$skipStr = $this->getArgument('--skip');
$skipRepos = $skipStr !== '' ? array_map('trim', explode(',', $skipStr)) : [];
$parser = new MokoStandardsParser();
$tmpBase = sys_get_temp_dir() . '/moko-manifest-push-' . getmypid();
echo "=== moko-platform XML Manifest Push ===\n";
echo "Org: {$giteaOrg}\n";
echo "Mode: " . ($this->dryRun ? "DRY RUN" : "LIVE") . "\n";
if ($repoFilter) {
echo "Filter: {$repoFilter}\n";
}
echo "\n";
if (empty($token)) {
$this->log('ERROR', 'GA_TOKEN or GH_TOKEN environment variable required');
return 1;
}
$repos = $this->fetchRepos($giteaUrl, $giteaOrg, $token);
echo "Found " . count($repos) . " repositories\n\n";
$stats = ['created' => 0, 'updated' => 0, 'skipped' => 0, 'failed' => 0];
foreach ($repos as $repo) {
$name = $repo['name'];
if ($repoFilter && $name !== $repoFilter) {
continue;
}
if (in_array($name, $skipRepos, true)) {
echo " SKIP {$name} (excluded)\n";
$stats['skipped']++;
continue;
}
if ($repo['archived'] ?? false) {
echo " SKIP {$name} (archived)\n";
$stats['skipped']++;
continue;
}
$platform = $this->detectPlatform($repo);
$defaultBranch = $repo['default_branch'] ?? 'main';
$httpsUrl = $repo['clone_url'] ?? "{$giteaUrl}/{$giteaOrg}/{$name}.git";
$authedUrl = preg_replace('#^https://#', "https://gitea-actions:{$token}@", $httpsUrl);
echo " {$name} [{$platform}] ... ";
// Generate XML manifest
$xmlContent = $parser->generate([
'name' => $name,
'org' => $giteaOrg,
'platform' => $platform,
'standards_version' => '04.07.00',
'description' => $repo['description'] ?? '',
'license' => 'GPL-3.0-or-later',
'topics' => $repo['topics'] ?? [],
'language' => $repo['language'] ?? MokoStandardsParser::platformLanguage($platform),
'package_type' => MokoStandardsParser::platformPackageType($platform),
'last_synced' => date('c'),
]);
if ($this->dryRun) {
echo "WOULD WRITE ({$platform})\n";
$stats['created']++;
continue;
}
// Clone shallow via HTTPS (token-authed)
$workDir = "{$tmpBase}/{$name}";
@mkdir($workDir, 0755, true);
[$ret, $out] = $this->safeExec(
'git clone --depth 1 --branch ' . escapeshellarg($defaultBranch) . ' '
. escapeshellarg($authedUrl) . ' ' . escapeshellarg($workDir)
);
if ($ret !== 0) {
echo "FAIL (clone)\n";
fprintf(STDERR, " %s\n", $out);
$stats['failed']++;
continue;
}
// Check if already XML and up-to-date
$manifestPath = "{$workDir}/.mokogitea/manifest.xml";
$existingIsXml = file_exists($manifestPath) && str_contains(file_get_contents($manifestPath), '<moko-platform');
if ($existingIsXml && !$force) {
$existingPlatform = $parser->extractPlatform(file_get_contents($manifestPath));
if ($existingPlatform === $platform) {
echo "SKIP (already XML)\n";
$stats['skipped']++;
$this->rmTree($workDir);
continue;
}
}
// Write manifest
@mkdir("{$workDir}/.gitea", 0755, true);
file_put_contents($manifestPath, $xmlContent);
// Delete legacy files if present
$legacyDeleted = [];
foreach (['.mokostandards', '.github/.mokostandards'] as $legacy) {
$legacyPath = "{$workDir}/{$legacy}";
if (file_exists($legacyPath)) {
unlink($legacyPath);
$legacyDeleted[] = $legacy;
}
}
// Commit
$isNew = !$existingIsXml;
$commitMsg = $isNew
? 'chore: add XML manifest.xml'
: 'chore: update .mokostandards to XML format';
if (!empty($legacyDeleted)) {
$commitMsg .= "\n\nRemoved legacy: " . implode(', ', $legacyDeleted);
}
$this->gitCmd($workDir, 'config', 'user.name', 'gitea-actions[bot]');
$this->gitCmd($workDir, 'config', 'user.email', 'gitea-actions[bot]@git.mokoconsulting.tech');
$this->gitCmd($workDir, 'add', '.mokogitea/manifest.xml');
foreach ($legacyDeleted as $lf) {
$this->gitCmd($workDir, 'add', $lf);
}
[$commitRet, $commitOut] = $this->gitCmd($workDir, 'commit', '-m', $commitMsg);
if ($commitRet !== 0 && str_contains($commitOut, 'nothing to commit')) {
echo "SKIP (no changes)\n";
$stats['skipped']++;
$this->rmTree($workDir);
continue;
}
if ($commitRet !== 0) {
echo "FAIL (commit)\n";
fprintf(STDERR, " %s\n", $commitOut);
$stats['failed']++;
$this->rmTree($workDir);
continue;
}
[$pushRet, $pushOut] = $this->gitCmd($workDir, 'push', 'origin', $defaultBranch);
if ($pushRet !== 0) {
echo "FAIL (push)\n";
fprintf(STDERR, " %s\n", $pushOut);
$stats['failed']++;
} else {
$action = $isNew ? 'CREATED' : 'UPDATED';
echo "{$action}\n";
$stats[$isNew ? 'created' : 'updated']++;
}
// Cleanup
$this->rmTree($workDir);
}
// Cleanup tmp base
@rmdir($tmpBase);
echo "\n=== Summary ===\n";
echo "Created: {$stats['created']}\n";
echo "Updated: {$stats['updated']}\n";
echo "Skipped: {$stats['skipped']}\n";
echo "Failed: {$stats['failed']}\n";
return 0;
}
private function detectPlatform(array $repo): string
{
$name = $repo['name'] ?? ''; $name = $repo['name'] ?? '';
$nameLower = strtolower($name); $nameLower = strtolower($name);
$description = strtolower($repo['description'] ?? ''); $description = strtolower($repo['description'] ?? '');
$topics = $repo['topics'] ?? []; $topics = $repo['topics'] ?? [];
if (in_array($name, $CRM_PLATFORM_REPOS, true)) { if (in_array($name, self::CRM_PLATFORM_REPOS, true)) {
return 'crm-platform'; return 'crm-platform';
} }
if (in_array('dolibarr-platform', $topics)) { if (in_array('dolibarr-platform', $topics)) {
@@ -101,14 +255,13 @@ function detectPlatform(array $repo): string
return 'standards-repository'; return 'standards-repository';
} }
return 'default-repository'; return 'default-repository';
} }
/** /**
* Safe shell execution — uses proc_open with explicit arguments to avoid injection.
* @return array{int, string} * @return array{int, string}
*/ */
function safeExec(string $command, string $cwd = '.'): array private function safeExec(string $command, string $cwd = '.'): array
{ {
$proc = proc_open( $proc = proc_open(
$command, $command,
[1 => ['pipe', 'w'], 2 => ['pipe', 'w']], [1 => ['pipe', 'w'], 2 => ['pipe', 'w']],
@@ -124,44 +277,40 @@ function safeExec(string $command, string $cwd = '.'): array
fclose($pipes[2]); fclose($pipes[2]);
$code = proc_close($proc); $code = proc_close($proc);
return [$code, trim($stdout . "\n" . $stderr)]; return [$code, trim($stdout . "\n" . $stderr)];
} }
/** Recursively remove a directory (cross-platform). */ private function rmTree(string $dir): void
function rmTree(string $dir): void {
{
if (!is_dir($dir)) { if (!is_dir($dir)) {
return; return;
} }
$it = new RecursiveDirectoryIterator($dir, FilesystemIterator::SKIP_DOTS); $it = new \RecursiveDirectoryIterator($dir, \FilesystemIterator::SKIP_DOTS);
$files = new RecursiveIteratorIterator($it, RecursiveIteratorIterator::CHILD_FIRST); $files = new \RecursiveIteratorIterator($it, \RecursiveIteratorIterator::CHILD_FIRST);
foreach ($files as $file) { foreach ($files as $file) {
if ($file->isDir()) { if ($file->isDir()) {
@rmdir($file->getPathname()); @rmdir($file->getPathname());
} else { } else {
// Clear read-only flag (git objects on Windows)
@chmod($file->getPathname(), 0777); @chmod($file->getPathname(), 0777);
@unlink($file->getPathname()); @unlink($file->getPathname());
} }
} }
@rmdir($dir); @rmdir($dir);
} }
/** /**
* Run a git command safely in a given working directory.
* @return array{int, string} * @return array{int, string}
*/ */
function gitCmd(string $workDir, string ...$args): array private function gitCmd(string $workDir, string ...$args): array
{ {
$cmd = 'git'; $cmd = 'git';
foreach ($args as $a) { foreach ($args as $a) {
$cmd .= ' ' . escapeshellarg($a); $cmd .= ' ' . escapeshellarg($a);
} }
return safeExec($cmd, $workDir); return $this->safeExec($cmd, $workDir);
} }
// ── Fetch all repos via API ────────────────────────────────────────────── private function fetchRepos(string $url, string $org, string $token): array
function fetchRepos(string $url, string $org, string $token): array {
{
$repos = []; $repos = [];
$page = 1; $page = 1;
do { do {
@@ -176,7 +325,7 @@ function fetchRepos(string $url, string $org, string $token): array
curl_close($ch); curl_close($ch);
if ($code !== 200) { if ($code !== 200) {
fprintf(STDERR, "API error (HTTP %d) fetching repos page %d\n", $code, $page); $this->log('ERROR', "API error (HTTP {$code}) fetching repos page {$page}");
break; break;
} }
@@ -189,165 +338,8 @@ function fetchRepos(string $url, string $org, string $token): array
} while (count($batch) >= 50); } while (count($batch) >= 50);
return $repos; return $repos;
}
} }
// ── Main ───────────────────────────────────────────────────────────────── $app = new PushMokostandardsXmlCli();
echo "=== MokoStandards XML Manifest Push ===\n"; exit($app->execute());
echo "Org: {$giteaOrg}\n";
echo "Mode: " . ($dryRun ? "DRY RUN" : "LIVE") . "\n";
if ($repoFilter) {
echo "Filter: {$repoFilter}\n";
}
echo "\n";
if (empty($token)) {
fprintf(STDERR, "ERROR: GA_TOKEN or GH_TOKEN environment variable required\n");
exit(1);
}
$repos = fetchRepos($giteaUrl, $giteaOrg, $token);
echo "Found " . count($repos) . " repositories\n\n";
$stats = ['created' => 0, 'updated' => 0, 'skipped' => 0, 'failed' => 0];
foreach ($repos as $repo) {
$name = $repo['name'];
if ($repoFilter && $name !== $repoFilter) {
continue;
}
if (in_array($name, $skipRepos, true)) {
echo " SKIP {$name} (excluded)\n";
$stats['skipped']++;
continue;
}
if ($repo['archived'] ?? false) {
echo " SKIP {$name} (archived)\n";
$stats['skipped']++;
continue;
}
$platform = detectPlatform($repo);
$defaultBranch = $repo['default_branch'] ?? 'main';
// Prefer HTTPS with token (SSH port 2222 may be blocked); fall back to SSH
$httpsUrl = $repo['clone_url'] ?? "{$giteaUrl}/{$giteaOrg}/{$name}.git";
// Embed token in HTTPS URL for push auth
$authedUrl = preg_replace('#^https://#', "https://gitea-actions:{$token}@", $httpsUrl);
echo " {$name} [{$platform}] ... ";
// Generate XML manifest
$xmlContent = $parser->generate([
'name' => $name,
'org' => $giteaOrg,
'platform' => $platform,
'standards_version' => '04.07.00',
'description' => $repo['description'] ?? '',
'license' => 'GPL-3.0-or-later',
'topics' => $repo['topics'] ?? [],
'language' => $repo['language'] ?? MokoStandardsParser::platformLanguage($platform),
'package_type' => MokoStandardsParser::platformPackageType($platform),
'last_synced' => date('c'),
]);
if ($dryRun) {
echo "WOULD WRITE ({$platform})\n";
$stats['created']++;
continue;
}
// Clone shallow via HTTPS (token-authed)
$workDir = "{$tmpBase}/{$name}";
@mkdir($workDir, 0755, true);
[$ret, $out] = safeExec(
'git clone --depth 1 --branch ' . escapeshellarg($defaultBranch) . ' '
. escapeshellarg($authedUrl) . ' ' . escapeshellarg($workDir)
);
if ($ret !== 0) {
echo "FAIL (clone)\n";
fprintf(STDERR, " %s\n", $out);
$stats['failed']++;
continue;
}
// Check if already XML and up-to-date
$manifestPath = "{$workDir}/.mokogitea/.mokostandards";
$existingIsXml = file_exists($manifestPath) && str_contains(file_get_contents($manifestPath), '<mokostandards');
if ($existingIsXml && !$force) {
$existingPlatform = $parser->extractPlatform(file_get_contents($manifestPath));
if ($existingPlatform === $platform) {
echo "SKIP (already XML)\n";
$stats['skipped']++;
rmTree($workDir);
continue;
}
}
// Write manifest
@mkdir("{$workDir}/.gitea", 0755, true);
file_put_contents($manifestPath, $xmlContent);
// Delete legacy files if present
$legacyDeleted = [];
foreach (['.mokostandards', '.github/.mokostandards'] as $legacy) {
$legacyPath = "{$workDir}/{$legacy}";
if (file_exists($legacyPath)) {
unlink($legacyPath);
$legacyDeleted[] = $legacy;
}
}
// Commit
$isNew = !$existingIsXml;
$commitMsg = $isNew
? 'chore: add XML .mokostandards manifest'
: 'chore: update .mokostandards to XML format';
if (!empty($legacyDeleted)) {
$commitMsg .= "\n\nRemoved legacy: " . implode(', ', $legacyDeleted);
}
gitCmd($workDir, 'config', 'user.name', 'gitea-actions[bot]');
gitCmd($workDir, 'config', 'user.email', 'gitea-actions[bot]@git.mokoconsulting.tech');
gitCmd($workDir, 'add', '.mokogitea/.mokostandards');
foreach ($legacyDeleted as $lf) {
gitCmd($workDir, 'add', $lf);
}
[$commitRet, $commitOut] = gitCmd($workDir, 'commit', '-m', $commitMsg);
if ($commitRet !== 0 && str_contains($commitOut, 'nothing to commit')) {
echo "SKIP (no changes)\n";
$stats['skipped']++;
rmTree($workDir);
continue;
}
if ($commitRet !== 0) {
echo "FAIL (commit)\n";
fprintf(STDERR, " %s\n", $commitOut);
$stats['failed']++;
rmTree($workDir);
continue;
}
[$pushRet, $pushOut] = gitCmd($workDir, 'push', 'origin', $defaultBranch);
if ($pushRet !== 0) {
echo "FAIL (push)\n";
fprintf(STDERR, " %s\n", $pushOut);
$stats['failed']++;
} else {
$action = $isNew ? 'CREATED' : 'UPDATED';
echo "{$action}\n";
$stats[$isNew ? 'created' : 'updated']++;
}
// Cleanup
rmTree($workDir);
}
// Cleanup tmp base
@rmdir($tmpBase);
echo "\n=== Summary ===\n";
echo "Created: {$stats['created']}\n";
echo "Updated: {$stats['updated']}\n";
echo "Skipped: {$stats['skipped']}\n";
echo "Failed: {$stats['failed']}\n";
+12 -12
View File
@@ -9,8 +9,8 @@
* SPDX-License-Identifier: GPL-3.0-or-later * SPDX-License-Identifier: GPL-3.0-or-later
* *
* FILE INFORMATION * FILE INFORMATION
* DEFGROUP: MokoStandards.Automation * DEFGROUP: MokoPlatform.Automation
* INGROUP: MokoStandards.Scripts * INGROUP: MokoPlatform.Scripts
* REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform * REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform
* PATH: /automation/repo_cleanup.php * PATH: /automation/repo_cleanup.php
* BRIEF: Enterprise repository cleanup — branches, PRs, issues, workflows, labels, logs * BRIEF: Enterprise repository cleanup — branches, PRs, issues, workflows, labels, logs
@@ -38,15 +38,15 @@ use MokoEnterprise\{ApiClient, AuditLogger, CliFramework, Config, GitPlatformAda
*/ */
class RepoCleanup extends CliFramework class RepoCleanup extends CliFramework
{ {
private const VERSION = '04.06.00'; private const VERSION = '09.23.00';
private const SYNC_PREFIX = 'chore/sync-mokostandards-'; private const SYNC_PREFIX = 'chore/sync-moko-platform-';
private const CURRENT_BRANCH = 'chore/sync-mokostandards-v04.02.00'; private const CURRENT_BRANCH = 'chore/sync-moko-platform-v04.02.00';
/** Workflow files that have been retired and should be deleted from governed repos. */ /** Workflow files that have been retired and should be deleted from governed repos. */
private const RETIRED_WORKFLOWS = [ private const RETIRED_WORKFLOWS = [
'build.yml', 'code-quality.yml', 'release-cycle.yml', 'release-pipeline.yml', 'build.yml', 'code-quality.yml', 'release-cycle.yml', 'release-pipeline.yml',
'branch-cleanup.yml', 'auto-update-changelog.yml', 'enterprise-issue-manager.yml', 'branch-cleanup.yml', 'auto-update-changelog.yml', 'enterprise-issue-manager.yml',
'flush-actions-cache.yml', 'mokostandards-script-runner.yml', 'unified-ci.yml', 'flush-actions-cache.yml', 'moko-platform-script-runner.yml', 'unified-ci.yml',
'unified-platform-testing.yml', 'reusable-build.yml', 'reusable-ci-validation.yml', 'unified-platform-testing.yml', 'reusable-build.yml', 'reusable-ci-validation.yml',
'reusable-deploy.yml', 'reusable-php-quality.yml', 'reusable-platform-testing.yml', 'reusable-deploy.yml', 'reusable-php-quality.yml', 'reusable-platform-testing.yml',
'reusable-project-detector.yml', 'reusable-release.yml', 'reusable-script-executor.yml', 'reusable-project-detector.yml', 'reusable-release.yml', 'reusable-script-executor.yml',
@@ -98,7 +98,7 @@ class RepoCleanup extends CliFramework
} }
$this->logMsg("🧹 MokoStandards Repository Cleanup v" . self::VERSION); $this->logMsg("🧹 moko-platform Repository Cleanup v" . self::VERSION);
$this->logMsg("Organization: {$org}"); $this->logMsg("Organization: {$org}");
$this->logMsg("Current sync branch: " . self::CURRENT_BRANCH); $this->logMsg("Current sync branch: " . self::CURRENT_BRANCH);
if ($this->dryRun) { if ($this->dryRun) {
@@ -225,7 +225,7 @@ class RepoCleanup extends CliFramework
} }
$allRepos = $this->adapter->listOrgRepos($org, $skipArchived); $allRepos = $this->adapter->listOrgRepos($org, $skipArchived);
return array_filter($allRepos, fn($r) => !in_array($r['name'], ['MokoStandards', '.github-private'], true)); return array_filter($allRepos, fn($r) => !in_array($r['name'], ['moko-platform', '.github-private'], true));
} }
// ─── Cleanup operations ────────────────────────────────────────────── // ─── Cleanup operations ──────────────────────────────────────────────
@@ -463,9 +463,9 @@ class RepoCleanup extends CliFramework
private function checkLabels(string $org, string $repo, array &$results): void private function checkLabels(string $org, string $repo, array &$results): void
{ {
try { try {
$this->api->get("/repos/{$org}/{$repo}/labels/mokostandards"); $this->api->get("/repos/{$org}/{$repo}/labels/moko-platform");
} catch (\Exception $e) { } catch (\Exception $e) {
$this->logMsg(" ⚠️ Missing 'mokostandards' label"); $this->logMsg(" ⚠️ Missing 'moko-platform' label");
$results['labels_missing']++; $results['labels_missing']++;
$this->api->resetCircuitBreaker(); $this->api->resetCircuitBreaker();
} }
@@ -479,9 +479,9 @@ class RepoCleanup extends CliFramework
if (preg_match('/^\s*VERSION:\s*(\d{2}\.\d{2}\.\d{2})/m', $content, $m)) { if (preg_match('/^\s*VERSION:\s*(\d{2}\.\d{2}\.\d{2})/m', $content, $m)) {
$version = $m[1]; $version = $m[1];
// Check .mokostandards for the tracked MokoStandards version // Check manifest.xml for the tracked moko-platform version
try { try {
$mokoFile = $this->api->get("/repos/{$org}/{$repo}/contents/.mokostandards"); $mokoFile = $this->api->get("/repos/{$org}/{$repo}/contents/.mokogitea/manifest.xml");
$mokoContent = base64_decode($mokoFile['content'] ?? ''); $mokoContent = base64_decode($mokoFile['content'] ?? '');
if (preg_match('/standards_version:\s*(\d{2}\.\d{2}\.\d{2})/m', $mokoContent, $vm)) { if (preg_match('/standards_version:\s*(\d{2}\.\d{2}\.\d{2})/m', $mokoContent, $vm)) {
if ($vm[1] !== self::VERSION) { if ($vm[1] !== self::VERSION) {
+2 -2
View File
@@ -4,8 +4,8 @@
# Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech> # Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
# SPDX-License-Identifier: GPL-3.0-or-later # SPDX-License-Identifier: GPL-3.0-or-later
# #
# DEFGROUP: MokoStandards.Automation.ServerAutoheal # DEFGROUP: MokoPlatform.Automation.ServerAutoheal
# INGROUP: MokoStandards.Automation # INGROUP: MokoPlatform.Automation
# REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform # REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform
# PATH: /automation/server-autoheal.sh # PATH: /automation/server-autoheal.sh
# BRIEF: Server auto-heal on unclean restart + split system/content backups # BRIEF: Server auto-heal on unclean restart + split system/content backups
+98 -24
View File
@@ -19,26 +19,22 @@
* php bin/moko <command> [options] (all platforms) * php bin/moko <command> [options] (all platforms)
* ./bin/moko <command> [options] (Unix, after: chmod +x bin/moko) * ./bin/moko <command> [options] (Unix, after: chmod +x bin/moko)
* *
* COMMANDS * COMMANDS (run `php bin/moko list` for the full list — 97 commands)
* sync Bulk-sync MokoStandards to organisation repos
* health Full repository health check (runs most validators)
* inventory Refresh docs/reference/REPOSITORY_INVENTORY.md
* *
* check:syntax PHP syntax check (php -l) on all tracked .php files * Automation sync, automation:cleanup, automation:migrate-gitea
* check:version Verify VERSION fields and badges match composer.json * Validation health, detect, drift, check:syntax, check:version, ...
* check:changelog Validate CHANGELOG.md format * Release release, release:joomla, release:create, release:publish, ...
* check:structure Verify required root files and directories * Version version:read, version:bump, version:auto-bump, ...
* check:headers Check SPDX-License-Identifier presence in source files * Build build:package, build:joomla, build:updates-xml, ...
* check:secrets Scan for leaked credentials / API keys * Deploy deploy:joomla, deploy:dolibarr, deploy:sftp, deploy:rollback, ...
* check:tabs Detect tab characters in YAML files * Repository repo:create, repo:archive, repo:rename-branch, repo:reset-dev, ...
* check:paths Detect backslash path separators in PHP source * Bulk Operations bulk:push-workflow, bulk:push-manifest, bulk:template-joomla, ...
* check:xml Validate XML files are well-formed * Maintenance maintenance:labels, maintenance:rotate-secrets, maintenance:pin-shas, ...
* check:enterprise Full enterprise-readiness check (headers, strict types, PSR-12) * Fix fix:line-endings, fix:tabs, fix:trailing, fix:permissions
* check:dolibarr Validate Dolibarr module directory structure * Monitoring dashboard, grafana, client:inventory, client:health-check
* check:joomla Validate Joomla XML manifest * Platform platform:detect, manifest:read, manifest:element
* check:language Validate Joomla/Dolibarr .ini language files * Wiki wiki:sync
* detect Auto-detect repository platform type * Badges badge:update
* drift Scan org repos for drift from MokoStandards templates
* *
* COMMON OPTIONS (passed through to each script) * COMMON OPTIONS (passed through to each script)
* --path <dir> Repository root to check (default: .) * --path <dir> Repository root to check (default: .)
@@ -88,11 +84,22 @@ require_once $autoloader;
* All paths are relative to the repo root. * All paths are relative to the repo root.
*/ */
const COMMAND_MAP = [ const COMMAND_MAP = [
// Audit
'audit:query' => 'cli/audit_query.php',
// Automation // Automation
'sync' => 'automation/bulk_sync.php', 'sync' => 'automation/bulk_sync.php',
'automation:cleanup' => 'automation/repo_cleanup.php',
'automation:migrate-gitea' => 'automation/migrate_to_gitea.php',
// Maintenance // Maintenance
'inventory' => 'maintenance/update_repo_inventory.php', 'inventory' => 'maintenance/update_repo_inventory.php',
'maintenance:pin-shas' => 'maintenance/pin_action_shas.php',
'maintenance:inventory' => 'maintenance/repo_inventory.php',
'maintenance:rotate-secrets' => 'maintenance/rotate_secrets.php',
'maintenance:labels' => 'maintenance/setup_labels.php',
'maintenance:sync-dolibarr' => 'maintenance/sync_dolibarr_readmes.php',
'maintenance:update-shas' => 'maintenance/update_sha_hashes.php',
// Validation — general // Validation — general
'health' => 'validate/check_repo_health.php', 'health' => 'validate/check_repo_health.php',
@@ -110,8 +117,10 @@ const COMMAND_MAP = [
// Validation — platform-specific // Validation — platform-specific
'check:dolibarr' => 'validate/check_dolibarr_module.php', 'check:dolibarr' => 'validate/check_dolibarr_module.php',
'check:joomla' => 'validate/check_joomla_manifest.php', 'check:joomla' => 'validate/check_joomla_manifest.php',
'check:joomla-compat' => 'cli/joomla_compat_check.php',
'check:language' => 'validate/check_language_structure.php', 'check:language' => 'validate/check_language_structure.php',
'check:client' => 'validate/check_client_theme.php', 'check:client' => 'validate/check_client_theme.php',
'check:theme' => 'cli/theme_lint.php',
'check:wiki' => 'validate/check_wiki_health.php', 'check:wiki' => 'validate/check_wiki_health.php',
// Detection // Detection
@@ -124,13 +133,18 @@ const COMMAND_MAP = [
'release' => 'cli/release.php', 'release' => 'cli/release.php',
'release:notes' => 'cli/release_notes.php', 'release:notes' => 'cli/release_notes.php',
'release:validate' => 'cli/release_validate.php', 'release:validate' => 'cli/release_validate.php',
'manifest:element' => 'cli/manifest_element.php',
'release:cascade' => 'cli/release_cascade.php', 'release:cascade' => 'cli/release_cascade.php',
'release:promote' => 'cli/release_promote.php', 'release:promote' => 'cli/release_promote.php',
'release:create' => 'cli/release_create.php', 'release:create' => 'cli/release_create.php',
'release:manage' => 'cli/release_manage.php', 'release:manage' => 'cli/release_manage.php',
'release:mirror' => 'cli/release_mirror.php', 'release:mirror' => 'cli/release_mirror.php',
'release:package' => 'cli/release_package.php', 'release:package' => 'cli/release_package.php',
'release:joomla' => 'cli/joomla_release.php',
'release:body-update' => 'cli/release_body_update.php',
'release:publish' => 'cli/release_publish.php',
'release:verify' => 'cli/release_verify.php',
'release:gen-dolibarr' => 'release/generate_dolibarr_version_txt.php',
'release:gen-joomla' => 'release/generate_joomla_update_xml.php',
// Changelog // Changelog
'changelog:promote' => 'cli/changelog_promote.php', 'changelog:promote' => 'cli/changelog_promote.php',
@@ -143,31 +157,71 @@ const COMMAND_MAP = [
'version:propagate' => 'maintenance/update_version_from_readme.php', 'version:propagate' => 'maintenance/update_version_from_readme.php',
'version:set-platform' => 'cli/version_set_platform.php', 'version:set-platform' => 'cli/version_set_platform.php',
'version:reset-dev' => 'cli/version_reset_dev.php', 'version:reset-dev' => 'cli/version_reset_dev.php',
'version:auto-bump' => 'cli/version_auto_bump.php',
'version:bump-remote' => 'cli/version_bump_remote.php',
// Build & package // Build & package
'build:package' => 'cli/package_build.php', 'build:package' => 'cli/package_build.php',
'build:joomla' => 'cli/joomla_build.php', 'build:joomla' => 'cli/joomla_build.php',
'build:updates-xml' => 'cli/updates_xml_build.php', 'build:updates-xml' => 'cli/updates_xml_build.php',
'build:updates-xml-sync' => 'cli/updates_xml_sync.php',
// Platform detection // Platform detection & manifest
'platform:detect' => 'cli/platform_detect.php', 'platform:detect' => 'cli/platform_detect.php',
'manifest:read' => 'cli/manifest_read.php', 'manifest:read' => 'cli/manifest_read.php',
'manifest:element' => 'cli/manifest_element.php',
// Repository management // Repository management
'repo:create' => 'cli/create_repo.php', 'repo:create' => 'cli/create_repo.php',
'repo:create-project' => 'cli/create_project.php',
'repo:archive' => 'cli/archive_repo.php', 'repo:archive' => 'cli/archive_repo.php',
'repo:scaffold-client' => 'cli/scaffold_client.php', 'repo:scaffold-client' => 'cli/scaffold_client.php',
'repo:provision' => 'cli/client_provision.php', 'repo:provision' => 'cli/client_provision.php',
'repo:rename-branch' => 'cli/branch_rename.php',
'repo:reset-dev' => 'cli/dev_branch_reset.php',
// Bulk operations // Bulk operations
'bulk:push-workflow' => 'cli/bulk_workflow_push.php', 'bulk:push-workflow' => 'cli/bulk_workflow_push.php',
'bulk:trigger' => 'cli/bulk_workflow_trigger.php', 'bulk:trigger' => 'cli/bulk_workflow_trigger.php',
'bulk:sync-rulesets' => 'cli/sync_rulesets.php', 'bulk:sync-rulesets' => 'cli/sync_rulesets.php',
'bulk:push-files' => 'automation/push_files.php',
'bulk:push-manifest' => 'automation/push_manifest_xml.php',
'bulk:push-mokostandards' => 'automation/push_mokostandards_xml.php',
'bulk:enrich-manifest' => 'automation/enrich_manifest_xml.php',
'bulk:enrich-mokostandards' => 'automation/enrich_mokostandards_xml.php',
'bulk:template-joomla' => 'automation/bulk_joomla_template.php',
// Deploy
'deploy:joomla' => 'cli/deploy_joomla.php',
'deploy:joomla-legacy' => 'deploy/deploy-joomla.php',
'deploy:dolibarr' => 'deploy/deploy-dolibarr.php',
'deploy:sftp' => 'deploy/deploy-sftp.php',
'deploy:backup' => 'deploy/backup-before-deploy.php',
'deploy:health-check' => 'deploy/health-check.php',
'deploy:rollback' => 'deploy/rollback-joomla.php',
'deploy:sync' => 'deploy/sync-joomla.php',
// Fix / auto-remediation
'fix:line-endings' => 'fix/fix_line_endings.php',
'fix:tabs' => 'fix/fix_tabs.php',
'fix:trailing' => 'fix/fix_trailing_spaces.php',
'fix:permissions' => 'fix/fix_permissions.php',
// Monitoring & dashboards // Monitoring & dashboards
'dashboard' => 'cli/client_dashboard.php', 'dashboard' => 'cli/client_dashboard.php',
'grafana' => 'cli/grafana_dashboard.php', 'grafana' => 'cli/grafana_dashboard.php',
'client:inventory' => 'cli/client_inventory.php', 'client:inventory' => 'cli/client_inventory.php',
'client:health-check' => 'cli/client_health_check.php',
// Badge & wiki
'badge:update' => 'cli/badge_update.php',
'wiki:sync' => 'cli/wiki_sync.php',
// Licensing
'license' => 'cli/license_manage.php',
// Shell completion
'completion' => 'cli/completion.php',
// Module validation // Module validation
'validate:module' => 'bin/validate-module', 'validate:module' => 'bin/validate-module',
@@ -197,16 +251,28 @@ if ($command === 'list' || $command === 'commands') {
// ── Dispatch ────────────────────────────────────────────────────────────────── // ── Dispatch ──────────────────────────────────────────────────────────────────
if (!array_key_exists($command, COMMAND_MAP)) { $scriptRelative = null;
if (array_key_exists($command, COMMAND_MAP)) {
$scriptRelative = COMMAND_MAP[$command];
} else {
// Fall back to plugin-provided commands before giving up.
$pluginCommands = loadPluginCommands();
if (isset($pluginCommands[$command]) && !empty($pluginCommands[$command]['script'])) {
$scriptRelative = $pluginCommands[$command]['script'];
}
}
if ($scriptRelative === null) {
fwrite(STDERR, "Error: Unknown command '{$command}'\n\n"); fwrite(STDERR, "Error: Unknown command '{$command}'\n\n");
printCommandList(); printCommandList();
exit(2); exit(2);
} }
$scriptPath = $repoRoot . '/' . COMMAND_MAP[$command]; $scriptPath = $repoRoot . '/' . $scriptRelative;
if (!is_file($scriptPath)) { if (!is_file($scriptPath)) {
fwrite(STDERR, "Error: Script not found: " . COMMAND_MAP[$command] . "\n"); fwrite(STDERR, "Error: Script not found: {$scriptRelative}\n");
fwrite(STDERR, "Ensure the repository is complete and run: composer install\n"); fwrite(STDERR, "Ensure the repository is complete and run: composer install\n");
exit(2); exit(2);
} }
@@ -268,6 +334,12 @@ function printCommandList(): void
'bulk' => 'Bulk Operations', 'bulk' => 'Bulk Operations',
'client' => 'Client Management', 'client' => 'Client Management',
'validate' => 'Module Validation', 'validate' => 'Module Validation',
'deploy' => 'Deploy',
'fix' => 'Fix / Auto-remediation',
'maintenance' => 'Maintenance',
'automation' => 'Automation',
'badge' => 'Badges',
'wiki' => 'Wiki',
default => ucfirst($prefix), default => ucfirst($prefix),
}; };
} else { } else {
@@ -277,6 +349,8 @@ function printCommandList(): void
'health' => 'Validation', 'health' => 'Validation',
'detect', 'drift' => 'Validation', 'detect', 'drift' => 'Validation',
'dashboard', 'grafana' => 'Monitoring', 'dashboard', 'grafana' => 'Monitoring',
'release' => 'Release',
'license' => 'Licensing',
default => 'Other', default => 'Other',
}; };
} }
-19
View File
@@ -1,19 +0,0 @@
# Build Index: /api/build
## Purpose
This folder contains build system management and compilation scripts.
## Quick Links
- [README](./README.md) - Build scripts documentation
## Scripts
- [moko-make](./moko-make) - Build system wrapper
- [resolve_makefile.py](./resolve_makefile.py) - Makefile resolution
## Metadata
- **Document Type:** index
- **Auto-generated:** This file is manually maintained for ignored directory
-112
View File
@@ -1,112 +0,0 @@
#!/usr/bin/env bash
# Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
# SPDX-License-Identifier: GPL-3.0-or-later
#
# Moko Build Wrapper
# Automatically finds and uses appropriate Makefile from MokoStandards
set -e
# Colors
COLOR_RESET="\033[0m"
COLOR_GREEN="\033[32m"
COLOR_BLUE="\033[34m"
COLOR_RED="\033[31m"
# Find MokoStandards root
find_mokostandards() {
# Check environment variable
if [ -n "$MOKOSTANDARDS_ROOT" ] && [ -d "$MOKOSTANDARDS_ROOT/templates/build" ]; then
echo "$MOKOSTANDARDS_ROOT"
return 0
fi
# Check adjacent directories
if [ -d "../MokoStandards/templates/build" ]; then
echo "$(cd ../MokoStandards && pwd)"
return 0
fi
if [ -d "../../MokoStandards/templates/build" ]; then
echo "$(cd ../../MokoStandards && pwd)"
return 0
fi
# Check home directory
if [ -d "$HOME/.mokostandards/templates/build" ]; then
echo "$HOME/.mokostandards"
return 0
fi
# Check system location
if [ -d "/opt/mokostandards/templates/build" ]; then
echo "/opt/mokostandards"
return 0
fi
return 1
}
# Find appropriate Makefile
find_makefile() {
# Check for local Makefile
if [ -f "Makefile" ]; then
echo "Makefile"
return 0
fi
# Check for .moko/Makefile
if [ -f ".moko/Makefile" ]; then
echo ".moko/Makefile"
return 0
fi
# Find MokoStandards
MOKO_ROOT=$(find_mokostandards)
if [ $? -ne 0 ]; then
echo -e "${COLOR_RED}✗${COLOR_RESET} MokoStandards repository not found" >&2
echo -e "${COLOR_BLUE}Hint:${COLOR_RESET} Set MOKOSTANDARDS_ROOT or clone adjacent" >&2
return 1
fi
# Detect project type
if [ -d "core/modules" ] && ls core/modules/mod*.class.php >/dev/null 2>&1; then
echo "$MOKO_ROOT/templates/build/dolibarr/Makefile"
return 0
fi
# Check for Joomla XML files
shopt -s nullglob # Prevent glob expansion if no matches
for xml in *.xml; do
if [ -f "$xml" ]; then
if grep -q 'type="component"' "$xml" 2>/dev/null; then
echo "$MOKO_ROOT/templates/build/joomla/Makefile.component"
return 0
elif grep -q 'type="module"' "$xml" 2>/dev/null; then
echo "$MOKO_ROOT/templates/build/joomla/Makefile.module"
return 0
elif grep -q 'type="plugin"' "$xml" 2>/dev/null; then
echo "$MOKO_ROOT/templates/build/joomla/Makefile.plugin"
return 0
fi
fi
done
shopt -u nullglob
echo -e "${COLOR_RED}✗${COLOR_RESET} Could not detect project type" >&2
return 1
}
# Main execution
MAKEFILE=$(find_makefile)
if [ $? -ne 0 ]; then
exit 1
fi
# Show which Makefile we're using
if [[ "$MAKEFILE" == *"MokoStandards"* ]] || [[ "$MAKEFILE" == *".mokostandards"* ]]; then
echo -e "${COLOR_BLUE}${COLOR_RESET} Using MokoStandards template"
fi
# Run make with the found Makefile
exec make -f "$MAKEFILE" "$@"
+82 -58
View File
@@ -1,5 +1,6 @@
#!/usr/bin/env php #!/usr/bin/env php
<?php <?php
/* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech> /* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
* *
* This file is part of a Moko Consulting project. * This file is part of a Moko Consulting project.
@@ -12,62 +13,63 @@
* REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform * REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform
* PATH: /cli/archive_repo.php * PATH: /cli/archive_repo.php
* BRIEF: Gracefully retire a governed repository — archive, close issues/PRs, remove sync def * BRIEF: Gracefully retire a governed repository — archive, close issues/PRs, remove sync def
*
* USAGE
* php cli/archive_repo.php --repo MokoOldModule
* php cli/archive_repo.php --repo MokoOldModule --dry-run
* php cli/archive_repo.php --repo MokoOldModule --skip-close # Archive only, keep issues open
*/ */
declare(strict_types=1); declare(strict_types=1);
require_once __DIR__ . '/../vendor/autoload.php'; require_once __DIR__ . '/../vendor/autoload.php';
require_once __DIR__ . '/../lib/Enterprise/CliFramework.php';
use MokoEnterprise\CliFramework;
use MokoEnterprise\Config; use MokoEnterprise\Config;
use MokoEnterprise\PlatformAdapterFactory; use MokoEnterprise\PlatformAdapterFactory;
$dryRun = in_array('--dry-run', $argv); class ArchiveRepoCli extends CliFramework
$skipClose = in_array('--skip-close', $argv); {
protected function configure(): void
{
$this->setDescription('Gracefully retire a governed repository — archive, close issues/PRs, remove sync def');
$this->addArgument('--repo', 'Repository name to archive', '');
$this->addArgument('--skip-close', 'Archive only, keep issues open', false);
}
$repoName = null; protected function run(): int
{
$repoName = $this->getArgument('--repo');
$skipClose = $this->getArgument('--skip-close');
foreach ($argv as $i => $arg) { if (empty($repoName)) {
if ($arg === '--repo' && isset($argv[$i + 1])) { $repoName = $argv[$i + 1]; } $this->log('ERROR', 'Usage: php archive_repo.php --repo <RepoName> [--skip-close] [--dry-run]');
} return 2;
}
if (!$repoName) { $config = Config::load();
fwrite(STDERR, "Usage: php archive_repo.php --repo <RepoName> [--skip-close] [--dry-run]\n"); $adapter = PlatformAdapterFactory::create($config);
exit(2); $org = $config->getString(
}
$config = Config::load();
$adapter = PlatformAdapterFactory::create($config);
$org = $config->getString(
$adapter->getPlatformName() . '.organization', $adapter->getPlatformName() . '.organization',
'mokoconsulting-tech' 'mokoconsulting-tech'
); );
$repoRoot = dirname(__DIR__, 2); $platformName = $adapter->getPlatformName();
$platformName = $adapter->getPlatformName();
echo "Archiving repository: {$org}/{$repoName} (on {$platformName})\n\n"; echo "Archiving repository: {$org}/{$repoName} (on {$platformName})\n\n";
// ── Step 1: Verify repo exists ────────────────────────────────────────── // -- Step 1: Verify repo exists --
echo "Step 1: Verifying repository...\n"; echo "Step 1: Verifying repository...\n";
try { try {
$repoData = $adapter->getRepo($org, $repoName); $repoData = $adapter->getRepo($org, $repoName);
} catch (\Exception $e) { } catch (\Exception $e) {
fwrite(STDERR, " Repository {$org}/{$repoName} not found: " . $e->getMessage() . "\n"); $this->log('ERROR', "Repository {$org}/{$repoName} not found: " . $e->getMessage());
exit(1); return 1;
} }
if ($repoData['archived'] ?? false) { if ($repoData['archived'] ?? false) {
echo " Already archived — nothing to do\n"; echo " Already archived — nothing to do\n";
exit(0); return 0;
} }
echo " Found: " . ($repoData['html_url'] ?? "{$org}/{$repoName}") . "\n"; echo " Found: " . ($repoData['html_url'] ?? "{$org}/{$repoName}") . "\n";
// ── Step 2: Close all open PRs ────────────────────────────────────────── // -- Step 2: Close all open PRs --
if (!$skipClose) { if (!$skipClose) {
echo "Step 2: Closing open pull requests...\n"; echo "Step 2: Closing open pull requests...\n";
$prs = $adapter->listPullRequests($org, $repoName, ['state' => 'open']); $prs = $adapter->listPullRequests($org, $repoName, ['state' => 'open']);
$prCount = count($prs); $prCount = count($prs);
@@ -75,16 +77,19 @@ if (!$skipClose) {
foreach ($prs as $pr) { foreach ($prs as $pr) {
$num = $pr['number']; $num = $pr['number'];
if (!$dryRun) { if (!$this->dryRun) {
$adapter->updatePullRequest($org, $repoName, $num, ['state' => 'closed']); $adapter->updatePullRequest($org, $repoName, $num, ['state' => 'closed']);
$adapter->addIssueComment($org, $repoName, $num, $adapter->addIssueComment(
$org,
$repoName,
$num,
"Closed as part of repository archival. This repository is being retired.\n\n*Auto-closed by `archive_repo.php`*" "Closed as part of repository archival. This repository is being retired.\n\n*Auto-closed by `archive_repo.php`*"
); );
} }
echo " Closed PR #{$num}: {$pr['title']}\n"; echo " Closed PR #{$num}: {$pr['title']}\n";
} }
// ── Step 3: Close all open issues ─────────────────────────────────── // -- Step 3: Close all open issues --
echo "Step 3: Closing open issues...\n"; echo "Step 3: Closing open issues...\n";
$issues = $adapter->listIssues($org, $repoName, ['state' => 'open']); $issues = $adapter->listIssues($org, $repoName, ['state' => 'open']);
$issues = array_filter($issues, fn($i) => !isset($i['pull_request'])); $issues = array_filter($issues, fn($i) => !isset($i['pull_request']));
@@ -93,53 +98,72 @@ if (!$skipClose) {
foreach ($issues as $issue) { foreach ($issues as $issue) {
$num = $issue['number']; $num = $issue['number'];
if (!$dryRun) { if (!$this->dryRun) {
$adapter->closeIssue($org, $repoName, $num); $adapter->closeIssue($org, $repoName, $num);
$adapter->addIssueComment($org, $repoName, $num, $adapter->addIssueComment(
$org,
$repoName,
$num,
"Closed as part of repository archival.\n\n*Auto-closed by `archive_repo.php`*" "Closed as part of repository archival.\n\n*Auto-closed by `archive_repo.php`*"
); );
} }
echo " Closed issue #{$num}: {$issue['title']}\n"; echo " Closed issue #{$num}: {$issue['title']}\n";
} }
} else { } else {
echo "Step 2-3: Skipping issue/PR closure (--skip-close)\n"; echo "Step 2-3: Skipping issue/PR closure (--skip-close)\n";
} }
// ── Step 4: Archive the repository ────────────────────────────────────── // -- Step 4: Archive the repository --
echo "Step 4: Archiving repository...\n"; echo "Step 4: Archiving repository...\n";
if (!$dryRun) { if (!$this->dryRun) {
try { try {
$adapter->archiveRepo($org, $repoName); $adapter->archiveRepo($org, $repoName);
echo " Repository archived\n"; echo " Repository archived\n";
} catch (\Exception $e) { } catch (\Exception $e) {
echo " Failed to archive: " . $e->getMessage() . "\n"; echo " Failed to archive: " . $e->getMessage() . "\n";
} }
} else { } else {
echo " (dry-run) would archive {$org}/{$repoName}\n"; echo " (dry-run) would archive {$org}/{$repoName}\n";
} }
// ── Step 5: (removed — sync definitions no longer used) ───────────────── // -- Step 5: (removed — sync definitions no longer used) --
// ── Step 6: Create archival record ────────────────────────────────────── // -- Step 6: Create archival record --
echo "Step 6: Creating archival record...\n"; echo "Step 6: Creating archival record...\n";
if (!$dryRun) { if (!$this->dryRun) {
$now = gmdate('Y-m-d H:i:s') . ' UTC'; $now = gmdate('Y-m-d H:i:s') . ' UTC';
try { try {
$issue = $adapter->createIssue($org, 'MokoStandards', $issue = $adapter->createIssue(
$org,
'moko-platform',
"chore: archived repository {$repoName}", "chore: archived repository {$repoName}",
"## Repository Archived\n\n**Repository:** `{$org}/{$repoName}`\n**Archived:** {$now}\n**Platform:** {$platformName}\n**Sync definition removed:** yes\n\n---\n*Auto-created by `archive_repo.php`*\n", "## Repository Archived\n\n"
. "**Repository:** `{$org}/{$repoName}`\n"
. "**Archived:** {$now}\n"
. "**Platform:** {$platformName}\n"
. "**Sync definition removed:** yes\n\n"
. "---\n"
. "*Auto-created by `archive_repo.php`*\n",
[ [
'labels' => ['type: chore', 'automation', 'archived'], 'labels' => ['type: chore', 'automation', 'archived'],
'assignees' => ['jmiller'], 'assignees' => ['jmiller'],
] ]
); );
if (isset($issue['number'])) { echo " Archival record: MokoStandards#{$issue['number']}\n"; } if (isset($issue['number'])) {
echo " Archival record: moko-platform#{$issue['number']}\n";
}
} catch (\Exception $e) { } catch (\Exception $e) {
echo " Warning: could not create archival record: " . $e->getMessage() . "\n"; echo " Warning: could not create archival record: " . $e->getMessage() . "\n";
} }
} else { } else {
echo " (dry-run) would create archival record issue\n"; echo " (dry-run) would create archival record issue\n";
}
echo "\n" . str_repeat('-', 50) . "\n";
echo "Repository {$org}/{$repoName} archived successfully\n";
return 0;
}
} }
echo "\n" . str_repeat('-', 50) . "\n"; $app = new ArchiveRepoCli();
echo "Repository {$org}/{$repoName} archived successfully\n"; exit($app->execute());
+457
View File
@@ -0,0 +1,457 @@
#!/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
*
* This program is free software; you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation; either version 3 of the License, or
* (at your option) any later version.
*
* FILE INFORMATION
* DEFGROUP: MokoPlatform.Enterprise.CLI
* INGROUP: MokoPlatform.Enterprise
* REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform
* PATH: /cli/audit_query.php
* BRIEF: Search, filter, and export audit logs
*/
declare(strict_types=1);
require_once __DIR__ . '/../lib/Enterprise/CliFramework.php';
use MokoEnterprise\CliFramework;
/**
* CLI tool to search, filter, and export audit logs.
*
* Reads JSONL audit log files from var/logs/audit/ and provides
* filtering by service, user, event type, level, and date range.
*
* @since 09.01.00
*/
class AuditQueryCli extends CliFramework
{
protected function configure(): void
{
$this->setDescription('Search, filter, and export audit logs');
$this->addArgument('--path', 'Repository root (for var/logs/audit/)', '.');
$this->addArgument('--log-dir', 'Custom log directory', '');
$this->addArgument('--service', 'Filter by service name', '');
$this->addArgument('--user', 'Filter by user', '');
$this->addArgument('--event', 'Filter by event type', '');
$this->addArgument('--level', 'Filter by log level (info/warning/error)', '');
$this->addArgument('--since', 'Show entries since date (YYYY-MM-DD)', '');
$this->addArgument('--until', 'Show entries until date (YYYY-MM-DD)', '');
$this->addArgument('--limit', 'Max entries to show', '50');
$this->addArgument('--format', 'Output format: table, json, jsonl', 'table');
$this->addArgument('--tail', 'Show last N entries (like tail)', false);
$this->addArgument('--stats', 'Show summary statistics instead of entries', false);
}
protected function run(): int
{
$logDir = $this->resolveLogDir();
if ($logDir === null) {
return self::EXIT_NOT_FOUND;
}
$files = $this->findLogFiles($logDir);
if (empty($files)) {
$this->log('WARNING', 'No audit log files found in ' . $logDir);
return self::EXIT_SUCCESS;
}
$this->log('DEBUG', sprintf('Found %d log file(s) in %s', count($files), $logDir));
$entries = $this->loadEntries($files);
$entries = $this->filterEntries($entries);
// Sort by timestamp descending (newest first).
usort($entries, static function (array $a, array $b): int {
return ($b['timestamp'] ?? '') <=> ($a['timestamp'] ?? '');
});
// Stats mode — show aggregated counts.
if ($this->getArgument('--stats')) {
return $this->showStats($entries);
}
// Apply limit.
$limit = (int) $this->getArgument('--limit', '50');
if ($limit > 0 && count($entries) > $limit) {
$entries = array_slice($entries, 0, $limit);
}
if (empty($entries)) {
$this->log('INFO', 'No entries match the given filters.');
return self::EXIT_SUCCESS;
}
return $this->outputEntries($entries);
}
/**
* Resolve the audit log directory path.
*
* @return string|null Resolved directory path or null if not found.
*/
private function resolveLogDir(): ?string
{
$customDir = $this->getArgument('--log-dir');
if ($customDir !== '' && $customDir !== null) {
$logDir = (string) $customDir;
} else {
$repoPath = (string) $this->getArgument('--path', '.');
$logDir = rtrim($repoPath, '/\\') . '/var/logs/audit';
}
if (!is_dir($logDir)) {
$this->log('ERROR', 'Audit log directory not found: ' . $logDir);
return null;
}
return $logDir;
}
/**
* Find audit log files matching date range filter.
*
* @param string $logDir Path to audit log directory.
* @return string[] Array of file paths sorted by name.
*/
private function findLogFiles(string $logDir): array
{
$pattern = $logDir . '/audit_*.jsonl';
$allFiles = glob($pattern) ?: [];
$serviceFilter = (string) $this->getArgument('--service');
$sinceDate = (string) $this->getArgument('--since');
$untilDate = (string) $this->getArgument('--until');
$filtered = [];
foreach ($allFiles as $file) {
$basename = basename($file);
// Parse service and date from filename: audit_<service>_<YYYYMMDD>.jsonl
if (!preg_match('/^audit_(.+)_(\d{8})\.jsonl$/', $basename, $matches)) {
continue;
}
$fileService = $matches[1];
$fileDate = $matches[2];
// Filter by service name from filename (efficient pre-filter).
if ($serviceFilter !== '' && $fileService !== $serviceFilter) {
continue;
}
// Filter by date range from filename (efficient pre-filter).
if ($sinceDate !== '') {
$sinceCompact = str_replace('-', '', $sinceDate);
if ($fileDate < $sinceCompact) {
continue;
}
}
if ($untilDate !== '') {
$untilCompact = str_replace('-', '', $untilDate);
if ($fileDate > $untilCompact) {
continue;
}
}
$filtered[] = $file;
}
sort($filtered);
return $filtered;
}
/**
* Load and parse JSONL entries from log files.
*
* @param string[] $files Array of file paths.
* @return array<int, array<string, mixed>> Parsed entries.
*/
private function loadEntries(array $files): array
{
$entries = [];
$lineCount = 0;
foreach ($files as $file) {
$handle = fopen($file, 'r');
if ($handle === false) {
$this->log('WARNING', 'Cannot open file: ' . $file);
continue;
}
while (($line = fgets($handle)) !== false) {
$line = trim($line);
if ($line === '') {
continue;
}
$entry = json_decode($line, true);
if (!is_array($entry)) {
$lineCount++;
continue;
}
$entries[] = $entry;
$lineCount++;
}
fclose($handle);
}
$this->log('DEBUG', sprintf('Parsed %d entries from %d lines', count($entries), $lineCount));
return $entries;
}
/**
* Apply user/event/level/date filters to entries.
*
* @param array<int, array<string, mixed>> $entries Raw entries.
* @return array<int, array<string, mixed>> Filtered entries.
*/
private function filterEntries(array $entries): array
{
$userFilter = (string) $this->getArgument('--user');
$eventFilter = (string) $this->getArgument('--event');
$levelFilter = (string) $this->getArgument('--level');
$serviceFilter = (string) $this->getArgument('--service');
$sinceDate = (string) $this->getArgument('--since');
$untilDate = (string) $this->getArgument('--until');
$filtered = [];
foreach ($entries as $entry) {
// Filter by service (in case filename pre-filter was not exact).
if ($serviceFilter !== '' && ($entry['service'] ?? '') !== $serviceFilter) {
continue;
}
// Filter by user.
if ($userFilter !== '' && ($entry['user'] ?? '') !== $userFilter) {
continue;
}
// Filter by event type (matches event_type or event_subtype).
if ($eventFilter !== '') {
$eventType = $entry['event_type'] ?? '';
$eventSubtype = $entry['event_subtype'] ?? '';
if ($eventType !== $eventFilter && $eventSubtype !== $eventFilter) {
continue;
}
}
// Filter by level.
if ($levelFilter !== '' && ($entry['level'] ?? '') !== $levelFilter) {
continue;
}
// Filter by timestamp (precise, within-file filtering).
$timestamp = $entry['timestamp'] ?? '';
if ($timestamp !== '' && $sinceDate !== '') {
$entryDate = substr($timestamp, 0, 10); // YYYY-MM-DD from ISO 8601
if ($entryDate < $sinceDate) {
continue;
}
}
if ($timestamp !== '' && $untilDate !== '') {
$entryDate = substr($timestamp, 0, 10);
if ($entryDate > $untilDate) {
continue;
}
}
$filtered[] = $entry;
}
return $filtered;
}
/**
* Output entries in the requested format.
*
* @param array<int, array<string, mixed>> $entries Filtered entries.
* @return int Exit code.
*/
private function outputEntries(array $entries): int
{
$format = (string) $this->getArgument('--format', 'table');
$this->section('Audit Log Results');
$this->log('INFO', sprintf('Showing %d entries', count($entries)));
switch ($format) {
case 'json':
echo json_encode($entries, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES) . "\n";
break;
case 'jsonl':
foreach ($entries as $entry) {
echo json_encode($entry, JSON_UNESCAPED_SLASHES) . "\n";
}
break;
case 'table':
default:
$this->renderTable($entries);
break;
}
return self::EXIT_SUCCESS;
}
/**
* Render entries as a formatted table.
*
* @param array<int, array<string, mixed>> $entries Entries to display.
*/
private function renderTable(array $entries): void
{
$headers = ['Time', 'Service', 'User', 'Event', 'Message'];
$rows = [];
foreach ($entries as $entry) {
$timestamp = $entry['timestamp'] ?? '';
// Shorten timestamp to YYYY-MM-DD HH:MM:SS.
if (strlen($timestamp) >= 19) {
$time = substr($timestamp, 0, 19);
$time = str_replace('T', ' ', $time);
} else {
$time = $timestamp;
}
$service = $entry['service'] ?? '';
$user = $entry['user'] ?? '';
// Build event string from event_type + event_subtype.
$eventParts = [];
if (!empty($entry['event_type'])) {
$eventParts[] = $entry['event_type'];
}
if (!empty($entry['event_subtype'])) {
$eventParts[] = $entry['event_subtype'];
}
$event = implode('/', $eventParts);
// Build message from message field or data summary.
$message = $entry['message'] ?? '';
if ($message === '' && !empty($entry['data']) && is_array($entry['data'])) {
$dataParts = [];
foreach ($entry['data'] as $key => $value) {
if (is_scalar($value)) {
$dataParts[] = "{$key}={$value}";
}
}
$message = implode(', ', array_slice($dataParts, 0, 3));
if (count($dataParts) > 3) {
$message .= '...';
}
}
// Truncate long messages.
if (strlen($message) > 60) {
$message = substr($message, 0, 57) . '...';
}
$rows[] = [$time, $service, $user, $event, $message];
}
$this->table($headers, $rows);
}
/**
* Show aggregate statistics from filtered entries.
*
* @param array<int, array<string, mixed>> $entries Filtered entries.
* @return int Exit code.
*/
private function showStats(array $entries): int
{
$this->section('Audit Log Statistics');
$total = count($entries);
if ($total === 0) {
$this->log('INFO', 'No entries match the given filters.');
return self::EXIT_SUCCESS;
}
// Aggregate counts.
$byService = [];
$byUser = [];
$byEventType = [];
$byLevel = [];
foreach ($entries as $entry) {
$service = $entry['service'] ?? 'unknown';
$user = $entry['user'] ?? 'unknown';
$eventType = $entry['event_type'] ?? 'unknown';
$level = $entry['level'] ?? '-';
$byService[$service] = ($byService[$service] ?? 0) + 1;
$byUser[$user] = ($byUser[$user] ?? 0) + 1;
$byEventType[$eventType] = ($byEventType[$eventType] ?? 0) + 1;
$byLevel[$level] = ($byLevel[$level] ?? 0) + 1;
}
arsort($byService);
arsort($byUser);
arsort($byEventType);
arsort($byLevel);
// Build summary rows.
$rows = ['Total entries' => $total];
// Top services.
$i = 0;
foreach ($byService as $name => $count) {
if ($i >= 5) {
break;
}
$rows["Service: {$name}"] = $count;
$i++;
}
// Top users.
$i = 0;
foreach ($byUser as $name => $count) {
if ($i >= 5) {
break;
}
$rows["User: {$name}"] = $count;
$i++;
}
// Event types.
foreach ($byEventType as $name => $count) {
$rows["Event: {$name}"] = $count;
}
// Levels.
foreach ($byLevel as $name => $count) {
$rows["Level: {$name}"] = $count;
}
$this->printSummaryBox($rows);
return self::EXIT_SUCCESS;
}
}
$app = new AuditQueryCli();
exit($app->execute());
+38 -26
View File
@@ -1,5 +1,6 @@
#!/usr/bin/env php #!/usr/bin/env php
<?php <?php
/* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech> /* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
* *
* SPDX-License-Identifier: GPL-3.0-or-later * SPDX-License-Identifier: GPL-3.0-or-later
@@ -10,44 +11,48 @@
* REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform * REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform
* PATH: /cli/badge_update.php * PATH: /cli/badge_update.php
* BRIEF: Update [VERSION: XX.XX.XX] badges in all markdown files * BRIEF: Update [VERSION: XX.XX.XX] badges in all markdown files
*
* Usage:
* php badge_update.php --path /repo --version 04.01.00
*/ */
declare(strict_types=1); declare(strict_types=1);
$path = '.'; require_once __DIR__ . '/../lib/Enterprise/CliFramework.php';
$version = null;
foreach ($argv as $i => $arg) { use MokoEnterprise\CliFramework;
if ($arg === '--path' && isset($argv[$i + 1])) $path = $argv[$i + 1];
if ($arg === '--version' && isset($argv[$i + 1])) $version = $argv[$i + 1];
}
if ($version === null) { class BadgeUpdateCli extends CliFramework
fwrite(STDERR, "Usage: badge_update.php --path . --version XX.YY.ZZ\n"); {
exit(1); protected function configure(): void
} {
$this->setDescription('Update VERSION badges in all markdown files');
$this->addArgument('--path', 'Repository root path', '.');
$this->addArgument('--version', 'Version string XX.YY.ZZ', '');
}
$root = realpath($path) ?: $path; protected function run(): int
$pattern = '/\[VERSION:\s*\d{2}\.\d{2}\.\d{2}\]/'; {
$replacement = "[VERSION: {$version}]"; $path = $this->getArgument('--path');
$updated = 0; $version = $this->getArgument('--version');
$iterator = new RecursiveIteratorIterator( if (empty($version)) {
$this->log('ERROR', 'Usage: badge_update.php --path . --version XX.YY.ZZ');
return 1;
}
$root = realpath($path) ?: $path;
$pattern = '/\[VERSION:\s*\d{2}\.\d{2}\.\d{2}\]/';
$replacement = "[VERSION: {$version}]";
$updated = 0;
$iterator = new RecursiveIteratorIterator(
new RecursiveDirectoryIterator($root, RecursiveDirectoryIterator::SKIP_DOTS) new RecursiveDirectoryIterator($root, RecursiveDirectoryIterator::SKIP_DOTS)
); );
foreach ($iterator as $file) { foreach ($iterator as $file) {
$filePath = $file->getPathname(); $filePath = $file->getPathname();
// Skip .git and vendor directories
if (preg_match('#[/\\\\](\.git|vendor)[/\\\\]#', $filePath)) { if (preg_match('#[/\\\\](\.git|vendor)[/\\\\]#', $filePath)) {
continue; continue;
} }
// Only process markdown files
if (!preg_match('/\.md$/i', $filePath)) { if (!preg_match('/\.md$/i', $filePath)) {
continue; continue;
} }
@@ -56,13 +61,20 @@ foreach ($iterator as $file) {
if (preg_match($pattern, $content)) { if (preg_match($pattern, $content)) {
$newContent = preg_replace($pattern, $replacement, $content); $newContent = preg_replace($pattern, $replacement, $content);
if ($newContent !== $content) { if ($newContent !== $content) {
if (!$this->dryRun) {
file_put_contents($filePath, $newContent); file_put_contents($filePath, $newContent);
}
$relative = str_replace($root . DIRECTORY_SEPARATOR, '', $filePath); $relative = str_replace($root . DIRECTORY_SEPARATOR, '', $filePath);
echo "Updated: {$relative}\n"; $this->log('INFO', "Updated: {$relative}");
$updated++; $updated++;
} }
} }
}
$this->success("Updated {$updated} file(s) to {$replacement}");
return 0;
}
} }
echo "Updated {$updated} file(s) to {$replacement}\n"; $app = new BadgeUpdateCli();
exit(0); exit($app->execute());
+97 -87
View File
@@ -1,5 +1,6 @@
#!/usr/bin/env php #!/usr/bin/env php
<?php <?php
/* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech> /* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
* *
* SPDX-License-Identifier: GPL-3.0-or-later * SPDX-License-Identifier: GPL-3.0-or-later
@@ -9,53 +10,116 @@
* INGROUP: moko-platform * INGROUP: moko-platform
* REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform * REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform
* PATH: /cli/branch_rename.php * PATH: /cli/branch_rename.php
* VERSION: 09.21.00 * VERSION: 09.24.00
* BRIEF: Rename a git branch via Gitea API (create new, update PR, delete old) * BRIEF: Rename a git branch via Gitea API (create new, update PR, delete old)
*
* Usage:
* php branch_rename.php --from dev --to rc --token TOKEN --api-base URL [--pr 42]
* php branch_rename.php --from dev --to rc --token TOKEN --api-base URL --pr 42 --dry-run
*/ */
declare(strict_types=1); declare(strict_types=1);
$from = ''; require_once __DIR__ . '/../lib/Enterprise/CliFramework.php';
$to = '';
$token = '';
$apiBase = '';
$prNum = '';
$dryRun = false;
foreach ($argv as $i => $arg) { use MokoEnterprise\CliFramework;
if ($arg === '--from' && isset($argv[$i + 1])) $from = $argv[$i + 1];
if ($arg === '--to' && isset($argv[$i + 1])) $to = $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 === '--pr' && isset($argv[$i + 1])) $prNum = $argv[$i + 1];
if ($arg === '--dry-run') $dryRun = true;
}
if (empty($from) || empty($to) || empty($token) || empty($apiBase)) { class BranchRenameCli extends CliFramework
fwrite(STDERR, "Usage: branch_rename.php --from BRANCH --to BRANCH --token TOKEN --api-base URL [--pr NUM] [--dry-run]\n"); {
exit(1); protected function configure(): void
} {
$this->setDescription('Rename a git branch via Gitea API (create new, update PR, delete old)');
$this->addArgument('--from', 'Source branch name', '');
$this->addArgument('--to', 'Target branch name', '');
$this->addArgument('--token', 'API token', '');
$this->addArgument('--api-base', 'API base URL', '');
$this->addArgument('--pr', 'PR number to update head branch', '');
}
if ($from === $to) { protected function run(): int
{
$from = $this->getArgument('--from');
$to = $this->getArgument('--to');
$token = $this->getArgument('--token');
$apiBase = $this->getArgument('--api-base');
$prNum = $this->getArgument('--pr');
if (empty($from) || empty($to) || empty($token) || empty($apiBase)) {
$this->log('ERROR', 'Usage: branch_rename.php --from BRANCH --to BRANCH --token TOKEN --api-base URL [--pr NUM] [--dry-run]');
return 1;
}
if ($from === $to) {
echo "Source and target are the same ({$from}) — nothing to do\n"; echo "Source and target are the same ({$from}) — nothing to do\n";
exit(0); return 0;
} }
$headers = [ $headers = [
"Authorization: token {$token}", "Authorization: token {$token}",
'Content-Type: application/json', 'Content-Type: application/json',
'Accept: application/json', 'Accept: application/json',
]; ];
/** // Step 1: Verify source branch exists
echo "Checking source branch: {$from}\n";
$check = $this->apiRequest('GET', "{$apiBase}/branches/{$from}", $headers);
if ($check['code'] !== 200) {
$this->log('ERROR', "Source branch '{$from}' not found (HTTP {$check['code']})");
return 1;
}
// Step 2: Delete target branch if it already exists
$targetCheck = $this->apiRequest('GET', "{$apiBase}/branches/{$to}", $headers);
if ($targetCheck['code'] === 200) {
echo "Target branch '{$to}' already exists — deleting\n";
if (!$this->dryRun) {
$this->apiRequest('DELETE', "{$apiBase}/branches/{$to}", $headers);
}
}
// Step 3: Create new branch from source
echo "Creating branch: {$to} (from {$from})\n";
if (!$this->dryRun) {
$create = $this->apiRequest('POST', "{$apiBase}/branches", $headers, [
'new_branch_name' => $to,
'old_branch_name' => $from,
]);
if ($create['code'] < 200 || $create['code'] >= 300) {
$this->log('ERROR', "Failed to create branch '{$to}': HTTP {$create['code']}");
$this->log('ERROR', json_encode($create['body']));
return 1;
}
}
// Step 4: Update PR head branch if PR number provided
if (!empty($prNum)) {
echo "Updating PR #{$prNum} head branch: {$from} -> {$to}\n";
if (!$this->dryRun) {
$update = $this->apiRequest('PATCH', "{$apiBase}/pulls/{$prNum}", $headers, [
'head' => $to,
]);
if ($update['code'] < 200 || $update['code'] >= 300) {
$this->log('ERROR', "Warning: Could not update PR head branch (HTTP {$update['code']})");
// Non-fatal — the PR may need manual update
}
}
}
// Step 5: Delete old source branch
echo "Deleting old branch: {$from}\n";
if (!$this->dryRun) {
$delete = $this->apiRequest('DELETE', "{$apiBase}/branches/{$from}", $headers);
if ($delete['code'] !== 204 && $delete['code'] !== 200) {
$this->log('ERROR', "Warning: Could not delete old branch '{$from}' (HTTP {$delete['code']})");
// Non-fatal — branch protection may prevent deletion
}
}
echo "Renamed: {$from} -> {$to}\n";
return 0;
}
/**
* Make an API request. * Make an API request.
*/ */
function apiRequest(string $method, string $url, array $headers, ?array $body = null): array private function apiRequest(string $method, string $url, array $headers, ?array $body = null): array
{ {
$ch = curl_init(); $ch = curl_init();
curl_setopt_array($ch, [ curl_setopt_array($ch, [
CURLOPT_URL => $url, CURLOPT_URL => $url,
@@ -77,62 +141,8 @@ function apiRequest(string $method, string $url, array $headers, ?array $body =
'code' => $httpCode, 'code' => $httpCode,
'body' => json_decode($response ?: '{}', true) ?: [], 'body' => json_decode($response ?: '{}', true) ?: [],
]; ];
}
// Step 1: Verify source branch exists
echo "Checking source branch: {$from}\n";
$check = apiRequest('GET', "{$apiBase}/branches/{$from}", $headers);
if ($check['code'] !== 200) {
fwrite(STDERR, "Source branch '{$from}' not found (HTTP {$check['code']})\n");
exit(1);
}
// Step 2: Delete target branch if it already exists
$targetCheck = apiRequest('GET', "{$apiBase}/branches/{$to}", $headers);
if ($targetCheck['code'] === 200) {
echo "Target branch '{$to}' already exists — deleting\n";
if (!$dryRun) {
apiRequest('DELETE', "{$apiBase}/branches/{$to}", $headers);
} }
} }
// Step 3: Create new branch from source $app = new BranchRenameCli();
echo "Creating branch: {$to} (from {$from})\n"; exit($app->execute());
if (!$dryRun) {
$create = apiRequest('POST', "{$apiBase}/branches", $headers, [
'new_branch_name' => $to,
'old_branch_name' => $from,
]);
if ($create['code'] < 200 || $create['code'] >= 300) {
fwrite(STDERR, "Failed to create branch '{$to}': HTTP {$create['code']}\n");
fwrite(STDERR, json_encode($create['body']) . "\n");
exit(1);
}
}
// Step 4: Update PR head branch if PR number provided
if (!empty($prNum)) {
echo "Updating PR #{$prNum} head branch: {$from} -> {$to}\n";
if (!$dryRun) {
$update = apiRequest('PATCH', "{$apiBase}/pulls/{$prNum}", $headers, [
'head' => $to,
]);
if ($update['code'] < 200 || $update['code'] >= 300) {
fwrite(STDERR, "Warning: Could not update PR head branch (HTTP {$update['code']})\n");
// Non-fatal — the PR may need manual update
}
}
}
// Step 5: Delete old source branch
echo "Deleting old branch: {$from}\n";
if (!$dryRun) {
$delete = apiRequest('DELETE', "{$apiBase}/branches/{$from}", $headers);
if ($delete['code'] !== 204 && $delete['code'] !== 200) {
fwrite(STDERR, "Warning: Could not delete old branch '{$from}' (HTTP {$delete['code']})\n");
// Non-fatal — branch protection may prevent deletion
}
}
echo "Renamed: {$from} -> {$to}\n";
exit(0);
+87 -165
View File
@@ -12,110 +12,125 @@
* INGROUP: moko-platform * INGROUP: moko-platform
* REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform * REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform
* PATH: /cli/bulk_workflow_push.php * PATH: /cli/bulk_workflow_push.php
* VERSION: 09.21.00 * VERSION: 09.24.00
* BRIEF: Push a workflow file to all governed repos via the Gitea Contents API * BRIEF: Push a workflow file to all governed repos via the Gitea Contents API
*/ */
declare(strict_types=1); declare(strict_types=1);
final class BulkWorkflowPush require_once __DIR__ . '/../lib/Enterprise/CliFramework.php';
{
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;
use MokoEnterprise\CliFramework;
class BulkWorkflowPushCli extends CliFramework
{
private int $updated = 0; private int $updated = 0;
private int $created = 0; private int $created = 0;
private int $skipped = 0; private int $skipped = 0;
private int $errors = 0; private int $errors = 0;
public function run(): int protected function configure(): void
{ {
$this->parseArgs(); $this->setDescription('Push a workflow file to all governed repos via the Gitea Contents API');
$this->addArgument('--gitea-url', 'Gitea URL (default: https://git.mokoconsulting.tech)', 'https://git.mokoconsulting.tech');
$this->addArgument('--token', 'Gitea API token', '');
$this->addArgument('--org', 'Target organization', '');
$this->addArgument('--file', 'Local workflow file to push', '');
$this->addArgument('--dest', 'Destination path in repos (default: .mokogitea/workflows/<filename>)', '');
$this->addArgument('--branch', 'Target branch (default: main)', 'main');
}
if ($this->token === '') { protected function run(): int
$this->log('ERROR: --token is required.'); {
$this->printUsage(); $giteaUrl = rtrim($this->getArgument('--gitea-url'), '/');
$token = $this->getArgument('--token');
$org = $this->getArgument('--org');
$workflowFile = $this->getArgument('--file');
$destPath = $this->getArgument('--dest');
$branch = $this->getArgument('--branch');
if ($token === '') {
$this->log('ERROR', '--token is required.');
return 1; return 1;
} }
if ($this->workflowFile === '') { if ($workflowFile === '') {
$this->log('ERROR: --file is required.'); $this->log('ERROR', '--file is required.');
$this->printUsage();
return 1; return 1;
} }
if (!file_exists($this->workflowFile)) { if (!file_exists($workflowFile)) {
$this->log("ERROR: File not found: {$this->workflowFile}"); $this->log('ERROR', "File not found: {$workflowFile}");
return 1; return 1;
} }
if ($this->org === '') { if ($org === '') {
$this->log('ERROR: --org is required.'); $this->log('ERROR', '--org is required.');
$this->printUsage();
return 1; return 1;
} }
if ($this->destPath === '') { if ($destPath === '') {
$this->destPath = '.mokogitea/workflows/' . basename($this->workflowFile); $destPath = '.mokogitea/workflows/' . basename($workflowFile);
} }
$localContent = file_get_contents($this->workflowFile); $localContent = file_get_contents($workflowFile);
if ($localContent === false) { if ($localContent === false) {
$this->log("ERROR: Could not read file: {$this->workflowFile}"); $this->log('ERROR', "Could not read file: {$workflowFile}");
return 1; return 1;
} }
$this->log("Pushing: {$this->workflowFile}"); $this->log('INFO', "Pushing: {$workflowFile}");
$this->log(" -> {$this->destPath} (branch: {$this->branch})"); $this->log('INFO', " -> {$destPath} (branch: {$branch})");
$this->log(" -> Org: {$this->org} @ {$this->giteaUrl}"); $this->log('INFO', " -> Org: {$org} @ {$giteaUrl}");
if ($this->dryRun) { if ($this->dryRun) {
$this->log('[DRY RUN] No changes will be made.'); $this->log('INFO', '[DRY RUN] No changes will be made.');
} }
$this->log(''); echo "\n";
$repos = $this->fetchOrgRepos(); $repos = $this->fetchOrgRepos($giteaUrl, $token, $org);
if ($repos === null) { if ($repos === null) {
return 1; return 1;
} }
$this->log("Found " . count($repos) . " repo(s) in \"{$this->org}\"."); $this->log('INFO', "Found " . count($repos) . " repo(s) in \"{$org}\".");
$this->log(''); echo "\n";
$this->log(sprintf('%-45s | %s', 'Repo', 'Status')); fprintf(STDERR, "%-45s | %s\n", 'Repo', 'Status');
$this->log(str_repeat('-', 70)); fprintf(STDERR, "%s\n", str_repeat('-', 70));
$encodedContent = base64_encode($localContent); $encodedContent = base64_encode($localContent);
foreach ($repos as $repo) { foreach ($repos as $repo) {
$this->pushToRepo($repo, $encodedContent, $localContent); $this->pushToRepo($giteaUrl, $token, $repo, $encodedContent, $localContent, $destPath, $branch);
} }
$this->log(''); echo "\n";
$this->log("Done: {$this->created} created, {$this->updated} updated, " $this->log('INFO', "Done: {$this->created} created, {$this->updated} updated, "
. "{$this->skipped} skipped, {$this->errors} error(s)."); . "{$this->skipped} skipped, {$this->errors} error(s).");
return $this->errors > 0 ? 1 : 0; return $this->errors > 0 ? 1 : 0;
} }
private function pushToRepo( private function pushToRepo(
string $giteaUrl,
string $token,
string $repoFullName, string $repoFullName,
string $encodedContent, string $encodedContent,
string $localContent string $localContent,
string $destPath,
string $branch
): void { ): void {
[$owner, $repoName] = explode('/', $repoFullName, 2); [$owner, $repoName] = explode('/', $repoFullName, 2);
$existing = $this->apiRequest( $existing = $this->apiRequest(
$giteaUrl,
$token,
'GET', 'GET',
"/api/v1/repos/{$owner}/{$repoName}/contents/" "/api/v1/repos/{$owner}/{$repoName}/contents/"
. "{$this->destPath}?ref={$this->branch}" . "{$destPath}?ref={$branch}"
); );
if ($existing['code'] === 200) { if ($existing['code'] === 200) {
@@ -124,21 +139,13 @@ final class BulkWorkflowPush
$remoteContent = base64_decode($data['content'] ?? ''); $remoteContent = base64_decode($data['content'] ?? '');
if ($remoteContent === $localContent) { if ($remoteContent === $localContent) {
$this->log(sprintf( fprintf(STDERR, "%-45s | %s\n", $repoFullName, 'IDENTICAL (skipped)');
'%-45s | %s',
$repoFullName,
'IDENTICAL (skipped)'
));
$this->skipped++; $this->skipped++;
return; return;
} }
if ($this->dryRun) { if ($this->dryRun) {
$this->log(sprintf( fprintf(STDERR, "%-45s | %s\n", $repoFullName, 'WOULD UPDATE');
'%-45s | %s',
$repoFullName,
'WOULD UPDATE'
));
$this->updated++; $this->updated++;
return; return;
} }
@@ -146,100 +153,82 @@ final class BulkWorkflowPush
$payload = json_encode([ $payload = json_encode([
'content' => $encodedContent, 'content' => $encodedContent,
'sha' => $remoteSha, 'sha' => $remoteSha,
'message' => "chore: sync {$this->destPath} " 'message' => "chore: sync {$destPath} "
. "from moko-platform [skip ci]", . "from moko-platform [skip ci]",
'branch' => $this->branch, 'branch' => $branch,
]); ]);
$response = $this->apiRequest( $response = $this->apiRequest(
$giteaUrl,
$token,
'PUT', 'PUT',
"/api/v1/repos/{$owner}/{$repoName}/contents/" "/api/v1/repos/{$owner}/{$repoName}/contents/"
. $this->destPath, . $destPath,
$payload $payload
); );
if ($response['code'] === 200) { if ($response['code'] === 200) {
$this->log(sprintf( fprintf(STDERR, "%-45s | %s\n", $repoFullName, 'UPDATED');
'%-45s | %s',
$repoFullName,
'UPDATED'
));
$this->updated++; $this->updated++;
} else { } else {
$this->log(sprintf( fprintf(STDERR, "%-45s | %s\n", $repoFullName, "ERROR (HTTP {$response['code']})");
'%-45s | %s',
$repoFullName,
"ERROR (HTTP {$response['code']})"
));
$this->errors++; $this->errors++;
} }
} elseif ($existing['code'] === 404) { } elseif ($existing['code'] === 404) {
if ($this->dryRun) { if ($this->dryRun) {
$this->log(sprintf( fprintf(STDERR, "%-45s | %s\n", $repoFullName, 'WOULD CREATE');
'%-45s | %s',
$repoFullName,
'WOULD CREATE'
));
$this->created++; $this->created++;
return; return;
} }
$payload = json_encode([ $payload = json_encode([
'content' => $encodedContent, 'content' => $encodedContent,
'message' => "chore: add {$this->destPath} " 'message' => "chore: add {$destPath} "
. "from moko-platform [skip ci]", . "from moko-platform [skip ci]",
'branch' => $this->branch, 'branch' => $branch,
]); ]);
$response = $this->apiRequest( $response = $this->apiRequest(
$giteaUrl,
$token,
'POST', 'POST',
"/api/v1/repos/{$owner}/{$repoName}/contents/" "/api/v1/repos/{$owner}/{$repoName}/contents/"
. $this->destPath, . $destPath,
$payload $payload
); );
if ($response['code'] === 201) { if ($response['code'] === 201) {
$this->log(sprintf( fprintf(STDERR, "%-45s | %s\n", $repoFullName, 'CREATED');
'%-45s | %s',
$repoFullName,
'CREATED'
));
$this->created++; $this->created++;
} else { } else {
$this->log(sprintf( fprintf(STDERR, "%-45s | %s\n", $repoFullName, "ERROR (HTTP {$response['code']})");
'%-45s | %s',
$repoFullName,
"ERROR (HTTP {$response['code']})"
));
$this->errors++; $this->errors++;
} }
} else { } else {
$this->log(sprintf( fprintf(STDERR, "%-45s | %s\n", $repoFullName, "ERROR (HTTP {$existing['code']})");
'%-45s | %s',
$repoFullName,
"ERROR (HTTP {$existing['code']})"
));
$this->errors++; $this->errors++;
} }
} }
private function fetchOrgRepos(): ?array private function fetchOrgRepos(string $giteaUrl, string $token, string $org): ?array
{ {
$this->log("Fetching repos from org: {$this->org}"); $this->log('INFO', "Fetching repos from org: {$org}");
$page = 1; $page = 1;
$repos = []; $repos = [];
while (true) { while (true) {
$response = $this->apiRequest( $response = $this->apiRequest(
$giteaUrl,
$token,
'GET', 'GET',
"/api/v1/orgs/{$this->org}/repos?" "/api/v1/orgs/{$org}/repos?"
. "limit=50&page={$page}" . "limit=50&page={$page}"
); );
if ($response['code'] < 200 || $response['code'] >= 300) { if ($response['code'] < 200 || $response['code'] >= 300) {
if ($page === 1) { if ($page === 1) {
$this->log("ERROR: Could not fetch repos " $this->log('ERROR', "Could not fetch repos "
. "(HTTP {$response['code']})."); . "(HTTP {$response['code']}).");
return null; return null;
} }
@@ -271,76 +260,14 @@ final class BulkWorkflowPush
return $repos; 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( private function apiRequest(
string $giteaUrl,
string $token,
string $method, string $method,
string $endpoint, string $endpoint,
?string $body = null ?string $body = null
): array { ): array {
$url = $this->giteaUrl . $endpoint; $url = $giteaUrl . $endpoint;
$ch = curl_init(); $ch = curl_init();
curl_setopt($ch, CURLOPT_URL, $url); curl_setopt($ch, CURLOPT_URL, $url);
@@ -349,7 +276,7 @@ final class BulkWorkflowPush
curl_setopt($ch, CURLOPT_HTTPHEADER, [ curl_setopt($ch, CURLOPT_HTTPHEADER, [
'Content-Type: application/json', 'Content-Type: application/json',
'Accept: application/json', 'Accept: application/json',
"Authorization: token {$this->token}", "Authorization: token {$token}",
]); ]);
if ($body !== null) { if ($body !== null) {
@@ -376,12 +303,7 @@ final class BulkWorkflowPush
return ['code' => $httpCode, 'body' => $responseBody]; return ['code' => $httpCode, 'body' => $responseBody];
} }
private function log(string $message): void
{
fwrite(STDERR, $message . PHP_EOL);
}
} }
$app = new BulkWorkflowPush(); $app = new BulkWorkflowPushCli();
exit($app->run()); exit($app->execute());
+72 -146
View File
@@ -1,5 +1,6 @@
#!/usr/bin/env php #!/usr/bin/env php
<?php <?php
/* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech> /* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
* *
* This file is part of a Moko Consulting project. * This file is part of a Moko Consulting project.
@@ -11,13 +12,17 @@
* INGROUP: moko-platform * INGROUP: moko-platform
* REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform * REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform
* PATH: /cli/bulk_workflow_trigger.php * PATH: /cli/bulk_workflow_trigger.php
* VERSION: 09.21.00 * VERSION: 09.24.00
* BRIEF: Trigger a workflow across multiple repos at once * BRIEF: Trigger a workflow across multiple repos at once
*/ */
declare(strict_types=1); declare(strict_types=1);
final class BulkWorkflowTrigger require_once __DIR__ . '/../lib/Enterprise/CliFramework.php';
use MokoEnterprise\CliFramework;
class BulkWorkflowTriggerCli extends CliFramework
{ {
private string $giteaUrl = 'https://git.mokoconsulting.tech'; private string $giteaUrl = 'https://git.mokoconsulting.tech';
private string $token = ''; private string $token = '';
@@ -26,93 +31,96 @@ final class BulkWorkflowTrigger
private string $workflow = ''; private string $workflow = '';
private string $ref = 'main'; private string $ref = 'main';
private string $inputs = ''; private string $inputs = '';
private bool $dryRun = false;
public function run(): int protected function configure(): void
{ {
$this->parseArgs(); $this->setDescription('Trigger a workflow across multiple repos at once');
$this->addArgument('--gitea-url', 'Gitea URL (default: https://git.mokoconsulting.tech)', 'https://git.mokoconsulting.tech');
$this->addArgument('--token', 'Gitea API token', '');
$this->addArgument('--repos', 'File with newline-separated owner/repo list', '');
$this->addArgument('--org', 'Trigger on all repos in an org', '');
$this->addArgument('--workflow', 'Workflow file (e.g., "sync-servers.yml")', '');
$this->addArgument('--ref', 'Branch ref (default: "main")', 'main');
$this->addArgument('--inputs', 'Workflow inputs as JSON string', '');
}
if ($this->token === '') protected function run(): int
{ {
$this->log('ERROR: --token is required.'); $this->giteaUrl = rtrim($this->getArgument('--gitea-url'), '/');
$this->printUsage(); $this->token = $this->getArgument('--token');
$this->reposFile = $this->getArgument('--repos');
$this->org = $this->getArgument('--org');
$this->workflow = $this->getArgument('--workflow');
$this->ref = $this->getArgument('--ref');
$this->inputs = $this->getArgument('--inputs');
if ($this->token === '') {
$this->log('ERROR', '--token is required.');
return 1; return 1;
} }
if ($this->workflow === '') if ($this->workflow === '') {
{ $this->log('ERROR', '--workflow is required.');
$this->log('ERROR: --workflow is required.');
$this->printUsage();
return 1; return 1;
} }
if ($this->reposFile === '' && $this->org === '') if ($this->reposFile === '' && $this->org === '') {
{ $this->log('ERROR', 'Either --repos <file> or --org <org> is required.');
$this->log('ERROR: Either --repos <file> or --org <org> is required.');
$this->printUsage();
return 1; return 1;
} }
// Build repo list // Build repo list
$repos = $this->buildRepoList(); $repos = $this->buildRepoList();
if ($repos === null || count($repos) === 0) if ($repos === null || count($repos) === 0) {
{ $this->log('ERROR', 'No repos found to process.');
$this->log('ERROR: No repos found to process.');
return 1; return 1;
} }
$this->log("Triggering workflow \"{$this->workflow}\" on ref \"{$this->ref}\" across " . count($repos) . " repo(s)."); $this->log('INFO', "Triggering workflow \"{$this->workflow}\" on ref \"{$this->ref}\" across " . count($repos) . " repo(s).");
$this->log("Gitea URL: {$this->giteaUrl}"); $this->log('INFO', "Gitea URL: {$this->giteaUrl}");
if ($this->dryRun) if ($this->dryRun) {
{ $this->log('INFO', '[DRY RUN] No requests will be sent.');
$this->log('[DRY RUN] No requests will be sent.');
} }
$this->log(''); $this->log('INFO', '');
// Parse inputs // Parse inputs
$inputsDecoded = null; $inputsDecoded = null;
if ($this->inputs !== '') if ($this->inputs !== '') {
{
$inputsDecoded = json_decode($this->inputs, true); $inputsDecoded = json_decode($this->inputs, true);
if (!is_array($inputsDecoded)) if (!is_array($inputsDecoded)) {
{ $this->log('ERROR', '--inputs must be valid JSON.');
$this->log('ERROR: --inputs must be valid JSON.');
return 1; return 1;
} }
} }
// Print header // Print header
$this->log(sprintf('%-40s | %s', 'Repo', 'Status')); $this->log('INFO', sprintf('%-40s | %s', 'Repo', 'Status'));
$this->log(str_repeat('-', 60)); $this->log('INFO', str_repeat('-', 60));
$failCount = 0; $failCount = 0;
foreach ($repos as $repo) foreach ($repos as $repo) {
{
$repo = trim($repo); $repo = trim($repo);
if ($repo === '' || strpos($repo, '/') === false) if ($repo === '' || strpos($repo, '/') === false) {
{
continue; continue;
} }
[$owner, $repoName] = explode('/', $repo, 2); [$owner, $repoName] = explode('/', $repo, 2);
if ($this->dryRun) if ($this->dryRun) {
{ $this->log('INFO', sprintf('%-40s | %s', $repo, 'DRY RUN (skipped)'));
$this->log(sprintf('%-40s | %s', $repo, 'DRY RUN (skipped)'));
continue; continue;
} }
$payload = ['ref' => $this->ref]; $payload = ['ref' => $this->ref];
if ($inputsDecoded !== null) if ($inputsDecoded !== null) {
{
$payload['inputs'] = $inputsDecoded; $payload['inputs'] = $inputsDecoded;
} }
@@ -122,101 +130,32 @@ final class BulkWorkflowTrigger
json_encode($payload) json_encode($payload)
); );
if ($response['code'] >= 200 && $response['code'] < 300) if ($response['code'] >= 200 && $response['code'] < 300) {
{
$status = 'TRIGGERED'; $status = 'TRIGGERED';
} } elseif ($response['code'] === 404) {
elseif ($response['code'] === 404)
{
$status = 'FAILED (not found)'; $status = 'FAILED (not found)';
$failCount++; $failCount++;
} } elseif ($response['code'] === 422) {
elseif ($response['code'] === 422)
{
$status = 'SKIPPED (unprocessable)'; $status = 'SKIPPED (unprocessable)';
} } else {
else
{
$status = "FAILED (HTTP {$response['code']})"; $status = "FAILED (HTTP {$response['code']})";
$failCount++; $failCount++;
} }
$this->log(sprintf('%-40s | %s', $repo, $status)); $this->log('INFO', sprintf('%-40s | %s', $repo, $status));
} }
$this->log(''); $this->log('INFO', '');
$this->log('Done. ' . ($failCount > 0 ? "{$failCount} failure(s)." : 'All succeeded.')); $this->log('INFO', 'Done. ' . ($failCount > 0 ? "{$failCount} failure(s)." : 'All succeeded.'));
return $failCount > 0 ? 1 : 0; return $failCount > 0 ? 1 : 0;
} }
private function parseArgs(): void
{
$args = $_SERVER['argv'] ?? [];
$count = count($args);
for ($i = 1; $i < $count; $i++)
{
switch ($args[$i])
{
case '--gitea-url':
$this->giteaUrl = rtrim($args[++$i] ?? '', '/');
break;
case '--token':
$this->token = $args[++$i] ?? '';
break;
case '--repos':
$this->reposFile = $args[++$i] ?? '';
break;
case '--org':
$this->org = $args[++$i] ?? '';
break;
case '--workflow':
$this->workflow = $args[++$i] ?? '';
break;
case '--ref':
$this->ref = $args[++$i] ?? 'main';
break;
case '--inputs':
$this->inputs = $args[++$i] ?? '';
break;
case '--dry-run':
$this->dryRun = true;
break;
case '--help':
case '-h':
$this->printUsage();
exit(0);
default:
$this->log("WARNING: Unknown argument: {$args[$i]}");
break;
}
}
}
private function printUsage(): void
{
$this->log('Usage: bulk_workflow_trigger.php --token <token> --workflow <file> [options]');
$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(' --repos <file> File with newline-separated owner/repo list');
$this->log(' --org <org> Trigger on all repos in an org');
$this->log(' --workflow <filename> Workflow file (e.g., "sync-servers.yml")');
$this->log(' --ref <branch> Branch ref (default: "main")');
$this->log(' --inputs <json> Workflow inputs as JSON string');
$this->log(' --dry-run Show what would be done without triggering');
$this->log(' --help, -h Show this help');
}
private function buildRepoList(): ?array private function buildRepoList(): ?array
{ {
if ($this->reposFile !== '') if ($this->reposFile !== '') {
{ if (!file_exists($this->reposFile)) {
if (!file_exists($this->reposFile)) $this->log('ERROR', "Repos file not found: {$this->reposFile}");
{
$this->log("ERROR: Repos file not found: {$this->reposFile}");
return null; return null;
} }
@@ -229,20 +168,17 @@ final class BulkWorkflowTrigger
} }
// Fetch all repos from org // Fetch all repos from org
$this->log("Fetching repos from org: {$this->org}"); $this->log('INFO', "Fetching repos from org: {$this->org}");
$page = 1; $page = 1;
$repos = []; $repos = [];
while (true) while (true) {
{
$response = $this->apiRequest('GET', "/api/v1/orgs/{$this->org}/repos?limit=50&page={$page}"); $response = $this->apiRequest('GET', "/api/v1/orgs/{$this->org}/repos?limit=50&page={$page}");
if ($response['code'] < 200 || $response['code'] >= 300) if ($response['code'] < 200 || $response['code'] >= 300) {
{ if ($page === 1) {
if ($page === 1) $this->log('ERROR', "Could not fetch repos for org (HTTP {$response['code']}).");
{
$this->log("ERROR: Could not fetch repos for org (HTTP {$response['code']}).");
return null; return null;
} }
@@ -251,17 +187,14 @@ final class BulkWorkflowTrigger
$data = json_decode($response['body'], true); $data = json_decode($response['body'], true);
if (!is_array($data) || count($data) === 0) if (!is_array($data) || count($data) === 0) {
{
break; break;
} }
foreach ($data as $repo) foreach ($data as $repo) {
{
$fullName = $repo['full_name'] ?? ''; $fullName = $repo['full_name'] ?? '';
if ($fullName !== '') if ($fullName !== '') {
{
$repos[] = $fullName; $repos[] = $fullName;
} }
} }
@@ -269,7 +202,7 @@ final class BulkWorkflowTrigger
$page++; $page++;
} }
$this->log('Found ' . count($repos) . " repo(s) in org \"{$this->org}\"."); $this->log('INFO', 'Found ' . count($repos) . " repo(s) in org \"{$this->org}\".");
return $repos; return $repos;
} }
@@ -288,16 +221,14 @@ final class BulkWorkflowTrigger
"Authorization: token {$this->token}", "Authorization: token {$this->token}",
]); ]);
if ($body !== null) if ($body !== null) {
{
curl_setopt($ch, CURLOPT_POSTFIELDS, $body); curl_setopt($ch, CURLOPT_POSTFIELDS, $body);
} }
$responseBody = curl_exec($ch); $responseBody = curl_exec($ch);
$httpCode = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE); $httpCode = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE);
if (curl_errno($ch)) if (curl_errno($ch)) {
{
$error = curl_error($ch); $error = curl_error($ch);
curl_close($ch); curl_close($ch);
@@ -308,12 +239,7 @@ final class BulkWorkflowTrigger
return ['code' => $httpCode, 'body' => $responseBody]; return ['code' => $httpCode, 'body' => $responseBody];
} }
private function log(string $message): void
{
fwrite(STDERR, $message . PHP_EOL);
}
} }
$app = new BulkWorkflowTrigger(); $app = new BulkWorkflowTriggerCli();
exit($app->run()); exit($app->execute());
+52 -41
View File
@@ -1,5 +1,6 @@
#!/usr/bin/env php #!/usr/bin/env php
<?php <?php
/* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech> /* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
* *
* SPDX-License-Identifier: GPL-3.0-or-later * SPDX-License-Identifier: GPL-3.0-or-later
@@ -10,63 +11,68 @@
* REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform * REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform
* PATH: /cli/changelog_promote.php * PATH: /cli/changelog_promote.php
* BRIEF: Promote [Unreleased] section in CHANGELOG.md to a versioned entry * BRIEF: Promote [Unreleased] section in CHANGELOG.md to a versioned entry
*
* Usage:
* php changelog_promote.php --path /repo --version 04.01.00
* php changelog_promote.php --path /repo --version 04.01.00 --date 2026-05-21
*/ */
declare(strict_types=1); declare(strict_types=1);
$path = '.'; require_once __DIR__ . '/../lib/Enterprise/CliFramework.php';
$version = null;
$date = date('Y-m-d');
foreach ($argv as $i => $arg) { use MokoEnterprise\CliFramework;
if ($arg === '--path' && isset($argv[$i + 1])) $path = $argv[$i + 1];
if ($arg === '--version' && isset($argv[$i + 1])) $version = $argv[$i + 1];
if ($arg === '--date' && isset($argv[$i + 1])) $date = $argv[$i + 1];
}
if ($version === null) { class ChangelogPromoteCli extends CliFramework
fwrite(STDERR, "Usage: changelog_promote.php --path . --version XX.YY.ZZ [--date YYYY-MM-DD]\n"); {
exit(1); protected function configure(): void
} {
$this->setDescription('Promote [Unreleased] CHANGELOG section to a versioned entry');
$this->addArgument('--path', 'Repository root path', '.');
$this->addArgument('--version', 'Version string XX.YY.ZZ', '');
$this->addArgument('--date', 'Release date YYYY-MM-DD', date('Y-m-d'));
}
$changelog = realpath($path) . '/CHANGELOG.md'; protected function run(): int
if (!file_exists($changelog)) { {
fwrite(STDERR, "No CHANGELOG.md found at {$path}\n"); $path = $this->getArgument('--path');
exit(1); $version = $this->getArgument('--version');
} $date = $this->getArgument('--date');
$content = file_get_contents($changelog); if (empty($version)) {
$this->log('ERROR', 'Usage: changelog_promote.php --path . --version XX.YY.ZZ [--date YYYY-MM-DD]');
return 1;
}
// Check if [Unreleased] section exists $changelog = realpath($path) . '/CHANGELOG.md';
if (!preg_match('/## \[?Unreleased\]?/i', $content)) { if (!file_exists($changelog)) {
fwrite(STDERR, "No [Unreleased] section found in CHANGELOG.md\n"); $this->log('ERROR', "No CHANGELOG.md found at {$path}");
exit(1); return 1;
} }
// Replace [Unreleased] with versioned entry $content = file_get_contents($changelog);
$content = preg_replace(
if (!preg_match('/## \[?Unreleased\]?/i', $content)) {
$this->log('ERROR', 'No [Unreleased] section found in CHANGELOG.md');
return 1;
}
// Replace [Unreleased] with versioned entry
$content = preg_replace(
'/## \[Unreleased\]/i', '/## \[Unreleased\]/i',
"## [{$version}] --- {$date}", "## [{$version}] --- {$date}",
$content, $content,
1 1
); );
$content = preg_replace( $content = preg_replace(
'/## Unreleased/i', '/## Unreleased/i',
"## [{$version}] --- {$date}", "## [{$version}] --- {$date}",
$content, $content,
1 1
); );
// Insert new [Unreleased] section after the first heading line (# Changelog) // Insert new [Unreleased] section after the first heading line
$lines = explode("\n", $content); $lines = explode("\n", $content);
$inserted = false; $inserted = false;
$result = []; $result = [];
foreach ($lines as $line) { foreach ($lines as $line) {
$result[] = $line; $result[] = $line;
if (!$inserted && preg_match('/^# /', $line)) { if (!$inserted && preg_match('/^# /', $line)) {
$result[] = ''; $result[] = '';
@@ -74,9 +80,14 @@ foreach ($lines as $line) {
$result[] = ''; $result[] = '';
$inserted = true; $inserted = true;
} }
}
$content = implode("\n", $result);
file_put_contents($changelog, $content);
$this->success("CHANGELOG promoted: [Unreleased] -> [{$version}] --- {$date}");
return 0;
}
} }
$content = implode("\n", $result); $app = new ChangelogPromoteCli();
file_put_contents($changelog, $content); exit($app->execute());
echo "CHANGELOG promoted: [Unreleased] -> [{$version}] --- {$date}\n";
exit(0);
+74 -73
View File
@@ -1,5 +1,6 @@
#!/usr/bin/env php #!/usr/bin/env php
<?php <?php
/* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech> /* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
* *
* SPDX-License-Identifier: GPL-3.0-or-later * SPDX-License-Identifier: GPL-3.0-or-later
@@ -10,48 +11,43 @@
* REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform * REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform
* PATH: /cli/changelog_prune.php * PATH: /cli/changelog_prune.php
* BRIEF: Prune old CHANGELOG.md entries — keeps [Unreleased] + last N releases * BRIEF: Prune old CHANGELOG.md entries — keeps [Unreleased] + last N releases
*
* Usage:
* php changelog_prune.php --path /repo --keep 5
* php changelog_prune.php --path /repo --keep 3 --dry-run
*/ */
declare(strict_types=1); declare(strict_types=1);
$path = '.'; require_once __DIR__ . '/../lib/Enterprise/CliFramework.php';
$keep = 5;
$dryRun = false;
foreach ($argv as $i => $arg) { use MokoEnterprise\CliFramework;
if ($arg === '--path' && isset($argv[$i + 1])) $path = $argv[$i + 1];
if ($arg === '--keep' && isset($argv[$i + 1])) $keep = (int)$argv[$i + 1]; class ChangelogPruneCli extends CliFramework
if ($arg === '--dry-run') $dryRun = true; {
if ($arg === '--help') { protected function configure(): void
echo "changelog_prune — Keep [Unreleased] + last N versioned entries\n\n"; {
echo "Usage: php changelog_prune.php --path . --keep 5 [--dry-run]\n\n"; $this->setDescription('Prune old CHANGELOG.md entries — keeps [Unreleased] + last N releases');
echo "Options:\n"; $this->addArgument('--path', 'Repository path', '.');
echo " --path Repository path (default: .)\n"; $this->addArgument('--keep', 'Number of versioned releases to keep', '5');
echo " --keep Number of versioned releases to keep (default: 5)\n";
echo " --dry-run Preview without writing\n";
exit(0);
} }
}
$changelog = realpath($path) . '/CHANGELOG.md'; protected function run(): int
if (!file_exists($changelog)) { {
fwrite(STDERR, "No CHANGELOG.md found at {$path}\n"); $path = $this->getArgument('--path');
exit(1); $keep = (int) $this->getArgument('--keep');
}
$content = file_get_contents($changelog); $changelog = realpath($path) . '/CHANGELOG.md';
$lines = explode("\n", $content); if (!file_exists($changelog)) {
$this->log('ERROR', "No CHANGELOG.md found at {$path}");
return 1;
}
// Split into sections by ## headings $content = file_get_contents($changelog);
$sections = []; $lines = explode("\n", $content);
$current = [];
$currentHeading = null;
foreach ($lines as $line) { // Split into sections by ## headings
$sections = [];
$current = [];
$currentHeading = null;
foreach ($lines as $line) {
if (preg_match('/^## /', $line)) { if (preg_match('/^## /', $line)) {
if ($currentHeading !== null) { if ($currentHeading !== null) {
$sections[] = ['heading' => $currentHeading, 'lines' => $current]; $sections[] = ['heading' => $currentHeading, 'lines' => $current];
@@ -61,74 +57,79 @@ foreach ($lines as $line) {
} else { } else {
$current[] = $line; $current[] = $line;
} }
} }
if ($currentHeading !== null) { if ($currentHeading !== null) {
$sections[] = ['heading' => $currentHeading, 'lines' => $current]; $sections[] = ['heading' => $currentHeading, 'lines' => $current];
} }
// Find the header (everything before the first ## section) // Find the header (everything before the first ## section)
$header = []; $header = [];
$contentLines = explode("\n", $content); $contentLines = explode("\n", $content);
foreach ($contentLines as $line) { foreach ($contentLines as $line) {
if (preg_match('/^## /', $line)) { if (preg_match('/^## /', $line)) {
break; break;
} }
$header[] = $line; $header[] = $line;
} }
// Separate [Unreleased] from versioned sections // Separate [Unreleased] from versioned sections
$unreleased = null; $unreleased = null;
$versioned = []; $versioned = [];
foreach ($sections as $section) { foreach ($sections as $section) {
if (preg_match('/\[Unreleased\]/i', $section['heading'])) { if (preg_match('/\[Unreleased\]/i', $section['heading'])) {
$unreleased = $section; $unreleased = $section;
} else { } else {
$versioned[] = $section; $versioned[] = $section;
} }
} }
$totalVersioned = count($versioned); $totalVersioned = count($versioned);
$pruned = $totalVersioned - $keep; $pruned = $totalVersioned - $keep;
if ($pruned <= 0) { if ($pruned <= 0) {
echo "CHANGELOG has {$totalVersioned} versioned entries — nothing to prune (keeping {$keep})\n"; echo "CHANGELOG has {$totalVersioned} versioned entries — nothing to prune (keeping {$keep})\n";
exit(0); return 0;
} }
// Keep only the first N versioned sections // Keep only the first N versioned sections
$keptVersioned = array_slice($versioned, 0, $keep); $keptVersioned = array_slice($versioned, 0, $keep);
$droppedVersioned = array_slice($versioned, $keep); $droppedVersioned = array_slice($versioned, $keep);
// Report // Report
echo "CHANGELOG: {$totalVersioned} versioned entries found\n"; echo "CHANGELOG: {$totalVersioned} versioned entries found\n";
echo " Keeping: {$keep} most recent\n"; echo " Keeping: {$keep} most recent\n";
echo " Pruning: {$pruned} old entries\n"; echo " Pruning: {$pruned} old entries\n";
foreach ($droppedVersioned as $section) { foreach ($droppedVersioned as $section) {
$heading = trim($section['heading']); $heading = trim($section['heading']);
echo " - {$heading}\n"; echo " - {$heading}\n";
} }
if ($dryRun) { if ($this->dryRun) {
echo "\n(dry-run) No changes written\n"; echo "\n(dry-run) No changes written\n";
exit(0); return 0;
} }
// Rebuild the file // Rebuild the file
$output = implode("\n", $header); $output = implode("\n", $header);
if ($unreleased !== null) { if ($unreleased !== null) {
$output .= implode("\n", $unreleased['lines']) . "\n"; $output .= implode("\n", $unreleased['lines']) . "\n";
} }
foreach ($keptVersioned as $section) { foreach ($keptVersioned as $section) {
$output .= implode("\n", $section['lines']) . "\n"; $output .= implode("\n", $section['lines']) . "\n";
}
// Clean up excessive blank lines at end
$output = rtrim($output) . "\n";
file_put_contents($changelog, $output);
echo "\nCHANGELOG pruned: removed {$pruned} old entries\n";
return 0;
}
} }
// Clean up excessive blank lines at end $app = new ChangelogPruneCli();
$output = rtrim($output) . "\n"; exit($app->execute());
file_put_contents($changelog, $output);
echo "\nCHANGELOG pruned: removed {$pruned} old entries\n";
exit(0);
+35 -78
View File
@@ -12,13 +12,17 @@
* INGROUP: moko-platform * INGROUP: moko-platform
* REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform * REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform
* PATH: /cli/client_dashboard.php * PATH: /cli/client_dashboard.php
* VERSION: 09.21.00 * VERSION: 09.24.00
* BRIEF: Generate unified client dashboard HTML * BRIEF: Generate unified client dashboard HTML
*/ */
declare(strict_types=1); declare(strict_types=1);
final class ClientDashboard require_once __DIR__ . '/../lib/Enterprise/CliFramework.php';
use MokoEnterprise\CliFramework;
class ClientDashboardCli extends CliFramework
{ {
private string $giteaUrl = 'https://git.mokoconsulting.tech'; private string $giteaUrl = 'https://git.mokoconsulting.tech';
private string $token = ''; private string $token = '';
@@ -29,29 +33,47 @@ final class ClientDashboard
private int $sslWarnDays = 30; private int $sslWarnDays = 30;
private int $httpTimeout = 10; private int $httpTimeout = 10;
public function run(): int protected function configure(): void
{ {
$this->parseArgs(); $this->setDescription('Generate unified client dashboard HTML');
$this->addArgument('--token', 'Gitea token (or MOKOGITEA_TOKEN)', '');
$this->addArgument('--gitea-url', 'Gitea URL', 'https://git.mokoconsulting.tech');
$this->addArgument('--org', 'Primary org (default: MokoConsulting)', 'MokoConsulting');
$this->addArgument('--output', 'Output HTML file (default: stdout)', '');
$this->addArgument('-o', 'Output HTML file (alias)', '');
$this->addArgument('--no-ssl', 'Skip SSL checks', false);
$this->addArgument('--no-uptime', 'Skip HTTP uptime checks', false);
$this->addArgument('--ssl-warn-days', 'SSL warning days (default: 30)', '30');
}
protected function run(): int
{
$this->giteaUrl = rtrim($this->getArgument('--gitea-url'), '/');
$this->token = $this->getArgument('--token');
$this->org = $this->getArgument('--org');
$this->outputFile = $this->getArgument('--output') ?: $this->getArgument('-o');
$this->checkSsl = !$this->getArgument('--no-ssl');
$this->checkUptime = !$this->getArgument('--no-uptime');
$this->sslWarnDays = (int) $this->getArgument('--ssl-warn-days');
if ($this->token === '') { if ($this->token === '') {
$this->token = getenv('MOKOGITEA_TOKEN') ?: ''; $this->token = getenv('MOKOGITEA_TOKEN') ?: '';
} }
if ($this->token === '') { if ($this->token === '') {
$this->log('ERROR: --token or MOKOGITEA_TOKEN required.'); $this->log('ERROR', '--token or MOKOGITEA_TOKEN required.');
$this->printUsage();
return 1; return 1;
} }
$this->log('Gathering client data...'); $this->log('INFO', 'Gathering client data...');
$clients = $this->discoverClients(); $clients = $this->discoverClients();
if ($clients === null) { if ($clients === null) {
$this->log('ERROR: Could not fetch client repos.'); $this->log('ERROR', 'Could not fetch client repos.');
return 1; return 1;
} }
$this->log('Found ' . count($clients) . ' client(s).'); $this->log('INFO', 'Found ' . count($clients) . ' client(s).');
foreach ($clients as &$client) { foreach ($clients as &$client) {
$this->enrichClient($client); $this->enrichClient($client);
@@ -63,7 +85,7 @@ final class ClientDashboard
if ($this->outputFile !== '') { if ($this->outputFile !== '') {
file_put_contents($this->outputFile, $html); file_put_contents($this->outputFile, $html);
$this->log("Dashboard: {$this->outputFile}"); $this->log('INFO', "Dashboard: {$this->outputFile}");
} else { } else {
fwrite(STDOUT, $html); fwrite(STDOUT, $html);
} }
@@ -151,9 +173,8 @@ final class ClientDashboard
private function enrichClient(array &$client): void private function enrichClient(array &$client): void
{ {
$repo = $client['repo']; $repo = $client['repo'];
$this->log(" Checking {$client['name']}..."); $this->log('INFO', " Checking {$client['name']}...");
// Fetch variables
$resp = $this->api('GET', "/api/v1/repos/{$repo}/actions/variables"); $resp = $this->api('GET', "/api/v1/repos/{$repo}/actions/variables");
$vars = []; $vars = [];
@@ -185,7 +206,6 @@ final class ClientDashboard
} }
} }
// SSL
$client['ssl_expiry'] = null; $client['ssl_expiry'] = null;
$client['ssl_days'] = null; $client['ssl_days'] = null;
$client['ssl_status'] = 'unknown'; $client['ssl_status'] = 'unknown';
@@ -212,7 +232,6 @@ final class ClientDashboard
} }
} }
// Last release
$client['last_release'] = ''; $client['last_release'] = '';
$client['last_release_date'] = ''; $client['last_release_date'] = '';
$relResp = $this->api('GET', "/api/v1/repos/{$repo}/releases?limit=1"); $relResp = $this->api('GET', "/api/v1/repos/{$repo}/releases?limit=1");
@@ -461,69 +480,7 @@ CARD;
curl_close($ch); curl_close($ch);
return ['code' => $code, 'body' => $body]; 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 MOKOGITEA_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(); $app = new ClientDashboardCli();
exit($app->run()); exit($app->execute());
+78 -68
View File
@@ -1,5 +1,6 @@
#!/usr/bin/env php #!/usr/bin/env php
<?php <?php
/* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech> /* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
* *
* SPDX-License-Identifier: GPL-3.0-or-later * SPDX-License-Identifier: GPL-3.0-or-later
@@ -10,43 +11,45 @@
* REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform * REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform
* PATH: /cli/client_health_check.php * PATH: /cli/client_health_check.php
* BRIEF: Verify a client site's update server, installed version, and release availability * 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); declare(strict_types=1);
$path = '.'; require_once __DIR__ . '/../lib/Enterprise/CliFramework.php';
$updateUrl = null;
$siteUrl = null;
$apiToken = null;
$ghOutput = false;
foreach ($argv as $i => $arg) { use MokoEnterprise\CliFramework;
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; class ClientHealthCheckCli extends CliFramework
$checks = []; {
protected function configure(): void
{
$this->setDescription('Verify a client site\'s update server, installed version, and release availability');
$this->addArgument('--path', 'Repository root (reads update server URL from manifest)', '.');
$this->addArgument('--update-url', 'Update server XML URL (overrides manifest)', '');
$this->addArgument('--site-url', 'Live site URL for version checking via Joomla API', '');
$this->addArgument('--api-token', 'Joomla API token for site-url', '');
$this->addArgument('--github-output', 'Export results to $GITHUB_OUTPUT', false);
}
// ── Resolve update server URL from manifest ───────────────────────────── protected function run(): int
if ($updateUrl === null) { {
$path = $this->getArgument('--path');
$updateUrl = $this->getArgument('--update-url');
$siteUrl = $this->getArgument('--site-url');
$apiToken = $this->getArgument('--api-token');
$ghOutput = $this->getArgument('--github-output');
$root = realpath($path) ?: $path;
$checks = [];
// -- Resolve update server URL from manifest --
if ($updateUrl === '') {
$updateUrl = null;
$searchDirs = ["{$root}/src", $root]; $searchDirs = ["{$root}/src", $root];
foreach ($searchDirs as $dir) { foreach ($searchDirs as $dir) {
if (!is_dir($dir)) continue; if (!is_dir($dir)) {
continue;
}
foreach (glob("{$dir}/*.xml") ?: [] as $f) { foreach (glob("{$dir}/*.xml") ?: [] as $f) {
$xml = file_get_contents($f); $xml = file_get_contents($f);
if (preg_match('/<server[^>]*>([^<]+)<\/server>/', $xml, $m)) { if (preg_match('/<server[^>]*>([^<]+)<\/server>/', $xml, $m)) {
@@ -55,41 +58,41 @@ if ($updateUrl === null) {
} }
} }
} }
} }
if ($updateUrl === null) { if ($updateUrl === null || $updateUrl === '') {
fwrite(STDERR, "No update server URL found. Use --update-url or provide a manifest with <updateservers>.\n"); $this->log('ERROR', 'No update server URL found. Use --update-url or provide a manifest with <updateservers>.');
exit(1); return 1;
} }
echo "Update server: {$updateUrl}\n\n"; echo "Update server: {$updateUrl}\n\n";
// ── Check 1: Update server accessible ─────────────────────────────────── // -- Check 1: Update server accessible --
echo "--- Update Server ---\n"; echo "--- Update Server ---\n";
$ch = curl_init($updateUrl); $ch = curl_init($updateUrl);
curl_setopt_array($ch, [ curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true, CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => 15, CURLOPT_TIMEOUT => 15,
CURLOPT_FOLLOWLOCATION => true, CURLOPT_FOLLOWLOCATION => true,
CURLOPT_HTTPHEADER => ['User-Agent: MokoHealthCheck/1.0'], CURLOPT_HTTPHEADER => ['User-Agent: MokoHealthCheck/1.0'],
]); ]);
$response = curl_exec($ch); $response = curl_exec($ch);
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE); $httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch); curl_close($ch);
if ($httpCode === 200 && !empty($response)) { if ($httpCode === 200 && !empty($response)) {
echo " PASS: HTTP {$httpCode}, " . strlen($response) . " bytes\n"; echo " PASS: HTTP {$httpCode}, " . strlen($response) . " bytes\n";
$checks['update_server'] = 'pass'; $checks['update_server'] = 'pass';
} else { } else {
echo " FAIL: HTTP {$httpCode}\n"; echo " FAIL: HTTP {$httpCode}\n";
$checks['update_server'] = 'fail'; $checks['update_server'] = 'fail';
} }
// ── Check 2: Parse updates.xml for stable version ─────────────────────── // -- Check 2: Parse updates.xml for stable version --
$stableVersion = null; $stableVersion = null;
$downloadUrl = null; $downloadUrl = null;
if (!empty($response)) { if (!empty($response)) {
$sections = preg_split('/<update>/', $response); $sections = preg_split('/<update>/', $response);
foreach ($sections as $section) { foreach ($sections as $section) {
if (strpos($section, '<tag>stable</tag>') !== false) { if (strpos($section, '<tag>stable</tag>') !== false) {
@@ -106,19 +109,19 @@ if (!empty($response)) {
if ($stableVersion === null && preg_match('/<version>([^<]+)<\/version>/', $response, $m)) { if ($stableVersion === null && preg_match('/<version>([^<]+)<\/version>/', $response, $m)) {
$stableVersion = $m[1]; $stableVersion = $m[1];
} }
} }
echo "\n--- Stable Release ---\n"; echo "\n--- Stable Release ---\n";
if ($stableVersion !== null) { if ($stableVersion !== null) {
echo " Version: {$stableVersion}\n"; echo " Version: {$stableVersion}\n";
$checks['stable_version'] = $stableVersion; $checks['stable_version'] = $stableVersion;
} else { } else {
echo " FAIL: Could not parse stable version\n"; echo " FAIL: Could not parse stable version\n";
$checks['stable_version'] = 'fail'; $checks['stable_version'] = 'fail';
} }
// ── Check 3: Download URL accessible ──────────────────────────────────── // -- Check 3: Download URL accessible --
if ($downloadUrl !== null) { if ($downloadUrl !== null) {
echo "\n--- Download URL ---\n"; echo "\n--- Download URL ---\n";
$ch = curl_init($downloadUrl); $ch = curl_init($downloadUrl);
curl_setopt_array($ch, [ curl_setopt_array($ch, [
@@ -139,10 +142,10 @@ if ($downloadUrl !== null) {
echo " FAIL: HTTP {$dlCode}\n"; echo " FAIL: HTTP {$dlCode}\n";
$checks['download'] = 'fail'; $checks['download'] = 'fail';
} }
} }
// ── Check 4: Site version (optional) ──────────────────────────────────── // -- Check 4: Site version (optional) --
if ($siteUrl !== null && $apiToken !== null) { if ($siteUrl !== '' && $apiToken !== '') {
echo "\n--- Site Version ---\n"; echo "\n--- Site Version ---\n";
$apiUrl = rtrim($siteUrl, '/') . '/api/index.php/v1/extensions?filter[type]=file'; $apiUrl = rtrim($siteUrl, '/') . '/api/index.php/v1/extensions?filter[type]=file';
$ch = curl_init($apiUrl); $ch = curl_init($apiUrl);
@@ -165,24 +168,31 @@ if ($siteUrl !== null && $apiToken !== null) {
echo " WARN: Site API returned HTTP {$siteCode}\n"; echo " WARN: Site API returned HTTP {$siteCode}\n";
$checks['site_api'] = 'warn'; $checks['site_api'] = 'warn';
} }
} }
// ── Summary ───────────────────────────────────────────────────────────── // -- Summary --
echo "\n=== Health Check Summary ===\n"; echo "\n=== Health Check Summary ===\n";
$failed = 0; $failed = 0;
foreach ($checks as $name => $result) { foreach ($checks as $name => $result) {
$icon = ($result === 'fail') ? 'FAIL' : (($result === 'warn') ? 'WARN' : 'OK'); $icon = ($result === 'fail') ? 'FAIL' : (($result === 'warn') ? 'WARN' : 'OK');
if ($result === 'fail') $failed++; if ($result === 'fail') {
$failed++;
}
echo " {$icon}: {$name} = {$result}\n"; echo " {$icon}: {$name} = {$result}\n";
} }
if ($ghOutput) { if ($ghOutput) {
$ghFile = getenv('GITHUB_OUTPUT'); $ghFile = getenv('GITHUB_OUTPUT');
if ($ghFile) { if ($ghFile) {
file_put_contents($ghFile, "health_status=" . ($failed > 0 ? 'fail' : 'pass') . "\n", FILE_APPEND); 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_version=" . ($stableVersion ?? 'unknown') . "\n", FILE_APPEND);
file_put_contents($ghFile, "health_failures={$failed}\n", FILE_APPEND); file_put_contents($ghFile, "health_failures={$failed}\n", FILE_APPEND);
} }
}
return $failed > 0 ? 1 : 0;
}
} }
exit($failed > 0 ? 1 : 0); $app = new ClientHealthCheckCli();
exit($app->execute());
+68 -125
View File
@@ -1,5 +1,6 @@
#!/usr/bin/env php #!/usr/bin/env php
<?php <?php
/* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech> /* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
* *
* This file is part of a Moko Consulting project. * This file is part of a Moko Consulting project.
@@ -11,68 +12,74 @@
* INGROUP: moko-platform * INGROUP: moko-platform
* REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform * REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform
* PATH: /cli/client_inventory.php * PATH: /cli/client_inventory.php
* VERSION: 09.21.00 * VERSION: 09.24.00
* BRIEF: Discover and list all client-waas repos with their server configuration status * BRIEF: Discover and list all client-waas repos with their server configuration status
*/ */
declare(strict_types=1); declare(strict_types=1);
final class ClientInventory require_once __DIR__ . '/../lib/Enterprise/CliFramework.php';
use MokoEnterprise\CliFramework;
class ClientInventoryCli extends CliFramework
{ {
private string $giteaUrl = 'https://git.mokoconsulting.tech'; private string $giteaUrl = 'https://git.mokoconsulting.tech';
private string $token = ''; private string $token = '';
private bool $jsonOutput = false; private bool $jsonOutput = false;
public function run(): int protected function configure(): void
{ {
$this->parseArgs(); $this->setDescription('Discover and list all client-waas repos with their server configuration status');
$this->addArgument('--gitea-url', 'Gitea URL (default: https://git.mokoconsulting.tech)', 'https://git.mokoconsulting.tech');
$this->addArgument('--token', 'Gitea API token', '');
$this->addArgument('--json', 'Output results as JSON', false);
}
if ($this->token === '') protected function run(): int
{ {
$this->log('ERROR: --token is required.'); $this->giteaUrl = rtrim($this->getArgument('--gitea-url'), '/');
$this->printUsage(); $this->token = $this->getArgument('--token');
$this->jsonOutput = (bool) $this->getArgument('--json');
if ($this->token === '') {
$this->log('ERROR', '--token is required.');
return 1; return 1;
} }
$this->log("Scanning Gitea instance: {$this->giteaUrl}"); $this->log('INFO', "Scanning Gitea instance: {$this->giteaUrl}");
// Step 1: List all orgs // Step 1: List all orgs
$orgs = $this->fetchOrgs(); $orgs = $this->fetchOrgs();
if ($orgs === null) if ($orgs === null) {
{ $this->log('ERROR', 'Failed to fetch organizations.');
$this->log('ERROR: Failed to fetch organizations.');
return 1; return 1;
} }
$this->log('Found ' . count($orgs) . ' organization(s).'); $this->log('INFO', 'Found ' . count($orgs) . ' organization(s).');
// Step 2 & 3: For each org, find client-waas repos // Step 2 & 3: For each org, find client-waas repos
$inventory = []; $inventory = [];
foreach ($orgs as $org) foreach ($orgs as $org) {
{
$orgName = $org['username'] ?? $org['name'] ?? ''; $orgName = $org['username'] ?? $org['name'] ?? '';
if ($orgName === '') if ($orgName === '') {
{
continue; continue;
} }
$repos = $this->fetchOrgRepos($orgName); $repos = $this->fetchOrgRepos($orgName);
if ($repos === null) if ($repos === null) {
{ $this->log('WARNING', "Could not fetch repos for org: {$orgName}");
$this->log("WARNING: Could not fetch repos for org: {$orgName}");
continue; continue;
} }
foreach ($repos as $repo) foreach ($repos as $repo) {
{
$repoName = $repo['name'] ?? ''; $repoName = $repo['name'] ?? '';
if (strpos($repoName, 'client-waas') === false) if (strpos($repoName, 'client-waas') === false) {
{
continue; continue;
} }
@@ -81,23 +88,17 @@ final class ClientInventory
$lastPush = $repo['updated_at'] ?? 'unknown'; $lastPush = $repo['updated_at'] ?? 'unknown';
if ($lastPush !== 'unknown') if ($lastPush !== 'unknown') {
{
$lastPush = substr($lastPush, 0, 19); $lastPush = substr($lastPush, 0, 19);
} }
$status = 'OK'; $status = 'OK';
if (!$hasDevConfig && !$hasLiveConfig) if (!$hasDevConfig && !$hasLiveConfig) {
{
$status = 'UNCONFIGURED'; $status = 'UNCONFIGURED';
} } elseif (!$hasDevConfig) {
elseif (!$hasDevConfig)
{
$status = 'NO DEV'; $status = 'NO DEV';
} } elseif (!$hasLiveConfig) {
elseif (!$hasLiveConfig)
{
$status = 'NO LIVE'; $status = 'NO LIVE';
} }
@@ -113,29 +114,31 @@ final class ClientInventory
} }
// Output results // Output results
if ($this->jsonOutput) if ($this->jsonOutput) {
{
fwrite(STDOUT, json_encode($inventory, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES) . PHP_EOL); fwrite(STDOUT, json_encode($inventory, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES) . PHP_EOL);
return 0; return 0;
} }
if (count($inventory) === 0) if (count($inventory) === 0) {
{ $this->log('INFO', 'No client-waas repos found.');
$this->log('No client-waas repos found.');
return 0; return 0;
} }
// Print table // Print table
$this->log(''); $this->log('INFO', '');
$this->log(sprintf( $this->log('INFO', sprintf(
'%-20s | %-35s | %-10s | %-11s | %-19s | %s', '%-20s | %-35s | %-10s | %-11s | %-19s | %s',
'Org', 'Repo', 'Dev Config', 'Live Config', 'Last Push', 'Status' 'Org',
'Repo',
'Dev Config',
'Live Config',
'Last Push',
'Status'
)); ));
$this->log(str_repeat('-', 120)); $this->log('INFO', str_repeat('-', 120));
foreach ($inventory as $entry) foreach ($inventory as $entry) {
{ $this->log('INFO', sprintf(
$this->log(sprintf(
'%-20s | %-35s | %-10s | %-11s | %-19s | %s', '%-20s | %-35s | %-10s | %-11s | %-19s | %s',
$entry['org'], $entry['org'],
$entry['repo'], $entry['repo'],
@@ -146,77 +149,33 @@ final class ClientInventory
)); ));
} }
$this->log(''); $this->log('INFO', '');
$this->log('Total: ' . count($inventory) . ' client-waas repo(s).'); $this->log('INFO', 'Total: ' . count($inventory) . ' client-waas repo(s).');
return 0; return 0;
} }
private function parseArgs(): void
{
$args = $_SERVER['argv'] ?? [];
$count = count($args);
for ($i = 1; $i < $count; $i++)
{
switch ($args[$i])
{
case '--gitea-url':
$this->giteaUrl = rtrim($args[++$i] ?? '', '/');
break;
case '--token':
$this->token = $args[++$i] ?? '';
break;
case '--json':
$this->jsonOutput = true;
break;
case '--help':
case '-h':
$this->printUsage();
exit(0);
default:
$this->log("WARNING: Unknown argument: {$args[$i]}");
break;
}
}
}
private function printUsage(): void
{
$this->log('Usage: client_inventory.php --token <token> [options]');
$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(' --json Output results as JSON');
$this->log(' --help, -h Show this help');
}
private function fetchOrgs(): ?array private function fetchOrgs(): ?array
{ {
// Try admin endpoint first, fall back to user-visible orgs // Try admin endpoint first, fall back to user-visible orgs
$response = $this->apiRequest('GET', '/api/v1/admin/orgs?limit=50'); $response = $this->apiRequest('GET', '/api/v1/admin/orgs?limit=50');
if ($response['code'] >= 200 && $response['code'] < 300) if ($response['code'] >= 200 && $response['code'] < 300) {
{
$data = json_decode($response['body'], true); $data = json_decode($response['body'], true);
if (is_array($data)) if (is_array($data)) {
{
return $data; return $data;
} }
} }
$this->log('Admin orgs endpoint unavailable, falling back to user orgs...'); $this->log('INFO', 'Admin orgs endpoint unavailable, falling back to user orgs...');
$response = $this->apiRequest('GET', '/api/v1/user/orgs?limit=50'); $response = $this->apiRequest('GET', '/api/v1/user/orgs?limit=50');
if ($response['code'] >= 200 && $response['code'] < 300) if ($response['code'] >= 200 && $response['code'] < 300) {
{
$data = json_decode($response['body'], true); $data = json_decode($response['body'], true);
if (is_array($data)) if (is_array($data)) {
{
return $data; return $data;
} }
} }
@@ -229,19 +188,16 @@ final class ClientInventory
$page = 1; $page = 1;
$allRepos = []; $allRepos = [];
while (true) while (true) {
{
$response = $this->apiRequest('GET', "/api/v1/orgs/{$org}/repos?limit=50&page={$page}"); $response = $this->apiRequest('GET', "/api/v1/orgs/{$org}/repos?limit=50&page={$page}");
if ($response['code'] < 200 || $response['code'] >= 300) if ($response['code'] < 200 || $response['code'] >= 300) {
{
return $page === 1 ? null : $allRepos; return $page === 1 ? null : $allRepos;
} }
$data = json_decode($response['body'], true); $data = json_decode($response['body'], true);
if (!is_array($data) || count($data) === 0) if (!is_array($data) || count($data) === 0) {
{
break; break;
} }
@@ -256,32 +212,26 @@ final class ClientInventory
{ {
$response = $this->apiRequest('GET', "/api/v1/repos/{$org}/{$repo}/actions/variables"); $response = $this->apiRequest('GET', "/api/v1/repos/{$org}/{$repo}/actions/variables");
if ($response['code'] < 200 || $response['code'] >= 300) if ($response['code'] < 200 || $response['code'] >= 300) {
{
return false; return false;
} }
$data = json_decode($response['body'], true); $data = json_decode($response['body'], true);
if (!is_array($data)) if (!is_array($data)) {
{
return false; return false;
} }
$existingVars = []; $existingVars = [];
foreach ($data as $variable) foreach ($data as $variable) {
{ if (isset($variable['name'])) {
if (isset($variable['name']))
{
$existingVars[] = $variable['name']; $existingVars[] = $variable['name'];
} }
} }
foreach ($requiredVars as $var) foreach ($requiredVars as $var) {
{ if (!in_array($var, $existingVars, true)) {
if (!in_array($var, $existingVars, true))
{
return false; return false;
} }
} }
@@ -303,16 +253,14 @@ final class ClientInventory
"Authorization: token {$this->token}", "Authorization: token {$this->token}",
]); ]);
if ($body !== null) if ($body !== null) {
{
curl_setopt($ch, CURLOPT_POSTFIELDS, $body); curl_setopt($ch, CURLOPT_POSTFIELDS, $body);
} }
$responseBody = curl_exec($ch); $responseBody = curl_exec($ch);
$httpCode = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE); $httpCode = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE);
if (curl_errno($ch)) if (curl_errno($ch)) {
{
$error = curl_error($ch); $error = curl_error($ch);
curl_close($ch); curl_close($ch);
@@ -323,12 +271,7 @@ final class ClientInventory
return ['code' => $httpCode, 'body' => $responseBody]; return ['code' => $httpCode, 'body' => $responseBody];
} }
private function log(string $message): void
{
fwrite(STDERR, $message . PHP_EOL);
}
} }
$app = new ClientInventory(); $app = new ClientInventoryCli();
exit($app->run()); exit($app->execute());
+68 -111
View File
@@ -12,13 +12,17 @@
* INGROUP: moko-platform * INGROUP: moko-platform
* REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform * REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform
* PATH: /cli/client_provision.php * PATH: /cli/client_provision.php
* VERSION: 09.21.00 * VERSION: 09.24.00
* BRIEF: Provision a new client environment end-to-end * BRIEF: Provision a new client environment end-to-end
*/ */
declare(strict_types=1); declare(strict_types=1);
final class ClientProvision require_once __DIR__ . '/../lib/Enterprise/CliFramework.php';
use MokoEnterprise\CliFramework;
class ClientProvisionCli extends CliFramework
{ {
private string $giteaUrl = 'https://git.mokoconsulting.tech'; private string $giteaUrl = 'https://git.mokoconsulting.tech';
private string $giteaToken = ''; private string $giteaToken = '';
@@ -26,24 +30,30 @@ final class ClientProvision
private string $grafanaToken = ''; private string $grafanaToken = '';
private string $configFile = ''; private string $configFile = '';
private string $step = ''; private string $step = '';
private bool $dryRun = false;
/** @var array<string, mixed> */ /** @var array<string, mixed> */
private array $config = []; private array $config = [];
private string $org = ''; private string $org = '';
private string $repoName = ''; private string $repoName = '';
public function run(): int protected function configure(): void
{ {
$this->parseArgs(); $this->setDescription('Provision a new client environment end-to-end');
$this->addArgument('--config', 'Client config JSON', '');
$this->addArgument('--step', 'Run one step: repo, variables, secrets, monitoring, summary', '');
}
protected function run(): int
{
$this->configFile = $this->getArgument('--config');
$this->step = $this->getArgument('--step');
if ($this->configFile === '') { if ($this->configFile === '') {
$this->log('ERROR: --config is required.'); $this->log('ERROR', '--config is required.');
$this->printUsage();
return 1; return 1;
} }
if (!file_exists($this->configFile)) { if (!file_exists($this->configFile)) {
$this->log("ERROR: Not found: {$this->configFile}"); $this->log('ERROR', "Not found: {$this->configFile}");
return 1; return 1;
} }
@@ -51,7 +61,7 @@ final class ClientProvision
$this->config = json_decode($json, true); $this->config = json_decode($json, true);
if (!is_array($this->config)) { if (!is_array($this->config)) {
$this->log('ERROR: Invalid JSON in config file.'); $this->log('ERROR', 'Invalid JSON in config file.');
return 1; return 1;
} }
@@ -65,7 +75,7 @@ final class ClientProvision
?? $this->giteaUrl; ?? $this->giteaUrl;
if ($this->giteaToken === '') { if ($this->giteaToken === '') {
$this->log('ERROR: gitea_token or MOKOGITEA_TOKEN required.'); $this->log('ERROR', 'gitea_token or MOKOGITEA_TOKEN required.');
return 1; return 1;
} }
@@ -73,21 +83,21 @@ final class ClientProvision
$clientName = $this->config['name'] ?? ''; $clientName = $this->config['name'] ?? '';
if ($this->org === '' || $clientName === '') { if ($this->org === '' || $clientName === '') {
$this->log('ERROR: "org" and "name" required in config.'); $this->log('ERROR', '"org" and "name" required in config.');
return 1; return 1;
} }
$this->repoName = 'client-waas-' . $clientName; $this->repoName = 'client-waas-' . $clientName;
$this->log("=== Client Provisioning: {$clientName} ==="); $this->log('INFO', "=== Client Provisioning: {$clientName} ===");
$this->log(" Org: {$this->org}"); $this->log('INFO', " Org: {$this->org}");
$this->log(" Repo: {$this->repoName}"); $this->log('INFO', " Repo: {$this->repoName}");
if ($this->dryRun) { if ($this->dryRun) {
$this->log(' Mode: DRY RUN'); $this->log('INFO', ' Mode: DRY RUN');
} }
$this->log(''); echo "\n";
$steps = [ $steps = [
'repo' => 'createRepo', 'repo' => 'createRepo',
@@ -116,7 +126,7 @@ final class ClientProvision
private function createRepo(): int private function createRepo(): int
{ {
$this->log('[1/5] Creating repository...'); $this->log('INFO', '[1/5] Creating repository...');
$check = $this->giteaApi( $check = $this->giteaApi(
'GET', 'GET',
@@ -124,14 +134,12 @@ final class ClientProvision
); );
if ($check['code'] === 200) { if ($check['code'] === 200) {
$this->log(" SKIP: repo already exists"); $this->log('INFO', ' SKIP: repo already exists');
return 0; return 0;
} }
if ($this->dryRun) { if ($this->dryRun) {
$this->log( $this->log('INFO', " WOULD CREATE: {$this->org}/{$this->repoName}");
" WOULD CREATE: {$this->org}/{$this->repoName}"
);
return 0; return 0;
} }
@@ -153,11 +161,11 @@ final class ClientProvision
); );
if ($resp['code'] < 200 || $resp['code'] >= 300) { if ($resp['code'] < 200 || $resp['code'] >= 300) {
$this->log(" ERROR: HTTP {$resp['code']}"); $this->log('ERROR', "HTTP {$resp['code']}");
return 1; return 1;
} }
$this->log(' OK: Repo created'); $this->log('INFO', ' OK: Repo created');
$this->giteaApi( $this->giteaApi(
'POST', 'POST',
@@ -168,19 +176,19 @@ final class ClientProvision
]) ])
); );
$this->log(' OK: dev branch created'); $this->log('INFO', ' OK: dev branch created');
return 0; return 0;
} }
private function setVariables(): int private function setVariables(): int
{ {
$this->log('[2/5] Setting repo variables...'); $this->log('INFO', '[2/5] Setting repo variables...');
$vars = $this->config['variables'] ?? []; $vars = $this->config['variables'] ?? [];
if (empty($vars)) { if (empty($vars)) {
$this->log(' SKIP: No variables in config'); $this->log('INFO', ' SKIP: No variables in config');
return 0; return 0;
} }
@@ -192,16 +200,16 @@ final class ClientProvision
if ($this->dryRun) { if ($this->dryRun) {
$display = strlen($value) > 40 $display = strlen($value) > 40
? substr($value, 0, 37) . '...' : $value; ? substr($value, 0, 37) . '...' : $value;
$this->log(" WOULD SET: {$name} = {$display}"); $this->log('INFO', " WOULD SET: {$name} = {$display}");
continue; continue;
} }
$ok = $this->setOrCreateVariable($api, $name, $value); $ok = $this->setOrCreateVariable($api, $name, $value);
if ($ok) { if ($ok) {
$this->log(" OK: {$name}"); $this->log('INFO', " OK: {$name}");
} else { } else {
$this->log(" ERROR: {$name}"); $this->log('ERROR', " {$name}");
$errors++; $errors++;
} }
} }
@@ -211,12 +219,12 @@ final class ClientProvision
private function setSecrets(): int private function setSecrets(): int
{ {
$this->log('[3/5] Setting repo secrets...'); $this->log('INFO', '[3/5] Setting repo secrets...');
$secrets = $this->config['secrets'] ?? []; $secrets = $this->config['secrets'] ?? [];
if (empty($secrets)) { if (empty($secrets)) {
$this->log(' SKIP: No secrets in config'); $this->log('INFO', ' SKIP: No secrets in config');
return 0; return 0;
} }
@@ -229,7 +237,7 @@ final class ClientProvision
$keyPath = substr($value, 1); $keyPath = substr($value, 1);
if (!file_exists($keyPath)) { if (!file_exists($keyPath)) {
$this->log(" ERROR: {$name} file not found: {$keyPath}"); $this->log('ERROR', " {$name} file not found: {$keyPath}");
$errors++; $errors++;
continue; continue;
} }
@@ -238,7 +246,7 @@ final class ClientProvision
} }
if ($this->dryRun) { if ($this->dryRun) {
$this->log(" WOULD SET: {$name} (len: " . strlen($value) . ")"); $this->log('INFO', " WOULD SET: {$name} (len: " . strlen($value) . ")");
continue; continue;
} }
@@ -249,9 +257,9 @@ final class ClientProvision
); );
if ($resp['code'] >= 200 && $resp['code'] < 300) { if ($resp['code'] >= 200 && $resp['code'] < 300) {
$this->log(" OK: {$name}"); $this->log('INFO', " OK: {$name}");
} else { } else {
$this->log(" ERROR: {$name} (HTTP {$resp['code']})"); $this->log('ERROR', " {$name} (HTTP {$resp['code']})");
$errors++; $errors++;
} }
} }
@@ -261,12 +269,12 @@ final class ClientProvision
private function setupMonitoring(): int private function setupMonitoring(): int
{ {
$this->log('[4/5] Setting up monitoring...'); $this->log('INFO', '[4/5] Setting up monitoring...');
$mon = $this->config['monitoring'] ?? []; $mon = $this->config['monitoring'] ?? [];
if (empty($mon)) { if (empty($mon)) {
$this->log(' SKIP: No monitoring config'); $this->log('INFO', ' SKIP: No monitoring config');
return 0; return 0;
} }
@@ -291,10 +299,10 @@ final class ClientProvision
$urlStr = implode("\n", $urls); $urlStr = implode("\n", $urls);
if ($this->dryRun) { if ($this->dryRun) {
$this->log(" WOULD SET: MONITORED_URLS"); $this->log('INFO', ' WOULD SET: MONITORED_URLS');
} else { } else {
$this->setOrCreateVariable($api, 'MONITORED_URLS', $urlStr); $this->setOrCreateVariable($api, 'MONITORED_URLS', $urlStr);
$this->log(' OK: MONITORED_URLS'); $this->log('INFO', ' OK: MONITORED_URLS');
} }
} }
@@ -302,10 +310,10 @@ final class ClientProvision
$domainStr = implode("\n", $domains); $domainStr = implode("\n", $domains);
if ($this->dryRun) { if ($this->dryRun) {
$this->log(" WOULD SET: MONITORED_DOMAINS"); $this->log('INFO', ' WOULD SET: MONITORED_DOMAINS');
} else { } else {
$this->setOrCreateVariable($api, 'MONITORED_DOMAINS', $domainStr); $this->setOrCreateVariable($api, 'MONITORED_DOMAINS', $domainStr);
$this->log(' OK: MONITORED_DOMAINS'); $this->log('INFO', ' OK: MONITORED_DOMAINS');
} }
} }
@@ -315,19 +323,19 @@ final class ClientProvision
private function pushGrafanaDashboard(string $file, string $folder): void private function pushGrafanaDashboard(string $file, string $folder): void
{ {
if (!file_exists($file)) { if (!file_exists($file)) {
$this->log(" WARN: Dashboard not found: {$file}"); $this->warning("Dashboard not found: {$file}");
return; return;
} }
if ($this->dryRun) { if ($this->dryRun) {
$this->log(" WOULD PUSH: dashboard to \"{$folder}\""); $this->log('INFO', " WOULD PUSH: dashboard to \"{$folder}\"");
return; return;
} }
$dashboard = json_decode(file_get_contents($file), true); $dashboard = json_decode(file_get_contents($file), true);
if (!is_array($dashboard)) { if (!is_array($dashboard)) {
$this->log(' ERROR: Invalid dashboard JSON'); $this->log('ERROR', 'Invalid dashboard JSON');
return; return;
} }
@@ -346,9 +354,9 @@ final class ClientProvision
if ($resp['code'] === 200) { if ($resp['code'] === 200) {
$data = json_decode($resp['body'], true); $data = json_decode($resp['body'], true);
$this->log(" OK: Dashboard (uid: " . ($data['uid'] ?? '?') . ")"); $this->log('INFO', " OK: Dashboard (uid: " . ($data['uid'] ?? '?') . ")");
} else { } else {
$this->log(" ERROR: Dashboard push (HTTP {$resp['code']})"); $this->log('ERROR', " Dashboard push (HTTP {$resp['code']})");
} }
} }
@@ -379,20 +387,19 @@ final class ClientProvision
{ {
$vars = $this->config['variables'] ?? []; $vars = $this->config['variables'] ?? [];
$secrets = $this->config['secrets'] ?? []; $secrets = $this->config['secrets'] ?? [];
$clientName = $this->config['name'] ?? '';
$this->log(''); echo "\n";
$this->log('[5/5] Provisioning summary'); $this->log('INFO', '[5/5] Provisioning summary');
$this->log(str_repeat('=', 60)); echo str_repeat('=', 60) . "\n";
$this->log(" Repo: {$this->giteaUrl}/{$this->org}/{$this->repoName}"); echo " Repo: {$this->giteaUrl}/{$this->org}/{$this->repoName}\n";
$this->log(' Variables: ' . count($vars) . ' set'); echo ' Variables: ' . count($vars) . " set\n";
$this->log(' Secrets: ' . count($secrets) . ' set'); echo ' Secrets: ' . count($secrets) . " set\n";
$this->log(''); echo "\n";
$this->log('Next steps:'); echo "Next steps:\n";
$this->log(' 1. Clone and customize the Joomla template'); echo " 1. Clone and customize the Joomla template\n";
$this->log(' 2. Push to dev to trigger dev deployment'); echo " 2. Push to dev to trigger dev deployment\n";
$this->log(' 3. Merge dev -> main for production release'); echo " 3. Merge dev -> main for production release\n";
$this->log(str_repeat('=', 60)); echo str_repeat('=', 60) . "\n";
return 0; return 0;
} }
@@ -419,51 +426,6 @@ final class ClientProvision
return $resp['code'] >= 200 && $resp['code'] < 300; 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(' MOKOGITEA_TOKEN Gitea API token');
$this->log(' GRAFANA_URL Grafana instance URL');
$this->log(' GRAFANA_TOKEN Grafana API token');
}
private function giteaApi( private function giteaApi(
string $method, string $method,
string $endpoint, string $endpoint,
@@ -523,12 +485,7 @@ final class ClientProvision
return ['code' => $httpCode, 'body' => $responseBody]; return ['code' => $httpCode, 'body' => $responseBody];
} }
private function log(string $message): void
{
fwrite(STDERR, $message . PHP_EOL);
}
} }
$app = new ClientProvision(); $app = new ClientProvisionCli();
exit($app->run()); exit($app->execute());
+169
View File
@@ -0,0 +1,169 @@
#!/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/completion.php
* BRIEF: Generate bash/zsh tab completion scripts for bin/moko
*/
declare(strict_types=1);
require_once __DIR__ . '/../lib/Enterprise/CliFramework.php';
use MokoEnterprise\CliFramework;
class CompletionCli extends CliFramework
{
protected function configure(): void
{
$this->setDescription('Generate bash/zsh tab completion scripts for bin/moko');
$this->addArgument('--shell', 'Shell type: bash or zsh', 'bash');
}
protected function run(): int
{
$shell = $this->getArgument('--shell');
// Also accept positional-style: check raw argv for bash/zsh
global $argv;
foreach ($argv as $arg) {
if (in_array($arg, ['bash', 'zsh'], true)) {
$shell = $arg;
break;
}
}
// Extract command names from bin/moko COMMAND_MAP using regex (no eval).
$mokoFile = dirname(__DIR__) . '/bin/moko';
$content = file_get_contents($mokoFile);
// Isolate the COMMAND_MAP block, then extract keys.
if (!preg_match('/const COMMAND_MAP\s*=\s*\[(.+?)\];/s', $content, $block)) {
$this->log('ERROR', 'Could not find COMMAND_MAP in bin/moko');
return 1;
}
// Match 'command-name' => 'path' entries within the block.
if (!preg_match_all("/'([a-z][a-z0-9:_-]*)'\s*=>/m", $block[1], $matches)) {
$this->log('ERROR', 'Could not parse command names from COMMAND_MAP');
return 1;
}
$commandNames = array_unique($matches[1]);
sort($commandNames);
// Common flags supported by CliFramework.
$commonFlags = ['--help', '--verbose', '--quiet', '--dry-run', '--json', '--no-color', '--path'];
if ($shell === 'zsh') {
$this->generateZsh($commandNames, $commonFlags);
} else {
$this->generateBash($commandNames, $commonFlags);
}
return 0;
}
// -- Generators --
private function generateBash(array $commands, array $flags): void
{
$cmdList = implode(' ', $commands);
$flagList = implode(' ', $flags);
echo <<<BASH
# moko bash completion — generated by: php bin/moko completion bash
_moko_complete() {
local cur prev commands flags
COMPREPLY=()
cur="\${COMP_WORDS[COMP_CWORD]}"
prev="\${COMP_WORDS[COMP_CWORD-1]}"
commands="{$cmdList}"
flags="{$flagList}"
# Complete commands (first argument after 'moko')
if [[ \$COMP_CWORD -eq 1 ]] || [[ \$COMP_CWORD -eq 2 && "\${COMP_WORDS[1]}" == "php" ]]; then
COMPREPLY=( \$(compgen -W "\$commands list help" -- "\$cur") )
return 0
fi
# Complete flags
if [[ "\$cur" == -* ]]; then
COMPREPLY=( \$(compgen -W "\$flags" -- "\$cur") )
return 0
fi
# Complete --path with directories
if [[ "\$prev" == "--path" ]]; then
COMPREPLY=( \$(compgen -d -- "\$cur") )
return 0
fi
}
# Register for both direct and php invocation
complete -F _moko_complete moko
complete -F _moko_complete ./bin/moko
complete -F _moko_complete bin/moko
BASH;
}
private function generateZsh(array $commands, array $flags): void
{
$cmdLines = '';
foreach ($commands as $cmd) {
$cmdLines .= " '{$cmd}'\n";
}
$flagLines = '';
foreach ($flags as $flag) {
$desc = match ($flag) {
'--help' => 'Show help for the command',
'--verbose' => 'Show detailed output',
'--quiet' => 'Suppress non-error output',
'--dry-run' => 'Preview changes without writing',
'--json' => 'Machine-readable JSON output',
'--no-color' => 'Disable ANSI colour output',
'--path' => 'Repository root path',
default => $flag,
};
$flagLines .= " '{$flag}[{$desc}]'\n";
}
echo <<<ZSH
#compdef moko bin/moko
# moko zsh completion — generated by: php bin/moko completion zsh
_moko() {
local -a commands flags
commands=(
{$cmdLines} 'list'
'help'
)
flags=(
{$flagLines} )
if (( CURRENT == 2 )); then
_describe 'command' commands
else
_arguments '*:flags:_values "flag" \${flags[@]}'
fi
}
compdef _moko moko
compdef _moko bin/moko
ZSH;
}
}
$app = new CompletionCli();
exit($app->execute());
+179 -216
View File
@@ -1,5 +1,6 @@
#!/usr/bin/env php #!/usr/bin/env php
<?php <?php
/* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech> /* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
* *
* This file is part of a Moko Consulting project. * This file is part of a Moko Consulting project.
@@ -12,63 +13,21 @@
* REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform * REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform
* PATH: /cli/create_project.php * PATH: /cli/create_project.php
* BRIEF: Create baseline GitHub Projects for repositories with standard fields and views * BRIEF: Create baseline GitHub Projects for repositories with standard fields and views
*
* USAGE
* php cli/create_project.php --repo MokoCRM # Auto-detect type, create project
* php cli/create_project.php --repo MokoCRM --type dolibarr # Force type
* php cli/create_project.php --org mokoconsulting-tech --all # All repos without projects
* php cli/create_project.php --repo MokoCRM --dry-run # Preview without changes
*/ */
declare(strict_types=1); declare(strict_types=1);
$dryRun = in_array('--dry-run', $argv); require_once __DIR__ . '/../lib/Enterprise/CliFramework.php';
$allMode = in_array('--all', $argv);
$org = 'mokoconsulting-tech'; use MokoEnterprise\CliFramework;
$repoName = null;
$typeOverride = null;
foreach ($argv as $i => $arg) { class CreateProjectCli extends CliFramework
if ($arg === '--repo' && isset($argv[$i + 1])) { {
$repoName = $argv[$i + 1]; /** @var string[] */
} private array $ALWAYS_EXCLUDE = ['moko-platform', '.github-private'];
if ($arg === '--org' && isset($argv[$i + 1])) {
$org = $argv[$i + 1];
}
if ($arg === '--type' && isset($argv[$i + 1])) {
$typeOverride = $argv[$i + 1];
}
}
if (!$repoName && !$allMode) { /** @var array<string, string> */
fwrite(STDERR, "Usage: php create_project.php --repo <name> [--type <type>] [--dry-run]\n"); private array $PLATFORM_TO_TYPE = [
fwrite(STDERR, " php create_project.php --all [--org <org>] [--dry-run]\n");
fwrite(STDERR, "\nTypes: generic, dolibarr, joomla, nodejs, terraform, python, wordpress, mobile-app, api, documentation\n");
exit(2);
}
$config = \MokoEnterprise\Config::load();
$platform = $config->getString('platform', 'gitea');
try {
$adapter = \MokoEnterprise\PlatformAdapterFactory::create($config);
$api = $adapter->getApiClient();
} catch (\Exception $e) {
fwrite(STDERR, "Platform initialization failed: " . $e->getMessage() . "\n");
exit(1);
}
$token = $platform === 'gitea'
? $config->getString('gitea.token', '')
: $config->getString('github.token', '');
$repoRoot = dirname(__DIR__, 2);
$templatesDir = "{$repoRoot}/templates/projects";
// ── Always-exclude list (no project needed) ─────────────────────────────
$ALWAYS_EXCLUDE = ['MokoStandards', '.github-private'];
// ── Platform type map ───────────────────────────────────────────────────
$PLATFORM_TO_TYPE = [
'crm-module' => 'dolibarr', 'crm-module' => 'dolibarr',
'crm-platform' => 'dolibarr', 'crm-platform' => 'dolibarr',
'waas-component' => 'joomla', 'waas-component' => 'joomla',
@@ -82,10 +41,10 @@ $PLATFORM_TO_TYPE = [
'mobile' => 'mobile-app', 'mobile' => 'mobile-app',
'api' => 'api', 'api' => 'api',
'documentation' => 'documentation', 'documentation' => 'documentation',
]; ];
// ── Template file map ─────────────────────────────────────────────────── /** @var array<string, string> */
$TYPE_TO_TEMPLATE = [ private array $TYPE_TO_TEMPLATE = [
'generic' => 'generic-project-definition.tf', 'generic' => 'generic-project-definition.tf',
'dolibarr' => 'dolibarr-project-definition.tf', 'dolibarr' => 'dolibarr-project-definition.tf',
'joomla' => 'joomla-project-definition.tf', 'joomla' => 'joomla-project-definition.tf',
@@ -96,15 +55,122 @@ $TYPE_TO_TEMPLATE = [
'mobile-app' => 'mobile-app-project-definition.tf', 'mobile-app' => 'mobile-app-project-definition.tf',
'api' => 'api-project-definition.tf', 'api' => 'api-project-definition.tf',
'documentation' => 'documentation-project-definition.tf', 'documentation' => 'documentation-project-definition.tf',
]; ];
/** protected function configure(): void
* Execute a GraphQL query (GitHub only — Gitea does not support GraphQL). {
* $this->setDescription('Create baseline GitHub Projects for repositories with standard fields and views');
* @return array<string, mixed> $this->addArgument('--repo', 'Repository name', '');
*/ $this->addArgument('--org', 'Organization (default: mokoconsulting-tech)', 'mokoconsulting-tech');
function graphql(string $query, array $variables, string $token, string $platformName = 'gitea'): array $this->addArgument('--type', 'Force project type', '');
{ $this->addArgument('--all', 'Process all repos without projects', false);
}
protected function run(): int
{
$repoName = $this->getArgument('--repo') ?: null;
$org = $this->getArgument('--org');
$typeOverride = $this->getArgument('--type') ?: null;
$allMode = $this->getArgument('--all');
if (!$repoName && !$allMode) {
$this->log('ERROR', "Usage: php create_project.php --repo <name> [--type <type>] [--dry-run]");
$this->log('ERROR', " php create_project.php --all [--org <org>] [--dry-run]");
$this->log('ERROR', "Types: generic, dolibarr, joomla, nodejs, terraform, python, wordpress, mobile-app, api, documentation");
return 2;
}
$config = \MokoEnterprise\Config::load();
$platformName = $config->getString('platform', 'gitea');
try {
$adapter = \MokoEnterprise\PlatformAdapterFactory::create($config);
$api = $adapter->getApiClient();
} catch (\Exception $e) {
$this->log('ERROR', "Platform initialization failed: " . $e->getMessage());
return 1;
}
$token = $platformName === 'gitea'
? $config->getString('gitea.token', '')
: $config->getString('github.token', '');
$repoRoot = dirname(__DIR__, 2);
$templatesDir = "{$repoRoot}/templates/projects";
$repos = [];
if ($allMode) {
echo "Fetching repositories from {$org}...\n";
$page = 1;
do {
$batch = $this->restGet("orgs/{$org}/repos?per_page=100&page={$page}&type=all", $token, $api);
foreach ($batch as $r) {
if (!$r['archived'] && !in_array($r['name'], $this->ALWAYS_EXCLUDE, true)) {
$repos[] = $r['name'];
}
}
$page++;
} while (count($batch) === 100);
sort($repos);
echo "Found " . count($repos) . " repositories\n\n";
} else {
$repos = [$repoName];
}
$ownerId = $this->getOrgNodeId($org, $token);
if (empty($ownerId)) {
$this->log('ERROR', "Could not resolve org node ID for {$org}");
return 1;
}
$created = 0;
$skipped = 0;
$failed = 0;
foreach ($repos as $repo) {
echo "Processing {$repo}...\n";
[$hasProject, $existingTitle] = $this->repoHasProject($org, $repo, $token);
if ($hasProject) {
echo " Already has project: {$existingTitle} -- skipping\n";
$skipped++;
continue;
}
$type = $typeOverride;
if (!$type) {
$platform = $this->detectRepoPlatform($org, $repo, $token, $api);
$type = $this->PLATFORM_TO_TYPE[$platform] ?? 'generic';
echo " Platform: {$platform} -> type: {$type}\n";
}
$templateFile = $this->TYPE_TO_TEMPLATE[$type] ?? $this->TYPE_TO_TEMPLATE['generic'];
$template = $this->parseTemplate("{$templatesDir}/{$templateFile}");
$repoId = $this->getRepoNodeId($org, $repo, $token);
if (empty($repoId)) {
$this->log('ERROR', " Could not resolve repo node ID for {$repo}");
$failed++;
continue;
}
$ok = $this->createProject($org, $repo, $ownerId, $repoId, $template, $token);
if ($ok) {
$created++;
} else {
$failed++;
}
echo "\n";
}
echo str_repeat('-', 50) . "\n";
echo "Done: {$created} created, {$skipped} skipped, {$failed} failed\n";
return $failed > 0 ? 1 : 0;
}
private function graphql(string $query, array $variables, string $token, string $platformName = 'gitea'): array
{
if ($platformName !== 'github') { if ($platformName !== 'github') {
return []; return [];
} }
@@ -117,7 +183,7 @@ function graphql(string $query, array $variables, string $token, string $platfor
CURLOPT_HTTPHEADER => [ CURLOPT_HTTPHEADER => [
'Authorization: bearer ' . $token, 'Authorization: bearer ' . $token,
'Content-Type: application/json', 'Content-Type: application/json',
'User-Agent: MokoStandards-CreateProject', 'User-Agent: moko-platform-CreateProject',
], ],
]); ]);
$body = (string) curl_exec($ch); $body = (string) curl_exec($ch);
@@ -125,27 +191,22 @@ function graphql(string $query, array $variables, string $token, string $platfor
curl_close($ch); curl_close($ch);
if ($status !== 200) { if ($status !== 200) {
fwrite(STDERR, "GraphQL request failed (HTTP {$status}): {$body}\n"); $this->log('ERROR', "GraphQL request failed (HTTP {$status}): {$body}");
return []; return [];
} }
$data = json_decode($body, true) ?? []; $data = json_decode($body, true) ?? [];
if (!empty($data['errors'])) { if (!empty($data['errors'])) {
foreach ($data['errors'] as $err) { foreach ($data['errors'] as $err) {
fwrite(STDERR, " GraphQL error: " . ($err['message'] ?? 'unknown') . "\n"); $this->log('ERROR', " GraphQL error: " . ($err['message'] ?? 'unknown'));
} }
} }
return $data['data'] ?? []; return $data['data'] ?? [];
} }
/** private function restGet(string $path, string $token, ?\MokoEnterprise\ApiClient $apiClient = null): array
* Execute a REST API GET call via the platform adapter's ApiClient. {
*
* @return array<string, mixed>
*/
function restGet(string $path, string $token, ?\MokoEnterprise\ApiClient $apiClient = null): array
{
if ($apiClient !== null) { if ($apiClient !== null) {
try { try {
return $apiClient->get("/{$path}"); return $apiClient->get("/{$path}");
@@ -154,16 +215,12 @@ function restGet(string $path, string $token, ?\MokoEnterprise\ApiClient $apiCli
} }
} }
return []; return [];
} }
/** private function detectRepoPlatform(string $org, string $repo, string $token, ?\MokoEnterprise\ApiClient $apiClient = null): string
* Detect platform type from .mokostandards file in the repo. {
*/
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) { foreach (['.github/.mokostandards', '.mokogitea/.mokostandards', '.mokostandards'] as $path) {
$data = restGet("repos/{$org}/{$repo}/contents/{$path}", $token, $apiClient); $data = $this->restGet("repos/{$org}/{$repo}/contents/{$path}", $token, $apiClient);
if (!empty($data['content'])) { if (!empty($data['content'])) {
$content = base64_decode($data['content']); $content = base64_decode($data['content']);
if (preg_match('/^platform:\s*(.+)/m', $content, $m)) { if (preg_match('/^platform:\s*(.+)/m', $content, $m)) {
@@ -172,42 +229,32 @@ function detectRepoPlatform(string $org, string $repo, string $token, ?\MokoEnte
} }
} }
return ''; return '';
} }
/** private function getRepoNodeId(string $org, string $repo, string $token): string
* Get the GitHub node ID for a repository. {
*/ $data = $this->graphql(
function getRepoNodeId(string $org, string $repo, string $token): string
{
$data = graphql(
'query($owner: String!, $name: String!) { repository(owner: $owner, name: $name) { id } }', 'query($owner: String!, $name: String!) { repository(owner: $owner, name: $name) { id } }',
['owner' => $org, 'name' => $repo], ['owner' => $org, 'name' => $repo],
$token $token
); );
return $data['repository']['id'] ?? ''; return $data['repository']['id'] ?? '';
} }
/** private function getOrgNodeId(string $org, string $token): string
* Get the GitHub node ID for the organization owner. {
*/ $data = $this->graphql(
function getOrgNodeId(string $org, string $token): string
{
$data = graphql(
'query($login: String!) { organization(login: $login) { id } }', 'query($login: String!) { organization(login: $login) { id } }',
['login' => $org], ['login' => $org],
$token $token
); );
return $data['organization']['id'] ?? ''; return $data['organization']['id'] ?? '';
} }
/** /** @return array{bool, string} */
* Check if a repo already has a GitHub Project linked. private function repoHasProject(string $org, string $repo, string $token): array
* {
* @return array{bool, string} [hasProject, projectTitle] $data = $this->graphql(
*/
function repoHasProject(string $org, string $repo, string $token): array
{
$data = graphql(
'query($owner: String!, $name: String!) { 'query($owner: String!, $name: String!) {
repository(owner: $owner, name: $name) { repository(owner: $owner, name: $name) {
projectsV2(first: 1) { nodes { id title } totalCount } projectsV2(first: 1) { nodes { id title } totalCount }
@@ -220,15 +267,11 @@ function repoHasProject(string $org, string $repo, string $token): array
$count = $data['repository']['projectsV2']['totalCount'] ?? 0; $count = $data['repository']['projectsV2']['totalCount'] ?? 0;
$title = $data['repository']['projectsV2']['nodes'][0]['title'] ?? ''; $title = $data['repository']['projectsV2']['nodes'][0]['title'] ?? '';
return [$count > 0, $title]; return [$count > 0, $title];
} }
/** /** @return array{name: string, fields: array, views: array} */
* Parse a .tf template file to extract custom fields. private function parseTemplate(string $filePath): array
* {
* @return array{name: string, fields: array, views: array}
*/
function parseTemplate(string $filePath): array
{
if (!file_exists($filePath)) { if (!file_exists($filePath)) {
return ['name' => 'Development Board', 'fields' => [], 'views' => []]; return ['name' => 'Development Board', 'fields' => [], 'views' => []];
} }
@@ -236,13 +279,14 @@ function parseTemplate(string $filePath): array
$content = file_get_contents($filePath); $content = file_get_contents($filePath);
$result = ['name' => 'Development Board', 'fields' => [], 'views' => []]; $result = ['name' => 'Development Board', 'fields' => [], 'views' => []];
// Extract project name
if (preg_match('/name\s*=\s*"([^"]+)"/', $content, $m)) { if (preg_match('/name\s*=\s*"([^"]+)"/', $content, $m)) {
$result['name'] = $m[1]; $result['name'] = $m[1];
} }
// Extract custom fields $fieldPattern = '/\{\s*name\s*=\s*"([^"]+)"\s*type\s*=\s*"([^"]+)"'
if (preg_match_all('/\{\s*name\s*=\s*"([^"]+)"\s*type\s*=\s*"([^"]+)"\s*description\s*=\s*"([^"]+)"(?:\s*options\s*=\s*\[([^\]]*)\])?\s*\}/s', $content, $matches, PREG_SET_ORDER)) { . '\s*description\s*=\s*"([^"]+)"'
. '(?:\s*options\s*=\s*\[([^\]]*)\])?\s*\}/s';
if (preg_match_all($fieldPattern, $content, $matches, PREG_SET_ORDER)) {
foreach ($matches as $match) { foreach ($matches as $match) {
$field = [ $field = [
'name' => $match[1], 'name' => $match[1],
@@ -261,31 +305,26 @@ function parseTemplate(string $filePath): array
} }
return $result; return $result;
} }
/** private function createProject(
* Create a GitHub Project V2 for a repository.
*/
function createProject(
string $org, string $org,
string $repo, string $repo,
string $ownerId, string $ownerId,
string $repoId, string $repoId,
array $template, array $template,
string $token, string $token
bool $dryRun ): bool {
): bool { $title = "{$repo} -- {$template['name']}";
$title = "{$repo}{$template['name']}";
if ($dryRun) { if ($this->dryRun) {
echo " (dry-run) would create project: {$title}\n"; echo " (dry-run) would create project: {$title}\n";
echo " (dry-run) fields: " . count($template['fields']) . "\n"; echo " (dry-run) fields: " . count($template['fields']) . "\n";
return true; return true;
} }
// Step 1: Create the project
echo " Creating project: {$title}\n"; echo " Creating project: {$title}\n";
$data = graphql( $data = $this->graphql(
'mutation($ownerId: ID!, $title: String!) { 'mutation($ownerId: ID!, $title: String!) {
createProjectV2(input: { ownerId: $ownerId, title: $title }) { createProjectV2(input: { ownerId: $ownerId, title: $title }) {
projectV2 { id number url } projectV2 { id number url }
@@ -299,14 +338,13 @@ function createProject(
$projectUrl = $data['createProjectV2']['projectV2']['url'] ?? ''; $projectUrl = $data['createProjectV2']['projectV2']['url'] ?? '';
if (empty($projectId)) { if (empty($projectId)) {
fwrite(STDERR, " Failed to create project for {$repo}\n"); $this->log('ERROR', " Failed to create project for {$repo}");
return false; return false;
} }
echo " Project created: {$projectUrl}\n"; echo " Project created: {$projectUrl}\n";
// Step 2: Link the project to the repository $this->graphql(
graphql(
'mutation($projectId: ID!, $repositoryId: ID!) { 'mutation($projectId: ID!, $repositoryId: ID!) {
linkProjectV2ToRepository(input: { projectId: $projectId, repositoryId: $repositoryId }) { linkProjectV2ToRepository(input: { projectId: $projectId, repositoryId: $repositoryId }) {
repository { id } repository { id }
@@ -317,7 +355,6 @@ function createProject(
); );
echo " Linked to {$org}/{$repo}\n"; echo " Linked to {$org}/{$repo}\n";
// Step 3: Create custom fields
$fieldCount = 0; $fieldCount = 0;
foreach ($template['fields'] as $field) { foreach ($template['fields'] as $field) {
$fieldType = match ($field['type']) { $fieldType = match ($field['type']) {
@@ -335,7 +372,6 @@ function createProject(
'dataType' => $fieldType, 'dataType' => $fieldType,
]; ];
// Single select fields need options created with the field
if ($fieldType === 'SINGLE_SELECT' && !empty($field['options'])) { if ($fieldType === 'SINGLE_SELECT' && !empty($field['options'])) {
$optionInputs = array_map( $optionInputs = array_map(
fn($o) => ['name' => $o, 'description' => '', 'color' => 'GRAY'], fn($o) => ['name' => $o, 'description' => '', 'color' => 'GRAY'],
@@ -343,8 +379,11 @@ function createProject(
); );
$vars['singleSelectOptions'] = $optionInputs; $vars['singleSelectOptions'] = $optionInputs;
graphql( $this->graphql(
'mutation($projectId: ID!, $name: String!, $dataType: ProjectV2CustomFieldType!, $singleSelectOptions: [ProjectV2SingleSelectFieldOptionInput!]) { 'mutation($projectId: ID!, $name: String!,'
. ' $dataType: ProjectV2CustomFieldType!,'
. ' $singleSelectOptions:'
. ' [ProjectV2SingleSelectFieldOptionInput!]) {
createProjectV2Field(input: { createProjectV2Field(input: {
projectId: $projectId, projectId: $projectId,
dataType: $dataType, dataType: $dataType,
@@ -358,7 +397,7 @@ function createProject(
$token $token
); );
} else { } else {
graphql( $this->graphql(
'mutation($projectId: ID!, $name: String!, $dataType: ProjectV2CustomFieldType!) { 'mutation($projectId: ID!, $name: String!, $dataType: ProjectV2CustomFieldType!) {
createProjectV2Field(input: { createProjectV2Field(input: {
projectId: $projectId, projectId: $projectId,
@@ -378,103 +417,27 @@ function createProject(
echo " Created {$fieldCount} custom fields\n"; echo " Created {$fieldCount} custom fields\n";
// Step 4: Update project description and README $this->graphql(
graphql(
'mutation($projectId: ID!, $shortDescription: String!) { 'mutation($projectId: ID!, $shortDescription: String!) {
updateProjectV2(input: { updateProjectV2(input: {
projectId: $projectId, projectId: $projectId,
shortDescription: $shortDescription, shortDescription: $shortDescription,
readme: "Managed by MokoStandards. Run `php cli/create_project.php` to regenerate." readme: "Managed by moko-platform. Run `php cli/create_project.php` to regenerate."
}) { }) {
projectV2 { id } projectV2 { id }
} }
}', }',
[ [
'projectId' => $projectId, 'projectId' => $projectId,
'shortDescription' => "Standard project board for {$repo}. Auto-created by MokoStandards.", 'shortDescription' => "Standard project board for {$repo}. Auto-created by moko-platform.",
], ],
$token $token
); );
echo " Project setup complete\n"; echo " Project setup complete\n";
return true; return true;
}
} }
// ── Main ──────────────────────────────────────────────────────────────── $app = new CreateProjectCli();
exit($app->execute());
$repos = [];
if ($allMode) {
echo "Fetching repositories from {$org}...\n";
$page = 1;
do {
$batch = restGet("orgs/{$org}/repos?per_page=100&page={$page}&type=all", $token);
foreach ($batch as $r) {
if (!$r['archived'] && !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];
}
$ownerId = getOrgNodeId($org, $token);
if (empty($ownerId)) {
fwrite(STDERR, "Could not resolve org node ID for {$org}\n");
exit(1);
}
$created = 0;
$skipped = 0;
$failed = 0;
foreach ($repos as $repo) {
echo "Processing {$repo}...\n";
// Check if project already exists
[$hasProject, $existingTitle] = repoHasProject($org, $repo, $token);
if ($hasProject) {
echo " Already has project: {$existingTitle} — skipping\n";
$skipped++;
continue;
}
// Detect project type
$type = $typeOverride;
if (!$type) {
$platform = detectRepoPlatform($org, $repo, $token);
$type = $PLATFORM_TO_TYPE[$platform] ?? 'generic';
echo " Platform: {$platform} → type: {$type}\n";
}
// Load template
$templateFile = $TYPE_TO_TEMPLATE[$type] ?? $TYPE_TO_TEMPLATE['generic'];
$template = parseTemplate("{$templatesDir}/{$templateFile}");
// Get repo node ID
$repoId = getRepoNodeId($org, $repo, $token);
if (empty($repoId)) {
fwrite(STDERR, " Could not resolve repo node ID for {$repo}\n");
$failed++;
continue;
}
// Create the project
$ok = createProject($org, $repo, $ownerId, $repoId, $template, $token, $dryRun);
if ($ok) {
$created++;
} else {
$failed++;
}
echo "\n";
}
echo str_repeat('-', 50) . "\n";
echo "Done: {$created} created, {$skipped} skipped, {$failed} failed\n";
exit($failed > 0 ? 1 : 0);
+125 -151
View File
@@ -1,5 +1,6 @@
#!/usr/bin/env php #!/usr/bin/env php
<?php <?php
/* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech> /* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
* *
* This file is part of a Moko Consulting project. * This file is part of a Moko Consulting project.
@@ -11,50 +12,44 @@
* INGROUP: moko-platform * INGROUP: moko-platform
* REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform * REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform
* PATH: /cli/create_repo.php * PATH: /cli/create_repo.php
* BRIEF: Scaffold a new governed repository with full MokoStandards baseline * BRIEF: Scaffold a new governed repository with full moko-platform baseline
*
* USAGE
* php cli/create_repo.php --name MokoNewModule --type dolibarr --description "My new module"
* php cli/create_repo.php --name MokoNewModule --type joomla --private
* php cli/create_repo.php --name MokoNewModule --type generic --dry-run
*/ */
declare(strict_types=1); declare(strict_types=1);
require_once __DIR__ . '/../vendor/autoload.php'; require_once __DIR__ . '/../vendor/autoload.php';
require_once __DIR__ . '/../lib/Enterprise/CliFramework.php';
use MokoEnterprise\CliFramework;
use MokoEnterprise\Config; use MokoEnterprise\Config;
use MokoEnterprise\PlatformAdapterFactory; use MokoEnterprise\PlatformAdapterFactory;
$dryRun = in_array('--dry-run', $argv); class CreateRepoCli extends CliFramework
$private = in_array('--private', $argv); {
protected function configure(): void
{
$this->setDescription('Scaffold a new governed repository with full moko-platform baseline');
$this->addArgument('--name', 'Repository name', null);
$this->addArgument('--type', 'Project type', null);
$this->addArgument('--description', 'Repository description', '');
$this->addArgument('--private', 'Create as private', false);
}
$name = null; protected function run(): int
$type = null; {
$description = ''; $name = $this->getArgument('--name');
$type = $this->getArgument('--type');
foreach ($argv as $i => $arg) { $description = $this->getArgument('--description');
if ($arg === '--name' && isset($argv[$i + 1])) { $name = $argv[$i + 1]; } $private = (bool) $this->getArgument('--private');
if ($arg === '--type' && isset($argv[$i + 1])) { $type = $argv[$i + 1]; } if (!$name || !$type) {
if ($arg === '--description' && isset($argv[$i + 1])) { $description = $argv[$i + 1]; } $this->log('ERROR', "Usage: php create_repo.php --name <RepoName> --type <type> [--description \"...\"] [--private] [--dry-run]");
} return 2;
}
if (!$name || !$type) { $config = Config::load();
fwrite(STDERR, "Usage: php create_repo.php --name <RepoName> --type <type> [--description \"...\"] [--private] [--dry-run]\n"); $adapter = PlatformAdapterFactory::create($config);
fwrite(STDERR, "\nTypes: generic, dolibarr, dolibarr-platform, joomla, nodejs, terraform, python, wordpress\n"); $org = $config->getString($adapter->getPlatformName() . '.organization', 'mokoconsulting-tech');
exit(2); $repoRoot = dirname(__DIR__, 2);
} $TYPE_TO_PLATFORM = [
$config = Config::load();
$adapter = PlatformAdapterFactory::create($config);
$org = $config->getString(
$adapter->getPlatformName() . '.organization',
'mokoconsulting-tech'
);
$repoRoot = dirname(__DIR__, 2);
$TYPE_TO_PLATFORM = [
'dolibarr' => 'crm-module', 'dolibarr' => 'crm-module',
'dolibarr-platform' => 'crm-platform', 'dolibarr-platform' => 'crm-platform',
'joomla' => 'waas-component', 'joomla' => 'waas-component',
@@ -63,34 +58,33 @@ $TYPE_TO_PLATFORM = [
'python' => 'python', 'python' => 'python',
'wordpress' => 'wordpress', 'wordpress' => 'wordpress',
'generic' => 'generic', 'generic' => 'generic',
]; ];
$TYPE_TO_TOPICS = [
'dolibarr' => ['dolibarr', 'erp', 'crm', 'php', 'moko-platform'],
'joomla' => ['joomla', 'cms', 'php', 'moko-platform'],
'nodejs' => ['nodejs', 'javascript', 'typescript', 'moko-platform'],
'terraform' => ['terraform', 'infrastructure', 'iac', 'moko-platform'],
'python' => ['python', 'moko-platform'],
'wordpress' => ['wordpress', 'php', 'cms', 'moko-platform'],
'generic' => ['moko-platform'],
];
$platform = $TYPE_TO_PLATFORM[$type] ?? 'generic';
$topics = $TYPE_TO_TOPICS[$type] ?? ['moko-platform'];
$platformName = $adapter->getPlatformName();
$vis = $private ? 'private' : 'public';
echo "Scaffolding new repository: {$org}/{$name}"
. " (on {$platformName})\n"
. " Type: {$type} (platform: {$platform})\n"
. " Visibility: {$vis}\n";
if ($description) {
echo " Description: {$description}\n";
} echo "\n";
$TYPE_TO_TOPICS = [ echo "Step 1: Creating repository...\n";
'dolibarr' => ['dolibarr', 'erp', 'crm', 'php', 'mokostandards'], if (!$this->dryRun) {
'joomla' => ['joomla', 'cms', 'php', 'mokostandards'],
'nodejs' => ['nodejs', 'javascript', 'typescript', 'mokostandards'],
'terraform' => ['terraform', 'infrastructure', 'iac', 'mokostandards'],
'python' => ['python', 'mokostandards'],
'wordpress' => ['wordpress', 'php', 'cms', 'mokostandards'],
'generic' => ['mokostandards'],
];
$platform = $TYPE_TO_PLATFORM[$type] ?? 'generic';
$topics = $TYPE_TO_TOPICS[$type] ?? ['mokostandards'];
$platformName = $adapter->getPlatformName();
echo "Scaffolding new repository: {$org}/{$name} (on {$platformName})\n";
echo " Type: {$type} (platform: {$platform})\n";
echo " Visibility: " . ($private ? 'private' : 'public') . "\n";
if ($description) { echo " Description: {$description}\n"; }
echo "\n";
// ── Step 1: Create the repository ───────────────────────────────────────
echo "Step 1: Creating repository...\n";
if (!$dryRun) {
try { try {
$data = $adapter->createOrgRepo($org, $name, [ $data = $adapter->createOrgRepo($org, $name, [
'description' => $description ?: "Managed by MokoStandards ({$type})", 'description' => $description ?: "Managed by moko-platform ({$type})",
'private' => $private, 'private' => $private,
'has_issues' => true, 'has_issues' => true,
'has_projects' => true, 'has_projects' => true,
@@ -104,89 +98,65 @@ if (!$dryRun) {
echo " Created: " . ($data['html_url'] ?? "{$org}/{$name}") . "\n"; echo " Created: " . ($data['html_url'] ?? "{$org}/{$name}") . "\n";
} catch (\Exception $e) { } catch (\Exception $e) {
if (str_contains($e->getMessage(), '422') || str_contains($e->getMessage(), 'already exists')) { if (str_contains($e->getMessage(), '422') || str_contains($e->getMessage(), 'already exists')) {
echo " Repository already exists continuing with setup\n"; echo " Repository already exists -- continuing with setup\n";
} else { } else {
fwrite(STDERR, " Failed to create repo: " . $e->getMessage() . "\n"); $this->log('ERROR', "Failed to create repo: " . $e->getMessage());
exit(1); return 1;
} }
} }
} else { } else {
echo " (dry-run) would create {$org}/{$name}\n"; echo " (dry-run) would create {$org}/{$name}\n";
} }
// ── Step 2: Set topics ────────────────────────────────────────────────── echo "Step 2: Setting topics...\n";
echo "Step 2: Setting topics...\n"; if (!$this->dryRun) {
if (!$dryRun) {
$adapter->setRepoTopics($org, $name, $topics); $adapter->setRepoTopics($org, $name, $topics);
echo " Topics: " . implode(', ', $topics) . "\n"; echo " Topics: " . implode(', ', $topics) . "\n";
} else { } else {
echo " (dry-run) would set topics: " . implode(', ', $topics) . "\n"; echo " (dry-run) would set topics: " . implode(', ', $topics) . "\n";
} }
// ── Step 3: Create .mokostandards file ────────────────────────────────── echo "Step 3: Creating .mokogitea/manifest.xml...\n";
echo "Step 3: Creating .github/.mokostandards...\n"; $mokoContent = "platform: {$platform}\nversion: 04.02.30\nmanaged: true\n";
$mokoContent = "platform: {$platform}\nversion: 04.02.30\nmanaged: true\n"; if (!$this->dryRun) {
if (!$dryRun) {
try { try {
$adapter->createOrUpdateFile( $adapter->createOrUpdateFile(
$org, $name, '.github/.mokostandards', $mokoContent, $org,
'chore: add .mokostandards platform config [skip ci]' $name,
'.mokogitea/manifest.xml',
$mokoContent,
'chore: add manifest.xml platform config [skip ci]'
); );
echo " .mokostandards created\n"; echo " manifest.xml created\n";
} catch (\Exception $e) { } catch (\Exception $e) {
echo " Warning: " . $e->getMessage() . "\n"; echo " Warning: " . $e->getMessage() . "\n";
} }
} else { } else {
echo " (dry-run) would create .github/.mokostandards\n"; echo " (dry-run) would create .mokogitea/manifest.xml\n";
} }
// ── Step 4: Create initial README.md ──────────────────────────────────── echo "Step 4: Creating README.md...\n";
echo "Step 4: Creating README.md...\n"; $baseUrl = $platformName === 'gitea' ? $config->getString('gitea.url', 'https://git.mokoconsulting.tech') : 'https://github.com';
$repoUrl = "{$baseUrl}/{$org}/{$name}";
// Determine the repo base URL based on platform $standardsUrl = "{$baseUrl}/{$org}/MokoStandards";
$baseUrl = $platformName === 'gitea' $readmeContent = "<!--\n"
? $config->getString('gitea.url', 'https://git.mokoconsulting.tech') . "Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>\n"
: 'https://github.com'; . "SPDX-License-Identifier: GPL-3.0-or-later\n"
$repoUrl = "{$baseUrl}/{$org}/{$name}"; . "DEFGROUP: {$name}\n"
$standardsUrl = "{$baseUrl}/{$org}/MokoStandards"; . "INGROUP: moko-platform\n"
. "REPO: {$repoUrl}\n"
$readmeContent = <<<MD . "PATH: /README.md\n"
<!-- . "BRIEF: {$description}\n"
Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech> . "-->\n\n"
. "# {$name}\n\n"
SPDX-License-Identifier: GPL-3.0-or-later . "{$description}\n\n"
. "## Getting Started\n\n"
# FILE INFORMATION . "This repository is governed by"
DEFGROUP: {$name} . " [moko-platform]({$standardsUrl}).\n\n"
INGROUP: moko-platform . "## License\n\n"
REPO: {$repoUrl} . "GPL-3.0-or-later. See [LICENSE](LICENSE)"
PATH: /README.md . " for details.\n";
BRIEF: {$description} if (!$this->dryRun) {
-->
# {$name}
[![MokoStandards](https://img.shields.io/badge/MokoStandards-04.06.00-blue)]({$standardsUrl})
[![Version](https://img.shields.io/badge/version-01.00.00-green)]({$repoUrl})
{$description}
## Getting Started
This repository is governed by [MokoStandards]({$standardsUrl}).
## License
This project is licensed under the GPL-3.0-or-later license. See [LICENSE](LICENSE) for details.
---
*This file is part of the Moko Consulting ecosystem. All rights reserved.*
*This program is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version.*
MD;
if (!$dryRun) {
// Get existing README sha (auto_init creates one)
$sha = null; $sha = null;
try { try {
$existing = $adapter->getFileContents($org, $name, 'README.md'); $existing = $adapter->getFileContents($org, $name, 'README.md');
@@ -194,60 +164,64 @@ if (!$dryRun) {
} catch (\Exception $e) { } catch (\Exception $e) {
$adapter->getApiClient()->resetCircuitBreaker(); $adapter->getApiClient()->resetCircuitBreaker();
} }
$adapter->createOrUpdateFile( $adapter->createOrUpdateFile(
$org, $name, 'README.md', $readmeContent, $org,
'docs: initialize README with MokoStandards header [skip ci]', $name,
'README.md',
$readmeContent,
'docs: initialize README with moko-platform header [skip ci]',
$sha $sha
); );
echo " README.md created\n"; echo " README.md created\n";
} else { } else {
echo " (dry-run) would create README.md\n"; echo " (dry-run) would create README.md\n";
} }
// ── Step 5: Provision labels ──────────────────────────────────────────── echo "Step 5: Provisioning labels...\n";
echo "Step 5: Provisioning labels...\n"; if (!$this->dryRun) {
if (!$dryRun) {
$labelScript = "{$repoRoot}/api/maintenance/setup_labels.php"; $labelScript = "{$repoRoot}/api/maintenance/setup_labels.php";
if (file_exists($labelScript)) { if (file_exists($labelScript)) {
$exitCode = 0; $exitCode = 0;
passthru("php " . escapeshellarg($labelScript) . " --org " . escapeshellarg($org) . " --repo " . escapeshellarg($name), $exitCode); passthru("php " . escapeshellarg($labelScript) . " --org " . escapeshellarg($org) . " --repo " . escapeshellarg($name), $exitCode);
echo $exitCode === 0 ? " Labels provisioned\n" : " Label provisioning had issues\n";
} else { } else {
echo " Labels will be provisioned on next sync\n"; echo " Labels will be provisioned on next sync\n";
} }
} else { } else {
echo " (dry-run) would provision standard labels\n"; echo " (dry-run) would provision standard labels\n";
} }
// ── Step 6: Run first sync ────────────────────────────────────────────── echo "Step 6: Running initial sync...\n";
echo "Step 6: Running initial sync...\n"; if (!$this->dryRun) {
if (!$dryRun) {
$syncScript = "{$repoRoot}/api/automation/bulk_sync.php"; $syncScript = "{$repoRoot}/api/automation/bulk_sync.php";
if (file_exists($syncScript)) { if (file_exists($syncScript)) {
passthru("php " . escapeshellarg($syncScript) . " --repos " . escapeshellarg($name) . " --force --yes"); passthru("php " . escapeshellarg($syncScript) . " --repos " . escapeshellarg($name) . " --force --yes");
} else { } else {
echo " Run manually: php automation/bulk_sync.php --repos {$name} --force --yes\n"; echo " Run manually: php automation/bulk_sync.php --repos {$name} --force --yes\n";
} }
} else { } else {
echo " (dry-run) would run initial sync\n"; echo " (dry-run) would run initial sync\n";
} }
// ── Step 7: Create Project ────────────────────────────────────────────── echo "Step 7: Creating Project...\n";
echo "Step 7: Creating Project...\n"; if (!$this->dryRun) {
if (!$dryRun) {
$projectScript = "{$repoRoot}/api/cli/create_project.php"; $projectScript = "{$repoRoot}/api/cli/create_project.php";
if (file_exists($projectScript)) { if (file_exists($projectScript)) {
passthru("php " . escapeshellarg($projectScript) . " --repo " . escapeshellarg($name) . " --type " . escapeshellarg($type)); passthru("php " . escapeshellarg($projectScript) . " --repo " . escapeshellarg($name) . " --type " . escapeshellarg($type));
} else { } else {
echo " Run manually: php cli/create_project.php --repo {$name} --type {$type}\n"; echo " Run manually: php cli/create_project.php --repo {$name} --type {$type}\n";
} }
} else { } else {
echo " (dry-run) would create Project\n"; echo " (dry-run) would create Project\n";
}
echo "\n" . str_repeat('-', 50) . "\n"
. "Repository {$org}/{$name} scaffolded successfully\n"
. " URL: {$repoUrl}\n"
. " Platform: {$platform} ({$platformName})\n"
. " Next: verify the sync and merge any PRs\n";
return 0;
}
} }
echo "\n" . str_repeat('-', 50) . "\n"; $app = new CreateRepoCli();
echo "Repository {$org}/{$name} scaffolded successfully\n"; exit($app->execute());
echo " URL: {$repoUrl}\n";
echo " Platform: {$platform} ({$platformName})\n";
echo " Next: verify the sync and merge any PRs\n";
File diff suppressed because it is too large Load Diff
+61 -58
View File
@@ -1,5 +1,6 @@
#!/usr/bin/env php #!/usr/bin/env php
<?php <?php
/* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech> /* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
* *
* SPDX-License-Identifier: GPL-3.0-or-later * SPDX-License-Identifier: GPL-3.0-or-later
@@ -10,88 +11,90 @@
* REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform * REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform
* PATH: /cli/dev_branch_reset.php * PATH: /cli/dev_branch_reset.php
* BRIEF: Delete and recreate dev branch from main via Gitea API * BRIEF: Delete and recreate dev branch from main via Gitea API
*
* Usage:
* php dev_branch_reset.php --token TOKEN --api-base URL
* php dev_branch_reset.php --token TOKEN --api-base URL --branch dev --from main
*
* Options:
* --token Gitea API token (required)
* --api-base Gitea API base URL (required)
* --branch Branch to reset (default: dev)
* --from Source branch (default: main)
* --output-summary Write to $GITHUB_STEP_SUMMARY
*/ */
declare(strict_types=1); declare(strict_types=1);
$token = null; require_once __DIR__ . '/../lib/Enterprise/CliFramework.php';
$apiBase = null;
$branch = 'dev';
$from = 'main';
$outputSummary = false;
foreach ($argv as $i => $arg) { use MokoEnterprise\CliFramework;
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 === '--branch' && isset($argv[$i + 1])) $branch = $argv[$i + 1];
if ($arg === '--from' && isset($argv[$i + 1])) $from = $argv[$i + 1];
if ($arg === '--output-summary') $outputSummary = true;
}
if ($token === null) $token = getenv('MOKOGITEA_TOKEN') ?: getenv('GITEA_TOKEN') ?: null; class DevBranchResetCli extends CliFramework
{
protected function configure(): void
{
$this->setDescription('Delete and recreate dev branch from main via Gitea API');
$this->addArgument('--token', 'Gitea API token', '');
$this->addArgument('--api-base', 'Gitea API base URL', '');
$this->addArgument('--branch', 'Branch to reset', 'dev');
$this->addArgument('--from', 'Source branch', 'main');
$this->addArgument('--output-summary', 'Write to $GITHUB_STEP_SUMMARY', false);
}
if ($token === null || $apiBase === null) { protected function run(): int
fwrite(STDERR, "Usage: dev_branch_reset.php --token TOKEN --api-base URL [--branch dev] [--from main]\n"); {
exit(1); $token = $this->getArgument('--token') ?: getenv('MOKOGITEA_TOKEN') ?: getenv('GITEA_TOKEN') ?: '';
} $apiBase = $this->getArgument('--api-base');
$branch = $this->getArgument('--branch');
$from = $this->getArgument('--from');
$outputSummary = $this->getArgument('--output-summary');
// Delete branch (tolerate 404) if (empty($token) || empty($apiBase)) {
$ch = curl_init("{$apiBase}/branches/{$branch}"); $this->log('ERROR', 'Usage: dev_branch_reset.php --token TOKEN --api-base URL [--branch dev] [--from main]');
curl_setopt_array($ch, [ return 1;
}
// Delete branch (tolerate 404)
$ch = curl_init("{$apiBase}/branches/{$branch}");
curl_setopt_array($ch, [
CURLOPT_CUSTOMREQUEST => 'DELETE', CURLOPT_CUSTOMREQUEST => 'DELETE',
CURLOPT_RETURNTRANSFER => true, CURLOPT_RETURNTRANSFER => true,
CURLOPT_HTTPHEADER => ["Authorization: token {$token}"], CURLOPT_HTTPHEADER => ["Authorization: token {$token}"],
CURLOPT_TIMEOUT => 30, CURLOPT_TIMEOUT => 30,
]); ]);
curl_exec($ch); curl_exec($ch);
$delCode = curl_getinfo($ch, CURLINFO_HTTP_CODE); $delCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch); curl_close($ch);
if ($delCode === 204 || $delCode === 200) { if ($delCode === 204 || $delCode === 200) {
echo "Deleted branch '{$branch}'\n"; $this->log('INFO', "Deleted branch '{$branch}'");
} elseif ($delCode === 404) { } elseif ($delCode === 404) {
echo "Branch '{$branch}' did not exist (skipped delete)\n"; $this->log('INFO', "Branch '{$branch}' did not exist (skipped delete)");
} else { } else {
fwrite(STDERR, "WARNING: Delete branch returned HTTP {$delCode}\n"); $this->warning("Delete branch returned HTTP {$delCode}");
} }
// Create branch from source // Create branch from source
$payload = json_encode(['new_branch_name' => $branch, 'old_branch_name' => $from]); $payload = json_encode(['new_branch_name' => $branch, 'old_branch_name' => $from]);
$ch = curl_init("{$apiBase}/branches"); $ch = curl_init("{$apiBase}/branches");
curl_setopt_array($ch, [ curl_setopt_array($ch, [
CURLOPT_POST => true, CURLOPT_POST => true,
CURLOPT_RETURNTRANSFER => true, CURLOPT_RETURNTRANSFER => true,
CURLOPT_HTTPHEADER => ["Authorization: token {$token}", "Content-Type: application/json"], CURLOPT_HTTPHEADER => ["Authorization: token {$token}", "Content-Type: application/json"],
CURLOPT_POSTFIELDS => $payload, CURLOPT_POSTFIELDS => $payload,
CURLOPT_TIMEOUT => 30, CURLOPT_TIMEOUT => 30,
]); ]);
$response = curl_exec($ch); $response = curl_exec($ch);
$createCode = curl_getinfo($ch, CURLINFO_HTTP_CODE); $createCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch); curl_close($ch);
if ($createCode === 201) { if ($createCode === 201) {
echo "Recreated '{$branch}' from '{$from}'\n"; $this->success("Recreated '{$branch}' from '{$from}'");
} else { } else {
fwrite(STDERR, "Failed to create branch '{$branch}' from '{$from}' (HTTP {$createCode})\n"); $this->log('ERROR', "Failed to create branch '{$branch}' from '{$from}' (HTTP {$createCode})");
exit(1); return 1;
} }
if ($outputSummary) { if ($outputSummary) {
$summaryFile = getenv('GITHUB_STEP_SUMMARY'); $summaryFile = getenv('GITHUB_STEP_SUMMARY');
if ($summaryFile) { if ($summaryFile) {
file_put_contents($summaryFile, "Dev branch reset: '{$branch}' recreated from '{$from}'\n", FILE_APPEND); file_put_contents($summaryFile, "Dev branch reset: '{$branch}' recreated from '{$from}'\n", FILE_APPEND);
} }
}
return 0;
}
} }
exit(0); $app = new DevBranchResetCli();
exit($app->execute());
+78 -175
View File
@@ -12,13 +12,17 @@
* INGROUP: moko-platform * INGROUP: moko-platform
* REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform * REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform
* PATH: /cli/grafana_dashboard.php * PATH: /cli/grafana_dashboard.php
* VERSION: 09.21.00 * VERSION: 09.24.00
* BRIEF: Manage Grafana dashboards via API * BRIEF: Manage Grafana dashboards via API
*/ */
declare(strict_types=1); declare(strict_types=1);
final class GrafanaDashboard require_once __DIR__ . '/../lib/Enterprise/CliFramework.php';
use MokoEnterprise\CliFramework;
class GrafanaDashboardCli extends CliFramework
{ {
private string $grafanaUrl = ''; private string $grafanaUrl = '';
private string $token = ''; private string $token = '';
@@ -29,24 +33,52 @@ final class GrafanaDashboard
private string $folderTitle = ''; private string $folderTitle = '';
private bool $overwrite = true; private bool $overwrite = true;
public function run(): int protected function configure(): void
{ {
$this->parseArgs(); $this->setDescription('Manage Grafana dashboards via API');
$this->addArgument('--url', 'Grafana URL (or GRAFANA_URL)', '');
$this->addArgument('--token', 'API token (or GRAFANA_TOKEN)', '');
$this->addArgument('--uid', 'Dashboard UID (delete/export)', '');
$this->addArgument('--file', 'JSON file (push/export)', '');
$this->addArgument('--folder', 'Folder name (push/list)', '');
$this->addArgument('--folder-id', 'Folder ID (push/list)', '0');
$this->addArgument('--no-overwrite', 'Fail if dashboard exists', false);
$this->addArgument('--command', 'Command: push, delete, list, export', '');
}
protected function run(): int
{
// Parse positional command from raw argv
$rawArgs = $_SERVER['argv'] ?? [];
foreach ($rawArgs as $arg) {
if (in_array($arg, ['push', 'delete', 'list', 'export'], true)) {
$this->command = $arg;
break;
}
}
if ($this->command === '' && $this->getArgument('--command') !== '') {
$this->command = $this->getArgument('--command');
}
$this->grafanaUrl = $this->getArgument('--url');
$this->token = $this->getArgument('--token');
$this->uid = $this->getArgument('--uid');
$this->file = $this->getArgument('--file');
$this->folderTitle = $this->getArgument('--folder');
$this->folderId = (int) $this->getArgument('--folder-id');
$this->overwrite = !$this->getArgument('--no-overwrite');
if ($this->grafanaUrl === '') { if ($this->grafanaUrl === '') {
$this->grafanaUrl = getenv('GRAFANA_URL') ?: ''; $this->grafanaUrl = getenv('GRAFANA_URL') ?: '';
} }
$this->grafanaUrl = rtrim($this->grafanaUrl, '/');
if ($this->token === '') { if ($this->token === '') {
$this->token = getenv('GRAFANA_TOKEN') ?: ''; $this->token = getenv('GRAFANA_TOKEN') ?: '';
} }
if ($this->grafanaUrl === '' || $this->token === '') { if ($this->grafanaUrl === '' || $this->token === '') {
$this->log( $this->log('ERROR', '--url and --token are required (or set GRAFANA_URL / GRAFANA_TOKEN env vars).');
'ERROR: --url and --token are required '
. '(or set GRAFANA_URL / GRAFANA_TOKEN env vars).'
);
$this->printUsage();
return 1; return 1;
} }
@@ -62,12 +94,12 @@ final class GrafanaDashboard
private function pushDashboard(): int private function pushDashboard(): int
{ {
if ($this->file === '') { if ($this->file === '') {
$this->log('ERROR: --file is required for push.'); $this->log('ERROR', '--file is required for push.');
return 1; return 1;
} }
if (!file_exists($this->file)) { if (!file_exists($this->file)) {
$this->log("ERROR: File not found: {$this->file}"); $this->log('ERROR', "File not found: {$this->file}");
return 1; return 1;
} }
@@ -75,14 +107,12 @@ final class GrafanaDashboard
$dashboard = json_decode($json, true); $dashboard = json_decode($json, true);
if (!is_array($dashboard)) { if (!is_array($dashboard)) {
$this->log('ERROR: Invalid JSON in dashboard file.'); $this->log('ERROR', 'Invalid JSON in dashboard file.');
return 1; return 1;
} }
if ($this->folderTitle !== '' && $this->folderId === 0) { if ($this->folderTitle !== '' && $this->folderId === 0) {
$this->folderId = $this->resolveFolderId( $this->folderId = $this->resolveFolderId($this->folderTitle);
$this->folderTitle
);
if ($this->folderId < 0) { if ($this->folderId < 0) {
return 1; return 1;
@@ -97,29 +127,23 @@ final class GrafanaDashboard
'overwrite' => $this->overwrite, 'overwrite' => $this->overwrite,
]); ]);
$response = $this->apiRequest( $response = $this->apiRequest('POST', '/api/dashboards/db', $payload);
'POST',
'/api/dashboards/db',
$payload
);
if ($response['code'] === 200) { if ($response['code'] === 200) {
$data = json_decode($response['body'], true); $data = json_decode($response['body'], true);
$uid = $data['uid'] ?? '?'; $uid = $data['uid'] ?? '?';
$url = $data['url'] ?? ''; $url = $data['url'] ?? '';
$status = $data['status'] ?? 'success'; $status = $data['status'] ?? 'success';
$this->log("OK: {$status} (uid: {$uid})"); $this->log('INFO', "OK: {$status} (uid: {$uid})");
if ($url !== '') { if ($url !== '') {
$this->log("URL: {$this->grafanaUrl}{$url}"); $this->log('INFO', "URL: {$this->grafanaUrl}{$url}");
} }
return 0; return 0;
} }
$this->log( $this->log('ERROR', "Push failed (HTTP {$response['code']})");
"ERROR: Push failed (HTTP {$response['code']})"
);
$this->logApiError($response['body']); $this->logApiError($response['body']);
return 1; return 1;
@@ -128,30 +152,23 @@ final class GrafanaDashboard
private function deleteDashboard(): int private function deleteDashboard(): int
{ {
if ($this->uid === '') { if ($this->uid === '') {
$this->log('ERROR: --uid is required for delete.'); $this->log('ERROR', '--uid is required for delete.');
return 1; return 1;
} }
$response = $this->apiRequest( $response = $this->apiRequest('DELETE', "/api/dashboards/uid/{$this->uid}");
'DELETE',
"/api/dashboards/uid/{$this->uid}"
);
if ($response['code'] === 200) { if ($response['code'] === 200) {
$this->log("OK: Deleted dashboard {$this->uid}"); $this->log('INFO', "OK: Deleted dashboard {$this->uid}");
return 0; return 0;
} }
if ($response['code'] === 404) { if ($response['code'] === 404) {
$this->log( $this->warning("Dashboard {$this->uid} not found.");
"WARN: Dashboard {$this->uid} not found."
);
return 0; return 0;
} }
$this->log( $this->log('ERROR', "Delete failed (HTTP {$response['code']})");
"ERROR: Delete failed (HTTP {$response['code']})"
);
$this->logApiError($response['body']); $this->logApiError($response['body']);
return 1; return 1;
@@ -176,42 +193,33 @@ final class GrafanaDashboard
$response = $this->apiRequest('GET', $query); $response = $this->apiRequest('GET', $query);
if ($response['code'] !== 200) { if ($response['code'] !== 200) {
$this->log( $this->log('ERROR', "List failed (HTTP {$response['code']})");
"ERROR: List failed (HTTP {$response['code']})"
);
$this->logApiError($response['body']); $this->logApiError($response['body']);
return 1; return 1;
} }
$dashboards = json_decode($response['body'], true); $dashboards = json_decode($response['body'], true);
if ( if (!is_array($dashboards) || count($dashboards) === 0) {
!is_array($dashboards) $this->log('INFO', 'No dashboards found.');
|| count($dashboards) === 0
) {
$this->log('No dashboards found.');
return 0; return 0;
} }
$this->log(sprintf( fprintf(STDERR, "%-30s | %-20s | %s\n", 'Title', 'UID', 'Folder');
'%-30s | %-20s | %s', fprintf(STDERR, "%s\n", str_repeat('-', 75));
'Title',
'UID',
'Folder'
));
$this->log(str_repeat('-', 75));
foreach ($dashboards as $d) { foreach ($dashboards as $d) {
$this->log(sprintf( fprintf(
'%-30s | %-20s | %s', STDERR,
"%-30s | %-20s | %s\n",
substr($d['title'] ?? '', 0, 30), substr($d['title'] ?? '', 0, 30),
$d['uid'] ?? '', $d['uid'] ?? '',
$d['folderTitle'] ?? 'General' $d['folderTitle'] ?? 'General'
)); );
} }
$this->log(''); echo "\n";
$this->log(count($dashboards) . ' dashboard(s).'); $this->log('INFO', count($dashboards) . ' dashboard(s).');
return 0; return 0;
} }
@@ -219,20 +227,14 @@ final class GrafanaDashboard
private function exportDashboard(): int private function exportDashboard(): int
{ {
if ($this->uid === '') { if ($this->uid === '') {
$this->log('ERROR: --uid is required for export.'); $this->log('ERROR', '--uid is required for export.');
return 1; return 1;
} }
$response = $this->apiRequest( $response = $this->apiRequest('GET', "/api/dashboards/uid/{$this->uid}");
'GET',
"/api/dashboards/uid/{$this->uid}"
);
if ($response['code'] !== 200) { if ($response['code'] !== 200) {
$this->log( $this->log('ERROR', "Export failed (HTTP {$response['code']})");
"ERROR: Export failed "
. "(HTTP {$response['code']})"
);
$this->logApiError($response['body']); $this->logApiError($response['body']);
return 1; return 1;
} }
@@ -241,9 +243,7 @@ final class GrafanaDashboard
$dashboard = $data['dashboard'] ?? null; $dashboard = $data['dashboard'] ?? null;
if ($dashboard === null) { if ($dashboard === null) {
$this->log( $this->log('ERROR', 'No dashboard data in response.');
'ERROR: No dashboard data in response.'
);
return 1; return 1;
} }
@@ -254,9 +254,7 @@ final class GrafanaDashboard
if ($this->file !== '') { if ($this->file !== '') {
file_put_contents($this->file, $output); file_put_contents($this->file, $output);
$this->log( $this->log('INFO', "Exported {$this->uid} to {$this->file}");
"Exported {$this->uid} to {$this->file}"
);
} else { } else {
fwrite(STDOUT, $output); fwrite(STDOUT, $output);
} }
@@ -269,10 +267,7 @@ final class GrafanaDashboard
$response = $this->apiRequest('GET', '/api/folders'); $response = $this->apiRequest('GET', '/api/folders');
if ($response['code'] !== 200) { if ($response['code'] !== 200) {
$this->log( $this->log('ERROR', "Could not fetch folders (HTTP {$response['code']})");
"ERROR: Could not fetch folders "
. "(HTTP {$response['code']})"
);
return -1; return -1;
} }
@@ -283,106 +278,22 @@ final class GrafanaDashboard
} }
foreach ($folders as $f) { foreach ($folders as $f) {
if ( if (strcasecmp($f['title'] ?? '', $title) === 0) {
strcasecmp(
$f['title'] ?? '',
$title
) === 0
) {
return (int) ($f['id'] ?? 0); return (int) ($f['id'] ?? 0);
} }
} }
$this->log( $this->warning("Folder \"{$title}\" not found, using General.");
"WARN: Folder \"{$title}\" not found, "
. "using General."
);
return 0; return 0;
} }
private function noCommand(): int private function noCommand(): int
{ {
$this->log('ERROR: No command specified.'); $this->log('ERROR', 'No command specified. Use: push, delete, list, export');
$this->printUsage();
return 1; 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( private function apiRequest(
string $method, string $method,
string $endpoint, string $endpoint,
@@ -405,10 +316,7 @@ final class GrafanaDashboard
} }
$responseBody = curl_exec($ch); $responseBody = curl_exec($ch);
$httpCode = (int) curl_getinfo( $httpCode = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE);
$ch,
CURLINFO_HTTP_CODE
);
if (curl_errno($ch)) { if (curl_errno($ch)) {
$error = curl_error($ch); $error = curl_error($ch);
@@ -430,15 +338,10 @@ final class GrafanaDashboard
$data = json_decode($body, true); $data = json_decode($body, true);
if (is_array($data) && isset($data['message'])) { if (is_array($data) && isset($data['message'])) {
$this->log(" Grafana: {$data['message']}"); $this->log('ERROR', " Grafana: {$data['message']}");
} }
} }
private function log(string $message): void
{
fwrite(STDERR, $message . PHP_EOL);
}
} }
$app = new GrafanaDashboard(); $app = new GrafanaDashboardCli();
exit($app->run()); exit($app->execute());
+180 -133
View File
@@ -1,5 +1,6 @@
#!/usr/bin/env php #!/usr/bin/env php
<?php <?php
/* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech> /* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
* *
* SPDX-License-Identifier: GPL-3.0-or-later * SPDX-License-Identifier: GPL-3.0-or-later
@@ -9,97 +10,106 @@
* INGROUP: moko-platform * INGROUP: moko-platform
* REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform * REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform
* PATH: /cli/joomla_build.php * PATH: /cli/joomla_build.php
* VERSION: 09.21.00 * VERSION: 09.24.00
* BRIEF: Build a Joomla extension ZIP from manifest — all types supported * BRIEF: Build a Joomla extension ZIP from manifest — all types supported
* NOTE: Called by pre-release and auto-release workflows. * NOTE: Called by pre-release and auto-release workflows.
*
* USAGE
* php joomla_build.php --path . --version 02.01.24
* php joomla_build.php --path . --version 02.01.24 --suffix -dev
* php joomla_build.php --path . --version 02.01.24 --output build --github-output
*
* Supports: plugin, module, component, template, package, library, file
*/ */
declare(strict_types=1); declare(strict_types=1);
// ── Argument parsing ──────────────────────────────────────────────────── require_once __DIR__ . '/../lib/Enterprise/CliFramework.php';
$path = '.';
$version = '';
$suffix = '';
$outputDir = 'build';
$ghOutput = false;
foreach ($argv as $i => $arg) { use MokoEnterprise\CliFramework;
if ($arg === '--path' && isset($argv[$i + 1])) $path = $argv[$i + 1];
if ($arg === '--version' && isset($argv[$i + 1])) $version = $argv[$i + 1];
if ($arg === '--suffix' && isset($argv[$i + 1])) $suffix = $argv[$i + 1];
if ($arg === '--output' && isset($argv[$i + 1])) $outputDir = $argv[$i + 1];
if ($arg === '--github-output') $ghOutput = true;
}
if ($version === '') { class JoomlaBuildCli extends CliFramework
fwrite(STDERR, "::error::--version is required\n"); {
exit(1); protected function configure(): void
} {
$this->setDescription('Build a Joomla extension ZIP from manifest');
$this->addArgument('--path', 'Repository root path', '.');
$this->addArgument('--version', 'Version string (required)', '');
$this->addArgument('--suffix', 'Version suffix (e.g. -dev)', '');
$this->addArgument('--output', 'Output directory', 'build');
$this->addArgument('--github-output', 'Write outputs to GITHUB_OUTPUT file', false);
}
$path = realpath($path) ?: $path; protected function run(): int
{
$path = $this->getArgument('--path');
$version = $this->getArgument('--version');
$suffix = $this->getArgument('--suffix');
$outputDir = $this->getArgument('--output');
$ghOutput = (bool) $this->getArgument('--github-output');
// ── Find source directory ────────────────────────────────────────────── if ($version === '') {
$srcDir = null; $this->log('ERROR', '::error::--version is required');
foreach (['src', 'htdocs'] as $d) { return 1;
if (is_dir("{$path}/{$d}")) { $srcDir = "{$path}/{$d}"; break; } }
}
if ($srcDir === null) {
fwrite(STDERR, "::error::No src/ or htdocs/ directory in {$path}\n");
exit(1);
}
// ── Find manifest ────────────────────────────────────────────────────── $path = realpath($path) ?: $path;
$manifest = findManifest($srcDir);
if ($manifest === null) {
fwrite(STDERR, "::error::No Joomla manifest found in {$srcDir}\n");
exit(1);
}
fwrite(STDERR, "Manifest: {$manifest}\n"); // ── Find source directory ──────────────────────────────────────────────
$srcDir = null;
foreach (['src', 'htdocs'] as $d) {
if (is_dir("{$path}/{$d}")) {
$srcDir = "{$path}/{$d}";
break;
}
}
if ($srcDir === null) {
$this->log('ERROR', "::error::No src/ or htdocs/ directory in {$path}");
return 1;
}
// ── Parse manifest ───────────────────────────────────────────────────── // ── Find manifest ─────────────────────────────────────────────────────
$meta = parseManifest($manifest); $manifest = $this->findManifest($srcDir);
if ($manifest === null) {
$this->log('ERROR', "::error::No Joomla manifest found in {$srcDir}");
return 1;
}
// Resolve language-key names (e.g. PLG_SYSTEM_MOKOWAAS -> "System - Moko WaaS") $this->log('INFO', "Manifest: {$manifest}");
if (preg_match('/^[A-Z_]+$/', $meta['name'])) {
$resolved = resolveLanguageKey($srcDir, $meta['name']);
if ($resolved !== null) { $meta['name'] = $resolved; }
}
$prefix = typePrefix($meta); // ── Parse manifest ─────────────────────────────────────────────────────
$zipName = "{$prefix}{$meta['element']}-{$version}{$suffix}.zip"; $meta = $this->parseManifest($manifest);
$zipPath = "{$outputDir}/{$zipName}";
fwrite(STDERR, "=== Joomla Build: {$meta['type']}{$meta['element']} {$version}{$suffix} ===\n"); // Resolve language-key names (e.g. PLG_SYSTEM_MOKOWAAS -> "System - Moko WaaS")
fwrite(STDERR, " Type: {$meta['type']}\n"); if (preg_match('/^[A-Z_]+$/', $meta['name'])) {
fwrite(STDERR, " Element: {$meta['element']}\n"); $resolved = $this->resolveLanguageKey($srcDir, $meta['name']);
fwrite(STDERR, " Group: " . ($meta['group'] ?: 'n/a') . "\n"); if ($resolved !== null) {
fwrite(STDERR, " Name: {$meta['name']}\n"); $meta['name'] = $resolved;
fwrite(STDERR, " Output: {$zipName}\n"); }
}
// ── Build ────────────────────────────────────────────────────────────── $prefix = $this->typePrefix($meta);
if (!is_dir($outputDir)) { mkdir($outputDir, 0755, true); } $zipName = "{$prefix}{$meta['element']}-{$version}{$suffix}.zip";
$zipPath = "{$outputDir}/{$zipName}";
if ($meta['type'] === 'package') { $this->log('INFO', "=== Joomla Build: {$meta['type']}{$meta['element']} {$version}{$suffix} ===");
buildPackageZip($srcDir, $zipPath); $this->log('INFO', " Type: {$meta['type']}");
} else { $this->log('INFO', " Element: {$meta['element']}");
buildZip($srcDir, $zipPath); $this->log('INFO', " Group: " . ($meta['group'] ?: 'n/a'));
} $this->log('INFO', " Name: {$meta['name']}");
$this->log('INFO', " Output: {$zipName}");
$sha256 = hash_file('sha256', $zipPath); // ── Build ──────────────────────────────────────────────────────────────
$size = filesize($zipPath); if (!is_dir($outputDir)) {
mkdir($outputDir, 0755, true);
}
fwrite(STDERR, "Package: {$zipPath} ({$size} bytes, SHA: " . substr($sha256, 0, 16) . "...)\n"); if ($meta['type'] === 'package') {
$this->buildPackageZip($srcDir, $zipPath);
} else {
$this->buildZip($srcDir, $zipPath);
}
// ── Output variables ─────────────────────────────────────────────────── $sha256 = hash_file('sha256', $zipPath);
$vars = [ $size = filesize($zipPath);
$this->log('INFO', "Package: {$zipPath} ({$size} bytes, SHA: " . substr($sha256, 0, 16) . "...)");
// ── Output variables ───────────────────────────────────────────────────
$vars = [
'zip_name' => $zipName, 'zip_name' => $zipName,
'zip_path' => $zipPath, 'zip_path' => $zipPath,
'sha256' => $sha256, 'sha256' => $sha256,
@@ -108,29 +118,38 @@ $vars = [
'ext_name' => $meta['name'], 'ext_name' => $meta['name'],
'ext_group' => $meta['group'], 'ext_group' => $meta['group'],
'type_prefix' => $prefix, 'type_prefix' => $prefix,
]; ];
if ($ghOutput && ($ghFile = getenv('GITHUB_OUTPUT')) !== false && $ghFile !== '') { if ($ghOutput && ($ghFile = getenv('GITHUB_OUTPUT')) !== false && $ghFile !== '') {
$fh = fopen($ghFile, 'a'); $fh = fopen($ghFile, 'a');
foreach ($vars as $k => $v) { fwrite($fh, "{$k}={$v}\n"); } foreach ($vars as $k => $v) {
fwrite($fh, "{$k}={$v}\n");
}
fclose($fh); fclose($fh);
fwrite(STDERR, "Wrote " . count($vars) . " outputs to GITHUB_OUTPUT\n"); $this->log('INFO', "Wrote " . count($vars) . " outputs to GITHUB_OUTPUT");
} else { } else {
foreach ($vars as $k => $v) { echo "{$k}={$v}\n"; } foreach ($vars as $k => $v) {
} echo "{$k}={$v}\n";
}
}
exit(0); return 0;
}
// ═══════════════════════════════════════════════════════════════════════ // ═══════════════════════════════════════════════════════════════════════
// Functions // Private methods
// ═══════════════════════════════════════════════════════════════════════ // ═══════════════════════════════════════════════════════════════════════
function findManifest(string $dir): ?string private function findManifest(string $dir): ?string
{ {
// Priority: pkg_*.xml (packages), then any *.xml with <extension> // Priority: pkg_*.xml (packages), then any *.xml with <extension>
foreach (glob("{$dir}/pkg_*.xml") ?: [] as $f) { return $f; } foreach (glob("{$dir}/pkg_*.xml") ?: [] as $f) {
return $f;
}
foreach (glob("{$dir}/*.xml") ?: [] as $f) { foreach (glob("{$dir}/*.xml") ?: [] as $f) {
if (str_contains((string) file_get_contents($f), '<extension')) { return $f; } if (str_contains((string) file_get_contents($f), '<extension')) {
return $f;
}
} }
// Broader nested search // Broader nested search
$iter = new RecursiveIteratorIterator( $iter = new RecursiveIteratorIterator(
@@ -145,10 +164,10 @@ function findManifest(string $dir): ?string
} }
} }
return null; return null;
} }
function parseManifest(string $file): array private function parseManifest(string $file): array
{ {
$xml = simplexml_load_file($file); $xml = simplexml_load_file($file);
$name = (string) ($xml->name ?? ''); $name = (string) ($xml->name ?? '');
$type = (string) ($xml->attributes()->type ?? 'component'); $type = (string) ($xml->attributes()->type ?? 'component');
@@ -164,8 +183,12 @@ function parseManifest(string $file): array
} }
// Fallback element detection // Fallback element detection
if ($element === '') { $element = (string) ($xml->attributes()->plugin ?? ''); } if ($element === '') {
if ($element === '') { $element = (string) ($xml->attributes()->module ?? ''); } $element = (string) ($xml->attributes()->plugin ?? '');
}
if ($element === '') {
$element = (string) ($xml->attributes()->module ?? '');
}
if ($element === '') { if ($element === '') {
$element = strtolower(basename($file, '.xml')); $element = strtolower(basename($file, '.xml'));
if (in_array($element, ['templatedetails', 'manifest'], true)) { if (in_array($element, ['templatedetails', 'manifest'], true)) {
@@ -173,16 +196,18 @@ function parseManifest(string $file): array
} }
} }
// Strip existing type prefix to prevent duplication (e.g. pkg_mokowaas mokowaas) // Strip existing type prefix to prevent duplication (e.g. pkg_mokowaas -> mokowaas)
$element = preg_replace('/^(pkg_|com_|mod_|plg_\w+_|tpl_|lib_)/', '', $element); $element = preg_replace('/^(pkg_|com_|mod_|plg_\w+_|tpl_|lib_)/', '', $element);
if ($name === '') { $name = $element; } if ($name === '') {
$name = $element;
}
return compact('name', 'type', 'element', 'group'); return compact('name', 'type', 'element', 'group');
} }
function typePrefix(array $meta): string private function typePrefix(array $meta): string
{ {
return match ($meta['type']) { return match ($meta['type']) {
'plugin' => "plg_{$meta['group']}_", 'plugin' => "plg_{$meta['group']}_",
'module' => 'mod_', 'module' => 'mod_',
@@ -192,10 +217,10 @@ function typePrefix(array $meta): string
'library' => 'lib_', 'library' => 'lib_',
default => '', default => '',
}; };
} }
function resolveLanguageKey(string $srcDir, string $key): ?string private function resolveLanguageKey(string $srcDir, string $key): ?string
{ {
$iter = new RecursiveIteratorIterator( $iter = new RecursiveIteratorIterator(
new RecursiveDirectoryIterator($srcDir, FilesystemIterator::SKIP_DOTS) new RecursiveDirectoryIterator($srcDir, FilesystemIterator::SKIP_DOTS)
); );
@@ -209,24 +234,32 @@ function resolveLanguageKey(string $srcDir, string $key): ?string
} }
} }
return null; return null;
} }
function isExcluded(string $name): bool private function isExcluded(string $name): bool
{ {
if ($name === '.ftpignore') return true; if ($name === '.ftpignore') {
if (str_starts_with($name, 'sftp-config')) return true; return true;
if (str_starts_with($name, '.env')) return true; }
if (str_starts_with($name, '.build-trigger')) return true; if (str_starts_with($name, 'sftp-config')) {
return true;
}
if (str_starts_with($name, '.env')) {
return true;
}
if (str_starts_with($name, '.build-trigger')) {
return true;
}
$ext = pathinfo($name, PATHINFO_EXTENSION); $ext = pathinfo($name, PATHINFO_EXTENSION);
return in_array($ext, ['ppk', 'pem', 'key', 'local'], true); return in_array($ext, ['ppk', 'pem', 'key', 'local'], true);
} }
function buildZip(string $srcDir, string $outPath): void private function buildZip(string $srcDir, string $outPath): void
{ {
$zip = new ZipArchive(); $zip = new ZipArchive();
if ($zip->open($outPath, ZipArchive::CREATE | ZipArchive::OVERWRITE) !== true) { if ($zip->open($outPath, ZipArchive::CREATE | ZipArchive::OVERWRITE) !== true) {
fwrite(STDERR, "::error::Cannot create ZIP: {$outPath}\n"); $this->log('ERROR', "::error::Cannot create ZIP: {$outPath}");
exit(1); return;
} }
$iter = new RecursiveIteratorIterator( $iter = new RecursiveIteratorIterator(
new RecursiveDirectoryIterator($srcDir, FilesystemIterator::SKIP_DOTS), new RecursiveDirectoryIterator($srcDir, FilesystemIterator::SKIP_DOTS),
@@ -234,15 +267,17 @@ function buildZip(string $srcDir, string $outPath): void
); );
foreach ($iter as $file) { foreach ($iter as $file) {
$local = str_replace('\\', '/', substr($file->getPathname(), strlen($srcDir) + 1)); $local = str_replace('\\', '/', substr($file->getPathname(), strlen($srcDir) + 1));
if (isExcluded(basename($local))) continue; if ($this->isExcluded(basename($local))) {
continue;
}
$file->isDir() ? $zip->addEmptyDir($local) : $zip->addFile($file->getPathname(), $local); $file->isDir() ? $zip->addEmptyDir($local) : $zip->addFile($file->getPathname(), $local);
} }
$zip->close(); $zip->close();
} }
function buildPackageZip(string $srcDir, string $outPath): void private function buildPackageZip(string $srcDir, string $outPath): void
{ {
fwrite(STDERR, "Building Joomla package (multi-extension)...\n"); $this->log('INFO', "Building Joomla package (multi-extension)...");
$staging = sys_get_temp_dir() . '/moko_pkg_' . uniqid(); $staging = sys_get_temp_dir() . '/moko_pkg_' . uniqid();
mkdir($staging, 0755, true); mkdir($staging, 0755, true);
@@ -250,39 +285,45 @@ function buildPackageZip(string $srcDir, string $outPath): void
$packagesDir = "{$srcDir}/packages"; $packagesDir = "{$srcDir}/packages";
if (is_dir($packagesDir)) { if (is_dir($packagesDir)) {
foreach (glob("{$packagesDir}/*", GLOB_ONLYDIR) as $extDir) { foreach (glob("{$packagesDir}/*", GLOB_ONLYDIR) as $extDir) {
$subManifest = findManifest($extDir); $subManifest = $this->findManifest($extDir);
if ($subManifest) { if ($subManifest) {
$sub = parseManifest($subManifest); $sub = $this->parseManifest($subManifest);
$subPrefix = typePrefix($sub); $subPrefix = $this->typePrefix($sub);
$subZipName = "{$subPrefix}{$sub['element']}.zip"; $subZipName = "{$subPrefix}{$sub['element']}.zip";
} else { } else {
$subZipName = basename($extDir) . '.zip'; $subZipName = basename($extDir) . '.zip';
} }
fwrite(STDERR, " Sub-extension: {$subZipName}\n"); $this->log('INFO', " Sub-extension: {$subZipName}");
buildZip($extDir, "{$staging}/{$subZipName}"); $this->buildZip($extDir, "{$staging}/{$subZipName}");
} }
} }
// 2. Copy package-level files (manifest, script, language) // 2. Copy package-level files (manifest, script, language)
foreach (glob("{$srcDir}/*.xml") ?: [] as $f) copy($f, "{$staging}/" . basename($f)); foreach (glob("{$srcDir}/*.xml") ?: [] as $f) {
foreach (glob("{$srcDir}/*.php") ?: [] as $f) copy($f, "{$staging}/" . basename($f)); copy($f, "{$staging}/" . basename($f));
}
foreach (glob("{$srcDir}/*.php") ?: [] as $f) {
copy($f, "{$staging}/" . basename($f));
}
foreach (['language', 'administrator'] as $d) { foreach (['language', 'administrator'] as $d) {
if (is_dir("{$srcDir}/{$d}")) { if (is_dir("{$srcDir}/{$d}")) {
copyTree("{$srcDir}/{$d}", "{$staging}/{$d}"); $this->copyTree("{$srcDir}/{$d}", "{$staging}/{$d}");
} }
} }
// 3. Create outer zip // 3. Create outer zip
buildZip($staging, $outPath); $this->buildZip($staging, $outPath);
// Cleanup // Cleanup
rmTree($staging); $this->rmTree($staging);
} }
function copyTree(string $src, string $dst): void private function copyTree(string $src, string $dst): void
{ {
if (!is_dir($dst)) mkdir($dst, 0755, true); if (!is_dir($dst)) {
mkdir($dst, 0755, true);
}
$iter = new RecursiveIteratorIterator( $iter = new RecursiveIteratorIterator(
new RecursiveDirectoryIterator($src, FilesystemIterator::SKIP_DOTS), new RecursiveDirectoryIterator($src, FilesystemIterator::SKIP_DOTS),
RecursiveIteratorIterator::SELF_FIRST RecursiveIteratorIterator::SELF_FIRST
@@ -291,11 +332,13 @@ function copyTree(string $src, string $dst): void
$target = "{$dst}/" . $iter->getSubPathname(); $target = "{$dst}/" . $iter->getSubPathname();
$item->isDir() ? (is_dir($target) || mkdir($target, 0755, true)) : copy($item->getPathname(), $target); $item->isDir() ? (is_dir($target) || mkdir($target, 0755, true)) : copy($item->getPathname(), $target);
} }
} }
function rmTree(string $dir): void private function rmTree(string $dir): void
{ {
if (!is_dir($dir)) return; if (!is_dir($dir)) {
return;
}
$iter = new RecursiveIteratorIterator( $iter = new RecursiveIteratorIterator(
new RecursiveDirectoryIterator($dir, FilesystemIterator::SKIP_DOTS), new RecursiveDirectoryIterator($dir, FilesystemIterator::SKIP_DOTS),
RecursiveIteratorIterator::CHILD_FIRST RecursiveIteratorIterator::CHILD_FIRST
@@ -304,4 +347,8 @@ function rmTree(string $dir): void
$item->isDir() ? rmdir($item->getPathname()) : unlink($item->getPathname()); $item->isDir() ? rmdir($item->getPathname()) : unlink($item->getPathname());
} }
rmdir($dir); rmdir($dir);
}
} }
$app = new JoomlaBuildCli();
exit($app->execute());
+70 -62
View File
@@ -1,5 +1,6 @@
#!/usr/bin/env php #!/usr/bin/env php
<?php <?php
/* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech> /* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
* *
* SPDX-License-Identifier: GPL-3.0-or-later * SPDX-License-Identifier: GPL-3.0-or-later
@@ -10,33 +11,37 @@
* REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform * REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform
* PATH: /cli/joomla_compat_check.php * PATH: /cli/joomla_compat_check.php
* BRIEF: Check if extension targetplatform regex matches the latest Joomla version * 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); declare(strict_types=1);
$path = '.'; require_once __DIR__ . '/../lib/Enterprise/CliFramework.php';
$ghOutput = false;
foreach ($argv as $i => $arg) { use MokoEnterprise\CliFramework;
if ($arg === '--path' && isset($argv[$i + 1])) $path = $argv[$i + 1];
if ($arg === '--github-output') $ghOutput = true;
}
$root = realpath($path) ?: $path; class JoomlaCompatCheckCli extends CliFramework
{
protected function configure(): void
{
$this->setDescription('Check if extension targetplatform regex matches the latest Joomla version');
$this->addArgument('--path', 'Repository root', '.');
$this->addArgument('--github-output', 'Export results to $GITHUB_OUTPUT', false);
}
// ── Find manifest and extract targetplatform ──────────────────────────── protected function run(): int
$manifest = null; {
$searchDirs = ["{$root}/src", $root]; $path = $this->getArgument('--path');
foreach ($searchDirs as $dir) { $ghOutput = $this->getArgument('--github-output');
if (!is_dir($dir)) continue;
$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) { foreach (glob("{$dir}/*.xml") ?: [] as $f) {
$xml = file_get_contents($f); $xml = file_get_contents($f);
if (strpos($xml, '<extension') !== false && strpos($xml, 'targetplatform') !== false) { if (strpos($xml, '<extension') !== false && strpos($xml, 'targetplatform') !== false) {
@@ -44,71 +49,69 @@ foreach ($searchDirs as $dir) {
break 2; break 2;
} }
} }
} }
if ($manifest === null) { if ($manifest === null) {
fwrite(STDERR, "No manifest with targetplatform found\n"); $this->log('ERROR', 'No manifest with targetplatform found');
exit(1); return 1;
} }
$xml = file_get_contents($manifest); $xml = file_get_contents($manifest);
$relManifest = str_replace($root . '/', '', $manifest); $relManifest = str_replace($root . '/', '', $manifest);
// Extract targetplatform version regex // Extract targetplatform version regex
$targetRegex = ''; $targetRegex = '';
if (preg_match('/targetplatform[^>]*version="([^"]+)"/', $xml, $m)) { if (preg_match('/targetplatform[^>]*version="([^"]+)"/', $xml, $m)) {
$targetRegex = $m[1]; $targetRegex = $m[1];
} }
if (empty($targetRegex)) { if (empty($targetRegex)) {
echo "No targetplatform version found in {$relManifest}\n"; echo "No targetplatform version found in {$relManifest}\n";
exit(1); return 1;
} }
echo "Manifest: {$relManifest}\n"; echo "Manifest: {$relManifest}\n";
echo "Target regex: {$targetRegex}\n"; echo "Target regex: {$targetRegex}\n";
// ── Fetch latest Joomla version ───────────────────────────────────────── // -- Fetch latest Joomla version --
$joomlaVersions = []; $joomlaVersions = [];
$updateUrl = 'https://update.joomla.org/core/sts/list_sts.xml'; $updateUrl = 'https://update.joomla.org/core/sts/list_sts.xml';
$updateXml = @file_get_contents($updateUrl); $updateXml = @file_get_contents($updateUrl);
if ($updateXml === false) { if ($updateXml === false) {
// Fallback: try the LTS feed // Fallback: try the LTS feed
$updateUrl = 'https://update.joomla.org/core/list.xml'; $updateUrl = 'https://update.joomla.org/core/list.xml';
$updateXml = @file_get_contents($updateUrl); $updateXml = @file_get_contents($updateUrl);
} }
if ($updateXml !== false) { if ($updateXml !== false) {
// Parse all version entries // Parse all version entries
preg_match_all('/<version>([^<]+)<\/version>/', $updateXml, $matches); preg_match_all('/<version>([^<]+)<\/version>/', $updateXml, $matches);
$joomlaVersions = $matches[1] ?? []; $joomlaVersions = $matches[1] ?? [];
} }
if (empty($joomlaVersions)) { if (empty($joomlaVersions)) {
echo "WARNING: Could not fetch Joomla versions from update server\n"; echo "WARNING: Could not fetch Joomla versions from update server\n";
echo "Tested URL: {$updateUrl}\n"; echo "Tested URL: {$updateUrl}\n";
exit(0); return 0;
} }
// Sort and get latest // Sort and get latest
usort($joomlaVersions, 'version_compare'); usort($joomlaVersions, 'version_compare');
$latestJoomla = end($joomlaVersions); $latestJoomla = end($joomlaVersions);
echo "Latest Joomla: {$latestJoomla}\n"; echo "Latest Joomla: {$latestJoomla}\n";
// ── Test compatibility ────────────────────────────────────────────────── // -- Test compatibility --
// The targetplatform regex uses Joomla's regex format $compatible = @preg_match("/{$targetRegex}/", $latestJoomla);
// Common patterns: "5\.[0-9]+" or "((5.[0-9])|(6.[0-9]))"
$compatible = @preg_match("/{$targetRegex}/", $latestJoomla);
if ($compatible === false) { if ($compatible === false) {
echo "ERROR: Invalid regex in targetplatform: {$targetRegex}\n"; echo "ERROR: Invalid regex in targetplatform: {$targetRegex}\n";
$result = 'error'; $result = 'error';
} elseif ($compatible === 1) { } elseif ($compatible === 1) {
echo "PASS: Joomla {$latestJoomla} matches targetplatform regex\n"; echo "PASS: Joomla {$latestJoomla} matches targetplatform regex\n";
$result = 'pass'; $result = 'pass';
} else { } else {
// Check which major versions are supported // Check which major versions are supported
$supported = []; $supported = [];
foreach (['5.0', '5.1', '5.2', '5.3', '5.4', '6.0', '6.1', '6.2', '7.0'] as $v) { foreach (['5.0', '5.1', '5.2', '5.3', '5.4', '6.0', '6.1', '6.2', '7.0'] as $v) {
@@ -121,16 +124,21 @@ if ($compatible === false) {
echo "Supported versions: " . implode(', ', $supported) . "\n"; echo "Supported versions: " . implode(', ', $supported) . "\n";
echo "Consider updating targetplatform to include Joomla {$latestJoomla}\n"; echo "Consider updating targetplatform to include Joomla {$latestJoomla}\n";
$result = 'warn'; $result = 'warn';
} }
// ── Export ─────────────────────────────────────────────────────────────── // -- Export --
if ($ghOutput) { if ($ghOutput) {
$ghFile = getenv('GITHUB_OUTPUT'); $ghFile = getenv('GITHUB_OUTPUT');
if ($ghFile) { if ($ghFile) {
file_put_contents($ghFile, "compat_result={$result}\n", FILE_APPEND); 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_joomla={$latestJoomla}\n", FILE_APPEND);
file_put_contents($ghFile, "compat_regex={$targetRegex}\n", FILE_APPEND); file_put_contents($ghFile, "compat_regex={$targetRegex}\n", FILE_APPEND);
} }
}
return $result === 'error' ? 1 : 0;
}
} }
exit($result === 'error' ? 1 : 0); $app = new JoomlaCompatCheckCli();
exit($app->execute());
+41 -14
View File
@@ -1,5 +1,6 @@
#!/usr/bin/env php #!/usr/bin/env php
<?php <?php
/* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech> /* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
* *
* This file is part of a Moko Consulting project. * This file is part of a Moko Consulting project.
@@ -36,7 +37,7 @@ use MokoEnterprise\{ApiClient, AuditLogger, CliFramework, Config, PlatformAdapte
*/ */
class JoomlaRelease extends CliFramework class JoomlaRelease extends CliFramework
{ {
private const VERSION = '04.06.00'; private const VERSION = '09.23.00';
private const ORG = 'MokoConsulting'; private const ORG = 'MokoConsulting';
private const STABILITY_TAGS = [ private const STABILITY_TAGS = [
@@ -86,7 +87,9 @@ class JoomlaRelease extends CliFramework
if ($repo !== '') { if ($repo !== '') {
$path = $this->cloneRepo($repo); $path = $this->cloneRepo($repo);
if ($path === null) { return 1; } if ($path === null) {
return 1;
}
} }
$path = rtrim($path, '/\\'); $path = rtrim($path, '/\\');
@@ -191,7 +194,9 @@ class JoomlaRelease extends CliFramework
private function findManifest(string $path): ?string private function findManifest(string $path): ?string
{ {
foreach ([$path, "{$path}/src", "{$path}/htdocs"] as $dir) { foreach ([$path, "{$path}/src", "{$path}/htdocs"] as $dir) {
if (!is_dir($dir)) { continue; } if (!is_dir($dir)) {
continue;
}
foreach (glob("{$dir}/*.xml") as $file) { foreach (glob("{$dir}/*.xml") as $file) {
if (str_contains((string) file_get_contents($file), '<extension')) { if (str_contains((string) file_get_contents($file), '<extension')) {
return $file; return $file;
@@ -235,7 +240,9 @@ class JoomlaRelease extends CliFramework
private function readVersion(string $path): ?string private function readVersion(string $path): ?string
{ {
$readme = "{$path}/README.md"; $readme = "{$path}/README.md";
if (!is_file($readme)) { return null; } if (!is_file($readme)) {
return null;
}
if (preg_match('/VERSION:\s*(\d{2}\.\d{2}\.\d{2})/', file_get_contents($readme), $m)) { if (preg_match('/VERSION:\s*(\d{2}\.\d{2}\.\d{2})/', file_get_contents($readme), $m)) {
return $m[1]; return $m[1];
} }
@@ -301,8 +308,12 @@ class JoomlaRelease extends CliFramework
} }
// 2. Copy package-level files (manifest, script, language) // 2. Copy package-level files (manifest, script, language)
foreach (glob("{$srcDir}/*.xml") as $f) { copy($f, "{$staging}/" . basename($f)); } foreach (glob("{$srcDir}/*.xml") as $f) {
foreach (glob("{$srcDir}/*.php") as $f) { copy($f, "{$staging}/" . basename($f)); } copy($f, "{$staging}/" . basename($f));
}
foreach (glob("{$srcDir}/*.php") as $f) {
copy($f, "{$staging}/" . basename($f));
}
foreach (['language', 'administrator'] as $d) { foreach (['language', 'administrator'] as $d) {
if (is_dir("{$srcDir}/{$d}")) { if (is_dir("{$srcDir}/{$d}")) {
$this->copyDir("{$srcDir}/{$d}", "{$staging}/{$d}"); $this->copyDir("{$srcDir}/{$d}", "{$staging}/{$d}");
@@ -321,7 +332,9 @@ class JoomlaRelease extends CliFramework
*/ */
private function copyDir(string $src, string $dst): void private function copyDir(string $src, string $dst): void
{ {
if (!is_dir($dst)) { mkdir($dst, 0755, true); } if (!is_dir($dst)) {
mkdir($dst, 0755, true);
}
$iter = new \RecursiveIteratorIterator( $iter = new \RecursiveIteratorIterator(
new \RecursiveDirectoryIterator($src, \FilesystemIterator::SKIP_DOTS), new \RecursiveDirectoryIterator($src, \FilesystemIterator::SKIP_DOTS),
\RecursiveIteratorIterator::SELF_FIRST \RecursiveIteratorIterator::SELF_FIRST
@@ -342,7 +355,9 @@ class JoomlaRelease extends CliFramework
); );
foreach ($iter as $file) { foreach ($iter as $file) {
$local = str_replace('\\', '/', str_replace($srcDir . DIRECTORY_SEPARATOR, '', $file->getPathname())); $local = str_replace('\\', '/', str_replace($srcDir . DIRECTORY_SEPARATOR, '', $file->getPathname()));
if ($this->isExcluded(basename($local))) { continue; } if ($this->isExcluded(basename($local))) {
continue;
}
$file->isDir() ? $zip->addEmptyDir($local) : $zip->addFile($file->getPathname(), $local); $file->isDir() ? $zip->addEmptyDir($local) : $zip->addFile($file->getPathname(), $local);
} }
$zip->close(); $zip->close();
@@ -359,17 +374,29 @@ class JoomlaRelease extends CliFramework
private function isExcluded(string $name): bool private function isExcluded(string $name): bool
{ {
if ($name === '.ftpignore') { return true; } if ($name === '.ftpignore') {
if (str_starts_with($name, 'sftp-config')) { return true; } return true;
if (str_starts_with($name, '.env')) { return true; } }
if (str_starts_with($name, 'sftp-config')) {
return true;
}
if (str_starts_with($name, '.env')) {
return true;
}
$ext = pathinfo($name, PATHINFO_EXTENSION); $ext = pathinfo($name, PATHINFO_EXTENSION);
return in_array($ext, ['ppk', 'pem', 'key'], true); return in_array($ext, ['ppk', 'pem', 'key'], true);
} }
// ── GitHub Release ─────────────────────────────────────────────── // ── GitHub Release ───────────────────────────────────────────────
private function ensureRelease(string $repo, string $tag, string $version, string $stability, string $extName = '', string $packageName = ''): void private function ensureRelease(
{ string $repo,
string $tag,
string $version,
string $stability,
string $extName = '',
string $packageName = ''
): void {
$releaseName = $extName !== '' $releaseName = $extName !== ''
? "{$extName} {$version} ({$packageName})" ? "{$extName} {$version} ({$packageName})"
: (($stability === 'stable') ? "v" . explode('.', $version)[0] . " (latest: {$version})" : "{$tag} ({$version})"); : (($stability === 'stable') ? "v" . explode('.', $version)[0] . " (latest: {$version})" : "{$tag} ({$version})");
@@ -379,7 +406,7 @@ class JoomlaRelease extends CliFramework
$this->api->post("/repos/{$repo}/releases", [ $this->api->post("/repos/{$repo}/releases", [
'tag_name' => $tag, 'tag_name' => $tag,
'name' => $releaseName, 'name' => $releaseName,
'body' => "## {$version}\n\nCreated by MokoStandards release pipeline.", 'body' => "## {$version}\n\nCreated by moko-platform release pipeline.",
'prerelease' => ($stability !== 'stable'), 'prerelease' => ($stability !== 'stable'),
]); ]);
} }
+686
View File
@@ -0,0 +1,686 @@
#!/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/license_manage.php
* BRIEF: Manage license packages and keys via MokoGitea licensing API
*
* Usage:
* php bin/moko license:list --org MokoConsulting
* php bin/moko license:create-package --org MokoConsulting --name "Pro Annual" --duration 365 --max-sites 5
* php bin/moko license:issue --org MokoConsulting --package-id 1 --licensee "Client Inc" --email client@example.com
* php bin/moko license:revoke --org MokoConsulting --key-id 42
* php bin/moko license:renew --org MokoConsulting --key-id 42 --days 365
* php bin/moko license:validate --key MOKO-ABCD-1234-EF56-7890 --domain example.com
* php bin/moko license:usage --org MokoConsulting --key-id 42
* php bin/moko license:master-key --org MokoConsulting
*/
declare(strict_types=1);
require_once __DIR__ . '/../vendor/autoload.php';
require_once __DIR__ . '/../lib/Enterprise/CliFramework.php';
use MokoEnterprise\CliFramework;
class LicenseManage extends CliFramework
{
private string $apiBase = '';
private string $token = '';
private string $subcommand = '';
protected function configure(): void
{
$this->setDescription('Manage license packages and keys via MokoGitea licensing API');
$this->addArgument('--org', 'Organization name', '');
$this->addArgument('--api-base', 'Gitea API base URL', '');
$this->addArgument('--token', 'API token (or set GH_TOKEN env)', '');
// Package args
$this->addArgument('--name', 'Package name (for create-package)', '');
$this->addArgument('--description', 'Package description', '');
$this->addArgument('--duration', 'Duration in days (0 = lifetime)', '0');
$this->addArgument('--max-sites', 'Max sites per key (0 = unlimited)', '0');
$this->addArgument('--repo-scope', 'Repo scope: all or comma-separated repo IDs', 'all');
$this->addArgument('--channels', 'Allowed channels: JSON array or comma-separated', '');
// Key args
$this->addArgument('--package-id', 'License package ID', '');
$this->addArgument('--key-id', 'License key ID', '');
$this->addArgument('--key', 'Raw license key string (for validate)', '');
$this->addArgument('--licensee', 'Licensee name', '');
$this->addArgument('--email', 'Licensee email', '');
$this->addArgument('--domain', 'Domain restriction or validation domain', '');
$this->addArgument('--domains', 'Comma-separated allowed domains', '');
$this->addArgument('--payment-ref', 'Payment reference (idempotency key)', '');
$this->addArgument('--days', 'Days to extend (for renew)', '365');
$this->addArgument('--custom-key', 'Use a custom key string instead of auto-generated', '');
// Output
$this->addArgument('--json', 'Output as JSON', false);
}
protected function initialize(): void
{
// Resolve API base
$this->apiBase = $this->getArgument('--api-base')
?: getenv('GITEA_URL')
?: 'https://git.mokoconsulting.tech';
$this->apiBase = rtrim($this->apiBase, '/');
// Resolve token
$this->token = $this->getArgument('--token')
?: getenv('GH_TOKEN')
?: getenv('GITHUB_TOKEN')
?: '';
if (empty($this->token)) {
$ghToken = trim((string) @shell_exec('gh auth token 2>/dev/null'));
if (!empty($ghToken)) {
$this->token = $ghToken;
}
}
// Determine subcommand from argv
global $argv;
foreach ($argv as $arg) {
if (
in_array($arg, [
'list', 'create-package', 'update-package', 'delete-package',
'issue', 'revoke', 'activate', 'renew', 'validate',
'usage', 'master-key', 'keys', 'packages',
], true)
) {
$this->subcommand = $arg;
break;
}
}
}
protected function run(): int
{
if (empty($this->token)) {
$this->log('No API token found. Set GH_TOKEN or pass --token.', 'ERROR');
return 1;
}
return match ($this->subcommand) {
'packages', 'list' => $this->listPackages(),
'create-package' => $this->createPackage(),
'update-package' => $this->updatePackage(),
'delete-package' => $this->deletePackage(),
'keys' => $this->listKeys(),
'issue' => $this->issueKey(),
'revoke' => $this->revokeKey(),
'activate' => $this->activateKey(),
'renew' => $this->renewKey(),
'validate' => $this->validateKey(),
'usage' => $this->viewUsage(),
'master-key' => $this->ensureMasterKey(),
default => $this->showSubcommandHelp(),
};
}
// ── Subcommand help ──────────────────────────────────────────────────
private function showSubcommandHelp(): int
{
$this->section('License Management — Subcommands');
echo <<<HELP
Package Management:
packages List all license packages for an org
create-package Create a new license package
update-package Update a license package (--package-id required)
delete-package Delete a license package (--package-id required)
Key Management:
keys List all license keys for an org
issue Issue a new license key (--package-id required)
revoke Deactivate a license key (--key-id required)
activate Re-activate a revoked key (--key-id required)
renew Extend key expiration (--key-id, --days required)
validate Validate a raw key string (--key required)
master-key Ensure master key exists for org
Analytics:
usage View usage logs for a key (--key-id required)
Examples:
php bin/moko license packages --org MokoConsulting
php bin/moko license create-package --org MokoConsulting --name "Pro Annual" --duration 365
php bin/moko license issue --org MokoConsulting --package-id 1 --licensee "Client"
php bin/moko license validate --key MOKO-ABCD-1234-EF56-7890 --domain example.com
php bin/moko license renew --org MokoConsulting --key-id 42 --days 365
HELP;
return 0;
}
// ── Package operations ───────────────────────────────────────────────
private function listPackages(): int
{
$org = $this->requireOrg();
if ($org === null) {
return 1;
}
$result = $this->apiGet("/orgs/{$org}/license-packages");
if ($result === null) {
return 1;
}
if ($this->getArgument('--json')) {
echo json_encode($result, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES) . "\n";
return 0;
}
$this->section("License Packages — {$org}");
if (empty($result)) {
$this->log('No packages found.', 'WARN');
return 0;
}
foreach ($result as $pkg) {
$duration = ($pkg['duration_days'] ?? 0) === 0 ? 'lifetime' : ($pkg['duration_days'] . ' days');
$sites = ($pkg['max_sites'] ?? 0) === 0 ? 'unlimited' : (string)$pkg['max_sites'];
$active = ($pkg['is_active'] ?? true) ? 'active' : 'inactive';
$this->status(
sprintf('#%d %s', $pkg['id'] ?? 0, $pkg['name'] ?? ''),
true,
sprintf('%s | %s sites | %s', $duration, $sites, $active)
);
}
return 0;
}
private function createPackage(): int
{
$org = $this->requireOrg();
if ($org === null) {
return 1;
}
$name = $this->getArgument('--name');
if (empty($name)) {
$this->log('--name is required for create-package', 'ERROR');
return 1;
}
$channels = $this->getArgument('--channels');
if (!empty($channels) && $channels[0] !== '[') {
$channels = json_encode(explode(',', $channels));
}
$data = [
'name' => $name,
'description' => $this->getArgument('--description') ?: '',
'duration_days' => (int) $this->getArgument('--duration'),
'max_sites' => (int) $this->getArgument('--max-sites'),
'repo_scope' => $this->getArgument('--repo-scope'),
'allowed_channels' => $channels ?: '',
];
if ($this->isDryRun()) {
$this->log('Would create package: ' . json_encode($data, JSON_PRETTY_PRINT), 'DRY-RUN');
return 0;
}
$result = $this->apiPost("/orgs/{$org}/license-packages", $data);
if ($result === null) {
return 1;
}
if ($this->getArgument('--json')) {
echo json_encode($result, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES) . "\n";
} else {
$this->log(sprintf('Created package #%d: %s', $result['id'] ?? 0, $name), 'OK');
}
return 0;
}
private function updatePackage(): int
{
$org = $this->requireOrg();
$pkgId = $this->getArgument('--package-id');
if ($org === null || empty($pkgId)) {
$this->log('--org and --package-id are required', 'ERROR');
return 1;
}
$data = array_filter([
'name' => $this->getArgument('--name') ?: null,
'description' => $this->getArgument('--description') ?: null,
'duration_days' => $this->getArgument('--duration') !== '0' ? (int)$this->getArgument('--duration') : null,
'max_sites' => $this->getArgument('--max-sites') !== '0' ? (int)$this->getArgument('--max-sites') : null,
], fn($v) => $v !== null);
if (empty($data)) {
$this->log('No fields to update. Pass --name, --description, --duration, or --max-sites.', 'WARN');
return 1;
}
if ($this->isDryRun()) {
$this->log("Would update package #{$pkgId}: " . json_encode($data), 'DRY-RUN');
return 0;
}
$result = $this->apiPatch("/orgs/{$org}/license-packages/{$pkgId}", $data);
if ($result === null) {
return 1;
}
$this->log("Updated package #{$pkgId}", 'OK');
return 0;
}
private function deletePackage(): int
{
$org = $this->requireOrg();
$pkgId = $this->getArgument('--package-id');
if ($org === null || empty($pkgId)) {
$this->log('--org and --package-id are required', 'ERROR');
return 1;
}
if ($this->isDryRun()) {
$this->log("Would delete package #{$pkgId}", 'DRY-RUN');
return 0;
}
$result = $this->apiDelete("/orgs/{$org}/license-packages/{$pkgId}");
if ($result === null) {
return 1;
}
$this->log("Deleted package #{$pkgId}", 'OK');
return 0;
}
// ── Key operations ───────────────────────────────────────────────────
private function listKeys(): int
{
$org = $this->requireOrg();
if ($org === null) {
return 1;
}
$pkgId = $this->getArgument('--package-id');
$endpoint = $pkgId
? "/orgs/{$org}/license-packages/{$pkgId}/keys"
: "/orgs/{$org}/license-keys";
$result = $this->apiGet($endpoint);
if ($result === null) {
return 1;
}
if ($this->getArgument('--json')) {
echo json_encode($result, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES) . "\n";
return 0;
}
$this->section("License Keys — {$org}" . ($pkgId ? " (Package #{$pkgId})" : ''));
if (empty($result)) {
$this->log('No keys found.', 'WARN');
return 0;
}
foreach ($result as $key) {
$prefix = $key['key_prefix'] ?? '???';
$licensee = $key['licensee_name'] ?? 'N/A';
$active = ($key['is_active'] ?? true) ? 'active' : 'revoked';
$internal = ($key['is_internal'] ?? false) ? ' [MASTER]' : '';
$domains = $key['domain_restriction'] ?? '';
$expires = ($key['expires_unix'] ?? 0) > 0
? date('Y-m-d', (int) $key['expires_unix'])
: 'never';
$this->status(
sprintf('#%d %s', $key['id'] ?? 0, $prefix),
$key['is_active'] ?? true,
sprintf('%s | %s | expires: %s | domains: %s%s', $licensee, $active, $expires, $domains ?: 'any', $internal)
);
}
return 0;
}
private function issueKey(): int
{
$org = $this->requireOrg();
$pkgId = $this->getArgument('--package-id');
if ($org === null || empty($pkgId)) {
$this->log('--org and --package-id are required', 'ERROR');
return 1;
}
$data = [
'package_id' => (int) $pkgId,
'licensee_name' => $this->getArgument('--licensee') ?: '',
'licensee_email' => $this->getArgument('--email') ?: '',
'domain_restriction' => $this->getArgument('--domains') ?: '',
'max_sites' => (int) $this->getArgument('--max-sites'),
'payment_ref' => $this->getArgument('--payment-ref') ?: '',
];
$customKey = $this->getArgument('--custom-key');
if (!empty($customKey)) {
$data['custom_key'] = $customKey;
}
if ($this->isDryRun()) {
$this->log('Would issue key: ' . json_encode($data, JSON_PRETTY_PRINT), 'DRY-RUN');
return 0;
}
$result = $this->apiPost("/orgs/{$org}/license-keys", $data);
if ($result === null) {
return 1;
}
if ($this->getArgument('--json')) {
echo json_encode($result, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES) . "\n";
} else {
$rawKey = $result['raw_key'] ?? '';
$this->section('License Key Issued');
if (!empty($rawKey)) {
echo "\n";
$this->log("Raw Key: {$rawKey}", 'OK');
$this->log('This key will NOT be shown again. Save it now.', 'WARN');
echo "\n";
}
$this->log(sprintf('Key ID: #%d | Prefix: %s', $result['id'] ?? 0, $result['key_prefix'] ?? ''), 'INFO');
}
return 0;
}
private function revokeKey(): int
{
return $this->toggleKey(false);
}
private function activateKey(): int
{
return $this->toggleKey(true);
}
private function toggleKey(bool $activate): int
{
$org = $this->requireOrg();
$keyId = $this->getArgument('--key-id');
if ($org === null || empty($keyId)) {
$this->log('--org and --key-id are required', 'ERROR');
return 1;
}
$action = $activate ? 'activate' : 'revoke';
if ($this->isDryRun()) {
$this->log("Would {$action} key #{$keyId}", 'DRY-RUN');
return 0;
}
$result = $this->apiPatch("/orgs/{$org}/license-keys/{$keyId}", [
'is_active' => $activate,
]);
if ($result === null) {
return 1;
}
$label = $activate ? 'Activated' : 'Revoked';
$this->log("{$label} key #{$keyId}", 'OK');
return 0;
}
private function renewKey(): int
{
$org = $this->requireOrg();
$keyId = $this->getArgument('--key-id');
$days = (int) $this->getArgument('--days');
if ($org === null || empty($keyId)) {
$this->log('--org and --key-id are required', 'ERROR');
return 1;
}
if ($this->isDryRun()) {
$this->log("Would renew key #{$keyId} by {$days} days", 'DRY-RUN');
return 0;
}
$result = $this->apiPost("/orgs/{$org}/license-keys/{$keyId}/renew", [
'days' => $days,
]);
if ($result === null) {
return 1;
}
$newExpiry = isset($result['expires_unix']) && $result['expires_unix'] > 0
? date('Y-m-d', (int) $result['expires_unix'])
: 'never';
$this->log("Renewed key #{$keyId} — new expiry: {$newExpiry}", 'OK');
return 0;
}
private function validateKey(): int
{
$rawKey = $this->getArgument('--key');
if (empty($rawKey)) {
$this->log('--key is required for validate', 'ERROR');
return 1;
}
$data = ['key' => $rawKey];
$domain = $this->getArgument('--domain');
if (!empty($domain)) {
$data['domain'] = $domain;
}
$result = $this->apiPost('/license-keys/validate', $data);
if ($result === null) {
return 1;
}
if ($this->getArgument('--json')) {
echo json_encode($result, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES) . "\n";
return 0;
}
$valid = $result['valid'] ?? false;
if ($valid) {
$this->status('License Valid', true, sprintf(
'Package: %s | Expires: %s | Sites: %s',
$result['package_name'] ?? 'N/A',
isset($result['expires_unix']) && $result['expires_unix'] > 0
? date('Y-m-d', (int) $result['expires_unix']) : 'never',
$result['max_sites'] ?? 'unlimited'
));
return 0;
} else {
$this->status('License Invalid', false, $result['error'] ?? 'Unknown reason');
return 1;
}
}
private function viewUsage(): int
{
$org = $this->requireOrg();
$keyId = $this->getArgument('--key-id');
if ($org === null || empty($keyId)) {
$this->log('--org and --key-id are required', 'ERROR');
return 1;
}
$result = $this->apiGet("/orgs/{$org}/license-keys/{$keyId}/usage");
if ($result === null) {
return 1;
}
if ($this->getArgument('--json')) {
echo json_encode($result, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES) . "\n";
return 0;
}
$this->section("Usage — Key #{$keyId}");
$entries = $result['entries'] ?? $result;
if (empty($entries)) {
$this->log('No usage recorded.', 'WARN');
return 0;
}
foreach ($entries as $u) {
$date = isset($u['created_unix']) ? date('Y-m-d H:i', (int) $u['created_unix']) : 'N/A';
$domain = $u['domain'] ?? '';
$ip = $u['ip_address'] ?? '';
$from = $u['version_from'] ?? '';
$this->log(sprintf('%s | %s | %s | from %s', $date, $domain ?: 'no domain', $ip, $from ?: 'unknown'), 'INFO');
}
return 0;
}
private function ensureMasterKey(): int
{
$org = $this->requireOrg();
if ($org === null) {
return 1;
}
if ($this->isDryRun()) {
$this->log("Would ensure master key for {$org}", 'DRY-RUN');
return 0;
}
$result = $this->apiPost("/orgs/{$org}/license-keys/master", []);
if ($result === null) {
return 1;
}
if ($this->getArgument('--json')) {
echo json_encode($result, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES) . "\n";
return 0;
}
$rawKey = $result['raw_key'] ?? '';
if (!empty($rawKey)) {
$this->section('Master Key Created');
echo "\n";
$this->log("Raw Key: {$rawKey}", 'OK');
$this->log('This key will NOT be shown again. Save it now.', 'WARN');
echo "\n";
} else {
$this->log('Master key already exists.', 'INFO');
}
return 0;
}
// ── Helpers ──────────────────────────────────────────────────────────
private function requireOrg(): ?string
{
$org = $this->getArgument('--org');
if (empty($org)) {
// Try to detect from git remote
$remote = trim((string) @shell_exec('git remote get-url origin 2>/dev/null'));
if (preg_match('#[/:]([^/]+)/[^/]+?(?:\.git)?$#', $remote, $m)) {
$org = $m[1];
}
}
if (empty($org)) {
$this->log('--org is required (or must be detectable from git remote)', 'ERROR');
return null;
}
return $org;
}
private function apiGet(string $path): ?array
{
return $this->apiRequest('GET', $path);
}
private function apiPost(string $path, array $data): ?array
{
return $this->apiRequest('POST', $path, $data);
}
private function apiPatch(string $path, array $data): ?array
{
return $this->apiRequest('PATCH', $path, $data);
}
private function apiDelete(string $path): ?array
{
return $this->apiRequest('DELETE', $path);
}
private function apiRequest(string $method, string $path, ?array $data = null): ?array
{
$url = $this->apiBase . '/api/v1' . $path;
$ch = curl_init();
curl_setopt_array($ch, [
CURLOPT_URL => $url,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_CUSTOMREQUEST => $method,
CURLOPT_HTTPHEADER => [
'Authorization: token ' . $this->token,
'Content-Type: application/json',
'Accept: application/json',
],
CURLOPT_TIMEOUT => 30,
]);
if ($data !== null && in_array($method, ['POST', 'PUT', 'PATCH'], true)) {
curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($data));
}
if ($this->getArgument('--verbose')) {
$this->log("{$method} {$url}", 'DEBUG');
}
$response = curl_exec($ch);
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
$error = curl_error($ch);
curl_close($ch);
if (!empty($error)) {
$this->log("API error: {$error}", 'ERROR');
return null;
}
if ($httpCode === 404) {
$this->log("API endpoint not found: {$path}", 'ERROR');
$this->log('The licensing API may not be deployed yet. Check MokoGitea version.', 'WARN');
return null;
}
if ($httpCode === 204) {
return []; // success, no content
}
if ($httpCode >= 400) {
$body = json_decode((string) $response, true);
$msg = $body['message'] ?? $response;
$this->log("API error ({$httpCode}): {$msg}", 'ERROR');
return null;
}
$decoded = json_decode((string) $response, true);
if ($decoded === null && !empty($response)) {
$this->log('Failed to parse API response', 'ERROR');
return null;
}
return $decoded ?? [];
}
}
$app = new LicenseManage();
exit($app->execute());
+63 -109
View File
@@ -11,116 +11,85 @@
* REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform * REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform
* PATH: /cli/manifest_element.php * PATH: /cli/manifest_element.php
* BRIEF: Extract element name, type, type prefix, and ZIP name from manifest * BRIEF: Extract element name, type, type prefix, and ZIP name from manifest
*
* Usage:
* php manifest_element.php --path .
* php manifest_element.php --path . --version 09.01.00 --stability dev --github-output
*
* Detects platform (joomla, dolibarr, generic) and resolves:
* ext_element — canonical element name (e.g. mokojgdpc)
* ext_type — extension type (plugin, module, component, package, etc.)
* ext_folder — group/folder for plugins (e.g. system)
* ext_name — human-readable name (e.g. "Moko JGDPC")
* type_prefix — Joomla type prefix (plg_system_, com_, mod_, etc.)
* zip_name — computed ZIP filename
*/ */
declare(strict_types=1); declare(strict_types=1);
$path = '.'; require_once __DIR__ . '/../lib/Enterprise/CliFramework.php';
$version = null;
$stability = 'stable';
$githubOutput = false;
$repoName = '';
foreach ($argv as $i => $arg) { use MokoEnterprise\CliFramework;
if ($arg === '--path' && isset($argv[$i + 1])) {
$path = $argv[$i + 1];
}
if ($arg === '--version' && isset($argv[$i + 1])) {
$version = $argv[$i + 1];
}
if ($arg === '--stability' && isset($argv[$i + 1])) {
$stability = $argv[$i + 1];
}
if ($arg === '--repo' && isset($argv[$i + 1])) {
$repoName = $argv[$i + 1];
}
if ($arg === '--github-output') {
$githubOutput = true;
}
}
$root = realpath($path) ?: $path; class ManifestElementCli extends CliFramework
{
protected function configure(): void
{
$this->setDescription('Extract element name, type, type prefix, and ZIP name from manifest');
$this->addArgument('--path', 'Repository root', '.');
$this->addArgument('--version', 'Version string', null);
$this->addArgument('--stability', 'Stability level', 'stable');
$this->addArgument('--repo', 'Repository name', '');
$this->addArgument('--github-output', 'Export results to $GITHUB_OUTPUT', false);
}
// ── Detect platform from manifest.xml ──────────────────────────────────────── protected function run(): int
$platform = 'generic'; {
$manifestXml = "{$root}/.mokogitea/manifest.xml"; $path = $this->getArgument('--path');
if (file_exists($manifestXml)) { $version = $this->getArgument('--version');
$stability = $this->getArgument('--stability');
$repoName = $this->getArgument('--repo');
$githubOutput = (bool) $this->getArgument('--github-output');
$root = realpath($path) ?: $path;
$platform = 'generic';
$manifestXml = "{$root}/.mokogitea/manifest.xml";
if (file_exists($manifestXml)) {
$content = file_get_contents($manifestXml); $content = file_get_contents($manifestXml);
if (preg_match('/<platform>([^<]+)<\/platform>/', $content, $pm)) { if (preg_match('/<platform>([^<]+)<\/platform>/', $content, $pm)) {
$platform = trim($pm[1]); $platform = trim($pm[1]);
} }
} }
$extManifest = null;
// ── Find extension manifest (Joomla XML) ───────────────────────────────────── $manifestFiles = array_merge(glob("{$root}/src/pkg_*.xml") ?: [], glob("{$root}/src/*.xml") ?: [], glob("{$root}/*.xml") ?: []);
$extManifest = null; foreach ($manifestFiles as $file) {
$manifestFiles = array_merge(
glob("{$root}/src/pkg_*.xml") ?: [],
glob("{$root}/src/*.xml") ?: [],
glob("{$root}/*.xml") ?: []
);
foreach ($manifestFiles as $file) {
$c = file_get_contents($file); $c = file_get_contents($file);
if (strpos($c, '<extension') !== false) { if (strpos($c, '<extension') !== false) {
$extManifest = $file; $extManifest = $file;
break; break;
} }
} }
$modFile = null;
// ── Find Dolibarr module file ──────────────────────────────────────────────── $modFiles = array_merge(
$modFile = null;
$modFiles = array_merge(
glob("{$root}/src/core/modules/mod*.class.php") ?: [], glob("{$root}/src/core/modules/mod*.class.php") ?: [],
glob("{$root}/htdocs/core/modules/mod*.class.php") ?: [], glob("{$root}/htdocs/core/modules/mod*.class.php") ?: [],
glob("{$root}/core/modules/mod*.class.php") ?: [] glob("{$root}/core/modules/mod*.class.php") ?: []
); );
foreach ($modFiles as $file) { foreach ($modFiles as $file) {
$c = file_get_contents($file); $c = file_get_contents($file);
if (strpos($c, 'extends DolibarrModules') !== false) { if (strpos($c, 'extends DolibarrModules') !== false) {
$modFile = $file; $modFile = $file;
break; break;
} }
} }
$extElement = '';
// ── Extract metadata ───────────────────────────────────────────────────────── $extType = '';
$extElement = ''; $extFolder = '';
$extType = ''; $extName = '';
$extFolder = ''; switch (true) {
$extName = '';
switch (true) {
// Joomla platforms
case in_array($platform, ['joomla', 'waas-component'], true) && $extManifest !== null: case in_array($platform, ['joomla', 'waas-component'], true) && $extManifest !== null:
$xml = file_get_contents($extManifest); $xml = file_get_contents($extManifest);
// Extension type and folder
if (preg_match('/type="([^"]*)"/', $xml, $tm)) { if (preg_match('/type="([^"]*)"/', $xml, $tm)) {
$extType = $tm[1]; $extType = $tm[1];
} }
if (preg_match('/group="([^"]*)"/', $xml, $gm)) { if (preg_match('/group="([^"]*)"/', $xml, $gm)) {
$extFolder = $gm[1]; $extFolder = $gm[1];
} }
// Element name: <element>, module= attribute, plugin= attribute, <packagename>, or filename
if (preg_match('/<element>([^<]+)<\/element>/', $xml, $em)) { if (preg_match('/<element>([^<]+)<\/element>/', $xml, $em)) {
$extElement = $em[1]; $extElement = $em[1];
} }
if (empty($extElement) && preg_match('/module="([^"]*)"/', $xml, $mm)) { if (empty($extElement) && preg_match('/module="([^"]*)"/', $xml, $mm)) {
$extElement = $mm[1]; $extElement = $mm[1];
} }
if (empty($extElement) && preg_match('/plugin="([^"]*)"/', $xml, $pm)) { if (empty($extElement) && preg_match('/plugin="([^"]*)"/', $xml, $pm2)) {
$extElement = $pm[1]; $extElement = $pm2[1];
} }
if ($extType === 'package' && preg_match('/<packagename>([^<]+)<\/packagename>/', $xml, $pn)) { if ($extType === 'package' && preg_match('/<packagename>([^<]+)<\/packagename>/', $xml, $pn)) {
$extElement = $pn[1]; $extElement = $pn[1];
@@ -131,38 +100,27 @@ switch (true) {
$extElement = strtolower(str_replace([' ', '-'], '', $repoName ?: basename($root))); $extElement = strtolower(str_replace([' ', '-'], '', $repoName ?: basename($root)));
} }
} }
// Human-readable name
if (preg_match('/<name>([^<]+)<\/name>/', $xml, $nm)) { if (preg_match('/<name>([^<]+)<\/name>/', $xml, $nm)) {
$extName = trim($nm[1]); $extName = trim($nm[1]);
} }
break; break;
// Dolibarr platforms
case in_array($platform, ['dolibarr', 'crm-module'], true) && $modFile !== null: case in_array($platform, ['dolibarr', 'crm-module'], true) && $modFile !== null:
$extType = 'dolibarr-module'; $extType = 'dolibarr-module';
$modBasename = basename($modFile, '.class.php'); $modBasename = basename($modFile, '.class.php');
$extElement = strtolower(preg_replace('/^mod/', '', $modBasename)); $extElement = strtolower(preg_replace('/^mod/', '', $modBasename));
$modContent = file_get_contents($modFile); $modContent = file_get_contents($modFile);
if (preg_match('/\$this->name\s*=\s*[\'"]([^\'"]+)[\'"]/', $modContent, $nm)) { if (preg_match('/\$this->name\s*=\s*[\'"]([^\'"]+)[\'"]/', $modContent, $nm)) {
$extName = $nm[1]; $extName = $nm[1];
} }
break; break;
// Generic / fallback
default: default:
$extElement = strtolower(str_replace([' ', '-'], '', $repoName ?: basename($root))); $extElement = strtolower(str_replace([' ', '-'], '', $repoName ?: basename($root)));
$extType = 'generic'; $extType = 'generic';
break; break;
} }
$extElement = preg_replace('/^(pkg_|com_|mod_|plg_[a-z]+_|tpl_|lib_)/', '', $extElement);
// ── Strip existing type prefix from element to prevent duplication ──────────── $typePrefix = '';
$extElement = preg_replace('/^(pkg_|com_|mod_|plg_[a-z]+_|tpl_|lib_)/', '', $extElement); switch ($extType) {
// ── Compute type prefix ──────────────────────────────────────────────────────
$typePrefix = '';
switch ($extType) {
case 'plugin': case 'plugin':
$typePrefix = "plg_{$extFolder}_"; $typePrefix = "plg_{$extFolder}_";
break; break;
@@ -181,10 +139,8 @@ switch ($extType) {
case 'package': case 'package':
$typePrefix = 'pkg_'; $typePrefix = 'pkg_';
break; break;
} }
$suffixMap = [
// ── Compute ZIP name ─────────────────────────────────────────────────────────
$suffixMap = [
'development' => '-dev', 'development' => '-dev',
'dev' => '-dev', 'dev' => '-dev',
'alpha' => '-alpha', 'alpha' => '-alpha',
@@ -192,20 +148,16 @@ $suffixMap = [
'rc' => '-rc', 'rc' => '-rc',
'release-candidate' => '-rc', 'release-candidate' => '-rc',
'stable' => '', 'stable' => '',
]; ];
$suffix = $suffixMap[$stability] ?? ''; $suffix = $suffixMap[$stability] ?? '';
$zipName = ''; $zipName = '';
if ($version !== null) { if ($version !== null) {
$zipName = "{$typePrefix}{$extElement}-{$version}{$suffix}.zip"; $zipName = "{$typePrefix}{$extElement}-{$version}{$suffix}.zip";
} }
if (empty($extName)) {
// Fallback name
if (empty($extName)) {
$extName = $repoName ?: basename($root); $extName = $repoName ?: basename($root);
} }
$outputs = [
// ── Output ───────────────────────────────────────────────────────────────────
$outputs = [
'platform' => $platform, 'platform' => $platform,
'ext_element' => $extElement, 'ext_element' => $extElement,
'ext_type' => $extType, 'ext_type' => $extType,
@@ -213,9 +165,8 @@ $outputs = [
'ext_name' => $extName, 'ext_name' => $extName,
'type_prefix' => $typePrefix, 'type_prefix' => $typePrefix,
'zip_name' => $zipName, 'zip_name' => $zipName,
]; ];
if ($githubOutput) {
if ($githubOutput) {
$ghOutput = getenv('GITHUB_OUTPUT'); $ghOutput = getenv('GITHUB_OUTPUT');
$lines = []; $lines = [];
foreach ($outputs as $key => $value) { foreach ($outputs as $key => $value) {
@@ -224,15 +175,18 @@ if ($githubOutput) {
if ($ghOutput) { if ($ghOutput) {
file_put_contents($ghOutput, implode("\n", $lines) . "\n", FILE_APPEND); file_put_contents($ghOutput, implode("\n", $lines) . "\n", FILE_APPEND);
} else { } else {
// Fallback: echo ::set-output (legacy)
foreach ($outputs as $key => $value) { foreach ($outputs as $key => $value) {
echo "::set-output name={$key}::{$value}\n"; echo "::set-output name={$key}::{$value}\n";
} }
} }
} else { } else {
foreach ($outputs as $key => $value) { foreach ($outputs as $key => $value) {
echo "{$key}={$value}\n"; echo "{$key}={$value}\n";
} }
}
return 0;
}
} }
exit(0); $app = new ManifestElementCli();
exit($app->execute());
+81 -86
View File
@@ -1,5 +1,6 @@
#!/usr/bin/env php #!/usr/bin/env php
<?php <?php
/* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech> /* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
* *
* SPDX-License-Identifier: GPL-3.0-or-later * SPDX-License-Identifier: GPL-3.0-or-later
@@ -9,87 +10,87 @@
* INGROUP: moko-platform * INGROUP: moko-platform
* REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform * REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform
* PATH: /cli/manifest_read.php * PATH: /cli/manifest_read.php
* VERSION: 09.21.00 * VERSION: 09.24.00
* BRIEF: Parse .manifest.xml and output requested field(s) for CI consumption * BRIEF: Parse .manifest.xml and output requested field(s) for CI consumption
*
* Usage:
* php manifest_read.php --path /repo --field platform
* php manifest_read.php --path /repo --field entry-point
* php manifest_read.php --path /repo --all
* php manifest_read.php --path /repo --github-output
*
* Fields: name, org, description, license, license-spdx, platform,
* standards-version, standards-source, language, package-type, entry-point,
* source-dir, remote-subdir, excludes, dev-host, demo-host
*
* --all Print all fields as KEY=VALUE lines
* --github-output Append all fields to $GITHUB_OUTPUT (for Gitea/GitHub Actions)
* --json Output all fields as JSON
* --field <name> Print a single field value (no key, just value)
*/ */
declare(strict_types=1); declare(strict_types=1);
// -- Argument parsing --------------------------------------------------------- require_once __DIR__ . '/../lib/Enterprise/CliFramework.php';
$path = '.';
$field = null;
$mode = 'field'; // field | all | github-output | json
foreach ($argv as $i => $arg) { use MokoEnterprise\CliFramework;
if ($arg === '--path' && isset($argv[$i + 1])) $path = $argv[$i + 1];
if ($arg === '--field' && isset($argv[$i + 1])) $field = $argv[$i + 1];
if ($arg === '--all') $mode = 'all';
if ($arg === '--github-output') $mode = 'github-output';
if ($arg === '--json') $mode = 'json';
}
// -- Locate manifest ---------------------------------------------------------- class ManifestReadCli extends CliFramework
$root = realpath($path) ?: $path; {
$manifestFile = null; protected function configure(): void
{
$this->setDescription('Parse manifest.xml and output requested field(s) for CI consumption');
$this->addArgument('--path', 'Repository root path', '.');
$this->addArgument('--field', 'Single field name to output', '');
$this->addArgument('--all', 'Print all fields as KEY=VALUE lines', false);
$this->addArgument('--github-output', 'Append all fields to $GITHUB_OUTPUT', false);
$this->addArgument('--json', 'Output all fields as JSON', false);
}
// Priority: manifest.xml (current standard) protected function run(): int
$candidates = [ {
$path = $this->getArgument('--path');
$field = $this->getArgument('--field');
$showAll = $this->getArgument('--all');
$ghOutput = $this->getArgument('--github-output');
$jsonMode = $this->getArgument('--json');
// Determine mode
if ($ghOutput) {
$mode = 'github-output';
} elseif ($showAll) {
$mode = 'all';
} elseif ($jsonMode) {
$mode = 'json';
} else {
$mode = 'field';
}
// -- Locate manifest --
$root = realpath($path) ?: $path;
$manifestFile = null;
// Priority: manifest.xml (current standard)
$candidates = [
"{$root}/.mokogitea/manifest.xml", "{$root}/.mokogitea/manifest.xml",
"{$root}/.mokogitea/.manifest.xml", // legacy (dot-prefixed) "{$root}/.mokogitea/.manifest.xml", // legacy (dot-prefixed)
"{$root}/.mokogitea/.moko-platform", // legacy v4 "{$root}/.mokogitea/.moko-platform", // legacy v4
]; ];
foreach ($candidates as $candidate) { foreach ($candidates as $candidate) {
if (file_exists($candidate)) { if (file_exists($candidate)) {
$manifestFile = $candidate; $manifestFile = $candidate;
break; break;
} }
} }
if ($manifestFile === null) { if ($manifestFile === null) {
fwrite(STDERR, "No manifest found in {$root} $this->log('ERROR', "No manifest found in {$root}");
"); return 1;
exit(1); }
}
// -- Parse XML ---------------------------------------------------------------- // -- Parse XML --
$xml = @simplexml_load_file($manifestFile); $xml = @simplexml_load_file($manifestFile);
if ($xml === false) { if ($xml === false) {
// Fallback: try YAML format (.mokostandards legacy) // Fallback: try YAML format (.mokostandards legacy)
$content = file_get_contents($manifestFile); $content = file_get_contents($manifestFile);
$fields = []; $fields = [];
if (preg_match('/^platform:\s*(.+)/m', $content, $m)) { if (preg_match('/^platform:\s*(.+)/m', $content, $m)) {
$fields['platform'] = trim($m[1], " $fields['platform'] = trim($m[1], " \t\n\r\"'");
\"'");
} }
if (preg_match('/^standards_version:\s*(.+)/m', $content, $m)) { if (preg_match('/^standards_version:\s*(.+)/m', $content, $m)) {
$fields['standards-version'] = trim($m[1], " $fields['standards-version'] = trim($m[1], " \t\n\r\"'");
\"'");
} }
if (preg_match('/^governed_repo:\s*(.+)/m', $content, $m)) { if (preg_match('/^governed_repo:\s*(.+)/m', $content, $m)) {
$fields['name'] = trim($m[1], " $fields['name'] = trim($m[1], " \t\n\r\"'");
\"'");
} }
} else { } else {
// Register namespace for XPath (optional, simple path works without) // Register namespace for XPath (optional, simple path works without)
$fields = [ $fields = [
'name' => (string)($xml->identity->name ?? ''), 'name' => (string)($xml->identity->name ?? ''),
@@ -112,64 +113,58 @@ if ($xml === false) {
'demo-host' => (string)($xml->deploy->{"demo-host"} ?? ''), 'demo-host' => (string)($xml->deploy->{"demo-host"} ?? ''),
'manifest-file' => $manifestFile, 'manifest-file' => $manifestFile,
]; ];
}
// Strip empty values for cleaner output
$fields = array_filter($fields, fn($v) => $v !== '');
// -- Output -------------------------------------------------------------------
switch ($mode) {
case 'field':
if ($field === null) {
fwrite(STDERR, "Usage: manifest_read.php --path <dir> --field <name>
");
fwrite(STDERR, " manifest_read.php --path <dir> --all
");
fwrite(STDERR, " manifest_read.php --path <dir> --json
");
fwrite(STDERR, " manifest_read.php --path <dir> --github-output
");
exit(2);
} }
echo ($fields[$field] ?? '') . "
"; // Strip empty values for cleaner output
$fields = array_filter($fields, fn($v) => $v !== '');
// -- Output --
switch ($mode) {
case 'field':
if ($field === '') {
$this->log('ERROR', "Usage: manifest_read.php --path <dir> --field <name>");
$this->log('ERROR', " manifest_read.php --path <dir> --all");
$this->log('ERROR', " manifest_read.php --path <dir> --json");
$this->log('ERROR', " manifest_read.php --path <dir> --github-output");
return 2;
}
echo ($fields[$field] ?? '') . "\n";
break; break;
case 'all': case 'all':
foreach ($fields as $k => $v) { foreach ($fields as $k => $v) {
echo "{$k}={$v} echo "{$k}={$v}\n";
";
} }
break; break;
case 'json': case 'json':
echo json_encode($fields, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES) . " echo json_encode($fields, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES) . "\n";
";
break; break;
case 'github-output': case 'github-output':
$outputFile = getenv('GITHUB_OUTPUT'); $outputFile = getenv('GITHUB_OUTPUT');
if ($outputFile === false || $outputFile === '') { if ($outputFile === false || $outputFile === '') {
fwrite(STDERR, "GITHUB_OUTPUT not set — printing to stdout instead $this->log('ERROR', 'GITHUB_OUTPUT not set — printing to stdout instead');
");
foreach ($fields as $k => $v) { foreach ($fields as $k => $v) {
// Convert field-name to FIELD_NAME for env var style // Convert field-name to FIELD_NAME for env var style
$envKey = str_replace('-', '_', $k); $envKey = str_replace('-', '_', $k);
echo "{$envKey}={$v} echo "{$envKey}={$v}\n";
";
} }
} else { } else {
$fh = fopen($outputFile, 'a'); $fh = fopen($outputFile, 'a');
foreach ($fields as $k => $v) { foreach ($fields as $k => $v) {
$envKey = str_replace('-', '_', $k); $envKey = str_replace('-', '_', $k);
fwrite($fh, "{$envKey}={$v} fwrite($fh, "{$envKey}={$v}\n");
");
} }
fclose($fh); fclose($fh);
fwrite(STDERR, "Wrote " . count($fields) . " fields to GITHUB_OUTPUT $this->log('INFO', "Wrote " . count($fields) . " fields to GITHUB_OUTPUT");
");
} }
break; break;
}
return 0;
}
} }
exit(0); $app = new ManifestReadCli();
exit($app->execute());
+108 -118
View File
@@ -12,85 +12,70 @@
* PATH: /cli/package_build.php * PATH: /cli/package_build.php
* BRIEF: Build ZIP and tar.gz install packages for Joomla/Dolibarr/generic projects * BRIEF: Build ZIP and tar.gz install packages for Joomla/Dolibarr/generic projects
* *
* Usage:
* php package_build.php --path /repo --version 04.01.00
* php package_build.php --path /repo --version 04.01.00 --output-dir /tmp
* php package_build.php --path /repo --version 04.01.00 --github-output
*
* Options:
* --path Repository root (default: .)
* --version Version string (required)
* --output-dir Directory for built packages (default: /tmp)
* --type-prefix Override type prefix (e.g. plg_system_)
* --element Override element name
* --github-output Export zip_name, tar_name, sha256_zip, sha256_tar to $GITHUB_OUTPUT
*
* NOTE: Uses PHP exec() with escapeshellarg() for tar — all arguments are escaped. * NOTE: Uses PHP exec() with escapeshellarg() for tar — all arguments are escaped.
*/ */
declare(strict_types=1); declare(strict_types=1);
$path = '.'; require_once __DIR__ . '/../lib/Enterprise/CliFramework.php';
$version = null;
$outputDir = '/tmp';
$typePrefixOverride = null;
$elementOverride = null;
$githubOutput = false;
foreach ($argv as $i => $arg) { use MokoEnterprise\CliFramework;
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) { class PackageBuildCli extends CliFramework
fwrite(STDERR, "Usage: package_build.php --path . --version XX.YY.ZZ [--output-dir /tmp]\n"); {
exit(1); protected function configure(): void
} {
$this->setDescription('Build ZIP and tar.gz install packages for Joomla/Dolibarr/generic projects');
$this->addArgument('--path', 'Repository root (default: .)', '.');
$this->addArgument('--version', 'Version string (required)', '');
$this->addArgument('--output-dir', 'Directory for built packages (default: /tmp)', '/tmp');
$this->addArgument('--type-prefix', 'Override type prefix (e.g. plg_system_)', '');
$this->addArgument('--element', 'Override element name', '');
$this->addArgument('--github-output', 'Export zip_name, tar_name, sha256_zip, sha256_tar to $GITHUB_OUTPUT', false);
}
$root = realpath($path) ?: $path; protected function run(): int
{
$path = $this->getArgument('--path');
$version = $this->getArgument('--version');
$outputDir = $this->getArgument('--output-dir');
$typePrefixOverride = $this->getArgument('--type-prefix') ?: null;
$elementOverride = $this->getArgument('--element') ?: null;
$githubOutput = $this->getArgument('--github-output');
// Ensure output directory exists if ($version === '') {
if (!is_dir($outputDir)) { $this->log('ERROR', 'Usage: package_build.php --path . --version XX.YY.ZZ [--output-dir /tmp]');
return 1;
}
$root = realpath($path) ?: $path;
// Ensure output directory exists
if (!is_dir($outputDir)) {
mkdir($outputDir, 0755, true); mkdir($outputDir, 0755, true);
} }
// -- Determine source directory ----------------------------------------------- // -- Determine source directory -----------------------------------------------
$sourceDir = null; $sourceDir = null;
foreach (['src', 'htdocs'] as $candidate) { foreach (['src', 'htdocs'] as $candidate) {
if (is_dir("{$root}/{$candidate}")) { if (is_dir("{$root}/{$candidate}")) {
$sourceDir = "{$root}/{$candidate}"; $sourceDir = "{$root}/{$candidate}";
break; break;
} }
} }
if ($sourceDir === null) { if ($sourceDir === null) {
fwrite(STDERR, "No src/ or htdocs/ directory found in {$root}\n"); $this->log('ERROR', "No src/ or htdocs/ directory found in {$root}");
exit(1); return 1;
} }
// -- Determine element and type prefix from manifest -------------------------- // -- Determine element and type prefix from manifest --------------------------
$extElement = $elementOverride; $extElement = $elementOverride;
$typePrefix = $typePrefixOverride ?? ''; $typePrefix = $typePrefixOverride ?? '';
$extType = ''; $extType = '';
$isPackage = false; $isPackage = false;
if ($extElement === null || $typePrefixOverride === null) { if ($extElement === null || $typePrefixOverride === null) {
// Find manifest // Find manifest
$manifest = null; $manifest = null;
foreach (glob("{$sourceDir}/pkg_*.xml") ?: [] as $f) { foreach (glob("{$sourceDir}/pkg_*.xml") ?: [] as $f) {
@@ -156,34 +141,34 @@ if ($extElement === null || $typePrefixOverride === null) {
$isPackage = ($extType === 'package' && is_dir("{$sourceDir}/packages")); $isPackage = ($extType === 'package' && is_dir("{$sourceDir}/packages"));
} }
} }
if ($extElement === null) { if ($extElement === null) {
$extElement = strtolower(basename($root)); $extElement = strtolower(basename($root));
} }
// Prevent double prefix (e.g. pkg_pkg_mokogallery) // Prevent double prefix (e.g. pkg_pkg_mokogallery)
if ($typePrefix !== '' && str_starts_with($extElement, rtrim($typePrefix, '_'))) { if ($typePrefix !== '' && str_starts_with($extElement, rtrim($typePrefix, '_'))) {
$extElement = substr($extElement, strlen(rtrim($typePrefix, '_')) + 1); $extElement = substr($extElement, strlen(rtrim($typePrefix, '_')) + 1);
} }
$zipName = "{$typePrefix}{$extElement}-{$version}.zip"; $zipName = "{$typePrefix}{$extElement}-{$version}.zip";
$tarName = "{$typePrefix}{$extElement}-{$version}.tar.gz"; $tarName = "{$typePrefix}{$extElement}-{$version}.tar.gz";
$zipPath = "{$outputDir}/{$zipName}"; $zipPath = "{$outputDir}/{$zipName}";
$tarPath = "{$outputDir}/{$tarName}"; $tarPath = "{$outputDir}/{$tarName}";
// -- Exclude patterns --------------------------------------------------------- // -- Exclude patterns ---------------------------------------------------------
$excludePatterns = [ $excludePatterns = [
'.ftpignore', '.ftpignore',
'sftp-config*', 'sftp-config*',
'*.ppk', '*.ppk',
'*.pem', '*.pem',
'*.key', '*.key',
'.env*', '.env*',
]; ];
// -- Build packages ----------------------------------------------------------- // -- Build packages -----------------------------------------------------------
if ($isPackage) { if ($isPackage) {
echo "=== Building Joomla PACKAGE (multi-extension) ===\n"; echo "=== Building Joomla PACKAGE (multi-extension) ===\n";
$stagingDir = sys_get_temp_dir() . '/moko-pkg-' . uniqid(); $stagingDir = sys_get_temp_dir() . '/moko-pkg-' . uniqid();
@@ -195,14 +180,14 @@ if ($isPackage) {
$subName = basename($extDir); $subName = basename($extDir);
echo " Packaging sub-extension: {$subName}\n"; echo " Packaging sub-extension: {$subName}\n";
$subZip = new ZipArchive(); $subZip = new \ZipArchive();
$subZipPath = "{$packagesDir}/{$subName}.zip"; $subZipPath = "{$packagesDir}/{$subName}.zip";
if ($subZip->open($subZipPath, ZipArchive::CREATE | ZipArchive::OVERWRITE) !== true) { if ($subZip->open($subZipPath, \ZipArchive::CREATE | \ZipArchive::OVERWRITE) !== true) {
fwrite(STDERR, "Failed to create ZIP for {$subName}\n"); $this->log('ERROR', "Failed to create ZIP for {$subName}");
continue; continue;
} }
addDirectoryToZip($subZip, $extDir, '', $excludePatterns); $this->addDirectoryToZip($subZip, $extDir, '', $excludePatterns);
$subZip->close(); $subZip->close();
echo " -> packages/{$subName}.zip (" . filesize($subZipPath) . " bytes)\n"; echo " -> packages/{$subName}.zip (" . filesize($subZipPath) . " bytes)\n";
} }
@@ -216,9 +201,9 @@ if ($isPackage) {
if (is_dir("{$sourceDir}/language")) { if (is_dir("{$sourceDir}/language")) {
$langDest = "{$stagingDir}/language"; $langDest = "{$stagingDir}/language";
mkdir($langDest, 0755, true); mkdir($langDest, 0755, true);
$langIterator = new RecursiveIteratorIterator( $langIterator = new \RecursiveIteratorIterator(
new RecursiveDirectoryIterator("{$sourceDir}/language", RecursiveDirectoryIterator::SKIP_DOTS), new \RecursiveDirectoryIterator("{$sourceDir}/language", \RecursiveDirectoryIterator::SKIP_DOTS),
RecursiveIteratorIterator::SELF_FIRST \RecursiveIteratorIterator::SELF_FIRST
); );
foreach ($langIterator as $item) { foreach ($langIterator as $item) {
$target = $langDest . '/' . substr($item->getPathname(), strlen("{$sourceDir}/language") + 1); $target = $langDest . '/' . substr($item->getPathname(), strlen("{$sourceDir}/language") + 1);
@@ -231,12 +216,12 @@ if ($isPackage) {
} }
// Create ZIP from staging // Create ZIP from staging
$zip = new ZipArchive(); $zip = new \ZipArchive();
if ($zip->open($zipPath, ZipArchive::CREATE | ZipArchive::OVERWRITE) !== true) { if ($zip->open($zipPath, \ZipArchive::CREATE | \ZipArchive::OVERWRITE) !== true) {
fwrite(STDERR, "Failed to create ZIP: {$zipPath}\n"); $this->log('ERROR', "Failed to create ZIP: {$zipPath}");
exit(1); return 1;
} }
addDirectoryToZip($zip, $stagingDir, '', []); $this->addDirectoryToZip($zip, $stagingDir, '', []);
$zip->close(); $zip->close();
// Create tar.gz — all arguments are escaped via escapeshellarg() // Create tar.gz — all arguments are escaped via escapeshellarg()
@@ -250,16 +235,16 @@ if ($isPackage) {
// Cleanup staging // Cleanup staging
$cleanCmd = sprintf('rm -rf %s', escapeshellarg($stagingDir)); $cleanCmd = sprintf('rm -rf %s', escapeshellarg($stagingDir));
passthru($cleanCmd); passthru($cleanCmd);
} else { } else {
echo "=== Building standard extension package ===\n"; echo "=== Building standard extension package ===\n";
// ZIP // ZIP
$zip = new ZipArchive(); $zip = new \ZipArchive();
if ($zip->open($zipPath, ZipArchive::CREATE | ZipArchive::OVERWRITE) !== true) { if ($zip->open($zipPath, \ZipArchive::CREATE | \ZipArchive::OVERWRITE) !== true) {
fwrite(STDERR, "Failed to create ZIP: {$zipPath}\n"); $this->log('ERROR', "Failed to create ZIP: {$zipPath}");
exit(1); return 1;
} }
addDirectoryToZip($zip, $sourceDir, '', $excludePatterns); $this->addDirectoryToZip($zip, $sourceDir, '', $excludePatterns);
$zip->close(); $zip->close();
// tar.gz — all arguments are escaped via escapeshellarg() // tar.gz — all arguments are escaped via escapeshellarg()
@@ -274,25 +259,25 @@ if ($isPackage) {
$excludeArgs $excludeArgs
); );
passthru($tarCmd, $tarReturn); passthru($tarCmd, $tarReturn);
} }
// -- Calculate SHA-256 -------------------------------------------------------- // -- Calculate SHA-256 --------------------------------------------------------
$sha256Zip = hash_file('sha256', $zipPath); $sha256Zip = hash_file('sha256', $zipPath);
$sha256Tar = file_exists($tarPath) ? hash_file('sha256', $tarPath) : ''; $sha256Tar = file_exists($tarPath) ? hash_file('sha256', $tarPath) : '';
$zipSize = filesize($zipPath); $zipSize = filesize($zipPath);
$tarSize = file_exists($tarPath) ? filesize($tarPath) : 0; $tarSize = file_exists($tarPath) ? filesize($tarPath) : 0;
echo "\n"; echo "\n";
echo "ZIP: {$zipName} ({$zipSize} bytes)\n"; echo "ZIP: {$zipName} ({$zipSize} bytes)\n";
echo " SHA-256: {$sha256Zip}\n"; echo " SHA-256: {$sha256Zip}\n";
if ($tarSize > 0) { if ($tarSize > 0) {
echo "TAR: {$tarName} ({$tarSize} bytes)\n"; echo "TAR: {$tarName} ({$tarSize} bytes)\n";
echo " SHA-256: {$sha256Tar}\n"; echo " SHA-256: {$sha256Tar}\n";
} }
// -- Export to GITHUB_OUTPUT -------------------------------------------------- // -- Export to GITHUB_OUTPUT --------------------------------------------------
if ($githubOutput) { if ($githubOutput) {
$ghOutput = getenv('GITHUB_OUTPUT'); $ghOutput = getenv('GITHUB_OUTPUT');
$lines = [ $lines = [
"zip_name={$zipName}", "zip_name={$zipName}",
@@ -306,24 +291,25 @@ if ($githubOutput) {
]; ];
if ($ghOutput) { if ($ghOutput) {
file_put_contents($ghOutput, implode("\n", $lines) . "\n", FILE_APPEND); file_put_contents($ghOutput, implode("\n", $lines) . "\n", FILE_APPEND);
fwrite(STDERR, "Exported " . count($lines) . " fields to GITHUB_OUTPUT\n"); $this->log('INFO', "Exported " . count($lines) . " fields to GITHUB_OUTPUT");
} else { } else {
foreach ($lines as $line) { foreach ($lines as $line) {
echo "{$line}\n"; echo "{$line}\n";
} }
} }
} }
exit(0); return 0;
}
// ============================================================================= /**
// Helper: recursively add directory contents to a ZipArchive * Recursively add directory contents to a ZipArchive.
// ============================================================================= */
function addDirectoryToZip(ZipArchive $zip, string $dir, string $prefix, array $excludes): void private function addDirectoryToZip(\ZipArchive $zip, string $dir, string $prefix, array $excludes): void
{ {
$iterator = new RecursiveIteratorIterator( $iterator = new \RecursiveIteratorIterator(
new RecursiveDirectoryIterator($dir, RecursiveDirectoryIterator::SKIP_DOTS), new \RecursiveDirectoryIterator($dir, \RecursiveDirectoryIterator::SKIP_DOTS),
RecursiveIteratorIterator::SELF_FIRST \RecursiveIteratorIterator::SELF_FIRST
); );
foreach ($iterator as $file) { foreach ($iterator as $file) {
@@ -352,4 +338,8 @@ function addDirectoryToZip(ZipArchive $zip, string $dir, string $prefix, array $
$zip->addFile($filePath, $relativePath); $zip->addFile($filePath, $relativePath);
} }
} }
}
} }
$app = new PackageBuildCli();
exit($app->execute());
+34 -17
View File
@@ -1,5 +1,6 @@
#!/usr/bin/env php #!/usr/bin/env php
<?php <?php
/* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech> /* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
* *
* SPDX-License-Identifier: GPL-3.0-or-later * SPDX-License-Identifier: GPL-3.0-or-later
@@ -9,32 +10,48 @@
* INGROUP: moko-platform * INGROUP: moko-platform
* REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform * REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform
* PATH: /cli/platform_detect.php * PATH: /cli/platform_detect.php
* BRIEF: Detect platform from .mokostandards file — outputs platform string * BRIEF: Detect platform from manifest.xml file — outputs platform string
*/ */
declare(strict_types=1); declare(strict_types=1);
$path = '.'; require_once __DIR__ . '/../lib/Enterprise/CliFramework.php';
foreach ($argv as $i => $arg) {
if ($arg === '--path' && isset($argv[$i + 1])) $path = $argv[$i + 1];
}
$root = realpath($path) ?: $path; use MokoEnterprise\CliFramework;
// Check .github/.mokostandards first, fallback to root
$file = "{$root}/.github/.mokostandards"; class PlatformDetectCli extends CliFramework
if (!file_exists($file)) { {
protected function configure(): void
{
$this->setDescription('Detect platform from manifest.xml file');
$this->addArgument('--path', 'Repository root path', '.');
}
protected function run(): int
{
$path = $this->getArgument('--path');
$root = realpath($path) ?: $path;
// Check .mokogitea/manifest.xml first, fallback to root
$file = "{$root}/.mokogitea/manifest.xml";
if (!file_exists($file)) {
$file = "{$root}/.mokostandards"; $file = "{$root}/.mokostandards";
} }
if (!file_exists($file)) { if (!file_exists($file)) {
echo "unknown\n"; echo "unknown\n";
exit(0); return 0;
} }
$content = file_get_contents($file); $content = file_get_contents($file);
if (preg_match('/^platform:\s*(.+)/m', $content, $m)) { if (preg_match('/^platform:\s*(.+)/m', $content, $m)) {
echo trim($m[1], " \t\n\r\"'") . "\n"; echo trim($m[1], " \t\n\r\"'") . "\n";
} else { } else {
echo "unknown\n"; echo "unknown\n";
}
return 0;
}
} }
exit(0); $app = new PlatformDetectCli();
exit($app->execute());
+98 -77
View File
@@ -1,5 +1,6 @@
#!/usr/bin/env php #!/usr/bin/env php
<?php <?php
/* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech> /* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
* *
* SPDX-License-Identifier: GPL-3.0-or-later * SPDX-License-Identifier: GPL-3.0-or-later
@@ -9,60 +10,74 @@
* INGROUP: moko-platform * INGROUP: moko-platform
* REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform * REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform
* PATH: /cli/release.php * PATH: /cli/release.php
* BRIEF: Automate the MokoStandards version branch release flow * BRIEF: Automate the moko-platform version branch release flow
*
* USAGE
* php cli/release.php # Release current version
* php cli/release.php --bump minor # Bump minor, then release
* php cli/release.php --bump major # Bump major, then release
* php cli/release.php --dry-run # Preview without changes
*/ */
declare(strict_types=1); declare(strict_types=1);
$dryRun = in_array('--dry-run', $argv); require_once __DIR__ . '/../lib/Enterprise/CliFramework.php';
$bumpType = null;
foreach ($argv as $i => $arg) {
if ($arg === '--bump' && isset($argv[$i + 1])) {
$bumpType = $argv[$i + 1]; // patch | minor | major
}
}
$repoRoot = dirname(__DIR__, 2); use MokoEnterprise\CliFramework;
$syncFile = "{$repoRoot}/lib/Enterprise/RepositorySynchronizer.php";
// Check both workflow directories for the bulk-repo-sync workflow class ReleaseCli extends CliFramework
$bulkSyncFile = file_exists("{$repoRoot}/.mokogitea/workflows/bulk-repo-sync.yml") {
protected function configure(): void
{
$this->setDescription('Automate the moko-platform version branch release flow');
$this->addArgument('--bump', 'Bump type: patch, minor, or major', '');
}
protected function run(): int
{
$bumpType = $this->getArgument('--bump');
if (empty($bumpType)) {
$bumpType = null;
}
$repoRoot = dirname(__DIR__, 2);
$syncFile = "{$repoRoot}/lib/Enterprise/RepositorySynchronizer.php";
// Check both workflow directories for the bulk-repo-sync workflow
$bulkSyncFile = file_exists("{$repoRoot}/.mokogitea/workflows/bulk-repo-sync.yml")
? "{$repoRoot}/.mokogitea/workflows/bulk-repo-sync.yml" ? "{$repoRoot}/.mokogitea/workflows/bulk-repo-sync.yml"
: "{$repoRoot}/.github/workflows/bulk-repo-sync.yml"; : "{$repoRoot}/.github/workflows/bulk-repo-sync.yml";
$cleanupFile = "{$repoRoot}/templates/workflows/shared/repository-cleanup.yml.template"; $cleanupFile = "{$repoRoot}/templates/workflows/shared/repository-cleanup.yml.template";
// ── Step 1: Read current version ──────────────────────────────────────── // -- Step 1: Read current version --
$readme = "{$repoRoot}/README.md"; $readme = "{$repoRoot}/README.md";
$content = file_get_contents($readme); $content = file_get_contents($readme);
if (!preg_match('/^\s*VERSION:\s*(\d{2})\.(\d{2})\.(\d{2})/m', $content, $m)) { if (!preg_match('/^\s*VERSION:\s*(\d{2})\.(\d{2})\.(\d{2})/m', $content, $m)) {
fwrite(STDERR, "No VERSION found in README.md\n"); $this->log('ERROR', 'No VERSION found in README.md');
exit(1); return 1;
} }
$major = (int)$m[1]; $major = (int)$m[1];
$minor = (int)$m[2]; $minor = (int)$m[2];
$patch = (int)$m[3]; $patch = (int)$m[3];
$currentVersion = sprintf('%02d.%02d.%02d', $major, $minor, $patch); $currentVersion = sprintf('%02d.%02d.%02d', $major, $minor, $patch);
// ── Step 2: Bump version if requested ─────────────────────────────────── // -- Step 2: Bump version if requested --
if ($bumpType) { if ($bumpType) {
switch ($bumpType) { switch ($bumpType) {
case 'major': $major++; $minor = 0; $patch = 0; break; case 'major':
case 'minor': $minor++; $patch = 0; break; $major++;
case 'patch': $patch++; break; $minor = 0;
$patch = 0;
break;
case 'minor':
$minor++;
$patch = 0;
break;
case 'patch':
$patch++;
break;
default: default:
fwrite(STDERR, "Invalid bump type: {$bumpType} (use patch/minor/major)\n"); $this->log('ERROR', "Invalid bump type: {$bumpType} (use patch/minor/major)");
exit(1); return 1;
} }
$newVersion = sprintf('%02d.%02d.%02d', $major, $minor, $patch); $newVersion = sprintf('%02d.%02d.%02d', $major, $minor, $patch);
echo "Bumping: {$currentVersion} {$newVersion}\n"; echo "Bumping: {$currentVersion} -> {$newVersion}\n";
if (!$dryRun) { if (!$this->dryRun) {
// Update README.md // Update README.md
$content = preg_replace( $content = preg_replace(
'/^(\s*VERSION:\s*)\d{2}\.\d{2}\.\d{2}/m', '/^(\s*VERSION:\s*)\d{2}\.\d{2}\.\d{2}/m',
@@ -77,19 +92,19 @@ if ($bumpType) {
passthru("php {$repoRoot}/api/maintenance/update_version_from_readme.php --path {$repoRoot}"); passthru("php {$repoRoot}/api/maintenance/update_version_from_readme.php --path {$repoRoot}");
} }
$currentVersion = $newVersion; $currentVersion = $newVersion;
} else { } else {
echo "Version: {$currentVersion}\n"; echo "Version: {$currentVersion}\n";
} }
// Derive major.minor for branch naming (patches update existing branch) // Derive major.minor for branch naming (patches update existing branch)
$versionParts = explode('.', $currentVersion); $versionParts = explode('.', $currentVersion);
$minorVersion = $versionParts[0] . '.' . $versionParts[1]; $minorVersion = $versionParts[0] . '.' . $versionParts[1];
$branch = "version/{$minorVersion}"; $branch = "version/{$minorVersion}";
// ── Step 3: Update STANDARDS_VERSION + STANDARDS_MINOR constants ──────── // -- Step 3: Update STANDARDS_VERSION + STANDARDS_MINOR constants --
echo "Updating STANDARDS_VERSION {$currentVersion}\n"; echo "Updating STANDARDS_VERSION -> {$currentVersion}\n";
echo "Updating STANDARDS_MINOR {$minorVersion}\n"; echo "Updating STANDARDS_MINOR -> {$minorVersion}\n";
if (!$dryRun) { if (!$this->dryRun) {
$syncContent = file_get_contents($syncFile); $syncContent = file_get_contents($syncFile);
$syncContent = preg_replace( $syncContent = preg_replace(
"/STANDARDS_VERSION\s*=\s*'[^']+'/", "/STANDARDS_VERSION\s*=\s*'[^']+'/",
@@ -102,11 +117,11 @@ if (!$dryRun) {
$syncContent $syncContent
); );
file_put_contents($syncFile, $syncContent); file_put_contents($syncFile, $syncContent);
} }
// ── Step 4: Update bulk-repo-sync.yml checkout ref ────────────────────── // -- Step 4: Update bulk-repo-sync.yml checkout ref --
echo "Updating bulk-repo-sync.yml {$branch}\n"; echo "Updating bulk-repo-sync.yml -> {$branch}\n";
if (!$dryRun) { if (!$this->dryRun) {
$bulkContent = file_get_contents($bulkSyncFile); $bulkContent = file_get_contents($bulkSyncFile);
$bulkContent = preg_replace( $bulkContent = preg_replace(
'/ref:\s*version\/[\d.]+/', '/ref:\s*version\/[\d.]+/',
@@ -114,11 +129,11 @@ if (!$dryRun) {
$bulkContent $bulkContent
); );
file_put_contents($bulkSyncFile, $bulkContent); file_put_contents($bulkSyncFile, $bulkContent);
} }
// ── Step 5: Update repository-cleanup.yml current branch ──────────────── // -- Step 5: Update repository-cleanup.yml current branch --
echo "Updating repository-cleanup.yml chore/sync-mokostandards-v{$minorVersion}\n"; echo "Updating repository-cleanup.yml -> chore/sync-mokostandards-v{$minorVersion}\n";
if (!$dryRun) { if (!$this->dryRun) {
$cleanupContent = file_get_contents($cleanupFile); $cleanupContent = file_get_contents($cleanupFile);
$cleanupContent = preg_replace( $cleanupContent = preg_replace(
'/CURRENT="chore\/sync-mokostandards-v[^"]*"/', '/CURRENT="chore\/sync-mokostandards-v[^"]*"/',
@@ -126,25 +141,25 @@ if (!$dryRun) {
$cleanupContent $cleanupContent
); );
file_put_contents($cleanupFile, $cleanupContent); file_put_contents($cleanupFile, $cleanupContent);
} }
// ── Step 6: Commit changes ────────────────────────────────────────────── // -- Step 6: Commit changes --
if (!$dryRun) { if (!$this->dryRun) {
echo "Committing...\n"; echo "Committing...\n";
passthru("cd {$repoRoot} && git add -A && git commit -m \"chore(release): prepare {$currentVersion} release [skip ci]\""); passthru("cd {$repoRoot} && git add -A && git commit -m \"chore(release): prepare {$currentVersion} release [skip ci]\"");
passthru("cd {$repoRoot} && git pull --rebase 2>/dev/null; git push"); passthru("cd {$repoRoot} && git pull --rebase 2>/dev/null; git push");
} }
// ── Step 7: Create or update version branch ───────────────────────────── // -- Step 7: Create or update version branch --
$isPatch = ($versionParts[2] ?? '00') !== '00'; $isPatch = ($versionParts[2] ?? '00') !== '00';
if ($isPatch) { if ($isPatch) {
echo "Updating version branch: {$branch} (patch update)\n"; echo "Updating version branch: {$branch} (patch update)\n";
if (!$dryRun) { if (!$this->dryRun) {
passthru("cd " . escapeshellarg($repoRoot) . " && git push origin main:{$branch} --force 2>&1"); passthru("cd " . escapeshellarg($repoRoot) . " && git push origin main:{$branch} --force 2>&1");
} }
} else { } else {
echo "Creating version branch: {$branch} (minor release)\n"; echo "Creating version branch: {$branch} (minor release)\n";
if (!$dryRun) { if (!$this->dryRun) {
$exitCode = 0; $exitCode = 0;
passthru("cd " . escapeshellarg($repoRoot) . " && git push origin main:{$branch} 2>&1", $exitCode); passthru("cd " . escapeshellarg($repoRoot) . " && git push origin main:{$branch} 2>&1", $exitCode);
if ($exitCode !== 0) { if ($exitCode !== 0) {
@@ -152,20 +167,26 @@ if ($isPatch) {
passthru("cd " . escapeshellarg($repoRoot) . " && git push origin main:{$branch} --force 2>&1"); passthru("cd " . escapeshellarg($repoRoot) . " && git push origin main:{$branch} --force 2>&1");
} }
} }
} }
// ── Step 8: Create git tag (never overwrite existing) ─────────────────── // -- Step 8: Create git tag (never overwrite existing) --
$tag = "v{$currentVersion}"; $tag = "v{$currentVersion}";
echo "Creating tag {$tag}\n"; echo "Creating tag {$tag}\n";
if (!$dryRun) { if (!$this->dryRun) {
$exitCode = 0; $exitCode = 0;
passthru("cd {$repoRoot} && git tag {$tag} 2>/dev/null && git push origin {$tag} 2>/dev/null", $exitCode); passthru("cd {$repoRoot} && git tag {$tag} 2>/dev/null && git push origin {$tag} 2>/dev/null", $exitCode);
if ($exitCode !== 0) { if ($exitCode !== 0) {
echo "⚠️ Tag {$tag} already exists — skipping\n"; echo "Tag {$tag} already exists — skipping\n";
}
}
echo "\nRelease {$currentVersion} complete\n";
echo " Branch: {$branch}\n";
echo " Tag: {$tag}\n";
echo " Next: run bulk sync to push to all repos\n";
return 0;
} }
} }
echo "\n✅ Release {$currentVersion} complete\n"; $app = new ReleaseCli();
echo " Branch: {$branch}\n"; exit($app->execute());
echo " Tag: {$tag}\n";
echo " Next: run bulk sync to push to all repos\n";
+97 -91
View File
@@ -1,5 +1,6 @@
#!/usr/bin/env php #!/usr/bin/env php
<?php <?php
/* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech> /* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
* *
* SPDX-License-Identifier: GPL-3.0-or-later * SPDX-License-Identifier: GPL-3.0-or-later
@@ -10,63 +11,59 @@
* REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform * REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform
* PATH: /cli/release_body_update.php * PATH: /cli/release_body_update.php
* BRIEF: Update Gitea release body with changelog extract and checksums * BRIEF: Update Gitea release body with changelog extract and checksums
*
* Usage:
* php release_body_update.php --version 04.01.00 --release-tag stable --token TOKEN --api-base URL
* php release_body_update.php --version 04.01.00 --release-tag stable --token TOKEN --api-base URL --zip-name pkg.zip --zip-sha abc123
*
* Options:
* --path Repo root for CHANGELOG.md (default: .)
* --version Version string (required)
* --release-tag Gitea release tag (required)
* --token Gitea API token (required)
* --api-base Gitea API base URL (required)
* --zip-name ZIP filename for checksum table
* --tar-name tar.gz filename for checksum table
* --zip-sha SHA256 of ZIP
* --tar-sha SHA256 of tar.gz
* --output-summary Write to $GITHUB_STEP_SUMMARY
*/ */
declare(strict_types=1); declare(strict_types=1);
$path = '.'; require_once __DIR__ . '/../lib/Enterprise/CliFramework.php';
$version = null;
$releaseTag = null;
$token = null;
$apiBase = null;
$zipName = null;
$tarName = null;
$zipSha = null;
$tarSha = null;
$outputSummary = false;
foreach ($argv as $i => $arg) { use MokoEnterprise\CliFramework;
if ($arg === '--path' && isset($argv[$i + 1])) $path = $argv[$i + 1];
if ($arg === '--version' && isset($argv[$i + 1])) $version = $argv[$i + 1];
if ($arg === '--release-tag' && isset($argv[$i + 1])) $releaseTag = $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 === '--zip-name' && isset($argv[$i + 1])) $zipName = $argv[$i + 1];
if ($arg === '--tar-name' && isset($argv[$i + 1])) $tarName = $argv[$i + 1];
if ($arg === '--zip-sha' && isset($argv[$i + 1])) $zipSha = $argv[$i + 1];
if ($arg === '--tar-sha' && isset($argv[$i + 1])) $tarSha = $argv[$i + 1];
if ($arg === '--output-summary') $outputSummary = true;
}
if ($token === null) $token = getenv('MOKOGITEA_TOKEN') ?: getenv('GITEA_TOKEN') ?: null; class ReleaseBodyUpdateCli extends CliFramework
{
protected function configure(): void
{
$this->setDescription('Update Gitea release body with changelog extract and checksums');
$this->addArgument('--path', 'Repo root for CHANGELOG.md', '.');
$this->addArgument('--version', 'Version string', '');
$this->addArgument('--release-tag', 'Gitea release tag', '');
$this->addArgument('--token', 'Gitea API token', '');
$this->addArgument('--api-base', 'Gitea API base URL', '');
$this->addArgument('--zip-name', 'ZIP filename for checksum table', '');
$this->addArgument('--tar-name', 'tar.gz filename for checksum table', '');
$this->addArgument('--zip-sha', 'SHA256 of ZIP', '');
$this->addArgument('--tar-sha', 'SHA256 of tar.gz', '');
$this->addArgument('--output-summary', 'Write to $GITHUB_STEP_SUMMARY', false);
}
if ($version === null || $releaseTag === null || $token === null || $apiBase === null) { protected function run(): int
fwrite(STDERR, "Usage: release_body_update.php --version VER --release-tag TAG --token TOKEN --api-base URL\n"); {
exit(1); $path = $this->getArgument('--path');
} $version = $this->getArgument('--version');
$releaseTag = $this->getArgument('--release-tag');
$token = $this->getArgument('--token');
$apiBase = $this->getArgument('--api-base');
$zipName = $this->getArgument('--zip-name');
$tarName = $this->getArgument('--tar-name');
$zipSha = $this->getArgument('--zip-sha');
$tarSha = $this->getArgument('--tar-sha');
$outputSummary = $this->getArgument('--output-summary');
$root = realpath($path) ?: $path; if (empty($token)) {
$token = getenv('MOKOGITEA_TOKEN') ?: getenv('GITEA_TOKEN') ?: '';
}
// Extract changelog section for this version if (empty($version) || empty($releaseTag) || empty($token) || empty($apiBase)) {
$changelog = ''; $this->log('ERROR', 'Usage: release_body_update.php --version VER --release-tag TAG --token TOKEN --api-base URL');
$clFile = "{$root}/CHANGELOG.md"; return 1;
if (file_exists($clFile)) { }
$root = realpath($path) ?: $path;
// Extract changelog section for this version
$changelog = '';
$clFile = "{$root}/CHANGELOG.md";
if (file_exists($clFile)) {
$lines = file($clFile, FILE_IGNORE_NEW_LINES); $lines = file($clFile, FILE_IGNORE_NEW_LINES);
$capturing = false; $capturing = false;
$clLines = []; $clLines = [];
@@ -75,78 +72,87 @@ if (file_exists($clFile)) {
$capturing = true; $capturing = true;
continue; continue;
} }
if ($capturing && preg_match('/^## /', $line)) break; if ($capturing && preg_match('/^## /', $line)) {
if ($capturing) $clLines[] = $line; break;
}
if ($capturing) {
$clLines[] = $line;
}
} }
$changelog = trim(implode("\n", $clLines)); $changelog = trim(implode("\n", $clLines));
} }
// Build release body // Build release body
$body = "## {$version} (" . date('Y-m-d') . ")\n\n"; $body = "## {$version} (" . date('Y-m-d') . ")\n\n";
if (!empty($changelog)) { if (!empty($changelog)) {
$body .= "{$changelog}\n\n"; $body .= "{$changelog}\n\n";
} }
if ($zipSha !== null || $tarSha !== null) { if (!empty($zipSha) || !empty($tarSha)) {
$body .= "---\n\n### Checksums\n\n| File | SHA-256 |\n|------|--------|\n"; $body .= "---\n\n### Checksums\n\n| File | SHA-256 |\n|------|--------|\n";
if ($zipName !== null && $zipSha !== null) { if (!empty($zipName) && !empty($zipSha)) {
$body .= "| `{$zipName}` | `{$zipSha}` |\n"; $body .= "| `{$zipName}` | `{$zipSha}` |\n";
} }
if ($tarName !== null && $tarSha !== null) { if (!empty($tarName) && !empty($tarSha)) {
$body .= "| `{$tarName}` | `{$tarSha}` |\n"; $body .= "| `{$tarName}` | `{$tarSha}` |\n";
} }
} }
// Get release ID by tag // Get release ID by tag
$ch = curl_init("{$apiBase}/releases/tags/{$releaseTag}"); $ch = curl_init("{$apiBase}/releases/tags/{$releaseTag}");
curl_setopt_array($ch, [ curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true, CURLOPT_RETURNTRANSFER => true,
CURLOPT_HTTPHEADER => ["Authorization: token {$token}"], CURLOPT_HTTPHEADER => ["Authorization: token {$token}"],
CURLOPT_TIMEOUT => 30, CURLOPT_TIMEOUT => 30,
]); ]);
$response = curl_exec($ch); $response = curl_exec($ch);
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE); $httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch); curl_close($ch);
if ($httpCode !== 200 || empty($response)) { if ($httpCode !== 200 || empty($response)) {
fwrite(STDERR, "Failed to get release for tag '{$releaseTag}' (HTTP {$httpCode})\n"); $this->log('ERROR', "Failed to get release for tag '{$releaseTag}' (HTTP {$httpCode})");
exit(1); return 1;
} }
$release = json_decode($response, true); $release = json_decode($response, true);
$releaseId = $release['id'] ?? null; $releaseId = $release['id'] ?? null;
if ($releaseId === null) { if ($releaseId === null) {
fwrite(STDERR, "No release ID found for tag '{$releaseTag}'\n"); $this->log('ERROR', "No release ID found for tag '{$releaseTag}'");
exit(1); return 1;
} }
// PATCH release body // PATCH release body
$payload = json_encode(['body' => $body]); $payload = json_encode(['body' => $body]);
$ch = curl_init("{$apiBase}/releases/{$releaseId}"); $ch = curl_init("{$apiBase}/releases/{$releaseId}");
curl_setopt_array($ch, [ curl_setopt_array($ch, [
CURLOPT_CUSTOMREQUEST => 'PATCH', CURLOPT_CUSTOMREQUEST => 'PATCH',
CURLOPT_RETURNTRANSFER => true, CURLOPT_RETURNTRANSFER => true,
CURLOPT_HTTPHEADER => ["Authorization: token {$token}", "Content-Type: application/json"], CURLOPT_HTTPHEADER => ["Authorization: token {$token}", "Content-Type: application/json"],
CURLOPT_POSTFIELDS => $payload, CURLOPT_POSTFIELDS => $payload,
CURLOPT_TIMEOUT => 30, CURLOPT_TIMEOUT => 30,
]); ]);
$response = curl_exec($ch); $response = curl_exec($ch);
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE); $httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch); curl_close($ch);
if ($httpCode !== 200) { if ($httpCode !== 200) {
fwrite(STDERR, "Failed to update release body (HTTP {$httpCode})\n"); $this->log('ERROR', "Failed to update release body (HTTP {$httpCode})");
exit(1); return 1;
} }
echo "Release body updated for {$releaseTag} (release #{$releaseId})\n"; echo "Release body updated for {$releaseTag} (release #{$releaseId})\n";
if ($outputSummary) { if ($outputSummary) {
$summaryFile = getenv('GITHUB_STEP_SUMMARY'); $summaryFile = getenv('GITHUB_STEP_SUMMARY');
if ($summaryFile) { if ($summaryFile) {
file_put_contents($summaryFile, "Release body updated with changelog + checksums\n", FILE_APPEND); file_put_contents($summaryFile, "Release body updated with changelog + checksums\n", FILE_APPEND);
} }
}
return 0;
}
} }
exit(0); $app = new ReleaseBodyUpdateCli();
exit($app->execute());
+24 -3
View File
@@ -1,5 +1,6 @@
#!/usr/bin/env php #!/usr/bin/env php
<?php <?php
/* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech> /* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
* *
* SPDX-License-Identifier: GPL-3.0-or-later * SPDX-License-Identifier: GPL-3.0-or-later
@@ -9,9 +10,29 @@
* INGROUP: moko-platform * INGROUP: moko-platform
* REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform * REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform
* PATH: /cli/release_cascade.php * PATH: /cli/release_cascade.php
* VERSION: 09.21.00 * VERSION: 09.24.00
* BRIEF: DEPRECATED — cascade behavior removed. Each release stream is independent. * BRIEF: DEPRECATED — cascade behavior removed. Each release stream is independent.
*/ */
echo "release_cascade.php: No-op (cascade behavior removed — each stream is independent)\n"; declare(strict_types=1);
exit(0);
require_once __DIR__ . '/../lib/Enterprise/CliFramework.php';
use MokoEnterprise\CliFramework;
class ReleaseCascadeCli extends CliFramework
{
protected function configure(): void
{
$this->setDescription('DEPRECATED — cascade behavior removed');
}
protected function run(): int
{
$this->log('INFO', 'No-op (cascade behavior removed — each stream is independent)');
return 0;
}
}
$app = new ReleaseCascadeCli();
exit($app->execute());
+250 -278
View File
@@ -11,59 +11,42 @@
* REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform * REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform
* PATH: /cli/release_create.php * PATH: /cli/release_create.php
* BRIEF: Create or overwrite a Gitea release with proper naming * BRIEF: Create or overwrite a Gitea release with proper naming
*
* Usage:
* php release_create.php --version 09.01.00 --tag stable --token TOKEN --api-base URL
* php release_create.php --version 09.01.00 --tag development --token TOKEN --api-base URL --prerelease
* php release_create.php --version 09.01.00 --tag stable --token TOKEN --api-base URL --path . --repo MyRepo
*
* Replaces the inline bash in auto-release.yml Step 7b.
* Detects extension metadata from manifest, builds a proper release name,
* generates release notes, and creates (or overwrites) a Gitea release.
*/ */
declare(strict_types=1); declare(strict_types=1);
// ── Argument parsing ──────────────────────────────────────────────────────── require_once __DIR__ . '/../lib/Enterprise/CliFramework.php';
$path = '.'; use MokoEnterprise\CliFramework;
$version = null;
$tag = null;
$token = null;
$apiBase = null;
$branch = 'main';
$repoName = '';
$prerelease = false;
foreach ($argv as $i => $arg) { class ReleaseCreateCli extends CliFramework
if ($arg === '--path' && isset($argv[$i + 1])) { {
$path = $argv[$i + 1]; protected function configure(): void
{
$this->setDescription('Create or overwrite a Gitea release with proper naming');
$this->addArgument('--path', 'Repo root for manifest detection (default: .)', '.');
$this->addArgument('--version', 'Version string (required)', '');
$this->addArgument('--tag', 'Release tag name (required)', '');
$this->addArgument('--token', 'Gitea API token (required)', '');
$this->addArgument('--api-base', 'Gitea API base URL for the repo (required)', '');
$this->addArgument('--branch', 'Target commitish (default: main)', 'main');
$this->addArgument('--repo', 'Repo name for fallback element detection', '');
$this->addArgument('--prerelease', 'Mark release as prerelease', false);
} }
if ($arg === '--version' && isset($argv[$i + 1])) {
$version = $argv[$i + 1];
}
if ($arg === '--tag' && isset($argv[$i + 1])) {
$tag = $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 === '--branch' && isset($argv[$i + 1])) {
$branch = $argv[$i + 1];
}
if ($arg === '--repo' && isset($argv[$i + 1])) {
$repoName = $argv[$i + 1];
}
if ($arg === '--prerelease') {
$prerelease = true;
}
}
// Allow token from environment protected function run(): int
if ($token === null) { {
$path = $this->getArgument('--path');
$version = $this->getArgument('--version');
$tag = $this->getArgument('--tag');
$token = $this->getArgument('--token');
$apiBase = $this->getArgument('--api-base');
$branch = $this->getArgument('--branch');
$repoName = $this->getArgument('--repo');
$prerelease = (bool) $this->getArgument('--prerelease');
// Allow token from environment
if ($token === '') {
$envToken = getenv('MOKOGITEA_TOKEN'); $envToken = getenv('MOKOGITEA_TOKEN');
if ($envToken === false || $envToken === '') { if ($envToken === false || $envToken === '') {
$envToken = getenv('GITEA_TOKEN'); $envToken = getenv('GITEA_TOKEN');
@@ -71,32 +54,229 @@ if ($token === null) {
if ($envToken !== false && $envToken !== '') { if ($envToken !== false && $envToken !== '') {
$token = $envToken; $token = $envToken;
} }
} }
if ($version === null || $tag === null || $token === null || $apiBase === null) { if ($version === '' || $tag === '' || $token === '' || $apiBase === '') {
fwrite(STDERR, "Usage: release_create.php --version VER --tag TAG --token TOKEN --api-base URL [options]\n"); $this->log('ERROR', "Usage: release_create.php --version VER --tag TAG --token TOKEN --api-base URL [options]");
fwrite(STDERR, " --path . Repo root for manifest detection (default: .)\n"); $this->log('ERROR', " --path . Repo root for manifest detection (default: .)");
fwrite(STDERR, " --branch main Target commitish (default: main)\n"); $this->log('ERROR', " --branch main Target commitish (default: main)");
fwrite(STDERR, " --repo REPO Repo name for fallback element detection\n"); $this->log('ERROR', " --repo REPO Repo name for fallback element detection");
fwrite(STDERR, " --prerelease Mark release as prerelease\n"); $this->log('ERROR', " --prerelease Mark release as prerelease");
fwrite(STDERR, " Token can also be set via MOKOGITEA_TOKEN or GITEA_TOKEN env var\n"); $this->log('ERROR', " Token can also be set via MOKOGITEA_TOKEN or GITEA_TOKEN env var");
exit(1); return 1;
} }
// ── Helper: Gitea API request ─────────────────────────────────────────────── // ── Detect element metadata ─────────────────────────────────────────────
/** $root = realpath($path) ?: $path;
* Send a request to the Gitea API.
* $extElement = '';
* @param string $url Full API URL $extType = '';
* @param string $token Authorization token $extFolder = '';
* @param string $method HTTP method (GET, POST, DELETE, etc.) $extName = '';
* @param string|null $body JSON request body $typePrefix = '';
*
* @return array<string, mixed>|null Decoded response or null on failure // Detect platform and display name from manifest.xml
*/ $platform = 'generic';
function giteaApi(string $url, string $token, string $method = 'GET', ?string $body = null): ?array $prettyName = '';
{ $manifestXml = "{$root}/.mokogitea/manifest.xml";
if (file_exists($manifestXml)) {
$content = file_get_contents($manifestXml);
if ($content !== false) {
if (preg_match('/<platform>([^<]+)<\/platform>/', $content, $pm)) {
$platform = trim($pm[1]);
}
if (preg_match('/<display-name>([^<]+)<\/display-name>/', $content, $dn)) {
$prettyName = trim($dn[1]);
} elseif (preg_match('/<name>([^<]+)<\/name>/', $content, $nm)) {
$prettyName = trim($nm[1]);
}
}
}
// Find extension manifest (Joomla XML)
$extManifest = null;
$manifestFiles = array_merge(
glob("{$root}/src/pkg_*.xml") ?: [],
glob("{$root}/src/*.xml") ?: [],
glob("{$root}/*.xml") ?: []
);
foreach ($manifestFiles as $file) {
$c = file_get_contents($file);
if ($c !== false && strpos($c, '<extension') !== false) {
$extManifest = $file;
break;
}
}
// Find Dolibarr module file
$modFile = null;
$modFiles = array_merge(
glob("{$root}/src/core/modules/mod*.class.php") ?: [],
glob("{$root}/htdocs/core/modules/mod*.class.php") ?: [],
glob("{$root}/core/modules/mod*.class.php") ?: []
);
foreach ($modFiles as $file) {
$c = file_get_contents($file);
if ($c !== false && strpos($c, 'extends DolibarrModules') !== false) {
$modFile = $file;
break;
}
}
// Extract metadata based on platform
switch (true) {
case in_array($platform, ['joomla', 'waas-component'], true) && $extManifest !== null:
$xml = file_get_contents($extManifest);
if ($xml === false) {
break;
}
if (preg_match('/type="([^"]*)"/', $xml, $tm)) {
$extType = $tm[1];
}
if (preg_match('/group="([^"]*)"/', $xml, $gm)) {
$extFolder = $gm[1];
}
if (preg_match('/<element>([^<]+)<\/element>/', $xml, $em)) {
$extElement = $em[1];
}
if (empty($extElement) && preg_match('/plugin="([^"]*)"/', $xml, $pm2)) {
$extElement = $pm2[1];
}
if ($extType === 'package' && preg_match('/<packagename>([^<]+)<\/packagename>/', $xml, $pn)) {
$extElement = $pn[1];
}
if (empty($extElement)) {
$extElement = strtolower(basename($extManifest, '.xml'));
if (in_array($extElement, ['templatedetails', 'manifest'], true)) {
$extElement = strtolower(str_replace([' ', '-'], '', $repoName !== '' ? $repoName : basename($root)));
}
}
if (preg_match('/<name>([^<]+)<\/name>/', $xml, $nm)) {
$extName = trim($nm[1]);
}
break;
case in_array($platform, ['dolibarr', 'crm-module'], true) && $modFile !== null:
$extType = 'dolibarr-module';
$modBasename = basename($modFile, '.class.php');
$extElement = strtolower(preg_replace('/^mod/', '', $modBasename) ?? $modBasename);
$modContent = file_get_contents($modFile);
if ($modContent !== false && preg_match('/\$this->name\s*=\s*[\'"]([^\'"]+)[\'"]/', $modContent, $nm2)) {
$extName = $nm2[1];
}
break;
default:
$extElement = strtolower(str_replace([' ', '-'], '', $repoName !== '' ? $repoName : basename($root)));
$extType = 'generic';
break;
}
// Strip existing type prefix from element to prevent duplication
$extElement = preg_replace('/^(pkg_|com_|mod_|plg_[a-z]+_|tpl_|lib_)/', '', $extElement) ?? $extElement;
// Compute type prefix
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;
}
// Fallback name
if (empty($extName)) {
$extName = $repoName !== '' ? $repoName : basename($root);
}
echo "Element: {$extElement}, Type: {$extType}, Prefix: {$typePrefix}, Name: {$extName}\n";
// ── Build release name ──────────────────────────────────────────────────────
$displayName = !empty($prettyName) ? $prettyName : $extName;
$releaseName = "{$displayName} (VERSION: {$version})";
echo "Release name: {$releaseName}\n";
// ── Generate release notes ──────────────────────────────────────────────────
$releaseNotes = "Release {$version}";
$releaseNotesScript = dirname(__DIR__) . '/cli/release_notes.php';
if (file_exists($releaseNotesScript)) {
$cmd = sprintf(
'php %s --path %s --version %s',
escapeshellarg($releaseNotesScript),
escapeshellarg($root),
escapeshellarg($version)
);
$output = [];
$exitCode = 0;
exec($cmd, $output, $exitCode);
if ($exitCode === 0 && count($output) > 0) {
$notes = implode("\n", $output);
if (trim($notes) !== '') {
$releaseNotes = $notes;
echo "Release notes: generated from CHANGELOG.md\n";
}
}
}
// ── Delete existing release at tag (if present) ─────────────────────────────
$existing = $this->giteaApi("{$apiBase}/releases/tags/{$tag}", $token);
if ($existing !== null && !empty($existing['id'])) {
$existingId = $existing['id'];
echo "Deleting existing release: {$tag} (id: {$existingId})\n";
// Delete release
$this->giteaApi("{$apiBase}/releases/{$existingId}", $token, 'DELETE');
// Delete tag
$this->giteaApi("{$apiBase}/tags/{$tag}", $token, 'DELETE');
}
// ── Create new release ──────────────────────────────────────────────────────
$payload = json_encode([
'tag_name' => $tag,
'target_commitish' => $branch,
'name' => $releaseName,
'body' => $releaseNotes,
'prerelease' => $prerelease,
]);
$newRelease = $this->giteaApi("{$apiBase}/releases", $token, 'POST', $payload !== false ? $payload : '{}');
if ($newRelease === null || empty($newRelease['id'])) {
$this->log('ERROR', "Failed to create release at tag: {$tag}");
return 1;
}
$releaseId = $newRelease['id'];
echo "Created release: {$tag} (id: {$releaseId})\n";
// Output release_id to stdout for CI consumption
echo "release_id={$releaseId}\n";
return 0;
}
private function giteaApi(string $url, string $token, string $method = 'GET', ?string $body = null): ?array
{
$ch = curl_init($url); $ch = curl_init($url);
if ($ch === false) { if ($ch === false) {
return null; return null;
@@ -123,216 +303,8 @@ function giteaApi(string $url, string $token, string $method = 'GET', ?string $b
$decoded = json_decode($response, true); $decoded = json_decode($response, true);
return is_array($decoded) ? $decoded : null; return is_array($decoded) ? $decoded : null;
}
// ── Detect element metadata ─────────────────────────────────────────────────
$root = realpath($path) ?: $path;
$extElement = '';
$extType = '';
$extFolder = '';
$extName = '';
$typePrefix = '';
// Detect platform and display name from manifest.xml
$platform = 'generic';
$prettyName = '';
$manifestXml = "{$root}/.mokogitea/manifest.xml";
if (file_exists($manifestXml)) {
$content = file_get_contents($manifestXml);
if ($content !== false) {
if (preg_match('/<platform>([^<]+)<\/platform>/', $content, $pm)) {
$platform = trim($pm[1]);
}
// <display-name> is the human-friendly name; <name> is the element/repo name
if (preg_match('/<display-name>([^<]+)<\/display-name>/', $content, $dn)) {
$prettyName = trim($dn[1]);
} elseif (preg_match('/<name>([^<]+)<\/name>/', $content, $nm)) {
$prettyName = trim($nm[1]);
}
} }
} }
// Find extension manifest (Joomla XML) $app = new ReleaseCreateCli();
$extManifest = null; exit($app->execute());
$manifestFiles = array_merge(
glob("{$root}/src/pkg_*.xml") ?: [],
glob("{$root}/src/*.xml") ?: [],
glob("{$root}/*.xml") ?: []
);
foreach ($manifestFiles as $file) {
$c = file_get_contents($file);
if ($c !== false && strpos($c, '<extension') !== false) {
$extManifest = $file;
break;
}
}
// Find Dolibarr module file
$modFile = null;
$modFiles = array_merge(
glob("{$root}/src/core/modules/mod*.class.php") ?: [],
glob("{$root}/htdocs/core/modules/mod*.class.php") ?: [],
glob("{$root}/core/modules/mod*.class.php") ?: []
);
foreach ($modFiles as $file) {
$c = file_get_contents($file);
if ($c !== false && strpos($c, 'extends DolibarrModules') !== false) {
$modFile = $file;
break;
}
}
// Extract metadata based on platform
switch (true) {
case in_array($platform, ['joomla', 'waas-component'], true) && $extManifest !== null:
$xml = file_get_contents($extManifest);
if ($xml === false) {
break;
}
if (preg_match('/type="([^"]*)"/', $xml, $tm)) {
$extType = $tm[1];
}
if (preg_match('/group="([^"]*)"/', $xml, $gm)) {
$extFolder = $gm[1];
}
// Element name: <element>, plugin= attribute, <packagename>, or filename
if (preg_match('/<element>([^<]+)<\/element>/', $xml, $em)) {
$extElement = $em[1];
}
if (empty($extElement) && preg_match('/plugin="([^"]*)"/', $xml, $pm2)) {
$extElement = $pm2[1];
}
if ($extType === 'package' && preg_match('/<packagename>([^<]+)<\/packagename>/', $xml, $pn)) {
$extElement = $pn[1];
}
if (empty($extElement)) {
$extElement = strtolower(basename($extManifest, '.xml'));
if (in_array($extElement, ['templatedetails', 'manifest'], true)) {
$extElement = strtolower(str_replace([' ', '-'], '', $repoName !== '' ? $repoName : basename($root)));
}
}
// Human-readable name
if (preg_match('/<name>([^<]+)<\/name>/', $xml, $nm)) {
$extName = trim($nm[1]);
}
break;
case in_array($platform, ['dolibarr', 'crm-module'], true) && $modFile !== null:
$extType = 'dolibarr-module';
$modBasename = basename($modFile, '.class.php');
$extElement = strtolower(preg_replace('/^mod/', '', $modBasename) ?? $modBasename);
$modContent = file_get_contents($modFile);
if ($modContent !== false && preg_match('/\$this->name\s*=\s*[\'"]([^\'"]+)[\'"]/', $modContent, $nm2)) {
$extName = $nm2[1];
}
break;
default:
$extElement = strtolower(str_replace([' ', '-'], '', $repoName !== '' ? $repoName : basename($root)));
$extType = 'generic';
break;
}
// Strip existing type prefix from element to prevent duplication
$extElement = preg_replace('/^(pkg_|com_|mod_|plg_[a-z]+_|tpl_|lib_)/', '', $extElement) ?? $extElement;
// Compute type prefix
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;
}
// Fallback name
if (empty($extName)) {
$extName = $repoName !== '' ? $repoName : basename($root);
}
echo "Element: {$extElement}, Type: {$extType}, Prefix: {$typePrefix}, Name: {$extName}\n";
// ── Build release name ──────────────────────────────────────────────────────
// Use display-name from manifest.xml if available, otherwise fall back to extName
$displayName = !empty($prettyName) ? $prettyName : $extName;
$releaseName = "{$displayName} {$version} ({$typePrefix}{$extElement}-{$version})";
echo "Release name: {$releaseName}\n";
// ── Generate release notes ──────────────────────────────────────────────────
$releaseNotes = "Release {$version}";
$releaseNotesScript = dirname(__DIR__) . '/cli/release_notes.php';
if (file_exists($releaseNotesScript)) {
$cmd = sprintf(
'php %s --path %s --version %s',
escapeshellarg($releaseNotesScript),
escapeshellarg($root),
escapeshellarg($version)
);
$output = [];
$exitCode = 0;
exec($cmd, $output, $exitCode);
if ($exitCode === 0 && count($output) > 0) {
$notes = implode("\n", $output);
if (trim($notes) !== '') {
$releaseNotes = $notes;
echo "Release notes: generated from CHANGELOG.md\n";
}
}
}
// ── Delete existing release at tag (if present) ─────────────────────────────
$existing = giteaApi("{$apiBase}/releases/tags/{$tag}", $token);
if ($existing !== null && !empty($existing['id'])) {
$existingId = $existing['id'];
echo "Deleting existing release: {$tag} (id: {$existingId})\n";
// Delete release
giteaApi("{$apiBase}/releases/{$existingId}", $token, 'DELETE');
// Delete tag
giteaApi("{$apiBase}/tags/{$tag}", $token, 'DELETE');
}
// ── Create new release ──────────────────────────────────────────────────────
$payload = json_encode([
'tag_name' => $tag,
'target_commitish' => $branch,
'name' => $releaseName,
'body' => $releaseNotes,
'prerelease' => $prerelease,
]);
$newRelease = giteaApi("{$apiBase}/releases", $token, 'POST', $payload !== false ? $payload : '{}');
if ($newRelease === null || empty($newRelease['id'])) {
fwrite(STDERR, "Failed to create release at tag: {$tag}\n");
exit(1);
}
$releaseId = $newRelease['id'];
echo "Created release: {$tag} (id: {$releaseId})\n";
// Output release_id to stdout for CI consumption
echo "release_id={$releaseId}\n";
exit(0);
+139 -203
View File
@@ -1,5 +1,6 @@
#!/usr/bin/env php #!/usr/bin/env php
<?php <?php
/* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech> /* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
* *
* SPDX-License-Identifier: GPL-3.0-or-later * SPDX-License-Identifier: GPL-3.0-or-later
@@ -10,90 +11,144 @@
* REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform * REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform
* PATH: /cli/release_manage.php * PATH: /cli/release_manage.php
* BRIEF: Create/update Gitea releases, upload assets, update release body * BRIEF: Create/update Gitea releases, upload assets, update release body
*
* Usage:
* # Create a release
* php release_manage.php --action create --tag stable --name "My Plugin 04.01.00" \
* --body "Release notes" --target main --token TOKEN --api-base URL
*
* # Upload assets to a release
* php release_manage.php --action upload --tag stable --files "/tmp/pkg.zip,/tmp/pkg.tar.gz" \
* --token TOKEN --api-base URL
*
* # Update release body (e.g. add SHA checksums)
* php release_manage.php --action update-body --tag stable --body "New body" \
* --token TOKEN --api-base URL
*
* # Delete a release and its tag
* php release_manage.php --action delete --tag stable --token TOKEN --api-base URL
*
* Options:
* --action create | upload | update-body | delete (required)
* --tag Release tag name (required)
* --name Release name/title (for create)
* --body Release body/description (for create, update-body)
* --body-file Read body from file instead of --body
* --target Target branch/commitish (for create, default: main)
* --files Comma-separated file paths to upload (for upload)
* --token Gitea API token (or MOKOGITEA_TOKEN/GITEA_TOKEN env var)
* --api-base Gitea API base URL (e.g. https://git.mokoconsulting.tech/api/v1/repos/Org/Repo)
*
* NOTE: This script uses PHP curl for all HTTP operations (no shell calls).
*/ */
declare(strict_types=1); declare(strict_types=1);
$action = null; require_once __DIR__ . '/../lib/Enterprise/CliFramework.php';
$tag = null;
$name = null;
$body = null;
$bodyFile = null;
$target = 'main';
$files = [];
$token = null;
$apiBase = null;
foreach ($argv as $i => $arg) { use MokoEnterprise\CliFramework;
if ($arg === '--action' && isset($argv[$i + 1])) $action = $argv[$i + 1];
if ($arg === '--tag' && isset($argv[$i + 1])) $tag = $argv[$i + 1];
if ($arg === '--name' && isset($argv[$i + 1])) $name = $argv[$i + 1];
if ($arg === '--body' && isset($argv[$i + 1])) $body = $argv[$i + 1];
if ($arg === '--body-file' && isset($argv[$i + 1])) $bodyFile = $argv[$i + 1];
if ($arg === '--target' && isset($argv[$i + 1])) $target = $argv[$i + 1];
if ($arg === '--files' && isset($argv[$i + 1])) $files = array_filter(explode(',', $argv[$i + 1]));
if ($arg === '--token' && isset($argv[$i + 1])) $token = $argv[$i + 1];
if ($arg === '--api-base' && isset($argv[$i + 1])) $apiBase = $argv[$i + 1];
}
// Allow token from environment class ReleaseManageCli extends CliFramework
if ($token === null) {
$token = getenv('MOKOGITEA_TOKEN') ?: getenv('GITEA_TOKEN') ?: null;
}
// Read body from file if specified
if ($bodyFile !== null && file_exists($bodyFile)) {
$body = file_get_contents($bodyFile);
}
if ($action === null || $tag === null || $token === null || $apiBase === null) {
fwrite(STDERR, "Usage: release_manage.php --action [create|upload|update-body|delete] --tag TAG --token TOKEN --api-base URL\n");
exit(1);
}
/**
* Make a Gitea API request using curl
*/
function releaseGiteaApi(string $url, string $method, string $token, ?string $jsonBody = null, ?string $filePath = null): array
{ {
protected function configure(): void
{
$this->setDescription('Create/update Gitea releases, upload assets, update release body');
$this->addArgument('--action', 'create | upload | update-body | delete', null);
$this->addArgument('--tag', 'Release tag name', null);
$this->addArgument('--name', 'Release name/title', null);
$this->addArgument('--body', 'Release body/description', null);
$this->addArgument('--body-file', 'Read body from file', null);
$this->addArgument('--target', 'Target branch/commitish', 'main');
$this->addArgument('--files', 'Comma-separated file paths to upload', null);
$this->addArgument('--token', 'Gitea API token', null);
$this->addArgument('--api-base', 'Gitea API base URL', null);
}
protected function run(): int
{
$action = $this->getArgument('--action');
$tag = $this->getArgument('--tag');
$name = $this->getArgument('--name');
$body = $this->getArgument('--body');
$bodyFile = $this->getArgument('--body-file');
$target = $this->getArgument('--target');
$filesArg = $this->getArgument('--files');
$token = $this->getArgument('--token');
$apiBase = $this->getArgument('--api-base');
$files = $filesArg !== null ? array_filter(explode(',', $filesArg)) : [];
if ($token === null) {
$token = getenv('MOKOGITEA_TOKEN') ?: getenv('GITEA_TOKEN') ?: null;
}
if ($bodyFile !== null && file_exists($bodyFile)) {
$body = file_get_contents($bodyFile);
}
if ($action === null || $tag === null || $token === null || $apiBase === null) {
$this->log('ERROR', "Usage: release_manage.php --action [create|upload|update-body|delete] --tag TAG --token TOKEN --api-base URL");
return 1;
}
switch ($action) {
case 'create':
$existing = $this->getReleaseByTag($apiBase, $tag, $token);
if ($existing !== null) {
$existingId = $existing['id'];
$this->releaseGiteaApi("{$apiBase}/releases/{$existingId}", 'DELETE', $token);
$this->releaseGiteaApi("{$apiBase}/tags/{$tag}", 'DELETE', $token);
echo "Deleted previous release: {$tag} (id: {$existingId})\n";
}
$payload = json_encode(['tag_name' => $tag, 'name' => $name ?? $tag, 'body' => $body ?? '', 'target_commitish' => $target]);
$result = $this->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";
} else {
$this->log('ERROR', "Failed to create release: HTTP {$result['code']}");
return 1;
}
break;
case 'upload':
if (empty($files)) {
$this->log('ERROR', "No files specified. Use --files /path/to/file1,/path/to/file2");
return 1;
}
$release = $this->getReleaseByTag($apiBase, $tag, $token);
if ($release === null) {
$this->log('ERROR', "No release found for tag: {$tag}");
return 1;
}
$releaseId = $release['id'];
$assetsResult = $this->releaseGiteaApi("{$apiBase}/releases/{$releaseId}/assets", 'GET', $token);
$existingAssets = $assetsResult['data'] ?? [];
foreach ($files as $filePath) {
$filePath = trim($filePath);
if (!file_exists($filePath)) {
$this->log('ERROR', "File not found: {$filePath}");
continue;
}
$fileName = basename($filePath);
foreach ($existingAssets as $asset) {
if (($asset['name'] ?? '') === $fileName) {
$this->releaseGiteaApi("{$apiBase}/releases/{$releaseId}/assets/{$asset['id']}", 'DELETE', $token);
echo "Deleted existing asset: {$fileName}\n";
break;
}
}
$uploadUrl = "{$apiBase}/releases/{$releaseId}/assets?name=" . urlencode($fileName);
$result = $this->releaseGiteaApi($uploadUrl, 'POST', $token, null, $filePath);
if ($result['code'] >= 200 && $result['code'] < 300) {
echo "Uploaded: {$fileName}\n";
} else {
$this->log('ERROR', "Failed to upload {$fileName}: HTTP {$result['code']}");
}
}
break;
case 'update-body':
$release = $this->getReleaseByTag($apiBase, $tag, $token);
if ($release === null) {
$this->log('ERROR', "No release found for tag: {$tag}");
return 1;
}
$payload = json_encode(['body' => $body ?? '']);
$result = $this->releaseGiteaApi("{$apiBase}/releases/{$release['id']}", 'PATCH', $token, $payload);
if ($result['code'] >= 200 && $result['code'] < 300) {
echo "Release body updated for tag: {$tag}\n";
} else {
$this->log('ERROR', "Failed to update body: HTTP {$result['code']}");
return 1;
}
break;
case 'delete':
$existing = $this->getReleaseByTag($apiBase, $tag, $token);
if ($existing !== null) {
$this->releaseGiteaApi("{$apiBase}/releases/{$existing['id']}", 'DELETE', $token);
$this->releaseGiteaApi("{$apiBase}/tags/{$tag}", 'DELETE', $token);
echo "Deleted: {$tag} (id: {$existing['id']})\n";
} else {
echo "No release found for tag: {$tag}\n";
}
break;
default:
$this->log('ERROR', "Unknown action: {$action}");
return 1;
}
return 0;
}
private function releaseGiteaApi(string $url, string $method, string $token, ?string $jsonBody = null, ?string $filePath = null): array
{
$ch = curl_init($url); $ch = curl_init($url);
$headers = ["Authorization: token {$token}"]; $headers = ["Authorization: token {$token}"];
$opts = [CURLOPT_RETURNTRANSFER => true, CURLOPT_TIMEOUT => 60, CURLOPT_CUSTOMREQUEST => $method];
$opts = [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => 60,
CURLOPT_CUSTOMREQUEST => $method,
];
if ($jsonBody !== null) { if ($jsonBody !== null) {
$headers[] = 'Content-Type: application/json'; $headers[] = 'Content-Type: application/json';
$opts[CURLOPT_POSTFIELDS] = $jsonBody; $opts[CURLOPT_POSTFIELDS] = $jsonBody;
@@ -101,139 +156,20 @@ function releaseGiteaApi(string $url, string $method, string $token, ?string $js
$headers[] = 'Content-Type: application/octet-stream'; $headers[] = 'Content-Type: application/octet-stream';
$opts[CURLOPT_POSTFIELDS] = file_get_contents($filePath); $opts[CURLOPT_POSTFIELDS] = file_get_contents($filePath);
} }
$opts[CURLOPT_HTTPHEADER] = $headers; $opts[CURLOPT_HTTPHEADER] = $headers;
curl_setopt_array($ch, $opts); curl_setopt_array($ch, $opts);
$response = curl_exec($ch); $response = curl_exec($ch);
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE); $httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch); curl_close($ch);
return ['code' => $httpCode, 'data' => json_decode($response ?: '{}', true) ?: []];
}
$data = json_decode($response ?: '{}', true) ?: []; private function getReleaseByTag(string $apiBase, string $tag, string $token): ?array
return ['code' => $httpCode, 'data' => $data]; {
$result = $this->releaseGiteaApi("{$apiBase}/releases/tags/{$tag}", 'GET', $token);
return ($result['code'] === 200 && isset($result['data']['id'])) ? $result['data'] : null;
}
} }
/** $app = new ReleaseManageCli();
* Get release by tag exit($app->execute());
*/
function getReleaseByTag(string $apiBase, string $tag, string $token): ?array
{
$result = releaseGiteaApi("{$apiBase}/releases/tags/{$tag}", 'GET', $token);
if ($result['code'] === 200 && isset($result['data']['id'])) {
return $result['data'];
}
return null;
}
// -- Action dispatch ----------------------------------------------------------
switch ($action) {
case 'create':
// Delete existing release if present
$existing = getReleaseByTag($apiBase, $tag, $token);
if ($existing !== null) {
$existingId = $existing['id'];
releaseGiteaApi("{$apiBase}/releases/{$existingId}", 'DELETE', $token);
releaseGiteaApi("{$apiBase}/tags/{$tag}", 'DELETE', $token);
echo "Deleted previous release: {$tag} (id: {$existingId})\n";
}
$payload = json_encode([
'tag_name' => $tag,
'name' => $name ?? $tag,
'body' => $body ?? '',
'target_commitish' => $target,
]);
$result = 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";
} else {
fwrite(STDERR, "Failed to create release: HTTP {$result['code']}\n");
fwrite(STDERR, json_encode($result['data']) . "\n");
exit(1);
}
break;
case 'upload':
if (empty($files)) {
fwrite(STDERR, "No files specified. Use --files /path/to/file1,/path/to/file2\n");
exit(1);
}
$release = getReleaseByTag($apiBase, $tag, $token);
if ($release === null) {
fwrite(STDERR, "No release found for tag: {$tag}\n");
exit(1);
}
$releaseId = $release['id'];
// Get existing assets to avoid duplicates
$assetsResult = releaseGiteaApi("{$apiBase}/releases/{$releaseId}/assets", 'GET', $token);
$existingAssets = $assetsResult['data'] ?? [];
foreach ($files as $filePath) {
$filePath = trim($filePath);
if (!file_exists($filePath)) {
fwrite(STDERR, "File not found: {$filePath}\n");
continue;
}
$fileName = basename($filePath);
// Delete existing asset with same name
foreach ($existingAssets as $asset) {
if (($asset['name'] ?? '') === $fileName) {
releaseGiteaApi("{$apiBase}/releases/{$releaseId}/assets/{$asset['id']}", 'DELETE', $token);
echo "Deleted existing asset: {$fileName}\n";
break;
}
}
// Upload
$uploadUrl = "{$apiBase}/releases/{$releaseId}/assets?name=" . urlencode($fileName);
$result = releaseGiteaApi($uploadUrl, 'POST', $token, null, $filePath);
if ($result['code'] >= 200 && $result['code'] < 300) {
echo "Uploaded: {$fileName}\n";
} else {
fwrite(STDERR, "Failed to upload {$fileName}: HTTP {$result['code']}\n");
}
}
break;
case 'update-body':
$release = getReleaseByTag($apiBase, $tag, $token);
if ($release === null) {
fwrite(STDERR, "No release found for tag: {$tag}\n");
exit(1);
}
$releaseId = $release['id'];
$payload = json_encode(['body' => $body ?? '']);
$result = releaseGiteaApi("{$apiBase}/releases/{$releaseId}", 'PATCH', $token, $payload);
if ($result['code'] >= 200 && $result['code'] < 300) {
echo "Release body updated for tag: {$tag}\n";
} else {
fwrite(STDERR, "Failed to update body: HTTP {$result['code']}\n");
exit(1);
}
break;
case 'delete':
$existing = getReleaseByTag($apiBase, $tag, $token);
if ($existing !== null) {
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";
}
break;
default:
fwrite(STDERR, "Unknown action: {$action}\n");
fwrite(STDERR, "Valid actions: create, upload, update-body, delete\n");
exit(1);
}
exit(0);
+146 -199
View File
@@ -11,81 +11,148 @@
* REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform * REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform
* PATH: /cli/release_mirror.php * PATH: /cli/release_mirror.php
* BRIEF: Mirror a Gitea release (with assets) to a GitHub repository * BRIEF: Mirror a Gitea release (with assets) to a GitHub repository
*
* Usage:
* php release_mirror.php --version 09.01.00 --tag stable --token TOKEN --api-base URL \
* --gh-token GH_MIRROR_TOKEN --gh-repo MokoConsulting/MokoWaaS
*
* Mirrors a Gitea release (title, body, assets) to a corresponding GitHub release.
* If the GitHub release already exists at the same tag, its title is updated via PATCH.
* All assets from the Gitea release are downloaded and uploaded to the GitHub release.
*/ */
declare(strict_types=1); declare(strict_types=1);
// ── Argument parsing ───────────────────────────────────────────────────────── require_once __DIR__ . '/../lib/Enterprise/CliFramework.php';
$version = null; use MokoEnterprise\CliFramework;
$tag = null;
$token = null;
$apiBase = null;
$ghToken = null;
$ghRepo = null;
$branch = 'main';
foreach ($argv as $i => $arg) { class ReleaseMirrorCli extends CliFramework
if ($arg === '--version' && isset($argv[$i + 1])) {
$version = $argv[$i + 1];
}
if ($arg === '--tag' && isset($argv[$i + 1])) {
$tag = $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 === '--gh-token' && isset($argv[$i + 1])) {
$ghToken = $argv[$i + 1];
}
if ($arg === '--gh-repo' && isset($argv[$i + 1])) {
$ghRepo = $argv[$i + 1];
}
if ($arg === '--branch' && isset($argv[$i + 1])) {
$branch = $argv[$i + 1];
}
}
// Allow tokens from environment
$token = $token ?: (getenv('MOKOGITEA_TOKEN') ?: (getenv('GITEA_TOKEN') ?: null));
$ghToken = $ghToken ?: (getenv('GH_MIRROR_TOKEN') ?: null);
if (
$version === null || $tag === null || $token === null || $apiBase === null
|| $ghToken === null || $ghRepo === null
) {
fwrite(STDERR, "Usage: release_mirror.php --version VER --tag TAG --token TOKEN " .
"--api-base URL --gh-token GH_MIRROR_TOKEN --gh-repo org/repo [--branch main]\n");
fwrite(STDERR, " --token: Gitea token (or MOKOGITEA_TOKEN / GITEA_TOKEN env)\n");
fwrite(STDERR, " --gh-token: GitHub token (or GH_MIRROR_TOKEN env)\n");
exit(1);
}
// ── Helper: Gitea API request ────────────────────────────────────────────────
/**
* Send a request to the Gitea API.
*
* @param string $url Full Gitea API URL
* @param string $token Gitea API token
* @param string $method HTTP method (GET, POST, PATCH, DELETE)
* @param string|null $body JSON request body or null
*
* @return array<string, mixed>|null Decoded response or null on failure
*/
function giteaApi(string $url, string $token, string $method = 'GET', ?string $body = null): ?array
{ {
protected function configure(): void
{
$this->setDescription('Mirror a Gitea release (with assets) to a GitHub repository');
$this->addArgument('--version', 'Version string (required)', '');
$this->addArgument('--tag', 'Release tag name (required)', '');
$this->addArgument('--token', 'Gitea API token', '');
$this->addArgument('--api-base', 'Gitea API base URL for the repo (required)', '');
$this->addArgument('--gh-token', 'GitHub personal access token', '');
$this->addArgument('--gh-repo', 'GitHub org/repo (required)', '');
$this->addArgument('--branch', 'Target branch (default: main)', 'main');
}
protected function run(): int
{
$version = $this->getArgument('--version');
$tag = $this->getArgument('--tag');
$token = $this->getArgument('--token');
$apiBase = $this->getArgument('--api-base');
$ghToken = $this->getArgument('--gh-token');
$ghRepo = $this->getArgument('--gh-repo');
$branch = $this->getArgument('--branch');
// Allow tokens from environment
$token = $token ?: (getenv('MOKOGITEA_TOKEN') ?: (getenv('GITEA_TOKEN') ?: ''));
$ghToken = $ghToken ?: (getenv('GH_MIRROR_TOKEN') ?: '');
if ($version === '' || $tag === '' || $token === '' || $apiBase === '' || $ghToken === '' || $ghRepo === '') {
$this->log('ERROR', "Usage: release_mirror.php --version VER --tag TAG --token TOKEN " .
"--api-base URL --gh-token GH_MIRROR_TOKEN --gh-repo org/repo [--branch main]");
$this->log('ERROR', " --token: Gitea token (or MOKOGITEA_TOKEN / GITEA_TOKEN env)");
$this->log('ERROR', " --gh-token: GitHub token (or GH_MIRROR_TOKEN env)");
return 1;
}
// ── Step 1: Get Gitea release by tag ─────────────────────────────────────────
echo "Fetching Gitea release: {$tag}\n";
$giteaRelease = $this->giteaApi("{$apiBase}/releases/tags/{$tag}", $token);
if (!$giteaRelease || empty($giteaRelease['id'])) {
$this->log('ERROR', "No Gitea release found with tag: {$tag}");
return 1;
}
$giteaId = $giteaRelease['id'];
$releaseName = $giteaRelease['name'] ?? "{$version}";
$releaseBody = $giteaRelease['body'] ?? '';
$assets = $giteaRelease['assets'] ?? [];
echo " Name: {$releaseName}\n";
echo " Assets: " . count($assets) . " file(s)\n";
// ── Step 2: Check / create GitHub release ────────────────────────────────────
$ghApiBase = "https://api.github.com/repos/{$ghRepo}";
$ghUploadBase = "https://uploads.github.com/repos/{$ghRepo}";
echo "Checking GitHub release: {$tag}\n";
$ghRelease = $this->githubApi("{$ghApiBase}/releases/tags/{$tag}", $ghToken);
if ($ghRelease && !empty($ghRelease['id'])) {
// Update existing release title
$ghReleaseId = $ghRelease['id'];
echo " GitHub release exists (id: {$ghReleaseId}), updating title\n";
$patchPayload = json_encode([
'name' => $releaseName,
'body' => $releaseBody,
]);
$this->githubApi("{$ghApiBase}/releases/{$ghReleaseId}", $ghToken, 'PATCH', $patchPayload);
} else {
// Create new release
echo " Creating GitHub release\n";
$createPayload = json_encode([
'tag_name' => $tag,
'target_commitish' => $branch,
'name' => $releaseName,
'body' => $releaseBody,
'draft' => false,
'prerelease' => ($tag !== 'stable'),
]);
$ghRelease = $this->githubApi("{$ghApiBase}/releases", $ghToken, 'POST', $createPayload);
if (!$ghRelease || empty($ghRelease['id'])) {
$this->log('ERROR', 'Failed to create GitHub release');
return 1;
}
$ghReleaseId = $ghRelease['id'];
echo " Created GitHub release (id: {$ghReleaseId})\n";
}
// ── Step 3: Download assets from Gitea ───────────────────────────────────────
$tmpDir = sys_get_temp_dir() . '/moko-mirror-' . getmypid();
@mkdir($tmpDir, 0755, true);
$uploadUrl = "{$ghUploadBase}/releases/{$ghReleaseId}/assets";
foreach ($assets as $asset) {
$name = $asset['name'] ?? '';
$downloadUrl = $asset['browser_download_url'] ?? '';
if ($name === '' || $downloadUrl === '') {
continue;
}
$localPath = "{$tmpDir}/{$name}";
echo " Downloading: {$name}\n";
if (!$this->giteaDownload($downloadUrl, $token, $localPath)) {
$this->log('ERROR', " Failed to download: {$name}");
continue;
}
// ── Step 4: Upload asset to GitHub ───────────────────────────────────────
echo " Uploading: {$name}\n";
$code = $this->githubUploadAsset($uploadUrl, $ghToken, $localPath, $name);
$status = ($code >= 200 && $code < 300) ? 'OK' : "FAILED ({$code})";
echo " {$status}\n";
}
// ── Cleanup ──────────────────────────────────────────────────────────────────
array_map('unlink', glob("{$tmpDir}/*") ?: []);
@rmdir($tmpDir);
// ── Summary ──────────────────────────────────────────────────────────────────
echo "\nMirror complete: {$tag} -> github.com/{$ghRepo}\n";
echo " Version: {$version}\n";
echo " Assets: " . count($assets) . " file(s)\n";
return 0;
}
private function giteaApi(string $url, string $token, string $method = 'GET', ?string $body = null): ?array
{
$ch = curl_init($url); $ch = curl_init($url);
curl_setopt_array($ch, [ curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true, CURLOPT_RETURNTRANSFER => true,
@@ -107,19 +174,10 @@ function giteaApi(string $url, string $token, string $method = 'GET', ?string $b
return null; return null;
} }
return json_decode($response, true) ?: null; return json_decode($response, true) ?: null;
} }
/** private function giteaDownload(string $url, string $token, string $dest): bool
* Download a file from Gitea to a local path. {
*
* @param string $url Download URL
* @param string $token Gitea API token
* @param string $dest Local destination path
*
* @return bool True on success
*/
function giteaDownload(string $url, string $token, string $dest): bool
{
$ch = curl_init($url); $ch = curl_init($url);
$fp = fopen($dest, 'wb'); $fp = fopen($dest, 'wb');
curl_setopt_array($ch, [ curl_setopt_array($ch, [
@@ -133,20 +191,10 @@ function giteaDownload(string $url, string $token, string $dest): bool
curl_close($ch); curl_close($ch);
fclose($fp); fclose($fp);
return $httpCode >= 200 && $httpCode < 300; return $httpCode >= 200 && $httpCode < 300;
} }
/** private function githubApi(string $url, string $token, string $method = 'GET', ?string $body = null): ?array
* Send a request to the GitHub API. {
*
* @param string $url Full GitHub API URL
* @param string $token GitHub personal access token
* @param string $method HTTP method (GET, POST, PATCH, DELETE)
* @param string|null $body JSON request body or null
*
* @return array<string, mixed>|null Decoded response or null on failure
*/
function githubApi(string $url, string $token, string $method = 'GET', ?string $body = null): ?array
{
$ch = curl_init($url); $ch = curl_init($url);
curl_setopt_array($ch, [ curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true, CURLOPT_RETURNTRANSFER => true,
@@ -170,20 +218,10 @@ function githubApi(string $url, string $token, string $method = 'GET', ?string $
return null; return null;
} }
return json_decode($response, true) ?: null; return json_decode($response, true) ?: null;
} }
/** private function githubUploadAsset(string $uploadUrl, string $token, string $filePath, string $name): int
* Upload a binary asset to a GitHub release. {
*
* @param string $uploadUrl GitHub upload URL (uploads.github.com)
* @param string $token GitHub personal access token
* @param string $filePath Local file path to upload
* @param string $name Asset filename for GitHub
*
* @return int HTTP status code
*/
function githubUploadAsset(string $uploadUrl, string $token, string $filePath, string $name): int
{
$url = $uploadUrl . '?name=' . urlencode($name); $url = $uploadUrl . '?name=' . urlencode($name);
$ch = curl_init($url); $ch = curl_init($url);
curl_setopt_array($ch, [ curl_setopt_array($ch, [
@@ -202,99 +240,8 @@ function githubUploadAsset(string $uploadUrl, string $token, string $filePath, s
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE); $httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch); curl_close($ch);
return $httpCode; return $httpCode;
}
// ── Step 1: Get Gitea release by tag ─────────────────────────────────────────
echo "Fetching Gitea release: {$tag}\n";
$giteaRelease = giteaApi("{$apiBase}/releases/tags/{$tag}", $token);
if (!$giteaRelease || empty($giteaRelease['id'])) {
fwrite(STDERR, "No Gitea release found with tag: {$tag}\n");
exit(1);
}
$giteaId = $giteaRelease['id'];
$releaseName = $giteaRelease['name'] ?? "{$version}";
$releaseBody = $giteaRelease['body'] ?? '';
$assets = $giteaRelease['assets'] ?? [];
echo " Name: {$releaseName}\n";
echo " Assets: " . count($assets) . " file(s)\n";
// ── Step 2: Check / create GitHub release ────────────────────────────────────
$ghApiBase = "https://api.github.com/repos/{$ghRepo}";
$ghUploadBase = "https://uploads.github.com/repos/{$ghRepo}";
echo "Checking GitHub release: {$tag}\n";
$ghRelease = githubApi("{$ghApiBase}/releases/tags/{$tag}", $ghToken);
if ($ghRelease && !empty($ghRelease['id'])) {
// Update existing release title
$ghReleaseId = $ghRelease['id'];
echo " GitHub release exists (id: {$ghReleaseId}), updating title\n";
$patchPayload = json_encode([
'name' => $releaseName,
'body' => $releaseBody,
]);
githubApi("{$ghApiBase}/releases/{$ghReleaseId}", $ghToken, 'PATCH', $patchPayload);
} else {
// Create new release
echo " Creating GitHub release\n";
$createPayload = json_encode([
'tag_name' => $tag,
'target_commitish' => $branch,
'name' => $releaseName,
'body' => $releaseBody,
'draft' => false,
'prerelease' => ($tag !== 'stable'),
]);
$ghRelease = githubApi("{$ghApiBase}/releases", $ghToken, 'POST', $createPayload);
if (!$ghRelease || empty($ghRelease['id'])) {
fwrite(STDERR, "Failed to create GitHub release\n");
exit(1);
} }
$ghReleaseId = $ghRelease['id'];
echo " Created GitHub release (id: {$ghReleaseId})\n";
} }
// ── Step 3: Download assets from Gitea ─────────────────────────────────────── $app = new ReleaseMirrorCli();
exit($app->execute());
$tmpDir = sys_get_temp_dir() . '/moko-mirror-' . getmypid();
@mkdir($tmpDir, 0755, true);
$uploadUrl = "{$ghUploadBase}/releases/{$ghReleaseId}/assets";
foreach ($assets as $asset) {
$name = $asset['name'] ?? '';
$downloadUrl = $asset['browser_download_url'] ?? '';
if ($name === '' || $downloadUrl === '') {
continue;
}
$localPath = "{$tmpDir}/{$name}";
echo " Downloading: {$name}\n";
if (!giteaDownload($downloadUrl, $token, $localPath)) {
fwrite(STDERR, " Failed to download: {$name}\n");
continue;
}
// ── Step 4: Upload asset to GitHub ───────────────────────────────────────
echo " Uploading: {$name}\n";
$code = githubUploadAsset($uploadUrl, $ghToken, $localPath, $name);
$status = ($code >= 200 && $code < 300) ? 'OK' : "FAILED ({$code})";
echo " {$status}\n";
}
// ── Cleanup ──────────────────────────────────────────────────────────────────
array_map('unlink', glob("{$tmpDir}/*") ?: []);
@rmdir($tmpDir);
// ── Summary ──────────────────────────────────────────────────────────────────
echo "\nMirror complete: {$tag} -> github.com/{$ghRepo}\n";
echo " Version: {$version}\n";
echo " Assets: " . count($assets) . " file(s)\n";
exit(0);
+42 -25
View File
@@ -1,5 +1,6 @@
#!/usr/bin/env php #!/usr/bin/env php
<?php <?php
/* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech> /* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
* *
* SPDX-License-Identifier: GPL-3.0-or-later * SPDX-License-Identifier: GPL-3.0-or-later
@@ -14,14 +15,25 @@
declare(strict_types=1); declare(strict_types=1);
$path = '.'; require_once __DIR__ . '/../lib/Enterprise/CliFramework.php';
$version = null;
foreach ($argv as $i => $arg) {
if ($arg === '--path' && isset($argv[$i + 1])) $path = $argv[$i + 1];
if ($arg === '--version' && isset($argv[$i + 1])) $version = $argv[$i + 1];
}
if ($version === null) { use MokoEnterprise\CliFramework;
class ReleaseNotesCli extends CliFramework
{
protected function configure(): void
{
$this->setDescription('Extract release notes from CHANGELOG.md for a given version');
$this->addArgument('--path', 'Repository root path', '.');
$this->addArgument('--version', 'Version to extract notes for', '');
}
protected function run(): int
{
$path = $this->getArgument('--path');
$version = $this->getArgument('--version') ?: null;
if ($version === null || $version === '') {
// Read from README.md // Read from README.md
$readme = realpath($path) . '/README.md'; $readme = realpath($path) . '/README.md';
if (file_exists($readme)) { if (file_exists($readme)) {
@@ -30,37 +42,42 @@ if ($version === null) {
$version = $m[1]; $version = $m[1];
} }
} }
} }
if ($version === null) { if ($version === null || $version === '') {
fwrite(STDERR, "Usage: release_notes.php --path . --version XX.YY.ZZ\n"); $this->log('ERROR', 'Usage: release_notes.php --path . --version XX.YY.ZZ');
exit(1); return 1;
} }
$changelog = realpath($path) . '/CHANGELOG.md'; $changelog = realpath($path) . '/CHANGELOG.md';
if (!file_exists($changelog)) { if (!file_exists($changelog)) {
echo "Release {$version}\n"; echo "Release {$version}\n";
exit(0); return 0;
} }
$lines = file($changelog, FILE_IGNORE_NEW_LINES); $lines = file($changelog, FILE_IGNORE_NEW_LINES);
$notes = []; $notes = [];
$capturing = false; $capturing = false;
foreach ($lines as $line) { foreach ($lines as $line) {
if (preg_match('/^##\s.*' . preg_quote($version, '/') . '/', $line)) { if (preg_match('/^##\s.*' . preg_quote($version, '/') . '/', $line)) {
$capturing = true; $capturing = true;
continue; continue;
} }
if ($capturing && preg_match('/^## /', $line)) { if ($capturing && preg_match('/^## /', $line)) {
break; // Next version heading — stop break;
} }
if ($capturing) { if ($capturing) {
$notes[] = $line; $notes[] = $line;
} }
}
$result = trim(implode("\n", $notes));
echo $result ?: "Release {$version}";
echo "\n";
return 0;
}
} }
$result = trim(implode("\n", $notes)); $app = new ReleaseNotesCli();
echo $result ?: "Release {$version}"; exit($app->execute());
echo "\n";
exit(0);
+407 -465
View File
@@ -11,88 +11,410 @@
* REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform * REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform
* PATH: /cli/release_package.php * PATH: /cli/release_package.php
* BRIEF: Build packages (ZIP + tar.gz) with SHA-256 and upload to Gitea release * BRIEF: Build packages (ZIP + tar.gz) with SHA-256 and upload to Gitea release
*
* Usage:
* php release_package.php --path . --version 09.01.00 --tag stable --token TOKEN --api-base URL
* php release_package.php --path . --version 09.01.00 --tag development --token TOKEN --api-base URL --repo myrepo
*
* Builds ZIP and tar.gz packages from src/ or htdocs/, computes SHA-256 checksums,
* creates .sha256 sidecar files, and uploads all assets to an existing Gitea release.
*
* For Joomla packages (type=package with packages/ subdir):
* - ZIPs each sub-extension directory
* - Copies top-level XML/PHP to package root before archiving
*
* For standard extensions:
* - Builds ZIP and tar.gz from source dir
* - Excludes: sftp-config*, .ftpignore, *.ppk, *.pem, *.key, .env*, *.local, .build-trigger
*/ */
declare(strict_types=1); declare(strict_types=1);
// ── Argument parsing ───────────────────────────────────────────────────────── require_once __DIR__ . '/../lib/Enterprise/CliFramework.php';
$path = '.'; use MokoEnterprise\CliFramework;
$version = null;
$tag = null;
$token = null;
$apiBase = null;
$repoName = '';
$outputDir = sys_get_temp_dir();
foreach ($argv as $i => $arg) { class ReleasePackageCli extends CliFramework
if ($arg === '--path' && isset($argv[$i + 1])) { {
$path = $argv[$i + 1]; /** @var array<int, string> */
} private array $excludePatterns = [
if ($arg === '--version' && isset($argv[$i + 1])) { 'sftp-config*',
$version = $argv[$i + 1]; '.ftpignore',
} '*.ppk',
if ($arg === '--tag' && isset($argv[$i + 1])) { '*.pem',
$tag = $argv[$i + 1]; '*.key',
} '.env*',
if ($arg === '--token' && isset($argv[$i + 1])) { '*.local',
$token = $argv[$i + 1]; '.build-trigger',
} ];
if ($arg === '--api-base' && isset($argv[$i + 1])) {
$apiBase = $argv[$i + 1];
}
if ($arg === '--repo' && isset($argv[$i + 1])) {
$repoName = $argv[$i + 1];
}
if ($arg === '--output' && isset($argv[$i + 1])) {
$outputDir = $argv[$i + 1];
}
}
// Allow token from environment protected function configure(): void
if ($token === null) { {
$token = getenv('MOKOGITEA_TOKEN') ?: (getenv('GITEA_TOKEN') ?: null); $this->setDescription('Build packages (ZIP + tar.gz) with SHA-256 and upload to Gitea release');
} $this->addArgument('--path', 'Repository root path', '.');
$this->addArgument('--version', 'Release version', '');
$this->addArgument('--tag', 'Release tag name', '');
$this->addArgument('--token', 'Gitea API token', '');
$this->addArgument('--api-base', 'Gitea API base URL', '');
$this->addArgument('--repo', 'Repo name for element detection fallback', '');
$this->addArgument('--output', 'Output directory for built packages', '');
}
if ($version === null || $tag === null || $token === null || $apiBase === null) { protected function run(): int
fwrite(STDERR, "Usage: release_package.php --path . --version VER --tag TAG --token TOKEN --api-base URL\n"); {
fwrite(STDERR, " --repo REPO Repo name for element detection fallback\n"); $path = $this->getArgument('--path');
fwrite(STDERR, " --output DIR Output directory for built packages (default: sys_get_temp_dir())\n"); $version = $this->getArgument('--version');
fwrite(STDERR, " Token can also be set via MOKOGITEA_TOKEN or GITEA_TOKEN env var\n"); $tag = $this->getArgument('--tag');
exit(1); $token = $this->getArgument('--token');
} $apiBase = $this->getArgument('--api-base');
$repoName = $this->getArgument('--repo');
$outputDir = $this->getArgument('--output');
$root = realpath($path) ?: $path; if ($outputDir === '' || $outputDir === null) {
$outputDir = sys_get_temp_dir();
}
// ── Helper: Gitea API request ──────────────────────────────────────────────── // Allow token from environment
if ($token === '' || $token === null) {
$token = getenv('MOKOGITEA_TOKEN') ?: (getenv('GITEA_TOKEN') ?: '');
}
/** if ($version === '' || $tag === '' || $token === '' || $apiBase === '') {
$this->log('ERROR', "Usage: release_package.php --path . --version VER --tag TAG --token TOKEN --api-base URL");
$this->log('ERROR', " --repo REPO Repo name for element detection fallback");
$this->log('ERROR', " --output DIR Output directory for built packages (default: sys_get_temp_dir())");
$this->log('ERROR', " Token can also be set via MOKOGITEA_TOKEN or GITEA_TOKEN env var");
return 1;
}
$root = realpath($path) ?: $path;
// ── Read platform from .mokogitea/manifest.xml ───────────────────────
$detectedPlatform = 'generic';
$detectedEntryPoint = '';
$mokoManifest = "{$root}/.mokogitea/manifest.xml";
if (file_exists($mokoManifest)) {
$mokoXml = @simplexml_load_file($mokoManifest);
if ($mokoXml !== false) {
$rawPlatform = (string)($mokoXml->governance->platform ?? '');
if ($rawPlatform !== '') {
$detectedPlatform = match ($rawPlatform) {
'waas-component' => 'joomla',
'crm-module' => 'dolibarr',
default => $rawPlatform,
};
}
$detectedEntryPoint = (string)($mokoXml->build->{"entry-point"} ?? '');
}
}
// ── Detect element metadata from manifest XML ────────────────────────
$extElement = '';
$extType = '';
$extFolder = '';
$typePrefix = '';
$manifestFiles = array_merge(
glob("{$root}/src/pkg_*.xml") ?: [],
glob("{$root}/src/*.xml") ?: [],
glob("{$root}/*.xml") ?: []
);
$extManifest = null;
foreach ($manifestFiles as $file) {
$content = file_get_contents($file);
if ($content !== false && strpos($content, '<extension') !== false) {
$extManifest = $file;
break;
}
}
if ($extManifest !== null) {
$xml = file_get_contents($extManifest);
if ($xml === false) {
$xml = '';
}
// Extension type and folder
if (preg_match('/type="([^"]*)"/', $xml, $tm)) {
$extType = $tm[1];
}
if (preg_match('/group="([^"]*)"/', $xml, $gm)) {
$extFolder = $gm[1];
}
// Element name
if (preg_match('/<element>([^<]+)<\/element>/', $xml, $em)) {
$extElement = $em[1];
}
if ($extElement === '' && preg_match('/module="([^"]*)"/', $xml, $mm)) {
$extElement = $mm[1];
}
if ($extElement === '' && preg_match('/plugin="([^"]*)"/', $xml, $pm)) {
$extElement = $pm[1];
}
if ($extType === 'package' && preg_match('/<packagename>([^<]+)<\/packagename>/', $xml, $pn)) {
$extElement = $pn[1];
}
if ($extElement === '') {
$extElement = strtolower(basename($extManifest, '.xml'));
if (in_array($extElement, ['templatedetails', 'manifest'], true)) {
$extElement = strtolower(str_replace([' ', '-'], '', $repoName !== '' ? $repoName : basename($root)));
}
}
}
// Fallback to repo name
if ($extElement === '') {
$extElement = strtolower(str_replace([' ', '-'], '', $repoName !== '' ? $repoName : basename($root)));
}
// Strip existing type prefix to prevent duplication
$extElement = (string) preg_replace('/^(pkg_|com_|mod_|plg_[a-z]+_|tpl_|lib_)/', '', $extElement);
// Compute type prefix
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;
}
echo "Element: {$typePrefix}{$extElement}\n";
echo "Type: {$extType}\n";
// ── Compute filenames ────────────────────────────────────────────────
$baseName = "{$typePrefix}{$extElement}-{$version}";
$zipFile = "{$outputDir}/{$baseName}.zip";
$tarFile = "{$outputDir}/{$baseName}.tar.gz";
echo "ZIP: {$baseName}.zip\n";
echo "TAR: {$baseName}.tar.gz\n";
// ── Find source directory ────────────────────────────────────────────
$sourceDir = null;
if ($detectedEntryPoint !== '') {
$entryDir = rtrim(dirname($detectedEntryPoint) === '.' ? $detectedEntryPoint : dirname($detectedEntryPoint), '/');
if (is_dir("{$root}/{$entryDir}")) {
$sourceDir = "{$root}/{$entryDir}";
}
}
if ($sourceDir === null && is_dir("{$root}/src")) {
$sourceDir = "{$root}/src";
} elseif ($sourceDir === null && is_dir("{$root}/htdocs")) {
$sourceDir = "{$root}/htdocs";
}
if ($sourceDir === null) {
echo "No src/ or htdocs/ directory found — skipping package build\n";
return 0;
}
echo "Source: {$sourceDir}\n";
// ── Build packages ───────────────────────────────────────────────────
$isJoomlaPackage = ($extType === 'package' && is_dir("{$sourceDir}/packages"));
if ($isJoomlaPackage) {
echo "Building Joomla package (sub-extensions)...\n";
$zip = new \ZipArchive();
if ($zip->open($zipFile, \ZipArchive::CREATE | \ZipArchive::OVERWRITE) !== true) {
$this->log('ERROR', "Failed to create ZIP: {$zipFile}");
return 1;
}
$packageDirs = glob("{$sourceDir}/packages/*", GLOB_ONLYDIR) ?: [];
foreach ($packageDirs as $pkgDir) {
$subName = basename($pkgDir);
$subZipPath = "{$outputDir}/{$subName}.zip";
$subZip = new \ZipArchive();
if ($subZip->open($subZipPath, \ZipArchive::CREATE | \ZipArchive::OVERWRITE) !== true) {
$this->log('ERROR', "Failed to create sub-package ZIP: {$subZipPath}");
continue;
}
$this->addDirToZip($subZip, $pkgDir, '', $this->excludePatterns);
$subZip->close();
$zip->addFile($subZipPath, "packages/{$subName}.zip");
echo " Sub-package: {$subName}.zip\n";
}
$pkgManifests = glob("{$sourceDir}/pkg_*.xml") ?: [];
foreach ($pkgManifests as $pkgXml) {
$pkgContent = file_get_contents($pkgXml);
if (strpos($pkgContent, '<files>') !== false && strpos($pkgContent, 'folder="packages"') === false) {
$pkgContent = str_replace('<files>', '<files folder="packages">', $pkgContent);
file_put_contents($pkgXml, $pkgContent);
echo " Fixed: added folder=\"packages\" to " . basename($pkgXml) . "\n";
}
}
$topLevelFiles = array_merge(
glob("{$sourceDir}/*.xml") ?: [],
glob("{$sourceDir}/*.php") ?: []
);
foreach ($topLevelFiles as $tlFile) {
if (!$this->isExcluded(basename($tlFile), $this->excludePatterns)) {
$zip->addFile($tlFile, basename($tlFile));
}
}
$topLevelDirs = glob("{$sourceDir}/*", GLOB_ONLYDIR) ?: [];
foreach ($topLevelDirs as $tlDir) {
$dirName = basename($tlDir);
if ($dirName === 'packages') {
continue;
}
$this->addDirToZip($zip, $tlDir, $dirName, $this->excludePatterns);
echo " Included dir: {$dirName}/\n";
}
$zip->close();
echo "ZIP created: {$zipFile}\n";
} else {
echo "Building standard extension ZIP...\n";
$zip = new \ZipArchive();
if ($zip->open($zipFile, \ZipArchive::CREATE | \ZipArchive::OVERWRITE) !== true) {
$this->log('ERROR', "Failed to create ZIP: {$zipFile}");
return 1;
}
$this->addDirToZip($zip, $sourceDir, '', $this->excludePatterns);
$zip->close();
echo "ZIP created: {$zipFile}\n";
}
// ── Build tar.gz ─────────────────────────────────────────────────────
$tarExcludeArgs = [];
foreach ($this->excludePatterns as $pattern) {
$tarExcludeArgs[] = '--exclude=' . escapeshellarg($pattern);
}
$tarCommand = sprintf(
'tar -czf %s -C %s %s .',
escapeshellarg($tarFile),
escapeshellarg($sourceDir),
implode(' ', $tarExcludeArgs)
);
$tarReturnCode = 0;
$tarOutputLines = [];
exec($tarCommand . ' 2>&1', $tarOutputLines, $tarReturnCode);
if (!file_exists($tarFile)) {
$this->log('ERROR', "Failed to create tar.gz: {$tarFile}");
if ($tarOutputLines !== []) {
$this->log('ERROR', implode("\n", $tarOutputLines));
}
return 1;
}
echo "TAR created: {$tarFile}\n";
// ── Compute SHA-256 checksums ────────────────────────────────────────
$zipHash = hash_file('sha256', $zipFile);
$tarHash = hash_file('sha256', $tarFile);
if ($zipHash === false || $tarHash === false) {
$this->log('ERROR', "Failed to compute SHA-256 checksums");
return 1;
}
$zipSha = "{$zipFile}.sha256";
$tarSha = "{$tarFile}.sha256";
file_put_contents($zipSha, "{$zipHash} {$baseName}.zip\n");
file_put_contents($tarSha, "{$tarHash} {$baseName}.tar.gz\n");
echo "SHA-256 (ZIP): {$zipHash}\n";
echo "SHA-256 (TAR): {$tarHash}\n";
echo "sha256_zip={$zipHash}\n";
echo "zip_name={$baseName}.zip\n";
// Write to GITHUB_OUTPUT if available
$ghOutput = getenv('GITHUB_OUTPUT');
if ($ghOutput) {
file_put_contents($ghOutput, "sha256_zip={$zipHash}\nzip_name={$baseName}.zip\n", FILE_APPEND);
}
// ── Get release ID from tag ──────────────────────────────────────────
$result = $this->giteaApiRequest("{$apiBase}/releases/tags/{$tag}", $token);
if ($result['data'] === null || !isset($result['data']['id'])) {
$this->log('ERROR', "No release found for tag: {$tag} (HTTP {$result['code']})");
return 1;
}
$releaseId = (int) $result['data']['id'];
echo "Release ID: {$releaseId} (tag: {$tag})\n";
// ── Delete existing assets with same names ───────────────────────────
$assetsResult = $this->giteaApiRequest("{$apiBase}/releases/{$releaseId}/assets", $token);
$existingAssets = $assetsResult['data'] ?? [];
$uploadNames = [
"{$baseName}.zip",
"{$baseName}.tar.gz",
"{$baseName}.zip.sha256",
"{$baseName}.tar.gz.sha256",
];
foreach ($existingAssets as $asset) {
if (!is_array($asset)) {
continue;
}
$assetName = $asset['name'] ?? '';
$assetId = $asset['id'] ?? 0;
if (in_array($assetName, $uploadNames, true) && $assetId > 0) {
$this->giteaApiRequest("{$apiBase}/releases/{$releaseId}/assets/{$assetId}", $token, 'DELETE');
echo "Deleted existing asset: {$assetName}\n";
}
}
// ── Upload assets ────────────────────────────────────────────────────
$filesToUpload = [
"{$baseName}.zip" => $zipFile,
"{$baseName}.tar.gz" => $tarFile,
"{$baseName}.zip.sha256" => $zipSha,
"{$baseName}.tar.gz.sha256" => $tarSha,
];
$uploaded = 0;
foreach ($filesToUpload as $name => $localPath) {
if (!file_exists($localPath)) {
$this->log('ERROR', "File not found, skipping: {$localPath}");
continue;
}
$uploadUrl = "{$apiBase}/releases/{$releaseId}/assets?name=" . urlencode($name);
$httpCode = $this->giteaUploadAsset($uploadUrl, $token, $localPath);
$status = ($httpCode >= 200 && $httpCode < 300) ? 'OK' : "FAILED ({$httpCode})";
echo "Upload: {$name}{$status}\n";
if ($httpCode >= 200 && $httpCode < 300) {
$uploaded++;
}
}
// ── Summary ──────────────────────────────────────────────────────────
echo "\n";
echo "Package build complete\n";
echo " Element: {$typePrefix}{$extElement}\n";
echo " Version: {$version}\n";
echo " Tag: {$tag}\n";
echo " Uploaded: {$uploaded}/" . count($filesToUpload) . " asset(s)\n";
return $uploaded === count($filesToUpload) ? 0 : 1;
}
/**
* Perform a Gitea API request. * Perform a Gitea API request.
* *
* @param string $url Full API URL
* @param string $token API token
* @param string $method HTTP method
* @param string|null $body Request body (JSON)
*
* @return array{data: array<string, mixed>|null, code: int} * @return array{data: array<string, mixed>|null, code: int}
*/ */
function giteaApiRequest(string $url, string $token, string $method = 'GET', ?string $body = null): array private function giteaApiRequest(string $url, string $token, string $method = 'GET', ?string $body = null): array
{ {
$ch = curl_init($url); $ch = curl_init($url);
if ($ch === false) { if ($ch === false) {
return ['data' => null, 'code' => 0]; return ['data' => null, 'code' => 0];
@@ -119,19 +441,13 @@ function giteaApiRequest(string $url, string $token, string $method = 'GET', ?st
$decoded = json_decode($response, true); $decoded = json_decode($response, true);
return ['data' => is_array($decoded) ? $decoded : null, 'code' => $httpCode]; return ['data' => is_array($decoded) ? $decoded : null, 'code' => $httpCode];
} }
/** /**
* Upload a file as a release asset. * Upload a file as a release asset.
*
* @param string $url Upload endpoint URL
* @param string $token API token
* @param string $filePath Local file path
*
* @return int HTTP status code
*/ */
function giteaUploadAsset(string $url, string $token, string $filePath): int private function giteaUploadAsset(string $url, string $token, string $filePath): int
{ {
$ch = curl_init($url); $ch = curl_init($url);
if ($ch === false) { if ($ch === false) {
return 0; return 0;
@@ -155,178 +471,13 @@ function giteaUploadAsset(string $url, string $token, string $filePath): int
curl_close($ch); curl_close($ch);
return $httpCode; return $httpCode;
}
// ── Read platform from .mokogitea/manifest.xml ───────────────────────────────
$detectedPlatform = 'generic';
$detectedEntryPoint = '';
$mokoManifest = "{$root}/.mokogitea/manifest.xml";
if (file_exists($mokoManifest)) {
$mokoXml = @simplexml_load_file($mokoManifest);
if ($mokoXml !== false) {
$rawPlatform = (string)($mokoXml->governance->platform ?? '');
if ($rawPlatform !== '') {
$detectedPlatform = match ($rawPlatform) {
'waas-component' => 'joomla',
'crm-module' => 'dolibarr',
default => $rawPlatform,
};
}
$detectedEntryPoint = (string)($mokoXml->build->{"entry-point"} ?? '');
}
}
// ── Detect element metadata from manifest XML ────────────────────────────────
$extElement = '';
$extType = '';
$extFolder = '';
$typePrefix = '';
$manifestFiles = array_merge(
glob("{$root}/src/pkg_*.xml") ?: [],
glob("{$root}/src/*.xml") ?: [],
glob("{$root}/*.xml") ?: []
);
$extManifest = null;
foreach ($manifestFiles as $file) {
$content = file_get_contents($file);
if ($content !== false && strpos($content, '<extension') !== false) {
$extManifest = $file;
break;
}
}
if ($extManifest !== null) {
$xml = file_get_contents($extManifest);
if ($xml === false) {
$xml = '';
} }
// Extension type and folder /**
if (preg_match('/type="([^"]*)"/', $xml, $tm)) {
$extType = $tm[1];
}
if (preg_match('/group="([^"]*)"/', $xml, $gm)) {
$extFolder = $gm[1];
}
// Element name: <element>, module= attribute, plugin= attribute, <packagename>, or filename
if (preg_match('/<element>([^<]+)<\/element>/', $xml, $em)) {
$extElement = $em[1];
}
if ($extElement === '' && preg_match('/module="([^"]*)"/', $xml, $mm)) {
$extElement = $mm[1];
}
if ($extElement === '' && preg_match('/plugin="([^"]*)"/', $xml, $pm)) {
$extElement = $pm[1];
}
// For packages: prefer <packagename> over filename
if ($extType === 'package' && preg_match('/<packagename>([^<]+)<\/packagename>/', $xml, $pn)) {
$extElement = $pn[1];
}
if ($extElement === '') {
$extElement = strtolower(basename($extManifest, '.xml'));
if (in_array($extElement, ['templatedetails', 'manifest'], true)) {
$extElement = strtolower(str_replace([' ', '-'], '', $repoName !== '' ? $repoName : basename($root)));
}
}
}
// Fallback to repo name
if ($extElement === '') {
$extElement = strtolower(str_replace([' ', '-'], '', $repoName !== '' ? $repoName : basename($root)));
}
// Strip existing type prefix to prevent duplication
$extElement = (string) preg_replace('/^(pkg_|com_|mod_|plg_[a-z]+_|tpl_|lib_)/', '', $extElement);
// Compute type prefix
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;
}
echo "Element: {$typePrefix}{$extElement}\n";
echo "Type: {$extType}\n";
// ── Compute filenames ────────────────────────────────────────────────────────
$baseName = "{$typePrefix}{$extElement}-{$version}";
$zipFile = "{$outputDir}/{$baseName}.zip";
$tarFile = "{$outputDir}/{$baseName}.tar.gz";
echo "ZIP: {$baseName}.zip\n";
echo "TAR: {$baseName}.tar.gz\n";
// ── Find source directory ────────────────────────────────────────────────────
$sourceDir = null;
// Use entry-point from manifest.xml if available
if ($detectedEntryPoint !== '') {
$entryDir = rtrim(dirname($detectedEntryPoint) === '.' ? $detectedEntryPoint : dirname($detectedEntryPoint), '/');
if (is_dir("{$root}/{$entryDir}")) {
$sourceDir = "{$root}/{$entryDir}";
}
}
// Fallback to common directories
if ($sourceDir === null && is_dir("{$root}/src")) {
$sourceDir = "{$root}/src";
} elseif ($sourceDir === null && is_dir("{$root}/htdocs")) {
$sourceDir = "{$root}/htdocs";
}
if ($sourceDir === null) {
echo "No src/ or htdocs/ directory found — skipping package build\n";
exit(0);
}
echo "Source: {$sourceDir}\n";
// ── File exclusion patterns ──────────────────────────────────────────────────
/** @var array<int, string> */
$excludePatterns = [
'sftp-config*',
'.ftpignore',
'*.ppk',
'*.pem',
'*.key',
'.env*',
'*.local',
'.build-trigger',
];
/**
* Check if a filename matches any exclusion pattern. * Check if a filename matches any exclusion pattern.
*
* @param string $filename Filename to check
* @param array<int,string> $patterns Glob patterns to exclude
*
* @return bool True if the file should be excluded
*/ */
function isExcluded(string $filename, array $patterns): bool private function isExcluded(string $filename, array $patterns): bool
{ {
$basename = basename($filename); $basename = basename($filename);
foreach ($patterns as $pattern) { foreach ($patterns as $pattern) {
if (fnmatch($pattern, $basename)) { if (fnmatch($pattern, $basename)) {
@@ -334,25 +485,20 @@ function isExcluded(string $filename, array $patterns): bool
} }
} }
return false; return false;
} }
/** /**
* Recursively add files from a directory to a ZipArchive. * Recursively add files from a directory to a ZipArchive.
*
* @param ZipArchive $zip ZipArchive instance
* @param string $sourceDir Source directory path
* @param string $prefix Path prefix inside the archive
* @param array<int,string> $excludes Exclusion patterns
*/ */
function addDirToZip(ZipArchive $zip, string $sourceDir, string $prefix, array $excludes): void private function addDirToZip(\ZipArchive $zip, string $sourceDir, string $prefix, array $excludes): void
{ {
$iterator = new RecursiveIteratorIterator( $iterator = new \RecursiveIteratorIterator(
new RecursiveDirectoryIterator($sourceDir, RecursiveDirectoryIterator::SKIP_DOTS), new \RecursiveDirectoryIterator($sourceDir, \RecursiveDirectoryIterator::SKIP_DOTS),
RecursiveIteratorIterator::LEAVES_ONLY \RecursiveIteratorIterator::LEAVES_ONLY
); );
foreach ($iterator as $file) { foreach ($iterator as $file) {
if (!$file instanceof SplFileInfo || !$file->isFile()) { if (!$file instanceof \SplFileInfo || !$file->isFile()) {
continue; continue;
} }
@@ -361,7 +507,7 @@ function addDirToZip(ZipArchive $zip, string $sourceDir, string $prefix, array $
continue; continue;
} }
if (isExcluded($file->getFilename(), $excludes)) { if ($this->isExcluded($file->getFilename(), $excludes)) {
continue; continue;
} }
@@ -371,212 +517,8 @@ function addDirToZip(ZipArchive $zip, string $sourceDir, string $prefix, array $
$archivePath = $prefix !== '' ? "{$prefix}/{$relativePath}" : $relativePath; $archivePath = $prefix !== '' ? "{$prefix}/{$relativePath}" : $relativePath;
$zip->addFile($realPath, $archivePath); $zip->addFile($realPath, $archivePath);
} }
}
// ── Build packages ───────────────────────────────────────────────────────────
$isJoomlaPackage = ($extType === 'package' && is_dir("{$sourceDir}/packages"));
if ($isJoomlaPackage) {
// ── Joomla package: ZIP each sub-extension, then combine ─────────────────
echo "Building Joomla package (sub-extensions)...\n";
$zip = new ZipArchive();
if ($zip->open($zipFile, ZipArchive::CREATE | ZipArchive::OVERWRITE) !== true) {
fwrite(STDERR, "Failed to create ZIP: {$zipFile}\n");
exit(1);
}
// ZIP each sub-extension directory
$packageDirs = glob("{$sourceDir}/packages/*", GLOB_ONLYDIR) ?: [];
foreach ($packageDirs as $pkgDir) {
$subName = basename($pkgDir);
$subZipPath = "{$outputDir}/{$subName}.zip";
$subZip = new ZipArchive();
if ($subZip->open($subZipPath, ZipArchive::CREATE | ZipArchive::OVERWRITE) !== true) {
fwrite(STDERR, "Failed to create sub-package ZIP: {$subZipPath}\n");
continue;
}
addDirToZip($subZip, $pkgDir, '', $excludePatterns);
$subZip->close();
$zip->addFile($subZipPath, "packages/{$subName}.zip");
echo " Sub-package: {$subName}.zip\n";
}
// Ensure package manifest has folder="packages" on <files> element
// since sub-packages are stored in a packages/ subdirectory
$pkgManifests = glob("{$sourceDir}/pkg_*.xml") ?: [];
foreach ($pkgManifests as $pkgXml) {
$pkgContent = file_get_contents($pkgXml);
if (strpos($pkgContent, '<files>') !== false && strpos($pkgContent, 'folder="packages"') === false) {
$pkgContent = str_replace('<files>', '<files folder="packages">', $pkgContent);
file_put_contents($pkgXml, $pkgContent);
echo " Fixed: added folder=\"packages\" to " . basename($pkgXml) . "\n";
}
}
// Copy top-level XML and PHP files into the package root
$topLevelFiles = array_merge(
glob("{$sourceDir}/*.xml") ?: [],
glob("{$sourceDir}/*.php") ?: []
);
foreach ($topLevelFiles as $tlFile) {
if (!isExcluded(basename($tlFile), $excludePatterns)) {
$zip->addFile($tlFile, basename($tlFile));
}
}
// Include top-level directories (e.g. language/) that aren't packages/
$topLevelDirs = glob("{$sourceDir}/*", GLOB_ONLYDIR) ?: [];
foreach ($topLevelDirs as $tlDir) {
$dirName = basename($tlDir);
if ($dirName === 'packages') {
continue;
}
addDirToZip($zip, $tlDir, $dirName, $excludePatterns);
echo " Included dir: {$dirName}/\n";
}
$zip->close();
echo "ZIP created: {$zipFile}\n";
} else {
// ── Standard extension: ZIP from source dir ──────────────────────────────
echo "Building standard extension ZIP...\n";
$zip = new ZipArchive();
if ($zip->open($zipFile, ZipArchive::CREATE | ZipArchive::OVERWRITE) !== true) {
fwrite(STDERR, "Failed to create ZIP: {$zipFile}\n");
exit(1);
}
addDirToZip($zip, $sourceDir, '', $excludePatterns);
$zip->close();
echo "ZIP created: {$zipFile}\n";
}
// ── Build tar.gz ─────────────────────────────────────────────────────────────
$tarExcludeArgs = [];
foreach ($excludePatterns as $pattern) {
$tarExcludeArgs[] = '--exclude=' . escapeshellarg($pattern);
}
$tarCommand = sprintf(
'tar -czf %s -C %s %s .',
escapeshellarg($tarFile),
escapeshellarg($sourceDir),
implode(' ', $tarExcludeArgs)
);
$tarReturnCode = 0;
$tarOutputLines = [];
exec($tarCommand . ' 2>&1', $tarOutputLines, $tarReturnCode);
if (!file_exists($tarFile)) {
fwrite(STDERR, "Failed to create tar.gz: {$tarFile}\n");
if ($tarOutputLines !== []) {
fwrite(STDERR, implode("\n", $tarOutputLines) . "\n");
}
exit(1);
}
echo "TAR created: {$tarFile}\n";
// ── Compute SHA-256 checksums ────────────────────────────────────────────────
$zipHash = hash_file('sha256', $zipFile);
$tarHash = hash_file('sha256', $tarFile);
if ($zipHash === false || $tarHash === false) {
fwrite(STDERR, "Failed to compute SHA-256 checksums\n");
exit(1);
}
$zipSha = "{$zipFile}.sha256";
$tarSha = "{$tarFile}.sha256";
file_put_contents($zipSha, "{$zipHash} {$baseName}.zip\n");
file_put_contents($tarSha, "{$tarHash} {$baseName}.tar.gz\n");
echo "SHA-256 (ZIP): {$zipHash}\n";
echo "SHA-256 (TAR): {$tarHash}\n";
echo "sha256_zip={$zipHash}\n";
echo "zip_name={$baseName}.zip\n";
// Write to GITHUB_OUTPUT if available
$ghOutput = getenv('GITHUB_OUTPUT');
if ($ghOutput) {
file_put_contents($ghOutput, "sha256_zip={$zipHash}\nzip_name={$baseName}.zip\n", FILE_APPEND);
}
// ── Get release ID from tag ──────────────────────────────────────────────────
$result = giteaApiRequest("{$apiBase}/releases/tags/{$tag}", $token);
if ($result['data'] === null || !isset($result['data']['id'])) {
fwrite(STDERR, "No release found for tag: {$tag} (HTTP {$result['code']})\n");
exit(1);
}
$releaseId = (int) $result['data']['id'];
echo "Release ID: {$releaseId} (tag: {$tag})\n";
// ── Delete existing assets with same names ───────────────────────────────────
$assetsResult = giteaApiRequest("{$apiBase}/releases/{$releaseId}/assets", $token);
$existingAssets = $assetsResult['data'] ?? [];
$uploadNames = [
"{$baseName}.zip",
"{$baseName}.tar.gz",
"{$baseName}.zip.sha256",
"{$baseName}.tar.gz.sha256",
];
foreach ($existingAssets as $asset) {
if (!is_array($asset)) {
continue;
}
$assetName = $asset['name'] ?? '';
$assetId = $asset['id'] ?? 0;
if (in_array($assetName, $uploadNames, true) && $assetId > 0) {
giteaApiRequest("{$apiBase}/releases/{$releaseId}/assets/{$assetId}", $token, 'DELETE');
echo "Deleted existing asset: {$assetName}\n";
} }
} }
// ── Upload assets ──────────────────────────────────────────────────────────── $app = new ReleasePackageCli();
exit($app->execute());
$filesToUpload = [
"{$baseName}.zip" => $zipFile,
"{$baseName}.tar.gz" => $tarFile,
"{$baseName}.zip.sha256" => $zipSha,
"{$baseName}.tar.gz.sha256" => $tarSha,
];
$uploaded = 0;
foreach ($filesToUpload as $name => $localPath) {
if (!file_exists($localPath)) {
fwrite(STDERR, "File not found, skipping: {$localPath}\n");
continue;
}
$uploadUrl = "{$apiBase}/releases/{$releaseId}/assets?name=" . urlencode($name);
$httpCode = giteaUploadAsset($uploadUrl, $token, $localPath);
$status = ($httpCode >= 200 && $httpCode < 300) ? 'OK' : "FAILED ({$httpCode})";
echo "Upload: {$name}{$status}\n";
if ($httpCode >= 200 && $httpCode < 300) {
$uploaded++;
}
}
// ── Summary ──────────────────────────────────────────────────────────────────
echo "\n";
echo "Package build complete\n";
echo " Element: {$typePrefix}{$extElement}\n";
echo " Version: {$version}\n";
echo " Tag: {$tag}\n";
echo " Uploaded: {$uploaded}/" . count($filesToUpload) . " asset(s)\n";
exit($uploaded === count($filesToUpload) ? 0 : 1);
+149 -155
View File
@@ -11,113 +11,60 @@
* REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform * REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform
* PATH: /cli/release_promote.php * PATH: /cli/release_promote.php
* BRIEF: Promote a Gitea release from one channel to another (rename release, tag, assets) * BRIEF: Promote a Gitea release from one channel to another (rename release, tag, assets)
*
* Usage:
* php release_promote.php --from development --to release-candidate --token TOKEN --api-base URL
* php release_promote.php --from release-candidate --to stable --token TOKEN --api-base URL --path .
*
* When promoting to stable, --path detects extension type prefix for asset renaming.
* When --from is "auto", checks beta > alpha > development and uses the first found.
*/ */
declare(strict_types=1); declare(strict_types=1);
$from = null; require_once __DIR__ . '/../lib/Enterprise/CliFramework.php';
$to = null;
$token = null;
$apiBase = null;
$path = '.';
$branch = 'main';
foreach ($argv as $i => $arg) { use MokoEnterprise\CliFramework;
if ($arg === '--from' && isset($argv[$i + 1])) {
$from = $argv[$i + 1];
}
if ($arg === '--to' && isset($argv[$i + 1])) {
$to = $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 === '--path' && isset($argv[$i + 1])) {
$path = $argv[$i + 1];
}
if ($arg === '--branch' && isset($argv[$i + 1])) {
$branch = $argv[$i + 1];
}
}
$token = $token ?: (getenv('MOKOGITEA_TOKEN') ?: (getenv('GITEA_TOKEN') ?: null)); class ReleasePromoteCli extends CliFramework
{
protected function configure(): void
{
$this->setDescription('Promote a Gitea release from one channel to another');
$this->addArgument('--from', 'Source channel (or "auto")', '');
$this->addArgument('--to', 'Target channel (required)', '');
$this->addArgument('--token', 'Gitea API token', '');
$this->addArgument('--api-base', 'Gitea API base URL for the repo', '');
$this->addArgument('--path', 'Repository root for type prefix detection', '.');
$this->addArgument('--branch', 'Target branch', 'main');
}
if ($to === null || $token === null || $apiBase === null) { protected function run(): int
fwrite(STDERR, "Usage: release_promote.php --from <channel|auto> --to <channel> --token TOKEN --api-base URL [--path .]\n"); {
fwrite(STDERR, " --from auto: checks beta > alpha > development\n"); $from = $this->getArgument('--from') ?: null;
exit(1); $to = $this->getArgument('--to') ?: null;
} $token = $this->getArgument('--token') ?: null;
$apiBase = $this->getArgument('--api-base') ?: null;
$path = $this->getArgument('--path');
$branch = $this->getArgument('--branch');
// ── Suffix maps ────────────────────────────────────────────────────────────── $token = $token ?: (getenv('MOKOGITEA_TOKEN') ?: (getenv('GITEA_TOKEN') ?: null));
$suffixMap = [
if ($to === null || $token === null || $apiBase === null) {
$this->log('ERROR', "Usage: release_promote.php --from <channel|auto> --to <channel> --token TOKEN --api-base URL [--path .]");
$this->log('ERROR', " --from auto: checks beta > alpha > development");
return 1;
}
// ── Suffix maps ──────────────────────────────────────────────────────────────
$suffixMap = [
'development' => '-dev', 'development' => '-dev',
'alpha' => '-alpha', 'alpha' => '-alpha',
'beta' => '-beta', 'beta' => '-beta',
'release-candidate' => '-rc', 'release-candidate' => '-rc',
'stable' => '', 'stable' => '',
]; ];
// ── Channel hierarchy (highest first) ──────────────────────────────────────── // ── Channel hierarchy (highest first) ────────────────────────────────────────
$channelOrder = ['beta', 'alpha', 'development']; $channelOrder = ['beta', 'alpha', 'development'];
// ── Helper: Gitea API request ──────────────────────────────────────────────── // ── Resolve --from auto ──────────────────────────────────────────────────────
/** @return array<string, mixed>|null */ if ($from === 'auto') {
function giteaApi(string $url, string $token, string $method = 'GET', ?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_TIMEOUT => 30,
CURLOPT_CUSTOMREQUEST => $method,
]);
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 < 200 || $httpCode >= 300 || empty($response)) {
return null;
}
return json_decode($response, true) ?: null;
}
function giteaDownload(string $url, string $token, string $dest): bool
{
$ch = curl_init($url);
$fp = fopen($dest, 'wb');
curl_setopt_array($ch, [
CURLOPT_HTTPHEADER => ["Authorization: token {$token}"],
CURLOPT_FILE => $fp,
CURLOPT_FOLLOWLOCATION => true,
CURLOPT_TIMEOUT => 120,
]);
curl_exec($ch);
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
fclose($fp);
return $httpCode >= 200 && $httpCode < 300;
}
// ── Resolve --from auto ──────────────────────────────────────────────────────
if ($from === 'auto') {
foreach ($channelOrder as $candidate) { foreach ($channelOrder as $candidate) {
$data = giteaApi("{$apiBase}/releases/tags/{$candidate}", $token); $data = $this->giteaApi("{$apiBase}/releases/tags/{$candidate}", $token);
if ($data && !empty($data['id'])) { if ($data && !empty($data['id'])) {
$from = $candidate; $from = $candidate;
echo "Auto-detected source channel: {$from}\n"; echo "Auto-detected source channel: {$from}\n";
@@ -126,40 +73,40 @@ if ($from === 'auto') {
} }
if ($from === 'auto') { if ($from === 'auto') {
echo "No pre-release found to promote\n"; echo "No pre-release found to promote\n";
exit(0); return 0;
}
} }
}
// ── Find source release ────────────────────────────────────────────────────── // ── Find source release ──────────────────────────────────────────────────────
$sourceRelease = giteaApi("{$apiBase}/releases/tags/{$from}", $token); $sourceRelease = $this->giteaApi("{$apiBase}/releases/tags/{$from}", $token);
if (!$sourceRelease || empty($sourceRelease['id'])) { if (!$sourceRelease || empty($sourceRelease['id'])) {
fwrite(STDERR, "No release found with tag: {$from}\n"); $this->log('ERROR', "No release found with tag: {$from}");
exit(1); return 1;
} }
$sourceId = $sourceRelease['id']; $sourceId = $sourceRelease['id'];
$sourceName = $sourceRelease['name'] ?? ''; $sourceName = $sourceRelease['name'] ?? '';
$sourceBody = $sourceRelease['body'] ?? ''; $sourceBody = $sourceRelease['body'] ?? '';
echo "Source: {$from} (id: {$sourceId}) — {$sourceName}\n"; echo "Source: {$from} (id: {$sourceId}) — {$sourceName}\n";
// ── Get source assets ──────────────────────────────────────────────────────── // ── Get source assets ────────────────────────────────────────────────────────
$assets = giteaApi("{$apiBase}/releases/{$sourceId}/assets", $token) ?: []; $assets = $this->giteaApi("{$apiBase}/releases/{$sourceId}/assets", $token) ?: [];
echo "Assets: " . count($assets) . " file(s)\n"; echo "Assets: " . count($assets) . " file(s)\n";
// ── Download assets to temp ────────────────────────────────────────────────── // ── Download assets to temp ──────────────────────────────────────────────────
$tmpDir = sys_get_temp_dir() . '/moko-promote-' . getmypid(); $tmpDir = sys_get_temp_dir() . '/moko-promote-' . getmypid();
@mkdir($tmpDir, 0755, true); @mkdir($tmpDir, 0755, true);
foreach ($assets as $asset) { foreach ($assets as $asset) {
$name = $asset['name']; $name = $asset['name'];
$downloadUrl = $asset['browser_download_url']; $downloadUrl = $asset['browser_download_url'];
echo " Downloading: {$name}\n"; echo " Downloading: {$name}\n";
giteaDownload($downloadUrl, $token, "{$tmpDir}/{$name}"); $this->giteaDownload($downloadUrl, $token, "{$tmpDir}/{$name}");
} }
// ── Detect type prefix for stable promotion ────────────────────────────────── // ── Detect type prefix for stable promotion ──────────────────────────────────
$typePrefix = ''; $typePrefix = '';
if ($to === 'stable') { if ($to === 'stable') {
$root = realpath($path) ?: $path; $root = realpath($path) ?: $path;
$manifestFiles = array_merge( $manifestFiles = array_merge(
glob("{$root}/src/pkg_*.xml") ?: [], glob("{$root}/src/pkg_*.xml") ?: [],
@@ -205,14 +152,14 @@ if ($to === 'stable') {
break; break;
} }
} }
} }
// ── Rename assets ──────────────────────────────────────────────────────────── // ── Rename assets ────────────────────────────────────────────────────────────
$oldSuffix = $suffixMap[$from] ?? ''; $oldSuffix = $suffixMap[$from] ?? '';
$newSuffix = $suffixMap[$to] ?? ''; $newSuffix = $suffixMap[$to] ?? '';
$renamedAssets = []; $renamedAssets = [];
foreach ($assets as $asset) { foreach ($assets as $asset) {
$oldName = $asset['name']; $oldName = $asset['name'];
$newName = $oldName; $newName = $oldName;
@@ -238,49 +185,49 @@ foreach ($assets as $asset) {
if ($oldName !== $newName) { if ($oldName !== $newName) {
echo " Rename: {$oldName}{$newName}\n"; echo " Rename: {$oldName}{$newName}\n";
} }
} }
// ── Delete source release + tag ────────────────────────────────────────────── // ── Delete source release + tag ──────────────────────────────────────────────
giteaApi("{$apiBase}/releases/{$sourceId}", $token, 'DELETE'); $this->giteaApi("{$apiBase}/releases/{$sourceId}", $token, 'DELETE');
giteaApi("{$apiBase}/tags/{$from}", $token, 'DELETE'); $this->giteaApi("{$apiBase}/tags/{$from}", $token, 'DELETE');
echo "Deleted source: {$from} release + tag\n"; echo "Deleted source: {$from} release + tag\n";
// ── Delete existing target release + tag (if any) ──────────────────────────── // ── Delete existing target release + tag (if any) ────────────────────────────
$existingTarget = giteaApi("{$apiBase}/releases/tags/{$to}", $token); $existingTarget = $this->giteaApi("{$apiBase}/releases/tags/{$to}", $token);
if ($existingTarget && !empty($existingTarget['id'])) { if ($existingTarget && !empty($existingTarget['id'])) {
giteaApi("{$apiBase}/releases/{$existingTarget['id']}", $token, 'DELETE'); $this->giteaApi("{$apiBase}/releases/{$existingTarget['id']}", $token, 'DELETE');
giteaApi("{$apiBase}/tags/{$to}", $token, 'DELETE'); $this->giteaApi("{$apiBase}/tags/{$to}", $token, 'DELETE');
echo "Deleted existing target: {$to} release + tag\n"; echo "Deleted existing target: {$to} release + tag\n";
} }
// ── Create target release ──────────────────────────────────────────────────── // ── Create target release ────────────────────────────────────────────────────
$isPrerelease = ($to !== 'stable'); $isPrerelease = ($to !== 'stable');
$newName = preg_replace('/\(' . preg_quote($from, '/') . '\)/', "({$to})", $sourceName); $newName = preg_replace('/\(' . preg_quote($from, '/') . '\)/', "({$to})", $sourceName);
if ($newName === $sourceName) { if ($newName === $sourceName) {
$newName = str_ireplace($from, $to, $sourceName); $newName = str_ireplace($from, $to, $sourceName);
} }
$newBody = str_ireplace($from, $to, $sourceBody); $newBody = str_ireplace($from, $to, $sourceBody);
$payload = json_encode([ $payload = json_encode([
'tag_name' => $to, 'tag_name' => $to,
'target_commitish' => $branch, 'target_commitish' => $branch,
'name' => $newName, 'name' => $newName,
'body' => $newBody, 'body' => $newBody,
'prerelease' => $isPrerelease, 'prerelease' => $isPrerelease,
]); ]);
$newRelease = giteaApi("{$apiBase}/releases", $token, 'POST', $payload); $newRelease = $this->giteaApi("{$apiBase}/releases", $token, 'POST', $payload);
if (!$newRelease || empty($newRelease['id'])) { if (!$newRelease || empty($newRelease['id'])) {
fwrite(STDERR, "Failed to create {$to} release\n"); $this->log('ERROR', "Failed to create {$to} release");
exit(1); return 1;
} }
$newId = $newRelease['id']; $newId = $newRelease['id'];
echo "Created: {$to} release (id: {$newId})\n"; echo "Created: {$to} release (id: {$newId})\n";
// ── Upload renamed assets ──────────────────────────────────────────────────── // ── Upload renamed assets ────────────────────────────────────────────────────
foreach ($renamedAssets as $entry) { foreach ($renamedAssets as $entry) {
$localFile = "{$tmpDir}/{$entry['old']}"; $localFile = "{$tmpDir}/{$entry['old']}";
if (!file_exists($localFile)) { if (!file_exists($localFile)) {
continue; continue;
@@ -306,11 +253,58 @@ foreach ($renamedAssets as $entry) {
$status = ($code >= 200 && $code < 300) ? 'OK' : "FAILED ({$code})"; $status = ($code >= 200 && $code < 300) ? 'OK' : "FAILED ({$code})";
echo " Upload: {$entry['new']}{$status}\n"; echo " Upload: {$entry['new']}{$status}\n";
}
// ── Cleanup temp ─────────────────────────────────────────────────────────────
array_map('unlink', glob("{$tmpDir}/*") ?: []);
@rmdir($tmpDir);
echo "Promoted: {$from}{$to}\n";
return 0;
}
private function giteaApi(string $url, string $token, string $method = 'GET', ?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_TIMEOUT => 30,
CURLOPT_CUSTOMREQUEST => $method,
]);
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 < 200 || $httpCode >= 300 || empty($response)) {
return null;
}
return json_decode($response, true) ?: null;
}
private function giteaDownload(string $url, string $token, string $dest): bool
{
$ch = curl_init($url);
$fp = fopen($dest, 'wb');
curl_setopt_array($ch, [
CURLOPT_HTTPHEADER => ["Authorization: token {$token}"],
CURLOPT_FILE => $fp,
CURLOPT_FOLLOWLOCATION => true,
CURLOPT_TIMEOUT => 120,
]);
curl_exec($ch);
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
fclose($fp);
return $httpCode >= 200 && $httpCode < 300;
}
} }
// ── Cleanup temp ───────────────────────────────────────────────────────────── $app = new ReleasePromoteCli();
array_map('unlink', glob("{$tmpDir}/*") ?: []); exit($app->execute());
@rmdir($tmpDir);
echo "Promoted: {$from}{$to}\n";
exit(0);
+233 -204
View File
@@ -1,5 +1,6 @@
#!/usr/bin/env php #!/usr/bin/env php
<?php <?php
/* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech> /* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
* *
* SPDX-License-Identifier: GPL-3.0-or-later * SPDX-License-Identifier: GPL-3.0-or-later
@@ -9,203 +10,253 @@
* INGROUP: moko-platform * INGROUP: moko-platform
* REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform * REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform
* PATH: /cli/release_publish.php * PATH: /cli/release_publish.php
* VERSION: 09.21.00 * VERSION: 09.24.00
* BRIEF: Publish a release and create copies for all lesser stability streams. * BRIEF: Publish a release and create copies for all lesser stability streams.
*
* When a release is published at a given stability, copies are created for all
* lower stability streams with the same base version and their respective suffix.
* updates.xml is updated for ALL streams and synced to ALL branches.
*
* Usage:
* php release_publish.php --path . --stability stable --token TOKEN
* php release_publish.php --path . --stability rc --token TOKEN --bump minor
* php release_publish.php --path . --stability dev --token TOKEN --bump patch
* php release_publish.php --path . --stability stable --token TOKEN --dry-run
*
* Options:
* --path Repository root (default: .)
* --stability Target stability: dev|alpha|beta|rc|stable (required)
* --token Gitea API token (required)
* --bump Version bump type before release: patch|minor|none (default: none)
* --branch Current branch (default: auto-detect)
* --gitea-url Gitea URL (default: env GITEA_URL)
* --org Organization (default: env GITEA_ORG)
* --repo Repository name (default: env GITEA_REPO)
* --dry-run Preview without making changes
*/ */
declare(strict_types=1); declare(strict_types=1);
$path = '.'; require_once __DIR__ . '/../lib/Enterprise/CliFramework.php';
$stability = '';
$token = '';
$bumpType = 'none';
$branch = '';
$giteaUrl = getenv('GITEA_URL') ?: 'https://git.mokoconsulting.tech';
$org = getenv('GITEA_ORG') ?: '';
$repo = getenv('GITEA_REPO') ?: '';
$dryRun = false;
$repoUrl = '';
foreach ($argv as $i => $arg) { use MokoEnterprise\CliFramework;
if ($arg === '--path' && isset($argv[$i + 1])) $path = $argv[$i + 1];
if ($arg === '--stability' && isset($argv[$i + 1])) $stability = $argv[$i + 1];
if ($arg === '--token' && isset($argv[$i + 1])) $token = $argv[$i + 1];
if ($arg === '--bump' && isset($argv[$i + 1])) $bumpType = $argv[$i + 1];
if ($arg === '--branch' && isset($argv[$i + 1])) $branch = $argv[$i + 1];
if ($arg === '--gitea-url' && isset($argv[$i + 1])) $giteaUrl = $argv[$i + 1];
if ($arg === '--org' && isset($argv[$i + 1])) $org = $argv[$i + 1];
if ($arg === '--repo' && isset($argv[$i + 1])) $repo = $argv[$i + 1];
if ($arg === '--repo-url' && isset($argv[$i + 1])) $repoUrl = $argv[$i + 1];
if ($arg === '--dry-run') $dryRun = true;
}
if (empty($stability) || empty($token)) { class ReleasePublishCli extends CliFramework
fwrite(STDERR, "Usage: release_publish.php --stability <dev|alpha|beta|rc|stable> --token TOKEN [options]\n"); {
exit(1); protected function configure(): void
} {
$this->setDescription('Publish a release and update stability streams');
$cli = __DIR__; $this->addArgument('--path', 'Repository root (default: .)', '.');
$php = '"' . PHP_BINARY . '"'; $this->addArgument('--stability', 'Target stability: dev|alpha|beta|rc|stable (required)', '');
$giteaUrl = rtrim($giteaUrl, '/'); $this->addArgument('--token', 'Gitea API token (required)', '');
$this->addArgument('--bump', 'Version bump type: patch|minor|none (default: none)', 'none');
// Resolve path early for shell commands (Windows needs native paths) $this->addArgument('--branch', 'Current branch (default: auto-detect)', '');
$resolvedPath = realpath($path) ?: $path; $this->addArgument('--gitea-url', 'Gitea URL', '');
$this->addArgument('--org', 'Organization', '');
// Auto-detect org/repo from git remote if not set $this->addArgument('--repo', 'Repository name', '');
if (empty($org) || empty($repo)) { $this->addArgument('--repo-url', 'Repository URL for git auth', '');
$remote = trim((string) @shell_exec((PHP_OS_FAMILY === 'Windows' ? "cd /d " : "cd ") . escapeshellarg($resolvedPath) . " && git remote get-url origin 2>/dev/null")); $this->addArgument('--skip-update-stream', 'Skip updates.xml generation and sync (managed externally)', false);
if (preg_match('#/([^/]+)/([^/.]+?)(?:\.git)?$#', $remote, $m)) {
if (empty($org)) $org = $m[1];
if (empty($repo)) $repo = $m[2];
} }
}
// Auto-construct repo URL for git auth if not provided protected function run(): int
if (empty($repoUrl) && !empty($token) && !empty($org) && !empty($repo)) { {
$path = $this->getArgument('--path');
$stability = $this->getArgument('--stability');
$token = $this->getArgument('--token');
$bumpType = $this->getArgument('--bump');
$branch = $this->getArgument('--branch');
$giteaUrl = $this->getArgument('--gitea-url') ?: (getenv('GITEA_URL') ?: 'https://git.mokoconsulting.tech');
$org = $this->getArgument('--org') ?: (getenv('GITEA_ORG') ?: '');
$repo = $this->getArgument('--repo') ?: (getenv('GITEA_REPO') ?: '');
$repoUrl = $this->getArgument('--repo-url');
$skipUpdateStream = $this->getArgument('--skip-update-stream');
if (empty($stability) || empty($token)) {
$this->log('ERROR', "Usage: release_publish.php --stability <dev|alpha|beta|rc|stable> --token TOKEN [options]");
return 1;
}
$cli = __DIR__;
$php = '"' . PHP_BINARY . '"';
$giteaUrl = rtrim($giteaUrl, '/');
// Resolve path early for shell commands (Windows needs native paths)
$resolvedPath = realpath($path) ?: $path;
// Auto-detect org/repo from git remote if not set
if (empty($org) || empty($repo)) {
$cd = PHP_OS_FAMILY === 'Windows' ? "cd /d " : "cd ";
$remote = trim((string) @shell_exec(
$cd . escapeshellarg($resolvedPath)
. " && git remote get-url origin 2>/dev/null"
));
if (preg_match('#/([^/]+)/([^/.]+?)(?:\.git)?$#', $remote, $m)) {
if (empty($org)) {
$org = $m[1];
}
if (empty($repo)) {
$repo = $m[2];
}
}
}
// Auto-construct repo URL for git auth if not provided
if (empty($repoUrl) && !empty($token) && !empty($org) && !empty($repo)) {
$host = preg_replace('#^https?://#', '', $giteaUrl); $host = preg_replace('#^https?://#', '', $giteaUrl);
$repoUrl = "https://x-access-token:{$token}@{$host}/{$org}/{$repo}.git"; $repoUrl = "https://x-access-token:{$token}@{$host}/{$org}/{$repo}.git";
} }
// Auto-detect branch // Auto-detect branch
if (empty($branch)) { if (empty($branch)) {
$branch = getenv('GITHUB_REF_NAME') ?: trim((string) @shell_exec((PHP_OS_FAMILY === 'Windows' ? "cd /d " : "cd ") . escapeshellarg($resolvedPath) . " && git rev-parse --abbrev-ref HEAD 2>/dev/null")); $cdCmd = PHP_OS_FAMILY === 'Windows' ? "cd /d " : "cd ";
} $branch = getenv('GITHUB_REF_NAME')
?: trim((string) @shell_exec(
$cdCmd . escapeshellarg($resolvedPath)
. " && git rev-parse --abbrev-ref HEAD 2>/dev/null"
));
}
$apiBase = "{$giteaUrl}/api/v1/repos/{$org}/{$repo}"; $apiBase = "{$giteaUrl}/api/v1/repos/{$org}/{$repo}";
// Stability ordering and suffix mapping // Stability ordering and suffix mapping
$allStabilities = ['dev', 'alpha', 'beta', 'rc', 'stable']; $allStabilities = ['dev', 'alpha', 'beta', 'rc', 'stable'];
$suffixMap = [ $suffixMap = [
'dev' => '-dev', 'dev' => '-dev',
'alpha' => '-alpha', 'alpha' => '-alpha',
'beta' => '-beta', 'beta' => '-beta',
'rc' => '-rc', 'rc' => '-rc',
'stable' => '', 'stable' => '',
]; ];
$releaseTagMap = [ $releaseTagMap = [
'dev' => 'development', 'dev' => 'development',
'alpha' => 'alpha', 'alpha' => 'alpha',
'beta' => 'beta', 'beta' => 'beta',
'rc' => 'release-candidate', 'rc' => 'release-candidate',
'stable' => 'stable', 'stable' => 'stable',
]; ];
$stabilityIndex = array_search($stability, $allStabilities); $stabilityIndex = array_search($stability, $allStabilities);
if ($stabilityIndex === false) { if ($stabilityIndex === false) {
fwrite(STDERR, "Invalid stability: {$stability}\n"); $this->log('ERROR', "Invalid stability: {$stability}");
exit(1); return 1;
} }
echo "=== Release Publish ===\n"; echo "=== Release Publish ===\n";
echo "Stability: {$stability} | Bump: {$bumpType} | Branch: {$branch}\n"; echo "Stability: {$stability} | Bump: {$bumpType} | Branch: {$branch}\n";
echo "Repo: {$org}/{$repo}\n"; echo "Repo: {$org}/{$repo}\n";
// -- Step 1: Version bump (if requested) -- // -- Step 1: Version bump (if requested) --
if ($bumpType !== 'none') { if ($bumpType !== 'none') {
$bumpFlag = $bumpType === 'minor' ? '--minor' : ''; $bumpFlag = $bumpType === 'minor' ? '--minor' : '';
echo "\n--- Step 1: Version bump ({$bumpType}) ---\n"; echo "\n--- Step 1: Version bump ({$bumpType}) ---\n";
if (!$dryRun) { if (!$this->dryRun) {
passthru("{$php} {$cli}/version_bump.php --path " . escapeshellarg($path) . " {$bumpFlag} 2>&1"); passthru("{$php} {$cli}/version_bump.php --path " . escapeshellarg($path) . " {$bumpFlag} 2>&1");
} else { } else {
echo "[DRY-RUN] Would run version_bump.php {$bumpFlag}\n"; echo "[DRY-RUN] Would run version_bump.php {$bumpFlag}\n";
} }
} }
// -- Step 2: Read version and set stability suffix -- // -- Step 2: Read version and set stability suffix --
echo "\n--- Step 2: Set version suffix ---\n"; echo "\n--- Step 2: Set version suffix ---\n";
$versionOutput = []; $versionOutput = [];
$devNull = PHP_OS_FAMILY === 'Windows' ? '2>NUL' : '2>/dev/null'; $devNull = PHP_OS_FAMILY === 'Windows' ? '2>NUL' : '2>/dev/null';
exec("{$php} {$cli}/version_read.php --path " . escapeshellarg($resolvedPath) . " {$devNull}", $versionOutput); exec("{$php} {$cli}/version_read.php --path " . escapeshellarg($resolvedPath) . " {$devNull}", $versionOutput);
$version = trim($versionOutput[0] ?? ''); $version = trim($versionOutput[0] ?? '');
if (empty($version)) { if (empty($version)) {
fwrite(STDERR, "No version found\n"); $this->log('ERROR', 'No version found');
exit(1); return 1;
} }
// Strip existing suffix to get base version // Strip existing suffix to get base version
$baseVersion = preg_replace('/(-(dev|alpha|beta|rc))+$/', '', $version); $baseVersion = preg_replace('/(-(dev|alpha|beta|rc))+$/', '', $version);
if (!$dryRun) { if (!$this->dryRun) {
passthru("{$php} {$cli}/version_set_platform.php --path " . escapeshellarg($path) passthru("{$php} {$cli}/version_set_platform.php --path " . escapeshellarg($path)
. " --version " . escapeshellarg($baseVersion) . " --version " . escapeshellarg($baseVersion)
. " --branch " . escapeshellarg($branch) . " --branch " . escapeshellarg($branch)
. " --stability " . escapeshellarg($stability) . " 2>&1"); . " --stability " . escapeshellarg($stability) . " 2>&1");
passthru("{$php} {$cli}/version_check.php --path " . escapeshellarg($path) . " --fix 2>/dev/null"); passthru("{$php} {$cli}/version_check.php --path " . escapeshellarg($path) . " --fix 2>/dev/null");
} }
$releaseVersion = $baseVersion . $suffixMap[$stability]; $releaseVersion = $baseVersion . $suffixMap[$stability];
echo "Release version: {$releaseVersion}\n"; echo "Release version: {$releaseVersion}\n";
// -- Step 2b: Update badges and changelog -- // -- Step 2b: Update badges and changelog --
if (!$dryRun) { if (!$this->dryRun) {
passthru("{$php} {$cli}/badge_update.php --path " . escapeshellarg($path) . " --version " . escapeshellarg($baseVersion) . " 2>/dev/null"); passthru(
"{$php} {$cli}/badge_update.php --path "
. escapeshellarg($path) . " --version "
. escapeshellarg($baseVersion) . " 2>/dev/null"
);
$changelogFile = realpath($path) . '/CHANGELOG.md'; $changelogFile = realpath($path) . '/CHANGELOG.md';
if (file_exists($changelogFile)) { if (file_exists($changelogFile)) {
passthru("{$php} {$cli}/changelog_promote.php --path " . escapeshellarg($path) . " --version " . escapeshellarg($baseVersion) . " 2>/dev/null"); passthru(
passthru("{$php} {$cli}/changelog_prune.php --path " . escapeshellarg($path) . " --keep 5 2>/dev/null"); "{$php} {$cli}/changelog_promote.php --path "
. escapeshellarg($path) . " --version "
. escapeshellarg($baseVersion) . " 2>/dev/null"
);
passthru(
"{$php} {$cli}/changelog_prune.php --path "
. escapeshellarg($path) . " --keep 5 2>/dev/null"
);
}
} }
}
// -- Step 2c: Commit version changes before building -- // -- Step 2c: Commit version changes before building --
$root = realpath($path) ?: $path; $root = realpath($path) ?: $path;
if (!$dryRun) { if (!$this->dryRun) {
// Configure git // Configure git
@shell_exec((PHP_OS_FAMILY === 'Windows' ? "cd /d " : "cd ") . escapeshellarg($root) . " && git config --local user.email \"gitea-actions[bot]@mokoconsulting.tech\""); $cdPfx = PHP_OS_FAMILY === 'Windows' ? "cd /d " : "cd ";
@shell_exec((PHP_OS_FAMILY === 'Windows' ? "cd /d " : "cd ") . escapeshellarg($root) . " && git config --local user.name \"gitea-actions[bot]\""); $cdR = $cdPfx . escapeshellarg($root);
@shell_exec(
$cdR . " && git config --local user.email"
. " \"gitea-actions[bot]@mokoconsulting.tech\""
);
@shell_exec(
$cdR . " && git config --local user.name"
. " \"gitea-actions[bot]\""
);
if (!empty($repoUrl)) { if (!empty($repoUrl)) {
@shell_exec((PHP_OS_FAMILY === 'Windows' ? "cd /d " : "cd ") . escapeshellarg($root) . " && git remote set-url origin " . escapeshellarg($repoUrl)); @shell_exec(
$cdR . " && git remote set-url origin "
. escapeshellarg($repoUrl)
);
} }
// Ensure we're on the actual branch (not detached HEAD from PR merge) // Ensure we're on the actual branch (not detached HEAD from PR merge)
@shell_exec((PHP_OS_FAMILY === 'Windows' ? "cd /d " : "cd ") . escapeshellarg($root) . " && git fetch origin " . escapeshellarg($branch) . " 2>/dev/null"); @shell_exec(
@shell_exec((PHP_OS_FAMILY === 'Windows' ? "cd /d " : "cd ") . escapeshellarg($root) . " && git checkout -B " . escapeshellarg($branch) . " FETCH_HEAD 2>/dev/null"); $cdR . " && git fetch origin "
. escapeshellarg($branch) . " 2>/dev/null"
);
@shell_exec(
$cdR . " && git checkout -B "
. escapeshellarg($branch) . " FETCH_HEAD 2>/dev/null"
);
// Re-apply version changes on the checked-out branch // Re-apply version changes on the checked-out branch
passthru("{$php} {$cli}/version_set_platform.php --path " . escapeshellarg($path) passthru("{$php} {$cli}/version_set_platform.php --path " . escapeshellarg($path)
. " --version " . escapeshellarg($baseVersion) . " --version " . escapeshellarg($baseVersion)
. " --branch " . escapeshellarg($branch) . " --branch " . escapeshellarg($branch)
. " --stability " . escapeshellarg($stability) . " 2>/dev/null"); . " --stability " . escapeshellarg($stability) . " 2>/dev/null");
passthru("{$php} {$cli}/version_check.php --path " . escapeshellarg($path) . " --fix 2>/dev/null"); passthru(
passthru("{$php} {$cli}/badge_update.php --path " . escapeshellarg($path) . " --version " . escapeshellarg($baseVersion) . " 2>/dev/null"); "{$php} {$cli}/version_check.php --path "
. escapeshellarg($path) . " --fix 2>/dev/null"
);
passthru(
"{$php} {$cli}/badge_update.php --path "
. escapeshellarg($path) . " --version "
. escapeshellarg($baseVersion) . " 2>/dev/null"
);
$diffCheck = trim((string) @shell_exec((PHP_OS_FAMILY === 'Windows' ? "cd /d " : "cd ") . escapeshellarg($root) . " && git diff --quiet && git diff --cached --quiet 2>&1 && echo clean || echo dirty")); $diffCheck = trim((string) @shell_exec(
$cdR . " && git diff --quiet"
. " && git diff --cached --quiet"
. " 2>&1 && echo clean || echo dirty"
));
if ($diffCheck === 'dirty') { if ($diffCheck === 'dirty') {
@shell_exec((PHP_OS_FAMILY === 'Windows' ? "cd /d " : "cd ") . escapeshellarg($root) . " && git add -A"); @shell_exec($cdR . " && git add -A");
@shell_exec((PHP_OS_FAMILY === 'Windows' ? "cd /d " : "cd ") . escapeshellarg($root) . " && git commit -m " . escapeshellarg("chore(release): build {$releaseVersion} [skip ci]") $commitMsg = "chore(release): build"
. " --author=\"gitea-actions[bot] <gitea-actions[bot]@mokoconsulting.tech>\""); . " {$releaseVersion} [skip ci]";
$pushResult = @shell_exec((PHP_OS_FAMILY === 'Windows' ? "cd /d " : "cd ") . escapeshellarg($root) . " && git push origin " . escapeshellarg($branch) . " 2>&1"); @shell_exec(
$cdR . " && git commit -m "
. escapeshellarg($commitMsg)
. " --author=\"gitea-actions[bot]"
. " <gitea-actions[bot]@mokoconsulting.tech>\""
);
$pushResult = @shell_exec(
$cdR . " && git push origin "
. escapeshellarg($branch) . " 2>&1"
);
echo " Committed release changes\n"; echo " Committed release changes\n";
echo " Push: " . trim($pushResult ?? '') . "\n"; echo " Push: " . trim($pushResult ?? '') . "\n";
} }
} }
// -- Step 3: Build release package -- // -- Step 3: Build release package --
echo "\n--- Step 3: Build and upload release ---\n"; echo "\n--- Step 3: Build and upload release ---\n";
$releaseTag = $releaseTagMap[$stability]; $releaseTag = $releaseTagMap[$stability];
$sha256 = ''; $sha256 = '';
if (!$dryRun) { if (!$this->dryRun) {
// Create release // Create release
passthru("{$php} {$cli}/release_create.php --path " . escapeshellarg($path) passthru("{$php} {$cli}/release_create.php --path " . escapeshellarg($path)
. " --version " . escapeshellarg($releaseVersion) . " --version " . escapeshellarg($releaseVersion)
@@ -239,69 +290,27 @@ if (!$dryRun) {
$sha256 = $m[1]; $sha256 = $m[1];
} }
} }
} else {
echo "[DRY-RUN] Would build and upload {$releaseVersion} to {$releaseTag}\n";
}
// -- Step 4: Build separate packages for all lesser stability streams --
// Each stream gets its own ZIP with the correct version INSIDE templateDetails.xml.
// Joomla reads version from the ZIP after install, so it must match.
echo "\n--- Step 4: Build packages for lesser streams ---\n";
for ($i = 0; $i < $stabilityIndex; $i++) {
$lesserStability = $allStabilities[$i];
$lesserTag = $releaseTagMap[$lesserStability];
$lesserVersion = $baseVersion . $suffixMap[$lesserStability];
echo " Building {$lesserStability} release: {$lesserVersion}\n";
if (!$dryRun) {
// Set version to lesser stream's suffixed version in source files
passthru("{$php} {$cli}/version_set_platform.php --path " . escapeshellarg($path)
. " --version " . escapeshellarg($baseVersion)
. " --branch " . escapeshellarg($lesserStability)
. " --stability " . escapeshellarg($lesserStability) . " 2>/dev/null");
// Create release tag
passthru("{$php} {$cli}/release_create.php --path " . escapeshellarg($path)
. " --version " . escapeshellarg($lesserVersion)
. " --tag " . escapeshellarg($lesserTag)
. " --token " . escapeshellarg($token)
. " --api-base " . escapeshellarg($apiBase)
. " --repo " . escapeshellarg($repo)
. " --branch " . escapeshellarg($branch) . " 2>&1");
// Build and upload package (ZIP will contain the lesser version)
passthru("{$php} {$cli}/release_package.php --path " . escapeshellarg($path)
. " --version " . escapeshellarg($lesserVersion)
. " --tag " . escapeshellarg($lesserTag)
. " --token " . escapeshellarg($token)
. " --api-base " . escapeshellarg($apiBase)
. " --repo " . escapeshellarg($repo)
. " --output /tmp 2>&1");
} else { } else {
echo " [DRY-RUN] Would build {$lesserVersion} ZIP and upload to {$lesserTag}\n"; echo "[DRY-RUN] Would build and upload {$releaseVersion} to {$releaseTag}\n";
} }
}
// Restore primary release version in source files // -- Step 4: No lesser stream copies --
if (!$dryRun && $stabilityIndex > 0) { echo "\n--- Step 4: Skipped (no lesser stream copies) ---\n";
passthru("{$php} {$cli}/version_set_platform.php --path " . escapeshellarg($path)
. " --version " . escapeshellarg($baseVersion)
. " --branch " . escapeshellarg($branch)
. " --stability " . escapeshellarg($stability) . " 2>/dev/null");
}
// -- Step 5: Update ALL streams in updates.xml -- if ($skipUpdateStream) {
echo "\n--- Step 5: Update updates.xml for ALL streams ---\n"; echo "\n--- Step 5: Skipped (--skip-update-stream) ---\n";
// Write entry for the primary stream and all lesser streams echo "\n--- Step 6: Skipped (--skip-update-stream) ---\n";
$streamsToWrite = array_slice($allStabilities, 0, $stabilityIndex + 1); } else {
// -- Step 5: Update ONLY this stream in updates.xml --
echo "\n--- Step 5: Update {$stability} stream in updates.xml ---\n";
$streamsToWrite = [$stability];
foreach ($streamsToWrite as $stream) { foreach ($streamsToWrite as $stream) {
$streamVersion = $baseVersion . $suffixMap[$stream]; $streamVersion = $releaseVersion;
$shaFlag = !empty($sha256) ? "--sha {$sha256}" : ''; $shaFlag = !empty($sha256) ? "--sha {$sha256}" : '';
echo " Writing {$stream} stream: {$streamVersion}\n"; echo " Writing {$stream} stream: {$streamVersion}\n";
if (!$dryRun) { if (!$this->dryRun) {
passthru("{$php} {$cli}/updates_xml_build.php --path " . escapeshellarg($path) passthru("{$php} {$cli}/updates_xml_build.php --path " . escapeshellarg($path)
. " --version " . escapeshellarg($streamVersion) . " --version " . escapeshellarg($streamVersion)
. " --stability " . escapeshellarg($stream) . " --stability " . escapeshellarg($stream)
@@ -310,19 +319,33 @@ foreach ($streamsToWrite as $stream) {
. " --repo " . escapeshellarg($repo) . " --repo " . escapeshellarg($repo)
. " {$shaFlag} 2>&1"); . " {$shaFlag} 2>&1");
} }
} }
// -- Step 6: Commit updates.xml and sync to all branches -- // -- Step 6: Commit updates.xml and sync to all branches --
echo "\n--- Step 6: Commit and sync updates.xml ---\n"; echo "\n--- Step 6: Commit and sync updates.xml ---\n";
$root = realpath($path) ?: $path; $root = realpath($path) ?: $path;
if (!$dryRun) { if (!$this->dryRun) {
$diffCheck = trim((string) @shell_exec((PHP_OS_FAMILY === 'Windows' ? "cd /d " : "cd ") . escapeshellarg($root) . " && git diff --quiet updates.xml 2>&1 && echo clean || echo dirty")); $cdX = PHP_OS_FAMILY === 'Windows' ? "cd /d " : "cd ";
$cdRt = $cdX . escapeshellarg($root);
$diffCheck = trim((string) @shell_exec(
$cdRt . " && git diff --quiet updates.xml"
. " 2>&1 && echo clean || echo dirty"
));
if ($diffCheck === 'dirty') { if ($diffCheck === 'dirty') {
@shell_exec((PHP_OS_FAMILY === 'Windows' ? "cd /d " : "cd ") . escapeshellarg($root) . " && git add updates.xml"); @shell_exec($cdRt . " && git add updates.xml");
@shell_exec((PHP_OS_FAMILY === 'Windows' ? "cd /d " : "cd ") . escapeshellarg($root) . " && git commit -m " . escapeshellarg("chore: update channels for {$releaseVersion} [skip ci]") $chMsg = "chore: update channels for"
. " --author=\"gitea-actions[bot] <gitea-actions[bot]@mokoconsulting.tech>\""); . " {$releaseVersion} [skip ci]";
@shell_exec((PHP_OS_FAMILY === 'Windows' ? "cd /d " : "cd ") . escapeshellarg($root) . " && git push origin " . escapeshellarg($branch) . " 2>&1"); @shell_exec(
$cdRt . " && git commit -m "
. escapeshellarg($chMsg)
. " --author=\"gitea-actions[bot]"
. " <gitea-actions[bot]@mokoconsulting.tech>\""
);
@shell_exec(
$cdRt . " && git push origin "
. escapeshellarg($branch) . " 2>&1"
);
echo " Committed updates.xml\n"; echo " Committed updates.xml\n";
} }
@@ -334,16 +357,22 @@ if (!$dryRun) {
. " --gitea-url " . escapeshellarg($giteaUrl) . " --gitea-url " . escapeshellarg($giteaUrl)
. " --org " . escapeshellarg($org) . " --org " . escapeshellarg($org)
. " --repo " . escapeshellarg($repo) . " 2>&1"); . " --repo " . escapeshellarg($repo) . " 2>&1");
} else { } else {
echo "[DRY-RUN] Would commit updates.xml and sync to all branches\n"; echo "[DRY-RUN] Would commit updates.xml and sync to all branches\n";
} }
}
echo "\n=== Release published: {$releaseVersion} ===\n"; echo "\n=== Release published: {$releaseVersion} ===\n";
// Output for CI // Output for CI
$ghOutput = getenv('GITHUB_OUTPUT'); $ghOutput = getenv('GITHUB_OUTPUT');
if ($ghOutput) { if ($ghOutput) {
file_put_contents($ghOutput, "version={$releaseVersion}\nbase_version={$baseVersion}\n", FILE_APPEND); file_put_contents($ghOutput, "version={$releaseVersion}\nbase_version={$baseVersion}\n", FILE_APPEND);
}
return 0;
}
} }
exit(0); $app = new ReleasePublishCli();
exit($app->execute());
+146 -166
View File
@@ -10,54 +10,45 @@
* INGROUP: moko-platform * INGROUP: moko-platform
* REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform * REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform
* PATH: /cli/release_validate.php * PATH: /cli/release_validate.php
* BRIEF: Pre-release validation version consistency, required files, manifest checks * BRIEF: Pre-release validation -- version consistency, required files, manifest checks
*
* Usage:
* php release_validate.php --path /repo --version 04.01.00
* php release_validate.php --path /repo --version 04.01.00 --platform joomla --output-summary
*
* Options:
* --path Repository root (default: .)
* --version Expected version string (required)
* --platform joomla|dolibarr|generic (default: joomla)
* --output-summary Write markdown table to $GITHUB_STEP_SUMMARY
*/ */
declare(strict_types=1); declare(strict_types=1);
$path = '.'; require_once __DIR__ . '/../lib/Enterprise/CliFramework.php';
$version = null;
$platform = null;
$outputSummary = false;
$githubOutput = false;
foreach ($argv as $i => $arg) { use MokoEnterprise\CliFramework;
if ($arg === '--path' && isset($argv[$i + 1])) {
$path = $argv[$i + 1];
}
if ($arg === '--version' && isset($argv[$i + 1])) {
$version = $argv[$i + 1];
}
if ($arg === '--platform' && isset($argv[$i + 1])) {
$platform = $argv[$i + 1];
}
if ($arg === '--output-summary') {
$outputSummary = true;
}
if ($arg === '--github-output') {
$githubOutput = true;
}
}
if ($version === null) { class ReleaseValidateCli extends CliFramework
fwrite(STDERR, "Usage: release_validate.php --path . --version XX.YY.ZZ [--platform joomla]\n"); {
exit(1); private int $pass = 0;
} private int $fail = 0;
private int $warn = 0;
private array $results = [];
$root = realpath($path) ?: $path; protected function configure(): void
{
$this->setDescription('Pre-release validation -- version consistency, required files, manifest checks');
$this->addArgument('--path', 'Repository root', '.');
$this->addArgument('--version', 'Expected version string', null);
$this->addArgument('--platform', 'joomla|dolibarr|generic', null);
$this->addArgument('--output-summary', 'Write markdown to $GITHUB_STEP_SUMMARY', false);
$this->addArgument('--github-output', 'Export counts to $GITHUB_OUTPUT', false);
}
// Auto-detect platform from manifest.xml if not specified protected function run(): int
if ($platform === null) { {
$path = $this->getArgument('--path');
$version = $this->getArgument('--version');
$platform = $this->getArgument('--platform');
$outputSummary = (bool) $this->getArgument('--output-summary');
$githubOutput = (bool) $this->getArgument('--github-output');
if ($version === null) {
$this->log('ERROR', "Usage: release_validate.php --path . --version XX.YY.ZZ [--platform joomla]");
return 1;
}
$root = realpath($path) ?: $path;
if ($platform === null) {
$manifestXml = "{$root}/.mokogitea/manifest.xml"; $manifestXml = "{$root}/.mokogitea/manifest.xml";
if (file_exists($manifestXml)) { if (file_exists($manifestXml)) {
$mContent = file_get_contents($manifestXml); $mContent = file_get_contents($manifestXml);
@@ -65,7 +56,6 @@ if ($platform === null) {
$platform = trim($pm[1]); $platform = trim($pm[1]);
} }
} }
// Normalize platform aliases
if (in_array($platform, ['waas-component'], true)) { if (in_array($platform, ['waas-component'], true)) {
$platform = 'joomla'; $platform = 'joomla';
} }
@@ -75,89 +65,54 @@ if ($platform === null) {
if ($platform === null) { if ($platform === null) {
$platform = 'generic'; $platform = 'generic';
} }
}
$pass = 0;
$fail = 0;
$warn = 0;
/** @var array<int, array{check: string, status: string, details: string}> */
$results = [];
/**
* Record a validation result.
*
* @param string $check Check name
* @param string $status PASS, FAIL, or WARN
* @param string $details Human-readable details
*/
function addResult(string $check, string $status, string $details): void
{
global $pass, $fail, $warn, $results;
$results[] = ['check' => $check, 'status' => $status, 'details' => $details];
if ($status === 'PASS') {
$pass++;
} elseif ($status === 'FAIL') {
$fail++;
} elseif ($status === 'WARN') {
$warn++;
} }
} $hasSource = is_dir("{$root}/src") || is_dir("{$root}/htdocs");
$this->addVResult('Source directory', $hasSource ? 'PASS' : 'WARN', $hasSource ? 'src/ or htdocs/ found' : 'No src/ or htdocs/ directory');
// 0. Source directory check if (!file_exists("{$root}/README.md")) {
$hasSource = is_dir("{$root}/src") || is_dir("{$root}/htdocs"); $this->addVResult('README.md', 'FAIL', 'Not found');
if ($hasSource) { } else {
addResult('Source directory', 'PASS', 'src/ or htdocs/ found');
} else {
addResult('Source directory', 'WARN', 'No src/ or htdocs/ directory');
}
// 1. README.md exists and contains VERSION
if (!file_exists("{$root}/README.md")) {
addResult('README.md', 'FAIL', 'Not found');
} else {
$readme = file_get_contents("{$root}/README.md"); $readme = file_get_contents("{$root}/README.md");
if ( $quotedVer = preg_quote($version, '/');
preg_match('/VERSION:\s*' . preg_quote($version, '/') . '/', $readme) || $readmeHasVer = preg_match(
strpos($readme, $version) !== false '/VERSION:\s*' . $quotedVer . '/',
) { $readme
addResult('README.md version', 'PASS', "`{$version}` found"); ) || strpos($readme, $version) !== false;
} else { $this->addVResult(
addResult('README.md version', 'FAIL', "`{$version}` not found in README.md"); 'README.md version',
$readmeHasVer ? 'PASS' : 'FAIL',
$readmeHasVer
? "`{$version}` found"
: "`{$version}` not found"
);
} }
} if (!file_exists("{$root}/CHANGELOG.md")) {
$this->addVResult('CHANGELOG.md', 'WARN', 'Not found');
// 2. CHANGELOG.md exists with matching section } else {
if (!file_exists("{$root}/CHANGELOG.md")) {
addResult('CHANGELOG.md', 'WARN', 'Not found');
} else {
$cl = file_get_contents("{$root}/CHANGELOG.md"); $cl = file_get_contents("{$root}/CHANGELOG.md");
if (preg_match('/^##\s.*' . preg_quote($version, '/') . '/m', $cl)) { $clHasVer = preg_match(
addResult('CHANGELOG.md version', 'PASS', "Section for `{$version}` found"); '/^##\s.*' . preg_quote($version, '/') . '/m',
} else { $cl
addResult('CHANGELOG.md version', 'WARN', "No section header for `{$version}`"); );
$this->addVResult(
'CHANGELOG.md version',
$clHasVer ? 'PASS' : 'WARN',
$clHasVer ? "Section found" : "No section header"
);
} }
} $licenseFound = false;
foreach (['LICENSE', 'LICENSE.md', 'LICENSE.txt', 'COPYING'] as $lf) {
// 3. LICENSE file exists
$licenseFound = false;
foreach (['LICENSE', 'LICENSE.md', 'LICENSE.txt', 'COPYING'] as $lf) {
if (file_exists("{$root}/{$lf}")) { if (file_exists("{$root}/{$lf}")) {
$licenseFound = true; $licenseFound = true;
break; break;
} }
} }
addResult('LICENSE', $licenseFound ? 'PASS' : 'FAIL', $licenseFound ? 'Found' : 'Not found'); $this->addVResult('LICENSE', $licenseFound ? 'PASS' : 'FAIL', $licenseFound ? 'Found' : 'Not found');
if ($platform === 'joomla') {
// 4. Platform-specific checks
if ($platform === 'joomla') {
// Find XML manifest
$manifest = null; $manifest = null;
$searchDirs = ["{$root}/src", $root]; foreach (["{$root}/src", $root] as $dir) {
foreach ($searchDirs as $dir) {
if (!is_dir($dir)) { if (!is_dir($dir)) {
continue; continue;
} } foreach (glob("{$dir}/*.xml") as $xmlFile) {
foreach (glob("{$dir}/*.xml") as $xmlFile) {
$content = file_get_contents($xmlFile); $content = file_get_contents($xmlFile);
if (strpos($content, '<extension') !== false) { if (strpos($content, '<extension') !== false) {
$manifest = $xmlFile; $manifest = $xmlFile;
@@ -166,92 +121,117 @@ if ($platform === 'joomla') {
} }
} }
if ($manifest === null) { if ($manifest === null) {
addResult('XML manifest', 'FAIL', 'No Joomla manifest found'); $this->addVResult('XML manifest', 'FAIL', 'No Joomla manifest found');
} else { } else {
if (preg_match('/<version>([^<]+)<\/version>/', file_get_contents($manifest), $m)) { $manifestContent = file_get_contents($manifest);
if (preg_match('/<version>([^<]+)<\/version>/', $manifestContent, $m)) {
$mVer = trim($m[1]); $mVer = trim($m[1]);
if ($mVer === $version) { $this->addVResult(
addResult('Manifest version', 'PASS', "`{$mVer}` matches"); 'Manifest version',
$mVer === $version ? 'PASS' : 'FAIL',
$mVer === $version
? "`{$mVer}` matches"
: "`{$mVer}` != `{$version}`"
);
} else { } else {
addResult('Manifest version', 'FAIL', "`{$mVer}` != `{$version}`"); $this->addVResult('Manifest version', 'FAIL', 'No <version> tag');
}
} else {
addResult('Manifest version', 'FAIL', 'No <version> tag in manifest');
} }
} }
// updates.xml
if (!file_exists("{$root}/updates.xml")) { if (!file_exists("{$root}/updates.xml")) {
addResult('updates.xml', 'WARN', 'Not found'); $this->addVResult('updates.xml', 'WARN', 'Not found');
} else { } else {
$ux = file_get_contents("{$root}/updates.xml"); $ux = file_get_contents("{$root}/updates.xml");
if (preg_match('/<version>' . preg_quote($version, '/') . '<\/version>/', $ux)) { $uxHasVer = preg_match(
addResult('updates.xml version', 'PASS', "`{$version}` found"); '/<version>' . preg_quote($version, '/')
} else { . '<\/version>/',
addResult('updates.xml version', 'FAIL', "`{$version}` not in updates.xml"); $ux
);
$this->addVResult(
'updates.xml version',
$uxHasVer ? 'PASS' : 'FAIL',
$uxHasVer
? "`{$version}` found"
: "`{$version}` not found"
);
} }
} } elseif ($platform === 'dolibarr') {
} elseif ($platform === 'dolibarr') {
$modFile = null; $modFile = null;
foreach (['src', 'htdocs'] as $sd) { foreach (['src', 'htdocs'] as $sd) {
$pattern = "{$root}/{$sd}/mod*.class.php"; $matches = glob("{$root}/{$sd}/mod*.class.php");
$matches = glob($pattern);
if (!empty($matches)) { if (!empty($matches)) {
$modFile = $matches[0]; $modFile = $matches[0];
break; break;
} }
} }
if ($modFile === null) { if ($modFile === null) {
addResult('Dolibarr mod file', 'FAIL', 'No mod*.class.php found'); $this->addVResult('Dolibarr mod file', 'FAIL', 'No mod*.class.php found');
} else { } else {
$mc = file_get_contents($modFile); $mc = file_get_contents($modFile);
if (preg_match("/\\\$this->version\s*=\s*'" . preg_quote($version, '/') . "'/", $mc)) { $dolPattern = "/\\\$this->version\s*=\s*'"
addResult('Dolibarr version', 'PASS', "`{$version}` matches"); . preg_quote($version, '/') . "'/";
} else { $dolMatch = preg_match($dolPattern, $mc);
addResult('Dolibarr version', 'FAIL', "`{$version}` not found in " . basename($modFile)); $this->addVResult(
'Dolibarr version',
$dolMatch ? 'PASS' : 'FAIL',
$dolMatch
? "`{$version}` matches"
: "`{$version}` not found"
);
} }
} }
} if (file_exists("{$root}/composer.json")) {
// 5. composer.json version (if present)
if (file_exists("{$root}/composer.json")) {
$composer = json_decode(file_get_contents("{$root}/composer.json"), true); $composer = json_decode(file_get_contents("{$root}/composer.json"), true);
if (isset($composer['version'])) { if (isset($composer['version'])) {
if ($composer['version'] === $version) { $compMatch = $composer['version'] === $version;
addResult('composer.json version', 'PASS', "`{$version}` matches"); $this->addVResult(
} else { 'composer.json version',
addResult('composer.json version', 'WARN', "`{$composer['version']}` != `{$version}`"); $compMatch ? 'PASS' : 'WARN',
$compMatch
? "`{$version}` matches"
: "`{$composer['version']}` != `{$version}`"
);
} }
} }
} $table = "| Check | Result | Details |\n|-------|--------|--------|\n";
foreach ($this->results as $r) {
// Output
$table = "| Check | Result | Details |\n|-------|--------|--------|\n";
foreach ($results as $r) {
$table .= "| {$r['check']} | {$r['status']} | {$r['details']} |\n"; $table .= "| {$r['check']} | {$r['status']} | {$r['details']} |\n";
} }
$table .= "\n**Validation: {$pass} passed, {$fail} failed, {$warn} warnings**\n"; $table .= "\n**Validation: {$this->pass} passed, {$this->fail} failed, {$this->warn} warnings**\n";
echo $table;
echo $table; if ($outputSummary) {
if ($outputSummary) {
$summaryFile = getenv('GITHUB_STEP_SUMMARY'); $summaryFile = getenv('GITHUB_STEP_SUMMARY');
if ($summaryFile) { if ($summaryFile) {
file_put_contents($summaryFile, "## Pre-Release Sanity Checks ({$platform})\n\n{$table}\n", FILE_APPEND); file_put_contents($summaryFile, "## Pre-Release Sanity Checks ({$platform})\n\n{$table}\n", FILE_APPEND);
} }
} }
if ($githubOutput) {
if ($githubOutput) {
$ghOutput = getenv('GITHUB_OUTPUT'); $ghOutput = getenv('GITHUB_OUTPUT');
$lines = [
"validation_pass={$pass}",
"validation_fail={$fail}",
"validation_warn={$warn}",
"validation_platform={$platform}",
];
if ($ghOutput) { if ($ghOutput) {
file_put_contents($ghOutput, implode("\n", $lines) . "\n", FILE_APPEND); file_put_contents(
$ghOutput,
"validation_pass={$this->pass}\n"
. "validation_fail={$this->fail}\n"
. "validation_warn={$this->warn}\n"
. "validation_platform={$platform}\n",
FILE_APPEND
);
}
}
return $this->fail > 0 ? 1 : 0;
}
private function addVResult(string $check, string $status, string $details): void
{
$this->results[] = ['check' => $check, 'status' => $status, 'details' => $details];
if ($status === 'PASS') {
$this->pass++;
} elseif ($status === 'FAIL') {
$this->fail++;
} elseif ($status === 'WARN') {
$this->warn++;
}
} }
} }
exit($fail > 0 ? 1 : 0); $app = new ReleaseValidateCli();
exit($app->execute());
+99 -78
View File
@@ -1,5 +1,6 @@
#!/usr/bin/env php #!/usr/bin/env php
<?php <?php
/* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech> /* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
* *
* SPDX-License-Identifier: GPL-3.0-or-later * SPDX-License-Identifier: GPL-3.0-or-later
@@ -10,74 +11,63 @@
* REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform * REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform
* PATH: /cli/release_verify.php * PATH: /cli/release_verify.php
* BRIEF: Verify a built release artifact — version, SHA256, disallowed files * BRIEF: Verify a built release artifact — version, SHA256, disallowed files
*
* Usage:
* php release_verify.php --zip-path /tmp/pkg.zip --version 04.01.00
* php release_verify.php --zip-path /tmp/pkg.zip --version 04.01.00 --updates-xml updates.xml
* php release_verify.php --zip-path /tmp/pkg.zip --version 04.01.00 --output-summary
*
* Options:
* --zip-path Path to ZIP file (required)
* --version Expected version string (required)
* --platform joomla|dolibarr|generic (default: joomla)
* --updates-xml Path to updates.xml for SHA256 comparison
* --github-output Export verify_pass, verify_fail to $GITHUB_OUTPUT
* --output-summary Write markdown table to $GITHUB_STEP_SUMMARY
*/ */
declare(strict_types=1); declare(strict_types=1);
$zipPath = null; require_once __DIR__ . '/../lib/Enterprise/CliFramework.php';
$version = null;
$platform = 'joomla';
$updatesXml = null;
$githubOutput = false;
$outputSummary = false;
foreach ($argv as $i => $arg) { use MokoEnterprise\CliFramework;
if ($arg === '--zip-path' && isset($argv[$i + 1])) $zipPath = $argv[$i + 1];
if ($arg === '--version' && isset($argv[$i + 1])) $version = $argv[$i + 1];
if ($arg === '--platform' && isset($argv[$i + 1])) $platform = $argv[$i + 1];
if ($arg === '--updates-xml' && isset($argv[$i + 1])) $updatesXml = $argv[$i + 1];
if ($arg === '--github-output') $githubOutput = true;
if ($arg === '--output-summary') $outputSummary = true;
}
if ($zipPath === null || $version === null) { class ReleaseVerifyCli extends CliFramework
fwrite(STDERR, "Usage: release_verify.php --zip-path FILE --version XX.YY.ZZ [--platform joomla] [--updates-xml FILE]\n"); {
exit(1); private int $pass = 0;
} private int $fail = 0;
private int $warn = 0;
private array $results = [];
$pass = 0; protected function configure(): void
$fail = 0; {
$warn = 0; $this->setDescription('Verify a built release artifact — version, SHA256, disallowed files');
$results = []; $this->addArgument('--zip-path', 'Path to ZIP file (required)', '');
$this->addArgument('--version', 'Expected version string (required)', '');
$this->addArgument('--platform', 'joomla|dolibarr|generic', 'joomla');
$this->addArgument('--updates-xml', 'Path to updates.xml for SHA256 comparison', '');
$this->addArgument('--github-output', 'Export verify_pass, verify_fail to $GITHUB_OUTPUT', false);
$this->addArgument('--output-summary', 'Write markdown table to $GITHUB_STEP_SUMMARY', false);
}
function addResult(string $check, string $status, string $details): void { protected function run(): int
global $pass, $fail, $warn, $results; {
$results[] = ['check' => $check, 'status' => $status, 'details' => $details]; $zipPath = $this->getArgument('--zip-path');
if ($status === 'PASS') $pass++; $version = $this->getArgument('--version');
elseif ($status === 'FAIL') $fail++; $platform = $this->getArgument('--platform');
elseif ($status === 'WARN') $warn++; $updatesXml = $this->getArgument('--updates-xml');
} $githubOutput = $this->getArgument('--github-output');
$outputSummary = $this->getArgument('--output-summary');
// 1. ZIP exists and is readable if ($zipPath === '' || $version === '') {
if (!file_exists($zipPath) || !is_readable($zipPath)) { $this->log('ERROR', 'Usage: release_verify.php --zip-path FILE --version XX.YY.ZZ [--platform joomla] [--updates-xml FILE]');
addResult('ZIP exists', 'FAIL', "Not found or not readable: {$zipPath}"); return 1;
} else { }
addResult('ZIP exists', 'PASS', basename($zipPath));
// 1. ZIP exists and is readable
if (!file_exists($zipPath) || !is_readable($zipPath)) {
$this->addResult('ZIP exists', 'FAIL', "Not found or not readable: {$zipPath}");
} else {
$this->addResult('ZIP exists', 'PASS', basename($zipPath));
// 2. Extract ZIP // 2. Extract ZIP
$tmpDir = sys_get_temp_dir() . '/release-verify-' . uniqid(); $tmpDir = sys_get_temp_dir() . '/release-verify-' . uniqid();
mkdir($tmpDir, 0755, true); mkdir($tmpDir, 0755, true);
$zip = new ZipArchive(); $zip = new \ZipArchive();
if ($zip->open($zipPath) !== true) { if ($zip->open($zipPath) !== true) {
addResult('ZIP extract', 'FAIL', 'ZipArchive could not open file'); $this->addResult('ZIP extract', 'FAIL', 'ZipArchive could not open file');
} else { } else {
$zip->extractTo($tmpDir); $zip->extractTo($tmpDir);
$zip->close(); $zip->close();
addResult('ZIP extract', 'PASS', 'Extracted successfully'); $this->addResult('ZIP extract', 'PASS', 'Extracted successfully');
// 3. Manifest version check (Joomla) // 3. Manifest version check (Joomla)
if ($platform === 'joomla') { if ($platform === 'joomla') {
@@ -93,38 +83,44 @@ if (!file_exists($zipPath) || !is_readable($zipPath)) {
if (preg_match('/<version>([^<]+)<\/version>/', file_get_contents($manifest), $m)) { if (preg_match('/<version>([^<]+)<\/version>/', file_get_contents($manifest), $m)) {
$manifestVer = trim($m[1]); $manifestVer = trim($m[1]);
if ($manifestVer === $version) { if ($manifestVer === $version) {
addResult('Manifest version', 'PASS', "`{$manifestVer}` matches release"); $this->addResult('Manifest version', 'PASS', "`{$manifestVer}` matches release");
} else { } else {
addResult('Manifest version', 'FAIL', "`{$manifestVer}` != `{$version}`"); $this->addResult('Manifest version', 'FAIL', "`{$manifestVer}` != `{$version}`");
} }
} else { } else {
addResult('Manifest version', 'WARN', 'No <version> tag in manifest'); $this->addResult('Manifest version', 'WARN', 'No <version> tag in manifest');
} }
} else { } else {
addResult('Manifest version', 'WARN', 'No XML manifest found in ZIP'); $this->addResult('Manifest version', 'WARN', 'No XML manifest found in ZIP');
} }
} }
// 4. SHA256 vs updates.xml // 4. SHA256 vs updates.xml
$zipSha = hash_file('sha256', $zipPath); $zipSha = hash_file('sha256', $zipPath);
if ($updatesXml !== null && file_exists($updatesXml)) { if ($updatesXml !== '' && file_exists($updatesXml)) {
$uxContent = file_get_contents($updatesXml); $uxContent = file_get_contents($updatesXml);
if (preg_match('/<sha256>([^<]+)<\/sha256>/', $uxContent, $m)) { if (preg_match('/<sha256>([^<]+)<\/sha256>/', $uxContent, $m)) {
$expectedSha = trim($m[1]); $expectedSha = trim($m[1]);
if ($zipSha === $expectedSha) { if ($zipSha === $expectedSha) {
addResult('SHA256 vs updates.xml', 'PASS', '`' . substr($zipSha, 0, 16) . '...`'); $this->addResult('SHA256 vs updates.xml', 'PASS', '`' . substr($zipSha, 0, 16) . '...`');
} else { } else {
addResult('SHA256 vs updates.xml', 'FAIL', "ZIP=`" . substr($zipSha, 0, 16) . "...` updates.xml=`" . substr($expectedSha, 0, 16) . "...`"); $this->addResult(
'SHA256 vs updates.xml',
'FAIL',
"ZIP=`" . substr($zipSha, 0, 16)
. "...` updates.xml=`"
. substr($expectedSha, 0, 16) . "...`"
);
} }
} else { } else {
addResult('SHA256 vs updates.xml', 'WARN', 'No <sha256> in updates.xml'); $this->addResult('SHA256 vs updates.xml', 'WARN', 'No <sha256> in updates.xml');
} }
} }
// 5. Disallowed files // 5. Disallowed files
$disallowed = ['.claude', '.mcp.json', 'TODO.md', 'todo.md', '.git', 'node_modules', '.env']; $disallowed = ['.claude', '.mcp.json', 'TODO.md', 'todo.md', '.git', 'node_modules', '.env'];
$found = []; $found = [];
$rit = new RecursiveIteratorIterator(new RecursiveDirectoryIterator($tmpDir, RecursiveDirectoryIterator::SKIP_DOTS)); $rit = new \RecursiveIteratorIterator(new \RecursiveDirectoryIterator($tmpDir, \RecursiveDirectoryIterator::SKIP_DOTS));
foreach ($rit as $file) { foreach ($rit as $file) {
$name = $file->getFilename(); $name = $file->getFilename();
if (in_array($name, $disallowed, true)) { if (in_array($name, $disallowed, true)) {
@@ -132,57 +128,82 @@ if (!file_exists($zipPath) || !is_readable($zipPath)) {
} }
} }
if (count($found) > 0) { if (count($found) > 0) {
addResult('Disallowed files', 'FAIL', 'Found: ' . implode(', ', array_unique($found))); $this->addResult('Disallowed files', 'FAIL', 'Found: ' . implode(', ', array_unique($found)));
} else { } else {
addResult('Disallowed files', 'PASS', 'None found'); $this->addResult('Disallowed files', 'PASS', 'None found');
} }
// 6. Non-vendor .min files // 6. Non-vendor .min files
$minCount = 0; $minCount = 0;
$rit = new RecursiveIteratorIterator(new RecursiveDirectoryIterator($tmpDir, RecursiveDirectoryIterator::SKIP_DOTS)); $rit = new \RecursiveIteratorIterator(new \RecursiveDirectoryIterator($tmpDir, \RecursiveDirectoryIterator::SKIP_DOTS));
foreach ($rit as $file) { foreach ($rit as $file) {
$rel = str_replace($tmpDir . '/', '', $file->getPathname()); $rel = str_replace($tmpDir . '/', '', $file->getPathname());
if (strpos($rel, 'vendor/') !== false) continue; if (strpos($rel, 'vendor/') !== false) {
continue;
}
if (preg_match('/\.(min\.css|min\.js)$/', $file->getFilename())) { if (preg_match('/\.(min\.css|min\.js)$/', $file->getFilename())) {
$minCount++; $minCount++;
} }
} }
if ($minCount > 0) { if ($minCount > 0) {
addResult('Non-vendor .min files', 'WARN', "{$minCount} file(s) — should be generated at runtime"); $this->addResult('Non-vendor .min files', 'WARN', "{$minCount} file(s) — should be generated at runtime");
} else { } else {
addResult('Non-vendor .min files', 'PASS', 'None shipped'); $this->addResult('Non-vendor .min files', 'PASS', 'None shipped');
} }
// Clean up // Clean up
$rit = new RecursiveIteratorIterator(new RecursiveDirectoryIterator($tmpDir, RecursiveDirectoryIterator::SKIP_DOTS), RecursiveIteratorIterator::CHILD_FIRST); $rit = new \RecursiveIteratorIterator(
new \RecursiveDirectoryIterator(
$tmpDir,
\RecursiveDirectoryIterator::SKIP_DOTS
),
\RecursiveIteratorIterator::CHILD_FIRST
);
foreach ($rit as $file) { foreach ($rit as $file) {
$file->isDir() ? rmdir($file->getPathname()) : unlink($file->getPathname()); $file->isDir() ? rmdir($file->getPathname()) : unlink($file->getPathname());
} }
rmdir($tmpDir); rmdir($tmpDir);
} }
} }
// Output // Output
$table = "| Check | Result | Details |\n|-------|--------|--------|\n"; $table = "| Check | Result | Details |\n|-------|--------|--------|\n";
foreach ($results as $r) { foreach ($this->results as $r) {
$table .= "| {$r['check']} | {$r['status']} | {$r['details']} |\n"; $table .= "| {$r['check']} | {$r['status']} | {$r['details']} |\n";
} }
$table .= "\n**Verification: {$pass} passed, {$fail} failed, {$warn} warnings**\n"; $table .= "\n**Verification: {$this->pass} passed, {$this->fail} failed, {$this->warn} warnings**\n";
echo $table; echo $table;
if ($outputSummary) { if ($outputSummary) {
$summaryFile = getenv('GITHUB_STEP_SUMMARY'); $summaryFile = getenv('GITHUB_STEP_SUMMARY');
if ($summaryFile) { if ($summaryFile) {
file_put_contents($summaryFile, "### Release Verification\n\n{$table}\n", FILE_APPEND); file_put_contents($summaryFile, "### Release Verification\n\n{$table}\n", FILE_APPEND);
} }
} }
if ($githubOutput) { if ($githubOutput) {
$outputFile = getenv('GITHUB_OUTPUT'); $outputFile = getenv('GITHUB_OUTPUT');
if ($outputFile) { if ($outputFile) {
file_put_contents($outputFile, "verify_pass={$pass}\nverify_fail={$fail}\nverify_warn={$warn}\n", FILE_APPEND); file_put_contents($outputFile, "verify_pass={$this->pass}\nverify_fail={$this->fail}\nverify_warn={$this->warn}\n", FILE_APPEND);
}
}
return $this->fail > 0 ? 1 : 0;
}
private function addResult(string $check, string $status, string $details): void
{
$this->results[] = ['check' => $check, 'status' => $status, 'details' => $details];
if ($status === 'PASS') {
$this->pass++;
} elseif ($status === 'FAIL') {
$this->fail++;
} elseif ($status === 'WARN') {
$this->warn++;
}
} }
} }
exit($fail > 0 ? 1 : 0); $app = new ReleaseVerifyCli();
exit($app->execute());
+68 -183
View File
@@ -1,5 +1,6 @@
#!/usr/bin/env php #!/usr/bin/env php
<?php <?php
/* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech> /* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
* *
* This file is part of a Moko Consulting project. * This file is part of a Moko Consulting project.
@@ -11,240 +12,124 @@
* INGROUP: moko-platform * INGROUP: moko-platform
* REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform * REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform
* PATH: /cli/scaffold_client.php * PATH: /cli/scaffold_client.php
* VERSION: 09.21.00 * VERSION: 09.24.00
* BRIEF: Scaffold a new client-waas repo from Template-Client-WaaS with pre-configured settings * BRIEF: Scaffold a new client-waas repo from Template-Client-WaaS with pre-configured settings
*/ */
declare(strict_types=1); declare(strict_types=1);
final class ScaffoldClient require_once __DIR__ . '/../lib/Enterprise/CliFramework.php';
use MokoEnterprise\CliFramework;
class ScaffoldClientCli extends CliFramework
{ {
private string $name = ''; protected function configure(): void
private string $org = '';
private string $giteaUrl = 'https://git.mokoconsulting.tech';
private string $token = '';
private bool $dryRun = false;
public function run(): int
{ {
$this->parseArgs(); $this->setDescription('Scaffold a new client-waas repo from Template-Client-WaaS');
$this->addArgument('--name', 'Client name', '');
$this->addArgument('--org', 'Gitea organization', '');
$this->addArgument('--gitea-url', 'Gitea URL', 'https://git.mokoconsulting.tech');
$this->addArgument('--token', 'Gitea API token', '');
}
if ($this->name === '' || $this->org === '' || $this->token === '') protected function run(): int
{ {
$this->log('ERROR: --name, --org, and --token are required.'); $name = $this->getArgument('--name');
$this->printUsage(); $org = $this->getArgument('--org');
$giteaUrl = rtrim($this->getArgument('--gitea-url'), '/');
$token = $this->getArgument('--token');
if ($name === '' || $org === '' || $token === '') {
$this->log('ERROR', '--name, --org, and --token are required.');
return 1; return 1;
} }
$repoName = 'client-waas-' . $name;
$repoName = 'client-waas-' . $this->name; $this->log('INFO', "Scaffolding client repo: {$org}/{$repoName}");
$this->log('INFO', "Gitea URL: {$giteaUrl}");
$this->log("Scaffolding client repo: {$this->org}/{$repoName}"); if ($this->dryRun) {
$this->log("Gitea URL: {$this->giteaUrl}"); $this->log('INFO', '[DRY RUN] Would create repo from template MokoConsulting/Template-Client-WaaS');
$this->log('INFO', "[DRY RUN] Repo: {$org}/{$repoName}");
if ($this->dryRun) $this->printPostSetupInstructions($repoName, $giteaUrl, $org);
{
$this->log('[DRY RUN] Would create repo from template MokoConsulting/Template-Client-WaaS');
$this->log("[DRY RUN] Repo: {$this->org}/{$repoName}");
$this->log("[DRY RUN] Description: \"{$this->name} WaaS site\"");
$this->log('[DRY RUN] Would create dev branch from main');
$this->printPostSetupInstructions($repoName);
return 0; return 0;
} }
$this->log('INFO', 'Step 1: Creating repo from template...');
// Step 1: Create repo from template
$this->log('Step 1: Creating repo from template...');
$createPayload = json_encode([ $createPayload = json_encode([
'owner' => $this->org, 'owner' => $org,
'name' => $repoName, 'name' => $repoName,
'description' => "{$this->name} WaaS site", 'description' => "{$name} WaaS site",
'private' => true, 'private' => true,
'git_content' => true, 'git_content' => true,
'topics' => true, 'topics' => true,
'labels' => true, 'labels' => true,
]); ]);
$response = $this->apiRequest( $response = $this->apiRequest(
'POST', 'POST',
"/api/v1/repos/MokoConsulting/Template-Client-WaaS/generate", "/api/v1/repos/MokoConsulting/Template-Client-WaaS/generate",
$giteaUrl,
$token,
$createPayload $createPayload
); );
if ($response['code'] < 200 || $response['code'] >= 300) {
if ($response['code'] < 200 || $response['code'] >= 300) $this->log('ERROR', "Failed to create repo (HTTP {$response['code']}).");
{
$this->log("ERROR: Failed to create repo (HTTP {$response['code']}).");
$this->log("Response: {$response['body']}");
return 1; return 1;
} }
$this->log('INFO', "Repo created: {$org}/{$repoName}");
$this->log("Repo created: {$this->org}/{$repoName}"); $this->log('INFO', 'Step 2: Updating repo description...');
$this->apiRequest('PATCH', "/api/v1/repos/{$org}/{$repoName}", $giteaUrl, $token, json_encode(['description' => "{$name} WaaS site"]));
// Step 2: Set repo description (already set via generate, but confirm) $this->log('INFO', 'Step 3: Creating dev branch from main...');
$this->log('Step 2: Updating repo description...');
$updatePayload = json_encode([
'description' => "{$this->name} WaaS site",
]);
$response = $this->apiRequest(
'PATCH',
"/api/v1/repos/{$this->org}/{$repoName}",
$updatePayload
);
if ($response['code'] >= 200 && $response['code'] < 300)
{
$this->log('Description updated.');
}
else
{
$this->log("WARNING: Could not update description (HTTP {$response['code']}).");
}
// Step 3: Create dev branch from main
$this->log('Step 3: Creating dev branch from main...');
$branchPayload = json_encode([
'new_branch_name' => 'dev',
'old_branch_name' => 'main',
]);
$response = $this->apiRequest( $response = $this->apiRequest(
'POST', 'POST',
"/api/v1/repos/{$this->org}/{$repoName}/branches", "/api/v1/repos/{$org}/{$repoName}/branches",
$branchPayload $giteaUrl,
$token,
json_encode([
'new_branch_name' => 'dev',
'old_branch_name' => 'main',
])
); );
if ($response['code'] >= 200 && $response['code'] < 300) {
if ($response['code'] >= 200 && $response['code'] < 300) $this->log('INFO', 'Branch "dev" created from "main".');
{ } else {
$this->log('Branch "dev" created from "main".'); $this->log('WARN', "Could not create dev branch (HTTP {$response['code']}).");
} }
else $this->printPostSetupInstructions($repoName, $giteaUrl, $org);
{ $this->log('INFO', 'Scaffold complete.');
$this->log("WARNING: Could not create dev branch (HTTP {$response['code']}).");
$this->log("Response: {$response['body']}");
}
// Step 4: Print post-setup instructions
$this->printPostSetupInstructions($repoName);
$this->log('Scaffold complete.');
return 0; return 0;
} }
private function parseArgs(): void private function printPostSetupInstructions(string $repoName, string $giteaUrl, string $org): void
{ {
$args = $_SERVER['argv'] ?? []; fwrite(STDERR, "\n=== POST-SETUP INSTRUCTIONS ===\n\n"
$count = count($args); . "Navigate to: {$giteaUrl}/{$org}/{$repoName}/settings\n\n"
. "Set REPO VARIABLES:\n"
for ($i = 1; $i < $count; $i++) . " DEV_SYNC_HOST, DEV_SYNC_PORT, DEV_SYNC_USER, DEV_SYNC_PATH\n"
{ . " LIVE_SSH_HOST, LIVE_SSH_PORT, LIVE_SSH_USER, LIVE_SYNC_PATH\n\n"
switch ($args[$i]) . "Set REPO SECRETS:\n"
{ . " DEV_SYNC_KEY, LIVE_SSH_KEY\n\n"
case '--name': . "================================\n");
$this->name = $args[++$i] ?? '';
break;
case '--org':
$this->org = $args[++$i] ?? '';
break;
case '--gitea-url':
$this->giteaUrl = rtrim($args[++$i] ?? '', '/');
break;
case '--token':
$this->token = $args[++$i] ?? '';
break;
case '--dry-run':
$this->dryRun = true;
break;
case '--help':
case '-h':
$this->printUsage();
exit(0);
default:
$this->log("WARNING: Unknown argument: {$args[$i]}");
break;
}
}
} }
private function printUsage(): void private function apiRequest(string $method, string $endpoint, string $giteaUrl, string $token, ?string $body = null): array
{ {
$this->log('Usage: scaffold_client.php --name <client-name> --org <gitea-org> --token <token> [options]');
$this->log('');
$this->log('Options:');
$this->log(' --name <name> Client name (e.g., "clarksvillefurs")');
$this->log(' --org <org> Gitea organization (e.g., "ClarksvilleFurs")');
$this->log(' --gitea-url <url> Gitea URL (default: https://git.mokoconsulting.tech)');
$this->log(' --token <token> Gitea API token');
$this->log(' --dry-run Show what would be done without making changes');
$this->log(' --help, -h Show this help');
}
private function printPostSetupInstructions(string $repoName): void
{
$this->log('');
$this->log('=== POST-SETUP INSTRUCTIONS ===');
$this->log('');
$this->log("Navigate to: {$this->giteaUrl}/{$this->org}/{$repoName}/settings");
$this->log('');
$this->log('Set the following REPO VARIABLES (Settings > Actions > Variables):');
$this->log(' DEV_SYNC_HOST - Dev server hostname or IP');
$this->log(' DEV_SYNC_PORT - Dev server SSH port (default: 22)');
$this->log(' DEV_SYNC_USER - Dev server SSH username');
$this->log(' DEV_SYNC_PATH - Dev server deploy path');
$this->log(' LIVE_SSH_HOST - Live server hostname or IP');
$this->log(' LIVE_SSH_PORT - Live server SSH port (default: 22)');
$this->log(' LIVE_SSH_USER - Live server SSH username');
$this->log(' LIVE_SYNC_PATH - Live server deploy path');
$this->log('');
$this->log('Set the following REPO SECRETS (Settings > Actions > Secrets):');
$this->log(' DEV_SYNC_KEY - Private SSH key for dev server');
$this->log(' LIVE_SSH_KEY - Private SSH key for live server');
$this->log('');
$this->log('================================');
}
private function apiRequest(string $method, string $endpoint, ?string $body = null): array
{
$url = $this->giteaUrl . $endpoint;
$ch = curl_init(); $ch = curl_init();
curl_setopt($ch, CURLOPT_URL, $url); curl_setopt($ch, CURLOPT_URL, $giteaUrl . $endpoint);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_CUSTOMREQUEST, $method); curl_setopt($ch, CURLOPT_CUSTOMREQUEST, $method);
curl_setopt($ch, CURLOPT_HTTPHEADER, [ curl_setopt($ch, CURLOPT_HTTPHEADER, ['Content-Type: application/json', 'Accept: application/json', "Authorization: token {$token}"]);
'Content-Type: application/json', if ($body !== null) {
'Accept: application/json',
"Authorization: token {$this->token}",
]);
if ($body !== null)
{
curl_setopt($ch, CURLOPT_POSTFIELDS, $body); curl_setopt($ch, CURLOPT_POSTFIELDS, $body);
} }
$responseBody = curl_exec($ch); $responseBody = curl_exec($ch);
$httpCode = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE); $httpCode = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE);
if (curl_errno($ch)) {
if (curl_errno($ch))
{
$error = curl_error($ch); $error = curl_error($ch);
curl_close($ch); curl_close($ch);
return ['code' => 0, 'body' => "cURL error: {$error}"]; return ['code' => 0, 'body' => "cURL error: {$error}"];
} }
curl_close($ch); curl_close($ch);
return ['code' => $httpCode, 'body' => $responseBody]; return ['code' => $httpCode, 'body' => $responseBody];
} }
private function log(string $message): void
{
fwrite(STDERR, $message . PHP_EOL);
}
} }
$app = new ScaffoldClient(); $app = new ScaffoldClientCli();
exit($app->run()); exit($app->execute());
+46 -41
View File
@@ -1,5 +1,6 @@
#!/usr/bin/env php #!/usr/bin/env php
<?php <?php
/* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech> /* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
* *
* This file is part of a Moko Consulting project. * This file is part of a Moko Consulting project.
@@ -12,46 +13,43 @@
* REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform * REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform
* PATH: /cli/sync_rulesets.php * PATH: /cli/sync_rulesets.php
* BRIEF: Apply branch protection rules to all repos via platform adapter * BRIEF: Apply branch protection rules to all repos via platform adapter
*
* USAGE
* php cli/sync_rulesets.php # Apply to all repos
* php cli/sync_rulesets.php --repo MokoCRM # Single repo
* php cli/sync_rulesets.php --dry-run # Preview only
* php cli/sync_rulesets.php --delete # Remove then re-apply
*
* NOTE: On GitHub, this creates rulesets via the rulesets API.
* On Gitea, this creates branch_protections via the branch protection API.
*/ */
declare(strict_types=1); declare(strict_types=1);
require_once __DIR__ . '/../vendor/autoload.php'; require_once __DIR__ . '/../vendor/autoload.php';
require_once __DIR__ . '/../lib/Enterprise/CliFramework.php';
use MokoEnterprise\CliFramework;
use MokoEnterprise\Config; use MokoEnterprise\Config;
use MokoEnterprise\PlatformAdapterFactory; use MokoEnterprise\PlatformAdapterFactory;
$dryRun = in_array('--dry-run', $argv); class SyncRulesetsCli extends CliFramework
$deleteOld = in_array('--delete', $argv); {
protected function configure(): void
{
$this->setDescription('Apply branch protection rules to all repos via platform adapter');
$this->addArgument('--repo', 'Single repository name (default: all repos)', '');
$this->addArgument('--delete', 'Remove existing protections before re-applying', false);
}
$repoName = null; protected function run(): int
{
$repoName = $this->getArgument('--repo');
$deleteOld = $this->getArgument('--delete');
foreach ($argv as $i => $arg) { $config = Config::load();
if ($arg === '--repo' && isset($argv[$i + 1])) { $repoName = $argv[$i + 1]; } $adapter = PlatformAdapterFactory::create($config);
} $org = $config->getString(
$config = Config::load();
$adapter = PlatformAdapterFactory::create($config);
$org = $config->getString(
$adapter->getPlatformName() . '.organization', $adapter->getPlatformName() . '.organization',
'mokoconsulting-tech' 'mokoconsulting-tech'
); );
$platformName = $adapter->getPlatformName(); $platformName = $adapter->getPlatformName();
$ALWAYS_EXCLUDE = ['MokoStandards', '.github-private']; $ALWAYS_EXCLUDE = ['moko-platform', '.github-private'];
// ── Protection rules (platform-agnostic format) ───────────────────────── // -- Protection rules (platform-agnostic format) --
// On GitHub → rulesets API. On Gitea → branch_protections API. $PROTECTIONS = [
$PROTECTIONS = [
[ [
'name' => 'MAIN — protect default branch', 'name' => 'MAIN — protect default branch',
'branch' => 'main', 'branch' => 'main',
@@ -90,13 +88,13 @@ $PROTECTIONS = [
'whitelist_actions_user' => true, 'whitelist_actions_user' => true,
], ],
], ],
]; ];
// ── Build repo list ───────────────────────────────────────────────────── // -- Build repo list --
$repos = []; $repos = [];
if ($repoName) { if ($repoName !== '') {
$repos = [$repoName]; $repos = [$repoName];
} else { } else {
echo "Fetching repositories from {$org} ({$platformName})...\n"; echo "Fetching repositories from {$org} ({$platformName})...\n";
$allRepos = $adapter->listOrgRepos($org, true); // skip archived $allRepos = $adapter->listOrgRepos($org, true); // skip archived
foreach ($allRepos as $r) { foreach ($allRepos as $r) {
@@ -106,13 +104,13 @@ if ($repoName) {
} }
sort($repos); sort($repos);
echo "Found " . count($repos) . " repositories\n\n"; echo "Found " . count($repos) . " repositories\n\n";
} }
$created = 0; $created = 0;
$skipped = 0; $skipped = 0;
$failed = 0; $failed = 0;
foreach ($repos as $repo) { foreach ($repos as $repo) {
echo "Processing {$repo}...\n"; echo "Processing {$repo}...\n";
// Check existing protections // Check existing protections
@@ -132,7 +130,7 @@ foreach ($repos as $repo) {
$pName = $protection['name']; $pName = $protection['name'];
if ($deleteOld && isset($existingNames[$pName])) { if ($deleteOld && isset($existingNames[$pName])) {
if (!$dryRun) { if (!$this->dryRun) {
try { try {
// Platform-specific deletion via raw API // Platform-specific deletion via raw API
$adapter->getApiClient()->delete( $adapter->getApiClient()->delete(
@@ -140,7 +138,9 @@ foreach ($repos as $repo) {
($platformName === 'github' ? 'rulesets' : 'branch_protections') . ($platformName === 'github' ? 'rulesets' : 'branch_protections') .
"/{$existingNames[$pName]}" "/{$existingNames[$pName]}"
); );
} catch (\Exception $e) { /* ignore delete errors */ } } catch (\Exception $e) {
/* ignore delete errors */
}
} }
echo " Deleted: {$pName}\n"; echo " Deleted: {$pName}\n";
unset($existingNames[$pName]); unset($existingNames[$pName]);
@@ -152,7 +152,7 @@ foreach ($repos as $repo) {
continue; continue;
} }
if ($dryRun) { if ($this->dryRun) {
echo " (dry-run) would create: {$pName}\n"; echo " (dry-run) would create: {$pName}\n";
$created++; $created++;
continue; continue;
@@ -174,8 +174,13 @@ foreach ($repos as $repo) {
} }
} }
echo "\n"; echo "\n";
}
echo str_repeat('-', 50) . "\n";
echo "Done: {$created} created, {$skipped} skipped, {$failed} failed\n";
return $failed > 0 ? 1 : 0;
}
} }
echo str_repeat('-', 50) . "\n"; $app = new SyncRulesetsCli();
echo "Done: {$created} created, {$skipped} skipped, {$failed} failed\n"; exit($app->execute());
exit($failed > 0 ? 1 : 0);
+97 -113
View File
@@ -1,5 +1,6 @@
#!/usr/bin/env php #!/usr/bin/env php
<?php <?php
/* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech> /* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
* *
* SPDX-License-Identifier: GPL-3.0-or-later * SPDX-License-Identifier: GPL-3.0-or-later
@@ -9,149 +10,131 @@
* INGROUP: moko-platform * INGROUP: moko-platform
* REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform * REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform
* PATH: /cli/theme_lint.php * PATH: /cli/theme_lint.php
* BRIEF: Lint theme files CSS syntax, image sizes, hardcoded URLs * 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); declare(strict_types=1);
$path = '.'; require_once __DIR__ . '/../lib/Enterprise/CliFramework.php';
$maxImageKb = 500;
$ghOutput = false;
$strict = false;
foreach ($argv as $i => $arg) { use MokoEnterprise\CliFramework;
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; class ThemeLintCli extends CliFramework
$errors = 0; {
$warnings = 0; protected function configure(): void
{
$this->setDescription('Lint theme files -- CSS syntax, image sizes, hardcoded URLs');
$this->addArgument('--path', 'Repository root', '.');
$this->addArgument('--max-image-kb', 'Maximum image file size in KB', '500');
$this->addArgument('--github-output', 'Export results to $GITHUB_OUTPUT', false);
$this->addArgument('--strict', 'Exit 1 on any warning', false);
}
// ── Find source directory ─────────────────────────────────────────────── protected function run(): int
$srcDir = null; {
foreach (['src', 'htdocs'] as $d) { $path = $this->getArgument('--path');
if (is_dir("{$root}/{$d}")) { $srcDir = "{$root}/{$d}"; break; } $maxImageKb = (int) $this->getArgument('--max-image-kb');
} $ghOutput = (bool) $this->getArgument('--github-output');
if ($srcDir === null) { $strict = (bool) $this->getArgument('--strict');
fwrite(STDERR, "No src/ or htdocs/ directory in {$root}\n");
exit(1);
}
echo "Theme Lint: {$srcDir}\n\n"; $root = realpath($path) ?: $path;
$errors = 0;
$warnings = 0;
// ── Check 1: CSS syntax validation ────────────────────────────────────── $srcDir = null;
echo "--- CSS Syntax ---\n"; foreach (['src', 'htdocs'] as $d) {
$cssFiles = findFiles($srcDir, '*.css'); if (is_dir("{$root}/{$d}")) {
$cssMinFiles = findFiles($srcDir, '*.min.css'); $srcDir = "{$root}/{$d}";
$cssToCheck = array_diff($cssFiles, $cssMinFiles); break;
}
}
if ($srcDir === null) {
$this->log('ERROR', "No src/ or htdocs/ directory in {$root}");
return 1;
}
if (empty($cssToCheck)) { echo "Theme Lint: {$srcDir}\n\n";
echo "--- CSS Syntax ---\n";
$cssFiles = $this->findFiles($srcDir, '*.css');
$cssMinFiles = $this->findFiles($srcDir, '*.min.css');
$cssToCheck = array_diff($cssFiles, $cssMinFiles);
if (empty($cssToCheck)) {
echo " No CSS files to check\n"; echo " No CSS files to check\n";
} else { } else {
foreach ($cssToCheck as $file) { foreach ($cssToCheck as $file) {
$content = file_get_contents($file); $content = file_get_contents($file);
$relPath = str_replace($root . '/', '', $file); $relPath = str_replace($root . '/', '', $file);
// Check for unmatched braces
$openBraces = substr_count($content, '{'); $openBraces = substr_count($content, '{');
$closeBraces = substr_count($content, '}'); $closeBraces = substr_count($content, '}');
if ($openBraces !== $closeBraces) { if ($openBraces !== $closeBraces) {
echo " ERROR: {$relPath}: unmatched braces (open={$openBraces}, close={$closeBraces})\n"; echo " ERROR: {$relPath}: unmatched braces (open={$openBraces}, close={$closeBraces})\n";
$errors++; $errors++;
} }
// Check for empty rules
if (preg_match_all('/\{[\s]*\}/', $content, $m)) { if (preg_match_all('/\{[\s]*\}/', $content, $m)) {
$count = count($m[0]); $count = count($m[0]);
echo " WARN: {$relPath}: {$count} empty rule(s)\n"; echo " WARN: {$relPath}: {$count} empty rule(s)\n";
$warnings++; $warnings++;
} }
// Check for !important abuse (more than 10 in one file)
$importantCount = substr_count($content, '!important'); $importantCount = substr_count($content, '!important');
if ($importantCount > 10) { if ($importantCount > 10) {
echo " WARN: {$relPath}: {$importantCount} !important declarations (consider refactoring)\n"; echo " WARN: {$relPath}: {$importantCount} !important declarations (consider refactoring)\n";
$warnings++; $warnings++;
} }
} }
if ($errors === 0) { if ($errors === 0) {
echo " OK: " . count($cssToCheck) . " CSS file(s) checked\n"; 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; echo "\n--- Image Sizes (max {$maxImageKb}KB) ---\n";
$totalSize = 0; $imageExts = ['*.jpg', '*.jpeg', '*.png', '*.gif', '*.webp', '*.svg', '*.bmp'];
foreach ($images as $file) { $images = [];
foreach ($imageExts as $ext) {
$images = array_merge($images, $this->findFiles($srcDir, $ext));
}
if (is_dir("{$root}/images")) {
foreach ($imageExts as $ext) {
$images = array_merge($images, $this->findFiles("{$root}/images", $ext));
}
}
$oversized = 0;
$totalSize = 0;
foreach ($images as $file) {
$size = filesize($file); $size = filesize($file);
$totalSize += $size; $totalSize += $size;
$relPath = str_replace($root . '/', '', $file); $relPath = str_replace($root . '/', '', $file);
$sizeKb = round($size / 1024); $sizeKb = round($size / 1024);
if ($sizeKb > $maxImageKb) { if ($sizeKb > $maxImageKb) {
echo " WARN: {$relPath}: {$sizeKb}KB (exceeds {$maxImageKb}KB limit)\n"; echo " WARN: {$relPath}: {$sizeKb}KB (exceeds {$maxImageKb}KB limit)\n";
$oversized++; $oversized++;
$warnings++; $warnings++;
} }
} }
$totalMb = round($totalSize / 1024 / 1024, 1); $totalMb = round($totalSize / 1024 / 1024, 1);
echo " " . count($images) . " image(s), {$totalMb}MB total"; echo " " . count($images) . " image(s), {$totalMb}MB total";
if ($oversized > 0) { if ($oversized > 0) {
echo ", {$oversized} oversized"; echo ", {$oversized} oversized";
} }
echo "\n"; echo "\n";
// ── Check 3: Hardcoded URLs in CSS/JS ─────────────────────────────────── echo "\n--- Hardcoded URLs ---\n";
echo "\n--- Hardcoded URLs ---\n"; $codeFiles = array_merge($this->findFiles($srcDir, '*.css'), $this->findFiles($srcDir, '*.js'));
$codeFiles = array_merge( $codeFiles = array_filter($codeFiles, function ($f) {
findFiles($srcDir, '*.css'),
findFiles($srcDir, '*.js')
);
// Exclude minified files
$codeFiles = array_filter($codeFiles, function($f) {
return !preg_match('/\.min\.(css|js)$/', $f); return !preg_match('/\.min\.(css|js)$/', $f);
}); });
$urlPatterns = [
$urlPatterns = [
'/https?:\/\/clarksvillefurs\.com/' => 'hardcoded production URL', '/https?:\/\/clarksvillefurs\.com/' => 'hardcoded production URL',
'/https?:\/\/[a-z]+\.dev\.mokoconsulting\.tech/' => 'hardcoded dev URL', '/https?:\/\/[a-z]+\.dev\.mokoconsulting\.tech/' => 'hardcoded dev URL',
'/https?:\/\/localhost/' => 'localhost reference', '/https?:\/\/localhost/' => 'localhost reference',
]; ];
$urlIssues = 0;
$urlIssues = 0; foreach ($codeFiles as $file) {
foreach ($codeFiles as $file) {
$content = file_get_contents($file); $content = file_get_contents($file);
$relPath = str_replace($root . '/', '', $file); $relPath = str_replace($root . '/', '', $file);
foreach ($urlPatterns as $pattern => $desc) { foreach ($urlPatterns as $pattern => $desc) {
if (preg_match_all($pattern, $content, $matches)) { if (preg_match_all($pattern, $content, $matches)) {
$count = count($matches[0]); $count = count($matches[0]);
@@ -160,18 +143,16 @@ foreach ($codeFiles as $file) {
$warnings++; $warnings++;
} }
} }
} }
if ($urlIssues === 0) {
if ($urlIssues === 0) {
echo " OK: No hardcoded URLs found\n"; echo " OK: No hardcoded URLs found\n";
} }
// ── Summary ───────────────────────────────────────────────────────────── echo "\n=== Summary ===\n";
echo "\n=== Summary ===\n"; echo "Errors: {$errors}\n";
echo "Errors: {$errors}\n"; echo "Warnings: {$warnings}\n";
echo "Warnings: {$warnings}\n";
if ($ghOutput) { if ($ghOutput) {
$ghFile = getenv('GITHUB_OUTPUT'); $ghFile = getenv('GITHUB_OUTPUT');
if ($ghFile) { if ($ghFile) {
file_put_contents($ghFile, "lint_errors={$errors}\n", FILE_APPEND); file_put_contents($ghFile, "lint_errors={$errors}\n", FILE_APPEND);
@@ -179,31 +160,34 @@ if ($ghOutput) {
file_put_contents($ghFile, "lint_images=" . count($images) . "\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); file_put_contents($ghFile, "lint_css=" . count($cssToCheck) . "\n", FILE_APPEND);
} }
} }
if ($errors > 0) { if ($errors > 0) {
exit(1); return 1;
} }
if ($strict && $warnings > 0) { if ($strict && $warnings > 0) {
exit(1); return 1;
} }
exit(0); return 0;
}
// ── Helper: recursively find files matching a glob pattern ────────────── private function findFiles(string $dir, string $pattern): array
function findFiles(string $dir, string $pattern): array {
{
$results = []; $results = [];
if (!is_dir($dir)) return $results; if (!is_dir($dir)) {
return $results;
}
$iterator = new RecursiveIteratorIterator( $iterator = new RecursiveIteratorIterator(
new RecursiveDirectoryIterator($dir, RecursiveDirectoryIterator::SKIP_DOTS) new RecursiveDirectoryIterator($dir, RecursiveDirectoryIterator::SKIP_DOTS)
); );
foreach ($iterator as $file) { foreach ($iterator as $file) {
if (fnmatch($pattern, $file->getFilename())) { if (fnmatch($pattern, $file->getFilename())) {
$results[] = $file->getPathname(); $results[] = $file->getPathname();
} }
} }
return $results; return $results;
}
} }
$app = new ThemeLintCli();
exit($app->execute());
+220 -233
View File
@@ -11,84 +11,59 @@
* REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform * REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform
* PATH: /cli/updates_xml_build.php * PATH: /cli/updates_xml_build.php
* BRIEF: Generate Joomla updates.xml from extension manifest metadata * BRIEF: Generate Joomla updates.xml from extension manifest metadata
*
* Usage:
* php updates_xml_build.php --path /repo --version 04.01.00 --stability stable
* php updates_xml_build.php --path /repo --version 04.01.00 --stability stable --sha SHA256
* php updates_xml_build.php --path /repo --version 04.01.00 --stability stable --github-output
*
* Options:
* --path Repository root (default: .)
* --version Version string (required)
* --stability One of: stable, rc, beta, alpha, development (default: stable)
* --sha SHA-256 hash of the ZIP package (optional)
* --gitea-url Gitea instance URL (default: env GITEA_URL or https://git.mokoconsulting.tech)
* --org Organization (default: env GITEA_ORG)
* --repo Repository name (default: env GITEA_REPO)
* --output Output file path (default: updates.xml in --path)
* --github-output Export ext_element, ext_name, ext_type, ext_folder to $GITHUB_OUTPUT
*/ */
declare(strict_types=1); declare(strict_types=1);
// -- Argument parsing --------------------------------------------------------- require_once __DIR__ . '/../lib/Enterprise/CliFramework.php';
$path = '.';
$version = null;
$stability = 'stable';
$sha = null;
$giteaUrl = getenv('GITEA_URL') ?: 'https://git.mokoconsulting.tech';
$org = getenv('GITEA_ORG') ?: '';
$repo = getenv('GITEA_REPO') ?: '';
$outputFile = null;
$githubOutput = false;
foreach ($argv as $i => $arg) { use MokoEnterprise\CliFramework;
if ($arg === '--path' && isset($argv[$i + 1])) {
$path = $argv[$i + 1];
}
if ($arg === '--version' && isset($argv[$i + 1])) {
$version = $argv[$i + 1];
}
if ($arg === '--stability' && isset($argv[$i + 1])) {
$stability = $argv[$i + 1];
}
if ($arg === '--sha' && isset($argv[$i + 1])) {
$sha = $argv[$i + 1];
}
if ($arg === '--gitea-url' && isset($argv[$i + 1])) {
$giteaUrl = $argv[$i + 1];
}
if ($arg === '--org' && isset($argv[$i + 1])) {
$org = $argv[$i + 1];
}
if ($arg === '--repo' && isset($argv[$i + 1])) {
$repo = $argv[$i + 1];
}
if ($arg === '--output' && isset($argv[$i + 1])) {
$outputFile = $argv[$i + 1];
}
if ($arg === '--github-output') {
$githubOutput = true;
}
}
if ($version === null) { class UpdatesXmlBuildCli extends CliFramework
fwrite(STDERR, "Usage: updates_xml_build.php --path . --version XX.YY.ZZ [--stability stable] [--sha SHA]\n"); {
exit(1); protected function configure(): void
} {
$this->setDescription('Generate Joomla updates.xml from extension manifest metadata');
$this->addArgument('--path', 'Repository root (default: .)', '.');
$this->addArgument('--version', 'Version string (required)', '');
$this->addArgument('--stability', 'One of: stable, rc, beta, alpha, development (default: stable)', 'stable');
$this->addArgument('--sha', 'SHA-256 hash of the ZIP package', '');
$this->addArgument('--gitea-url', 'Gitea instance URL', '');
$this->addArgument('--org', 'Organization', '');
$this->addArgument('--repo', 'Repository name', '');
$this->addArgument('--output', 'Output file path (default: updates.xml in --path)', '');
$this->addArgument('--github-output', 'Export ext_element, ext_name, ext_type, ext_folder to $GITHUB_OUTPUT', false);
}
// Strip any existing stability suffix from version (e.g. 01.02.20-dev → 01.02.20) protected function run(): int
// so per-channel suffixes are applied cleanly without doubling {
$version = preg_replace('/-(dev|alpha|beta|rc)$/', '', $version); $path = $this->getArgument('--path');
$version = $this->getArgument('--version');
$stability = $this->getArgument('--stability');
$sha = $this->getArgument('--sha') ?: null;
$giteaUrl = $this->getArgument('--gitea-url') ?: (getenv('GITEA_URL') ?: 'https://git.mokoconsulting.tech');
$org = $this->getArgument('--org') ?: (getenv('GITEA_ORG') ?: '');
$repo = $this->getArgument('--repo') ?: (getenv('GITEA_REPO') ?: '');
$outputFile = $this->getArgument('--output') ?: null;
$githubOutput = $this->getArgument('--github-output');
$root = realpath($path) ?: $path; if ($version === '') {
$this->log('ERROR', 'Usage: updates_xml_build.php --path . --version XX.YY.ZZ [--stability stable] [--sha SHA]');
return 1;
}
// -- Read platform from .mokogitea/manifest.xml -------------------------------- // Strip suffix — stability is applied via --stability parameter
$detectedPlatform = 'joomla'; // default for backward compat $version = preg_replace('/-(dev|alpha|beta|rc)$/', '', $version);
$detectedName = $repo;
$detectedPackageType = ''; $root = realpath($path) ?: $path;
$mokoManifest = "{$root}/.mokogitea/manifest.xml";
if (file_exists($mokoManifest)) { // -- Read platform from .mokogitea/manifest.xml --------------------------------
$detectedPlatform = 'joomla';
$detectedName = $repo;
$detectedPackageType = '';
$detectedDisplayName = '';
$mokoManifest = "{$root}/.mokogitea/manifest.xml";
if (file_exists($mokoManifest)) {
$mokoXml = @simplexml_load_file($mokoManifest); $mokoXml = @simplexml_load_file($mokoManifest);
if ($mokoXml !== false) { if ($mokoXml !== false) {
$rawPlatform = (string)($mokoXml->governance->platform ?? ''); $rawPlatform = (string)($mokoXml->governance->platform ?? '');
@@ -100,25 +75,49 @@ if (file_exists($mokoManifest)) {
}; };
} }
$detectedName = (string)($mokoXml->identity->name ?? $repo); $detectedName = (string)($mokoXml->identity->name ?? $repo);
// <display-name> is the human-friendly name for releases and updates.xml
$detectedDisplayName = (string)($mokoXml->identity->{"display-name"} ?? ''); $detectedDisplayName = (string)($mokoXml->identity->{"display-name"} ?? '');
$detectedPackageType = (string)($mokoXml->build->{"package-type"} ?? ''); $detectedPackageType = (string)($mokoXml->build->{"package-type"} ?? '');
if (empty($org)) {
$manifestOrg = (string)($mokoXml->identity->org ?? '');
if ($manifestOrg !== '') {
$org = $manifestOrg;
}
}
if (empty($repo)) {
$manifestName = (string)($mokoXml->identity->name ?? '');
if ($manifestName !== '') {
$repo = $manifestName;
}
}
}
} }
}
// -- Locate Joomla manifest --------------------------------------------------- // -- Fallback: detect org/repo from git remote --------------------------------
$manifest = null; if (empty($org) || empty($repo)) {
$remoteUrl = trim(shell_exec("git -C " . escapeshellarg($root) . " remote get-url origin 2>/dev/null") ?? '');
if (preg_match('#[/:]([^/:]+)/([^/]+?)(?:\.git)?$#', $remoteUrl, $m)) {
if (empty($org)) {
$org = $m[1];
}
if (empty($repo)) {
$repo = $m[2];
}
}
}
// Priority: pkg_*.xml in src/ > any extension XML in src/ > any in root // -- Locate Joomla manifest ---------------------------------------------------
$candidates = glob("{$root}/src/pkg_*.xml") ?: []; $manifest = null;
foreach ($candidates as $f) {
$candidates = glob("{$root}/src/pkg_*.xml") ?: [];
foreach ($candidates as $f) {
if (strpos(file_get_contents($f), '<extension') !== false) { if (strpos(file_get_contents($f), '<extension') !== false) {
$manifest = $f; $manifest = $f;
break; break;
} }
} }
if ($manifest === null) { if ($manifest === null) {
$searchDirs = ["{$root}/src", "{$root}"]; $searchDirs = ["{$root}/src", "{$root}"];
foreach ($searchDirs as $dir) { foreach ($searchDirs as $dir) {
if (!is_dir($dir)) { if (!is_dir($dir)) {
@@ -131,24 +130,23 @@ if ($manifest === null) {
} }
} }
} }
} }
if ($manifest === null && $detectedPlatform === 'joomla') { if ($manifest === null && $detectedPlatform === 'joomla') {
fwrite(STDERR, "No Joomla XML manifest found in {$root}\n"); $this->log('ERROR', "No Joomla XML manifest found in {$root}");
exit(1); return 1;
} }
// -- Parse extension metadata ------------------------------------------------- // -- Parse extension metadata -------------------------------------------------
$extName = ''; $extName = '';
$extType = ''; $extType = '';
$extElement = ''; $extElement = '';
$extClient = ''; $extClient = '';
$extFolder = ''; $extFolder = '';
$targetPlatform = ''; $targetPlatform = '';
$phpMinimum = ''; $phpMinimum = '';
if ($manifest !== null) { if ($manifest !== null) {
// Joomla manifest found — parse extension metadata from it
$xml = file_get_contents($manifest); $xml = file_get_contents($manifest);
if (preg_match('/<name>([^<]+)<\/name>/', $xml, $m)) { if (preg_match('/<name>([^<]+)<\/name>/', $xml, $m)) {
@@ -194,37 +192,31 @@ if ($manifest !== null) {
if (preg_match('/<php_minimum>([^<]+)<\/php_minimum>/', $xml, $m)) { if (preg_match('/<php_minimum>([^<]+)<\/php_minimum>/', $xml, $m)) {
$phpMinimum = $m[1]; $phpMinimum = $m[1];
} }
} else { } else {
// Non-Joomla platform — derive metadata from .mokogitea/manifest.xml
$extName = $detectedName ?: ($repo ?: basename($root)); $extName = $detectedName ?: ($repo ?: basename($root));
$extElement = strtolower(str_replace([' ', '-'], '', $extName)); $extElement = strtolower(str_replace([' ', '-'], '', $extName));
$extType = $detectedPackageType ?: 'generic'; $extType = $detectedPackageType ?: 'generic';
$targetPlatform = "<targetplatform name=\"{$detectedPlatform}\" version=\".*\" />"; $targetPlatform = "<targetplatform name=\"{$detectedPlatform}\" version=\".*\" />";
} }
// Display name resolution moved to manifest.xml <display-name> (below) if (empty($extName)) {
// Fallbacks
if (empty($extName)) {
$extName = $repo ?: basename($root); $extName = $repo ?: basename($root);
} }
if (empty($extType)) { if (empty($extType)) {
$extType = 'component'; $extType = 'component';
} }
// Display name: use <display-name> from manifest.xml if available if (!empty($detectedDisplayName)) {
// This is the canonical human-friendly name — no type prefix added
if (!empty($detectedDisplayName)) {
$displayName = $detectedDisplayName; $displayName = $detectedDisplayName;
} elseif (!empty($detectedName)) { } elseif (!empty($detectedName)) {
$displayName = $detectedName; $displayName = $detectedName;
} else { } else {
$displayName = $extName; $displayName = $extName;
} }
// -- Build type prefix -------------------------------------------------------- // -- Build type prefix --------------------------------------------------------
$typePrefix = ''; $typePrefix = '';
switch ($extType) { switch ($extType) {
case 'plugin': case 'plugin':
$typePrefix = "plg_{$extFolder}_"; $typePrefix = "plg_{$extFolder}_";
break; break;
@@ -243,10 +235,10 @@ switch ($extType) {
case 'package': case 'package':
$typePrefix = 'pkg_'; $typePrefix = 'pkg_';
break; break;
} }
// -- Export to GITHUB_OUTPUT if requested ------------------------------------- // -- Export to GITHUB_OUTPUT if requested -------------------------------------
if ($githubOutput) { if ($githubOutput) {
$ghOutput = getenv('GITHUB_OUTPUT'); $ghOutput = getenv('GITHUB_OUTPUT');
$lines = [ $lines = [
"ext_element={$extElement}", "ext_element={$extElement}",
@@ -257,80 +249,153 @@ if ($githubOutput) {
]; ];
if ($ghOutput) { if ($ghOutput) {
file_put_contents($ghOutput, implode("\n", $lines) . "\n", FILE_APPEND); file_put_contents($ghOutput, implode("\n", $lines) . "\n", FILE_APPEND);
fwrite(STDERR, "Exported " . count($lines) . " fields to GITHUB_OUTPUT\n"); $this->log('INFO', "Exported " . count($lines) . " fields to GITHUB_OUTPUT");
} else { } else {
foreach ($lines as $line) { foreach ($lines as $line) {
echo "{$line}\n"; echo "{$line}\n";
} }
} }
} }
// -- Stability suffix map ----------------------------------------------------- // -- Stability suffix map -----------------------------------------------------
$stabilitySuffixMap = [ $stabilitySuffixMap = [
'stable' => '', 'stable' => '',
'rc' => '-rc', 'rc' => '-rc',
'beta' => '-beta', 'beta' => '-beta',
'alpha' => '-alpha', 'alpha' => '-alpha',
'development' => '-dev', 'development' => '-dev',
'dev' => '-dev', 'dev' => '-dev',
]; ];
// Joomla <tags><tag> values — maps to Joomla's stabilityTagToInteger() $stabilityTagMap = [
$stabilityTagMap = [
'stable' => 'stable', 'stable' => 'stable',
'rc' => 'rc', 'rc' => 'rc',
'beta' => 'beta', 'beta' => 'beta',
'alpha' => 'alpha', 'alpha' => 'alpha',
'development' => 'dev', 'development' => 'dev',
'dev' => 'dev', 'dev' => 'dev',
]; ];
// Gitea release tag names (used in download/info URLs) $releaseTagMap = [
$releaseTagMap = [
'stable' => 'stable', 'stable' => 'stable',
'rc' => 'release-candidate', 'rc' => 'release-candidate',
'beta' => 'beta', 'beta' => 'beta',
'alpha' => 'alpha', 'alpha' => 'alpha',
'development' => 'development', 'development' => 'development',
'dev' => 'development', 'dev' => 'development',
]; ];
// -- Build update entries ----------------------------------------------------- $primarySuffix = $stabilitySuffixMap[$stability] ?? '';
// For the primary entry: apply suffix if not stable $primaryVersion = $version . $primarySuffix;
$primarySuffix = $stabilitySuffixMap[$stability] ?? '';
$primaryVersion = $version . $primarySuffix;
// Build client tag — Joomla requires <client>site</client> to match updates $clientTag = '';
// to installed extensions. Without it, extension_id=0 in #__updates. if (!empty($extClient)) {
$clientTag = '';
if (!empty($extClient)) {
$clientTag = " <client>{$extClient}</client>"; $clientTag = " <client>{$extClient}</client>";
} else { } else {
$clientTag = ' <client>site</client>'; $clientTag = ' <client>site</client>';
} }
// Build folder tag $folderTag = '';
$folderTag = ''; if (!empty($extFolder) && $extType === 'plugin') {
if (!empty($extFolder) && $extType === 'plugin') {
$folderTag = " <folder>{$extFolder}</folder>"; $folderTag = " <folder>{$extFolder}</folder>";
} }
// PHP minimum tag $phpTag = '';
$phpTag = ''; if (!empty($phpMinimum)) {
if (!empty($phpMinimum)) {
$phpTag = " <php_minimum>{$phpMinimum}</php_minimum>"; $phpTag = " <php_minimum>{$phpMinimum}</php_minimum>";
} }
// SHA tag $shaTag = '';
$shaTag = ''; if (!empty($sha)) {
if (!empty($sha)) {
$shaTag = " <sha256>{$sha}</sha256>"; $shaTag = " <sha256>{$sha}</sha256>";
} }
/** // -- Write ONLY the single channel being released --------------------------------
* Build a single <update> entry for a given stability tag $entries = [];
*/ $giteaTag = $releaseTagMap[$stability] ?? $stability;
function buildEntry( $channelVersion = $version . ($stabilitySuffixMap[$stability] ?? '');
$channelDownloadUrl = "{$giteaUrl}/{$org}/{$repo}/releases/download/{$giteaTag}/{$typePrefix}{$extElement}-{$channelVersion}.zip";
$channelInfoUrl = "{$giteaUrl}/{$org}/{$repo}/releases/tag/{$giteaTag}";
$joomlaTag = $stabilityTagMap[$stability] ?? $stability;
$changelogUrl = "{$giteaUrl}/{$org}/{$repo}/raw/branch/main/CHANGELOG.md";
$entries[] = $this->buildEntry(
$joomlaTag,
$channelVersion,
$channelDownloadUrl,
$displayName,
$stability,
$extElement,
$extType,
$clientTag,
$folderTag,
$channelInfoUrl,
$targetPlatform,
$phpTag,
$shaTag,
$changelogUrl
);
// -- 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) {
$writtenTag = $joomlaTag;
$writtenAliases = [$writtenTag];
if ($writtenTag === 'dev') {
$writtenAliases[] = 'development';
}
if ($writtenTag === 'development') {
$writtenAliases[] = 'dev';
}
foreach ($existingXml->update as $existingUpdate) {
$existingTag = '';
if (isset($existingUpdate->tags->tag)) {
$existingTag = (string) $existingUpdate->tags->tag;
}
if (!empty($existingTag) && !in_array($existingTag, $writtenAliases, true)) {
$preservedEntries[] = ' ' . trim($existingUpdate->asXML());
}
}
}
}
// -- Write updates.xml --------------------------------------------------------
$year = date('Y');
$output = <<<XML
<?xml version='1.0' encoding='UTF-8'?>
<!-- Copyright (C) {$year} Moko Consulting <hello@mokoconsulting.tech>
SPDX-License-Identifier: GPL-3.0-or-later
VERSION: {$primaryVersion}
-->
<updates>
XML;
$allEntries = array_merge($preservedEntries, $entries);
$stabilityOrder = ['dev' => 0, 'development' => 0, 'alpha' => 1, 'beta' => 2, 'rc' => 3, 'stable' => 4];
usort($allEntries, function ($a, $b) use ($stabilityOrder) {
preg_match('/<tag>([^<]+)<\/tag>/', $a, $ma);
preg_match('/<tag>([^<]+)<\/tag>/', $b, $mb);
return ($stabilityOrder[$ma[1] ?? ''] ?? 99) - ($stabilityOrder[$mb[1] ?? ''] ?? 99);
});
$output .= "\n" . implode("\n", $allEntries) . "\n</updates>\n";
$dest = $outputFile ?? "{$root}/updates.xml";
file_put_contents($dest, $output);
$channelCount = count($entries);
echo "updates.xml: {$primaryVersion} ({$channelCount} channel(s), stability={$stability})\n";
echo "Output: {$dest}\n";
return 0;
}
private function buildEntry(
string $tagName, string $tagName,
string $entryVersion, string $entryVersion,
string $entryDownloadUrl, string $entryDownloadUrl,
@@ -345,14 +410,11 @@ function buildEntry(
string $phpTag, string $phpTag,
string $shaTag, string $shaTag,
string $changelogUrl = '' string $changelogUrl = ''
): string { ): string {
$lines = []; $lines = [];
$lines[] = ' <update>'; $lines[] = ' <update>';
$lines[] = " <name>{$displayName}</name>"; $lines[] = " <name>{$displayName}</name>";
$lines[] = " <description>{$displayName} {$stabilityLabel} build.</description>"; $lines[] = " <description>{$displayName} {$stabilityLabel} build.</description>";
// Element in updates.xml must match what Joomla stores in #__extensions.
// Plugins and templates are stored as bare element (no prefix).
// Other types need their prefix: mod_, com_, pkg_, lib_.
$prefixMap = [ $prefixMap = [
'package' => 'pkg_', 'package' => 'pkg_',
'module' => 'mod_', 'module' => 'mod_',
@@ -387,83 +449,8 @@ function buildEntry(
} }
$lines[] = ' </update>'; $lines[] = ' </update>';
return implode("\n", $lines); return implode("\n", $lines);
}
// -- Write ONLY the single channel being released --------------------------------
// No cascading. Each update stream is independent.
// When dev releases, only the dev entry is written/updated.
// When stable releases, only the stable entry is written/updated.
// All other channel entries are preserved exactly as-is.
$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}";
$joomlaTag = $stabilityTagMap[$stability] ?? $stability;
$changelogUrl = "{$giteaUrl}/{$org}/{$repo}/raw/branch/main/CHANGELOG.md";
$entries[] = buildEntry(
$joomlaTag,
$channelVersion,
$channelDownloadUrl,
$displayName,
$stability,
$extElement,
$extType,
$clientTag,
$folderTag,
$channelInfoUrl,
$targetPlatform,
$phpTag,
$shaTag,
$changelogUrl
);
// -- 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) {
// Only the channel we're writing gets replaced — everything else is preserved
$writtenTag = $joomlaTag;
// Also match legacy alternate (e.g. 'development' = 'dev')
$writtenAliases = [$writtenTag];
if ($writtenTag === 'dev') $writtenAliases[] = 'development';
if ($writtenTag === 'development') $writtenAliases[] = 'dev';
foreach ($existingXml->update as $existingUpdate) {
$existingTag = '';
if (isset($existingUpdate->tags->tag)) {
$existingTag = (string) $existingUpdate->tags->tag;
}
// Keep ALL entries except the one channel we're overwriting
if (!empty($existingTag) && !in_array($existingTag, $writtenAliases, true)) {
$preservedEntries[] = ' ' . trim($existingUpdate->asXML());
}
}
} }
} }
// -- Write updates.xml -------------------------------------------------------- $app = new UpdatesXmlBuildCli();
$year = date('Y'); exit($app->execute());
$output = <<<XML
<?xml version='1.0' encoding='UTF-8'?>
<!-- Copyright (C) {$year} Moko Consulting <hello@mokoconsulting.tech>
SPDX-License-Identifier: GPL-3.0-or-later
VERSION: {$primaryVersion}
-->
<updates>
XML;
$allEntries = array_merge($preservedEntries, $entries);
$output .= "\n" . implode("\n", $allEntries) . "\n</updates>\n";
$dest = $outputFile ?? "{$root}/updates.xml";
file_put_contents($dest, $output);
$channelCount = count($entries);
echo "updates.xml: {$primaryVersion} ({$channelCount} channel(s), stability={$stability})\n";
echo "Output: {$dest}\n";
exit(0);
+124 -97
View File
@@ -1,5 +1,6 @@
#!/usr/bin/env php #!/usr/bin/env php
<?php <?php
/* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech> /* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
* *
* SPDX-License-Identifier: GPL-3.0-or-later * SPDX-License-Identifier: GPL-3.0-or-later
@@ -9,70 +10,78 @@
* INGROUP: moko-platform * INGROUP: moko-platform
* REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform * REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform
* PATH: /cli/updates_xml_sync.php * PATH: /cli/updates_xml_sync.php
* VERSION: 09.21.00 * VERSION: 09.24.00
* BRIEF: Sync updates.xml to target branches via Gitea API * BRIEF: Sync updates.xml to target branches via Gitea API
* NOTE: Called by pre-release and auto-release workflows after updates.xml * NOTE: Called by pre-release and auto-release workflows after updates.xml
* is modified on the current branch. Pushes the file to other branches * is modified on the current branch. Pushes the file to other branches
* without requiring a git checkout (avoids merge conflicts). * without requiring a git checkout (avoids merge conflicts).
*
* Usage:
* php updates_xml_sync.php --path /repo --branches main,dev --current dev
* php updates_xml_sync.php --path /repo --all --current dev --version 02.01.27
*
* Options:
* --path Repository root containing updates.xml (default: .)
* --branches Comma-separated target branches to sync to (default: main,dev)
* --all Auto-discover all branches via Gitea API (overrides --branches)
* --current Current branch to skip (required)
* --version Version string for commit message (optional)
* --token Gitea API token (default: env MOKOGITEA_TOKEN)
* --gitea-url Gitea instance URL (default: env GITEA_URL or https://git.mokoconsulting.tech)
* --org Organization (default: env GITEA_ORG)
* --repo Repository name (default: env GITEA_REPO)
*/ */
declare(strict_types=1); declare(strict_types=1);
// ── Argument parsing ──────────────────────────────────────────────────── require_once __DIR__ . '/../lib/Enterprise/CliFramework.php';
$path = '.';
$branches = 'main,dev';
$current = '';
$version = '';
$token = getenv('MOKOGITEA_TOKEN') ?: '';
$giteaUrl = getenv('GITEA_URL') ?: 'https://git.mokoconsulting.tech';
$org = getenv('GITEA_ORG') ?: '';
$repo = getenv('GITEA_REPO') ?: '';
$discoverAll = false;
foreach ($argv as $i => $arg) { use MokoEnterprise\CliFramework;
if ($arg === '--path' && isset($argv[$i + 1])) $path = $argv[$i + 1];
if ($arg === '--branches' && isset($argv[$i + 1])) $branches = $argv[$i + 1];
if ($arg === '--all') $discoverAll = true;
if ($arg === '--current' && isset($argv[$i + 1])) $current = $argv[$i + 1];
if ($arg === '--version' && isset($argv[$i + 1])) $version = $argv[$i + 1];
if ($arg === '--token' && isset($argv[$i + 1])) $token = $argv[$i + 1];
if ($arg === '--gitea-url' && isset($argv[$i + 1])) $giteaUrl = $argv[$i + 1];
if ($arg === '--org' && isset($argv[$i + 1])) $org = $argv[$i + 1];
if ($arg === '--repo' && isset($argv[$i + 1])) $repo = $argv[$i + 1];
}
if ($current === '') { class UpdatesXmlSyncCli extends CliFramework
fwrite(STDERR, "Error: --current is required\n"); {
exit(1); protected function configure(): void
} {
$this->setDescription('Sync updates.xml to target branches via Gitea API');
$this->addArgument('--path', 'Repository root containing updates.xml', '.');
$this->addArgument('--branches', 'Comma-separated target branches to sync to', 'main,dev');
$this->addArgument('--all', 'Auto-discover all branches via Gitea API', false);
$this->addArgument('--current', 'Current branch to skip (required)', '');
$this->addArgument('--version', 'Version string for commit message', '');
$this->addArgument('--token', 'Gitea API token', '');
$this->addArgument('--gitea-url', 'Gitea instance URL', '');
$this->addArgument('--org', 'Organization', '');
$this->addArgument('--repo', 'Repository name', '');
}
if ($token === '') { protected function run(): int
fwrite(STDERR, "Error: --token or MOKOGITEA_TOKEN env is required\n"); {
exit(1); $path = $this->getArgument('--path');
} $branches = $this->getArgument('--branches');
$discoverAll = $this->getArgument('--all');
$current = $this->getArgument('--current');
$version = $this->getArgument('--version');
$token = $this->getArgument('--token');
$giteaUrl = $this->getArgument('--gitea-url');
$org = $this->getArgument('--org');
$repo = $this->getArgument('--repo');
if ($org === '' || $repo === '') { // Fall back to environment variables
fwrite(STDERR, "Error: --org and --repo (or GITEA_ORG/GITEA_REPO env) are required\n"); if ($token === '') {
exit(1); $token = getenv('MOKOGITEA_TOKEN') ?: '';
} }
if ($giteaUrl === '') {
$giteaUrl = getenv('GITEA_URL') ?: 'https://git.mokoconsulting.tech';
}
if ($org === '') {
$org = getenv('GITEA_ORG') ?: '';
}
if ($repo === '') {
$repo = getenv('GITEA_REPO') ?: '';
}
// Auto-discover branches if --all flag is set if ($current === '') {
if ($discoverAll) { $this->log('ERROR', '--current is required');
return 1;
}
if ($token === '') {
$this->log('ERROR', '--token or MOKOGITEA_TOKEN env is required');
return 1;
}
if ($org === '' || $repo === '') {
$this->log('ERROR', '--org and --repo (or GITEA_ORG/GITEA_REPO env) are required');
return 1;
}
// Auto-discover branches if --all flag is set
if ($discoverAll) {
$apiUrl = "{$giteaUrl}/api/v1/repos/{$org}/{$repo}/branches?limit=50"; $apiUrl = "{$giteaUrl}/api/v1/repos/{$org}/{$repo}/branches?limit=50";
$ch = curl_init($apiUrl); $ch = curl_init($apiUrl);
curl_setopt_array($ch, [ curl_setopt_array($ch, [
@@ -86,7 +95,8 @@ if ($discoverAll) {
$discovered = []; $discovered = [];
foreach ($branchList as $b) { foreach ($branchList as $b) {
$name = $b['name'] ?? ''; $name = $b['name'] ?? '';
if ($name !== '' && $name !== $current if (
$name !== '' && $name !== $current
&& !str_starts_with($name, 'version/') && !str_starts_with($name, 'version/')
&& !str_starts_with($name, 'feature/') && !str_starts_with($name, 'feature/')
&& !str_starts_with($name, 'patch/') && !str_starts_with($name, 'patch/')
@@ -98,81 +108,91 @@ if ($discoverAll) {
$branches = implode(',', $discovered); $branches = implode(',', $discovered);
echo "Discovered branches: {$branches}\n"; echo "Discovered branches: {$branches}\n";
} }
} }
$updatesFile = rtrim($path, '/') . '/updates.xml'; $updatesFile = rtrim($path, '/') . '/updates.xml';
if (!file_exists($updatesFile)) { if (!file_exists($updatesFile)) {
fwrite(STDERR, "No updates.xml found at {$updatesFile}\n"); $this->log('ERROR', "No updates.xml found at {$updatesFile}");
exit(0); return 0;
} }
$content = file_get_contents($updatesFile); $content = file_get_contents($updatesFile);
$encoded = base64_encode($content); $encoded = base64_encode($content);
$giteaUrl = rtrim($giteaUrl, '/'); $giteaUrl = rtrim($giteaUrl, '/');
$apiBase = "{$giteaUrl}/api/v1/repos/{$org}/{$repo}"; $apiBase = "{$giteaUrl}/api/v1/repos/{$org}/{$repo}";
$vLabel = $version !== '' ? " {$version}" : ''; $vLabel = $version !== '' ? " {$version}" : '';
$targets = array_filter( $targets = array_filter(
array_map('trim', explode(',', $branches)), array_map('trim', explode(',', $branches)),
fn($b) => $b !== '' && $b !== $current fn($b) => $b !== '' && $b !== $current
); );
if (empty($targets)) { if (empty($targets)) {
fwrite(STDERR, "No target branches to sync to (current: {$current})\n"); $this->log('ERROR', "No target branches to sync to (current: {$current})");
exit(0); return 0;
} }
$synced = 0; $synced = 0;
$failed = 0; $failed = 0;
foreach ($targets as $branch) { foreach ($targets as $branch) {
fwrite(STDERR, "Syncing updates.xml -> {$branch}...\n"); $this->log('INFO', "Syncing updates.xml -> {$branch}...");
$sha = getFileSha($apiBase, $token, $branch); $sha = $this->getFileSha($apiBase, $token, $branch);
if ($sha === null) { if ($sha === null) {
fwrite(STDERR, " WARNING: could not get SHA from {$branch}\n"); $this->warning("could not get SHA from {$branch}");
$failed++; $failed++;
continue; continue;
} }
$ok = putFile($apiBase, $token, $branch, $encoded, $sha, $ok = $this->putFile(
"chore: sync updates.xml{$vLabel} from {$current} [skip ci]"); $apiBase,
$token,
$branch,
$encoded,
$sha,
"chore: sync updates.xml{$vLabel} from {$current} [skip ci]"
);
if ($ok) { if ($ok) {
fwrite(STDERR, " Synced to {$branch}\n"); $this->log('INFO', "Synced to {$branch}");
$synced++; $synced++;
} else { } else {
fwrite(STDERR, " WARNING: push to {$branch} failed\n"); $this->warning("push to {$branch} failed");
$failed++; $failed++;
} }
} }
fwrite(STDERR, "Done: {$synced} synced, {$failed} failed\n"); $this->log('INFO', "Done: {$synced} synced, {$failed} failed");
exit($failed > 0 ? 1 : 0); return $failed > 0 ? 1 : 0;
}
// ═══════════════════════════════════════════════════════════════════════ private function getFileSha(string $apiBase, string $token, string $branch): ?string
{
function getFileSha(string $apiBase, string $token, string $branch): ?string $resp = $this->apiCall('GET', "{$apiBase}/contents/updates.xml?ref={$branch}", $token);
{
$resp = apiCall('GET', "{$apiBase}/contents/updates.xml?ref={$branch}", $token);
return $resp['sha'] ?? null; return $resp['sha'] ?? null;
} }
function putFile(string $apiBase, string $token, string $branch, private function putFile(
string $encoded, string $sha, string $msg): bool string $apiBase,
{ string $token,
$resp = apiCall('PUT', "{$apiBase}/contents/updates.xml", $token, [ string $branch,
string $encoded,
string $sha,
string $msg
): bool {
$resp = $this->apiCall('PUT', "{$apiBase}/contents/updates.xml", $token, [
'content' => $encoded, 'content' => $encoded,
'sha' => $sha, 'sha' => $sha,
'message' => $msg, 'message' => $msg,
'branch' => $branch, 'branch' => $branch,
]); ]);
return $resp !== null; return $resp !== null;
} }
function apiCall(string $method, string $url, string $token, ?array $data = null): ?array private function apiCall(string $method, string $url, string $token, ?array $data = null): ?array
{ {
$headers = [ $headers = [
"Authorization: token {$token}", "Authorization: token {$token}",
'Content-Type: application/json', 'Content-Type: application/json',
@@ -187,8 +207,11 @@ function apiCall(string $method, string $url, string $token, ?array $data = null
curl_setopt($ch, CURLOPT_FOLLOWLOCATION, true); curl_setopt($ch, CURLOPT_FOLLOWLOCATION, true);
if ($data !== null) { if ($data !== null) {
curl_setopt($ch, CURLOPT_POSTFIELDS, curl_setopt(
json_encode($data, JSON_UNESCAPED_SLASHES)); $ch,
CURLOPT_POSTFIELDS,
json_encode($data, JSON_UNESCAPED_SLASHES)
);
} }
$body = curl_exec($ch); $body = curl_exec($ch);
@@ -198,4 +221,8 @@ function apiCall(string $method, string $url, string $token, ?array $data = null
return ($code >= 200 && $code < 300) return ($code >= 200 && $code < 300)
? (json_decode($body, true) ?: []) ? (json_decode($body, true) ?: [])
: null; : null;
}
} }
$app = new UpdatesXmlSyncCli();
exit($app->execute());
+126 -92
View File
@@ -1,5 +1,6 @@
#!/usr/bin/env php #!/usr/bin/env php
<?php <?php
/* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech> /* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
* *
* SPDX-License-Identifier: GPL-3.0-or-later * SPDX-License-Identifier: GPL-3.0-or-later
@@ -9,64 +10,66 @@
* INGROUP: moko-platform * INGROUP: moko-platform
* REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform * REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform
* PATH: /cli/version_auto_bump.php * PATH: /cli/version_auto_bump.php
* VERSION: 09.21.00 * VERSION: 09.24.00
* BRIEF: Auto patch-bump, set stability suffix, and commit — single CLI replacing inline workflow bash * BRIEF: Auto patch-bump, set stability suffix, and commit — single CLI replacing inline workflow bash
*
* Usage:
* php version_auto_bump.php --path . --branch dev
* php version_auto_bump.php --path . --branch feature/my-feature --token TOKEN --repo-url URL
* php version_auto_bump.php --path . --branch alpha --dry-run
*/ */
declare(strict_types=1); declare(strict_types=1);
$path = '.'; require_once __DIR__ . '/../lib/Enterprise/CliFramework.php';
$branch = null;
$token = '';
$repoUrl = '';
$dryRun = false;
$watchPath = ''; use MokoEnterprise\CliFramework;
foreach ($argv as $i => $arg) { class VersionAutoBumpCli extends CliFramework
if ($arg === '--path' && isset($argv[$i + 1])) $path = $argv[$i + 1]; {
if ($arg === '--branch' && isset($argv[$i + 1])) $branch = $argv[$i + 1]; protected function configure(): void
if ($arg === '--token' && isset($argv[$i + 1])) $token = $argv[$i + 1]; {
if ($arg === '--repo-url' && isset($argv[$i + 1])) $repoUrl = $argv[$i + 1]; $this->setDescription('Auto patch-bump, set stability suffix, and commit');
if ($arg === '--watch-path' && isset($argv[$i + 1])) $watchPath = $argv[$i + 1]; $this->addArgument('--path', 'Repository root path', '.');
if ($arg === '--dry-run') $dryRun = true; $this->addArgument('--branch', 'Git branch name', '');
} $this->addArgument('--token', 'API token for push', '');
$this->addArgument('--repo-url', 'Repository URL for git remote', '');
$this->addArgument('--watch-path', 'Path to watch for changes', '');
}
// Auto-detect branch from git or CI env protected function run(): int
if ($branch === null) { {
$path = $this->getArgument('--path');
$branch = $this->getArgument('--branch');
$token = $this->getArgument('--token');
$repoUrl = $this->getArgument('--repo-url');
$watchPath = $this->getArgument('--watch-path');
// Auto-detect branch from git or CI env
if ($branch === '') {
$branch = getenv('GITHUB_REF_NAME') ?: trim((string) @shell_exec('git rev-parse --abbrev-ref HEAD 2>/dev/null')); $branch = getenv('GITHUB_REF_NAME') ?: trim((string) @shell_exec('git rev-parse --abbrev-ref HEAD 2>/dev/null'));
if (empty($branch) || $branch === 'HEAD') { if (empty($branch) || $branch === 'HEAD') {
fwrite(STDERR, "Cannot detect branch — pass --branch\n"); $this->log('ERROR', 'Cannot detect branch — pass --branch');
exit(1); return 1;
}
} }
}
// Map branch to stability suffix // Map branch to stability suffix
$stabilityMap = [ $stabilityMap = [
'dev' => 'dev', 'dev' => 'dev',
'alpha' => 'alpha', 'alpha' => 'alpha',
'beta' => 'beta', 'beta' => 'beta',
'rc' => 'rc', 'rc' => 'rc',
]; ];
if (array_key_exists($branch, $stabilityMap)) { if (array_key_exists($branch, $stabilityMap)) {
$stability = $stabilityMap[$branch]; $stability = $stabilityMap[$branch];
} elseif (str_starts_with($branch, 'feature/') || str_starts_with($branch, 'patch/')) { } elseif (str_starts_with($branch, 'feature/') || str_starts_with($branch, 'patch/')) {
$stability = 'dev'; $stability = 'dev';
} else { } else {
$stability = 'dev'; $stability = 'dev';
} }
$cli = __DIR__; $cli = __DIR__;
$php = '"' . PHP_BINARY . '"'; $php = '"' . PHP_BINARY . '"';
// Auto-detect watch path from manifest.xml if not provided // Auto-detect watch path from manifest.xml if not provided
if (empty($watchPath)) { if (empty($watchPath)) {
$manifestFile = realpath($path) . '/.mokogitea/manifest.xml'; $manifestFile = realpath($path) . '/.mokogitea/manifest.xml';
if (file_exists($manifestFile)) { if (file_exists($manifestFile)) {
$xml = @simplexml_load_file($manifestFile); $xml = @simplexml_load_file($manifestFile);
@@ -74,14 +77,17 @@ if (empty($watchPath)) {
$watchPath = (string) $xml->build->{'entry-point'}; $watchPath = (string) $xml->build->{'entry-point'};
} }
} }
} }
// Check if code files actually changed (skip bump for docs/config-only changes) // Check if code files actually changed (skip bump for docs/config-only changes)
$shouldBump = true; $shouldBump = true;
if (!empty($watchPath)) { if (!empty($watchPath)) {
$root = realpath($path) ?: $path; $root = realpath($path) ?: $path;
$cdCmd = PHP_OS_FAMILY === 'Windows' ? "cd /d " : "cd ";
$diffOutput = trim((string) @shell_exec( $diffOutput = trim((string) @shell_exec(
(PHP_OS_FAMILY === 'Windows' ? "cd /d " : "cd ") . escapeshellarg($root) . " && git diff --name-only HEAD~1 HEAD -- " . escapeshellarg($watchPath) . " 2>/dev/null" $cdCmd . escapeshellarg($root)
. " && git diff --name-only HEAD~1 HEAD -- "
. escapeshellarg($watchPath) . " 2>/dev/null"
)); ));
if (empty($diffOutput)) { if (empty($diffOutput)) {
echo "No changes in {$watchPath} — skipping version bump\n"; echo "No changes in {$watchPath} — skipping version bump\n";
@@ -89,85 +95,113 @@ if (!empty($watchPath)) {
} else { } else {
echo "Changes detected in {$watchPath}:\n{$diffOutput}\n"; echo "Changes detected in {$watchPath}:\n{$diffOutput}\n";
} }
} }
if (!$shouldBump) { if (!$shouldBump) {
echo "No code changes — nothing to do\n"; echo "No code changes — nothing to do\n";
exit(0); return 0;
} }
// Step 1: Patch bump // Step 1: Patch bump
$bumpOutput = []; $bumpOutput = [];
exec("{$php} {$cli}/version_bump.php --path " . escapeshellarg($path) . " 2>&1", $bumpOutput, $bumpRc); exec("{$php} {$cli}/version_bump.php --path " . escapeshellarg($path) . " 2>&1", $bumpOutput, $bumpRc);
foreach ($bumpOutput as $line) { foreach ($bumpOutput as $line) {
echo "{$line}\n"; echo "{$line}\n";
} }
// Step 2: Read version // Step 2: Read version
$versionOutput = []; $versionOutput = [];
exec("{$php} {$cli}/version_read.php --path " . escapeshellarg($path) . " 2>&1", $versionOutput, $versionRc); exec("{$php} {$cli}/version_read.php --path " . escapeshellarg($path) . " 2>&1", $versionOutput, $versionRc);
$version = trim($versionOutput[0] ?? ''); $version = trim($versionOutput[0] ?? '');
if (empty($version)) { if (empty($version)) {
echo "No version found — skipping\n"; echo "No version found — skipping\n";
exit(0); return 0;
} }
echo "Version: {$version} | Branch: {$branch} | Stability: {$stability}\n"; echo "Version: {$version} | Branch: {$branch} | Stability: {$stability}\n";
// Step 3: Set platform version with stability suffix // Step 3: Set platform version with stability suffix
exec("{$php} {$cli}/version_set_platform.php --path " . escapeshellarg($path) $setPlatOutput = [];
exec("{$php} {$cli}/version_set_platform.php --path " . escapeshellarg($path)
. " --version " . escapeshellarg($version) . " --version " . escapeshellarg($version)
. " --branch " . escapeshellarg($branch) . " --branch " . escapeshellarg($branch)
. " --stability " . escapeshellarg($stability) . " 2>&1", $setPlatOutput); . " --stability " . escapeshellarg($stability) . " 2>&1", $setPlatOutput);
foreach ($setPlatOutput as $line) { foreach ($setPlatOutput as $line) {
echo "{$line}\n"; echo "{$line}\n";
} }
// Step 4: Version consistency check and fix // Step 4: Version consistency check and fix
exec("{$php} {$cli}/version_check.php --path " . escapeshellarg($path) . " --fix 2>&1", $checkOutput); exec("{$php} {$cli}/version_check.php --path " . escapeshellarg($path) . " --fix 2>&1", $checkOutput);
// Re-read version (now includes suffix from version_set_platform) // Re-read version (now includes suffix from version_set_platform)
$suffixMap = [ $suffixMap = [
'dev' => '-dev', 'dev' => '-dev',
'alpha' => '-alpha', 'alpha' => '-alpha',
'beta' => '-beta', 'beta' => '-beta',
'rc' => '-rc', 'rc' => '-rc',
]; ];
$displayVersion = preg_replace('/(-(dev|alpha|beta|rc))+$/', '', $version) . ($suffixMap[$stability] ?? ''); $displayVersion = preg_replace('/(-(dev|alpha|beta|rc))+$/', '', $version) . ($suffixMap[$stability] ?? '');
if ($dryRun) { if ($this->dryRun) {
echo "[DRY-RUN] Would commit and push {$displayVersion} to {$branch}\n"; echo "[DRY-RUN] Would commit and push {$displayVersion} to {$branch}\n";
exit(0); return 0;
} }
// Step 5: Git commit and push // Step 5: Git commit and push
$root = realpath($path) ?: $path; $root = realpath($path) ?: $path;
// Check if anything changed // Check if anything changed
$diffStatus = trim((string) @shell_exec((PHP_OS_FAMILY === 'Windows' ? "cd /d " : "cd ") . escapeshellarg($root) . " && git diff --quiet && git diff --cached --quiet 2>&1 && echo clean || echo dirty")); $cdPrefix = PHP_OS_FAMILY === 'Windows' ? "cd /d " : "cd ";
if ($diffStatus === 'clean') { $diffStatus = trim((string) @shell_exec(
$cdPrefix . escapeshellarg($root)
. " && git diff --quiet && git diff --cached --quiet"
. " 2>&1 && echo clean || echo dirty"
));
if ($diffStatus === 'clean') {
echo "No version changes to commit\n"; echo "No version changes to commit\n";
exit(0); return 0;
} }
// Configure git // Configure git
@shell_exec((PHP_OS_FAMILY === 'Windows' ? "cd /d " : "cd ") . escapeshellarg($root) . " && git config --local user.email \"gitea-actions[bot]@mokoconsulting.tech\""); $cd = PHP_OS_FAMILY === 'Windows' ? "cd /d " : "cd ";
@shell_exec((PHP_OS_FAMILY === 'Windows' ? "cd /d " : "cd ") . escapeshellarg($root) . " && git config --local user.name \"gitea-actions[bot]\""); $cdRoot = $cd . escapeshellarg($root);
@shell_exec(
$cdRoot . " && git config --local user.email"
. " \"gitea-actions[bot]@mokoconsulting.tech\""
);
@shell_exec(
$cdRoot . " && git config --local user.name"
. " \"gitea-actions[bot]\""
);
if (!empty($repoUrl)) { if (!empty($repoUrl)) {
@shell_exec((PHP_OS_FAMILY === 'Windows' ? "cd /d " : "cd ") . escapeshellarg($root) . " && git remote set-url origin " . escapeshellarg($repoUrl)); @shell_exec(
} $cdRoot . " && git remote set-url origin "
. escapeshellarg($repoUrl)
);
}
@shell_exec((PHP_OS_FAMILY === 'Windows' ? "cd /d " : "cd ") . escapeshellarg($root) . " && git add -A"); @shell_exec($cdRoot . " && git add -A");
$commitMsg = $shouldBump $commitMsg = $shouldBump
? "chore(version): auto-bump patch {$displayVersion} [skip ci]" ? "chore(version): auto-bump patch {$displayVersion} [skip ci]"
: "chore(version): set {$stability} suffix {$displayVersion} [skip ci]"; : "chore(version): set {$stability} suffix {$displayVersion} [skip ci]";
@shell_exec((PHP_OS_FAMILY === 'Windows' ? "cd /d " : "cd ") . escapeshellarg($root) . " && git commit -m " . escapeshellarg($commitMsg) @shell_exec(
. " --author=\"gitea-actions[bot] <gitea-actions[bot]@mokoconsulting.tech>\""); $cdRoot . " && git commit -m " . escapeshellarg($commitMsg)
. " --author=\"gitea-actions[bot]"
. " <gitea-actions[bot]@mokoconsulting.tech>\""
);
$pushResult = @shell_exec((PHP_OS_FAMILY === 'Windows' ? "cd /d " : "cd ") . escapeshellarg($root) . " && git push origin " . escapeshellarg($branch) . " 2>&1"); $pushResult = @shell_exec(
echo $pushResult ?? ''; $cdRoot . " && git push origin "
. escapeshellarg($branch) . " 2>&1"
);
echo $pushResult ?? '';
echo "Bumped to {$displayVersion}\n"; echo "Bumped to {$displayVersion}\n";
exit(0); return 0;
}
}
$app = new VersionAutoBumpCli();
exit($app->execute());
+132 -159
View File
@@ -1,5 +1,6 @@
#!/usr/bin/env php #!/usr/bin/env php
<?php <?php
/* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech> /* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
* *
* SPDX-License-Identifier: GPL-3.0-or-later * SPDX-License-Identifier: GPL-3.0-or-later
@@ -9,118 +10,122 @@
* INGROUP: moko-platform * INGROUP: moko-platform
* REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform * REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform
* PATH: /cli/version_bump.php * PATH: /cli/version_bump.php
* BRIEF: Auto-increment version manifest.xml is canonical, cascades to all XML and MD files * BRIEF: Auto-increment version -- manifest.xml is canonical, cascades to all XML and MD files
*/ */
declare(strict_types=1); declare(strict_types=1);
$path = '.'; require_once __DIR__ . '/../lib/Enterprise/CliFramework.php';
$type = 'patch'; // patch | minor | major
foreach ($argv as $i => $arg) {
if ($arg === '--path' && isset($argv[$i + 1])) $path = $argv[$i + 1];
if ($arg === '--minor') $type = 'minor';
if ($arg === '--major') $type = 'major';
}
$root = realpath($path) ?: $path; use MokoEnterprise\CliFramework;
// -- 1. Read version from .mokogitea/manifest.xml (canonical) -- class VersionBumpCli extends CliFramework
$mokoVersion = null; {
$mokoSuffix = ''; protected function configure(): void
$mokoManifest = "{$root}/.mokogitea/manifest.xml"; {
$mokoContent = ''; $this->setDescription('Auto-increment version -- manifest.xml is canonical, cascades to all XML and MD files');
if (file_exists($mokoManifest)) { $this->addArgument('--path', 'Repository root', '.');
$mokoContent = file_get_contents($mokoManifest); $this->addArgument('--minor', 'Bump minor version', false);
if (preg_match('#<version>(\d{2}\.\d{2}\.\d{2})(?:-((?:(?:dev|alpha|beta|rc)-?)+))?</version>#', $mokoContent, $m)) { $this->addArgument('--major', 'Bump major version', false);
$mokoVersion = $m[1];
$mokoSuffix = isset($m[2]) ? $m[2] : '';
} }
}
// -- 2. Fallback: README.md -- protected function run(): int
$readmeVersion = null; {
$readme = "{$root}/README.md"; $path = $this->getArgument('--path');
$readmeContent = ''; $type = 'patch';
if (file_exists($readme)) { if ($this->getArgument('--minor')) {
$type = 'minor';
}
if ($this->getArgument('--major')) {
$type = 'major';
}
$root = realpath($path) ?: $path;
$mokoVersion = null;
$existingSuffix = '';
$mokoManifest = "{$root}/.mokogitea/manifest.xml";
$mokoContent = '';
if (file_exists($mokoManifest)) {
$mokoContent = file_get_contents($mokoManifest);
if (preg_match('#<version>(\d{2}\.\d{2}\.\d{2})((?:-(?:dev|alpha|beta|rc))+)?</version>#', $mokoContent, $m)) {
$mokoVersion = $m[1];
$existingSuffix = $m[2] ?? '';
}
}
$readmeVersion = null;
$readme = "{$root}/README.md";
$readmeContent = '';
if (file_exists($readme)) {
$readmeContent = file_get_contents($readme); $readmeContent = file_get_contents($readme);
if (preg_match('/VERSION:\s*(\d{2}\.\d{2}\.\d{2})/m', $readmeContent, $m)) { if (preg_match('/VERSION:\s*(\d{2}\.\d{2}\.\d{2})/m', $readmeContent, $m)) {
$readmeVersion = $m[1]; $readmeVersion = $m[1];
} }
} }
$manifestVersion = null;
// -- 3. Fallback: Joomla manifest XML -- $manifestFiles = array_merge(
$manifestVersion = null;
$manifestSuffix = '';
$manifestFiles = array_merge(
glob("{$root}/src/pkg_*.xml") ?: [], glob("{$root}/src/pkg_*.xml") ?: [],
glob("{$root}/src/*.xml") ?: [], glob("{$root}/src/*.xml") ?: [],
glob("{$root}/src/packages/*/mokowaas.xml") ?: [], glob("{$root}/src/packages/*/mokowaas.xml") ?: [],
glob("{$root}/src/packages/*/*.xml") ?: [], glob("{$root}/src/packages/*/*.xml") ?: [],
glob("{$root}/*.xml") ?: [] glob("{$root}/*.xml") ?: []
); );
foreach ($manifestFiles as $xmlFile) {
foreach ($manifestFiles as $xmlFile) {
$xmlContent = file_get_contents($xmlFile); $xmlContent = file_get_contents($xmlFile);
if (strpos($xmlContent, '<extension') === false && strpos($xmlContent, '<version>') === false) { if (strpos($xmlContent, '<extension') === false && strpos($xmlContent, '<version>') === false) {
continue; continue;
} } if (preg_match('#<version>(\d{2}\.\d{2}\.\d{2})((?:-(?:dev|alpha|beta|rc))+)?</version>#', $xmlContent, $xm)) {
if (preg_match('#<version>(\d{2}\.\d{2}\.\d{2})((?:-(?:dev|alpha|beta|rc))+)?</version>#', $xmlContent, $xm)) {
$candidate = $xm[1]; $candidate = $xm[1];
if ($manifestVersion === null || version_compare($candidate, $manifestVersion, '>')) { if ($manifestVersion === null || version_compare($candidate, $manifestVersion, '>')) {
$manifestVersion = $candidate; $manifestVersion = $candidate;
// Preserve the suffix from the manifest (e.g. dev, rc) — strip leading dash
$manifestSuffix = ltrim($xm[2] ?? '', '-');
} }
} }
} }
$baseVersion = null;
// -- Use the highest version as base -- $candidates = array_filter([$mokoVersion, $readmeVersion, $manifestVersion]);
$baseVersion = null; foreach ($candidates as $v) {
$candidates = array_filter([$mokoVersion, $readmeVersion, $manifestVersion]);
foreach ($candidates as $v) {
if ($baseVersion === null || version_compare($v, $baseVersion, '>')) { if ($baseVersion === null || version_compare($v, $baseVersion, '>')) {
$baseVersion = $v; $baseVersion = $v;
} }
} }
if ($baseVersion === null) {
if ($baseVersion === null) { $this->log('ERROR', "No version found in manifest.xml, README.md, or Joomla XML");
fwrite(STDERR, "No version found in manifest.xml, README.md, or Joomla XML\n"); return 1;
exit(1); }
} if (!preg_match('/^(\d{2})\.(\d{2})\.(\d{2})$/', $baseVersion, $parts)) {
$this->log('ERROR', "Invalid version format: {$baseVersion}");
// -- Parse and bump -- return 1;
if (!preg_match('/^(\d{2})\.(\d{2})\.(\d{2})$/', $baseVersion, $parts)) { }
fwrite(STDERR, "Invalid version format: {$baseVersion}\n"); $major = (int)$parts[1];
exit(1); $minor = (int)$parts[2];
} $patch = (int)$parts[3];
$old = sprintf('%02d.%02d.%02d', $major, $minor, $patch);
$major = (int)$parts[1]; switch ($type) {
$minor = (int)$parts[2]; case 'major':
$patch = (int)$parts[3]; $major++;
$old = sprintf('%02d.%02d.%02d', $major, $minor, $patch); $minor = 0;
$patch = 0;
switch ($type) { break;
case 'major': $major++; $minor = 0; $patch = 0; break; case 'minor':
case 'minor': $minor++; $patch = 0; break; $minor++;
$patch = 0;
break;
default: default:
$patch++; $patch++;
if ($patch > 99) { $minor++; $patch = 0; } if ($patch > 99) {
if ($minor > 99) { $major++; $minor = 0; } $minor++;
$patch = 0;
} if ($minor > 99) {
$major++;
$minor = 0;
}
break; break;
} }
$newBase = sprintf('%02d.%02d.%02d', $major, $minor, $patch);
$new = sprintf('%02d.%02d.%02d', $major, $minor, $patch); $newFull = $newBase . $existingSuffix;
if (file_exists($mokoManifest) && !empty($mokoContent)) {
// -- Write clean version (no suffix) ------------------------------------------ $pattern = '#<version>\d{2}\.\d{2}\.\d{2}'
// Suffixes (-dev, -alpha, -beta, -rc) are managed by version_set_platform.php . '(?:(?:-(?:dev|alpha|beta|rc))+)?</version>#';
// called from CI workflows with the appropriate --stability flag. version_bump
// always writes a clean base version so the suffix layer stays consistent.
$newFull = $new;
// -- Update .mokogitea/manifest.xml (canonical — preserves suffix) --
if (file_exists($mokoManifest) && !empty($mokoContent)) {
$updated = preg_replace( $updated = preg_replace(
'#<version>\d{2}\.\d{2}\.\d{2}(?:(?:-(?:dev|alpha|beta|rc))+)?</version>#', $pattern,
"<version>{$newFull}</version>", "<version>{$newFull}</version>",
$mokoContent, $mokoContent,
1 1
@@ -128,39 +133,24 @@ if (file_exists($mokoManifest) && !empty($mokoContent)) {
if ($updated !== null) { if ($updated !== null) {
file_put_contents($mokoManifest, $updated); file_put_contents($mokoManifest, $updated);
} }
} }
if (file_exists($readme) && !empty($readmeContent)) {
// -- Update README.md -- $updated = preg_replace('/(VERSION:\s*)\d{2}\.\d{2}\.\d{2}(?:(?:-(?:dev|alpha|beta|rc))+)?/m', '${1}' . $newBase, $readmeContent, 1);
if (file_exists($readme) && !empty($readmeContent)) {
$updated = preg_replace(
'/(VERSION:\s*)\d{2}\.\d{2}\.\d{2}(?:(?:-(?:dev|alpha|beta|rc))+)?/m',
'${1}' . $newFull,
$readmeContent,
1
);
if ($updated !== null) { if ($updated !== null) {
file_put_contents($readme, $updated); file_put_contents($readme, $updated);
} }
} }
$updatedFiles = [];
// -- Cascade to ALL Joomla extension XML manifests -- foreach (["{$root}/src/pkg_*.xml", "{$root}/src/*.xml", "{$root}/src/packages/*/*.xml", "{$root}/*.xml"] as $pattern) {
$xmlPatterns = [
"{$root}/src/pkg_*.xml",
"{$root}/src/*.xml",
"{$root}/src/packages/*/*.xml",
"{$root}/*.xml",
];
$updatedFiles = [];
foreach ($xmlPatterns as $pattern) {
foreach (glob($pattern) ?: [] as $xmlFile) { foreach (glob($pattern) ?: [] as $xmlFile) {
$content = file_get_contents($xmlFile); $content = file_get_contents($xmlFile);
// Only update files that have an <extension> tag (Joomla manifests)
if (strpos($content, '<extension') === false) { if (strpos($content, '<extension') === false) {
continue; continue;
} }
$xmlPattern = '#<version>\d{2}\.\d{2}\.\d{2}'
. '(?:(?:-(?:dev|alpha|beta|rc))+)?</version>#';
$newContent = preg_replace( $newContent = preg_replace(
'#<version>\d{2}\.\d{2}\.\d{2}(?:(?:-(?:dev|alpha|beta|rc))+)?</version>#', $xmlPattern,
"<version>{$newFull}</version>", "<version>{$newFull}</version>",
$content $content
); );
@@ -169,18 +159,17 @@ foreach ($xmlPatterns as $pattern) {
$updatedFiles[] = substr($xmlFile, strlen($root) + 1); $updatedFiles[] = substr($xmlFile, strlen($root) + 1);
} }
} }
} }
if (!empty($updatedFiles)) {
if (!empty($updatedFiles)) {
fwrite(STDERR, "Updated " . count($updatedFiles) . " Joomla manifest(s): " . implode(', ', $updatedFiles) . "\n"); fwrite(STDERR, "Updated " . count($updatedFiles) . " Joomla manifest(s): " . implode(', ', $updatedFiles) . "\n");
} }
$packageJsonFile = "{$root}/package.json";
// -- Update package.json (Node.js / MCP) -- if (file_exists($packageJsonFile)) {
$packageJsonFile = "{$root}/package.json";
if (file_exists($packageJsonFile)) {
$pkgContent = file_get_contents($packageJsonFile); $pkgContent = file_get_contents($packageJsonFile);
$pkgPattern = '/("version"\s*:\s*")\d{2}\.\d{2}\.\d{2}'
. '(?:(?:-(?:dev|alpha|beta|rc))+)?(")/m';
$updatedPkg = preg_replace( $updatedPkg = preg_replace(
'/("version"\s*:\s*")\d{2}\.\d{2}\.\d{2}(?:(?:-(?:dev|alpha|beta|rc))+)?(")/m', $pkgPattern,
'${1}' . $newFull . '${2}', '${1}' . $newFull . '${2}',
$pkgContent $pkgContent
); );
@@ -188,14 +177,14 @@ if (file_exists($packageJsonFile)) {
file_put_contents($packageJsonFile, $updatedPkg); file_put_contents($packageJsonFile, $updatedPkg);
fwrite(STDERR, "Updated package.json\n"); fwrite(STDERR, "Updated package.json\n");
} }
} }
$pyprojectFile = "{$root}/pyproject.toml";
// -- Update pyproject.toml (Python) -- if (file_exists($pyprojectFile)) {
$pyprojectFile = "{$root}/pyproject.toml";
if (file_exists($pyprojectFile)) {
$pyContent = file_get_contents($pyprojectFile); $pyContent = file_get_contents($pyprojectFile);
$pyPattern = '/^(version\s*=\s*")\d{2}\.\d{2}\.\d{2}'
. '(?:(?:-(?:dev|alpha|beta|rc))+)?(")/m';
$updatedPy = preg_replace( $updatedPy = preg_replace(
'/^(version\s*=\s*")\d{2}\.\d{2}\.\d{2}(?:(?:-(?:dev|alpha|beta|rc))+)?(")/m', $pyPattern,
'${1}' . $newFull . '${2}', '${1}' . $newFull . '${2}',
$pyContent $pyContent
); );
@@ -203,51 +192,36 @@ if (file_exists($pyprojectFile)) {
file_put_contents($pyprojectFile, $updatedPy); file_put_contents($pyprojectFile, $updatedPy);
fwrite(STDERR, "Updated pyproject.toml\n"); fwrite(STDERR, "Updated pyproject.toml\n");
} }
} }
$changelogFile = "{$root}/CHANGELOG.md";
// -- Update CHANGELOG.md -- if (file_exists($changelogFile)) {
$changelogFile = "{$root}/CHANGELOG.md";
if (file_exists($changelogFile)) {
$clContent = file_get_contents($changelogFile); $clContent = file_get_contents($changelogFile);
$updatedCl = preg_replace( $updatedCl = preg_replace('/(VERSION:\s*)\d{2}\.\d{2}\.\d{2}(?:(?:-(?:dev|alpha|beta|rc))+)?/m', '${1}' . $newBase, $clContent);
'/(VERSION:\s*)\d{2}\.\d{2}\.\d{2}(?:(?:-(?:dev|alpha|beta|rc))+)?/m',
'${1}' . $newFull,
$clContent
);
if ($updatedCl !== null && $updatedCl !== $clContent) { if ($updatedCl !== null && $updatedCl !== $clContent) {
file_put_contents($changelogFile, $updatedCl); file_put_contents($changelogFile, $updatedCl);
fwrite(STDERR, "Updated CHANGELOG.md\n"); fwrite(STDERR, "Updated CHANGELOG.md\n");
} }
} }
$scanExtensions = ['php', 'yml', 'yaml', 'md', 'txt', 'xml', 'sh', 'toml', 'ini', 'css', 'js'];
// -- Generic VERSION: pattern scan across all text files -- $excludeDirs = ['.git', 'vendor', 'node_modules', 'build', 'dist', '.claude'];
$scanExtensions = ['php', 'yml', 'yaml', 'md', 'txt', 'xml', 'sh', 'toml', 'ini', 'css', 'js']; $versionPattern = '/(VERSION:\s*)\d{2}\.\d{2}\.\d{2}/m';
$excludeDirs = ['.git', 'vendor', 'node_modules', 'build', 'dist', '.claude']; $directory = new RecursiveDirectoryIterator($root, RecursiveDirectoryIterator::SKIP_DOTS);
$versionPattern = '/(VERSION:\s*)\d{2}\.\d{2}\.\d{2}(?:(?:-(?:dev|alpha|beta|rc))+)?/m'; $filter = new RecursiveCallbackFilterIterator($directory, function ($current, $key, $iterator) use ($excludeDirs) {
$directory = new RecursiveDirectoryIterator($root, RecursiveDirectoryIterator::SKIP_DOTS);
$filter = new RecursiveCallbackFilterIterator($directory, function ($current, $key, $iterator) use ($excludeDirs) {
if ($current->isDir() && in_array($current->getFilename(), $excludeDirs, true)) { if ($current->isDir() && in_array($current->getFilename(), $excludeDirs, true)) {
return false; return false;
} } return true;
return true; });
}); $iterator = new RecursiveIteratorIterator($filter);
$iterator = new RecursiveIteratorIterator($filter); $genericUpdated = [];
foreach ($iterator as $fileInfo) {
$genericUpdated = [];
foreach ($iterator as $fileInfo) {
if ($fileInfo->isDir()) { if ($fileInfo->isDir()) {
continue; continue;
} }
$ext = strtolower($fileInfo->getExtension()); $ext = strtolower($fileInfo->getExtension());
if (!in_array($ext, $scanExtensions, true)) { if (!in_array($ext, $scanExtensions, true)) {
continue; continue;
} }
$filePath = $fileInfo->getPathname(); $filePath = $fileInfo->getPathname();
// Skip files already handled above
$relPath = str_replace([$root . '/', $root . '\\'], '', $filePath); $relPath = str_replace([$root . '/', $root . '\\'], '', $filePath);
if (in_array($relPath, ['README.md', 'CHANGELOG.md', 'package.json', 'pyproject.toml'], true)) { if (in_array($relPath, ['README.md', 'CHANGELOG.md', 'package.json', 'pyproject.toml'], true)) {
continue; continue;
@@ -258,27 +232,26 @@ foreach ($iterator as $fileInfo) {
if (strpos($relPath, '.mokogitea/manifest.xml') !== false) { if (strpos($relPath, '.mokogitea/manifest.xml') !== false) {
continue; continue;
} }
$content = @file_get_contents($filePath); $content = @file_get_contents($filePath);
if ($content === false) { if ($content === false) {
continue; continue;
} }
// Skip synced files — they have their own version managed by their source repo
if (preg_match('/^#\s*REPO:\s*https?:\/\//m', $content)) { if (preg_match('/^#\s*REPO:\s*https?:\/\//m', $content)) {
continue; continue;
} }
$updated = preg_replace($versionPattern, '${1}' . $newBase, $content);
$updated = preg_replace($versionPattern, '${1}' . $newFull, $content);
if ($updated !== null && $updated !== $content) { if ($updated !== null && $updated !== $content) {
file_put_contents($filePath, $updated); file_put_contents($filePath, $updated);
$genericUpdated[] = $relPath; $genericUpdated[] = $relPath;
} }
} }
if (!empty($genericUpdated)) {
if (!empty($genericUpdated)) {
fwrite(STDERR, "Updated VERSION: in " . count($genericUpdated) . " file(s): " . implode(', ', $genericUpdated) . "\n"); fwrite(STDERR, "Updated VERSION: in " . count($genericUpdated) . " file(s): " . implode(', ', $genericUpdated) . "\n");
}
echo "{$old} -> {$newFull}\n";
return 0;
}
} }
echo "{$old} -> {$newFull}\n"; $app = new VersionBumpCli();
exit(0); exit($app->execute());
+125 -157
View File
@@ -1,5 +1,6 @@
#!/usr/bin/env php #!/usr/bin/env php
<?php <?php
/* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech> /* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
* *
* SPDX-License-Identifier: GPL-3.0-or-later * SPDX-License-Identifier: GPL-3.0-or-later
@@ -10,67 +11,59 @@
* REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform * REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform
* PATH: /cli/version_bump_remote.php * PATH: /cli/version_bump_remote.php
* BRIEF: Bump version in manifest XML and CHANGELOG.md on a remote branch via Gitea API * 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 MOKOGITEA_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); declare(strict_types=1);
$path = '.'; require_once __DIR__ . '/../lib/Enterprise/CliFramework.php';
$branch = null;
$bumpType = 'minor';
$token = null;
$apiBase = null;
$noChangelog = false;
$repo = null;
$giteaUrl = null;
foreach ($argv as $i => $arg) { use MokoEnterprise\CliFramework;
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('MOKOGITEA_TOKEN') ?: getenv('GITEA_TOKEN') ?: null; class VersionBumpRemoteCli extends CliFramework
if ($giteaUrl === null) $giteaUrl = getenv('GITEA_URL') ?: 'https://git.mokoconsulting.tech'; {
protected function configure(): void
{
$this->setDescription('Bump version in manifest XML and CHANGELOG.md on a remote branch via Gitea API');
$this->addArgument('--path', 'Repository root', '.');
$this->addArgument('--branch', 'Target branch to bump (required)', null);
$this->addArgument('--bump', 'Bump type: patch | minor | major', 'minor');
$this->addArgument('--token', 'Gitea API token (or MOKOGITEA_TOKEN env var)', null);
$this->addArgument('--api-base', 'Gitea API base URL for the repo', null);
$this->addArgument('--no-changelog', 'Skip CHANGELOG.md bump', false);
$this->addArgument('--repo', 'Repository path (owner/repo)', null);
$this->addArgument('--gitea-url', 'Gitea instance URL', null);
}
if ($apiBase === null && $repo !== null) { protected function run(): int
{
$path = $this->getArgument('--path');
$branch = $this->getArgument('--branch');
$bumpType = $this->getArgument('--bump');
$token = $this->getArgument('--token');
$apiBase = $this->getArgument('--api-base');
$noChangelog = (bool) $this->getArgument('--no-changelog');
$repo = $this->getArgument('--repo');
$giteaUrl = $this->getArgument('--gitea-url');
if ($token === null) {
$token = getenv('MOKOGITEA_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; $apiBase = rtrim($giteaUrl, '/') . '/api/v1/repos/' . $repo;
} }
if ($branch === null || $token === null || $apiBase === null) {
if ($branch === null || $token === null || $apiBase === null) { $this->log('ERROR', "Usage: version_bump_remote.php --branch BRANCH --token TOKEN --api-base URL [--bump minor|patch|major]");
fwrite(STDERR, "Usage: version_bump_remote.php --branch BRANCH --token TOKEN --api-base URL [--bump minor|patch|major]\n"); return 1;
fwrite(STDERR, " or: version_bump_remote.php --branch BRANCH --token TOKEN --repo owner/repo\n"); }
exit(1); $root = realpath($path) ?: $path;
} $version = null;
$manifestFile = null;
$root = realpath($path) ?: $path; foreach (["{$root}/src", $root] as $dir) {
if (!is_dir($dir)) {
// ── Read current version from local manifest ──────────────────────────── continue;
$version = null; }
$manifestFile = null;
$searchDirs = ["{$root}/src", $root];
foreach ($searchDirs as $dir) {
if (!is_dir($dir)) continue;
foreach (glob("{$dir}/*.xml") ?: [] as $f) { foreach (glob("{$dir}/*.xml") ?: [] as $f) {
$xml = file_get_contents($f); $xml = file_get_contents($f);
if (strpos($xml, '<extension') !== false || strpos($xml, '<version>') !== false) { if (strpos($xml, '<extension') !== false || strpos($xml, '<version>') !== false) {
@@ -82,35 +75,77 @@ foreach ($searchDirs as $dir) {
} }
} }
} }
} }
if ($version === null) {
$this->log('ERROR', "No version found in manifest XML");
return 1;
}
if (!preg_match('/^(\d{2})\.(\d{2})\.(\d{2})$/', $version, $parts)) {
$this->log('ERROR', "Invalid version format: {$version}");
return 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";
if ($version === null) { $manifestPaths = [];
fwrite(STDERR, "No version found in manifest XML\n"); if ($manifestFile !== null) {
exit(1); $manifestPaths[] = "src/{$manifestFile}";
} }
$manifestPaths = array_merge($manifestPaths, ['src/templateDetails.xml', 'src/manifest.xml']);
$manifestUpdated = false;
foreach ($manifestPaths as $mPath) {
$result = $this->updateRemoteFile($apiBase, $token, $mPath, $branch, function (string $content) use ($version, $nextVersion): string {
return str_replace("<version>{$version}</version>", "<version>{$nextVersion}</version>", $content);
}, "chore(version): bump {$version} -> {$nextVersion} [skip ci]");
if ($result) {
$manifestUpdated = true;
break;
}
}
if (!$manifestUpdated) {
$this->log('WARN', "could not update manifest on {$branch}");
}
if (!$noChangelog) {
$this->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) {
$header = "## [{$nextVersion}] - Unreleased\n\n"
. "### Added\n\n### Changed\n\n"
. "### Fixed\n\n";
$content = str_replace(
$marker,
$header . $marker,
$content
);
}
}
return $content;
}, "chore(version): bump CHANGELOG {$version} -> {$nextVersion} [skip ci]");
}
return 0;
}
// ── Compute next version ──────────────────────────────────────────────── private function giteaApi(string $method, string $url, string $token, ?string $body = null): ?array
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); $ch = curl_init($url);
curl_setopt_array($ch, [ curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true, CURLOPT_RETURNTRANSFER => true,
@@ -127,107 +162,40 @@ function giteaApi(string $method, string $url, string $token, ?string $body = nu
$response = curl_exec($ch); $response = curl_exec($ch);
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE); $httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch); curl_close($ch);
if ($httpCode >= 400 || $response === false) { if ($httpCode >= 400 || $response === false) {
return null; return null;
} }
return json_decode($response, true) ?: []; return json_decode($response, true) ?: [];
} }
// ── Helper: Update a file on a remote branch ──────────────────────────── private function updateRemoteFile(
function updateRemoteFile(
string $apiBase, string $apiBase,
string $token, string $token,
string $filePath, string $filePath,
string $branch, string $branch,
callable $transform, callable $transform,
string $commitMessage string $commitMessage
): bool { ): bool {
$url = "{$apiBase}/contents/{$filePath}?ref={$branch}"; $file = $this->giteaApi('GET', "{$apiBase}/contents/{$filePath}?ref={$branch}", $token);
$file = giteaApi('GET', $url, $token);
if ($file === null || !isset($file['sha']) || !isset($file['content'])) { if ($file === null || !isset($file['sha']) || !isset($file['content'])) {
return false; return false;
} }
$content = base64_decode($file['content']); $content = base64_decode($file['content']);
$newContent = $transform($content); $newContent = $transform($content);
if ($newContent === $content) { if ($newContent === $content) {
fwrite(STDERR, " {$filePath}: no changes needed\n"); $this->log('INFO', "{$filePath}: no changes needed");
return true; return true;
} }
$payload = json_encode(['content' => base64_encode($newContent), 'sha' => $file['sha'], 'message' => $commitMessage, 'branch' => $branch]);
$payload = json_encode([ $result = $this->giteaApi('PUT', "{$apiBase}/contents/{$filePath}", $token, $payload);
'content' => base64_encode($newContent),
'sha' => $file['sha'],
'message' => $commitMessage,
'branch' => $branch,
]);
$result = giteaApi('PUT', "{$apiBase}/contents/{$filePath}", $token, $payload);
if ($result === null) { if ($result === null) {
fwrite(STDERR, " {$filePath}: failed to update\n"); $this->log('ERROR', "{$filePath}: failed to update");
return false; return false;
} }
echo " {$filePath}: updated on {$branch}\n"; echo " {$filePath}: updated on {$branch}\n";
return true; 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) { $app = new VersionBumpRemoteCli();
fwrite(STDERR, "WARNING: could not update manifest on {$branch}\n"); exit($app->execute());
}
// ── 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);
+98 -133
View File
@@ -1,5 +1,6 @@
#!/usr/bin/env php #!/usr/bin/env php
<?php <?php
/* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech> /* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
* *
* SPDX-License-Identifier: GPL-3.0-or-later * SPDX-License-Identifier: GPL-3.0-or-later
@@ -9,34 +10,36 @@
* INGROUP: moko-platform * INGROUP: moko-platform
* REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform * REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform
* PATH: /cli/version_check.php * PATH: /cli/version_check.php
* VERSION: 09.21.00 * VERSION: 09.24.00
* BRIEF: Validate version consistency across README, manifests, and sub-packages * 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); declare(strict_types=1);
$path = '.'; require_once __DIR__ . '/../lib/Enterprise/CliFramework.php';
$strict = false;
$fix = false;
foreach ($argv as $i => $arg) { use MokoEnterprise\CliFramework;
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; class VersionCheckCli extends CliFramework
$errors = 0; {
$versions = []; protected function configure(): void
{
$this->setDescription('Validate version consistency across README, manifests, and sub-packages');
$this->addArgument('--path', 'Repository root', '.');
$this->addArgument('--strict', 'Exit 1 on mismatch', false);
$this->addArgument('--fix', 'Fix mismatches to highest version', false);
}
// ── Read .mokogitea/manifest.xml (canonical) ──────────────────────────────── protected function run(): int
$mokoManifest = "{$root}/.mokogitea/manifest.xml"; {
if (file_exists($mokoManifest)) { $path = $this->getArgument('--path');
$strict = (bool) $this->getArgument('--strict');
$fix = (bool) $this->getArgument('--fix');
$root = realpath($path) ?: $path;
$errors = 0;
$versions = [];
$mokoManifest = "{$root}/.mokogitea/manifest.xml";
if (file_exists($mokoManifest)) {
$xml = @simplexml_load_file($mokoManifest); $xml = @simplexml_load_file($mokoManifest);
if ($xml !== false) { if ($xml !== false) {
$v = (string)($xml->identity->version ?? ''); $v = (string)($xml->identity->version ?? '');
@@ -45,194 +48,156 @@ if (file_exists($mokoManifest)) {
$versions['.mokogitea/manifest.xml'] = $base; $versions['.mokogitea/manifest.xml'] = $base;
} }
} }
} }
$readme = "{$root}/README.md";
// ── Read README.md version ─────────────────────────────────────────────────── if (file_exists($readme)) {
$readme = "{$root}/README.md";
if (file_exists($readme)) {
$content = file_get_contents($readme); $content = file_get_contents($readme);
if (preg_match('/VERSION:\s*(\d{2}\.\d{2}\.\d{2})/m', $content, $m)) { if (preg_match('/VERSION:\s*(\d{2}\.\d{2}\.\d{2})/m', $content, $m)) {
$versions['README.md'] = $m[1]; $versions['README.md'] = $m[1];
} }
} }
$changelog = "{$root}/CHANGELOG.md";
// ── Read CHANGELOG.md version ─────────────────────────────────────────────── if (file_exists($changelog)) {
$changelog = "{$root}/CHANGELOG.md";
if (file_exists($changelog)) {
$content = file_get_contents($changelog); $content = file_get_contents($changelog);
if (preg_match('/VERSION:\s*(\d{2}\.\d{2}\.\d{2})/m', $content, $m)) { if (preg_match('/VERSION:\s*(\d{2}\.\d{2}\.\d{2})/m', $content, $m)) {
$versions['CHANGELOG.md'] = $m[1]; $versions['CHANGELOG.md'] = $m[1];
} }
} }
$packageJson = "{$root}/package.json";
// ── Read package.json version ─────────────────────────────────────────────── if (file_exists($packageJson)) {
$packageJson = "{$root}/package.json";
if (file_exists($packageJson)) {
$pkg = json_decode(file_get_contents($packageJson), true); $pkg = json_decode(file_get_contents($packageJson), true);
if (isset($pkg['version']) && preg_match('/^\d{2}\.\d{2}\.\d{2}$/', $pkg['version'])) { if (isset($pkg['version']) && preg_match('/^\d{2}\.\d{2}\.\d{2}$/', $pkg['version'])) {
$versions['package.json'] = $pkg['version']; $versions['package.json'] = $pkg['version'];
} }
} }
$pyproject = "{$root}/pyproject.toml";
// ── Read pyproject.toml version ───────────────────────────────────────────── if (file_exists($pyproject)) {
$pyproject = "{$root}/pyproject.toml";
if (file_exists($pyproject)) {
$content = file_get_contents($pyproject); $content = file_get_contents($pyproject);
if (preg_match('/^version\s*=\s*"(\d{2}\.\d{2}\.\d{2})"/m', $content, $m)) { if (preg_match('/^version\s*=\s*"(\d{2}\.\d{2}\.\d{2})"/m', $content, $m)) {
$versions['pyproject.toml'] = $m[1]; $versions['pyproject.toml'] = $m[1];
} }
} }
foreach (["{$root}/src/pkg_*.xml", "{$root}/src/*.xml", "{$root}/src/packages/*/*.xml", "{$root}/*.xml"] as $glob) {
// ── 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) { foreach (glob($glob) ?: [] as $file) {
// Skip updates.xml if (basename($file) === 'updates.xml') {
if (basename($file) === 'updates.xml') continue; continue;
}
$xmlContent = file_get_contents($file); $xmlContent = file_get_contents($file);
if (strpos($xmlContent, '<extension') === false) continue; if (strpos($xmlContent, '<extension') === false) {
continue;
}
if (preg_match('#<version>(\d{2}\.\d{2}\.\d{2})(?:(?:-(?:dev|alpha|beta|rc))+)?</version>#', $xmlContent, $xm)) { if (preg_match('#<version>(\d{2}\.\d{2}\.\d{2})(?:(?:-(?:dev|alpha|beta|rc))+)?</version>#', $xmlContent, $xm)) {
$relPath = str_replace($root . '/', '', $file); $relPath = str_replace([$root . '/', $root . '\\'], '', $file);
$relPath = str_replace($root . '\\', '', $relPath);
$versions[$relPath] = $xm[1]; $versions[$relPath] = $xm[1];
} }
} }
} }
if (empty($versions)) {
if (empty($versions)) { $this->log('ERROR', "No version sources found");
fwrite(STDERR, "No version sources found\n"); return 1;
exit(1); }
} $uniqueVersions = array_unique(array_values($versions));
$highestVersion = '00.00.00';
// ── Compare versions ───────────────────────────────────────────────────────── foreach ($versions as $v) {
$uniqueVersions = array_unique(array_values($versions));
$highestVersion = '00.00.00';
foreach ($versions as $v) {
if (version_compare($v, $highestVersion, '>')) { if (version_compare($v, $highestVersion, '>')) {
$highestVersion = $v; $highestVersion = $v;
} }
} }
echo "=== Version Consistency Check ===\n";
echo "=== Version Consistency Check ===\n"; foreach ($versions as $source => $ver) {
foreach ($versions as $source => $ver) {
$status = ($ver === $highestVersion) ? 'OK' : 'MISMATCH'; $status = ($ver === $highestVersion) ? 'OK' : 'MISMATCH';
if ($status === 'MISMATCH') $errors++; if ($status === 'MISMATCH') {
echo sprintf(" %-50s %s %s\n", $source, $ver, $status === 'OK' ? '' : "** MISMATCH (expected {$highestVersion})"); $errors++;
} } echo sprintf(" %-50s %s %s\n", $source, $ver, $status === 'OK' ? '' : "** MISMATCH (expected {$highestVersion})");
}
if (count($uniqueVersions) === 1) { if (count($uniqueVersions) === 1) {
echo "\nAll {$ver} consistent.\n"; echo "\nAll {$ver} -- consistent.\n";
} else { } else {
echo "\n** {$errors} mismatch(es) found. Highest version: {$highestVersion}\n"; echo "\n** {$errors} mismatch(es) found. Highest version: {$highestVersion}\n";
if ($fix) { if ($fix) {
echo "\n=== Fixing mismatches to {$highestVersion} ===\n"; echo "\n=== Fixing mismatches to {$highestVersion} ===\n";
// Fix README.md
if (isset($versions['README.md']) && $versions['README.md'] !== $highestVersion) { if (isset($versions['README.md']) && $versions['README.md'] !== $highestVersion) {
$content = file_get_contents($readme); $content = file_get_contents($readme);
$updated = preg_replace( $updated = preg_replace('/(VERSION:\s*)\d{2}\.\d{2}\.\d{2}/m', '${1}' . $highestVersion, $content, 1);
'/(VERSION:\s*)\d{2}\.\d{2}\.\d{2}/m',
'${1}' . $highestVersion,
$content,
1
);
if ($updated !== null) { if ($updated !== null) {
file_put_contents($readme, $updated); file_put_contents($readme, $updated);
} echo " Fixed: README.md -> {$highestVersion}\n";
} }
echo " Fixed: README.md -> {$highestVersion}\n";
}
// Fix .mokogitea/manifest.xml
if (isset($versions['.mokogitea/manifest.xml']) && $versions['.mokogitea/manifest.xml'] !== $highestVersion) { if (isset($versions['.mokogitea/manifest.xml']) && $versions['.mokogitea/manifest.xml'] !== $highestVersion) {
$content = file_get_contents($mokoManifest); $content = file_get_contents($mokoManifest);
$vPat = '#<version>\d{2}\.\d{2}\.\d{2}'
. '(?:(?:-(?:dev|alpha|beta|rc))+)?</version>#';
$updated = preg_replace( $updated = preg_replace(
'#<version>\d{2}\.\d{2}\.\d{2}(?:(?:-(?:dev|alpha|beta|rc))+)?</version>#', $vPat,
"<version>{$highestVersion}</version>", "<version>{$highestVersion}</version>",
$content $content
); );
if ($updated !== null) { if ($updated !== null) {
file_put_contents($mokoManifest, $updated); file_put_contents($mokoManifest, $updated);
} echo " Fixed: .mokogitea/manifest.xml -> {$highestVersion}\n";
} }
echo " Fixed: .mokogitea/manifest.xml -> {$highestVersion}\n";
}
// Fix CHANGELOG.md
if (isset($versions['CHANGELOG.md']) && $versions['CHANGELOG.md'] !== $highestVersion) { if (isset($versions['CHANGELOG.md']) && $versions['CHANGELOG.md'] !== $highestVersion) {
$content = file_get_contents($changelog); $content = file_get_contents($changelog);
$clPat = '/(VERSION:\s*)\d{2}\.\d{2}\.\d{2}'
. '(?:(?:-(?:dev|alpha|beta|rc))+)?/m';
$updated = preg_replace( $updated = preg_replace(
'/(VERSION:\s*)\d{2}\.\d{2}\.\d{2}(?:(?:-(?:dev|alpha|beta|rc))+)?/m', $clPat,
'${1}' . $highestVersion, '${1}' . $highestVersion,
$content $content
); );
if ($updated !== null) { if ($updated !== null) {
file_put_contents($changelog, $updated); file_put_contents($changelog, $updated);
} echo " Fixed: CHANGELOG.md -> {$highestVersion}\n";
} }
echo " Fixed: CHANGELOG.md -> {$highestVersion}\n";
}
// Fix package.json
if (isset($versions['package.json']) && $versions['package.json'] !== $highestVersion) { if (isset($versions['package.json']) && $versions['package.json'] !== $highestVersion) {
$content = file_get_contents($packageJson); $content = file_get_contents($packageJson);
$pkPat = '/("version"\s*:\s*")\d{2}\.\d{2}\.\d{2}'
. '(?:(?:-(?:dev|alpha|beta|rc))+)?(")/m';
$updated = preg_replace( $updated = preg_replace(
'/("version"\s*:\s*")\d{2}\.\d{2}\.\d{2}(?:(?:-(?:dev|alpha|beta|rc))+)?(")/m', $pkPat,
'${1}' . $highestVersion . '${2}', '${1}' . $highestVersion . '${2}',
$content $content
); );
if ($updated !== null) { if ($updated !== null) {
file_put_contents($packageJson, $updated); file_put_contents($packageJson, $updated);
} echo " Fixed: package.json -> {$highestVersion}\n";
} }
echo " Fixed: package.json -> {$highestVersion}\n";
}
// Fix pyproject.toml
if (isset($versions['pyproject.toml']) && $versions['pyproject.toml'] !== $highestVersion) { if (isset($versions['pyproject.toml']) && $versions['pyproject.toml'] !== $highestVersion) {
$content = file_get_contents($pyproject); $content = file_get_contents($pyproject);
$pyPat = '/^(version\s*=\s*")\d{2}\.\d{2}\.\d{2}'
. '(?:(?:-(?:dev|alpha|beta|rc))+)?(")/m';
$updated = preg_replace( $updated = preg_replace(
'/^(version\s*=\s*")\d{2}\.\d{2}\.\d{2}(?:(?:-(?:dev|alpha|beta|rc))+)?(")/m', $pyPat,
'${1}' . $highestVersion . '${2}', '${1}' . $highestVersion . '${2}',
$content $content
); );
if ($updated !== null) { if ($updated !== null) {
file_put_contents($pyproject, $updated); file_put_contents($pyproject, $updated);
} echo " Fixed: pyproject.toml -> {$highestVersion}\n";
} }
echo " Fixed: pyproject.toml -> {$highestVersion}\n";
}
// Fix XML manifests
foreach ($versions as $source => $ver) { foreach ($versions as $source => $ver) {
if (in_array($source, ['README.md', 'CHANGELOG.md', '.mokogitea/manifest.xml', 'package.json', 'pyproject.toml'], true)) continue; if (in_array($source, ['README.md', 'CHANGELOG.md', '.mokogitea/manifest.xml', 'package.json', 'pyproject.toml'], true)) {
if ($ver === $highestVersion) continue; continue;
} if ($ver === $highestVersion) {
$file = "{$root}/{$source}"; continue;
if (!file_exists($file)) continue; } $file = "{$root}/{$source}";
if (!file_exists($file)) {
$content = file_get_contents($file); continue;
$updated = preg_replace( } $content = file_get_contents($file);
'#<version>[^<]*</version>#', $updated = preg_replace('#<version>[^<]*</version>#', "<version>{$highestVersion}</version>", $content);
"<version>{$highestVersion}</version>",
$content
);
if ($updated !== null) { if ($updated !== null) {
file_put_contents($file, $updated); file_put_contents($file, $updated);
} echo " Fixed: {$source} -> {$highestVersion}\n";
} }
echo " Fixed: {$source} -> {$highestVersion}\n";
}
echo "Done.\n"; echo "Done.\n";
} }
}
if ($strict && $errors > 0) {
return 1;
}
return 0;
}
} }
if ($strict && $errors > 0) { $app = new VersionCheckCli();
exit(1); exit($app->execute());
}
exit(0);
+65 -51
View File
@@ -1,5 +1,6 @@
#!/usr/bin/env php #!/usr/bin/env php
<?php <?php
/* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech> /* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
* *
* SPDX-License-Identifier: GPL-3.0-or-later * SPDX-License-Identifier: GPL-3.0-or-later
@@ -14,19 +15,27 @@
declare(strict_types=1); declare(strict_types=1);
$path = '.'; require_once __DIR__ . '/../lib/Enterprise/CliFramework.php';
foreach ($argv as $i => $arg) {
if ($arg === '--path' && isset($argv[$i + 1])) { use MokoEnterprise\CliFramework;
$path = $argv[$i + 1];
class VersionReadCli extends CliFramework
{
protected function configure(): void
{
$this->setDescription('Read version — manifest.xml is canonical, falls back to README.md and Joomla XML');
$this->addArgument('--path', 'Repository root path', '.');
} }
}
$root = realpath($path) ?: $path; protected function run(): int
{
$path = $this->getArgument('--path');
$root = realpath($path) ?: $path;
// -- 1. Read from .mokogitea/manifest.xml (canonical source) -- // -- 1. Read from .mokogitea/manifest.xml (canonical source) --
$mokoVersion = null; $mokoVersion = null;
$mokoManifest = "{$root}/.mokogitea/manifest.xml"; $mokoManifest = "{$root}/.mokogitea/manifest.xml";
if (file_exists($mokoManifest)) { if (file_exists($mokoManifest)) {
$xml = @simplexml_load_file($mokoManifest); $xml = @simplexml_load_file($mokoManifest);
if ($xml !== false) { if ($xml !== false) {
$v = (string)($xml->identity->version ?? ''); $v = (string)($xml->identity->version ?? '');
@@ -34,34 +43,34 @@ if (file_exists($mokoManifest)) {
$mokoVersion = $v; $mokoVersion = $v;
} }
} }
} }
// If manifest.xml has a version, that is authoritative // If manifest.xml has a version, that is authoritative
if ($mokoVersion !== null) { if ($mokoVersion !== null) {
echo $mokoVersion . "\n"; echo $mokoVersion . "\n";
exit(0); return 0;
} }
// -- 2. Fallback: README.md -- // -- 2. Fallback: README.md --
$readmeVersion = null; $readmeVersion = null;
$readme = "{$root}/README.md"; $readme = "{$root}/README.md";
if (file_exists($readme)) { if (file_exists($readme)) {
$content = file_get_contents($readme); $content = file_get_contents($readme);
if (preg_match('/VERSION:\s*(\d{2}\.\d{2}\.\d{2})/m', $content, $m)) { if (preg_match('/VERSION:\s*(\d{2}\.\d{2}\.\d{2})/m', $content, $m)) {
$readmeVersion = $m[1]; $readmeVersion = $m[1];
} }
} }
// -- 3. Fallback: Joomla manifest XML -- // -- 3. Fallback: Joomla manifest XML --
$manifestVersion = null; $manifestVersion = null;
$manifestFiles = array_merge( $manifestFiles = array_merge(
glob("{$root}/src/pkg_*.xml") ?: [], glob("{$root}/src/pkg_*.xml") ?: [],
glob("{$root}/src/*.xml") ?: [], glob("{$root}/src/*.xml") ?: [],
glob("{$root}/src/packages/*/*.xml") ?: [], glob("{$root}/src/packages/*/*.xml") ?: [],
glob("{$root}/*.xml") ?: [] glob("{$root}/*.xml") ?: []
); );
foreach ($manifestFiles as $xmlFile) { foreach ($manifestFiles as $xmlFile) {
$xmlContent = file_get_contents($xmlFile); $xmlContent = file_get_contents($xmlFile);
if (strpos($xmlContent, '<extension') === false && strpos($xmlContent, '<version>') === false) { if (strpos($xmlContent, '<extension') === false && strpos($xmlContent, '<version>') === false) {
continue; continue;
@@ -74,50 +83,50 @@ foreach ($manifestFiles as $xmlFile) {
$manifestVersion = $candidate; $manifestVersion = $candidate;
} }
} }
} }
// -- 4. Fallback: package.json (Node.js / MCP) -- // -- 4. Fallback: package.json (Node.js / MCP) --
$packageJsonVersion = null; $packageJsonVersion = null;
$packageJsonFile = "{$root}/package.json"; $packageJsonFile = "{$root}/package.json";
if (file_exists($packageJsonFile)) { if (file_exists($packageJsonFile)) {
$pkgData = json_decode(file_get_contents($packageJsonFile), true); $pkgData = json_decode(file_get_contents($packageJsonFile), true);
if (isset($pkgData['version']) && preg_match('/^\d{2}\.\d{2}\.\d{2}$/', $pkgData['version'])) { if (isset($pkgData['version']) && preg_match('/^\d{2}\.\d{2}\.\d{2}$/', $pkgData['version'])) {
$packageJsonVersion = $pkgData['version']; $packageJsonVersion = $pkgData['version'];
} }
} }
// -- 5. Fallback: pyproject.toml (Python) -- // -- 5. Fallback: pyproject.toml (Python) --
$pyprojectVersion = null; $pyprojectVersion = null;
$pyprojectFile = "{$root}/pyproject.toml"; $pyprojectFile = "{$root}/pyproject.toml";
if (file_exists($pyprojectFile)) { if (file_exists($pyprojectFile)) {
$pyContent = file_get_contents($pyprojectFile); $pyContent = file_get_contents($pyprojectFile);
if (preg_match('/^version\s*=\s*"(\d{2}\.\d{2}\.\d{2})"/m', $pyContent, $pm)) { if (preg_match('/^version\s*=\s*"(\d{2}\.\d{2}\.\d{2})"/m', $pyContent, $pm)) {
$pyprojectVersion = $pm[1]; $pyprojectVersion = $pm[1];
} }
} }
// -- Output the higher version -- // -- Output the higher version --
$candidates = array_filter([ $candidates = array_filter([
$readmeVersion, $readmeVersion,
$manifestVersion, $manifestVersion,
$packageJsonVersion, $packageJsonVersion,
$pyprojectVersion, $pyprojectVersion,
]); ]);
$version = null; $version = null;
foreach ($candidates as $candidate) { foreach ($candidates as $candidate) {
if ($version === null || version_compare($candidate, $version, '>')) { if ($version === null || version_compare($candidate, $version, '>')) {
$version = $candidate; $version = $candidate;
} }
} }
if ($version === null) { if ($version === null) {
fwrite(STDERR, "No version found in manifest.xml, README.md, Joomla XML, package.json, or pyproject.toml\n"); $this->log('ERROR', 'No version found in manifest.xml, README.md, Joomla XML, package.json, or pyproject.toml');
exit(1); return 1;
} }
// -- Backfill: if manifest.xml exists but lacks <version>, insert it -- // -- Backfill: if manifest.xml exists but lacks <version>, insert it --
if (file_exists($mokoManifest)) { if (file_exists($mokoManifest)) {
$content = file_get_contents($mokoManifest); $content = file_get_contents($mokoManifest);
if (!preg_match('#<version>\d{2}\.\d{2}\.\d{2}((?:-(?:dev|alpha|beta|rc))+)?</version>#', $content)) { if (!preg_match('#<version>\d{2}\.\d{2}\.\d{2}((?:-(?:dev|alpha|beta|rc))+)?</version>#', $content)) {
if (strpos($content, '<license') !== false) { if (strpos($content, '<license') !== false) {
@@ -136,9 +145,14 @@ if (file_exists($mokoManifest)) {
); );
} }
file_put_contents($mokoManifest, $content); file_put_contents($mokoManifest, $content);
fwrite(STDERR, "Backfilled manifest.xml with version {$version}\n"); $this->log('ERROR', "Backfilled manifest.xml with version {$version}");
}
}
echo $version . "\n";
return 0;
} }
} }
echo $version . "\n"; $app = new VersionReadCli();
exit(0); exit($app->execute());
+68 -136
View File
@@ -11,138 +11,87 @@
* REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform * REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform
* PATH: /cli/version_reset_dev.php * PATH: /cli/version_reset_dev.php
* BRIEF: Reset platform version to 'development' on a branch via Gitea API * BRIEF: Reset platform version to 'development' on a branch via Gitea API
*
* Usage:
* php version_reset_dev.php --token TOKEN --api-base URL
* php version_reset_dev.php --token TOKEN --api-base URL --branch dev
* php version_reset_dev.php --token TOKEN --api-base URL --platform dolibarr
* php version_reset_dev.php --token TOKEN --api-base URL --path /repo/root
*
* This replaces the inline curl+python3+sed block previously used in
* auto-release.yml to reset Dolibarr's $this->version on the dev branch
* after a stable release.
*/ */
declare(strict_types=1); declare(strict_types=1);
// ── Argument parsing ───────────────────────────────────────────────────────── require_once __DIR__ . '/../lib/Enterprise/CliFramework.php';
$token = null; use MokoEnterprise\CliFramework;
$apiBase = null;
$branch = 'dev';
$platform = null;
$path = null;
foreach ($argv as $i => $arg) { class VersionResetDevCli extends CliFramework
if ($arg === '--token' && isset($argv[$i + 1])) { {
$token = $argv[$i + 1]; protected function configure(): void
{
$this->setDescription('Reset platform version to development on a branch via Gitea API');
$this->addArgument('--token', 'Gitea API token (also reads MOKOGITEA_TOKEN / GITEA_TOKEN env)', '');
$this->addArgument('--api-base', 'Gitea API base URL for the repo', '');
$this->addArgument('--branch', 'Target branch (default: dev)', 'dev');
$this->addArgument('--platform', 'Platform type: dolibarr, crm-module, joomla, waas-component', '');
$this->addArgument('--path', 'Repo root for auto-detecting platform from manifest.xml', '');
} }
if ($arg === '--api-base' && isset($argv[$i + 1])) {
$apiBase = rtrim($argv[$i + 1], '/');
}
if ($arg === '--branch' && isset($argv[$i + 1])) {
$branch = $argv[$i + 1];
}
if ($arg === '--platform' && isset($argv[$i + 1])) {
$platform = $argv[$i + 1];
}
if ($arg === '--path' && isset($argv[$i + 1])) {
$path = $argv[$i + 1];
}
if ($arg === '--help' || $arg === '-h') {
printUsage();
exit(0);
}
}
// Allow token from environment protected function run(): int
if ($token === null) { {
$token = $this->getArgument('--token');
$apiBase = $this->getArgument('--api-base');
$branch = $this->getArgument('--branch');
$platform = $this->getArgument('--platform');
$path = $this->getArgument('--path');
// Allow token from environment
if ($token === '') {
$envToken = getenv('MOKOGITEA_TOKEN'); $envToken = getenv('MOKOGITEA_TOKEN');
if ($envToken !== false && $envToken !== '') { if ($envToken !== false && $envToken !== '') {
$token = $envToken; $token = $envToken;
} }
} }
if ($token === null) { if ($token === '') {
$envToken = getenv('GITEA_TOKEN'); $envToken = getenv('GITEA_TOKEN');
if ($envToken !== false && $envToken !== '') { if ($envToken !== false && $envToken !== '') {
$token = $envToken; $token = $envToken;
} }
} }
if ($token === null || $apiBase === null) { if ($token === '' || $apiBase === '') {
fwrite(STDERR, "Error: --token and --api-base are required.\n\n"); $this->log('ERROR', '--token and --api-base are required.');
printUsage(); return 1;
exit(1); }
}
// ── Platform detection ─────────────────────────────────────────────────────── $apiBase = rtrim($apiBase, '/');
if ($platform === null && $path !== null) { // ── Platform detection ───────────────────────────────────────────────────────
$platform = detectPlatform($path);
if ($platform !== null) { if ($platform === '' && $path !== '') {
$platform = $this->detectPlatform($path) ?? '';
if ($platform !== '') {
echo "Detected platform: {$platform}\n"; echo "Detected platform: {$platform}\n";
} }
} }
if ($platform === null) { if ($platform === '') {
fwrite(STDERR, "Error: could not determine platform. Use --platform or --path.\n"); $this->log('ERROR', 'Could not determine platform. Use --platform or --path.');
exit(1); return 1;
} }
// ── Dispatch by platform ───────────────────────────────────────────────────── // ── Dispatch by platform ─────────────────────────────────────────────────────
$changed = 0; $changed = 0;
if (in_array($platform, ['dolibarr', 'crm-module'], true)) { if (in_array($platform, ['dolibarr', 'crm-module'], true)) {
$changed = resetDolibarrVersion($apiBase, $token, $branch); $changed = $this->resetDolibarrVersion($apiBase, $token, $branch);
} elseif (in_array($platform, ['joomla', 'waas-component'], true)) { } elseif (in_array($platform, ['joomla', 'waas-component'], true)) {
echo "Joomla version reset is not yet implemented — skipping.\n"; echo "Joomla version reset is not yet implemented — skipping.\n";
} else { } else {
echo "Platform '{$platform}' has no version-reset logic — skipping.\n"; echo "Platform '{$platform}' has no version-reset logic — skipping.\n";
} }
echo "Reset {$changed} file(s) to 'development' on branch '{$branch}'.\n"; echo "Reset {$changed} file(s) to 'development' on branch '{$branch}'.\n";
exit(0); return 0;
}
// ══════════════════════════════════════════════════════════════════════════════ private function detectPlatform(string $repoPath): ?string
// Helper functions {
// ══════════════════════════════════════════════════════════════════════════════
/**
* Print usage information to stdout.
*
* @return void
*/
function printUsage(): void
{
echo <<<'USAGE'
Reset platform version to 'development' on a branch via Gitea API.
Usage:
php version_reset_dev.php --token TOKEN --api-base URL [options]
Required:
--token TOKEN Gitea API token (also reads MOKOGITEA_TOKEN / GITEA_TOKEN env)
--api-base URL Gitea API base URL for the repo
e.g. https://git.mokoconsulting.tech/api/v1/repos/Org/Repo
Options:
--branch BRANCH Target branch (default: dev)
--platform TYPE Platform type: dolibarr, crm-module, joomla, waas-component
--path DIR Repo root for auto-detecting platform from manifest.xml
--help Show this help
USAGE;
}
/**
* Detect the platform type from a repo's .mokogitea/manifest.xml file.
*
* @param string $repoPath Path to the repository root
* @return string|null The detected platform, or null if detection fails
*/
function detectPlatform(string $repoPath): ?string
{
$root = realpath($repoPath) ?: $repoPath; $root = realpath($repoPath) ?: $repoPath;
$manifestXml = "{$root}/.mokogitea/manifest.xml"; $manifestXml = "{$root}/.mokogitea/manifest.xml";
@@ -163,22 +112,13 @@ function detectPlatform(string $repoPath): ?string
} }
return null; return null;
} }
/** private function giteaApiCall(string $url, string $token, string $method = 'GET', ?string $body = null): ?array
* Make a Gitea API call and return the decoded JSON response. {
*
* @param string $url Full API URL
* @param string $token Gitea API token
* @param string $method HTTP method (GET, PUT, POST, DELETE)
* @param string|null $body JSON request body, or null for bodiless requests
* @return array<string, mixed>|null Decoded JSON response, or null on failure
*/
function giteaApiCall(string $url, string $token, string $method = 'GET', ?string $body = null): ?array
{
$ch = curl_init($url); $ch = curl_init($url);
if ($ch === false) { if ($ch === false) {
fwrite(STDERR, "Error: curl_init() failed for {$url}\n"); $this->log('ERROR', "curl_init() failed for {$url}");
return null; return null;
} }
@@ -215,28 +155,16 @@ function giteaApiCall(string $url, string $token, string $method = 'GET', ?strin
} }
return $data; return $data;
} }
/** private function resetDolibarrVersion(string $apiBase, string $token, string $branch): int
* Reset Dolibarr module version to 'development' on the target branch. {
*
* Searches the repository tree for mod*.class.php files that contain
* `extends DolibarrModules`, then replaces `$this->version = '...'`
* with `$this->version = 'development'` via the Gitea file contents API.
*
* @param string $apiBase Gitea API base URL for the repo
* @param string $token Gitea API token
* @param string $branch Target branch name
* @return int Number of files modified
*/
function resetDolibarrVersion(string $apiBase, string $token, string $branch): int
{
// Search the repo tree for mod*.class.php files // Search the repo tree for mod*.class.php files
$treeUrl = "{$apiBase}/git/trees/{$branch}?recursive=true"; $treeUrl = "{$apiBase}/git/trees/{$branch}?recursive=true";
$tree = giteaApiCall($treeUrl, $token); $tree = $this->giteaApiCall($treeUrl, $token);
if ($tree === null || !isset($tree['tree']) || !is_array($tree['tree'])) { if ($tree === null || !isset($tree['tree']) || !is_array($tree['tree'])) {
fwrite(STDERR, "Error: could not read repository tree for branch '{$branch}'.\n"); $this->log('ERROR', "Could not read repository tree for branch '{$branch}'.");
return 0; return 0;
} }
@@ -263,7 +191,7 @@ function resetDolibarrVersion(string $apiBase, string $token, string $branch): i
// GET file contents via API // GET file contents via API
$encodedPath = implode('/', array_map('rawurlencode', explode('/', $filePath))); $encodedPath = implode('/', array_map('rawurlencode', explode('/', $filePath)));
$fileUrl = "{$apiBase}/contents/{$encodedPath}?ref={$branch}"; $fileUrl = "{$apiBase}/contents/{$encodedPath}?ref={$branch}";
$fileData = giteaApiCall($fileUrl, $token); $fileData = $this->giteaApiCall($fileUrl, $token);
if ($fileData === null || !isset($fileData['content'])) { if ($fileData === null || !isset($fileData['content'])) {
echo "Skipping {$filePath}: could not fetch contents.\n"; echo "Skipping {$filePath}: could not fetch contents.\n";
@@ -305,15 +233,19 @@ function resetDolibarrVersion(string $apiBase, string $token, string $branch): i
]); ]);
$putUrl = "{$apiBase}/contents/{$encodedPath}"; $putUrl = "{$apiBase}/contents/{$encodedPath}";
$result = giteaApiCall($putUrl, $token, 'PUT', $putBody); $result = $this->giteaApiCall($putUrl, $token, 'PUT', $putBody);
if ($result !== null) { if ($result !== null) {
echo "Reset: {$filePath} -> \$this->version = 'development'\n"; echo "Reset: {$filePath} -> \$this->version = 'development'\n";
$changed++; $changed++;
} else { } else {
fwrite(STDERR, "Error: failed to update {$filePath} on branch '{$branch}'.\n"); $this->log('ERROR', "Failed to update {$filePath} on branch '{$branch}'.");
} }
} }
return $changed; return $changed;
}
} }
$app = new VersionResetDevCli();
exit($app->execute());
+65 -54
View File
@@ -1,5 +1,6 @@
#!/usr/bin/env php #!/usr/bin/env php
<?php <?php
/* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech> /* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
* *
* SPDX-License-Identifier: GPL-3.0-or-later * SPDX-License-Identifier: GPL-3.0-or-later
@@ -10,47 +11,50 @@
* REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform * REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform
* PATH: /cli/version_set_platform.php * PATH: /cli/version_set_platform.php
* BRIEF: Set version in platform-specific files (Dolibarr $this->version, Joomla <version>) * BRIEF: Set version in platform-specific files (Dolibarr $this->version, Joomla <version>)
*
* Usage:
* php version_set_platform.php --path . --version 04.01.00
* php version_set_platform.php --path . --version 04.01.00 --stability alpha
*
* When --stability is set to anything other than "stable", the suffix is
* appended to the version (e.g. 04.01.00-dev, 04.01.00-alpha, 04.01.00-rc).
*/ */
declare(strict_types=1); declare(strict_types=1);
$path = '.'; require_once __DIR__ . '/../lib/Enterprise/CliFramework.php';
$version = null;
$branch = null;
$stability = 'stable';
foreach ($argv as $i => $arg) { use MokoEnterprise\CliFramework;
if ($arg === '--path' && isset($argv[$i + 1])) $path = $argv[$i + 1];
if ($arg === '--version' && isset($argv[$i + 1])) $version = $argv[$i + 1];
if ($arg === '--branch' && isset($argv[$i + 1])) $branch = $argv[$i + 1];
if ($arg === '--stability' && isset($argv[$i + 1])) $stability = $argv[$i + 1];
}
// Auto-detect branch from git or GitHub env class VersionSetPlatformCli extends CliFramework
if ($branch === null) { {
protected function configure(): void
{
$this->setDescription('Set version in platform-specific files (Dolibarr $this->version, Joomla <version>)');
$this->addArgument('--path', 'Repository root path', '.');
$this->addArgument('--version', 'Version string XX.YY.ZZ', '');
$this->addArgument('--branch', 'Git branch name', '');
$this->addArgument('--stability', 'Stability level (stable, dev, alpha, beta, rc)', 'stable');
}
protected function run(): int
{
$path = $this->getArgument('--path');
$version = $this->getArgument('--version');
$branch = $this->getArgument('--branch');
$stability = $this->getArgument('--stability');
// Auto-detect branch from git or GitHub env
if ($branch === '') {
$branch = trim((string) @shell_exec('git rev-parse --abbrev-ref HEAD 2>/dev/null')); $branch = trim((string) @shell_exec('git rev-parse --abbrev-ref HEAD 2>/dev/null'));
if (empty($branch) || $branch === 'HEAD') { if (empty($branch) || $branch === 'HEAD') {
$branch = getenv('GITHUB_REF_NAME') ?: 'main'; $branch = getenv('GITHUB_REF_NAME') ?: 'main';
} }
} }
if ($version === null) { if ($version === '') {
fwrite(STDERR, "Usage: version_set_platform.php --path . --version 04.01.00 [--stability dev]\n"); $this->log('ERROR', 'Usage: version_set_platform.php --path . --version 04.01.00 [--stability dev]');
exit(1); return 1;
} }
// Strip any existing suffix(es) before applying the correct one // Strip any existing suffix(es) before applying the correct one
$version = preg_replace('/(-(dev|alpha|beta|rc))+$/', '', $version); $version = preg_replace('/(-(dev|alpha|beta|rc))+$/', '', $version);
// Append stability suffix for non-stable releases // Append stability suffix for non-stable releases
$stabilitySuffixMap = [ $stabilitySuffixMap = [
'stable' => '', 'stable' => '',
'development' => '-dev', 'development' => '-dev',
'dev' => '-dev', 'dev' => '-dev',
@@ -58,32 +62,32 @@ $stabilitySuffixMap = [
'beta' => '-beta', 'beta' => '-beta',
'rc' => '-rc', 'rc' => '-rc',
'release-candidate' => '-rc', 'release-candidate' => '-rc',
]; ];
$suffix = $stabilitySuffixMap[$stability] ?? ''; $suffix = $stabilitySuffixMap[$stability] ?? '';
if ($suffix !== '' && !str_ends_with($version, $suffix)) { if ($suffix !== '' && !str_ends_with($version, $suffix)) {
$version .= $suffix; $version .= $suffix;
echo "Version with stability suffix: {$version}\n"; echo "Version with stability suffix: {$version}\n";
} }
$root = realpath($path) ?: $path; $root = realpath($path) ?: $path;
// Detect platform — check manifest.xml first, then legacy .mokostandards // Detect platform — check manifest.xml first, then legacy .mokostandards
$platform = ''; $platform = '';
// New format: .mokogitea/manifest.xml (XML with <platform> tag) // New format: .mokogitea/manifest.xml (XML with <platform> tag)
$manifestXml = "{$root}/.mokogitea/manifest.xml"; $manifestXml = "{$root}/.mokogitea/manifest.xml";
if (file_exists($manifestXml)) { if (file_exists($manifestXml)) {
$xml = @simplexml_load_file($manifestXml); $xml = @simplexml_load_file($manifestXml);
if ($xml && isset($xml->governance->platform)) { if ($xml && isset($xml->governance->platform)) {
$platform = (string) $xml->governance->platform; $platform = (string) $xml->governance->platform;
} }
} }
// Legacy: .mokostandards YAML file // Legacy: .mokostandards YAML file
if (empty($platform)) { if (empty($platform)) {
$mokoStandards = "{$root}/.github/.mokostandards"; $mokoStandards = "{$root}/.github/.mokostandards";
if (!file_exists($mokoStandards)) { if (!file_exists($mokoStandards)) {
$mokoStandards = "{$root}/.mokogitea/.mokostandards"; $mokoStandards = "{$root}/.mokogitea/manifest.xml";
} }
if (!file_exists($mokoStandards)) { if (!file_exists($mokoStandards)) {
$mokoStandards = "{$root}/.mokostandards"; $mokoStandards = "{$root}/.mokostandards";
@@ -94,12 +98,12 @@ if (empty($platform)) {
$platform = trim($m[1], " \t\n\r\"'"); $platform = trim($m[1], " \t\n\r\"'");
} }
} }
} }
$changed = 0; $changed = 0;
// Dolibarr: $this->version + $this->url_last_version in mod*.class.php // Dolibarr: $this->version + $this->url_last_version in mod*.class.php
if ($platform === 'crm-module') { if ($platform === 'crm-module') {
$pattern = "{$root}/src/core/modules/mod*.class.php"; $pattern = "{$root}/src/core/modules/mod*.class.php";
foreach (glob($pattern) ?: [] as $file) { foreach (glob($pattern) ?: [] as $file) {
$content = file_get_contents($file); $content = file_get_contents($file);
@@ -132,10 +136,10 @@ if ($platform === 'crm-module') {
$changed++; $changed++;
} }
} }
} }
// Joomla: <version> in XML manifests (top-level + sub-packages) // Joomla: <version> in XML manifests (top-level + sub-packages)
if (in_array($platform, ['waas-component', 'joomla'], true)) { if (in_array($platform, ['waas-component', 'joomla'], true)) {
$xmlFiles = array_merge( $xmlFiles = array_merge(
glob("{$root}/src/*.xml") ?: [], glob("{$root}/src/*.xml") ?: [],
glob("{$root}/src/packages/*/*.xml") ?: [], glob("{$root}/src/packages/*/*.xml") ?: [],
@@ -146,7 +150,9 @@ if (in_array($platform, ['waas-component', 'joomla'], true)) {
} }
foreach ($xmlFiles as $file) { foreach ($xmlFiles as $file) {
$content = file_get_contents($file); $content = file_get_contents($file);
if (!str_contains($content, '<extension')) continue; if (!str_contains($content, '<extension')) {
continue;
}
$updated = preg_replace( $updated = preg_replace(
'|<version>[^<]*</version>|', '|<version>[^<]*</version>|',
"<version>{$version}</version>", "<version>{$version}</version>",
@@ -159,14 +165,19 @@ if (in_array($platform, ['waas-component', 'joomla'], true)) {
$changed++; $changed++;
} }
} }
} }
if ($changed === 0) { if ($changed === 0) {
if (empty($platform)) { if (empty($platform)) {
echo "No .mokostandards file — skipping platform version set\n"; echo "No manifest.xml file — skipping platform version set\n";
} else { } else {
echo "No platform-specific version files found for {$platform}\n"; echo "No platform-specific version files found for {$platform}\n";
} }
}
return 0;
}
} }
exit(0); $app = new VersionSetPlatformCli();
exit($app->execute());
+60 -95
View File
@@ -1,5 +1,6 @@
#!/usr/bin/env php #!/usr/bin/env php
<?php <?php
/* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech> /* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
* *
* SPDX-License-Identifier: GPL-3.0-or-later * SPDX-License-Identifier: GPL-3.0-or-later
@@ -9,13 +10,17 @@
* INGROUP: moko-platform * INGROUP: moko-platform
* REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform * REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform
* PATH: /cli/wiki_sync.php * PATH: /cli/wiki_sync.php
* VERSION: 09.21.00 * VERSION: 09.24.00
* BRIEF: Sync select wiki pages from moko-platform to all template repos * BRIEF: Sync select wiki pages from moko-platform to all template repos
*/ */
declare(strict_types=1); declare(strict_types=1);
final class WikiSync require_once __DIR__ . '/../lib/Enterprise/CliFramework.php';
use MokoEnterprise\CliFramework;
class WikiSyncCli extends CliFramework
{ {
private string $giteaUrl = 'https://git.mokoconsulting.tech'; private string $giteaUrl = 'https://git.mokoconsulting.tech';
private string $token = ''; private string $token = '';
@@ -23,27 +28,52 @@ final class WikiSync
private string $sourceRepo = 'moko-platform'; private string $sourceRepo = 'moko-platform';
private array $targetRepos = []; private array $targetRepos = [];
private array $pages = []; private array $pages = [];
private bool $dryRun = false;
private bool $allTemplates = false; private bool $allTemplates = false;
private bool $allStandards = false;
private int $synced = 0; private int $synced = 0;
private int $created = 0; private int $created = 0;
private int $skipped = 0; private int $skipped = 0;
private int $errors = 0; private int $errors = 0;
public function run(): int protected function configure(): void
{ {
$this->parseArgs(); $this->setDescription('Sync wiki pages from moko-platform to template repos');
$this->addArgument('--token', 'Gitea API token (required)', '');
$this->addArgument('--org', 'Organization (default: MokoConsulting)', 'MokoConsulting');
$this->addArgument('--source', 'Source repo (default: moko-platform)', 'moko-platform');
$this->addArgument('--target', 'Target repo (can repeat)', '');
$this->addArgument('--page', 'Page to sync (can repeat)', '');
$this->addArgument('--all-standards', 'Sync all UPPERCASE standards pages', false);
$this->addArgument('--all-templates', 'Target all Template-* repos', false);
}
protected function run(): int
{
$this->token = $this->getArgument('--token');
$this->org = $this->getArgument('--org');
$this->sourceRepo = $this->getArgument('--source');
$this->allStandards = (bool) $this->getArgument('--all-standards');
$this->allTemplates = (bool) $this->getArgument('--all-templates');
// Handle repeatable args from raw argv
global $argv;
foreach ($argv as $i => $arg) {
if ($arg === '--target' && isset($argv[$i + 1])) {
$this->targetRepos[] = $argv[$i + 1];
}
if ($arg === '--page' && isset($argv[$i + 1])) {
$this->pages[] = $argv[$i + 1];
}
}
if ($this->token === '') { if ($this->token === '') {
$this->log('ERROR: --token is required.'); $this->log('ERROR', '--token is required.');
$this->printUsage();
return 1; return 1;
} }
if (empty($this->pages) && !$this->allTemplates) { if (empty($this->pages) && !$this->allStandards) {
$this->log('ERROR: --page or --all-standards is required.'); $this->log('ERROR', '--page or --all-standards is required.');
$this->printUsage();
return 1; return 1;
} }
@@ -53,7 +83,7 @@ final class WikiSync
} }
if (empty($this->targetRepos)) { if (empty($this->targetRepos)) {
$this->log('No target repos found.'); $this->log('INFO', 'No target repos found.');
return 0; return 0;
} }
@@ -62,16 +92,16 @@ final class WikiSync
$this->pages = $this->getStandardsPages(); $this->pages = $this->getStandardsPages();
} }
$this->log("Syncing " . count($this->pages) . " page(s) to " . count($this->targetRepos) . " repo(s)"); $this->log('INFO', "Syncing " . count($this->pages) . " page(s) to " . count($this->targetRepos) . " repo(s)");
if ($this->dryRun) { if ($this->dryRun) {
$this->log("[DRY RUN] No changes will be made.\n"); $this->log('INFO', "[DRY RUN] No changes will be made.\n");
} }
foreach ($this->pages as $pageName) { foreach ($this->pages as $pageName) {
$this->log("\n--- Page: {$pageName} ---"); $this->log('INFO', "\n--- Page: {$pageName} ---");
$sourceContent = $this->getWikiPage($this->sourceRepo, $pageName); $sourceContent = $this->getWikiPage($this->sourceRepo, $pageName);
if ($sourceContent === null) { if ($sourceContent === null) {
$this->log(" WARNING: page not found in {$this->sourceRepo}"); $this->log('WARNING', "page not found in {$this->sourceRepo}");
$this->errors++; $this->errors++;
continue; continue;
} }
@@ -79,30 +109,30 @@ final class WikiSync
foreach ($this->targetRepos as $repo) { foreach ($this->targetRepos as $repo) {
$existing = $this->getWikiPage($repo, $pageName); $existing = $this->getWikiPage($repo, $pageName);
if ($existing !== null && $existing === $sourceContent) { if ($existing !== null && $existing === $sourceContent) {
$this->log(" {$repo}: IDENTICAL (skipped)"); $this->log('INFO', " {$repo}: IDENTICAL (skipped)");
$this->skipped++; $this->skipped++;
continue; continue;
} }
if ($this->dryRun) { if ($this->dryRun) {
$action = $existing !== null ? 'WOULD UPDATE' : 'WOULD CREATE'; $action = $existing !== null ? 'WOULD UPDATE' : 'WOULD CREATE';
$this->log(" {$repo}: {$action}"); $this->log('INFO', " {$repo}: {$action}");
continue; continue;
} }
if ($existing !== null) { if ($existing !== null) {
$ok = $this->updateWikiPage($repo, $pageName, $sourceContent); $ok = $this->updateWikiPage($repo, $pageName, $sourceContent);
$this->log(" {$repo}: " . ($ok ? 'UPDATED' : 'ERROR')); $this->log('INFO', " {$repo}: " . ($ok ? 'UPDATED' : 'ERROR'));
$ok ? $this->synced++ : $this->errors++; $ok ? $this->synced++ : $this->errors++;
} else { } else {
$ok = $this->createWikiPage($repo, $pageName, $sourceContent); $ok = $this->createWikiPage($repo, $pageName, $sourceContent);
$this->log(" {$repo}: " . ($ok ? 'CREATED' : 'ERROR')); $this->log('INFO', " {$repo}: " . ($ok ? 'CREATED' : 'ERROR'));
$ok ? $this->created++ : $this->errors++; $ok ? $this->created++ : $this->errors++;
} }
} }
} }
$this->log("\nDone: {$this->synced} updated, {$this->created} created, {$this->skipped} skipped, {$this->errors} error(s)"); $this->log('INFO', "\nDone: {$this->synced} updated, {$this->created} created, {$this->skipped} skipped, {$this->errors} error(s)");
return $this->errors > 0 ? 1 : 0; return $this->errors > 0 ? 1 : 0;
} }
@@ -116,7 +146,7 @@ final class WikiSync
} }
} }
sort($templates); sort($templates);
$this->log("Found template repos: " . implode(', ', $templates)); $this->log('INFO', "Found template repos: " . implode(', ', $templates));
return $templates; return $templates;
} }
@@ -132,7 +162,7 @@ final class WikiSync
} }
} }
sort($standards); sort($standards);
$this->log("Found " . count($standards) . " standards pages: " . implode(', ', $standards)); $this->log('INFO', "Found " . count($standards) . " standards pages: " . implode(', ', $standards));
return $standards; return $standards;
} }
@@ -175,7 +205,9 @@ final class WikiSync
]; ];
$ctx = stream_context_create($opts); $ctx = stream_context_create($opts);
$result = @file_get_contents($url, false, $ctx); $result = @file_get_contents($url, false, $ctx);
if ($result === false) return null; if ($result === false) {
return null;
}
$data = json_decode($result, true); $data = json_decode($result, true);
return is_array($data) ? $data : null; return is_array($data) ? $data : null;
} }
@@ -203,80 +235,13 @@ final class WikiSync
]; ];
$ctx = stream_context_create($opts); $ctx = stream_context_create($opts);
$result = @file_get_contents($url, false, $ctx); $result = @file_get_contents($url, false, $ctx);
if ($result === false) return null; if ($result === false) {
return null;
}
$data = json_decode($result, true); $data = json_decode($result, true);
return is_array($data) ? $data : null; return is_array($data) ? $data : null;
} }
private function parseArgs(): void
{
global $argv;
$args = $argv;
for ($i = 1; $i < count($args); $i++) {
switch ($args[$i]) {
case '--token':
$this->token = $args[++$i] ?? '';
break;
case '--org':
$this->org = $args[++$i] ?? '';
break;
case '--source':
$this->sourceRepo = $args[++$i] ?? '';
break;
case '--target':
$this->targetRepos[] = $args[++$i] ?? '';
break;
case '--page':
$this->pages[] = $args[++$i] ?? '';
break;
case '--all-standards':
$this->pages = []; // will be populated from source wiki
$this->allTemplates = true;
break;
case '--all-templates':
$this->allTemplates = true;
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: wiki_sync.php --token <token> [options]');
$this->log('');
$this->log('Sync wiki pages from moko-platform to template repos.');
$this->log('');
$this->log('Options:');
$this->log(' --token <token> Gitea API token (required)');
$this->log(' --org <org> Organization (default: MokoConsulting)');
$this->log(' --source <repo> Source repo (default: moko-platform)');
$this->log(' --target <repo> Target repo (can repeat; default: all Template-* repos)');
$this->log(' --page <name> Page to sync (can repeat)');
$this->log(' --all-standards Sync all UPPERCASE standards pages');
$this->log(' --all-templates Target all Template-* repos');
$this->log(' --dry-run Show what would be done');
$this->log(' --help, -h Show this help');
$this->log('');
$this->log('Examples:');
$this->log(' php wiki_sync.php --token xxx --page MANIFEST_STANDARD --all-templates');
$this->log(' php wiki_sync.php --token xxx --all-standards --all-templates --dry-run');
$this->log(' php wiki_sync.php --token xxx --page WORKFLOW_STANDARDS --target Template-Joomla');
}
private function log(string $msg): void
{
fwrite(STDERR, $msg . "\n");
}
} }
(new WikiSync())->run(); $app = new WikiSyncCli();
exit($app->execute());
+2 -2
View File
@@ -1,8 +1,8 @@
{ {
"name": "mokoconsulting-tech/enterprise", "name": "mokoconsulting-tech/enterprise",
"description": "MokoStandards Enterprise API \u2014 PHP implementation", "description": "moko-platform Enterprise API \u2014 PHP implementation",
"type": "library", "type": "library",
"version": "09.01.00", "version": "09.23.00",
"license": "GPL-3.0-or-later", "license": "GPL-3.0-or-later",
"authors": [ "authors": [
{ {
+49 -66
View File
@@ -1,5 +1,6 @@
#!/usr/bin/env php #!/usr/bin/env php
<?php <?php
/* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech> /* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
* *
* This file is part of a Moko Consulting project. * This file is part of a Moko Consulting project.
@@ -7,22 +8,22 @@
* SPDX-License-Identifier: GPL-3.0-or-later * SPDX-License-Identifier: GPL-3.0-or-later
* *
* FILE INFORMATION * FILE INFORMATION
* DEFGROUP: MokoStandards.Scripts.Deploy * DEFGROUP: MokoPlatform.Scripts.Deploy
* INGROUP: MokoStandards * INGROUP: MokoPlatform
* REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform * REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform
* PATH: /deploy/backup-before-deploy.php * PATH: /deploy/backup-before-deploy.php
* VERSION: 09.21.00 * VERSION: 09.24.00
* BRIEF: Snapshot Joomla directories before deployment for rollback capability * BRIEF: Snapshot Joomla directories before deployment for rollback capability
*/ */
declare(strict_types=1); declare(strict_types=1);
class BackupBeforeDeploy require_once __DIR__ . '/../lib/Enterprise/CliFramework.php';
{
private bool $verbose = false;
private string $configPath = '';
private string $outputDir = '';
use MokoEnterprise\CliFramework;
class BackupBeforeDeployCli extends CliFramework
{
private const JOOMLA_DIRS = [ private const JOOMLA_DIRS = [
'administrator/components', 'administrator/components',
'administrator/language', 'administrator/language',
@@ -38,20 +39,28 @@ class BackupBeforeDeploy
'templates', 'templates',
]; ];
public function run(): int protected function configure(): void
{ {
$this->parseArgs(); $this->setDescription('Snapshot Joomla directories before deployment for rollback capability');
$this->addArgument('--config', 'Path to sftp-config.json', '');
$this->addArgument('--output', 'Local output directory for snapshot', '');
}
if ($this->configPath === '') { protected function run(): int
$this->log('Usage: backup-before-deploy.php --config <sftp-config.json> [--output <local-dir>] [--verbose]'); {
$configPath = $this->getArgument('--config');
$outputDir = $this->getArgument('--output');
if ($configPath === '') {
$this->log('ERROR', 'Usage: backup-before-deploy.php --config <sftp-config.json> [--output <local-dir>] [--verbose]');
return 1; return 1;
} }
if ($this->outputDir === '') { if ($outputDir === '') {
$this->outputDir = '/tmp/moko-snapshot-' . date('Ymd-His'); $outputDir = '/tmp/moko-snapshot-' . date('Ymd-His');
} }
$config = $this->loadConfig($this->configPath); $config = $this->loadConfig($configPath);
if ($config === null) { if ($config === null) {
return 1; return 1;
} }
@@ -63,27 +72,27 @@ class BackupBeforeDeploy
$sshKey = $config['ssh_key_file'] ?? ''; $sshKey = $config['ssh_key_file'] ?? '';
if ($host === '' || $user === '' || $remotePath === '') { if ($host === '' || $user === '' || $remotePath === '') {
$this->log('ERROR: Config must contain host, user, and remote_path.'); $this->log('ERROR', 'Config must contain host, user, and remote_path.');
return 1; return 1;
} }
// Create output directory // Create output directory
if (!is_dir($this->outputDir)) { if (!is_dir($outputDir)) {
if (!mkdir($this->outputDir, 0755, true)) { if (!mkdir($outputDir, 0755, true)) {
$this->log("ERROR: Could not create output directory: {$this->outputDir}"); $this->log('ERROR', "Could not create output directory: {$outputDir}");
return 1; return 1;
} }
} }
$this->log('Starting pre-deploy snapshot...'); $this->log('INFO', 'Starting pre-deploy snapshot...');
$this->log("Source: {$user}@{$host}:{$remotePath}"); $this->log('INFO', "Source: {$user}@{$host}:{$remotePath}");
$this->log("Output: {$this->outputDir}"); $this->log('INFO', "Output: {$outputDir}");
$failed = 0; $failed = 0;
foreach (self::JOOMLA_DIRS as $dir) { foreach (self::JOOMLA_DIRS as $dir) {
$remoteSource = "{$remotePath}/{$dir}/"; $remoteSource = "{$remotePath}/{$dir}/";
$localTarget = rtrim($this->outputDir, '/\\') . '/' . $dir . '/'; $localTarget = rtrim($outputDir, '/\\') . '/' . $dir . '/';
// Ensure local subdirectory exists // Ensure local subdirectory exists
if (!is_dir($localTarget)) { if (!is_dir($localTarget)) {
@@ -101,9 +110,9 @@ class BackupBeforeDeploy
$localTarget $localTarget
); );
$this->log("Downloading: {$dir}"); $this->log('INFO', "Downloading: {$dir}");
if ($this->verbose) { if ($this->verbose) {
$this->log("CMD: {$cmd}"); $this->log('INFO', "CMD: {$cmd}");
} }
$output = []; $output = [];
@@ -111,65 +120,45 @@ class BackupBeforeDeploy
exec($cmd, $output, $exitCode); exec($cmd, $output, $exitCode);
if ($exitCode !== 0) { if ($exitCode !== 0) {
$this->log("ERROR: rsync failed for {$dir} (exit code {$exitCode})"); $this->log('ERROR', "rsync failed for {$dir} (exit code {$exitCode})");
foreach ($output as $line) { foreach ($output as $line) {
$this->log(" {$line}"); $this->log('ERROR', " {$line}");
} }
$failed++; $failed++;
} else { } else {
if ($this->verbose) { if ($this->verbose) {
foreach ($output as $line) { foreach ($output as $line) {
$this->log(" {$line}"); $this->log('INFO', " {$line}");
} }
} }
} }
} }
if ($failed > 0) { if ($failed > 0) {
$this->log("Snapshot completed with {$failed} error(s)."); $this->log('ERROR', "Snapshot completed with {$failed} error(s).");
return 1; return 1;
} }
$this->log(''); $this->log('INFO', '');
$this->log('Snapshot completed successfully.'); $this->log('INFO', 'Snapshot completed successfully.');
$this->log("SNAPSHOT_PATH={$this->outputDir}"); $this->log('INFO', "SNAPSHOT_PATH={$outputDir}");
$this->log(''); $this->log('INFO', '');
$this->log('To rollback, run:'); $this->log('INFO', 'To rollback, run:');
$this->log(" php rollback-joomla.php --config {$this->configPath} --snapshot-dir {$this->outputDir}"); $this->log('INFO', " php rollback-joomla.php --config {$configPath} --snapshot-dir {$outputDir}");
return 0; return 0;
} }
private function parseArgs(): void
{
$args = $_SERVER['argv'] ?? [];
$count = count($args);
for ($i = 1; $i < $count; $i++) {
switch ($args[$i]) {
case '--config':
$this->configPath = $args[++$i] ?? '';
break;
case '--output':
$this->outputDir = $args[++$i] ?? '';
break;
case '--verbose':
$this->verbose = true;
break;
}
}
}
private function loadConfig(string $path): ?array private function loadConfig(string $path): ?array
{ {
if (!is_file($path)) { if (!is_file($path)) {
$this->log("ERROR: Config file not found: {$path}"); $this->log('ERROR', "Config file not found: {$path}");
return null; return null;
} }
$raw = file_get_contents($path); $raw = file_get_contents($path);
if ($raw === false) { if ($raw === false) {
$this->log("ERROR: Could not read config file: {$path}"); $this->log('ERROR', "Could not read config file: {$path}");
return null; return null;
} }
@@ -178,7 +167,7 @@ class BackupBeforeDeploy
$config = json_decode($cleaned, true); $config = json_decode($cleaned, true);
if (!is_array($config)) { if (!is_array($config)) {
$this->log('ERROR: Invalid JSON in config file.'); $this->log('ERROR', 'Invalid JSON in config file.');
return null; return null;
} }
@@ -200,13 +189,7 @@ class BackupBeforeDeploy
return implode(' ', $parts); return implode(' ', $parts);
} }
private function log(string $message): void
{
$timestamp = date('Y-m-d H:i:s');
fwrite(STDERR, "[{$timestamp}] {$message}" . PHP_EOL);
}
} }
$app = new BackupBeforeDeploy(); $app = new BackupBeforeDeployCli();
exit($app->run()); exit($app->execute());
+48 -67
View File
@@ -1,5 +1,6 @@
#!/usr/bin/env php #!/usr/bin/env php
<?php <?php
/* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech> /* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
* *
* This file is part of a Moko Consulting project. * This file is part of a Moko Consulting project.
@@ -7,21 +8,22 @@
* SPDX-License-Identifier: GPL-3.0-or-later * SPDX-License-Identifier: GPL-3.0-or-later
* *
* FILE INFORMATION * FILE INFORMATION
* DEFGROUP: MokoStandards.Scripts.Deploy * DEFGROUP: MokoPlatform.Scripts.Deploy
* INGROUP: MokoStandards * INGROUP: MokoPlatform
* REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform * REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform
* PATH: /deploy/deploy-dolibarr.php * PATH: /deploy/deploy-dolibarr.php
* VERSION: 09.21.00 * VERSION: 09.24.00
* BRIEF: Deploy Dolibarr module files to a remote server via SFTP/rsync * BRIEF: Deploy Dolibarr module files to a remote server via SFTP/rsync
*/ */
declare(strict_types=1); declare(strict_types=1);
class DeployDolibarr require_once __DIR__ . '/../lib/Enterprise/CliFramework.php';
use MokoEnterprise\CliFramework;
class DeployDolibarrCli extends CliFramework
{ {
private bool $verbose = false;
private bool $dryRun = false;
private string $configPath = '';
private string $source = ''; private string $source = '';
private const MODULE_DIRS = [ private const MODULE_DIRS = [
@@ -42,27 +44,35 @@ class DeployDolibarr
'node_modules/', 'node_modules/',
]; ];
public function run(): int protected function configure(): void
{ {
$this->parseArgs(); $this->setDescription('Deploy Dolibarr module files to a remote server via SFTP/rsync');
$this->addArgument('--source', 'Local source directory', '');
$this->addArgument('--config', 'Path to sftp-config.json', '');
}
if ($this->configPath === '' || $this->source === '') { protected function run(): int
$this->log('Usage: deploy-dolibarr.php --source <local-path> --config <sftp-config.json> [--dry-run] [--verbose]'); {
$configPath = $this->getArgument('--config');
$this->source = $this->getArgument('--source');
if ($configPath === '' || $this->source === '') {
$this->log('ERROR', 'Usage: deploy-dolibarr.php --source <local-path> --config <sftp-config.json> [--dry-run] [--verbose]');
return 1; return 1;
} }
if (!is_dir($this->source)) { if (!is_dir($this->source)) {
$this->log("ERROR: Source directory does not exist: {$this->source}"); $this->log('ERROR', "Source directory does not exist: {$this->source}");
return 1; return 1;
} }
$moduleName = $this->detectModuleName(); $moduleName = $this->detectModuleName();
if ($moduleName === null) { if ($moduleName === null) {
$this->log('ERROR: Could not auto-detect module name. Expected core/modules/mod*.class.php'); $this->log('ERROR', 'Could not auto-detect module name. Expected core/modules/mod*.class.php');
return 1; return 1;
} }
$config = $this->loadConfig($this->configPath); $config = $this->loadConfig($configPath);
if ($config === null) { if ($config === null) {
return 1; return 1;
} }
@@ -74,18 +84,18 @@ class DeployDolibarr
$sshKey = $config['ssh_key_file'] ?? ''; $sshKey = $config['ssh_key_file'] ?? '';
if ($host === '' || $user === '' || $remotePath === '') { if ($host === '' || $user === '' || $remotePath === '') {
$this->log('ERROR: Config must contain host, user, and remote_path.'); $this->log('ERROR', 'Config must contain host, user, and remote_path.');
return 1; return 1;
} }
$remoteBase = "{$remotePath}/htdocs/custom/{$moduleName}"; $remoteBase = "{$remotePath}/htdocs/custom/{$moduleName}";
$this->log("Deploying Dolibarr module: {$moduleName}"); $this->log('INFO', "Deploying Dolibarr module: {$moduleName}");
$this->log("Source: {$this->source}"); $this->log('INFO', "Source: {$this->source}");
$this->log("Target: {$user}@{$host}:{$remoteBase}"); $this->log('INFO', "Target: {$user}@{$host}:{$remoteBase}");
if ($this->dryRun) { if ($this->dryRun) {
$this->log('*** DRY RUN — no changes will be made ***'); $this->log('INFO', '*** DRY RUN — no changes will be made ***');
} }
$failed = 0; $failed = 0;
@@ -96,7 +106,7 @@ class DeployDolibarr
if (!is_dir($localDir)) { if (!is_dir($localDir)) {
if ($this->verbose) { if ($this->verbose) {
$this->log("SKIP: {$dir} (not present in source)"); $this->log('INFO', "SKIP: {$dir} (not present in source)");
} }
continue; continue;
} }
@@ -112,7 +122,7 @@ class DeployDolibarr
// Deploy root PHP files // Deploy root PHP files
$rootPhpFiles = glob(rtrim($this->source, '/\\') . '/*.php'); $rootPhpFiles = glob(rtrim($this->source, '/\\') . '/*.php');
if (!empty($rootPhpFiles)) { if (!empty($rootPhpFiles)) {
$this->log('Syncing root PHP files...'); $this->log('INFO', 'Syncing root PHP files...');
$sourceRoot = rtrim($this->source, '/\\') . '/'; $sourceRoot = rtrim($this->source, '/\\') . '/';
$remoteTarget = "{$remoteBase}/"; $remoteTarget = "{$remoteBase}/";
@@ -129,7 +139,7 @@ class DeployDolibarr
); );
if ($this->verbose) { if ($this->verbose) {
$this->log("CMD: {$cmd}"); $this->log('INFO', "CMD: {$cmd}");
} }
$output = []; $output = [];
@@ -137,52 +147,29 @@ class DeployDolibarr
exec($cmd, $output, $exitCode); exec($cmd, $output, $exitCode);
if ($exitCode !== 0) { if ($exitCode !== 0) {
$this->log("ERROR: rsync failed for root PHP files (exit code {$exitCode})"); $this->log('ERROR', "rsync failed for root PHP files (exit code {$exitCode})");
foreach ($output as $line) { foreach ($output as $line) {
$this->log(" {$line}"); $this->log('ERROR', " {$line}");
} }
$failed++; $failed++;
} else { } else {
if ($this->verbose) { if ($this->verbose) {
foreach ($output as $line) { foreach ($output as $line) {
$this->log(" {$line}"); $this->log('INFO', " {$line}");
} }
} }
} }
} }
if ($failed > 0) { if ($failed > 0) {
$this->log("Deployment completed with {$failed} error(s)."); $this->log('ERROR', "Deployment completed with {$failed} error(s).");
return 1; return 1;
} }
$this->log('Deployment completed successfully.'); $this->log('INFO', 'Deployment completed successfully.');
return 0; return 0;
} }
private function parseArgs(): void
{
$args = $_SERVER['argv'] ?? [];
$count = count($args);
for ($i = 1; $i < $count; $i++) {
switch ($args[$i]) {
case '--source':
$this->source = $args[++$i] ?? '';
break;
case '--config':
$this->configPath = $args[++$i] ?? '';
break;
case '--dry-run':
$this->dryRun = true;
break;
case '--verbose':
$this->verbose = true;
break;
}
}
}
private function detectModuleName(): ?string private function detectModuleName(): ?string
{ {
$pattern = rtrim($this->source, '/\\') . '/core/modules/mod*.class.php'; $pattern = rtrim($this->source, '/\\') . '/core/modules/mod*.class.php';
@@ -193,7 +180,7 @@ class DeployDolibarr
} }
$filename = basename($matches[0]); $filename = basename($matches[0]);
// mod{ModuleName}.class.php extract ModuleName, lowercase it // mod{ModuleName}.class.php -> extract ModuleName, lowercase it
if (preg_match('/^mod(.+)\.class\.php$/', $filename, $m)) { if (preg_match('/^mod(.+)\.class\.php$/', $filename, $m)) {
return strtolower($m[1]); return strtolower($m[1]);
} }
@@ -204,13 +191,13 @@ class DeployDolibarr
private function loadConfig(string $path): ?array private function loadConfig(string $path): ?array
{ {
if (!is_file($path)) { if (!is_file($path)) {
$this->log("ERROR: Config file not found: {$path}"); $this->log('ERROR', "Config file not found: {$path}");
return null; return null;
} }
$raw = file_get_contents($path); $raw = file_get_contents($path);
if ($raw === false) { if ($raw === false) {
$this->log("ERROR: Could not read config file: {$path}"); $this->log('ERROR', "Could not read config file: {$path}");
return null; return null;
} }
@@ -219,7 +206,7 @@ class DeployDolibarr
$config = json_decode($cleaned, true); $config = json_decode($cleaned, true);
if (!is_array($config)) { if (!is_array($config)) {
$this->log('ERROR: Invalid JSON in config file.'); $this->log('ERROR', 'Invalid JSON in config file.');
return null; return null;
} }
@@ -236,9 +223,9 @@ class DeployDolibarr
$cmd = $this->buildRsyncCommand($sshCmd, $localDir, "{$user}@{$host}:{$remoteTarget}"); $cmd = $this->buildRsyncCommand($sshCmd, $localDir, "{$user}@{$host}:{$remoteTarget}");
$this->log("Syncing: {$dirName}"); $this->log('INFO', "Syncing: {$dirName}");
if ($this->verbose) { if ($this->verbose) {
$this->log("CMD: {$cmd}"); $this->log('INFO', "CMD: {$cmd}");
} }
$output = []; $output = [];
@@ -246,16 +233,16 @@ class DeployDolibarr
exec($cmd, $output, $exitCode); exec($cmd, $output, $exitCode);
if ($exitCode !== 0) { if ($exitCode !== 0) {
$this->log("ERROR: rsync failed for {$dirName} (exit code {$exitCode})"); $this->log('ERROR', "rsync failed for {$dirName} (exit code {$exitCode})");
foreach ($output as $line) { foreach ($output as $line) {
$this->log(" {$line}"); $this->log('ERROR', " {$line}");
} }
return false; return false;
} }
if ($this->verbose) { if ($this->verbose) {
foreach ($output as $line) { foreach ($output as $line) {
$this->log(" {$line}"); $this->log('INFO', " {$line}");
} }
} }
@@ -289,13 +276,7 @@ class DeployDolibarr
return implode(' ', $parts); return implode(' ', $parts);
} }
private function log(string $message): void
{
$timestamp = date('Y-m-d H:i:s');
fwrite(STDERR, "[{$timestamp}] {$message}" . PHP_EOL);
}
} }
$app = new DeployDolibarr(); $app = new DeployDolibarrCli();
exit($app->run()); exit($app->execute());
+97 -107
View File
@@ -1,5 +1,6 @@
#!/usr/bin/env php #!/usr/bin/env php
<?php <?php
/* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech> /* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
* *
* This file is part of a Moko Consulting project. * This file is part of a Moko Consulting project.
@@ -7,8 +8,8 @@
* SPDX-License-Identifier: GPL-3.0-or-later * SPDX-License-Identifier: GPL-3.0-or-later
* *
* FILE INFORMATION * FILE INFORMATION
* DEFGROUP: MokoStandards.Scripts.Deploy * DEFGROUP: MokoPlatform.Scripts.Deploy
* INGROUP: MokoStandards * INGROUP: MokoPlatform
* REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform * REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform
* PATH: /deploy/deploy-joomla.php * PATH: /deploy/deploy-joomla.php
* BRIEF: Smart Joomla deploy — routes files to correct Joomla directories based on XML manifest * BRIEF: Smart Joomla deploy — routes files to correct Joomla directories based on XML manifest
@@ -27,7 +28,9 @@
declare(strict_types=1); declare(strict_types=1);
require_once __DIR__ . '/../vendor/autoload.php'; require_once __DIR__ . '/../vendor/autoload.php';
require_once __DIR__ . '/../lib/Enterprise/CliFramework.php';
use MokoEnterprise\CliFramework;
use phpseclib3\Net\SFTP; use phpseclib3\Net\SFTP;
use phpseclib3\Crypt\PublicKeyLoader; use phpseclib3\Crypt\PublicKeyLoader;
@@ -43,67 +46,103 @@ use phpseclib3\Crypt\PublicKeyLoader;
* Then maps src/ subdirectories to their correct Joomla server paths: * Then maps src/ subdirectories to their correct Joomla server paths:
* *
* Component: * Component:
* src/admin/ administrator/components/{element}/ * src/admin/ -> administrator/components/{element}/
* src/site/ components/{element}/ * src/site/ -> components/{element}/
* src/media/ media/{element}/ * src/media/ -> media/{element}/
* src/api/ api/components/{element}/ * src/api/ -> api/components/{element}/
* *
* Module: * Module:
* src/ modules/{element}/ (site) or administrator/modules/{element}/ (admin) * src/ -> modules/{element}/ (site) or administrator/modules/{element}/ (admin)
* src/media/ media/{element}/ * src/media/ -> media/{element}/
* *
* Plugin: * Plugin:
* src/ plugins/{group}/{name}/ * src/ -> plugins/{group}/{name}/
* src/media/ media/{element}/ * src/media/ -> media/{element}/
* *
* Template: * Template:
* src/ templates/{name}/ (site) or administrator/templates/{name}/ (admin) * src/ -> templates/{name}/ (site) or administrator/templates/{name}/ (admin)
* src/media/ media/templates/site/{name}/ or media/templates/administrator/{name}/ * src/media/ -> media/templates/site/{name}/ or media/templates/administrator/{name}/
* *
* Library: * Library:
* src/ libraries/{name}/ * src/ -> libraries/{name}/
* src/media/ media/{element}/ * src/media/ -> media/{element}/
*/ */
class DeployJoomla class DeployJoomlaCli extends CliFramework
{ {
private string $repoPath; private string $repoPath = '.';
private string $srcDir; private string $srcDir = 'src';
private array $config = []; private array $config = [];
private bool $dryRun = false;
private bool $verbose = false;
private int $uploaded = 0; private int $uploaded = 0;
private int $unchanged = 0; private int $unchanged = 0;
private int $skipped = 0; private int $skipped = 0;
private int $deleted = 0; private int $deleted = 0;
private array $ignorePatterns = []; private array $ignorePatterns = [];
public function run(): int protected function configure(): void
{ {
$this->parseArgs(); $this->setDescription('Smart Joomla deploy — routes files to correct Joomla directories based on XML manifest');
$this->addArgument('--path', 'Repository root path', '.');
$this->addArgument('--src-dir', 'Source directory relative to path', 'src');
$this->addArgument('--config', 'Path to sftp-config.json', '');
$this->addArgument('--key-passphrase', 'SSH key passphrase', '');
}
protected function run(): int
{
$this->repoPath = $this->getArgument('--path');
$this->srcDir = $this->getArgument('--src-dir');
$configPath = $this->getArgument('--config');
$keyPassphrase = $this->getArgument('--key-passphrase');
if ($keyPassphrase !== '') {
$this->config['key_passphrase'] = $keyPassphrase;
}
$this->repoPath = realpath($this->repoPath) ?: $this->repoPath;
// Resolve src dir
if (!str_starts_with($this->srcDir, '/')) {
$this->srcDir = "{$this->repoPath}/{$this->srcDir}";
}
// Try htdocs/ as fallback
if (!is_dir($this->srcDir) && is_dir("{$this->repoPath}/htdocs")) {
$this->srcDir = "{$this->repoPath}/htdocs";
}
// Load config
if ($configPath !== '' && file_exists($configPath)) {
$json = file_get_contents($configPath);
$json = preg_replace('#^\s*//.*$#m', '', $json);
$json = preg_replace('#,\s*([\]}])#', '$1', $json);
$parsed = json_decode($json, true);
if (is_array($parsed)) {
$this->config = array_merge($this->config, $parsed);
}
}
$manifest = $this->findManifest(); $manifest = $this->findManifest();
if ($manifest === null) { if ($manifest === null) {
$this->log("No Joomla XML manifest found in {$this->srcDir}", 'ERROR'); $this->log('ERROR', "No Joomla XML manifest found in {$this->srcDir}");
return 1; return 1;
} }
$ext = $this->parseManifest($manifest); $ext = $this->parseManifest($manifest);
if ($ext === null) { if ($ext === null) {
$this->log("Failed to parse manifest: {$manifest}", 'ERROR'); $this->log('ERROR', "Failed to parse manifest: {$manifest}");
return 1; return 1;
} }
$this->log("Extension: {$ext['type']} / {$ext['element']} (client: {$ext['client']})"); $this->log('INFO', "Extension: {$ext['type']} / {$ext['element']} (client: {$ext['client']})");
$deployMap = $this->buildDeployMap($ext); $deployMap = $this->buildDeployMap($ext);
if (empty($deployMap)) { if (empty($deployMap)) {
$this->log("No deploy mappings for extension type: {$ext['type']}", 'ERROR'); $this->log('ERROR', "No deploy mappings for extension type: {$ext['type']}");
return 1; return 1;
} }
$this->log("Deploy mappings:"); $this->log('INFO', "Deploy mappings:");
foreach ($deployMap as $map) { foreach ($deployMap as $map) {
$this->log(" {$map['local']} {$map['remote']}"); $this->log('INFO', " {$map['local']} -> {$map['remote']}");
} }
// Load ignore patterns // Load ignore patterns
@@ -113,7 +152,7 @@ class DeployJoomla
$this->checkManifestChange($ext, $manifest); $this->checkManifestChange($ext, $manifest);
if ($this->dryRun) { if ($this->dryRun) {
$this->log("[DRY RUN] Would deploy " . count($deployMap) . " mappings"); $this->log('INFO', "[DRY RUN] Would deploy " . count($deployMap) . " mappings");
foreach ($deployMap as $map) { foreach ($deployMap as $map) {
if (is_dir($map['local'])) { if (is_dir($map['local'])) {
$count = iterator_count( $count = iterator_count(
@@ -121,14 +160,14 @@ class DeployJoomla
new \RecursiveDirectoryIterator($map['local'], \FilesystemIterator::SKIP_DOTS) new \RecursiveDirectoryIterator($map['local'], \FilesystemIterator::SKIP_DOTS)
) )
); );
$this->log(" {$map['local']} ({$count} files) {$map['remote']}"); $this->log('INFO', " {$map['local']} ({$count} files) -> {$map['remote']}");
} }
} }
return 0; return 0;
} }
// Connect // Connect
$sftp = $this->connect(); $sftp = $this->connectSftp();
if ($sftp === null) { if ($sftp === null) {
return 1; return 1;
} }
@@ -137,14 +176,16 @@ class DeployJoomla
$errors = 0; $errors = 0;
foreach ($deployMap as $map) { foreach ($deployMap as $map) {
if (!is_dir($map['local'])) { if (!is_dir($map['local'])) {
$this->log(" SKIP: {$map['local']} (directory not found)", 'DEBUG'); if ($this->verbose) {
$this->log('INFO', " SKIP: {$map['local']} (directory not found)");
}
continue; continue;
} }
// Ensure remote directory exists // Ensure remote directory exists
$stat = @$sftp->stat($map['remote']); $stat = @$sftp->stat($map['remote']);
if ($stat === false) { if ($stat === false) {
$this->log(" MKDIR: {$map['remote']}"); $this->log('INFO', " MKDIR: {$map['remote']}");
$sftp->mkdir($map['remote'], -1, true); $sftp->mkdir($map['remote'], -1, true);
} }
@@ -160,10 +201,10 @@ class DeployJoomla
$manifestName = basename($manifest); $manifestName = basename($manifest);
$remoteDest = "{$adminRemote}/{$manifestName}"; $remoteDest = "{$adminRemote}/{$manifestName}";
$this->uploadFile($sftp, $manifest, $remoteDest); $this->uploadFile($sftp, $manifest, $remoteDest);
$this->log(" Manifest: {$manifestName} {$remoteDest}"); $this->log('INFO', " Manifest: {$manifestName} -> {$remoteDest}");
} }
$this->log("Done. Uploaded: {$this->uploaded}, Unchanged: {$this->unchanged}, Skipped: {$this->skipped}"); $this->log('INFO', "Done. Uploaded: {$this->uploaded}, Unchanged: {$this->unchanged}, Skipped: {$this->skipped}");
return $errors > 0 ? 1 : 0; return $errors > 0 ? 1 : 0;
} }
@@ -251,7 +292,7 @@ class DeployJoomla
} }
/** /**
* Build the localremote deploy mapping based on extension type. * Build the local->remote deploy mapping based on extension type.
* *
* @return array<int, array{local: string, remote: string}> * @return array<int, array{local: string, remote: string}>
*/ */
@@ -355,11 +396,11 @@ class DeployJoomla
private function checkManifestChange(array $ext, string $manifestPath): void private function checkManifestChange(array $ext, string $manifestPath): void
{ {
$manifestName = basename($manifestPath); $manifestName = basename($manifestPath);
$this->log(""); $this->log('INFO', '');
$this->log("NOTE: If {$manifestName} has changed (new fields, permissions, menu items,"); $this->log('INFO', "NOTE: If {$manifestName} has changed (new fields, permissions, menu items,");
$this->log(" database schema), you must reinstall the extension through Joomla."); $this->log('INFO', ' database schema), you must reinstall the extension through Joomla.');
$this->log(" Code changes (PHP, JS, CSS, language) do NOT require reinstall."); $this->log('INFO', ' Code changes (PHP, JS, CSS, language) do NOT require reinstall.');
$this->log(""); $this->log('INFO', '');
} }
/** /**
@@ -428,10 +469,10 @@ class DeployJoomla
if ($result) { if ($result) {
$this->uploaded++; $this->uploaded++;
if ($this->verbose) { if ($this->verbose) {
$this->log(" UPLOAD: {$remotePath}"); $this->log('INFO', " UPLOAD: {$remotePath}");
} }
} else { } else {
$this->log(" FAIL: {$remotePath}", 'ERROR'); $this->log('ERROR', " FAIL: {$remotePath}");
} }
return $result; return $result;
} }
@@ -464,10 +505,14 @@ class DeployJoomla
$patterns = []; $patterns = [];
foreach ([$this->srcDir, $this->repoPath] as $dir) { foreach ([$this->srcDir, $this->repoPath] as $dir) {
$file = "{$dir}/.ftpignore"; $file = "{$dir}/.ftpignore";
if (!file_exists($file)) { continue; } if (!file_exists($file)) {
continue;
}
foreach (file($file, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES) as $line) { foreach (file($file, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES) as $line) {
$line = trim($line); $line = trim($line);
if ($line === '' || str_starts_with($line, '#')) { continue; } if ($line === '' || str_starts_with($line, '#')) {
continue;
}
// Convert glob to regex // Convert glob to regex
$regex = str_replace(['.', '*', '?'], ['\\.', '.*', '.'], $line); $regex = str_replace(['.', '*', '?'], ['\\.', '.*', '.'], $line);
$patterns[] = "#^{$regex}(/|$)#i"; $patterns[] = "#^{$regex}(/|$)#i";
@@ -479,13 +524,13 @@ class DeployJoomla
/** /**
* Connect to the SFTP server. * Connect to the SFTP server.
*/ */
private function connect(): ?SFTP private function connectSftp(): ?SFTP
{ {
$host = (string) $this->config['host']; $host = (string) $this->config['host'];
$port = (int) ($this->config['port'] ?? 22); $port = (int) ($this->config['port'] ?? 22);
$user = (string) $this->config['user']; $user = (string) $this->config['user'];
$this->log("Connecting to {$user}@{$host}:{$port}..."); $this->log('INFO', "Connecting to {$user}@{$host}:{$port}...");
$sftp = new SFTP($host, $port, 30); $sftp = new SFTP($host, $port, 30);
@@ -499,80 +544,25 @@ class DeployJoomla
$passphrase = $this->config['key_passphrase'] ?? ''; $passphrase = $this->config['key_passphrase'] ?? '';
$key = PublicKeyLoader::load(file_get_contents($keyPath), $passphrase); $key = PublicKeyLoader::load(file_get_contents($keyPath), $passphrase);
if ($sftp->login($user, $key)) { if ($sftp->login($user, $key)) {
$this->log("Connected via SSH key"); $this->log('INFO', 'Connected via SSH key');
return $sftp; return $sftp;
} }
$this->log("Key auth failed", 'WARN'); $this->warning('Key auth failed');
} }
} }
// Fallback to password // Fallback to password
if (!empty($this->config['password'])) { if (!empty($this->config['password'])) {
if ($sftp->login($user, $this->config['password'])) { if ($sftp->login($user, $this->config['password'])) {
$this->log("Connected via password"); $this->log('INFO', 'Connected via password');
return $sftp; return $sftp;
} }
} }
$this->log("Authentication failed", 'ERROR'); $this->log('ERROR', 'Authentication failed');
return null; return null;
} }
/**
* Parse CLI arguments.
*/
private function parseArgs(): void
{
global $argv;
$this->repoPath = '.';
$this->srcDir = 'src';
$configPath = null;
foreach ($argv as $i => $arg) {
if ($arg === '--path' && isset($argv[$i + 1])) { $this->repoPath = $argv[$i + 1]; }
if ($arg === '--src-dir' && isset($argv[$i + 1])) { $this->srcDir = $argv[$i + 1]; }
if ($arg === '--config' && isset($argv[$i + 1])) { $configPath = $argv[$i + 1]; }
if ($arg === '--key-passphrase' && isset($argv[$i + 1])) { $this->config['key_passphrase'] = $argv[$i + 1]; }
if ($arg === '--dry-run') { $this->dryRun = true; }
if ($arg === '--verbose') { $this->verbose = true; }
}
$this->repoPath = realpath($this->repoPath) ?: $this->repoPath;
// Resolve src dir
if (!str_starts_with($this->srcDir, '/')) {
$this->srcDir = "{$this->repoPath}/{$this->srcDir}";
}
// Try htdocs/ as fallback
if (!is_dir($this->srcDir) && is_dir("{$this->repoPath}/htdocs")) {
$this->srcDir = "{$this->repoPath}/htdocs";
}
// Load config
if ($configPath && file_exists($configPath)) {
$json = file_get_contents($configPath);
$json = preg_replace('#^\s*//.*$#m', '', $json);
$json = preg_replace('#,\s*([\]}])#', '$1', $json);
$parsed = json_decode($json, true);
if (is_array($parsed)) {
$this->config = array_merge($this->config, $parsed);
}
}
}
private function log(string $msg, string $level = 'INFO'): void
{
$prefix = match ($level) {
'ERROR' => 'ERROR: ',
'WARN' => 'WARN: ',
'DEBUG' => $this->verbose ? '' : null,
default => '',
};
if ($prefix === null) { return; }
fwrite($level === 'ERROR' ? STDERR : STDOUT, "{$prefix}{$msg}\n");
}
} }
$deploy = new DeployJoomla(); $app = new DeployJoomlaCli();
exit($deploy->run()); exit($app->execute());
+49 -135
View File
@@ -1,5 +1,6 @@
#!/usr/bin/env php #!/usr/bin/env php
<?php <?php
/* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech> /* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
* *
* This file is part of a Moko Consulting project. * This file is part of a Moko Consulting project.
@@ -7,8 +8,8 @@
* SPDX-License-Identifier: GPL-3.0-or-later * SPDX-License-Identifier: GPL-3.0-or-later
* *
* FILE INFORMATION * FILE INFORMATION
* DEFGROUP: MokoStandards.Scripts.Deploy * DEFGROUP: MokoPlatform.Scripts.Deploy
* INGROUP: MokoStandards * INGROUP: MokoPlatform
* REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform * REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform
* PATH: /deploy/deploy-sftp.php * PATH: /deploy/deploy-sftp.php
* BRIEF: Deploy a repository src/ directory to a remote web server via SFTP * BRIEF: Deploy a repository src/ directory to a remote web server via SFTP
@@ -18,7 +19,9 @@ declare(strict_types=1);
require_once __DIR__ . '/../vendor/autoload.php'; require_once __DIR__ . '/../vendor/autoload.php';
use MokoEnterprise\CLIApp; require_once __DIR__ . '/../lib/Enterprise/CliFramework.php';
use MokoEnterprise\CliFramework;
use phpseclib3\Net\SFTP; use phpseclib3\Net\SFTP;
use phpseclib3\Crypt\PublicKeyLoader; use phpseclib3\Crypt\PublicKeyLoader;
@@ -29,7 +32,7 @@ use phpseclib3\Crypt\PublicKeyLoader;
* format with // comments stripped) and recursively uploads the src/ * format with // comments stripped) and recursively uploads the src/
* directory of a repository to the configured remote path. * directory of a repository to the configured remote path.
*/ */
class DeploySftp extends CLIApp class DeploySftp extends CliFramework
{ {
/** @var array<string,mixed> Parsed sftp-config.json contents */ /** @var array<string,mixed> Parsed sftp-config.json contents */
private array $config = []; private array $config = [];
@@ -46,97 +49,14 @@ class DeploySftp extends CLIApp
/** @var int Count of remote files deleted (smart deploy) */ /** @var int Count of remote files deleted (smart deploy) */
private int $deleted = 0; private int $deleted = 0;
public function __construct() protected function configure(): void
{ {
parent::__construct( $this->setDescription('Deploy a repository src/ directory to a remote web server via SFTP');
'deploy-sftp', $this->addArgument('--path', 'Repository root (default: current directory)', '.');
'Deploy a repository src/ directory to a remote web server via SFTP', $this->addArgument('--src-dir', 'Source sub-directory to upload (default: src)', 'src');
'04.00.15' $this->addArgument('--env', 'Target environment: dev or rs', '');
); $this->addArgument('--config', 'Explicit config file path — overrides --env', '');
} $this->addArgument('--key-passphrase', 'Passphrase for the SSH private key', '');
/**
* Print full help including usage examples.
*
* Overrides CLIApp::printHelp() to add an EXAMPLES section and
* document the scripts/keys/ key-resolution convention.
*/
protected function printHelp(): void
{
parent::printHelp();
echo <<<'DEPLOY_SFTP_HELP'
ARGUMENTS
--path <dir> Repository root (default: current directory).
--src-dir <dir> Sub-directory inside the repo to upload (default: src).
--env <dev|rs> Target environment. Selects the named config file:
dev → {path}/scripts/sftp-config/sftp-config.dev.json
rs → {path}/scripts/sftp-config/sftp-config.rs.json
--config <file> Explicit config path — overrides --env and auto-lookup.
--key-passphrase <pw> Passphrase for the SSH private key if it is encrypted.
DIRECTORY LAYOUT (gitignored — create locally from templates/scripts/deploy/)
{repo_root}/
scripts/
sftp-config/ ← gitignored; place sftp-config.{env}.json files here
keys/ ← gitignored; place .ppk / PEM key files here
KEY RESOLUTION
ssh_key_file in sftp-config.json may be an absolute path or a bare filename.
When it is not absolute the script looks for the key under:
{path}/scripts/keys/{filename}
before falling back to the raw value as a relative path from CWD.
Supported key formats: PuTTY .ppk | OpenSSH PEM (via phpseclib)
CONFIG FORMAT
sftp-config.json follows Sublime Text SFTP plugin conventions.
// line comments and trailing commas are stripped before parsing.
EXAMPLES
# Dry-run preview of dev deployment
php deploy/deploy-sftp.php --env dev --dry-run --verbose
# Deploy to dev server
php deploy/deploy-sftp.php --path /repos/mymodule --env dev
# Deploy to release/production server
php deploy/deploy-sftp.php --path /repos/mymodule --env rs
# Use a different source directory
php deploy/deploy-sftp.php --env dev --src-dir htdocs
# Explicit config with encrypted key
php deploy/deploy-sftp.php \
--path /repos/mymodule \
--env rs \
--key-passphrase "my passphrase"
# Quiet mode (errors only)
php deploy/deploy-sftp.php --env dev --quiet
EXIT CODES
0 All files uploaded successfully
1 Connection failed or one or more files could not be uploaded
2 Invalid arguments or config file error
DEPLOY_SFTP_HELP;
}
/**
* Register script-specific CLI arguments.
*
* @return array<string,string> Option spec => description
*/
protected function setupArguments(): array
{
return [
'path:' => 'Path to the repository to deploy (default: current directory)',
'src-dir:' => 'Source sub-directory to upload (default: src)',
'env:' => 'Target environment: dev (sftp-config.dev.json) or rs (sftp-config.rs.json)',
'config:' => 'Explicit config file path — overrides --env and default lookup',
'key-passphrase:' => 'Passphrase for the SSH private key file (if required)',
];
} }
/** /**
@@ -198,7 +118,7 @@ DEPLOY_SFTP_HELP;
if (!$dirExists) { if (!$dirExists) {
$this->log("Remote directory not found — creating: {$remotePath}"); $this->log("Remote directory not found — creating: {$remotePath}");
if (!$sftp->mkdir($remotePath, -1, true)) { if (!$sftp->mkdir($remotePath, -1, true)) {
$this->log("Failed to create remote directory: {$remotePath}", 'ERROR'); $this->log('ERROR', "Failed to create remote directory: {$remotePath}");
return 1; return 1;
} }
} }
@@ -220,12 +140,11 @@ DEPLOY_SFTP_HELP;
*/ */
private function resolveRepoPath(): string private function resolveRepoPath(): string
{ {
$raw = $this->getOption('path', '.'); $raw = $this->getArgument('--path', '.');
$path = realpath($raw); $path = realpath($raw);
if ($path === false || !is_dir($path)) { if ($path === false || !is_dir($path)) {
$this->log("Repository path does not exist or is not a directory: {$raw}", 'ERROR'); $this->error("Repository path does not exist or is not a directory: {$raw}");
exit(1);
} }
return $path; return $path;
@@ -239,12 +158,11 @@ DEPLOY_SFTP_HELP;
*/ */
private function resolveSrcDir(string $repoPath): string private function resolveSrcDir(string $repoPath): string
{ {
$sub = $this->getOption('src-dir', 'src'); $sub = $this->getArgument('--src-dir', 'src');
$dir = $repoPath . DIRECTORY_SEPARATOR . $sub; $dir = $repoPath . DIRECTORY_SEPARATOR . $sub;
if (!is_dir($dir)) { if (!is_dir($dir)) {
$this->log("Source directory does not exist: {$dir}", 'ERROR'); $this->error("Source directory does not exist: {$dir}");
exit(1);
} }
return $dir; return $dir;
@@ -272,30 +190,27 @@ DEPLOY_SFTP_HELP;
$configDir = $repoPath . DIRECTORY_SEPARATOR . 'scripts' . DIRECTORY_SEPARATOR . 'sftp-config'; $configDir = $repoPath . DIRECTORY_SEPARATOR . 'scripts' . DIRECTORY_SEPARATOR . 'sftp-config';
// 1. Explicit --config wins unconditionally // 1. Explicit --config wins unconditionally
$explicit = $this->getOption('config', null); $explicit = $this->getArgument('--config') ?: null;
if ($explicit !== null) { if ($explicit !== null) {
$path = realpath($explicit); $path = realpath($explicit);
if ($path === false) { if ($path === false) {
$this->log("Config file not found: {$explicit}", 'ERROR'); $this->error("Config file not found: {$explicit}");
exit(1);
} }
return $path; return $path;
} }
// 2. --env selects the named config file // 2. --env selects the named config file
$env = $this->getOption('env', null); $env = $this->getArgument('--env') ?: null;
if ($env !== null) { if ($env !== null) {
$env = strtolower((string) $env); $env = strtolower((string) $env);
if (!isset(self::ENV_CONFIG_MAP[$env])) { if (!isset(self::ENV_CONFIG_MAP[$env])) {
$valid = implode(', ', array_keys(self::ENV_CONFIG_MAP)); $valid = implode(', ', array_keys(self::ENV_CONFIG_MAP));
$this->log("Unknown --env value '{$env}'. Valid values: {$valid}", 'ERROR'); $this->error("Unknown --env value '{$env}'. Valid values: {$valid}", self::EXIT_USAGE);
exit(2);
} }
$envConfig = $configDir . DIRECTORY_SEPARATOR . self::ENV_CONFIG_MAP[$env]; $envConfig = $configDir . DIRECTORY_SEPARATOR . self::ENV_CONFIG_MAP[$env];
if (!file_exists($envConfig)) { if (!file_exists($envConfig)) {
$this->log("Config file not found for --env {$env}: {$envConfig}", 'ERROR'); $this->log('ERROR', "Copy templates/scripts/deploy/sftp-config.{$env}.json.example → {$envConfig}");
$this->log("Copy templates/scripts/deploy/sftp-config.{$env}.json.example → {$envConfig}", 'ERROR'); $this->error("Config file not found for --env {$env}: {$envConfig}");
exit(1);
} }
return $envConfig; return $envConfig;
} }
@@ -303,9 +218,8 @@ DEPLOY_SFTP_HELP;
// 3. Generic fallback // 3. Generic fallback
$default = $configDir . DIRECTORY_SEPARATOR . 'sftp-config.json'; $default = $configDir . DIRECTORY_SEPARATOR . 'sftp-config.json';
if (!file_exists($default)) { if (!file_exists($default)) {
$this->log("No config file found. Tried: {$default}", 'ERROR'); $this->log('ERROR', "Use --env dev, --env rs, or --config <path>.");
$this->log("Use --env dev, --env rs, or --config <path>.", 'ERROR'); $this->error("No config file found. Tried: {$default}");
exit(1);
} }
return $default; return $default;
@@ -324,7 +238,7 @@ DEPLOY_SFTP_HELP;
{ {
$raw = file_get_contents($configPath); $raw = file_get_contents($configPath);
if ($raw === false) { if ($raw === false) {
$this->log("Cannot read config file: {$configPath}", 'ERROR'); $this->log('ERROR', "Cannot read config file: {$configPath}");
return false; return false;
} }
@@ -336,7 +250,7 @@ DEPLOY_SFTP_HELP;
$decoded = json_decode($stripped ?? '', true); $decoded = json_decode($stripped ?? '', true);
if (json_last_error() !== JSON_ERROR_NONE) { if (json_last_error() !== JSON_ERROR_NONE) {
$this->log("Failed to parse config file: " . json_last_error_msg(), 'ERROR'); $this->log('ERROR', "Failed to parse config file: " . json_last_error_msg());
return false; return false;
} }
@@ -365,7 +279,7 @@ DEPLOY_SFTP_HELP;
} }
if (!empty($missing)) { if (!empty($missing)) {
$this->log("Missing required config fields: " . implode(', ', $missing), 'ERROR'); $this->log('ERROR', "Missing required config fields: " . implode(', ', $missing));
return false; return false;
} }
@@ -400,7 +314,7 @@ DEPLOY_SFTP_HELP;
return []; return [];
} }
$this->log("Loading ignore rules from .ftpignore", 'DEBUG'); $this->log('DEBUG', "Loading ignore rules from .ftpignore");
$patterns = []; $patterns = [];
$lines = file($ignoreFile, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES); $lines = file($ignoreFile, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES);
@@ -417,7 +331,7 @@ DEPLOY_SFTP_HELP;
// Negation patterns (!) are not supported — log and skip // Negation patterns (!) are not supported — log and skip
if (str_starts_with($line, '!')) { if (str_starts_with($line, '!')) {
$this->log(" .ftpignore: negation patterns not supported, skipping: {$line}", 'DEBUG'); $this->log('DEBUG', " .ftpignore: negation patterns not supported, skipping: {$line}");
continue; continue;
} }
@@ -437,7 +351,7 @@ DEPLOY_SFTP_HELP;
$regex = $anchored ? '^' . $regex : '(^|/)' . $regex; $regex = $anchored ? '^' . $regex : '(^|/)' . $regex;
$patterns[] = $regex . '(/|$)'; $patterns[] = $regex . '(/|$)';
$this->log(" .ftpignore rule: {$line} → /{$regex}/i", 'DEBUG'); $this->log('DEBUG', " .ftpignore rule: {$line} → /{$regex}/i");
} }
return $patterns; return $patterns;
@@ -457,7 +371,7 @@ DEPLOY_SFTP_HELP;
try { try {
$sftp = new SFTP($host, $port, timeout: 30); $sftp = new SFTP($host, $port, timeout: 30);
} catch (\Throwable $e) { } catch (\Throwable $e) {
$this->log("Cannot reach {$host}:{$port}" . $e->getMessage(), 'ERROR'); $this->log('ERROR', "Cannot reach {$host}:{$port}" . $e->getMessage());
return null; return null;
} }
@@ -465,14 +379,14 @@ DEPLOY_SFTP_HELP;
if (!empty($rawKeyFile)) { if (!empty($rawKeyFile)) {
$keyFile = $this->resolveKeyPath((string) $rawKeyFile, $repoPath); $keyFile = $this->resolveKeyPath((string) $rawKeyFile, $repoPath);
$this->log("Using SSH key: {$keyFile}", 'DEBUG'); $this->log('DEBUG', "Using SSH key: {$keyFile}");
return $this->authenticateWithKey($sftp, $user, $keyFile); return $this->authenticateWithKey($sftp, $user, $keyFile);
} }
// Password fallback // Password fallback
$password = (string) ($this->config['password'] ?? ''); $password = (string) ($this->config['password'] ?? '');
if (!$sftp->login($user, $password)) { if (!$sftp->login($user, $password)) {
$this->log("SFTP password authentication failed for {$user}@{$host}", 'ERROR'); $this->log('ERROR', "SFTP password authentication failed for {$user}@{$host}");
return null; return null;
} }
@@ -520,11 +434,11 @@ DEPLOY_SFTP_HELP;
private function authenticateWithKey(SFTP $sftp, string $user, string $keyFile): ?SFTP private function authenticateWithKey(SFTP $sftp, string $user, string $keyFile): ?SFTP
{ {
if (!file_exists($keyFile)) { if (!file_exists($keyFile)) {
$this->log("SSH key file not found: {$keyFile}", 'ERROR'); $this->log('ERROR', "SSH key file not found: {$keyFile}");
return null; return null;
} }
$passphrase = $this->getOption('key-passphrase', null); $passphrase = $this->getArgument('--key-passphrase') ?: null;
try { try {
$keyData = file_get_contents($keyFile); $keyData = file_get_contents($keyFile);
@@ -536,12 +450,12 @@ DEPLOY_SFTP_HELP;
? PublicKeyLoader::load($keyData, $passphrase) ? PublicKeyLoader::load($keyData, $passphrase)
: PublicKeyLoader::load($keyData); : PublicKeyLoader::load($keyData);
} catch (\Throwable $e) { } catch (\Throwable $e) {
$this->log("Failed to load SSH key: " . $e->getMessage(), 'ERROR'); $this->log('ERROR', "Failed to load SSH key: " . $e->getMessage());
return null; return null;
} }
if (!$sftp->login($user, $key)) { if (!$sftp->login($user, $key)) {
$this->log("SFTP key authentication failed for {$user}", 'ERROR'); $this->log('ERROR', "SFTP key authentication failed for {$user}");
return null; return null;
} }
@@ -591,7 +505,7 @@ DEPLOY_SFTP_HELP;
): int { ): int {
$entries = scandir($localDir); $entries = scandir($localDir);
if ($entries === false) { if ($entries === false) {
$this->log("Cannot read directory: {$localDir}", 'ERROR'); $this->log('ERROR', "Cannot read directory: {$localDir}");
return 1; return 1;
} }
@@ -604,7 +518,7 @@ DEPLOY_SFTP_HELP;
} }
if (str_starts_with($entry, '.')) { if (str_starts_with($entry, '.')) {
$this->log(" SKIP {$entry} (dotfile)", 'DEBUG'); $this->log('DEBUG', " SKIP {$entry} (dotfile)");
$this->skipped++; $this->skipped++;
continue; continue;
} }
@@ -614,7 +528,7 @@ DEPLOY_SFTP_HELP;
$relative = ltrim(str_replace($srcRoot, '', $localEntry), DIRECTORY_SEPARATOR . '/'); $relative = ltrim(str_replace($srcRoot, '', $localEntry), DIRECTORY_SEPARATOR . '/');
if ($this->shouldIgnore($relative, $ignorePatterns)) { if ($this->shouldIgnore($relative, $ignorePatterns)) {
$this->log(" SKIP {$relative}", 'DEBUG'); $this->log('DEBUG', " SKIP {$relative}");
$this->skipped++; $this->skipped++;
continue; continue;
} }
@@ -624,7 +538,7 @@ DEPLOY_SFTP_HELP;
if (is_dir($localEntry)) { if (is_dir($localEntry)) {
$stat = @$sftp->stat($remoteEntry); $stat = @$sftp->stat($remoteEntry);
if ($stat === false) { if ($stat === false) {
$this->log(" MKDIR {$remoteEntry}", 'DEBUG'); $this->log('DEBUG', " MKDIR {$remoteEntry}");
$sftp->mkdir($remoteEntry, -1, true); $sftp->mkdir($remoteEntry, -1, true);
} }
$result = $this->uploadDirectory($sftp, $localEntry, $remoteEntry, $srcRoot, $ignorePatterns); $result = $this->uploadDirectory($sftp, $localEntry, $remoteEntry, $srcRoot, $ignorePatterns);
@@ -634,14 +548,14 @@ DEPLOY_SFTP_HELP;
} else { } else {
// Smart diff: compare local vs remote before uploading // Smart diff: compare local vs remote before uploading
if ($this->isFileUnchanged($sftp, $localEntry, $remoteEntry)) { if ($this->isFileUnchanged($sftp, $localEntry, $remoteEntry)) {
$this->log(" SAME {$relative}", 'DEBUG'); $this->log('DEBUG', " SAME {$relative}");
$this->unchanged++; $this->unchanged++;
continue; continue;
} }
$this->log(" PUT {$relative}{$remoteEntry}"); $this->log(" PUT {$relative}{$remoteEntry}");
if (!$sftp->put($remoteEntry, $localEntry, SFTP::SOURCE_LOCAL_FILE)) { if (!$sftp->put($remoteEntry, $localEntry, SFTP::SOURCE_LOCAL_FILE)) {
$this->log("Failed to upload: {$relative}", 'ERROR'); $this->log('ERROR', "Failed to upload: {$relative}");
return 1; return 1;
} }
$this->uploaded++; $this->uploaded++;
@@ -751,7 +665,7 @@ DEPLOY_SFTP_HELP;
): int { ): int {
$entries = scandir($localDir); $entries = scandir($localDir);
if ($entries === false) { if ($entries === false) {
$this->log("Cannot read directory: {$localDir}", 'ERROR'); $this->log('ERROR', "Cannot read directory: {$localDir}");
return 1; return 1;
} }
@@ -789,5 +703,5 @@ DEPLOY_SFTP_HELP;
} }
} }
$script = new DeploySftp(); $app = new DeploySftp();
exit($script->execute()); exit($app->execute());
+38 -49
View File
@@ -1,5 +1,6 @@
#!/usr/bin/env php #!/usr/bin/env php
<?php <?php
/* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech> /* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
* *
* This file is part of a Moko Consulting project. * This file is part of a Moko Consulting project.
@@ -7,17 +8,21 @@
* SPDX-License-Identifier: GPL-3.0-or-later * SPDX-License-Identifier: GPL-3.0-or-later
* *
* FILE INFORMATION * FILE INFORMATION
* DEFGROUP: MokoStandards.Scripts.Deploy * DEFGROUP: MokoPlatform.Scripts.Deploy
* INGROUP: MokoStandards * INGROUP: MokoPlatform
* REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform * REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform
* PATH: /deploy/health-check.php * PATH: /deploy/health-check.php
* VERSION: 09.21.00 * VERSION: 09.24.00
* BRIEF: Post-deploy health check — verify a Joomla site is responding correctly * BRIEF: Post-deploy health check — verify a Joomla site is responding correctly
*/ */
declare(strict_types=1); declare(strict_types=1);
class HealthCheck require_once __DIR__ . '/../lib/Enterprise/CliFramework.php';
use MokoEnterprise\CliFramework;
class HealthCheckCli extends CliFramework
{ {
private string $url = ''; private string $url = '';
private int $timeout = 30; private int $timeout = 30;
@@ -26,21 +31,32 @@ class HealthCheck
private int $passed = 0; private int $passed = 0;
private int $failed = 0; private int $failed = 0;
public function run(): int protected function configure(): void
{ {
$this->parseArgs(); $this->setDescription('Post-deploy health check — verify a Joomla site is responding correctly');
$this->addArgument('--url', 'Site URL to check', '');
$this->addArgument('--timeout', 'Request timeout in seconds', '30');
$this->addArgument('--checks', 'Comma-separated list of checks: http,admin,api', 'http');
}
protected function run(): int
{
$this->url = $this->getArgument('--url');
$this->timeout = (int) $this->getArgument('--timeout');
$checksRaw = $this->getArgument('--checks');
$this->checks = array_map('trim', explode(',', $checksRaw));
if ($this->url === '') { if ($this->url === '') {
$this->log('Usage: health-check.php --url <site-url> [--timeout <seconds>] [--checks <http,admin,api>]'); $this->log('ERROR', 'Usage: health-check.php --url <site-url> [--timeout <seconds>] [--checks <http,admin,api>]');
return 1; return 1;
} }
$this->url = rtrim($this->url, '/'); $this->url = rtrim($this->url, '/');
$this->log("Health check for: {$this->url}"); $this->log('INFO', "Health check for: {$this->url}");
$this->log("Timeout: {$this->timeout}s"); $this->log('INFO', "Timeout: {$this->timeout}s");
$this->log("Checks: " . implode(', ', $this->checks)); $this->log('INFO', "Checks: " . implode(', ', $this->checks));
$this->log(''); $this->log('INFO', '');
foreach ($this->checks as $check) { foreach ($this->checks as $check) {
switch ($check) { switch ($check) {
@@ -54,41 +70,20 @@ class HealthCheck
$this->checkApi(); $this->checkApi();
break; break;
default: default:
$this->log("UNKNOWN CHECK: {$check} — skipping"); $this->log('WARN', "UNKNOWN CHECK: {$check} — skipping");
break; break;
} }
} }
$this->log(''); $this->log('INFO', '');
$this->log("Results: {$this->passed} passed, {$this->failed} failed"); $this->log('INFO', "Results: {$this->passed} passed, {$this->failed} failed");
return $this->failed > 0 ? 1 : 0; return $this->failed > 0 ? 1 : 0;
} }
private function parseArgs(): void
{
$args = $_SERVER['argv'] ?? [];
$count = count($args);
for ($i = 1; $i < $count; $i++) {
switch ($args[$i]) {
case '--url':
$this->url = $args[++$i] ?? '';
break;
case '--timeout':
$this->timeout = (int) ($args[++$i] ?? 30);
break;
case '--checks':
$raw = $args[++$i] ?? 'http';
$this->checks = array_map('trim', explode(',', $raw));
break;
}
}
}
private function checkHttp(): void private function checkHttp(): void
{ {
$this->log('[http] GET ' . $this->url); $this->log('INFO', '[http] GET ' . $this->url);
$result = $this->curlGet($this->url); $result = $this->curlGet($this->url);
@@ -113,7 +108,7 @@ class HealthCheck
private function checkAdmin(): void private function checkAdmin(): void
{ {
$adminUrl = $this->url . '/administrator/'; $adminUrl = $this->url . '/administrator/';
$this->log('[admin] GET ' . $adminUrl); $this->log('INFO', '[admin] GET ' . $adminUrl);
$result = $this->curlGet($adminUrl); $result = $this->curlGet($adminUrl);
@@ -133,7 +128,7 @@ class HealthCheck
private function checkApi(): void private function checkApi(): void
{ {
$apiUrl = $this->url . '/api/index.php/v1'; $apiUrl = $this->url . '/api/index.php/v1';
$this->log('[api] GET ' . $apiUrl); $this->log('INFO', '[api] GET ' . $apiUrl);
$result = $this->curlGet($apiUrl); $result = $this->curlGet($apiUrl);
@@ -169,7 +164,7 @@ class HealthCheck
if (curl_errno($ch)) { if (curl_errno($ch)) {
$error = curl_error($ch); $error = curl_error($ch);
$this->log(" cURL error: {$error}"); $this->log('ERROR', " cURL error: {$error}");
curl_close($ch); curl_close($ch);
return null; return null;
} }
@@ -207,21 +202,15 @@ class HealthCheck
private function pass(string $check, string $message): void private function pass(string $check, string $message): void
{ {
$this->passed++; $this->passed++;
$this->log("[{$check}] PASS: {$message}"); $this->log('INFO', "[{$check}] PASS: {$message}");
} }
private function fail(string $check, string $message): void private function fail(string $check, string $message): void
{ {
$this->failed++; $this->failed++;
$this->log("[{$check}] FAIL: {$message}"); $this->log('ERROR', "[{$check}] FAIL: {$message}");
}
private function log(string $message): void
{
$timestamp = date('Y-m-d H:i:s');
fwrite(STDERR, "[{$timestamp}] {$message}" . PHP_EOL);
} }
} }
$app = new HealthCheck(); $app = new HealthCheckCli();
exit($app->run()); exit($app->execute());
+43 -85
View File
@@ -1,5 +1,6 @@
#!/usr/bin/env php #!/usr/bin/env php
<?php <?php
/* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech> /* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
* *
* This file is part of a Moko Consulting project. * This file is part of a Moko Consulting project.
@@ -7,23 +8,22 @@
* SPDX-License-Identifier: GPL-3.0-or-later * SPDX-License-Identifier: GPL-3.0-or-later
* *
* FILE INFORMATION * FILE INFORMATION
* DEFGROUP: MokoStandards.Scripts.Deploy * DEFGROUP: MokoPlatform.Scripts.Deploy
* INGROUP: MokoStandards * INGROUP: MokoPlatform
* REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform * REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform
* PATH: /deploy/rollback-joomla.php * PATH: /deploy/rollback-joomla.php
* VERSION: 09.21.00 * VERSION: 09.24.00
* BRIEF: Rollback a Joomla deployment by restoring from a pre-deploy snapshot * BRIEF: Rollback a Joomla deployment by restoring from a pre-deploy snapshot
*/ */
declare(strict_types=1); declare(strict_types=1);
class RollbackJoomla require_once __DIR__ . '/../lib/Enterprise/CliFramework.php';
{
private bool $verbose = false;
private bool $dryRun = false;
private string $configPath = '';
private string $snapshotDir = '';
use MokoEnterprise\CliFramework;
class RollbackJoomlaCli extends CliFramework
{
private const JOOMLA_DIRS = [ private const JOOMLA_DIRS = [
'administrator/components', 'administrator/components',
'administrator/language', 'administrator/language',
@@ -39,21 +39,29 @@ class RollbackJoomla
'templates', 'templates',
]; ];
public function run(): int protected function configure(): void
{ {
$this->parseArgs(); $this->setDescription('Rollback a Joomla deployment by restoring from a pre-deploy snapshot');
$this->addArgument('--config', 'Path to sftp-config.json', '');
$this->addArgument('--snapshot-dir', 'Path to snapshot directory', '');
}
if ($this->configPath === '' || $this->snapshotDir === '') { protected function run(): int
$this->log('Usage: rollback-joomla.php --config <sftp-config.json> --snapshot-dir <path> [--dry-run] [--verbose]'); {
$configPath = $this->getArgument('--config');
$snapshotDir = $this->getArgument('--snapshot-dir');
if ($configPath === '' || $snapshotDir === '') {
$this->log('ERROR', 'Usage: rollback-joomla.php --config <sftp-config.json> --snapshot-dir <path> [--dry-run] [--verbose]');
return 1; return 1;
} }
if (!is_dir($this->snapshotDir)) { if (!is_dir($snapshotDir)) {
$this->log("ERROR: Snapshot directory does not exist: {$this->snapshotDir}"); $this->log('ERROR', "Snapshot directory does not exist: {$snapshotDir}");
return 1; return 1;
} }
$config = $this->loadConfig($this->configPath); $config = $this->loadConfig($configPath);
if ($config === null) { if ($config === null) {
return 1; return 1;
} }
@@ -65,26 +73,26 @@ class RollbackJoomla
$sshKey = $config['ssh_key_file'] ?? ''; $sshKey = $config['ssh_key_file'] ?? '';
if ($host === '' || $user === '' || $remotePath === '') { if ($host === '' || $user === '' || $remotePath === '') {
$this->log('ERROR: Config must contain host, user, and remote_path.'); $this->log('ERROR', 'Config must contain host, user, and remote_path.');
return 1; return 1;
} }
$this->log('Starting Joomla rollback from snapshot...'); $this->log('INFO', 'Starting Joomla rollback from snapshot...');
$this->log("Snapshot: {$this->snapshotDir}"); $this->log('INFO', "Snapshot: {$snapshotDir}");
$this->log("Target: {$user}@{$host}:{$remotePath}"); $this->log('INFO', "Target: {$user}@{$host}:{$remotePath}");
if ($this->dryRun) { if ($this->dryRun) {
$this->log('*** DRY RUN — no changes will be made ***'); $this->log('INFO', '*** DRY RUN — no changes will be made ***');
} }
$failed = 0; $failed = 0;
foreach (self::JOOMLA_DIRS as $dir) { foreach (self::JOOMLA_DIRS as $dir) {
$localDir = rtrim($this->snapshotDir, '/\\') . '/' . $dir . '/'; $localDir = rtrim($snapshotDir, '/\\') . '/' . $dir . '/';
if (!is_dir($localDir)) { if (!is_dir($localDir)) {
if ($this->verbose) { if ($this->verbose) {
$this->log("SKIP: {$dir} (not present in snapshot)"); $this->log('INFO', "SKIP: {$dir} (not present in snapshot)");
} }
continue; continue;
} }
@@ -95,32 +103,11 @@ class RollbackJoomla
$sshCmd .= " -i " . escapeshellarg($sshKey); $sshCmd .= " -i " . escapeshellarg($sshKey);
} }
$rsyncArgs = [
'rsync',
'-rlptz',
'--delete',
'--exclude=configuration.php',
'-e', $sshCmd,
];
if ($this->dryRun) {
$rsyncArgs[] = '--dry-run';
}
if ($this->verbose) {
$rsyncArgs[] = '-v';
}
$rsyncArgs[] = $localDir;
$rsyncArgs[] = "{$user}@{$host}:{$remoteTarget}";
$cmd = implode(' ', array_map('escapeshellarg', $rsyncArgs));
// rsync -e needs unescaped, rebuild manually
$cmd = $this->buildRsyncCommand($sshCmd, $localDir, "{$user}@{$host}:{$remoteTarget}"); $cmd = $this->buildRsyncCommand($sshCmd, $localDir, "{$user}@{$host}:{$remoteTarget}");
$this->log("Restoring: {$dir}"); $this->log('INFO', "Restoring: {$dir}");
if ($this->verbose) { if ($this->verbose) {
$this->log("CMD: {$cmd}"); $this->log('INFO', "CMD: {$cmd}");
} }
$output = []; $output = [];
@@ -128,62 +115,39 @@ class RollbackJoomla
exec($cmd, $output, $exitCode); exec($cmd, $output, $exitCode);
if ($exitCode !== 0) { if ($exitCode !== 0) {
$this->log("ERROR: rsync failed for {$dir} (exit code {$exitCode})"); $this->log('ERROR', "rsync failed for {$dir} (exit code {$exitCode})");
foreach ($output as $line) { foreach ($output as $line) {
$this->log(" {$line}"); $this->log('ERROR', " {$line}");
} }
$failed++; $failed++;
} else { } else {
if ($this->verbose) { if ($this->verbose) {
foreach ($output as $line) { foreach ($output as $line) {
$this->log(" {$line}"); $this->log('INFO', " {$line}");
} }
} }
} }
} }
if ($failed > 0) { if ($failed > 0) {
$this->log("Rollback completed with {$failed} error(s)."); $this->log('ERROR', "Rollback completed with {$failed} error(s).");
return 1; return 1;
} }
$this->log('Rollback completed successfully.'); $this->log('INFO', 'Rollback completed successfully.');
return 0; return 0;
} }
private function parseArgs(): void
{
$args = $_SERVER['argv'] ?? [];
$count = count($args);
for ($i = 1; $i < $count; $i++) {
switch ($args[$i]) {
case '--config':
$this->configPath = $args[++$i] ?? '';
break;
case '--snapshot-dir':
$this->snapshotDir = $args[++$i] ?? '';
break;
case '--dry-run':
$this->dryRun = true;
break;
case '--verbose':
$this->verbose = true;
break;
}
}
}
private function loadConfig(string $path): ?array private function loadConfig(string $path): ?array
{ {
if (!is_file($path)) { if (!is_file($path)) {
$this->log("ERROR: Config file not found: {$path}"); $this->log('ERROR', "Config file not found: {$path}");
return null; return null;
} }
$raw = file_get_contents($path); $raw = file_get_contents($path);
if ($raw === false) { if ($raw === false) {
$this->log("ERROR: Could not read config file: {$path}"); $this->log('ERROR', "Could not read config file: {$path}");
return null; return null;
} }
@@ -192,7 +156,7 @@ class RollbackJoomla
$config = json_decode($cleaned, true); $config = json_decode($cleaned, true);
if (!is_array($config)) { if (!is_array($config)) {
$this->log('ERROR: Invalid JSON in config file.'); $this->log('ERROR', 'Invalid JSON in config file.');
return null; return null;
} }
@@ -218,13 +182,7 @@ class RollbackJoomla
return implode(' ', $parts); return implode(' ', $parts);
} }
private function log(string $message): void
{
$timestamp = date('Y-m-d H:i:s');
fwrite(STDERR, "[{$timestamp}] {$message}" . PHP_EOL);
}
} }
$app = new RollbackJoomla(); $app = new RollbackJoomlaCli();
exit($app->run()); exit($app->execute());
+75 -189
View File
@@ -1,5 +1,6 @@
#!/usr/bin/env php #!/usr/bin/env php
<?php <?php
/* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech> /* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
* *
* This file is part of a Moko Consulting project. * This file is part of a Moko Consulting project.
@@ -7,40 +8,28 @@
* SPDX-License-Identifier: GPL-3.0-or-later * SPDX-License-Identifier: GPL-3.0-or-later
* *
* FILE INFORMATION * FILE INFORMATION
* DEFGROUP: MokoStandards.Scripts.Deploy * DEFGROUP: MokoPlatform.Scripts.Deploy
* INGROUP: MokoStandards * INGROUP: MokoPlatform
* REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform * REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform
* PATH: /deploy/sync-joomla.php * PATH: /deploy/sync-joomla.php
* VERSION: 09.21.00 * VERSION: 09.24.00
* BRIEF: Sync Joomla site directories between two servers via rsync over SSH * BRIEF: Sync Joomla site directories between two servers via rsync over SSH
*/ */
declare(strict_types=1); declare(strict_types=1);
class SyncJoomla require_once __DIR__ . '/../lib/Enterprise/CliFramework.php';
use MokoEnterprise\CliFramework;
class SyncJoomlaCli extends CliFramework
{ {
/** @var string Path to source sftp-config.json */
private string $sourceConfig = ''; private string $sourceConfig = '';
/** @var string Path to dest sftp-config.json */
private string $destConfig = ''; private string $destConfig = '';
/** @var bool Sync standard Joomla directories only */
private bool $rsyncMode = false; private bool $rsyncMode = false;
/** @var bool Sync everything under remote_path */
private bool $fullMode = false; private bool $fullMode = false;
/** @var string[] */
/** @var bool Dry-run (preview only) */
private bool $dryRun = false;
/** @var bool Verbose output */
private bool $verbose = false;
/** @var string[] Additional exclude patterns */
private array $excludes = []; private array $excludes = [];
/** @var string Local relay directory */
private string $relayDir = '/tmp/sync/'; private string $relayDir = '/tmp/sync/';
/** @var string[] Standard Joomla directories to sync */ /** @var string[] Standard Joomla directories to sync */
@@ -59,14 +48,31 @@ class SyncJoomla
'templates', 'templates',
]; ];
/** protected function configure(): void
* Main entry point.
*
* @return int Exit code
*/
public function run(): int
{ {
$this->parseArgs(); $this->setDescription('Sync Joomla site directories between two servers via rsync over SSH');
$this->addArgument('--source', 'sftp-config.json for source server', '');
$this->addArgument('--dest', 'sftp-config.json for dest server', '');
$this->addArgument('--rsync', 'Sync standard Joomla directories', false);
$this->addArgument('--full', 'Sync everything under the remote path', false);
$this->addArgument('--exclude', 'Additional exclude pattern (repeatable)', '');
}
protected function run(): int
{
$this->sourceConfig = $this->getArgument('--source');
$this->destConfig = $this->getArgument('--dest');
$this->rsyncMode = $this->getArgument('--rsync');
$this->fullMode = $this->getArgument('--full');
// Handle repeatable --exclude from raw argv
$rawArgs = $_SERVER['argv'] ?? [];
for ($i = 1; $i < count($rawArgs); $i++) {
if ($rawArgs[$i] === '--exclude' && isset($rawArgs[$i + 1])) {
$this->excludes[] = $rawArgs[$i + 1];
$i++;
}
}
if (!$this->validate()) { if (!$this->validate()) {
return 1; return 1;
@@ -79,11 +85,11 @@ class SyncJoomla
return 1; return 1;
} }
$this->log("Source: {$source['user']}@{$source['host']}:{$source['remote_path']}"); echo "Source: {$source['user']}@{$source['host']}:{$source['remote_path']}\n";
$this->log("Dest: {$dest['user']}@{$dest['host']}:{$dest['remote_path']}"); echo "Dest: {$dest['user']}@{$dest['host']}:{$dest['remote_path']}\n";
if ($this->dryRun) { if ($this->dryRun) {
$this->log('[DRY-RUN] No files will be transferred.'); echo "[DRY-RUN] No files will be transferred.\n";
} }
$this->prepareRelayDir(); $this->prepareRelayDir();
@@ -93,17 +99,17 @@ class SyncJoomla
$syncedDirs = 0; $syncedDirs = 0;
foreach ($dirs as $dir) { foreach ($dirs as $dir) {
$this->log("--- Syncing: {$dir}"); echo "--- Syncing: {$dir}\n";
$pulled = $this->pullFromSource($source, $dir); $pulled = $this->pullFromSource($source, $dir);
if ($pulled === false) { if ($pulled === false) {
$this->log(" WARNING: pull failed for {$dir}, skipping."); echo " WARNING: pull failed for {$dir}, skipping.\n";
continue; continue;
} }
$pushed = $this->pushToDest($dest, $dir); $pushed = $this->pushToDest($dest, $dir);
if ($pushed === false) { if ($pushed === false) {
$this->log(" WARNING: push failed for {$dir}, skipping."); echo " WARNING: push failed for {$dir}, skipping.\n";
continue; continue;
} }
@@ -112,105 +118,53 @@ class SyncJoomla
} }
$this->cleanup(); $this->cleanup();
$this->log(''); echo "\n";
$this->log('=== Sync Summary ==='); echo "=== Sync Summary ===\n";
$this->log("Directories synced: {$syncedDirs}/" . count($dirs)); echo "Directories synced: {$syncedDirs}/" . count($dirs) . "\n";
$this->log("Rsync operations: " . ($syncedDirs * 2) . " (pull + push)"); echo "Rsync operations: " . ($syncedDirs * 2) . " (pull + push)\n";
if ($this->dryRun) { if ($this->dryRun) {
$this->log('Mode: dry-run (no files were transferred)'); echo "Mode: dry-run (no files were transferred)\n";
} }
return 0; return 0;
} }
/**
* Parse command-line arguments.
*/
private function parseArgs(): void
{
global $argv;
$i = 1;
while ($i < count($argv)) {
switch ($argv[$i]) {
case '--source':
$this->sourceConfig = $argv[++$i] ?? '';
break;
case '--dest':
$this->destConfig = $argv[++$i] ?? '';
break;
case '--rsync':
$this->rsyncMode = true;
break;
case '--full':
$this->fullMode = true;
break;
case '--dry-run':
$this->dryRun = true;
break;
case '--verbose':
$this->verbose = true;
break;
case '--exclude':
$this->excludes[] = $argv[++$i] ?? '';
break;
default:
$this->log("Unknown argument: {$argv[$i]}");
break;
}
$i++;
}
}
/**
* Validate required arguments.
*
* @return bool True if valid
*/
private function validate(): bool private function validate(): bool
{ {
if ($this->sourceConfig === '' || $this->destConfig === '') { if ($this->sourceConfig === '' || $this->destConfig === '') {
$this->log('ERROR: --source and --dest are required.'); $this->log('ERROR', '--source and --dest are required.');
$this->printUsage();
return false; return false;
} }
if (!$this->rsyncMode && !$this->fullMode) { if (!$this->rsyncMode && !$this->fullMode) {
$this->log('ERROR: Either --rsync or --full must be specified.'); $this->log('ERROR', 'Either --rsync or --full must be specified.');
$this->printUsage();
return false; return false;
} }
if ($this->rsyncMode && $this->fullMode) { if ($this->rsyncMode && $this->fullMode) {
$this->log('ERROR: --rsync and --full are mutually exclusive.'); $this->log('ERROR', '--rsync and --full are mutually exclusive.');
return false; return false;
} }
if (!file_exists($this->sourceConfig)) { if (!file_exists($this->sourceConfig)) {
$this->log("ERROR: Source config not found: {$this->sourceConfig}"); $this->log('ERROR', "Source config not found: {$this->sourceConfig}");
return false; return false;
} }
if (!file_exists($this->destConfig)) { if (!file_exists($this->destConfig)) {
$this->log("ERROR: Dest config not found: {$this->destConfig}"); $this->log('ERROR', "Dest config not found: {$this->destConfig}");
return false; return false;
} }
return true; return true;
} }
/**
* Load and decode an sftp-config.json file.
*
* @param string $path Path to the config file
* @return array|null Parsed config or null on error
*/
private function loadConfig(string $path): ?array private function loadConfig(string $path): ?array
{ {
$json = file_get_contents($path); $json = file_get_contents($path);
if ($json === false) { if ($json === false) {
$this->log("ERROR: Cannot read config: {$path}"); $this->log('ERROR', "Cannot read config: {$path}");
return null; return null;
} }
@@ -220,14 +174,14 @@ class SyncJoomla
$config = json_decode($json, true); $config = json_decode($json, true);
if (!is_array($config)) { if (!is_array($config)) {
$this->log("ERROR: Invalid JSON in config: {$path}"); $this->log('ERROR', "Invalid JSON in config: {$path}");
return null; return null;
} }
$required = ['host', 'user', 'remote_path', 'ssh_key_file']; $required = ['host', 'user', 'remote_path', 'ssh_key_file'];
foreach ($required as $key) { foreach ($required as $key) {
if (empty($config[$key])) { if (empty($config[$key])) {
$this->log("ERROR: Missing '{$key}' in config: {$path}"); $this->log('ERROR', "Missing '{$key}' in config: {$path}");
return null; return null;
} }
} }
@@ -239,11 +193,7 @@ class SyncJoomla
return $config; return $config;
} }
/** /** @return string[] */
* Resolve the list of directories to sync.
*
* @return string[] Directory paths (relative to remote_path)
*/
private function resolveDirs(): array private function resolveDirs(): array
{ {
if ($this->fullMode) { if ($this->fullMode) {
@@ -253,9 +203,6 @@ class SyncJoomla
return $this->joomlaDirs; return $this->joomlaDirs;
} }
/**
* Prepare the local relay directory.
*/
private function prepareRelayDir(): void private function prepareRelayDir(): void
{ {
if (is_dir($this->relayDir)) { if (is_dir($this->relayDir)) {
@@ -263,17 +210,9 @@ class SyncJoomla
} }
mkdir($this->relayDir, 0755, true); mkdir($this->relayDir, 0755, true);
$this->log("Relay directory: {$this->relayDir}"); echo "Relay directory: {$this->relayDir}\n";
} }
/**
* Build common rsync exclude flags.
*
* configuration.php is always excluded — it contains per-environment
* database credentials and settings that must never be synced.
*
* @return string Exclude arguments for rsync
*/
private function buildExcludes(): string private function buildExcludes(): string
{ {
$excludes = ['configuration.php']; $excludes = ['configuration.php'];
@@ -287,12 +226,6 @@ class SyncJoomla
return $flags; return $flags;
} }
/**
* Build SSH command fragment for rsync.
*
* @param array $config Server config
* @return string The -e flag value for rsync
*/
private function buildSshCmd(array $config): string private function buildSshCmd(array $config): string
{ {
$keyPath = escapeshellarg($config['ssh_key_file']); $keyPath = escapeshellarg($config['ssh_key_file']);
@@ -301,13 +234,7 @@ class SyncJoomla
return "ssh -i {$keyPath} -p {$port} -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null"; return "ssh -i {$keyPath} -p {$port} -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null";
} }
/** /** @return int|false */
* Pull a directory from the source server to the local relay.
*
* @param array $config Source server config
* @param string $dir Relative directory to sync
* @return int|false Number of files or false on failure
*/
private function pullFromSource(array $config, string $dir): int|false private function pullFromSource(array $config, string $dir): int|false
{ {
$remotePath = rtrim($config['remote_path'], '/') . '/' . ltrim($dir, './'); $remotePath = rtrim($config['remote_path'], '/') . '/' . ltrim($dir, './');
@@ -333,30 +260,28 @@ class SyncJoomla
. " {$remote} {$local}" . " {$remote} {$local}"
. " 2>&1"; . " 2>&1";
$this->logVerbose(" PULL: {$cmd}"); if ($this->verbose) {
echo " PULL: {$cmd}\n";
}
$output = []; $output = [];
$exitCode = 0; $exitCode = 0;
exec($cmd, $output, $exitCode); exec($cmd, $output, $exitCode);
if ($exitCode !== 0) { if ($exitCode !== 0) {
$this->log(" ERROR (exit {$exitCode}): " . implode("\n", $output)); echo " ERROR (exit {$exitCode}): " . implode("\n", $output) . "\n";
return false; return false;
} }
$fileCount = count($output); $fileCount = count($output);
$this->logVerbose(" Pulled {$fileCount} line(s) of output."); if ($this->verbose) {
echo " Pulled {$fileCount} line(s) of output.\n";
}
return $fileCount; return $fileCount;
} }
/** /** @return int|false */
* Push a directory from the local relay to the destination server.
*
* @param array $config Dest server config
* @param string $dir Relative directory to sync
* @return int|false Number of files or false on failure
*/
private function pushToDest(array $config, string $dir): int|false private function pushToDest(array $config, string $dir): int|false
{ {
$remotePath = rtrim($config['remote_path'], '/') . '/' . ltrim($dir, './'); $remotePath = rtrim($config['remote_path'], '/') . '/' . ltrim($dir, './');
@@ -378,76 +303,37 @@ class SyncJoomla
. " {$local} {$remote}" . " {$local} {$remote}"
. " 2>&1"; . " 2>&1";
$this->logVerbose(" PUSH: {$cmd}"); if ($this->verbose) {
echo " PUSH: {$cmd}\n";
}
$output = []; $output = [];
$exitCode = 0; $exitCode = 0;
exec($cmd, $output, $exitCode); exec($cmd, $output, $exitCode);
if ($exitCode !== 0) { if ($exitCode !== 0) {
$this->log(" ERROR (exit {$exitCode}): " . implode("\n", $output)); echo " ERROR (exit {$exitCode}): " . implode("\n", $output) . "\n";
return false; return false;
} }
$fileCount = count($output); $fileCount = count($output);
$this->logVerbose(" Pushed {$fileCount} line(s) of output."); if ($this->verbose) {
echo " Pushed {$fileCount} line(s) of output.\n";
}
return $fileCount; return $fileCount;
} }
/**
* Clean up the relay directory.
*/
private function cleanup(): void private function cleanup(): void
{ {
if (is_dir($this->relayDir)) { if (is_dir($this->relayDir)) {
shell_exec("rm -rf " . escapeshellarg($this->relayDir)); shell_exec("rm -rf " . escapeshellarg($this->relayDir));
$this->logVerbose("Cleaned up relay directory.");
}
}
/**
* Print usage information.
*/
private function printUsage(): void
{
$this->log('');
$this->log('Usage: sync-joomla.php --source <config> --dest <config> [--rsync|--full] [options]');
$this->log('');
$this->log('Required:');
$this->log(' --source <path> sftp-config.json for source server');
$this->log(' --dest <path> sftp-config.json for dest server');
$this->log(' --rsync Sync standard Joomla directories');
$this->log(' --full Sync everything under the remote path');
$this->log('');
$this->log('Options:');
$this->log(' --dry-run Preview only, no files transferred');
$this->log(' --verbose Verbose output');
$this->log(' --exclude <pattern> Additional exclude pattern (repeatable)');
}
/**
* Log a message to stdout.
*
* @param string $message Message to log
*/
private function log(string $message): void
{
echo $message . PHP_EOL;
}
/**
* Log a verbose message (only when --verbose is set).
*
* @param string $message Message to log
*/
private function logVerbose(string $message): void
{
if ($this->verbose) { if ($this->verbose) {
$this->log($message); echo "Cleaned up relay directory.\n";
}
} }
} }
} }
$sync = new SyncJoomla(); $app = new SyncJoomlaCli();
exit($sync->run()); exit($app->execute());
+16 -30
View File
@@ -7,57 +7,43 @@
* SPDX-License-Identifier: GPL-3.0-or-later * SPDX-License-Identifier: GPL-3.0-or-later
* *
* FILE INFORMATION * FILE INFORMATION
* DEFGROUP: MokoStandards.Scripts.Fix * DEFGROUP: MokoPlatform.Scripts.Fix
* INGROUP: MokoStandards * INGROUP: MokoPlatform
* REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform * REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform
* PATH: /fix/fix_line_endings.php * PATH: /fix/fix_line_endings.php
* BRIEF: CLI script to fix line endings (CRLF → LF) in tracked files * BRIEF: CLI script to normalise CRLF/CR to LF in tracked source files
*/ */
declare(strict_types=1); declare(strict_types=1);
require_once __DIR__ . '/../lib/CliBase.php'; require_once __DIR__ . '/../vendor/autoload.php';
require_once __DIR__ . '/../../vendor/autoload.php'; require_once __DIR__ . '/../lib/Enterprise/CliFramework.php';
use MokoEnterprise\CliFramework;
use MokoEnterprise\FileFixUtility; use MokoEnterprise\FileFixUtility;
/** class FixLineEndings extends CliFramework
* CLI wrapper that delegates line-ending fixes to FileFixUtility.
*/
class FixLineEndings extends CliBase
{ {
/** protected function configure(): void
* Print usage information.
*/
protected function showHelp(): void
{ {
echo "Usage: {$this->scriptName} [--path DIR] [--dry-run] [--help]\n\n"; $this->setDescription('Normalise CRLF/CR to LF in tracked source files');
echo "Fixes CRLF line endings to LF in all tracked source files.\n\n"; $this->addArgument('--path', 'Repository root (default: current directory)', '.');
echo "OPTIONS:\n";
echo " --path DIR Repository root (default: current directory)\n";
echo " --dry-run Show what would be changed without modifying files\n";
echo " --help Show this help message\n";
} }
/** protected function run(): int
* Run the line-ending fix via FileFixUtility.
*
* @return int Exit code: 0 on success.
*/
protected function execute(): int
{ {
$path = (string) ($this->getOption('path') ?? '.'); $path = (string) $this->getArgument('--path');
$files = FileFixUtility::fixLineEndings($path, $this->dryRun); $files = FileFixUtility::fixLineEndings($path, $this->dryRun);
foreach ($files as $f) { foreach ($files as $f) {
$this->success("Fixed: {$f}"); $this->status(true, "Fixed: {$f}");
} }
$label = $this->dryRun ? 'Would fix' : 'Fixed'; $label = $this->dryRun ? 'Would fix' : 'Fixed';
$this->log("{$label} " . count($files) . ' file(s)'); $this->log("{$label} " . count($files) . ' file(s)');
return 0; return self::EXIT_SUCCESS;
} }
} }
$script = new FixLineEndings($argv); $app = new FixLineEndings();
exit($script->run()); exit($app->execute());
+18 -32
View File
@@ -7,57 +7,43 @@
* SPDX-License-Identifier: GPL-3.0-or-later * SPDX-License-Identifier: GPL-3.0-or-later
* *
* FILE INFORMATION * FILE INFORMATION
* DEFGROUP: MokoStandards.Scripts.Fix * DEFGROUP: MokoPlatform.Scripts.Fix
* INGROUP: MokoStandards * INGROUP: MokoPlatform
* REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform * REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform
* PATH: /fix/fix_permissions.php * PATH: /fix/fix_permissions.php
* BRIEF: CLI script to fix file permissions (dirs 755, files 644, scripts 755) * BRIEF: CLI script to normalise file permissions (dirs 755, files 644, scripts 755)
*/ */
declare(strict_types=1); declare(strict_types=1);
require_once __DIR__ . '/../lib/CliBase.php'; require_once __DIR__ . '/../vendor/autoload.php';
require_once __DIR__ . '/../../vendor/autoload.php'; require_once __DIR__ . '/../lib/Enterprise/CliFramework.php';
use MokoEnterprise\CliFramework;
use MokoEnterprise\FileFixUtility; use MokoEnterprise\FileFixUtility;
/** class FixPermissions extends CliFramework
* CLI wrapper that delegates permission fixes to FileFixUtility.
*/
class FixPermissions extends CliBase
{ {
/** protected function configure(): void
* Print usage information.
*/
protected function showHelp(): void
{ {
echo "Usage: {$this->scriptName} [--path DIR] [--dry-run] [--help]\n\n"; $this->setDescription('Normalise file permissions (dirs 755, files 644, scripts 755)');
echo "Fixes file permissions: 644 for files, 755 for dirs and *.php/*.sh scripts.\n\n"; $this->addArgument('--path', 'Repository root (default: current directory)', '.');
echo "OPTIONS:\n";
echo " --path DIR Repository root (default: current directory)\n";
echo " --dry-run Show what would be changed without modifying files\n";
echo " --help Show this help message\n";
} }
/** protected function run(): int
* Run the permissions fix via FileFixUtility.
*
* @return int Exit code: 0 on success.
*/
protected function execute(): int
{ {
$path = (string) ($this->getOption('path') ?? '.'); $path = (string) $this->getArgument('--path');
if ($this->dryRun) { if ($this->dryRun) {
$this->warning('[DRY-RUN] Would fix permissions (dirs 755, files 644, scripts 755)'); $this->log('WARNING', 'Would fix permissions (dirs 755, files 644, scripts 755)');
return 0; return self::EXIT_SUCCESS;
} }
FileFixUtility::fixPermissions($path, $this->dryRun); FileFixUtility::fixPermissions($path, $this->dryRun);
$this->success('[OK] Permissions fixed'); $this->log('SUCCESS', 'Permissions fixed');
return 0; return self::EXIT_SUCCESS;
} }
} }
$script = new FixPermissions($argv); $app = new FixPermissions();
exit($script->run()); exit($app->execute());
+19 -34
View File
@@ -7,8 +7,8 @@
* SPDX-License-Identifier: GPL-3.0-or-later * SPDX-License-Identifier: GPL-3.0-or-later
* *
* FILE INFORMATION * FILE INFORMATION
* DEFGROUP: MokoStandards.Scripts.Fix * DEFGROUP: MokoPlatform.Scripts.Fix
* INGROUP: MokoStandards * INGROUP: MokoPlatform
* REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform * REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform
* PATH: /fix/fix_tabs.php * PATH: /fix/fix_tabs.php
* BRIEF: CLI script to convert tabs to spaces in tracked source files * BRIEF: CLI script to convert tabs to spaces in tracked source files
@@ -16,57 +16,42 @@
declare(strict_types=1); declare(strict_types=1);
require_once __DIR__ . '/../lib/CliBase.php'; require_once __DIR__ . '/../vendor/autoload.php';
require_once __DIR__ . '/../../vendor/autoload.php'; require_once __DIR__ . '/../lib/Enterprise/CliFramework.php';
use MokoEnterprise\CliFramework;
use MokoEnterprise\FileFixUtility; use MokoEnterprise\FileFixUtility;
/** class FixTabs extends CliFramework
* CLI wrapper that delegates tab-to-space conversion to FileFixUtility.
*/
class FixTabs extends CliBase
{ {
/** protected function configure(): void
* Print usage information.
*/
protected function showHelp(): void
{ {
echo "Usage: {$this->scriptName} [--path DIR] [--type TYPE] [--dry-run] [--help]\n\n"; $this->setDescription('Convert tabs to spaces in tracked source files');
echo "Convert tabs to spaces in tracked source files.\n\n"; $this->addArgument('--path', 'Repository root (default: current directory)', '.');
echo "OPTIONS:\n"; $this->addArgument('--type', 'File type: yaml, python, shell, all', 'all');
echo " --path DIR Repository root (default: current directory)\n";
echo " --type TYPE File type: yaml, python, shell, all (default: all)\n";
echo " --dry-run Show changes without modifying files\n";
echo " --help Show this help message\n\n";
echo "NOTE: Makefile variants are always skipped.\n";
} }
/** protected function run(): int
* Run the tab-fix via FileFixUtility.
*
* @return int Exit code: 0 on success, 2 on invalid arguments.
*/
protected function execute(): int
{ {
$path = (string) ($this->getOption('path') ?? '.'); $path = (string) $this->getArgument('--path');
$fileType = (string) ($this->getOption('type') ?? 'all'); $fileType = (string) $this->getArgument('--type');
try { try {
$files = FileFixUtility::fixTabs($path, $fileType, $this->dryRun); $files = FileFixUtility::fixTabs($path, $fileType, $this->dryRun);
} catch (\InvalidArgumentException $e) { } catch (\InvalidArgumentException $e) {
$this->log($e->getMessage(), 'ERROR'); $this->log('ERROR', $e->getMessage());
return 2; return self::EXIT_USAGE;
} }
foreach ($files as $f) { foreach ($files as $f) {
$this->success("Fixed: {$f}"); $this->status(true, "Fixed: {$f}");
} }
$label = $this->dryRun ? 'Would fix' : 'Fixed'; $label = $this->dryRun ? 'Would fix' : 'Fixed';
$this->log("{$label} " . count($files) . ' file(s)'); $this->log("{$label} " . count($files) . ' file(s)');
return 0; return self::EXIT_SUCCESS;
} }
} }
$script = new FixTabs($argv); $app = new FixTabs();
exit($script->run()); exit($app->execute());

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