feat(Production): Complete production deployment infrastructure

- Add comprehensive health check system with multiple endpoints
- Add Prometheus metrics endpoint
- Add production logging configurations (5 strategies)
- Add complete deployment documentation suite:
  * QUICKSTART.md - 30-minute deployment guide
  * DEPLOYMENT_CHECKLIST.md - Printable verification checklist
  * DEPLOYMENT_WORKFLOW.md - Complete deployment lifecycle
  * PRODUCTION_DEPLOYMENT.md - Comprehensive technical reference
  * production-logging.md - Logging configuration guide
  * ANSIBLE_DEPLOYMENT.md - Infrastructure as Code automation
  * README.md - Navigation hub
  * DEPLOYMENT_SUMMARY.md - Executive summary
- Add deployment scripts and automation
- Add DEPLOYMENT_PLAN.md - Concrete plan for immediate deployment
- Update README with production-ready features

All production infrastructure is now complete and ready for deployment.
This commit is contained in:
2025-10-25 19:18:37 +02:00
parent caa85db796
commit fc3d7e6357
83016 changed files with 378904 additions and 20919 deletions

View File

@@ -7,36 +7,47 @@ namespace App\Framework\Console\Analytics\Migrations;
use App\Framework\Database\ConnectionInterface;
use App\Framework\Database\Migration\Migration;
use App\Framework\Database\Migration\MigrationVersion;
use App\Framework\Database\Schema\Blueprint;
use App\Framework\Database\Schema\Schema;
final readonly class CreateCommandUsageAnalyticsTable implements Migration
{
public function up(ConnectionInterface $connection): void
{
$sql = "CREATE TABLE IF NOT EXISTS command_usage_analytics (
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
command_name VARCHAR(255) NOT NULL,
executed_at DATETIME NOT NULL,
execution_time_ms DECIMAL(10, 3) NOT NULL,
exit_code TINYINT NOT NULL DEFAULT 0,
argument_count INT NOT NULL DEFAULT 0,
user_id VARCHAR(255) NULL,
metadata JSON NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
INDEX idx_command_name (command_name),
INDEX idx_executed_at (executed_at),
INDEX idx_user_id (user_id),
INDEX idx_command_executed (command_name, executed_at),
INDEX idx_command_exit (command_name, exit_code),
INDEX idx_executed_exit (executed_at, exit_code)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci";
$schema = new Schema($connection);
$connection->execute($sql);
$schema->createIfNotExists('command_usage_analytics', function (Blueprint $table) {
$table->bigIncrements('id');
$table->string('command_name', 255);
$table->dateTime('executed_at');
$table->decimal('execution_time_ms', 10, 3);
$table->tinyInteger('exit_code')->default(0);
$table->integer('argument_count')->default(0);
$table->string('user_id', 255)->nullable();
$table->json('metadata')->nullable();
$table->timestamp('created_at')->useCurrent();
$table->timestamp('updated_at')->useCurrent();
$table->index('command_name', 'idx_command_name');
$table->index('executed_at', 'idx_executed_at');
$table->index('user_id', 'idx_user_id');
$table->index(['command_name', 'executed_at'], 'idx_command_executed');
$table->index(['command_name', 'exit_code'], 'idx_command_exit');
$table->index(['executed_at', 'exit_code'], 'idx_executed_exit');
$table->engine('InnoDB');
$table->charset('utf8mb4');
$table->collation('utf8mb4_unicode_ci');
});
$schema->execute();
}
public function down(ConnectionInterface $connection): void
{
$connection->execute("DROP TABLE IF EXISTS command_usage_analytics");
$schema = new Schema($connection);
$schema->dropIfExists('command_usage_analytics');
$schema->execute();
}
public function getVersion(): MigrationVersion

View File

@@ -58,7 +58,7 @@ final readonly class ArgumentDefinition
*/
public static function flag(string $name, ?string $shortName = null, string $description = ''): self
{
return new self($name, ArgumentType::BOOLEAN, shortName: $shortName, description: $description);
return new self($name, ArgumentType::BOOLEAN, description: $description, shortName: $shortName);
}
/**

View File

@@ -83,17 +83,21 @@ final readonly class ArgumentParser
// Handle --option=value
if (str_contains($optionPart, '=')) {
[$name, $value] = explode('=', $optionPart, 2);
$parsed['options'][$name] = $this->parseValue($name, $value);
$camelCaseName = $this->kebabToCamelCase($name);
$parsed['options'][$camelCaseName] = $this->parseValue($camelCaseName, $value);
return $index + 1;
}
// Convert kebab-case to camelCase for definition lookup
$camelCaseName = $this->kebabToCamelCase($optionPart);
// Handle --option value or --flag
$definition = $this->findDefinitionByName($optionPart);
$definition = $this->findDefinitionByName($camelCaseName);
if ($definition && $definition->type === ArgumentType::BOOLEAN) {
// Boolean flag
$parsed['flags'][$optionPart] = true;
// Boolean flag - use camelCase name
$parsed['flags'][$camelCaseName] = true;
return $index + 1;
}
@@ -104,16 +108,25 @@ final readonly class ArgumentParser
throw new \InvalidArgumentException("Option '--{$optionPart}' requires a value");
}
// Optional option without value - treat as flag
$parsed['flags'][$optionPart] = true;
$parsed['flags'][$camelCaseName] = true;
return $index + 1;
}
$parsed['options'][$optionPart] = $this->parseValue($optionPart, $arguments[$index + 1]);
$parsed['options'][$camelCaseName] = $this->parseValue($camelCaseName, $arguments[$index + 1]);
return $index + 2;
}
/**
* Convert kebab-case to camelCase
* Example: dry-run -> dryRun
*/
private function kebabToCamelCase(string $kebab): string
{
return lcfirst(str_replace('-', '', ucwords($kebab, '-')));
}
/**
* Parse short option (-o or -o value or -abc)
*/
@@ -325,54 +338,3 @@ final readonly class ArgumentParser
return new ArgumentParserBuilder();
}
}
/**
* Fluent builder for ArgumentParser
*/
final class ArgumentParserBuilder
{
/** @var ArgumentDefinition[] */
private array $definitions = [];
public function addArgument(ArgumentDefinition $definition): self
{
$this->definitions[$definition->name] = $definition;
return $this;
}
public function requiredString(string $name, string $description = ''): self
{
return $this->addArgument(ArgumentDefinition::required($name, $description));
}
public function optionalString(string $name, string $default = '', string $description = ''): self
{
return $this->addArgument(ArgumentDefinition::optional($name, $default, $description));
}
public function flag(string $name, ?string $shortName = null, string $description = ''): self
{
return $this->addArgument(ArgumentDefinition::flag($name, $shortName, $description));
}
public function email(string $name, bool $required = true, string $description = ''): self
{
return $this->addArgument(ArgumentDefinition::email($name, $required, $description));
}
public function integer(string $name, bool $required = false, ?int $default = null, string $description = ''): self
{
return $this->addArgument(ArgumentDefinition::integer($name, $required, $default, $description));
}
public function choice(string $name, array $choices, bool $required = false, ?string $default = null, string $description = ''): self
{
return $this->addArgument(ArgumentDefinition::choice($name, $choices, $required, $default, $description));
}
public function build(): ArgumentParser
{
return new ArgumentParser($this->definitions);
}
}

