refactor(cli)!: hard-cut legacy scripts and standardize console runtime

- remove legacy bin/module-*.php and bin/doctor.php entry scripts

- move module/doctor execution into class-based runners under lib/Console

- add stable JSON output for doctor and module:sync

- introduce bin/dev for init/up/down/logs/console/qa workflow

- update DI wiring, phpstan scan config, tests, and docs to new CLI contract
This commit is contained in:
2026-04-01 17:14:20 +02:00
parent 0bf7f62fd8
commit 7121732fcf
38 changed files with 1686 additions and 986 deletions

View File

@@ -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());
}
}

View File

@@ -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.
*/

View File

@@ -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();
}
}

View File

@@ -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;
}
}

View File

@@ -0,0 +1,27 @@
<?php
namespace MintyPHP\Console\Commands\Module;
use MintyPHP\Console\Command;
use MintyPHP\Console\Runner\Module\ModuleRunner;
use MintyPHP\Console\Runner\Module\ModuleRunnerInterface;
abstract class AbstractModuleCommand extends Command
{
private ?ModuleRunnerInterface $moduleRunner;
public function __construct(?ModuleRunnerInterface $moduleRunner = null)
{
$this->moduleRunner = $moduleRunner;
}
protected function moduleRunner(): ModuleRunnerInterface
{
if ($this->moduleRunner instanceof ModuleRunnerInterface) {
return $this->moduleRunner;
}
$this->moduleRunner = new ModuleRunner();
return $this->moduleRunner;
}
}

View File

@@ -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'];
}
}

View File

@@ -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'];
}
}

View File

@@ -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'];
}
}

View File

@@ -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'];
}
}

View File

@@ -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'];
}
}

View File

@@ -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'];
}
}

View File

