diff --git a/CLAUDE.md b/CLAUDE.md index 79f6052..dddb278 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -90,7 +90,7 @@ db/updates/ # Idempotent SQL update scripts for existing installs tests/ # PHPUnit tests (namespace MintyPHP\Tests\) docs/ # German-language documentation (Markdown) 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 .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 - **Session keys:** Prefixed with `module..*` - **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 - **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: []` in their manifest. Undeclared cross-module imports are forbidden. Prefer loose coupling via events and providers over direct service injection diff --git a/README.md b/README.md index 47e6dcd..7e77e25 100644 --- a/README.md +++ b/README.md @@ -52,6 +52,16 @@ docker compose up --build -d 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) Es gibt eine getrennte Produktions-Compose: diff --git a/bin/dev b/bin/dev new file mode 100755 index 0000000..f3d63b3 --- /dev/null +++ b/bin/dev @@ -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 [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 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 diff --git a/bin/doctor.php b/bin/doctor.php deleted file mode 100755 index 3098d84..0000000 --- a/bin/doctor.php +++ /dev/null @@ -1,312 +0,0 @@ -#!/usr/bin/env php - $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); diff --git a/bin/module-assets-sync.php b/bin/module-assets-sync.php deleted file mode 100644 index 250e0da..0000000 --- a/bin/module-assets-sync.php +++ /dev/null @@ -1,61 +0,0 @@ -#!/usr/bin/env php - symlinks to modules//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()); -} diff --git a/bin/module-build.php b/bin/module-build.php deleted file mode 100644 index 000e3e2..0000000 --- a/bin/module-build.php +++ /dev/null @@ -1,68 +0,0 @@ -#!/usr/bin/env php -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()); -} diff --git a/bin/module-cli-bootstrap.php b/bin/module-cli-bootstrap.php deleted file mode 100644 index 8ee2026..0000000 --- a/bin/module-cli-bootstrap.php +++ /dev/null @@ -1,166 +0,0 @@ - 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); - } -} diff --git a/bin/module-deactivate.php b/bin/module-deactivate.php deleted file mode 100644 index 5547adf..0000000 --- a/bin/module-deactivate.php +++ /dev/null @@ -1,80 +0,0 @@ - --confirm - * php bin/module-deactivate.php --dry-run - * - * The module does NOT need to be in the enabled list — the manifest is loaded - * directly from modules//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 --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()); -} diff --git a/bin/module-migrate.php b/bin/module-migrate.php deleted file mode 100644 index 434f0d0..0000000 --- a/bin/module-migrate.php +++ /dev/null @@ -1,49 +0,0 @@ -#!/usr/bin/env php -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()); -} diff --git a/bin/module-permissions-sync.php b/bin/module-permissions-sync.php deleted file mode 100644 index 122d527..0000000 --- a/bin/module-permissions-sync.php +++ /dev/null @@ -1,56 +0,0 @@ -#!/usr/bin/env php -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()); -} diff --git a/bin/module-runtime-sync.php b/bin/module-runtime-sync.php deleted file mode 100755 index a4fd709..0000000 --- a/bin/module-runtime-sync.php +++ /dev/null @@ -1,77 +0,0 @@ -#!/usr/bin/env php - 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()); -} diff --git a/docs/explanation-di-container.md b/docs/explanation-di-container.md index d5ee3a3..8ab686b 100644 --- a/docs/explanation-di-container.md +++ b/docs/explanation-di-container.md @@ -103,6 +103,6 @@ $meinService = app(\MintyPHP\Service\MeinNeuerService::class); den konkreten Service direkt registrieren und per `app()` holen. - Der Container lebt **pro HTTP-Anfrage** — er wird in `web/index.php` initialisiert 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()`, `new ...Service()` oder `new ...Repository()` direkt instanziieren. diff --git a/docs/howto-lokale-entwicklung.md b/docs/howto-lokale-entwicklung.md index c6d5c26..dbfdddf 100644 --- a/docs/howto-lokale-entwicklung.md +++ b/docs/howto-lokale-entwicklung.md @@ -1,16 +1,29 @@ # Lokale Entwicklung -Letzte Aktualisierung: 2026-03-18 +Letzte Aktualisierung: 2026-04-01 ## Ziel 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 ```bash -docker compose up -d -docker compose down +bin/dev up +bin/dev down ``` 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. Alle verfuegbaren CLI-Kommandos: `php bin/console list` (siehe `/docs/reference-cli-commands.md`). +Alle Dev-Orchestrator-Kommandos: `bin/dev --help`. ## Schema-Stand diff --git a/docs/reference-cli-commands.md b/docs/reference-cli-commands.md index c01c910..51f9241 100644 --- a/docs/reference-cli-commands.md +++ b/docs/reference-cli-commands.md @@ -1,6 +1,6 @@ # CLI-Kommandoreferenz -Letzte Aktualisierung: 2026-03-25 +Letzte Aktualisierung: 2026-04-01 ## 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. +Maschinenlesbar: + +```bash +docker compose exec php php bin/console module:sync --format=json +``` + ### module:migrate 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. +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 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->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 | - -## 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. -``` diff --git a/docs/reference-extension-readiness.md b/docs/reference-extension-readiness.md index 82b38ce..a9a1bc2 100644 --- a/docs/reference-extension-readiness.md +++ b/docs/reference-extension-readiness.md @@ -25,7 +25,7 @@ Diese Readiness-Doku ist bewusst kurz und verweist fuer Felddefinitionen/Guards - Tenant-Scope serverseitig pruefen. - POST-Mutationen mit CSRF 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. 5. **Testbarkeit und Gates** - Architektur- und Domain-Tests fuer neue Logik ergaenzen. diff --git a/docs/reference-konventionen.md b/docs/reference-konventionen.md index 5440b74..24a1794 100644 --- a/docs/reference-konventionen.md +++ b/docs/reference-konventionen.md @@ -63,5 +63,5 @@ Letzte Aktualisierung: 2026-03-24 - `docker compose exec php vendor/bin/phpunit` - `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) diff --git a/docs/reference-module-manifest-contract.md b/docs/reference-module-manifest-contract.md index 21b482c..6945eab 100644 --- a/docs/reference-module-manifest-contract.md +++ b/docs/reference-module-manifest-contract.md @@ -63,8 +63,8 @@ Der `ModuleAutoloader` registriert daraus den absoluten Pfad `modules//i18n/ ``` - `key` und `description` sind Pflichtfelder. -- Runtime-Sync erfolgt ueber `php bin/module-permissions-sync.php`. -- `module-runtime-sync` fuehrt diesen Sync automatisch aus. +- Runtime-Sync erfolgt ueber `php bin/console module:sync`. +- `module:sync` fuehrt diesen Sync automatisch aus. ## `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. -- `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. -- 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. Methoden: `ModuleRuntimePageBuilder::expectedFingerprint()`, `readFingerprint()`, `writeFingerprint()`. @@ -138,12 +138,12 @@ API-Requests (`api/`-Prefix) ueberspringen den gesamten Web-Session-Flow: ## 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` -2. `module-permissions-sync` -3. `module-build` -4. `module-assets-sync` +1. `module:migrate` +2. `module:permissions-sync` +3. `module:build` +4. `module:assets-sync` Der Output enthaelt eine deterministische Step-Summary (`migrate`, `permissions-sync`, `build`, `assets-sync`) inklusive `vendor_warnings_ignored=`. First-party Warnings/Notices/Deprecations gelten als Fehler (fail-fast). diff --git a/lib/App/Container/Registrars/AppServicesRegistrar.php b/lib/App/Container/Registrars/AppServicesRegistrar.php index df50018..5574766 100644 --- a/lib/App/Container/Registrars/AppServicesRegistrar.php +++ b/lib/App/Container/Registrars/AppServicesRegistrar.php @@ -4,7 +4,10 @@ namespace MintyPHP\App\Container\Registrars; use MintyPHP\App\AppContainer; use MintyPHP\App\Container\ContainerRegistrar; +use MintyPHP\App\Module\ModulePermissionSynchronizer; use MintyPHP\App\Module\ModuleRegistry; +use MintyPHP\App\Module\ModuleRuntimeAssetPublisher; +use MintyPHP\App\Module\ModuleRuntimePageBuilder; use MintyPHP\Http\CookieStore; use MintyPHP\Http\CookieStoreInterface; use MintyPHP\Http\Input\RequestInputFactory; @@ -13,6 +16,8 @@ use MintyPHP\Http\RequestRuntime; use MintyPHP\Http\RequestRuntimeInterface; use MintyPHP\Http\SessionStore; use MintyPHP\Http\SessionStoreInterface; +use MintyPHP\Repository\Access\PermissionRepository; +use MintyPHP\Repository\Module\ModuleMigrationRepository; use MintyPHP\Repository\Search\SearchQueryRepository; use MintyPHP\Repository\Stats\AdminStatsRepository; use MintyPHP\Repository\System\SystemHealthRepository; @@ -27,6 +32,7 @@ use MintyPHP\Service\Import\ImportServicesFactory; use MintyPHP\Service\Mail\MailLogService; use MintyPHP\Service\Mail\MailService; use MintyPHP\Service\Mail\MailServicesFactory; +use MintyPHP\Service\Module\ModuleMigrationService; use MintyPHP\Service\Scheduler\ScheduledJobService; use MintyPHP\Service\Scheduler\SchedulerRunService; use MintyPHP\Service\Scheduler\SchedulerServicesFactory; @@ -41,6 +47,8 @@ final class AppServicesRegistrar implements ContainerRegistrar { public function register(AppContainer $container): void { + $projectRoot = dirname(__DIR__, 4); + $container->set(AdminStatsViewDataService::class, static fn (AppContainer $c): AdminStatsViewDataService => new AdminStatsViewDataService( $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(RequestRuntime::class, static fn (): RequestRuntime => new RequestRuntime()); $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()); } } diff --git a/lib/App/Module/Contracts/ModuleDeactivationHandler.php b/lib/App/Module/Contracts/ModuleDeactivationHandler.php index d6ae6e6..bc5d277 100644 --- a/lib/App/Module/Contracts/ModuleDeactivationHandler.php +++ b/lib/App/Module/Contracts/ModuleDeactivationHandler.php @@ -7,7 +7,7 @@ use MintyPHP\App\AppContainer; /** * 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 * can perform cleanup tasks like removing orphaned data or revoking permissions. */ diff --git a/lib/Console/Command.php b/lib/Console/Command.php index 6155981..f603df0 100644 --- a/lib/Console/Command.php +++ b/lib/Console/Command.php @@ -2,6 +2,9 @@ namespace MintyPHP\Console; +use MintyPHP\Console\Support\CliAppBootstrap; +use MintyPHP\Console\Support\ModuleCliRuntime; + /** * Base class for CLI commands. * @@ -40,13 +43,11 @@ abstract class Command /** * 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 { - require_once $this->projectRoot() . '/bin/cli-bootstrap.php'; - - cliBootstrapApp($channel, $path ?? 'bin/console ' . $this->name()); + CliAppBootstrap::bootstrap($channel, $path ?? 'bin/console ' . $this->name()); } /** @@ -55,17 +56,6 @@ abstract class Command */ protected function bootstrapModuleApp(): void { - require_once $this->projectRoot() . '/bin/module-cli-bootstrap.php'; - - 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; + ModuleCliRuntime::bootstrap(); } } diff --git a/lib/Console/Commands/DoctorCommand.php b/lib/Console/Commands/DoctorCommand.php index d6e9aff..d82ac39 100644 --- a/lib/Console/Commands/DoctorCommand.php +++ b/lib/Console/Commands/DoctorCommand.php @@ -3,9 +3,18 @@ namespace MintyPHP\Console\Commands; use MintyPHP\Console\Command; +use MintyPHP\Console\Runner\Doctor\DoctorRunner; +use MintyPHP\Console\Runner\Doctor\DoctorRunnerInterface; final class DoctorCommand extends Command { + private ?DoctorRunnerInterface $runner; + + public function __construct(?DoctorRunnerInterface $runner = null) + { + $this->runner = $runner; + } + public function name(): string { return 'doctor'; @@ -16,14 +25,58 @@ final class DoctorCommand extends Command 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 { - // doctor.php calls exit() internally — run as subprocess - // to avoid terminating the console process. - $scriptPath = $this->projectRoot() . '/bin/doctor.php'; - $command = PHP_BINARY . ' ' . escapeshellarg($scriptPath); - passthru($command, $exitCode); + $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 $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; } } diff --git a/lib/Console/Commands/Module/AbstractModuleCommand.php b/lib/Console/Commands/Module/AbstractModuleCommand.php new file mode 100644 index 0000000..590f04a --- /dev/null +++ b/lib/Console/Commands/Module/AbstractModuleCommand.php @@ -0,0 +1,27 @@ +moduleRunner = $moduleRunner; + } + + protected function moduleRunner(): ModuleRunnerInterface + { + if ($this->moduleRunner instanceof ModuleRunnerInterface) { + return $this->moduleRunner; + } + + $this->moduleRunner = new ModuleRunner(); + return $this->moduleRunner; + } +} diff --git a/lib/Console/Commands/Module/AssetsSyncCommand.php b/lib/Console/Commands/Module/AssetsSyncCommand.php index 1eed9cf..c868660 100644 --- a/lib/Console/Commands/Module/AssetsSyncCommand.php +++ b/lib/Console/Commands/Module/AssetsSyncCommand.php @@ -2,9 +2,7 @@ namespace MintyPHP\Console\Commands\Module; -use MintyPHP\Console\Command; - -final class AssetsSyncCommand extends Command +final class AssetsSyncCommand extends AbstractModuleCommand { public function name(): string { @@ -18,9 +16,20 @@ final class AssetsSyncCommand extends Command public function execute(array $args, array $options): int { - $this->requireBinScript('module-cli-bootstrap.php'); - $this->requireBinScript('module-assets-sync.php'); + $result = $this->moduleRunner()->assetsSync(); + 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']; } } diff --git a/lib/Console/Commands/Module/BuildCommand.php b/lib/Console/Commands/Module/BuildCommand.php index 763302e..a15acf5 100644 --- a/lib/Console/Commands/Module/BuildCommand.php +++ b/lib/Console/Commands/Module/BuildCommand.php @@ -2,9 +2,7 @@ namespace MintyPHP\Console\Commands\Module; -use MintyPHP\Console\Command; - -final class BuildCommand extends Command +final class BuildCommand extends AbstractModuleCommand { public function name(): string { @@ -18,9 +16,21 @@ final class BuildCommand extends Command public function execute(array $args, array $options): int { - $this->requireBinScript('module-cli-bootstrap.php'); - $this->requireBinScript('module-build.php'); + $result = $this->moduleRunner()->build(); + 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']; } } diff --git a/lib/Console/Commands/Module/DeactivateCommand.php b/lib/Console/Commands/Module/DeactivateCommand.php index e96f563..d1615a7 100644 --- a/lib/Console/Commands/Module/DeactivateCommand.php +++ b/lib/Console/Commands/Module/DeactivateCommand.php @@ -2,9 +2,7 @@ namespace MintyPHP\Console\Commands\Module; -use MintyPHP\Console\Command; - -final class DeactivateCommand extends Command +final class DeactivateCommand extends AbstractModuleCommand { public function name(): string { @@ -37,18 +35,15 @@ final class DeactivateCommand extends Command return 1; } - // Rebuild global argv for the underlying script - global $argv; - $argv = ['module-deactivate.php', $moduleId]; - if ($options['confirm'] ?? false) { - $argv[] = '--confirm'; - } - if ($options['dry-run'] ?? false) { - $argv[] = '--dry-run'; - } + $result = $this->moduleRunner()->deactivate( + $moduleId, + (bool) ($options['confirm'] ?? false), + (bool) ($options['dry-run'] ?? false) + ); - $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']; } } diff --git a/lib/Console/Commands/Module/MigrateCommand.php b/lib/Console/Commands/Module/MigrateCommand.php index 2e817f1..550e20e 100644 --- a/lib/Console/Commands/Module/MigrateCommand.php +++ b/lib/Console/Commands/Module/MigrateCommand.php @@ -2,9 +2,7 @@ namespace MintyPHP\Console\Commands\Module; -use MintyPHP\Console\Command; - -final class MigrateCommand extends Command +final class MigrateCommand extends AbstractModuleCommand { public function name(): string { @@ -18,9 +16,9 @@ final class MigrateCommand extends Command public function execute(array $args, array $options): int { - $this->requireBinScript('module-cli-bootstrap.php'); - $this->requireBinScript('module-migrate.php'); + $result = $this->moduleRunner()->migrate(); + fwrite(STDOUT, $result['message'] . PHP_EOL); - return moduleMigrateRun(); + return (int) $result['exit_code']; } } diff --git a/lib/Console/Commands/Module/PermissionsSyncCommand.php b/lib/Console/Commands/Module/PermissionsSyncCommand.php index e9cbde0..ceca7a8 100644 --- a/lib/Console/Commands/Module/PermissionsSyncCommand.php +++ b/lib/Console/Commands/Module/PermissionsSyncCommand.php @@ -2,9 +2,7 @@ namespace MintyPHP\Console\Commands\Module; -use MintyPHP\Console\Command; - -final class PermissionsSyncCommand extends Command +final class PermissionsSyncCommand extends AbstractModuleCommand { public function name(): string { @@ -18,9 +16,20 @@ final class PermissionsSyncCommand extends Command public function execute(array $args, array $options): int { - $this->requireBinScript('module-cli-bootstrap.php'); - $this->requireBinScript('module-permissions-sync.php'); + $result = $this->moduleRunner()->permissionsSync(); + 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']; } } diff --git a/lib/Console/Commands/Module/RuntimeSyncCommand.php b/lib/Console/Commands/Module/RuntimeSyncCommand.php index 1f78e6d..61d9de6 100644 --- a/lib/Console/Commands/Module/RuntimeSyncCommand.php +++ b/lib/Console/Commands/Module/RuntimeSyncCommand.php @@ -2,9 +2,7 @@ namespace MintyPHP\Console\Commands\Module; -use MintyPHP\Console\Command; - -final class RuntimeSyncCommand extends Command +final class RuntimeSyncCommand extends AbstractModuleCommand { public function name(): string { @@ -16,10 +14,42 @@ final class RuntimeSyncCommand extends Command 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 { - $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']; } } diff --git a/lib/Console/Runner/Doctor/DoctorRunner.php b/lib/Console/Runner/Doctor/DoctorRunner.php new file mode 100644 index 0000000..2570a9e --- /dev/null +++ b/lib/Console/Runner/Doctor/DoctorRunner.php @@ -0,0 +1,317 @@ + '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, + ]; + } +} diff --git a/lib/Console/Runner/Doctor/DoctorRunnerInterface.php b/lib/Console/Runner/Doctor/DoctorRunnerInterface.php new file mode 100644 index 0000000..9308e0a --- /dev/null +++ b/lib/Console/Runner/Doctor/DoctorRunnerInterface.php @@ -0,0 +1,22 @@ +, + * ok_count: int, + * warn_count: int, + * fail_count: int, + * exit_code: int, + * duration_ms: int + * } + */ + public function run(): array; +} diff --git a/lib/Console/Runner/Module/ModuleRunner.php b/lib/Console/Runner/Module/ModuleRunner.php new file mode 100644 index 0000000..973b5d4 --- /dev/null +++ b/lib/Console/Runner/Module/ModuleRunner.php @@ -0,0 +1,348 @@ + 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, + ]; + } +} diff --git a/lib/Console/Runner/Module/ModuleRunnerInterface.php b/lib/Console/Runner/Module/ModuleRunnerInterface.php new file mode 100644 index 0000000..daa83fa --- /dev/null +++ b/lib/Console/Runner/Module/ModuleRunnerInterface.php @@ -0,0 +1,82 @@ +, + * message: string + * } + */ + public function runtimeSync(): array; +} diff --git a/lib/Console/Support/CliAppBootstrap.php b/lib/Console/Support/CliAppBootstrap.php new file mode 100644 index 0000000..f347545 --- /dev/null +++ b/lib/Console/Support/CliAppBootstrap.php @@ -0,0 +1,55 @@ + $channel, + 'method' => 'CLI', + 'path' => $path, + ]); + + if (!self::$container instanceof AppContainer) { + throw new RuntimeException('CLI app container bootstrap failed.'); + } + + return self::$container; + } +} diff --git a/lib/Console/Support/ModuleCliRuntime.php b/lib/Console/Support/ModuleCliRuntime.php new file mode 100644 index 0000000..ed10fa7 --- /dev/null +++ b/lib/Console/Support/ModuleCliRuntime.php @@ -0,0 +1,173 @@ +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, + }; + } +} diff --git a/lib/Service/Module/ModuleMigrationService.php b/lib/Service/Module/ModuleMigrationService.php index 1c392cb..2223331 100644 --- a/lib/Service/Module/ModuleMigrationService.php +++ b/lib/Service/Module/ModuleMigrationService.php @@ -24,20 +24,21 @@ final class ModuleMigrationService /** * 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 { $modules = $this->registry->getModules(); + $modulesEnabled = count($modules); - if (count($modules) === 0) { - fwrite(STDOUT, "module-migrate: no modules enabled, nothing to do.\n"); - return ['ok' => true, 'applied' => 0]; + if ($modulesEnabled === 0) { + return ['ok' => true, 'applied' => 0, 'skipped_empty' => 0, 'modules_enabled' => 0]; } $this->repository->ensureTrackingTable(); $totalApplied = 0; + $skippedEmpty = 0; foreach ($modules as $manifest) { $migrationsPath = $manifest->migrationsPath; @@ -61,12 +62,10 @@ final class ModuleMigrationService $sql = file_get_contents($file); if ($sql === false || trim($sql) === '') { - fwrite(STDERR, sprintf("module-migrate: WARNING skipping empty file %s/%s\n", $manifest->id, $filename)); + $skippedEmpty++; continue; } - fwrite(STDOUT, sprintf("module-migrate: applying %s/%s ... ", $manifest->id, $filename)); - $this->repository->beginTransaction(); try { $statements = SqlStatementParser::splitStatements($sql); @@ -85,17 +84,15 @@ final class ModuleMigrationService ); } - fwrite(STDOUT, "OK\n"); $totalApplied++; } } - if ($totalApplied === 0) { - fwrite(STDOUT, "module-migrate: all modules up to date, 0 new migrations.\n"); - } else { - fwrite(STDOUT, sprintf("module-migrate: applied %d migration(s).\n", $totalApplied)); - } - - return ['ok' => true, 'applied' => $totalApplied]; + return [ + 'ok' => true, + 'applied' => $totalApplied, + 'skipped_empty' => $skippedEmpty, + 'modules_enabled' => $modulesEnabled, + ]; } } diff --git a/phpstan.neon b/phpstan.neon index ab1a06d..4b9da26 100644 --- a/phpstan.neon +++ b/phpstan.neon @@ -3,15 +3,7 @@ parameters: phpVersion: 80499 scanFiles: - web/index.php - - bin/cli-bootstrap.php - - 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 + - bin/console paths: - config - lib diff --git a/tests/Console/DoctorCommandTest.php b/tests/Console/DoctorCommandTest.php new file mode 100644 index 0000000..0060915 --- /dev/null +++ b/tests/Console/DoctorCommandTest.php @@ -0,0 +1,79 @@ + '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 $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 */ + private array $result; + + /** + * @param array $result + */ + public function __construct(array $result) + { + $this->result = $result; + } + + public function run(): array + { + return $this->result; + } +} diff --git a/tests/Console/ModuleCommandsTest.php b/tests/Console/ModuleCommandsTest.php new file mode 100644 index 0000000..4a4640e --- /dev/null +++ b/tests/Console/ModuleCommandsTest.php @@ -0,0 +1,180 @@ +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 $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 */ + 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 */ + 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; + } +}