View File

@@ -0,0 +1,54 @@
<?php
namespace App\Framework\Console;
/**
* Fluent builder for ArgumentParser
*/
final class ArgumentParserBuilder
{
/** @var ArgumentDefinition[] */
private array $definitions = [];
public function addArgument(ArgumentDefinition $definition): self
{
$this->definitions[$definition->name] = $definition;
return $this;
}
public function requiredString(string $name, string $description = ''): self
{
return $this->addArgument(ArgumentDefinition::required($name, $description));
}
public function optionalString(string $name, string $default = '', string $description = ''): self
{
return $this->addArgument(ArgumentDefinition::optional($name, $default, $description));
}
public function flag(string $name, ?string $shortName = null, string $description = ''): self
{
return $this->addArgument(ArgumentDefinition::flag($name, $shortName, $description));
}
public function email(string $name, bool $required = true, string $description = ''): self
{
return $this->addArgument(ArgumentDefinition::email($name, $required, $description));
}
public function integer(string $name, bool $required = false, ?int $default = null, string $description = ''): self
{
return $this->addArgument(ArgumentDefinition::integer($name, $required, $default, $description));
}
public function choice(string $name, array $choices, bool $required = false, ?string $default = null, string $description = ''): self
{
return $this->addArgument(ArgumentDefinition::choice($name, $choices, $required, $default, $description));
}
public function build(): ArgumentParser
{
return new ArgumentParser($this->definitions);
}
}

View File

@@ -4,7 +4,7 @@ declare(strict_types=1);
namespace App\Framework\Console;
use App\Framework\Filesystem\FilePath;
use App\Framework\Filesystem\ValueObjects\FilePath;
/**
* Command history and favorites management for console TUI

View File

@@ -4,7 +4,7 @@ declare(strict_types=1);
namespace App\Framework\Console;
use App\Framework\Exception\ErrorCode;
use App\Framework\Exception\Core\ConsoleErrorCode;
use App\Framework\Exception\FrameworkException;
use ArrayIterator;
use Countable;
@@ -27,7 +27,7 @@ final readonly class CommandList implements IteratorAggregate, Countable
foreach ($commands as $command) {
if (isset($commandMap[$command->name])) {
throw FrameworkException::create(
ErrorCode::CON_INVALID_COMMAND_STRUCTURE,
ConsoleErrorCode::INVALID_COMMAND_STRUCTURE,
"Duplicate command name '{$command->name}'"
)->withData(['command_name' => $command->name]);
}
@@ -47,7 +47,7 @@ final readonly class CommandList implements IteratorAggregate, Countable
{
if ($this->has($command->name)) {
throw FrameworkException::create(
ErrorCode::CON_INVALID_COMMAND_STRUCTURE,
ConsoleErrorCode::INVALID_COMMAND_STRUCTURE,
"Command '{$command->name}' already exists"
)->withData(['command_name' => $command->name]);
}
@@ -67,7 +67,7 @@ final readonly class CommandList implements IteratorAggregate, Countable
{
if (! $this->has($name)) {
throw FrameworkException::create(
ErrorCode::CON_COMMAND_NOT_FOUND,
ConsoleErrorCode::COMMAND_NOT_FOUND,
"Command '{$name}' not found"
)->withData(['command_name' => $name]);
}

View File

@@ -5,7 +5,8 @@ declare(strict_types=1);
namespace App\Framework\Console;
use App\Framework\Core\ValueObjects\Email;
use App\Framework\Core\ValueObjects\Url;
use App\Framework\Http\Url\Url;
use App\Framework\Http\Url\UrlFactory;
use ReflectionMethod;
use ReflectionNamedType;
use ReflectionParameter;
@@ -26,9 +27,13 @@ final readonly class CommandParameterResolver
* @param string[] $rawArguments
* @return array<int, mixed>
*/
public function resolveParameters(ReflectionMethod $method, array $rawArguments): array
{
// Generate argument definitions from method signature
public function resolveParameters(
ReflectionMethod $method,
array $rawArguments,
?ConsoleInput $consoleInput = null,
?ConsoleOutputInterface $consoleOutput = null
): array {
// Generate argument definitions from method signature (excluding framework types)
$definitions = $this->signatureAnalyzer->generateArgumentDefinitions($method);
// Create parser with auto-generated definitions
@@ -41,6 +46,14 @@ final readonly class CommandParameterResolver
$resolvedParams = [];
foreach ($method->getParameters() as $param) {
// Check if this is a framework-injected parameter
if ($this->isFrameworkParameter($param)) {
$frameworkValue = $this->resolveFrameworkParameter($param, $consoleInput, $consoleOutput);
$resolvedParams[] = $frameworkValue;
continue;
}
// Otherwise resolve from user arguments
$value = $this->resolveParameterValue($param, $parsedArgs);
$resolvedParams[] = $value;
}
@@ -48,6 +61,77 @@ final readonly class CommandParameterResolver
return $resolvedParams;
}
/**
* Check if parameter is a framework-provided type
*/
private function isFrameworkParameter(ReflectionParameter $param): bool
{
$paramType = $param->getType();
if (! ($paramType instanceof ReflectionNamedType)) {
return false;
}
$typeName = $paramType->getName();
$frameworkTypes = [
'App\\Framework\\Console\\ConsoleInput',
'App\\Framework\\Console\\ConsoleInputInterface',
'App\\Framework\\Console\\ConsoleOutputInterface',
'App\\Framework\\Console\\ConsoleOutput',
'ConsoleInput',
'ConsoleInputInterface',
'ConsoleOutputInterface',
'ConsoleOutput',
];
return in_array($typeName, $frameworkTypes, true);
}
/**
* Resolve framework-provided parameters (ConsoleInput, ConsoleOutput)
*/
private function resolveFrameworkParameter(
ReflectionParameter $param,
?ConsoleInput $consoleInput,
?ConsoleOutputInterface $consoleOutput
): mixed {
$paramType = $param->getType();
if (! ($paramType instanceof ReflectionNamedType)) {
throw new \LogicException("Framework parameter must have a named type");
}
$typeName = $paramType->getName();
// Check for ConsoleInput
if (in_array($typeName, ['App\\Framework\\Console\\ConsoleInput', 'ConsoleInput'], true)) {
if ($consoleInput === null && ! $paramType->allowsNull()) {
throw new \InvalidArgumentException(
"ConsoleInput is required but not provided for parameter '{$param->getName()}'"
);
}
return $consoleInput;
}
// Check for ConsoleOutput/ConsoleOutputInterface
if (in_array($typeName, [
'App\\Framework\\Console\\ConsoleOutputInterface',
'App\\Framework\\Console\\ConsoleOutput',
'ConsoleOutputInterface',
'ConsoleOutput'
], true)) {
if ($consoleOutput === null && ! $paramType->allowsNull()) {
throw new \InvalidArgumentException(
"ConsoleOutput is required but not provided for parameter '{$param->getName()}'"
);
}
return $consoleOutput;
}
throw new \LogicException("Unknown framework parameter type: {$typeName}");
}
/**
* Resolve individual parameter value
*/
@@ -61,6 +145,11 @@ final readonly class CommandParameterResolver
// Handle null/missing values
if ($rawValue === null || $rawValue === '') {
// Special case: nullable bool (?bool) - missing flag = null (not false!)
if ($this->isNullableBoolParameter($param)) {
return null;
}
if ($param->isDefaultValueAvailable()) {
return $param->getDefaultValue();
}
@@ -76,6 +165,41 @@ final readonly class CommandParameterResolver
return $this->convertToParameterType($rawValue, $paramType, $paramName);
}
/**
* Check if parameter is nullable bool (?bool)
*/
private function isNullableBoolParameter(ReflectionParameter $param): bool
{
$type = $param->getType();
if (!($type instanceof \ReflectionUnionType)) {
return false;
}
$types = $type->getTypes();
if (count($types) !== 2) {
return false;
}
$hasNull = false;
$hasBool = false;
foreach ($types as $unionType) {
if (!($unionType instanceof \ReflectionNamedType)) {
return false;
}
$name = strtolower($unionType->getName());
if ($name === 'null') {
$hasNull = true;
} elseif ($name === 'bool' || $name === 'boolean') {
$hasBool = true;
}
}
return $hasNull && $hasBool;
}
/**
* Convert value to the appropriate parameter type
*/
@@ -224,7 +348,7 @@ final readonly class CommandParameterResolver
// Handle framework value objects
return match ($typeName) {
Email::class => new Email($this->convertToString($value)),
Url::class => new Url($this->convertToString($value)),
Url::class => UrlFactory::parse($this->convertToString($value)),
default => $this->attemptGenericConstruction($value, $typeName, $paramName)
};
}