@@ -0,0 +1,317 @@
<?php
declare(strict_types=1);
namespace MintyPHP\Console\Runner\Doctor;
use MintyPHP\App\AppContainer;
use MintyPHP\App\Bootstrap\EnvValidator;
use MintyPHP\Console\Support\CliAppBootstrap;
use MintyPHP\DB;
use MintyPHP\Module\Audit\Service\SystemAuditService;
use MintyPHP\Service\Access\AuthorizationService;
use MintyPHP\Service\Access\PermissionService;
use MintyPHP\Service\Access\UiAccessService;
use MintyPHP\Service\Auth\AuthService;
use Throwable;
final class DoctorRunner implements DoctorRunnerInterface
{
public function run(): array
{
error_reporting(E_ALL & ~E_DEPRECATED & ~E_USER_DEPRECATED);
ini_set('display_errors', '0');
/** @var AppContainer $container */
$container = CliAppBootstrap::bootstrap('cli', 'bin/console doctor');
$startedAt = microtime(true);
$results = [];
$failCount = 0;
$warnCount = 0;
$runCheck = static function (string $name, callable $check) use (&$results, &$failCount, &$warnCount): void {
try {
$result = $check();
} catch (Throwable $throwable) {
$result = [
'status' => 'fail',
'message' => $throwable->getMessage(),
];
}
$status = strtolower(trim((string) ($result['status'] ?? 'fail')));
if (!in_array($status, ['ok', 'warn', 'fail'], true)) {
$status = 'fail';
}
$message = trim((string) ($result['message'] ?? ''));
if ($message === '') {
$message = 'no details';
}
if ($status === 'fail') {
$failCount++;
} elseif ($status === 'warn') {
$warnCount++;
}
$results[] = [
'status' => $status,
'name' => $name,
'message' => $message,
];
};
$runCheck('Environment validation', static function (): array {
EnvValidator::validate();
return [
'status' => 'ok',
'message' => 'all required env keys and formats are valid',
];
});
$runCheck('App container bootstrap', static function () use ($container): array {
if (!$container instanceof AppContainer) {
return [
'status' => 'fail',
'message' => 'registerContainer.php did not return AppContainer',
];
}
app(AuthService::class);
app(AuthorizationService::class);
app(UiAccessService::class);
return [
'status' => 'ok',
'message' => 'core services resolved successfully',
];
});
$runCheck('Database connectivity', static function (): array {
$pong = DB::selectValue('select 1');
if ((int) $pong !== 1) {
return [
'status' => 'fail',
'message' => 'select 1 did not return expected value',
];
}
return [
'status' => 'ok',
'message' => 'connection established',
];
});
$runCheck('Database schema basics', static function (): array {
$requiredTables = [
'users',
'roles',
'permissions',
'user_roles',
'role_permissions',
'tenants',
'departments',
'settings',
'scheduler_runtime_status',
];
$rows = DB::select(
'select table_name from information_schema.tables where table_schema = database() and table_name in (???)',
$requiredTables
);
$present = [];
foreach ((array) $rows as $row) {
$table = (string) ($row['tables']['table_name'] ?? $row['table_name'] ?? '');
if ($table !== '') {
$present[] = $table;
}
}
$present = array_values(array_unique($present));
sort($present, SORT_STRING);
$missing = array_values(array_diff($requiredTables, $present));
if ($missing !== []) {
return [
'status' => 'fail',
'message' => 'missing tables: ' . implode(', ', $missing),
];
}
return [
'status' => 'ok',
'message' => sprintf('%d core tables present', count($requiredTables)),
];
});
$runCheck('Storage path writeability', static function (): array {
$storagePath = defined('APP_STORAGE_PATH') && APP_STORAGE_PATH
? rtrim((string) APP_STORAGE_PATH, '/')
: rtrim(CliAppBootstrap::projectRoot() . '/storage', '/');
if (!is_dir($storagePath)) {
return [
'status' => 'fail',
'message' => "storage directory not found: {$storagePath}",
];
}
if (!is_writable($storagePath)) {
return [
'status' => 'fail',
'message' => "storage directory not writable: {$storagePath}",
];
}
$probeFile = $storagePath . '/.doctor-write-probe-' . uniqid('', true);
$written = @file_put_contents($probeFile, 'ok');
if ($written === false) {
return [
'status' => 'fail',
'message' => "write probe failed in: {$storagePath}",
];
}
@unlink($probeFile);
return [
'status' => 'ok',
'message' => "storage path is writable ({$storagePath})",
];
});
$runCheck('RBAC baseline permissions', static function (): array {
$requiredPermissions = [
PermissionService::USERS_VIEW,
PermissionService::TENANTS_VIEW,
PermissionService::DEPARTMENTS_VIEW,
PermissionService::ROLES_VIEW,
PermissionService::PERMISSIONS_VIEW,
PermissionService::SETTINGS_VIEW,
];
$rows = DB::select(
'select `key` from permissions where active = 1 and `key` in (???)',
$requiredPermissions
);
$present = [];
foreach ((array) $rows as $row) {
$key = (string) ($row['permissions']['key'] ?? $row['key'] ?? '');
if ($key !== '') {
$present[] = $key;
}
}
$present = array_values(array_unique($present));
sort($present, SORT_STRING);
$missing = array_values(array_diff($requiredPermissions, $present));
if ($missing !== []) {
return [
'status' => 'fail',
'message' => 'missing active permissions: ' . implode(', ', $missing),
];
}
return [
'status' => 'ok',
'message' => sprintf('%d baseline permissions active', count($requiredPermissions)),
];
});
$runCheck('Admin role assignment', static function (): array {
$count = (int) (DB::selectValue(
'select count(distinct ur.user_id) from user_roles ur join roles r on r.id = ur.role_id and r.active = 1 where r.description in (?, ?) or r.id = 1',
'Admin',
'Administrator'
) ?? 0);
if ($count <= 0) {
return [
'status' => 'fail',
'message' => 'no active user assigned to Admin/Administrator role',
];
}
return [
'status' => 'ok',
'message' => sprintf('%d admin user(s) assigned', $count),
];
});
$runCheck('Scheduler heartbeat', static function (): array {
$row = DB::selectOne('select last_heartbeat_at, last_result, last_error_code from scheduler_runtime_status where id = 1 limit 1');
$status = is_array($row) ? ($row['scheduler_runtime_status'] ?? $row) : null;
if (!is_array($status)) {
return [
'status' => 'warn',
'message' => 'no scheduler runtime status row found yet',
];
}
$heartbeat = trim((string) ($status['last_heartbeat_at'] ?? ''));
$result = trim((string) ($status['last_result'] ?? 'unknown'));
$errorCode = trim((string) ($status['last_error_code'] ?? ''));
if ($heartbeat === '') {
return [
'status' => 'warn',
'message' => 'scheduler heartbeat is empty',
];
}
$seconds = time() - strtotime($heartbeat . ' UTC');
if ($seconds < 0) {
$seconds = 0;
}
if ($seconds > 300) {
return [
'status' => 'warn',
'message' => "last heartbeat {$seconds}s ago (result={$result}" . ($errorCode !== '' ? ", error={$errorCode}" : '') . ')',
];
}
return [
'status' => 'ok',
'message' => "last heartbeat {$seconds}s ago (result={$result}" . ($errorCode !== '' ? ", error={$errorCode}" : '') . ')',
];
});
$okCount = count($results) - $warnCount - $failCount;
$exitCode = $failCount > 0 ? 1 : 0;
$status = $failCount > 0 ? 'fail' : ($warnCount > 0 ? 'warn' : 'ok');
$durationMs = max(0, (int) round((microtime(true) - $startedAt) * 1000));
try {
app(SystemAuditService::class)->record(
'cli.command',
$exitCode === 0 ? 'success' : 'failed',
[
'metadata' => [
'command' => 'bin/console doctor',
'exit_code' => $exitCode,
'duration_ms' => $durationMs,
'result' => $status,
'warn_count' => $warnCount,
'fail_count' => $failCount,
],
]
);
} catch (Throwable) {
// fail-open
} finally {
DB::close();
}
return [
'command' => 'doctor',
'status' => $status,
'checks' => $results,
'ok_count' => $okCount,
'warn_count' => $warnCount,
'fail_count' => $failCount,
'exit_code' => $exitCode,
'duration_ms' => $durationMs,
];
}
}

