forked from fa/breadcrumb-the-shire
Split detail page contracts
This commit is contained in:
20
CLAUDE.md
20
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.
|
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
|
## Tech Stack
|
||||||
|
|
||||||
- **Runtime:** PHP 8.5 (FPM) on MintyPHP framework (`mintyphp/core`)
|
- **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)
|
# Run PHPStan (level 5)
|
||||||
docker compose exec php vendor/bin/phpstan analyse -c phpstan.neon --no-progress
|
docker compose exec php vendor/bin/phpstan analyse -c phpstan.neon --no-progress
|
||||||
|
|
||||||
# Sync module runtime (after module changes or APP_ENABLED_MODULES change)
|
# CLI commands (single entry point)
|
||||||
docker compose exec php php bin/module-runtime-sync.php
|
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 <id> --confirm
|
||||||
|
docker compose exec php php bin/console scheduler:run
|
||||||
|
docker compose exec php php bin/console doctor
|
||||||
|
|
||||||
# Check unused Composer packages
|
# Check unused Composer packages
|
||||||
docker compose exec php vendor/bin/composer-unused
|
docker compose exec php vendor/bin/composer-unused
|
||||||
@@ -42,6 +52,8 @@ lib/ # All backend PHP (namespace MintyPHP\)
|
|||||||
Service/ # Business logic, validation, orchestration
|
Service/ # Business logic, validation, orchestration
|
||||||
Http/ # Auth guards, API auth, request helpers
|
Http/ # Auth guards, API auth, request helpers
|
||||||
Input/ # Request input handling (RequestInput, FormErrors)
|
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
|
Support/ # Crypto, flash, guard, search, helpers
|
||||||
modules/ # Self-contained feature modules (namespace MintyPHP\Module\<Name>\)
|
modules/ # Self-contained feature modules (namespace MintyPHP\Module\<Name>\)
|
||||||
<id>/module.php # Module manifest (routes, slots, providers, permissions)
|
<id>/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\)
|
tests/ # PHPUnit tests (namespace MintyPHP\Tests\)
|
||||||
docs/ # German-language documentation (Markdown)
|
docs/ # German-language documentation (Markdown)
|
||||||
i18n/ # Translation files (de, en)
|
i18n/ # Translation files (de, en)
|
||||||
bin/ # CLI scripts (scheduler, module-runtime-sync, doctor.php)
|
bin/ # CLI entry point (bin/console) + legacy scripts + shell tools
|
||||||
tools/ # Dev tooling scripts
|
|
||||||
docker/ # Dockerfiles, Nginx configs, PHP configs
|
docker/ # Dockerfiles, Nginx configs, PHP configs
|
||||||
|
.agents/ # Agent workflow system (contracts, prompts, checks, skills, runs)
|
||||||
```
|
```
|
||||||
|
|
||||||
## Architecture Rules
|
## Architecture Rules
|
||||||
|
|||||||
43
bin/console
Executable file
43
bin/console
Executable file
@@ -0,0 +1,43 @@
|
|||||||
|
#!/usr/bin/env php
|
||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* CoreCore CLI — single entry point for all commands.
|
||||||
|
*
|
||||||
|
* Usage:
|
||||||
|
* php bin/console <command> [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));
|
||||||
@@ -15,70 +15,75 @@ declare(strict_types=1);
|
|||||||
|
|
||||||
require_once __DIR__ . '/module-cli-bootstrap.php';
|
require_once __DIR__ . '/module-cli-bootstrap.php';
|
||||||
|
|
||||||
$exitCode = moduleCliRunStep('module-deactivate', static function (): int {
|
function moduleDeactivateRun(): int
|
||||||
global $argv;
|
{
|
||||||
|
return moduleCliRunStep('module-deactivate', static function (): int {
|
||||||
|
global $argv;
|
||||||
|
|
||||||
$moduleId = $argv[1] ?? '';
|
$moduleId = $argv[1] ?? '';
|
||||||
$flags = array_slice($argv, 2);
|
$flags = array_slice($argv, 2);
|
||||||
|
|
||||||
if ($moduleId === '' || $moduleId === '--help') {
|
if ($moduleId === '' || $moduleId === '--help') {
|
||||||
fwrite(STDOUT, "Usage: php bin/module-deactivate.php <module-id> --confirm|--dry-run\n");
|
fwrite(STDOUT, "Usage: php bin/module-deactivate.php <module-id> --confirm|--dry-run\n");
|
||||||
return $moduleId === '--help' ? 0 : 1;
|
return $moduleId === '--help' ? 0 : 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
$confirm = in_array('--confirm', $flags, true);
|
$confirm = in_array('--confirm', $flags, true);
|
||||||
$dryRun = in_array('--dry-run', $flags, true);
|
$dryRun = in_array('--dry-run', $flags, true);
|
||||||
|
|
||||||
if (!$confirm && !$dryRun) {
|
if (!$confirm && !$dryRun) {
|
||||||
fwrite(STDERR, "Error: must pass --confirm to execute or --dry-run to validate.\n");
|
fwrite(STDERR, "Error: must pass --confirm to execute or --dry-run to validate.\n");
|
||||||
return 1;
|
return 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
$projectRoot = moduleCliProjectRoot();
|
$projectRoot = moduleCliProjectRoot();
|
||||||
$manifestFile = $projectRoot . '/modules/' . $moduleId . '/module.php';
|
$manifestFile = $projectRoot . '/modules/' . $moduleId . '/module.php';
|
||||||
|
|
||||||
if (!is_file($manifestFile)) {
|
if (!is_file($manifestFile)) {
|
||||||
fwrite(STDERR, "Error: module manifest not found at {$manifestFile}\n");
|
fwrite(STDERR, "Error: module manifest not found at {$manifestFile}\n");
|
||||||
return 1;
|
return 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
$raw = require $manifestFile;
|
$raw = require $manifestFile;
|
||||||
if (!is_array($raw)) {
|
if (!is_array($raw)) {
|
||||||
fwrite(STDERR, "Error: manifest must return an array.\n");
|
fwrite(STDERR, "Error: manifest must return an array.\n");
|
||||||
return 1;
|
return 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
$manifest = \MintyPHP\App\Module\ModuleManifest::fromArray($raw, dirname($manifestFile));
|
$manifest = \MintyPHP\App\Module\ModuleManifest::fromArray($raw, dirname($manifestFile));
|
||||||
|
|
||||||
$handlerClass = $manifest->deactivationHandler;
|
$handlerClass = $manifest->deactivationHandler;
|
||||||
if ($handlerClass === null) {
|
if ($handlerClass === null) {
|
||||||
fwrite(STDOUT, "Module '{$moduleId}' has no deactivation_handler declared — nothing to do.\n");
|
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;
|
return 0;
|
||||||
}
|
});
|
||||||
|
}
|
||||||
|
|
||||||
if (!class_exists($handlerClass)) {
|
if (PHP_SAPI === 'cli' && realpath((string) ($_SERVER['SCRIPT_FILENAME'] ?? '')) === __FILE__) {
|
||||||
fwrite(STDERR, "Error: deactivation handler class '{$handlerClass}' not found.\n");
|
exit(moduleDeactivateRun());
|
||||||
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);
|
|
||||||
|
|||||||
23
lib/Console/Command.php
Normal file
23
lib/Console/Command.php
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace MintyPHP\Console;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Base class for CLI commands.
|
||||||
|
*
|
||||||
|
* Subclasses define a name (e.g. 'module:sync') and implement execute().
|
||||||
|
* Arguments and options are passed as a simple parsed array.
|
||||||
|
*/
|
||||||
|
abstract class Command
|
||||||
|
{
|
||||||
|
abstract public function name(): string;
|
||||||
|
|
||||||
|
abstract public function description(): string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param list<string> $args Positional arguments (after the command name)
|
||||||
|
* @param array<string, string|bool> $options Parsed --key=value and --flag options
|
||||||
|
* @return int Exit code (0 = success)
|
||||||
|
*/
|
||||||
|
abstract public function execute(array $args, array $options): int;
|
||||||
|
}
|
||||||
31
lib/Console/Commands/DoctorCommand.php
Normal file
31
lib/Console/Commands/DoctorCommand.php
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace MintyPHP\Console\Commands;
|
||||||
|
|
||||||
|
use MintyPHP\Console\Command;
|
||||||
|
|
||||||
|
final class DoctorCommand extends Command
|
||||||
|
{
|
||||||
|
public function name(): string
|
||||||
|
{
|
||||||
|
return 'doctor';
|
||||||
|
}
|
||||||
|
|
||||||
|
public function description(): string
|
||||||
|
{
|
||||||
|
return 'Run system health checks';
|
||||||
|
}
|
||||||
|
|
||||||
|
public function execute(array $args, array $options): int
|
||||||
|
{
|
||||||
|
// doctor.php is self-contained — include and let it run
|
||||||
|
$scriptPath = __DIR__ . '/../../../bin/doctor.php';
|
||||||
|
|
||||||
|
// Capture the exit code by running as a subprocess to avoid
|
||||||
|
// the script's own exit() call terminating our process.
|
||||||
|
$command = PHP_BINARY . ' ' . escapeshellarg($scriptPath);
|
||||||
|
passthru($command, $exitCode);
|
||||||
|
|
||||||
|
return $exitCode;
|
||||||
|
}
|
||||||
|
}
|
||||||
26
lib/Console/Commands/Module/AssetsSyncCommand.php
Normal file
26
lib/Console/Commands/Module/AssetsSyncCommand.php
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace MintyPHP\Console\Commands\Module;
|
||||||
|
|
||||||
|
use MintyPHP\Console\Command;
|
||||||
|
|
||||||
|
final class AssetsSyncCommand extends Command
|
||||||
|
{
|
||||||
|
public function name(): string
|
||||||
|
{
|
||||||
|
return 'module:assets-sync';
|
||||||
|
}
|
||||||
|
|
||||||
|
public function description(): string
|
||||||
|
{
|
||||||
|
return 'Publish module web assets to web/modules/';
|
||||||
|
}
|
||||||
|
|
||||||
|
public function execute(array $args, array $options): int
|
||||||
|
{
|
||||||
|
require_once __DIR__ . '/../../../../bin/module-cli-bootstrap.php';
|
||||||
|
require_once __DIR__ . '/../../../../bin/module-assets-sync.php';
|
||||||
|
|
||||||
|
return moduleAssetsSyncRun();
|
||||||
|
}
|
||||||
|
}
|
||||||
26
lib/Console/Commands/Module/BuildCommand.php
Normal file
26
lib/Console/Commands/Module/BuildCommand.php
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace MintyPHP\Console\Commands\Module;
|
||||||
|
|
||||||
|
use MintyPHP\Console\Command;
|
||||||
|
|
||||||
|
final class BuildCommand extends Command
|
||||||
|
{
|
||||||
|
public function name(): string
|
||||||
|
{
|
||||||
|
return 'module:build';
|
||||||
|
}
|
||||||
|
|
||||||
|
public function description(): string
|
||||||
|
{
|
||||||
|
return 'Build runtime page root with module symlinks';
|
||||||
|
}
|
||||||
|
|
||||||
|
public function execute(array $args, array $options): int
|
||||||
|
{
|
||||||
|
require_once __DIR__ . '/../../../../bin/module-cli-bootstrap.php';
|
||||||
|
require_once __DIR__ . '/../../../../bin/module-build.php';
|
||||||
|
|
||||||
|
return moduleBuildRun();
|
||||||
|
}
|
||||||
|
}
|
||||||
41
lib/Console/Commands/Module/DeactivateCommand.php
Normal file
41
lib/Console/Commands/Module/DeactivateCommand.php
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace MintyPHP\Console\Commands\Module;
|
||||||
|
|
||||||
|
use MintyPHP\Console\Command;
|
||||||
|
|
||||||
|
final class DeactivateCommand extends Command
|
||||||
|
{
|
||||||
|
public function name(): string
|
||||||
|
{
|
||||||
|
return 'module:deactivate';
|
||||||
|
}
|
||||||
|
|
||||||
|
public function description(): string
|
||||||
|
{
|
||||||
|
return 'Run a module\'s deactivation handler (--confirm or --dry-run)';
|
||||||
|
}
|
||||||
|
|
||||||
|
public function execute(array $args, array $options): int
|
||||||
|
{
|
||||||
|
$moduleId = $args[0] ?? '';
|
||||||
|
if ($moduleId === '') {
|
||||||
|
fwrite(STDERR, "Usage: php bin/console module:deactivate <module-id> --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();
|
||||||
|
}
|
||||||
|
}
|
||||||
26
lib/Console/Commands/Module/MigrateCommand.php
Normal file
26
lib/Console/Commands/Module/MigrateCommand.php
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace MintyPHP\Console\Commands\Module;
|
||||||
|
|
||||||
|
use MintyPHP\Console\Command;
|
||||||
|
|
||||||
|
final class MigrateCommand extends Command
|
||||||
|
{
|
||||||
|
public function name(): string
|
||||||
|
{
|
||||||
|
return 'module:migrate';
|
||||||
|
}
|
||||||
|
|
||||||
|
public function description(): string
|
||||||
|
{
|
||||||
|
return 'Apply pending module SQL migrations';
|
||||||
|
}
|
||||||
|
|
||||||
|
public function execute(array $args, array $options): int
|
||||||
|
{
|
||||||
|
require_once __DIR__ . '/../../../../bin/module-cli-bootstrap.php';
|
||||||
|
require_once __DIR__ . '/../../../../bin/module-migrate.php';
|
||||||
|
|
||||||
|
return moduleMigrateRun();
|
||||||
|
}
|
||||||
|
}
|
||||||
26
lib/Console/Commands/Module/PermissionsSyncCommand.php
Normal file
26
lib/Console/Commands/Module/PermissionsSyncCommand.php
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace MintyPHP\Console\Commands\Module;
|
||||||
|
|
||||||
|
use MintyPHP\Console\Command;
|
||||||
|
|
||||||
|
final class PermissionsSyncCommand extends Command
|
||||||
|
{
|
||||||
|
public function name(): string
|
||||||
|
{
|
||||||
|
return 'module:permissions-sync';
|
||||||
|
}
|
||||||
|
|
||||||
|
public function description(): string
|
||||||
|
{
|
||||||
|
return 'Sync module permissions to DB and deactivate orphaned ones';
|
||||||
|
}
|
||||||
|
|
||||||
|
public function execute(array $args, array $options): int
|
||||||
|
{
|
||||||
|
require_once __DIR__ . '/../../../../bin/module-cli-bootstrap.php';
|
||||||
|
require_once __DIR__ . '/../../../../bin/module-permissions-sync.php';
|
||||||
|
|
||||||
|
return modulePermissionsSyncRun();
|
||||||
|
}
|
||||||
|
}
|
||||||
25
lib/Console/Commands/Module/RuntimeSyncCommand.php
Normal file
25
lib/Console/Commands/Module/RuntimeSyncCommand.php
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace MintyPHP\Console\Commands\Module;
|
||||||
|
|
||||||
|
use MintyPHP\Console\Command;
|
||||||
|
|
||||||
|
final class RuntimeSyncCommand extends Command
|
||||||
|
{
|
||||||
|
public function name(): string
|
||||||
|
{
|
||||||
|
return 'module:sync';
|
||||||
|
}
|
||||||
|
|
||||||
|
public function description(): string
|
||||||
|
{
|
||||||
|
return 'Run full module runtime sync (migrate, permissions, build, assets)';
|
||||||
|
}
|
||||||
|
|
||||||
|
public function execute(array $args, array $options): int
|
||||||
|
{
|
||||||
|
require_once __DIR__ . '/../../../../bin/module-runtime-sync.php';
|
||||||
|
|
||||||
|
return moduleRuntimeSyncRun();
|
||||||
|
}
|
||||||
|
}
|
||||||
73
lib/Console/Commands/Scheduler/RunCommand.php
Normal file
73
lib/Console/Commands/Scheduler/RunCommand.php
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace MintyPHP\Console\Commands\Scheduler;
|
||||||
|
|
||||||
|
use MintyPHP\Console\Command;
|
||||||
|
use MintyPHP\DB;
|
||||||
|
use MintyPHP\Service\Audit\SystemAuditService;
|
||||||
|
use MintyPHP\Service\Scheduler\SchedulerServicesFactory;
|
||||||
|
|
||||||
|
final class RunCommand extends Command
|
||||||
|
{
|
||||||
|
public function name(): string
|
||||||
|
{
|
||||||
|
return 'scheduler:run';
|
||||||
|
}
|
||||||
|
|
||||||
|
public function description(): string
|
||||||
|
{
|
||||||
|
return 'Execute due scheduled jobs';
|
||||||
|
}
|
||||||
|
|
||||||
|
public function execute(array $args, array $options): int
|
||||||
|
{
|
||||||
|
require_once __DIR__ . '/../../../../bin/cli-bootstrap.php';
|
||||||
|
|
||||||
|
$exitCode = 0;
|
||||||
|
$startedAt = microtime(true);
|
||||||
|
try {
|
||||||
|
cliBootstrapApp('scheduler', 'bin/console scheduler:run');
|
||||||
|
$factory = app(SchedulerServicesFactory::class);
|
||||||
|
$result = $factory->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;
|
||||||
|
}
|
||||||
|
}
|
||||||
94
lib/Console/ConsoleApplication.php
Normal file
94
lib/Console/ConsoleApplication.php
Normal file
@@ -0,0 +1,94 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace MintyPHP\Console;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Minimal CLI command dispatcher.
|
||||||
|
*
|
||||||
|
* Parses argv into command name, positional args, and --options,
|
||||||
|
* then dispatches to the matching registered Command instance.
|
||||||
|
*/
|
||||||
|
final class ConsoleApplication
|
||||||
|
{
|
||||||
|
/** @var array<string, Command> */
|
||||||
|
private array $commands = [];
|
||||||
|
|
||||||
|
public function register(Command $command): void
|
||||||
|
{
|
||||||
|
$this->commands[$command->name()] = $command;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param list<string> $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 <command> [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<string> $tokens
|
||||||
|
* @return array{list<string>, array<string, string|bool>}
|
||||||
|
*/
|
||||||
|
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];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -53,7 +53,9 @@
|
|||||||
<file>tests/Architecture/DetailActionPolicyMigrationContractTest.php</file>
|
<file>tests/Architecture/DetailActionPolicyMigrationContractTest.php</file>
|
||||||
<file>tests/Architecture/DetailActionPolicyPartialContractTest.php</file>
|
<file>tests/Architecture/DetailActionPolicyPartialContractTest.php</file>
|
||||||
<file>tests/Architecture/DetailActionPolicyRuntimeContractTest.php</file>
|
<file>tests/Architecture/DetailActionPolicyRuntimeContractTest.php</file>
|
||||||
<file>tests/Architecture/DetailPageContractTest.php</file>
|
<file>tests/Architecture/DetailPageFormContractTest.php</file>
|
||||||
|
<file>tests/Architecture/DetailPageRuntimeContractTest.php</file>
|
||||||
|
<file>tests/Architecture/DetailPageTitlebarContractTest.php</file>
|
||||||
<file>tests/Architecture/DetailValidationActionContractTest.php</file>
|
<file>tests/Architecture/DetailValidationActionContractTest.php</file>
|
||||||
<file>tests/Architecture/DetailValidationAssetContractTest.php</file>
|
<file>tests/Architecture/DetailValidationAssetContractTest.php</file>
|
||||||
<file>tests/Architecture/DetailValidationTemplateContractTest.php</file>
|
<file>tests/Architecture/DetailValidationTemplateContractTest.php</file>
|
||||||
|
|||||||
22
tests/Architecture/DetailPageContractFiles.php
Normal file
22
tests/Architecture/DetailPageContractFiles.php
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace MintyPHP\Tests\Architecture;
|
||||||
|
|
||||||
|
final class DetailPageContractFiles
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* @return list<string>
|
||||||
|
*/
|
||||||
|
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',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,59 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace MintyPHP\Tests\Architecture;
|
|
||||||
|
|
||||||
use PHPUnit\Framework\TestCase;
|
|
||||||
|
|
||||||
class DetailPageContractTest extends TestCase
|
|
||||||
{
|
|
||||||
use ProjectFileAssertionSupport;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @return list<string>
|
|
||||||
*/
|
|
||||||
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"'));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
28
tests/Architecture/DetailPageFormContractTest.php
Normal file
28
tests/Architecture/DetailPageFormContractTest.php
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace MintyPHP\Tests\Architecture;
|
||||||
|
|
||||||
|
use PHPUnit\Framework\TestCase;
|
||||||
|
|
||||||
|
final class DetailPageFormContractTest extends TestCase
|
||||||
|
{
|
||||||
|
use ProjectFileAssertionSupport;
|
||||||
|
|
||||||
|
public function testScopedDetailFormsUseStandardDetailOptInAttribute(): void
|
||||||
|
{
|
||||||
|
foreach (DetailPageContractFiles::standardDetailFormFiles() as $file) {
|
||||||
|
$content = $this->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"'));
|
||||||
|
}
|
||||||
|
}
|
||||||
21
tests/Architecture/DetailPageRuntimeContractTest.php
Normal file
21
tests/Architecture/DetailPageRuntimeContractTest.php
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace MintyPHP\Tests\Architecture;
|
||||||
|
|
||||||
|
use PHPUnit\Framework\TestCase;
|
||||||
|
|
||||||
|
final class DetailPageRuntimeContractTest extends TestCase
|
||||||
|
{
|
||||||
|
use ProjectFileAssertionSupport;
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
18
tests/Architecture/DetailPageTitlebarContractTest.php
Normal file
18
tests/Architecture/DetailPageTitlebarContractTest.php
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace MintyPHP\Tests\Architecture;
|
||||||
|
|
||||||
|
use PHPUnit\Framework\TestCase;
|
||||||
|
|
||||||
|
final class DetailPageTitlebarContractTest extends TestCase
|
||||||
|
{
|
||||||
|
use ProjectFileAssertionSupport;
|
||||||
|
|
||||||
|
public function testDetailsTitlebarExposesUnsavedMessageAndPrimarySaveMarkers(): void
|
||||||
|
{
|
||||||
|
$content = $this->readProjectFile('templates/partials/app-details-titlebar.phtml');
|
||||||
|
|
||||||
|
$this->assertStringContainsString('data-detail-unsaved-message=', $content);
|
||||||
|
$this->assertStringContainsString('data-detail-save-primary="1"', $content);
|
||||||
|
}
|
||||||
|
}
|
||||||
125
tests/Console/ConsoleApplicationTest.php
Normal file
125
tests/Console/ConsoleApplicationTest.php
Normal file
@@ -0,0 +1,125 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace MintyPHP\Tests\Console;
|
||||||
|
|
||||||
|
use MintyPHP\Console\Command;
|
||||||
|
use MintyPHP\Console\ConsoleApplication;
|
||||||
|
use PHPUnit\Framework\TestCase;
|
||||||
|
|
||||||
|
final class ConsoleApplicationTest extends TestCase
|
||||||
|
{
|
||||||
|
public function testListReturnsZero(): void
|
||||||
|
{
|
||||||
|
$app = new ConsoleApplication();
|
||||||
|
$app->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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -35,4 +35,4 @@ ohne dass jede Aenderung immer die komplette Suite im Kopf behalten muss.
|
|||||||
- `tests/Architecture/ContainerFactoryContractTest.php`
|
- `tests/Architecture/ContainerFactoryContractTest.php`
|
||||||
- `tests/Architecture/SecurityLoggingContractTest.php`
|
- `tests/Architecture/SecurityLoggingContractTest.php`
|
||||||
- `tests/Architecture/StatusTaxonomyContractTest.php`
|
- `tests/Architecture/StatusTaxonomyContractTest.php`
|
||||||
- `tests/Architecture/DetailPageContractTest.php`
|
- `tests/Architecture/AuthHelpLinksContractTest.php`
|
||||||
|
|||||||
Reference in New Issue
Block a user