View File

@@ -9,7 +9,7 @@ use App\Framework\Console\Progress\ProgressMiddleware;
use App\Framework\DI\Container;
use App\Framework\Discovery\Results\DiscoveryRegistry;
use App\Framework\Discovery\ValueObjects\DiscoveredAttribute;
use App\Framework\Exception\ErrorCode;
use App\Framework\Exception\Core\ConsoleErrorCode;
use App\Framework\Exception\FrameworkException;
use App\Framework\Logging\Logger;
use App\Framework\Logging\ValueObjects\LogContext;
@@ -53,7 +53,7 @@ final readonly class CommandRegistry
{
if (! isset($this->discoveredAttributes[$commandName])) {
throw FrameworkException::create(
ErrorCode::CON_COMMAND_NOT_FOUND,
ConsoleErrorCode::COMMAND_NOT_FOUND,
"No discovered attribute found for command '{$commandName}'"
)->withData(['command_name' => $commandName]);
}
@@ -100,7 +100,7 @@ final readonly class CommandRegistry
// Validate command structure
if (! is_object($instance) || ! method_exists($instance, $methodName)) {
throw FrameworkException::create(
ErrorCode::CON_INVALID_COMMAND_STRUCTURE,
ConsoleErrorCode::INVALID_COMMAND_STRUCTURE,
"Invalid command configuration for '{$commandName}'"
)->withData([
'command_name' => $commandName,
@@ -128,7 +128,7 @@ final readonly class CommandRegistry
} catch (Throwable $e) {
throw FrameworkException::create(
ErrorCode::CON_COMMAND_EXECUTION_FAILED,
ConsoleErrorCode::EXECUTION_FAILED,
"Failed to execute command '{$commandName}': {$e->getMessage()}"
)->withData([
'command_name' => $commandName,
@@ -259,23 +259,25 @@ final readonly class CommandRegistry
try {
// Create the actual command execution callback
$commandExecutor = function (ConsoleInput $input, ConsoleOutputInterface $progressAwareOutput) use ($instance, $method, $arguments) {
// Check if method uses the new reflection-based parameter style
if ($this->usesReflectionParameters($method)) {
// Performance tracking: Parameter resolution
$parameterStart = microtime(true);
$resolvedParams = $this->parameterResolver->resolveParameters($method, $arguments);
$parameterDuration = (microtime(true) - $parameterStart) * 1000;
// Performance tracking: Parameter resolution
$parameterStart = microtime(true);
if ($this->performanceCollector) {
$this->performanceCollector->recordParameterResolutionTime($method->getDeclaringClass()->getName(), $parameterDuration);
}
// Resolve parameters with framework injection support
$resolvedParams = $this->parameterResolver->resolveParameters(
$method,
$arguments,
$input, // Inject ConsoleInput
$progressAwareOutput // Inject ConsoleOutput
);
$result = $method->invokeArgs($instance, $resolvedParams);
} else {
// For legacy style, use the progress-aware output
$result = $method->invoke($instance, $input, $progressAwareOutput);
$parameterDuration = (microtime(true) - $parameterStart) * 1000;
if ($this->performanceCollector) {
$this->performanceCollector->recordParameterResolutionTime($method->getDeclaringClass()->getName(), $parameterDuration);
}
$result = $method->invokeArgs($instance, $resolvedParams);
return $this->normalizeCommandResult($result);
};
@@ -295,43 +297,6 @@ final readonly class CommandRegistry
}
}
/**
* Determine if method uses reflection-based parameters
*/
private function usesReflectionParameters(ReflectionMethod $method): bool
{
$parameters = $method->getParameters();
// If no parameters, use simple invocation (no ConsoleInput/Output needed)
if (empty($parameters)) {
return true;
}
// If first parameter is ConsoleInput, it's legacy style
$firstParam = $parameters[0];
$firstParamType = $firstParam->getType();
if ($firstParamType instanceof \ReflectionNamedType) {
$typeName = $firstParamType->getName();
if ($typeName === ConsoleInput::class || $typeName === ConsoleInputInterface::class) {
return false; // Legacy style
}
}
// If method has ConsoleInput or ConsoleOutput in parameters, it's legacy
foreach ($parameters as $param) {
$type = $param->getType();
if ($type instanceof \ReflectionNamedType) {
$typeName = $type->getName();
if (in_array($typeName, [ConsoleInput::class, ConsoleInputInterface::class, ConsoleOutputInterface::class], true)) {
return false;
}
}
}
// All other cases are considered reflection-based
return true;
}
/**
* Generate help for a specific command
@@ -345,14 +310,7 @@ final readonly class CommandRegistry
try {
$reflectionMethod = new ReflectionMethod($className, $methodName);
if ($this->usesReflectionParameters($reflectionMethod)) {
return $this->parameterResolver->generateMethodHelp($reflectionMethod, $commandName);
} else {
// Generate basic help for legacy commands
$command = $this->commandList->get($commandName);
return "Command: {$commandName}\nDescription: {$command->description}\n\nThis command uses legacy parameter style.";
}
return $this->parameterResolver->generateMethodHelp($reflectionMethod, $commandName);
} catch (ReflectionException $e) {
return "Command: {$commandName}\nError generating help: {$e->getMessage()}";

View File

@@ -11,7 +11,7 @@ use App\Framework\Console\ExitCode;
use App\Framework\Console\Input\ConsoleInput;
use App\Framework\Console\Output\ConsoleOutput;
use App\Framework\Development\HotReload\HotReloadServer;
use App\Framework\Filesystem\FilePath;
use App\Framework\Filesystem\ValueObjects\FilePath;
use App\Framework\Filesystem\FileWatcher;
use App\Framework\Http\SseStream;
use Psr\Log\LoggerInterface;

View File

@@ -7,7 +7,10 @@ namespace App\Framework\Console\ErrorRecovery;
use App\Framework\Console\ConsoleColor;
use App\Framework\Console\ConsoleOutputInterface;
use App\Framework\Console\ExitCode;
use App\Framework\Exception\ErrorCode;
use App\Framework\Exception\Core\AuthErrorCode;
use App\Framework\Exception\Core\ConsoleErrorCode;
use App\Framework\Exception\Core\DatabaseErrorCode;
use App\Framework\Exception\Core\HttpErrorCode;
use App\Framework\Exception\FrameworkException;
use App\Framework\Logging\Logger;
use App\Framework\Logging\ValueObjects\LogContext;
@@ -115,21 +118,21 @@ final readonly class ConsoleErrorHandler
$errorCode = $exception->getErrorCode();
return match ($errorCode) {
ErrorCode::CON_COMMAND_NOT_FOUND => $this->handleCommandNotFound($command, $output),
ConsoleErrorCode::COMMAND_NOT_FOUND => $this->handleCommandNotFound($command, $output),
ErrorCode::CON_INVALID_ARGUMENTS => $this->handleValidationError(
ConsoleErrorCode::INVALID_ARGUMENT => $this->handleValidationError(
$command,
$exception->getMessage(),
$output
),
ErrorCode::AUTH_UNAUTHORIZED,
ErrorCode::AUTH_INSUFFICIENT_PRIVILEGES => $this->handlePermissionError($command, $output),
AuthErrorCode::UNAUTHORIZED,
AuthErrorCode::INSUFFICIENT_PRIVILEGES => $this->handlePermissionError($command, $output),
ErrorCode::DB_CONNECTION_FAILED,
ErrorCode::DB_QUERY_FAILED => $this->handleDatabaseError($command, $exception, $output),
DatabaseErrorCode::CONNECTION_FAILED,
DatabaseErrorCode::QUERY_FAILED => $this->handleDatabaseError($command, $exception, $output),
ErrorCode::HTTP_RATE_LIMIT_EXCEEDED => $this->handleRateLimitError($command, $exception, $output),
HttpErrorCode::RATE_LIMIT_EXCEEDED => $this->handleRateLimitError($command, $exception, $output),
default => $this->handleGeneralFrameworkError($command, $exception, $output)
};
@@ -188,13 +191,13 @@ final readonly class ConsoleErrorHandler
{
if ($error instanceof FrameworkException) {
return match ($error->getErrorCode()) {
ErrorCode::CON_COMMAND_NOT_FOUND => ExitCode::COMMAND_NOT_FOUND,
ErrorCode::CON_INVALID_ARGUMENTS => ExitCode::INVALID_INPUT,
ErrorCode::AUTH_UNAUTHORIZED,
ErrorCode::AUTH_INSUFFICIENT_PRIVILEGES => ExitCode::PERMISSION_DENIED,
ErrorCode::DB_CONNECTION_FAILED,
ErrorCode::DB_QUERY_FAILED => ExitCode::DATABASE_ERROR,
ErrorCode::HTTP_RATE_LIMIT_EXCEEDED => ExitCode::RATE_LIMITED,
ConsoleErrorCode::COMMAND_NOT_FOUND => ExitCode::COMMAND_NOT_FOUND,
ConsoleErrorCode::INVALID_ARGUMENT => ExitCode::INVALID_INPUT,
AuthErrorCode::UNAUTHORIZED,
AuthErrorCode::INSUFFICIENT_PRIVILEGES => ExitCode::PERMISSION_DENIED,
DatabaseErrorCode::CONNECTION_FAILED,
DatabaseErrorCode::QUERY_FAILED => ExitCode::DATABASE_ERROR,
HttpErrorCode::RATE_LIMIT_EXCEEDED => ExitCode::RATE_LIMITED,
default => ExitCode::GENERAL_ERROR
};
}

View File

@@ -8,7 +8,9 @@ use App\Framework\Console\CommandList;
use App\Framework\Console\CommandRegistry;
use App\Framework\Console\ConsoleColor;
use App\Framework\Console\ConsoleOutputInterface;
use App\Framework\Exception\ErrorCode;
use App\Framework\Exception\Core\ConsoleErrorCode;
use App\Framework\Exception\Core\DatabaseErrorCode;
use App\Framework\Exception\Core\SystemErrorCode;
use App\Framework\Exception\FrameworkException;
final readonly class ErrorRecoveryService
@@ -120,22 +122,22 @@ final readonly class ErrorRecoveryService
if ($error instanceof FrameworkException) {
$options = match ($error->getErrorCode()) {
ErrorCode::CON_COMMAND_NOT_FOUND => [
ConsoleErrorCode::COMMAND_NOT_FOUND => [
'Check if the command name is spelled correctly',
'Use "help" to see all available commands',
'Check if the command is properly registered',
],
ErrorCode::CON_INVALID_ARGUMENTS => [
ConsoleErrorCode::INVALID_ARGUMENT => [
'Check the command usage with "help {command}"',
'Verify all required arguments are provided',
'Check argument types and formats',
],
ErrorCode::DB_CONNECTION_FAILED => [
DatabaseErrorCode::CONNECTION_FAILED => [
'Check database connection settings',
'Verify database server is running',
'Check network connectivity to database',
],
ErrorCode::SYSTEM_DEPENDENCY_MISSING => [
SystemErrorCode::DEPENDENCY_MISSING => [
'Check if all required dependencies are installed',
'Verify service configuration',
'Check for missing environment variables',

View File

@@ -0,0 +1,258 @@
<?php
declare(strict_types=1);
namespace App\Framework\Console\Examples;
use App\Framework\Console\ConsoleCommand;
use App\Framework\Console\ConsoleInput;
use App\Framework\Console\ConsoleOutputInterface;
use App\Framework\Console\ExitCode;
use App\Framework\Core\ValueObjects\Email;
/**
* Best practices for Console Command parameters
*
* Shows recommended patterns and when to use different parameter styles.
*/
final readonly class ConsoleParameterBestPracticesCommand
{
/**
* ✅ RECOMMENDED: Typed parameters for known arguments
*
* Usage: php console.php best:typed username --max 20
*/
#[ConsoleCommand(
name: 'best:typed',
description: 'Typed parameters example'
)]
public function typedParameters(
ConsoleOutputInterface $output,
string $username,
int $max = 10
): ExitCode {
$output->writeLine("Processing user: {$username}");
$output->writeLine("Maximum results: {$max}");
return ExitCode::SUCCESS;
}
/**
* ✅ RECOMMENDED: Value Objects for validation
*
* Usage: php console.php best:value-objects user@example.com
*/
#[ConsoleCommand(
name: 'best:value-objects',
description: 'Value Objects for automatic validation'
)]
public function valueObjects(
ConsoleOutputInterface $output,
Email $email
): ExitCode {
// Email is already validated by Value Object!
$output->writeLine("Valid email: {$email->value}");
return ExitCode::SUCCESS;
}
/**
* ✅ RECOMMENDED: Nullable bool for optional flags
*
* Usage: php console.php best:flags production (verbose=null)
* php console.php best:flags production --verbose (verbose=true)
*/
#[ConsoleCommand(
name: 'best:flags',
description: 'Optional flags with nullable bool'
)]
public function withFlags(
ConsoleOutputInterface $output,
string $environment,
?bool $verbose = null,
?bool $force = null
): ExitCode {
$output->writeLine("Deploying to: {$environment}");
if ($verbose === true) {
$output->writeLine("Verbose mode enabled");
}
if ($force === true) {
$output->writeLine("Force mode enabled");
}
if ($verbose === null && $force === null) {
$output->writeLine("No flags provided");
}
return ExitCode::SUCCESS;
}
/**
* ✅ RECOMMENDED: Mix typed params with flags
*
* Usage: php console.php best:mixed Alice 25 --verified --active
*/
#[ConsoleCommand(
name: 'best:mixed',
description: 'Mix typed parameters with optional flags'
)]
public function mixedParametersAndFlags(
ConsoleOutputInterface $output,
string $name,
int $age,
?bool $verified = null,
?bool $active = null
): ExitCode {
$output->writeLine("Name: {$name}, Age: {$age}");
$status = [];
if ($verified === true) {
$status[] = 'verified';
}
if ($active === true) {
$status[] = 'active';
}
if (!empty($status)) {
$output->writeLine("Status: " . implode(', ', $status));
}
return ExitCode::SUCCESS;
}
/**
* ✅ RECOMMENDED: No framework params needed
*
* Usage: php console.php best:minimal Alice
*/
#[ConsoleCommand(
name: 'best:minimal',
description: 'Minimal command without ConsoleOutput'
)]
public function minimalCommand(string $name): ExitCode
{
// Silent processing - no output needed
file_put_contents('/tmp/user.txt', $name);
return ExitCode::SUCCESS;
}
/**
* ⚠️ USE SPARINGLY: ConsoleInput for variable args
*
* Only use ConsoleInput when you need:
* - Variable number of arguments
* - Raw command line access
* - Dynamic argument handling
*
* Usage: php console.php best:variable file1.txt file2.txt file3.txt
*/
#[ConsoleCommand(
name: 'best:variable',
description: 'Variable arguments with ConsoleInput'
)]
public function variableArguments(
ConsoleInput $input,
ConsoleOutputInterface $output
): ExitCode {
$files = $input->getArguments();
if (empty($files)) {
$output->error('No files provided');
return ExitCode::FAILURE;
}
foreach ($files as $file) {
$output->writeLine("Processing: {$file}");
}
return ExitCode::SUCCESS;
}
/**
* ❌ AVOID: Manual parsing with ConsoleInput
*
* This is the OLD style - avoid this!
*/
#[ConsoleCommand(
name: 'best:old-style',
description: 'Old style (avoid this!)'
)]
public function oldStyleAvoid(ConsoleInput $input, ConsoleOutputInterface $output): ExitCode
{
// ❌ Manual parsing - no type safety
$name = $input->getArgument('name');
$age = (int) ($input->getArgument('age') ?? 18);
$output->writeLine("Name: {$name}, Age: {$age}");
return ExitCode::SUCCESS;
}
/**
* ✅ BETTER: Typed parameters instead
*
* Prefer this over manual ConsoleInput parsing
*/
#[ConsoleCommand(
name: 'best:new-style',
description: 'New style with typed parameters'
)]
public function newStylePrefer(
ConsoleOutputInterface $output,
string $name,
int $age = 18
): ExitCode {
// ✅ Already typed and validated
$output->writeLine("Name: {$name}, Age: {$age}");
return ExitCode::SUCCESS;
}
/**
* ✅ RECOMMENDED: Complex flags example
*
* Shows different flag patterns
*
* Usage: php console.php best:complex production --dry-run --skip-backup
*/
#[ConsoleCommand(
name: 'best:complex',
description: 'Complex deployment with multiple flags'
)]
public function complexFlags(
ConsoleOutputInterface $output,
string $environment,
?bool $dryRun = null,
?bool $skipBackup = null,
?bool $skipTests = null,
?bool $force = null
): ExitCode {
$output->writeLine("Deploying to: {$environment}");
// Check flags
if ($dryRun === true) {
$output->writeLine("DRY RUN MODE - No actual changes");
}
if ($skipBackup === true) {
$output->writeLine("Skipping backup");
} else {
$output->writeLine("Creating backup...");
}
if ($skipTests === true) {
$output->writeLine("Skipping tests");
} else {
$output->writeLine("Running tests...");
}
if ($force === true && $environment === 'production') {
$output->warning("Force deploying to PRODUCTION!");
}
return ExitCode::SUCCESS;
}
}