View File

@@ -0,0 +1,22 @@
<?php
declare(strict_types=1);
namespace MintyPHP\Console\Runner\Doctor;
interface DoctorRunnerInterface
{
/**
* @return array{
* command: string,
* status: string,
* checks: list<array{status: string, name: string, message: string}>,
* ok_count: int,
* warn_count: int,
* fail_count: int,
* exit_code: int,
* duration_ms: int
* }
*/
public function run(): array;
}

View File

@@ -0,0 +1,348 @@
<?php
declare(strict_types=1);
namespace MintyPHP\Console\Runner\Module;
use MintyPHP\App\AppContainer;
use MintyPHP\App\Module\Contracts\ModuleDeactivationHandler;
use MintyPHP\App\Module\ModuleManifestLoader;
use MintyPHP\App\Module\ModulePermissionSynchronizer;
use MintyPHP\App\Module\ModuleRegistry;
use MintyPHP\App\Module\ModuleRuntimeAssetPublisher;
use MintyPHP\App\Module\ModuleRuntimePageBuilder;
use MintyPHP\Console\Support\ModuleCliRuntime;
use MintyPHP\Service\Module\ModuleMigrationService;
final class ModuleRunner implements ModuleRunnerInterface
{
public function migrate(): array
{
$summary = ['applied' => 0, 'modules_enabled' => 0, 'skipped_empty' => 0];
$message = 'module-migrate: all modules up to date, 0 new migrations.';
$step = ModuleCliRuntime::runStep('module-migrate', function () use (&$summary, &$message): int {
$lockResult = ModuleCliRuntime::withFileLock(
ModuleCliRuntime::projectRoot() . '/storage/runtime/.module-migrate.lock',
'module-migrate: another migration run is in progress, skipping.',
function () use (&$summary, &$message): int {
/** @var ModuleMigrationService $service */
$service = app(ModuleMigrationService::class);
$result = $service->applyPendingMigrations();
$summary = $result;
if ($result['modules_enabled'] === 0) {
$message = 'module-migrate: no modules enabled, nothing to do.';
} elseif ($result['applied'] === 0) {
$message = 'module-migrate: all modules up to date, 0 new migrations.';
} else {
$message = sprintf('module-migrate: applied %d migration(s).', $result['applied']);
}
if ($result['skipped_empty'] > 0) {
$message .= sprintf(' (%d empty file(s) skipped)', $result['skipped_empty']);
}
return $result['ok'] ? 0 : 1;
}
);
if ($lockResult['busy']) {
$message = (string) $lockResult['message'];
}
return $lockResult['exit_code'];
});
return [
'command' => 'module:migrate',
'status' => $step['status'],
'exit_code' => $step['exit_code'],
'vendor_warnings_ignored' => $step['vendor_warnings_ignored'],
'message' => $step['error'] ?? $message,
];
}
public function permissionsSync(): array
{
$sync = [
'created' => 0,
'updated' => 0,
'unchanged' => 0,
'deactivated' => 0,
'total' => 0,
];
$message = 'module-permissions-sync: ok';
$step = ModuleCliRuntime::runStep('module-permissions-sync', function () use (&$sync, &$message): int {
$lockResult = ModuleCliRuntime::withFileLock(
ModuleCliRuntime::projectRoot() . '/storage/runtime/.module-permissions-sync.lock',
'module-permissions-sync: another sync is in progress, skipping.',
function () use (&$sync): int {
/** @var ModulePermissionSynchronizer $synchronizer */
$synchronizer = app(ModulePermissionSynchronizer::class);
$sync = $synchronizer->sync();
return 0;
}
);
if ($lockResult['busy']) {
$message = (string) $lockResult['message'];
}
return $lockResult['exit_code'];
});
return [
'command' => 'module:permissions-sync',
'status' => $step['status'],
'exit_code' => $step['exit_code'],
'vendor_warnings_ignored' => $step['vendor_warnings_ignored'],
'created' => (int) $sync['created'],
'updated' => (int) $sync['updated'],
'unchanged' => (int) $sync['unchanged'],
'deactivated' => (int) $sync['deactivated'],
'total' => (int) $sync['total'],
'message' => $step['error'] ?? $message,
];
}
public function build(): array
{
$buildSummary = [
'core_entries' => 0,
'module_entries' => 0,
];
$message = 'module-build: ok';
$step = ModuleCliRuntime::runStep('module-build', function () use (&$buildSummary, &$message): int {
$runtimeDir = ModuleCliRuntime::projectRoot() . '/storage/runtime/pages';
$runtimeParent = dirname($runtimeDir);
if (!is_dir($runtimeParent) && !mkdir($runtimeParent, 0775, true) && !is_dir($runtimeParent)) {
$message = "Failed to create runtime directory: {$runtimeParent}";
return 1;
}
$lockResult = ModuleCliRuntime::withFileLock(
ModuleCliRuntime::projectRoot() . '/storage/runtime/.module-build.lock',
'module-build: another build is in progress, skipping.',
function () use (&$buildSummary, $runtimeDir): int {
/** @var ModuleRegistry $registry */
$registry = app(ModuleRegistry::class);
/** @var ModuleRuntimePageBuilder $builder */
$builder = app(ModuleRuntimePageBuilder::class);
$modules = $registry->getModules();
$buildSummary = $builder->build(
$runtimeDir,
ModuleCliRuntime::projectRoot() . '/pages',
$modules
);
ModuleRuntimePageBuilder::writeFingerprint($runtimeDir, $modules);
return 0;
}
);
if ($lockResult['busy']) {
$message = (string) $lockResult['message'];
}
return $lockResult['exit_code'];
});
return [
'command' => 'module:build',
'status' => $step['status'],
'exit_code' => $step['exit_code'],
'vendor_warnings_ignored' => $step['vendor_warnings_ignored'],
'core_entries' => (int) $buildSummary['core_entries'],
'module_entries' => (int) $buildSummary['module_entries'],
'message' => $step['error'] ?? $message,
];
}
public function assetsSync(): array
{
$summary = [
'created' => 0,
'updated' => 0,
'removed' => 0,
'unchanged' => 0,
];
$mode = trim((string) getenv('APP_MODULE_ASSET_MODE'));
if ($mode === '') {
$mode = 'symlink';
}
$message = 'module-assets-sync: ok';
$step = ModuleCliRuntime::runStep('module-assets-sync', function () use (&$summary, &$message, $mode): int {
$lockResult = ModuleCliRuntime::withFileLock(
ModuleCliRuntime::projectRoot() . '/storage/runtime/.module-assets-sync.lock',
'module-assets-sync: another sync is in progress, skipping.',
function () use (&$summary, $mode): int {
/** @var ModuleRegistry $registry */
$registry = app(ModuleRegistry::class);
/** @var ModuleRuntimeAssetPublisher $publisher */
$publisher = app(ModuleRuntimeAssetPublisher::class);
$summary = $publisher->publish(
ModuleCliRuntime::projectRoot() . '/web/modules',
$registry->getModules(),
$mode
);
return 0;
}
);
if ($lockResult['busy']) {
$message = (string) $lockResult['message'];
}
return $lockResult['exit_code'];
});
return [
'command' => 'module:assets-sync',
'status' => $step['status'],
'exit_code' => $step['exit_code'],
'vendor_warnings_ignored' => $step['vendor_warnings_ignored'],
'mode' => $mode,
'created' => (int) $summary['created'],
'updated' => (int) $summary['updated'],
'removed' => (int) $summary['removed'],
'unchanged' => (int) $summary['unchanged'],
'message' => $step['error'] ?? $message,
];
}
public function deactivate(string $moduleId, bool $confirm, bool $dryRun): array
{
$message = '';
$step = ModuleCliRuntime::runStep('module-deactivate', function () use ($moduleId, $confirm, $dryRun, &$message): int {
if ($moduleId === '') {
$message = 'Error: module id is required.';
return 1;
}
if (!$confirm && !$dryRun) {
$message = 'Error: must pass --confirm to execute or --dry-run to validate.';
return 1;
}
$manifest = ModuleManifestLoader::loadFromDisk(
ModuleCliRuntime::projectRoot() . '/modules',
$moduleId
);
$handlerClass = $manifest->deactivationHandler;
if ($handlerClass === null) {
$message = "Module '{$moduleId}' has no deactivation_handler declared — nothing to do.";
return 0;
}
if (!class_exists($handlerClass)) {
$message = "Error: deactivation handler class '{$handlerClass}' not found.";
return 1;
}
$container = $GLOBALS['minty_app_container'] ?? null;
if (!$container instanceof AppContainer) {
$message = 'Error: app container is not initialized.';
return 1;
}
$handler = $container->has($handlerClass)
? $container->get($handlerClass)
: new $handlerClass();
if (!$handler instanceof ModuleDeactivationHandler) {
$message = "Error: '{$handlerClass}' does not implement ModuleDeactivationHandler.";
return 1;
}
if ($dryRun) {
$message = "Dry-run: handler '{$handlerClass}' found and valid for module '{$moduleId}'.";
return 0;
}
$handler->deactivate($container);
$message = "Deactivation handler for module '{$moduleId}' completed.";
return 0;
});
return [
'command' => 'module:deactivate',
'status' => $step['status'],
'exit_code' => $step['exit_code'],
'vendor_warnings_ignored' => $step['vendor_warnings_ignored'],
'message' => $step['error'] ?? $message,
];
}
public function runtimeSync(): array
{
$startedAt = microtime(true);
$steps = [
'migrate' => ['status' => 'pending', 'exit_code' => 0, 'vendor_warnings_ignored' => 0],
'permissions-sync' => ['status' => 'pending', 'exit_code' => 0, 'vendor_warnings_ignored' => 0],
'build' => ['status' => 'pending', 'exit_code' => 0, 'vendor_warnings_ignored' => 0],
'assets-sync' => ['status' => 'pending', 'exit_code' => 0, 'vendor_warnings_ignored' => 0],
];
$message = '';
$step = ModuleCliRuntime::runStep('module-runtime-sync', function () use (&$steps, &$message): int {
$lockResult = ModuleCliRuntime::withFileLock(
ModuleCliRuntime::projectRoot() . '/storage/runtime/.module-runtime-sync.lock',
'module-runtime-sync: another runtime sync is in progress, skipping.',
function () use (&$steps): int {
$orderedSteps = [
'migrate' => fn (): array => $this->migrate(),
'permissions-sync' => fn (): array => $this->permissionsSync(),
'build' => fn (): array => $this->build(),
'assets-sync' => fn (): array => $this->assetsSync(),
];
foreach ($orderedSteps as $stepName => $runner) {
$result = $runner();
$steps[$stepName] = [
'status' => $result['status'],
'exit_code' => $result['exit_code'],
'vendor_warnings_ignored' => $result['vendor_warnings_ignored'],
];
if ($result['exit_code'] !== 0) {
return $result['exit_code'];
}
}
return 0;
}
);
if ($lockResult['busy']) {
$message = (string) $lockResult['message'];
}
return $lockResult['exit_code'];
});
if ($message === '') {
$message = sprintf(
'module-runtime-sync: summary migrate=%s permissions-sync=%s build=%s assets-sync=%s vendor_warnings_ignored=%d',
$steps['migrate']['status'],
$steps['permissions-sync']['status'],
$steps['build']['status'],
$steps['assets-sync']['status'],
$step['vendor_warnings_ignored']
);
}
return [
'command' => 'module:sync',
'status' => $step['status'],
'exit_code' => $step['exit_code'],
'vendor_warnings_ignored_total' => $step['vendor_warnings_ignored'],
'duration_ms' => max(0, (int) round((microtime(true) - $startedAt) * 1000)),
'steps' => $steps,
'message' => $step['error'] ?? $message,
];
}
}

