refactor(cli)!: hard-cut legacy scripts and standardize console runtime
- remove legacy bin/module-*.php and bin/doctor.php entry scripts - move module/doctor execution into class-based runners under lib/Console - add stable JSON output for doctor and module:sync - introduce bin/dev for init/up/down/logs/console/qa workflow - update DI wiring, phpstan scan config, tests, and docs to new CLI contract
This commit is contained in:
@@ -90,7 +90,7 @@ db/updates/ # Idempotent SQL update scripts for existing installs
|
|||||||
tests/ # PHPUnit tests (namespace MintyPHP\Tests\)
|
tests/ # PHPUnit tests (namespace MintyPHP\Tests\)
|
||||||
docs/ # German-language documentation (Markdown)
|
docs/ # German-language documentation (Markdown)
|
||||||
i18n/ # Translation files (de, en)
|
i18n/ # Translation files (de, en)
|
||||||
bin/ # CLI entry point (bin/console) + legacy scripts + shell tools
|
bin/ # CLI entry points (bin/console, bin/dev) + shell tools
|
||||||
docker/ # Dockerfiles, Nginx configs, PHP configs
|
docker/ # Dockerfiles, Nginx configs, PHP configs
|
||||||
.agents/ # Agent workflow system (contracts, prompts, checks, skills, runs)
|
.agents/ # Agent workflow system (contracts, prompts, checks, skills, runs)
|
||||||
```
|
```
|
||||||
@@ -118,7 +118,7 @@ docker/ # Dockerfiles, Nginx configs, PHP configs
|
|||||||
- **UI integration:** Only via platform slots (`aside.tab_panel`, `topbar.right_item`, `layout.head_style`, `runtime.component`, etc.) — no core template hardcoding
|
- **UI integration:** Only via platform slots (`aside.tab_panel`, `topbar.right_item`, `layout.head_style`, `runtime.component`, etc.) — no core template hardcoding
|
||||||
- **Session keys:** Prefixed with `module.<id>.*`
|
- **Session keys:** Prefixed with `module.<id>.*`
|
||||||
- **Activation:** `config/modules.php` or `APP_ENABLED_MODULES` env override
|
- **Activation:** `config/modules.php` or `APP_ENABLED_MODULES` env override
|
||||||
- **Runtime sync:** `php bin/module-runtime-sync.php` after any module/manifest change (builds page symlinks, syncs permissions, publishes assets)
|
- **Runtime sync:** `php bin/console module:sync` after any module/manifest change (builds page symlinks, syncs permissions, publishes assets)
|
||||||
- **Fingerprint guard:** `web/index.php` validates runtime state matches active modules; fails fast if stale
|
- **Fingerprint guard:** `web/index.php` validates runtime state matches active modules; fails fast if stale
|
||||||
- **API isolation:** API requests (`api/` prefix) skip session/auth/cookie flows entirely; modules using API endpoints rely on `ApiBootstrap.php`
|
- **API isolation:** API requests (`api/` prefix) skip session/auth/cookie flows entirely; modules using API endpoints rely on `ApiBootstrap.php`
|
||||||
- **Cross-module coupling:** Modules may depend on other modules but MUST declare `requires: [<module-id>]` in their manifest. Undeclared cross-module imports are forbidden. Prefer loose coupling via events and providers over direct service injection
|
- **Cross-module coupling:** Modules may depend on other modules but MUST declare `requires: [<module-id>]` in their manifest. Undeclared cross-module imports are forbidden. Prefer loose coupling via events and providers over direct service injection
|
||||||
|
|||||||
10
README.md
10
README.md
@@ -52,6 +52,16 @@ docker compose up --build -d
|
|||||||
docker compose down
|
docker compose down
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Alternativ ueber den Dev-Orchestrator:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
bin/dev init
|
||||||
|
bin/dev up
|
||||||
|
bin/dev down
|
||||||
|
bin/dev console list
|
||||||
|
bin/dev qa
|
||||||
|
```
|
||||||
|
|
||||||
## Produktion (Docker)
|
## Produktion (Docker)
|
||||||
|
|
||||||
Es gibt eine getrennte Produktions-Compose:
|
Es gibt eine getrennte Produktions-Compose:
|
||||||
|
|||||||
141
bin/dev
Executable file
141
bin/dev
Executable file
@@ -0,0 +1,141 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
script_dir="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
repo_root="$(cd -- "${script_dir}/.." && pwd)"
|
||||||
|
cd "${repo_root}"
|
||||||
|
|
||||||
|
usage() {
|
||||||
|
cat <<'USAGE'
|
||||||
|
Usage: bin/dev <command> [args...]
|
||||||
|
|
||||||
|
Commands:
|
||||||
|
init First-time, non-destructive setup
|
||||||
|
up Start local containers
|
||||||
|
down Stop local containers
|
||||||
|
logs [service] Follow docker compose logs (optional service)
|
||||||
|
console <args...> Run php bin/console inside php container
|
||||||
|
qa Run mandatory quality gates (QG-001/002/003/006)
|
||||||
|
USAGE
|
||||||
|
}
|
||||||
|
|
||||||
|
require_docker_compose() {
|
||||||
|
if ! command -v docker >/dev/null 2>&1; then
|
||||||
|
echo "[ERROR] docker is required." >&2
|
||||||
|
exit 2
|
||||||
|
fi
|
||||||
|
docker compose version >/dev/null 2>&1 || {
|
||||||
|
echo "[ERROR] docker compose is required." >&2
|
||||||
|
exit 2
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
wait_for_php_container() {
|
||||||
|
local max_attempts=60
|
||||||
|
local attempt=1
|
||||||
|
while (( attempt <= max_attempts )); do
|
||||||
|
if docker compose exec -T php php -v >/dev/null 2>&1; then
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
sleep 1
|
||||||
|
attempt=$((attempt + 1))
|
||||||
|
done
|
||||||
|
|
||||||
|
echo "[ERROR] php container did not become ready in time." >&2
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
|
run_console() {
|
||||||
|
if [[ $# -eq 0 ]]; then
|
||||||
|
echo "[ERROR] console requires arguments, e.g. bin/dev console list" >&2
|
||||||
|
exit 2
|
||||||
|
fi
|
||||||
|
docker compose exec -T php php bin/console "$@"
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd_init() {
|
||||||
|
require_docker_compose
|
||||||
|
|
||||||
|
if [[ ! -f .env ]]; then
|
||||||
|
if [[ ! -f .env.example ]]; then
|
||||||
|
echo "[ERROR] .env missing and .env.example not found." >&2
|
||||||
|
exit 2
|
||||||
|
fi
|
||||||
|
cp .env.example .env
|
||||||
|
echo "[INFO] created .env from .env.example"
|
||||||
|
fi
|
||||||
|
|
||||||
|
docker compose up --build -d
|
||||||
|
wait_for_php_container
|
||||||
|
|
||||||
|
run_console module:sync
|
||||||
|
run_console doctor
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd_up() {
|
||||||
|
require_docker_compose
|
||||||
|
docker compose up -d
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd_down() {
|
||||||
|
require_docker_compose
|
||||||
|
docker compose down
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd_logs() {
|
||||||
|
require_docker_compose
|
||||||
|
if [[ $# -gt 1 ]]; then
|
||||||
|
echo "[ERROR] logs accepts at most one optional service name." >&2
|
||||||
|
exit 2
|
||||||
|
fi
|
||||||
|
if [[ $# -eq 1 ]]; then
|
||||||
|
docker compose logs -f "$1"
|
||||||
|
else
|
||||||
|
docker compose logs -f
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd_qa() {
|
||||||
|
require_docker_compose
|
||||||
|
docker compose exec -T php vendor/bin/phpunit
|
||||||
|
docker compose exec -T php vendor/bin/phpstan analyse -c phpstan.neon --no-progress
|
||||||
|
docker compose exec -T php vendor/bin/phpunit tests/Architecture/CoreStarterkitContractTest.php
|
||||||
|
docker compose exec -T php vendor/bin/php-cs-fixer fix --config=tools/php-cs-fixer/.php-cs-fixer.dist.php --dry-run --diff --verbose
|
||||||
|
}
|
||||||
|
|
||||||
|
if [[ $# -eq 0 ]]; then
|
||||||
|
usage
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
command="$1"
|
||||||
|
shift || true
|
||||||
|
|
||||||
|
case "${command}" in
|
||||||
|
init)
|
||||||
|
cmd_init "$@"
|
||||||
|
;;
|
||||||
|
up)
|
||||||
|
cmd_up "$@"
|
||||||
|
;;
|
||||||
|
down)
|
||||||
|
cmd_down "$@"
|
||||||
|
;;
|
||||||
|
logs)
|
||||||
|
cmd_logs "$@"
|
||||||
|
;;
|
||||||
|
console)
|
||||||
|
run_console "$@"
|
||||||
|
;;
|
||||||
|
qa)
|
||||||
|
cmd_qa "$@"
|
||||||
|
;;
|
||||||
|
-h|--help|help)
|
||||||
|
usage
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
echo "[ERROR] unknown command: ${command}" >&2
|
||||||
|
usage
|
||||||
|
exit 2
|
||||||
|
;;
|
||||||
|
esac
|
||||||
312
bin/doctor.php
312
bin/doctor.php
@@ -1,312 +0,0 @@
|
|||||||
#!/usr/bin/env php
|
|
||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
use MintyPHP\App\AppContainer;
|
|
||||||
use MintyPHP\App\Bootstrap\EnvValidator;
|
|
||||||
use MintyPHP\DB;
|
|
||||||
use MintyPHP\Service\Access\AuthorizationService;
|
|
||||||
use MintyPHP\Service\Access\PermissionService;
|
|
||||||
use MintyPHP\Service\Access\UiAccessService;
|
|
||||||
use MintyPHP\Service\Audit\SystemAuditService;
|
|
||||||
use MintyPHP\Service\Auth\AuthService;
|
|
||||||
|
|
||||||
error_reporting(E_ALL & ~E_DEPRECATED & ~E_USER_DEPRECATED);
|
|
||||||
ini_set('display_errors', '0');
|
|
||||||
|
|
||||||
require_once __DIR__ . '/cli-bootstrap.php';
|
|
||||||
|
|
||||||
/** @var AppContainer $container */
|
|
||||||
$container = cliBootstrapApp('cli', 'bin/doctor.php');
|
|
||||||
$startedAt = microtime(true);
|
|
||||||
|
|
||||||
/** @var list<array{status: string, name: string, message: string}> $results */
|
|
||||||
$results = [];
|
|
||||||
$failCount = 0;
|
|
||||||
$warnCount = 0;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param callable():array{status: string, message: string} $check
|
|
||||||
*/
|
|
||||||
$runCheck = static function (string $name, callable $check) use (&$results, &$failCount, &$warnCount): void {
|
|
||||||
try {
|
|
||||||
$result = $check();
|
|
||||||
} catch (Throwable $exception) {
|
|
||||||
$result = [
|
|
||||||
'status' => 'fail',
|
|
||||||
'message' => $exception->getMessage(),
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
$status = strtolower(trim((string) ($result['status'] ?? 'fail')));
|
|
||||||
if (!in_array($status, ['ok', 'warn', 'fail'], true)) {
|
|
||||||
$status = 'fail';
|
|
||||||
}
|
|
||||||
|
|
||||||
$message = trim((string) ($result['message'] ?? ''));
|
|
||||||
if ($message === '') {
|
|
||||||
$message = 'no details';
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($status === 'fail') {
|
|
||||||
$failCount++;
|
|
||||||
} elseif ($status === 'warn') {
|
|
||||||
$warnCount++;
|
|
||||||
}
|
|
||||||
|
|
||||||
$results[] = [
|
|
||||||
'status' => $status,
|
|
||||||
'name' => $name,
|
|
||||||
'message' => $message,
|
|
||||||
];
|
|
||||||
};
|
|
||||||
|
|
||||||
$runCheck('Environment validation', static function (): array {
|
|
||||||
EnvValidator::validate();
|
|
||||||
return [
|
|
||||||
'status' => 'ok',
|
|
||||||
'message' => 'all required env keys and formats are valid',
|
|
||||||
];
|
|
||||||
});
|
|
||||||
|
|
||||||
$runCheck('App container bootstrap', static function () use ($container): array {
|
|
||||||
if (!$container instanceof AppContainer) {
|
|
||||||
return [
|
|
||||||
'status' => 'fail',
|
|
||||||
'message' => 'registerContainer.php did not return AppContainer',
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
app(AuthService::class);
|
|
||||||
app(AuthorizationService::class);
|
|
||||||
app(UiAccessService::class);
|
|
||||||
|
|
||||||
return [
|
|
||||||
'status' => 'ok',
|
|
||||||
'message' => 'core services resolved successfully',
|
|
||||||
];
|
|
||||||
});
|
|
||||||
|
|
||||||
$runCheck('Database connectivity', static function (): array {
|
|
||||||
$pong = DB::selectValue('select 1');
|
|
||||||
if ((int) $pong !== 1) {
|
|
||||||
return [
|
|
||||||
'status' => 'fail',
|
|
||||||
'message' => 'select 1 did not return expected value',
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
return [
|
|
||||||
'status' => 'ok',
|
|
||||||
'message' => 'connection established',
|
|
||||||
];
|
|
||||||
});
|
|
||||||
|
|
||||||
$runCheck('Database schema basics', static function (): array {
|
|
||||||
$requiredTables = [
|
|
||||||
'users',
|
|
||||||
'roles',
|
|
||||||
'permissions',
|
|
||||||
'user_roles',
|
|
||||||
'role_permissions',
|
|
||||||
'tenants',
|
|
||||||
'departments',
|
|
||||||
'settings',
|
|
||||||
'scheduler_runtime_status',
|
|
||||||
];
|
|
||||||
|
|
||||||
$rows = DB::select(
|
|
||||||
'select table_name from information_schema.tables where table_schema = database() and table_name in (???)',
|
|
||||||
$requiredTables
|
|
||||||
);
|
|
||||||
|
|
||||||
$present = [];
|
|
||||||
foreach ((array) $rows as $row) {
|
|
||||||
$table = (string) (($row['tables']['table_name'] ?? $row['table_name'] ?? ''));
|
|
||||||
if ($table !== '') {
|
|
||||||
$present[] = $table;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
$present = array_values(array_unique($present));
|
|
||||||
sort($present, SORT_STRING);
|
|
||||||
|
|
||||||
$missing = array_values(array_diff($requiredTables, $present));
|
|
||||||
if ($missing) {
|
|
||||||
return [
|
|
||||||
'status' => 'fail',
|
|
||||||
'message' => 'missing tables: ' . implode(', ', $missing),
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
return [
|
|
||||||
'status' => 'ok',
|
|
||||||
'message' => sprintf('%d core tables present', count($requiredTables)),
|
|
||||||
];
|
|
||||||
});
|
|
||||||
|
|
||||||
$runCheck('Storage path writeability', static function (): array {
|
|
||||||
$storagePath = defined('APP_STORAGE_PATH') && APP_STORAGE_PATH
|
|
||||||
? rtrim((string) APP_STORAGE_PATH, '/')
|
|
||||||
: rtrim(dirname(__DIR__) . '/storage', '/');
|
|
||||||
|
|
||||||
if (!is_dir($storagePath)) {
|
|
||||||
return [
|
|
||||||
'status' => 'fail',
|
|
||||||
'message' => "storage directory not found: {$storagePath}",
|
|
||||||
];
|
|
||||||
}
|
|
||||||
if (!is_writable($storagePath)) {
|
|
||||||
return [
|
|
||||||
'status' => 'fail',
|
|
||||||
'message' => "storage directory not writable: {$storagePath}",
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
$probeFile = $storagePath . '/.doctor-write-probe-' . uniqid('', true);
|
|
||||||
$written = @file_put_contents($probeFile, 'ok');
|
|
||||||
if ($written === false) {
|
|
||||||
return [
|
|
||||||
'status' => 'fail',
|
|
||||||
'message' => "write probe failed in: {$storagePath}",
|
|
||||||
];
|
|
||||||
}
|
|
||||||
@unlink($probeFile);
|
|
||||||
|
|
||||||
return [
|
|
||||||
'status' => 'ok',
|
|
||||||
'message' => "storage path is writable ({$storagePath})",
|
|
||||||
];
|
|
||||||
});
|
|
||||||
|
|
||||||
$runCheck('RBAC baseline permissions', static function (): array {
|
|
||||||
$requiredPermissions = [
|
|
||||||
PermissionService::USERS_VIEW,
|
|
||||||
PermissionService::TENANTS_VIEW,
|
|
||||||
PermissionService::DEPARTMENTS_VIEW,
|
|
||||||
PermissionService::ROLES_VIEW,
|
|
||||||
PermissionService::PERMISSIONS_VIEW,
|
|
||||||
PermissionService::SETTINGS_VIEW,
|
|
||||||
];
|
|
||||||
|
|
||||||
$rows = DB::select(
|
|
||||||
'select `key` from permissions where active = 1 and `key` in (???)',
|
|
||||||
$requiredPermissions
|
|
||||||
);
|
|
||||||
|
|
||||||
$present = [];
|
|
||||||
foreach ((array) $rows as $row) {
|
|
||||||
$key = (string) (($row['permissions']['key'] ?? $row['key'] ?? ''));
|
|
||||||
if ($key !== '') {
|
|
||||||
$present[] = $key;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
$present = array_values(array_unique($present));
|
|
||||||
sort($present, SORT_STRING);
|
|
||||||
|
|
||||||
$missing = array_values(array_diff($requiredPermissions, $present));
|
|
||||||
if ($missing) {
|
|
||||||
return [
|
|
||||||
'status' => 'fail',
|
|
||||||
'message' => 'missing active permissions: ' . implode(', ', $missing),
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
return [
|
|
||||||
'status' => 'ok',
|
|
||||||
'message' => sprintf('%d baseline permissions active', count($requiredPermissions)),
|
|
||||||
];
|
|
||||||
});
|
|
||||||
|
|
||||||
$runCheck('Admin role assignment', static function (): array {
|
|
||||||
$count = (int) (DB::selectValue(
|
|
||||||
'select count(distinct ur.user_id) from user_roles ur join roles r on r.id = ur.role_id and r.active = 1 where r.description in (?, ?) or r.id = 1',
|
|
||||||
'Admin',
|
|
||||||
'Administrator'
|
|
||||||
) ?? 0);
|
|
||||||
|
|
||||||
if ($count <= 0) {
|
|
||||||
return [
|
|
||||||
'status' => 'fail',
|
|
||||||
'message' => 'no active user assigned to Admin/Administrator role',
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
return [
|
|
||||||
'status' => 'ok',
|
|
||||||
'message' => sprintf('%d admin user(s) assigned', $count),
|
|
||||||
];
|
|
||||||
});
|
|
||||||
|
|
||||||
$runCheck('Scheduler heartbeat', static function (): array {
|
|
||||||
$row = DB::selectOne('select last_heartbeat_at, last_result, last_error_code from scheduler_runtime_status where id = 1 limit 1');
|
|
||||||
$status = is_array($row) ? ($row['scheduler_runtime_status'] ?? $row) : null;
|
|
||||||
if (!is_array($status)) {
|
|
||||||
return [
|
|
||||||
'status' => 'warn',
|
|
||||||
'message' => 'no scheduler runtime status row found yet',
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
$heartbeat = trim((string) ($status['last_heartbeat_at'] ?? ''));
|
|
||||||
$result = trim((string) ($status['last_result'] ?? 'unknown'));
|
|
||||||
$errorCode = trim((string) ($status['last_error_code'] ?? ''));
|
|
||||||
if ($heartbeat === '') {
|
|
||||||
return [
|
|
||||||
'status' => 'warn',
|
|
||||||
'message' => 'scheduler heartbeat is empty',
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
$seconds = time() - strtotime($heartbeat . ' UTC');
|
|
||||||
if ($seconds < 0) {
|
|
||||||
$seconds = 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($seconds > 300) {
|
|
||||||
return [
|
|
||||||
'status' => 'warn',
|
|
||||||
'message' => "last heartbeat {$seconds}s ago (result={$result}" . ($errorCode !== '' ? ", error={$errorCode}" : '') . ')',
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
return [
|
|
||||||
'status' => 'ok',
|
|
||||||
'message' => "last heartbeat {$seconds}s ago (result={$result}" . ($errorCode !== '' ? ", error={$errorCode}" : '') . ')',
|
|
||||||
];
|
|
||||||
});
|
|
||||||
|
|
||||||
fwrite(STDOUT, "Minty Doctor Report\n");
|
|
||||||
fwrite(STDOUT, "===================\n");
|
|
||||||
foreach ($results as $item) {
|
|
||||||
$statusLabel = strtoupper($item['status']);
|
|
||||||
fwrite(STDOUT, sprintf("[%s] %s: %s\n", $statusLabel, $item['name'], $item['message']));
|
|
||||||
}
|
|
||||||
|
|
||||||
$okCount = count($results) - $warnCount - $failCount;
|
|
||||||
fwrite(STDOUT, sprintf("\nSummary: ok=%d warn=%d fail=%d\n", $okCount, $warnCount, $failCount));
|
|
||||||
|
|
||||||
$exitCode = $failCount > 0 ? 1 : 0;
|
|
||||||
$resultLabel = $failCount > 0 ? 'fail' : ($warnCount > 0 ? 'warn' : 'ok');
|
|
||||||
|
|
||||||
try {
|
|
||||||
app(SystemAuditService::class)->record(
|
|
||||||
'cli.command',
|
|
||||||
$failCount > 0 ? 'failed' : 'success',
|
|
||||||
[
|
|
||||||
'metadata' => [
|
|
||||||
'command' => 'bin/doctor.php',
|
|
||||||
'exit_code' => $exitCode,
|
|
||||||
'duration_ms' => max(0, (int) round((microtime(true) - $startedAt) * 1000)),
|
|
||||||
'result' => $resultLabel,
|
|
||||||
'warn_count' => $warnCount,
|
|
||||||
'fail_count' => $failCount,
|
|
||||||
],
|
|
||||||
]
|
|
||||||
);
|
|
||||||
} catch (Throwable) {
|
|
||||||
// fail-open
|
|
||||||
}
|
|
||||||
|
|
||||||
DB::close();
|
|
||||||
exit($exitCode);
|
|
||||||
@@ -1,61 +0,0 @@
|
|||||||
#!/usr/bin/env php
|
|
||||||
<?php
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Publishes enabled module web assets into web/modules/.
|
|
||||||
*
|
|
||||||
* Usage: php bin/module-assets-sync.php
|
|
||||||
*
|
|
||||||
* APP_MODULE_ASSET_MODE:
|
|
||||||
* - symlink (default): create web/modules/<module-id> symlinks to modules/<id>/web
|
|
||||||
* - copy: copy module web directories (for environments without symlink support)
|
|
||||||
*
|
|
||||||
* Exit codes:
|
|
||||||
* 0 — sync successful
|
|
||||||
* 1 — error during sync
|
|
||||||
*/
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
use MintyPHP\App\Module\ModuleRegistry;
|
|
||||||
use MintyPHP\App\Module\ModuleRuntimeAssetPublisher;
|
|
||||||
|
|
||||||
require_once __DIR__ . '/module-cli-bootstrap.php';
|
|
||||||
|
|
||||||
function moduleAssetsSyncRun(): int
|
|
||||||
{
|
|
||||||
return moduleCliRunStep('module-assets-sync', static function (): int {
|
|
||||||
$runtimeModulesDir = __DIR__ . '/../web/modules';
|
|
||||||
$lockFile = __DIR__ . '/../storage/runtime/.module-assets-sync.lock';
|
|
||||||
$mode = (string) getenv('APP_MODULE_ASSET_MODE');
|
|
||||||
if (trim($mode) === '') {
|
|
||||||
$mode = 'symlink';
|
|
||||||
}
|
|
||||||
|
|
||||||
return moduleCliWithFileLock(
|
|
||||||
$lockFile,
|
|
||||||
'module-assets-sync: another sync is in progress, skipping.',
|
|
||||||
static function () use ($runtimeModulesDir, $mode): int {
|
|
||||||
$registry = app(ModuleRegistry::class);
|
|
||||||
$publisher = new ModuleRuntimeAssetPublisher();
|
|
||||||
$result = $publisher->publish($runtimeModulesDir, $registry->getModules(), $mode);
|
|
||||||
|
|
||||||
fwrite(STDOUT, sprintf(
|
|
||||||
"module-assets-sync: mode=%s created=%d updated=%d removed=%d unchanged=%d\n",
|
|
||||||
$mode,
|
|
||||||
$result['created'],
|
|
||||||
$result['updated'],
|
|
||||||
$result['removed'],
|
|
||||||
$result['unchanged']
|
|
||||||
));
|
|
||||||
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if (PHP_SAPI === 'cli' && realpath((string) ($_SERVER['SCRIPT_FILENAME'] ?? '')) === __FILE__) {
|
|
||||||
fwrite(STDERR, "Hint: prefer `php bin/console module:assets-sync` instead.\n");
|
|
||||||
exit(moduleAssetsSyncRun());
|
|
||||||
}
|
|
||||||
@@ -1,68 +0,0 @@
|
|||||||
#!/usr/bin/env php
|
|
||||||
<?php
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Builds the runtime page root by symlinking module pages into storage/runtime/pages/.
|
|
||||||
*
|
|
||||||
* Usage: php bin/module-build.php
|
|
||||||
*
|
|
||||||
* The runtime page root mirrors the core pages/ directory and adds symlinks for
|
|
||||||
* each enabled module's pages. MintyPHP Router::$pageRoot points to this directory.
|
|
||||||
*
|
|
||||||
* This script is idempotent and uses a lock file to prevent concurrent builds.
|
|
||||||
*
|
|
||||||
* Exit codes:
|
|
||||||
* 0 — build successful (or nothing to do)
|
|
||||||
* 1 — error during build
|
|
||||||
*/
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
use MintyPHP\App\Module\ModuleRegistry;
|
|
||||||
use MintyPHP\App\Module\ModuleRuntimePageBuilder;
|
|
||||||
|
|
||||||
require_once __DIR__ . '/module-cli-bootstrap.php';
|
|
||||||
|
|
||||||
function moduleBuildRun(): int
|
|
||||||
{
|
|
||||||
return moduleCliRunStep('module-build', static function (): int {
|
|
||||||
$runtimeDir = __DIR__ . '/../storage/runtime/pages';
|
|
||||||
$lockFile = __DIR__ . '/../storage/runtime/.module-build.lock';
|
|
||||||
|
|
||||||
// Ensure runtime directory exists
|
|
||||||
$runtimeParent = dirname($runtimeDir);
|
|
||||||
if (!is_dir($runtimeParent) && !mkdir($runtimeParent, 0775, true) && !is_dir($runtimeParent)) {
|
|
||||||
throw new \RuntimeException("Failed to create runtime directory: {$runtimeParent}");
|
|
||||||
}
|
|
||||||
|
|
||||||
return moduleCliWithFileLock(
|
|
||||||
$lockFile,
|
|
||||||
'module-build: another build is in progress, skipping.',
|
|
||||||
static function () use ($runtimeDir): int {
|
|
||||||
$registry = app(ModuleRegistry::class);
|
|
||||||
$modules = $registry->getModules();
|
|
||||||
$builder = new ModuleRuntimePageBuilder();
|
|
||||||
$result = $builder->build($runtimeDir, __DIR__ . '/../pages', $modules);
|
|
||||||
|
|
||||||
ModuleRuntimePageBuilder::writeFingerprint($runtimeDir, $modules);
|
|
||||||
|
|
||||||
if (count($modules) === 0) {
|
|
||||||
fwrite(STDOUT, "module-build: no modules, runtime -> core pages (symlink).\n");
|
|
||||||
} else {
|
|
||||||
fwrite(STDOUT, sprintf(
|
|
||||||
"module-build: runtime pages built - %d core entries + %d module entries.\n",
|
|
||||||
(int) ($result['core_entries'] ?? 0),
|
|
||||||
(int) ($result['module_entries'] ?? 0)
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if (PHP_SAPI === 'cli' && realpath((string) ($_SERVER['SCRIPT_FILENAME'] ?? '')) === __FILE__) {
|
|
||||||
fwrite(STDERR, "Hint: prefer `php bin/console module:build` instead.\n");
|
|
||||||
exit(moduleBuildRun());
|
|
||||||
}
|
|
||||||
@@ -1,166 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Shared CLI bootstrap and error policy for module runtime scripts.
|
|
||||||
*
|
|
||||||
* Policy:
|
|
||||||
* - first-party warnings/notices/deprecations => fail-fast (RuntimeException)
|
|
||||||
* - vendor warnings/notices/deprecations => ignored and counted
|
|
||||||
*/
|
|
||||||
|
|
||||||
function moduleCliProjectRoot(): string
|
|
||||||
{
|
|
||||||
return dirname(__DIR__);
|
|
||||||
}
|
|
||||||
|
|
||||||
function moduleCliVendorWarningsIgnoredTotal(): int
|
|
||||||
{
|
|
||||||
$count = $GLOBALS['module_cli_vendor_warnings_ignored'] ?? 0;
|
|
||||||
return is_int($count) ? $count : 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
function moduleCliVendorWarningsIgnoredSince(int $before): int
|
|
||||||
{
|
|
||||||
return max(0, moduleCliVendorWarningsIgnoredTotal() - $before);
|
|
||||||
}
|
|
||||||
|
|
||||||
function moduleCliIncrementVendorWarningsIgnored(): void
|
|
||||||
{
|
|
||||||
$current = moduleCliVendorWarningsIgnoredTotal();
|
|
||||||
$GLOBALS['module_cli_vendor_warnings_ignored'] = $current + 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
function moduleCliSeverityName(int $severity): string
|
|
||||||
{
|
|
||||||
return match ($severity) {
|
|
||||||
E_WARNING => 'E_WARNING',
|
|
||||||
E_NOTICE => 'E_NOTICE',
|
|
||||||
E_USER_WARNING => 'E_USER_WARNING',
|
|
||||||
E_USER_NOTICE => 'E_USER_NOTICE',
|
|
||||||
E_DEPRECATED => 'E_DEPRECATED',
|
|
||||||
E_USER_DEPRECATED => 'E_USER_DEPRECATED',
|
|
||||||
default => 'E_' . $severity,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function moduleCliInstallErrorPolicy(): void
|
|
||||||
{
|
|
||||||
static $installed = false;
|
|
||||||
if ($installed) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
$installed = true;
|
|
||||||
|
|
||||||
error_reporting(E_ALL);
|
|
||||||
ini_set('display_errors', '0');
|
|
||||||
|
|
||||||
set_error_handler(static function (
|
|
||||||
int $severity,
|
|
||||||
string $message,
|
|
||||||
string $file = '',
|
|
||||||
int $line = 0
|
|
||||||
): bool {
|
|
||||||
$handledSeverities = [
|
|
||||||
E_WARNING,
|
|
||||||
E_NOTICE,
|
|
||||||
E_USER_WARNING,
|
|
||||||
E_USER_NOTICE,
|
|
||||||
E_DEPRECATED,
|
|
||||||
E_USER_DEPRECATED,
|
|
||||||
];
|
|
||||||
if (!in_array($severity, $handledSeverities, true)) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
if ((error_reporting() & $severity) === 0) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
$normalizedFile = str_replace('\\', '/', $file);
|
|
||||||
if (str_contains($normalizedFile, '/vendor/')) {
|
|
||||||
moduleCliIncrementVendorWarningsIgnored();
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
throw new RuntimeException(sprintf(
|
|
||||||
"First-party runtime warning (%s) in %s:%d: %s",
|
|
||||||
moduleCliSeverityName($severity),
|
|
||||||
$file,
|
|
||||||
$line,
|
|
||||||
$message
|
|
||||||
));
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function moduleCliBootstrapApp(): void
|
|
||||||
{
|
|
||||||
static $booted = false;
|
|
||||||
if ($booted) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
moduleCliInstallErrorPolicy();
|
|
||||||
|
|
||||||
$projectRoot = moduleCliProjectRoot();
|
|
||||||
chdir($projectRoot);
|
|
||||||
|
|
||||||
require_once $projectRoot . '/vendor/autoload.php';
|
|
||||||
require_once $projectRoot . '/config/config.php';
|
|
||||||
require_once $projectRoot . '/lib/Support/helpers.php';
|
|
||||||
$container = require $projectRoot . '/lib/App/registerContainer.php';
|
|
||||||
setAppContainer($container);
|
|
||||||
|
|
||||||
$booted = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Runs one module CLI step with shared bootstrap, failure handling and vendor warning summary.
|
|
||||||
*
|
|
||||||
* @param callable():int $runner
|
|
||||||
*/
|
|
||||||
function moduleCliRunStep(string $stepName, callable $runner): int
|
|
||||||
{
|
|
||||||
$warningsBefore = moduleCliVendorWarningsIgnoredTotal();
|
|
||||||
moduleCliBootstrapApp();
|
|
||||||
|
|
||||||
$exitCode = 0;
|
|
||||||
try {
|
|
||||||
$exitCode = $runner();
|
|
||||||
} catch (Throwable $exception) {
|
|
||||||
fwrite(STDERR, sprintf("%s: FAILED: %s\n", $stepName, $exception->getMessage()));
|
|
||||||
$exitCode = 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
$vendorWarningsIgnored = moduleCliVendorWarningsIgnoredSince($warningsBefore);
|
|
||||||
fwrite(STDOUT, sprintf("%s: vendor_warnings_ignored=%d\n", $stepName, $vendorWarningsIgnored));
|
|
||||||
|
|
||||||
return $exitCode;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Executes a callable under a non-blocking file lock.
|
|
||||||
*
|
|
||||||
* @param callable():int $runner
|
|
||||||
*/
|
|
||||||
function moduleCliWithFileLock(string $lockFile, string $busyMessage, callable $runner): int
|
|
||||||
{
|
|
||||||
$lockDir = dirname($lockFile);
|
|
||||||
if (!is_dir($lockDir) && !mkdir($lockDir, 0775, true) && !is_dir($lockDir)) {
|
|
||||||
throw new RuntimeException("Failed to create lock directory: {$lockDir}");
|
|
||||||
}
|
|
||||||
|
|
||||||
$lock = fopen($lockFile, 'c');
|
|
||||||
if ($lock === false || !flock($lock, LOCK_EX | LOCK_NB)) {
|
|
||||||
fwrite(STDERR, $busyMessage . PHP_EOL);
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
return $runner();
|
|
||||||
} finally {
|
|
||||||
flock($lock, LOCK_UN);
|
|
||||||
fclose($lock);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,80 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* CLI script to run a module's deactivation handler.
|
|
||||||
*
|
|
||||||
* Usage:
|
|
||||||
* php bin/module-deactivate.php <module-id> --confirm
|
|
||||||
* php bin/module-deactivate.php <module-id> --dry-run
|
|
||||||
*
|
|
||||||
* The module does NOT need to be in the enabled list — the manifest is loaded
|
|
||||||
* directly from modules/<id>/module.php.
|
|
||||||
*/
|
|
||||||
|
|
||||||
require_once __DIR__ . '/module-cli-bootstrap.php';
|
|
||||||
|
|
||||||
function moduleDeactivateRun(): int
|
|
||||||
{
|
|
||||||
return moduleCliRunStep('module-deactivate', static function (): int {
|
|
||||||
global $argv;
|
|
||||||
|
|
||||||
$moduleId = $argv[1] ?? '';
|
|
||||||
$flags = array_slice($argv, 2);
|
|
||||||
|
|
||||||
if ($moduleId === '' || $moduleId === '--help') {
|
|
||||||
fwrite(STDOUT, "Usage: php bin/module-deactivate.php <module-id> --confirm|--dry-run\n");
|
|
||||||
return $moduleId === '--help' ? 0 : 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
$confirm = in_array('--confirm', $flags, true);
|
|
||||||
$dryRun = in_array('--dry-run', $flags, true);
|
|
||||||
|
|
||||||
if (!$confirm && !$dryRun) {
|
|
||||||
fwrite(STDERR, "Error: must pass --confirm to execute or --dry-run to validate.\n");
|
|
||||||
return 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
$projectRoot = moduleCliProjectRoot();
|
|
||||||
$manifest = \MintyPHP\App\Module\ModuleManifestLoader::loadFromDisk(
|
|
||||||
$projectRoot . '/modules',
|
|
||||||
$moduleId
|
|
||||||
);
|
|
||||||
|
|
||||||
$handlerClass = $manifest->deactivationHandler;
|
|
||||||
if ($handlerClass === null) {
|
|
||||||
fwrite(STDOUT, "Module '{$moduleId}' has no deactivation_handler declared — nothing to do.\n");
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!class_exists($handlerClass)) {
|
|
||||||
fwrite(STDERR, "Error: deactivation handler class '{$handlerClass}' not found.\n");
|
|
||||||
return 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
$handler = new $handlerClass();
|
|
||||||
if (!$handler instanceof \MintyPHP\App\Module\Contracts\ModuleDeactivationHandler) {
|
|
||||||
fwrite(STDERR, "Error: '{$handlerClass}' does not implement ModuleDeactivationHandler.\n");
|
|
||||||
return 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($dryRun) {
|
|
||||||
fwrite(STDOUT, "Dry-run: handler '{$handlerClass}' found and valid for module '{$moduleId}'.\n");
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
fwrite(STDOUT, "Running deactivation handler for module '{$moduleId}'...\n");
|
|
||||||
|
|
||||||
$container = app();
|
|
||||||
$handler->deactivate($container);
|
|
||||||
|
|
||||||
fwrite(STDOUT, "Deactivation handler for module '{$moduleId}' completed.\n");
|
|
||||||
return 0;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if (PHP_SAPI === 'cli' && realpath((string) ($_SERVER['SCRIPT_FILENAME'] ?? '')) === __FILE__) {
|
|
||||||
fwrite(STDERR, "Hint: prefer `php bin/console module:deactivate` instead.\n");
|
|
||||||
exit(moduleDeactivateRun());
|
|
||||||
}
|
|
||||||
@@ -1,49 +0,0 @@
|
|||||||
#!/usr/bin/env php
|
|
||||||
<?php
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Applies pending module migrations for all enabled modules.
|
|
||||||
*
|
|
||||||
* Usage: php bin/module-migrate.php
|
|
||||||
*
|
|
||||||
* Each module declares a migrations_path in its manifest. SQL files in that
|
|
||||||
* directory are applied in alphabetical order. Applied files are tracked in the
|
|
||||||
* module_migrations table to ensure idempotency.
|
|
||||||
*
|
|
||||||
* Exit codes:
|
|
||||||
* 0 — all migrations applied (or nothing to do)
|
|
||||||
* 1 — error during migration
|
|
||||||
*/
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
use MintyPHP\App\Module\ModuleRegistry;
|
|
||||||
use MintyPHP\Repository\Module\ModuleMigrationRepository;
|
|
||||||
use MintyPHP\Service\Module\ModuleMigrationService;
|
|
||||||
|
|
||||||
require_once __DIR__ . '/module-cli-bootstrap.php';
|
|
||||||
|
|
||||||
function moduleMigrateRun(): int
|
|
||||||
{
|
|
||||||
return moduleCliRunStep('module-migrate', static function (): int {
|
|
||||||
$lockFile = __DIR__ . '/../storage/runtime/.module-migrate.lock';
|
|
||||||
|
|
||||||
return moduleCliWithFileLock(
|
|
||||||
$lockFile,
|
|
||||||
'module-migrate: another migration run is in progress, skipping.',
|
|
||||||
static function (): int {
|
|
||||||
$service = new ModuleMigrationService(
|
|
||||||
app(ModuleRegistry::class),
|
|
||||||
new ModuleMigrationRepository()
|
|
||||||
);
|
|
||||||
$result = $service->applyPendingMigrations();
|
|
||||||
return $result['ok'] ? 0 : 1;
|
|
||||||
}
|
|
||||||
);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if (PHP_SAPI === 'cli' && realpath((string) ($_SERVER['SCRIPT_FILENAME'] ?? '')) === __FILE__) {
|
|
||||||
fwrite(STDERR, "Hint: prefer `php bin/console module:migrate` instead.\n");
|
|
||||||
exit(moduleMigrateRun());
|
|
||||||
}
|
|
||||||
@@ -1,56 +0,0 @@
|
|||||||
#!/usr/bin/env php
|
|
||||||
<?php
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Synchronizes module-declared permissions into the permissions table.
|
|
||||||
*
|
|
||||||
* Usage: php bin/module-permissions-sync.php
|
|
||||||
*
|
|
||||||
* Exit codes:
|
|
||||||
* 0 — sync successful
|
|
||||||
* 1 — error during sync
|
|
||||||
*/
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
use MintyPHP\App\Module\ModulePermissionSynchronizer;
|
|
||||||
use MintyPHP\App\Module\ModuleRegistry;
|
|
||||||
use MintyPHP\Repository\Access\PermissionRepository;
|
|
||||||
|
|
||||||
require_once __DIR__ . '/module-cli-bootstrap.php';
|
|
||||||
|
|
||||||
function modulePermissionsSyncRun(): int
|
|
||||||
{
|
|
||||||
return moduleCliRunStep('module-permissions-sync', static function (): int {
|
|
||||||
$lockFile = __DIR__ . '/../storage/runtime/.module-permissions-sync.lock';
|
|
||||||
|
|
||||||
return moduleCliWithFileLock(
|
|
||||||
$lockFile,
|
|
||||||
'module-permissions-sync: another sync is in progress, skipping.',
|
|
||||||
static function (): int {
|
|
||||||
$synchronizer = new ModulePermissionSynchronizer(
|
|
||||||
app(ModuleRegistry::class),
|
|
||||||
app(PermissionRepository::class),
|
|
||||||
moduleCliProjectRoot() . '/modules'
|
|
||||||
);
|
|
||||||
$result = $synchronizer->sync();
|
|
||||||
|
|
||||||
fwrite(STDOUT, sprintf(
|
|
||||||
"module-permissions-sync: created=%d updated=%d unchanged=%d deactivated=%d total=%d\n",
|
|
||||||
$result['created'],
|
|
||||||
$result['updated'],
|
|
||||||
$result['unchanged'],
|
|
||||||
$result['deactivated'],
|
|
||||||
$result['total']
|
|
||||||
));
|
|
||||||
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if (PHP_SAPI === 'cli' && realpath((string) ($_SERVER['SCRIPT_FILENAME'] ?? '')) === __FILE__) {
|
|
||||||
fwrite(STDERR, "Hint: prefer `php bin/console module:permissions-sync` instead.\n");
|
|
||||||
exit(modulePermissionsSyncRun());
|
|
||||||
}
|
|
||||||
@@ -1,77 +0,0 @@
|
|||||||
#!/usr/bin/env php
|
|
||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
require_once __DIR__ . '/module-cli-bootstrap.php';
|
|
||||||
|
|
||||||
require_once __DIR__ . '/module-migrate.php';
|
|
||||||
require_once __DIR__ . '/module-permissions-sync.php';
|
|
||||||
require_once __DIR__ . '/module-build.php';
|
|
||||||
require_once __DIR__ . '/module-assets-sync.php';
|
|
||||||
|
|
||||||
function moduleRuntimeSyncRun(): int
|
|
||||||
{
|
|
||||||
return moduleCliRunStep('module-runtime-sync', static function (): int {
|
|
||||||
$lockFile = __DIR__ . '/../storage/runtime/.module-runtime-sync.lock';
|
|
||||||
return moduleCliWithFileLock(
|
|
||||||
$lockFile,
|
|
||||||
'module-runtime-sync: another runtime sync is in progress, skipping.',
|
|
||||||
static function (): int {
|
|
||||||
moduleCliBootstrapApp();
|
|
||||||
$warningsBeforeAll = moduleCliVendorWarningsIgnoredTotal();
|
|
||||||
|
|
||||||
$steps = [
|
|
||||||
'migrate' => static fn (): int => moduleMigrateRun(),
|
|
||||||
'permissions-sync' => static fn (): int => modulePermissionsSyncRun(),
|
|
||||||
'build' => static fn (): int => moduleBuildRun(),
|
|
||||||
'assets-sync' => static fn (): int => moduleAssetsSyncRun(),
|
|
||||||
];
|
|
||||||
|
|
||||||
$statusByStep = [];
|
|
||||||
foreach (array_keys($steps) as $stepName) {
|
|
||||||
$statusByStep[$stepName] = 'pending';
|
|
||||||
}
|
|
||||||
|
|
||||||
$finalExitCode = 0;
|
|
||||||
foreach ($steps as $stepName => $runner) {
|
|
||||||
$stepWarningsBefore = moduleCliVendorWarningsIgnoredTotal();
|
|
||||||
$stepExitCode = $runner();
|
|
||||||
$stepWarnings = moduleCliVendorWarningsIgnoredSince($stepWarningsBefore);
|
|
||||||
$stepStatus = $stepExitCode === 0 ? 'ok' : 'failed';
|
|
||||||
$statusByStep[$stepName] = $stepStatus;
|
|
||||||
|
|
||||||
fwrite(STDOUT, sprintf(
|
|
||||||
"module-runtime-sync: step=%s status=%s exit_code=%d vendor_warnings_ignored=%d\n",
|
|
||||||
$stepName,
|
|
||||||
$stepStatus,
|
|
||||||
$stepExitCode,
|
|
||||||
$stepWarnings
|
|
||||||
));
|
|
||||||
|
|
||||||
if ($stepExitCode !== 0) {
|
|
||||||
$finalExitCode = $stepExitCode;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
$totalVendorWarnings = moduleCliVendorWarningsIgnoredSince($warningsBeforeAll);
|
|
||||||
fwrite(STDOUT, sprintf(
|
|
||||||
"module-runtime-sync: summary migrate=%s permissions-sync=%s build=%s assets-sync=%s vendor_warnings_ignored=%d\n",
|
|
||||||
$statusByStep['migrate'],
|
|
||||||
$statusByStep['permissions-sync'],
|
|
||||||
$statusByStep['build'],
|
|
||||||
$statusByStep['assets-sync'],
|
|
||||||
$totalVendorWarnings
|
|
||||||
));
|
|
||||||
|
|
||||||
return $finalExitCode;
|
|
||||||
}
|
|
||||||
);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if (PHP_SAPI === 'cli' && realpath((string) ($_SERVER['SCRIPT_FILENAME'] ?? '')) === __FILE__) {
|
|
||||||
fwrite(STDERR, "Hint: prefer `php bin/console module:sync` instead.\n");
|
|
||||||
exit(moduleRuntimeSyncRun());
|
|
||||||
}
|
|
||||||
@@ -103,6 +103,6 @@ $meinService = app(\MintyPHP\Service\MeinNeuerService::class);
|
|||||||
den konkreten Service direkt registrieren und per `app()` holen.
|
den konkreten Service direkt registrieren und per `app()` holen.
|
||||||
- Der Container lebt **pro HTTP-Anfrage** — er wird in `web/index.php` initialisiert
|
- Der Container lebt **pro HTTP-Anfrage** — er wird in `web/index.php` initialisiert
|
||||||
und über `setAppContainer()` im globalen State abgelegt.
|
und über `setAppContainer()` im globalen State abgelegt.
|
||||||
- In CLI-Skripten (`bin/`) wird der Container separat initialisiert (s. `bin/module-cli-bootstrap.php`).
|
- In CLI-Commands wird der Container separat initialisiert (ueber die Runtime-Bootstrap-Klassen unter `lib/Console/Support/`).
|
||||||
- In `lib/Service/**` außerhalb von `*Factory.php` niemals `new ...Factory()`,
|
- In `lib/Service/**` außerhalb von `*Factory.php` niemals `new ...Factory()`,
|
||||||
`new ...Service()` oder `new ...Repository()` direkt instanziieren.
|
`new ...Service()` oder `new ...Repository()` direkt instanziieren.
|
||||||
|
|||||||
@@ -1,16 +1,29 @@
|
|||||||
# Lokale Entwicklung
|
# Lokale Entwicklung
|
||||||
|
|
||||||
Letzte Aktualisierung: 2026-03-18
|
Letzte Aktualisierung: 2026-04-01
|
||||||
|
|
||||||
## Ziel
|
## Ziel
|
||||||
|
|
||||||
Kompakter Tagesworkflow für Entwicklung, Tests und schema-nahe Änderungen.
|
Kompakter Tagesworkflow für Entwicklung, Tests und schema-nahe Änderungen.
|
||||||
|
|
||||||
|
## First-Time Workflow
|
||||||
|
|
||||||
|
```bash
|
||||||
|
bin/dev init
|
||||||
|
```
|
||||||
|
|
||||||
|
`bin/dev init` ist nicht-destruktiv und fuehrt aus:
|
||||||
|
|
||||||
|
1. `.env` aus `.env.example` erstellen (falls fehlend)
|
||||||
|
2. `docker compose up --build -d`
|
||||||
|
3. `module:sync`
|
||||||
|
4. `doctor`
|
||||||
|
|
||||||
## Start/Stop
|
## Start/Stop
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
docker compose up -d
|
bin/dev up
|
||||||
docker compose down
|
bin/dev down
|
||||||
```
|
```
|
||||||
|
|
||||||
Hinweis: `up -d` startet auch den Scheduler-Service.
|
Hinweis: `up -d` startet auch den Scheduler-Service.
|
||||||
@@ -34,6 +47,7 @@ Dieser Befehl fuehrt automatisch aus: `migrate` → `permissions-sync` → `buil
|
|||||||
Falls `web/index.php` den Fehler "Module runtime is stale" wirft, ist der Sync noetig.
|
Falls `web/index.php` den Fehler "Module runtime is stale" wirft, ist der Sync noetig.
|
||||||
|
|
||||||
Alle verfuegbaren CLI-Kommandos: `php bin/console list` (siehe `/docs/reference-cli-commands.md`).
|
Alle verfuegbaren CLI-Kommandos: `php bin/console list` (siehe `/docs/reference-cli-commands.md`).
|
||||||
|
Alle Dev-Orchestrator-Kommandos: `bin/dev --help`.
|
||||||
|
|
||||||
## Schema-Stand
|
## Schema-Stand
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# CLI-Kommandoreferenz
|
# CLI-Kommandoreferenz
|
||||||
|
|
||||||
Letzte Aktualisierung: 2026-03-25
|
Letzte Aktualisierung: 2026-04-01
|
||||||
|
|
||||||
## Einstiegspunkt
|
## Einstiegspunkt
|
||||||
|
|
||||||
@@ -36,6 +36,12 @@ Noetig nach jeder Aenderung an Modulen, Manifesten, Routen oder `APP_ENABLED_MOD
|
|||||||
|
|
||||||
Falls `web/index.php` den Fehler "Module runtime is stale" wirft, ist dieser Befehl die Loesung.
|
Falls `web/index.php` den Fehler "Module runtime is stale" wirft, ist dieser Befehl die Loesung.
|
||||||
|
|
||||||
|
Maschinenlesbar:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose exec php php bin/console module:sync --format=json
|
||||||
|
```
|
||||||
|
|
||||||
### module:migrate
|
### module:migrate
|
||||||
|
|
||||||
Wendet ausstehende SQL-Migrationen aus aktiven Modulen an.
|
Wendet ausstehende SQL-Migrationen aus aktiven Modulen an.
|
||||||
@@ -120,6 +126,27 @@ docker compose exec php php bin/console doctor
|
|||||||
|
|
||||||
Siehe `/docs/howto-betriebscheck-doctor.md` fuer Details.
|
Siehe `/docs/howto-betriebscheck-doctor.md` fuer Details.
|
||||||
|
|
||||||
|
Maschinenlesbar:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose exec php php bin/console doctor --format=json
|
||||||
|
```
|
||||||
|
|
||||||
|
## Developer-Orchestrator (`bin/dev`)
|
||||||
|
|
||||||
|
Fuer lokalen Workflow gibt es zusaetzlich den Dev-Orchestrator:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
bin/dev init
|
||||||
|
bin/dev up
|
||||||
|
bin/dev down
|
||||||
|
bin/dev logs [service]
|
||||||
|
bin/dev console module:sync
|
||||||
|
bin/dev qa
|
||||||
|
```
|
||||||
|
|
||||||
|
`bin/dev console ...` delegiert intern auf `docker compose exec -T php php bin/console ...`.
|
||||||
|
|
||||||
## Neue Kommandos anlegen
|
## Neue Kommandos anlegen
|
||||||
|
|
||||||
1. Klasse in `lib/Console/Commands/` erstellen, die `MintyPHP\Console\Command` erweitert.
|
1. Klasse in `lib/Console/Commands/` erstellen, die `MintyPHP\Console\Command` erweitert.
|
||||||
@@ -156,13 +183,4 @@ Die `Command`-Basisklasse stellt Helfer bereit:
|
|||||||
|---|---|
|
|---|---|
|
||||||
| `$this->bootstrapApp($channel)` | App-Bootstrap fuer Nicht-Modul-Kommandos |
|
| `$this->bootstrapApp($channel)` | App-Bootstrap fuer Nicht-Modul-Kommandos |
|
||||||
| `$this->bootstrapModuleApp()` | App-Bootstrap mit Modul-Error-Policy und Locking |
|
| `$this->bootstrapModuleApp()` | App-Bootstrap mit Modul-Error-Policy und Locking |
|
||||||
| `$this->requireBinScript('name.php')` | Laedt ein `bin/`-Script ohne fragile relative Pfade |
|
|
||||||
| `$this->projectRoot()` | Gibt den absoluten Projekt-Root-Pfad zurueck |
|
| `$this->projectRoot()` | Gibt den absoluten Projekt-Root-Pfad zurueck |
|
||||||
|
|
||||||
## Legacy-Scripts
|
|
||||||
|
|
||||||
Die alten `bin/*.php`-Scripts funktionieren weiterhin, geben aber einen Hinweis auf `bin/console` aus:
|
|
||||||
|
|
||||||
```
|
|
||||||
Hint: prefer `php bin/console module:sync` instead.
|
|
||||||
```
|
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ Diese Readiness-Doku ist bewusst kurz und verweist fuer Felddefinitionen/Guards
|
|||||||
- Tenant-Scope serverseitig pruefen.
|
- Tenant-Scope serverseitig pruefen.
|
||||||
- POST-Mutationen mit CSRF absichern.
|
- POST-Mutationen mit CSRF absichern.
|
||||||
4. **Runtime-Hygiene absichern**
|
4. **Runtime-Hygiene absichern**
|
||||||
- `php bin/module-runtime-sync.php` laeuft reproduzierbar mit standardisierter Step-Summary.
|
- `php bin/console module:sync` laeuft reproduzierbar mit standardisierter Step-Summary.
|
||||||
- Keine First-party CLI-Warnings/Notices/Deprecations; Vendor-Rauschen wird nur gezaehlt/reportet.
|
- Keine First-party CLI-Warnings/Notices/Deprecations; Vendor-Rauschen wird nur gezaehlt/reportet.
|
||||||
5. **Testbarkeit und Gates**
|
5. **Testbarkeit und Gates**
|
||||||
- Architektur- und Domain-Tests fuer neue Logik ergaenzen.
|
- Architektur- und Domain-Tests fuer neue Logik ergaenzen.
|
||||||
|
|||||||
@@ -63,5 +63,5 @@ Letzte Aktualisierung: 2026-03-24
|
|||||||
|
|
||||||
- `docker compose exec php vendor/bin/phpunit`
|
- `docker compose exec php vendor/bin/phpunit`
|
||||||
- `docker compose exec php vendor/bin/phpstan analyse -c phpstan.neon --no-progress`
|
- `docker compose exec php vendor/bin/phpstan analyse -c phpstan.neon --no-progress`
|
||||||
- bei Modul-/Manifest-Aenderungen: `docker compose exec php php bin/module-runtime-sync.php`
|
- bei Modul-/Manifest-Aenderungen: `docker compose exec php php bin/console module:sync`
|
||||||
- bei JS-Änderungen: Browser-Smoke mit DevTools-Console (keine JS-Errors)
|
- bei JS-Änderungen: Browser-Smoke mit DevTools-Console (keine JS-Errors)
|
||||||
|
|||||||
@@ -63,8 +63,8 @@ Der `ModuleAutoloader` registriert daraus den absoluten Pfad `modules/<id>/i18n/
|
|||||||
```
|
```
|
||||||
|
|
||||||
- `key` und `description` sind Pflichtfelder.
|
- `key` und `description` sind Pflichtfelder.
|
||||||
- Runtime-Sync erfolgt ueber `php bin/module-permissions-sync.php`.
|
- Runtime-Sync erfolgt ueber `php bin/console module:sync`.
|
||||||
- `module-runtime-sync` fuehrt diesen Sync automatisch aus.
|
- `module:sync` fuehrt diesen Sync automatisch aus.
|
||||||
|
|
||||||
## `scheduler_jobs` Contract
|
## `scheduler_jobs` Contract
|
||||||
|
|
||||||
@@ -120,9 +120,9 @@ Abgrenzung CSS:
|
|||||||
|
|
||||||
`web/index.php` validiert bei jedem Request, dass der Runtime-Stand zur aktuellen Modul-Konfiguration passt.
|
`web/index.php` validiert bei jedem Request, dass der Runtime-Stand zur aktuellen Modul-Konfiguration passt.
|
||||||
|
|
||||||
- `bin/module-build.php` schreibt nach jedem Build `storage/runtime/.module-fingerprint` (sortierte Modul-IDs, kommasepariert).
|
- `php bin/console module:build` schreibt nach jedem Build `storage/runtime/.module-fingerprint` (sortierte Modul-IDs, kommasepariert).
|
||||||
- `web/index.php` vergleicht den Fingerprint mit der erwarteten Modul-Liste.
|
- `web/index.php` vergleicht den Fingerprint mit der erwarteten Modul-Liste.
|
||||||
- Bei Mismatch: `RuntimeException` mit Hinweis auf `php bin/module-runtime-sync.php`.
|
- Bei Mismatch: `RuntimeException` mit Hinweis auf `php bin/console module:sync`.
|
||||||
- Kein Auto-Build im Request-Pfad — Build ist ausschliesslich CLI-Aufgabe.
|
- Kein Auto-Build im Request-Pfad — Build ist ausschliesslich CLI-Aufgabe.
|
||||||
|
|
||||||
Methoden: `ModuleRuntimePageBuilder::expectedFingerprint()`, `readFingerprint()`, `writeFingerprint()`.
|
Methoden: `ModuleRuntimePageBuilder::expectedFingerprint()`, `readFingerprint()`, `writeFingerprint()`.
|
||||||
@@ -138,12 +138,12 @@ API-Requests (`api/`-Prefix) ueberspringen den gesamten Web-Session-Flow:
|
|||||||
|
|
||||||
## Standard-Runbook
|
## Standard-Runbook
|
||||||
|
|
||||||
`php bin/module-runtime-sync.php` fuehrt in fester Reihenfolge aus:
|
`php bin/console module:sync` fuehrt in fester Reihenfolge aus:
|
||||||
|
|
||||||
1. `module-migrate`
|
1. `module:migrate`
|
||||||
2. `module-permissions-sync`
|
2. `module:permissions-sync`
|
||||||
3. `module-build`
|
3. `module:build`
|
||||||
4. `module-assets-sync`
|
4. `module:assets-sync`
|
||||||
|
|
||||||
Der Output enthaelt eine deterministische Step-Summary (`migrate`, `permissions-sync`, `build`, `assets-sync`) inklusive `vendor_warnings_ignored=<n>`.
|
Der Output enthaelt eine deterministische Step-Summary (`migrate`, `permissions-sync`, `build`, `assets-sync`) inklusive `vendor_warnings_ignored=<n>`.
|
||||||
First-party Warnings/Notices/Deprecations gelten als Fehler (fail-fast).
|
First-party Warnings/Notices/Deprecations gelten als Fehler (fail-fast).
|
||||||
|
|||||||
@@ -4,7 +4,10 @@ namespace MintyPHP\App\Container\Registrars;
|
|||||||
|
|
||||||
use MintyPHP\App\AppContainer;
|
use MintyPHP\App\AppContainer;
|
||||||
use MintyPHP\App\Container\ContainerRegistrar;
|
use MintyPHP\App\Container\ContainerRegistrar;
|
||||||
|
use MintyPHP\App\Module\ModulePermissionSynchronizer;
|
||||||
use MintyPHP\App\Module\ModuleRegistry;
|
use MintyPHP\App\Module\ModuleRegistry;
|
||||||
|
use MintyPHP\App\Module\ModuleRuntimeAssetPublisher;
|
||||||
|
use MintyPHP\App\Module\ModuleRuntimePageBuilder;
|
||||||
use MintyPHP\Http\CookieStore;
|
use MintyPHP\Http\CookieStore;
|
||||||
use MintyPHP\Http\CookieStoreInterface;
|
use MintyPHP\Http\CookieStoreInterface;
|
||||||
use MintyPHP\Http\Input\RequestInputFactory;
|
use MintyPHP\Http\Input\RequestInputFactory;
|
||||||
@@ -13,6 +16,8 @@ use MintyPHP\Http\RequestRuntime;
|
|||||||
use MintyPHP\Http\RequestRuntimeInterface;
|
use MintyPHP\Http\RequestRuntimeInterface;
|
||||||
use MintyPHP\Http\SessionStore;
|
use MintyPHP\Http\SessionStore;
|
||||||
use MintyPHP\Http\SessionStoreInterface;
|
use MintyPHP\Http\SessionStoreInterface;
|
||||||
|
use MintyPHP\Repository\Access\PermissionRepository;
|
||||||
|
use MintyPHP\Repository\Module\ModuleMigrationRepository;
|
||||||
use MintyPHP\Repository\Search\SearchQueryRepository;
|
use MintyPHP\Repository\Search\SearchQueryRepository;
|
||||||
use MintyPHP\Repository\Stats\AdminStatsRepository;
|
use MintyPHP\Repository\Stats\AdminStatsRepository;
|
||||||
use MintyPHP\Repository\System\SystemHealthRepository;
|
use MintyPHP\Repository\System\SystemHealthRepository;
|
||||||
@@ -27,6 +32,7 @@ use MintyPHP\Service\Import\ImportServicesFactory;
|
|||||||
use MintyPHP\Service\Mail\MailLogService;
|
use MintyPHP\Service\Mail\MailLogService;
|
||||||
use MintyPHP\Service\Mail\MailService;
|
use MintyPHP\Service\Mail\MailService;
|
||||||
use MintyPHP\Service\Mail\MailServicesFactory;
|
use MintyPHP\Service\Mail\MailServicesFactory;
|
||||||
|
use MintyPHP\Service\Module\ModuleMigrationService;
|
||||||
use MintyPHP\Service\Scheduler\ScheduledJobService;
|
use MintyPHP\Service\Scheduler\ScheduledJobService;
|
||||||
use MintyPHP\Service\Scheduler\SchedulerRunService;
|
use MintyPHP\Service\Scheduler\SchedulerRunService;
|
||||||
use MintyPHP\Service\Scheduler\SchedulerServicesFactory;
|
use MintyPHP\Service\Scheduler\SchedulerServicesFactory;
|
||||||
@@ -41,6 +47,8 @@ final class AppServicesRegistrar implements ContainerRegistrar
|
|||||||
{
|
{
|
||||||
public function register(AppContainer $container): void
|
public function register(AppContainer $container): void
|
||||||
{
|
{
|
||||||
|
$projectRoot = dirname(__DIR__, 4);
|
||||||
|
|
||||||
$container->set(AdminStatsViewDataService::class, static fn (AppContainer $c): AdminStatsViewDataService => new AdminStatsViewDataService(
|
$container->set(AdminStatsViewDataService::class, static fn (AppContainer $c): AdminStatsViewDataService => new AdminStatsViewDataService(
|
||||||
$c->get(AdminStatsRepository::class)
|
$c->get(AdminStatsRepository::class)
|
||||||
));
|
));
|
||||||
@@ -77,5 +85,17 @@ final class AppServicesRegistrar implements ContainerRegistrar
|
|||||||
$container->set(CookieStoreInterface::class, static fn (AppContainer $c): CookieStoreInterface => $c->get(CookieStore::class));
|
$container->set(CookieStoreInterface::class, static fn (AppContainer $c): CookieStoreInterface => $c->get(CookieStore::class));
|
||||||
$container->set(RequestRuntime::class, static fn (): RequestRuntime => new RequestRuntime());
|
$container->set(RequestRuntime::class, static fn (): RequestRuntime => new RequestRuntime());
|
||||||
$container->set(RequestRuntimeInterface::class, static fn (AppContainer $c): RequestRuntimeInterface => $c->get(RequestRuntime::class));
|
$container->set(RequestRuntimeInterface::class, static fn (AppContainer $c): RequestRuntimeInterface => $c->get(RequestRuntime::class));
|
||||||
|
$container->set(ModuleMigrationRepository::class, static fn (): ModuleMigrationRepository => new ModuleMigrationRepository());
|
||||||
|
$container->set(ModuleMigrationService::class, static fn (AppContainer $c): ModuleMigrationService => new ModuleMigrationService(
|
||||||
|
$c->get(ModuleRegistry::class),
|
||||||
|
$c->get(ModuleMigrationRepository::class)
|
||||||
|
));
|
||||||
|
$container->set(ModulePermissionSynchronizer::class, fn (AppContainer $c): ModulePermissionSynchronizer => new ModulePermissionSynchronizer(
|
||||||
|
$c->get(ModuleRegistry::class),
|
||||||
|
$c->get(PermissionRepository::class),
|
||||||
|
$projectRoot . '/modules'
|
||||||
|
));
|
||||||
|
$container->set(ModuleRuntimePageBuilder::class, static fn (): ModuleRuntimePageBuilder => new ModuleRuntimePageBuilder());
|
||||||
|
$container->set(ModuleRuntimeAssetPublisher::class, static fn (): ModuleRuntimeAssetPublisher => new ModuleRuntimeAssetPublisher());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ use MintyPHP\App\AppContainer;
|
|||||||
/**
|
/**
|
||||||
* Handles cleanup when a module is deactivated.
|
* Handles cleanup when a module is deactivated.
|
||||||
*
|
*
|
||||||
* Invoked by `bin/module-deactivate.php` after a module has been removed
|
* Invoked by `php bin/console module:deactivate` after a module has been removed
|
||||||
* from the enabled list. The handler receives the full app container and
|
* from the enabled list. The handler receives the full app container and
|
||||||
* can perform cleanup tasks like removing orphaned data or revoking permissions.
|
* can perform cleanup tasks like removing orphaned data or revoking permissions.
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -2,6 +2,9 @@
|
|||||||
|
|
||||||
namespace MintyPHP\Console;
|
namespace MintyPHP\Console;
|
||||||
|
|
||||||
|
use MintyPHP\Console\Support\CliAppBootstrap;
|
||||||
|
use MintyPHP\Console\Support\ModuleCliRuntime;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Base class for CLI commands.
|
* Base class for CLI commands.
|
||||||
*
|
*
|
||||||
@@ -40,13 +43,11 @@ abstract class Command
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Bootstrap the app for non-module CLI commands.
|
* Bootstrap the app for non-module CLI commands.
|
||||||
* Wraps bin/cli-bootstrap.php with stable path resolution.
|
* Uses the centralized CLI bootstrap runtime.
|
||||||
*/
|
*/
|
||||||
protected function bootstrapApp(string $channel = 'cli', ?string $path = null): void
|
protected function bootstrapApp(string $channel = 'cli', ?string $path = null): void
|
||||||
{
|
{
|
||||||
require_once $this->projectRoot() . '/bin/cli-bootstrap.php';
|
CliAppBootstrap::bootstrap($channel, $path ?? 'bin/console ' . $this->name());
|
||||||
|
|
||||||
cliBootstrapApp($channel, $path ?? 'bin/console ' . $this->name());
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -55,17 +56,6 @@ abstract class Command
|
|||||||
*/
|
*/
|
||||||
protected function bootstrapModuleApp(): void
|
protected function bootstrapModuleApp(): void
|
||||||
{
|
{
|
||||||
require_once $this->projectRoot() . '/bin/module-cli-bootstrap.php';
|
ModuleCliRuntime::bootstrap();
|
||||||
|
|
||||||
moduleCliBootstrapApp();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Require a bin script by name (e.g. 'module-migrate.php').
|
|
||||||
* Resolves the path from projectRoot — no fragile relative paths.
|
|
||||||
*/
|
|
||||||
protected function requireBinScript(string $filename): void
|
|
||||||
{
|
|
||||||
require_once $this->projectRoot() . '/bin/' . $filename;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,9 +3,18 @@
|
|||||||
namespace MintyPHP\Console\Commands;
|
namespace MintyPHP\Console\Commands;
|
||||||
|
|
||||||
use MintyPHP\Console\Command;
|
use MintyPHP\Console\Command;
|
||||||
|
use MintyPHP\Console\Runner\Doctor\DoctorRunner;
|
||||||
|
use MintyPHP\Console\Runner\Doctor\DoctorRunnerInterface;
|
||||||
|
|
||||||
final class DoctorCommand extends Command
|
final class DoctorCommand extends Command
|
||||||
{
|
{
|
||||||
|
private ?DoctorRunnerInterface $runner;
|
||||||
|
|
||||||
|
public function __construct(?DoctorRunnerInterface $runner = null)
|
||||||
|
{
|
||||||
|
$this->runner = $runner;
|
||||||
|
}
|
||||||
|
|
||||||
public function name(): string
|
public function name(): string
|
||||||
{
|
{
|
||||||
return 'doctor';
|
return 'doctor';
|
||||||
@@ -16,14 +25,58 @@ final class DoctorCommand extends Command
|
|||||||
return 'Run system health checks';
|
return 'Run system health checks';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function usage(): string
|
||||||
|
{
|
||||||
|
return <<<'USAGE'
|
||||||
|
Usage: php bin/console doctor [--format=json]
|
||||||
|
|
||||||
|
Options:
|
||||||
|
--format=json Output stable JSON for automation
|
||||||
|
USAGE;
|
||||||
|
}
|
||||||
|
|
||||||
public function execute(array $args, array $options): int
|
public function execute(array $args, array $options): int
|
||||||
{
|
{
|
||||||
// doctor.php calls exit() internally — run as subprocess
|
$format = strtolower((string) ($options['format'] ?? 'human'));
|
||||||
// to avoid terminating the console process.
|
if (!in_array($format, ['human', 'json'], true)) {
|
||||||
$scriptPath = $this->projectRoot() . '/bin/doctor.php';
|
fwrite(STDERR, "Invalid format '{$format}'. Supported: human, json\n");
|
||||||
$command = PHP_BINARY . ' ' . escapeshellarg($scriptPath);
|
return 1;
|
||||||
passthru($command, $exitCode);
|
}
|
||||||
|
|
||||||
return $exitCode;
|
$result = $this->runner()->run();
|
||||||
|
|
||||||
|
if ($format === 'json') {
|
||||||
|
echo json_encode($result, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES) . PHP_EOL;
|
||||||
|
return (int) $result['exit_code'];
|
||||||
|
}
|
||||||
|
|
||||||
|
echo "Minty Doctor Report\n";
|
||||||
|
echo "===================\n";
|
||||||
|
foreach ($result['checks'] as $item) {
|
||||||
|
echo sprintf(
|
||||||
|
"[%s] %s: %s\n",
|
||||||
|
strtoupper($item['status']),
|
||||||
|
$item['name'],
|
||||||
|
$item['message']
|
||||||
|
);
|
||||||
|
}
|
||||||
|
echo sprintf(
|
||||||
|
"\nSummary: ok=%d warn=%d fail=%d\n",
|
||||||
|
$result['ok_count'],
|
||||||
|
$result['warn_count'],
|
||||||
|
$result['fail_count']
|
||||||
|
);
|
||||||
|
|
||||||
|
return (int) $result['exit_code'];
|
||||||
|
}
|
||||||
|
|
||||||
|
private function runner(): DoctorRunnerInterface
|
||||||
|
{
|
||||||
|
if ($this->runner instanceof DoctorRunnerInterface) {
|
||||||
|
return $this->runner;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->runner = new DoctorRunner();
|
||||||
|
return $this->runner;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
27
lib/Console/Commands/Module/AbstractModuleCommand.php
Normal file
27
lib/Console/Commands/Module/AbstractModuleCommand.php
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace MintyPHP\Console\Commands\Module;
|
||||||
|
|
||||||
|
use MintyPHP\Console\Command;
|
||||||
|
use MintyPHP\Console\Runner\Module\ModuleRunner;
|
||||||
|
use MintyPHP\Console\Runner\Module\ModuleRunnerInterface;
|
||||||
|
|
||||||
|
abstract class AbstractModuleCommand extends Command
|
||||||
|
{
|
||||||
|
private ?ModuleRunnerInterface $moduleRunner;
|
||||||
|
|
||||||
|
public function __construct(?ModuleRunnerInterface $moduleRunner = null)
|
||||||
|
{
|
||||||
|
$this->moduleRunner = $moduleRunner;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function moduleRunner(): ModuleRunnerInterface
|
||||||
|
{
|
||||||
|
if ($this->moduleRunner instanceof ModuleRunnerInterface) {
|
||||||
|
return $this->moduleRunner;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->moduleRunner = new ModuleRunner();
|
||||||
|
return $this->moduleRunner;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,9 +2,7 @@
|
|||||||
|
|
||||||
namespace MintyPHP\Console\Commands\Module;
|
namespace MintyPHP\Console\Commands\Module;
|
||||||
|
|
||||||
use MintyPHP\Console\Command;
|
final class AssetsSyncCommand extends AbstractModuleCommand
|
||||||
|
|
||||||
final class AssetsSyncCommand extends Command
|
|
||||||
{
|
{
|
||||||
public function name(): string
|
public function name(): string
|
||||||
{
|
{
|
||||||
@@ -18,9 +16,20 @@ final class AssetsSyncCommand extends Command
|
|||||||
|
|
||||||
public function execute(array $args, array $options): int
|
public function execute(array $args, array $options): int
|
||||||
{
|
{
|
||||||
$this->requireBinScript('module-cli-bootstrap.php');
|
$result = $this->moduleRunner()->assetsSync();
|
||||||
$this->requireBinScript('module-assets-sync.php');
|
fwrite(STDOUT, sprintf(
|
||||||
|
"module-assets-sync: mode=%s created=%d updated=%d removed=%d unchanged=%d\n",
|
||||||
|
$result['mode'],
|
||||||
|
$result['created'],
|
||||||
|
$result['updated'],
|
||||||
|
$result['removed'],
|
||||||
|
$result['unchanged']
|
||||||
|
));
|
||||||
|
|
||||||
return moduleAssetsSyncRun();
|
if ($result['message'] !== 'module-assets-sync: ok') {
|
||||||
|
fwrite(STDOUT, $result['message'] . PHP_EOL);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (int) $result['exit_code'];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,9 +2,7 @@
|
|||||||
|
|
||||||
namespace MintyPHP\Console\Commands\Module;
|
namespace MintyPHP\Console\Commands\Module;
|
||||||
|
|
||||||
use MintyPHP\Console\Command;
|
final class BuildCommand extends AbstractModuleCommand
|
||||||
|
|
||||||
final class BuildCommand extends Command
|
|
||||||
{
|
{
|
||||||
public function name(): string
|
public function name(): string
|
||||||
{
|
{
|
||||||
@@ -18,9 +16,21 @@ final class BuildCommand extends Command
|
|||||||
|
|
||||||
public function execute(array $args, array $options): int
|
public function execute(array $args, array $options): int
|
||||||
{
|
{
|
||||||
$this->requireBinScript('module-cli-bootstrap.php');
|
$result = $this->moduleRunner()->build();
|
||||||
$this->requireBinScript('module-build.php');
|
if ($result['status'] === 'ok') {
|
||||||
|
fwrite(STDOUT, sprintf(
|
||||||
|
"module-build: runtime pages built - %d core entries + %d module entries.\n",
|
||||||
|
$result['core_entries'],
|
||||||
|
$result['module_entries']
|
||||||
|
));
|
||||||
|
} else {
|
||||||
|
fwrite(STDERR, $result['message'] . PHP_EOL);
|
||||||
|
}
|
||||||
|
|
||||||
return moduleBuildRun();
|
if ($result['message'] !== 'module-build: ok' && $result['status'] === 'ok') {
|
||||||
|
fwrite(STDOUT, $result['message'] . PHP_EOL);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (int) $result['exit_code'];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,9 +2,7 @@
|
|||||||
|
|
||||||
namespace MintyPHP\Console\Commands\Module;
|
namespace MintyPHP\Console\Commands\Module;
|
||||||
|
|
||||||
use MintyPHP\Console\Command;
|
final class DeactivateCommand extends AbstractModuleCommand
|
||||||
|
|
||||||
final class DeactivateCommand extends Command
|
|
||||||
{
|
{
|
||||||
public function name(): string
|
public function name(): string
|
||||||
{
|
{
|
||||||
@@ -37,18 +35,15 @@ final class DeactivateCommand extends Command
|
|||||||
return 1;
|
return 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Rebuild global argv for the underlying script
|
$result = $this->moduleRunner()->deactivate(
|
||||||
global $argv;
|
$moduleId,
|
||||||
$argv = ['module-deactivate.php', $moduleId];
|
(bool) ($options['confirm'] ?? false),
|
||||||
if ($options['confirm'] ?? false) {
|
(bool) ($options['dry-run'] ?? false)
|
||||||
$argv[] = '--confirm';
|
);
|
||||||
}
|
|
||||||
if ($options['dry-run'] ?? false) {
|
|
||||||
$argv[] = '--dry-run';
|
|
||||||
}
|
|
||||||
|
|
||||||
$this->requireBinScript('module-deactivate.php');
|
$stream = $result['exit_code'] === 0 ? STDOUT : STDERR;
|
||||||
|
fwrite($stream, $result['message'] . PHP_EOL);
|
||||||
|
|
||||||
return moduleDeactivateRun();
|
return (int) $result['exit_code'];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,9 +2,7 @@
|
|||||||
|
|
||||||
namespace MintyPHP\Console\Commands\Module;
|
namespace MintyPHP\Console\Commands\Module;
|
||||||
|
|
||||||
use MintyPHP\Console\Command;
|
final class MigrateCommand extends AbstractModuleCommand
|
||||||
|
|
||||||
final class MigrateCommand extends Command
|
|
||||||
{
|
{
|
||||||
public function name(): string
|
public function name(): string
|
||||||
{
|
{
|
||||||
@@ -18,9 +16,9 @@ final class MigrateCommand extends Command
|
|||||||
|
|
||||||
public function execute(array $args, array $options): int
|
public function execute(array $args, array $options): int
|
||||||
{
|
{
|
||||||
$this->requireBinScript('module-cli-bootstrap.php');
|
$result = $this->moduleRunner()->migrate();
|
||||||
$this->requireBinScript('module-migrate.php');
|
fwrite(STDOUT, $result['message'] . PHP_EOL);
|
||||||
|
|
||||||
return moduleMigrateRun();
|
return (int) $result['exit_code'];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,9 +2,7 @@
|
|||||||
|
|
||||||
namespace MintyPHP\Console\Commands\Module;
|
namespace MintyPHP\Console\Commands\Module;
|
||||||
|
|
||||||
use MintyPHP\Console\Command;
|
final class PermissionsSyncCommand extends AbstractModuleCommand
|
||||||
|
|
||||||
final class PermissionsSyncCommand extends Command
|
|
||||||
{
|
{
|
||||||
public function name(): string
|
public function name(): string
|
||||||
{
|
{
|
||||||
@@ -18,9 +16,20 @@ final class PermissionsSyncCommand extends Command
|
|||||||
|
|
||||||
public function execute(array $args, array $options): int
|
public function execute(array $args, array $options): int
|
||||||
{
|
{
|
||||||
$this->requireBinScript('module-cli-bootstrap.php');
|
$result = $this->moduleRunner()->permissionsSync();
|
||||||
$this->requireBinScript('module-permissions-sync.php');
|
fwrite(STDOUT, sprintf(
|
||||||
|
"module-permissions-sync: created=%d updated=%d unchanged=%d deactivated=%d total=%d\n",
|
||||||
|
$result['created'],
|
||||||
|
$result['updated'],
|
||||||
|
$result['unchanged'],
|
||||||
|
$result['deactivated'],
|
||||||
|
$result['total']
|
||||||
|
));
|
||||||
|
|
||||||
return modulePermissionsSyncRun();
|
if ($result['message'] !== 'module-permissions-sync: ok') {
|
||||||
|
fwrite(STDOUT, $result['message'] . PHP_EOL);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (int) $result['exit_code'];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,9 +2,7 @@
|
|||||||
|
|
||||||
namespace MintyPHP\Console\Commands\Module;
|
namespace MintyPHP\Console\Commands\Module;
|
||||||
|
|
||||||
use MintyPHP\Console\Command;
|
final class RuntimeSyncCommand extends AbstractModuleCommand
|
||||||
|
|
||||||
final class RuntimeSyncCommand extends Command
|
|
||||||
{
|
{
|
||||||
public function name(): string
|
public function name(): string
|
||||||
{
|
{
|
||||||
@@ -16,10 +14,42 @@ final class RuntimeSyncCommand extends Command
|
|||||||
return 'Run full module runtime sync (migrate, permissions, build, assets)';
|
return 'Run full module runtime sync (migrate, permissions, build, assets)';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function usage(): string
|
||||||
|
{
|
||||||
|
return <<<'USAGE'
|
||||||
|
Usage: php bin/console module:sync [--format=json]
|
||||||
|
|
||||||
|
Options:
|
||||||
|
--format=json Output stable JSON for automation
|
||||||
|
USAGE;
|
||||||
|
}
|
||||||
|
|
||||||
public function execute(array $args, array $options): int
|
public function execute(array $args, array $options): int
|
||||||
{
|
{
|
||||||
$this->requireBinScript('module-runtime-sync.php');
|
$format = strtolower((string) ($options['format'] ?? 'human'));
|
||||||
|
if (!in_array($format, ['human', 'json'], true)) {
|
||||||
|
fwrite(STDERR, "Invalid format '{$format}'. Supported: human, json\n");
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
|
||||||
return moduleRuntimeSyncRun();
|
$result = $this->moduleRunner()->runtimeSync();
|
||||||
|
|
||||||
|
if ($format === 'json') {
|
||||||
|
echo json_encode($result, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES) . PHP_EOL;
|
||||||
|
return (int) $result['exit_code'];
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ($result['steps'] as $stepName => $step) {
|
||||||
|
echo sprintf(
|
||||||
|
"module-runtime-sync: step=%s status=%s exit_code=%d vendor_warnings_ignored=%d\n",
|
||||||
|
$stepName,
|
||||||
|
$step['status'],
|
||||||
|
$step['exit_code'],
|
||||||
|
$step['vendor_warnings_ignored']
|
||||||
|
);
|
||||||
|
}
|
||||||
|
echo $result['message'] . PHP_EOL;
|
||||||
|
|
||||||
|
return (int) $result['exit_code'];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
317
lib/Console/Runner/Doctor/DoctorRunner.php
Normal file
317
lib/Console/Runner/Doctor/DoctorRunner.php
Normal file
@@ -0,0 +1,317 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace MintyPHP\Console\Runner\Doctor;
|
||||||
|
|
||||||
|
use MintyPHP\App\AppContainer;
|
||||||
|
use MintyPHP\App\Bootstrap\EnvValidator;
|
||||||
|
use MintyPHP\Console\Support\CliAppBootstrap;
|
||||||
|
use MintyPHP\DB;
|
||||||
|
use MintyPHP\Module\Audit\Service\SystemAuditService;
|
||||||
|
use MintyPHP\Service\Access\AuthorizationService;
|
||||||
|
use MintyPHP\Service\Access\PermissionService;
|
||||||
|
use MintyPHP\Service\Access\UiAccessService;
|
||||||
|
use MintyPHP\Service\Auth\AuthService;
|
||||||
|
use Throwable;
|
||||||
|
|
||||||
|
final class DoctorRunner implements DoctorRunnerInterface
|
||||||
|
{
|
||||||
|
public function run(): array
|
||||||
|
{
|
||||||
|
error_reporting(E_ALL & ~E_DEPRECATED & ~E_USER_DEPRECATED);
|
||||||
|
ini_set('display_errors', '0');
|
||||||
|
|
||||||
|
/** @var AppContainer $container */
|
||||||
|
$container = CliAppBootstrap::bootstrap('cli', 'bin/console doctor');
|
||||||
|
$startedAt = microtime(true);
|
||||||
|
|
||||||
|
$results = [];
|
||||||
|
$failCount = 0;
|
||||||
|
$warnCount = 0;
|
||||||
|
|
||||||
|
$runCheck = static function (string $name, callable $check) use (&$results, &$failCount, &$warnCount): void {
|
||||||
|
try {
|
||||||
|
$result = $check();
|
||||||
|
} catch (Throwable $throwable) {
|
||||||
|
$result = [
|
||||||
|
'status' => 'fail',
|
||||||
|
'message' => $throwable->getMessage(),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
$status = strtolower(trim((string) ($result['status'] ?? 'fail')));
|
||||||
|
if (!in_array($status, ['ok', 'warn', 'fail'], true)) {
|
||||||
|
$status = 'fail';
|
||||||
|
}
|
||||||
|
|
||||||
|
$message = trim((string) ($result['message'] ?? ''));
|
||||||
|
if ($message === '') {
|
||||||
|
$message = 'no details';
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($status === 'fail') {
|
||||||
|
$failCount++;
|
||||||
|
} elseif ($status === 'warn') {
|
||||||
|
$warnCount++;
|
||||||
|
}
|
||||||
|
|
||||||
|
$results[] = [
|
||||||
|
'status' => $status,
|
||||||
|
'name' => $name,
|
||||||
|
'message' => $message,
|
||||||
|
];
|
||||||
|
};
|
||||||
|
|
||||||
|
$runCheck('Environment validation', static function (): array {
|
||||||
|
EnvValidator::validate();
|
||||||
|
return [
|
||||||
|
'status' => 'ok',
|
||||||
|
'message' => 'all required env keys and formats are valid',
|
||||||
|
];
|
||||||
|
});
|
||||||
|
|
||||||
|
$runCheck('App container bootstrap', static function () use ($container): array {
|
||||||
|
if (!$container instanceof AppContainer) {
|
||||||
|
return [
|
||||||
|
'status' => 'fail',
|
||||||
|
'message' => 'registerContainer.php did not return AppContainer',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
app(AuthService::class);
|
||||||
|
app(AuthorizationService::class);
|
||||||
|
app(UiAccessService::class);
|
||||||
|
|
||||||
|
return [
|
||||||
|
'status' => 'ok',
|
||||||
|
'message' => 'core services resolved successfully',
|
||||||
|
];
|
||||||
|
});
|
||||||
|
|
||||||
|
$runCheck('Database connectivity', static function (): array {
|
||||||
|
$pong = DB::selectValue('select 1');
|
||||||
|
if ((int) $pong !== 1) {
|
||||||
|
return [
|
||||||
|
'status' => 'fail',
|
||||||
|
'message' => 'select 1 did not return expected value',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
'status' => 'ok',
|
||||||
|
'message' => 'connection established',
|
||||||
|
];
|
||||||
|
});
|
||||||
|
|
||||||
|
$runCheck('Database schema basics', static function (): array {
|
||||||
|
$requiredTables = [
|
||||||
|
'users',
|
||||||
|
'roles',
|
||||||
|
'permissions',
|
||||||
|
'user_roles',
|
||||||
|
'role_permissions',
|
||||||
|
'tenants',
|
||||||
|
'departments',
|
||||||
|
'settings',
|
||||||
|
'scheduler_runtime_status',
|
||||||
|
];
|
||||||
|
|
||||||
|
$rows = DB::select(
|
||||||
|
'select table_name from information_schema.tables where table_schema = database() and table_name in (???)',
|
||||||
|
$requiredTables
|
||||||
|
);
|
||||||
|
|
||||||
|
$present = [];
|
||||||
|
foreach ((array) $rows as $row) {
|
||||||
|
$table = (string) ($row['tables']['table_name'] ?? $row['table_name'] ?? '');
|
||||||
|
if ($table !== '') {
|
||||||
|
$present[] = $table;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
$present = array_values(array_unique($present));
|
||||||
|
sort($present, SORT_STRING);
|
||||||
|
|
||||||
|
$missing = array_values(array_diff($requiredTables, $present));
|
||||||
|
if ($missing !== []) {
|
||||||
|
return [
|
||||||
|
'status' => 'fail',
|
||||||
|
'message' => 'missing tables: ' . implode(', ', $missing),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
'status' => 'ok',
|
||||||
|
'message' => sprintf('%d core tables present', count($requiredTables)),
|
||||||
|
];
|
||||||
|
});
|
||||||
|
|
||||||
|
$runCheck('Storage path writeability', static function (): array {
|
||||||
|
$storagePath = defined('APP_STORAGE_PATH') && APP_STORAGE_PATH
|
||||||
|
? rtrim((string) APP_STORAGE_PATH, '/')
|
||||||
|
: rtrim(CliAppBootstrap::projectRoot() . '/storage', '/');
|
||||||
|
|
||||||
|
if (!is_dir($storagePath)) {
|
||||||
|
return [
|
||||||
|
'status' => 'fail',
|
||||||
|
'message' => "storage directory not found: {$storagePath}",
|
||||||
|
];
|
||||||
|
}
|
||||||
|
if (!is_writable($storagePath)) {
|
||||||
|
return [
|
||||||
|
'status' => 'fail',
|
||||||
|
'message' => "storage directory not writable: {$storagePath}",
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
$probeFile = $storagePath . '/.doctor-write-probe-' . uniqid('', true);
|
||||||
|
$written = @file_put_contents($probeFile, 'ok');
|
||||||
|
if ($written === false) {
|
||||||
|
return [
|
||||||
|
'status' => 'fail',
|
||||||
|
'message' => "write probe failed in: {$storagePath}",
|
||||||
|
];
|
||||||
|
}
|
||||||
|
@unlink($probeFile);
|
||||||
|
|
||||||
|
return [
|
||||||
|
'status' => 'ok',
|
||||||
|
'message' => "storage path is writable ({$storagePath})",
|
||||||
|
];
|
||||||
|
});
|
||||||
|
|
||||||
|
$runCheck('RBAC baseline permissions', static function (): array {
|
||||||
|
$requiredPermissions = [
|
||||||
|
PermissionService::USERS_VIEW,
|
||||||
|
PermissionService::TENANTS_VIEW,
|
||||||
|
PermissionService::DEPARTMENTS_VIEW,
|
||||||
|
PermissionService::ROLES_VIEW,
|
||||||
|
PermissionService::PERMISSIONS_VIEW,
|
||||||
|
PermissionService::SETTINGS_VIEW,
|
||||||
|
];
|
||||||
|
|
||||||
|
$rows = DB::select(
|
||||||
|
'select `key` from permissions where active = 1 and `key` in (???)',
|
||||||
|
$requiredPermissions
|
||||||
|
);
|
||||||
|
|
||||||
|
$present = [];
|
||||||
|
foreach ((array) $rows as $row) {
|
||||||
|
$key = (string) ($row['permissions']['key'] ?? $row['key'] ?? '');
|
||||||
|
if ($key !== '') {
|
||||||
|
$present[] = $key;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
$present = array_values(array_unique($present));
|
||||||
|
sort($present, SORT_STRING);
|
||||||
|
|
||||||
|
$missing = array_values(array_diff($requiredPermissions, $present));
|
||||||
|
if ($missing !== []) {
|
||||||
|
return [
|
||||||
|
'status' => 'fail',
|
||||||
|
'message' => 'missing active permissions: ' . implode(', ', $missing),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
'status' => 'ok',
|
||||||
|
'message' => sprintf('%d baseline permissions active', count($requiredPermissions)),
|
||||||
|
];
|
||||||
|
});
|
||||||
|
|
||||||
|
$runCheck('Admin role assignment', static function (): array {
|
||||||
|
$count = (int) (DB::selectValue(
|
||||||
|
'select count(distinct ur.user_id) from user_roles ur join roles r on r.id = ur.role_id and r.active = 1 where r.description in (?, ?) or r.id = 1',
|
||||||
|
'Admin',
|
||||||
|
'Administrator'
|
||||||
|
) ?? 0);
|
||||||
|
|
||||||
|
if ($count <= 0) {
|
||||||
|
return [
|
||||||
|
'status' => 'fail',
|
||||||
|
'message' => 'no active user assigned to Admin/Administrator role',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
'status' => 'ok',
|
||||||
|
'message' => sprintf('%d admin user(s) assigned', $count),
|
||||||
|
];
|
||||||
|
});
|
||||||
|
|
||||||
|
$runCheck('Scheduler heartbeat', static function (): array {
|
||||||
|
$row = DB::selectOne('select last_heartbeat_at, last_result, last_error_code from scheduler_runtime_status where id = 1 limit 1');
|
||||||
|
$status = is_array($row) ? ($row['scheduler_runtime_status'] ?? $row) : null;
|
||||||
|
if (!is_array($status)) {
|
||||||
|
return [
|
||||||
|
'status' => 'warn',
|
||||||
|
'message' => 'no scheduler runtime status row found yet',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
$heartbeat = trim((string) ($status['last_heartbeat_at'] ?? ''));
|
||||||
|
$result = trim((string) ($status['last_result'] ?? 'unknown'));
|
||||||
|
$errorCode = trim((string) ($status['last_error_code'] ?? ''));
|
||||||
|
if ($heartbeat === '') {
|
||||||
|
return [
|
||||||
|
'status' => 'warn',
|
||||||
|
'message' => 'scheduler heartbeat is empty',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
$seconds = time() - strtotime($heartbeat . ' UTC');
|
||||||
|
if ($seconds < 0) {
|
||||||
|
$seconds = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($seconds > 300) {
|
||||||
|
return [
|
||||||
|
'status' => 'warn',
|
||||||
|
'message' => "last heartbeat {$seconds}s ago (result={$result}" . ($errorCode !== '' ? ", error={$errorCode}" : '') . ')',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
'status' => 'ok',
|
||||||
|
'message' => "last heartbeat {$seconds}s ago (result={$result}" . ($errorCode !== '' ? ", error={$errorCode}" : '') . ')',
|
||||||
|
];
|
||||||
|
});
|
||||||
|
|
||||||
|
$okCount = count($results) - $warnCount - $failCount;
|
||||||
|
$exitCode = $failCount > 0 ? 1 : 0;
|
||||||
|
$status = $failCount > 0 ? 'fail' : ($warnCount > 0 ? 'warn' : 'ok');
|
||||||
|
$durationMs = max(0, (int) round((microtime(true) - $startedAt) * 1000));
|
||||||
|
|
||||||
|
try {
|
||||||
|
app(SystemAuditService::class)->record(
|
||||||
|
'cli.command',
|
||||||
|
$exitCode === 0 ? 'success' : 'failed',
|
||||||
|
[
|
||||||
|
'metadata' => [
|
||||||
|
'command' => 'bin/console doctor',
|
||||||
|
'exit_code' => $exitCode,
|
||||||
|
'duration_ms' => $durationMs,
|
||||||
|
'result' => $status,
|
||||||
|
'warn_count' => $warnCount,
|
||||||
|
'fail_count' => $failCount,
|
||||||
|
],
|
||||||
|
]
|
||||||
|
);
|
||||||
|
} catch (Throwable) {
|
||||||
|
// fail-open
|
||||||
|
} finally {
|
||||||
|
DB::close();
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
'command' => 'doctor',
|
||||||
|
'status' => $status,
|
||||||
|
'checks' => $results,
|
||||||
|
'ok_count' => $okCount,
|
||||||
|
'warn_count' => $warnCount,
|
||||||
|
'fail_count' => $failCount,
|
||||||
|
'exit_code' => $exitCode,
|
||||||
|
'duration_ms' => $durationMs,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
22
lib/Console/Runner/Doctor/DoctorRunnerInterface.php
Normal file
22
lib/Console/Runner/Doctor/DoctorRunnerInterface.php
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace MintyPHP\Console\Runner\Doctor;
|
||||||
|
|
||||||
|
interface DoctorRunnerInterface
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* @return array{
|
||||||
|
* command: string,
|
||||||
|
* status: string,
|
||||||
|
* checks: list<array{status: string, name: string, message: string}>,
|
||||||
|
* ok_count: int,
|
||||||
|
* warn_count: int,
|
||||||
|
* fail_count: int,
|
||||||
|
* exit_code: int,
|
||||||
|
* duration_ms: int
|
||||||
|
* }
|
||||||
|
*/
|
||||||
|
public function run(): array;
|
||||||
|
}
|
||||||
348
lib/Console/Runner/Module/ModuleRunner.php
Normal file
348
lib/Console/Runner/Module/ModuleRunner.php
Normal file
@@ -0,0 +1,348 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace MintyPHP\Console\Runner\Module;
|
||||||
|
|
||||||
|
use MintyPHP\App\AppContainer;
|
||||||
|
use MintyPHP\App\Module\Contracts\ModuleDeactivationHandler;
|
||||||
|
use MintyPHP\App\Module\ModuleManifestLoader;
|
||||||
|
use MintyPHP\App\Module\ModulePermissionSynchronizer;
|
||||||
|
use MintyPHP\App\Module\ModuleRegistry;
|
||||||
|
use MintyPHP\App\Module\ModuleRuntimeAssetPublisher;
|
||||||
|
use MintyPHP\App\Module\ModuleRuntimePageBuilder;
|
||||||
|
use MintyPHP\Console\Support\ModuleCliRuntime;
|
||||||
|
use MintyPHP\Service\Module\ModuleMigrationService;
|
||||||
|
|
||||||
|
final class ModuleRunner implements ModuleRunnerInterface
|
||||||
|
{
|
||||||
|
public function migrate(): array
|
||||||
|
{
|
||||||
|
$summary = ['applied' => 0, 'modules_enabled' => 0, 'skipped_empty' => 0];
|
||||||
|
$message = 'module-migrate: all modules up to date, 0 new migrations.';
|
||||||
|
$step = ModuleCliRuntime::runStep('module-migrate', function () use (&$summary, &$message): int {
|
||||||
|
$lockResult = ModuleCliRuntime::withFileLock(
|
||||||
|
ModuleCliRuntime::projectRoot() . '/storage/runtime/.module-migrate.lock',
|
||||||
|
'module-migrate: another migration run is in progress, skipping.',
|
||||||
|
function () use (&$summary, &$message): int {
|
||||||
|
/** @var ModuleMigrationService $service */
|
||||||
|
$service = app(ModuleMigrationService::class);
|
||||||
|
$result = $service->applyPendingMigrations();
|
||||||
|
$summary = $result;
|
||||||
|
|
||||||
|
if ($result['modules_enabled'] === 0) {
|
||||||
|
$message = 'module-migrate: no modules enabled, nothing to do.';
|
||||||
|
} elseif ($result['applied'] === 0) {
|
||||||
|
$message = 'module-migrate: all modules up to date, 0 new migrations.';
|
||||||
|
} else {
|
||||||
|
$message = sprintf('module-migrate: applied %d migration(s).', $result['applied']);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($result['skipped_empty'] > 0) {
|
||||||
|
$message .= sprintf(' (%d empty file(s) skipped)', $result['skipped_empty']);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $result['ok'] ? 0 : 1;
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
if ($lockResult['busy']) {
|
||||||
|
$message = (string) $lockResult['message'];
|
||||||
|
}
|
||||||
|
|
||||||
|
return $lockResult['exit_code'];
|
||||||
|
});
|
||||||
|
|
||||||
|
return [
|
||||||
|
'command' => 'module:migrate',
|
||||||
|
'status' => $step['status'],
|
||||||
|
'exit_code' => $step['exit_code'],
|
||||||
|
'vendor_warnings_ignored' => $step['vendor_warnings_ignored'],
|
||||||
|
'message' => $step['error'] ?? $message,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function permissionsSync(): array
|
||||||
|
{
|
||||||
|
$sync = [
|
||||||
|
'created' => 0,
|
||||||
|
'updated' => 0,
|
||||||
|
'unchanged' => 0,
|
||||||
|
'deactivated' => 0,
|
||||||
|
'total' => 0,
|
||||||
|
];
|
||||||
|
$message = 'module-permissions-sync: ok';
|
||||||
|
|
||||||
|
$step = ModuleCliRuntime::runStep('module-permissions-sync', function () use (&$sync, &$message): int {
|
||||||
|
$lockResult = ModuleCliRuntime::withFileLock(
|
||||||
|
ModuleCliRuntime::projectRoot() . '/storage/runtime/.module-permissions-sync.lock',
|
||||||
|
'module-permissions-sync: another sync is in progress, skipping.',
|
||||||
|
function () use (&$sync): int {
|
||||||
|
/** @var ModulePermissionSynchronizer $synchronizer */
|
||||||
|
$synchronizer = app(ModulePermissionSynchronizer::class);
|
||||||
|
$sync = $synchronizer->sync();
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
if ($lockResult['busy']) {
|
||||||
|
$message = (string) $lockResult['message'];
|
||||||
|
}
|
||||||
|
|
||||||
|
return $lockResult['exit_code'];
|
||||||
|
});
|
||||||
|
|
||||||
|
return [
|
||||||
|
'command' => 'module:permissions-sync',
|
||||||
|
'status' => $step['status'],
|
||||||
|
'exit_code' => $step['exit_code'],
|
||||||
|
'vendor_warnings_ignored' => $step['vendor_warnings_ignored'],
|
||||||
|
'created' => (int) $sync['created'],
|
||||||
|
'updated' => (int) $sync['updated'],
|
||||||
|
'unchanged' => (int) $sync['unchanged'],
|
||||||
|
'deactivated' => (int) $sync['deactivated'],
|
||||||
|
'total' => (int) $sync['total'],
|
||||||
|
'message' => $step['error'] ?? $message,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function build(): array
|
||||||
|
{
|
||||||
|
$buildSummary = [
|
||||||
|
'core_entries' => 0,
|
||||||
|
'module_entries' => 0,
|
||||||
|
];
|
||||||
|
$message = 'module-build: ok';
|
||||||
|
|
||||||
|
$step = ModuleCliRuntime::runStep('module-build', function () use (&$buildSummary, &$message): int {
|
||||||
|
$runtimeDir = ModuleCliRuntime::projectRoot() . '/storage/runtime/pages';
|
||||||
|
$runtimeParent = dirname($runtimeDir);
|
||||||
|
if (!is_dir($runtimeParent) && !mkdir($runtimeParent, 0775, true) && !is_dir($runtimeParent)) {
|
||||||
|
$message = "Failed to create runtime directory: {$runtimeParent}";
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
$lockResult = ModuleCliRuntime::withFileLock(
|
||||||
|
ModuleCliRuntime::projectRoot() . '/storage/runtime/.module-build.lock',
|
||||||
|
'module-build: another build is in progress, skipping.',
|
||||||
|
function () use (&$buildSummary, $runtimeDir): int {
|
||||||
|
/** @var ModuleRegistry $registry */
|
||||||
|
$registry = app(ModuleRegistry::class);
|
||||||
|
/** @var ModuleRuntimePageBuilder $builder */
|
||||||
|
$builder = app(ModuleRuntimePageBuilder::class);
|
||||||
|
$modules = $registry->getModules();
|
||||||
|
$buildSummary = $builder->build(
|
||||||
|
$runtimeDir,
|
||||||
|
ModuleCliRuntime::projectRoot() . '/pages',
|
||||||
|
$modules
|
||||||
|
);
|
||||||
|
ModuleRuntimePageBuilder::writeFingerprint($runtimeDir, $modules);
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
if ($lockResult['busy']) {
|
||||||
|
$message = (string) $lockResult['message'];
|
||||||
|
}
|
||||||
|
|
||||||
|
return $lockResult['exit_code'];
|
||||||
|
});
|
||||||
|
|
||||||
|
return [
|
||||||
|
'command' => 'module:build',
|
||||||
|
'status' => $step['status'],
|
||||||
|
'exit_code' => $step['exit_code'],
|
||||||
|
'vendor_warnings_ignored' => $step['vendor_warnings_ignored'],
|
||||||
|
'core_entries' => (int) $buildSummary['core_entries'],
|
||||||
|
'module_entries' => (int) $buildSummary['module_entries'],
|
||||||
|
'message' => $step['error'] ?? $message,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function assetsSync(): array
|
||||||
|
{
|
||||||
|
$summary = [
|
||||||
|
'created' => 0,
|
||||||
|
'updated' => 0,
|
||||||
|
'removed' => 0,
|
||||||
|
'unchanged' => 0,
|
||||||
|
];
|
||||||
|
$mode = trim((string) getenv('APP_MODULE_ASSET_MODE'));
|
||||||
|
if ($mode === '') {
|
||||||
|
$mode = 'symlink';
|
||||||
|
}
|
||||||
|
$message = 'module-assets-sync: ok';
|
||||||
|
|
||||||
|
$step = ModuleCliRuntime::runStep('module-assets-sync', function () use (&$summary, &$message, $mode): int {
|
||||||
|
$lockResult = ModuleCliRuntime::withFileLock(
|
||||||
|
ModuleCliRuntime::projectRoot() . '/storage/runtime/.module-assets-sync.lock',
|
||||||
|
'module-assets-sync: another sync is in progress, skipping.',
|
||||||
|
function () use (&$summary, $mode): int {
|
||||||
|
/** @var ModuleRegistry $registry */
|
||||||
|
$registry = app(ModuleRegistry::class);
|
||||||
|
/** @var ModuleRuntimeAssetPublisher $publisher */
|
||||||
|
$publisher = app(ModuleRuntimeAssetPublisher::class);
|
||||||
|
$summary = $publisher->publish(
|
||||||
|
ModuleCliRuntime::projectRoot() . '/web/modules',
|
||||||
|
$registry->getModules(),
|
||||||
|
$mode
|
||||||
|
);
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
if ($lockResult['busy']) {
|
||||||
|
$message = (string) $lockResult['message'];
|
||||||
|
}
|
||||||
|
|
||||||
|
return $lockResult['exit_code'];
|
||||||
|
});
|
||||||
|
|
||||||
|
return [
|
||||||
|
'command' => 'module:assets-sync',
|
||||||
|
'status' => $step['status'],
|
||||||
|
'exit_code' => $step['exit_code'],
|
||||||
|
'vendor_warnings_ignored' => $step['vendor_warnings_ignored'],
|
||||||
|
'mode' => $mode,
|
||||||
|
'created' => (int) $summary['created'],
|
||||||
|
'updated' => (int) $summary['updated'],
|
||||||
|
'removed' => (int) $summary['removed'],
|
||||||
|
'unchanged' => (int) $summary['unchanged'],
|
||||||
|
'message' => $step['error'] ?? $message,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function deactivate(string $moduleId, bool $confirm, bool $dryRun): array
|
||||||
|
{
|
||||||
|
$message = '';
|
||||||
|
|
||||||
|
$step = ModuleCliRuntime::runStep('module-deactivate', function () use ($moduleId, $confirm, $dryRun, &$message): int {
|
||||||
|
if ($moduleId === '') {
|
||||||
|
$message = 'Error: module id is required.';
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!$confirm && !$dryRun) {
|
||||||
|
$message = 'Error: must pass --confirm to execute or --dry-run to validate.';
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
$manifest = ModuleManifestLoader::loadFromDisk(
|
||||||
|
ModuleCliRuntime::projectRoot() . '/modules',
|
||||||
|
$moduleId
|
||||||
|
);
|
||||||
|
|
||||||
|
$handlerClass = $manifest->deactivationHandler;
|
||||||
|
if ($handlerClass === null) {
|
||||||
|
$message = "Module '{$moduleId}' has no deactivation_handler declared — nothing to do.";
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!class_exists($handlerClass)) {
|
||||||
|
$message = "Error: deactivation handler class '{$handlerClass}' not found.";
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
$container = $GLOBALS['minty_app_container'] ?? null;
|
||||||
|
if (!$container instanceof AppContainer) {
|
||||||
|
$message = 'Error: app container is not initialized.';
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
$handler = $container->has($handlerClass)
|
||||||
|
? $container->get($handlerClass)
|
||||||
|
: new $handlerClass();
|
||||||
|
|
||||||
|
if (!$handler instanceof ModuleDeactivationHandler) {
|
||||||
|
$message = "Error: '{$handlerClass}' does not implement ModuleDeactivationHandler.";
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($dryRun) {
|
||||||
|
$message = "Dry-run: handler '{$handlerClass}' found and valid for module '{$moduleId}'.";
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
$handler->deactivate($container);
|
||||||
|
$message = "Deactivation handler for module '{$moduleId}' completed.";
|
||||||
|
|
||||||
|
return 0;
|
||||||
|
});
|
||||||
|
|
||||||
|
return [
|
||||||
|
'command' => 'module:deactivate',
|
||||||
|
'status' => $step['status'],
|
||||||
|
'exit_code' => $step['exit_code'],
|
||||||
|
'vendor_warnings_ignored' => $step['vendor_warnings_ignored'],
|
||||||
|
'message' => $step['error'] ?? $message,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function runtimeSync(): array
|
||||||
|
{
|
||||||
|
$startedAt = microtime(true);
|
||||||
|
$steps = [
|
||||||
|
'migrate' => ['status' => 'pending', 'exit_code' => 0, 'vendor_warnings_ignored' => 0],
|
||||||
|
'permissions-sync' => ['status' => 'pending', 'exit_code' => 0, 'vendor_warnings_ignored' => 0],
|
||||||
|
'build' => ['status' => 'pending', 'exit_code' => 0, 'vendor_warnings_ignored' => 0],
|
||||||
|
'assets-sync' => ['status' => 'pending', 'exit_code' => 0, 'vendor_warnings_ignored' => 0],
|
||||||
|
];
|
||||||
|
$message = '';
|
||||||
|
|
||||||
|
$step = ModuleCliRuntime::runStep('module-runtime-sync', function () use (&$steps, &$message): int {
|
||||||
|
$lockResult = ModuleCliRuntime::withFileLock(
|
||||||
|
ModuleCliRuntime::projectRoot() . '/storage/runtime/.module-runtime-sync.lock',
|
||||||
|
'module-runtime-sync: another runtime sync is in progress, skipping.',
|
||||||
|
function () use (&$steps): int {
|
||||||
|
$orderedSteps = [
|
||||||
|
'migrate' => fn (): array => $this->migrate(),
|
||||||
|
'permissions-sync' => fn (): array => $this->permissionsSync(),
|
||||||
|
'build' => fn (): array => $this->build(),
|
||||||
|
'assets-sync' => fn (): array => $this->assetsSync(),
|
||||||
|
];
|
||||||
|
|
||||||
|
foreach ($orderedSteps as $stepName => $runner) {
|
||||||
|
$result = $runner();
|
||||||
|
$steps[$stepName] = [
|
||||||
|
'status' => $result['status'],
|
||||||
|
'exit_code' => $result['exit_code'],
|
||||||
|
'vendor_warnings_ignored' => $result['vendor_warnings_ignored'],
|
||||||
|
];
|
||||||
|
|
||||||
|
if ($result['exit_code'] !== 0) {
|
||||||
|
return $result['exit_code'];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
if ($lockResult['busy']) {
|
||||||
|
$message = (string) $lockResult['message'];
|
||||||
|
}
|
||||||
|
|
||||||
|
return $lockResult['exit_code'];
|
||||||
|
});
|
||||||
|
|
||||||
|
if ($message === '') {
|
||||||
|
$message = sprintf(
|
||||||
|
'module-runtime-sync: summary migrate=%s permissions-sync=%s build=%s assets-sync=%s vendor_warnings_ignored=%d',
|
||||||
|
$steps['migrate']['status'],
|
||||||
|
$steps['permissions-sync']['status'],
|
||||||
|
$steps['build']['status'],
|
||||||
|
$steps['assets-sync']['status'],
|
||||||
|
$step['vendor_warnings_ignored']
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
'command' => 'module:sync',
|
||||||
|
'status' => $step['status'],
|
||||||
|
'exit_code' => $step['exit_code'],
|
||||||
|
'vendor_warnings_ignored_total' => $step['vendor_warnings_ignored'],
|
||||||
|
'duration_ms' => max(0, (int) round((microtime(true) - $startedAt) * 1000)),
|
||||||
|
'steps' => $steps,
|
||||||
|
'message' => $step['error'] ?? $message,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
82
lib/Console/Runner/Module/ModuleRunnerInterface.php
Normal file
82
lib/Console/Runner/Module/ModuleRunnerInterface.php
Normal file
@@ -0,0 +1,82 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace MintyPHP\Console\Runner\Module;
|
||||||
|
|
||||||
|
interface ModuleRunnerInterface
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* @return array{command: string, status: string, exit_code: int, vendor_warnings_ignored: int, message: string}
|
||||||
|
*/
|
||||||
|
public function migrate(): array;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array{
|
||||||
|
* command: string,
|
||||||
|
* status: string,
|
||||||
|
* exit_code: int,
|
||||||
|
* vendor_warnings_ignored: int,
|
||||||
|
* created: int,
|
||||||
|
* updated: int,
|
||||||
|
* unchanged: int,
|
||||||
|
* deactivated: int,
|
||||||
|
* total: int,
|
||||||
|
* message: string
|
||||||
|
* }
|
||||||
|
*/
|
||||||
|
public function permissionsSync(): array;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array{
|
||||||
|
* command: string,
|
||||||
|
* status: string,
|
||||||
|
* exit_code: int,
|
||||||
|
* vendor_warnings_ignored: int,
|
||||||
|
* core_entries: int,
|
||||||
|
* module_entries: int,
|
||||||
|
* message: string
|
||||||
|
* }
|
||||||
|
*/
|
||||||
|
public function build(): array;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array{
|
||||||
|
* command: string,
|
||||||
|
* status: string,
|
||||||
|
* exit_code: int,
|
||||||
|
* vendor_warnings_ignored: int,
|
||||||
|
* mode: string,
|
||||||
|
* created: int,
|
||||||
|
* updated: int,
|
||||||
|
* removed: int,
|
||||||
|
* unchanged: int,
|
||||||
|
* message: string
|
||||||
|
* }
|
||||||
|
*/
|
||||||
|
public function assetsSync(): array;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array{
|
||||||
|
* command: string,
|
||||||
|
* status: string,
|
||||||
|
* exit_code: int,
|
||||||
|
* vendor_warnings_ignored: int,
|
||||||
|
* message: string
|
||||||
|
* }
|
||||||
|
*/
|
||||||
|
public function deactivate(string $moduleId, bool $confirm, bool $dryRun): array;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array{
|
||||||
|
* command: string,
|
||||||
|
* status: string,
|
||||||
|
* exit_code: int,
|
||||||
|
* vendor_warnings_ignored_total: int,
|
||||||
|
* duration_ms: int,
|
||||||
|
* steps: array<string, array{status: string, exit_code: int, vendor_warnings_ignored: int}>,
|
||||||
|
* message: string
|
||||||
|
* }
|
||||||
|
*/
|
||||||
|
public function runtimeSync(): array;
|
||||||
|
}
|
||||||
55
lib/Console/Support/CliAppBootstrap.php
Normal file
55
lib/Console/Support/CliAppBootstrap.php
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace MintyPHP\Console\Support;
|
||||||
|
|
||||||
|
use MintyPHP\App\AppContainer;
|
||||||
|
use MintyPHP\Http\RequestContext;
|
||||||
|
use RuntimeException;
|
||||||
|
|
||||||
|
final class CliAppBootstrap
|
||||||
|
{
|
||||||
|
private static bool $booted = false;
|
||||||
|
private static ?AppContainer $container = null;
|
||||||
|
|
||||||
|
private function __construct()
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function projectRoot(): string
|
||||||
|
{
|
||||||
|
return dirname(__DIR__, 3);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function bootstrap(string $channel = 'cli', string $path = 'bin/cli'): AppContainer
|
||||||
|
{
|
||||||
|
if (!self::$booted) {
|
||||||
|
$projectRoot = self::projectRoot();
|
||||||
|
chdir($projectRoot);
|
||||||
|
|
||||||
|
require_once $projectRoot . '/vendor/autoload.php';
|
||||||
|
require_once $projectRoot . '/config/config.php';
|
||||||
|
require_once $projectRoot . '/lib/Support/helpers.php';
|
||||||
|
|
||||||
|
/** @var AppContainer $container */
|
||||||
|
$container = require $projectRoot . '/lib/App/registerContainer.php';
|
||||||
|
setAppContainer($container);
|
||||||
|
|
||||||
|
self::$container = $container;
|
||||||
|
self::$booted = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
RequestContext::start([
|
||||||
|
'channel' => $channel,
|
||||||
|
'method' => 'CLI',
|
||||||
|
'path' => $path,
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (!self::$container instanceof AppContainer) {
|
||||||
|
throw new RuntimeException('CLI app container bootstrap failed.');
|
||||||
|
}
|
||||||
|
|
||||||
|
return self::$container;
|
||||||
|
}
|
||||||
|
}
|
||||||
173
lib/Console/Support/ModuleCliRuntime.php
Normal file
173
lib/Console/Support/ModuleCliRuntime.php
Normal file
@@ -0,0 +1,173 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace MintyPHP\Console\Support;
|
||||||
|
|
||||||
|
use RuntimeException;
|
||||||
|
use Throwable;
|
||||||
|
|
||||||
|
final class ModuleCliRuntime
|
||||||
|
{
|
||||||
|
private static bool $booted = false;
|
||||||
|
private static bool $errorPolicyInstalled = false;
|
||||||
|
private static int $vendorWarningsIgnored = 0;
|
||||||
|
|
||||||
|
private function __construct()
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function bootstrap(): void
|
||||||
|
{
|
||||||
|
if (self::$booted) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
self::installErrorPolicy();
|
||||||
|
|
||||||
|
$projectRoot = CliAppBootstrap::projectRoot();
|
||||||
|
chdir($projectRoot);
|
||||||
|
|
||||||
|
require_once $projectRoot . '/vendor/autoload.php';
|
||||||
|
require_once $projectRoot . '/config/config.php';
|
||||||
|
require_once $projectRoot . '/lib/Support/helpers.php';
|
||||||
|
|
||||||
|
$container = require $projectRoot . '/lib/App/registerContainer.php';
|
||||||
|
setAppContainer($container);
|
||||||
|
|
||||||
|
self::$booted = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function projectRoot(): string
|
||||||
|
{
|
||||||
|
return CliAppBootstrap::projectRoot();
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function vendorWarningsIgnoredTotal(): int
|
||||||
|
{
|
||||||
|
return self::$vendorWarningsIgnored;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param callable():int $runner
|
||||||
|
* @return array{step: string, status: string, exit_code: int, vendor_warnings_ignored: int, error: string|null}
|
||||||
|
*/
|
||||||
|
public static function runStep(string $stepName, callable $runner): array
|
||||||
|
{
|
||||||
|
$warningsBefore = self::vendorWarningsIgnoredTotal();
|
||||||
|
self::bootstrap();
|
||||||
|
|
||||||
|
$exitCode = 0;
|
||||||
|
$error = null;
|
||||||
|
try {
|
||||||
|
$exitCode = $runner();
|
||||||
|
} catch (Throwable $throwable) {
|
||||||
|
$exitCode = 1;
|
||||||
|
$error = $throwable->getMessage();
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
'step' => $stepName,
|
||||||
|
'status' => $exitCode === 0 ? 'ok' : 'failed',
|
||||||
|
'exit_code' => $exitCode,
|
||||||
|
'vendor_warnings_ignored' => max(0, self::vendorWarningsIgnoredTotal() - $warningsBefore),
|
||||||
|
'error' => $error,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param callable():int $runner
|
||||||
|
* @return array{busy: bool, message: string|null, exit_code: int}
|
||||||
|
*/
|
||||||
|
public static function withFileLock(string $lockFile, string $busyMessage, callable $runner): array
|
||||||
|
{
|
||||||
|
$lockDir = dirname($lockFile);
|
||||||
|
if (!is_dir($lockDir) && !mkdir($lockDir, 0775, true) && !is_dir($lockDir)) {
|
||||||
|
throw new RuntimeException("Failed to create lock directory: {$lockDir}");
|
||||||
|
}
|
||||||
|
|
||||||
|
$lock = fopen($lockFile, 'c');
|
||||||
|
if ($lock === false || !flock($lock, LOCK_EX | LOCK_NB)) {
|
||||||
|
if (is_resource($lock)) {
|
||||||
|
fclose($lock);
|
||||||
|
}
|
||||||
|
return [
|
||||||
|
'busy' => true,
|
||||||
|
'message' => $busyMessage,
|
||||||
|
'exit_code' => 0,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
return [
|
||||||
|
'busy' => false,
|
||||||
|
'message' => null,
|
||||||
|
'exit_code' => $runner(),
|
||||||
|
];
|
||||||
|
} finally {
|
||||||
|
flock($lock, LOCK_UN);
|
||||||
|
fclose($lock);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function installErrorPolicy(): void
|
||||||
|
{
|
||||||
|
if (self::$errorPolicyInstalled) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
self::$errorPolicyInstalled = true;
|
||||||
|
|
||||||
|
error_reporting(E_ALL);
|
||||||
|
ini_set('display_errors', '0');
|
||||||
|
|
||||||
|
set_error_handler(static function (
|
||||||
|
int $severity,
|
||||||
|
string $message,
|
||||||
|
string $file = '',
|
||||||
|
int $line = 0
|
||||||
|
): bool {
|
||||||
|
$handledSeverities = [
|
||||||
|
E_WARNING,
|
||||||
|
E_NOTICE,
|
||||||
|
E_USER_WARNING,
|
||||||
|
E_USER_NOTICE,
|
||||||
|
E_DEPRECATED,
|
||||||
|
E_USER_DEPRECATED,
|
||||||
|
];
|
||||||
|
if (!in_array($severity, $handledSeverities, true)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ((error_reporting() & $severity) === 0) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
$normalizedFile = str_replace('\\', '/', $file);
|
||||||
|
if (str_contains($normalizedFile, '/vendor/')) {
|
||||||
|
self::$vendorWarningsIgnored++;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new RuntimeException(sprintf(
|
||||||
|
'First-party runtime warning (%s) in %s:%d: %s',
|
||||||
|
self::severityName($severity),
|
||||||
|
$file,
|
||||||
|
$line,
|
||||||
|
$message
|
||||||
|
));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function severityName(int $severity): string
|
||||||
|
{
|
||||||
|
return match ($severity) {
|
||||||
|
E_WARNING => 'E_WARNING',
|
||||||
|
E_NOTICE => 'E_NOTICE',
|
||||||
|
E_USER_WARNING => 'E_USER_WARNING',
|
||||||
|
E_USER_NOTICE => 'E_USER_NOTICE',
|
||||||
|
E_DEPRECATED => 'E_DEPRECATED',
|
||||||
|
E_USER_DEPRECATED => 'E_USER_DEPRECATED',
|
||||||
|
default => 'E_' . $severity,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -24,20 +24,21 @@ final class ModuleMigrationService
|
|||||||
/**
|
/**
|
||||||
* Apply all pending module migrations.
|
* Apply all pending module migrations.
|
||||||
*
|
*
|
||||||
* @return array{ok: bool, applied: int}
|
* @return array{ok: bool, applied: int, skipped_empty: int, modules_enabled: int}
|
||||||
*/
|
*/
|
||||||
public function applyPendingMigrations(): array
|
public function applyPendingMigrations(): array
|
||||||
{
|
{
|
||||||
$modules = $this->registry->getModules();
|
$modules = $this->registry->getModules();
|
||||||
|
$modulesEnabled = count($modules);
|
||||||
|
|
||||||
if (count($modules) === 0) {
|
if ($modulesEnabled === 0) {
|
||||||
fwrite(STDOUT, "module-migrate: no modules enabled, nothing to do.\n");
|
return ['ok' => true, 'applied' => 0, 'skipped_empty' => 0, 'modules_enabled' => 0];
|
||||||
return ['ok' => true, 'applied' => 0];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
$this->repository->ensureTrackingTable();
|
$this->repository->ensureTrackingTable();
|
||||||
|
|
||||||
$totalApplied = 0;
|
$totalApplied = 0;
|
||||||
|
$skippedEmpty = 0;
|
||||||
|
|
||||||
foreach ($modules as $manifest) {
|
foreach ($modules as $manifest) {
|
||||||
$migrationsPath = $manifest->migrationsPath;
|
$migrationsPath = $manifest->migrationsPath;
|
||||||
@@ -61,12 +62,10 @@ final class ModuleMigrationService
|
|||||||
|
|
||||||
$sql = file_get_contents($file);
|
$sql = file_get_contents($file);
|
||||||
if ($sql === false || trim($sql) === '') {
|
if ($sql === false || trim($sql) === '') {
|
||||||
fwrite(STDERR, sprintf("module-migrate: WARNING skipping empty file %s/%s\n", $manifest->id, $filename));
|
$skippedEmpty++;
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
fwrite(STDOUT, sprintf("module-migrate: applying %s/%s ... ", $manifest->id, $filename));
|
|
||||||
|
|
||||||
$this->repository->beginTransaction();
|
$this->repository->beginTransaction();
|
||||||
try {
|
try {
|
||||||
$statements = SqlStatementParser::splitStatements($sql);
|
$statements = SqlStatementParser::splitStatements($sql);
|
||||||
@@ -85,17 +84,15 @@ final class ModuleMigrationService
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
fwrite(STDOUT, "OK\n");
|
|
||||||
$totalApplied++;
|
$totalApplied++;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($totalApplied === 0) {
|
return [
|
||||||
fwrite(STDOUT, "module-migrate: all modules up to date, 0 new migrations.\n");
|
'ok' => true,
|
||||||
} else {
|
'applied' => $totalApplied,
|
||||||
fwrite(STDOUT, sprintf("module-migrate: applied %d migration(s).\n", $totalApplied));
|
'skipped_empty' => $skippedEmpty,
|
||||||
}
|
'modules_enabled' => $modulesEnabled,
|
||||||
|
];
|
||||||
return ['ok' => true, 'applied' => $totalApplied];
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
10
phpstan.neon
10
phpstan.neon
@@ -3,15 +3,7 @@ parameters:
|
|||||||
phpVersion: 80499
|
phpVersion: 80499
|
||||||
scanFiles:
|
scanFiles:
|
||||||
- web/index.php
|
- web/index.php
|
||||||
- bin/cli-bootstrap.php
|
- bin/console
|
||||||
- bin/module-cli-bootstrap.php
|
|
||||||
- bin/module-migrate.php
|
|
||||||
- bin/module-deactivate.php
|
|
||||||
- bin/module-build.php
|
|
||||||
- bin/module-assets-sync.php
|
|
||||||
- bin/module-permissions-sync.php
|
|
||||||
- bin/module-runtime-sync.php
|
|
||||||
- bin/doctor.php
|
|
||||||
paths:
|
paths:
|
||||||
- config
|
- config
|
||||||
- lib
|
- lib
|
||||||
|
|||||||
79
tests/Console/DoctorCommandTest.php
Normal file
79
tests/Console/DoctorCommandTest.php
Normal file
@@ -0,0 +1,79 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace MintyPHP\Tests\Console;
|
||||||
|
|
||||||
|
use MintyPHP\Console\Commands\DoctorCommand;
|
||||||
|
use MintyPHP\Console\Runner\Doctor\DoctorRunnerInterface;
|
||||||
|
use PHPUnit\Framework\TestCase;
|
||||||
|
|
||||||
|
final class DoctorCommandTest extends TestCase
|
||||||
|
{
|
||||||
|
public function testDoctorJsonFormatOutputsStableShapeAndExitCode(): void
|
||||||
|
{
|
||||||
|
$runner = new FakeDoctorRunner([
|
||||||
|
'command' => 'doctor',
|
||||||
|
'status' => 'fail',
|
||||||
|
'checks' => [
|
||||||
|
['status' => 'ok', 'name' => 'A', 'message' => 'B'],
|
||||||
|
],
|
||||||
|
'ok_count' => 1,
|
||||||
|
'warn_count' => 0,
|
||||||
|
'fail_count' => 1,
|
||||||
|
'exit_code' => 1,
|
||||||
|
'duration_ms' => 12,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$command = new DoctorCommand($runner);
|
||||||
|
|
||||||
|
ob_start();
|
||||||
|
$exitCode = $command->execute([], ['format' => 'json']);
|
||||||
|
$output = (string) ob_get_clean();
|
||||||
|
|
||||||
|
self::assertSame(1, $exitCode);
|
||||||
|
|
||||||
|
/** @var array<string, mixed> $decoded */
|
||||||
|
$decoded = json_decode($output, true, 512, JSON_THROW_ON_ERROR);
|
||||||
|
self::assertSame('doctor', $decoded['command']);
|
||||||
|
self::assertSame('fail', $decoded['status']);
|
||||||
|
self::assertSame(1, $decoded['exit_code']);
|
||||||
|
self::assertSame(12, $decoded['duration_ms']);
|
||||||
|
self::assertArrayHasKey('checks', $decoded);
|
||||||
|
self::assertIsArray($decoded['checks']);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testDoctorInvalidFormatReturnsOne(): void
|
||||||
|
{
|
||||||
|
$command = new DoctorCommand(new FakeDoctorRunner([
|
||||||
|
'command' => 'doctor',
|
||||||
|
'status' => 'ok',
|
||||||
|
'checks' => [],
|
||||||
|
'ok_count' => 0,
|
||||||
|
'warn_count' => 0,
|
||||||
|
'fail_count' => 0,
|
||||||
|
'exit_code' => 0,
|
||||||
|
'duration_ms' => 0,
|
||||||
|
]));
|
||||||
|
|
||||||
|
$exitCode = $command->execute([], ['format' => 'xml']);
|
||||||
|
self::assertSame(1, $exitCode);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
final class FakeDoctorRunner implements DoctorRunnerInterface
|
||||||
|
{
|
||||||
|
/** @var array<string, mixed> */
|
||||||
|
private array $result;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed> $result
|
||||||
|
*/
|
||||||
|
public function __construct(array $result)
|
||||||
|
{
|
||||||
|
$this->result = $result;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function run(): array
|
||||||
|
{
|
||||||
|
return $this->result;
|
||||||
|
}
|
||||||
|
}
|
||||||
180
tests/Console/ModuleCommandsTest.php
Normal file
180
tests/Console/ModuleCommandsTest.php
Normal file
@@ -0,0 +1,180 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace MintyPHP\Tests\Console;
|
||||||
|
|
||||||
|
use MintyPHP\Console\Commands\Module\DeactivateCommand;
|
||||||
|
use MintyPHP\Console\Commands\Module\RuntimeSyncCommand;
|
||||||
|
use MintyPHP\Console\Runner\Module\ModuleRunnerInterface;
|
||||||
|
use PHPUnit\Framework\TestCase;
|
||||||
|
|
||||||
|
final class ModuleCommandsTest extends TestCase
|
||||||
|
{
|
||||||
|
public function testRuntimeSyncJsonOutputShapeAndExitCode(): void
|
||||||
|
{
|
||||||
|
$runner = new FakeModuleRunner();
|
||||||
|
$runner->runtimeSyncResult = [
|
||||||
|
'command' => 'module:sync',
|
||||||
|
'status' => 'ok',
|
||||||
|
'exit_code' => 0,
|
||||||
|
'vendor_warnings_ignored_total' => 2,
|
||||||
|
'duration_ms' => 88,
|
||||||
|
'steps' => [
|
||||||
|
'migrate' => ['status' => 'ok', 'exit_code' => 0, 'vendor_warnings_ignored' => 0],
|
||||||
|
'permissions-sync' => ['status' => 'ok', 'exit_code' => 0, 'vendor_warnings_ignored' => 1],
|
||||||
|
'build' => ['status' => 'ok', 'exit_code' => 0, 'vendor_warnings_ignored' => 0],
|
||||||
|
'assets-sync' => ['status' => 'ok', 'exit_code' => 0, 'vendor_warnings_ignored' => 1],
|
||||||
|
],
|
||||||
|
'message' => 'done',
|
||||||
|
];
|
||||||
|
|
||||||
|
$command = new RuntimeSyncCommand($runner);
|
||||||
|
|
||||||
|
ob_start();
|
||||||
|
$exitCode = $command->execute([], ['format' => 'json']);
|
||||||
|
$output = (string) ob_get_clean();
|
||||||
|
|
||||||
|
self::assertSame(0, $exitCode);
|
||||||
|
|
||||||
|
/** @var array<string, mixed> $decoded */
|
||||||
|
$decoded = json_decode($output, true, 512, JSON_THROW_ON_ERROR);
|
||||||
|
self::assertSame('module:sync', $decoded['command']);
|
||||||
|
self::assertSame('ok', $decoded['status']);
|
||||||
|
self::assertSame(0, $decoded['exit_code']);
|
||||||
|
self::assertArrayHasKey('steps', $decoded);
|
||||||
|
self::assertArrayHasKey('migrate', $decoded['steps']);
|
||||||
|
self::assertArrayHasKey('assets-sync', $decoded['steps']);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testRuntimeSyncInvalidFormatReturnsOne(): void
|
||||||
|
{
|
||||||
|
$command = new RuntimeSyncCommand(new FakeModuleRunner());
|
||||||
|
$exitCode = $command->execute([], ['format' => 'xml']);
|
||||||
|
|
||||||
|
self::assertSame(1, $exitCode);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testDeactivateDoesNotMutateGlobalArgvAndPassesOptions(): void
|
||||||
|
{
|
||||||
|
$runner = new FakeModuleRunner();
|
||||||
|
$runner->deactivateResult = [
|
||||||
|
'command' => 'module:deactivate',
|
||||||
|
'status' => 'ok',
|
||||||
|
'exit_code' => 0,
|
||||||
|
'vendor_warnings_ignored' => 0,
|
||||||
|
'message' => 'ok',
|
||||||
|
];
|
||||||
|
|
||||||
|
$command = new DeactivateCommand($runner);
|
||||||
|
$argvBefore = ['bin/console', 'list'];
|
||||||
|
$GLOBALS['argv'] = $argvBefore;
|
||||||
|
|
||||||
|
$exitCode = $command->execute(['addressbook'], ['confirm' => true]);
|
||||||
|
|
||||||
|
self::assertSame(0, $exitCode);
|
||||||
|
self::assertSame('addressbook', $runner->deactivateModuleId);
|
||||||
|
self::assertTrue($runner->deactivateConfirm);
|
||||||
|
self::assertFalse($runner->deactivateDryRun);
|
||||||
|
self::assertSame($argvBefore, $GLOBALS['argv']);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
final class FakeModuleRunner implements ModuleRunnerInterface
|
||||||
|
{
|
||||||
|
/** @var array<string, mixed> */
|
||||||
|
public array $runtimeSyncResult = [
|
||||||
|
'command' => 'module:sync',
|
||||||
|
'status' => 'ok',
|
||||||
|
'exit_code' => 0,
|
||||||
|
'vendor_warnings_ignored_total' => 0,
|
||||||
|
'duration_ms' => 0,
|
||||||
|
'steps' => [
|
||||||
|
'migrate' => ['status' => 'ok', 'exit_code' => 0, 'vendor_warnings_ignored' => 0],
|
||||||
|
'permissions-sync' => ['status' => 'ok', 'exit_code' => 0, 'vendor_warnings_ignored' => 0],
|
||||||
|
'build' => ['status' => 'ok', 'exit_code' => 0, 'vendor_warnings_ignored' => 0],
|
||||||
|
'assets-sync' => ['status' => 'ok', 'exit_code' => 0, 'vendor_warnings_ignored' => 0],
|
||||||
|
],
|
||||||
|
'message' => 'ok',
|
||||||
|
];
|
||||||
|
|
||||||
|
/** @var array<string, mixed> */
|
||||||
|
public array $deactivateResult = [
|
||||||
|
'command' => 'module:deactivate',
|
||||||
|
'status' => 'ok',
|
||||||
|
'exit_code' => 0,
|
||||||
|
'vendor_warnings_ignored' => 0,
|
||||||
|
'message' => 'ok',
|
||||||
|
];
|
||||||
|
|
||||||
|
public string $deactivateModuleId = '';
|
||||||
|
public bool $deactivateConfirm = false;
|
||||||
|
public bool $deactivateDryRun = false;
|
||||||
|
|
||||||
|
public function migrate(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'command' => 'module:migrate',
|
||||||
|
'status' => 'ok',
|
||||||
|
'exit_code' => 0,
|
||||||
|
'vendor_warnings_ignored' => 0,
|
||||||
|
'message' => 'ok',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function permissionsSync(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'command' => 'module:permissions-sync',
|
||||||
|
'status' => 'ok',
|
||||||
|
'exit_code' => 0,
|
||||||
|
'vendor_warnings_ignored' => 0,
|
||||||
|
'created' => 0,
|
||||||
|
'updated' => 0,
|
||||||
|
'unchanged' => 0,
|
||||||
|
'deactivated' => 0,
|
||||||
|
'total' => 0,
|
||||||
|
'message' => 'ok',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function build(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'command' => 'module:build',
|
||||||
|
'status' => 'ok',
|
||||||
|
'exit_code' => 0,
|
||||||
|
'vendor_warnings_ignored' => 0,
|
||||||
|
'core_entries' => 0,
|
||||||
|
'module_entries' => 0,
|
||||||
|
'message' => 'ok',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function assetsSync(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'command' => 'module:assets-sync',
|
||||||
|
'status' => 'ok',
|
||||||
|
'exit_code' => 0,
|
||||||
|
'vendor_warnings_ignored' => 0,
|
||||||
|
'mode' => 'symlink',
|
||||||
|
'created' => 0,
|
||||||
|
'updated' => 0,
|
||||||
|
'removed' => 0,
|
||||||
|
'unchanged' => 0,
|
||||||
|
'message' => 'ok',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function deactivate(string $moduleId, bool $confirm, bool $dryRun): array
|
||||||
|
{
|
||||||
|
$this->deactivateModuleId = $moduleId;
|
||||||
|
$this->deactivateConfirm = $confirm;
|
||||||
|
$this->deactivateDryRun = $dryRun;
|
||||||
|
return $this->deactivateResult;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function runtimeSync(): array
|
||||||
|
{
|
||||||
|
return $this->runtimeSyncResult;
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user