View File

@@ -0,0 +1,170 @@
<?php
declare(strict_types=1);
namespace App\Framework\Console\Examples;
use App\Framework\Console\ConsoleCommand;
use App\Framework\Console\ConsoleInput;
use App\Framework\Console\ConsoleOutputInterface;
use App\Framework\Console\ExitCode;
/**
* Demo command showing flexible parameter support
*
* This command demonstrates that ConsoleInput and ConsoleOutput
* parameters are now optional and can be mixed with other parameters
*/
final readonly class FlexibleParameterDemoCommand
{
/**
* Command without any parameters
*/
#[ConsoleCommand(
name: 'demo:no-params',
description: 'Demo command without any parameters'
)]
public function withoutParameters(): ExitCode
{
echo "This command has no parameters!\n";
return ExitCode::SUCCESS;
}
/**
* Command with only user parameters (no framework types)
*/
#[ConsoleCommand(
name: 'demo:user-params',
description: 'Demo command with only user parameters'
)]
public function withUserParameters(string $name, int $age = 18): ExitCode
{
echo "Name: {$name}\n";
echo "Age: {$age}\n";
return ExitCode::SUCCESS;
}
/**
* Command with ConsoleOutput only
*/
#[ConsoleCommand(
name: 'demo:with-output',
description: 'Demo command with ConsoleOutput parameter'
)]
public function withOutput(ConsoleOutputInterface $output, string $message = 'Hello World'): ExitCode
{
$output->writeLine("Message from ConsoleOutput: {$message}");
return ExitCode::SUCCESS;
}
/**
* Command with ConsoleInput only
*/
#[ConsoleCommand(
name: 'demo:with-input',
description: 'Demo command with ConsoleInput parameter'
)]
public function withInput(ConsoleInput $input): ExitCode
{
$args = $input->getArguments();
echo "Arguments: " . json_encode($args) . "\n";
return ExitCode::SUCCESS;
}
/**
* Command with both ConsoleInput and ConsoleOutput
*/
#[ConsoleCommand(
name: 'demo:with-both',
description: 'Demo command with both ConsoleInput and ConsoleOutput'
)]
public function withBoth(ConsoleInput $input, ConsoleOutputInterface $output): ExitCode
{
$output->writeLine("Input arguments: " . json_encode($input->getArguments()));
return ExitCode::SUCCESS;
}
/**
* Command mixing framework and user parameters (Output first)
*/
#[ConsoleCommand(
name: 'demo:mixed-output-first',
description: 'Demo command with mixed parameters (output first)'
)]
public function mixedOutputFirst(
ConsoleOutputInterface $output,
string $name,
int $count = 3
): ExitCode {
$output->writeLine("Name: {$name}");
$output->writeLine("Count: {$count}");
return ExitCode::SUCCESS;
}
/**
* Command mixing framework and user parameters (Output in middle)
*/
#[ConsoleCommand(
name: 'demo:mixed-output-middle',
description: 'Demo command with mixed parameters (output in middle)'
)]
public function mixedOutputMiddle(
string $name,
ConsoleOutputInterface $output,
int $count = 3
): ExitCode {
$output->writeLine("Name: {$name}");
$output->writeLine("Count: {$count}");
return ExitCode::SUCCESS;
}
/**
* Command mixing framework and user parameters (Output last)
*/
#[ConsoleCommand(
name: 'demo:mixed-output-last',
description: 'Demo command with mixed parameters (output last)'
)]
public function mixedOutputLast(
string $name,
int $count,
ConsoleOutputInterface $output
): ExitCode {
$output->writeLine("Name: {$name}");
$output->writeLine("Count: {$count}");
return ExitCode::SUCCESS;
}
/**
* Command with all types mixed
*/
#[ConsoleCommand(
name: 'demo:all-mixed',
description: 'Demo command with all parameter types mixed'
)]
public function allMixed(
ConsoleInput $input,
string $operation,
ConsoleOutputInterface $output,
int $iterations = 1,
bool $verbose = false
): ExitCode {
$output->writeLine("Operation: {$operation}");
$output->writeLine("Iterations: {$iterations}");
$output->writeLine("Verbose: " . ($verbose ? 'true' : 'false'));
if ($verbose) {
$output->writeLine("Raw arguments: " . json_encode($input->getArguments()));
}
return ExitCode::SUCCESS;
}
}