View File

@@ -0,0 +1,82 @@
<?php
declare(strict_types=1);
namespace MintyPHP\Console\Runner\Module;
interface ModuleRunnerInterface
{
/**
* @return array{command: string, status: string, exit_code: int, vendor_warnings_ignored: int, message: string}
*/
public function migrate(): array;
/**
* @return array{
* command: string,
* status: string,
* exit_code: int,
* vendor_warnings_ignored: int,
* created: int,
* updated: int,
* unchanged: int,
* deactivated: int,
* total: int,
* message: string
* }
*/
public function permissionsSync(): array;
/**
* @return array{
* command: string,
* status: string,
* exit_code: int,
* vendor_warnings_ignored: int,
* core_entries: int,
* module_entries: int,
* message: string
* }
*/
public function build(): array;
/**
* @return array{
* command: string,
* status: string,
* exit_code: int,
* vendor_warnings_ignored: int,
* mode: string,
* created: int,
* updated: int,
* removed: int,
* unchanged: int,
* message: string
* }
*/
public function assetsSync(): array;
/**
* @return array{
* command: string,
* status: string,
* exit_code: int,
* vendor_warnings_ignored: int,
* message: string
* }
*/
public function deactivate(string $moduleId, bool $confirm, bool $dryRun): array;
/**
* @return array{
* command: string,
* status: string,
* exit_code: int,
* vendor_warnings_ignored_total: int,
* duration_ms: int,
* steps: array<string, array{status: string, exit_code: int, vendor_warnings_ignored: int}>,
* message: string
* }
*/
public function runtimeSync(): array;
}

