diff --git a/CLAUDE.md b/CLAUDE.md index 0229b2f..531f5f0 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -2,6 +2,8 @@ Multi-tenant admin application built on MintyPHP. Manages tenants, users, departments, roles, permissions, address books, mail, CSV imports, scheduled jobs, and Microsoft Entra ID SSO. +> **Agent workflow:** guards, quality gates, role prompts, contracts, and skills live in `.agents/` — start with `.agents/README.md`. + ## Tech Stack - **Runtime:** PHP 8.5 (FPM) on MintyPHP framework (`mintyphp/core`) @@ -25,8 +27,16 @@ docker compose exec php vendor/bin/phpunit # Run PHPStan (level 5) docker compose exec php vendor/bin/phpstan analyse -c phpstan.neon --no-progress -# Sync module runtime (after module changes or APP_ENABLED_MODULES change) -docker compose exec php php bin/module-runtime-sync.php +# CLI commands (single entry point) +docker compose exec php php bin/console list +docker compose exec php php bin/console module:sync # full module runtime sync +docker compose exec php php bin/console module:migrate # apply module SQL migrations +docker compose exec php php bin/console module:permissions-sync # sync + deactivate orphaned +docker compose exec php php bin/console module:build # build runtime page root +docker compose exec php php bin/console module:assets-sync # publish module web assets +docker compose exec php php bin/console module:deactivate --confirm +docker compose exec php php bin/console scheduler:run +docker compose exec php php bin/console doctor # Check unused Composer packages docker compose exec php vendor/bin/composer-unused @@ -42,6 +52,8 @@ lib/ # All backend PHP (namespace MintyPHP\) Service/ # Business logic, validation, orchestration Http/ # Auth guards, API auth, request helpers Input/ # Request input handling (RequestInput, FormErrors) + Console/ # CLI command framework (ConsoleApplication, Command base class) + Commands/ # Command classes (Module/, Scheduler/, Tools/) Support/ # Crypto, flash, guard, search, helpers modules/ # Self-contained feature modules (namespace MintyPHP\Module\\) /module.php # Module manifest (routes, slots, providers, permissions) @@ -70,9 +82,9 @@ 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 scripts (scheduler, module-runtime-sync, doctor.php) -tools/ # Dev tooling scripts +bin/ # CLI entry point (bin/console) + legacy scripts + shell tools docker/ # Dockerfiles, Nginx configs, PHP configs +.agents/ # Agent workflow system (contracts, prompts, checks, skills, runs) ``` ## Architecture Rules diff --git a/bin/console b/bin/console new file mode 100755 index 0000000..e9957ea --- /dev/null +++ b/bin/console @@ -0,0 +1,43 @@ +#!/usr/bin/env php + [arguments] [options] + * php bin/console list + * + * Examples: + * php bin/console module:sync + * php bin/console module:deactivate addressbook --confirm + * php bin/console scheduler:run + * php bin/console doctor + */ + +require_once __DIR__ . '/../vendor/autoload.php'; + +use MintyPHP\Console\ConsoleApplication; +use MintyPHP\Console\Commands\DoctorCommand; +use MintyPHP\Console\Commands\Module\AssetsSyncCommand; +use MintyPHP\Console\Commands\Module\BuildCommand; +use MintyPHP\Console\Commands\Module\DeactivateCommand; +use MintyPHP\Console\Commands\Module\MigrateCommand; +use MintyPHP\Console\Commands\Module\PermissionsSyncCommand; +use MintyPHP\Console\Commands\Module\RuntimeSyncCommand; +use MintyPHP\Console\Commands\Scheduler\RunCommand; + +$app = new ConsoleApplication(); + +$app->register(new RuntimeSyncCommand()); +$app->register(new MigrateCommand()); +$app->register(new PermissionsSyncCommand()); +$app->register(new BuildCommand()); +$app->register(new AssetsSyncCommand()); +$app->register(new DeactivateCommand()); +$app->register(new RunCommand()); +$app->register(new DoctorCommand()); + +exit($app->run($argv)); diff --git a/bin/module-deactivate.php b/bin/module-deactivate.php index 4e601cf..6a4c731 100644 --- a/bin/module-deactivate.php +++ b/bin/module-deactivate.php @@ -15,70 +15,75 @@ declare(strict_types=1); require_once __DIR__ . '/module-cli-bootstrap.php'; -$exitCode = moduleCliRunStep('module-deactivate', static function (): int { - global $argv; +function moduleDeactivateRun(): int +{ + return moduleCliRunStep('module-deactivate', static function (): int { + global $argv; - $moduleId = $argv[1] ?? ''; - $flags = array_slice($argv, 2); + $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; - } + 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); + $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; - } + if (!$confirm && !$dryRun) { + fwrite(STDERR, "Error: must pass --confirm to execute or --dry-run to validate.\n"); + return 1; + } - $projectRoot = moduleCliProjectRoot(); - $manifestFile = $projectRoot . '/modules/' . $moduleId . '/module.php'; + $projectRoot = moduleCliProjectRoot(); + $manifestFile = $projectRoot . '/modules/' . $moduleId . '/module.php'; - if (!is_file($manifestFile)) { - fwrite(STDERR, "Error: module manifest not found at {$manifestFile}\n"); - return 1; - } + if (!is_file($manifestFile)) { + fwrite(STDERR, "Error: module manifest not found at {$manifestFile}\n"); + return 1; + } - $raw = require $manifestFile; - if (!is_array($raw)) { - fwrite(STDERR, "Error: manifest must return an array.\n"); - return 1; - } + $raw = require $manifestFile; + if (!is_array($raw)) { + fwrite(STDERR, "Error: manifest must return an array.\n"); + return 1; + } - $manifest = \MintyPHP\App\Module\ModuleManifest::fromArray($raw, dirname($manifestFile)); + $manifest = \MintyPHP\App\Module\ModuleManifest::fromArray($raw, dirname($manifestFile)); - $handlerClass = $manifest->deactivationHandler; - if ($handlerClass === null) { - fwrite(STDOUT, "Module '{$moduleId}' has no deactivation_handler declared — nothing to do.\n"); + $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 (!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; -}); - -exit($exitCode); +if (PHP_SAPI === 'cli' && realpath((string) ($_SERVER['SCRIPT_FILENAME'] ?? '')) === __FILE__) { + exit(moduleDeactivateRun()); +} diff --git a/lib/Console/Command.php b/lib/Console/Command.php new file mode 100644 index 0000000..7394285 --- /dev/null +++ b/lib/Console/Command.php @@ -0,0 +1,23 @@ + $args Positional arguments (after the command name) + * @param array $options Parsed --key=value and --flag options + * @return int Exit code (0 = success) + */ + abstract public function execute(array $args, array $options): int; +} diff --git a/lib/Console/Commands/DoctorCommand.php b/lib/Console/Commands/DoctorCommand.php new file mode 100644 index 0000000..23fc611 --- /dev/null +++ b/lib/Console/Commands/DoctorCommand.php @@ -0,0 +1,31 @@ + --confirm|--dry-run\n"); + 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'; + } + + require_once __DIR__ . '/../../../../bin/module-deactivate.php'; + + return moduleDeactivateRun(); + } +} diff --git a/lib/Console/Commands/Module/MigrateCommand.php b/lib/Console/Commands/Module/MigrateCommand.php new file mode 100644 index 0000000..1db646b --- /dev/null +++ b/lib/Console/Commands/Module/MigrateCommand.php @@ -0,0 +1,26 @@ +createSchedulerRunService()->runDueJobs(); + if (!($result['ok'] ?? false)) { + $error = (string) ($result['error'] ?? 'unexpected_error'); + fwrite(STDERR, sprintf("scheduler run failed: %s\n", $error)); + $exitCode = 1; + } else { + fwrite(STDOUT, sprintf( + "scheduler completed: processed=%d success=%d failed=%d skipped=%d duration_ms=%d\n", + (int) ($result['processed'] ?? 0), + (int) ($result['success'] ?? 0), + (int) ($result['failed'] ?? 0), + (int) ($result['skipped'] ?? 0), + (int) ($result['duration_ms'] ?? 0) + )); + } + } catch (\Throwable $exception) { + try { + app(SystemAuditService::class)->record('scheduler.run', 'failed', [ + 'error_code' => 'unexpected_error', + 'metadata' => [ + 'trigger_type' => 'scheduler', + 'run_status' => 'failed', + 'processed' => 0, + 'success_count' => 0, + 'failed_count' => 0, + 'skipped_count' => 0, + 'duration_ms' => max(0, (int) round((microtime(true) - $startedAt) * 1000)), + 'bootstrap_error' => true, + ], + ]); + } catch (\Throwable) { + // fail-open + } + + fwrite(STDERR, sprintf("scheduler run failed: unexpected_error: %s\n", $exception->getMessage())); + $exitCode = 1; + } finally { + DB::close(); + } + + return $exitCode; + } +} diff --git a/lib/Console/ConsoleApplication.php b/lib/Console/ConsoleApplication.php new file mode 100644 index 0000000..de0349e --- /dev/null +++ b/lib/Console/ConsoleApplication.php @@ -0,0 +1,94 @@ + */ + private array $commands = []; + + public function register(Command $command): void + { + $this->commands[$command->name()] = $command; + } + + /** + * @param list $argv Raw CLI arguments (including script name at [0]) + */ + public function run(array $argv): int + { + $commandName = $argv[1] ?? ''; + + if ($commandName === '' || $commandName === 'list' || $commandName === '--help') { + $this->printCommandList(); + return 0; + } + + if (!isset($this->commands[$commandName])) { + fwrite(STDERR, "Unknown command: {$commandName}\n\n"); + $this->printCommandList(); + return 1; + } + + [$args, $options] = self::parseArgv(array_slice($argv, 2)); + + return $this->commands[$commandName]->execute($args, $options); + } + + private function printCommandList(): void + { + fwrite(STDOUT, "Usage: php bin/console [arguments] [options]\n\n"); + fwrite(STDOUT, "Available commands:\n"); + + // Group by namespace prefix + $grouped = []; + foreach ($this->commands as $name => $command) { + $parts = explode(':', $name, 2); + $group = count($parts) === 2 ? $parts[0] : ''; + $grouped[$group][$name] = $command->description(); + } + ksort($grouped); + + foreach ($grouped as $group => $commands) { + if ($group !== '') { + fwrite(STDOUT, " {$group}\n"); + } + ksort($commands); + foreach ($commands as $name => $description) { + fwrite(STDOUT, sprintf(" %-30s %s\n", $name, $description)); + } + } + } + + /** + * @param list $tokens + * @return array{list, array} + */ + private static function parseArgv(array $tokens): array + { + $args = []; + $options = []; + + foreach ($tokens as $token) { + if (str_starts_with($token, '--')) { + $option = substr($token, 2); + if (str_contains($option, '=')) { + [$key, $value] = explode('=', $option, 2); + $options[$key] = $value; + } else { + $options[$option] = true; + } + } else { + $args[] = $token; + } + } + + return [$args, $options]; + } +} diff --git a/phpunit.xml b/phpunit.xml index 307ad54..1d7b8a2 100644 --- a/phpunit.xml +++ b/phpunit.xml @@ -53,7 +53,9 @@ tests/Architecture/DetailActionPolicyMigrationContractTest.php tests/Architecture/DetailActionPolicyPartialContractTest.php tests/Architecture/DetailActionPolicyRuntimeContractTest.php - tests/Architecture/DetailPageContractTest.php + tests/Architecture/DetailPageFormContractTest.php + tests/Architecture/DetailPageRuntimeContractTest.php + tests/Architecture/DetailPageTitlebarContractTest.php tests/Architecture/DetailValidationActionContractTest.php tests/Architecture/DetailValidationAssetContractTest.php tests/Architecture/DetailValidationTemplateContractTest.php diff --git a/tests/Architecture/DetailPageContractFiles.php b/tests/Architecture/DetailPageContractFiles.php new file mode 100644 index 0000000..07c3c4e --- /dev/null +++ b/tests/Architecture/DetailPageContractFiles.php @@ -0,0 +1,22 @@ + + */ + public static function standardDetailFormFiles(): array + { + return [ + 'pages/admin/users/_form.phtml', + 'pages/admin/tenants/_form.phtml', + 'pages/admin/departments/_form.phtml', + 'pages/admin/roles/_form.phtml', + 'pages/admin/permissions/_form.phtml', + 'pages/admin/scheduled-jobs/edit(default).phtml', + 'pages/admin/settings/index(default).phtml', + ]; + } +} diff --git a/tests/Architecture/DetailPageContractTest.php b/tests/Architecture/DetailPageContractTest.php deleted file mode 100644 index 88dd568..0000000 --- a/tests/Architecture/DetailPageContractTest.php +++ /dev/null @@ -1,59 +0,0 @@ - - */ - private function standardDetailFormFiles(): array - { - return [ - 'pages/admin/users/_form.phtml', - 'pages/admin/tenants/_form.phtml', - 'pages/admin/departments/_form.phtml', - 'pages/admin/roles/_form.phtml', - 'pages/admin/permissions/_form.phtml', - 'pages/admin/scheduled-jobs/edit(default).phtml', - 'pages/admin/settings/index(default).phtml', - ]; - } - - public function testScopedDetailFormsUseStandardDetailOptInAttribute(): void - { - foreach ($this->standardDetailFormFiles() as $file) { - $content = $this->readProjectFile($file); - $this->assertStringContainsString('data-standard-detail-form="1"', $content, $file); - } - } - - public function testAppInitIncludesStandardDetailPageAutoInitComponent(): void - { - $content = $this->readProjectFile('web/js/app-init.js'); - $this->assertStringContainsString("import { initStandardDetailPages } from './components/app-standard-detail-page.js';", $content); - $this->assertStringContainsString("runtime.register('standard-detail-page', initStandardDetailPages", $content); - } - - public function testDetailsTitlebarExposesUnsavedMessageAndPrimarySaveMarkers(): void - { - $content = $this->readProjectFile('templates/partials/app-details-titlebar.phtml'); - $this->assertStringContainsString('data-detail-unsaved-message=', $content); - $this->assertStringContainsString('data-detail-action-policy="1"', $content); - $this->assertStringContainsString('data-detail-action-kind=', $content); - $this->assertStringContainsString('data-detail-save-primary="1"', $content); - } - - public function testImportsWizardFormsUseStandardDetailOptIn(): void - { - $content = $this->readProjectFile('pages/admin/imports/index(default).phtml'); - $this->assertStringContainsString('id="imports-upload-form"', $content); - $this->assertStringContainsString('id="imports-mapping-form"', $content); - $this->assertStringContainsString('id="imports-commit-form"', $content); - $this->assertSame(3, substr_count($content, 'data-standard-detail-form="1"')); - } -} diff --git a/tests/Architecture/DetailPageFormContractTest.php b/tests/Architecture/DetailPageFormContractTest.php new file mode 100644 index 0000000..87a398c --- /dev/null +++ b/tests/Architecture/DetailPageFormContractTest.php @@ -0,0 +1,28 @@ +readProjectFile($file); + $this->assertStringContainsString('data-standard-detail-form="1"', $content, $file); + } + } + + public function testImportsWizardFormsUseStandardDetailOptIn(): void + { + $content = $this->readProjectFile('pages/admin/imports/index(default).phtml'); + + $this->assertStringContainsString('id="imports-upload-form"', $content); + $this->assertStringContainsString('id="imports-mapping-form"', $content); + $this->assertStringContainsString('id="imports-commit-form"', $content); + $this->assertSame(3, substr_count($content, 'data-standard-detail-form="1"')); + } +} diff --git a/tests/Architecture/DetailPageRuntimeContractTest.php b/tests/Architecture/DetailPageRuntimeContractTest.php new file mode 100644 index 0000000..2713ab4 --- /dev/null +++ b/tests/Architecture/DetailPageRuntimeContractTest.php @@ -0,0 +1,21 @@ +readProjectFile('web/js/app-init.js'); + + $this->assertStringContainsString( + "import { initStandardDetailPages } from './components/app-standard-detail-page.js';", + $content + ); + $this->assertStringContainsString("runtime.register('standard-detail-page', initStandardDetailPages", $content); + } +} diff --git a/tests/Architecture/DetailPageTitlebarContractTest.php b/tests/Architecture/DetailPageTitlebarContractTest.php new file mode 100644 index 0000000..87e158f --- /dev/null +++ b/tests/Architecture/DetailPageTitlebarContractTest.php @@ -0,0 +1,18 @@ +readProjectFile('templates/partials/app-details-titlebar.phtml'); + + $this->assertStringContainsString('data-detail-unsaved-message=', $content); + $this->assertStringContainsString('data-detail-save-primary="1"', $content); + } +} diff --git a/tests/Console/ConsoleApplicationTest.php b/tests/Console/ConsoleApplicationTest.php new file mode 100644 index 0000000..568dac2 --- /dev/null +++ b/tests/Console/ConsoleApplicationTest.php @@ -0,0 +1,125 @@ +register(new DummyCommand()); + + $exitCode = $app->run(['bin/console', 'list']); + + self::assertSame(0, $exitCode); + } + + public function testEmptyArgvShowsListAndReturnsZero(): void + { + $app = new ConsoleApplication(); + + $exitCode = $app->run(['bin/console']); + + self::assertSame(0, $exitCode); + } + + public function testUnknownCommandReturnsOne(): void + { + $app = new ConsoleApplication(); + + $exitCode = $app->run(['bin/console', 'nonexistent']); + + self::assertSame(1, $exitCode); + } + + public function testDispatchesRegisteredCommand(): void + { + $command = new DummyCommand(); + $app = new ConsoleApplication(); + $app->register($command); + + $exitCode = $app->run(['bin/console', 'test:dummy', 'arg1', '--flag', '--key=value']); + + self::assertSame(0, $exitCode); + self::assertSame(['arg1'], $command->lastArgs); + self::assertSame(['flag' => true, 'key' => 'value'], $command->lastOptions); + } + + public function testCommandExitCodePropagates(): void + { + $app = new ConsoleApplication(); + $app->register(new FailingCommand()); + + $exitCode = $app->run(['bin/console', 'test:fail']); + + self::assertSame(42, $exitCode); + } + + public function testMultiplePositionalArgs(): void + { + $command = new DummyCommand(); + $app = new ConsoleApplication(); + $app->register($command); + + $app->run(['bin/console', 'test:dummy', 'first', 'second', 'third']); + + self::assertSame(['first', 'second', 'third'], $command->lastArgs); + } + + public function testOptionWithEqualsSign(): void + { + $command = new DummyCommand(); + $app = new ConsoleApplication(); + $app->register($command); + + $app->run(['bin/console', 'test:dummy', '--module=addressbook']); + + self::assertSame([], $command->lastArgs); + self::assertSame(['module' => 'addressbook'], $command->lastOptions); + } +} + +final class DummyCommand extends Command +{ + public array $lastArgs = []; + public array $lastOptions = []; + + public function name(): string + { + return 'test:dummy'; + } + + public function description(): string + { + return 'A dummy command'; + } + + public function execute(array $args, array $options): int + { + $this->lastArgs = $args; + $this->lastOptions = $options; + return 0; + } +} + +final class FailingCommand extends Command +{ + public function name(): string + { + return 'test:fail'; + } + + public function description(): string + { + return 'Always fails'; + } + + public function execute(array $args, array $options): int + { + return 42; + } +} diff --git a/tests/README.md b/tests/README.md index 885a49e..263996e 100644 --- a/tests/README.md +++ b/tests/README.md @@ -35,4 +35,4 @@ ohne dass jede Aenderung immer die komplette Suite im Kopf behalten muss. - `tests/Architecture/ContainerFactoryContractTest.php` - `tests/Architecture/SecurityLoggingContractTest.php` - `tests/Architecture/StatusTaxonomyContractTest.php` -- `tests/Architecture/DetailPageContractTest.php` +- `tests/Architecture/AuthHelpLinksContractTest.php`