View File

@@ -16,6 +16,7 @@ final readonly class MethodSignatureAnalyzer
{
/**
* Generate ArgumentDefinitions from method parameters
* Excludes framework-provided parameters (ConsoleInput, ConsoleOutput)
*
* @return ArgumentDefinition[]
*/
@@ -24,6 +25,11 @@ final readonly class MethodSignatureAnalyzer
$definitions = [];
foreach ($method->getParameters() as $param) {
// Skip framework-provided parameters
if ($this->isFrameworkProvidedParameter($param)) {
continue;
}
$definition = $this->createDefinitionFromParameter($param);
$definitions[$param->getName()] = $definition;
}
@@ -31,6 +37,33 @@ final readonly class MethodSignatureAnalyzer
return $definitions;
}
/**
* Check if parameter is framework-provided (ConsoleInput, ConsoleOutput)
*/
private function isFrameworkProvidedParameter(ReflectionParameter $param): bool
{
$type = $param->getType();
if (! ($type instanceof ReflectionNamedType)) {
return false;
}
$typeName = $type->getName();
$frameworkTypes = [
'App\\Framework\\Console\\ConsoleInput',
'App\\Framework\\Console\\ConsoleInputInterface',
'App\\Framework\\Console\\ConsoleOutputInterface',
'App\\Framework\\Console\\ConsoleOutput',
'ConsoleInput',
'ConsoleInputInterface',
'ConsoleOutputInterface',
'ConsoleOutput',
];
return in_array($typeName, $frameworkTypes, true);
}
/**
* Create ArgumentDefinition from ReflectionParameter
*/
@@ -112,6 +145,11 @@ final readonly class MethodSignatureAnalyzer
}
if ($type instanceof ReflectionUnionType) {
// Special case: ?bool (nullable bool) = flag
if ($this->isNullableBool($type)) {
return ArgumentType::BOOLEAN;
}
// For union types, try to find the most specific type
foreach ($type->getTypes() as $unionType) {
if ($unionType instanceof ReflectionNamedType && ! $unionType->isBuiltin()) {
@@ -131,6 +169,36 @@ final readonly class MethodSignatureAnalyzer
return ArgumentType::STRING;
}
/**
* Check if type is nullable bool (?bool)
*/
private function isNullableBool(ReflectionUnionType $type): bool
{
$types = $type->getTypes();
if (count($types) !== 2) {
return false;
}
$hasNull = false;
$hasBool = false;
foreach ($types as $unionType) {
if (!($unionType instanceof ReflectionNamedType)) {
return false;
}
$name = strtolower($unionType->getName());
if ($name === 'null') {
$hasNull = true;
} elseif ($name === 'bool' || $name === 'boolean') {
$hasBool = true;
}
}
return $hasNull && $hasBool;
}
/**
* Map ReflectionNamedType to ArgumentType
*/
@@ -289,7 +357,12 @@ final readonly class MethodSignatureAnalyzer
}
if ($type instanceof ReflectionUnionType) {
// Union types are complex, be conservative
// ?bool (nullable bool) is allowed for flags
if ($this->isNullableBool($type)) {
return true;
}
// Other union types are complex, be conservative
return false;
}
@@ -302,7 +375,7 @@ final readonly class MethodSignatureAnalyzer
return in_array(strtolower($typeName), $allowedTypes, true) ||
$this->isFrameworkValueObject($typeName) ||
$this->isConsoleType($typeName);
$this->isFrameworkProvidedParameter($param);
}
return false;
@@ -318,21 +391,4 @@ final readonly class MethodSignatureAnalyzer
str_contains($className, 'Url') ||
str_contains($className, 'ValueObject');
}
/**
* Check if type is a console-related type
*/
private function isConsoleType(string $className): bool
{
$consoleTypes = [
'App\\Framework\\Console\\ConsoleInput',
'App\\Framework\\Console\\ConsoleOutputInterface',
'App\\Framework\\Console\\ConsoleOutput',
'ConsoleInput',
'ConsoleOutputInterface',
'ConsoleOutput',
];
return in_array($className, $consoleTypes, true);
}
}