View File

@@ -0,0 +1,55 @@
<?php
declare(strict_types=1);
namespace MintyPHP\Console\Support;
use MintyPHP\App\AppContainer;
use MintyPHP\Http\RequestContext;
use RuntimeException;
final class CliAppBootstrap
{
private static bool $booted = false;
private static ?AppContainer $container = null;
private function __construct()
{
}
public static function projectRoot(): string
{
return dirname(__DIR__, 3);
}
public static function bootstrap(string $channel = 'cli', string $path = 'bin/cli'): AppContainer
{
if (!self::$booted) {
$projectRoot = self::projectRoot();
chdir($projectRoot);
require_once $projectRoot . '/vendor/autoload.php';
require_once $projectRoot . '/config/config.php';
require_once $projectRoot . '/lib/Support/helpers.php';
/** @var AppContainer $container */
$container = require $projectRoot . '/lib/App/registerContainer.php';
setAppContainer($container);
self::$container = $container;
self::$booted = true;
}
RequestContext::start([
'channel' => $channel,
'method' => 'CLI',
'path' => $path,
]);
if (!self::$container instanceof AppContainer) {
throw new RuntimeException('CLI app container bootstrap failed.');
}
return self::$container;
}
}

View File

@@ -0,0 +1,173 @@
<?php
declare(strict_types=1);
namespace MintyPHP\Console\Support;
use RuntimeException;
use Throwable;
final class ModuleCliRuntime
{
private static bool $booted = false;
private static bool $errorPolicyInstalled = false;
private static int $vendorWarningsIgnored = 0;
private function __construct()
{
}
public static function bootstrap(): void
{
if (self::$booted) {
return;
}
self::installErrorPolicy();
$projectRoot = CliAppBootstrap::projectRoot();
chdir($projectRoot);
require_once $projectRoot . '/vendor/autoload.php';
require_once $projectRoot . '/config/config.php';
require_once $projectRoot . '/lib/Support/helpers.php';
$container = require $projectRoot . '/lib/App/registerContainer.php';
setAppContainer($container);
self::$booted = true;
}
public static function projectRoot(): string
{
return CliAppBootstrap::projectRoot();
}
public static function vendorWarningsIgnoredTotal(): int
{
return self::$vendorWarningsIgnored;
}
/**
* @param callable():int $runner
* @return array{step: string, status: string, exit_code: int, vendor_warnings_ignored: int, error: string|null}
*/
public static function runStep(string $stepName, callable $runner): array
{
$warningsBefore = self::vendorWarningsIgnoredTotal();
self::bootstrap();
$exitCode = 0;
$error = null;
try {
$exitCode = $runner();
} catch (Throwable $throwable) {
$exitCode = 1;
$error = $throwable->getMessage();
}
return [
'step' => $stepName,
'status' => $exitCode === 0 ? 'ok' : 'failed',
'exit_code' => $exitCode,
'vendor_warnings_ignored' => max(0, self::vendorWarningsIgnoredTotal() - $warningsBefore),
'error' => $error,
];
}
/**
* @param callable():int $runner
* @return array{busy: bool, message: string|null, exit_code: int}
*/
public static function withFileLock(string $lockFile, string $busyMessage, callable $runner): array
{
$lockDir = dirname($lockFile);
if (!is_dir($lockDir) && !mkdir($lockDir, 0775, true) && !is_dir($lockDir)) {
throw new RuntimeException("Failed to create lock directory: {$lockDir}");
}
$lock = fopen($lockFile, 'c');
if ($lock === false || !flock($lock, LOCK_EX | LOCK_NB)) {
if (is_resource($lock)) {
fclose($lock);
}
return [
'busy' => true,
'message' => $busyMessage,
'exit_code' => 0,
];
}
try {
return [
'busy' => false,
'message' => null,
'exit_code' => $runner(),
];
} finally {
flock($lock, LOCK_UN);
fclose($lock);
}
}
private static function installErrorPolicy(): void
{
if (self::$errorPolicyInstalled) {
return;
}
self::$errorPolicyInstalled = true;
error_reporting(E_ALL);
ini_set('display_errors', '0');
set_error_handler(static function (
int $severity,
string $message,
string $file = '',
int $line = 0
): bool {
$handledSeverities = [
E_WARNING,
E_NOTICE,
E_USER_WARNING,
E_USER_NOTICE,
E_DEPRECATED,
E_USER_DEPRECATED,
];
if (!in_array($severity, $handledSeverities, true)) {
return false;
}
if ((error_reporting() & $severity) === 0) {
return true;
}
$normalizedFile = str_replace('\\', '/', $file);
if (str_contains($normalizedFile, '/vendor/')) {
self::$vendorWarningsIgnored++;
return true;
}
throw new RuntimeException(sprintf(
'First-party runtime warning (%s) in %s:%d: %s',
self::severityName($severity),
$file,
$line,
$message
));
});
}
private static function severityName(int $severity): string
{
return match ($severity) {
E_WARNING => 'E_WARNING',
E_NOTICE => 'E_NOTICE',
E_USER_WARNING => 'E_USER_WARNING',
E_USER_NOTICE => 'E_USER_NOTICE',
E_DEPRECATED => 'E_DEPRECATED',
E_USER_DEPRECATED => 'E_USER_DEPRECATED',
default => 'E_' . $severity,
};
}
}

View File

@@ -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,
];
}
}