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:
141
bin/dev
Executable file
141
bin/dev
Executable file
@@ -0,0 +1,141 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
script_dir="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
|
||||
repo_root="$(cd -- "${script_dir}/.." && pwd)"
|
||||
cd "${repo_root}"
|
||||
|
||||
usage() {
|
||||
cat <<'USAGE'
|
||||
Usage: bin/dev <command> [args...]
|
||||
|
||||
Commands:
|
||||
init First-time, non-destructive setup
|
||||
up Start local containers
|
||||
down Stop local containers
|
||||
logs [service] Follow docker compose logs (optional service)
|
||||
console <args...> Run php bin/console inside php container
|
||||
qa Run mandatory quality gates (QG-001/002/003/006)
|
||||
USAGE
|
||||
}
|
||||
|
||||
require_docker_compose() {
|
||||
if ! command -v docker >/dev/null 2>&1; then
|
||||
echo "[ERROR] docker is required." >&2
|
||||
exit 2
|
||||
fi
|
||||
docker compose version >/dev/null 2>&1 || {
|
||||
echo "[ERROR] docker compose is required." >&2
|
||||
exit 2
|
||||
}
|
||||
}
|
||||
|
||||
wait_for_php_container() {
|
||||
local max_attempts=60
|
||||
local attempt=1
|
||||
while (( attempt <= max_attempts )); do
|
||||
if docker compose exec -T php php -v >/dev/null 2>&1; then
|
||||
return 0
|
||||
fi
|
||||
sleep 1
|
||||
attempt=$((attempt + 1))
|
||||
done
|
||||
|
||||
echo "[ERROR] php container did not become ready in time." >&2
|
||||
return 1
|
||||
}
|
||||
|
||||
run_console() {
|
||||
if [[ $# -eq 0 ]]; then
|
||||
echo "[ERROR] console requires arguments, e.g. bin/dev console list" >&2
|
||||
exit 2
|
||||
fi
|
||||
docker compose exec -T php php bin/console "$@"
|
||||
}
|
||||
|
||||
cmd_init() {
|
||||
require_docker_compose
|
||||
|
||||
if [[ ! -f .env ]]; then
|
||||
if [[ ! -f .env.example ]]; then
|
||||
echo "[ERROR] .env missing and .env.example not found." >&2
|
||||
exit 2
|
||||
fi
|
||||
cp .env.example .env
|
||||
echo "[INFO] created .env from .env.example"
|
||||
fi
|
||||
|
||||
docker compose up --build -d
|
||||
wait_for_php_container
|
||||
|
||||
run_console module:sync
|
||||
run_console doctor
|
||||
}
|
||||
|
||||
cmd_up() {
|
||||
require_docker_compose
|
||||
docker compose up -d
|
||||
}
|
||||
|
||||
cmd_down() {
|
||||
require_docker_compose
|
||||
docker compose down
|
||||
}
|
||||
|
||||
cmd_logs() {
|
||||
require_docker_compose
|
||||
if [[ $# -gt 1 ]]; then
|
||||
echo "[ERROR] logs accepts at most one optional service name." >&2
|
||||
exit 2
|
||||
fi
|
||||
if [[ $# -eq 1 ]]; then
|
||||
docker compose logs -f "$1"
|
||||
else
|
||||
docker compose logs -f
|
||||
fi
|
||||
}
|
||||
|
||||
cmd_qa() {
|
||||
require_docker_compose
|
||||
docker compose exec -T php vendor/bin/phpunit
|
||||
docker compose exec -T php vendor/bin/phpstan analyse -c phpstan.neon --no-progress
|
||||
docker compose exec -T php vendor/bin/phpunit tests/Architecture/CoreStarterkitContractTest.php
|
||||
docker compose exec -T php vendor/bin/php-cs-fixer fix --config=tools/php-cs-fixer/.php-cs-fixer.dist.php --dry-run --diff --verbose
|
||||
}
|
||||
|
||||
if [[ $# -eq 0 ]]; then
|
||||
usage
|
||||
exit 0
|
||||
fi
|
||||
|
||||
command="$1"
|
||||
shift || true
|
||||
|
||||
case "${command}" in
|
||||
init)
|
||||
cmd_init "$@"
|
||||
;;
|
||||
up)
|
||||
cmd_up "$@"
|
||||
;;
|
||||
down)
|
||||
cmd_down "$@"
|
||||
;;
|
||||
logs)
|
||||
cmd_logs "$@"
|
||||
;;
|
||||
console)
|
||||
run_console "$@"
|
||||
;;
|
||||
qa)
|
||||
cmd_qa "$@"
|
||||
;;
|
||||
-h|--help|help)
|
||||
usage
|
||||
;;
|
||||
*)
|
||||
echo "[ERROR] unknown command: ${command}" >&2
|
||||
usage
|
||||
exit 2
|
||||
;;
|
||||
esac
|
||||
312
bin/doctor.php
312
bin/doctor.php
@@ -1,312 +0,0 @@
|
||||
#!/usr/bin/env php
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use MintyPHP\App\AppContainer;
|
||||
use MintyPHP\App\Bootstrap\EnvValidator;
|
||||
use MintyPHP\DB;
|
||||
use MintyPHP\Service\Access\AuthorizationService;
|
||||
use MintyPHP\Service\Access\PermissionService;
|
||||
use MintyPHP\Service\Access\UiAccessService;
|
||||
use MintyPHP\Service\Audit\SystemAuditService;
|
||||
use MintyPHP\Service\Auth\AuthService;
|
||||
|
||||
error_reporting(E_ALL & ~E_DEPRECATED & ~E_USER_DEPRECATED);
|
||||
ini_set('display_errors', '0');
|
||||
|
||||
require_once __DIR__ . '/cli-bootstrap.php';
|
||||
|
||||
/** @var AppContainer $container */
|
||||
$container = cliBootstrapApp('cli', 'bin/doctor.php');
|
||||
$startedAt = microtime(true);
|
||||
|
||||
/** @var list<array{status: string, name: string, message: string}> $results */
|
||||
$results = [];
|
||||
$failCount = 0;
|
||||
$warnCount = 0;
|
||||
|
||||
/**
|
||||
* @param callable():array{status: string, message: string} $check
|
||||
*/
|
||||
$runCheck = static function (string $name, callable $check) use (&$results, &$failCount, &$warnCount): void {
|
||||
try {
|
||||
$result = $check();
|
||||
} catch (Throwable $exception) {
|
||||
$result = [
|
||||
'status' => 'fail',
|
||||
'message' => $exception->getMessage(),
|
||||
];
|
||||
}
|
||||
|
||||
$status = strtolower(trim((string) ($result['status'] ?? 'fail')));
|
||||
if (!in_array($status, ['ok', 'warn', 'fail'], true)) {
|
||||
$status = 'fail';
|
||||
}
|
||||
|
||||
$message = trim((string) ($result['message'] ?? ''));
|
||||
if ($message === '') {
|
||||
$message = 'no details';
|
||||
}
|
||||
|
||||
if ($status === 'fail') {
|
||||
$failCount++;
|
||||
} elseif ($status === 'warn') {
|
||||
$warnCount++;
|
||||
}
|
||||
|
||||
$results[] = [
|
||||
'status' => $status,
|
||||
'name' => $name,
|
||||
'message' => $message,
|
||||
];
|
||||
};
|
||||
|
||||
$runCheck('Environment validation', static function (): array {
|
||||
EnvValidator::validate();
|
||||
return [
|
||||
'status' => 'ok',
|
||||
'message' => 'all required env keys and formats are valid',
|
||||
];
|
||||
});
|
||||
|
||||
$runCheck('App container bootstrap', static function () use ($container): array {
|
||||
if (!$container instanceof AppContainer) {
|
||||
return [
|
||||
'status' => 'fail',
|
||||
'message' => 'registerContainer.php did not return AppContainer',
|
||||
];
|
||||
}
|
||||
|
||||
app(AuthService::class);
|
||||
app(AuthorizationService::class);
|
||||
app(UiAccessService::class);
|
||||
|
||||
return [
|
||||
'status' => 'ok',
|
||||
'message' => 'core services resolved successfully',
|
||||
];
|
||||
});
|
||||
|
||||
$runCheck('Database connectivity', static function (): array {
|
||||
$pong = DB::selectValue('select 1');
|
||||
if ((int) $pong !== 1) {
|
||||
return [
|
||||
'status' => 'fail',
|
||||
'message' => 'select 1 did not return expected value',
|
||||
];
|
||||
}
|
||||
|
||||
return [
|
||||
'status' => 'ok',
|
||||
'message' => 'connection established',
|
||||
];
|
||||
});
|
||||
|
||||
$runCheck('Database schema basics', static function (): array {
|
||||
$requiredTables = [
|
||||
'users',
|
||||
'roles',
|
||||
'permissions',
|
||||
'user_roles',
|
||||
'role_permissions',
|
||||
'tenants',
|
||||
'departments',
|
||||
'settings',
|
||||
'scheduler_runtime_status',
|
||||
];
|
||||
|
||||
$rows = DB::select(
|
||||
'select table_name from information_schema.tables where table_schema = database() and table_name in (???)',
|
||||
$requiredTables
|
||||
);
|
||||
|
||||
$present = [];
|
||||
foreach ((array) $rows as $row) {
|
||||
$table = (string) (($row['tables']['table_name'] ?? $row['table_name'] ?? ''));
|
||||
if ($table !== '') {
|
||||
$present[] = $table;
|
||||
}
|
||||
}
|
||||
$present = array_values(array_unique($present));
|
||||
sort($present, SORT_STRING);
|
||||
|
||||
$missing = array_values(array_diff($requiredTables, $present));
|
||||
if ($missing) {
|
||||
return [
|
||||
'status' => 'fail',
|
||||
'message' => 'missing tables: ' . implode(', ', $missing),
|
||||
];
|
||||
}
|
||||
|
||||
return [
|
||||
'status' => 'ok',
|
||||
'message' => sprintf('%d core tables present', count($requiredTables)),
|
||||
];
|
||||
});
|
||||
|
||||
$runCheck('Storage path writeability', static function (): array {
|
||||
$storagePath = defined('APP_STORAGE_PATH') && APP_STORAGE_PATH
|
||||
? rtrim((string) APP_STORAGE_PATH, '/')
|
||||
: rtrim(dirname(__DIR__) . '/storage', '/');
|
||||
|
||||
if (!is_dir($storagePath)) {
|
||||
return [
|
||||
'status' => 'fail',
|
||||
'message' => "storage directory not found: {$storagePath}",
|
||||
];
|
||||
}
|
||||
if (!is_writable($storagePath)) {
|
||||
return [
|
||||
'status' => 'fail',
|
||||
'message' => "storage directory not writable: {$storagePath}",
|
||||
];
|
||||
}
|
||||
|
||||
$probeFile = $storagePath . '/.doctor-write-probe-' . uniqid('', true);
|
||||
$written = @file_put_contents($probeFile, 'ok');
|
||||
if ($written === false) {
|
||||
return [
|
||||
'status' => 'fail',
|
||||
'message' => "write probe failed in: {$storagePath}",
|
||||
];
|
||||
}
|
||||
@unlink($probeFile);
|
||||
|
||||
return [
|
||||
'status' => 'ok',
|
||||
'message' => "storage path is writable ({$storagePath})",
|
||||
];
|
||||
});
|
||||
|
||||
$runCheck('RBAC baseline permissions', static function (): array {
|
||||
$requiredPermissions = [
|
||||
PermissionService::USERS_VIEW,
|
||||
PermissionService::TENANTS_VIEW,
|
||||
PermissionService::DEPARTMENTS_VIEW,
|
||||
PermissionService::ROLES_VIEW,
|
||||
PermissionService::PERMISSIONS_VIEW,
|
||||
PermissionService::SETTINGS_VIEW,
|
||||
];
|
||||
|
||||
$rows = DB::select(
|
||||
'select `key` from permissions where active = 1 and `key` in (???)',
|
||||
$requiredPermissions
|
||||
);
|
||||
|
||||
$present = [];
|
||||
foreach ((array) $rows as $row) {
|
||||
$key = (string) (($row['permissions']['key'] ?? $row['key'] ?? ''));
|
||||
if ($key !== '') {
|
||||
$present[] = $key;
|
||||
}
|
||||
}
|
||||
$present = array_values(array_unique($present));
|
||||
sort($present, SORT_STRING);
|
||||
|
||||
$missing = array_values(array_diff($requiredPermissions, $present));
|
||||
if ($missing) {
|
||||
return [
|
||||
'status' => 'fail',
|
||||
'message' => 'missing active permissions: ' . implode(', ', $missing),
|
||||
];
|
||||
}
|
||||
|
||||
return [
|
||||
'status' => 'ok',
|
||||
'message' => sprintf('%d baseline permissions active', count($requiredPermissions)),
|
||||
];
|
||||
});
|
||||
|
||||
$runCheck('Admin role assignment', static function (): array {
|
||||
$count = (int) (DB::selectValue(
|
||||
'select count(distinct ur.user_id) from user_roles ur join roles r on r.id = ur.role_id and r.active = 1 where r.description in (?, ?) or r.id = 1',
|
||||
'Admin',
|
||||
'Administrator'
|
||||
) ?? 0);
|
||||
|
||||
if ($count <= 0) {
|
||||
return [
|
||||
'status' => 'fail',
|
||||
'message' => 'no active user assigned to Admin/Administrator role',
|
||||
];
|
||||
}
|
||||
|
||||
return [
|
||||
'status' => 'ok',
|
||||
'message' => sprintf('%d admin user(s) assigned', $count),
|
||||
];
|
||||
});
|
||||
|
||||
$runCheck('Scheduler heartbeat', static function (): array {
|
||||
$row = DB::selectOne('select last_heartbeat_at, last_result, last_error_code from scheduler_runtime_status where id = 1 limit 1');
|
||||
$status = is_array($row) ? ($row['scheduler_runtime_status'] ?? $row) : null;
|
||||
if (!is_array($status)) {
|
||||
return [
|
||||
'status' => 'warn',
|
||||
'message' => 'no scheduler runtime status row found yet',
|
||||
];
|
||||
}
|
||||
|
||||
$heartbeat = trim((string) ($status['last_heartbeat_at'] ?? ''));
|
||||
$result = trim((string) ($status['last_result'] ?? 'unknown'));
|
||||
$errorCode = trim((string) ($status['last_error_code'] ?? ''));
|
||||
if ($heartbeat === '') {
|
||||
return [
|
||||
'status' => 'warn',
|
||||
'message' => 'scheduler heartbeat is empty',
|
||||
];
|
||||
}
|
||||
|
||||
$seconds = time() - strtotime($heartbeat . ' UTC');
|
||||
if ($seconds < 0) {
|
||||
$seconds = 0;
|
||||
}
|
||||
|
||||
if ($seconds > 300) {
|
||||
return [
|
||||
'status' => 'warn',
|
||||
'message' => "last heartbeat {$seconds}s ago (result={$result}" . ($errorCode !== '' ? ", error={$errorCode}" : '') . ')',
|
||||
];
|
||||
}
|
||||
|
||||
return [
|
||||
'status' => 'ok',
|
||||
'message' => "last heartbeat {$seconds}s ago (result={$result}" . ($errorCode !== '' ? ", error={$errorCode}" : '') . ')',
|
||||
];
|
||||
});
|
||||
|
||||
fwrite(STDOUT, "Minty Doctor Report\n");
|
||||
fwrite(STDOUT, "===================\n");
|
||||
foreach ($results as $item) {
|
||||
$statusLabel = strtoupper($item['status']);
|
||||
fwrite(STDOUT, sprintf("[%s] %s: %s\n", $statusLabel, $item['name'], $item['message']));
|
||||
}
|
||||
|
||||
$okCount = count($results) - $warnCount - $failCount;
|
||||
fwrite(STDOUT, sprintf("\nSummary: ok=%d warn=%d fail=%d\n", $okCount, $warnCount, $failCount));
|
||||
|
||||
$exitCode = $failCount > 0 ? 1 : 0;
|
||||
$resultLabel = $failCount > 0 ? 'fail' : ($warnCount > 0 ? 'warn' : 'ok');
|
||||
|
||||
try {
|
||||
app(SystemAuditService::class)->record(
|
||||
'cli.command',
|
||||
$failCount > 0 ? 'failed' : 'success',
|
||||
[
|
||||
'metadata' => [
|
||||
'command' => 'bin/doctor.php',
|
||||
'exit_code' => $exitCode,
|
||||
'duration_ms' => max(0, (int) round((microtime(true) - $startedAt) * 1000)),
|
||||
'result' => $resultLabel,
|
||||
'warn_count' => $warnCount,
|
||||
'fail_count' => $failCount,
|
||||
],
|
||||
]
|
||||
);
|
||||
} catch (Throwable) {
|
||||
// fail-open
|
||||
}
|
||||
|
||||
DB::close();
|
||||
exit($exitCode);
|
||||
@@ -1,61 +0,0 @@
|
||||
#!/usr/bin/env php
|
||||
<?php
|
||||
|
||||
/**
|
||||
* Publishes enabled module web assets into web/modules/.
|
||||
*
|
||||
* Usage: php bin/module-assets-sync.php
|
||||
*
|
||||
* APP_MODULE_ASSET_MODE:
|
||||
* - symlink (default): create web/modules/<module-id> symlinks to modules/<id>/web
|
||||
* - copy: copy module web directories (for environments without symlink support)
|
||||
*
|
||||
* Exit codes:
|
||||
* 0 — sync successful
|
||||
* 1 — error during sync
|
||||
*/
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use MintyPHP\App\Module\ModuleRegistry;
|
||||
use MintyPHP\App\Module\ModuleRuntimeAssetPublisher;
|
||||
|
||||
require_once __DIR__ . '/module-cli-bootstrap.php';
|
||||
|
||||
function moduleAssetsSyncRun(): int
|
||||
{
|
||||
return moduleCliRunStep('module-assets-sync', static function (): int {
|
||||
$runtimeModulesDir = __DIR__ . '/../web/modules';
|
||||
$lockFile = __DIR__ . '/../storage/runtime/.module-assets-sync.lock';
|
||||
$mode = (string) getenv('APP_MODULE_ASSET_MODE');
|
||||
if (trim($mode) === '') {
|
||||
$mode = 'symlink';
|
||||
}
|
||||
|
||||
return moduleCliWithFileLock(
|
||||
$lockFile,
|
||||
'module-assets-sync: another sync is in progress, skipping.',
|
||||
static function () use ($runtimeModulesDir, $mode): int {
|
||||
$registry = app(ModuleRegistry::class);
|
||||
$publisher = new ModuleRuntimeAssetPublisher();
|
||||
$result = $publisher->publish($runtimeModulesDir, $registry->getModules(), $mode);
|
||||
|
||||
fwrite(STDOUT, sprintf(
|
||||
"module-assets-sync: mode=%s created=%d updated=%d removed=%d unchanged=%d\n",
|
||||
$mode,
|
||||
$result['created'],
|
||||
$result['updated'],
|
||||
$result['removed'],
|
||||
$result['unchanged']
|
||||
));
|
||||
|
||||
return 0;
|
||||
}
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
if (PHP_SAPI === 'cli' && realpath((string) ($_SERVER['SCRIPT_FILENAME'] ?? '')) === __FILE__) {
|
||||
fwrite(STDERR, "Hint: prefer `php bin/console module:assets-sync` instead.\n");
|
||||
exit(moduleAssetsSyncRun());
|
||||
}
|
||||
@@ -1,68 +0,0 @@
|
||||
#!/usr/bin/env php
|
||||
<?php
|
||||
|
||||
/**
|
||||
* Builds the runtime page root by symlinking module pages into storage/runtime/pages/.
|
||||
*
|
||||
* Usage: php bin/module-build.php
|
||||
*
|
||||
* The runtime page root mirrors the core pages/ directory and adds symlinks for
|
||||
* each enabled module's pages. MintyPHP Router::$pageRoot points to this directory.
|
||||
*
|
||||
* This script is idempotent and uses a lock file to prevent concurrent builds.
|
||||
*
|
||||
* Exit codes:
|
||||
* 0 — build successful (or nothing to do)
|
||||
* 1 — error during build
|
||||
*/
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use MintyPHP\App\Module\ModuleRegistry;
|
||||
use MintyPHP\App\Module\ModuleRuntimePageBuilder;
|
||||
|
||||
require_once __DIR__ . '/module-cli-bootstrap.php';
|
||||
|
||||
function moduleBuildRun(): int
|
||||
{
|
||||
return moduleCliRunStep('module-build', static function (): int {
|
||||
$runtimeDir = __DIR__ . '/../storage/runtime/pages';
|
||||
$lockFile = __DIR__ . '/../storage/runtime/.module-build.lock';
|
||||
|
||||
// Ensure runtime directory exists
|
||||
$runtimeParent = dirname($runtimeDir);
|
||||
if (!is_dir($runtimeParent) && !mkdir($runtimeParent, 0775, true) && !is_dir($runtimeParent)) {
|
||||
throw new \RuntimeException("Failed to create runtime directory: {$runtimeParent}");
|
||||
}
|
||||
|
||||
return moduleCliWithFileLock(
|
||||
$lockFile,
|
||||
'module-build: another build is in progress, skipping.',
|
||||
static function () use ($runtimeDir): int {
|
||||
$registry = app(ModuleRegistry::class);
|
||||
$modules = $registry->getModules();
|
||||
$builder = new ModuleRuntimePageBuilder();
|
||||
$result = $builder->build($runtimeDir, __DIR__ . '/../pages', $modules);
|
||||
|
||||
ModuleRuntimePageBuilder::writeFingerprint($runtimeDir, $modules);
|
||||
|
||||
if (count($modules) === 0) {
|
||||
fwrite(STDOUT, "module-build: no modules, runtime -> core pages (symlink).\n");
|
||||
} else {
|
||||
fwrite(STDOUT, sprintf(
|
||||
"module-build: runtime pages built - %d core entries + %d module entries.\n",
|
||||
(int) ($result['core_entries'] ?? 0),
|
||||
(int) ($result['module_entries'] ?? 0)
|
||||
));
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
if (PHP_SAPI === 'cli' && realpath((string) ($_SERVER['SCRIPT_FILENAME'] ?? '')) === __FILE__) {
|
||||
fwrite(STDERR, "Hint: prefer `php bin/console module:build` instead.\n");
|
||||
exit(moduleBuildRun());
|
||||
}
|
||||
@@ -1,166 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* Shared CLI bootstrap and error policy for module runtime scripts.
|
||||
*
|
||||
* Policy:
|
||||
* - first-party warnings/notices/deprecations => fail-fast (RuntimeException)
|
||||
* - vendor warnings/notices/deprecations => ignored and counted
|
||||
*/
|
||||
|
||||
function moduleCliProjectRoot(): string
|
||||
{
|
||||
return dirname(__DIR__);
|
||||
}
|
||||
|
||||
function moduleCliVendorWarningsIgnoredTotal(): int
|
||||
{
|
||||
$count = $GLOBALS['module_cli_vendor_warnings_ignored'] ?? 0;
|
||||
return is_int($count) ? $count : 0;
|
||||
}
|
||||
|
||||
function moduleCliVendorWarningsIgnoredSince(int $before): int
|
||||
{
|
||||
return max(0, moduleCliVendorWarningsIgnoredTotal() - $before);
|
||||
}
|
||||
|
||||
function moduleCliIncrementVendorWarningsIgnored(): void
|
||||
{
|
||||
$current = moduleCliVendorWarningsIgnoredTotal();
|
||||
$GLOBALS['module_cli_vendor_warnings_ignored'] = $current + 1;
|
||||
}
|
||||
|
||||
function moduleCliSeverityName(int $severity): string
|
||||
{
|
||||
return match ($severity) {
|
||||
E_WARNING => 'E_WARNING',
|
||||
E_NOTICE => 'E_NOTICE',
|
||||
E_USER_WARNING => 'E_USER_WARNING',
|
||||
E_USER_NOTICE => 'E_USER_NOTICE',
|
||||
E_DEPRECATED => 'E_DEPRECATED',
|
||||
E_USER_DEPRECATED => 'E_USER_DEPRECATED',
|
||||
default => 'E_' . $severity,
|
||||
};
|
||||
}
|
||||
|
||||
function moduleCliInstallErrorPolicy(): void
|
||||
{
|
||||
static $installed = false;
|
||||
if ($installed) {
|
||||
return;
|
||||
}
|
||||
$installed = true;
|
||||
|
||||
error_reporting(E_ALL);
|
||||
ini_set('display_errors', '0');
|
||||
|
||||
set_error_handler(static function (
|
||||
int $severity,
|
||||
string $message,
|
||||
string $file = '',
|
||||
int $line = 0
|
||||
): bool {
|
||||
$handledSeverities = [
|
||||
E_WARNING,
|
||||
E_NOTICE,
|
||||
E_USER_WARNING,
|
||||
E_USER_NOTICE,
|
||||
E_DEPRECATED,
|
||||
E_USER_DEPRECATED,
|
||||
];
|
||||
if (!in_array($severity, $handledSeverities, true)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if ((error_reporting() & $severity) === 0) {
|
||||
return true;
|
||||
}
|
||||
|
||||
$normalizedFile = str_replace('\\', '/', $file);
|
||||
if (str_contains($normalizedFile, '/vendor/')) {
|
||||
moduleCliIncrementVendorWarningsIgnored();
|
||||
return true;
|
||||
}
|
||||
|
||||
throw new RuntimeException(sprintf(
|
||||
"First-party runtime warning (%s) in %s:%d: %s",
|
||||
moduleCliSeverityName($severity),
|
||||
$file,
|
||||
$line,
|
||||
$message
|
||||
));
|
||||
});
|
||||
}
|
||||
|
||||
function moduleCliBootstrapApp(): void
|
||||
{
|
||||
static $booted = false;
|
||||
if ($booted) {
|
||||
return;
|
||||
}
|
||||
|
||||
moduleCliInstallErrorPolicy();
|
||||
|
||||
$projectRoot = moduleCliProjectRoot();
|
||||
chdir($projectRoot);
|
||||
|
||||
require_once $projectRoot . '/vendor/autoload.php';
|
||||
require_once $projectRoot . '/config/config.php';
|
||||
require_once $projectRoot . '/lib/Support/helpers.php';
|
||||
$container = require $projectRoot . '/lib/App/registerContainer.php';
|
||||
setAppContainer($container);
|
||||
|
||||
$booted = true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Runs one module CLI step with shared bootstrap, failure handling and vendor warning summary.
|
||||
*
|
||||
* @param callable():int $runner
|
||||
*/
|
||||
function moduleCliRunStep(string $stepName, callable $runner): int
|
||||
{
|
||||
$warningsBefore = moduleCliVendorWarningsIgnoredTotal();
|
||||
moduleCliBootstrapApp();
|
||||
|
||||
$exitCode = 0;
|
||||
try {
|
||||
$exitCode = $runner();
|
||||
} catch (Throwable $exception) {
|
||||
fwrite(STDERR, sprintf("%s: FAILED: %s\n", $stepName, $exception->getMessage()));
|
||||
$exitCode = 1;
|
||||
}
|
||||
|
||||
$vendorWarningsIgnored = moduleCliVendorWarningsIgnoredSince($warningsBefore);
|
||||
fwrite(STDOUT, sprintf("%s: vendor_warnings_ignored=%d\n", $stepName, $vendorWarningsIgnored));
|
||||
|
||||
return $exitCode;
|
||||
}
|
||||
|
||||
/**
|
||||
* Executes a callable under a non-blocking file lock.
|
||||
*
|
||||
* @param callable():int $runner
|
||||
*/
|
||||
function moduleCliWithFileLock(string $lockFile, string $busyMessage, callable $runner): int
|
||||
{
|
||||
$lockDir = dirname($lockFile);
|
||||
if (!is_dir($lockDir) && !mkdir($lockDir, 0775, true) && !is_dir($lockDir)) {
|
||||
throw new RuntimeException("Failed to create lock directory: {$lockDir}");
|
||||
}
|
||||
|
||||
$lock = fopen($lockFile, 'c');
|
||||
if ($lock === false || !flock($lock, LOCK_EX | LOCK_NB)) {
|
||||
fwrite(STDERR, $busyMessage . PHP_EOL);
|
||||
return 0;
|
||||
}
|
||||
|
||||
try {
|
||||
return $runner();
|
||||
} finally {
|
||||
flock($lock, LOCK_UN);
|
||||
fclose($lock);
|
||||
}
|
||||
}
|
||||
@@ -1,80 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* CLI script to run a module's deactivation handler.
|
||||
*
|
||||
* Usage:
|
||||
* php bin/module-deactivate.php <module-id> --confirm
|
||||
* php bin/module-deactivate.php <module-id> --dry-run
|
||||
*
|
||||
* The module does NOT need to be in the enabled list — the manifest is loaded
|
||||
* directly from modules/<id>/module.php.
|
||||
*/
|
||||
|
||||
require_once __DIR__ . '/module-cli-bootstrap.php';
|
||||
|
||||
function moduleDeactivateRun(): int
|
||||
{
|
||||
return moduleCliRunStep('module-deactivate', static function (): int {
|
||||
global $argv;
|
||||
|
||||
$moduleId = $argv[1] ?? '';
|
||||
$flags = array_slice($argv, 2);
|
||||
|
||||
if ($moduleId === '' || $moduleId === '--help') {
|
||||
fwrite(STDOUT, "Usage: php bin/module-deactivate.php <module-id> --confirm|--dry-run\n");
|
||||
return $moduleId === '--help' ? 0 : 1;
|
||||
}
|
||||
|
||||
$confirm = in_array('--confirm', $flags, true);
|
||||
$dryRun = in_array('--dry-run', $flags, true);
|
||||
|
||||
if (!$confirm && !$dryRun) {
|
||||
fwrite(STDERR, "Error: must pass --confirm to execute or --dry-run to validate.\n");
|
||||
return 1;
|
||||
}
|
||||
|
||||
$projectRoot = moduleCliProjectRoot();
|
||||
$manifest = \MintyPHP\App\Module\ModuleManifestLoader::loadFromDisk(
|
||||
$projectRoot . '/modules',
|
||||
$moduleId
|
||||
);
|
||||
|
||||
$handlerClass = $manifest->deactivationHandler;
|
||||
if ($handlerClass === null) {
|
||||
fwrite(STDOUT, "Module '{$moduleId}' has no deactivation_handler declared — nothing to do.\n");
|
||||
return 0;
|
||||
}
|
||||
|
||||
if (!class_exists($handlerClass)) {
|
||||
fwrite(STDERR, "Error: deactivation handler class '{$handlerClass}' not found.\n");
|
||||
return 1;
|
||||
}
|
||||
|
||||
$handler = new $handlerClass();
|
||||
if (!$handler instanceof \MintyPHP\App\Module\Contracts\ModuleDeactivationHandler) {
|
||||
fwrite(STDERR, "Error: '{$handlerClass}' does not implement ModuleDeactivationHandler.\n");
|
||||
return 1;
|
||||
}
|
||||
|
||||
if ($dryRun) {
|
||||
fwrite(STDOUT, "Dry-run: handler '{$handlerClass}' found and valid for module '{$moduleId}'.\n");
|
||||
return 0;
|
||||
}
|
||||
|
||||
fwrite(STDOUT, "Running deactivation handler for module '{$moduleId}'...\n");
|
||||
|
||||
$container = app();
|
||||
$handler->deactivate($container);
|
||||
|
||||
fwrite(STDOUT, "Deactivation handler for module '{$moduleId}' completed.\n");
|
||||
return 0;
|
||||
});
|
||||
}
|
||||
|
||||
if (PHP_SAPI === 'cli' && realpath((string) ($_SERVER['SCRIPT_FILENAME'] ?? '')) === __FILE__) {
|
||||
fwrite(STDERR, "Hint: prefer `php bin/console module:deactivate` instead.\n");
|
||||
exit(moduleDeactivateRun());
|
||||
}
|
||||
@@ -1,49 +0,0 @@
|
||||
#!/usr/bin/env php
|
||||
<?php
|
||||
|
||||
/**
|
||||
* Applies pending module migrations for all enabled modules.
|
||||
*
|
||||
* Usage: php bin/module-migrate.php
|
||||
*
|
||||
* Each module declares a migrations_path in its manifest. SQL files in that
|
||||
* directory are applied in alphabetical order. Applied files are tracked in the
|
||||
* module_migrations table to ensure idempotency.
|
||||
*
|
||||
* Exit codes:
|
||||
* 0 — all migrations applied (or nothing to do)
|
||||
* 1 — error during migration
|
||||
*/
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use MintyPHP\App\Module\ModuleRegistry;
|
||||
use MintyPHP\Repository\Module\ModuleMigrationRepository;
|
||||
use MintyPHP\Service\Module\ModuleMigrationService;
|
||||
|
||||
require_once __DIR__ . '/module-cli-bootstrap.php';
|
||||
|
||||
function moduleMigrateRun(): int
|
||||
{
|
||||
return moduleCliRunStep('module-migrate', static function (): int {
|
||||
$lockFile = __DIR__ . '/../storage/runtime/.module-migrate.lock';
|
||||
|
||||
return moduleCliWithFileLock(
|
||||
$lockFile,
|
||||
'module-migrate: another migration run is in progress, skipping.',
|
||||
static function (): int {
|
||||
$service = new ModuleMigrationService(
|
||||
app(ModuleRegistry::class),
|
||||
new ModuleMigrationRepository()
|
||||
);
|
||||
$result = $service->applyPendingMigrations();
|
||||
return $result['ok'] ? 0 : 1;
|
||||
}
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
if (PHP_SAPI === 'cli' && realpath((string) ($_SERVER['SCRIPT_FILENAME'] ?? '')) === __FILE__) {
|
||||
fwrite(STDERR, "Hint: prefer `php bin/console module:migrate` instead.\n");
|
||||
exit(moduleMigrateRun());
|
||||
}
|
||||
@@ -1,56 +0,0 @@
|
||||
#!/usr/bin/env php
|
||||
<?php
|
||||
|
||||
/**
|
||||
* Synchronizes module-declared permissions into the permissions table.
|
||||
*
|
||||
* Usage: php bin/module-permissions-sync.php
|
||||
*
|
||||
* Exit codes:
|
||||
* 0 — sync successful
|
||||
* 1 — error during sync
|
||||
*/
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use MintyPHP\App\Module\ModulePermissionSynchronizer;
|
||||
use MintyPHP\App\Module\ModuleRegistry;
|
||||
use MintyPHP\Repository\Access\PermissionRepository;
|
||||
|
||||
require_once __DIR__ . '/module-cli-bootstrap.php';
|
||||
|
||||
function modulePermissionsSyncRun(): int
|
||||
{
|
||||
return moduleCliRunStep('module-permissions-sync', static function (): int {
|
||||
$lockFile = __DIR__ . '/../storage/runtime/.module-permissions-sync.lock';
|
||||
|
||||
return moduleCliWithFileLock(
|
||||
$lockFile,
|
||||
'module-permissions-sync: another sync is in progress, skipping.',
|
||||
static function (): int {
|
||||
$synchronizer = new ModulePermissionSynchronizer(
|
||||
app(ModuleRegistry::class),
|
||||
app(PermissionRepository::class),
|
||||
moduleCliProjectRoot() . '/modules'
|
||||
);
|
||||
$result = $synchronizer->sync();
|
||||
|
||||
fwrite(STDOUT, sprintf(
|
||||
"module-permissions-sync: created=%d updated=%d unchanged=%d deactivated=%d total=%d\n",
|
||||
$result['created'],
|
||||
$result['updated'],
|
||||
$result['unchanged'],
|
||||
$result['deactivated'],
|
||||
$result['total']
|
||||
));
|
||||
|
||||
return 0;
|
||||
}
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
if (PHP_SAPI === 'cli' && realpath((string) ($_SERVER['SCRIPT_FILENAME'] ?? '')) === __FILE__) {
|
||||
fwrite(STDERR, "Hint: prefer `php bin/console module:permissions-sync` instead.\n");
|
||||
exit(modulePermissionsSyncRun());
|
||||
}
|
||||
@@ -1,77 +0,0 @@
|
||||
#!/usr/bin/env php
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
require_once __DIR__ . '/module-cli-bootstrap.php';
|
||||
|
||||
require_once __DIR__ . '/module-migrate.php';
|
||||
require_once __DIR__ . '/module-permissions-sync.php';
|
||||
require_once __DIR__ . '/module-build.php';
|
||||
require_once __DIR__ . '/module-assets-sync.php';
|
||||
|
||||
function moduleRuntimeSyncRun(): int
|
||||
{
|
||||
return moduleCliRunStep('module-runtime-sync', static function (): int {
|
||||
$lockFile = __DIR__ . '/../storage/runtime/.module-runtime-sync.lock';
|
||||
return moduleCliWithFileLock(
|
||||
$lockFile,
|
||||
'module-runtime-sync: another runtime sync is in progress, skipping.',
|
||||
static function (): int {
|
||||
moduleCliBootstrapApp();
|
||||
$warningsBeforeAll = moduleCliVendorWarningsIgnoredTotal();
|
||||
|
||||
$steps = [
|
||||
'migrate' => static fn (): int => moduleMigrateRun(),
|
||||
'permissions-sync' => static fn (): int => modulePermissionsSyncRun(),
|
||||
'build' => static fn (): int => moduleBuildRun(),
|
||||
'assets-sync' => static fn (): int => moduleAssetsSyncRun(),
|
||||
];
|
||||
|
||||
$statusByStep = [];
|
||||
foreach (array_keys($steps) as $stepName) {
|
||||
$statusByStep[$stepName] = 'pending';
|
||||
}
|
||||
|
||||
$finalExitCode = 0;
|
||||
foreach ($steps as $stepName => $runner) {
|
||||
$stepWarningsBefore = moduleCliVendorWarningsIgnoredTotal();
|
||||
$stepExitCode = $runner();
|
||||
$stepWarnings = moduleCliVendorWarningsIgnoredSince($stepWarningsBefore);
|
||||
$stepStatus = $stepExitCode === 0 ? 'ok' : 'failed';
|
||||
$statusByStep[$stepName] = $stepStatus;
|
||||
|
||||
fwrite(STDOUT, sprintf(
|
||||
"module-runtime-sync: step=%s status=%s exit_code=%d vendor_warnings_ignored=%d\n",
|
||||
$stepName,
|
||||
$stepStatus,
|
||||
$stepExitCode,
|
||||
$stepWarnings
|
||||
));
|
||||
|
||||
if ($stepExitCode !== 0) {
|
||||
$finalExitCode = $stepExitCode;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
$totalVendorWarnings = moduleCliVendorWarningsIgnoredSince($warningsBeforeAll);
|
||||
fwrite(STDOUT, sprintf(
|
||||
"module-runtime-sync: summary migrate=%s permissions-sync=%s build=%s assets-sync=%s vendor_warnings_ignored=%d\n",
|
||||
$statusByStep['migrate'],
|
||||
$statusByStep['permissions-sync'],
|
||||
$statusByStep['build'],
|
||||
$statusByStep['assets-sync'],
|
||||
$totalVendorWarnings
|
||||
));
|
||||
|
||||
return $finalExitCode;
|
||||
}
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
if (PHP_SAPI === 'cli' && realpath((string) ($_SERVER['SCRIPT_FILENAME'] ?? '')) === __FILE__) {
|
||||
fwrite(STDERR, "Hint: prefer `php bin/console module:sync` instead.\n");
|
||||
exit(moduleRuntimeSyncRun());
|
||||
}
|
||||
Reference in New Issue
Block a user