Compare commits

..

181 Commits

Author SHA1 Message Date
git-actions[bot] 1f49c49ebd chore: promote changelog [Unreleased] → [02.67.00]
Generic: Standards Compliance / Secret Scanning (push) Has been cancelled
Generic: Standards Compliance / License Header Validation (push) Has been cancelled
Generic: Standards Compliance / Repository Structure Validation (push) Has been cancelled
Generic: Standards Compliance / Coding Standards Check (push) Has been cancelled
Generic: Standards Compliance / Workflow Configuration Check (push) Has been cancelled
Generic: Standards Compliance / Documentation Quality Check (push) Has been cancelled
Generic: Standards Compliance / README Completeness Check (push) Has been cancelled
Generic: Standards Compliance / Git Repository Hygiene (push) Has been cancelled
Generic: Standards Compliance / Script Integrity Validation (push) Has been cancelled
Generic: Standards Compliance / Line Length Check (push) Has been cancelled
Generic: Standards Compliance / File Naming Standards (push) Has been cancelled
Generic: Standards Compliance / Insecure Code Pattern Detection (push) Has been cancelled
Generic: Standards Compliance / Code Complexity Analysis (push) Has been cancelled
Generic: Standards Compliance / Dead Code Detection (push) Has been cancelled
Generic: Standards Compliance / File Size Limits (push) Has been cancelled
Generic: Standards Compliance / Binary File Detection (push) Has been cancelled
Generic: Standards Compliance / TODO/FIXME Tracking (push) Has been cancelled
Generic: Standards Compliance / Dependency Vulnerability Scanning (push) Has been cancelled
Generic: Standards Compliance / Unused Dependencies Check (push) Has been cancelled
Generic: Standards Compliance / Broken Link Detection (push) Has been cancelled
Generic: Standards Compliance / API Documentation Coverage (push) Has been cancelled
Generic: Standards Compliance / Accessibility Check (push) Has been cancelled
Generic: Standards Compliance / Performance Metrics (push) Has been cancelled
Generic: Standards Compliance / Enterprise Readiness Check (push) Has been cancelled
Generic: Standards Compliance / Repository Health Check (push) Has been cancelled
Generic: Standards Compliance / Terraform Configuration Validation (push) Has been cancelled
Generic: Standards Compliance / Compliance Summary (push) Has been cancelled
Generic: Standards Compliance / Version Consistency Check (push) Successful in 45s
Generic: Standards Compliance / Code Duplication Detection (push) Successful in 43s
Universal: Auto Version Bump / Version Bump (push) Successful in 9s
Universal: Pre-Release / Build Pre-Release (${{ inputs.stability || github.ref_name }}) (push) Successful in 22s
2026-07-17 00:33:27 +00:00
git-actions[bot] 64875771a7 chore(release): build 02.67.00 [skip ci] 2026-07-17 00:33:16 +00:00
jmiller 605e45a967 Release: dev -> main (#303) — pre-update backup hardening + config lang fix
Generic: Standards Compliance / Secret Scanning (push) Failing after 5s
Generic: Standards Compliance / License Header Validation (push) Successful in 4s
Generic: Standards Compliance / Repository Structure Validation (push) Successful in 4s
Generic: Standards Compliance / Coding Standards Check (push) Successful in 5s
Generic: Standards Compliance / Workflow Configuration Check (push) Failing after 5s
Generic: Standards Compliance / Documentation Quality Check (push) Successful in 4s
Generic: Standards Compliance / README Completeness Check (push) Failing after 4s
Generic: Standards Compliance / Git Repository Hygiene (push) Successful in 7s
Generic: Standards Compliance / Script Integrity Validation (push) Successful in 6s
Generic: Standards Compliance / Line Length Check (push) Successful in 5s
Generic: Standards Compliance / File Naming Standards (push) Successful in 4s
Generic: Standards Compliance / Insecure Code Pattern Detection (push) Successful in 4s
Generic: Standards Compliance / Version Consistency Check (push) Successful in 50s
Generic: Standards Compliance / Dead Code Detection (push) Successful in 6s
Generic: Standards Compliance / File Size Limits (push) Successful in 4s
Generic: Standards Compliance / Binary File Detection (push) Successful in 7s
Generic: Standards Compliance / TODO/FIXME Tracking (push) Successful in 4s
Generic: Standards Compliance / Code Complexity Analysis (push) Successful in 48s
Generic: Standards Compliance / Code Duplication Detection (push) Successful in 47s
Generic: Standards Compliance / Broken Link Detection (push) Successful in 6s
Generic: Standards Compliance / API Documentation Coverage (push) Successful in 4s
Generic: Standards Compliance / Accessibility Check (push) Successful in 4s
Generic: Standards Compliance / Performance Metrics (push) Successful in 3s
Generic: Standards Compliance / Dependency Vulnerability Scanning (push) Successful in 52s
Generic: Standards Compliance / Unused Dependencies Check (push) Successful in 57s
Generic: Standards Compliance / Terraform Configuration Validation (push) Successful in 9s
Generic: Standards Compliance / Enterprise Readiness Check (push) Failing after 50s
Generic: Standards Compliance / Repository Health Check (push) Failing after 50s
Generic: Standards Compliance / Compliance Summary (push) Has been cancelled
https://claude.ai/code/session_01WbGBN9VyRK61zczYWcCQ2i
2026-07-17 00:32:56 +00:00
mokogit-actions[bot] d7639ec3d9 chore(version): pre-release bump to 02.66.07-dev [skip ci]
Universal: Build & Release / Promote to RC (pull_request) Has been skipped
Universal: Build & Release / Build & Release Pipeline (pull_request) Successful in 34s
Branch Cleanup / Delete merged branch (pull_request) Has been cancelled
RC Revert / Rename rc/ back to dev/ (pull_request) Has been cancelled
2026-07-17 00:26:33 +00:00
git-actions[bot] 9c37bc6303 chore(version): auto-bump patch 02.66.06-dev [skip ci] 2026-07-17 00:26:14 +00:00
jmiller c01b0ab112 Merge branch 'fix/preaction-skip-ttl' into dev
Universal: Auto Version Bump / Version Bump (push) Successful in 11s
Universal: Pre-Release / Build Pre-Release (${{ inputs.stability || github.ref_name }}) (push) Successful in 30s
fix(preupdate): time-bound the pre-action skip flag

Claude-Session: https://claude.ai/code/session_01WbGBN9VyRK61zczYWcCQ2i
2026-07-16 19:24:56 -05:00
jmiller 462aed0e51 fix(preupdate): time-bound the pre-action skip flag
Universal: Pre-Release / Build Pre-Release (${{ inputs.stability || github.ref_name }}) (push) Failing after 7s
The one-shot "client already backed up" skip flag was a bare boolean with no
expiry. If a user ran the full-screen pre-update backup then abandoned the flow
(closed the tab before the re-fired update fired), the flag lingered for the
whole session and could suppress a LATER, unrelated pre-action backup — a real
update running with no backup.

Store the unix time the flag was armed (AjaxController::armPreactionSkip) and
honour the skip only within PREACTION_SKIP_TTL (300s), consuming it one-shot.
A stale flag is now ignored and the server backs up as normal.

Completes the pre-update double-backup hardening: server-side arming stops the
"ack fails -> double backup" direction; the TTL stops the "abandoned flow ->
skipped backup" direction. Fail-safe both ways.

Claude-Session: https://claude.ai/code/session_01WbGBN9VyRK61zczYWcCQ2i
2026-07-16 19:24:37 -05:00
mokogit-actions[bot] c7f0a122a4 chore(version): pre-release bump to 02.66.05-dev [skip ci] 2026-07-17 00:20:45 +00:00
git-actions[bot] 5fac72b5ed chore(version): auto-bump patch 02.66.04-dev [skip ci] 2026-07-17 00:20:27 +00:00
jmiller 3efadf941f Merge branch 'fix/preupdate-double-backup' into dev
Universal: Auto Version Bump / Version Bump (push) Successful in 13s
Universal: Pre-Release / Build Pre-Release (${{ inputs.stability || github.ref_name }}) (push) Successful in 28s
fix(preupdate): arm pre-action skip flag server-side to stop double backup

Claude-Session: https://claude.ai/code/session_01WbGBN9VyRK61zczYWcCQ2i
2026-07-16 19:18:56 -05:00
jmiller dabc556e18 fix(preupdate): arm pre-action skip flag server-side to stop double backup
Universal: Pre-Release / Build Pre-Release (${{ inputs.stability || github.ref_name }}) (push) Successful in 32s
The one-shot flag that tells plg_system_mokosuitebackup to skip its synchronous
pre-update/pre-uninstall backup was set only by the client-side ajax.preupdateAck
POST, whose failure the runbackup screen swallows in a catch(). If that POST
failed, the re-fired extension update ran a SECOND backup behind the black
"update is running" overlay — the "backup runs twice" symptom.

Arm the skip flag server-side in AjaxController::step the moment a backup that
was launched as a pre-update/pre-uninstall front completes (keyed by the
msb_action the runbackup screen now passes on each step). This is authoritative
and independent of the fragile ack POST, which is kept as a redundant fallback.

Failure-safe by design: the flag is armed ONLY on genuine completion (done, no
error), so a failed or cancelled backup never arms it — a real update is never
left un-backed-up. Worst case is one redundant backup, never a missing one.

Claude-Session: https://claude.ai/code/session_01WbGBN9VyRK61zczYWcCQ2i
2026-07-16 19:18:30 -05:00
jmiller 6717f0ed68 Release: merge dev to main
Generic: Standards Compliance / Secret Scanning (push) Failing after 4s
Generic: Standards Compliance / License Header Validation (push) Successful in 5s
Generic: Standards Compliance / Repository Structure Validation (push) Successful in 4s
Generic: Standards Compliance / Coding Standards Check (push) Successful in 5s
Generic: Standards Compliance / Workflow Configuration Check (push) Failing after 5s
Generic: Standards Compliance / Documentation Quality Check (push) Successful in 5s
Generic: Standards Compliance / README Completeness Check (push) Failing after 4s
Generic: Standards Compliance / Git Repository Hygiene (push) Successful in 7s
Generic: Standards Compliance / Script Integrity Validation (push) Successful in 6s
Generic: Standards Compliance / File Naming Standards (push) Successful in 4s
Generic: Standards Compliance / Line Length Check (push) Successful in 5s
Generic: Standards Compliance / Insecure Code Pattern Detection (push) Successful in 5s
Generic: Standards Compliance / Version Consistency Check (push) Successful in 51s
Generic: Standards Compliance / Dead Code Detection (push) Successful in 7s
Generic: Standards Compliance / File Size Limits (push) Successful in 4s
Generic: Standards Compliance / Binary File Detection (push) Successful in 7s
Generic: Standards Compliance / TODO/FIXME Tracking (push) Successful in 4s
Generic: Standards Compliance / Code Complexity Analysis (push) Successful in 52s
Generic: Standards Compliance / Code Duplication Detection (push) Successful in 52s
Generic: Standards Compliance / Broken Link Detection (push) Successful in 4s
Generic: Standards Compliance / API Documentation Coverage (push) Successful in 4s
Generic: Standards Compliance / Accessibility Check (push) Successful in 4s
Generic: Standards Compliance / Performance Metrics (push) Successful in 4s
Generic: Standards Compliance / Dependency Vulnerability Scanning (push) Successful in 53s
Generic: Standards Compliance / Unused Dependencies Check (push) Successful in 55s
Generic: Standards Compliance / Terraform Configuration Validation (push) Successful in 9s
Generic: Standards Compliance / Enterprise Readiness Check (push) Failing after 50s
Generic: Standards Compliance / Repository Health Check (push) Failing after 50s
Generic: Standards Compliance / Compliance Summary (push) Has been cancelled
- feat(manifest): component 'Component - MokoSuiteBackup' pretty name via <element> tag (#299)
- fix(lang): Options page shows raw COM_MOKOSUITEBACKUP_CONFIGURATION (#234)

Claude-Session: https://claude.ai/code/session_01WbGBN9VyRK61zczYWcCQ2i

# Conflicts:
#	.mokogit/workflows/issue-branch.yml
#	CHANGELOG.md
#	SECURITY.md
#	source/packages/com_mokosuitebackup/mokosuitebackup.xml
#	source/packages/mod_mokosuitebackup_cpanel/mod_mokosuitebackup_cpanel.xml
#	source/packages/plg_actionlog_mokosuitebackup/mokosuitebackup.xml
#	source/packages/plg_console_mokosuitebackup/mokosuitebackup.xml
#	source/packages/plg_content_mokosuitebackup/mokosuitebackup.xml
#	source/packages/plg_quickicon_mokosuitebackup/mokosuitebackup.xml
#	source/packages/plg_system_mokosuitebackup/mokosuitebackup.xml
#	source/packages/plg_task_mokosuitebackup/mokosuitebackup.xml
#	source/packages/plg_webservices_mokosuitebackup/mokosuitebackup.xml
#	source/pkg_mokosuitebackup.xml
2026-07-16 17:00:31 -05:00
mokogit-actions[bot] a6de828fec chore(version): pre-release bump to 02.66.03-dev [skip ci] 2026-07-16 21:56:44 +00:00
git-actions[bot] a5e0775b0b chore(version): auto-bump patch 02.66.02-dev [skip ci] 2026-07-16 21:56:27 +00:00
jmiller cba074b0aa Merge branch 'fix/config-lang-stem' into dev
Universal: Auto Version Bump / Version Bump (push) Successful in 13s
Universal: Pre-Release / Build Pre-Release (${{ inputs.stability || github.ref_name }}) (push) Successful in 27s
fix(lang): Options page shows raw COM_MOKOSUITEBACKUP_CONFIGURATION (#234)

Claude-Session: https://claude.ai/code/session_01WbGBN9VyRK61zczYWcCQ2i
2026-07-16 16:55:51 -05:00
jmiller 455689532e chore: sync issue-branch.yml from Template-Joomla [skip ci] 2026-07-16 21:38:35 +00:00
jmiller a9882b9b51 fix(lang): Options page shows raw COM_MOKOSUITEBACKUP_CONFIGURATION
Universal: Pre-Release / Build Pre-Release (${{ inputs.stability || github.ref_name }}) (push) Successful in 34s
Joomla's com_config derives the Options-page title and component name from
the extension element (com_mokosuitebackup), but the language files defined
those keys only under the legacy COM_MOKOJOOMBACKUP_ stem, so the Options
screen rendered the raw constant.

Add the element-derived COM_MOKOSUITEBACKUP and COM_MOKOSUITEBACKUP_CONFIGURATION
keys to all four language files (en-GB/en-US, .ini and .sys.ini), mirroring the
already-migrated COM_MOKOSUITEBACKUP_ACTION_* ACL keys. The 400+ explicitly
referenced COM_MOKOJOOMBACKUP_* keys are unaffected.

Refs #234

Claude-Session: https://claude.ai/code/session_01WbGBN9VyRK61zczYWcCQ2i
2026-07-16 14:45:02 -05:00
mokogit-actions[bot] 2d382cd62b chore(version): pre-release bump to 02.66.01-dev [skip ci] 2026-07-16 19:30:54 +00:00
jmiller db9734b156 Merge pull request 'feat(manifest): component uses Type - Name pretty name via <element> tag' (#299) from feat/component-typename-display into dev
Universal: Auto Version Bump / Version Bump (push) Has been skipped
Universal: Pre-Release / Build Pre-Release (${{ inputs.stability || github.ref_name }}) (push) Successful in 30s
2026-07-16 19:27:52 +00:00
jmiller a4c8b72733 feat(manifest): give the component a "Type - Name" pretty name safely
Universal: PR Check / Branch Policy (pull_request) Successful in 1s
Universal: PR Check / Secret Scan (pull_request) Successful in 7s
Generic: Project CI / Lint & Validate (pull_request) Successful in 11s
Universal: PR Check / Validate PR (pull_request) Failing after 9s
Joomla: Metadata Validation / Validate Joomla Metadata (pull_request) Successful in 9s
Branch Cleanup / Delete merged branch (pull_request) Successful in 2s
RC Revert / Rename rc/ back to dev/ (pull_request) Has been skipped
Generic: Project CI / Tests (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
All sub-extensions already use the "Type - Name" convention; the
component was the exception because its <name> derives the element
(com_ + sanitized name), so "Component - MokoSuiteBackup" would produce
com_component-mokosuitebackup and orphan the real component.

Fix: pin the element with an explicit <element>com_mokosuitebackup</element>
tag. Joomla's InstallerAdapter::getElement() reads <element> before
falling back to <name>, so the element stays com_mokosuitebackup (matches
the existing record on upgrade — no orphan) while the Extensions manager
now shows "Component - MokoSuiteBackup". Manifest filename stays
mokosuitebackup.xml. Verified against Joomla 6.1 InstallerAdapter/
ComponentAdapter and manifest XML is well-formed.

Claude-Session: https://claude.ai/code/session_01WbGBN9VyRK61zczYWcCQ2i
2026-07-16 14:27:24 -05:00
git-actions[bot] 92ba36bc8d chore: promote changelog [Unreleased] → [02.66.00]
Generic: Standards Compliance / Secret Scanning (push) Failing after 4s
Generic: Standards Compliance / License Header Validation (push) Successful in 4s
Generic: Standards Compliance / Repository Structure Validation (push) Successful in 3s
Generic: Standards Compliance / Coding Standards Check (push) Successful in 5s
Generic: Standards Compliance / Workflow Configuration Check (push) Failing after 4s
Generic: Standards Compliance / Documentation Quality Check (push) Successful in 5s
Generic: Standards Compliance / README Completeness Check (push) Failing after 4s
Generic: Standards Compliance / Git Repository Hygiene (push) Successful in 5s
Generic: Standards Compliance / Line Length Check (push) Successful in 4s
Generic: Standards Compliance / Script Integrity Validation (push) Successful in 6s
Generic: Standards Compliance / File Naming Standards (push) Successful in 4s
Generic: Standards Compliance / Insecure Code Pattern Detection (push) Successful in 4s
Generic: Standards Compliance / Version Consistency Check (push) Successful in 50s
Generic: Standards Compliance / Dead Code Detection (push) Successful in 6s
Generic: Standards Compliance / File Size Limits (push) Successful in 3s
Generic: Standards Compliance / Binary File Detection (push) Successful in 6s
Generic: Standards Compliance / TODO/FIXME Tracking (push) Successful in 4s
Generic: Standards Compliance / Unused Dependencies Check (push) Has been cancelled
Generic: Standards Compliance / Broken Link Detection (push) Has been cancelled
Generic: Standards Compliance / API Documentation Coverage (push) Has been cancelled
Generic: Standards Compliance / Accessibility Check (push) Has been cancelled
Generic: Standards Compliance / Performance Metrics (push) Has been cancelled
Generic: Standards Compliance / Enterprise Readiness Check (push) Has been cancelled
Generic: Standards Compliance / Repository Health Check (push) Has been cancelled
Generic: Standards Compliance / Terraform Configuration Validation (push) Has been cancelled
Generic: Standards Compliance / Code Complexity Analysis (push) Successful in 48s
Generic: Standards Compliance / Code Duplication Detection (push) Successful in 49s
Generic: Standards Compliance / Compliance Summary (push) Has been cancelled
Generic: Standards Compliance / Dependency Vulnerability Scanning (push) Successful in 49s
2026-07-16 19:22:48 +00:00
git-actions[bot] 2b6f3d709f chore(release): build 02.66.00 [skip ci] 2026-07-16 19:22:38 +00:00
jmiller 102746e834 Merge pull request 'Release: dev -> main (package install redeclare fix + backlog)' (#302) from release/dev-to-main-r3 into main
Generic: Standards Compliance / Secret Scanning (push) Failing after 4s
Generic: Standards Compliance / License Header Validation (push) Successful in 4s
Generic: Standards Compliance / Repository Structure Validation (push) Successful in 4s
Generic: Standards Compliance / Coding Standards Check (push) Successful in 6s
Generic: Standards Compliance / Workflow Configuration Check (push) Failing after 5s
Generic: Standards Compliance / Documentation Quality Check (push) Successful in 5s
Generic: Standards Compliance / README Completeness Check (push) Failing after 5s
Generic: Standards Compliance / Git Repository Hygiene (push) Successful in 6s
Generic: Standards Compliance / Script Integrity Validation (push) Successful in 6s
Generic: Standards Compliance / Line Length Check (push) Successful in 5s
Generic: Standards Compliance / File Naming Standards (push) Successful in 4s
Generic: Standards Compliance / Insecure Code Pattern Detection (push) Successful in 4s
Generic: Standards Compliance / Version Consistency Check (push) Successful in 52s
Generic: Standards Compliance / Dead Code Detection (push) Successful in 7s
Generic: Standards Compliance / File Size Limits (push) Successful in 4s
Generic: Standards Compliance / Binary File Detection (push) Successful in 7s
Generic: Standards Compliance / TODO/FIXME Tracking (push) Successful in 4s
Generic: Standards Compliance / Code Complexity Analysis (push) Successful in 52s
Generic: Standards Compliance / Code Duplication Detection (push) Successful in 52s
Generic: Standards Compliance / Broken Link Detection (push) Successful in 4s
Generic: Standards Compliance / API Documentation Coverage (push) Successful in 5s
Generic: Standards Compliance / Accessibility Check (push) Successful in 4s
Generic: Standards Compliance / Performance Metrics (push) Successful in 4s
Generic: Standards Compliance / Dependency Vulnerability Scanning (push) Successful in 54s
Generic: Standards Compliance / Unused Dependencies Check (push) Successful in 57s
Generic: Standards Compliance / Terraform Configuration Validation (push) Successful in 9s
Generic: Standards Compliance / Enterprise Readiness Check (push) Failing after 52s
Generic: Standards Compliance / Repository Health Check (push) Failing after 51s
Generic: Standards Compliance / Compliance Summary (push) Has been cancelled
2026-07-16 19:21:59 +00:00
mokogit-actions[bot] 1f189349f5 chore(version): pre-release bump to 02.65.04-dev [skip ci]
Universal: Build & Release / Promote to RC (pull_request) Has been skipped
Universal: Build & Release / Build & Release Pipeline (pull_request) Successful in 32s
Branch Cleanup / Delete merged branch (pull_request) Successful in 2s
RC Revert / Rename rc/ back to dev/ (pull_request) Has been skipped
2026-07-16 19:21:10 +00:00
jmiller 28adcd0778 Merge pull request 'fix(install): guard package script against Cannot redeclare class fatal' (#301) from fix/package-script-redeclare-guard into dev
Universal: Auto Version Bump / Version Bump (push) Has been skipped
Universal: Pre-Release / Build Pre-Release (${{ inputs.stability || github.ref_name }}) (push) Successful in 33s
2026-07-16 19:20:43 +00:00
mokogit-actions[bot] 3644a99d07 chore(version): pre-release bump to 02.65.03-dev [skip ci]
Branch Cleanup / Delete merged branch (pull_request) Successful in 1s
RC Revert / Rename rc/ back to dev/ (pull_request) Has been skipped
2026-07-16 19:17:07 +00:00
jmiller c70f024201 fix(install): guard package script against "Cannot redeclare class" fatal
Universal: Pre-Release / Build Pre-Release (${{ inputs.stability || github.ref_name }}) (push) Successful in 44s
Generic: Project CI / Lint & Validate (pull_request) Has been cancelled
Universal: PR Check / Branch Policy (pull_request) Has been cancelled
Universal: PR Check / Secret Scan (pull_request) Has been cancelled
Universal: PR Check / Validate PR (pull_request) Has been cancelled
Joomla: Metadata Validation / Validate Joomla Metadata (pull_request) Has been cancelled
Generic: Project CI / Tests (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
Package updates could fatal with a compile-time "Cannot redeclare class
Pkg_MokoSuiteBackupInstallerScript" when Joomla extracts/includes the
package script.php twice in one request (two tmp/install_* dirs). Because
the class is a top-level unconditional declaration it is early-bound at
compile time, so a runtime return can't prevent the redeclare — wrapping
it in `if (!class_exists(...)) { ... }` makes it a conditional (runtime)
declaration, so the second include is a no-op instead of a fatal.

Claude-Session: https://claude.ai/code/session_01WbGBN9VyRK61zczYWcCQ2i
2026-07-16 14:16:09 -05:00
mokogit-actions[bot] 9677edcf8b chore(version): pre-release bump to 02.65.02-dev [skip ci] 2026-07-16 18:59:59 +00:00
git-actions[bot] 90e6fe07ce chore(version): auto-bump patch 02.65.01-dev [skip ci] 2026-07-16 18:59:43 +00:00
git-actions[bot] 87c8a9a2ca chore: promote changelog [Unreleased] → [02.65.00]
Generic: Standards Compliance / Secret Scanning (push) Failing after 4s
Generic: Standards Compliance / License Header Validation (push) Successful in 3s
Generic: Standards Compliance / Repository Structure Validation (push) Successful in 3s
Generic: Standards Compliance / Coding Standards Check (push) Successful in 4s
Generic: Standards Compliance / Workflow Configuration Check (push) Failing after 5s
Generic: Standards Compliance / Documentation Quality Check (push) Successful in 4s
Generic: Standards Compliance / README Completeness Check (push) Failing after 5s
Generic: Standards Compliance / Git Repository Hygiene (push) Successful in 7s
Generic: Standards Compliance / Script Integrity Validation (push) Successful in 5s
Generic: Standards Compliance / Line Length Check (push) Successful in 4s
Generic: Standards Compliance / File Naming Standards (push) Successful in 4s
Generic: Standards Compliance / Insecure Code Pattern Detection (push) Successful in 3s
Generic: Standards Compliance / Version Consistency Check (push) Successful in 45s
Generic: Standards Compliance / Dead Code Detection (push) Successful in 5s
Generic: Standards Compliance / File Size Limits (push) Successful in 3s
Generic: Standards Compliance / Binary File Detection (push) Successful in 5s
Generic: Standards Compliance / TODO/FIXME Tracking (push) Successful in 3s
Generic: Standards Compliance / Code Complexity Analysis (push) Successful in 40s
Generic: Standards Compliance / Code Duplication Detection (push) Successful in 43s
Generic: Standards Compliance / Broken Link Detection (push) Successful in 5s
Generic: Standards Compliance / API Documentation Coverage (push) Successful in 5s
Generic: Standards Compliance / Accessibility Check (push) Successful in 4s
Generic: Standards Compliance / Performance Metrics (push) Successful in 4s
Generic: Standards Compliance / Dependency Vulnerability Scanning (push) Successful in 54s
Generic: Standards Compliance / Unused Dependencies Check (push) Successful in 58s
Generic: Standards Compliance / Terraform Configuration Validation (push) Successful in 8s
Generic: Standards Compliance / Enterprise Readiness Check (push) Failing after 51s
Universal: Auto Version Bump / Version Bump (push) Successful in 11s
Generic: Standards Compliance / Repository Health Check (push) Failing after 51s
Universal: Pre-Release / Build Pre-Release (${{ inputs.stability || github.ref_name }}) (push) Successful in 27s
Generic: Standards Compliance / Compliance Summary (push) Has been cancelled
2026-07-16 18:44:09 +00:00
git-actions[bot] 21bcb0a54b chore(release): build 02.65.00 [skip ci] 2026-07-16 18:43:59 +00:00
jmiller 84fd06bf2d Merge pull request 'Release: dev -> main (restore fixes, theming, schema-checker, quickicon hardening)' (#300) from release/dev-to-main-r2 into main
Generic: Standards Compliance / Secret Scanning (push) Failing after 4s
Generic: Standards Compliance / License Header Validation (push) Successful in 4s
Generic: Standards Compliance / Repository Structure Validation (push) Successful in 4s
Generic: Standards Compliance / Coding Standards Check (push) Successful in 5s
Generic: Standards Compliance / Workflow Configuration Check (push) Failing after 4s
Generic: Standards Compliance / Documentation Quality Check (push) Successful in 4s
Generic: Standards Compliance / README Completeness Check (push) Failing after 4s
Generic: Standards Compliance / Git Repository Hygiene (push) Successful in 7s
Generic: Standards Compliance / Script Integrity Validation (push) Successful in 5s
Generic: Standards Compliance / File Naming Standards (push) Successful in 4s
Generic: Standards Compliance / Line Length Check (push) Successful in 6s
Generic: Standards Compliance / Insecure Code Pattern Detection (push) Successful in 5s
Generic: Standards Compliance / Version Consistency Check (push) Successful in 50s
Generic: Standards Compliance / Dead Code Detection (push) Successful in 6s
Generic: Standards Compliance / File Size Limits (push) Successful in 3s
Generic: Standards Compliance / Binary File Detection (push) Successful in 6s
Generic: Standards Compliance / TODO/FIXME Tracking (push) Successful in 3s
Generic: Standards Compliance / Code Complexity Analysis (push) Successful in 45s
Generic: Standards Compliance / Code Duplication Detection (push) Successful in 44s
Generic: Standards Compliance / Broken Link Detection (push) Successful in 3s
Generic: Standards Compliance / API Documentation Coverage (push) Successful in 3s
Generic: Standards Compliance / Accessibility Check (push) Successful in 3s
Generic: Standards Compliance / Performance Metrics (push) Successful in 4s
Generic: Standards Compliance / Dependency Vulnerability Scanning (push) Successful in 48s
Generic: Standards Compliance / Unused Dependencies Check (push) Successful in 51s
Generic: Standards Compliance / Terraform Configuration Validation (push) Successful in 7s
Generic: Standards Compliance / Enterprise Readiness Check (push) Failing after 47s
Generic: Standards Compliance / Repository Health Check (push) Failing after 46s
Generic: Standards Compliance / Compliance Summary (push) Has been cancelled
2026-07-16 18:43:04 +00:00
jmiller 9a82fc97f2 Merge remote-tracking branch 'origin/dev' into release/dev-to-main-r2
Universal: Build & Release / Promote to RC (pull_request) Has been skipped
Universal: Build & Release / Build & Release Pipeline (pull_request) Successful in 32s
Universal: PR Check / Branch Policy (pull_request) Successful in 1s
Generic: Project CI / Lint & Validate (pull_request) Successful in 10s
Universal: PR Check / Secret Scan (pull_request) Successful in 6s
Universal: PR Check / Validate PR (pull_request) Failing after 9s
Generic: Repo Health / Site Health (pull_request) Has been skipped
Generic: Repo Health / Access control (pull_request) Successful in 2s
Generic: Standards Compliance / Secret Scanning (pull_request) Failing after 4s
Generic: Standards Compliance / License Header Validation (pull_request) Successful in 4s
Generic: Standards Compliance / Repository Structure Validation (pull_request) Successful in 4s
Joomla: Metadata Validation / Validate Joomla Metadata (pull_request) Successful in 20s
Generic: Standards Compliance / Coding Standards Check (pull_request) Successful in 5s
Generic: Standards Compliance / Workflow Configuration Check (pull_request) Failing after 5s
Generic: Standards Compliance / Documentation Quality Check (pull_request) Successful in 4s
Generic: Standards Compliance / README Completeness Check (pull_request) Failing after 4s
Generic: Standards Compliance / Git Repository Hygiene (pull_request) Successful in 6s
Generic: Standards Compliance / Script Integrity Validation (pull_request) Successful in 6s
Generic: Standards Compliance / Line Length Check (pull_request) Successful in 5s
Generic: Standards Compliance / File Naming Standards (pull_request) Successful in 4s
Generic: Standards Compliance / Insecure Code Pattern Detection (pull_request) Successful in 4s
Generic: Standards Compliance / Version Consistency Check (pull_request) Successful in 49s
Generic: Standards Compliance / Dead Code Detection (pull_request) Successful in 8s
Generic: Standards Compliance / File Size Limits (pull_request) Successful in 4s
Generic: Standards Compliance / Binary File Detection (pull_request) Successful in 6s
Generic: Standards Compliance / TODO/FIXME Tracking (pull_request) Successful in 4s
Generic: Standards Compliance / Code Complexity Analysis (pull_request) Successful in 51s
Generic: Standards Compliance / Code Duplication Detection (pull_request) Successful in 52s
Generic: Standards Compliance / Broken Link Detection (pull_request) Successful in 4s
Generic: Standards Compliance / API Documentation Coverage (pull_request) Successful in 4s
Generic: Standards Compliance / Accessibility Check (pull_request) Successful in 4s
Generic: Standards Compliance / Performance Metrics (pull_request) Successful in 4s
Generic: Standards Compliance / Dependency Vulnerability Scanning (pull_request) Successful in 50s
Generic: Standards Compliance / Unused Dependencies Check (pull_request) Successful in 53s
Generic: Standards Compliance / Terraform Configuration Validation (pull_request) Successful in 8s
Generic: Standards Compliance / Enterprise Readiness Check (pull_request) Failing after 51s
Branch Cleanup / Delete merged branch (pull_request) Successful in 1s
RC Revert / Rename rc/ back to dev/ (pull_request) Has been skipped
Generic: Standards Compliance / Repository Health Check (pull_request) Failing after 52s
Generic: Project CI / Tests (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: Scripts Governance (pull_request) Has been cancelled
Generic: Repo Health / Report: Repository Health (pull_request) Has been cancelled
Generic: Standards Compliance / Compliance Summary (pull_request) Has been cancelled
# Conflicts:
#	.mokogit/workflows/issue-branch.yml
2026-07-16 13:40:57 -05:00
mokogit-actions[bot] 22e8ebe832 chore(version): pre-release bump to 02.64.13-dev [skip ci] 2026-07-16 18:09:26 +00:00
jmiller b1b81fd107 Merge pull request 'fix(quickicon): never let the dashboard widget crash the admin page' (#298) from fix/quickicon-harden into dev
Universal: Auto Version Bump / Version Bump (push) Has been skipped
Universal: Pre-Release / Build Pre-Release (${{ inputs.stability || github.ref_name }}) (push) Successful in 22s
2026-07-16 18:09:12 +00:00
mokogit-actions[bot] 747e50bf62 chore(version): pre-release bump to 02.64.12-dev [skip ci]
RC Revert / Rename rc/ back to dev/ (pull_request) Has been skipped
Branch Cleanup / Delete merged branch (pull_request) Successful in 1s
2026-07-16 18:09:03 +00:00
jmiller 7e58ca7598 fix(quickicon): never let the dashboard widget crash the admin page (#297)
Universal: PR Check / Branch Policy (pull_request) Successful in 1s
Universal: PR Check / Secret Scan (pull_request) Successful in 7s
Generic: Project CI / Lint & Validate (pull_request) Successful in 11s
Universal: PR Check / Validate PR (pull_request) Failing after 9s
Joomla: Metadata Validation / Validate Joomla Metadata (pull_request) Successful in 8s
Universal: Pre-Release / Build Pre-Release (${{ inputs.stability || github.ref_name }}) (push) Successful in 23s
Generic: Project CI / Tests (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
The mod_quickicon helper array_merge()s a defaults array into each icon a
plugin returns; if a plugin's DB query throws or it returns a malformed
(unwrapped) icon, the whole admin dashboard can 500 with an array_merge
TypeError. The current handler is structurally correct (icons are wrapped
in a list, matching core plg_quickicon_joomlaupdate), so this hardens it:

- Extract the query/state logic into buildStatusIcon().
- Wrap it in try/catch — on any Throwable, log and contribute no icon
  instead of bubbling into the dashboard render.
- Document why the [$icon] list-wrapping is load-bearing.

Closes #297.

Claude-Session: https://claude.ai/code/session_01WbGBN9VyRK61zczYWcCQ2i
2026-07-16 13:07:26 -05:00
mokogit-actions[bot] 54d7710783 chore(version): pre-release bump to 02.64.11-dev [skip ci] 2026-07-16 18:07:20 +00:00
jmiller f3bcf9d79e Merge pull request 'fix(restore): surface DatabaseImporter errors + quote-aware split' (#294) from fix/databaseimporter-surface-errors into dev
Universal: Auto Version Bump / Version Bump (push) Has been skipped
Universal: Pre-Release / Build Pre-Release (${{ inputs.stability || github.ref_name }}) (push) Successful in 30s
2026-07-16 18:06:56 +00:00
jmiller 028e1c7e80 fix(restore): surface DatabaseImporter errors + quote-aware split (#188)
Universal: Pre-Release / Build Pre-Release (${{ inputs.stability || github.ref_name }}) (push) Failing after 7s
Universal: PR Check / Branch Policy (pull_request) Successful in 2s
Universal: PR Check / Secret Scan (pull_request) Successful in 8s
Universal: PR Check / Validate PR (pull_request) Failing after 12s
Generic: Project CI / Lint & Validate (pull_request) Successful in 15s
Branch Cleanup / Delete merged branch (pull_request) Successful in 1s
RC Revert / Rename rc/ back to dev/ (pull_request) Has been skipped
Joomla: Metadata Validation / Validate Joomla Metadata (pull_request) Successful in 12s
Generic: Project CI / Tests (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
RestoreEngine ignored DatabaseImporter::hasErrors(), so the non-stepped
restore always reported success even when SQL statements failed. It now
logs the failures and returns a warning ("database may be incomplete").
DatabaseImporter's splitter is also quote/backtick/escape-aware so a ; or
newline inside a value no longer splits a statement, and comments are only
skipped between statements — matching the stepped-engine fix (#291).

Closes #188.

Claude-Session: https://claude.ai/code/session_01WbGBN9VyRK61zczYWcCQ2i
2026-07-16 13:06:44 -05:00
mokogit-actions[bot] c42962c919 chore(version): pre-release bump to 02.64.10-dev [skip ci] 2026-07-16 18:02:27 +00:00
mokogit-actions[bot] 3616f49570 chore(version): pre-release bump to 02.64.09-dev [skip ci] 2026-07-16 18:02:01 +00:00
jmiller ed2dbee579 Merge pull request 'fix: default ntfy server to ntfy.mokoconsulting.tech' (#293) from fix/ntfy-default-url into dev
Universal: Auto Version Bump / Version Bump (push) Has been skipped
Universal: Pre-Release / Build Pre-Release (${{ inputs.stability || github.ref_name }}) (push) Successful in 21s
2026-07-16 18:01:33 +00:00
jmiller 95d2b3375c Merge pull request 'fix: snapshots restore type labels show raw type' (#295) from fix/snapshots-typelabel into dev
Universal: Auto Version Bump / Version Bump (push) Has been skipped
Universal: Pre-Release / Build Pre-Release (${{ inputs.stability || github.ref_name }}) (push) Successful in 37s
2026-07-16 18:01:24 +00:00
jmiller 2b6dbdac23 fix: snapshots restore type labels show raw type (#170)
RC Revert / Rename rc/ back to dev/ (pull_request) Has been skipped
Branch Cleanup / Delete merged branch (pull_request) Successful in 4s
typeLabels[TYPE] referenced an undefined uppercase variable; the forEach
parameter is lowercase `type`, so every type label rendered as the raw
type string instead of its translated label.

Closes #170.

Claude-Session: https://claude.ai/code/session_01WbGBN9VyRK61zczYWcCQ2i
2026-07-16 12:04:02 -05:00
jmiller 313b96b070 fix: default ntfy server to ntfy.mokoconsulting.tech (#166)
RC Revert / Rename rc/ back to dev/ (pull_request) Has been skipped
Branch Cleanup / Delete merged branch (pull_request) Successful in 3s
Replaces the old public ntfy.sh default in the runtime defaults: profile
form default/hint, NotificationSender fallbacks, the ntfy_server column
default in install.mysql.sql, and the en-GB field description. ntfy.sh is
still documented as the public alternative.

Closes #166.

Claude-Session: https://claude.ai/code/session_01WbGBN9VyRK61zczYWcCQ2i
2026-07-16 11:57:26 -05:00
jmiller 57e7dd98b1 Merge pull request 'fix(restore): surface partial DB-import failures + quote-aware SQL split' (#292) from fix/restore-silent-sql-import into dev
Universal: Auto Version Bump / Version Bump (push) Has been skipped
2026-07-16 16:49:14 +00:00
jmiller 9323de955f fix(restore): surface partial DB-import failures + quote-aware SQL split
Stepped restore silently swallowed every failed SQL statement and always
reported "Restore complete", so a half-imported database looked fine.

- SteppedRestoreEngine::stepDatabase() now records a failure count + first
  few messages (recordSqlError) instead of only error_log(), and the
  completion step returns the count with a warning; the modal shows
  "Restore Complete (with warnings)" and does NOT auto-return.
- New quote/backtick/escape-aware isCompleteStatement() replaces the naive
  "line ends with ;" terminator, so a ; or newline inside a string value no
  longer splits a statement mid-value.
- Comment/blank lines are only skipped BETWEEN statements, never inside an
  accumulating multi-line statement.

Relates to #188 (DatabaseImporter) and the standalone MokoRestore splitter;
tracked in #291.

Claude-Session: https://claude.ai/code/session_01WbGBN9VyRK61zczYWcCQ2i
2026-07-16 11:48:41 -05:00
jmiller b0590bef80 Merge pull request 'fix(restore): clean up staging + session state on cancel/abandon' (#287) from fix/restore-cancel-cleanup into dev 2026-07-16 16:47:00 +00:00
jmiller ab6bee7b51 fix(restore): clean up staging + session state on cancel/abandon
The stepped-restore Cancel button (added with the progress-modal controls)
stopped the client loop and reloaded, but left the server-side restore
session, *.restore.json state, and the extracted staging directory behind
with no reaper — so cancelled/abandoned restores leaked disk indefinitely.

- SteppedRestoreEngine::cancel() mirrors the failure-cleanup path (delete
  staging dir + restore state + session).
- AjaxController::restoreCancel() exposes it (token + restore ACL gated).
- Restore modal Cancel now calls ajax.restoreCancel with the session id
  before leaving (session id tracked in restoreSessionId).
- SteppedSession::cleanupOldSessions() also reaps orphaned
  mokosuitebackup-restore-* staging dirs >24h (backstop for browser-close).

Found by an audit of the restore path against recent changes; the schema
(retired sftp_*/VARCHAR include_mokorestore) and PHP 8.3 changes were
confirmed to NOT affect restore.

Claude-Session: https://claude.ai/code/session_01WbGBN9VyRK61zczYWcCQ2i
2026-07-16 11:46:31 -05:00
jmiller 388db8c7d0 Merge pull request 'feat(ui): re-brand standalone MokoRestore wizard to the black theme' (#286) from feat/mokorestore-black-theme into dev 2026-07-16 16:46:07 +00:00
jmiller f8e121d925 feat(ui): re-brand the standalone MokoRestore wizard to the black theme
The generated restore.php wizard used a light "Joomla-styled" blue theme.
Restyled its stylesheet to the full-black MokoSuite look that matches the
runbackup / backup-progress screens: black background + big header, dark
panels/cards, dark form inputs with readable contrast, MokoSuite blue/green
accents, dark step-bar/checks/alerts/progress/log, and all inline help-box
styles converted from light to the dark palette. No logic changes.

Claude-Session: https://claude.ai/code/session_01WbGBN9VyRK61zczYWcCQ2i
2026-07-16 11:12:49 -05:00
jmiller e88040f60d Merge pull request 'feat(ui): auto-continue + action buttons on the backup-progress modal' (#285) from feat/progress-modal-controls into dev 2026-07-16 15:54:55 +00:00
jmiller 4ab24b20a6 feat(ui): mirror progress-modal controls onto the restore modal
Restore-progress modal now matches the backup modal: auto-continue
checkbox, terminal buttons (Retry restore / Back to backups), and a
double-confirm Cancel. Restore's cancel warns that stopping mid-flight
may leave the site partially restored (no server rollback), and there is
no "view record" button (restore has no new record). startSteppedRestore
split into a re-runnable runSteppedRestore() so Retry works. Title via
safe DOM (no innerHTML).

Claude-Session: https://claude.ai/code/session_01WbGBN9VyRK61zczYWcCQ2i
2026-07-16 10:52:02 -05:00
jmiller d0c12914a8 feat(ui): add auto-continue + action buttons to the backup-progress modal
Brings the full-screen runbackup screen's controls onto the black
backup-progress modal in the backups view:
- "Automatically return to the list when finished" checkbox (default on);
  unchecked leaves the completion screen up with buttons.
- Cancel button while running (double confirm) — stops the step loop and
  cancels the record via ajax.cancelBackup.
- Terminal buttons: View backup record + Back to backups on success;
  Retry + Back to backups on failure.
Title icon/text updated via safe DOM (no innerHTML). Restore modal
unchanged in this pass.

Claude-Session: https://claude.ai/code/session_01WbGBN9VyRK61zczYWcCQ2i
2026-07-16 10:46:53 -05:00
jmiller c77e921582 Merge pull request 'fix(lang): drop redundant backup word in admin-lock message' (#284) from fix/lock-message-wording into dev 2026-07-16 15:38:50 +00:00
jmiller cac691ec92 fix(lang): drop redundant "backup" in admin-lock message
"A MokoSuiteBackup backup is currently running" -> "A MokoSuiteBackup
is currently running". en-GB + en-US.

Claude-Session: https://claude.ai/code/session_01WbGBN9VyRK61zczYWcCQ2i
2026-07-16 10:38:39 -05:00
jmiller e0b5d72c40 Merge pull request 'chore(ci): converge dev with main full gitea->git rebrand' (#283) from chore/converge-full-rebrand into dev 2026-07-16 14:38:14 +00:00
jmiller 0b9cf6f4d8 chore(ci): converge dev with main's full gitea->git rebrand
Back-fills dev with the fuller rebrand that shipped on main in the
dev->main release (#282): MokoGitea->MokoGIT, GITEA_ORG->GIT_ORG,
mokogit-actions[bot], and the corrected .mokogit/CLAUDE.md path. dev's
earlier partial rename (#279) is superseded so dev and main no longer
diverge on CI config.

Claude-Session: https://claude.ai/code/session_01WbGBN9VyRK61zczYWcCQ2i
2026-07-16 09:38:06 -05:00
jmiller 775942e9f7 Merge pull request 'Release: dev → main — schema-checker fix + unified progress theme' (#282) from release/dev-to-main into main 2026-07-16 14:36:06 +00:00
jmiller 532c5171dc Merge remote-tracking branch 'origin/dev' into release/dev-to-main
# Conflicts:
#	.mokogit/.mokogit/CLAUDE.md
#	.mokogit/.mokogit/workflows/branch-cleanup.yml
#	.mokogit/.mokogit/workflows/issue-branch.yml
#	.mokogit/CLAUDE.md
#	.mokogit/workflows/auto-release.yml
#	.mokogit/workflows/branch-cleanup.yml
#	.mokogit/workflows/ci-joomla.yml
#	.mokogit/workflows/issue-branch.yml
#	.mokogit/workflows/pr-metadata-check.yml
#	.mokogit/workflows/pre-release.yml
#	.mokogit/workflows/rc-revert.yml
#	.mokogitea/CLAUDE.md
#	.mokogitea/workflows/branch-cleanup.yml
#	.mokogitea/workflows/issue-branch.yml
2026-07-16 09:35:02 -05:00
jmiller 2eeb9343b3 Merge pull request 'feat(ui): unify backup/restore progress modals with the runbackup theme' (#281) from feat/unified-progress-theme into dev 2026-07-16 14:25:28 +00:00
jmiller 57d94b701b docs(changelog): note unified backup/restore progress theming
Claude-Session: https://claude.ai/code/session_01WbGBN9VyRK61zczYWcCQ2i
2026-07-16 09:25:04 -05:00
jmiller 15992574c3 feat(ui): unify backup/restore progress modals with the runbackup theme
Gives the stepped backup-progress and restore-progress modals in the
backups view the same full-screen black, big-text, Atum-coloured
progress-bar look as the view=runbackup screen, so every backup progress
surface matches.

- New media/css/backup-progress.css: black is scoped to .msb-progress-modal
  (not html/body), reuses the runbackup .msb-rb-progress/.msb-rb-bar bar with
  animated/success/danger states.
- Registered com_mokosuitebackup.backup-progress asset; loaded in the backups
  view via addStyleSheet (useStyle does not emit for this component).
- Both modals: full-screen dialog, big heading, big status text, shared bar.
- setBarState() drives animated -> green(complete)/red(fail) for both bars.

Claude-Session: https://claude.ai/code/session_01WbGBN9VyRK61zczYWcCQ2i
2026-07-16 09:24:29 -05:00
jmiller 7c6fe53b64 Merge pull request 'fix(db): reconcile mokosuitebackup_profiles schema with update history' (#278) from fix/schema-checker-profiles into dev 2026-07-16 14:23:39 +00:00
jmiller b481390355 fix(db): retire sftp_* checker demands instead of re-adding columns
Review of PR #278 caught that script.php postflight
(dropLegacyRemoteColumns) and 02.52.25.sql deliberately purge the
legacy sftp_* columns on every install/update, so re-adding them to
install.mysql.sql was futile — postflight drops them right back,
leaving the ADD-COLUMN checks (01.35.00/01.36.00) still failing.

Corrected approach:
- Revert the install.mysql.sql sftp_* block.
- Rewrite the retired sftp_* ADD COLUMN statements in 01.35.00/01.36.00
  as bare ADD. MySQL treats ADD and ADD COLUMN identically, but Joomla's
  MysqlChangeItem only builds an ADD-COLUMN check for the `ADD COLUMN`
  form; bare `ADD` is skipped. Upgrades still add the columns (so the
  01.41.00 migration works) and 02.52.25/postflight still drop them, so
  they stay absent at rest and the checker no longer flags them.

include_mokorestore fixes (01.01.01 VARCHAR, 01.39.00 MODIFY) and the
02.64.05 bump are unchanged.

Claude-Session: https://claude.ai/code/session_01WbGBN9VyRK61zczYWcCQ2i
2026-07-16 08:38:00 -05:00
mokogitea-actions[bot] 7425f9d204 chore(version): pre-release bump to 02.64.06-dev [skip ci] 2026-07-16 08:38:00 -05:00
jmiller 3765c99769 fix(db): reconcile mokosuitebackup_profiles schema with update history
Joomla's Database maintenance view reported "One Problem" on
MokoSuiteBackup with four sub-issues in #__mokosuitebackup_profiles.
All were schema-definition drift, not data corruption (confirmed
against Joomla 6.1 core: ChangeSet/MysqlChangeItem check each update
statement independently, with no cross-file supersession).

- install.mysql.sql: restore the legacy sftp_* columns so the old
  ADD COLUMN checks (01.35.00/01.36.00) pass on fresh installs. The
  data itself lives in #__mokosuitebackup_remotes; these columns are
  retained only for checker consistency with upgraded sites.
- 01.01.01.sql: declare include_mokorestore as VARCHAR(20) (was
  TINYINT, later widened in 01.39.00). Without supersession the stale
  TINYINT check fails forever against the now-VARCHAR column.
- 01.39.00.sql: MODIFY COLUMN -> MODIFY. The checker misparses
  "MODIFY COLUMN x" as column-name="COLUMN".
- 02.64.05.sql: idempotent MODIFY to guarantee VARCHAR on stragglers;
  version bump 02.64.04 -> 02.64.05 with its matching update file.

Claude-Session: https://claude.ai/code/session_01WbGBN9VyRK61zczYWcCQ2i
2026-07-16 08:37:59 -05:00
jmiller 4006a7e4c8 Merge pull request 'chore(ci): rename .mokogitea workflow directory to .mokogit' (#279) from chore/rename-mokogit-dir into dev 2026-07-16 13:37:24 +00:00
jmiller 595a5d9f3f chore(ci): rename MOKOGITEA_TOKEN/URL to MOKOGIT_TOKEN/URL
Completes the .mokogit rebrand to match main: the server secret was
renamed MOKOGITEA_TOKEN -> MOKOGIT_TOKEN and the workflow env var
MOKOGITEA_URL -> MOKOGIT_URL. Without this the merged workflows would
reference an undefined secret and CI auth would fail.

Claude-Session: https://claude.ai/code/session_01WbGBN9VyRK61zczYWcCQ2i
2026-07-16 08:36:49 -05:00
jmiller d1a5a57847 chore(ci): rename .mokogitea workflow directory to .mokogit
Server-side Actions scanning path was reconfigured to .mokogit/workflows.
Renames the directory (history preserved) and updates every internal path
reference: trigger globs, reusable-workflow `uses:` includes, WORKFLOWS_DIR,
REPO_REQUIRED_ARTIFACTS, PATH headers, and .gitattributes export-ignore.
Scope: this repo only. Product name "MokoGitea", the actions bot, env vars,
and URLs are unaffected.

Claude-Session: https://claude.ai/code/session_01WbGBN9VyRK61zczYWcCQ2i
2026-07-16 07:47:14 -05:00
mokogitea-actions[bot] 120cca5594 chore(version): pre-release bump to 02.64.04-dev [skip ci] 2026-07-15 03:33:52 +00:00
jmiller 536a9b0c78 Merge pull request 'feat: lock admin UI while a backup is running' (#277) from feat/lock-admin-during-backup into dev
Universal: Auto Version Bump / Version Bump (push) Has been skipped
Universal: Pre-Release / Build Pre-Release (${{ inputs.stability || github.ref_name }}) (push) Successful in 49s
2026-07-15 03:33:15 +00:00
jmiller 257003fb74 feat: lock the admin UI while a backup is actively running
Generic: Project CI / Lint & Validate (pull_request) Successful in 13s
Universal: PR Check / Branch Policy (pull_request) Successful in 2s
Universal: PR Check / Secret Scan (pull_request) Successful in 7s
Universal: PR Check / Validate PR (pull_request) Failing after 9s
Joomla: Metadata Validation / Validate Joomla Metadata (pull_request) Successful in 10s
Branch Cleanup / Delete merged branch (pull_request) Successful in 3s
RC Revert / Rename rc/ back to dev/ (pull_request) Has been skipped
Generic: Project CI / Tests (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
New "Lock admin during backup" option (on by default). While a stepped
backup is running, every admin page load except the full-screen backup
screen and the login page is replaced by a "backup in progress"
interstitial that auto-refreshes, so no other window can navigate or change
anything mid-backup.

Detection uses the stepped-backup session file heartbeat
(JPATH_ROOT/tmp/mokosuitebackup-sessions/mb_*.json is rewritten every step
and deleted on completion) — a file with an mtime within ~45s means a
backup is live. A crashed/abandoned backup goes stale and releases the lock
on its own, so the admin is never permanently locked. Implemented in the
system plugin's onAfterRoute (admin, HTML page loads only; AJAX/assets and
the backup's own step requests are unaffected).

Claude-Session: https://claude.ai/code/session_01WbGBN9VyRK61zczYWcCQ2i
2026-07-14 22:33:07 -05:00
jmiller d3e0b32737 fix(ci): harden branch-cleanup.yml against Actions injection (sync from Template-Generic) 2026-07-15 03:25:22 +00:00
jmiller 353adf17c7 fix(ci): harden issue-branch.yml against Actions injection (sync from Template-Generic) 2026-07-15 03:25:21 +00:00
mokogitea-actions[bot] 254bf18c45 chore(version): pre-release bump to 02.64.03-dev [skip ci] 2026-07-15 03:17:23 +00:00
jmiller 6fc57eb163 Merge pull request 'fix(task): scheduled-task fieldset label renders raw string' (#276) from fix/scheduler-fieldset-label into dev
Universal: Auto Version Bump / Version Bump (push) Has been skipped
Universal: Pre-Release / Build Pre-Release (${{ inputs.stability || github.ref_name }}) (push) Successful in 24s
2026-07-15 03:16:58 +00:00
jmiller 9e7ebfe89e fix(task): scheduled-task fieldset label renders raw string
Universal: PR Check / Branch Policy (pull_request) Successful in 1s
Universal: PR Check / Secret Scan (pull_request) Successful in 6s
Generic: Project CI / Lint & Validate (pull_request) Successful in 11s
Branch Cleanup / Delete merged branch (pull_request) Successful in 2s
RC Revert / Rename rc/ back to dev/ (pull_request) Has been skipped
Universal: Pre-Release / Build Pre-Release (${{ inputs.stability || github.ref_name }}) (push) Failing after 10s
Universal: PR Check / Validate PR (pull_request) Failing after 11s
Joomla: Metadata Validation / Validate Joomla Metadata (pull_request) Successful in 10s
Generic: Project CI / Tests (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
com_scheduler's task edit view renders a params fieldset legend as
Text::_($fieldset->label ?: 'COM_SCHEDULER_FIELDSET_'.$name). The
run_profile / run_snapshot fieldsets had no `label` attribute, so it fell
back to an undefined generated key (COM_SCHEDULER_RUN_PROFILE_FIELDSET_LABEL)
and showed the raw constant. Give each fieldset a label attribute pointing
to a plugin-defined constant, and define them in en-GB/en-US.

Claude-Session: https://claude.ai/code/session_01WbGBN9VyRK61zczYWcCQ2i
2026-07-14 22:16:47 -05:00
jmiller 4ea0761706 Merge pull request 'chore(rebrand): fix residual gitea (space-path + named files)' (#275) from chore/rebrand-gitea-residual into main 2026-07-15 01:40:19 +00:00
jmiller 828aa3100e chore(rebrand): fix residual gitea (space-path files + gitea-named files)
Claude-Session: https://claude.ai/code/session_01DQEMmJPe61ya7HDfA6BHP8
2026-07-14 20:40:16 -05:00
jmiller c7b38067f6 Merge pull request 'chore(rebrand): gitea -> git (MokoGIT, .mokogitea -> .mokogit)' (#274) from chore/rebrand-gitea-to-git into main 2026-07-15 01:21:37 +00:00
jmiller 745f85d512 chore(rebrand): gitea -> git (MokoGIT brand, .mokogitea -> .mokogit)
Case-sensitive gitea->git: brand MokoGitea->MokoGIT, MOKOGITEA_TOKEN->
MOKOGIT_TOKEN, GITEA_URL/ORG/REPO->GIT_*, gitea-actions[bot]->git-actions,
ntfy gitea-*->git-*, .mokogitea/->.mokogit/. Protected: literal .gitea and
git.mokoconsulting.tech.

Claude-Session: https://claude.ai/code/session_01DQEMmJPe61ya7HDfA6BHP8
2026-07-14 20:21:34 -05:00
jmiller 823a36edbb chore: sync version-set.yml from Template-Joomla [skip ci] 2026-07-14 21:16:41 +00:00
jmiller 96b811a95c chore: sync standards-compliance.yml from Template-Joomla [skip ci] 2026-07-14 21:16:40 +00:00
jmiller b16970a857 chore: sync repo-health.yml from Template-Joomla [skip ci] 2026-07-14 21:16:39 +00:00
jmiller fe74431b7e chore: sync rc-revert.yml from Template-Joomla [skip ci] 2026-07-14 21:16:38 +00:00
jmiller 6cad0ed325 chore: sync pre-release.yml from Template-Joomla [skip ci] 2026-07-14 21:16:37 +00:00
jmiller 6fcd18742e chore: sync pr-metadata-check.yml from Template-Joomla [skip ci] 2026-07-14 21:16:36 +00:00
jmiller 204fbe9349 chore: sync pr-check.yml from Template-Joomla [skip ci] 2026-07-14 21:16:35 +00:00
jmiller 8228e2b851 chore: sync notify.yml from Template-Joomla [skip ci] 2026-07-14 21:16:34 +00:00
jmiller 5d053e3a23 chore: sync issue-branch.yml from Template-Joomla [skip ci] 2026-07-14 21:16:33 +00:00
jmiller 52d4f56158 chore: sync gitleaks.yml from Template-Joomla [skip ci] 2026-07-14 21:16:32 +00:00
jmiller f31f90eb86 chore: sync cleanup.yml from Template-Joomla [skip ci] 2026-07-14 21:16:31 +00:00
jmiller 6e068ebd1d chore: sync ci-joomla.yml from Template-Joomla [skip ci] 2026-07-14 21:16:30 +00:00
jmiller 117816e5a5 chore: sync ci-issue-reporter.yml from Template-Joomla [skip ci] 2026-07-14 21:16:29 +00:00
jmiller 31948f3e16 chore: sync ci-generic.yml from Template-Joomla [skip ci] 2026-07-14 21:16:28 +00:00
jmiller 18804ab76e chore: sync cascade-dev.yml from Template-Joomla [skip ci] 2026-07-14 21:16:27 +00:00
jmiller ef69edb18d chore: sync branch-cleanup.yml from Template-Joomla [skip ci] 2026-07-14 21:16:26 +00:00
jmiller e97a5c17c7 chore: sync auto-release.yml from Template-Joomla [skip ci] 2026-07-14 21:16:25 +00:00
jmiller 4ea1d311ac chore: sync auto-bump.yml from Template-Joomla [skip ci] 2026-07-14 21:16:24 +00:00
mokogitea-actions[bot] 8df658c5da chore(version): pre-release bump to 02.64.02-dev [skip ci] 2026-07-14 20:40:49 +00:00
gitea-actions[bot] 6c79443be0 chore(version): auto-bump patch 02.64.01-dev [skip ci] 2026-07-14 20:40:32 +00:00
gitea-actions[bot] 78d120d428 chore: promote changelog [Unreleased] → [02.64.00]
Generic: Standards Compliance / Secret Scanning (push) Failing after 4s
Generic: Standards Compliance / License Header Validation (push) Successful in 3s
Generic: Standards Compliance / Repository Structure Validation (push) Successful in 3s
Generic: Standards Compliance / Coding Standards Check (push) Successful in 4s
Generic: Standards Compliance / Workflow Configuration Check (push) Failing after 4s
Generic: Standards Compliance / Documentation Quality Check (push) Successful in 3s
Generic: Standards Compliance / README Completeness Check (push) Failing after 3s
Generic: Standards Compliance / Git Repository Hygiene (push) Successful in 4s
Generic: Standards Compliance / Script Integrity Validation (push) Successful in 5s
Generic: Standards Compliance / Line Length Check (push) Successful in 3s
Generic: Standards Compliance / File Naming Standards (push) Successful in 4s
Generic: Standards Compliance / Version Consistency Check (push) Successful in 41s
Generic: Standards Compliance / Insecure Code Pattern Detection (push) Successful in 4s
Generic: Standards Compliance / Dead Code Detection (push) Successful in 6s
Generic: Standards Compliance / File Size Limits (push) Successful in 4s
Generic: Standards Compliance / Binary File Detection (push) Successful in 6s
Generic: Standards Compliance / TODO/FIXME Tracking (push) Successful in 3s
Generic: Standards Compliance / Code Complexity Analysis (push) Successful in 43s
Generic: Standards Compliance / Code Duplication Detection (push) Successful in 41s
Generic: Standards Compliance / Broken Link Detection (push) Successful in 3s
Generic: Standards Compliance / API Documentation Coverage (push) Successful in 3s
Generic: Standards Compliance / Accessibility Check (push) Successful in 4s
Generic: Standards Compliance / Performance Metrics (push) Successful in 3s
Generic: Standards Compliance / Dependency Vulnerability Scanning (push) Successful in 41s
Generic: Standards Compliance / Unused Dependencies Check (push) Successful in 50s
Generic: Standards Compliance / Enterprise Readiness Check (push) Failing after 42s
Generic: Standards Compliance / Terraform Configuration Validation (push) Successful in 10s
Generic: Standards Compliance / Repository Health Check (push) Failing after 45s
Universal: Auto Version Bump / Version Bump (push) Successful in 9s
Universal: Pre-Release / Build Pre-Release (${{ inputs.stability || github.ref_name }}) (push) Successful in 25s
Generic: Standards Compliance / Compliance Summary (push) Has been cancelled
2026-07-14 20:33:23 +00:00
gitea-actions[bot] aa2473e4ac chore(release): build 02.64.00 [skip ci]
Branch Cleanup / Delete merged branch (pull_request) Has been skipped
RC Revert / Rename rc/ back to dev/ (pull_request) Has been skipped
2026-07-14 20:33:14 +00:00
jmiller 78556d2775 Merge pull request 'Release: dev -> main (runbackup UX + safety)' (#273) from dev into main
Universal: PR Check / Branch Policy (pull_request) Successful in 1s
Universal: PR Check / Secret Scan (pull_request) Successful in 5s
Generic: Project CI / Lint & Validate (pull_request) Successful in 10s
Universal: PR Check / Validate PR (pull_request) Failing after 8s
Generic: Standards Compliance / Secret Scanning (push) Failing after 5s
Generic: Standards Compliance / License Header Validation (push) Successful in 4s
Joomla: Metadata Validation / Validate Joomla Metadata (pull_request) Successful in 10s
Generic: Standards Compliance / Repository Structure Validation (push) Successful in 4s
Generic: Standards Compliance / Coding Standards Check (push) Successful in 5s
Generic: Standards Compliance / Workflow Configuration Check (push) Failing after 4s
Generic: Standards Compliance / Documentation Quality Check (push) Successful in 4s
Generic: Standards Compliance / README Completeness Check (push) Failing after 5s
Generic: Standards Compliance / Git Repository Hygiene (push) Successful in 6s
Generic: Standards Compliance / Script Integrity Validation (push) Successful in 6s
Generic: Standards Compliance / Line Length Check (push) Successful in 5s
Generic: Standards Compliance / File Naming Standards (push) Successful in 3s
Generic: Standards Compliance / Insecure Code Pattern Detection (push) Successful in 3s
Generic: Standards Compliance / Version Consistency Check (push) Successful in 50s
Generic: Standards Compliance / Dead Code Detection (push) Successful in 5s
Generic: Standards Compliance / File Size Limits (push) Successful in 3s
Generic: Standards Compliance / Binary File Detection (push) Successful in 6s
Generic: Standards Compliance / TODO/FIXME Tracking (push) Successful in 4s
Generic: Standards Compliance / Code Complexity Analysis (push) Successful in 50s
Generic: Standards Compliance / Code Duplication Detection (push) Successful in 50s
Generic: Standards Compliance / Broken Link Detection (push) Successful in 3s
Generic: Standards Compliance / API Documentation Coverage (push) Successful in 4s
Generic: Standards Compliance / Accessibility Check (push) Successful in 4s
Generic: Standards Compliance / Performance Metrics (push) Successful in 4s
Generic: Standards Compliance / Dependency Vulnerability Scanning (push) Successful in 44s
Generic: Standards Compliance / Unused Dependencies Check (push) Successful in 50s
Generic: Standards Compliance / Terraform Configuration Validation (push) Successful in 8s
Generic: Standards Compliance / Enterprise Readiness Check (push) Failing after 42s
Generic: Standards Compliance / Repository Health Check (push) Failing after 42s
Generic: Project CI / Tests (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: Standards Compliance / Compliance Summary (push) Has been cancelled
2026-07-14 20:32:59 +00:00
mokogitea-actions[bot] 46dabae61c chore(version): pre-release bump to 02.63.11-dev [skip ci]
Universal: Build & Release / Promote to RC (pull_request) Has been skipped
Branch Cleanup / Delete merged branch (pull_request) Has been skipped
RC Revert / Rename rc/ back to dev/ (pull_request) Has been skipped
Universal: Build & Release / Build & Release Pipeline (pull_request) Successful in 27s
2026-07-14 20:32:52 +00:00
gitea-actions[bot] e21574a917 chore(version): auto-bump patch 02.63.10-dev [skip ci] 2026-07-14 20:32:41 +00:00
jmiller 79a914f930 Merge remote-tracking branch 'origin/dev' into dev
Universal: Auto Version Bump / Version Bump (push) Successful in 8s
Universal: Pre-Release / Build Pre-Release (${{ inputs.stability || github.ref_name }}) (push) Successful in 19s
2026-07-14 15:32:27 -05:00
jmiller fe90f0024b @
chore: back-merge main into dev (reconcile release version stamps)

Claude-Session: https://claude.ai/code/session_01WbGBN9VyRK61zczYWcCQ2i
@
2026-07-14 15:31:52 -05:00
mokogitea-actions[bot] e9eb94ccfd chore(version): pre-release bump to 02.63.09-dev [skip ci] 2026-07-14 20:30:44 +00:00
jmiller 7e1a76a7cb Merge pull request 'fix(runbackup): failed pre-update backup blocks the update' (#272) from fix/no-update-on-error into dev
Universal: Auto Version Bump / Version Bump (push) Has been skipped
Universal: Pre-Release / Build Pre-Release (${{ inputs.stability || github.ref_name }}) (push) Successful in 19s
2026-07-14 20:30:15 +00:00
jmiller c48fd3b8eb fix(runbackup): a failed pre-update backup blocks the update
Universal: PR Check / Branch Policy (pull_request) Successful in 1s
Universal: PR Check / Require Docs Update (pull_request) Has been skipped
Universal: PR Check / Wiki Update Reminder (pull_request) Has been skipped
Generic: Project CI / Tests (pull_request) Failing after 7s
Generic: Project CI / Lint & Validate (pull_request) Successful in 11s
Universal: PR Check / Secret Scan (pull_request) Successful in 6s
RC Revert / Rename rc/ back to dev/ (pull_request) Has been skipped
Branch Cleanup / Delete merged branch (pull_request) Successful in 1s
Universal: Pre-Release / Build Pre-Release (${{ inputs.stability || github.ref_name }}) (push) Successful in 21s
Universal: PR Check / Validate PR (pull_request) Failing after 18s
Universal: PR Check / Build RC Package (pull_request) Has been cancelled
Universal: PR Check / Report Issues (pull_request) Has been cancelled
If the pre-update/uninstall backup fails, the update must not run. The
completion screen no longer offers "Continue without backup" on error —
the only options are Retry (re-run the backup) or Back to dashboard (bail
out without updating). The status message makes clear the update was not
started.

Claude-Session: https://claude.ai/code/session_01WbGBN9VyRK61zczYWcCQ2i
2026-07-14 15:30:03 -05:00
mokogitea-actions[bot] f5423bab5f chore(version): pre-release bump to 02.63.07-dev [skip ci] 2026-07-14 20:18:42 +00:00
jmiller 4b17842496 Merge pull request 'feat(runbackup): cancel button with double confirmation' (#271) from feat/runbackup-cancel into dev
Universal: Auto Version Bump / Version Bump (push) Has been skipped
Universal: Pre-Release / Build Pre-Release (${{ inputs.stability || github.ref_name }}) (push) Successful in 22s
2026-07-14 20:18:25 +00:00
jmiller 85c0967e89 feat(runbackup): cancel button with double confirmation
Generic: Project CI / Lint & Validate (pull_request) Successful in 9s
Generic: Project CI / Tests (pull_request) Failing after 5s
Universal: PR Check / Branch Policy (pull_request) Successful in 1s
Universal: PR Check / Require Docs Update (pull_request) Has been skipped
Universal: PR Check / Wiki Update Reminder (pull_request) Has been skipped
Universal: PR Check / Secret Scan (pull_request) Successful in 5s
Joomla: Metadata Validation / Validate Joomla Metadata (pull_request) Successful in 7s
Branch Cleanup / Delete merged branch (pull_request) Successful in 1s
RC Revert / Rename rc/ back to dev/ (pull_request) Has been skipped
Universal: PR Check / Validate PR (pull_request) Failing after 16s
Universal: PR Check / Build RC Package (pull_request) Has been cancelled
Universal: PR Check / Report Issues (pull_request) Has been cancelled
Adds a "Cancel backup" button on the backup progress screen (visible while
the backup runs). Cancelling requires TWO confirmations; on confirm it
stops the stepped-backup loop, best-effort cancels the record server-side
(ajax.cancelBackup), and returns to the dashboard WITHOUT continuing any
pending update. The button is hidden once the backup completes or fails.

Claude-Session: https://claude.ai/code/session_01WbGBN9VyRK61zczYWcCQ2i
2026-07-14 15:17:38 -05:00
mokogitea-actions[bot] d89812cbfb chore(version): pre-release bump to 02.63.06-dev [skip ci] 2026-07-14 15:35:56 +00:00
jmiller 79efde483e Merge pull request 'feat(runbackup): do-not-close warning + update-running overlay' (#270) from feat/runbackup-dontclose-warning into dev
Universal: Auto Version Bump / Version Bump (push) Has been skipped
Universal: Pre-Release / Build Pre-Release (${{ inputs.stability || github.ref_name }}) (push) Successful in 23s
2026-07-14 15:35:39 +00:00
jmiller c567890bfa feat(runbackup): "do not close window" warning + "update is running" overlay
Generic: Project CI / Lint & Validate (pull_request) Successful in 10s
Generic: Project CI / Tests (pull_request) Failing after 5s
Universal: PR Check / Require Docs Update (pull_request) Has been skipped
Universal: PR Check / Branch Policy (pull_request) Successful in 2s
Universal: PR Check / Wiki Update Reminder (pull_request) Has been skipped
Universal: PR Check / Secret Scan (pull_request) Successful in 6s
Joomla: Metadata Validation / Validate Joomla Metadata (pull_request) Successful in 7s
Branch Cleanup / Delete merged branch (pull_request) Successful in 1s
RC Revert / Rename rc/ back to dev/ (pull_request) Has been skipped
Universal: PR Check / Validate PR (pull_request) Failing after 17s
Universal: PR Check / Build RC Package (pull_request) Has been cancelled
Universal: PR Check / Report Issues (pull_request) Has been cancelled
- Backup progress screen shows a prominent warning not to close or switch
  windows until the backup finishes (hidden once it completes/errors).
- After the backup hands back to an extension update, a full-screen
  "update is running — please wait" overlay is shown while the re-fired
  update POST processes, so the user knows it is working (not stuck).

Adds language strings (component + plugin) and a cfg.updateRunningText option.

Claude-Session: https://claude.ai/code/session_01WbGBN9VyRK61zczYWcCQ2i
2026-07-14 10:35:23 -05:00
mokogitea-actions[bot] ba070f004d chore(version): pre-release bump to 02.63.05-dev [skip ci] 2026-07-14 15:13:02 +00:00
jmiller ce15cecf85 Merge pull request 'fix(installer): extension update auto-continues after pre-update backup' (#269) from fix/extension-refire into dev
Universal: Auto Version Bump / Version Bump (push) Has been skipped
Universal: Pre-Release / Build Pre-Release (${{ inputs.stability || github.ref_name }}) (push) Successful in 21s
2026-07-14 15:12:30 +00:00
jmiller 2b4af3ce28 @
Universal: Pre-Release / Build Pre-Release (${{ inputs.stability || github.ref_name }}) (push) Successful in 21s
Generic: Project CI / Tests (pull_request) Failing after 5s
Universal: PR Check / Branch Policy (pull_request) Successful in 1s
Universal: PR Check / Require Docs Update (pull_request) Has been skipped
Generic: Project CI / Lint & Validate (pull_request) Successful in 9s
Universal: PR Check / Wiki Update Reminder (pull_request) Has been skipped
Universal: PR Check / Secret Scan (pull_request) Successful in 5s
Joomla: Metadata Validation / Validate Joomla Metadata (pull_request) Successful in 8s
Branch Cleanup / Delete merged branch (pull_request) Successful in 1s
RC Revert / Rename rc/ back to dev/ (pull_request) Has been skipped
Universal: PR Check / Validate PR (pull_request) Failing after 18s
Universal: PR Check / Build RC Package (pull_request) Has been cancelled
Universal: PR Check / Report Issues (pull_request) Has been cancelled
fix(installer): extension update now auto-continues after pre-update backup

Two bugs in the client-side resume path made the extension update return
to the selection screen (requiring a manual re-select + re-submit) instead
of continuing after the backup:

1. The re-fire clicked the OUTER <joomla-toolbar-button> (which carries the
   task attr), but the web component binds its click handler to the INNER
   <button>. Clicking the wrapper did nothing. Now clicks the inner element.
2. list-selection toolbar buttons start DISABLED and only enable when
   boxchecked fires a `change` event. We set boxchecked.value but never
   dispatched change, so the button stayed disabled and executeTask() no-opd.
2026-07-14 10:11:32 -05:00
jmiller a1cba42f43 chore: sync version-set.yml from Template-Joomla [skip ci] 2026-07-14 04:45:47 +00:00
jmiller 9eef484585 chore: sync repo-health.yml from Template-Joomla [skip ci] 2026-07-14 04:45:46 +00:00
jmiller ff14a1dcba chore: sync rc-revert.yml from Template-Joomla [skip ci] 2026-07-14 04:45:45 +00:00
jmiller 925b27f2dc chore: sync pre-release.yml from Template-Joomla [skip ci] 2026-07-14 04:45:44 +00:00
jmiller 4e519b4e7c chore: sync pr-metadata-check.yml from Template-Joomla [skip ci] 2026-07-14 04:45:43 +00:00
jmiller 118c1cbe2f chore: sync pr-check.yml from Template-Joomla [skip ci] 2026-07-14 04:45:42 +00:00
jmiller 05aec9b315 chore: sync notify.yml from Template-Joomla [skip ci] 2026-07-14 04:45:40 +00:00
jmiller b345e42cd0 chore: sync gitleaks.yml from Template-Joomla [skip ci] 2026-07-14 04:45:39 +00:00
jmiller f99e39efa3 chore: sync ci-joomla.yml from Template-Joomla [skip ci] 2026-07-14 04:45:38 +00:00
jmiller ae5e1f48dd chore: sync ci-issue-reporter.yml from Template-Joomla [skip ci] 2026-07-14 04:45:36 +00:00
jmiller 2ddd6c27cd chore: sync ci-generic.yml from Template-Joomla [skip ci] 2026-07-14 04:45:35 +00:00
jmiller 362e75a9a8 chore: sync cascade-dev.yml from Template-Joomla [skip ci] 2026-07-14 04:45:34 +00:00
jmiller cb4c227853 chore: sync branch-cleanup.yml from Template-Joomla [skip ci] 2026-07-14 04:45:33 +00:00
jmiller b8e8487f6b chore: sync auto-release.yml from Template-Joomla [skip ci] 2026-07-14 04:45:32 +00:00
jmiller 7abe3ef035 chore: sync auto-bump.yml from Template-Joomla [skip ci] 2026-07-14 04:45:31 +00:00
jmiller 344881809d Merge pull request 'chore(ci): remove template-only sync workflows from root' (#268) from chore/remove-template-sync-leaks into main
Generic: Project CI / Lint & Validate (pull_request) Has been cancelled
Generic: Project CI / Tests (pull_request) Has been cancelled
Universal: PR Check / Branch Policy (pull_request) Has been cancelled
Universal: PR Check / Require Docs Update (pull_request) Has been cancelled
Universal: PR Check / Wiki Update Reminder (pull_request) Has been cancelled
Universal: PR Check / Secret Scan (pull_request) Has been cancelled
Universal: PR Check / Validate PR (pull_request) Has been cancelled
Joomla: Metadata Validation / Validate Joomla Metadata (pull_request) Has been cancelled
Cascade Main -> Dev / Cascade main -> dev (push) Has been cancelled
Generic: Standards Compliance / Secret Scanning (push) Has been cancelled
Generic: Standards Compliance / License Header Validation (push) Has been cancelled
Generic: Standards Compliance / Repository Structure Validation (push) Has been cancelled
Generic: Standards Compliance / Coding Standards Check (push) Has been cancelled
Generic: Standards Compliance / Version Consistency Check (push) Has been cancelled
Generic: Standards Compliance / Workflow Configuration Check (push) Has been cancelled
Generic: Standards Compliance / Documentation Quality Check (push) Has been cancelled
Generic: Standards Compliance / README Completeness Check (push) Has been cancelled
Generic: Standards Compliance / Git Repository Hygiene (push) Has been cancelled
Generic: Standards Compliance / Script Integrity Validation (push) Has been cancelled
Generic: Standards Compliance / Line Length Check (push) Has been cancelled
Generic: Standards Compliance / File Naming Standards (push) Has been cancelled
Generic: Standards Compliance / Insecure Code Pattern Detection (push) Has been cancelled
Generic: Standards Compliance / Code Complexity Analysis (push) Has been cancelled
Generic: Standards Compliance / Code Duplication Detection (push) Has been cancelled
Generic: Standards Compliance / Dead Code Detection (push) Has been cancelled
Generic: Standards Compliance / File Size Limits (push) Has been cancelled
Generic: Standards Compliance / Binary File Detection (push) Has been cancelled
Generic: Standards Compliance / TODO/FIXME Tracking (push) Has been cancelled
Generic: Standards Compliance / Dependency Vulnerability Scanning (push) Has been cancelled
Generic: Standards Compliance / Unused Dependencies Check (push) Has been cancelled
Generic: Standards Compliance / Broken Link Detection (push) Has been cancelled
Generic: Standards Compliance / API Documentation Coverage (push) Has been cancelled
Generic: Standards Compliance / Accessibility Check (push) Has been cancelled
Generic: Standards Compliance / Performance Metrics (push) Has been cancelled
Generic: Standards Compliance / Enterprise Readiness Check (push) Has been cancelled
Generic: Standards Compliance / Repository Health Check (push) Has been cancelled
Generic: Standards Compliance / Terraform Configuration Validation (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: Standards Compliance / Compliance Summary (push) Has been cancelled
2026-07-13 20:46:34 +00:00
jmiller 47d4023108 chore(ci): remove template-only sync workflows from root
Universal: Pre-Release / Build Pre-Release (${{ inputs.stability || github.ref_name }}) (push) Successful in 24s
Generic: Project CI / Lint & Validate (pull_request) Has been cancelled
Generic: Project CI / Tests (pull_request) Has been cancelled
Joomla: Extension CI / Lint & Validate (pull_request) Has been cancelled
Joomla: Extension CI / Release Readiness Check (pull_request) Has been cancelled
Universal: PR Check / Branch Policy (pull_request) Has been cancelled
Universal: PR Check / Require Docs Update (pull_request) Has been cancelled
Universal: PR Check / Wiki Update Reminder (pull_request) Has been cancelled
Universal: PR Check / Secret Scan (pull_request) Has been cancelled
Universal: PR Check / Validate PR (pull_request) Has been cancelled
Joomla: Metadata Validation / Validate Joomla Metadata (pull_request) Has been cancelled
Generic: Repo Health / Access control (pull_request) Has been cancelled
Generic: Repo Health / Site Health (pull_request) Has been cancelled
Generic: Standards Compliance / Secret Scanning (pull_request) Has been cancelled
Generic: Standards Compliance / License Header Validation (pull_request) Has been cancelled
Generic: Standards Compliance / Repository Structure Validation (pull_request) Has been cancelled
Generic: Standards Compliance / Coding Standards Check (pull_request) Has been cancelled
Generic: Standards Compliance / Version Consistency Check (pull_request) Has been cancelled
Generic: Standards Compliance / Workflow Configuration Check (pull_request) Has been cancelled
Generic: Standards Compliance / Documentation Quality Check (pull_request) Has been cancelled
Generic: Standards Compliance / README Completeness Check (pull_request) Has been cancelled
Generic: Standards Compliance / Git Repository Hygiene (pull_request) Has been cancelled
Generic: Standards Compliance / Script Integrity Validation (pull_request) Has been cancelled
Generic: Standards Compliance / Line Length Check (pull_request) Has been cancelled
Generic: Standards Compliance / File Naming Standards (pull_request) Has been cancelled
Generic: Standards Compliance / Insecure Code Pattern Detection (pull_request) Has been cancelled
Generic: Standards Compliance / Code Complexity Analysis (pull_request) Has been cancelled
Generic: Standards Compliance / Code Duplication Detection (pull_request) Has been cancelled
Generic: Standards Compliance / Dead Code Detection (pull_request) Has been cancelled
Generic: Standards Compliance / File Size Limits (pull_request) Has been cancelled
Generic: Standards Compliance / Binary File Detection (pull_request) Has been cancelled
Generic: Standards Compliance / TODO/FIXME Tracking (pull_request) Has been cancelled
Generic: Standards Compliance / Dependency Vulnerability Scanning (pull_request) Has been cancelled
Generic: Standards Compliance / Unused Dependencies Check (pull_request) Has been cancelled
Generic: Standards Compliance / Broken Link Detection (pull_request) Has been cancelled
Generic: Standards Compliance / API Documentation Coverage (pull_request) Has been cancelled
Generic: Standards Compliance / Accessibility Check (pull_request) Has been cancelled
Generic: Standards Compliance / Performance Metrics (pull_request) Has been cancelled
Generic: Standards Compliance / Enterprise Readiness Check (pull_request) Has been cancelled
Generic: Standards Compliance / Repository Health Check (pull_request) Has been cancelled
Generic: Standards Compliance / Terraform Configuration Validation (pull_request) Has been cancelled
Branch Cleanup / Delete merged branch (pull_request) Has been cancelled
RC Revert / Rename rc/ back to dev/ (pull_request) Has been cancelled
Joomla: Extension CI / Tests (PHP 8.2) (pull_request) Has been cancelled
Joomla: Extension CI / Tests (PHP 8.3) (pull_request) Has been cancelled
Joomla: Extension CI / PHPStan Analysis (pull_request) Has been cancelled
Joomla: Extension CI / Build RC Pre-Release (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: Scripts Governance (pull_request) Has been cancelled
Generic: Repo Health / Report: Repository Health (pull_request) Has been cancelled
Generic: Standards Compliance / Compliance Summary (pull_request) Has been cancelled
Template-only (gated Template-*), dead no-ops in child repos, inherited at scaffold. Real sync lives in templates' workflows/custom/.
2026-07-13 20:46:29 +00:00
jmiller 373662b0d3 chore: sync standards-compliance.yml from Template-Generic [skip ci] 2026-07-13 19:51:49 +00:00
jmiller c7ea43edcf chore: sync pr-check.yml from Template-Generic [skip ci] 2026-07-13 16:22:34 +00:00
jmiller dbdb3a0e8a chore: sync cascade-dev.yml from Template-Generic [skip ci] 2026-07-13 16:22:32 +00:00
jmiller 1127b23d1e chore: sync auto-release.yml from Template-Generic [skip ci] 2026-07-13 16:22:30 +00:00
mokogitea-actions[bot] 1c41cdc03b chore(version): pre-release bump to 02.63.02-dev [skip ci] 2026-07-13 15:56:55 +00:00
jmiller 79b23215eb Merge pull request 'feat(runbackup): record link new window + harden leave warning' (#267) from feat/runbackup-record-newtab into dev
Universal: Auto Version Bump / Version Bump (push) Has been skipped
Universal: Pre-Release / Build Pre-Release (${{ inputs.stability || github.ref_name }}) (push) Successful in 24s
2026-07-13 15:56:37 +00:00
jmiller bd2effd89c @
Generic: Project CI / Tests (pull_request) Failing after 5s
Universal: PR Check / Require Docs Update (pull_request) Has been skipped
Universal: PR Check / Wiki Update Reminder (pull_request) Has been skipped
Universal: PR Check / Branch Policy (pull_request) Successful in 1s
Generic: Project CI / Lint & Validate (pull_request) Successful in 8s
Universal: PR Check / Secret Scan (pull_request) Successful in 5s
Branch Cleanup / Delete merged branch (pull_request) Successful in 1s
RC Revert / Rename rc/ back to dev/ (pull_request) Has been skipped
Joomla: Metadata Validation / Validate Joomla Metadata (pull_request) Successful in 7s
Universal: PR Check / Validate PR (pull_request) Failing after 17s
Universal: PR Check / Build RC Package (pull_request) Has been cancelled
Universal: PR Check / Report Issues (pull_request) Has been cancelled
feat(runbackup): open record link in new window; harden leave warning

- The "View backup record" link on the pre-update completion screen now
  opens in a new window (target=_blank rel=noopener), so viewing the
  record does not leave the "Continue the update" flow.
- beforeunload leave-warning now also returns the message (broader browser
  support) so closing the window mid-backup reliably prompts a warning.

Claude-Session: https://claude.ai/code/session_01WbGBN9VyRK61zczYWcCQ2i
@
2026-07-13 10:56:29 -05:00
jmiller 1c646332e2 chore: sync workflow-sync-trigger.yml from Template-Generic [skip ci] 2026-07-13 15:50:07 +00:00
jmiller 2deaa4faa4 chore: sync version-set.yml from Template-Generic [skip ci] 2026-07-13 15:50:07 +00:00
jmiller 629b8be470 chore: sync sync-on-merge.yml from Template-Generic [skip ci] 2026-07-13 15:50:06 +00:00
jmiller d2dd8a8da4 chore: sync standards-compliance.yml from Template-Generic [skip ci] 2026-07-13 15:50:05 +00:00
jmiller ba90ce3365 chore: sync repo-health.yml from Template-Generic [skip ci] 2026-07-13 15:50:04 +00:00
jmiller 2de8ab942b chore: sync rc-revert.yml from Template-Generic [skip ci] 2026-07-13 15:50:04 +00:00
jmiller 3ccad868bf chore: sync pre-release.yml from Template-Generic [skip ci] 2026-07-13 15:50:03 +00:00
jmiller 4606f21a05 chore: sync pr-check.yml from Template-Generic [skip ci] 2026-07-13 15:50:02 +00:00
jmiller aae23b46f0 chore: sync notify.yml from Template-Generic [skip ci] 2026-07-13 15:50:01 +00:00
jmiller 08135beaf0 chore: sync issue-branch.yml from Template-Generic [skip ci] 2026-07-13 15:50:01 +00:00
jmiller 2cd83e0369 chore: sync gitleaks.yml from Template-Generic [skip ci] 2026-07-13 15:50:00 +00:00
jmiller a52ed25325 chore: sync cleanup.yml from Template-Generic [skip ci] 2026-07-13 15:49:59 +00:00
jmiller 3c00fc2422 chore: sync ci-issue-reporter.yml from Template-Generic [skip ci] 2026-07-13 15:49:58 +00:00
jmiller c08015c324 chore: sync ci-generic.yml from Template-Generic [skip ci] 2026-07-13 15:49:58 +00:00
jmiller 90aa2cd58b chore: sync cascade-dev.yml from Template-Generic [skip ci] 2026-07-13 15:49:57 +00:00
jmiller f17367456a chore: sync branch-cleanup.yml from Template-Generic [skip ci] 2026-07-13 15:49:57 +00:00
jmiller 92c4818410 chore: sync auto-release.yml from Template-Generic [skip ci] 2026-07-13 15:49:56 +00:00
jmiller a5db8fba5b chore: sync auto-bump.yml from Template-Generic [skip ci] 2026-07-13 15:49:55 +00:00
jmiller a133d80f5f chore: sync standards-compliance.yml from Template-Generic [skip ci] 2026-07-13 15:29:14 +00:00
mokogitea-actions[bot] 6da7ef0b69 chore(version): pre-release bump to 02.63.01-dev [skip ci] 2026-07-13 15:06:15 +00:00
jmiller 65cf9fbf9f chore: sync issue-branch.yml from Template-Generic [skip ci] 2026-07-13 10:12:01 +00:00
gitea-actions[bot] 29e5a8c85b chore(release): build 02.63.00 [skip ci] 2026-07-13 09:51:34 +00:00
mokogitea-actions[bot] cbc8627fb7 chore(version): pre-release bump to 02.62.05-dev [skip ci] 2026-07-13 09:50:29 +00:00
gitea-actions[bot] f1dfc18f3d chore(version): auto-bump patch 02.62.04-dev [skip ci] 2026-07-13 09:50:15 +00:00
jmiller 17ff3ce702 Merge pull request 'Release: dev -> main' (#265) from dev into main
Sync Workflows to Repos / sync (push) Has been skipped
Cascade Main -> Dev / Cascade main -> dev (push) Successful in 3s
Generic: Project CI / Lint & Validate (pull_request) Successful in 9s
Universal: PR Check / Require Docs Update (pull_request) Has been skipped
Universal: PR Check / Wiki Update Reminder (pull_request) Has been skipped
Universal: PR Check / Branch Policy (pull_request) Successful in 1s
Generic: Project CI / Tests (pull_request) Failing after 5s
Universal: PR Check / Secret Scan (pull_request) Successful in 4s
Joomla: Metadata Validation / Validate Joomla Metadata (pull_request) Successful in 7s
Universal: PR Check / Validate PR (pull_request) Failing after 19s
Universal: PR Check / Build RC Package (pull_request) Has been cancelled
Universal: PR Check / Report Issues (pull_request) Has been cancelled
2026-07-13 09:39:38 +00:00
mokogitea-actions[bot] 20fcd1c39c Merge remote-tracking branch 'origin/main' into dev
Generic: Project CI / Tests (pull_request) Failing after 6s
Generic: Project CI / Lint & Validate (pull_request) Successful in 9s
Joomla: Extension CI / Release Readiness Check (pull_request) Failing after 4s
Universal: PR Check / Branch Policy (pull_request) Successful in 2s
Joomla: Extension CI / Lint & Validate (pull_request) Failing after 6s
Universal: PR Check / Wiki Update Reminder (pull_request) Successful in 2s
Universal: PR Check / Require Docs Update (pull_request) Failing after 5s
Universal: PR Check / Secret Scan (pull_request) Successful in 5s
Generic: Repo Health / Access control (pull_request) Successful in 1s
Generic: Repo Health / Site Health (pull_request) Has been skipped
Joomla: Metadata Validation / Validate Joomla Metadata (pull_request) Successful in 7s
Universal: PR Check / Validate PR (pull_request) Failing after 18s
Branch Cleanup / Delete merged branch (pull_request) Has been skipped
RC Revert / Rename rc/ back to dev/ (pull_request) Has been skipped
Universal: Workflow Sync Trigger / Sync workflows to live repos (pull_request) Has been skipped
Universal: Auto Version Bump / Version Bump (push) Successful in 9s
Universal: Pre-Release / Build Pre-Release (${{ inputs.stability || github.ref_name }}) (push) Successful in 22s
Universal: Build & Release / Promote to RC (pull_request) Has been skipped
Universal: Build & Release / Build & Release Pipeline (pull_request) Successful in 25s
Joomla: Extension CI / Tests (PHP 8.2) (pull_request) Has been cancelled
Joomla: Extension CI / Tests (PHP 8.3) (pull_request) Has been cancelled
Joomla: Extension CI / PHPStan Analysis (pull_request) Has been cancelled
Joomla: Extension CI / Build RC Pre-Release (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: Scripts Governance (pull_request) Has been cancelled
Generic: Repo Health / Report: Repository Health (pull_request) Has been cancelled
# Conflicts:
#	.mokogitea/workflows/issue-branch.yml
2026-07-13 04:39:03 -05:00
mokogitea-actions[bot] 3fb82bc03c chore(version): pre-release bump to 02.62.03-dev [skip ci] 2026-07-13 09:37:20 +00:00
mokogitea-actions[bot] 499436a8d0 chore(version): pre-release bump to 02.62.02-dev [skip ci] 2026-07-13 09:36:51 +00:00
jmiller a004b9c0d9 chore(sync): CI notify retry + JEXEC whole-file scan from Template-Generic
Universal: Auto Version Bump / Version Bump (push) Successful in 8s
Universal: Pre-Release / Build Pre-Release (${{ inputs.stability || github.ref_name }}) (push) Successful in 24s
2026-07-13 09:31:49 +00:00
jmiller 5b1cf9ea8e chore(sync): CI notify retry + JEXEC whole-file scan from Template-Generic
Universal: Auto Version Bump / Version Bump (push) Successful in 7s
Universal: Pre-Release / Build Pre-Release (${{ inputs.stability || github.ref_name }}) (push) Successful in 19s
2026-07-13 09:31:48 +00:00
jmiller 86fe470158 chore: sync pr-check.yml from Template-Generic [skip ci] 2026-07-13 09:28:49 +00:00
jmiller a6e196ffd0 chore: sync notify.yml from Template-Generic [skip ci] 2026-07-13 09:28:48 +00:00
jmiller d43b9405c0 chore: sync issue-branch.yml from Template-Generic [skip ci] 2026-07-13 09:28:47 +00:00
mokogitea-actions[bot] 8506573898 chore(version): pre-release bump to 02.62.01-dev [skip ci] 2026-07-13 08:47:36 +00:00
jmiller 6ad5046f82 Merge pull request 'chore(sync): cascade main -> dev' (#264) from main into dev
Universal: Auto Version Bump / Version Bump (push) Has been skipped
Universal: Pre-Release / Build Pre-Release (${{ inputs.stability || github.ref_name }}) (push) Successful in 17s
2026-07-13 08:47:20 +00:00
107 changed files with 9302 additions and 329 deletions
+1 -1
View File
@@ -49,7 +49,7 @@
*.eot binary
# Export ignore (not included in archives)
.mokogitea/ export-ignore
.mokogit/ export-ignore
.editorconfig export-ignore
.gitattributes export-ignore
.gitignore export-ignore
+73
View File
@@ -0,0 +1,73 @@
# MokoSuiteBackup
Full-site backup and restore for Joomla — database, files, and configuration. Replaces Akeeba Backup Pro.
## Quick Reference
| Field | Value |
|---|---|
| **Package** | `pkg_mokosuitebackup` |
| **Language** | PHP 8.3+ |
| **Branch** | develop on `dev`, merge to `main` (protected) |
| **Wiki** | [MokoSuiteBackup Wiki](https://git.mokoconsulting.tech/MokoConsulting/MokoSuiteBackup/wiki) |
## Commands
```bash
make build # Build package ZIP
make lint # Run linters
make validate # Validate structure
make release # Full release pipeline
make clean # Clean build artifacts
composer install # Install PHP dependencies
```
## Architecture
Joomla **package** with four sub-extensions:
### com_mokosuitebackup (Component)
- Admin backend for managing backup profiles and records
- Backup engine: `Engine/BackupEngine`, `Engine/DatabaseDumper`, `Engine/FileScanner`, `Engine/Archiver`
- Joomla 6 MVC: Controllers, Models, Views, Tables
- Namespace: `Joomla\Component\MokoSuiteBackup\Administrator`
- DB tables: `#__mokosuitebackup_profiles`, `#__mokosuitebackup_records`
- CLI: `cli/mokosuitebackup.php` for cron-based backups
### plg_system_mokosuitebackup (System Plugin)
- Cleanup of expired backup archives (age + count limits)
- Namespace: `Joomla\Plugin\System\MokoSuiteBackup`
### plg_task_mokosuitebackup (Task Plugin)
- Integrates with Joomla's Scheduled Tasks (com_scheduler)
- Registers "Run Backup Profile" task type
- Namespace: `Joomla\Plugin\Task\MokoSuiteBackup`
### plg_webservices_mokosuitebackup (WebServices Plugin)
- REST API for remote backup management (wire-compatible with mcp_mokosuitebackup)
- Endpoints: backup, backups, profiles, download, delete
- Namespace: `Joomla\Plugin\WebServices\MokoSuiteBackup`
### Database Schema
- `#__mokosuitebackup_profiles` — backup profiles (name, description, config JSON, filters JSON)
- `#__mokosuitebackup_records` — backup records (profile_id, status, origin, archive path, sizes, timestamps)
## Rules
- **Never commit** `.claude/`, `.mcp.json`, `TODO.md`, `*.min.css`/`*.min.js`
- **Attribution**: `Authored-by: Moko Consulting`
- **Workflow directory**: `.mokogit/` (not `.gitea/` or `.github/`)
- **Minification**: handled at build time (CI)
- **Wiki**: documentation lives in the MokoGIT wiki, not `docs/` files
- **Standards**: [MokoStandards](https://git.mokoconsulting.tech/MokoConsulting/.mokogit/wiki)
## Coding Standards
- PHP 8.3+ minimum (Joomla 6)
- Joomla 6 DI container pattern: `services/provider.php` → Extension class
- Legacy stub `.php` file required for plugin loader but empty
- `SubscriberInterface` for event subscription (not `on*` method naming)
- `bind() → check() → store()` for Table operations (not `save()`)
- Language file placement: site (no `folder`) vs admin (`folder="administrator"`)
- SPDX license headers on all PHP files
+66
View File
@@ -0,0 +1,66 @@
# Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
#
# SPDX-License-Identifier: GPL-3.0-or-later
#
# FILE INFORMATION
# DEFGROUP: MokoGIT.Workflow
# INGROUP: MokoCLI.Release
# REPO: https://git.mokoconsulting.tech/MokoConsulting/mokocli
# PATH: /.mokogit/workflows/auto-bump.yml
# VERSION: 09.02.00
# BRIEF: Auto patch-bump version on every push to dev (skips merge commits)
name: "Universal: Auto Version Bump"
on:
push:
branches:
- dev
- rc
- 'feature/**'
- 'patch/**'
env:
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
MOKOGIT_URL: ${{ vars.GITEA_URL || 'https://git.mokoconsulting.tech' }}
permissions:
contents: write
jobs:
bump:
name: Version Bump
runs-on: release
if: >-
!contains(github.event.head_commit.message, '[skip ci]') &&
!contains(github.event.head_commit.message, '[skip bump]') &&
!startsWith(github.event.head_commit.message, 'Merge pull request')
steps:
- name: Checkout
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
with:
token: ${{ secrets.MOKOGIT_TOKEN }}
fetch-depth: 1
- name: Setup MokoCLI tools
run: |
if ! command -v composer &> /dev/null; then
sudo apt-get update -qq && sudo apt-get install -y -qq php-cli php-mbstring php-xml php-zip php-curl composer >/dev/null 2>&1
fi
if [ -d "/opt/mokocli/cli" ]; then
echo "MOKO_CLI=/opt/mokocli/cli" >> "$GITHUB_ENV"
else
git clone --depth 1 --branch main --quiet \
"https://x-access-token:${{ secrets.MOKOGIT_TOKEN }}@git.mokoconsulting.tech/MokoConsulting/mokocli.git" \
/tmp/mokocli
cd /tmp/mokocli && composer install --no-dev --no-interaction --quiet
echo "MOKO_CLI=/tmp/mokocli/cli" >> "$GITHUB_ENV"
fi
- name: Bump version
run: |
php ${MOKO_CLI}/version_auto_bump.php \
--path . --branch "${GITHUB_REF_NAME}" \
--token "${{ secrets.MOKOGIT_TOKEN }}" \
--repo-url "https://x-access-token:${{ secrets.MOKOGIT_TOKEN }}@git.mokoconsulting.tech/${{ github.repository }}.git"
+469
View File
@@ -0,0 +1,469 @@
# Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
#
# SPDX-License-Identifier: GPL-3.0-or-later
#
# FILE INFORMATION
# DEFGROUP: MokoGIT.Workflow
# INGROUP: MokoCLI.Release
# REPO: https://git.mokoconsulting.tech/MokoConsulting/mokocli
# PATH: /templates/workflows/universal/auto-release.yml.template
# VERSION: 05.01.00
# BRIEF: Universal build & release detects platform from MokoGIT repo metadata (API)
#
# +=======================================================================+
# | UNIVERSAL BUILD & RELEASE PIPELINE |
# +=======================================================================+
# | |
# | Reads MokoGIT repo metadata (joomla|dolibarr|generic) to branch logic. |
# | |
# | Platform-specific: |
# | joomla: XML manifest, type-prefixed packages |
# | dolibarr: mod*.class.php, update.txt, dev version reset |
# | generic: README-only, no update stream |
# | |
# +=======================================================================+
name: "Universal: Build & Release"
on:
pull_request:
types: [opened, synchronize, closed]
branches:
- main
paths-ignore:
- '.mokogit/workflows/**'
- '*.md'
- 'wiki/**'
- '.editorconfig'
- '.gitignore'
- '.gitattributes'
- '.gitmessage'
- 'LICENSE'
workflow_dispatch:
inputs:
action:
description: 'Action to perform'
required: false
type: choice
default: release
options:
- release
- promote-rc
env:
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
MOKOGIT_URL: ${{ vars.GITEA_URL || 'https://git.mokoconsulting.tech' }}
GIT_ORG: ${{ vars.GITEA_ORG || github.repository_owner }}
GIT_REPO: ${{ vars.GITEA_REPO || github.event.repository.name }}
permissions:
contents: write
jobs:
# ── PR Opened → Rename branch to RC and build RC release ─────────────────────────
promote-rc:
name: Promote to RC
runs-on: release
# Skip on template repos (Template-*) — they scaffold other repos and do not release.
if: >-
!startsWith(github.event.repository.name, 'Template-') &&
(
(github.event.action == 'opened' && github.event.pull_request.merged != true) ||
(github.event.action == 'synchronize' && github.event.pull_request.merged != true) ||
(github.event_name == 'workflow_dispatch' && inputs.action == 'promote-rc')
)
steps:
- name: Checkout repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
with:
token: ${{ secrets.MOKOGIT_TOKEN }}
fetch-depth: 1
submodules: recursive
- name: Setup MokoCLI tools
env:
MOKO_CLONE_TOKEN: ${{ secrets.MOKOGIT_TOKEN }}
MOKO_CLONE_HOST: git.mokoconsulting.tech/MokoConsulting
run: |
if [ -f /opt/mokocli/cli/version_bump.php ] && [ -f /opt/mokocli/vendor/autoload.php ]; then
echo Using pre-installed /opt/mokocli
echo MOKO_CLI=/opt/mokocli/cli >> $GITHUB_ENV
else
echo Falling back to fresh clone
if ! command -v composer > /dev/null 2>&1; then
sudo apt-get update -qq && sudo apt-get install -y -qq php-cli php-mbstring php-xml php-zip php-curl composer > /dev/null 2>&1
fi
rm -rf /tmp/mokocli
CLONE_URL=https://x-access-token:${MOKO_CLONE_TOKEN}@${MOKO_CLONE_HOST}/mokocli.git
git clone --depth 1 --branch main --quiet $CLONE_URL /tmp/mokocli
cd /tmp/mokocli
composer install --no-dev --no-interaction --quiet
echo MOKO_CLI=/tmp/mokocli/cli >> $GITHUB_ENV
fi
- name: Rename branch to rc
run: |
php ${MOKO_CLI}/branch_rename.php \
--from "${{ github.event.pull_request.head.ref || 'dev' }}" --to rc \
--token "${{ secrets.MOKOGIT_TOKEN }}" \
--api-base "${MOKOGIT_URL}/api/v1/repos/${GIT_ORG}/${GIT_REPO}" \
--pr "${{ github.event.pull_request.number }}"
- name: Checkout rc and configure git
run: |
git fetch origin rc
git checkout rc
git config --local user.email "mokogit-actions[bot]@mokoconsulting.tech"
git config --local user.name "mokogit-actions[bot]"
git remote set-url origin "https://x-access-token:${{ secrets.MOKOGIT_TOKEN }}@git.mokoconsulting.tech/${{ github.repository }}.git"
- name: Publish RC release
run: |
php ${MOKO_CLI}/release_publish.php \
--path . --stability rc --bump minor --branch rc \
--token "${{ secrets.MOKOGIT_TOKEN }}"
- name: Update RC release notes from CHANGELOG.md
run: |
API_BASE="${MOKOGIT_URL}/api/v1/repos/${GIT_ORG}/${GIT_REPO}"
TOKEN="${{ secrets.MOKOGIT_TOKEN }}"
# Extract [Unreleased] section from changelog
NOTES=""
if [ -f "CHANGELOG.md" ]; then
NOTES=$(awk '/^## \[Unreleased\]/{found=1; next} /^## \[/{if(found) exit} found{print}' CHANGELOG.md)
fi
[ -z "$NOTES" ] && NOTES="Release candidate"
# Find the RC release and update its body
RELEASE_ID=$(curl -sf -H "Authorization: token ${TOKEN}" \
"${API_BASE}/releases/tags/release-candidate" \
| python3 -c "import json,sys; print(json.load(sys.stdin).get('id',''))" 2>/dev/null || true)
if [ -n "$RELEASE_ID" ]; then
python3 -c "
import json, urllib.request
body = open('/dev/stdin').read()
payload = json.dumps({'body': body}).encode()
req = urllib.request.Request(
'${API_BASE}/releases/${RELEASE_ID}',
data=payload, method='PATCH',
headers={
'Authorization': 'token ${TOKEN}',
'Content-Type': 'application/json'
})
urllib.request.urlopen(req)
" <<< "$NOTES"
echo "RC release notes updated from CHANGELOG.md"
fi
- name: Summary
if: always()
run: |
echo "## Promoted to Release Candidate" >> $GITHUB_STEP_SUMMARY
echo "Branch renamed to rc, minor bump, RC release built" >> $GITHUB_STEP_SUMMARY
# ── Merged PR → Build & Release (or promote RC to stable) ─────────────────────────
release:
name: Build & Release Pipeline
runs-on: release
# Skip on template repos (Template-*) — they scaffold other repos and do not release.
if: >-
!startsWith(github.event.repository.name, 'Template-') &&
(
github.event.pull_request.merged == true ||
(github.event_name == 'workflow_dispatch' && inputs.action != 'promote-rc')
)
steps:
- name: Checkout repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
with:
token: ${{ secrets.MOKOGIT_TOKEN }}
fetch-depth: 0
submodules: recursive
- name: Configure git for bot pushes
run: |
git config --local user.email "mokogit-actions[bot]@mokoconsulting.tech"
git config --local user.name "mokogit-actions[bot]"
git remote set-url origin "https://x-access-token:${{ secrets.MOKOGIT_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 MokoCLI tools
env:
MOKO_CLONE_TOKEN: ${{ secrets.MOKOGIT_TOKEN }}
MOKO_CLONE_HOST: git.mokoconsulting.tech/MokoConsulting
COMPOSER_AUTH: '{"github-oauth":{"github.com":"${{ secrets.GH_MIRROR_TOKEN }}"}}'
run: |
if [ -f /opt/mokocli/cli/version_bump.php ] && [ -f /opt/mokocli/vendor/autoload.php ]; then
echo Using pre-installed /opt/mokocli
echo MOKO_CLI=/opt/mokocli/cli >> $GITHUB_ENV
else
echo Falling back to fresh clone
if ! command -v composer > /dev/null 2>&1; then
sudo apt-get update -qq && sudo apt-get install -y -qq php-cli php-mbstring php-xml php-zip php-curl composer > /dev/null 2>&1
fi
rm -rf /tmp/mokocli
CLONE_URL=https://x-access-token:${MOKO_CLONE_TOKEN}@${MOKO_CLONE_HOST}/mokocli.git
git clone --depth 1 --branch main --quiet $CLONE_URL /tmp/mokocli
cd /tmp/mokocli
composer install --no-dev --no-interaction --quiet
echo MOKO_CLI=/tmp/mokocli/cli >> $GITHUB_ENV
fi
- name: "Detect platform"
id: platform
run: |
php ${MOKO_CLI}/platform_detect.php --path . --github-output 2>/dev/null || true
php ${MOKO_CLI}/manifest_read.php --path . --github-output 2>/dev/null || true
- name: "Determine version bump level"
id: bump
run: |
# Fix/patch branches: version was already bumped by pre-release, just strip suffix
# Feature/dev branches: bump minor for the new stable release
HEAD_REF="${{ github.event.pull_request.head.ref || 'dev' }}"
case "$HEAD_REF" in
fix/*|patch/*|hotfix/*|bugfix/*) BUMP="none" ;;
*) BUMP="minor" ;;
esac
echo "level=${BUMP}" >> "$GITHUB_OUTPUT"
echo "Bump level: ${BUMP} (from branch: ${HEAD_REF})"
- name: "Publish stable release"
run: |
BUMP_FLAG=""
if [ "${{ steps.bump.outputs.level }}" != "none" ]; then
BUMP_FLAG="--bump ${{ steps.bump.outputs.level }}"
fi
php ${MOKO_CLI}/release_publish.php \
--path . --stability stable ${BUMP_FLAG} --branch main \
--token "${{ secrets.MOKOGIT_TOKEN }}"
- name: "Read published version"
id: version
run: |
VERSION=$(php ${MOKO_CLI}/version_read.php --path . 2>/dev/null || echo "")
VERSION=$(echo "$VERSION" | sed 's/-\(dev\|alpha\|beta\|rc\)$//')
[ -z "$VERSION" ] && VERSION="00.00.00" && echo "skip=true" >> "$GITHUB_OUTPUT"
echo "version=${VERSION}" >> "$GITHUB_OUTPUT"
PLATFORM="${{ steps.platform.outputs.platform }}"
if [[ "$PLATFORM" == joomla* ]]; then
echo "tag=stable" >> "$GITHUB_OUTPUT"
echo "release_tag=stable" >> "$GITHUB_OUTPUT"
else
echo "tag=v${VERSION}" >> "$GITHUB_OUTPUT"
echo "release_tag=v${VERSION}" >> "$GITHUB_OUTPUT"
fi
echo "branch=main" >> "$GITHUB_OUTPUT"
echo "Published version: ${VERSION}"
- name: "Create semver tag for non-Joomla repos"
id: semver
if: |
steps.version.outputs.skip != 'true' &&
!startsWith(steps.platform.outputs.platform, 'joomla')
run: |
VERSION="${{ steps.version.outputs.version }}"
API_BASE="${MOKOGIT_URL}/api/v1/repos/${GIT_ORG}/${GIT_REPO}"
TOKEN="${{ secrets.MOKOGIT_TOKEN }}"
SEMVER_TAG="v${VERSION}"
echo "Creating semver tag: ${SEMVER_TAG}"
# Create the git tag via API
HTTP_CODE=$(curl -sf -o /dev/null -w "%{http_code}" \
-X POST -H "Authorization: token ${TOKEN}" \
-H "Content-Type: application/json" \
"${API_BASE}/tags" \
-d "{\"tag_name\":\"${SEMVER_TAG}\",\"target\":\"main\",\"message\":\"Release ${VERSION}\"}" 2>/dev/null || echo "000")
if [ "$HTTP_CODE" = "201" ] || [ "$HTTP_CODE" = "200" ]; then
echo "Created semver tag: ${SEMVER_TAG}"
elif [ "$HTTP_CODE" = "409" ]; then
echo "Semver tag ${SEMVER_TAG} already exists (skipped)"
else
echo "::warning::Failed to create semver tag ${SEMVER_TAG} (HTTP ${HTTP_CODE})"
fi
echo "semver_tag=${SEMVER_TAG}" >> "$GITHUB_OUTPUT"
- name: Update release notes and promote changelog
run: |
API_BASE="${MOKOGIT_URL}/api/v1/repos/${GIT_ORG}/${GIT_REPO}"
TOKEN="${{ secrets.MOKOGIT_TOKEN }}"
# Get the stable release info (version and ID)
RELEASE_JSON=$(curl -sf -H "Authorization: token ${TOKEN}" \
"${API_BASE}/releases/tags/stable" 2>/dev/null || echo '{}')
RELEASE_ID=$(python3 -c "import json,sys; print(json.load(sys.stdin).get('id',''))" <<< "$RELEASE_JSON" 2>/dev/null || true)
# Extract version from release name (e.g. "06.17.00" or "v06.17.00")
VERSION=$(python3 -c "
import json, sys, re
r = json.load(sys.stdin)
name = r.get('name', '')
m = re.search(r'(\d+\.\d+\.\d+)', name)
print(m.group(1) if m else '')
" <<< "$RELEASE_JSON" 2>/dev/null || true)
# Extract [Unreleased] section from changelog
NOTES=""
if [ -f "CHANGELOG.md" ]; then
NOTES=$(awk '/^## \[Unreleased\]/{found=1; next} /^## \[/{if(found) exit} found{print}' CHANGELOG.md)
fi
[ -z "$NOTES" ] && NOTES="Stable release"
# Update release body via API
if [ -n "$RELEASE_ID" ]; then
python3 -c "
import json, urllib.request
body = open('/dev/stdin').read()
payload = json.dumps({'body': body}).encode()
req = urllib.request.Request(
'${API_BASE}/releases/${RELEASE_ID}',
data=payload, method='PATCH',
headers={
'Authorization': 'token ${TOKEN}',
'Content-Type': 'application/json'
})
urllib.request.urlopen(req)
" <<< "$NOTES"
echo "Release notes updated from CHANGELOG.md"
fi
# Promote [Unreleased] → [version] in CHANGELOG.md and reset
if [ -n "$VERSION" ] && [ -f "CHANGELOG.md" ]; then
DATE=$(date +%Y-%m-%d)
python3 -c "
import sys
version, date = sys.argv[1], sys.argv[2]
content = open('CHANGELOG.md').read()
old = '## [Unreleased]'
new = f'## [Unreleased]\n\n## [{version}] --- {date}'
content = content.replace(old, new, 1)
open('CHANGELOG.md', 'w').write(content)
" "$VERSION" "$DATE"
git add CHANGELOG.md
git commit -m "chore: promote changelog [Unreleased] → [${VERSION}]" || true
git push origin main || true
echo "Changelog promoted: [Unreleased] → [${VERSION}]"
fi
# -- STEP 9: Mirror to GitHub (stable only) --------------------------------
- name: "Step 9: Mirror release to GitHub"
if: >-
steps.version.outputs.skip != 'true' &&
secrets.GH_MIRROR_TOKEN != ''
continue-on-error: true
run: |
VERSION="${{ steps.bump.outputs.version || steps.version.outputs.version }}"
RELEASE_TAG="${{ steps.version.outputs.release_tag }}"
GH_REPO="${{ vars.GH_MIRROR_REPO || github.repository }}"
API_BASE="${MOKOGIT_URL}/api/v1/repos/${GIT_ORG}/${GIT_REPO}"
php ${MOKO_CLI}/release_mirror.php \
--version "$VERSION" --tag "$RELEASE_TAG" \
--token "${{ secrets.MOKOGIT_TOKEN }}" --api-base "$API_BASE" \
--gh-token "${{ secrets.GH_MIRROR_TOKEN }}" --gh-repo "$GH_REPO" \
--branch main 2>&1 || true
echo "GitHub mirror updated" >> $GITHUB_STEP_SUMMARY
# -- STEP 10: Sync main branch to GitHub mirror ----------------------------
- name: "Step 10: Push main to GitHub mirror"
if: >-
steps.version.outputs.skip != 'true' &&
secrets.GH_MIRROR_TOKEN != ''
continue-on-error: true
run: |
GH_REPO="${{ vars.GH_MIRROR_REPO || github.repository }}"
GH_ORG=$(echo "$GH_REPO" | cut -d/ -f1)
GH_NAME=$(echo "$GH_REPO" | cut -d/ -f2)
git remote add github "https://x-access-token:${{ secrets.GH_MIRROR_TOKEN }}@github.com/${GH_ORG}/${GH_NAME}.git" 2>/dev/null || \
git remote set-url github "https://x-access-token:${{ secrets.GH_MIRROR_TOKEN }}@github.com/${GH_ORG}/${GH_NAME}.git"
git fetch origin main --depth=1
git push github origin/main:refs/heads/main --force 2>/dev/null \
&& echo "main branch pushed to GitHub mirror" \
|| echo "WARNING: GitHub mirror push failed"
- name: "Step 11: Delete rc branch (dev reset moved to cascade-dev.yml)"
if: steps.version.outputs.skip != 'true'
continue-on-error: true
run: |
API_BASE="${MOKOGIT_URL}/api/v1/repos/${GIT_ORG}/${GIT_REPO}"
TOKEN="${{ secrets.MOKOGIT_TOKEN }}"
# Delete rc branch (ephemeral — created by promote-rc)
curl -sf -X DELETE -H "Authorization: token ${TOKEN}" \
"${API_BASE}/branches/rc" 2>/dev/null \
&& echo "Deleted rc branch" || echo "rc branch not found"
# dev is reset from main by the dedicated "Cascade Main -> Dev" workflow
# (cascade-dev.yml), which runs after this release completes.
echo "rc cleaned; dev reset handled by cascade-dev.yml" >> $GITHUB_STEP_SUMMARY
- name: "Step 12: Create version branch from main"
if: steps.version.outputs.skip != 'true'
continue-on-error: true
run: |
API_BASE="${MOKOGIT_URL}/api/v1/repos/${GIT_ORG}/${GIT_REPO}"
TOKEN="${{ secrets.MOKOGIT_TOKEN }}"
VERSION="${{ steps.bump.outputs.version || steps.version.outputs.version }}"
BRANCH_NAME="version/${VERSION}"
MAIN_SHA=$(git rev-parse HEAD)
# Delete old version branch if it exists (same version re-release)
curl -sf -X DELETE -H "Authorization: token ${TOKEN}" "${API_BASE}/branches/${BRANCH_NAME}" 2>/dev/null && echo "Deleted old ${BRANCH_NAME}"
# Create version/XX.YY.ZZ from main
curl -sf -X POST -H "Authorization: token ${TOKEN}" -H "Content-Type: application/json" "${API_BASE}/branches" -d "{\"new_branch_name\":\"${BRANCH_NAME}\",\"old_branch_name\":\"main\"}" 2>/dev/null && echo "Created ${BRANCH_NAME} from main (${MAIN_SHA})" || echo "WARNING: ${BRANCH_NAME} creation failed"
echo "Version branch created: ${BRANCH_NAME} (${MAIN_SHA})" >> $GITHUB_STEP_SUMMARY
# -- Dolibarr post-release: Reset dev version -----------------------------
- name: "Post-release: Reset dev version"
if: steps.version.outputs.skip != 'true'
continue-on-error: true
run: |
API_BASE="${MOKOGIT_URL}/api/v1/repos/${GIT_ORG}/${GIT_REPO}"
php ${MOKO_CLI}/version_reset_dev.php \
--token "${{ secrets.MOKOGIT_TOKEN }}" --api-base "${API_BASE}" \
--branch dev --path . 2>&1 || true
# -- Summary --------------------------------------------------------------
- name: Pipeline Summary
if: always()
run: |
VERSION="${{ steps.bump.outputs.version || steps.version.outputs.version }}"
PLATFORM="${{ steps.platform.outputs.platform }}"
if [ "${{ steps.version.outputs.skip }}" = "true" ]; then
echo "## Release Skipped" >> $GITHUB_STEP_SUMMARY
echo "No VERSION in README.md" >> $GITHUB_STEP_SUMMARY
elif [ "${{ steps.check.outputs.already_released }}" = "true" ]; then
echo "## Already Released — ${VERSION}" >> $GITHUB_STEP_SUMMARY
else
echo "" >> $GITHUB_STEP_SUMMARY
echo "## Build & Release Complete (${PLATFORM})" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "| Step | Result |" >> $GITHUB_STEP_SUMMARY
echo "|------|--------|" >> $GITHUB_STEP_SUMMARY
echo "| Platform | \`${PLATFORM}\` |" >> $GITHUB_STEP_SUMMARY
echo "| Version | \`${VERSION}\` |" >> $GITHUB_STEP_SUMMARY
echo "| Branch | \`${{ steps.version.outputs.branch }}\` |" >> $GITHUB_STEP_SUMMARY
echo "| Tag | \`${{ steps.version.outputs.tag }}\` |" >> $GITHUB_STEP_SUMMARY
echo "| Release | [View](${MOKOGIT_URL}/${GIT_ORG}/${GIT_REPO}/releases/tag/${{ steps.version.outputs.tag }}) |" >> $GITHUB_STEP_SUMMARY
fi
+60
View File
@@ -0,0 +1,60 @@
# Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
#
# SPDX-License-Identifier: GPL-3.0-or-later
#
# FILE INFORMATION
# DEFGROUP: MokoGIT.Workflow
# INGROUP: MokoCLI.Universal
# REPO: https://git.mokoconsulting.tech/MokoConsulting/Template-Generic
# PATH: /.mokogit/workflows/branch-cleanup.yml
# VERSION: 01.00.00
# BRIEF: Delete feature branches after PR merge
name: "Branch Cleanup"
on:
pull_request:
types: [closed]
env:
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
jobs:
cleanup:
name: Delete merged branch
runs-on: ubuntu-latest
if: >-
github.event.pull_request.merged == true &&
github.event.pull_request.head.ref != 'dev' &&
github.event.pull_request.head.ref != 'main'
steps:
- name: Delete source branch
# SECURITY: the PR head ref is attacker-controllable. Pass it (and the
# repo/token) through env so the Actions engine cannot splice it into the
# shell source, and validate it to a safe charset before use.
env:
MOKOGIT_TOKEN: ${{ secrets.MOKOGIT_TOKEN }}
REPO: ${{ github.repository }}
API_BASE: ${{ vars.GITEA_URL || 'https://git.mokoconsulting.tech' }}
BRANCH: ${{ github.event.pull_request.head.ref }}
run: |
set -euo pipefail
if ! printf '%s' "${BRANCH}" | grep -Eq '^[A-Za-z0-9._/-]+$'; then
echo "::error::unsafe branch name; refusing to proceed"; exit 1
fi
API="${API_BASE}/api/v1/repos/${REPO}/branches"
# URL-encode the branch name's slashes (no PHP dependency on the runner)
ENCODED=$(printf '%s' "${BRANCH}" | sed 's|/|%2F|g')
STATUS=$(curl -sf -o /dev/null -w "%{http_code}" -X DELETE \
-H "Authorization: token ${MOKOGIT_TOKEN}" \
"${API}/${ENCODED}" 2>/dev/null || true)
if [ "$STATUS" = "204" ]; then
echo "Deleted branch: ${BRANCH}" >> $GITHUB_STEP_SUMMARY
elif [ "$STATUS" = "404" ]; then
echo "Branch already deleted: ${BRANCH}" >> $GITHUB_STEP_SUMMARY
else
echo "::warning::Failed to delete branch ${BRANCH} (HTTP ${STATUS})"
fi
+65
View File
@@ -0,0 +1,65 @@
# Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
# SPDX-License-Identifier: GPL-3.0-or-later
# FILE INFORMATION
# DEFGROUP: MokoGIT.Workflow
# INGROUP: MokoCLI.Release
# BRIEF: Reset dev to main after each release (cascade). Moved out of auto-release Step 11.
#
# +========================================================================+
# | CASCADE MAIN -> DEV |
# +========================================================================+
# | dev mirrors main and is reset on every release. |
# | delete+recreate cannot run against a protected branch, so this |
# | force-pushes main -> dev. The automation identity is force-push |
# | allowlisted on dev via branch-protection.yml. |
# | Runs AFTER the release workflow completes, so dev picks up the |
# | fully version-bumped + changelog-promoted main (not the pre-bump |
# | state). Force-push (not merge) => no version-file conflicts. |
# +========================================================================+
name: "Cascade Main -> Dev"
on:
workflow_run:
workflows: ["Universal: Build & Release"]
types: [completed]
workflow_dispatch:
concurrency:
group: cascade-dev-${{ github.repository }}
cancel-in-progress: true
permissions:
contents: write
jobs:
cascade:
name: Reset dev to main
if: >-
!startsWith(github.event.repository.name, 'Template-') &&
(github.event_name == 'workflow_dispatch' ||
github.event.workflow_run.conclusion == 'success')
runs-on: ubuntu-latest
steps:
- name: Force dev to main
env:
TOKEN: ${{ secrets.MOKOGIT_TOKEN }}
SERVER: ${{ vars.GITEA_URL || 'https://git.mokoconsulting.tech' }}
REPO: ${{ github.repository }}
run: |
set -euo pipefail
git init -q sync && cd sync
git config user.email "mokogit-actions[bot]@mokoconsulting.tech"
git config user.name "mokogit-actions[bot]"
git remote add origin "https://x-access-token:${TOKEN}@${SERVER#https://}/${REPO}.git"
git fetch -q --depth=1 origin main
if git ls-remote --exit-code --heads origin dev >/dev/null 2>&1; then
# dev exists -> force it to match main (dev is a mirror of main)
git push --force origin FETCH_HEAD:refs/heads/dev
echo "dev reset to main" >> "$GITHUB_STEP_SUMMARY"
else
# dev missing (e.g. renamed to rc mid-cycle) -> recreate from main
git push origin FETCH_HEAD:refs/heads/dev
echo "dev created from main" >> "$GITHUB_STEP_SUMMARY"
fi
+202
View File
@@ -0,0 +1,202 @@
# Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
#
# SPDX-License-Identifier: GPL-3.0-or-later
#
# FILE INFORMATION
# DEFGROUP: MokoGIT.Workflow
# INGROUP: MokoCLI.CI
# REPO: https://git.mokoconsulting.tech/MokoConsulting/Template-Generic
# PATH: /.mokogit/workflows/ci-generic.yml
# VERSION: 01.00.00
# BRIEF: CI pipeline — lint, validate, and test for generic projects (PHP + Node.js)
name: "Generic: Project CI"
on:
pull_request:
branches:
- main
- dev
- dev/**
- rc/**
workflow_dispatch:
permissions:
contents: read
env:
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
jobs:
# ── Lint & Validate ───────────────────────────────────────────────────
lint:
name: Lint & Validate
runs-on: ubuntu-latest
# Skip on template repos (Template-*) — they hold placeholder scaffolding, not buildable source.
if: ${{ !startsWith(github.event.repository.name, 'Template-') }}
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Detect toolchain
id: detect
run: |
HAS_PHP=false
HAS_NODE=false
[ -f "composer.json" ] && HAS_PHP=true
[ -f "package.json" ] && HAS_NODE=true
echo "has_php=$HAS_PHP" >> "$GITHUB_OUTPUT"
echo "has_node=$HAS_NODE" >> "$GITHUB_OUTPUT"
echo "Toolchain: PHP=$HAS_PHP Node=$HAS_NODE"
- name: Setup PHP
if: steps.detect.outputs.has_php == 'true'
run: |
if ! command -v php &> /dev/null; then
sudo apt-get update -qq
sudo apt-get install -y -qq php-cli php-mbstring php-xml >/dev/null 2>&1
fi
php -v
- name: Setup Node.js
if: steps.detect.outputs.has_node == 'true'
uses: actions/setup-node@v4
with:
node-version: '20'
- name: Install PHP dependencies
if: steps.detect.outputs.has_php == 'true'
run: |
if [ -f "composer.json" ]; then
composer install --no-interaction --prefer-dist --quiet 2>/dev/null || true
fi
- name: Install Node.js dependencies
if: steps.detect.outputs.has_node == 'true'
run: |
if [ -f "package.json" ]; then
npm ci --quiet 2>/dev/null || npm install --quiet 2>/dev/null || true
fi
- name: PHP syntax check
if: steps.detect.outputs.has_php == 'true'
run: |
ERRORS=0
while IFS= read -r -d '' file; do
if ! php -l "$file" 2>&1 | grep -q "No syntax errors"; then
echo "::error file=${file}::PHP syntax error"
ERRORS=$((ERRORS + 1))
fi
done < <(find . -name "*.php" -not -path "./.git/*" -not -path "./vendor/*" -not -path "./node_modules/*" -print0)
echo "## PHP Lint" >> $GITHUB_STEP_SUMMARY
if [ "$ERRORS" -eq 0 ]; then
echo "All PHP files passed syntax check." >> $GITHUB_STEP_SUMMARY
else
echo "${ERRORS} file(s) with syntax errors." >> $GITHUB_STEP_SUMMARY
exit 1
fi
- name: TypeScript/JavaScript lint
if: steps.detect.outputs.has_node == 'true'
run: |
if [ -f "node_modules/.bin/eslint" ]; then
npx eslint src/ --quiet 2>&1 || { echo "::error::ESLint errors found"; exit 1; }
echo "## ESLint" >> $GITHUB_STEP_SUMMARY
echo "All files passed ESLint." >> $GITHUB_STEP_SUMMARY
elif [ -f ".eslintrc.json" ] || [ -f ".eslintrc.js" ] || [ -f "eslint.config.js" ]; then
echo "::warning::ESLint config found but eslint not installed"
else
echo "No ESLint configured — skipping"
fi
- name: TypeScript compile check
if: steps.detect.outputs.has_node == 'true'
run: |
if [ -f "tsconfig.json" ] && [ -f "node_modules/.bin/tsc" ]; then
npx tsc --noEmit 2>&1 || { echo "::error::TypeScript compilation errors"; exit 1; }
echo "## TypeScript" >> $GITHUB_STEP_SUMMARY
echo "TypeScript compilation passed." >> $GITHUB_STEP_SUMMARY
fi
- name: PHPStan static analysis
if: steps.detect.outputs.has_php == 'true'
run: |
if [ -f "phpstan.neon" ] && [ -f "vendor/bin/phpstan" ]; then
vendor/bin/phpstan analyse --no-progress 2>&1 || { echo "::warning::PHPStan found issues"; }
fi
# ── Tests ─────────────────────────────────────────────────────────────
test:
name: Tests
runs-on: ubuntu-latest
needs: lint
# Run only when lint succeeded; always() forces evaluation so a skipped
# lint (e.g. template repos) skips this job cleanly instead of hanging.
if: ${{ always() && needs.lint.result == 'success' }}
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Detect toolchain
id: detect
run: |
HAS_PHP=false
HAS_NODE=false
[ -f "composer.json" ] && HAS_PHP=true
[ -f "package.json" ] && HAS_NODE=true
echo "has_php=$HAS_PHP" >> "$GITHUB_OUTPUT"
echo "has_node=$HAS_NODE" >> "$GITHUB_OUTPUT"
- name: Setup PHP
if: steps.detect.outputs.has_php == 'true'
run: |
if ! command -v php &> /dev/null; then
sudo apt-get update -qq
sudo apt-get install -y -qq php-cli php-mbstring php-xml >/dev/null 2>&1
fi
- name: Setup Node.js
if: steps.detect.outputs.has_node == 'true'
uses: actions/setup-node@v4
with:
node-version: '20'
- name: Install dependencies
run: |
[ -f "composer.json" ] && composer install --no-interaction --prefer-dist --quiet 2>/dev/null || true
[ -f "package.json" ] && { npm ci --quiet 2>/dev/null || npm install --quiet 2>/dev/null || true; }
- name: Run PHP tests
if: steps.detect.outputs.has_php == 'true'
run: |
if [ -f "vendor/bin/phpunit" ]; then
vendor/bin/phpunit --testdox 2>&1
echo "## PHPUnit" >> $GITHUB_STEP_SUMMARY
echo "Tests passed." >> $GITHUB_STEP_SUMMARY
elif [ -f "phpunit.xml" ] || [ -f "phpunit.xml.dist" ]; then
echo "::warning::PHPUnit config found but phpunit not installed"
else
echo "No PHPUnit configured — skipping"
fi
- name: Run Node.js tests
if: steps.detect.outputs.has_node == 'true'
run: |
if jq -e '.scripts.test' package.json > /dev/null 2>&1; then
npm test 2>&1
echo "## Node.js Tests" >> $GITHUB_STEP_SUMMARY
echo "Tests passed." >> $GITHUB_STEP_SUMMARY
else
echo "No test script in package.json — skipping"
fi
- name: Build check
run: |
if [ -f "Makefile" ]; then
make build 2>&1 || echo "::warning::Build failed or not configured"
elif [ -f "package.json" ] && jq -e '.scripts.build' package.json > /dev/null 2>&1; then
npm run build 2>&1 || echo "::warning::Build failed"
fi
+68
View File
@@ -0,0 +1,68 @@
# Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
#
# SPDX-License-Identifier: GPL-3.0-or-later
#
# FILE INFORMATION
# DEFGROUP: MokoGIT.Workflow
# INGROUP: MokoCLI.Universal
# REPO: https://git.mokoconsulting.tech/MokoConsulting/mokocli
# PATH: /.mokogit/workflows/ci-issue-reporter.yml
# VERSION: 01.00.00
# BRIEF: Reusable workflow — creates/updates a MokoGIT issue when a CI gate fails.
# Clones MokoCLI and runs cli/ci_issue_reporter.sh.
name: "Universal: CI Issue Reporter"
on:
workflow_call:
inputs:
gate:
description: "CI gate name (e.g. PR Validation, Repository Health)"
required: true
type: string
details:
description: "Human-readable failure description"
required: true
type: string
severity:
description: "error or warning"
required: false
type: string
default: "error"
workflow:
description: "Workflow name for the issue title"
required: false
type: string
default: ""
secrets:
MOKOGIT_TOKEN:
required: true
env:
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
jobs:
report:
name: "Report: ${{ inputs.gate }}"
runs-on: ubuntu-latest
steps:
- name: Clone MokoCLI
env:
MOKOGIT_TOKEN: ${{ secrets.MOKOGIT_TOKEN }}
run: |
MOKOGIT_URL="${{ vars.GITEA_URL || 'https://git.mokoconsulting.tech' }}"
git clone --depth 1 --filter=blob:none --sparse "${MOKOGIT_URL}/MokoConsulting/mokocli.git" /tmp/mokocli
cd /tmp/mokocli && git sparse-checkout set cli/ci_issue_reporter.sh
- name: Report CI failure
env:
MOKOGIT_TOKEN: ${{ secrets.MOKOGIT_TOKEN }}
MOKOGIT_URL: ${{ vars.GITEA_URL || 'https://git.mokoconsulting.tech' }}
run: |
chmod +x /tmp/mokocli/cli/ci_issue_reporter.sh
/tmp/mokocli/cli/ci_issue_reporter.sh \
--gate "${{ inputs.gate }}" \
--details "${{ inputs.details }}" \
--severity "${{ inputs.severity }}" \
--workflow "${{ inputs.workflow }}"
File diff suppressed because it is too large Load Diff
+87
View File
@@ -0,0 +1,87 @@
# Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
#
# SPDX-License-Identifier: GPL-3.0-or-later
#
# FILE INFORMATION
# DEFGROUP: MokoGIT.Workflow
# INGROUP: MokoCLI.Maintenance
# REPO: https://git.mokoconsulting.tech/MokoConsulting/mokocli
# PATH: /.mokogit/workflows/cleanup.yml
# VERSION: 01.00.00
# BRIEF: Scheduled cleanup — delete merged branches and old workflow runs
name: "Universal: Repository Cleanup"
on:
schedule:
- cron: '0 3 * * 0' # Weekly on Sunday at 03:00 UTC
workflow_dispatch:
permissions:
contents: write
env:
MOKOGIT_URL: ${{ vars.GITEA_URL || 'https://git.mokoconsulting.tech' }}
jobs:
cleanup:
name: Clean Merged Branches
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 0
token: ${{ secrets.MOKOGIT_TOKEN }}
- name: Delete merged branches
env:
MOKOGIT_TOKEN: ${{ secrets.MOKOGIT_TOKEN }}
run: |
echo "=== Merged Branch Cleanup ==="
API="${MOKOGIT_URL}/api/v1/repos/${{ github.repository }}"
# List branches via API
BRANCHES=$(curl -sS -H "Authorization: token ${MOKOGIT_TOKEN}" \
"${API}/branches?limit=50" | jq -r '.[].name')
DELETED=0
for BRANCH in $BRANCHES; do
# Skip protected branches
case "$BRANCH" in
main|master|dev|develop|rc|beta|alpha|release|release/*|production|stable|staging|hotfix/*|version/*) continue ;;
esac
# Check if branch is merged into main
if git merge-base --is-ancestor "origin/${BRANCH}" origin/main 2>/dev/null; then
echo " Deleting merged branch: ${BRANCH}"
curl -sS -X DELETE -H "Authorization: token ${MOKOGIT_TOKEN}" \
"${API}/branches/${BRANCH}" 2>/dev/null || true
DELETED=$((DELETED + 1))
fi
done
echo "Deleted ${DELETED} merged branch(es)"
- name: Clean old workflow runs
env:
MOKOGIT_TOKEN: ${{ secrets.MOKOGIT_TOKEN }}
run: |
echo "=== Workflow Run Cleanup ==="
API="${MOKOGIT_URL}/api/v1/repos/${{ github.repository }}"
CUTOFF=$(date -d "30 days ago" +%Y-%m-%dT%H:%M:%SZ 2>/dev/null || date -v-30d +%Y-%m-%dT%H:%M:%SZ)
# Get old completed runs
RUNS=$(curl -sS -H "Authorization: token ${MOKOGIT_TOKEN}" \
"${API}/actions/runs?status=completed&limit=50" | \
jq -r ".workflow_runs[] | select(.created_at < \"${CUTOFF}\") | .id" 2>/dev/null)
DELETED=0
for RUN_ID in $RUNS; do
curl -sS -X DELETE -H "Authorization: token ${MOKOGIT_TOKEN}" \
"${API}/actions/runs/${RUN_ID}" 2>/dev/null || true
DELETED=$((DELETED + 1))
done
echo "Deleted ${DELETED} old workflow run(s)"
+92
View File
@@ -0,0 +1,92 @@
# Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
#
# SPDX-License-Identifier: GPL-3.0-or-later
#
# FILE INFORMATION
# DEFGROUP: MokoGIT.Workflow
# INGROUP: MokoCLI.Security
# REPO: https://git.mokoconsulting.tech/MokoConsulting/mokocli
# PATH: /templates/workflows/gitleaks.yml.template
# VERSION: 01.00.00
# BRIEF: Secret scanning — detect leaked credentials, API keys, and tokens
#
# +========================================================================+
# | SECRET SCANNING |
# +========================================================================+
# | |
# | Scans commits for leaked secrets using Gitleaks. |
# | |
# | - PR scan: only new commits in the PR |
# | - Scheduled: full repo scan weekly |
# | - Alerts via ntfy on findings |
# | |
# +========================================================================+
name: "Universal: Secret Scanning"
on:
schedule:
- cron: '0 5 * * 1' # Weekly Monday 05:00 UTC
workflow_dispatch:
permissions:
contents: read
env:
NTFY_URL: ${{ vars.NTFY_URL || 'https://ntfy.mokoconsulting.tech' }}
NTFY_TOPIC: ${{ vars.NTFY_TOPIC || 'git-security' }}
jobs:
gitleaks:
name: Gitleaks Secret Scan
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Install Gitleaks
run: |
GITLEAKS_VERSION="8.21.2"
curl -sSL "https://github.com/gitleaks/gitleaks/releases/download/v${GITLEAKS_VERSION}/gitleaks_${GITLEAKS_VERSION}_linux_x64.tar.gz" \
| tar -xz -C /usr/local/bin gitleaks
gitleaks version
- name: Scan for secrets
id: scan
run: |
echo "### Secret Scanning" >> $GITHUB_STEP_SUMMARY
ARGS="--source . --verbose --report-format json --report-path /tmp/gitleaks-report.json"
if [ "${{ github.event_name }}" = "pull_request" ]; then
# Scan only PR commits
ARGS="$ARGS --log-opts=${{ github.event.pull_request.base.sha }}..${{ github.event.pull_request.head.sha }}"
echo "Scanning PR commits only" >> $GITHUB_STEP_SUMMARY
else
echo "Full repository scan" >> $GITHUB_STEP_SUMMARY
fi
if gitleaks detect $ARGS 2>&1; then
echo "result=clean" >> "$GITHUB_OUTPUT"
echo "**No secrets detected.**" >> $GITHUB_STEP_SUMMARY
else
echo "result=found" >> "$GITHUB_OUTPUT"
FINDINGS=$(jq length /tmp/gitleaks-report.json 2>/dev/null || echo "unknown")
echo "**${FINDINGS} potential secret(s) detected.**" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "Review the findings and rotate any exposed credentials immediately." >> $GITHUB_STEP_SUMMARY
exit 1
fi
- name: Notify on findings
if: failure() && steps.scan.outputs.result == 'found'
run: |
REPO="${{ github.event.repository.name }}"
curl -sS \
-H "Title: ${REPO} — secrets detected in code" \
-H "Tags: rotating_light,key" \
-H "Priority: urgent" \
-d "Gitleaks found potential secrets. Review and rotate credentials immediately." \
"${NTFY_URL}/${NTFY_TOPIC}" || true
+81
View File
@@ -0,0 +1,81 @@
# Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
#
# SPDX-License-Identifier: GPL-3.0-or-later
#
# FILE INFORMATION
# DEFGROUP: MokoGIT.Workflow
# INGROUP: MokoCLI.Automation
# VERSION: 02.67.00
# BRIEF: Auto-create feature branch when an issue is opened
name: "Universal: Issue Branch"
on:
issues:
types: [opened]
permissions:
contents: write
issues: write
env:
MOKOGIT_URL: ${{ vars.GITEA_URL || 'https://git.mokoconsulting.tech' }}
jobs:
create-branch:
name: Create feature branch
runs-on: ubuntu-latest
steps:
- name: Create branch and comment
# SECURITY: never interpolate github.event.* into a run: script — the
# Actions engine splices the raw value into the shell source (command
# injection via a crafted issue title). Pass everything through env so
# the values arrive as ordinary shell variables that are not re-parsed.
env:
MOKOGIT_TOKEN: ${{ secrets.MOKOGIT_TOKEN }}
REPO: ${{ github.repository }}
ISSUE_NUM: ${{ github.event.issue.number }}
ISSUE_TITLE: ${{ github.event.issue.title }}
run: |
TOKEN="${MOKOGIT_TOKEN}"
API="${MOKOGIT_URL}/api/v1/repos/${REPO}"
# Build slug from title: lowercase, replace non-alnum with dash, trim.
# printf (not echo) so a title beginning with "-" is not read as flags.
SLUG=$(printf '%s' "${ISSUE_TITLE}" | tr '[:upper:]' '[:lower:]' | sed 's/[^a-z0-9]/-/g' | sed 's/--*/-/g' | sed 's/^-//;s/-$//' | cut -c1-40)
BRANCH="feature/${ISSUE_NUM}-${SLUG}"
# Check dev branch exists
DEV_EXISTS=$(curl -sf -o /dev/null -w '%{http_code}' \
-H "Authorization: token ${TOKEN}" \
"${API}/branches/dev" 2>/dev/null || echo "000")
if [ "${DEV_EXISTS}" != "200" ]; then
echo "No dev branch -- skipping"
exit 0
fi
# Create branch from dev
HTTP=$(curl -sf -o /dev/null -w '%{http_code}' -X POST \
-H "Authorization: token ${TOKEN}" \
-H "Content-Type: application/json" \
"${API}/branches" \
-d "{\"new_branch_name\":\"${BRANCH}\",\"old_branch_name\":\"dev\"}" 2>/dev/null || echo "000")
if [ "${HTTP}" = "201" ]; then
echo "Created branch: ${BRANCH}"
# Comment on issue with branch link
REPO_URL="${MOKOGIT_URL}/${REPO}"
BODY="Branch created: [\`${BRANCH}\`](${REPO_URL}/src/branch/${BRANCH})\n\n\`\`\`bash\ngit fetch origin\ngit checkout ${BRANCH}\n\`\`\`"
curl -sf -X POST \
-H "Authorization: token ${TOKEN}" \
-H "Content-Type: application/json" \
"${API}/issues/${ISSUE_NUM}/comments" \
-d "{\"body\":\"${BODY}\"}" > /dev/null 2>&1
echo "Commented on issue #${ISSUE_NUM}"
else
echo "Failed to create branch (HTTP ${HTTP}) -- may already exist"
fi
+70
View File
@@ -0,0 +1,70 @@
# Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
#
# SPDX-License-Identifier: GPL-3.0-or-later
#
# FILE INFORMATION
# DEFGROUP: MokoGIT.Workflow
# INGROUP: MokoCLI.Notifications
# REPO: https://git.mokoconsulting.tech/MokoConsulting/mokocli
# PATH: /.mokogit/workflows/notify.yml
# VERSION: 01.00.00
# BRIEF: Push notifications via ntfy on release success or workflow failure
name: "Universal: Notifications"
on:
workflow_run:
workflows:
- "Universal: Build & Release"
- "Joomla: Extension CI"
- "Generic: Project CI"
types:
- completed
permissions:
contents: read
env:
NTFY_URL: ${{ vars.NTFY_URL || 'https://ntfy.mokoconsulting.tech' }}
NTFY_TOPIC: ${{ vars.NTFY_TOPIC || 'git-releases' }}
jobs:
notify:
name: Send Notification
runs-on: ubuntu-latest
if: >-
github.event.workflow_run.conclusion == 'success' ||
github.event.workflow_run.conclusion == 'failure'
steps:
- name: Notify on success (releases only)
if: >-
github.event.workflow_run.conclusion == 'success' &&
contains(github.event.workflow_run.name, 'Release')
run: |
REPO="${{ github.event.repository.name }}"
WORKFLOW="${{ github.event.workflow_run.name }}"
URL="${{ github.event.workflow_run.html_url }}"
curl -sS \
-H "Title: ${REPO} released" \
-H "Tags: white_check_mark,package" \
-H "Priority: default" \
-H "Click: ${URL}" \
-d "${WORKFLOW} completed successfully." \
"${NTFY_URL}/${NTFY_TOPIC}"
- name: Notify on failure
if: github.event.workflow_run.conclusion == 'failure'
run: |
REPO="${{ github.event.repository.name }}"
WORKFLOW="${{ github.event.workflow_run.name }}"
URL="${{ github.event.workflow_run.html_url }}"
curl -sS \
-H "Title: ${REPO} workflow failed" \
-H "Tags: x,warning" \
-H "Priority: high" \
-H "Click: ${URL}" \
-d "${WORKFLOW} failed. Check the run for details." \
"${NTFY_URL}/${NTFY_TOPIC}"
+527
View File
@@ -0,0 +1,527 @@
# Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
#
# SPDX-License-Identifier: GPL-3.0-or-later
#
# FILE INFORMATION
# DEFGROUP: MokoGIT.Workflow
# INGROUP: MokoCLI.CI
# REPO: https://git.mokoconsulting.tech/MokoConsulting/mokocli
# PATH: /templates/workflows/universal/pr-check.yml.template
# VERSION: 09.23.00
# BRIEF: PR gate — branch policy + code validation before merge
name: "Universal: PR Check"
on:
pull_request:
types: [opened, synchronize, reopened, edited]
permissions:
contents: read
pull-requests: write
env:
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
jobs:
# ── Branch Policy ──────────────────────────────────────────────────────
branch-policy:
name: Branch Policy
runs-on: ubuntu-latest
steps:
- name: Check branch merge target
run: |
HEAD="${{ github.head_ref }}"
BASE="${{ github.base_ref }}"
echo "PR: ${HEAD} → ${BASE}"
ALLOWED=true
REASON=""
case "$HEAD" in
feature/*|feat/*)
if [ "$BASE" != "dev" ]; then
ALLOWED=false
REASON="Feature branches must target 'dev', not '${BASE}'"
fi
;;
fix/*|bugfix/*)
if [ "$BASE" != "dev" ]; then
ALLOWED=false
REASON="Fix branches must target 'dev', not '${BASE}'"
fi
;;
patch/*)
if [ "$BASE" != "dev" ] && [ "$BASE" != "rc" ]; then
ALLOWED=false
REASON="Patch branches must target 'dev' or 'rc', not '${BASE}'"
fi
;;
hotfix/*)
if [ "$BASE" != "dev" ] && [ "$BASE" != "main" ]; then
ALLOWED=false
REASON="Hotfix branches can only target 'dev' or 'main', not '${BASE}'"
fi
;;
rc)
if [ "$BASE" != "main" ]; then
ALLOWED=false
REASON="RC branch can only merge into 'main', not '${BASE}'"
fi
;;
dev)
if [ "$BASE" != "main" ]; then
ALLOWED=false
REASON="Dev branch can only merge into 'main', not '${BASE}'"
fi
;;
esac
if [ "$ALLOWED" = false ]; then
echo "::error::${REASON}"
echo "## Branch Policy Violation" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "${REASON}" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "### Allowed merge paths:" >> $GITHUB_STEP_SUMMARY
echo "- \`feature/*\` → \`dev\`" >> $GITHUB_STEP_SUMMARY
echo "- \`fix/*\` → \`dev\`" >> $GITHUB_STEP_SUMMARY
echo "- \`hotfix/*\` → \`dev\` or \`main\`" >> $GITHUB_STEP_SUMMARY
echo "- \`dev\` → \`main\`" >> $GITHUB_STEP_SUMMARY
echo "- \`rc/*\` → \`main\`" >> $GITHUB_STEP_SUMMARY
exit 1
fi
echo "Branch policy: OK (${HEAD} → ${BASE})"
echo "## Branch Policy: Passed" >> $GITHUB_STEP_SUMMARY
# ── Secret Scanning ──────────────────────────────────────────────────
gitleaks:
name: Secret Scan
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Install Gitleaks
run: |
GITLEAKS_VERSION="8.21.2"
curl -sSL "https://github.com/gitleaks/gitleaks/releases/download/v${GITLEAKS_VERSION}/gitleaks_${GITLEAKS_VERSION}_linux_x64.tar.gz" \
| tar -xz -C /usr/local/bin gitleaks
- name: Scan PR commits for secrets
run: |
if gitleaks detect --source . --verbose \
--log-opts=${{ github.event.pull_request.base.sha }}..${{ github.event.pull_request.head.sha }} 2>&1; then
echo "**No secrets detected.**" >> $GITHUB_STEP_SUMMARY
else
echo "::error::Potential secrets detected in PR commits"
exit 1
fi
# ── Code Validation ────────────────────────────────────────────────────
validate:
name: Validate PR
runs-on: ubuntu-latest
# Skip on template repos (Template-*) — no real manifest/source/changelog to validate.
if: ${{ !startsWith(github.event.repository.name, 'Template-') }}
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Check for merge conflict markers
run: |
CONFLICTS=$(grep -rn '<<<<<<< \|>>>>>>> \|^=======$' --include='*.php' --include='*.xml' --include='*.css' --include='*.js' --include='*.json' --include='*.md' --include='*.yml' --include='*.yaml' --include='*.ini' --include='*.txt' . 2>/dev/null | grep -v '.git/' || true)
if [ -n "$CONFLICTS" ]; then
echo "::error::Merge conflict markers found in source files"
echo "## Conflict Markers Found" >> $GITHUB_STEP_SUMMARY
echo '```' >> $GITHUB_STEP_SUMMARY
echo "$CONFLICTS" >> $GITHUB_STEP_SUMMARY
echo '```' >> $GITHUB_STEP_SUMMARY
exit 1
fi
echo "No conflict markers found"
- name: Detect platform
id: platform
run: |
# Platform comes from the MokoGIT metadata API (public GET); manifest.xml is no longer used.
API="${GITHUB_SERVER_URL:-https://git.mokoconsulting.tech}/api/v1/repos/${GITHUB_REPOSITORY}/metadata"
PLATFORM="$(curl -sf "$API" 2>/dev/null | python3 -c "import sys, json; print(json.load(sys.stdin).get('platform') or '')" 2>/dev/null || true)"
[ -z "$PLATFORM" ] && PLATFORM="generic"
echo "platform=$PLATFORM" >> "$GITHUB_OUTPUT"
echo "Detected platform: $PLATFORM"
- name: Setup PHP
if: steps.platform.outputs.platform == 'joomla' || steps.platform.outputs.platform == 'dolibarr'
run: |
if ! command -v php &> /dev/null; then
sudo apt-get update -qq
sudo apt-get install -y -qq php-cli php-mbstring php-xml >/dev/null 2>&1
fi
- name: PHP syntax check
if: steps.platform.outputs.platform == 'joomla' || steps.platform.outputs.platform == 'dolibarr'
run: |
ERRORS=0
while IFS= read -r -d '' file; do
if ! php -l "$file" 2>&1 | grep -q "No syntax errors"; then
ERRORS=$((ERRORS + 1))
fi
done < <(find . -name "*.php" -not -path "./.git/*" -not -path "./vendor/*" -print0)
echo "PHP lint: ${ERRORS} error(s)"
[ "$ERRORS" -eq 0 ] || { echo "::error::PHP syntax errors found"; exit 1; }
- name: Joomla JEXEC guard check
if: steps.platform.outputs.platform == 'joomla'
run: |
ERRORS=0
while IFS= read -r -d '' file; do
# Skip vendor, node_modules, and index.html stub files
case "$file" in ./vendor/*|./node_modules/*) continue ;; esac
# Check first 10 lines for JEXEC or JPATH guard
if ! head -20 "$file" | grep -qE "defined\s*\(\s*['\"](_JEXEC|JPATH_BASE|\\\\JPATH_PLATFORM)['\"]"; then
echo "::error file=${file}::Missing JEXEC guard: ${file}"
ERRORS=$((ERRORS + 1))
fi
done < <(find . -name "*.php" -path "*/src/*" -not -path "./.git/*" -not -path "./vendor/*" -print0)
if [ "$ERRORS" -gt 0 ]; then
echo "::error::${ERRORS} PHP file(s) missing defined('_JEXEC') or die guard"
echo "## JEXEC Guard Check: Failed" >> $GITHUB_STEP_SUMMARY
echo "${ERRORS} file(s) in src/ are missing the Joomla execution guard." >> $GITHUB_STEP_SUMMARY
exit 1
fi
echo "JEXEC guard: OK"
- name: Joomla directory listing protection
if: steps.platform.outputs.platform == 'joomla'
run: |
MISSING=0
SOURCE_DIR="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
run: |
PLATFORM="${{ steps.platform.outputs.platform }}"
case "$PLATFORM" in
joomla)
MANIFEST=$(find . -maxdepth 3 -name "*.xml" ! -path "./.git/*" -exec grep -l '<extension' {} \; 2>/dev/null | head -1)
if [ -z "$MANIFEST" ]; then
echo "::warning::No Joomla manifest found (WaaS site)"
exit 0
fi
echo "Manifest: ${MANIFEST}"
if command -v php &> /dev/null; then
php -r "libxml_use_internal_errors(true); \$x = simplexml_load_file('$MANIFEST'); if(!\$x){foreach(libxml_get_errors() as \$e) echo \$e->message; exit(1);}" || { echo "::error::Manifest XML is malformed"; exit 1; }
fi
for ELEMENT in name version description; do
grep -q "<${ELEMENT}>" "$MANIFEST" || { echo "::error::Missing <${ELEMENT}> in manifest"; exit 1; }
done
# Block legacy raw/branch update server URLs on MokoGIT
RAW_URLS=$(grep -n 'raw/branch' "$MANIFEST" | grep -i 'mokoconsulting\|mokogit\|git\.mokoconsulting\.tech' || true)
if [ -n "$RAW_URLS" ]; then
echo "::error::Manifest contains legacy raw/branch update server URL on MokoGIT. Use the MokoGIT Pages URL instead (e.g. /{REPO}/updates.xml not /{REPO}/raw/branch/main/updates.xml)"
echo "$RAW_URLS"
exit 1
fi
echo "Joomla manifest valid"
;;
dolibarr)
MOD_FILE=$(find . -maxdepth 4 -name "mod*.class.php" ! -path "./.git/*" -exec grep -l 'extends DolibarrModules' {} \; 2>/dev/null | head -1)
if [ -z "$MOD_FILE" ]; then
echo "::error::No mod*.class.php found"
exit 1
fi
echo "Dolibarr module: ${MOD_FILE}"
;;
*)
echo "Generic platform — no manifest validation"
;;
esac
- name: Check update stream format
run: |
PLATFORM="${{ steps.platform.outputs.platform }}"
case "$PLATFORM" in
joomla)
if [ -f "updates.xml" ]; then
if command -v php &> /dev/null; then
php -r "libxml_use_internal_errors(true); \$x = simplexml_load_file('updates.xml'); if(!\$x){foreach(libxml_get_errors() as \$e) echo \$e->message; exit(1);}" || { echo "::error::updates.xml is malformed"; exit 1; }
fi
echo "updates.xml valid"
fi
;;
dolibarr)
[ -f "update.txt" ] && echo "update.txt present" || echo "::warning::No update.txt"
;;
esac
- name: Validate Joomla language files
if: steps.platform.outputs.platform == 'joomla'
run: |
ERRORS=0
WARNINGS=0
# Require both en-GB and en-US language directories
LANG_ROOT=$(find . -path "*/language" -type d -not -path "./.git/*" 2>/dev/null | head -1)
if [ -z "$LANG_ROOT" ]; then
echo "No language/ directory found — skipping"
exit 0
fi
if [ ! -d "$LANG_ROOT/en-GB" ]; then
echo "::error::Missing en-GB language directory (${LANG_ROOT}/en-GB)"
ERRORS=$((ERRORS + 1))
fi
if [ ! -d "$LANG_ROOT/en-US" ]; then
echo "::error::Missing en-US language directory (${LANG_ROOT}/en-US)"
ERRORS=$((ERRORS + 1))
fi
# Check that en-GB and en-US have matching .ini files
if [ -d "$LANG_ROOT/en-GB" ] && [ -d "$LANG_ROOT/en-US" ]; then
for GB_INI in "$LANG_ROOT/en-GB"/*.ini; do
[ ! -f "$GB_INI" ] && continue
US_INI="$LANG_ROOT/en-US/$(basename "$GB_INI")"
if [ ! -f "$US_INI" ]; then
echo "::error::$(basename "$GB_INI") exists in en-GB but missing from en-US"
ERRORS=$((ERRORS + 1))
fi
done
for US_INI in "$LANG_ROOT/en-US"/*.ini; do
[ ! -f "$US_INI" ] && continue
GB_INI="$LANG_ROOT/en-GB/$(basename "$US_INI")"
if [ ! -f "$GB_INI" ]; then
echo "::error::$(basename "$US_INI") exists in en-US but missing from en-GB"
ERRORS=$((ERRORS + 1))
fi
done
fi
# Find all .ini language files
INI_FILES=$(find . -path "*/language/*/*.ini" -not -path "./.git/*" 2>/dev/null)
if [ -z "$INI_FILES" ]; then
echo "No .ini language files found"
[ "$ERRORS" -gt 0 ] && exit 1
exit 0
fi
echo "Found $(echo "$INI_FILES" | wc -l) language file(s)"
for FILE in $INI_FILES; do
FNAME=$(basename "$FILE")
LINENUM=0
SEEN_KEYS=""
while IFS= read -r line || [ -n "$line" ]; do
LINENUM=$((LINENUM + 1))
# Skip empty lines and comments
[ -z "$line" ] && continue
echo "$line" | grep -qE '^\s*;' && continue
echo "$line" | grep -qE '^\s*$' && continue
# Must match KEY="VALUE" format
if ! echo "$line" | grep -qE '^[A-Z_][A-Z0-9_]*=".*"$'; then
echo "::error file=${FILE},line=${LINENUM}::Malformed line: ${line}"
ERRORS=$((ERRORS + 1))
continue
fi
# Extract key and check for duplicates
KEY=$(echo "$line" | sed 's/=.*//')
if echo "$SEEN_KEYS" | grep -qx "$KEY"; then
echo "::error file=${FILE},line=${LINENUM}::Duplicate key: ${KEY}"
ERRORS=$((ERRORS + 1))
fi
SEEN_KEYS="${SEEN_KEYS}
${KEY}"
done < "$FILE"
echo " ${FILE}: checked ${LINENUM} lines"
done
# Cross-check en-GB vs en-US key consistency
GB_DIR=$(find . -path "*/language/en-GB" -type d -not -path "./.git/*" 2>/dev/null | head -1)
US_DIR=$(find . -path "*/language/en-US" -type d -not -path "./.git/*" 2>/dev/null | head -1)
if [ -n "$GB_DIR" ] && [ -n "$US_DIR" ]; then
for GB_FILE in "$GB_DIR"/*.ini; do
[ ! -f "$GB_FILE" ] && continue
FNAME=$(basename "$GB_FILE")
US_FILE="$US_DIR/$FNAME"
[ ! -f "$US_FILE" ] && continue
GB_KEYS=$(grep -oP '^[A-Z_][A-Z0-9_]*(?==)' "$GB_FILE" 2>/dev/null | sort)
US_KEYS=$(grep -oP '^[A-Z_][A-Z0-9_]*(?==)' "$US_FILE" 2>/dev/null | sort)
# Keys in en-GB but not en-US
MISSING_US=$(comm -23 <(echo "$GB_KEYS") <(echo "$US_KEYS"))
if [ -n "$MISSING_US" ]; then
echo "::warning::Keys in en-GB/$FNAME but missing from en-US/$FNAME:"
echo "$MISSING_US" | while read -r k; do echo " - $k"; done
WARNINGS=$((WARNINGS + 1))
fi
# Keys in en-US but not en-GB
MISSING_GB=$(comm -13 <(echo "$GB_KEYS") <(echo "$US_KEYS"))
if [ -n "$MISSING_GB" ]; then
echo "::warning::Keys in en-US/$FNAME but missing from en-GB/$FNAME:"
echo "$MISSING_GB" | while read -r k; do echo " - $k"; done
WARNINGS=$((WARNINGS + 1))
fi
done
fi
{
echo "### Language File Validation"
echo "| Metric | Count |"
echo "|---|---|"
echo "| Files checked | $(echo "$INI_FILES" | wc -l) |"
echo "| Errors | ${ERRORS} |"
echo "| Warnings | ${WARNINGS} |"
} >> $GITHUB_STEP_SUMMARY
if [ "$ERRORS" -gt 0 ]; then
echo "::error::Language validation failed with ${ERRORS} error(s)"
exit 1
fi
echo "Language files: OK (${WARNINGS} warning(s))"
- name: Check changelog has unreleased entry
run: |
if [ ! -f "CHANGELOG.md" ]; then
echo "::warning::No CHANGELOG.md found"
exit 0
fi
# Check for content under [Unreleased] section
if ! grep -q "## \[Unreleased\]" CHANGELOG.md; then
echo "::error::CHANGELOG.md missing [Unreleased] section"
exit 1
fi
# Check there's at least one entry (Added/Changed/Fixed/Removed) under Unreleased
UNRELEASED_CONTENT=$(sed -n '/## \[Unreleased\]/,/## \[/p' CHANGELOG.md | grep -cE '^\s*-\s' || true)
if [ "$UNRELEASED_CONTENT" -eq 0 ]; then
echo "::error::CHANGELOG.md [Unreleased] section has no entries. Add a changelog entry describing your changes."
echo "## Changelog Check: Failed" >> $GITHUB_STEP_SUMMARY
echo "The \`[Unreleased]\` section in CHANGELOG.md has no entries." >> $GITHUB_STEP_SUMMARY
echo "Add a line like \`- Description of your change\` under a heading (\`### Added\`, \`### Changed\`, \`### Fixed\`, etc.)" >> $GITHUB_STEP_SUMMARY
exit 1
fi
echo "Changelog: ${UNRELEASED_CONTENT} entry/entries in [Unreleased]"
- name: Verify package source
run: |
SOURCE_DIR="src"
[ ! -d "$SOURCE_DIR" ] && SOURCE_DIR="htdocs"
if [ ! -d "$SOURCE_DIR" ]; then
echo "::warning::No src/ or htdocs/ directory"
exit 0
fi
FILE_COUNT=$(find "$SOURCE_DIR" -type f | wc -l)
echo "Source: ${FILE_COUNT} files"
[ "$FILE_COUNT" -gt 0 ] || { echo "::error::Source directory is empty"; exit 1; }
# ── Pre-Release RC Build ─────────────────────────────────────────────────
pre-release:
name: Build RC Package
runs-on: ubuntu-latest
needs: [branch-policy, validate]
# Run only when both gates succeeded; always() forces evaluation so a skipped
# validate (e.g. template repos) skips this job cleanly instead of hanging.
if: ${{ always() && needs.branch-policy.result == 'success' && needs.validate.result == 'success' }}
steps:
- name: Trigger RC pre-release
env:
MOKOGIT_TOKEN: ${{ secrets.MOKOGIT_TOKEN }}
REPO: ${{ github.repository }}
BRANCH: ${{ github.head_ref }}
MOKOGIT_URL: ${{ vars.GITEA_URL || 'https://git.mokoconsulting.tech' }}
run: |
curl -s -X POST "${MOKOGIT_URL}/api/v1/repos/${REPO}/actions/workflows/pre-release.yml/dispatches" -H "Authorization: token ${MOKOGIT_TOKEN}" -H "Content-Type: application/json" -d "{\"ref\":\"${BRANCH}\",\"inputs\":{\"stability\":\"release-candidate\"}}"
echo "### Pre-Release" >> $GITHUB_STEP_SUMMARY
echo "Triggered RC build on branch \`${BRANCH}\`" >> $GITHUB_STEP_SUMMARY
# ── Issue Reporter ──────────────────────────────────────────────────────
report-issues:
name: Report Issues
needs: [branch-policy, validate]
if: >-
always() &&
needs.validate.result == 'failure'
uses: ./.mokogit/workflows/ci-issue-reporter.yml
with:
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."
secrets: inherit
+73
View File
@@ -0,0 +1,73 @@
# Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
#
# SPDX-License-Identifier: GPL-3.0-or-later
#
# FILE INFORMATION
# DEFGROUP: MokoGIT.Workflow
# INGROUP: MokoCLI.Validation
# REPO: https://git.mokoconsulting.tech/MokoConsulting/mokocli
# PATH: /templates/workflows/joomla/pr-metadata-check.yml.template
# VERSION: 01.00.00
# BRIEF: Validate MokoGIT metadata matches Joomla extension manifest on PRs
name: "Joomla: Metadata Validation"
on:
pull_request:
types: [opened, synchronize, reopened, converted_to_draft, ready_for_review]
permissions:
contents: read
env:
MOKOGIT_URL: ${{ vars.GITEA_URL || 'https://git.mokoconsulting.tech' }}
GIT_ORG: ${{ vars.GITEA_ORG || github.repository_owner }}
GIT_REPO: ${{ vars.GITEA_REPO || github.event.repository.name }}
jobs:
validate-metadata:
name: "Validate Joomla Metadata"
runs-on: ubuntu-latest
# Skip on template repos (Template-*) — they have no real extension manifest to validate.
if: ${{ !startsWith(github.event.repository.name, 'Template-') }}
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup MokoCLI tools
env:
MOKO_CLONE_TOKEN: ${{ secrets.MOKOGIT_TOKEN }}
MOKO_CLONE_HOST: git.mokoconsulting.tech/MokoConsulting
run: |
if [ -f /opt/mokocli/cli/joomla_metadata_validate.php ] && [ -f /opt/mokocli/vendor/autoload.php ]; then
echo Using pre-installed /opt/mokocli
echo MOKO_CLI=/opt/mokocli/cli >> $GITHUB_ENV
else
echo Falling back to fresh clone
if ! command -v composer > /dev/null 2>&1; then
sudo apt-get update -qq && sudo apt-get install -y -qq php-cli php-mbstring php-xml php-zip php-curl composer > /dev/null 2>&1
fi
rm -rf /tmp/mokocli
CLONE_URL=https://x-access-token:${MOKO_CLONE_TOKEN}@${MOKO_CLONE_HOST}/mokocli.git
git clone --depth 1 --branch main --quiet $CLONE_URL /tmp/mokocli
cd /tmp/mokocli && composer install --no-dev --no-interaction --quiet
echo MOKO_CLI=/tmp/mokocli/cli >> $GITHUB_ENV
fi
- name: Validate metadata against Joomla manifest
env:
MOKOGIT_TOKEN: ${{ secrets.MOKOGIT_TOKEN }}
run: |
php ${MOKO_CLI}/joomla_metadata_validate.php \
--path . \
--token "${MOKOGIT_TOKEN}" \
--org "${GIT_ORG}" \
--repo "${GIT_REPO}" \
--api-base "${MOKOGIT_URL}/api/v1" \
--ci
if [ $? -ne 0 ]; then
echo "::error::Joomla metadata mismatch — update delivery will fail. Run 'php cli/joomla_metadata_validate.php' locally to see details."
exit 1
fi
+277
View File
@@ -0,0 +1,277 @@
# Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
#
# SPDX-License-Identifier: GPL-3.0-or-later
#
# FILE INFORMATION
# DEFGROUP: MokoGIT.Workflow
# INGROUP: MokoCLI.Release
# REPO: https://git.mokoconsulting.tech/MokoConsulting/mokocli
# PATH: /templates/workflows/universal/pre-release.yml.template
# VERSION: 05.02.00
# BRIEF: Auto pre-release on push to dev/alpha/beta/rc branches
name: "Universal: Pre-Release"
on:
push:
branches:
- dev
- 'fix/**'
- 'patch/**'
- 'hotfix/**'
- 'bugfix/**'
- 'chore/**'
- alpha
- beta
- rc
workflow_dispatch:
inputs:
stability:
description: 'Pre-release channel'
required: true
type: choice
options:
- development
- alpha
- beta
- release-candidate
permissions:
contents: write
env:
GIT_URL: ${{ vars.GITEA_URL || 'https://git.mokoconsulting.tech' }}
GIT_ORG: ${{ vars.GITEA_ORG || github.repository_owner }}
GIT_REPO: ${{ vars.GITEA_REPO || github.event.repository.name }}
jobs:
build:
name: "Build Pre-Release (${{ inputs.stability || github.ref_name }})"
runs-on: release
# Skip on template repos (Template-*) — they scaffold other repos and do not release.
if: >-
!startsWith(github.event.repository.name, 'Template-') &&
(
github.event_name == 'workflow_dispatch' ||
github.event_name == 'push'
)
steps:
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 0
token: ${{ secrets.MOKOGIT_TOKEN }}
ref: ${{ github.ref_name }}
submodules: recursive
- name: Update submodules to main
run: |
git submodule foreach --quiet 'git checkout main && git pull --quiet origin main' 2>/dev/null || true
- name: Setup MokoCLI tools
env:
MOKO_CLONE_TOKEN: ${{ secrets.MOKOGIT_TOKEN }}
MOKO_CLONE_HOST: git.mokoconsulting.tech/MokoConsulting
run: |
# Use pre-installed /opt/mokocli if available (updated by cron every 6h)
if [ -f /opt/mokocli/cli/version_bump.php ] && [ -f /opt/mokocli/cli/manifest_element.php ] && [ -f /opt/mokocli/vendor/autoload.php ]; then
echo Using pre-installed /opt/mokocli
echo MOKO_CLI=/opt/mokocli/cli >> $GITHUB_ENV
else
echo Falling back to fresh clone
if ! command -v composer > /dev/null 2>&1; then
sudo apt-get update -qq && sudo apt-get install -y -qq php-cli php-mbstring php-xml php-zip php-curl composer > /dev/null 2>&1
fi
rm -rf /tmp/mokocli
CLONE_URL=https://x-access-token:${MOKO_CLONE_TOKEN}@${MOKO_CLONE_HOST}/mokocli.git
git clone --depth 1 --branch main --quiet $CLONE_URL /tmp/mokocli
cd /tmp/mokocli && composer install --no-dev --no-interaction --quiet
echo MOKO_CLI=/tmp/mokocli/cli >> $GITHUB_ENV
fi
- name: Detect platform
id: platform
run: |
# Auto-detect and update platform if not set in manifest
php ${MOKO_CLI}/platform_detect.php --path . --github-output 2>/dev/null || true
php ${MOKO_CLI}/manifest_read.php --path . --github-output
- name: Check platform eligibility (Joomla only)
id: eligibility
run: |
PLATFORM="${{ steps.platform.outputs.platform }}"
if [[ "$PLATFORM" == joomla* ]] || [[ "$PLATFORM" == "joomla" ]]; then
echo "proceed=true" >> "$GITHUB_OUTPUT"
else
echo "proceed=false" >> "$GITHUB_OUTPUT"
echo "::notice::Platform '$PLATFORM' — non-Joomla, skipping pre-release auto-bump"
fi
- name: Resolve metadata and bump version
id: meta
if: steps.eligibility.outputs.proceed == 'true'
run: |
# Auto-detect stability from branch name on push, or use input on dispatch
if [ "${{ github.event_name }}" = "push" ]; then
case "${{ github.ref_name }}" in
rc) STABILITY="release-candidate" ;;
alpha) STABILITY="alpha" ;;
beta) STABILITY="beta" ;;
*) STABILITY="development" ;;
esac
else
STABILITY="${{ inputs.stability || 'development' }}"
fi
case "$STABILITY" in
development) SUFFIX="-dev"; TAG="development" ;;
alpha) SUFFIX="-alpha"; TAG="alpha" ;;
beta) SUFFIX="-beta"; TAG="beta" ;;
release-candidate) SUFFIX="-rc"; TAG="release-candidate" ;;
esac
# Bump version via CLI: patch for dev/alpha/beta, minor for RC
case "$STABILITY" in
release-candidate) BUMP="minor" ;;
*) BUMP="patch" ;;
esac
php ${MOKO_CLI}/version_bump.php --path . $([ "$BUMP" = "minor" ] && echo "--minor") 2>/dev/null || true
# Set stability suffix and verify consistency
VERSION=$(php ${MOKO_CLI}/version_read.php --path . 2>/dev/null || echo "00.00.01")
VERSION=$(echo "$VERSION" | sed 's/-\(dev\|alpha\|beta\|rc\)$//')
php ${MOKO_CLI}/version_set_platform.php \
--path . --version "$VERSION" --branch "${{ github.ref_name }}" --stability "$STABILITY" 2>/dev/null || true
php ${MOKO_CLI}/version_check.php --path . --fix 2>/dev/null || true
# Ensure licensing tags (updateservers, dlid) if enabled in manifest.xml
php ${MOKO_CLI}/manifest_licensing.php --path . --fix 2>/dev/null || true
# Append suffix for output
if [ -n "$SUFFIX" ]; then
VERSION="${VERSION}${SUFFIX}"
fi
# Commit version bump
git config --local user.email "mokogit-actions[bot]@mokoconsulting.tech"
git config --local user.name "mokogit-actions[bot]"
git remote set-url origin "https://x-access-token:${{ secrets.MOKOGIT_TOKEN }}@git.mokoconsulting.tech/${{ github.repository }}.git"
git add -A
git diff --cached --quiet || {
git commit -m "chore(version): pre-release bump to ${VERSION} [skip ci]"
git push origin HEAD 2>&1
}
# Auto-detect element via manifest_element.php
php ${MOKO_CLI}/manifest_element.php \
--path . --version "$VERSION" --stability "$STABILITY" \
--repo "${GIT_REPO}" --github-output
# Read back element outputs
EXT_ELEMENT=$(grep '^ext_element=' "$GITHUB_OUTPUT" | tail -1 | cut -d= -f2)
ZIP_NAME=$(grep '^zip_name=' "$GITHUB_OUTPUT" | tail -1 | cut -d= -f2)
[ -z "$EXT_ELEMENT" ] && EXT_ELEMENT=$(echo "${GIT_REPO}" | tr '[:upper:]' '[:lower:]' | tr -d ' -')
[ -z "$ZIP_NAME" ] && ZIP_NAME="${EXT_ELEMENT}-${VERSION}.zip"
echo "version=${VERSION}" >> "$GITHUB_OUTPUT"
echo "stability=${STABILITY}" >> "$GITHUB_OUTPUT"
echo "suffix=${SUFFIX}" >> "$GITHUB_OUTPUT"
echo "tag=${TAG}" >> "$GITHUB_OUTPUT"
echo "zip_name=${ZIP_NAME}" >> "$GITHUB_OUTPUT"
echo "ext_element=${EXT_ELEMENT}" >> "$GITHUB_OUTPUT"
echo "=== Pre-Release: ${EXT_ELEMENT} ${VERSION}${SUFFIX} ==="
- name: Create release
id: release
if: steps.eligibility.outputs.proceed == 'true'
run: |
TAG="${{ steps.meta.outputs.tag }}"
VERSION="${{ steps.meta.outputs.version }}"
API_BASE="${GIT_URL}/api/v1/repos/${GIT_ORG}/${GIT_REPO}"
php ${MOKO_CLI}/release_create.php \
--path . --version "$VERSION" --tag "$TAG" \
--token "${{ secrets.MOKOGIT_TOKEN }}" --api-base "$API_BASE" \
--repo "${GIT_REPO}" --branch "${{ github.ref_name }}" --prerelease
- name: Update release notes from CHANGELOG.md
if: steps.eligibility.outputs.proceed == 'true'
run: |
TAG="${{ steps.meta.outputs.tag }}"
VERSION="${{ steps.meta.outputs.version }}"
API_BASE="${GIT_URL}/api/v1/repos/${GIT_ORG}/${GIT_REPO}"
# Extract [Unreleased] section from changelog (everything between [Unreleased] and next ## heading)
if [ -f "CHANGELOG.md" ]; then
NOTES=$(awk '/^## \[Unreleased\]/{found=1; next} /^## \[/{if(found) exit} found{print}' CHANGELOG.md)
[ -z "$NOTES" ] && NOTES="Release ${VERSION}"
else
NOTES="Release ${VERSION}"
fi
# Update release body via API
RELEASE_ID=$(curl -sf -H "Authorization: token ${{ secrets.MOKOGIT_TOKEN }}" \
"${API_BASE}/releases/tags/${TAG}" | python3 -c "import json,sys; print(json.load(sys.stdin).get('id',''))" 2>/dev/null || true)
if [ -n "$RELEASE_ID" ]; then
python3 -c "
import json, urllib.request
body = open('/dev/stdin').read()
payload = json.dumps({'body': body}).encode()
req = urllib.request.Request(
'${API_BASE}/releases/${RELEASE_ID}',
data=payload, method='PATCH',
headers={
'Authorization': 'token ${{ secrets.MOKOGIT_TOKEN }}',
'Content-Type': 'application/json'
})
urllib.request.urlopen(req)
" <<< "$NOTES"
echo "Release notes updated from CHANGELOG.md"
fi
- name: Build package and upload
id: package
if: steps.eligibility.outputs.proceed == 'true'
run: |
VERSION="${{ steps.meta.outputs.version }}"
TAG="${{ steps.meta.outputs.tag }}"
API_BASE="${GIT_URL}/api/v1/repos/${GIT_ORG}/${GIT_REPO}"
php ${MOKO_CLI}/release_package.php \
--path . --version "$VERSION" --tag "$TAG" \
--token "${{ secrets.MOKOGIT_TOKEN }}" --api-base "$API_BASE" \
--repo "${GIT_REPO}" --output /tmp || true
# updates.xml is generated dynamically by MokoGIT license server
# No need to build, commit, or sync updates.xml from workflows
- name: "Delete lesser pre-release channels (cascade)"
if: steps.eligibility.outputs.proceed == 'true'
continue-on-error: true
run: |
API_BASE="${GIT_URL}/api/v1/repos/${GIT_ORG}/${GIT_REPO}"
TOKEN="${{ secrets.MOKOGIT_TOKEN }}"
php ${MOKO_CLI}/release_cascade.php \
--stability "${{ steps.meta.outputs.stability }}" \
--token "${TOKEN}" \
--api-base "${API_BASE}"
- name: Summary
if: always()
run: |
VERSION="${{ steps.meta.outputs.version }}"
STABILITY="${{ steps.meta.outputs.stability }}"
ZIP_NAME="${{ steps.meta.outputs.zip_name }}"
SHA256="${{ steps.package.outputs.sha256_zip }}"
echo "## Pre-Release Complete" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "| Field | Value |" >> $GITHUB_STEP_SUMMARY
echo "|-------|-------|" >> $GITHUB_STEP_SUMMARY
echo "| Version | \`${VERSION}\` |" >> $GITHUB_STEP_SUMMARY
echo "| Channel | ${STABILITY} |" >> $GITHUB_STEP_SUMMARY
echo "| Package | \`${ZIP_NAME}\` |" >> $GITHUB_STEP_SUMMARY
echo "| SHA-256 | \`${SHA256:-n/a}\` |" >> $GITHUB_STEP_SUMMARY
+71
View File
@@ -0,0 +1,71 @@
# Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
#
# SPDX-License-Identifier: GPL-3.0-or-later
#
# FILE INFORMATION
# DEFGROUP: MokoGIT.Workflow
# INGROUP: MokoCLI.Universal
# REPO: https://git.mokoconsulting.tech/MokoConsulting/mokocli
# PATH: /.mokogit/workflows/rc-revert.yml
# VERSION: 09.23.00
# BRIEF: Rename rc/ branch back to dev/ when PR is closed without merge
name: "RC Revert"
on:
pull_request:
types: [closed]
env:
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
jobs:
revert:
name: Rename rc/ back to dev/
runs-on: ubuntu-latest
if: >-
github.event.pull_request.merged == false &&
startsWith(github.event.pull_request.head.ref, 'rc/')
steps:
- name: Rename branch
env:
BRANCH: ${{ github.event.pull_request.head.ref }}
REPO: ${{ github.repository }}
GIT_URL: ${{ vars.GITEA_URL || 'https://git.mokoconsulting.tech' }}
TOKEN: ${{ secrets.MOKOGIT_TOKEN }}
run: |
set -euo pipefail
# BRANCH is attacker-controlled (PR head ref). Strict allowlist before ANY use.
if ! printf '%s' "$BRANCH" | grep -Eq '^rc/[A-Za-z0-9._/-]+$'; then
echo "::error::Refusing unsafe branch name: $BRANCH"; exit 1
fi
SUFFIX="${BRANCH#rc/}"
DEV_BRANCH="dev/${SUFFIX}"
API="${GIT_URL}/api/v1/repos/${REPO}/branches"
# Create dev/ branch from rc/ branch
STATUS=$(curl -sf -o /dev/null -w "%{http_code}" -X POST \
-H "Authorization: token ${TOKEN}" \
-H "Content-Type: application/json" \
-d "{\"new_branch_name\": \"${DEV_BRANCH}\", \"old_branch_name\": \"${BRANCH}\"}" \
"${API}" 2>/dev/null || true)
if [ "$STATUS" = "201" ]; then
echo "Created branch: ${DEV_BRANCH}" >> "$GITHUB_STEP_SUMMARY"
else
echo "::error::Failed to create ${DEV_BRANCH} from ${BRANCH} (HTTP ${STATUS})"; exit 1
fi
# Read BRANCH from the environment inside PHP (getenv, no string interpolation -> no PHP injection)
ENCODED=$(php -r 'echo rawurlencode(getenv("BRANCH"));')
STATUS=$(curl -sf -o /dev/null -w "%{http_code}" -X DELETE \
-H "Authorization: token ${TOKEN}" \
"${API}/${ENCODED}" 2>/dev/null || true)
if [ "$STATUS" = "204" ]; then
echo "Deleted branch: ${BRANCH}" >> "$GITHUB_STEP_SUMMARY"
else
echo "::warning::Failed to delete ${BRANCH} (HTTP ${STATUS})"
fi
echo "### RC Reverted" >> "$GITHUB_STEP_SUMMARY"
echo "${BRANCH} → ${DEV_BRANCH}" >> "$GITHUB_STEP_SUMMARY"
+700
View File
@@ -0,0 +1,700 @@
# ============================================================================
# Copyright (C) 2025 Moko Consulting <hello@mokoconsulting.tech>
#
# This file is part of a Moko Consulting project.
#
# SPDX-License-Identifier: GPL-3.0-or-later
#
# FILE INFORMATION
# DEFGROUP: MokoGIT.Workflow
# INGROUP: MokoCLI.Validation
# REPO: https://git.mokoconsulting.tech/MokoConsulting/mokocli
# PATH: /templates/workflows/joomla/repo_health.yml.template
# VERSION: 09.23.00
# BRIEF: Enforces repository guardrails by validating scripts governance, tooling availability, and core repository health artifacts.
# ============================================================================
name: "Generic: Repo Health"
defaults:
run:
shell: bash
on:
workflow_dispatch:
inputs:
profile:
description: 'Validation profile: all, scripts, or repo'
required: true
default: all
type: choice
options:
- all
- scripts
- repo
pull_request:
branches:
- main
permissions:
contents: read
env:
# Scripts governance policy
SCRIPTS_REQUIRED_DIRS:
SCRIPTS_ALLOWED_DIRS: scripts,scripts/fix,scripts/lib,scripts/release,scripts/run,scripts/validate
# Repo health policy
REPO_REQUIRED_ARTIFACTS: README.md,LICENSE,CHANGELOG.md,CONTRIBUTING.md,CODE_OF_CONDUCT.md,.mokogit/workflows/
REPO_OPTIONAL_FILES: SECURITY.md,GOVERNANCE.md,.editorconfig,.gitattributes,.gitignore,README.md,docs/
REPO_DISALLOWED_DIRS:
REPO_DISALLOWED_FILES: TODO.md,todo.md
# Extended checks toggles
EXTENDED_CHECKS: "true"
# File / directory variables
DOCS_INDEX: docs/docs-index.md
SCRIPT_DIR: scripts
WORKFLOWS_DIR: .mokogit/workflows
SHELLCHECK_PATTERN: '*.sh'
SPDX_FILE_GLOBS: '*.sh,*.php,*.js,*.ts,*.css,*.xml,*.yml,*.yaml'
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
jobs:
access_check:
name: Access control
runs-on: ubuntu-latest
timeout-minutes: 10
permissions:
contents: read
outputs:
allowed: ${{ steps.perm.outputs.allowed }}
permission: ${{ steps.perm.outputs.permission }}
steps:
- name: Check actor permission (admin only)
id: perm
env:
TOKEN: ${{ secrets.MOKOGIT_TOKEN || github.token }}
REPO: ${{ github.repository }}
ACTOR: ${{ github.actor }}
run: |
set -euo pipefail
ALLOWED=false
PERMISSION=unknown
METHOD=""
# Hardcoded authorized users — always allowed
case "$ACTOR" in
jmiller|mokogit-actions[bot])
ALLOWED=true
PERMISSION=admin
METHOD="hardcoded allowlist"
;;
*)
# Detect platform and check permissions via API
API_BASE="${GITHUB_API_URL:-${GIT_API_URL:-https://api.github.com}}"
RESP=$(curl -sf -H "Authorization: token ${TOKEN}" \
"${API_BASE}/repos/${REPO}/collaborators/${ACTOR}/permission" 2>/dev/null || echo '{}')
PERMISSION=$(echo "$RESP" | grep -oP '"permission"\s*:\s*"\K[^"]+' || echo "unknown")
if [ "$PERMISSION" = "admin" ] || [ "$PERMISSION" = "maintain" ] || [ "$PERMISSION" = "owner" ]; then
ALLOWED=true
fi
METHOD="collaborator API"
;;
esac
echo "permission=${PERMISSION}" >> "$GITHUB_OUTPUT"
echo "allowed=${ALLOWED}" >> "$GITHUB_OUTPUT"
{
echo "## Access Authorization"
echo ""
echo "| Field | Value |"
echo "|-------|-------|"
echo "| **Actor** | \`${ACTOR}\` |"
echo "| **Repository** | \`${REPO}\` |"
echo "| **Permission** | \`${PERMISSION}\` |"
echo "| **Method** | ${METHOD} |"
echo "| **Authorized** | ${ALLOWED} |"
echo ""
if [ "$ALLOWED" = "true" ]; then
echo "${ACTOR} authorized (${METHOD})"
else
echo "${ACTOR} is NOT authorized. Requires admin or maintain role."
fi
} >> "${GITHUB_STEP_SUMMARY}"
- name: Deny execution when not permitted
if: ${{ steps.perm.outputs.allowed != 'true' }}
run: |
set -euo pipefail
printf '%s\n' 'ERROR: Access denied. Admin permission required.' >> "${GITHUB_STEP_SUMMARY}"
exit 1
scripts_governance:
name: Scripts governance
needs: access_check
if: ${{ needs.access_check.outputs.allowed == 'true' }}
runs-on: ubuntu-latest
timeout-minutes: 15
permissions:
contents: read
steps:
- name: Checkout
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
with:
fetch-depth: 0
- name: Scripts folder checks
env:
PROFILE_RAW: ${{ github.event.inputs.profile }}
run: |
set -euo pipefail
profile="${PROFILE_RAW:-all}"
case "${profile}" in
all|scripts|repo) ;;
*)
printf '%s\n' "ERROR: Unknown profile: ${profile}" >> "${GITHUB_STEP_SUMMARY}"
exit 1
;;
esac
if [ "${profile}" = 'repo' ]; then
{
printf '%s\n' '### Scripts governance'
printf '%s\n' "Profile: ${profile}"
printf '%s\n' 'Status: SKIPPED'
printf '%s\n' 'Reason: profile excludes scripts governance'
printf '\n'
} >> "${GITHUB_STEP_SUMMARY}"
exit 0
fi
if [ ! -d "${SCRIPT_DIR}" ]; then
{
printf '%s\n' '### Scripts governance'
printf '%s\n' 'Status: OK (advisory)'
printf '%s\n' 'scripts/ directory not present. No scripts governance enforced.'
printf '\n'
} >> "${GITHUB_STEP_SUMMARY}"
exit 0
fi
if [ -n "${SCRIPTS_REQUIRED_DIRS:-}" ]; then IFS=',' read -r -a required_dirs <<< "${SCRIPTS_REQUIRED_DIRS}"; else required_dirs=(); fi
IFS=',' read -r -a allowed_dirs <<< "${SCRIPTS_ALLOWED_DIRS}"
missing_dirs=()
unapproved_dirs=()
for d in "${required_dirs[@]}"; do
req="${d%/}"
[ ! -d "${req}" ] && missing_dirs+=("${req}/")
done
while IFS= read -r d; do
allowed=false
for a in "${allowed_dirs[@]}"; do
a_norm="${a%/}"
[ "${d%/}" = "${a_norm}" ] && allowed=true
done
[ "${allowed}" = false ] && unapproved_dirs+=("${d%/}/")
done < <(find "${SCRIPT_DIR}" -maxdepth 1 -mindepth 1 -type d 2>/dev/null | sed 's#^\./##')
{
printf '%s\n' '### Scripts governance'
printf '%s\n' "Profile: ${profile}"
printf '%s\n' '| Area | Status | Notes |'
printf '%s\n' '|---|---|---|'
if [ "${#missing_dirs[@]}" -gt 0 ]; then
printf '%s\n' '| Required directories | Warning | Missing required subfolders |'
else
printf '%s\n' '| Required directories | OK | All required subfolders present |'
fi
if [ "${#unapproved_dirs[@]}" -gt 0 ]; then
printf '%s\n' '| Directory policy | Warning | Unapproved directories detected |'
else
printf '%s\n' '| Directory policy | OK | No unapproved directories |'
fi
printf '%s\n' '| Enforcement mode | Advisory | scripts folder is optional |'
printf '\n'
if [ "${#missing_dirs[@]}" -gt 0 ]; then
printf '%s\n' 'Missing required script directories:'
for m in "${missing_dirs[@]}"; do printf '%s\n' "- ${m}"; done
printf '\n'
else
printf '%s\n' 'Missing required script directories: none.'
printf '\n'
fi
if [ "${#unapproved_dirs[@]}" -gt 0 ]; then
printf '%s\n' 'Unapproved script directories detected:'
for m in "${unapproved_dirs[@]}"; do printf '%s\n' "- ${m}"; done
printf '\n'
else
printf '%s\n' 'Unapproved script directories detected: none.'
printf '\n'
fi
printf '%s\n' 'Scripts governance completed in advisory mode.'
printf '\n'
} >> "${GITHUB_STEP_SUMMARY}"
repo_health:
name: Repository health
needs: access_check
if: ${{ needs.access_check.outputs.allowed == 'true' }}
runs-on: ubuntu-latest
timeout-minutes: 20
permissions:
contents: read
steps:
- name: Checkout
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
with:
fetch-depth: 0
- name: Repository health checks
env:
PROFILE_RAW: ${{ github.event.inputs.profile }}
run: |
set -euo pipefail
profile="${PROFILE_RAW:-all}"
case "${profile}" in
all|scripts|repo) ;;
*)
printf '%s\n' "ERROR: Unknown profile: ${profile}" >> "${GITHUB_STEP_SUMMARY}"
exit 1
;;
esac
if [ "${profile}" = 'scripts' ]; then
{
printf '%s\n' '### Repository health'
printf '%s\n' "Profile: ${profile}"
printf '%s\n' 'Status: SKIPPED'
printf '%s\n' 'Reason: profile excludes repository health'
printf '\n'
} >> "${GITHUB_STEP_SUMMARY}"
exit 0
fi
IFS=',' read -r -a required_artifacts <<< "${REPO_REQUIRED_ARTIFACTS}"
IFS=',' read -r -a optional_files <<< "${REPO_OPTIONAL_FILES}"
if [ -n "${REPO_DISALLOWED_DIRS:-}" ]; then IFS=',' read -r -a disallowed_dirs <<< "${REPO_DISALLOWED_DIRS}"; else disallowed_dirs=(); fi
IFS=',' read -r -a disallowed_files <<< "${REPO_DISALLOWED_FILES:-}"
missing_required=()
missing_optional=()
# Source directory: src/ or htdocs/ (either is valid for extension repos)
SOURCE_DIR=""
if [ -d "src" ]; then
SOURCE_DIR="src"
elif [ -d "htdocs" ]; then
SOURCE_DIR="htdocs"
elif [ -d "deploy" ] || [ -d "cli" ] || [ -d "monitoring" ]; then
# Platform/tooling repos don't need src/
SOURCE_DIR=""
else
missing_required+=("src/ or htdocs/ (source directory required)")
fi
for item in "${required_artifacts[@]}"; do
if printf '%s' "${item}" | grep -q '/$'; then
d="${item%/}"
[ ! -d "${d}" ] && missing_required+=("${item}")
else
[ ! -f "${item}" ] && missing_required+=("${item}")
fi
done
for f in "${optional_files[@]}"; do
if printf '%s' "${f}" | grep -q '/$'; then
d="${f%/}"
[ ! -d "${d}" ] && missing_optional+=("${f}")
else
[ ! -f "${f}" ] && missing_optional+=("${f}")
fi
done
for d in "${disallowed_dirs[@]}"; do
d_norm="${d%/}"
[ -d "${d_norm}" ] && missing_required+=("${d_norm}/ (disallowed)")
done
for f in "${disallowed_files[@]}"; do
[ -f "${f}" ] && missing_required+=("${f} (disallowed)")
done
git fetch origin --prune
dev_paths=()
dev_branches=()
while IFS= read -r b; do
name="${b#origin/}"
if [ "${name}" = 'dev' ]; then
dev_branches+=("${name}")
else
dev_paths+=("${name}")
fi
done < <(git branch -r --list 'origin/dev*' | sed 's/^ *//')
if [ "${#dev_paths[@]}" -eq 0 ] && [ "${#dev_branches[@]}" -eq 0 ]; then
missing_required+=("dev or dev/* branch")
fi
content_warnings=()
if [ -f 'CHANGELOG.md' ] && ! grep -Eq '^# Changelog' CHANGELOG.md; then
content_warnings+=("CHANGELOG.md missing '# Changelog' header")
fi
if [ -f 'CHANGELOG.md' ] && grep -Eq '^[# ]*Unreleased' CHANGELOG.md; then
content_warnings+=("CHANGELOG.md contains Unreleased section (review release readiness)")
fi
if [ -f 'LICENSE' ] && ! grep -qiE 'GNU GENERAL PUBLIC LICENSE|GPL' LICENSE; then
content_warnings+=("LICENSE does not look like a GPL text")
fi
if [ -f 'README.md' ] && ! grep -qiE 'moko|Moko' README.md; then
content_warnings+=("README.md missing expected brand keyword")
fi
export PROFILE_RAW="${profile}"
export MISSING_REQUIRED="$(printf '%s\n' "${missing_required[@]:-}")"
export MISSING_OPTIONAL="$(printf '%s\n' "${missing_optional[@]:-}")"
export CONTENT_WARNINGS="$(printf '%s\n' "${content_warnings[@]:-}")"
report_json=$(printf '{"profile":"%s","missing_required":%d,"missing_optional":%d,"content_warnings":%d}' "$profile" "${#missing_required[@]}" "${#missing_optional[@]}" "${#content_warnings[@]}")
{
printf '%s\n' '### Repository health'
printf '%s\n' "Profile: ${profile}"
printf '%s\n' '| Metric | Value |'
printf '%s\n' '|---|---|'
printf '%s\n' "| Missing required | ${#missing_required[@]} |"
printf '%s\n' "| Missing optional | ${#missing_optional[@]} |"
printf '%s\n' "| Content warnings | ${#content_warnings[@]} |"
printf '\n'
printf '%s\n' '### Guardrails report (JSON)'
printf '%s\n' '```json'
printf '%s\n' "${report_json}"
printf '%s\n' '```'
printf '\n'
} >> "${GITHUB_STEP_SUMMARY}"
if [ "${#missing_required[@]}" -gt 0 ]; then
{
printf '%s\n' '### Missing required repo artifacts'
for m in "${missing_required[@]}"; do printf '%s\n' "- ${m}"; done
printf '%s\n' 'ERROR: Guardrails failed. Missing required repository artifacts.'
printf '\n'
} >> "${GITHUB_STEP_SUMMARY}"
exit 1
fi
if [ "${#missing_optional[@]}" -gt 0 ]; then
{
printf '%s\n' '### Missing optional repo artifacts'
for m in "${missing_optional[@]}"; do printf '%s\n' "- ${m}"; done
printf '\n'
} >> "${GITHUB_STEP_SUMMARY}"
fi
if [ "${#content_warnings[@]}" -gt 0 ]; then
{
printf '%s\n' '### Repo content warnings'
for m in "${content_warnings[@]}"; do printf '%s\n' "- ${m}"; done
printf '\n'
} >> "${GITHUB_STEP_SUMMARY}"
fi
# -- Joomla-specific checks --
joomla_findings=()
MANIFEST="$(find . -maxdepth 2 -name '*.xml' -exec grep -l '<extension' {} \; 2>/dev/null | head -1 || true)"
if [ -z "${MANIFEST}" ]; then
joomla_findings+=("Joomla XML manifest not found (no *.xml with <extension> tag)")
else
if ! grep -qP '<version>' "${MANIFEST}"; then
joomla_findings+=("XML manifest: <version> tag missing")
fi
if ! grep -qP 'type="(component|module|plugin|library|package|template|language)"' "${MANIFEST}"; then
joomla_findings+=("XML manifest: type attribute missing or invalid")
fi
if ! grep -qP '<name>' "${MANIFEST}"; then
joomla_findings+=("XML manifest: <name> tag missing")
fi
if ! grep -qP '<author>' "${MANIFEST}"; then
joomla_findings+=("XML manifest: <author> tag missing")
fi
if ! grep -qP '<namespace' "${MANIFEST}"; then
joomla_findings+=("XML manifest: <namespace> missing (required for Joomla 5+)")
fi
fi
INI_COUNT="$(find . -name '*.ini' -type f 2>/dev/null | wc -l)"
if [ "${INI_COUNT}" -eq 0 ]; then
joomla_findings+=("No .ini language files found")
fi
if [ ! -f 'updates.xml' ]; then
joomla_findings+=("updates.xml missing in root (required for Joomla update server)")
fi
if [ -n "${SOURCE_DIR}" ]; then
INDEX_DIRS=("${SOURCE_DIR}" "${SOURCE_DIR}/admin" "${SOURCE_DIR}/site")
for dir in "${INDEX_DIRS[@]}"; do
if [ -d "${dir}" ] && [ ! -f "${dir}/index.html" ]; then
joomla_findings+=("${dir}/index.html missing (directory listing protection)")
fi
done
fi
if [ "${#joomla_findings[@]}" -gt 0 ]; then
{
printf '%s\n' '### Joomla extension checks'
printf '%s\n' '| Check | Status |'
printf '%s\n' '|---|---|'
for f in "${joomla_findings[@]}"; do
printf '%s\n' "| ${f} | Warning |"
done
printf '\n'
} >> "${GITHUB_STEP_SUMMARY}"
else
{
printf '%s\n' '### Joomla extension checks'
printf '%s\n' 'All Joomla-specific checks passed.'
printf '\n'
} >> "${GITHUB_STEP_SUMMARY}"
fi
extended_enabled="${EXTENDED_CHECKS:-true}"
extended_findings=()
if [ "${extended_enabled}" = 'true' ]; then
if [ -f '.github/CODEOWNERS' ] || [ -f 'CODEOWNERS' ] || [ -f 'docs/CODEOWNERS' ]; then
:
else
extended_findings+=("CODEOWNERS not found (.github/CODEOWNERS preferred)")
fi
if ls "${WORKFLOWS_DIR}"/*.yml >/dev/null 2>&1 || ls "${WORKFLOWS_DIR}"/*.yaml >/dev/null 2>&1; then
bad_refs="$(grep -RIn --include='*.yml' --include='*.yaml' -E '^[[:space:]]*uses:[[:space:]]*[^#]+@(main|master)\b' "${WORKFLOWS_DIR}" 2>/dev/null || true)"
if [ -n "${bad_refs}" ]; then
extended_findings+=("Workflows reference actions @main/@master (pin versions): see log excerpt")
{
printf '%s\n' '### Workflow pinning advisory'
printf '%s\n' 'Found uses: entries pinned to main/master:'
printf '%s\n' '```'
printf '%s\n' "${bad_refs}"
printf '%s\n' '```'
printf '\n'
} >> "${GITHUB_STEP_SUMMARY}"
fi
fi
if [ -f "${DOCS_INDEX}" ]; then
missing_links=""
while IFS= read -r docline; do
for link in $(echo "$docline" | grep -oE '\]\([^)]+\)' | sed 's/\](//' | sed 's/)$//' || true); do
case "$link" in http://*|https://*|"#"*|mailto:*) continue ;; esac
linkpath="${link%%#*}"
linkpath="${linkpath%%\?*}"
[ -z "$linkpath" ] && continue
if [ "${linkpath:0:1}" = "/" ]; then
testpath="${linkpath#/}"
else
testpath="$(dirname "${DOCS_INDEX}")/${linkpath}"
fi
[ ! -e "$testpath" ] && missing_links="${missing_links}${testpath} "
done
done < "${DOCS_INDEX}"
if [ -n "${missing_links}" ]; then
extended_findings+=("docs/docs-index.md contains broken relative links")
{
printf '%s\n' '### Docs index link integrity'
printf '%s\n' 'Broken relative links:'
for bl in ${missing_links}; do
printf '%s\n' "- ${bl}"
done
printf '\n'
} >> "${GITHUB_STEP_SUMMARY}"
fi
fi
if [ -d "${SCRIPT_DIR}" ]; then
if ! command -v shellcheck >/dev/null 2>&1; then
sudo apt-get update -qq
sudo apt-get install -y shellcheck >/dev/null
fi
sc_out=''
while IFS= read -r shf; do
[ -z "${shf}" ] && continue
out_one="$(shellcheck -S warning -x "${shf}" 2>/dev/null || true)"
if [ -n "${out_one}" ]; then
sc_out="${sc_out}${out_one}\n"
fi
done < <(find "${SCRIPT_DIR}" -type f -name "${SHELLCHECK_PATTERN}" 2>/dev/null | sort)
if [ -n "${sc_out}" ]; then
extended_findings+=("ShellCheck warnings detected (advisory)")
sc_head="$(printf '%s' "${sc_out}" | head -n 200)"
{
printf '%s\n' '### ShellCheck (advisory)'
printf '%s\n' '```'
printf '%s\n' "${sc_head}"
printf '%s\n' '```'
printf '\n'
} >> "${GITHUB_STEP_SUMMARY}"
fi
fi
spdx_missing=()
IFS=',' read -r -a spdx_globs <<< "${SPDX_FILE_GLOBS}"
spdx_args=()
for g in "${spdx_globs[@]}"; do spdx_args+=("${g}"); done
while IFS= read -r f; do
[ -z "${f}" ] && continue
if ! head -n 40 "${f}" | grep -q 'SPDX-License-Identifier:'; then
spdx_missing+=("${f}")
fi
done < <(git ls-files "${spdx_args[@]}" 2>/dev/null || true)
if [ "${#spdx_missing[@]}" -gt 0 ]; then
extended_findings+=("SPDX header missing in some tracked files (advisory)")
{
printf '%s\n' '### SPDX header advisory'
printf '%s\n' 'Files missing SPDX-License-Identifier (first 40 lines scan):'
for f in "${spdx_missing[@]}"; do printf '%s\n' "- ${f}"; done
printf '\n'
} >> "${GITHUB_STEP_SUMMARY}"
fi
stale_cutoff_days=180
stale_branches="$(git for-each-ref --format='%(refname:short) %(committerdate:unix)' refs/remotes/origin 2>/dev/null | awk -v now="$(date +%s)" -v days="${stale_cutoff_days}" '{if (now-$2 > days*86400) print $1}' | head -50)"
if [ -n "${stale_branches}" ]; then
extended_findings+=("Stale remote branches detected (advisory)")
{
printf '%s\n' '### Git hygiene advisory'
printf '%s\n' "Branches with last commit older than ${stale_cutoff_days} days (sample up to 50):"
while IFS= read -r b; do [ -n "${b}" ] && printf '%s\n' "- ${b}"; done <<< "${stale_branches}"
printf '\n'
} >> "${GITHUB_STEP_SUMMARY}"
fi
fi
{
printf '%s\n' '### Guardrails coverage matrix'
printf '%s\n' '| Domain | Status | Notes |'
printf '%s\n' '|---|---|---|'
printf '%s\n' '| Access control | OK | Admin-only execution gate |'
printf '%s\n' '| Release policy | N/A | Releases handled by MokoGIT |'
printf '%s\n' '| Scripts governance | OK | Directory policy and advisory reporting |'
printf '%s\n' '| Repo required artifacts | OK | Required, optional, disallowed enforcement |'
printf '%s\n' '| Repo content heuristics | OK | Brand, license, changelog structure |'
if [ "${extended_enabled}" = 'true' ]; then
if [ "${#extended_findings[@]}" -gt 0 ]; then
printf '%s\n' '| Extended checks | Warning | See extended findings below |'
else
printf '%s\n' '| Extended checks | OK | No findings |'
fi
else
printf '%s\n' '| Extended checks | SKIPPED | EXTENDED_CHECKS disabled |'
fi
printf '\n'
} >> "${GITHUB_STEP_SUMMARY}"
if [ "${extended_enabled}" = 'true' ] && [ "${#extended_findings[@]}" -gt 0 ]; then
{
printf '%s\n' '### Extended findings (advisory)'
for f in "${extended_findings[@]}"; do printf '%s\n' "- ${f}"; done
printf '\n'
} >> "${GITHUB_STEP_SUMMARY}"
fi
printf '%s\n' 'Repository health guardrails passed.' >> "${GITHUB_STEP_SUMMARY}"
site-health:
name: Site Health
runs-on: ubuntu-latest
if: github.event_name == 'workflow_dispatch'
steps:
- uses: actions/checkout@v4
- name: Setup PHP
uses: shivammathur/setup-php@v2
with:
php-version: '8.3'
- name: Uptime check
if: env.URLS != ''
run: |
echo "$URLS" > /tmp/urls.txt
php monitoring/uptime-probe.php --urls /tmp/urls.txt --timeout 15 || echo "::warning::Some sites are down"
rm -f /tmp/urls.txt
env:
URLS: ${{ vars.MONITORED_URLS }}
- name: SSL certificate check
if: env.DOMAINS != ''
run: |
echo "$DOMAINS" > /tmp/domains.txt
php monitoring/ssl-check.php --domains /tmp/domains.txt --warn-days 30 || echo "::warning::SSL certificates expiring soon"
rm -f /tmp/domains.txt
env:
DOMAINS: ${{ vars.MONITORED_DOMAINS }}
- name: Summary
if: always()
run: |
echo "### Site Health" >> $GITHUB_STEP_SUMMARY
echo "Uptime and SSL checks completed." >> $GITHUB_STEP_SUMMARY
# ═══════════════════════════════════════════════════════════════════════
# Issue Reporter — file issues for failed gates
# ═══════════════════════════════════════════════════════════════════════
report-scripts:
name: "Report: Scripts Governance"
needs: [access_check, scripts_governance]
if: >-
always() &&
needs.scripts_governance.result == 'failure'
uses: ./.mokogit/workflows/ci-issue-reporter.yml
with:
gate: "Scripts Governance"
workflow: "Repo Health"
severity: error
details: "Scripts directory policy violations detected. Review required and allowed directories."
secrets: inherit
report-health:
name: "Report: Repository Health"
needs: [access_check, repo_health]
if: >-
always() &&
needs.repo_health.result == 'failure'
uses: ./.mokogit/workflows/ci-issue-reporter.yml
with:
gate: "Repository Health"
workflow: "Repo Health"
severity: error
details: "Repository health checks failed — missing required artifacts, disallowed files, or content warnings. Check the CI run summary."
secrets: inherit
File diff suppressed because it is too large Load Diff
+130
View File
@@ -0,0 +1,130 @@
# Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
#
# SPDX-License-Identifier: GPL-3.0-or-later
#
# FILE INFORMATION
# DEFGROUP: MokoGIT.Workflow.Template
# INGROUP: MokoCLI.CI
# REPO: https://git.mokoconsulting.tech/MokoConsulting/Template-Joomla
# PATH: /.mokogit/workflows/version-set.yml
# VERSION: 01.00.00
# BRIEF: Set or reset the extension version across all version-bearing files
name: "Joomla: Set Version"
on:
workflow_dispatch:
inputs:
version:
description: "Version number (e.g. 01.00.00)"
required: true
type: string
branch:
description: "Branch to update (default: current)"
required: false
type: string
permissions:
contents: write
env:
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
jobs:
set-version:
name: Set Version to ${{ inputs.version }}
runs-on: ubuntu-latest
steps:
- name: Validate version format
run: |
VERSION="${{ inputs.version }}"
if ! echo "$VERSION" | grep -qP '^\d{2}\.\d{2}\.\d{2}$'; then
echo "::error::Invalid version format '${VERSION}' — expected XX.YY.ZZ (e.g. 01.00.00)"
exit 1
fi
echo "VERSION=${VERSION}" >> "$GITHUB_ENV"
- name: Checkout
uses: actions/checkout@v4
with:
token: ${{ secrets.MOKOGIT_TOKEN || github.token }}
ref: ${{ inputs.branch || github.ref }}
fetch-depth: 1
- name: Update manifest version
run: |
MANIFEST=""
for XML_FILE in $(find . -maxdepth 3 -name "*.xml" -not -path "./.git/*" -not -path "./vendor/*"); do
if grep -q "<extension" "$XML_FILE" 2>/dev/null; then
MANIFEST="$XML_FILE"
break
fi
done
if [ -z "$MANIFEST" ]; then
echo "::warning::No Joomla extension manifest found — skipping manifest update"
else
OLD_VER=$(grep -oP '<version>\K[^<]+' "$MANIFEST" | head -1)
sed -i "s|<version>${OLD_VER}</version>|<version>${VERSION}</version>|" "$MANIFEST"
echo "Manifest: ${OLD_VER} → ${VERSION} (${MANIFEST})"
fi
- name: Update README.md version
run: |
if [ -f "README.md" ]; then
if grep -qP '^\s*VERSION:\s*\d' README.md; then
sed -i -E "s/(VERSION:\s*)[0-9]{2}\.[0-9]{2}\.[0-9]{2}/\1${VERSION}/" README.md
echo "README.md version updated to ${VERSION}"
else
echo "::warning::No VERSION line found in README.md — skipping"
fi
fi
- name: Update CHANGELOG.md
run: |
if [ -f "CHANGELOG.md" ]; then
DATE=$(date +%Y-%m-%d)
# Check if this version already has an entry
if grep -q "^\#\# \[${VERSION}\]" CHANGELOG.md; then
echo "CHANGELOG.md already has entry for ${VERSION} — skipping"
else
# Insert new version entry after [Unreleased] or at the top after header
if grep -q '^\#\# \[Unreleased\]' CHANGELOG.md; then
sed -i "/^\#\# \[Unreleased\]/a\\\\n## [${VERSION}] --- ${DATE}" CHANGELOG.md
else
sed -i "/^\# Changelog/a\\\\n## [Unreleased]\n\n## [${VERSION}] --- ${DATE}" CHANGELOG.md
fi
echo "CHANGELOG.md: added entry for ${VERSION}"
fi
else
echo "::warning::No CHANGELOG.md found — skipping"
fi
- name: Update FILE INFORMATION blocks
run: |
# Update VERSION in file header blocks (# VERSION: XX.YY.ZZ)
find . -maxdepth 1 -type f \( -name "*.yml" -o -name "*.yaml" -o -name "*.php" -o -name "*.md" \) \
-not -path "./.git/*" -not -path "./vendor/*" -print0 2>/dev/null | \
while IFS= read -r -d '' FILE; do
if head -20 "$FILE" | grep -qP '^\s*#?\s*VERSION:\s*\d{2}\.\d{2}\.\d{2}'; then
sed -i -E "s/(#?\s*VERSION:\s*)[0-9]{2}\.[0-9]{2}\.[0-9]{2}/\1${VERSION}/" "$FILE"
echo "Updated FILE INFORMATION VERSION in ${FILE}"
fi
done
- name: Commit and push
run: |
git config user.name "Moko Consulting [bot]"
git config user.email "hello@mokoconsulting.tech"
git add -A
if git diff --cached --quiet; then
echo "No version changes detected — nothing to commit"
else
git commit -m "chore: set version to ${VERSION} [skip bump]
Authored-by: Moko Consulting"
git push
echo "### Version Set" >> $GITHUB_STEP_SUMMARY
echo "Version updated to \`${VERSION}\` on branch \`${GITHUB_REF_NAME}\`" >> $GITHUB_STEP_SUMMARY
fi
+21 -44
View File
@@ -1,57 +1,34 @@
# Changelog
## [Unreleased]
## [02.62.00] --- 2026-07-13
## [02.67.00] --- 2026-07-17
## [02.61.00] --- 2026-07-13
## [02.60.00] --- 2026-07-12
## [02.59.00] --- 2026-07-12
## [02.67.00] --- 2026-07-17
### Fixed
- Pre-update backup now fires for **every** extension update/uninstall, not just the first one in a 10-minute window. A coarse 600-second session throttle (armed after any pre-action backup — even after updating MokoSuiteBackup itself or a core Joomla update) was disabling the client-side interceptor **and** the server-side fallback for *all* extensions for 10 minutes, so distinct updates silently went un-backed-up (looked like "only the backup extension triggers it"). Replaced with **one backup per Update action**: a single batch/click still backs up once (deduped per-request), the client re-fire is skipped via a **one-shot** action-keyed flag (consumed once), and the core-update path uses its own dedicated key so it no longer suppresses extension backups.
- Pre-update backup on the **Joomla Update** page is now **seamless** (Akeeba-style): after the full-screen backup runs, the plugin auto-ticks Joomla's "I have taken a backup" checkbox and clicks Install, so the update continues automatically instead of stopping for a second manual click.
- Pre-update full-screen backup now actually fires on the **Joomla Update** page. The "Install the update" button posts to `option=com_joomlaupdate&layout=update` (Joomla's confirm/updating page) — carrying **only `layout=update`**, no `view`/`task` — so the previous match on `view=update`/`update.install` never triggered. The plugin now intercepts `layout=update` (as well as `view=update` and the legacy `update.install`) and returns the browser there flagged `is_backed_up=1`.
- Pre-update / pre-uninstall skip flag is now **time-bounded**. It records the moment it was armed and is honoured only within a short TTL (5 min), always consumed one-shot. Previously a bare boolean: if a user ran the full-screen pre-update backup then abandoned the flow (closed the tab before the update re-fired), the flag lingered for the whole session and could silently suppress a later, unrelated pre-action backup — i.e. a real update running with **no** backup. Now a stale flag is ignored and the server backs up as normal.
- Pre-update / pre-uninstall backup can no longer run **twice**. The one-shot "skip the synchronous server-side backup" flag is now armed **server-side** the moment the full-screen backup completes (in `AjaxController::step`, keyed by the `msb_action` it is fronting), instead of relying solely on the best-effort client `ajax.preupdateAck` POST (whose failure was silently swallowed in a `catch()`). If that ack ever fails to land, the re-fired extension update/uninstall no longer triggers a second backup behind the "update is running" overlay. The flag is armed **only** on genuine completion (`done`, no error), so a failed or cancelled backup still lets the server back up before the action — the failure mode stays "one extra backup", never "no backup".
- Component **Options** page showed the raw `COM_MOKOSUITEBACKUP_CONFIGURATION` constant instead of a translated title. Joomla's `com_config` derives the Options-page title (and the component name) from the extension *element* (`com_mokosuitebackup`), but the language files defined those keys only under the legacy `COM_MOKOJOOMBACKUP_` stem. Added the element-derived `COM_MOKOSUITEBACKUP` and `COM_MOKOSUITEBACKUP_CONFIGURATION` keys to all four language files (en-GB/en-US, `.ini` and `.sys.ini`), matching the already-migrated `COM_MOKOSUITEBACKUP_ACTION_*` ACL keys. The 400+ explicitly-referenced `COM_MOKOJOOMBACKUP_*` keys are unaffected and left as-is. (#234)
## [02.66.00] --- 2026-07-16
## [02.65.00] --- 2026-07-16
## [02.65.00] --- 2026-07-16
### Added
- **Snapshot transfer + master→slave injection (#237):** download a content snapshot as a portable **`.msbsnap`** file (zip of `manifest.json` + `snapshot.json` + a sha256 integrity check); **import** one on another site via a new Import button on the Snapshots page (materialises a local snapshot record you can then restore); and a **direct injection API**`POST /api/index.php/v1/mokosuitebackup/snapshot/inject` — so a **master** site can push a snapshot straight into a **slave**'s Web Services API. Two conflict modes ship now: **overwrite** (replace matching items by ID — master wins) and **create** (skip existing, add only new), selectable per inject call or defaulted in **Options → Snapshot Transfer**. A receiving site must opt in via the new *Allow Snapshot Injection* setting. A third **duplicate** mode inserts everything as brand-new records with **remapped IDs** (articles, categories, modules — plus their tags, custom-field values and featured flags) using Joomla's Table API so assets/UCM/nested-sets stay valid; each item is inserted defensively (a bad item is skipped and logged, never fatal).
- Full-screen backup now also fronts **extension updates and uninstalls** (Extensions → Update / Manage), not just core Joomla updates. Because `com_installer`'s `update.update` / `manage.remove` are POST actions (CSRF token + a checked `cid[]` list) that a server-side redirect can't cleanly resume, this is done client-side: the toolbar Update/Uninstall click is intercepted, the selection is captured, the full-screen backup runs, and on return the original selection is restored and the real POST form is re-submitted. Gated by `backup_before_update` / `backup_before_uninstall`, super-user only, and deduped to one backup per Update action.
- Manual **"Backup Now"** completion now offers a **View backup record** button (links straight to the record that was just created). It appears only for manual backups — the pre-update / pre-uninstall flow hands control back to Joomla instead.
- **Full-screen backup screen** (`view=runbackup`) for both pre-update and manual backups, modelled on Akeeba's Backup-on-Update — replaces the earlier popup-modal approach. Clicking Joomla's **Install the update** now redirects (server-side, Akeeba-style) to a dedicated full-page backup screen that runs the stepped backup with a real progress bar and then **automatically continues the update** via a validated `returnurl` (flagged `is_backed_up=1` so the backup isn't repeated). Crucially, **no backup runs synchronously inside the update request** — which is what white-screened large sites. The dashboard **Backup Now** now opens the same full-screen screen instead of an inline modal. (#196)
- Retention now prunes **remote** copies too: when a backup is pruned by age/count, its archive is deleted from every enabled remote destination (SFTP / FTP / S3 / Google Drive), not just the local copy. Each uploader gained an idempotent `delete()` method (already-absent file = success), and removal is best-effort — a failing destination is logged but never blocks local pruning. The shared standalone `restore.php` is intentionally left in place (every backup overwrites it, so newer backups still depend on it). (#229)
### Changed
- Consolidated backup plumbing into shared helpers (#230):
- New `RemoteUploaderFactory` replaces the `createUploaderFromParams()` copy that was duplicated in `BackupEngine` and `SteppedBackupEngine`.
- `RetentionManager` is now the single retention authority — it takes the global `max_age_days`/`max_backups` fallback and gained `pruneOrphans()`; the system plugin's hourly cleanup delegates to it and its duplicate `deleteBackupRecord()` logic is removed.
- The backend controller, Web Services API controller, and legacy `cli/mokosuitebackup.php` now run backups through the shared `BackupRunner` (gaining the normalized complete/warning/fail status) instead of instantiating `BackupEngine` directly.
- Backup- and restore-progress modals (backups view) now have the same controls as the full-screen runbackup screen: an "automatically return to the list when finished" checkbox, a Cancel button (double-confirmed) while running, and terminal action buttons. Backup: View backup record / Back to backups on success, Retry / Back on failure. Restore matches, with a stronger cancel warning (a restore cannot be rolled back mid-flight) and no record link.
### Fixed
- Package installer is now **honest about success**: the postflight no longer prints "installed successfully" / next-steps when the install actually failed or only partially completed. Joomla logs a failed child extension but still runs the package postflight, so the postflight now (a) verifies every bundled child declared in the manifest actually registered in `#__extensions` (matched by element + type, and folder/group for plugins) and (b) verifies the component's schema tables — derived dynamically from the installed `install.mysql.sql` — actually exist. If anything is missing it enqueues an error listing what's missing and stops before the success message. Fail-open: any manifest/DB/IO glitch is treated as "nothing missing" so a transient error never turns a good install into a false failure. Unconditional housekeeping (e.g. download-key restore) still runs regardless.
- Pre-update full-screen backup screen now actually triggers on **Joomla 6** (and 4/5). The redirect matched only the legacy `update.install` task, which Joomla 4/5/6 don't use — they server-side-redirect to the **updating page `view=update`**, which then extracts the downloaded package from JavaScript. The plugin now intercepts the `view=update` page **load** (the last point before any files change) and returns the browser there flagged `is_backed_up=1` so the extraction proceeds after the backup. (`update.finalise` is intentionally not intercepted — by then the files are already extracted.)
- Pre-update/uninstall backup no longer **white-screens** the update on large sites. The synchronous backup that runs inside the extension update/uninstall request now raises `max_execution_time`/`memory_limit` (and `ignore_user_abort`) like the web-cron path, so it can't exhaust the request's default limits mid-backup. (Core Joomla updates additionally get a full-screen backup screen — see below.)
- Archive names for **CLI/console-triggered backups** no longer come out as `joomla.invalid_…`. The `[HOST]` placeholder took `$_SERVER['HTTP_HOST']` verbatim, but Joomla's console fills that with the reserved sentinel host `joomla.invalid`; the resolver now treats that (like empty/`localhost`) as unusable and falls back to the configured `live_site` host, then the system hostname. (Set the site's *live_site* to get the exact domain in CLI-built names.)
- Standalone restore script generation no longer aborts backups with `str_replace() expects at least 3 arguments, 2 given`. `MokoRestore::generateStandaloneScript()` had a `str_replace()` call (the "Backup Archive" pre-check rewrite) that was missing its `$php` subject argument, so **every** standalone-mode backup fatally errored while "Generating standalone restore.php…" — the archive still finalized and uploaded, but no `restore.php` was ever produced (the true root cause behind #226). (#226)
- Remote upload: the standalone restore script upload is no longer silent — its result is now checked and logged, and a failed restore-script upload marks the backup as `warning` (previously the result was discarded, so a missing restore script on the remote went unreported while the archive still showed success). (#226)
## [02.58.00] --- 2026-07-06
### Added
- SFTP destinations: **upload an SSH private key file** in the Add/Edit Destination modal instead of pasting it (reads the file into the key field client-side; pasting still works).
- ntfy default server URL now points to `ntfy.mokoconsulting.tech` (was the public `ntfy.sh`) in the profile form default/hint, `NotificationSender` fallbacks, the `ntfy_server` column default, and the field description. (#166)
- Non-stepped restore (`RestoreEngine`) now surfaces database-import failures instead of always reporting a clean success: it checks `DatabaseImporter::hasErrors()`, logs the failed statements, and returns a `warning` with a "database may be incomplete" message. `DatabaseImporter`'s statement splitter is also now quote-aware (a `;`/newline inside a value no longer splits a statement) with comments only skipped between statements. (#188)
- Restore Cancel no longer orphans server-side state. The stepped-restore modal's Cancel button now calls a new `ajax.restoreCancel` action that deletes the restore staging directory and session/state files, and `SteppedSession::cleanupOldSessions()` gained a backstop that reaps orphaned `mokosuitebackup-restore-*` staging dirs (for restores abandoned by closing the browser). Previously a cancelled/abandoned restore left the extracted staging dir behind indefinitely.
- Stepped restore no longer reports a partial database import as a clean "Restore complete". `SteppedRestoreEngine::stepDatabase()` now counts failed statements and surfaces the total (the completion message warns and the modal keeps the result on-screen instead of auto-returning). The statement splitter is also quote-aware — a `;` or newline inside a string value no longer splits a statement mid-value — and comment lines are only skipped between statements. (Sibling of #188 for the stepped/standalone paths; see #291.)
- Database view "One Problem" on MokoSuiteBackup: reconciled the `#__mokosuitebackup_profiles` schema-checker output with the update-file history. Corrected the stale `TINYINT` definition in `01.01.01.sql` to the final `VARCHAR(20)` type, fixed the `MODIFY COLUMN` parser trip-up in `01.39.00.sql`, and rewrote the retired `sftp_*` `ADD COLUMN` statements in `01.35.00.sql`/`01.36.00.sql` as bare `ADD` so Joomla's checker no longer demands columns that `02.52.25` and the postflight purge deliberately remove. Joomla's schema checker now reports no problems.
### Changed
- Add/Edit Destination modal: wrap the content in a Bootstrap `.container-fluid` and give it a 1rem padding frame via the WAM stylesheet (Bootstrap's `.modal-content` has no padding of its own).
- Component now shows the "Type - Name" pretty name `Component - MokoSuiteBackup` in the Extensions manager, consistent with the other sub-extensions. Achieved safely with an `<element>com_mokosuitebackup</element>` tag in the manifest (Joomla reads `<element>` before `<name>` for the extension element), so the element stays `com_mokosuitebackup` and no duplicate/orphaned component is created.
- Backup/restore progress modals in the backups view now use the same full-screen black, big-text, animated Atum-coloured progress-bar theme as the `runbackup` screen, so all backup progress surfaces match. New `media/css/backup-progress.css` scopes the black to the modal (the list page is unaffected until a modal opens).
- Standalone MokoRestore wizard (generated `restore.php`) re-branded to the full-black MokoSuite theme — black background, big header, dark panels/forms, and MokoSuite accent colours — so the restore experience matches the rest of the system instead of the old light blue "Joomla-styled" look.
### Fixed
- Pre-update backup now shows an admin notification on **every** outcome (success / warning / failure) — previously a *successful* pre-update backup fired nothing, so it looked like the notification wasn't working. (#192)
### Changed
- Component admin CSS now loads via the Joomla **Web Asset Manager** (`media/com_mokosuitebackup/` + `joomla.asset.json`) instead of an inline `<style>` block.
- "Keep local copy" is configured **per remote destination** (in the Add/Edit Destination modal); the redundant profile-level field and its "Remote" tab are removed.
- Add/Edit Destination modal: cleaned up the cramped padding/margins.
### Fixed
- Admin menu / duplicate-component fallout: (a) the orphaned `com_component-mokosuitebackup` is now removed in **preflight** too, so its "Backup" menu no longer collides on install ("The alias backup is already being used"); (b) `ensureSubmenuItems()` **recreates the top-level "Backup" menu** if it was deleted, instead of giving up — so Backup no longer disappears from the admin menu (and the cPanel module / MokoSuiteClient can find it again); (c) the component menu label now uses the short constant `COM_MOKOJOOMBACKUP_SHORT`. (#213 fallout)
- Installer no longer aborts on MySQL 8 with "This command is not supported in the prepared statement protocol yet" (error 1295). The legacy remote-column purge migration (`02.56.01.sql`) used `PREPARE`/`EXECUTE`/`DEALLOCATE`, which Joomla's installer rejects; the drop now runs in the package installer script via an INFORMATION_SCHEMA-gated plain `ALTER` (portable across MariaDB and MySQL 8). (#213 update path)
## [02.64.00] --- 2026-07-14
+1 -1
View File
@@ -4,7 +4,7 @@ Thank you for your interest in contributing to MokoSuiteBackup.
## Getting Started
1. Fork the repository on MokoGitea
1. Fork the repository on MokoGIT
2. Create a feature branch from `dev` (`feature/your-feature`)
3. Make your changes following the coding standards below
4. Submit a pull request targeting `dev`
+1 -1
View File
@@ -23,7 +23,7 @@ DEFGROUP: Template-Joomla
INGROUP: Template-Joomla.Documentation
REPO: https://git.mokoconsulting.tech/MokoConsulting/Template-Joomla
PATH: /SECURITY.md
VERSION: 02.62.00
VERSION: 02.67.00
BRIEF: Security vulnerability reporting and handling policy
-->
@@ -162,6 +162,17 @@
<option value="1">JYES</option>
<option value="0">JNO</option>
</field>
<field
name="lock_admin_during_backup"
type="radio"
label="COM_MOKOJOOMBACKUP_CONFIG_LOCK_ADMIN"
description="COM_MOKOJOOMBACKUP_CONFIG_LOCK_ADMIN_DESC"
default="1"
class="btn-group"
>
<option value="1">JYES</option>
<option value="0">JNO</option>
</field>
</fieldset>
<fieldset name="cleanup" label="COM_MOKOJOOMBACKUP_CONFIG_CLEANUP">
@@ -292,8 +292,8 @@
label="COM_MOKOJOOMBACKUP_FIELD_NTFY_SERVER"
description="COM_MOKOJOOMBACKUP_FIELD_NTFY_SERVER_DESC"
maxlength="512"
default="https://ntfy.sh"
hint="https://ntfy.sh"
default="https://ntfy.mokoconsulting.tech"
hint="https://ntfy.mokoconsulting.tech"
/>
<field
name="ntfy_token"
@@ -7,6 +7,13 @@
COM_MOKOJOOMBACKUP="MokoSuiteBackup"
COM_MOKOJOOMBACKUP_SHORT="Backup"
COM_MOKOJOOMBACKUP_CONFIGURATION="MokoSuiteBackup Options"
; Framework-derived keys: Joomla builds the component name and the Options-page
; title from the element (com_mokosuitebackup), NOT from the legacy
; COM_MOKOJOOMBACKUP_ stem, so they must exist under the new stem too (mirrors
; the COM_MOKOSUITEBACKUP_ACTION_* ACL keys). Without these the Options screen
; shows the raw COM_MOKOSUITEBACKUP_CONFIGURATION constant. See #234.
COM_MOKOSUITEBACKUP="MokoSuiteBackup"
COM_MOKOSUITEBACKUP_CONFIGURATION="MokoSuiteBackup Options"
COM_MOKOJOOMBACKUP_DESCRIPTION="Full-site backup and restore for Joomla"
; Submenu
@@ -259,7 +266,7 @@ COM_MOKOJOOMBACKUP_FIELD_NTFY_SPACER_DESC="<strong>Push Notifications (ntfy)</st
COM_MOKOJOOMBACKUP_FIELD_NTFY_TOPIC="ntfy Topic"
COM_MOKOJOOMBACKUP_FIELD_NTFY_TOPIC_DESC="The ntfy topic to publish notifications to. Leave blank to disable push notifications."
COM_MOKOJOOMBACKUP_FIELD_NTFY_SERVER="ntfy Server"
COM_MOKOJOOMBACKUP_FIELD_NTFY_SERVER_DESC="URL of the ntfy server. Default is the public ntfy.sh service. Use your own server URL for self-hosted instances."
COM_MOKOJOOMBACKUP_FIELD_NTFY_SERVER_DESC="URL of the ntfy server. Default is the Moko Consulting ntfy server (ntfy.mokoconsulting.tech). Use your own server URL for self-hosted instances, or https://ntfy.sh for the public service."
COM_MOKOJOOMBACKUP_FIELD_NTFY_TOKEN="Access Token"
COM_MOKOJOOMBACKUP_FIELD_NTFY_TOKEN_DESC="Optional access token for private ntfy topics. Leave blank for public topics."
@@ -335,6 +342,8 @@ COM_MOKOJOOMBACKUP_CONFIG_PREUPDATE_AUTO_INTERCEPT="Auto-run on Update click"
COM_MOKOJOOMBACKUP_CONFIG_PREUPDATE_AUTO_INTERCEPT_DESC="When enabled, clicking the Update button on the Joomla Update or Extensions update page first pops the backup progress modal, runs the pre-update backup, then automatically continues the update. When disabled, a notice with a manual 'Back up now' button is shown instead."
COM_MOKOJOOMBACKUP_CONFIG_BACKUP_BEFORE_UNINSTALL="Backup Before Extension Uninstall"
COM_MOKOJOOMBACKUP_CONFIG_BACKUP_BEFORE_UNINSTALL_DESC="Automatically run a full backup before any extension is uninstalled. Uses the default profile. Throttled to once per 10 minutes."
COM_MOKOJOOMBACKUP_CONFIG_LOCK_ADMIN="Lock admin during backup"
COM_MOKOJOOMBACKUP_CONFIG_LOCK_ADMIN_DESC="While a backup is actively running, show a \"backup in progress\" screen on all other admin pages until it finishes, so nothing changes mid-backup. The backup screen itself and the login page stay accessible. Auto-releases if a backup stops responding."
; Full-screen backup progress view (runbackup)
COM_MOKOJOOMBACKUP_RUNBACKUP_TITLE="Backing Up"
@@ -350,6 +359,13 @@ COM_MOKOJOOMBACKUP_RUNBACKUP_BACK_TO_DASHBOARD="Back to dashboard"
COM_MOKOJOOMBACKUP_RUNBACKUP_VIEW_RECORD="View backup record"
COM_MOKOJOOMBACKUP_RUNBACKUP_LEAVE_WARNING="A backup is in progress. Leaving now will cancel it."
COM_MOKOJOOMBACKUP_RUNBACKUP_AUTOCONTINUE="Automatically continue the update when the backup finishes"
COM_MOKOJOOMBACKUP_RUNBACKUP_DONT_CLOSE="Please do not close this window or switch to another window until the backup finishes."
COM_MOKOJOOMBACKUP_RUNBACKUP_UPDATE_RUNNING="Backup complete. The update is now running — please wait and do not close this window…"
COM_MOKOJOOMBACKUP_RUNBACKUP_CANCEL="Cancel backup"
COM_MOKOJOOMBACKUP_RUNBACKUP_CANCEL_CONFIRM1="Cancel the backup that is in progress?"
COM_MOKOJOOMBACKUP_RUNBACKUP_CANCEL_CONFIRM2="Are you sure? The backup will be stopped and the update will NOT continue."
COM_MOKOJOOMBACKUP_RUNBACKUP_CANCELLING="Cancelling…"
COM_MOKOJOOMBACKUP_RUNBACKUP_FAILED_NO_UPDATE="Backup failed — the update was not started. Retry the backup, or go back without updating."
COM_MOKOJOOMBACKUP_RUNBACKUP_CONTINUE_UPDATE="Continue the update"
COM_MOKOJOOMBACKUP_CONFIG_CLEANUP="Cleanup Defaults"
@@ -7,6 +7,9 @@
COM_MOKOJOOMBACKUP="MokoSuiteBackup"
COM_MOKOJOOMBACKUP_SHORT="Backup"
COM_MOKOJOOMBACKUP_DESCRIPTION="Full-site backup and restore for Joomla — database, files, and configuration."
; Element-derived aliases (see the .ini file for the rationale). #234
COM_MOKOSUITEBACKUP="MokoSuiteBackup"
COM_MOKOSUITEBACKUP_CONFIGURATION="MokoSuiteBackup Options"
COM_MOKOJOOMBACKUP_SUBMENU_DASHBOARD="Dashboard"
COM_MOKOJOOMBACKUP_SUBMENU_BACKUPS="Backup Records"
COM_MOKOJOOMBACKUP_SUBMENU_PROFILES="Backup Profiles"
@@ -7,6 +7,13 @@
COM_MOKOJOOMBACKUP="MokoSuiteBackup"
COM_MOKOJOOMBACKUP_SHORT="Backup"
COM_MOKOJOOMBACKUP_DESCRIPTION="Full-site backup and restore for Joomla"
; Framework-derived keys: Joomla builds the component name and the Options-page
; title from the element (com_mokosuitebackup), NOT from the legacy
; COM_MOKOJOOMBACKUP_ stem, so they must exist under the new stem too (mirrors
; the COM_MOKOSUITEBACKUP_ACTION_* ACL keys). Without these the Options screen
; shows the raw COM_MOKOSUITEBACKUP_CONFIGURATION constant. See #234.
COM_MOKOSUITEBACKUP="MokoSuiteBackup"
COM_MOKOSUITEBACKUP_CONFIGURATION="MokoSuiteBackup Options"
COM_MOKOJOOMBACKUP_SUBMENU_DASHBOARD="Dashboard"
COM_MOKOJOOMBACKUP_SUBMENU_BACKUPS="Backup Records"
COM_MOKOJOOMBACKUP_SUBMENU_PROFILES="Backup Profiles"
@@ -57,6 +64,8 @@ COM_MOKOJOOMBACKUP_CONFIG_PREUPDATE_AUTO_INTERCEPT="Auto-run on Update click"
COM_MOKOJOOMBACKUP_CONFIG_PREUPDATE_AUTO_INTERCEPT_DESC="When enabled, clicking the Update button on the Joomla Update or Extensions update page first pops the backup progress modal, runs the pre-update backup, then automatically continues the update. When disabled, a notice with a manual 'Back up now' button is shown instead."
COM_MOKOJOOMBACKUP_CONFIG_BACKUP_BEFORE_UNINSTALL="Backup Before Extension Uninstall"
COM_MOKOJOOMBACKUP_CONFIG_BACKUP_BEFORE_UNINSTALL_DESC="Automatically run a full backup before any extension is uninstalled. Uses the default profile. Throttled to once per 10 minutes."
COM_MOKOJOOMBACKUP_CONFIG_LOCK_ADMIN="Lock admin during backup"
COM_MOKOJOOMBACKUP_CONFIG_LOCK_ADMIN_DESC="While a backup is actively running, show a \"backup in progress\" screen on all other admin pages until it finishes, so nothing changes mid-backup. The backup screen itself and the login page stay accessible. Auto-releases if a backup stops responding."
; Full-screen backup progress view (runbackup)
COM_MOKOJOOMBACKUP_RUNBACKUP_TITLE="Backing Up"
@@ -72,6 +81,13 @@ COM_MOKOJOOMBACKUP_RUNBACKUP_BACK_TO_DASHBOARD="Back to dashboard"
COM_MOKOJOOMBACKUP_RUNBACKUP_VIEW_RECORD="View backup record"
COM_MOKOJOOMBACKUP_RUNBACKUP_LEAVE_WARNING="A backup is in progress. Leaving now will cancel it."
COM_MOKOJOOMBACKUP_RUNBACKUP_AUTOCONTINUE="Automatically continue the update when the backup finishes"
COM_MOKOJOOMBACKUP_RUNBACKUP_DONT_CLOSE="Please do not close this window or switch to another window until the backup finishes."
COM_MOKOJOOMBACKUP_RUNBACKUP_UPDATE_RUNNING="Backup complete. The update is now running — please wait and do not close this window…"
COM_MOKOJOOMBACKUP_RUNBACKUP_CANCEL="Cancel backup"
COM_MOKOJOOMBACKUP_RUNBACKUP_CANCEL_CONFIRM1="Cancel the backup that is in progress?"
COM_MOKOJOOMBACKUP_RUNBACKUP_CANCEL_CONFIRM2="Are you sure? The backup will be stopped and the update will NOT continue."
COM_MOKOJOOMBACKUP_RUNBACKUP_CANCELLING="Cancelling…"
COM_MOKOJOOMBACKUP_RUNBACKUP_FAILED_NO_UPDATE="Backup failed — the update was not started. Retry the backup, or go back without updating."
COM_MOKOJOOMBACKUP_RUNBACKUP_CONTINUE_UPDATE="Continue the update"
COM_MOKOJOOMBACKUP_CONFIG_CLEANUP="Cleanup Defaults"
@@ -6,6 +6,9 @@
COM_MOKOJOOMBACKUP="MokoSuiteBackup"
COM_MOKOJOOMBACKUP_SHORT="Backup"
; Element-derived aliases (see the .ini file for the rationale). #234
COM_MOKOSUITEBACKUP="MokoSuiteBackup"
COM_MOKOSUITEBACKUP_CONFIGURATION="MokoSuiteBackup Options"
COM_MOKOJOOMBACKUP_DESCRIPTION="Full-site backup and restore for Joomla — database, files, and configuration."
COM_MOKOJOOMBACKUP_SUBMENU_DASHBOARD="Dashboard"
COM_MOKOJOOMBACKUP_SUBMENU_BACKUPS="Backup Records"
@@ -0,0 +1,173 @@
/*
* @package MokoSuiteBackup
* @subpackage com_mokosuitebackup
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
* @license GNU General Public License version 3 or later; see LICENSE
*
* Full-screen backup/restore progress modals (backups view). Gives the stepped
* backup and restore modals the same full-black, big-text, Atum-coloured
* progress-bar treatment as the runbackup screen (media/css/runbackup.css), so
* all backup progress surfaces share one look. The black is scoped to the modal
* (via .msb-progress-modal) rather than html/body, so the list page behind it is
* unaffected until the modal opens.
*/
/* Accent colours pulled from the Atum admin template's own custom properties,
with Bootstrap and then a literal as progressive fallbacks — identical to the
runbackup screen so the two match whatever theme the site runs. */
.msb-progress-modal {
--msb-accent: var(--template-link-color, var(--bs-link-color, #2a69b8));
--msb-accent2: var(--template-special-color, var(--msb-accent));
--msb-success: var(--template-success-color, var(--bs-success, #2f7d32));
--msb-danger: var(--template-danger-color, var(--bs-danger, #c52128));
--msb-track: var(--template-bg-light, var(--bs-gray-200, #edf0f5));
}
/* Full-screen black takeover, mirroring the runbackup page. */
.msb-progress-modal .modal-dialog {
max-width: 100%;
width: 100%;
min-height: 100%;
margin: 0;
}
.msb-progress-modal .modal-content {
min-height: 100vh;
border: 0;
border-radius: 0;
background-color: #000;
color: #fff;
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
padding: 1.5rem;
}
.msb-progress-modal .modal-header {
border: 0;
padding: 0 0 1.5rem;
justify-content: center;
text-align: center;
}
/* Big heading, like the runbackup title. */
.msb-progress-modal .modal-title {
color: #fff;
font-size: clamp(1.75rem, 4vw, 2.75rem);
font-weight: 700;
letter-spacing: .01em;
}
.msb-progress-modal .modal-body {
width: 100%;
max-width: 680px;
padding: 0;
text-align: center;
}
.msb-progress-modal .msb-pm-status {
color: #fff;
font-size: 1.25rem;
margin: 1rem 0 .25rem;
}
.msb-progress-modal .msb-pm-phase {
color: rgba(255, 255, 255, .6);
font-size: .95rem;
margin: 0;
}
/* Auto-continue opt-out, matching the runbackup screen's checkbox. */
.msb-progress-modal .msb-pm-autocontinue {
margin-top: 1.5rem;
color: rgba(255, 255, 255, .85);
font-size: 1rem;
}
.msb-progress-modal .msb-pm-autocontinue label {
display: inline-flex;
align-items: center;
gap: .5rem;
cursor: pointer;
}
.msb-progress-modal .msb-pm-autocontinue input {
width: 1.1rem;
height: 1.1rem;
}
/* Terminal-state action buttons and the running-state cancel button. */
.msb-progress-modal .msb-pm-actions,
.msb-progress-modal .msb-pm-cancel {
margin-top: 1.5rem;
display: flex;
gap: .75rem;
justify-content: center;
flex-wrap: wrap;
}
.msb-progress-modal .msb-pm-actions .btn,
.msb-progress-modal .msb-pm-cancel .btn {
min-width: 10rem;
}
.msb-progress-modal .msb-pm-warn {
color: #ffd23f;
font-weight: 600;
font-size: 1rem;
margin-bottom: 1.25rem;
}
/* Progress bar — mirrors runbackup.css .msb-rb-progress/.msb-rb-bar. */
.msb-progress-modal .msb-rb-progress {
height: 2.75rem;
background: var(--msb-track);
border-radius: .35rem;
overflow: hidden;
}
.msb-progress-modal .msb-rb-bar {
position: relative;
height: 100%;
width: 0;
display: flex;
align-items: center;
justify-content: center;
color: #fff;
font-weight: 600;
font-size: 1rem;
white-space: nowrap;
background-color: var(--msb-accent);
background-image: linear-gradient(90deg, var(--msb-accent), var(--msb-accent2));
transition: width .35s ease;
}
.msb-progress-modal .msb-rb-bar.is-animated::after {
content: "";
position: absolute;
inset: 0;
background-image: linear-gradient(45deg, rgba(255, 255, 255, .18) 25%, transparent 25%, transparent 50%, rgba(255, 255, 255, .18) 50%, rgba(255, 255, 255, .18) 75%, transparent 75%, transparent);
background-size: 1rem 1rem;
animation: msb-pm-stripes 1s linear infinite;
}
.msb-progress-modal .msb-rb-bar.is-success {
background-image: none;
background-color: var(--msb-success);
}
.msb-progress-modal .msb-rb-bar.is-danger {
background-image: none;
background-color: var(--msb-danger);
}
@keyframes msb-pm-stripes {
from {
background-position: 1rem 0;
}
to {
background-position: 0 0;
}
}
@@ -14,6 +14,11 @@
"name": "com_mokosuitebackup.runbackup",
"type": "style",
"uri": "com_mokosuitebackup/css/runbackup.css"
},
{
"name": "com_mokosuitebackup.backup-progress",
"type": "style",
"uri": "com_mokosuitebackup/css/backup-progress.css"
}
]
}
@@ -7,17 +7,18 @@
-->
<extension type="component" method="upgrade">
<!--
IMPORTANT: a component's <name> drives the Joomla-derived extension
element (com_ + sanitized name), so it MUST stay element-safe:
"MokoSuiteBackup" -> com_mokosuitebackup. Do NOT apply the "Type - Name"
display convention here — "Component - MokoSuiteBackup" derives
com_component-mokosuitebackup, which registers a duplicate component and
orphans the real one. Packages are exempt because they set the element
via <packagename>; plugins/modules are exempt because <name> is only a
display label there.
The <element> tag PINS this extension's element to com_mokosuitebackup.
Joomla's InstallerAdapter::getElement() reads <element> BEFORE falling
back to <name> (libraries/src/Installer/InstallerAdapter.php), so the
display <name> can safely use the "Type - Name" convention without the
element being derived as com_component-mokosuitebackup (which would
register a duplicate component and orphan the real one). Without the
<element> tag, "Component - MokoSuiteBackup" would break the install.
NB: the manifest filename must stay mokosuitebackup.xml (short element).
-->
<name>MokoSuiteBackup</name>
<version>02.62.00</version>
<element>com_mokosuitebackup</element>
<name>Component - MokoSuiteBackup</name>
<version>02.67.00</version>
<creationDate>2026-06-02</creationDate>
<author>Moko Consulting</author>
<authorEmail>hello@mokoconsulting.tech</authorEmail>
@@ -26,7 +26,7 @@ CREATE TABLE IF NOT EXISTS `#__mokosuitebackup_profiles` (
`retention_days` INT(11) NOT NULL DEFAULT 0 COMMENT 'Delete backups older than N days; 0 = unlimited',
`retention_count` INT(11) NOT NULL DEFAULT 0 COMMENT 'Keep newest N backups; 0 = unlimited',
`ntfy_topic` VARCHAR(255) NOT NULL DEFAULT '' COMMENT 'ntfy topic name',
`ntfy_server` VARCHAR(512) NOT NULL DEFAULT 'https://ntfy.sh' COMMENT 'ntfy server URL',
`ntfy_server` VARCHAR(512) NOT NULL DEFAULT 'https://ntfy.mokoconsulting.tech' COMMENT 'ntfy server URL',
`ntfy_token` VARCHAR(255) NOT NULL DEFAULT '' COMMENT 'ntfy access token (optional)',
`published` TINYINT(1) NOT NULL DEFAULT 1,
`created` DATETIME NOT NULL DEFAULT '0000-00-00 00:00:00',
@@ -1 +1,5 @@
ALTER TABLE `#__mokosuitebackup_profiles` CHANGE `include_kickstart` `include_mokorestore` TINYINT(1) NOT NULL DEFAULT 0 COMMENT 'Include MokoRestore standalone restore script in archive';
-- Renames include_kickstart -> include_mokorestore. Declared VARCHAR(20) here to
-- match the final column type (widened from TINYINT in 01.39.00). Joomla's schema
-- checker does no cross-file supersession, so an earlier TINYINT definition would
-- be flagged forever against the now-VARCHAR column.
ALTER TABLE `#__mokosuitebackup_profiles` CHANGE `include_kickstart` `include_mokorestore` VARCHAR(20) NOT NULL DEFAULT '0' COMMENT 'Include MokoRestore standalone restore script in archive';
@@ -1,10 +1,16 @@
-- MokoSuiteBackup 01.35.00 — SFTP support with key file storage
--
-- These columns are legacy: 01.41.00 migrates them into #__mokosuitebackup_remotes
-- and 02.52.25 + the postflight dropLegacyRemoteColumns() purge them, so they do not
-- exist at rest. Written as bare `ADD` (not `ADD COLUMN`) on purpose: MySQL treats
-- the two identically, but Joomla's schema checker only builds an ADD-COLUMN check
-- for the `ADD COLUMN` form. Bare `ADD` is skipped by the checker, so it no longer
-- reports these retired columns as "missing" while the upgrade still adds them.
ALTER TABLE `#__mokosuitebackup_profiles`
ADD COLUMN `sftp_host` VARCHAR(255) NOT NULL DEFAULT '' AFTER `ftp_ssl`,
ADD COLUMN `sftp_port` INT(5) UNSIGNED NOT NULL DEFAULT 22 AFTER `sftp_host`,
ADD COLUMN `sftp_username` VARCHAR(255) NOT NULL DEFAULT '' AFTER `sftp_port`,
ADD COLUMN `sftp_password` VARCHAR(255) NOT NULL DEFAULT '' AFTER `sftp_username`,
ADD COLUMN `sftp_key_data` MEDIUMTEXT AFTER `sftp_password`,
ADD COLUMN `sftp_passphrase` VARCHAR(255) NOT NULL DEFAULT '' AFTER `sftp_key_data`,
ADD COLUMN `sftp_path` VARCHAR(512) NOT NULL DEFAULT '/backups' AFTER `sftp_passphrase`;
ADD `sftp_host` VARCHAR(255) NOT NULL DEFAULT '' AFTER `ftp_ssl`,
ADD `sftp_port` INT(5) UNSIGNED NOT NULL DEFAULT 22 AFTER `sftp_host`,
ADD `sftp_username` VARCHAR(255) NOT NULL DEFAULT '' AFTER `sftp_port`,
ADD `sftp_password` VARCHAR(255) NOT NULL DEFAULT '' AFTER `sftp_username`,
ADD `sftp_key_data` MEDIUMTEXT AFTER `sftp_password`,
ADD `sftp_passphrase` VARCHAR(255) NOT NULL DEFAULT '' AFTER `sftp_key_data`,
ADD `sftp_path` VARCHAR(512) NOT NULL DEFAULT '/backups' AFTER `sftp_passphrase`;
@@ -1,4 +1,6 @@
-- MokoSuiteBackup 01.36.00 — SFTP auth type column
--
-- Legacy column (see 01.35.00). Bare `ADD` so Joomla's schema checker skips it —
-- the column is purged by 02.52.25 + postflight and must not exist at rest.
ALTER TABLE `#__mokosuitebackup_profiles`
ADD COLUMN `sftp_auth_type` VARCHAR(20) NOT NULL DEFAULT 'key' AFTER `sftp_username`;
ADD `sftp_auth_type` VARCHAR(20) NOT NULL DEFAULT 'key' AFTER `sftp_username`;
@@ -1,5 +1,8 @@
-- MokoSuiteBackup 01.39.00 — Change include_mokorestore from TINYINT to VARCHAR
-- Needed to support 'standalone' value alongside 0/1
-- NB: MySQL syntax is `MODIFY <col>`, not `MODIFY COLUMN <col>`. Joomla's schema
-- checker parses `MODIFY COLUMN x` as column-name="COLUMN", type="x", producing a
-- bogus "wrong type for column 'COLUMN'" problem. Keep it as bare MODIFY.
ALTER TABLE `#__mokosuitebackup_profiles`
MODIFY COLUMN `include_mokorestore` VARCHAR(20) NOT NULL DEFAULT '0';
MODIFY `include_mokorestore` VARCHAR(20) NOT NULL DEFAULT '0';
@@ -0,0 +1 @@
/* 02.62.01 — no schema changes */
@@ -0,0 +1 @@
/* 02.62.02 — no schema changes */
@@ -0,0 +1 @@
/* 02.62.03 — no schema changes */
@@ -0,0 +1 @@
/* 02.62.04 — no schema changes */
@@ -0,0 +1 @@
/* 02.62.05 — no schema changes */
@@ -0,0 +1 @@
/* 02.63.00 — no schema changes */
@@ -0,0 +1 @@
/* 02.63.01 — no schema changes */
@@ -0,0 +1 @@
/* 02.63.02 — no schema changes */
@@ -0,0 +1 @@
/* 02.63.05 — no schema changes */
@@ -0,0 +1 @@
/* 02.63.06 — no schema changes */
@@ -0,0 +1 @@
/* 02.63.07 — no schema changes */
@@ -0,0 +1 @@
/* 02.63.09 — no schema changes */
@@ -0,0 +1 @@
/* 02.63.10 — no schema changes */
@@ -0,0 +1 @@
/* 02.63.11 — no schema changes */
@@ -0,0 +1 @@
/* 02.64.00 — no schema changes */
@@ -0,0 +1 @@
/* 02.64.01 — no schema changes */
@@ -0,0 +1 @@
/* 02.64.02 — no schema changes */
@@ -0,0 +1 @@
/* 02.64.03 — no schema changes */
@@ -0,0 +1 @@
/* 02.64.04 — no schema changes */
@@ -0,0 +1,6 @@
-- MokoSuiteBackup 02.64.05 — Schema-checker reconciliation for #__mokosuitebackup_profiles
-- Ensures include_mokorestore is VARCHAR(20) on any install (idempotent; no-op if
-- already VARCHAR). Paired with retro-fixes to 01.01.01/01.39.00 and the legacy
-- sftp_* columns restored to install.mysql.sql so the Database view reports no problems.
ALTER TABLE `#__mokosuitebackup_profiles`
MODIFY `include_mokorestore` VARCHAR(20) NOT NULL DEFAULT '0';
@@ -0,0 +1 @@
/* 02.64.06 — no schema changes */
@@ -0,0 +1 @@
/* 02.64.09 — no schema changes */
@@ -0,0 +1 @@
/* 02.64.10 — no schema changes */
@@ -0,0 +1 @@
/* 02.64.11 — no schema changes */
@@ -0,0 +1 @@
/* 02.64.12 — no schema changes */
@@ -0,0 +1 @@
/* 02.64.13 — no schema changes */
@@ -0,0 +1 @@
/* 02.65.00 — no schema changes */
@@ -0,0 +1 @@
/* 02.65.01 — no schema changes */
@@ -0,0 +1 @@
/* 02.65.02 — no schema changes */
@@ -0,0 +1 @@
/* 02.65.03 — no schema changes */
@@ -0,0 +1 @@
/* 02.65.04 — no schema changes */
@@ -0,0 +1 @@
/* 02.66.00 — no schema changes */
@@ -0,0 +1 @@
/* 02.66.01 — no schema changes */
@@ -0,0 +1 @@
/* 02.66.02 — no schema changes */
@@ -0,0 +1 @@
/* 02.66.03 — no schema changes */
@@ -0,0 +1 @@
/* 02.66.04 — no schema changes */
@@ -0,0 +1 @@
/* 02.66.05 — no schema changes */
@@ -0,0 +1 @@
/* 02.66.06 — no schema changes */
@@ -0,0 +1 @@
/* 02.66.07 — no schema changes */
@@ -0,0 +1 @@
/* 02.67.00 — no schema changes */
@@ -81,6 +81,19 @@ class AjaxController extends BaseController
$engine = new SteppedBackupEngine();
$result = $engine->runStep($sessionId);
/* Server-authoritative pre-action skip. When a backup that was launched as a
pre-update / pre-uninstall front COMPLETES here, arm the one-shot skip flag
on the SERVER — rather than relying solely on the client-side
ajax.preupdateAck POST, which is best-effort and swallows its own failure
in a catch(). This guarantees the imminent re-fired update/uninstall does
not trigger a SECOND synchronous backup even if that ack never lands.
Armed ONLY on genuine completion (done, no error), so a failed or cancelled
backup never arms it — preserving "no successful pre-action backup ⇒ the
server still backs up before the action". */
if (!empty($result['done']) && empty($result['error'])) {
$this->armPreactionSkip($this->input->getCmd('action', ''));
}
$this->sendJson($result);
}
@@ -116,16 +129,35 @@ class AjaxController extends BaseController
action so an update ack never suppresses a later, unrelated uninstall.
For a core Joomla update (no action) neither flag is set — a core
update doesn't fire onExtensionBefore(Update|Uninstall). */
$action = $this->app->getInput()->getCmd('action', '');
$this->armPreactionSkip($this->app->getInput()->getCmd('action', ''));
$this->sendJson(['error' => false, 'message' => 'Pre-action backup acknowledged']);
}
/**
* Arm the one-shot session skip flag for a specific pre-action, so the
* imminent re-fired extension update/uninstall skips its synchronous
* server-side backup exactly once (runPreActionBackup consumes it). Keyed by
* action so an update never suppresses a later, unrelated uninstall. A blank
* or unknown action (core Joomla update, manual "Backup Now") arms nothing.
*
* Called from two places: ajax.step on genuine completion (authoritative) and
* ajax.preupdateAck (best-effort client ack) — either one arming the flag is
* enough, and re-arming it is harmless.
*
* Stores the unix time it was armed (not a bare bool) so the plugin can honour
* the skip only within a short TTL and never let a stale flag from an abandoned
* backup suppress a genuine, unrelated pre-action backup later in the session.
*/
private function armPreactionSkip(string $action): void
{
$session = Factory::getSession();
if ($action === 'update') {
$session->set('mokosuitebackup.skip_preaction_backup_before_update', true);
$session->set('mokosuitebackup.skip_preaction_backup_before_update', time());
} elseif ($action === 'uninstall') {
$session->set('mokosuitebackup.skip_preaction_backup_before_uninstall', true);
$session->set('mokosuitebackup.skip_preaction_backup_before_uninstall', time());
}
$this->sendJson(['error' => false, 'message' => 'Pre-action backup acknowledged']);
}
/**
@@ -509,6 +541,39 @@ class AjaxController extends BaseController
$this->sendJson($result);
}
/**
* Cancel an in-progress stepped restore and clean up its staging directory
* and session/state files (prevents orphaned artefacts on a client cancel).
* POST: task=ajax.restoreCancel&session_id=mb_...
*/
public function restoreCancel(): void
{
if (!Session::checkToken('get') && !Session::checkToken('post')) {
$this->sendJson(['error' => true, 'message' => 'Invalid token'], 403);
return;
}
if (!$this->app->getIdentity()->authorise('mokosuitebackup.backup.restore', 'com_mokosuitebackup')) {
$this->sendJson(['error' => true, 'message' => 'Access denied'], 403);
return;
}
$sessionId = $this->input->getString('session_id', '');
if (empty($sessionId)) {
$this->sendJson(['error' => true, 'message' => 'Missing session_id']);
return;
}
$engine = new SteppedRestoreEngine();
$result = $engine->cancel($sessionId);
$this->sendJson($result);
}
/**
* Browse archive contents without extracting.
* POST: task=ajax.browseArchive&id=123
@@ -61,34 +61,37 @@ class DatabaseImporter
while (($line = fgets($handle)) !== false) {
$trimmed = trim($line);
// Skip empty lines
if ($trimmed === '') {
continue;
}
// Skip single-line comments
if (str_starts_with($trimmed, '--') || str_starts_with($trimmed, '#')) {
continue;
}
// Handle multi-line comments
if (str_starts_with($trimmed, '/*')) {
$inMultiLineComment = true;
}
if ($inMultiLineComment) {
if (str_contains($trimmed, '*/')) {
$inMultiLineComment = false;
// Comment/blank skipping applies only BETWEEN statements — never
// inside an accumulating statement, where a line may be part of a
// multi-line string value that happens to start with --, # or /*.
if ($currentStatement === '') {
if ($trimmed === '') {
continue;
}
continue;
if (str_starts_with($trimmed, '--') || str_starts_with($trimmed, '#')) {
continue;
}
if (str_starts_with($trimmed, '/*')) {
$inMultiLineComment = true;
}
if ($inMultiLineComment) {
if (str_contains($trimmed, '*/')) {
$inMultiLineComment = false;
}
continue;
}
}
// Accumulate the statement
$currentStatement .= $line;
// Check if statement is complete (ends with semicolon)
if (str_ends_with($trimmed, ';')) {
// A statement ends at a ';' that is NOT inside a quoted string
// literal, so a ';' or newline in a value does not split it early.
if ($this->isCompleteStatement($currentStatement)) {
$statement = trim($currentStatement);
$currentStatement = '';
@@ -136,6 +139,52 @@ class DatabaseImporter
return $statementsExecuted;
}
/**
* Whether an accumulated SQL string is a complete statement: it ends with
* ';' that is NOT inside a quoted string literal. Tracks single/double/
* backtick quoting (with backslash escapes; MySQL '' doubling falls out of
* the toggle naturally) so a ';' or newline inside a value does not
* terminate the statement early.
*/
private function isCompleteStatement(string $sql): bool
{
$len = \strlen($sql);
$inSingle = false;
$inDouble = false;
$inBacktick = false;
for ($i = 0; $i < $len; $i++) {
$ch = $sql[$i];
if ($inSingle) {
if ($ch === '\\') { $i++; continue; }
if ($ch === "'") { $inSingle = false; }
continue;
}
if ($inDouble) {
if ($ch === '\\') { $i++; continue; }
if ($ch === '"') { $inDouble = false; }
continue;
}
if ($inBacktick) {
if ($ch === '`') { $inBacktick = false; }
continue;
}
if ($ch === "'") { $inSingle = true; }
elseif ($ch === '"') { $inDouble = true; }
elseif ($ch === '`') { $inBacktick = true; }
}
if ($inSingle || $inDouble || $inBacktick) {
return false;
}
return str_ends_with(rtrim($sql), ';');
}
/**
* Non-fatal errors from the last import(), if any. A non-empty result
* means the restore completed with problems and should be treated as a
@@ -232,13 +232,13 @@ REPL,
if (backups.length === 0) {
var alert = document.createElement('div');
alert.style.cssText = 'padding:12px;background:#fef2f2;border:1px solid #fecaca;border-radius:6px;color:#dc2626;';
alert.style.cssText = 'padding:12px;background:rgba(224,49,49,0.12);border:1px solid rgba(224,49,49,0.4);border-radius:6px;color:#ff8787;';
alert.textContent = 'No ZIP files found in this directory. Upload a backup archive first.';
list.appendChild(alert);
} else if (backups.length === 1) {
hiddenInput.value = backups[0].name;
var found = document.createElement('div');
found.style.cssText = 'padding:12px;background:#dcfce7;border:1px solid #bbf7d0;border-radius:6px;color:#16a34a;';
found.style.cssText = 'padding:12px;background:rgba(47,158,68,0.15);border:1px solid rgba(47,158,68,0.35);border-radius:6px;color:#69db7c;';
var strong = document.createElement('strong');
strong.textContent = backups[0].name;
found.appendChild(document.createTextNode('Found: '));
@@ -247,13 +247,13 @@ REPL,
list.appendChild(found);
} else {
var hint = document.createElement('div');
hint.style.cssText = 'padding:8px 12px;background:#eff6ff;border:1px solid #bfdbfe;border-radius:6px;color:#1d4ed8;margin-bottom:8px;font-size:0.9em;';
hint.style.cssText = 'padding:8px 12px;background:rgba(42,105,184,0.12);border:1px solid rgba(42,105,184,0.35);border-radius:6px;color:#a5d8ff;margin-bottom:8px;font-size:0.9em;';
hint.textContent = 'Multiple backup archives found \u2014 select which one to restore:';
list.appendChild(hint);
backups.forEach(function(b, i) {
var label = document.createElement('label');
label.style.cssText = 'display:flex;align-items:center;padding:10px 12px;margin:4px 0;border:1px solid #e2e8f0;border-radius:6px;cursor:pointer;transition:background 0.15s;';
label.onmouseover = function() { this.style.background = '#f8fafc'; };
label.style.cssText = 'display:flex;align-items:center;padding:10px 12px;margin:4px 0;border:1px solid #2a2f37;border-radius:6px;cursor:pointer;transition:background 0.15s;';
label.onmouseover = function() { this.style.background = '#1e2229'; };
label.onmouseout = function() { this.style.background = ''; };
var radio = document.createElement('input');
radio.type = 'radio';
@@ -268,7 +268,7 @@ REPL,
nameStrong.textContent = b.name;
info.appendChild(nameStrong);
var meta = document.createElement('div');
meta.style.cssText = 'font-size:0.85em;color:#64748b;margin-top:2px;';
meta.style.cssText = 'font-size:0.85em;color:#9aa4b2;margin-top:2px;';
meta.textContent = (b.size / 1048576).toFixed(1) + ' MB \u2014 ' + b.date;
info.appendChild(meta);
label.appendChild(info);
@@ -1507,92 +1507,107 @@ PHP_BACKEND;
<title>MokoRestore — Site Installer</title>
<style>
*{box-sizing:border-box;margin:0;padding:0}
body{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,'Helvetica Neue',Arial,sans-serif;background:#f0f4f8;color:#333;line-height:1.6}
.mr-header{background:linear-gradient(135deg,#1a3c5e 0%,#2d6a9f 100%);color:#fff;padding:1.5rem 2rem;text-align:center}
.mr-header h1{font-size:1.6rem;font-weight:700;margin-bottom:0.2rem}
.mr-header p{font-size:0.9rem;opacity:0.85}
/* Full-black MokoSuite theme, matching the runbackup / backup-progress screens. */
:root{
--mr-bg:#000;
--mr-panel:#15181d;
--mr-input:#1e2229;
--mr-border:#2a2f37;
--mr-text:#f1f3f5;
--mr-muted:#9aa4b2;
--mr-accent:#2a69b8;
--mr-accent2:#3b82f6;
--mr-success:#2f9e44;
--mr-danger:#e03131;
--mr-warn:#f0b429;
}
body{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,'Helvetica Neue',Arial,sans-serif;background:var(--mr-bg);color:var(--mr-text);line-height:1.6}
.mr-header{background:#000;color:#fff;padding:2.25rem 2rem;text-align:center;border-bottom:1px solid var(--mr-border)}
.mr-header h1{font-size:2.5rem;font-weight:700;letter-spacing:.01em;margin-bottom:0.25rem}
.mr-header p{font-size:1rem;color:var(--mr-muted)}
.mr-container{max-width:800px;margin:1.5rem auto;padding:0 1rem}
/* Step progress bar */
.mr-steps{display:flex;justify-content:center;margin-bottom:1.5rem;gap:0;flex-wrap:wrap}
.mr-step{display:flex;align-items:center;gap:0.5rem;padding:0.5rem 1rem;font-size:0.8rem;color:#94a3b8;cursor:default;position:relative}
.mr-step .mr-num{width:28px;height:28px;border-radius:50%;border:2px solid #cbd5e1;display:flex;align-items:center;justify-content:center;font-weight:700;font-size:0.75rem;background:#fff;flex-shrink:0}
.mr-step.active{color:#1e40af;font-weight:600}
.mr-step.active .mr-num{border-color:#2563eb;background:#2563eb;color:#fff}
.mr-step.done{color:#16a34a}
.mr-step.done .mr-num{border-color:#16a34a;background:#16a34a;color:#fff}
.mr-step::after{content:'';width:24px;height:2px;background:#cbd5e1;margin-left:0.5rem}
.mr-step{display:flex;align-items:center;gap:0.5rem;padding:0.5rem 1rem;font-size:0.8rem;color:var(--mr-muted);cursor:default;position:relative}
.mr-step .mr-num{width:28px;height:28px;border-radius:50%;border:2px solid var(--mr-border);display:flex;align-items:center;justify-content:center;font-weight:700;font-size:0.75rem;background:var(--mr-input);color:var(--mr-muted);flex-shrink:0}
.mr-step.active{color:#fff;font-weight:600}
.mr-step.active .mr-num{border-color:var(--mr-accent);background:var(--mr-accent);color:#fff}
.mr-step.done{color:var(--mr-success)}
.mr-step.done .mr-num{border-color:var(--mr-success);background:var(--mr-success);color:#fff}
.mr-step::after{content:'';width:24px;height:2px;background:var(--mr-border);margin-left:0.5rem}
.mr-step:last-child::after{display:none}
.mr-step.done::after{background:#16a34a}
.mr-step.done::after{background:var(--mr-success)}
/* Panels */
.mr-panel{display:none;background:#fff;border-radius:12px;box-shadow:0 1px 3px rgba(0,0,0,0.1);padding:2rem;margin-bottom:1rem}
.mr-panel{display:none;background:var(--mr-panel);border:1px solid var(--mr-border);border-radius:12px;box-shadow:0 1px 3px rgba(0,0,0,0.5);padding:2rem;margin-bottom:1rem}
.mr-panel.visible{display:block}
.mr-panel h2{font-size:1.2rem;color:#1e293b;margin-bottom:0.25rem}
.mr-panel .mr-desc{color:#64748b;font-size:0.9rem;margin-bottom:1.5rem}
.mr-panel h2{font-size:1.4rem;color:#fff;margin-bottom:0.25rem}
.mr-panel .mr-desc{color:var(--mr-muted);font-size:0.9rem;margin-bottom:1.5rem}
/* Form elements */
.mr-field{margin-bottom:1rem}
.mr-field label{display:block;font-weight:600;font-size:0.85rem;color:#475569;margin-bottom:0.3rem}
.mr-field input,.mr-field select{width:100%;padding:0.6rem 0.75rem;border:1px solid #d1d5db;border-radius:6px;font-size:0.9rem;color:#1e293b;background:#fff;transition:border-color 0.2s}
.mr-field input:focus,.mr-field select:focus{outline:none;border-color:#2563eb;box-shadow:0 0 0 3px rgba(37,99,235,0.1)}
.mr-field .mr-hint{font-size:0.8rem;color:#94a3b8;margin-top:0.2rem}
.mr-field label{display:block;font-weight:600;font-size:0.85rem;color:#cfd6df;margin-bottom:0.3rem}
.mr-field input,.mr-field select{width:100%;padding:0.6rem 0.75rem;border:1px solid var(--mr-border);border-radius:6px;font-size:0.9rem;color:var(--mr-text);background:var(--mr-input);transition:border-color 0.2s}
.mr-field input::placeholder{color:#69707c}
.mr-field input:focus,.mr-field select:focus{outline:none;border-color:var(--mr-accent);box-shadow:0 0 0 3px rgba(42,105,184,0.25)}
.mr-field .mr-hint{font-size:0.8rem;color:var(--mr-muted);margin-top:0.2rem}
.mr-row{display:flex;gap:1rem}
.mr-row .mr-field{flex:1}
/* Buttons */
.mr-btn{display:inline-flex;align-items:center;gap:0.4rem;padding:0.65rem 1.5rem;border:none;border-radius:6px;font-size:0.9rem;font-weight:600;cursor:pointer;transition:all 0.2s}
.mr-btn:disabled{opacity:0.5;cursor:not-allowed}
.mr-btn-primary{background:#2563eb;color:#fff}
.mr-btn-primary:hover:not(:disabled){background:#1d4ed8}
.mr-btn-success{background:#16a34a;color:#fff}
.mr-btn-success:hover:not(:disabled){background:#15803d}
.mr-btn-danger{background:#dc2626;color:#fff}
.mr-btn-danger:hover:not(:disabled){background:#b91c1c}
.mr-btn-outline{background:transparent;color:#2563eb;border:1px solid #2563eb}
.mr-btn-outline:hover:not(:disabled){background:#eff6ff}
.mr-actions{display:flex;justify-content:space-between;align-items:center;margin-top:1.5rem;padding-top:1rem;border-top:1px solid #e2e8f0}
.mr-btn-primary{background:var(--mr-accent);color:#fff}
.mr-btn-primary:hover:not(:disabled){filter:brightness(1.15)}
.mr-btn-success{background:var(--mr-success);color:#fff}
.mr-btn-success:hover:not(:disabled){filter:brightness(1.15)}
.mr-btn-danger{background:var(--mr-danger);color:#fff}
.mr-btn-danger:hover:not(:disabled){filter:brightness(1.15)}
.mr-btn-outline{background:transparent;color:#74c0fc;border:1px solid var(--mr-accent)}
.mr-btn-outline:hover:not(:disabled){background:rgba(42,105,184,0.15)}
.mr-actions{display:flex;justify-content:space-between;align-items:center;margin-top:1.5rem;padding-top:1rem;border-top:1px solid var(--mr-border)}
/* Check list */
.mr-checks{list-style:none;margin:0;padding:0}
.mr-checks li{display:flex;align-items:center;gap:0.75rem;padding:0.5rem 0;border-bottom:1px solid #f1f5f9;font-size:0.9rem}
.mr-checks li{display:flex;align-items:center;gap:0.75rem;padding:0.5rem 0;border-bottom:1px solid var(--mr-border);font-size:0.9rem}
.mr-checks li:last-child{border-bottom:none}
.mr-check-icon{width:24px;height:24px;border-radius:50%;display:flex;align-items:center;justify-content:center;font-size:0.75rem;font-weight:700;flex-shrink:0}
.mr-check-ok{background:#dcfce7;color:#16a34a}
.mr-check-warn{background:#fef9c3;color:#a16207}
.mr-check-fail{background:#fef2f2;color:#dc2626}
.mr-check-info{background:#e0f2fe;color:#0284c7}
.mr-check-label{flex:1;font-weight:500}
.mr-check-value{color:#64748b;font-size:0.85rem}
.mr-check-hint{font-size:0.8rem;color:#94a3b8}
.mr-check-ok{background:rgba(47,158,68,0.18);color:#69db7c}
.mr-check-warn{background:rgba(240,180,41,0.18);color:#ffd43b}
.mr-check-fail{background:rgba(224,49,49,0.18);color:#ff8787}
.mr-check-info{background:rgba(42,105,184,0.18);color:#74c0fc}
.mr-check-label{flex:1;font-weight:500;color:var(--mr-text)}
.mr-check-value{color:var(--mr-muted);font-size:0.85rem}
.mr-check-hint{font-size:0.8rem;color:var(--mr-muted)}
/* Alert */
.mr-alert{padding:0.75rem 1rem;border-radius:6px;font-size:0.9rem;margin-bottom:1rem;display:flex;align-items:flex-start;gap:0.5rem}
.mr-alert-warn{background:#fef3c7;color:#92400e;border:1px solid #fde68a}
.mr-alert-danger{background:#fef2f2;color:#991b1b;border:1px solid #fecaca}
.mr-alert-success{background:#f0fdf4;color:#166534;border:1px solid #bbf7d0}
.mr-alert-info{background:#eff6ff;color:#1e40af;border:1px solid #bfdbfe}
.mr-alert-warn{background:rgba(240,180,41,0.12);color:#ffe08a;border:1px solid rgba(240,180,41,0.35)}
.mr-alert-danger{background:rgba(224,49,49,0.12);color:#ffc9c9;border:1px solid rgba(224,49,49,0.4)}
.mr-alert-success{background:rgba(47,158,68,0.12);color:#b2f2bb;border:1px solid rgba(47,158,68,0.35)}
.mr-alert-info{background:rgba(42,105,184,0.12);color:#a5d8ff;border:1px solid rgba(42,105,184,0.35)}
/* Progress */
.mr-progress{background:#e2e8f0;border-radius:8px;overflow:hidden;height:8px;margin:1rem 0}
.mr-progress-bar{height:100%;background:linear-gradient(90deg,#2563eb,#3b82f6);transition:width 0.4s;border-radius:8px}
.mr-status{font-size:0.85rem;color:#64748b;min-height:1.2rem}
.mr-progress{background:var(--mr-input);border-radius:8px;overflow:hidden;height:8px;margin:1rem 0}
.mr-progress-bar{height:100%;background:linear-gradient(90deg,var(--mr-accent),var(--mr-accent2));transition:width 0.4s;border-radius:8px}
.mr-status{font-size:0.85rem;color:var(--mr-muted);min-height:1.2rem}
/* Provisioning checkboxes */
.mr-provision-list{list-style:none;padding:0;margin:0}
.mr-provision-list li{display:flex;align-items:center;gap:0.75rem;padding:0.6rem 0.5rem;border-bottom:1px solid #f1f5f9;font-size:0.9rem;cursor:pointer;border-radius:4px}
.mr-provision-list li:hover{background:#f8fafc}
.mr-provision-list li{display:flex;align-items:center;gap:0.75rem;padding:0.6rem 0.5rem;border-bottom:1px solid var(--mr-border);font-size:0.9rem;cursor:pointer;border-radius:4px}
.mr-provision-list li:hover{background:var(--mr-input)}
.mr-provision-list li:last-child{border-bottom:none}
.mr-provision-list input[type=checkbox]{width:18px;height:18px;accent-color:#2563eb}
.mr-provision-desc{font-size:0.8rem;color:#94a3b8;margin-left:auto}
.mr-provision-list input[type=checkbox]{width:18px;height:18px;accent-color:var(--mr-accent)}
.mr-provision-desc{font-size:0.8rem;color:var(--mr-muted);margin-left:auto}
/* Log */
.mr-log{background:#1e293b;color:#94a3b8;border-radius:8px;padding:1rem;font-family:'SF Mono',SFMono-Regular,Consolas,monospace;font-size:0.8rem;max-height:200px;overflow-y:auto;white-space:pre-wrap;word-break:break-word;margin-top:1rem;display:none}
.mr-log{background:#0d0f12;color:var(--mr-muted);border:1px solid var(--mr-border);border-radius:8px;padding:1rem;font-family:'SF Mono',SFMono-Regular,Consolas,monospace;font-size:0.8rem;max-height:200px;overflow-y:auto;white-space:pre-wrap;word-break:break-word;margin-top:1rem;display:none}
.mr-log.visible{display:block}
/* Footer */
.mr-footer{text-align:center;padding:1.5rem;color:#94a3b8;font-size:0.8rem}
.mr-footer a{color:#2563eb;text-decoration:none}
.mr-footer{text-align:center;padding:1.5rem;color:var(--mr-muted);font-size:0.8rem}
.mr-footer a{color:#74c0fc;text-decoration:none}
/* Spinner */
.mr-spinner{display:inline-block;width:16px;height:16px;border:2px solid rgba(255,255,255,0.3);border-radius:50%;border-top-color:#fff;animation:spin 0.6s linear infinite}
@@ -1629,11 +1644,11 @@ body{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,'Helvetica N
<div class="mr-panel <?php echo $securityVerified ? '' : 'visible'; ?>" id="panel0">
<h2>Security Verification</h2>
<p class="mr-desc">To prevent unauthorized access, enter the security code from the file <code>mokorestore-security.php</code> in your site root.</p>
<div style="border:1px solid #e2e8f0;border-radius:8px;padding:1.25rem;margin-bottom:1.25rem;background:#f8fafc">
<div style="font-weight:600;font-size:0.9rem;color:#334155;margin-bottom:1rem;display:flex;align-items:center;gap:0.5rem">
<div style="border:1px solid #2a2f37;border-radius:8px;padding:1.25rem;margin-bottom:1.25rem;background:#1e2229">
<div style="font-weight:600;font-size:0.9rem;color:#cfd6df;margin-bottom:1rem;display:flex;align-items:center;gap:0.5rem">
<span style="font-size:1.1rem">&#128274;</span> How to find the code
</div>
<ol style="margin:0;padding-left:1.25rem;color:#475569;font-size:0.9rem;line-height:1.6">
<ol style="margin:0;padding-left:1.25rem;color:#9aa4b2;font-size:0.9rem;line-height:1.6">
<li>Connect to your server via FTP, SSH, or file manager</li>
<li>Open <code>mokorestore-security.php</code> in the site root directory</li>
<li>Copy the 8-character code and enter it below</li>
@@ -1666,7 +1681,7 @@ body{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,'Helvetica N
<h2>Extract Backup</h2>
<p class="mr-desc">Extract site-backup.zip into the current directory.</p>
<div class="mr-field">
<label>Archive Password <span style="font-weight:normal;color:#94a3b8">(leave blank if not encrypted)</span></label>
<label>Archive Password <span style="font-weight:normal;color:#9aa4b2">(leave blank if not encrypted)</span></label>
<input type="password" id="archivePassword" placeholder="Optional decryption password">
</div>
<div class="mr-progress"><div class="mr-progress-bar" id="extractProgress" style="width:0%"></div></div>
@@ -1694,8 +1709,8 @@ body{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,'Helvetica N
<strong>Merge</strong> = keep existing table, INSERT IGNORE new rows.
<strong>Data Only</strong> = keep schema, INSERT data as-is (assumes matching structure).
</div>
<div id="tableResolutionList" style="max-height:400px;overflow-y:auto;border:1px solid #e2e8f0;border-radius:8px;margin-top:1rem">
<div style="padding:1rem;color:#94a3b8;text-align:center">Scanning tables...</div>
<div id="tableResolutionList" style="max-height:400px;overflow-y:auto;border:1px solid #2a2f37;border-radius:8px;margin-top:1rem">
<div style="padding:1rem;color:#9aa4b2;text-align:center">Scanning tables...</div>
</div>
<input type="hidden" id="tableResolutions" value="{}">
<div class="mr-status" id="tableScanStatus"></div>
@@ -1735,21 +1750,21 @@ body{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,'Helvetica N
<h2>Site Configuration</h2>
<p class="mr-desc">Configure your site settings. Credentials were removed from the backup for security &mdash; enter the correct values for this server.</p>
<div style="border:1px solid #e2e8f0;border-radius:8px;padding:1.25rem;margin-bottom:1.25rem;background:#f8fafc">
<div style="font-weight:600;font-size:0.9rem;color:#334155;margin-bottom:1rem;display:flex;align-items:center;gap:0.5rem">
<div style="border:1px solid #2a2f37;border-radius:8px;padding:1.25rem;margin-bottom:1.25rem;background:#1e2229">
<div style="font-weight:600;font-size:0.9rem;color:#cfd6df;margin-bottom:1rem;display:flex;align-items:center;gap:0.5rem">
<span style="font-size:1.1rem">&#127760;</span> General
</div>
<div class="mr-field"><label>Site Name</label><input type="text" id="siteName" value="Joomla Site"></div>
<div class="mr-field">
<label>Live Site URL <span style="font-weight:normal;color:#94a3b8">(optional)</span></label>
<label>Live Site URL <span style="font-weight:normal;color:#9aa4b2">(optional)</span></label>
<input type="text" id="liveSite" placeholder="https://example.com">
<div class="mr-hint">Leave blank to auto-detect. Set this if using a reverse proxy or custom domain.</div>
</div>
</div>
<div style="border:1px solid #e2e8f0;border-radius:8px;padding:1.25rem;margin-bottom:1.25rem;background:#f8fafc">
<div style="font-weight:600;font-size:0.9rem;color:#334155;margin-bottom:1rem;display:flex;align-items:center;gap:0.5rem">
<span style="font-size:1.1rem">&#9993;</span> Mail / SMTP <span style="font-weight:normal;font-size:0.8rem;color:#94a3b8">&mdash; leave blank if using PHP mail()</span>
<div style="border:1px solid #2a2f37;border-radius:8px;padding:1.25rem;margin-bottom:1.25rem;background:#1e2229">
<div style="font-weight:600;font-size:0.9rem;color:#cfd6df;margin-bottom:1rem;display:flex;align-items:center;gap:0.5rem">
<span style="font-size:1.1rem">&#9993;</span> Mail / SMTP <span style="font-weight:normal;font-size:0.8rem;color:#9aa4b2">&mdash; leave blank if using PHP mail()</span>
</div>
<div class="mr-field"><label>SMTP Host</label><input type="text" id="smtpHost" placeholder="smtp.example.com"></div>
<div class="mr-row">
@@ -1758,8 +1773,8 @@ body{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,'Helvetica N
</div>
</div>
<div style="border:1px solid #e2e8f0;border-radius:8px;padding:1.25rem;margin-bottom:1.25rem;background:#f8fafc">
<div style="font-weight:600;font-size:0.9rem;color:#334155;margin-bottom:1rem;display:flex;align-items:center;gap:0.5rem">
<div style="border:1px solid #2a2f37;border-radius:8px;padding:1.25rem;margin-bottom:1.25rem;background:#1e2229">
<div style="font-weight:600;font-size:0.9rem;color:#cfd6df;margin-bottom:1rem;display:flex;align-items:center;gap:0.5rem">
<span style="font-size:1.1rem">&#128736;</span> Server
</div>
<div class="mr-field" style="display:flex;align-items:center;gap:0.5rem">
@@ -2031,7 +2046,7 @@ async function runPreflight() {
li.appendChild(val);
if (c.warn && c.hint) {
var hint = document.createElement('div');
hint.style.cssText = 'font-size:0.85em;color:#a16207;margin-top:4px;padding:4px 8px;background:#fef9c3;border-radius:4px;';
hint.style.cssText = 'font-size:0.85em;color:#ffd43b;margin-top:4px;padding:4px 8px;background:rgba(240,180,41,0.12);border-radius:4px;';
hint.textContent = c.hint;
li.appendChild(hint);
}
@@ -2113,7 +2128,7 @@ async function scanTables() {
const r = await post('scanTables');
if (!r.success || !r.tables || r.tables.length === 0) {
container.innerHTML = '<div style="padding:1rem;color:#94a3b8;text-align:center">No tables found in database.sql (or file not present). You can skip this step.</div>';
container.innerHTML = '<div style="padding:1rem;color:#9aa4b2;text-align:center">No tables found in database.sql (or file not present). You can skip this step.</div>';
setStatus('tableScanStatus', r.tables ? 'No tables found' : (r.message || 'Scan failed'), r.success ? '' : 'error');
log(r.message || 'No tables found');
return;
@@ -2134,16 +2149,16 @@ function renderTableList() {
tableList.forEach(function(name) {
var row = document.createElement('div');
row.style.cssText = 'display:flex;align-items:center;justify-content:space-between;padding:0.5rem 0.75rem;border-bottom:1px solid #f1f5f9;font-size:0.85rem;';
row.style.cssText = 'display:flex;align-items:center;justify-content:space-between;padding:0.5rem 0.75rem;border-bottom:1px solid #2a2f37;font-size:0.85rem;';
var label = document.createElement('span');
label.style.cssText = 'font-family:monospace;color:#334155;word-break:break-all;flex:1;margin-right:0.75rem;';
label.style.cssText = 'font-family:monospace;color:#cfd6df;word-break:break-all;flex:1;margin-right:0.75rem;';
label.textContent = name;
var sel = document.createElement('select');
sel.dataset.table = name;
sel.className = 'table-mode-select';
sel.style.cssText = 'padding:0.3rem 0.5rem;border:1px solid #d1d5db;border-radius:4px;font-size:0.8rem;min-width:120px;background:#fff;';
sel.style.cssText = 'padding:0.3rem 0.5rem;border:1px solid #2a2f37;border-radius:4px;font-size:0.8rem;min-width:120px;background:var(--mr-input);';
var modes = [
['replace', 'Replace'],
@@ -153,7 +153,7 @@ class NotificationSender
private static function sendNtfy(object $profile, object $record, bool $success): bool
{
$topic = trim($profile->ntfy_topic ?? '');
$server = trim($profile->ntfy_server ?? 'https://ntfy.sh');
$server = trim($profile->ntfy_server ?? 'https://ntfy.mokoconsulting.tech');
$token = trim($profile->ntfy_token ?? '');
if ($topic === '') {
@@ -428,7 +428,7 @@ class NotificationSender
private static function sendRestoreNtfy(object $profile, string $type, array $details): bool
{
$topic = trim($profile->ntfy_topic ?? '');
$server = trim($profile->ntfy_server ?? 'https://ntfy.sh');
$server = trim($profile->ntfy_server ?? 'https://ntfy.mokoconsulting.tech');
$token = trim($profile->ntfy_token ?? '');
if ($topic === '') {
@@ -122,6 +122,8 @@ class RestoreEngine
}
// Step 4: Import database
$dbErrorCount = 0;
if ($restoreDb) {
$sqlFile = $this->stagingDir . '/database.sql';
@@ -130,6 +132,16 @@ class RestoreEngine
$importer = new DatabaseImporter();
$tableCount = $importer->import($sqlFile);
$this->log('Database imported: ' . $tableCount . ' statements executed');
if ($importer->hasErrors()) {
$dbErrors = $importer->getErrors();
$dbErrorCount = count($dbErrors);
$this->log('WARNING: ' . $dbErrorCount . ' database statement(s) FAILED during import — the database may be incomplete:');
foreach (array_slice($dbErrors, 0, 5) as $err) {
$this->log(' - ' . substr($err, 0, 200));
}
}
} else {
$this->log('No database.sql found in archive — skipping database restore');
}
@@ -172,7 +184,10 @@ class RestoreEngine
return [
'success' => true,
'message' => 'Restore complete from: ' . basename($archivePath),
'warning' => $dbErrorCount > 0,
'message' => $dbErrorCount > 0
? 'Restore finished, but ' . $dbErrorCount . ' database statement(s) failed — the database may be incomplete. Check the log.'
: 'Restore complete from: ' . basename($archivePath),
'log' => implode("\n", $this->log),
];
} catch (\Throwable $e) {
@@ -185,15 +185,25 @@ class SteppedRestoreEngine
break;
case 'complete':
$sqlErrors = (int) ($restoreState['sql_errors'] ?? 0);
$this->destroyRestoreState($sessionId);
$session->destroy();
$completeMsg = 'Restore complete: ' . $session->archiveName;
if ($sqlErrors > 0) {
$completeMsg .= ' — WARNING: ' . $sqlErrors
. ' database statement(s) failed during import; the restored database may be incomplete. Check the site log.';
}
return [
'session_id' => $sessionId,
'phase' => 'complete',
'progress' => 100,
'message' => 'Restore complete: ' . $session->archiveName,
'message' => $completeMsg,
'done' => true,
'sql_errors' => $sqlErrors,
];
}
@@ -230,6 +240,39 @@ class SteppedRestoreEngine
}
}
/**
* Cancel an in-progress restore: delete the staging directory and the
* session/state files so a client-side cancel does not leave orphaned
* artefacts (staging dir + *.restore.json) behind. Best-effort — a missing
* or already-finished session is treated as success.
*
* @param string $sessionId The stepped-restore session id
*
* @return array ['error' => bool, 'message' => string]
*/
public function cancel(string $sessionId): array
{
$restoreState = $this->loadRestoreState($sessionId);
if ($restoreState !== null) {
$stagingDir = $restoreState['staging_dir'] ?? '';
if (!empty($stagingDir) && is_dir($stagingDir)) {
$this->recursiveDelete($stagingDir);
}
}
$this->destroyRestoreState($sessionId);
$session = SteppedSession::load($sessionId);
if ($session) {
$session->destroy();
}
return ['error' => false, 'message' => 'Restore cancelled'];
}
/**
* Extract phase: extract archive to staging directory.
*/
@@ -480,34 +523,37 @@ class SteppedRestoreEngine
while (($line = fgets($handle)) !== false) {
$trimmed = trim($line);
// Skip empty lines
if ($trimmed === '') {
continue;
}
// Skip single-line comments
if (str_starts_with($trimmed, '--') || str_starts_with($trimmed, '#')) {
continue;
}
// Handle multi-line comments
if (str_starts_with($trimmed, '/*')) {
$inMultiLineComment = true;
}
if ($inMultiLineComment) {
if (str_contains($trimmed, '*/')) {
$inMultiLineComment = false;
// Comment/blank skipping applies only BETWEEN statements — never inside
// an accumulating statement, where a line may be part of a multi-line
// string value that happens to start with --, # or /*.
if ($currentStatement === '') {
if ($trimmed === '') {
continue;
}
continue;
if (str_starts_with($trimmed, '--') || str_starts_with($trimmed, '#')) {
continue;
}
if (str_starts_with($trimmed, '/*')) {
$inMultiLineComment = true;
}
if ($inMultiLineComment) {
if (str_contains($trimmed, '*/')) {
$inMultiLineComment = false;
}
continue;
}
}
// Accumulate the statement
$currentStatement .= $line;
// Check if statement is complete (ends with semicolon)
if (str_ends_with($trimmed, ';')) {
// A statement ends at a ';' that is NOT inside a quoted string literal,
// so a ';' or newline embedded in a value does not split it prematurely.
if ($this->isCompleteStatement($currentStatement)) {
$statement = trim($currentStatement);
$currentStatement = '';
@@ -522,7 +568,7 @@ class SteppedRestoreEngine
$db->setQuery($statement);
$db->execute();
} catch (\Exception $e) {
error_log('MokoSuiteBackup SQL import warning: ' . $e->getMessage());
$this->recordSqlError($state, $e->getMessage());
}
$statementsExecuted++;
@@ -553,7 +599,7 @@ class SteppedRestoreEngine
$db->execute();
$state['sql_executed']++;
} catch (\Exception $e) {
error_log('MokoSuiteBackup SQL import warning (final): ' . $e->getMessage());
$this->recordSqlError($state, $e->getMessage());
}
}
@@ -562,8 +608,78 @@ class SteppedRestoreEngine
$state['sql_done'] = true;
$session->currentStep++;
$session->phase = 'config';
$session->statusMessage = 'Database import complete: ' . $state['sql_executed'] . ' statements';
$session->log('Database import complete: ' . $state['sql_executed'] . ' statements executed');
$sqlErrors = (int) ($state['sql_errors'] ?? 0);
$doneMsg = 'Database import complete: ' . $state['sql_executed'] . ' statements'
. ($sqlErrors > 0 ? ' (' . $sqlErrors . ' statement(s) FAILED — see log)' : '');
$session->statusMessage = $doneMsg;
$session->log($doneMsg);
}
/**
* Determine whether an accumulated SQL string is a complete statement: it
* ends with ';' that is NOT inside a quoted string literal. Tracks single,
* double and backtick quoting (with backslash escapes; MySQL '' doubling
* falls out of the toggle naturally) so a ';' or newline inside a value does
* not terminate the statement early.
*/
private function isCompleteStatement(string $sql): bool
{
$len = \strlen($sql);
$inSingle = false;
$inDouble = false;
$inBacktick = false;
for ($i = 0; $i < $len; $i++) {
$ch = $sql[$i];
if ($inSingle) {
if ($ch === '\\') { $i++; continue; }
if ($ch === "'") { $inSingle = false; }
continue;
}
if ($inDouble) {
if ($ch === '\\') { $i++; continue; }
if ($ch === '"') { $inDouble = false; }
continue;
}
if ($inBacktick) {
if ($ch === '`') { $inBacktick = false; }
continue;
}
if ($ch === "'") { $inSingle = true; }
elseif ($ch === '"') { $inDouble = true; }
elseif ($ch === '`') { $inBacktick = true; }
}
// If we ended inside a string literal, the statement is not finished yet.
if ($inSingle || $inDouble || $inBacktick) {
return false;
}
return str_ends_with(rtrim($sql), ';');
}
/**
* Record a failed SQL statement in the restore state (count + first few
* messages) so a partial import is surfaced instead of silently passing.
*/
private function recordSqlError(array &$state, string $message): void
{
$state['sql_errors'] = (int) ($state['sql_errors'] ?? 0) + 1;
if (!isset($state['sql_error_list']) || !\is_array($state['sql_error_list'])) {
$state['sql_error_list'] = [];
}
if (\count($state['sql_error_list']) < 5) {
$state['sql_error_list'][] = substr($message, 0, 200);
}
error_log('MokoSuiteBackup SQL import error: ' . $message);
}
/**
@@ -171,6 +171,11 @@ class SteppedSession
/**
* Clean up old session files (older than 24 hours).
*
* Reaps backup + restore session state (mb_*.json covers both the
* `<id>.json` and `<id>.restore.json` files) and, as a backstop for
* restores abandoned without a cancel (e.g. the browser was just closed),
* any orphaned restore staging directories left in the tmp folder.
*/
public static function cleanupOldSessions(): void
{
@@ -182,10 +187,42 @@ class SteppedSession
$cutoff = time() - 86400;
foreach (glob($dir . '/mb_*.json') as $file) {
foreach (glob($dir . '/mb_*.json') ?: [] as $file) {
if (filemtime($file) < $cutoff) {
@unlink($file);
}
}
// Orphaned restore staging directories (mokosuitebackup-restore-<tag>-<id>).
foreach (glob(JPATH_ROOT . '/tmp/mokosuitebackup-restore-*', GLOB_ONLYDIR) ?: [] as $stagingDir) {
if (@filemtime($stagingDir) < $cutoff) {
self::recursiveDelete($stagingDir);
}
}
}
/**
* Recursively delete a directory (used to reap orphaned restore staging dirs).
*/
private static function recursiveDelete(string $dir): void
{
if ($dir === '' || !is_dir($dir)) {
return;
}
$items = new \RecursiveIteratorIterator(
new \RecursiveDirectoryIterator($dir, \FilesystemIterator::SKIP_DOTS),
\RecursiveIteratorIterator::CHILD_FIRST
);
foreach ($items as $item) {
if ($item->isDir()) {
@rmdir($item->getPathname());
} else {
@unlink($item->getPathname());
}
}
@rmdir($dir);
}
}
@@ -16,13 +16,21 @@ use Joomla\CMS\Language\Text;
use Joomla\CMS\Layout\LayoutHelper;
use Joomla\CMS\Router\Route;
use Joomla\CMS\Session\Session;
use Joomla\CMS\Uri\Uri;
HTMLHelper::_('behavior.multiselect');
// Full-black progress theme for the backup/restore modals (matches view=runbackup).
// Loaded directly rather than via useStyle(), which does not emit a link for this
// component (same pattern as the runbackup screen).
$this->getDocument()->addStyleSheet(Uri::root(true) . '/media/com_mokosuitebackup/css/backup-progress.css');
$user = Factory::getApplication()->getIdentity();
$canDownload = $user->authorise('mokosuitebackup.backup.download', 'com_mokosuitebackup');
$ajaxToken = Session::getFormToken();
$ajaxUrl = Route::_('index.php?option=com_mokosuitebackup&format=json', false);
// Template for the "View backup record" button on the progress modal (__MSBID__ swapped in JS).
$recordUrlTpl = Route::_('index.php?option=com_mokosuitebackup&view=backup&id=__MSBID__', false);
$listOrder = $this->escape($this->state->get('list.ordering'));
$listDirn = $this->escape($this->state->get('list.direction'));
@@ -135,22 +143,42 @@ $listDirn = $this->escape($this->state->get('list.direction'));
</form>
<!-- Stepped Backup Modal (for shared hosting) -->
<div class="modal fade" id="mokosuitebackup-modal" tabindex="-1" aria-hidden="true" data-bs-backdrop="static" data-bs-keyboard="false">
<div class="modal fade msb-progress-modal" id="mokosuitebackup-modal" tabindex="-1" aria-hidden="true" data-bs-backdrop="static" data-bs-keyboard="false">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="mb-modal-title">Backup in Progress</h5>
<h2 class="modal-title" id="mb-modal-title">
<span class="icon-archive" aria-hidden="true" id="mb-modal-icon"></span>
<span id="mb-modal-titletext">Backup in Progress</span>
</h2>
</div>
<div class="modal-body">
<div class="alert alert-warning py-1 px-2 mb-2" style="font-size:0.85rem;">
<p class="msb-pm-warn">
<span class="icon-warning-circle" aria-hidden="true"></span>
<strong>Do not navigate away or close this window</strong> while the backup is running.
Do not navigate away or close this window while the backup is running.
</p>
<div class="msb-rb-progress" role="progressbar" aria-valuemin="0" aria-valuemax="100" aria-valuenow="0">
<div id="mb-progress-bar" class="msb-rb-bar is-animated" style="width:0%;">0%</div>
</div>
<div class="progress mb-2" style="height:24px;">
<div id="mb-progress-bar" class="progress-bar" role="progressbar" style="width:0%;">0%</div>
<p id="mb-status" class="msb-pm-status">Initializing...</p>
<p id="mb-phase" class="msb-pm-phase">Phase: init</p>
<div class="msb-pm-autocontinue" id="mb-autocontinue-wrap">
<label>
<input type="checkbox" id="mb-autocontinue" checked>
Automatically return to the backups list when the backup finishes
</label>
</div>
<div class="msb-pm-actions d-none" id="mb-actions">
<button type="button" id="mb-retry" class="btn btn-primary d-none">Retry backup</button>
<a href="#" id="mb-record" class="btn btn-primary d-none" target="_blank" rel="noopener">View backup record</a>
<button type="button" id="mb-done" class="btn btn-secondary d-none">Back to backups</button>
</div>
<div class="msb-pm-cancel" id="mb-cancel-wrap">
<button type="button" id="mb-cancel" class="btn btn-outline-danger">Cancel backup</button>
</div>
<p id="mb-status" class="text-muted mb-1" style="font-size:0.9rem;">Initializing...</p>
<p id="mb-phase" class="text-muted mb-0" style="font-size:0.8rem;">Phase: init</p>
</div>
</div>
</div>
@@ -183,12 +211,29 @@ $listDirn = $this->escape($this->state->get('list.direction'));
bootstrap.Modal.getInstance(document.getElementById('mokosuitebackup-modal'))?.hide();
}
// Shared bar-state styling for both the backup and restore progress bars,
// matching the runbackup screen: animated stripes while running, solid green
// on completion, solid red on failure.
function setBarState(bar, phase, progress) {
if (phase === 'failed') {
bar.classList.remove('is-animated', 'is-success');
bar.classList.add('is-danger');
} else if (progress >= 100 || phase === 'complete') {
bar.classList.remove('is-animated', 'is-danger');
bar.classList.add('is-success');
}
if (bar.parentElement) {
bar.parentElement.setAttribute('aria-valuenow', progress);
}
}
function updateProgress(progress, message, phase) {
const bar = document.getElementById('mb-progress-bar');
bar.style.width = progress + '%';
bar.textContent = progress + '%';
document.getElementById('mb-status').textContent = message;
document.getElementById('mb-phase').textContent = 'Phase: ' + phase;
setBarState(bar, phase, progress);
}
async function postAjax(params) {
@@ -205,64 +250,139 @@ $listDirn = $this->escape($this->state->get('list.direction'));
return res.json();
}
// --- Progress-modal controls (auto-continue, cancel, terminal buttons) ---
var RECORD_URL = <?php echo json_encode($recordUrlTpl); ?>;
var mbRecordId = 0;
var mbCancelled = false;
function mbEl(id) { return document.getElementById(id); }
function mbShow(n) { if (n) { n.classList.remove('d-none'); } }
function mbHide(n) { if (n) { n.classList.add('d-none'); } }
// Update the modal title's icon + text without innerHTML (values are static).
function setBackupTitle(iconClass, text) {
var ic = mbEl('mb-modal-icon');
if (ic) { ic.className = iconClass; }
var tx = mbEl('mb-modal-titletext');
if (tx) { tx.textContent = text; }
}
// Reset to the "running" control state (cancel + auto-continue visible, no actions).
function mbRunningControls() {
mbHide(mbEl('mb-actions'));
mbHide(mbEl('mb-retry'));
mbHide(mbEl('mb-record'));
mbHide(mbEl('mb-done'));
mbShow(mbEl('mb-cancel-wrap'));
mbShow(mbEl('mb-autocontinue-wrap'));
if (mbEl('mb-cancel')) { mbEl('mb-cancel').disabled = false; }
}
// Switch out of the "running" control state (hide cancel + auto-continue).
function mbTerminalControls() {
backupRunning = false;
mbHide(mbEl('mb-cancel-wrap'));
mbHide(mbEl('mb-autocontinue-wrap'));
}
function leaveToList() { hideModal(); location.reload(); }
document.addEventListener('DOMContentLoaded', function() {
var doneBtn = mbEl('mb-done');
if (doneBtn) { doneBtn.addEventListener('click', leaveToList); }
// Cancel: double confirmation, then stop the loop and cancel the record.
var cancelBtn = mbEl('mb-cancel');
if (cancelBtn) {
cancelBtn.addEventListener('click', function() {
if (!backupRunning || mbCancelled) { return; }
if (!confirm('Cancel the backup that is in progress?')) { return; }
if (!confirm('Are you sure? The backup will be stopped.')) { return; }
mbCancelled = true;
backupRunning = false;
cancelBtn.disabled = true;
document.getElementById('mb-status').textContent = 'Cancelling…';
if (mbRecordId > 0) {
postAjax({ task: 'ajax.cancelBackup', id: mbRecordId }).then(leaveToList, leaveToList);
} else {
leaveToList();
}
});
}
});
function backupComplete() {
updateProgress(100, 'Backup complete.', 'complete');
setBackupTitle('icon-check', 'Backup Complete');
mbTerminalControls();
// Auto-continue: return to the list automatically unless the user opted out.
if (mbEl('mb-autocontinue') && mbEl('mb-autocontinue').checked) {
setTimeout(leaveToList, 1500);
return;
}
mbShow(mbEl('mb-actions'));
mbShow(mbEl('mb-done'));
if (mbRecordId > 0 && RECORD_URL) {
var rec = mbEl('mb-record');
rec.setAttribute('href', RECORD_URL.replace('__MSBID__', String(mbRecordId)));
mbShow(rec);
}
}
function backupFailed(message) {
updateProgress(0, 'ERROR: ' + (message || 'Backup failed'), 'failed');
setBackupTitle('icon-cancel', 'Backup Failed');
mbTerminalControls();
mbShow(mbEl('mb-actions'));
mbShow(mbEl('mb-done'));
var retry = mbEl('mb-retry');
mbShow(retry);
retry.onclick = function() { startSteppedBackup(); };
}
async function startSteppedBackup() {
const profileSelect = document.getElementById('mb-profile-select');
const profileId = profileSelect ? profileSelect.value : '1';
mbRecordId = 0;
mbCancelled = false;
mbRunningControls();
setBackupTitle('icon-archive', 'Backup in Progress');
showModal();
updateProgress(0, 'Initializing backup...', 'init');
try {
// Init
const initResult = await postAjax({
task: 'ajax.init',
profile_id: profileId
});
const initResult = await postAjax({ task: 'ajax.init', profile_id: profileId });
if (initResult.error) {
updateProgress(0, 'ERROR: ' + initResult.message, 'failed');
setTimeout(hideModal, 5000);
return;
}
if (initResult.error) { backupFailed(initResult.message); return; }
// Show preflight warnings if any
if (initResult.warnings && initResult.warnings.length > 0) {
var warningEl = document.getElementById('mb-phase');
warningEl.textContent = 'Warnings: ' + initResult.warnings.join('; ');
warningEl.style.color = '#856404';
warningEl.style.color = '#ffd23f';
}
const sessionId = initResult.session_id;
if (initResult.record_id) { mbRecordId = initResult.record_id; }
updateProgress(initResult.progress, initResult.message, initResult.phase);
// Run steps until done
// Run steps until done (or cancelled)
let done = false;
while (!done) {
const stepResult = await postAjax({
task: 'ajax.step',
session_id: sessionId
});
if (stepResult.error) {
updateProgress(0, 'ERROR: ' + stepResult.message, 'failed');
setTimeout(hideModal, 5000);
return;
}
while (!done && !mbCancelled) {
const stepResult = await postAjax({ task: 'ajax.step', session_id: sessionId });
if (stepResult.error) { backupFailed(stepResult.message); return; }
if (stepResult.record_id) { mbRecordId = stepResult.record_id; }
updateProgress(stepResult.progress, stepResult.message, stepResult.phase);
done = stepResult.done || false;
}
// Complete
document.getElementById('mb-modal-title').textContent = 'Backup Complete';
setTimeout(function() {
hideModal();
location.reload();
}, 2000);
if (mbCancelled) { return; }
backupComplete();
} catch (err) {
updateProgress(0, 'ERROR: ' + err.message, 'failed');
setTimeout(hideModal, 5000);
if (mbCancelled) { return; }
backupFailed(err.message);
}
}
@@ -292,8 +412,10 @@ $listDirn = $this->escape($this->state->get('list.direction'));
// Close restore modal handled by Bootstrap data-bs-dismiss
// AJAX stepped restore
var restoreRunning = false;
// --- AJAX stepped restore (controls mirror the backup-progress modal) ---
var restoreRunning = false;
var restoreCancelled = false;
var restoreSessionId = '';
function showRestoreProgress() {
restoreRunning = true;
@@ -306,12 +428,38 @@ $listDirn = $this->escape($this->state->get('list.direction'));
bootstrap.Modal.getInstance(document.getElementById('mb-restore-progress-modal'))?.hide();
}
// Update the restore title's icon + text without innerHTML (values are static).
function setRestoreTitle(iconClass, text) {
var ic = mbEl('mb-restore-icon');
if (ic) { ic.className = iconClass; }
var tx = mbEl('mb-restore-titletext');
if (tx) { tx.textContent = text; }
}
function restoreRunningControls() {
mbHide(mbEl('mb-restore-actions'));
mbHide(mbEl('mb-restore-retry'));
mbHide(mbEl('mb-restore-done'));
mbShow(mbEl('mb-restore-cancel-wrap'));
mbShow(mbEl('mb-restore-autocontinue-wrap'));
if (mbEl('mb-restore-cancel')) { mbEl('mb-restore-cancel').disabled = false; }
}
function restoreTerminalControls() {
restoreRunning = false;
mbHide(mbEl('mb-restore-cancel-wrap'));
mbHide(mbEl('mb-restore-autocontinue-wrap'));
}
function leaveRestoreToList() { hideRestoreProgress(); location.reload(); }
function updateRestoreProgress(progress, message, phase) {
var bar = document.getElementById('mb-restore-progress-bar');
bar.style.width = progress + '%';
bar.textContent = progress + '%';
document.getElementById('mb-restore-status').textContent = message;
document.getElementById('mb-restore-phase').textContent = 'Phase: ' + phase;
setBarState(bar, phase, progress);
}
window.addEventListener('beforeunload', function(e) {
@@ -321,15 +469,79 @@ $listDirn = $this->escape($this->state->get('list.direction'));
}
});
async function startSteppedRestore(e) {
e.preventDefault();
document.addEventListener('DOMContentLoaded', function() {
var doneBtn = mbEl('mb-restore-done');
if (doneBtn) { doneBtn.addEventListener('click', leaveRestoreToList); }
// Cancel: double confirmation. A restore cannot be rolled back mid-flight,
// so the second prompt warns that the site may be left partially restored.
var cancelBtn = mbEl('mb-restore-cancel');
if (cancelBtn) {
cancelBtn.addEventListener('click', function() {
if (!restoreRunning || restoreCancelled) { return; }
if (!confirm('Cancel the restore that is in progress?')) { return; }
if (!confirm('Are you sure? Stopping mid-restore may leave the site partially restored.')) { return; }
restoreCancelled = true;
restoreRunning = false;
cancelBtn.disabled = true;
document.getElementById('mb-restore-status').textContent = 'Cancelling…';
// Tell the server to clean up the staging dir + session state, then leave.
if (restoreSessionId) {
postAjax({ task: 'ajax.restoreCancel', session_id: restoreSessionId }).then(leaveRestoreToList, leaveRestoreToList);
} else {
leaveRestoreToList();
}
});
}
});
function restoreComplete(result) {
var sqlErrors = (result && result.sql_errors) ? result.sql_errors : 0;
if (sqlErrors > 0) {
// Partial DB import — surface the warning and do NOT auto-return, so the
// admin actually sees that the restored database may be incomplete.
updateRestoreProgress(100, (result && result.message) || 'Restore finished, but some database statements failed.', 'complete');
setRestoreTitle('icon-warning', 'Restore Complete (with warnings)');
restoreTerminalControls();
mbShow(mbEl('mb-restore-actions'));
mbShow(mbEl('mb-restore-done'));
return;
}
updateRestoreProgress(100, 'Restore complete.', 'complete');
setRestoreTitle('icon-check', 'Restore Complete');
restoreTerminalControls();
if (mbEl('mb-restore-autocontinue') && mbEl('mb-restore-autocontinue').checked) {
setTimeout(leaveRestoreToList, 1500);
return;
}
mbShow(mbEl('mb-restore-actions'));
mbShow(mbEl('mb-restore-done'));
}
function restoreFailed(message) {
updateRestoreProgress(0, 'ERROR: ' + (message || 'Restore failed'), 'failed');
setRestoreTitle('icon-cancel', 'Restore Failed');
restoreTerminalControls();
mbShow(mbEl('mb-restore-actions'));
mbShow(mbEl('mb-restore-done'));
var retry = mbEl('mb-restore-retry');
mbShow(retry);
retry.onclick = function() { runSteppedRestore(); };
}
async function runSteppedRestore() {
var recordId = document.getElementById('mb-restore-record-id').value;
var restoreFiles = document.getElementById('mb-restore-files').checked ? 1 : 0;
var restoreDb = document.getElementById('mb-restore-db').checked ? 1 : 0;
var preserveConfig = document.getElementById('mb-restore-config').checked ? 1 : 0;
var password = document.getElementById('mb-restore-password').value;
restoreCancelled = false;
restoreSessionId = '';
restoreRunningControls();
setRestoreTitle('icon-refresh', 'Restore in Progress');
showRestoreProgress();
updateRestoreProgress(0, 'Initializing restore...', 'init');
@@ -343,47 +555,36 @@ $listDirn = $this->escape($this->state->get('list.direction'));
encryption_password: password
});
if (initResult.error) {
updateRestoreProgress(0, 'ERROR: ' + initResult.message, 'failed');
document.getElementById('mb-restore-title').textContent = 'Restore Failed';
setTimeout(hideRestoreProgress, 5000);
return;
}
if (initResult.error) { restoreFailed(initResult.message); return; }
var sessionId = initResult.session_id;
restoreSessionId = sessionId;
updateRestoreProgress(initResult.progress, initResult.message, initResult.phase);
var done = false;
while (!done) {
var stepResult = await postAjax({
task: 'ajax.restoreStep',
session_id: sessionId
});
if (stepResult.error) {
updateRestoreProgress(0, 'ERROR: ' + stepResult.message, 'failed');
document.getElementById('mb-restore-title').textContent = 'Restore Failed';
setTimeout(hideRestoreProgress, 5000);
return;
}
var lastResult = null;
while (!done && !restoreCancelled) {
var stepResult = await postAjax({ task: 'ajax.restoreStep', session_id: sessionId });
if (stepResult.error) { restoreFailed(stepResult.message); return; }
updateRestoreProgress(stepResult.progress, stepResult.message, stepResult.phase);
lastResult = stepResult;
done = stepResult.done || false;
}
document.getElementById('mb-restore-title').textContent = 'Restore Complete';
setTimeout(function() {
hideRestoreProgress();
location.reload();
}, 2000);
if (restoreCancelled) { return; }
restoreComplete(lastResult);
} catch (err) {
updateRestoreProgress(0, 'ERROR: ' + err.message, 'failed');
document.getElementById('mb-restore-title').textContent = 'Restore Failed';
setTimeout(hideRestoreProgress, 5000);
if (restoreCancelled) { return; }
restoreFailed(err.message);
}
}
function startSteppedRestore(e) {
e.preventDefault();
runSteppedRestore();
}
// Attach the AJAX restore handler to the restore form
document.addEventListener('DOMContentLoaded', function() {
var restoreForm = document.getElementById('mb-restore-form');
@@ -452,18 +653,41 @@ $listDirn = $this->escape($this->state->get('list.direction'));
</div>
<!-- Restore Progress Modal -->
<div class="modal fade" id="mb-restore-progress-modal" tabindex="-1" aria-hidden="true" data-bs-backdrop="static" data-bs-keyboard="false">
<div class="modal fade msb-progress-modal" id="mb-restore-progress-modal" tabindex="-1" aria-hidden="true" data-bs-backdrop="static" data-bs-keyboard="false">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="mb-restore-title">Restore in Progress</h5>
<h2 class="modal-title" id="mb-restore-title">
<span class="icon-refresh" aria-hidden="true" id="mb-restore-icon"></span>
<span id="mb-restore-titletext">Restore in Progress</span>
</h2>
</div>
<div class="modal-body">
<div class="progress mb-2" style="height:24px;">
<div id="mb-restore-progress-bar" class="progress-bar bg-danger" role="progressbar" style="width:0%;">0%</div>
<p class="msb-pm-warn">
<span class="icon-warning-circle" aria-hidden="true"></span>
Do not navigate away or close this window while the restore is running.
</p>
<div class="msb-rb-progress" role="progressbar" aria-valuemin="0" aria-valuemax="100" aria-valuenow="0">
<div id="mb-restore-progress-bar" class="msb-rb-bar is-animated is-danger" style="width:0%;">0%</div>
</div>
<p id="mb-restore-status" class="msb-pm-status">Initializing...</p>
<p id="mb-restore-phase" class="msb-pm-phase">Phase: init</p>
<div class="msb-pm-autocontinue" id="mb-restore-autocontinue-wrap">
<label>
<input type="checkbox" id="mb-restore-autocontinue" checked>
Automatically return to the backups list when the restore finishes
</label>
</div>
<div class="msb-pm-actions d-none" id="mb-restore-actions">
<button type="button" id="mb-restore-retry" class="btn btn-primary d-none">Retry restore</button>
<button type="button" id="mb-restore-done" class="btn btn-secondary d-none">Back to backups</button>
</div>
<div class="msb-pm-cancel" id="mb-restore-cancel-wrap">
<button type="button" id="mb-restore-cancel" class="btn btn-outline-danger">Cancel restore</button>
</div>
<p id="mb-restore-status" class="text-muted mb-1" style="font-size:0.9rem;">Initializing...</p>
<p id="mb-restore-phase" class="text-muted mb-0" style="font-size:0.8rem;">Phase: init</p>
</div>
</div>
</div>
@@ -87,6 +87,10 @@ $config = [
'viewRecord' => Text::_('COM_MOKOJOOMBACKUP_RUNBACKUP_VIEW_RECORD'),
'leaveWarn' => Text::_('COM_MOKOJOOMBACKUP_RUNBACKUP_LEAVE_WARNING'),
'continueUpdate' => Text::_('COM_MOKOJOOMBACKUP_RUNBACKUP_CONTINUE_UPDATE'),
'cancelConfirm1' => Text::_('COM_MOKOJOOMBACKUP_RUNBACKUP_CANCEL_CONFIRM1'),
'cancelConfirm2' => Text::_('COM_MOKOJOOMBACKUP_RUNBACKUP_CANCEL_CONFIRM2'),
'cancelling' => Text::_('COM_MOKOJOOMBACKUP_RUNBACKUP_CANCELLING'),
'failedNoUpdate' => Text::_('COM_MOKOJOOMBACKUP_RUNBACKUP_FAILED_NO_UPDATE'),
],
];
@@ -120,6 +124,11 @@ $this->getDocument()->addStyleSheet(Uri::root(true) . '/media/com_mokosuitebacku
<div id="msb-rb-phase" class="fw-bold mb-1"></div>
<div id="msb-rb-status" class="mb-0"><?php echo htmlspecialchars(Text::_('COM_MOKOJOOMBACKUP_RUNBACKUP_STARTING'), ENT_QUOTES, 'UTF-8'); ?></div>
<div id="msb-rb-warn" class="alert alert-warning fw-bold mt-3 mb-0" role="alert">
<span class="icon-warning" aria-hidden="true"></span>
<?php echo htmlspecialchars(Text::_('COM_MOKOJOOMBACKUP_RUNBACKUP_DONT_CLOSE'), ENT_QUOTES, 'UTF-8'); ?>
</div>
<?php if ($safeReturnUrl !== '') : ?>
<!-- Pre-update/uninstall flow: opt in to auto-continue, or get a manual "Continue" button on completion. -->
<div class="form-check mt-3" id="msb-rb-autocontinue-wrap">
@@ -132,10 +141,17 @@ $this->getDocument()->addStyleSheet(Uri::root(true) . '/media/com_mokosuitebacku
<div id="msb-rb-actions" class="d-none mt-3">
<button type="button" id="msb-rb-retry" class="btn btn-primary d-none"></button>
<a href="#" id="msb-rb-record" class="btn btn-primary d-none"></a>
<a href="#" id="msb-rb-record" class="btn btn-primary d-none" target="_blank" rel="noopener"></a>
<a href="#" id="msb-rb-continue" class="btn btn-warning d-none"></a>
<a href="<?php echo $dashboardUrl; ?>" id="msb-rb-dashboard" class="btn btn-secondary d-none"></a>
</div>
<!-- Cancel is available while the backup is running; requires a double confirmation. -->
<div id="msb-rb-cancel-wrap" class="mt-3">
<button type="button" id="msb-rb-cancel" class="btn btn-outline-danger">
<?php echo htmlspecialchars(Text::_('COM_MOKOJOOMBACKUP_RUNBACKUP_CANCEL'), ENT_QUOTES, 'UTF-8'); ?>
</button>
</div>
</div>
<!-- Full-bleed progress bar, edge-to-edge along the bottom of the card. -->
@@ -152,6 +168,7 @@ $this->getDocument()->addStyleSheet(Uri::root(true) . '/media/com_mokosuitebacku
var CFG = <?php echo json_encode($config); ?>;
var L = CFG.labels || {};
var running = false;
var cancelled = false;
var lastRecordId = 0;
var el = {
@@ -159,6 +176,7 @@ $this->getDocument()->addStyleSheet(Uri::root(true) . '/media/com_mokosuitebacku
track: document.querySelector('.msb-rb-progress'),
phase: document.getElementById('msb-rb-phase'),
status: document.getElementById('msb-rb-status'),
warn: document.getElementById('msb-rb-warn'),
title: document.getElementById('msb-rb-title'),
actions: document.getElementById('msb-rb-actions'),
retry: document.getElementById('msb-rb-retry'),
@@ -166,9 +184,38 @@ $this->getDocument()->addStyleSheet(Uri::root(true) . '/media/com_mokosuitebacku
continue: document.getElementById('msb-rb-continue'),
dashboard: document.getElementById('msb-rb-dashboard'),
autocontinue: document.getElementById('msb-rb-autocontinue'),
autocontinueWrap: document.getElementById('msb-rb-autocontinue-wrap')
autocontinueWrap: document.getElementById('msb-rb-autocontinue-wrap'),
cancel: document.getElementById('msb-rb-cancel'),
cancelWrap: document.getElementById('msb-rb-cancel-wrap')
};
function hideCancel() { if (el.cancelWrap) { el.cancelWrap.classList.add('d-none'); } }
/* Cancel the running backup — requires DOUBLE confirmation. Stops the step
loop, tells the server to cancel the record, and leaves to the dashboard
WITHOUT continuing any pending update. */
if (el.cancel) {
el.cancel.addEventListener('click', function () {
if (!running || cancelled) { return; }
if (!window.confirm(L.cancelConfirm1 || 'Cancel the backup that is in progress?')) { return; }
if (!window.confirm(L.cancelConfirm2 || 'Are you sure? The backup will be stopped and the update will NOT continue.')) { return; }
cancelled = true;
running = false; /* also disarms the leave-warning */
el.cancel.disabled = true;
if (el.warn) { el.warn.classList.add('d-none'); }
setStatus(L.cancelling || 'Cancelling…');
var go = function () { window.location.href = CFG.dashboardUrl; };
/* Best-effort server-side cancel of the record, then leave. */
if (lastRecordId > 0) {
post({ task: 'ajax.cancelBackup', id: lastRecordId }).then(go, go);
} else {
go();
}
});
}
function setBar(pct, striped) {
pct = Math.max(0, Math.min(100, parseInt(pct, 10) || 0));
el.bar.style.width = pct + '%';
@@ -204,6 +251,8 @@ $this->getDocument()->addStyleSheet(Uri::root(true) . '/media/com_mokosuitebacku
el.bar.classList.add('is-success');
setBar(100, false);
el.title.textContent = L.complete || 'Backup complete';
if (el.warn) { el.warn.classList.add('d-none'); }
hideCancel();
/* Set the one-shot skip flag for THIS action so the following
server-side onExtensionBefore(Update|Uninstall) backup is skipped once
@@ -254,30 +303,34 @@ $this->getDocument()->addStyleSheet(Uri::root(true) . '/media/com_mokosuitebacku
running = false;
el.bar.classList.remove('is-animated');
el.bar.classList.add('is-danger');
if (el.warn) { el.warn.classList.add('d-none'); }
el.title.textContent = L.failed || 'Backup failed';
setStatus(message || 'Backup failed');
/* Pre-update/uninstall flow: the backup failed, so the update must NOT
run. We deliberately do NOT offer a "continue without backup" option
here — the only ways forward are to retry the backup or go back. */
setStatus(CFG.returnUrl ? (L.failedNoUpdate || 'Backup failed — the update was not started. Retry the backup, or go back without updating.') : (message || 'Backup failed'));
hideCancel();
showActions();
el.retry.textContent = L.retry || 'Retry';
el.retry.classList.remove('d-none');
el.retry.onclick = function () {
el.retry.classList.add('d-none');
el.continue.classList.add('d-none');
el.bar.classList.remove('is-danger');
el.bar.classList.add('is-animated');
run();
};
if (CFG.returnUrl) {
showBtn(el.continue, L.continue || 'Continue without backup', CFG.returnUrl);
}
showBtn(el.dashboard, L.dashboard || 'Back to dashboard', CFG.dashboardUrl);
}
async function run() {
if (running) { return; }
running = true;
cancelled = false;
el.actions.classList.add('d-none');
if (el.cancelWrap) { el.cancelWrap.classList.remove('d-none'); }
if (el.cancel) { el.cancel.disabled = false; }
setBar(0, true);
setPhase('');
setStatus(L.starting || 'Starting backup…');
@@ -295,8 +348,12 @@ $this->getDocument()->addStyleSheet(Uri::root(true) . '/media/com_mokosuitebacku
if (init.record_id) { lastRecordId = init.record_id; }
var done = false;
while (!done) {
var step = await post({ task: 'ajax.step', session_id: init.session_id });
while (!done && !cancelled) {
/* Pass the pre-action so the SERVER can arm the one-shot skip flag the
moment this backup completes (see AjaxController::step) — the black
"update is running" overlay then never fronts a second backup even
if the completion ack POST below fails to land. */
var step = await post({ task: 'ajax.step', session_id: init.session_id, action: CFG.action || '' });
if (!step || step.error) {
throw new Error((step && step.message) || 'Backup step failed');
@@ -309,16 +366,27 @@ $this->getDocument()->addStyleSheet(Uri::root(true) . '/media/com_mokosuitebacku
done = step.done || false;
}
/* User cancelled mid-flight — the click handler already stops the
loop and navigates away, so do not fall through to completion. */
if (cancelled) { return; }
running = false;
await onComplete();
} catch (err) {
if (cancelled) { return; }
onError(err && err.message ? err.message : String(err));
}
}
/* Warn before leaving while a backup is mid-flight. */
/* Warn before closing/leaving the window while a backup is mid-flight.
Return the message too — some browsers need it, others show a generic
prompt, but any non-empty value triggers the confirmation dialog. */
window.addEventListener('beforeunload', function (e) {
if (running) { e.preventDefault(); e.returnValue = L.leaveWarn || ''; }
if (!running) { return undefined; }
var msg = L.leaveWarn || 'A backup is in progress. Leaving now will cancel it.';
e.preventDefault();
e.returnValue = msg;
return msg;
});
if (CFG.autostart) {
@@ -447,7 +447,7 @@ $listDirn = $this->escape($this->state->get('list.direction'));
var label = document.createElement('label');
label.className = 'form-check-label';
label.setAttribute('for', 'mb-rtype-' + type);
label.textContent = typeLabels[TYPE] || type;
label.textContent = typeLabels[type] || type;
div.appendChild(input);
div.appendChild(label);
@@ -8,7 +8,7 @@
-->
<extension type="module" client="administrator" method="upgrade">
<name>Module - MokoSuiteBackup - cPanel</name>
<version>02.62.00</version>
<version>02.67.00</version>
<creationDate>2026-06-23</creationDate>
<author>Moko Consulting</author>
<authorEmail>hello@mokoconsulting.tech</authorEmail>
@@ -7,7 +7,7 @@
-->
<extension type="plugin" group="actionlog" method="upgrade">
<name>Action Log - MokoSuiteBackup</name>
<version>02.62.00</version>
<version>02.67.00</version>
<creationDate>2026-06-04</creationDate>
<author>Moko Consulting</author>
<authorEmail>hello@mokoconsulting.tech</authorEmail>
@@ -7,7 +7,7 @@
-->
<extension type="plugin" group="console" method="upgrade">
<name>Console - MokoSuiteBackup</name>
<version>02.62.00</version>
<version>02.67.00</version>
<creationDate>2026-06-04</creationDate>
<author>Moko Consulting</author>
<authorEmail>hello@mokoconsulting.tech</authorEmail>
@@ -7,7 +7,7 @@
-->
<extension type="plugin" group="content" method="upgrade">
<name>Content - MokoSuiteBackup</name>
<version>02.62.00</version>
<version>02.67.00</version>
<creationDate>2026-06-04</creationDate>
<author>Moko Consulting</author>
<authorEmail>hello@mokoconsulting.tech</authorEmail>
@@ -1,7 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?>
<extension type="plugin" group="quickicon" method="upgrade">
<name>Quick Icon - MokoSuiteBackup</name>
<version>02.62.00</version>
<version>02.67.00</version>
<creationDate>2026-06-02</creationDate>
<author>Moko Consulting</author>
<authorEmail>hello@mokoconsulting.tech</authorEmail>
@@ -39,6 +39,34 @@ final class MokoSuiteBackupQuickicon extends CMSPlugin implements SubscriberInte
return;
}
// A dashboard quick-icon must NEVER be able to crash the whole admin page.
// Build the icon defensively; on any failure, contribute nothing and log.
try {
$icon = $this->buildStatusIcon();
} catch (\Throwable $e) {
error_log('MokoSuiteBackup quickicon widget failed: ' . $e->getMessage());
return;
}
// The module (mod_quickicon) expects each plugin to append a LIST of icons
// to the 'result' argument; it then iterates that list and array_merge()s a
// defaults array into each icon. Appending a bare icon dict instead of a
// [ list ] makes it iterate the dict's string values and crash in
// array_merge — so the wrapping [$icon] here is load-bearing.
$result = $event->getArgument('result', []);
$result[] = [$icon];
$event->setArgument('result', $result);
}
/**
* Build the single status quick-icon (backup freshness / failures).
*
* @return array A mod_quickicon icon definition.
*/
private function buildStatusIcon(): array
{
$db = Factory::getDbo();
// Get last completed backup
@@ -91,20 +119,15 @@ final class MokoSuiteBackupQuickicon extends CMSPlugin implements SubscriberInte
$subtitle = $ago . ' | ' . ($stats->total ?? 0) . ' backups | ' . $sizeTotal . ' MB';
}
$result = $event->getArgument('result', []);
$result[] = [
[
'link' => 'index.php?option=com_mokosuitebackup&view=backups',
'image' => $warning ? 'icon-warning' : 'icon-database',
'icon' => $warning ? 'icon-warning' : 'icon-database',
'text' => Text::_($text),
'linkadd' => $subtitle ? '<br><small>' . htmlspecialchars($subtitle) . '</small>' : '',
'id' => 'plg_quickicon_mokosuitebackup',
'group' => 'MOD_QUICKICON_MAINTENANCE',
],
return [
'link' => 'index.php?option=com_mokosuitebackup&view=backups',
'image' => $warning ? 'icon-warning' : 'icon-database',
'icon' => $warning ? 'icon-warning' : 'icon-database',
'text' => Text::_($text),
'linkadd' => $subtitle ? '<br><small>' . htmlspecialchars($subtitle) . '</small>' : '',
'id' => 'plg_quickicon_mokosuitebackup',
'group' => 'MOD_QUICKICON_MAINTENANCE',
];
$event->setArgument('result', $result);
}
private function timeAgo(string $datetime): string
@@ -14,3 +14,6 @@ PLG_SYSTEM_MOKOJOOMBACKUP_BACKING_UP="Backing up…"
PLG_SYSTEM_MOKOJOOMBACKUP_BACKUP_COMPLETE="Backup complete — safe to update."
PLG_SYSTEM_MOKOJOOMBACKUP_BACKUP_FAILED="Backup failed"
PLG_SYSTEM_MOKOJOOMBACKUP_BACKUP_STARTING="Starting backup…"
PLG_SYSTEM_MOKOSUITEBACKUP_UPDATE_RUNNING="Backup complete. The update is now running — please wait and do not close this window…"
PLG_SYSTEM_MOKOSUITEBACKUP_LOCK_TITLE="Backup in progress"
PLG_SYSTEM_MOKOSUITEBACKUP_LOCK_MESSAGE="A MokoSuiteBackup is currently running in another window. The administrator area is locked until it finishes — this page will refresh automatically. Please do not make changes while the backup runs."
@@ -14,3 +14,6 @@ PLG_SYSTEM_MOKOJOOMBACKUP_BACKING_UP="Backing up…"
PLG_SYSTEM_MOKOJOOMBACKUP_BACKUP_COMPLETE="Backup complete — safe to update."
PLG_SYSTEM_MOKOJOOMBACKUP_BACKUP_FAILED="Backup failed"
PLG_SYSTEM_MOKOJOOMBACKUP_BACKUP_STARTING="Starting backup…"
PLG_SYSTEM_MOKOSUITEBACKUP_UPDATE_RUNNING="Backup complete. The update is now running — please wait and do not close this window…"
PLG_SYSTEM_MOKOSUITEBACKUP_LOCK_TITLE="Backup in progress"
PLG_SYSTEM_MOKOSUITEBACKUP_LOCK_MESSAGE="A MokoSuiteBackup is currently running in another window. The administrator area is locked until it finishes — this page will refresh automatically. Please do not make changes while the backup runs."
@@ -101,6 +101,31 @@
window.location.href = target;
}, true);
/* Full-screen "update is running" overlay shown while the re-fired update
POST processes, so the user knows it is working and not to close/switch. */
function showUpdateOverlay() {
if (document.getElementById('msb-update-overlay')) { return; }
var o = document.createElement('div');
o.id = 'msb-update-overlay';
o.setAttribute('role', 'status');
o.style.cssText = 'position:fixed;inset:0;z-index:2147483647;background:#000;color:#fff;'
+ 'display:flex;flex-direction:column;align-items:center;justify-content:center;'
+ 'text-align:center;padding:2rem;font-size:1.15rem;font-weight:600;';
var spin = document.createElement('span');
spin.className = 'spinner-border mb-3';
spin.setAttribute('aria-hidden', 'true');
var msg = document.createElement('div');
msg.textContent = (cfg && cfg.updateRunningText)
|| 'Backup complete. The update is now running — please wait and do not close this window…';
o.appendChild(spin);
o.appendChild(msg);
document.body.appendChild(o);
}
/* On return from the backup screen: restore the selection and re-fire. */
function resume() {
var params = new URLSearchParams(window.location.search);
@@ -131,20 +156,40 @@
if (box) { box.checked = true; }
});
/* Joomla's list-check reads boxchecked — set it so the re-fire passes. */
/* Restore boxchecked AND fire its `change` event. Joomla's toolbar web
component (joomla-toolbar-button) keeps a list-selection button
DISABLED until boxchecked changes via a change event — setting the
value alone leaves the button disabled, so executeTask() would no-op
and the update never submits (it just returns to the list). */
var bc = form.querySelector('input[name="boxchecked"]');
if (bc) { bc.value = (data.cids || []).length; }
if (bc) {
bc.value = (data.cids || []).length;
bc.dispatchEvent(new Event('change', { bubbles: true }));
}
}
window.__msbResuming = true;
var btn = document.querySelector('[task="' + data.task + '"]');
/* Show a full-screen "update is running" overlay so the user gets clear
feedback that the update is proceeding while the re-fired POST runs.
It persists until the page navigates away (the update finishes and
redirects), matching the black full-screen backup screen. */
showUpdateOverlay();
if (btn) {
window.setTimeout(function () { btn.click(); }, 300);
} else if (window.Joomla && typeof Joomla.submitbutton === 'function') {
Joomla.submitbutton(data.task);
}
/* Re-fire the toolbar action. The `task` attribute is on the OUTER
<joomla-toolbar-button>, but its click handler is bound to the INNER
<button>/<a>, so click that inner element (clicking the wrapper does
nothing). Fall back to Joomla.submitbutton if the button isn't found. */
window.setTimeout(function () {
var wc = document.querySelector('[task="' + data.task + '"]');
var inner = wc ? (wc.querySelector('button, a') || wc) : null;
if (inner) {
inner.click();
} else if (window.Joomla && typeof Joomla.submitbutton === 'function') {
Joomla.submitbutton(data.task);
}
}, 300);
}
if (document.readyState === 'loading') {
@@ -7,7 +7,7 @@
-->
<extension type="plugin" group="system" method="upgrade">
<name>System - MokoSuiteBackup</name>
<version>02.62.00</version>
<version>02.67.00</version>
<creationDate>2026-06-02</creationDate>
<author>Moko Consulting</author>
<authorEmail>hello@mokoconsulting.tech</authorEmail>
@@ -15,6 +15,7 @@ defined('_JEXEC') or die;
use Joomla\CMS\Component\ComponentHelper;
use Joomla\CMS\Document\HtmlDocument;
use Joomla\CMS\Factory;
use Joomla\CMS\Language\Text;
use Joomla\CMS\Plugin\CMSPlugin;
use Joomla\CMS\Router\Route;
use Joomla\CMS\Session\Session;
@@ -34,6 +35,17 @@ final class MokoSuiteBackup extends CMSPlugin implements SubscriberInterface
/** @var array<string,bool> Per-request cache of the one-shot "client already backed up" skip flag. */
private array $preactionSkip = [];
/**
* Max age (seconds) of the client "already backed up" skip flag that this
* plugin will honour. The flag stores the unix time it was armed (by the
* full-screen backup's ajax.step completion / ajax.preupdateAck); the re-fired
* update/uninstall follows within a second or two, so a small window is ample.
* Bounding it means a backup that was run then ABANDONED (browser closed before
* the update re-fired) cannot silently suppress a genuine, unrelated pre-action
* backup minutes or hours later in the same session.
*/
private const PREACTION_SKIP_TTL = 300;
public static function getSubscribedEvents(): array
{
return [
@@ -132,6 +144,10 @@ final class MokoSuiteBackup extends CMSPlugin implements SubscriberInterface
return;
}
// While a backup is actively running, lock the admin UI so no other
// window/page can navigate or change anything mid-backup. May close().
$this->maybeLockAdminDuringBackup();
// Pre-update: send Joomla core-update installs through the full-screen
// backup page BEFORE the update runs (may not return from here).
$this->maybeRedirectForUpdate();
@@ -154,6 +170,97 @@ final class MokoSuiteBackup extends CMSPlugin implements SubscriberInterface
$this->cleanupOldSnapshots();
}
/**
* Detect whether a stepped backup is actively running right now.
*
* The stepped-backup engine rewrites its session JSON file
* (JPATH_ROOT/tmp/mokosuitebackup-sessions/mb_*.json) on every step and
* deletes it on completion, so a file whose mtime is within the last ~45s
* is a reliable, cross-session heartbeat. A crashed/abandoned backup goes
* stale and stops locking on its own — the admin is never permanently locked.
*/
private function backupIsActive(): bool
{
$dir = JPATH_ROOT . '/tmp/mokosuitebackup-sessions';
if (!is_dir($dir)) {
return false;
}
$cutoff = time() - 45;
foreach (glob($dir . '/mb_*.json') ?: [] as $file) {
if (@filemtime($file) >= $cutoff) {
return true;
}
}
return false;
}
/**
* While a backup is actively running, lock the admin UI: every admin page
* load (except the full-screen backup screen and the login/logout page)
* gets a "backup in progress" interstitial that auto-refreshes, so no other
* window can navigate or change anything mid-backup. Gated by the
* lock_admin_during_backup option; auto-released when the backup ends.
*/
private function maybeLockAdminDuringBackup(): void
{
if (!(int) ComponentHelper::getParams('com_mokosuitebackup')->get('lock_admin_during_backup', 1)) {
return;
}
$app = $this->getApplication();
$input = $app->getInput();
$option = $input->getCmd('option', '');
$view = $input->getCmd('view', '');
// Never lock the full-screen backup screen (it IS the backup) nor login/logout.
if (($option === 'com_mokosuitebackup' && $view === 'runbackup') || $option === 'com_login') {
return;
}
if (!$this->backupIsActive()) {
return;
}
$this->renderBackupLockPage();
$app->close();
}
/**
* Emit a self-contained "backup in progress" interstitial that refreshes
* itself (meta refresh) until the backup finishes and the lock releases.
*/
private function renderBackupLockPage(): void
{
$app = $this->getApplication();
$title = Text::_('PLG_SYSTEM_MOKOSUITEBACKUP_LOCK_TITLE');
$message = Text::_('PLG_SYSTEM_MOKOSUITEBACKUP_LOCK_MESSAGE');
$t = htmlspecialchars($title, ENT_QUOTES, 'UTF-8');
$m = htmlspecialchars($message, ENT_QUOTES, 'UTF-8');
$html = '<!doctype html><html lang="en"><head><meta charset="utf-8">'
. '<meta name="viewport" content="width=device-width, initial-scale=1">'
. '<meta http-equiv="refresh" content="8">'
. '<title>' . $t . '</title><style>'
. 'html,body{height:100%;margin:0}'
. 'body{background:#000;color:#fff;font-family:system-ui,-apple-system,"Segoe UI",Roboto,sans-serif;'
. 'display:flex;align-items:center;justify-content:center;text-align:center}'
. '.w{max-width:34rem;padding:2rem}'
. '.s{width:3rem;height:3rem;margin:0 auto 1.5rem;border:.35rem solid rgba(255,255,255,.2);'
. 'border-top-color:#fff;border-radius:50%;animation:sp 1s linear infinite}'
. 'h1{font-size:1.5rem;margin:.5rem 0}p{opacity:.85;line-height:1.55}'
. '@keyframes sp{to{transform:rotate(360deg)}}'
. '</style></head><body><div class="w"><div class="s"></div>'
. '<h1>' . $t . '</h1><p>' . $m . '</p></div></body></html>';
$app->setHeader('Content-Type', 'text/html; charset=utf-8', true);
$app->sendHeaders();
echo $html;
}
/**
* Inject the client-side interceptor on the Extensions → Update and
* Extensions → Manage pages, so an EXTENSION update / uninstall also runs
@@ -243,6 +350,7 @@ final class MokoSuiteBackup extends CMSPlugin implements SubscriberInterface
'backupBeforeUpdate' => $view === 'update' && (bool) $beforeUpdate,
'backupBeforeUninstall' => $view === 'manage' && (bool) $beforeUninst,
'recentBackup' => false,
'updateRunningText' => Text::_('PLG_SYSTEM_MOKOSUITEBACKUP_UPDATE_RUNNING'),
]);
$doc->addScript(Uri::root(true) . '/media/plg_system_mokosuitebackup/js/installer-backup.js', [], ['defer' => true]);
@@ -551,12 +659,16 @@ JS;
$skipKey = 'mokosuitebackup.skip_preaction_' . $paramName;
if (!array_key_exists($paramName, $this->preactionSkip)) {
$this->preactionSkip[$paramName] = (bool) $session->get($skipKey, false);
$session->set($skipKey, false);
/* The flag stores the unix time it was armed. Consume it one-shot (reset
to 0) and honour the skip ONLY if it was armed within the TTL — a
stale flag from an abandoned backup must not suppress a real one. */
$armedAt = (int) $session->get($skipKey, 0);
$session->set($skipKey, 0);
$this->preactionSkip[$paramName] = ($armedAt > 0 && (time() - $armedAt) <= self::PREACTION_SKIP_TTL);
}
if ($this->preactionSkip[$paramName]) {
// Client already backed up; skip this whole re-fired (possibly batch) request.
// Client already backed up (recently); skip this re-fired (possibly batch) request.
$this->preactionRan[$paramName] = true;
return;
@@ -5,7 +5,7 @@
* "MokoSuiteBackup: Run Backup Profile" task.
-->
<form>
<fieldset name="run_profile">
<fieldset name="run_profile" label="PLG_TASK_MOKOJOOMBACKUP_FIELDSET_RUN_PROFILE">
<field
name="profile_id"
type="sql"

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