View File

@@ -5,7 +5,8 @@ declare(strict_types=1);
namespace App\Framework\Console;
use App\Framework\Core\ValueObjects\Email;
use App\Framework\Core\ValueObjects\Url;
use App\Framework\Http\Url\Url;
use App\Framework\Http\Url\UrlFactory;
/**
* Container for parsed and validated console arguments
@@ -114,7 +115,7 @@ final readonly class ParsedArguments
*/
public function getUrl(string $name): Url
{
return new Url($this->getString($name));
return UrlFactory::parse($this->getString($name));
}
/**

View File

@@ -0,0 +1,270 @@
<?php
declare(strict_types=1);
namespace App\Framework\Console\Security\Commands;
use App\Framework\Console\ConsoleCommand;
use App\Framework\Console\ExitCode;
use App\Framework\Console\Input\ConsoleInput;
use App\Framework\Console\Layout\ResponsiveOutput;
use App\Framework\Console\Output\ConsoleOutput;
final readonly class DependencyAuditCommand
{
#[ConsoleCommand(
name: 'security:audit',
description: 'Audit dependencies for known security vulnerabilities'
)]
public function audit(ConsoleInput $input, ConsoleOutput $output): ExitCode
{
$format = $input->getOption('format') ?? 'table';
$noDevDeps = $input->hasOption('no-dev');
$failOnVulnerabilities = $input->hasOption('fail-on-vulnerabilities');
$output->writeLine("<yellow>Running Security Audit...</yellow>\n");
// Build composer audit command
$command = 'composer audit --format=' . escapeshellarg($format);
if ($noDevDeps) {
$command .= ' --no-dev';
}
// Execute composer audit
$startTime = microtime(true);
exec($command . ' 2>&1', $outputLines, $returnCode);
$duration = microtime(true) - $startTime;
$outputText = implode("\n", $outputLines);
// Parse JSON output if format is JSON
if ($format === 'json') {
$auditData = json_decode($outputText, true);
if (json_last_error() === JSON_ERROR_NONE) {
$this->displayJsonResults($auditData, $output);
return $this->determineExitCode(
$auditData,
$failOnVulnerabilities
);
}
}
// Display raw output for non-JSON formats
$output->writeLine($outputText);
$output->writeLine("\n<gray>Audit completed in " . number_format($duration, 2) . "s</gray>");
// Return appropriate exit code
if ($returnCode === 0) {
$output->writeLine("\n<green>✓ No security vulnerabilities found!</green>");
return ExitCode::SUCCESS;
}
$output->writeLine("\n<red>✗ Security vulnerabilities detected!</red>");
return $failOnVulnerabilities ? ExitCode::FAILURE : ExitCode::SUCCESS;
}
#[ConsoleCommand(
name: 'security:audit-summary',
description: 'Show summary of dependency security audit'
)]
public function auditSummary(ConsoleInput $input, ConsoleOutput $output): ExitCode
{
$output->writeLine("<yellow>Security Audit Summary</yellow>\n");
// Execute composer audit with JSON format
exec('composer audit --format=json 2>&1', $outputLines, $returnCode);
$auditData = json_decode(implode("\n", $outputLines), true);
if (json_last_error() !== JSON_ERROR_NONE) {
$output->writeLine('<red>Failed to parse audit results</red>');
return ExitCode::FAILURE;
}
$responsiveOutput = ResponsiveOutput::create($output);
// Count vulnerabilities by severity
$severityCounts = [
'critical' => 0,
'high' => 0,
'medium' => 0,
'low' => 0,
];
$totalVulnerabilities = 0;
$affectedPackages = [];
foreach ($auditData['advisories'] ?? [] as $packageName => $advisories) {
$affectedPackages[] = $packageName;
foreach ($advisories as $advisory) {
$severity = strtolower($advisory['severity'] ?? 'unknown');
if (isset($severityCounts[$severity])) {
$severityCounts[$severity]++;
}
$totalVulnerabilities++;
}
}
// Display summary
$summary = [
'Total Vulnerabilities' => $totalVulnerabilities,
'Critical' => "<red>{$severityCounts['critical']}</red>",
'High' => "<yellow>{$severityCounts['high']}</yellow>",
'Medium' => "<cyan>{$severityCounts['medium']}</cyan>",
'Low' => "<gray>{$severityCounts['low']}</gray>",
'Affected Packages' => count($affectedPackages),
'Abandoned Packages' => count($auditData['abandoned'] ?? []),
];
$responsiveOutput->writeKeyValue($summary);
if ($totalVulnerabilities > 0) {
$output->writeLine("\n<red>⚠ Security vulnerabilities detected!</red>");
$output->writeLine("Run <cyan>php console.php security:audit</cyan> for details");
} else {
$output->writeLine("\n<green>✓ No security vulnerabilities found!</green>");
}
if (!empty($auditData['abandoned'])) {
$output->writeLine("\n<yellow>Abandoned Packages:</yellow>");
foreach ($auditData['abandoned'] as $package => $replacement) {
$replacementText = $replacement ? " (use {$replacement} instead)" : '';
$output->writeLine("{$package}{$replacementText}");
}
}
return $totalVulnerabilities > 0 ? ExitCode::FAILURE : ExitCode::SUCCESS;
}
#[ConsoleCommand(
name: 'security:audit-details',
description: 'Show detailed information about a specific package vulnerability'
)]
public function auditDetails(ConsoleInput $input, ConsoleOutput $output): ExitCode
{
$packageName = $input->getArgument('package');
if (!$packageName) {
$output->writeLine('<red>Package name is required</red>');
$output->writeLine('Usage: php console.php security:audit-details <package>');
return ExitCode::INVALID_ARGUMENTS;
}
// Execute composer audit with JSON format
exec('composer audit --format=json 2>&1', $outputLines);
$auditData = json_decode(implode("\n", $outputLines), true);
if (json_last_error() !== JSON_ERROR_NONE) {
$output->writeLine('<red>Failed to parse audit results</red>');
return ExitCode::FAILURE;
}
// Find package advisories
$advisories = $auditData['advisories'][$packageName] ?? null;
if (!$advisories) {
$output->writeLine("<yellow>No vulnerabilities found for package: {$packageName}</yellow>");
return ExitCode::SUCCESS;
}
$output->writeLine("<yellow>Vulnerabilities for {$packageName}</yellow>\n");
foreach ($advisories as $advisory) {
$this->displayAdvisoryDetails($advisory, $output);
$output->writeLine(str_repeat('─', 80));
}
return ExitCode::SUCCESS;
}
private function displayJsonResults(array $auditData, ConsoleOutput $output): void
{
$advisories = $auditData['advisories'] ?? [];
$abandoned = $auditData['abandoned'] ?? [];
if (empty($advisories) && empty($abandoned)) {
$output->writeLine('<green>✓ No security issues found!</green>');
return;
}
if (!empty($advisories)) {
$output->writeLine("<red>Security Vulnerabilities:</red>\n");
foreach ($advisories as $packageName => $packageAdvisories) {
$output->writeLine("<yellow>{$packageName}</yellow>");
foreach ($packageAdvisories as $advisory) {
$severity = $advisory['severity'] ?? 'unknown';
$severityColor = $this->getSeverityColor($severity);
$cve = $advisory['cve'] ?? 'N/A';
$title = $advisory['title'] ?? 'No title';
$output->writeLine(" • [{$severityColor}{$severity}</color>] {$cve}: {$title}");
if (!empty($advisory['link'])) {
$output->writeLine(" Link: <cyan>{$advisory['link']}</cyan>");
}
}
$output->writeLine('');
}
}
if (!empty($abandoned)) {
$output->writeLine("<yellow>Abandoned Packages:</yellow>\n");
foreach ($abandoned as $package => $replacement) {
$replacementText = $replacement ? " (use {$replacement})" : '';
$output->writeLine("{$package}{$replacementText}");
}
}
}
private function displayAdvisoryDetails(array $advisory, ConsoleOutput $output): void
{
$responsiveOutput = ResponsiveOutput::create($output);
$details = [
'Title' => $advisory['title'] ?? 'Unknown',
'CVE' => $advisory['cve'] ?? 'N/A',
'Severity' => $this->formatSeverity($advisory['severity'] ?? 'unknown'),
'Affected Versions' => $advisory['affectedVersions'] ?? 'N/A',
'Source' => $advisory['sources'][0]['remoteId'] ?? 'N/A',
];
$responsiveOutput->writeKeyValue($details);
if (!empty($advisory['link'])) {
$output->writeLine("\n<cyan>More Info:</cyan> {$advisory['link']}");
}
$output->writeLine('');
}
private function getSeverityColor(string $severity): string
{
return match (strtolower($severity)) {
'critical' => '<red>',
'high' => '<yellow>',
'medium' => '<cyan>',
'low' => '<gray>',
default => '<white>',
};
}
private function formatSeverity(string $severity): string
{
$color = $this->getSeverityColor($severity);
return "{$color}" . strtoupper($severity) . '</color>';
}
private function determineExitCode(array $auditData, bool $failOnVulnerabilities): ExitCode
{
$hasVulnerabilities = !empty($auditData['advisories']);
if ($hasVulnerabilities && $failOnVulnerabilities) {
return ExitCode::FAILURE;
}
return ExitCode::SUCCESS;
}
}