docs: consolidate documentation into organized structure

- Move 12 markdown files from root to docs/ subdirectories
- Organize documentation by category:
  • docs/troubleshooting/ (1 file)  - Technical troubleshooting guides
  • docs/deployment/      (4 files) - Deployment and security documentation
  • docs/guides/          (3 files) - Feature-specific guides
  • docs/planning/        (4 files) - Planning and improvement proposals

Root directory cleanup:
- Reduced from 16 to 4 markdown files in root
- Only essential project files remain:
  • CLAUDE.md (AI instructions)
  • README.md (Main project readme)
  • CLEANUP_PLAN.md (Current cleanup plan)
  • SRC_STRUCTURE_IMPROVEMENTS.md (Structure improvements)

This improves:
 Documentation discoverability
 Logical organization by purpose
 Clean root directory
 Better maintainability
This commit is contained in:
2025-10-05 11:05:04 +02:00
parent 887847dde6
commit 5050c7d73a
36686 changed files with 196456 additions and 12398919 deletions

View File

@@ -0,0 +1,48 @@
<?php
declare(strict_types=1);
namespace App\Framework\Console\Analytics;
use App\Framework\Console\Analytics\Middleware\AnalyticsCollectionMiddleware;
use App\Framework\Console\Analytics\Repository\CommandUsageRepository;
use App\Framework\Console\Analytics\Repository\DatabaseCommandUsageRepository;
use App\Framework\DI\Attributes\Initializer;
use App\Framework\DI\Container;
use App\Framework\Environment\Environment;
use App\Framework\Environment\EnvKey;
final readonly class AnalyticsInitializer
{
public function __construct(
private Environment $environment
) {
}
#[Initializer]
public function initialize(Container $container): void
{
// Register repository
$container->bind(
CommandUsageRepository::class,
DatabaseCommandUsageRepository::class
);
// Register analytics service
$container->singleton(AnalyticsService::class, function (Container $container) {
return new AnalyticsService(
$container->get(CommandUsageRepository::class)
);
});
// Register analytics collection middleware
$container->singleton(AnalyticsCollectionMiddleware::class, function (Container $container) {
$enabled = $this->environment->getBool(EnvKey::CONSOLE_ANALYTICS_ENABLED, true);
return new AnalyticsCollectionMiddleware(
repository: $container->get(CommandUsageRepository::class),
enabled: $enabled
);
});
}
}

View File

@@ -0,0 +1,153 @@
<?php
declare(strict_types=1);
namespace App\Framework\Console\Analytics;
use App\Framework\Console\Analytics\Repository\CommandUsageRepository;
use App\Framework\Console\Analytics\ValueObjects\Period;
use App\Framework\Console\Analytics\ValueObjects\UsageStatistics;
use DateTimeImmutable;
final readonly class AnalyticsService
{
public function __construct(
private CommandUsageRepository $repository
) {
}
public function getCommandStatistics(string $commandName, ?DateTimeImmutable $since = null): UsageStatistics
{
return $this->repository->getUsageStatistics($commandName, $since);
}
public function getPopularCommands(int $limit = 10, ?DateTimeImmutable $since = null): array
{
return $this->repository->getPopularCommands($limit, $since);
}
public function getCommandTrends(
string $commandName,
Period $period = Period::DAILY,
?DateTimeImmutable $start = null,
?DateTimeImmutable $end = null
): array {
$start ??= new DateTimeImmutable('-30 days');
$end ??= new DateTimeImmutable();
return $this->repository->getTrendData($commandName, $period, $start, $end);
}
public function getAllCommandNames(): array
{
return $this->repository->getAllCommandNames();
}
public function cleanupOldData(int $daysToKeep = 90): int
{
$cutoffDate = new DateTimeImmutable("-{$daysToKeep} days");
return $this->repository->cleanup($cutoffDate);
}
public function getOverallStatistics(?DateTimeImmutable $since = null): array
{
$commands = $this->getAllCommandNames();
$statistics = [];
foreach ($commands as $commandName) {
$statistics[$commandName] = $this->getCommandStatistics($commandName, $since);
}
return $statistics;
}
public function getMostFailingCommands(int $limit = 10, ?DateTimeImmutable $since = null): array
{
$commands = $this->getAllCommandNames();
$failingCommands = [];
foreach ($commands as $commandName) {
$stats = $this->getCommandStatistics($commandName, $since);
if ($stats->totalExecutions > 0) {
$failingCommands[] = [
'command_name' => $commandName,
'failure_rate' => $stats->getFailureRate(),
'total_executions' => $stats->totalExecutions,
'failed_executions' => $stats->failedExecutions,
];
}
}
// Sort by failure rate descending
usort($failingCommands, function ($a, $b) {
return $b['failure_rate']->getValue() <=> $a['failure_rate']->getValue();
});
return array_slice($failingCommands, 0, $limit);
}
public function getSlowestCommands(int $limit = 10, ?DateTimeImmutable $since = null): array
{
$commands = $this->getAllCommandNames();
$slowCommands = [];
foreach ($commands as $commandName) {
$stats = $this->getCommandStatistics($commandName, $since);
if ($stats->totalExecutions > 0) {
$slowCommands[] = [
'command_name' => $commandName,
'average_execution_time' => $stats->averageExecutionTime,
'max_execution_time' => $stats->maxExecutionTime,
'total_executions' => $stats->totalExecutions,
];
}
}
// Sort by average execution time descending
usort($slowCommands, function ($a, $b) {
return $b['average_execution_time']->toMilliseconds() <=>
$a['average_execution_time']->toMilliseconds();
});
return array_slice($slowCommands, 0, $limit);
}
public function getUsageByHour(?DateTimeImmutable $since = null): array
{
$commands = $this->getAllCommandNames();
$hourlyUsage = array_fill(0, 24, 0);
foreach ($commands as $commandName) {
$stats = $this->getCommandStatistics($commandName, $since);
if ($stats->hourlyDistribution) {
foreach ($stats->hourlyDistribution as $hour => $count) {
$hourlyUsage[$hour] += $count;
}
}
}
return $hourlyUsage;
}
public function getCommandHealthScore(string $commandName, ?DateTimeImmutable $since = null): float
{
$stats = $this->getCommandStatistics($commandName, $since);
if ($stats->totalExecutions === 0) {
return 0.0;
}
// Health score based on success rate (70%) and performance (30%)
$successScore = $stats->getSuccessRate()->getValue() / 100;
// Performance score: inverse of execution time (normalized)
$avgTimeMs = $stats->averageExecutionTime->toMilliseconds();
$performanceScore = $avgTimeMs > 0 ? min(1.0, 1000 / $avgTimeMs) : 1.0;
return ($successScore * 0.7) + ($performanceScore * 0.3);
}
}

View File

@@ -0,0 +1,324 @@
<?php
declare(strict_types=1);
namespace App\Framework\Console\Analytics\Commands;
use App\Framework\Console\Analytics\AnalyticsService;
use App\Framework\Console\Analytics\ValueObjects\Period;
use App\Framework\Console\Attributes\ConsoleCommand;
use App\Framework\Console\ExitCode;
use App\Framework\Console\Input\ConsoleInput;
use App\Framework\Console\Layout\ResponsiveOutput;
use App\Framework\Console\Output\ConsoleOutput;
use DateTimeImmutable;
final readonly class AnalyticsCommand
{
public function __construct(
private AnalyticsService $analyticsService
) {
}
#[ConsoleCommand(
name: 'analytics:usage',
description: 'Show command usage analytics and statistics'
)]
public function showUsage(ConsoleInput $input, ConsoleOutput $output): ExitCode
{
$commandName = $input->getArgument('command');
$days = (int) ($input->getOption('days') ?? 30);
$since = new DateTimeImmutable("-{$days} days");
if ($commandName) {
return $this->showCommandUsage($commandName, $since, $output);
}
return $this->showOverallUsage($since, $output);
}
#[ConsoleCommand(
name: 'analytics:popular',
description: 'Show most popular commands'
)]
public function showPopularCommands(ConsoleInput $input, ConsoleOutput $output): ExitCode
{
$limit = (int) ($input->getOption('limit') ?? 10);
$days = (int) ($input->getOption('days') ?? 30);
$since = new DateTimeImmutable("-{$days} days");
$popularCommands = $this->analyticsService->getPopularCommands($limit, $since);
if (empty($popularCommands)) {
$output->writeLine('<red>No command usage data found.</red>');
return ExitCode::SUCCESS;
}
$responsiveOutput = ResponsiveOutput::create($output);
$output->writeLine("<yellow>Most Popular Commands (Last {$days} days)</yellow>\n");
// Prepare table data
$headers = ['Rank', 'Command', 'Executions', 'Usage %', 'Avg Time', 'Success Rate'];
$rows = [];
foreach ($popularCommands as $command) {
$rows[] = [
(string) $command->rank,
$command->commandName,
(string) $command->totalExecutions,
$command->usagePercentage->format(1),
$command->averageExecutionTime->toHumanReadable(),
$command->successRate->format(1),
];
}
$responsiveOutput->writeTable($headers, $rows);
return ExitCode::SUCCESS;
}
#[ConsoleCommand(
name: 'analytics:trends',
description: 'Show command usage trends over time'
)]
public function showTrends(ConsoleInput $input, ConsoleOutput $output): ExitCode
{
$commandName = $input->getArgument('command');
if (! $commandName) {
$output->writeLine('<red>Command name is required for trend analysis.</red>');
return ExitCode::INVALID_ARGUMENTS;
}
$periodStr = $input->getOption('period') ?? 'daily';
$period = match (strtolower($periodStr)) {
'hourly' => Period::HOURLY,
'daily' => Period::DAILY,
'weekly' => Period::WEEKLY,
'monthly' => Period::MONTHLY,
'yearly' => Period::YEARLY,
default => Period::DAILY
};
$days = (int) ($input->getOption('days') ?? 30);
$start = new DateTimeImmutable("-{$days} days");
$end = new DateTimeImmutable();
$trends = $this->analyticsService->getCommandTrends($commandName, $period, $start, $end);
if (empty($trends)) {
$output->writeLine('<red>No trend data found for this command.</red>');
return ExitCode::SUCCESS;
}
$responsiveOutput = ResponsiveOutput::create($output);
$trend = $trends[0];
$output->writeLine("<yellow>Usage Trends for '{$commandName}' ({$period->getDescription()})</yellow>\n");
// Show trend summary
$summary = [
'Period' => "{$trend->startDate->format('Y-m-d')} to {$trend->endDate->format('Y-m-d')}",
'Trend' => $trend->getTrendDescription(),
'Direction' => ($trend->trendDirection > 0 ? '+' : '') . round($trend->trendDirection, 3),
'Strength' => round($trend->trendStrength, 3),
];
$responsiveOutput->writeKeyValue($summary);
$output->writeLine('');
// Show trend data points as responsive table
$headers = ['Date', 'Executions', 'Avg Time', 'Success Rate'];
$rows = [];
foreach ($trend->dataPoints as $point) {
$rows[] = [
$point->date->format($period->getDateFormat()),
(string) $point->executionCount,
$point->averageExecutionTime->toHumanReadable(),
$point->successRate->format(1),
];
}
$responsiveOutput->writeTable($headers, $rows);
return ExitCode::SUCCESS;
}
#[ConsoleCommand(
name: 'analytics:health',
description: 'Show command health scores and problematic commands'
)]
public function showHealth(ConsoleInput $input, ConsoleOutput $output): ExitCode
{
$days = (int) ($input->getOption('days') ?? 30);
$since = new DateTimeImmutable("-{$days} days");
$output->writeLine("<yellow>Command Health Analysis (Last {$days} days)</yellow>\n");
// Show failing commands
$failingCommands = $this->analyticsService->getMostFailingCommands(10, $since);
if (! empty($failingCommands)) {
$output->writeLine("<red>Commands with Highest Failure Rates:</red>");
$output->writeLine(sprintf(
"%-25s %-12s %-12s %s",
'Command',
'Failure Rate',
'Total Runs',
'Failures'
));
$output->writeLine(str_repeat('-', 70));
foreach ($failingCommands as $command) {
$output->writeLine(sprintf(
"%-25s %-12s %-12d %d",
$command['command_name'],
$command['failure_rate']->format(1),
$command['total_executions'],
$command['failed_executions']
));
}
$output->writeLine('');
}
// Show slow commands
$slowCommands = $this->analyticsService->getSlowestCommands(10, $since);
if (! empty($slowCommands)) {
$output->writeLine("<yellow>Slowest Commands:</yellow>");
$output->writeLine(sprintf(
"%-25s %-15s %-15s %s",
'Command',
'Avg Time',
'Max Time',
'Total Runs'
));
$output->writeLine(str_repeat('-', 80));
foreach ($slowCommands as $command) {
$output->writeLine(sprintf(
"%-25s %-15s %-15s %d",
$command['command_name'],
$command['average_execution_time']->toHumanReadable(),
$command['max_execution_time']->toHumanReadable(),
$command['total_executions']
));
}
$output->writeLine('');
}
return ExitCode::SUCCESS;
}
#[ConsoleCommand(
name: 'analytics:cleanup',
description: 'Clean up old analytics data'
)]
public function cleanup(ConsoleInput $input, ConsoleOutput $output): ExitCode
{
$days = (int) ($input->getOption('days') ?? 90);
$output->writeLine("<yellow>Cleaning up analytics data older than {$days} days...</yellow>");
$deletedRecords = $this->analyticsService->cleanupOldData($days);
$output->writeLine("<green>Successfully deleted {$deletedRecords} old analytics records.</green>");
return ExitCode::SUCCESS;
}
private function showCommandUsage(string $commandName, DateTimeImmutable $since, ConsoleOutput $output): ExitCode
{
$stats = $this->analyticsService->getCommandStatistics($commandName, $since);
$output->writeLine("<yellow>Usage Statistics for '{$commandName}'</yellow>\n");
if ($stats->totalExecutions === 0) {
$output->writeLine('<red>No usage data found for this command.</red>');
return ExitCode::SUCCESS;
}
$output->writeLine("Total Executions: {$stats->totalExecutions}");
$output->writeLine("Successful: {$stats->successfulExecutions} ({$stats->getSuccessRate()->format(1)})");
$output->writeLine("Failed: {$stats->failedExecutions} ({$stats->getFailureRate()->format(1)})");
$output->writeLine('');
$output->writeLine("Performance:");
$output->writeLine(" Average Time: {$stats->averageExecutionTime->toHumanReadable()}");
$output->writeLine(" Min Time: {$stats->minExecutionTime->toHumanReadable()}");
$output->writeLine(" Max Time: {$stats->maxExecutionTime->toHumanReadable()}");
if ($stats->medianExecutionTime) {
$output->writeLine(" Median Time: {$stats->medianExecutionTime->toHumanReadable()}");
}
// Show hourly distribution
if ($stats->hourlyDistribution) {
$output->writeLine('');
$output->writeLine("Usage by Hour:");
$maxUsage = max($stats->hourlyDistribution);
for ($hour = 0; $hour < 24; $hour++) {
$usage = $stats->hourlyDistribution[$hour];
$percentage = $maxUsage > 0 ? ($usage / $maxUsage) * 100 : 0;
$bar = str_repeat('▓', (int) ($percentage / 5));
$output->writeLine(sprintf(
"%02d:00 %4d %s",
$hour,
$usage,
$bar
));
}
}
return ExitCode::SUCCESS;
}
private function showOverallUsage(DateTimeImmutable $since, ConsoleOutput $output): ExitCode
{
$commands = $this->analyticsService->getAllCommandNames();
$output->writeLine("<yellow>Overall Command Usage Statistics</yellow>\n");
if (empty($commands)) {
$output->writeLine('<red>No command usage data found.</red>');
return ExitCode::SUCCESS;
}
$totalExecutions = 0;
$totalCommands = count($commands);
foreach ($commands as $commandName) {
$stats = $this->analyticsService->getCommandStatistics($commandName, $since);
$totalExecutions += $stats->totalExecutions;
}
$output->writeLine("Total Commands: {$totalCommands}");
$output->writeLine("Total Executions: {$totalExecutions}");
$output->writeLine("Average per Command: " . round($totalExecutions / $totalCommands, 2));
$output->writeLine('');
// Show usage by hour
$hourlyUsage = $this->analyticsService->getUsageByHour($since);
$output->writeLine("Usage Distribution by Hour:");
$maxHourly = max($hourlyUsage);
for ($hour = 0; $hour < 24; $hour++) {
$usage = $hourlyUsage[$hour];
$percentage = $maxHourly > 0 ? ($usage / $maxHourly) * 100 : 0;
$bar = str_repeat('▓', (int) ($percentage / 5));
$output->writeLine(sprintf(
"%02d:00 %4d %s",
$hour,
$usage,
$bar
));
}
return ExitCode::SUCCESS;
}
}

View File

@@ -0,0 +1,120 @@
<?php
declare(strict_types=1);
namespace App\Framework\Console\Analytics\Middleware;
use App\Framework\Console\Analytics\Repository\CommandUsageRepository;
use App\Framework\Console\Analytics\ValueObjects\CommandUsageMetric;
use App\Framework\Console\ExitCode;
use App\Framework\Console\Input\ConsoleInput;
use App\Framework\Console\Middleware\ConsoleMiddleware;
use App\Framework\Console\Output\ConsoleOutput;
use App\Framework\Core\ValueObjects\Duration;
use DateTimeImmutable;
final readonly class AnalyticsCollectionMiddleware implements ConsoleMiddleware
{
public function __construct(
private CommandUsageRepository $repository,
private bool $enabled = true
) {
}
public function handle(ConsoleInput $input, ConsoleOutput $output, callable $next): ExitCode
{
if (! $this->enabled) {
return $next($input, $output);
}
$commandName = $this->extractCommandName($input);
$startTime = hrtime(true);
$executedAt = new DateTimeImmutable();
try {
$exitCode = $next($input, $output);
} catch (\Throwable $e) {
// Record the failure and re-throw
$this->recordUsage(
$commandName,
$executedAt,
$startTime,
ExitCode::GENERAL_ERROR,
$input
);
throw $e;
}
$this->recordUsage($commandName, $executedAt, $startTime, $exitCode, $input);
return $exitCode;
}
private function recordUsage(
string $commandName,
DateTimeImmutable $executedAt,
int $startTime,
ExitCode $exitCode,
ConsoleInput $input
): void {
try {
$endTime = hrtime(true);
$executionTimeNs = $endTime - $startTime;
$executionTime = Duration::fromNanoseconds($executionTimeNs);
$metric = CommandUsageMetric::create(
commandName: $commandName,
executionTime: $executionTime,
exitCode: $exitCode,
argumentCount: count($input->getArguments()),
userId: $this->getCurrentUserId(),
metadata: $this->collectMetadata($input, $exitCode)
);
$this->repository->store($metric);
} catch (\Throwable $e) {
// Analytics collection should never break the command execution
// In production, this could be logged to a separate error log
error_log("Analytics collection failed: " . $e->getMessage());
}
}
private function extractCommandName(ConsoleInput $input): string
{
$arguments = $input->getArguments();
// First argument after script name is usually the command
return $arguments[1] ?? 'unknown';
}
private function getCurrentUserId(): ?string
{
// In a real implementation, this would extract user ID from context
// Could be from environment variables, session data, etc.
return $_ENV['CONSOLE_USER_ID'] ?? null;
}
private function collectMetadata(ConsoleInput $input, ExitCode $exitCode): array
{
$metadata = [
'options' => $input->getOptions(),
'has_stdin' => ! empty(stream_get_contents(STDIN, 1)),
'php_version' => PHP_VERSION,
'memory_peak' => memory_get_peak_usage(true),
'exit_code_name' => $exitCode->name,
];
// Add environment context
if (isset($_ENV['APP_ENV'])) {
$metadata['environment'] = $_ENV['APP_ENV'];
}
// Add process info
if (function_exists('posix_getpid')) {
$metadata['process_id'] = posix_getpid();
}
return $metadata;
}
}

View File

@@ -0,0 +1,51 @@
<?php
declare(strict_types=1);
namespace App\Framework\Console\Analytics\Migrations;
use App\Framework\Database\ConnectionInterface;
use App\Framework\Database\Migration\Migration;
use App\Framework\Database\Migration\MigrationVersion;
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";
$connection->execute($sql);
}
public function down(ConnectionInterface $connection): void
{
$connection->execute("DROP TABLE IF EXISTS command_usage_analytics");
}
public function getVersion(): MigrationVersion
{
return MigrationVersion::fromTimestamp('2024_01_01_000001');
}
public function getDescription(): string
{
return 'Create command_usage_analytics table for console command metrics';
}
}

View File

@@ -0,0 +1,32 @@
<?php
declare(strict_types=1);
namespace App\Framework\Console\Analytics\Repository;
use App\Framework\Console\Analytics\ValueObjects\CommandUsageMetric;
use App\Framework\Console\Analytics\ValueObjects\Period;
use App\Framework\Console\Analytics\ValueObjects\UsageStatistics;
use DateTimeImmutable;
interface CommandUsageRepository
{
public function store(CommandUsageMetric $metric): void;
public function storeMultiple(CommandUsageMetric ...$metrics): void;
public function getUsageStatistics(string $commandName, ?DateTimeImmutable $since = null): UsageStatistics;
public function getPopularCommands(int $limit = 10, ?DateTimeImmutable $since = null): array;
public function getTrendData(
string $commandName,
Period $period,
DateTimeImmutable $start,
DateTimeImmutable $end
): array;
public function getAllCommandNames(): array;
public function cleanup(DateTimeImmutable $before): int;
}

View File

@@ -0,0 +1,391 @@
<?php
declare(strict_types=1);
namespace App\Framework\Console\Analytics\Repository;
use App\Framework\Console\Analytics\ValueObjects\CommandPopularity;
use App\Framework\Console\Analytics\ValueObjects\CommandUsageMetric;
use App\Framework\Console\Analytics\ValueObjects\Period;
use App\Framework\Console\Analytics\ValueObjects\UsageStatistics;
use App\Framework\Console\Analytics\ValueObjects\UsageTrend;
use App\Framework\Console\Analytics\ValueObjects\UsageTrendPoint;
use App\Framework\Core\ValueObjects\Duration;
use App\Framework\Core\ValueObjects\Percentage;
use App\Framework\Database\Connection;
use DateTimeImmutable;
final readonly class DatabaseCommandUsageRepository implements CommandUsageRepository
{
public function __construct(
private Connection $connection
) {
}
public function store(CommandUsageMetric $metric): void
{
$sql = "INSERT INTO command_usage_analytics (
command_name, executed_at, execution_time_ms, exit_code,
argument_count, user_id, metadata
) VALUES (?, ?, ?, ?, ?, ?, ?)";
$this->connection->execute($sql, [
$metric->commandName,
$metric->executedAt->format('Y-m-d H:i:s'),
$metric->executionTime->toMilliseconds(),
$metric->exitCode->value,
$metric->argumentCount,
$metric->userId,
$metric->metadata ? json_encode($metric->metadata) : null,
]);
}
public function storeMultiple(CommandUsageMetric ...$metrics): void
{
if (empty($metrics)) {
return;
}
$values = [];
$placeholders = [];
foreach ($metrics as $metric) {
$placeholders[] = "(?, ?, ?, ?, ?, ?, ?)";
$values[] = $metric->commandName;
$values[] = $metric->executedAt->format('Y-m-d H:i:s');
$values[] = $metric->executionTime->toMilliseconds();
$values[] = $metric->exitCode->value;
$values[] = $metric->argumentCount;
$values[] = $metric->userId;
$values[] = $metric->metadata ? json_encode($metric->metadata) : null;
}
$sql = "INSERT INTO command_usage_analytics (
command_name, executed_at, execution_time_ms, exit_code,
argument_count, user_id, metadata
) VALUES " . implode(', ', $placeholders);
$this->connection->execute($sql, $values);
}
public function getUsageStatistics(string $commandName, ?DateTimeImmutable $since = null): UsageStatistics
{
$whereClause = 'WHERE command_name = ?';
$params = [$commandName];
if ($since) {
$whereClause .= ' AND executed_at >= ?';
$params[] = $since->format('Y-m-d H:i:s');
}
$sql = "SELECT
COUNT(*) as total_executions,
SUM(CASE WHEN exit_code = 0 THEN 1 ELSE 0 END) as successful_executions,
SUM(CASE WHEN exit_code != 0 THEN 1 ELSE 0 END) as failed_executions,
AVG(execution_time_ms) as avg_execution_time,
MIN(execution_time_ms) as min_execution_time,
MAX(execution_time_ms) as max_execution_time,
GROUP_CONCAT(DISTINCT exit_code) as exit_codes
FROM command_usage_analytics
{$whereClause}";
$result = $this->connection->query($sql, $params);
if (empty($result) || $result[0]['total_executions'] == 0) {
return UsageStatistics::empty($commandName);
}
$row = $result[0];
$hourlyDistribution = $this->getHourlyDistribution($commandName, $since);
$exitCodeDistribution = $this->getExitCodeDistribution($commandName, $since);
return new UsageStatistics(
commandName: $commandName,
totalExecutions: (int) $row['total_executions'],
successfulExecutions: (int) $row['successful_executions'],
failedExecutions: (int) $row['failed_executions'],
averageExecutionTime: Duration::fromMilliseconds((float) $row['avg_execution_time']),
minExecutionTime: Duration::fromMilliseconds((float) $row['min_execution_time']),
maxExecutionTime: Duration::fromMilliseconds((float) $row['max_execution_time']),
medianExecutionTime: $this->getMedianExecutionTime($commandName, $since),
hourlyDistribution: $hourlyDistribution,
exitCodeDistribution: $exitCodeDistribution
);
}
public function getPopularCommands(int $limit = 10, ?DateTimeImmutable $since = null): array
{
$whereClause = '';
$params = [];
if ($since) {
$whereClause = 'WHERE executed_at >= ?';
$params[] = $since->format('Y-m-d H:i:s');
}
$sql = "SELECT
command_name,
COUNT(*) as total_executions,
AVG(execution_time_ms) as avg_execution_time,
SUM(CASE WHEN exit_code = 0 THEN 1 ELSE 0 END) / COUNT(*) * 100 as success_rate
FROM command_usage_analytics
{$whereClause}
GROUP BY command_name
ORDER BY total_executions DESC
LIMIT ?";
$params[] = $limit;
$results = $this->connection->query($sql, $params);
$totalExecutions = $this->getTotalExecutions($since);
$popularCommands = [];
foreach ($results as $index => $row) {
$usagePercentage = $totalExecutions > 0
? Percentage::fromValue(($row['total_executions'] / $totalExecutions) * 100)
: Percentage::zero();
$popularCommands[] = new CommandPopularity(
commandName: $row['command_name'],
rank: $index + 1,
totalExecutions: (int) $row['total_executions'],
usagePercentage: $usagePercentage,
averageExecutionTime: Duration::fromMilliseconds((float) $row['avg_execution_time']),
successRate: Percentage::fromValue((float) $row['success_rate'])
);
}
return $popularCommands;
}
public function getTrendData(
string $commandName,
Period $period,
DateTimeImmutable $start,
DateTimeImmutable $end
): array {
$groupByClause = match ($period) {
Period::HOURLY => "DATE_FORMAT(executed_at, '%Y-%m-%d %H:00:00')",
Period::DAILY => "DATE(executed_at)",
Period::WEEKLY => "YEARWEEK(executed_at, 1)",
Period::MONTHLY => "DATE_FORMAT(executed_at, '%Y-%m')",
Period::YEARLY => "YEAR(executed_at)"
};
$sql = "SELECT
{$groupByClause} as period_date,
COUNT(*) as execution_count,
AVG(execution_time_ms) as avg_execution_time,
SUM(CASE WHEN exit_code = 0 THEN 1 ELSE 0 END) / COUNT(*) * 100 as success_rate
FROM command_usage_analytics
WHERE command_name = ?
AND executed_at BETWEEN ? AND ?
GROUP BY {$groupByClause}
ORDER BY period_date";
$results = $this->connection->query($sql, [
$commandName,
$start->format('Y-m-d H:i:s'),
$end->format('Y-m-d H:i:s'),
]);
$trendPoints = [];
foreach ($results as $row) {
$date = match ($period) {
Period::WEEKLY => new DateTimeImmutable($row['period_date'] . '-1'), // First day of week
default => new DateTimeImmutable($row['period_date'])
};
$trendPoints[] = new UsageTrendPoint(
date: $date,
executionCount: (int) $row['execution_count'],
averageExecutionTime: Duration::fromMilliseconds((float) $row['avg_execution_time']),
successRate: Percentage::fromValue((float) $row['success_rate'])
);
}
// Calculate trend direction and strength
$trendDirection = $this->calculateTrendDirection($trendPoints);
$trendStrength = $this->calculateTrendStrength($trendPoints);
return [new UsageTrend(
commandName: $commandName,
period: $period,
startDate: $start,
endDate: $end,
dataPoints: $trendPoints,
trendDirection: $trendDirection,
trendStrength: $trendStrength
)];
}
public function getAllCommandNames(): array
{
$sql = "SELECT DISTINCT command_name FROM command_usage_analytics ORDER BY command_name";
$results = $this->connection->query($sql);
return array_column($results, 'command_name');
}
public function cleanup(DateTimeImmutable $before): int
{
$sql = "DELETE FROM command_usage_analytics WHERE executed_at < ?";
return $this->connection->execute($sql, [$before->format('Y-m-d H:i:s')]);
}
private function getHourlyDistribution(string $commandName, ?DateTimeImmutable $since): array
{
$whereClause = 'WHERE command_name = ?';
$params = [$commandName];
if ($since) {
$whereClause .= ' AND executed_at >= ?';
$params[] = $since->format('Y-m-d H:i:s');
}
$sql = "SELECT
HOUR(executed_at) as hour,
COUNT(*) as count
FROM command_usage_analytics
{$whereClause}
GROUP BY HOUR(executed_at)
ORDER BY hour";
$results = $this->connection->query($sql, $params);
// Initialize all hours to 0
$distribution = array_fill(0, 24, 0);
foreach ($results as $row) {
$distribution[(int) $row['hour']] = (int) $row['count'];
}
return $distribution;
}
private function getExitCodeDistribution(string $commandName, ?DateTimeImmutable $since): array
{
$whereClause = 'WHERE command_name = ?';
$params = [$commandName];
if ($since) {
$whereClause .= ' AND executed_at >= ?';
$params[] = $since->format('Y-m-d H:i:s');
}
$sql = "SELECT
exit_code,
COUNT(*) as count
FROM command_usage_analytics
{$whereClause}
GROUP BY exit_code
ORDER BY exit_code";
$results = $this->connection->query($sql, $params);
$distribution = [];
foreach ($results as $row) {
$distribution[$row['exit_code']] = (int) $row['count'];
}
return $distribution;
}
private function getMedianExecutionTime(string $commandName, ?DateTimeImmutable $since): ?Duration
{
$whereClause = 'WHERE command_name = ?';
$params = [$commandName];
if ($since) {
$whereClause .= ' AND executed_at >= ?';
$params[] = $since->format('Y-m-d H:i:s');
}
// MySQL median calculation using percentiles
$sql = "SELECT
PERCENTILE_CONT(0.5) WITHIN GROUP (ORDER BY execution_time_ms) as median_time
FROM command_usage_analytics
{$whereClause}";
$result = $this->connection->query($sql, $params);
if (empty($result) || $result[0]['median_time'] === null) {
return null;
}
return Duration::fromMilliseconds((float) $result[0]['median_time']);
}
private function getTotalExecutions(?DateTimeImmutable $since): int
{
$whereClause = '';
$params = [];
if ($since) {
$whereClause = 'WHERE executed_at >= ?';
$params[] = $since->format('Y-m-d H:i:s');
}
$sql = "SELECT COUNT(*) as total FROM command_usage_analytics {$whereClause}";
$result = $this->connection->query($sql, $params);
return (int) $result[0]['total'];
}
private function calculateTrendDirection(array $trendPoints): float
{
if (count($trendPoints) < 2) {
return 0.0;
}
$xValues = array_keys($trendPoints);
$yValues = array_map(fn ($point) => $point->executionCount, $trendPoints);
// Simple linear regression slope calculation
$n = count($trendPoints);
$sumX = array_sum($xValues);
$sumY = array_sum($yValues);
$sumXY = 0;
$sumXX = 0;
for ($i = 0; $i < $n; $i++) {
$sumXY += $xValues[$i] * $yValues[$i];
$sumXX += $xValues[$i] * $xValues[$i];
}
$denominator = $n * $sumXX - $sumX * $sumX;
if ($denominator == 0) {
return 0.0;
}
return ($n * $sumXY - $sumX * $sumY) / $denominator;
}
private function calculateTrendStrength(array $trendPoints): float
{
if (count($trendPoints) < 2) {
return 0.0;
}
$values = array_map(fn ($point) => $point->executionCount, $trendPoints);
$mean = array_sum($values) / count($values);
// Calculate coefficient of variation
$variance = 0;
foreach ($values as $value) {
$variance += ($value - $mean) ** 2;
}
$variance /= count($values);
$stdDev = sqrt($variance);
if ($mean == 0) {
return 0.0;
}
$coefficientOfVariation = $stdDev / $mean;
// Convert to 0-1 scale where higher variation = stronger trend
return min(1.0, $coefficientOfVariation);
}
}

View File

@@ -0,0 +1,66 @@
<?php
declare(strict_types=1);
namespace App\Framework\Console\Analytics\ValueObjects;
use App\Framework\Core\ValueObjects\Duration;
use App\Framework\Core\ValueObjects\Percentage;
/**
* Value object representing command popularity ranking
*/
final readonly class CommandPopularity
{
public function __construct(
public string $commandName,
public int $rank,
public int $totalExecutions,
public Percentage $usagePercentage,
public Duration $averageExecutionTime,
public Percentage $successRate,
public ?string $category = null
) {
}
public function isPopular(): bool
{
return $this->rank <= 10 || $this->usagePercentage->getValue() >= 5.0;
}
public function isRare(): bool
{
return $this->usagePercentage->getValue() < 0.5;
}
public function getPopularityLevel(): string
{
$usage = $this->usagePercentage->getValue();
return match (true) {
$usage >= 20 => 'very_high',
$usage >= 10 => 'high',
$usage >= 5 => 'medium',
$usage >= 1 => 'low',
default => 'very_low'
};
}
public function toArray(): array
{
return [
'command_name' => $this->commandName,
'rank' => $this->rank,
'total_executions' => $this->totalExecutions,
'usage_percentage' => $this->usagePercentage->format(2),
'usage_percentage_value' => $this->usagePercentage->getValue(),
'average_execution_time' => $this->averageExecutionTime->toHumanReadable(),
'success_rate' => $this->successRate->format(1),
'success_rate_value' => $this->successRate->getValue(),
'category' => $this->category,
'popularity_level' => $this->getPopularityLevel(),
'is_popular' => $this->isPopular(),
'is_rare' => $this->isRare(),
];
}
}

View File

@@ -0,0 +1,79 @@
<?php
declare(strict_types=1);
namespace App\Framework\Console\Analytics\ValueObjects;
use App\Framework\Console\ExitCode;
use App\Framework\Core\ValueObjects\Duration;
use DateTimeImmutable;
/**
* Value object representing a single command usage metric
*/
final readonly class CommandUsageMetric
{
public function __construct(
public string $commandName,
public DateTimeImmutable $executedAt,
public Duration $executionTime,
public ExitCode $exitCode,
public int $argumentCount,
public ?string $userId = null,
public ?array $metadata = null
) {
}
public function isSuccessful(): bool
{
return $this->exitCode === ExitCode::SUCCESS;
}
public function toArray(): array
{
return [
'command_name' => $this->commandName,
'executed_at' => $this->executedAt->format('Y-m-d H:i:s'),
'execution_time_ms' => $this->executionTime->toMilliseconds(),
'execution_time_seconds' => $this->executionTime->toSeconds(),
'execution_time_human' => $this->executionTime->toHumanReadable(),
'exit_code' => $this->exitCode->value,
'argument_count' => $this->argumentCount,
'user_id' => $this->userId,
'metadata' => $this->metadata,
'is_successful' => $this->isSuccessful(),
];
}
public static function fromArray(array $data): self
{
return new self(
commandName: $data['command_name'],
executedAt: new DateTimeImmutable($data['executed_at']),
executionTime: Duration::fromMilliseconds((float) $data['execution_time_ms']),
exitCode: ExitCode::from((int) $data['exit_code']),
argumentCount: (int) $data['argument_count'],
userId: $data['user_id'] ?? null,
metadata: $data['metadata'] ?? null
);
}
public static function create(
string $commandName,
Duration $executionTime,
ExitCode $exitCode,
int $argumentCount,
?string $userId = null,
?array $metadata = null
): self {
return new self(
commandName: $commandName,
executedAt: new DateTimeImmutable(),
executionTime: $executionTime,
exitCode: $exitCode,
argumentCount: $argumentCount,
userId: $userId,
metadata: $metadata
);
}
}

View File

@@ -0,0 +1,50 @@
<?php
declare(strict_types=1);
namespace App\Framework\Console\Analytics\ValueObjects;
/**
* Enum for analytics time periods
*/
enum Period: string
{
case HOURLY = 'hourly';
case DAILY = 'daily';
case WEEKLY = 'weekly';
case MONTHLY = 'monthly';
case YEARLY = 'yearly';
public function getDescription(): string
{
return match ($this) {
self::HOURLY => 'Hourly analysis',
self::DAILY => 'Daily analysis',
self::WEEKLY => 'Weekly analysis',
self::MONTHLY => 'Monthly analysis',
self::YEARLY => 'Yearly analysis',
};
}
public function getDateFormat(): string
{
return match ($this) {
self::HOURLY => 'Y-m-d H:00',
self::DAILY => 'Y-m-d',
self::WEEKLY => 'Y-\WW',
self::MONTHLY => 'Y-m',
self::YEARLY => 'Y',
};
}
public function getIntervalSpec(): string
{
return match ($this) {
self::HOURLY => 'PT1H',
self::DAILY => 'P1D',
self::WEEKLY => 'P1W',
self::MONTHLY => 'P1M',
self::YEARLY => 'P1Y',
};
}
}

View File

@@ -0,0 +1,81 @@
<?php
declare(strict_types=1);
namespace App\Framework\Console\Analytics\ValueObjects;
use App\Framework\Core\ValueObjects\Duration;
use App\Framework\Core\ValueObjects\Percentage;
/**
* Value object representing aggregated usage statistics for a command
*/
final readonly class UsageStatistics
{
public function __construct(
public string $commandName,
public int $totalExecutions,
public int $successfulExecutions,
public int $failedExecutions,
public Duration $averageExecutionTime,
public Duration $minExecutionTime,
public Duration $maxExecutionTime,
public ?Duration $medianExecutionTime = null,
public ?array $hourlyDistribution = null,
public ?array $exitCodeDistribution = null
) {
}
public function getSuccessRate(): Percentage
{
if ($this->totalExecutions === 0) {
return Percentage::zero();
}
return Percentage::fromRatio($this->successfulExecutions, $this->totalExecutions);
}
public function getFailureRate(): Percentage
{
if ($this->totalExecutions === 0) {
return Percentage::zero();
}
return Percentage::fromRatio($this->failedExecutions, $this->totalExecutions);
}
public function toArray(): array
{
return [
'command_name' => $this->commandName,
'total_executions' => $this->totalExecutions,
'successful_executions' => $this->successfulExecutions,
'failed_executions' => $this->failedExecutions,
'success_rate' => $this->getSuccessRate()->format(2),
'failure_rate' => $this->getFailureRate()->format(2),
'average_execution_time' => $this->averageExecutionTime->toHumanReadable(),
'average_execution_time_ms' => $this->averageExecutionTime->toMilliseconds(),
'min_execution_time' => $this->minExecutionTime->toHumanReadable(),
'max_execution_time' => $this->maxExecutionTime->toHumanReadable(),
'median_execution_time' => $this->medianExecutionTime?->toHumanReadable(),
'hourly_distribution' => $this->hourlyDistribution,
'exit_code_distribution' => $this->exitCodeDistribution,
];
}
public static function empty(string $commandName): self
{
return new self(
commandName: $commandName,
totalExecutions: 0,
successfulExecutions: 0,
failedExecutions: 0,
averageExecutionTime: Duration::zero(),
minExecutionTime: Duration::zero(),
maxExecutionTime: Duration::zero(),
medianExecutionTime: null,
hourlyDistribution: array_fill(0, 24, 0),
exitCodeDistribution: []
);
}
}

View File

@@ -0,0 +1,94 @@
<?php
declare(strict_types=1);
namespace App\Framework\Console\Analytics\ValueObjects;
use App\Framework\Core\ValueObjects\Duration;
use App\Framework\Core\ValueObjects\Percentage;
use DateTimeImmutable;
/**
* Value object representing usage trends over time
*/
final readonly class UsageTrend
{
public function __construct(
public string $commandName,
public Period $period,
public DateTimeImmutable $startDate,
public DateTimeImmutable $endDate,
public array $dataPoints, // Array of UsageTrendPoint
public float $trendDirection, // Positive = increasing, negative = decreasing
public float $trendStrength // 0-1, how strong the trend is
) {
}
public function isIncreasing(): bool
{
return $this->trendDirection > 0;
}
public function isDecreasing(): bool
{
return $this->trendDirection < 0;
}
public function isStable(): bool
{
return abs($this->trendDirection) < 0.1;
}
public function getTrendDescription(): string
{
if ($this->isStable()) {
return 'stable';
}
$strength = $this->trendStrength > 0.7 ? 'strongly' :
($this->trendStrength > 0.4 ? 'moderately' : 'slightly');
return $this->isIncreasing() ? "$strength increasing" : "$strength decreasing";
}
public function toArray(): array
{
return [
'command_name' => $this->commandName,
'period' => $this->period->value,
'period_description' => $this->period->getDescription(),
'start_date' => $this->startDate->format('Y-m-d'),
'end_date' => $this->endDate->format('Y-m-d'),
'data_points' => array_map(fn ($point) => $point->toArray(), $this->dataPoints),
'trend_direction' => $this->trendDirection,
'trend_strength' => $this->trendStrength,
'trend_description' => $this->getTrendDescription(),
];
}
}
/**
* Single data point in a usage trend
*/
final readonly class UsageTrendPoint
{
public function __construct(
public DateTimeImmutable $date,
public int $executionCount,
public Duration $averageExecutionTime,
public Percentage $successRate
) {
}
public function toArray(): array
{
return [
'date' => $this->date->format('Y-m-d'),
'execution_count' => $this->executionCount,
'average_execution_time' => $this->averageExecutionTime->toHumanReadable(),
'average_execution_time_ms' => $this->averageExecutionTime->toMilliseconds(),
'success_rate' => $this->successRate->format(2),
'success_rate_value' => $this->successRate->getValue(),
];
}
}

View File

@@ -0,0 +1,145 @@
<?php
declare(strict_types=1);
namespace App\Framework\Console;
/**
* Definition of a console command argument or option
*/
final readonly class ArgumentDefinition
{
public function __construct(
public string $name,
public ArgumentType $type = ArgumentType::STRING,
public bool $required = false,
public mixed $default = null,
public string $description = '',
public ?string $shortName = null,
/** @var string[] */
public array $allowedValues = []
) {
// Validate configuration
if (empty(trim($this->name))) {
throw new \InvalidArgumentException('Argument name cannot be empty');
}
if ($this->shortName !== null && strlen($this->shortName) !== 1) {
throw new \InvalidArgumentException('Short name must be exactly one character');
}
if ($this->required && $this->default !== null) {
throw new \InvalidArgumentException('Required arguments cannot have default values');
}
if ($this->type === ArgumentType::BOOLEAN && ! empty($this->allowedValues)) {
throw new \InvalidArgumentException('Boolean arguments cannot have allowed values');
}
}
/**
* Create a required string argument
*/
public static function required(string $name, string $description = ''): self
{
return new self($name, ArgumentType::STRING, required: true, description: $description);
}
/**
* Create an optional string argument with default
*/
public static function optional(string $name, string $default = '', string $description = ''): self
{
return new self($name, ArgumentType::STRING, default: $default, description: $description);
}
/**
* Create a boolean flag
*/
public static function flag(string $name, ?string $shortName = null, string $description = ''): self
{
return new self($name, ArgumentType::BOOLEAN, shortName: $shortName, description: $description);
}
/**
* Create an email argument
*/
public static function email(string $name, bool $required = true, string $description = ''): self
{
return new self($name, ArgumentType::EMAIL, required: $required, description: $description);
}
/**
* Create an integer argument
*/
public static function integer(string $name, bool $required = false, ?int $default = null, string $description = ''): self
{
return new self($name, ArgumentType::INTEGER, required: $required, default: $default, description: $description);
}
/**
* Create a choice argument with allowed values
*/
public static function choice(string $name, array $allowedValues, bool $required = false, ?string $default = null, string $description = ''): self
{
return new self(
$name,
ArgumentType::STRING,
required: $required,
default: $default,
description: $description,
allowedValues: $allowedValues
);
}
/**
* Get the display name for help text
*/
public function getDisplayName(): string
{
$display = $this->name;
if ($this->shortName) {
$display = "{$this->shortName}, {$display}";
}
return $display;
}
/**
* Get usage text for this argument
*/
public function getUsageText(): string
{
if ($this->type === ArgumentType::BOOLEAN) {
return $this->required ? "--{$this->name}" : "[--{$this->name}]";
}
$usage = "--{$this->name}";
if (! empty($this->allowedValues)) {
$usage .= "=" . implode('|', $this->allowedValues);
} else {
$usage .= "={$this->type->getExample()}";
}
return $this->required ? "<{$usage}>" : "[{$usage}]";
}
/**
* Validate a value against this argument definition
*/
public function validateValue(mixed $value): void
{
if ($this->required && ($value === null || $value === '')) {
throw new \InvalidArgumentException("Required argument '{$this->name}' is missing");
}
if (! empty($this->allowedValues) && ! in_array($value, $this->allowedValues, true)) {
throw new \InvalidArgumentException(
"Invalid value '{$value}' for argument '{$this->name}'. Allowed values: " .
implode(', ', $this->allowedValues)
);
}
}
}

View File

@@ -0,0 +1,378 @@
<?php
declare(strict_types=1);
namespace App\Framework\Console;
/**
* Advanced argument parser for console commands
*
* Supports:
* - Long options: --option=value, --option value, --flag
* - Short options: -o value, -f, -abc (combined flags)
* - Positional arguments
* - Type validation and casting
* - Required/optional arguments
*/
final readonly class ArgumentParser
{
/** @param array<string, ArgumentDefinition> $definitions */
public function __construct(
private array $definitions = []
) {
}
/**
* Parse command line arguments
*
* @param string[] $arguments Raw command line arguments
*/
public function parse(array $arguments): ParsedArguments
{
$parsed = [
'arguments' => [],
'options' => [],
'flags' => [],
'positional' => [],
];
$i = 0;
while ($i < count($arguments)) {
$arg = $arguments[$i];
if (str_starts_with($arg, '--')) {
$i = $this->parseLongOption($arguments, $i, $parsed);
} elseif (str_starts_with($arg, '-') && strlen($arg) > 1) {
$i = $this->parseShortOption($arguments, $i, $parsed);
} else {
$this->parsePositionalArgument($arg, $parsed);
$i++;
}
}
// Merge all parsed values
$allValues = array_merge($parsed['arguments'], $parsed['options'], $parsed['flags']);
// Apply defaults for missing values
foreach ($this->definitions as $name => $definition) {
if (! array_key_exists($name, $allValues) && $definition->default !== null) {
$allValues[$name] = $definition->default;
}
}
$result = new ParsedArguments(
$parsed['arguments'],
array_merge($parsed['options'], $parsed['flags']),
$this->definitions
);
// Validate all requirements
$result->validate();
return $result;
}
/**
* Parse long option (--option or --option=value)
*/
private function parseLongOption(array $arguments, int $index, array &$parsed): int
{
$arg = $arguments[$index];
$optionPart = substr($arg, 2); // Remove '--'
// Handle --option=value
if (str_contains($optionPart, '=')) {
[$name, $value] = explode('=', $optionPart, 2);
$parsed['options'][$name] = $this->parseValue($name, $value);
return $index + 1;
}
// Handle --option value or --flag
$definition = $this->findDefinitionByName($optionPart);
if ($definition && $definition->type === ArgumentType::BOOLEAN) {
// Boolean flag
$parsed['flags'][$optionPart] = true;
return $index + 1;
}
// Option that expects a value
if ($index + 1 >= count($arguments) || str_starts_with($arguments[$index + 1], '-')) {
if ($definition && $definition->required) {
throw new \InvalidArgumentException("Option '--{$optionPart}' requires a value");
}
// Optional option without value - treat as flag
$parsed['flags'][$optionPart] = true;
return $index + 1;
}
$parsed['options'][$optionPart] = $this->parseValue($optionPart, $arguments[$index + 1]);
return $index + 2;
}
/**
* Parse short option (-o or -o value or -abc)
*/
private function parseShortOption(array $arguments, int $index, array &$parsed): int
{
$arg = $arguments[$index];
$options = substr($arg, 1); // Remove '-'
// Handle combined short options: -abc = -a -b -c
if (strlen($options) > 1) {
foreach (str_split($options) as $shortOption) {
$definition = $this->findDefinitionByShortName($shortOption);
$longName = $definition?->name ?? $shortOption;
if ($definition && $definition->type !== ArgumentType::BOOLEAN) {
throw new \InvalidArgumentException(
"Short option '-{$shortOption}' requires a value and cannot be combined with other options"
);
}
$parsed['flags'][$longName] = true;
}
return $index + 1;
}
// Handle single short option: -f or -f value
$shortName = $options;
$definition = $this->findDefinitionByShortName($shortName);
$longName = $definition?->name ?? $shortName;
if ($definition && $definition->type === ArgumentType::BOOLEAN) {
$parsed['flags'][$longName] = true;
return $index + 1;
}
// Option that expects a value
if ($index + 1 >= count($arguments) || str_starts_with($arguments[$index + 1], '-')) {
if ($definition && $definition->required) {
throw new \InvalidArgumentException("Option '-{$shortName}' requires a value");
}
// Optional option without value - treat as flag
$parsed['flags'][$longName] = true;
return $index + 1;
}
$parsed['options'][$longName] = $this->parseValue($longName, $arguments[$index + 1]);
return $index + 2;
}
/**
* Parse positional argument
*/
private function parsePositionalArgument(string $value, array &$parsed): void
{
$parsed['positional'][] = $value;
// Try to match with positional argument definitions
$positionalIndex = count($parsed['positional']) - 1;
$positionalDefs = array_values(array_filter(
$this->definitions,
fn ($def) => $def->type !== ArgumentType::BOOLEAN
));
if (isset($positionalDefs[$positionalIndex])) {
$definition = $positionalDefs[$positionalIndex];
$parsed['arguments'][$definition->name] = $this->parseValue($definition->name, $value);
}
}
/**
* Parse and validate a value according to its definition
*/
private function parseValue(string $name, string $value): mixed
{
$definition = $this->findDefinitionByName($name);
if (! $definition) {
return $value; // Unknown option, keep as string
}
// Validate against allowed values
if (! empty($definition->allowedValues) && ! in_array($value, $definition->allowedValues, true)) {
throw new \InvalidArgumentException(
"Invalid value '{$value}' for '{$name}'. Allowed values: " .
implode(', ', $definition->allowedValues)
);
}
return $this->castToType($value, $definition->type);
}
/**
* Cast value to appropriate type
*/
private function castToType(mixed $value, ArgumentType $type): mixed
{
return match($type) {
ArgumentType::STRING => (string) $value,
ArgumentType::INTEGER => $this->parseInteger($value),
ArgumentType::FLOAT => $this->parseFloat($value),
ArgumentType::BOOLEAN => $this->parseBoolean($value),
ArgumentType::ARRAY => $this->parseArray($value),
ArgumentType::EMAIL => $this->validateEmail($value),
ArgumentType::URL => $this->validateUrl($value),
};
}
private function parseInteger(mixed $value): int
{
if (! is_numeric($value)) {
throw new \InvalidArgumentException("Value '{$value}' is not a valid integer");
}
return (int) $value;
}
private function parseFloat(mixed $value): float
{
if (! is_numeric($value)) {
throw new \InvalidArgumentException("Value '{$value}' is not a valid number");
}
return (float) $value;
}
private function parseBoolean(mixed $value): bool
{
if (is_bool($value)) {
return $value;
}
$lowered = strtolower((string) $value);
return in_array($lowered, ['true', '1', 'yes', 'on'], true);
}
private function parseArray(mixed $value): array
{
if (is_array($value)) {
return $value;
}
$items = array_map('trim', explode(',', (string) $value));
return array_filter($items, fn ($item) => $item !== '');
}
private function validateEmail(mixed $value): string
{
$email = (string) $value;
if (! filter_var($email, FILTER_VALIDATE_EMAIL)) {
throw new \InvalidArgumentException("'{$email}' is not a valid email address");
}
return $email;
}
private function validateUrl(mixed $value): string
{
$url = (string) $value;
if (! filter_var($url, FILTER_VALIDATE_URL)) {
throw new \InvalidArgumentException("'{$url}' is not a valid URL");
}
return $url;
}
/**
* Find argument definition by long name
*/
private function findDefinitionByName(string $name): ?ArgumentDefinition
{
return $this->definitions[$name] ?? null;
}
/**
* Find argument definition by short name
*/
private function findDefinitionByShortName(string $shortName): ?ArgumentDefinition
{
foreach ($this->definitions as $definition) {
if ($definition->shortName === $shortName) {
return $definition;
}
}
return null;
}
/**
* Get all argument definitions
*
* @return array<string, ArgumentDefinition>
*/
public function getDefinitions(): array
{
return $this->definitions;
}
/**
* Create parser with fluent interface
*/
public static function create(): ArgumentParserBuilder
{
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,59 @@
<?php
declare(strict_types=1);
namespace App\Framework\Console;
/**
* Supported argument types for console commands
*/
enum ArgumentType: string
{
case STRING = 'string';
case INTEGER = 'int';
case FLOAT = 'float';
case BOOLEAN = 'bool';
case ARRAY = 'array';
case EMAIL = 'email';
case URL = 'url';
/**
* Get human-readable description of the type
*/
public function getDescription(): string
{
return match($this) {
self::STRING => 'Text string',
self::INTEGER => 'Integer number',
self::FLOAT => 'Decimal number',
self::BOOLEAN => 'True/false flag',
self::ARRAY => 'Comma-separated values',
self::EMAIL => 'Valid email address',
self::URL => 'Valid URL',
};
}
/**
* Check if type requires a value (not just a flag)
*/
public function requiresValue(): bool
{
return $this !== self::BOOLEAN;
}
/**
* Get example value for help text
*/
public function getExample(): string
{
return match($this) {
self::STRING => 'text',
self::INTEGER => '123',
self::FLOAT => '12.34',
self::BOOLEAN => 'true|false',
self::ARRAY => 'item1,item2,item3',
self::EMAIL => 'user@example.com',
self::URL => 'https://example.com',
};
}
}

View File

@@ -0,0 +1,89 @@
<?php
declare(strict_types=1);
namespace App\Framework\Console;
use Attribute;
/**
* Attribute to define command groups for better organization and workflows
*/
#[Attribute(Attribute::TARGET_CLASS | Attribute::TARGET_METHOD)]
final readonly class CommandGroup
{
public function __construct(
public string $name,
public string $description = '',
public string $icon = '📁',
public int $priority = 0,
public array $dependencies = [],
public array $tags = [],
public bool $hidden = false
) {
}
/**
* Get group name
*/
public function getName(): string
{
return $this->name;
}
/**
* Get group description
*/
public function getDescription(): string
{
return $this->description;
}
/**
* Get group icon
*/
public function getIcon(): string
{
return $this->icon;
}
/**
* Get priority (higher = shown first)
*/
public function getPriority(): int
{
return $this->priority;
}
/**
* Get group dependencies
*/
public function getDependencies(): array
{
return $this->dependencies;
}
/**
* Get group tags
*/
public function getTags(): array
{
return $this->tags;
}
/**
* Check if group is hidden
*/
public function isHidden(): bool
{
return $this->hidden;
}
/**
* Check if group has tag
*/
public function hasTag(string $tag): bool
{
return in_array($tag, $this->tags, true);
}
}

View File

@@ -0,0 +1,293 @@
<?php
declare(strict_types=1);
namespace App\Framework\Console;
use App\Framework\Discovery\Results\DiscoveryRegistry;
/**
* Registry for command groups and workflows
*/
final readonly class CommandGroupRegistry
{
private array $groups;
private array $workflows;
private array $commandGroupMapping;
public function __construct(
private DiscoveryRegistry $discoveryRegistry
) {
$this->groups = $this->loadGroups();
$this->workflows = $this->loadWorkflows();
$this->commandGroupMapping = $this->buildCommandGroupMapping();
}
/**
* Get all command groups
*/
public function getGroups(): array
{
return $this->groups;
}
/**
* Get group by name
*/
public function getGroup(string $name): ?array
{
return $this->groups[$name] ?? null;
}
/**
* Get all workflows
*/
public function getWorkflows(): array
{
return $this->workflows;
}
/**
* Get workflow by name
*/
public function getWorkflow(string $name): ?array
{
return $this->workflows[$name] ?? null;
}
/**
* Get command group mapping
*/
public function getCommandGroupMapping(): array
{
return $this->commandGroupMapping;
}
/**
* Get commands for a group
*/
public function getCommandsForGroup(string $groupName): array
{
$commands = [];
$allCommands = $this->discoveryRegistry->attributes()->get(ConsoleCommand::class);
foreach ($allCommands as $commandData) {
$commandGroup = $this->getCommandGroup($commandData);
if ($commandGroup === $groupName) {
$commands[] = $commandData;
}
}
return $commands;
}
/**
* Get organized command structure
*/
public function getOrganizedCommands(): array
{
$organized = [];
$allCommands = $this->discoveryRegistry->attributes()->get(ConsoleCommand::class);
// Group commands by their groups
foreach ($allCommands as $commandData) {
$groupName = $this->getCommandGroup($commandData);
$group = $this->getGroup($groupName);
if (! isset($organized[$groupName])) {
$organized[$groupName] = [
'name' => $groupName,
'description' => $group['description'] ?? '',
'icon' => $group['icon'] ?? $this->inferIcon($groupName),
'priority' => $group['priority'] ?? 0,
'commands' => [],
];
}
$organized[$groupName]['commands'][] = $commandData;
}
// Sort by priority
uasort($organized, fn ($a, $b) => $b['priority'] <=> $a['priority']);
// Convert to numeric array for TUI navigation (TuiState expects numeric indices)
return array_values($organized);
}
/**
* Check if command has explicit group
*/
public function hasExplicitGroup(array $commandData): bool
{
return $this->getExplicitGroup($commandData) !== null;
}
/**
* Load groups from discovery
*/
private function loadGroups(): array
{
$groups = [];
$results = $this->discoveryRegistry->attributes()->get(CommandGroup::class);
foreach ($results as $result) {
$group = $result->createAttributeInstance();
if ($group === null) {
continue;
}
$groups[$group->name] = [
'name' => $group->name,
'description' => $group->description,
'icon' => $group->icon,
'priority' => $group->priority,
'dependencies' => $group->dependencies,
'tags' => $group->tags,
'hidden' => $group->hidden,
'class' => $result->className->getFullyQualified(),
];
}
return $groups;
}
/**
* Load workflows from discovery
*/
private function loadWorkflows(): array
{
$workflows = [];
$results = $this->discoveryRegistry->attributes()->get(CommandWorkflow::class);
foreach ($results as $result) {
$workflow = $result->createAttributeInstance();
if ($workflow === null) {
continue;
}
$workflows[$workflow->name] = [
'name' => $workflow->name,
'description' => $workflow->description,
'steps' => array_map(
fn ($step) => is_array($step) ? WorkflowStep::fromArray($step) : $step,
$workflow->steps
),
'prerequisites' => $workflow->prerequisites,
'stopOnError' => $workflow->stopOnError,
'rollbackSteps' => $workflow->rollbackSteps,
'timeoutSeconds' => $workflow->timeoutSeconds,
'environment' => $workflow->environment,
'class' => $result->className->getFullyQualified(),
'method' => $result->methodName?->toString(),
];
}
return $workflows;
}
/**
* Build command to group mapping
*/
private function buildCommandGroupMapping(): array
{
$mapping = [];
$allCommands = $this->discoveryRegistry->attributes()->get(ConsoleCommand::class);
foreach ($allCommands as $commandData) {
$attribute = $commandData->createAttributeInstance();
if ($attribute === null) {
continue;
}
$commandName = $attribute->name;
$groupName = $this->getCommandGroup($commandData);
$mapping[$commandName] = $groupName;
}
return $mapping;
}
/**
* Get command group (explicit or inferred)
*/
private function getCommandGroup($commandData): string
{
// Check for explicit group attribute
$explicitGroup = $this->getExplicitGroup($commandData);
if ($explicitGroup !== null) {
return $explicitGroup;
}
// Fall back to inference - get command name from attribute instance
$attribute = $commandData->createAttributeInstance();
if ($attribute === null) {
return 'General';
}
return $this->inferCategory($attribute->name);
}
/**
* Get explicit group from command
*/
private function getExplicitGroup($commandData): ?string
{
$class = $commandData->className->getFullyQualified();
$method = $commandData->methodName?->toString();
// Check method-level group attribute
if ($method) {
$reflection = new \ReflectionMethod($class, $method);
$attributes = $reflection->getAttributes(CommandGroup::class);
if (! empty($attributes)) {
return $attributes[0]->newInstance()->name;
}
}
// Check class-level group attribute
$reflection = new \ReflectionClass($class);
$attributes = $reflection->getAttributes(CommandGroup::class);
if (! empty($attributes)) {
return $attributes[0]->newInstance()->name;
}
return null;
}
/**
* Infer category from command name (fallback)
*/
private function inferCategory(string $commandName): string
{
if (str_contains($commandName, ':')) {
return ucfirst(explode(':', $commandName)[0]);
}
return match (true) {
str_starts_with($commandName, 'test') => 'Testing',
str_starts_with($commandName, 'demo') => 'Demo',
str_starts_with($commandName, 'make') => 'Generator',
str_starts_with($commandName, 'db') => 'Database',
str_starts_with($commandName, 'mcp') => 'MCP',
default => 'General'
};
}
/**
* Infer icon for group
*/
private function inferIcon(string $groupName): string
{
return match (strtolower($groupName)) {
'testing' => '🧪',
'demo' => '🎮',
'generator' => '⚙️',
'database' => '🗄️',
'mcp' => '🤖',
'workflow' => '🔄',
'system' => '⚙️',
'security' => '🛡️',
'general' => '📂',
default => '📁'
};
}
}

View File

@@ -0,0 +1,378 @@
<?php
declare(strict_types=1);
namespace App\Framework\Console;
/**
* Value object containing comprehensive command help information
*/
final readonly class CommandHelp
{
public function __construct(
public string $name,
public string $description,
public string $usage,
public array $parameters,
public array $examples,
public string $className,
public string $methodName,
public array $aliases = []
) {
}
/**
* Format help as plain text
*/
public function formatAsText(): string
{
$output = [];
// Header
$output[] = "Command: {$this->name}";
if (! empty($this->aliases)) {
$output[] = "Aliases: " . implode(', ', $this->aliases);
}
$output[] = str_repeat('=', 60);
// Description
if (! empty($this->description)) {
$output[] = "";
$output[] = "DESCRIPTION:";
$output[] = $this->wrapText($this->description, 2);
}
// Usage
$output[] = "";
$output[] = "USAGE:";
$output[] = " {$this->usage}";
// Parameters
if (! empty($this->parameters)) {
$output[] = "";
$output[] = "PARAMETERS:";
foreach ($this->parameters as $param) {
$paramLine = " {$param['name']}";
// Add type info
$type = $param['type']['name'];
if (isset($param['type']['nullable']) && $param['type']['nullable']) {
$type .= '?';
}
$paramLine .= " ({$type})";
// Add required/optional indicator
$paramLine .= $param['required'] ? " *required*" : " [optional]";
$output[] = $paramLine;
// Add description
if (! empty($param['description'])) {
$output[] = " " . $param['description'];
}
// Add default value
if ($param['default'] !== null) {
$defaultValue = is_bool($param['default'])
? ($param['default'] ? 'true' : 'false')
: $param['default'];
$output[] = " Default: {$defaultValue}";
}
// Add validation rules
if (! empty($param['validation_rules'])) {
$rules = [];
foreach ($param['validation_rules'] as $rule => $value) {
if ($rule === 'required') {
continue;
}
$rules[] = "{$rule}: {$value}";
}
if (! empty($rules)) {
$output[] = " Validation: " . implode(', ', $rules);
}
}
$output[] = "";
}
}
// Examples
if (! empty($this->examples)) {
$output[] = "EXAMPLES:";
foreach ($this->examples as $i => $example) {
$output[] = " " . ($i + 1) . ". {$example['description']}";
$output[] = " {$example['command']}";
$output[] = "";
}
}
// Technical info
$output[] = "TECHNICAL INFO:";
$output[] = " Class: {$this->className}";
$output[] = " Method: {$this->methodName}";
return implode("\n", $output);
}
/**
* Format help as colored console output
*/
public function formatAsColoredText(): array
{
$sections = [];
// Header
$sections[] = [
'text' => "Command: {$this->name}",
'color' => 'BRIGHT_CYAN',
];
if (! empty($this->aliases)) {
$sections[] = [
'text' => "Aliases: " . implode(', ', $this->aliases),
'color' => 'GRAY',
];
}
$sections[] = [
'text' => str_repeat('=', 60),
'color' => 'GRAY',
];
// Description
if (! empty($this->description)) {
$sections[] = ['text' => "", 'color' => 'WHITE'];
$sections[] = [
'text' => "DESCRIPTION:",
'color' => 'BRIGHT_YELLOW',
];
$sections[] = [
'text' => $this->wrapText($this->description, 2),
'color' => 'WHITE',
];
}
// Usage
$sections[] = ['text' => "", 'color' => 'WHITE'];
$sections[] = [
'text' => "USAGE:",
'color' => 'BRIGHT_YELLOW',
];
$sections[] = [
'text' => " {$this->usage}",
'color' => 'BRIGHT_WHITE',
];
// Parameters
if (! empty($this->parameters)) {
$sections[] = ['text' => "", 'color' => 'WHITE'];
$sections[] = [
'text' => "PARAMETERS:",
'color' => 'BRIGHT_YELLOW',
];
foreach ($this->parameters as $param) {
$paramText = " {$param['name']}";
// Add type info
$type = $param['type']['name'];
if (isset($param['type']['nullable']) && $param['type']['nullable']) {
$type .= '?';
}
$paramText .= " ({$type})";
// Add required/optional indicator
if ($param['required']) {
$sections[] = [
'text' => $paramText . " *required*",
'color' => 'BRIGHT_WHITE',
];
} else {
$sections[] = [
'text' => $paramText . " [optional]",
'color' => 'WHITE',
];
}
// Add description
if (! empty($param['description'])) {
$sections[] = [
'text' => " " . $param['description'],
'color' => 'GRAY',
];
}
// Add default value
if ($param['default'] !== null) {
$defaultValue = is_bool($param['default'])
? ($param['default'] ? 'true' : 'false')
: $param['default'];
$sections[] = [
'text' => " Default: {$defaultValue}",
'color' => 'YELLOW',
];
}
}
}
// Examples
if (! empty($this->examples)) {
$sections[] = ['text' => "", 'color' => 'WHITE'];
$sections[] = [
'text' => "EXAMPLES:",
'color' => 'BRIGHT_YELLOW',
];
foreach ($this->examples as $i => $example) {
$sections[] = [
'text' => " " . ($i + 1) . ". {$example['description']}",
'color' => 'WHITE',
];
$sections[] = [
'text' => " {$example['command']}",
'color' => 'BRIGHT_GREEN',
];
}
}
return $sections;
}
/**
* Get quick summary for lists
*/
public function getQuickSummary(): string
{
$summary = $this->name;
if (! empty($this->description)) {
$firstLine = strtok($this->description, "\n");
$summary .= " - " . $firstLine;
}
if (! empty($this->parameters)) {
$paramCount = count($this->parameters);
$requiredCount = count(array_filter($this->parameters, fn ($p) => $p['required']));
$summary .= " ({$requiredCount}/{$paramCount} params)";
}
return $summary;
}
/**
* Get parameter summary
*/
public function getParameterSummary(): string
{
if (empty($this->parameters)) {
return "No parameters";
}
$required = array_filter($this->parameters, fn ($p) => $p['required']);
$optional = array_filter($this->parameters, fn ($p) => ! $p['required']);
$summary = [];
if (! empty($required)) {
$summary[] = count($required) . " required";
}
if (! empty($optional)) {
$summary[] = count($optional) . " optional";
}
return implode(', ', $summary) . " parameters";
}
/**
* Check if command has parameters
*/
public function hasParameters(): bool
{
return ! empty($this->parameters);
}
/**
* Check if command has required parameters
*/
public function hasRequiredParameters(): bool
{
return ! empty(array_filter($this->parameters, fn ($p) => $p['required']));
}
/**
* Get all aliases including the main name
*/
public function getAllNames(): array
{
return array_merge([$this->name], $this->aliases);
}
/**
* Wrap text to specified width with indent
*/
private function wrapText(string $text, int $indent = 0): string
{
$width = 76 - $indent;
$indentStr = str_repeat(' ', $indent);
$lines = explode("\n", $text);
$wrappedLines = [];
foreach ($lines as $line) {
if (empty($line)) {
$wrappedLines[] = $indentStr;
continue;
}
$wrapped = wordwrap($line, $width, "\n", true);
$subLines = explode("\n", $wrapped);
foreach ($subLines as $subLine) {
$wrappedLines[] = $indentStr . $subLine;
}
}
return implode("\n", $wrappedLines);
}
/**
* Create empty help object
*/
public static function empty(string $className): self
{
return new self(
name: 'unknown',
description: 'No help available',
usage: 'unknown',
parameters: [],
examples: [],
className: $className,
methodName: '__invoke'
);
}
/**
* Convert to array format
*/
public function toArray(): array
{
return [
'name' => $this->name,
'description' => $this->description,
'usage' => $this->usage,
'parameters' => $this->parameters,
'examples' => $this->examples,
'className' => $this->className,
'methodName' => $this->methodName,
'aliases' => $this->aliases,
'summary' => [
'parameter_count' => count($this->parameters),
'required_parameters' => count(array_filter($this->parameters, fn ($p) => $p['required'])),
'has_examples' => ! empty($this->examples),
'quick_summary' => $this->getQuickSummary(),
],
];
}
}

View File

@@ -0,0 +1,347 @@
<?php
declare(strict_types=1);
namespace App\Framework\Console;
use ReflectionClass;
use ReflectionException;
use ReflectionMethod;
/**
* Generates comprehensive help text from command method signatures
*/
final readonly class CommandHelpGenerator
{
public function __construct(
private readonly ParameterInspector $parameterInspector
) {
}
/**
* Generate comprehensive help for a command
*/
public function generateHelp(object $command, string $methodName = '__invoke'): CommandHelp
{
try {
$reflection = new ReflectionMethod($command, $methodName);
$classReflection = new ReflectionClass($command);
$commandInfo = $this->extractCommandInfo($reflection);
$parameters = $this->parameterInspector->inspectCommand($command, $methodName);
$usage = $this->generateUsageExample($commandInfo['name'], $parameters);
$description = $this->generateDescription($commandInfo, $classReflection);
$examples = $this->generateExamples($commandInfo['name'], $parameters);
return new CommandHelp(
name: $commandInfo['name'],
description: $description,
usage: $usage,
parameters: $parameters,
examples: $examples,
className: $classReflection->getName(),
methodName: $methodName,
aliases: $commandInfo['aliases'] ?? []
);
} catch (ReflectionException $e) {
return CommandHelp::empty($command::class);
}
}
/**
* Extract command information from ConsoleCommand attribute
*/
private function extractCommandInfo(ReflectionMethod $method): array
{
$attributes = $method->getAttributes();
foreach ($attributes as $attribute) {
if ($attribute->getName() === ConsoleCommand::class) {
$args = $attribute->getArguments();
return [
'name' => $args[0] ?? $args['name'] ?? 'unknown',
'description' => $args[1] ?? $args['description'] ?? '',
'aliases' => $args['aliases'] ?? [],
];
}
}
return [
'name' => 'unknown',
'description' => '',
'aliases' => [],
];
}
/**
* Generate enhanced description with parameter analysis
*/
private function generateDescription(array $commandInfo, ReflectionClass $class): string
{
$description = $commandInfo['description'];
if (empty($description)) {
// Generate description from class name and method analysis
$className = $class->getShortName();
$cleanName = preg_replace('/Command$/', '', $className);
$description = "Execute {$cleanName} operation";
}
// Add class information
$classDoc = $class->getDocComment();
if ($classDoc && preg_match('/\*\s*(.+?)(?:\n|\*\/)/s', $classDoc, $matches)) {
$classDescription = trim($matches[1]);
if ($classDescription && $classDescription !== $description) {
$description .= "\n\n" . $classDescription;
}
}
return $description;
}
/**
* Generate usage example from parameters
*/
private function generateUsageExample(string $commandName, array $parameters): string
{
$usage = $commandName;
foreach ($parameters as $param) {
$paramName = $param['name'];
$isRequired = $param['required'];
$paramType = $param['type']['name'];
// Generate parameter format based on type
$paramFormat = match ($paramType) {
'bool' => $isRequired ? "--{$paramName}" : "[--{$paramName}]",
'int', 'float' => $isRequired ? "<{$paramName}>" : "[<{$paramName}>]",
'string' => $isRequired ? "<{$paramName}>" : "[<{$paramName}>]",
'array' => $isRequired ? "<{$paramName}...>" : "[<{$paramName}...>]",
default => $isRequired ? "<{$paramName}>" : "[<{$paramName}>]"
};
$usage .= " {$paramFormat}";
}
return $usage;
}
/**
* Generate usage examples based on parameter types
*/
private function generateExamples(string $commandName, array $parameters): array
{
$examples = [];
if (empty($parameters)) {
return [
[
'command' => $commandName,
'description' => 'Execute command with default settings',
],
];
}
// Basic example with required parameters
$requiredParams = array_filter($parameters, fn ($p) => $p['required']);
if (! empty($requiredParams)) {
$basicExample = $commandName;
$description = 'Execute with required parameters:';
foreach ($requiredParams as $param) {
$exampleValue = $this->generateExampleValue($param);
$basicExample .= " {$exampleValue}";
$description .= " {$param['name']}";
}
$examples[] = [
'command' => $basicExample,
'description' => $description,
];
}
// Advanced example with all parameters
if (count($parameters) > count($requiredParams)) {
$advancedExample = $commandName;
$description = 'Execute with all parameters:';
foreach ($parameters as $param) {
$exampleValue = $this->generateExampleValue($param);
$advancedExample .= " {$exampleValue}";
}
$examples[] = [
'command' => $advancedExample,
'description' => $description,
];
}
// Type-specific examples
$this->addTypeSpecificExamples($examples, $commandName, $parameters);
return $examples;
}
/**
* Generate example value for a parameter
*/
private function generateExampleValue(array $param): string
{
$name = $param['name'];
$type = $param['type']['name'];
$default = $param['default'];
return match ($type) {
'bool' => "--{$name}",
'int' => $this->generateIntExample($name, $default),
'float' => $this->generateFloatExample($name, $default),
'string' => $this->generateStringExample($name, $default),
'array' => $this->generateArrayExample($name),
default => "\"{$name}_value\""
};
}
/**
* Generate integer example based on parameter name
*/
private function generateIntExample(string $name, mixed $default): string
{
if ($default !== null) {
return (string) $default;
}
return match (true) {
str_contains($name, 'port') => '8080',
str_contains($name, 'count') => '10',
str_contains($name, 'limit') => '100',
str_contains($name, 'timeout') => '30',
str_contains($name, 'id') => '123',
default => '42'
};
}
/**
* Generate float example based on parameter name
*/
private function generateFloatExample(string $name, mixed $default): string
{
if ($default !== null) {
return (string) $default;
}
return match (true) {
str_contains($name, 'rate') => '0.5',
str_contains($name, 'percent') => '85.5',
str_contains($name, 'ratio') => '1.25',
default => '3.14'
};
}
/**
* Generate string example based on parameter name
*/
private function generateStringExample(string $name, mixed $default): string
{
if ($default !== null && is_string($default)) {
return "\"{$default}\"";
}
return match (true) {
str_contains($name, 'email') => '"user@example.com"',
str_contains($name, 'name') => '"example"',
str_contains($name, 'path') => '"/path/to/file"',
str_contains($name, 'url') => '"https://example.com"',
str_contains($name, 'password') => '"secretpassword"',
str_contains($name, 'key') => '"api_key_123"',
str_contains($name, 'token') => '"token_abc123"',
default => "\"{$name}_value\""
};
}
/**
* Generate array example based on parameter name
*/
private function generateArrayExample(string $name): string
{
return match (true) {
str_contains($name, 'file') => '"file1.txt,file2.txt"',
str_contains($name, 'tag') => '"tag1,tag2,tag3"',
str_contains($name, 'option') => '"option1,option2"',
default => '"item1,item2,item3"'
};
}
/**
* Add type-specific usage examples
*/
private function addTypeSpecificExamples(array &$examples, string $commandName, array $parameters): void
{
// Boolean parameter examples
$boolParams = array_filter($parameters, fn ($p) => $p['type']['name'] === 'bool');
if (! empty($boolParams)) {
foreach ($boolParams as $param) {
$examples[] = [
'command' => "{$commandName} --{$param['name']}",
'description' => "Enable {$param['name']} option",
];
}
}
// File/Path parameter examples
$pathParams = array_filter(
$parameters,
fn ($p) =>
str_contains($p['name'], 'path') || str_contains($p['name'], 'file')
);
if (! empty($pathParams)) {
$pathParam = reset($pathParams);
$examples[] = [
'command' => "{$commandName} \"/absolute/path/to/file\"",
'description' => "Use absolute path for {$pathParam['name']}",
];
}
}
/**
* Generate help for multiple commands
*/
public function generateMultipleHelp(array $commands): array
{
$helpTexts = [];
foreach ($commands as $command) {
$help = $this->generateHelp($command);
$helpTexts[$help->name] = $help;
}
return $helpTexts;
}
/**
* Quick help summary for command overview
*/
public function generateQuickHelp(object $command, string $methodName = '__invoke'): string
{
try {
$reflection = new ReflectionMethod($command, $methodName);
$commandInfo = $this->extractCommandInfo($reflection);
$parameters = $this->parameterInspector->inspectCommand($command, $methodName);
$summary = $commandInfo['name'];
if (! empty($commandInfo['description'])) {
$summary .= " - {$commandInfo['description']}";
}
if (! empty($parameters)) {
$paramCount = count($parameters);
$requiredCount = count(array_filter($parameters, fn ($p) => $p['required']));
$summary .= " ({$requiredCount}/{$paramCount} parameters)";
}
return $summary;
} catch (ReflectionException) {
return get_class($command) . " - No help available";
}
}
}

View File

@@ -0,0 +1,294 @@
<?php
declare(strict_types=1);
namespace App\Framework\Console;
use App\Framework\Filesystem\FilePath;
/**
* Command history and favorites management for console TUI
*/
final class CommandHistory
{
private array $history = [];
private array $favorites = [];
private int $maxHistorySize;
private FilePath $historyFile;
private FilePath $favoritesFile;
public function __construct(
int $maxHistorySize = 100,
?FilePath $storageDirectory = null
) {
$this->maxHistorySize = $maxHistorySize;
$storageDir = $storageDirectory ?? FilePath::create(sys_get_temp_dir() . '/console-history');
$this->historyFile = $storageDir->join('command_history.json');
$this->favoritesFile = $storageDir->join('command_favorites.json');
$this->loadHistory();
$this->loadFavorites();
}
/**
* Add a command to history
*/
public function addToHistory(string $commandName): void
{
// Remove if already exists to move to top
$this->history = array_filter($this->history, fn ($entry) => $entry['command'] !== $commandName);
// Add to beginning
array_unshift($this->history, [
'command' => $commandName,
'timestamp' => time(),
'count' => $this->getCommandCount($commandName) + 1,
]);
// Limit history size
if (count($this->history) > $this->maxHistorySize) {
$this->history = array_slice($this->history, 0, $this->maxHistorySize);
}
$this->saveHistory();
}
/**
* Add command to favorites
*/
public function addToFavorites(string $commandName): void
{
if (! $this->isFavorite($commandName)) {
$this->favorites[] = [
'command' => $commandName,
'added_at' => time(),
];
$this->saveFavorites();
}
}
/**
* Remove command from favorites
*/
public function removeFromFavorites(string $commandName): void
{
$this->favorites = array_filter(
$this->favorites,
fn ($entry) => $entry['command'] !== $commandName
);
$this->favorites = array_values($this->favorites); // Re-index
$this->saveFavorites();
}
/**
* Toggle favorite status
*/
public function toggleFavorite(string $commandName): bool
{
if ($this->isFavorite($commandName)) {
$this->removeFromFavorites($commandName);
return false;
} else {
$this->addToFavorites($commandName);
return true;
}
}
/**
* Check if command is in favorites
*/
public function isFavorite(string $commandName): bool
{
return array_reduce(
$this->favorites,
fn ($carry, $entry) => $carry || $entry['command'] === $commandName,
false
);
}
/**
* Get recent command history
*/
public function getRecentHistory(int $limit = 10): array
{
return array_slice($this->history, 0, $limit);
}
/**
* Get frequently used commands
*/
public function getFrequentCommands(int $limit = 10): array
{
$commands = $this->history;
// Sort by usage count (descending)
usort($commands, fn ($a, $b) => $b['count'] <=> $a['count']);
return array_slice($commands, 0, $limit);
}
/**
* Get all favorites
*/
public function getFavorites(): array
{
return $this->favorites;
}
/**
* Get command usage statistics
*/
public function getCommandStats(string $commandName): array
{
$entry = array_find($this->history, fn ($entry) => $entry['command'] === $commandName);
if (! $entry) {
return [
'command' => $commandName,
'count' => 0,
'last_used' => null,
'is_favorite' => $this->isFavorite($commandName),
];
}
return [
'command' => $commandName,
'count' => $entry['count'],
'last_used' => $entry['timestamp'],
'is_favorite' => $this->isFavorite($commandName),
];
}
/**
* Clear all history
*/
public function clearHistory(): void
{
$this->history = [];
$this->saveHistory();
}
/**
* Clear all favorites
*/
public function clearFavorites(): void
{
$this->favorites = [];
$this->saveFavorites();
}
/**
* Get suggestions based on partial input
*/
public function getSuggestions(string $partial, int $limit = 5): array
{
$suggestions = [];
// Add matching favorites first
foreach ($this->favorites as $favorite) {
if (str_starts_with($favorite['command'], $partial)) {
$suggestions[] = [
'command' => $favorite['command'],
'type' => 'favorite',
'score' => 1000, // High priority for favorites
];
}
}
// Add matching recent commands
foreach ($this->history as $entry) {
$command = $entry['command'];
if (str_starts_with($command, $partial) && ! $this->isFavorite($command)) {
$suggestions[] = [
'command' => $command,
'type' => 'recent',
'score' => 500 + $entry['count'], // Score based on usage count
];
}
}
// Sort by score (descending) and limit
usort($suggestions, fn ($a, $b) => $b['score'] <=> $a['score']);
return array_slice($suggestions, 0, $limit);
}
/**
* Get command count from history
*/
private function getCommandCount(string $commandName): int
{
$entry = array_find($this->history, fn ($entry) => $entry['command'] === $commandName);
return $entry ? $entry['count'] : 0;
}
/**
* Load history from file
*/
private function loadHistory(): void
{
if ($this->historyFile->exists()) {
$content = file_get_contents($this->historyFile->toString());
$decoded = json_decode($content, true);
if (is_array($decoded)) {
$this->history = $decoded;
}
}
}
/**
* Save history to file
*/
private function saveHistory(): void
{
$this->ensureDirectoryExists($this->historyFile->getDirectory());
file_put_contents(
$this->historyFile->toString(),
json_encode($this->history, JSON_PRETTY_PRINT)
);
}
/**
* Load favorites from file
*/
private function loadFavorites(): void
{
if ($this->favoritesFile->exists()) {
$content = file_get_contents($this->favoritesFile->toString());
$decoded = json_decode($content, true);
if (is_array($decoded)) {
$this->favorites = $decoded;
}
}
}
/**
* Save favorites to file
*/
private function saveFavorites(): void
{
$this->ensureDirectoryExists($this->favoritesFile->getDirectory());
file_put_contents(
$this->favoritesFile->toString(),
json_encode($this->favorites, JSON_PRETTY_PRINT)
);
}
/**
* Ensure storage directory exists
*/
private function ensureDirectoryExists(FilePath $directory): void
{
if (! $directory->exists()) {
mkdir($directory->toString(), 0755, true);
}
}
}

View File

@@ -118,6 +118,14 @@ final readonly class CommandList implements IteratorAggregate, Countable
return $this->commands;
}
/**
* @return array<int, ConsoleCommand>
*/
public function getAllCommands(): array
{
return array_values($this->commands);
}
public function isEmpty(): bool
{
return empty($this->commands);

View File

@@ -0,0 +1,393 @@
<?php
declare(strict_types=1);
namespace App\Framework\Console;
use App\Framework\Core\ValueObjects\Email;
use App\Framework\Core\ValueObjects\Url;
use ReflectionMethod;
use ReflectionNamedType;
use ReflectionParameter;
/**
* Resolves method parameters from parsed command line arguments
*/
final readonly class CommandParameterResolver
{
public function __construct(
private MethodSignatureAnalyzer $signatureAnalyzer
) {
}
/**
* Resolve method parameters from raw command line arguments
*
* @param string[] $rawArguments
* @return array<int, mixed>
*/
public function resolveParameters(ReflectionMethod $method, array $rawArguments): array
{
// Generate argument definitions from method signature
$definitions = $this->signatureAnalyzer->generateArgumentDefinitions($method);
// Create parser with auto-generated definitions
$parser = new ArgumentParser($definitions);
// Parse command line arguments
$parsedArgs = $parser->parse($rawArguments);
// Resolve each method parameter
$resolvedParams = [];
foreach ($method->getParameters() as $param) {
$value = $this->resolveParameterValue($param, $parsedArgs);
$resolvedParams[] = $value;
}
return $resolvedParams;
}
/**
* Resolve individual parameter value
*/
private function resolveParameterValue(ReflectionParameter $param, ParsedArguments $parsedArgs): mixed
{
$paramName = $param->getName();
$paramType = $param->getType();
// Get raw value from parsed arguments
$rawValue = $parsedArgs->get($paramName);
// Handle null/missing values
if ($rawValue === null || $rawValue === '') {
if ($param->isDefaultValueAvailable()) {
return $param->getDefaultValue();
}
if (! $param->allowsNull()) {
throw new \InvalidArgumentException("Required parameter '{$paramName}' is missing");
}
return null;
}
// Convert to appropriate type
return $this->convertToParameterType($rawValue, $paramType, $paramName);
}
/**
* Convert value to the appropriate parameter type
*/
private function convertToParameterType(mixed $value, ?\ReflectionType $type, string $paramName): mixed
{
if (! $type || ! ($type instanceof ReflectionNamedType)) {
return $value; // No type hint, return as-is
}
$typeName = $type->getName();
try {
return match (strtolower($typeName)) {
'string' => $this->convertToString($value),
'int', 'integer' => $this->convertToInt($value),
'float', 'double' => $this->convertToFloat($value),
'bool', 'boolean' => $this->convertToBool($value),
'array' => $this->convertToArray($value),
default => $this->convertToCustomType($value, $typeName, $paramName)
};
} catch (\Throwable $e) {
$valueString = $this->convertValueToString($value);
throw new \InvalidArgumentException(
"Cannot convert value '{$valueString}' to {$typeName} for parameter '{$paramName}': {$e->getMessage()}"
);
}
}
/**
* Convert value to string
*/
private function convertToString(mixed $value): string
{
if (is_string($value)) {
return $value;
}
if ($value instanceof \BackedEnum) {
return (string) $value->value;
}
if ($value instanceof \UnitEnum) {
return $value->name;
}
if (is_scalar($value)) {
return (string) $value;
}
if (method_exists($value, '__toString')) {
return (string) $value;
}
throw new \InvalidArgumentException('Value cannot be converted to string');
}
/**
* Convert value to integer
*/
private function convertToInt(mixed $value): int
{
if (is_int($value)) {
return $value;
}
if (is_numeric($value)) {
return (int) $value;
}
if (is_string($value) && ctype_digit(ltrim($value, '-'))) {
return (int) $value;
}
throw new \InvalidArgumentException("Value '{$value}' is not a valid integer");
}
/**
* Convert value to float
*/
private function convertToFloat(mixed $value): float
{
if (is_float($value)) {
return $value;
}
if (is_numeric($value)) {
return (float) $value;
}
throw new \InvalidArgumentException("Value '{$value}' is not a valid number");
}
/**
* Convert value to boolean
*/
private function convertToBool(mixed $value): bool
{
if (is_bool($value)) {
return $value;
}
if (is_string($value)) {
$lowered = strtolower(trim($value));
return in_array($lowered, ['true', '1', 'yes', 'on', 'y'], true);
}
if (is_numeric($value)) {
return (bool) $value;
}
return (bool) $value;
}
/**
* Convert value to array
*/
private function convertToArray(mixed $value): array
{
if (is_array($value)) {
return $value;
}
if (is_string($value)) {
// Split comma-separated values
$items = array_map('trim', explode(',', $value));
return array_filter($items, fn ($item) => $item !== '');
}
// Single value becomes array
return [$value];
}
/**
* Convert value to custom type (Value Objects, Enums, etc.)
*/
private function convertToCustomType(mixed $value, string $typeName, string $paramName): mixed
{
// Handle enums first
if (enum_exists($typeName)) {
return $this->convertToEnum($value, $typeName, $paramName);
}
// Handle framework value objects
return match ($typeName) {
Email::class => new Email($this->convertToString($value)),
Url::class => new Url($this->convertToString($value)),
default => $this->attemptGenericConstruction($value, $typeName, $paramName)
};
}
/**
* Convert value to enum
*/
private function convertToEnum(mixed $value, string $enumClass, string $paramName): mixed
{
$stringValue = $this->convertToString($value);
// Try backed enum first (string or int)
if (method_exists($enumClass, 'from')) {
try {
return $enumClass::from($stringValue);
} catch (\ValueError $e) {
// Try with integer conversion for int backed enums
if (is_numeric($stringValue)) {
try {
return $enumClass::from((int) $stringValue);
} catch (\ValueError) {
// Fall through to error below
}
}
}
}
// Try pure enum by name
if (method_exists($enumClass, 'cases')) {
foreach ($enumClass::cases() as $case) {
if (strcasecmp($case->name, $stringValue) === 0) {
return $case;
}
}
}
// Get valid values for error message
$validValues = [];
foreach ($enumClass::cases() as $case) {
if ($case instanceof \BackedEnum) {
$validValues[] = (string) $case->value;
} else {
$validValues[] = $case->name;
}
}
throw new \InvalidArgumentException(
"Invalid value '{$stringValue}' for enum parameter '{$paramName}'. " .
"Valid values are: " . implode(', ', $validValues)
);
}
/**
* Attempt to construct object generically
*/
private function attemptGenericConstruction(mixed $value, string $typeName, string $paramName): mixed
{
if (! class_exists($typeName)) {
throw new \InvalidArgumentException("Unknown type '{$typeName}' for parameter '{$paramName}'");
}
try {
// Try constructor with single string parameter
return new $typeName($this->convertToString($value));
} catch (\Throwable $e) {
throw new \InvalidArgumentException(
"Cannot construct {$typeName} from value '{$value}' for parameter '{$paramName}': {$e->getMessage()}"
);
}
}
/**
* Generate help text for method parameters
*/
public function generateMethodHelp(ReflectionMethod $method, string $commandName): string
{
$definitions = $this->signatureAnalyzer->generateArgumentDefinitions($method);
$helpGenerator = new HelpGenerator(new ConsoleOutput());
$description = $this->extractMethodDescription($method);
return $helpGenerator->generate($commandName, $definitions, $description);
}
/**
* Extract method description from docblock
*/
private function extractMethodDescription(ReflectionMethod $method): string
{
$docComment = $method->getDocComment();
if (! $docComment) {
return '';
}
// Extract first line of description (before any @tags)
$lines = explode("\n", $docComment);
$description = '';
foreach ($lines as $line) {
$line = trim($line, " \t\n\r\0\x0B/*");
if (empty($line) || str_starts_with($line, '@')) {
break;
}
if (! empty($description)) {
$description .= ' ';
}
$description .= $line;
}
return $description;
}
/**
* Create ArgumentParser for a method
*/
public function createParserForMethod(ReflectionMethod $method): ArgumentParser
{
$definitions = $this->signatureAnalyzer->generateArgumentDefinitions($method);
return new ArgumentParser($definitions);
}
/**
* Validate method signature is compatible
*/
public function validateMethodSignature(ReflectionMethod $method): void
{
if (! $this->signatureAnalyzer->isValidCommandMethod($method)) {
throw new \InvalidArgumentException(
"Method {$method->getDeclaringClass()->getName()}::{$method->getName()} " .
"is not compatible with command execution"
);
}
}
/**
* Convert value to string for error messages
*/
private function convertValueToString(mixed $value): string
{
if (is_string($value)) {
return $value;
}
if ($value instanceof \BackedEnum) {
return (string) $value->value;
}
if ($value instanceof \UnitEnum) {
return $value->name;
}
if (is_scalar($value)) {
return (string) $value;
}
if (method_exists($value, '__toString')) {
return (string) $value;
}
return get_class($value);
}
}

View File

@@ -4,11 +4,16 @@ declare(strict_types=1);
namespace App\Framework\Console;
use App\Framework\Console\Performance\ConsolePerformanceCollector;
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\FrameworkException;
use App\Framework\Logging\Logger;
use App\Framework\Logging\ValueObjects\LogContext;
use App\Framework\Performance\Contracts\PerformanceCollectorInterface;
use ReflectionException;
use ReflectionMethod;
use Throwable;
@@ -23,10 +28,19 @@ final readonly class CommandRegistry
/** @var array<string, DiscoveredAttribute> */
private array $discoveredAttributes;
private CommandParameterResolver $parameterResolver;
private ?ConsolePerformanceCollector $performanceCollector;
private ProgressMiddleware $progressMiddleware;
public function __construct(
private Container $container,
DiscoveryRegistry $discoveryRegistry
) {
$this->parameterResolver = new CommandParameterResolver(new MethodSignatureAnalyzer());
$this->performanceCollector = $this->createPerformanceCollector();
$this->progressMiddleware = new ProgressMiddleware();
$this->discoverCommands($discoveryRegistry);
}
@@ -51,6 +65,20 @@ final readonly class CommandRegistry
* @param array<int, string> $arguments
*/
public function executeCommand(string $commandName, array $arguments, ConsoleOutputInterface $output): ExitCode
{
if ($this->performanceCollector) {
return $this->performanceCollector->measureCommand(
$commandName,
fn () => $this->doExecuteCommand($commandName, $arguments, $output),
$arguments,
$output
);
}
return $this->doExecuteCommand($commandName, $arguments, $output);
}
private function doExecuteCommand(string $commandName, array $arguments, ConsoleOutputInterface $output): ExitCode
{
$command = $this->commandList->get($commandName);
$discoveredAttribute = $this->getDiscoveredAttribute($commandName);
@@ -60,8 +88,14 @@ final readonly class CommandRegistry
$className = $discoveredAttribute->className->getFullyQualified();
$methodName = $discoveredAttribute->methodName?->toString() ?? '__invoke';
// Get instance from container
// Performance tracking: Container resolution
$containerStart = microtime(true);
$instance = $this->container->get($className);
$containerDuration = (microtime(true) - $containerStart) * 1000;
if ($this->performanceCollector) {
$this->performanceCollector->recordContainerResolutionTime($commandName, $className, $containerDuration);
}
// Validate command structure
if (! is_object($instance) || ! method_exists($instance, $methodName)) {
@@ -75,21 +109,21 @@ final readonly class CommandRegistry
]);
}
// Create ConsoleInput
$input = new ConsoleInput($arguments, $output);
// Get reflection method for parameter resolution
$reflectionMethod = new ReflectionMethod($className, $methodName);
// Execute command
$startTime = microtime(true);
$result = $instance->$methodName($input, $output);
$executionTime = microtime(true) - $startTime;
// Performance tracking: Validation time
$validationStart = microtime(true);
$this->parameterResolver->validateMethodSignature($reflectionMethod);
$validationDuration = (microtime(true) - $validationStart) * 1000;
// Log long-running commands
if ($executionTime > 30.0) {
$output->writeLine(
sprintf("Warning: Command '%s' took %.2f seconds to execute", $commandName, $executionTime)
);
if ($this->performanceCollector) {
$this->performanceCollector->recordValidationTime($commandName, $validationDuration);
}
// Execute command with automatic parameter resolution
$result = $this->executeCommandWithReflection($instance, $reflectionMethod, $arguments, $output);
return $this->normalizeCommandResult($result);
} catch (Throwable $e) {
@@ -104,6 +138,21 @@ final readonly class CommandRegistry
}
}
private function createPerformanceCollector(): ?ConsolePerformanceCollector
{
try {
if ($this->container->has(PerformanceCollectorInterface::class)) {
$performanceCollector = $this->container->get(PerformanceCollectorInterface::class);
return new ConsolePerformanceCollector($performanceCollector);
}
} catch (Throwable $e) {
// Performance monitoring is optional - don't fail if unavailable
}
return null;
}
private function discoverCommands(DiscoveryRegistry $discoveryRegistry): void
{
$commands = [];
@@ -118,7 +167,15 @@ final readonly class CommandRegistry
} catch (Throwable $e) {
// Log warning but continue with other commands
error_log("Warning: Failed to register command from {$discoveredAttribute->className->getFullyQualified()}: {$e->getMessage()}");
if ($this->container->has(Logger::class)) {
$logger = $this->container->get(Logger::class);
$logger->warning('Failed to register command', LogContext::withData([
'class_name' => $discoveredAttribute->className->getFullyQualified(),
'error' => $e->getMessage(),
'error_class' => get_class($e),
'component' => 'CommandRegistry',
]));
}
}
}
@@ -189,4 +246,116 @@ final readonly class CommandRegistry
return ExitCode::SUCCESS;
}
/**
* Execute command with automatic parameter resolution
*/
private function executeCommandWithReflection(
object $instance,
ReflectionMethod $method,
array $arguments,
ConsoleOutputInterface $output
): ExitCode {
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;
if ($this->performanceCollector) {
$this->performanceCollector->recordParameterResolutionTime($method->getDeclaringClass()->getName(), $parameterDuration);
}
$result = $method->invokeArgs($instance, $resolvedParams);
} else {
// For legacy style, use the progress-aware output
$result = $method->invoke($instance, $input, $progressAwareOutput);
}
return $this->normalizeCommandResult($result);
};
// Wrap execution with ProgressMiddleware
$input = new ConsoleInput($arguments, $output);
return $this->progressMiddleware->handle($input, $output, $commandExecutor, $method, $instance);
} catch (\ArgumentCountError $e) {
throw new \InvalidArgumentException(
"Invalid number of arguments for command. " . $e->getMessage()
);
} catch (\TypeError $e) {
throw new \InvalidArgumentException(
"Type error in command execution: " . $e->getMessage()
);
}
}
/**
* 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
*/
public function generateCommandHelp(string $commandName): string
{
$discoveredAttribute = $this->getDiscoveredAttribute($commandName);
$className = $discoveredAttribute->className->getFullyQualified();
$methodName = $discoveredAttribute->methodName?->toString() ?? '__invoke';
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.";
}
} catch (ReflectionException $e) {
return "Command: {$commandName}\nError generating help: {$e->getMessage()}";
}
}
}

View File

@@ -0,0 +1,592 @@
<?php
declare(strict_types=1);
namespace App\Framework\Console;
use App\Framework\Core\ValueObjects\MethodName;
use ReflectionClass;
use ReflectionException;
use ReflectionMethod;
use ReflectionNamedType;
use ReflectionParameter;
/**
* Validates console command signatures and implementations
*/
final readonly class CommandValidator
{
/**
* Validate a command object and its method signature
*/
public function validateCommand(object $command, ?MethodName $methodName = null): ValidationResult
{
$errors = [];
$warnings = [];
try {
// If no method name provided, find the method with ConsoleCommand attribute
if ($methodName === null) {
$methodName = $this->findConsoleCommandMethod($command);
if ($methodName === null) {
$errors[] = [
'type' => 'no_command_method',
'message' => 'No method with #[ConsoleCommand] attribute found',
'severity' => 'critical',
'suggestion' => 'Add #[ConsoleCommand] attribute to a public method',
];
return new ValidationResult(
valid: false,
errors: $errors,
warnings: $warnings,
commandName: $this->extractCommandName($command)
);
}
}
$reflection = new ReflectionMethod($command, $methodName->toString());
// Validate method exists and is callable
$classValidation = $this->validateClassStructure($command);
$errors = array_merge($errors, $classValidation['errors']);
$warnings = array_merge($warnings, $classValidation['warnings']);
// Validate method signature
$signatureValidation = $this->validateMethodSignature($reflection);
$errors = array_merge($errors, $signatureValidation['errors']);
$warnings = array_merge($warnings, $signatureValidation['warnings']);
// Validate parameters
$parameterValidation = $this->validateParameters($reflection);
$errors = array_merge($errors, $parameterValidation['errors']);
$warnings = array_merge($warnings, $parameterValidation['warnings']);
// Validate return type
$returnValidation = $this->validateReturnType($reflection);
$errors = array_merge($errors, $returnValidation['errors']);
$warnings = array_merge($warnings, $returnValidation['warnings']);
// Validate ConsoleCommand attribute
$attributeValidation = $this->validateCommandAttribute($command, $methodName);
$errors = array_merge($errors, $attributeValidation['errors']);
$warnings = array_merge($warnings, $attributeValidation['warnings']);
} catch (ReflectionException $e) {
$methodNameStr = $methodName ? $methodName->toString() : 'unknown';
$errors[] = [
'type' => 'reflection_error',
'message' => "Could not reflect method '{$methodNameStr}': " . $e->getMessage(),
'severity' => 'critical',
'suggestion' => "Ensure the method '{$methodNameStr}' exists and is accessible",
];
}
return new ValidationResult(
valid: empty($errors),
errors: $errors,
warnings: $warnings,
commandName: $this->extractCommandName($command, $methodName)
);
}
/**
* Find the method with ConsoleCommand attribute
*/
private function findConsoleCommandMethod(object $command): ?MethodName
{
try {
$reflection = new ReflectionClass($command);
$methods = $reflection->getMethods(ReflectionMethod::IS_PUBLIC);
foreach ($methods as $method) {
$attributes = $method->getAttributes(ConsoleCommand::class);
if (! empty($attributes)) {
return MethodName::create($method->getName());
}
}
} catch (ReflectionException) {
// If reflection fails, return null
}
return null;
}
/**
* Validate class structure
*/
private function validateClassStructure(object $command): array
{
$errors = [];
$warnings = [];
$reflection = new ReflectionClass($command);
// Check if class is final
if (! $reflection->isFinal()) {
$warnings[] = [
'type' => 'class_structure',
'message' => 'Command class should be final for better performance and type safety',
'severity' => 'medium',
'suggestion' => 'Add "final" keyword to the class declaration',
];
}
// Check if class is readonly
if (PHP_VERSION_ID >= 80200 && ! $reflection->isReadOnly()) {
$warnings[] = [
'type' => 'class_structure',
'message' => 'Command class should be readonly for immutability',
'severity' => 'low',
'suggestion' => 'Add "readonly" keyword to the class declaration',
];
}
// Check for proper constructor dependency injection
$constructor = $reflection->getConstructor();
if ($constructor && ! $constructor->isPublic()) {
$errors[] = [
'type' => 'constructor',
'message' => 'Constructor must be public for dependency injection',
'severity' => 'critical',
'suggestion' => 'Change constructor visibility to public',
];
}
return ['errors' => $errors, 'warnings' => $warnings];
}
/**
* Validate method signature structure
*/
private function validateMethodSignature(ReflectionMethod $method): array
{
$errors = [];
$warnings = [];
// Check method visibility
if (! $method->isPublic()) {
$errors[] = [
'type' => 'method_visibility',
'message' => 'Command method must be public',
'severity' => 'critical',
'suggestion' => 'Change method visibility to public',
];
}
// Check if method is static
if ($method->isStatic()) {
$errors[] = [
'type' => 'method_static',
'message' => 'Command method should not be static',
'severity' => 'critical',
'suggestion' => 'Remove static keyword from method declaration',
];
}
// Validate parameter count
$parameters = $method->getParameters();
$requiredParams = array_filter($parameters, fn ($p) => ! $p->isOptional());
if (count($requiredParams) < 2) {
$errors[] = [
'type' => 'parameter_count',
'message' => 'Command method must have at least ConsoleInput and ConsoleOutput parameters',
'severity' => 'critical',
'suggestion' => 'Add ConsoleInput and ConsoleOutput parameters to method signature',
];
}
return ['errors' => $errors, 'warnings' => $warnings];
}
/**
* Validate method parameters
*/
private function validateParameters(ReflectionMethod $method): array
{
$errors = [];
$warnings = [];
$parameters = $method->getParameters();
if (count($parameters) < 2) {
return ['errors' => $errors, 'warnings' => $warnings];
}
// Validate first parameter (should be ConsoleInput)
$firstParam = $parameters[0];
$inputValidation = $this->validateConsoleInputParameter($firstParam);
$errors = array_merge($errors, $inputValidation['errors']);
$warnings = array_merge($warnings, $inputValidation['warnings']);
// Validate second parameter (should be ConsoleOutput)
$secondParam = $parameters[1];
$outputValidation = $this->validateConsoleOutputParameter($secondParam);
$errors = array_merge($errors, $outputValidation['errors']);
$warnings = array_merge($warnings, $outputValidation['warnings']);
// Validate additional parameters (business logic parameters)
if (count($parameters) > 2) {
for ($i = 2; $i < count($parameters); $i++) {
$param = $parameters[$i];
$businessValidation = $this->validateBusinessParameter($param, $i);
$errors = array_merge($errors, $businessValidation['errors']);
$warnings = array_merge($warnings, $businessValidation['warnings']);
}
}
return ['errors' => $errors, 'warnings' => $warnings];
}
/**
* Validate ConsoleInput parameter
*/
private function validateConsoleInputParameter(ReflectionParameter $parameter): array
{
$errors = [];
$warnings = [];
$type = $parameter->getType();
if (! $type instanceof ReflectionNamedType) {
$errors[] = [
'type' => 'parameter_type',
'message' => 'First parameter must be typed as ConsoleInput',
'severity' => 'critical',
'suggestion' => 'Add type hint: ConsoleInput $input',
];
return ['errors' => $errors, 'warnings' => $warnings];
}
$expectedTypes = [
'App\Framework\Console\ConsoleInput',
'App\Framework\Console\Input\ConsoleInput',
ConsoleInput::class,
];
if (! in_array($type->getName(), $expectedTypes)) {
$errors[] = [
'type' => 'parameter_type',
'message' => "First parameter must be ConsoleInput, got {$type->getName()}",
'severity' => 'critical',
'suggestion' => 'Change first parameter type to ConsoleInput',
];
}
// Check parameter name convention
$expectedNames = ['input', 'consoleInput'];
if (! in_array($parameter->getName(), $expectedNames)) {
$warnings[] = [
'type' => 'parameter_naming',
'message' => "ConsoleInput parameter should be named 'input' by convention",
'severity' => 'low',
'suggestion' => "Rename parameter to '\$input'",
];
}
return ['errors' => $errors, 'warnings' => $warnings];
}
/**
* Validate ConsoleOutput parameter
*/
private function validateConsoleOutputParameter(ReflectionParameter $parameter): array
{
$errors = [];
$warnings = [];
$type = $parameter->getType();
if (! $type instanceof ReflectionNamedType) {
$errors[] = [
'type' => 'parameter_type',
'message' => 'Second parameter must be typed as ConsoleOutput',
'severity' => 'critical',
'suggestion' => 'Add type hint: ConsoleOutput $output',
];
return ['errors' => $errors, 'warnings' => $warnings];
}
$expectedTypes = [
'App\Framework\Console\ConsoleOutput',
'App\Framework\Console\Output\ConsoleOutput',
'App\Framework\Console\ConsoleOutputInterface',
ConsoleOutput::class,
];
if (! in_array($type->getName(), $expectedTypes)) {
$errors[] = [
'type' => 'parameter_type',
'message' => "Second parameter must be ConsoleOutput, got {$type->getName()}",
'severity' => 'critical',
'suggestion' => 'Change second parameter type to ConsoleOutput',
];
}
// Check parameter name convention
$expectedNames = ['output', 'consoleOutput'];
if (! in_array($parameter->getName(), $expectedNames)) {
$warnings[] = [
'type' => 'parameter_naming',
'message' => "ConsoleOutput parameter should be named 'output' by convention",
'severity' => 'low',
'suggestion' => "Rename parameter to '\$output'",
];
}
return ['errors' => $errors, 'warnings' => $warnings];
}
/**
* Validate business logic parameters
*/
private function validateBusinessParameter(ReflectionParameter $parameter, int $position): array
{
$errors = [];
$warnings = [];
$type = $parameter->getType();
// Check if parameter has type hint
if (! $type) {
$warnings[] = [
'type' => 'parameter_type',
'message' => "Parameter '{$parameter->getName()}' should have a type hint",
'severity' => 'medium',
'suggestion' => 'Add appropriate type hint for better type safety',
];
}
// Check for complex types that might be hard to handle via CLI
if ($type instanceof ReflectionNamedType) {
$typeName = $type->getName();
// Check for problematic types
$problematicTypes = ['resource', 'object', 'callable'];
if (in_array($typeName, $problematicTypes)) {
$errors[] = [
'type' => 'parameter_type',
'message' => "Parameter type '{$typeName}' cannot be provided via command line",
'severity' => 'critical',
'suggestion' => 'Use primitive types (string, int, float, bool) or simple value objects',
];
}
// Check for array without documentation
if ($typeName === 'array') {
$warnings[] = [
'type' => 'parameter_type',
'message' => "Array parameter '{$parameter->getName()}' should have documentation about expected format",
'severity' => 'medium',
'suggestion' => 'Add PHPDoc comment describing the array structure',
];
}
}
// Check if parameter has default value for non-required parameters
if (! $parameter->isOptional() && $position > 2) {
$warnings[] = [
'type' => 'parameter_optional',
'message' => "Consider making parameter '{$parameter->getName()}' optional with a default value",
'severity' => 'low',
'suggestion' => 'Add default value or make parameter optional for better UX',
];
}
return ['errors' => $errors, 'warnings' => $warnings];
}
/**
* Validate return type
*/
private function validateReturnType(ReflectionMethod $method): array
{
$errors = [];
$warnings = [];
$returnType = $method->getReturnType();
if (! $returnType) {
$errors[] = [
'type' => 'return_type',
'message' => 'Command method must have ExitCode return type',
'severity' => 'critical',
'suggestion' => 'Add ": ExitCode" return type to method signature',
];
return ['errors' => $errors, 'warnings' => $warnings];
}
if ($returnType instanceof ReflectionNamedType) {
$expectedTypes = [
'App\Framework\Console\ExitCode',
ExitCode::class,
'int', // for backward compatibility
];
if (! in_array($returnType->getName(), $expectedTypes)) {
$errors[] = [
'type' => 'return_type',
'message' => "Return type must be ExitCode, got {$returnType->getName()}",
'severity' => 'critical',
'suggestion' => 'Change return type to ExitCode',
];
}
// Warn about using int instead of ExitCode
if ($returnType->getName() === 'int') {
$warnings[] = [
'type' => 'return_type',
'message' => 'Consider using ExitCode enum instead of int for better type safety',
'severity' => 'medium',
'suggestion' => 'Change return type from int to ExitCode',
];
}
}
return ['errors' => $errors, 'warnings' => $warnings];
}
/**
* Validate ConsoleCommand attribute
*/
private function validateCommandAttribute(object $command, MethodName $methodName): array
{
$errors = [];
$warnings = [];
try {
$method = new ReflectionMethod($command, $methodName->toString());
$attributes = $method->getAttributes(ConsoleCommand::class);
if (empty($attributes)) {
$errors[] = [
'type' => 'missing_attribute',
'message' => 'Command method must have #[ConsoleCommand] attribute',
'severity' => 'critical',
'suggestion' => 'Add #[ConsoleCommand(name: "command-name", description: "Command description")] attribute',
];
return ['errors' => $errors, 'warnings' => $warnings];
}
$consoleCommandAttribute = $attributes[0];
// Validate attribute arguments
$args = $consoleCommandAttribute->getArguments();
if (empty($args)) {
$errors[] = [
'type' => 'attribute_arguments',
'message' => 'ConsoleCommand attribute must have name and description',
'severity' => 'critical',
'suggestion' => 'Add name and description: #[ConsoleCommand("command-name", "Description")]',
];
} else {
// Check command name
$commandName = $args[0] ?? $args['name'] ?? null;
if (! $commandName || ! is_string($commandName)) {
$errors[] = [
'type' => 'command_name',
'message' => 'Command name must be a non-empty string',
'severity' => 'critical',
'suggestion' => 'Provide valid command name as first argument',
];
} else {
// Validate command name format
if (! preg_match('/^[a-z0-9]+(?:[:-][a-z0-9]+)*$/', $commandName)) {
$warnings[] = [
'type' => 'command_naming',
'message' => 'Command name should follow kebab-case convention (e.g., "user:create")',
'severity' => 'medium',
'suggestion' => 'Use lowercase letters, numbers, and hyphens/colons only',
];
}
}
// Check description
$description = $args[1] ?? $args['description'] ?? null;
if (! $description || ! is_string($description)) {
$warnings[] = [
'type' => 'command_description',
'message' => 'Command should have a descriptive description',
'severity' => 'medium',
'suggestion' => 'Add meaningful description as second argument',
];
} elseif (strlen($description) < 10) {
$warnings[] = [
'type' => 'command_description',
'message' => 'Command description should be more descriptive',
'severity' => 'low',
'suggestion' => 'Provide more detailed description (at least 10 characters)',
];
}
}
} catch (ReflectionException $e) {
$errors[] = [
'type' => 'reflection_error',
'message' => 'Could not validate ConsoleCommand attribute: ' . $e->getMessage(),
'severity' => 'critical',
'suggestion' => 'Check if method exists and is properly defined',
];
}
return ['errors' => $errors, 'warnings' => $warnings];
}
/**
* Extract command name from object
*/
private function extractCommandName(object $command, ?MethodName $methodName = null): ?string
{
try {
// If no method name provided, find the method with ConsoleCommand attribute
if ($methodName === null) {
$methodName = $this->findConsoleCommandMethod($command);
}
if ($methodName !== null) {
$method = new ReflectionMethod($command, $methodName->toString());
$attributes = $method->getAttributes(ConsoleCommand::class);
if (! empty($attributes)) {
$args = $attributes[0]->getArguments();
return $args[0] ?? $args['name'] ?? null;
}
}
} catch (ReflectionException) {
// Ignore
}
return get_class($command);
}
/**
* Validate multiple commands at once
*/
public function validateCommands(array $commands): array
{
$results = [];
foreach ($commands as $command) {
$result = $this->validateCommand($command);
$results[] = $result;
}
return $results;
}
/**
* Quick validation check (only critical errors)
*/
public function isValidCommand(object $command, ?MethodName $methodName = null): bool
{
$result = $this->validateCommand($command, $methodName);
return $result->isValid();
}
}

View File

@@ -0,0 +1,90 @@
<?php
declare(strict_types=1);
namespace App\Framework\Console;
use Attribute;
/**
* Attribute to define command workflows for automated sequences
*/
#[Attribute(Attribute::TARGET_METHOD)]
final readonly class CommandWorkflow
{
public function __construct(
public string $name,
public string $description = '',
public array $steps = [],
public array $prerequisites = [],
public bool $stopOnError = true,
public array $rollbackSteps = [],
public int $timeoutSeconds = 300,
public array $environment = []
) {
}
/**
* Get workflow name
*/
public function getName(): string
{
return $this->name;
}
/**
* Get workflow description
*/
public function getDescription(): string
{
return $this->description;
}
/**
* Get workflow steps
*/
public function getSteps(): array
{
return $this->steps;
}
/**
* Get prerequisites
*/
public function getPrerequisites(): array
{
return $this->prerequisites;
}
/**
* Check if workflow should stop on error
*/
public function shouldStopOnError(): bool
{
return $this->stopOnError;
}
/**
* Get rollback steps
*/
public function getRollbackSteps(): array
{
return $this->rollbackSteps;
}
/**
* Get timeout in seconds
*/
public function getTimeoutSeconds(): int
{
return $this->timeoutSeconds;
}
/**
* Get environment variables
*/
public function getEnvironment(): array
{
return $this->environment;
}
}

View File

@@ -0,0 +1,332 @@
<?php
declare(strict_types=1);
namespace App\Framework\Console\Commands;
use App\Framework\Console\CommandGroup;
use App\Framework\Console\CommandList;
use App\Framework\Console\CommandRegistry;
use App\Framework\Console\ConsoleColor;
use App\Framework\Console\ConsoleCommand;
use App\Framework\Console\ConsoleInput;
use App\Framework\Console\ConsoleOutputInterface;
use App\Framework\Console\ErrorRecovery\CommandSuggestionEngine;
use App\Framework\Console\ExitCode;
#[CommandGroup(
name: 'System',
description: 'System commands for console management and diagnostics',
icon: '⚙️',
priority: 90
)]
final class ErrorRecoveryCommand
{
private ?CommandSuggestionEngine $suggestionEngine = null;
public function __construct(
private readonly CommandRegistry $commandRegistry
) {
}
private function getCommandList(): CommandList
{
return $this->commandRegistry->getCommandList();
}
private function getSuggestionEngine(): CommandSuggestionEngine
{
if ($this->suggestionEngine === null) {
$this->suggestionEngine = new CommandSuggestionEngine($this->getCommandList());
}
return $this->suggestionEngine;
}
#[ConsoleCommand('suggest', 'Get command suggestions for a given input')]
public function suggestCommands(ConsoleInput $input, ConsoleOutputInterface $output): ExitCode
{
$command = $input->getArgument(0);
if ($command === null) {
$output->writeLine("❌ Please provide a command to get suggestions for.", ConsoleColor::RED);
$output->newLine();
$output->writeLine("Usage: suggest <command>", ConsoleColor::YELLOW);
$output->writeLine("Example: suggest database", ConsoleColor::GRAY);
return ExitCode::INVALID_INPUT;
}
$output->writeLine("🔍 Command Suggestions for: '{$command}'", ConsoleColor::BRIGHT_CYAN);
$output->newLine();
$suggestions = $this->getSuggestionEngine()->suggestCommand($command);
if ($suggestions->hasSuggestions()) {
$output->writeLine($suggestions->formatForDisplay(), ConsoleColor::WHITE);
} else {
$output->writeLine("No similar commands found.", ConsoleColor::YELLOW);
$this->showAlternativeHelp($output);
}
return ExitCode::SUCCESS;
}
#[ConsoleCommand('commands:by-category', 'List commands by category')]
public function listCommandsByCategory(ConsoleInput $input, ConsoleOutputInterface $output): ExitCode
{
$category = $input->getArgument(0);
if ($category) {
return $this->showSpecificCategory($category, $output);
}
$output->writeLine("📋 Commands by Category", ConsoleColor::BRIGHT_CYAN);
$output->newLine();
$categories = $this->categorizeCommands();
foreach ($categories as $categoryName => $commands) {
$count = count($commands);
$output->writeLine("📁 {$categoryName} ({$count} commands)", ConsoleColor::BRIGHT_YELLOW);
foreach (array_slice($commands, 0, 5) as $command) {
$output->writeLine("{$command->name} - {$command->description}", ConsoleColor::WHITE);
}
if ($count > 5) {
$remaining = $count - 5;
$output->writeLine(" ... and {$remaining} more", ConsoleColor::GRAY);
}
$output->newLine();
}
$output->writeLine("💡 Use 'commands:by-category <category>' to see all commands in a specific category", ConsoleColor::CYAN);
return ExitCode::SUCCESS;
}
#[ConsoleCommand('commands:search', 'Search commands by keyword')]
public function searchCommands(ConsoleInput $input, ConsoleOutputInterface $output): ExitCode
{
$keyword = $input->getArgument(0);
if ($keyword === null) {
$output->writeLine("❌ Please provide a keyword to search for.", ConsoleColor::RED);
$output->newLine();
$output->writeLine("Usage: commands:search <keyword>", ConsoleColor::YELLOW);
$output->writeLine("Example: commands:search database", ConsoleColor::GRAY);
return ExitCode::INVALID_INPUT;
}
$output->writeLine("🔍 Searching commands for: '{$keyword}'", ConsoleColor::BRIGHT_CYAN);
$output->newLine();
$matches = $this->searchCommandsByKeyword($keyword);
if (empty($matches)) {
$output->writeLine("No commands found matching '{$keyword}'.", ConsoleColor::YELLOW);
$output->newLine();
$this->showSearchHelp($output);
return ExitCode::SUCCESS;
}
$output->writeLine("Found " . count($matches) . " matching commands:", ConsoleColor::GREEN);
$output->newLine();
foreach ($matches as $match) {
$output->writeLine("📌 {$match['command']->name}", ConsoleColor::BRIGHT_WHITE);
$output->writeLine(" {$match['command']->description}", ConsoleColor::WHITE);
$output->writeLine(" Match reason: {$match['reason']}", ConsoleColor::GRAY);
$output->newLine();
}
return ExitCode::SUCCESS;
}
#[ConsoleCommand('commands:similar', 'Find commands similar to a specific command')]
public function findSimilarCommands(ConsoleInput $input, ConsoleOutputInterface $output): ExitCode
{
$commandName = $input->getArgument(0);
if ($commandName === null) {
$output->writeLine("❌ Please provide a command name to find similar commands for.", ConsoleColor::RED);
$output->newLine();
$output->writeLine("Usage: commands:similar <command>", ConsoleColor::YELLOW);
$output->writeLine("Example: commands:similar db:migrate", ConsoleColor::GRAY);
return ExitCode::INVALID_INPUT;
}
if (! $this->getCommandList()->has($commandName)) {
$output->writeLine("❌ Command '{$commandName}' not found.", ConsoleColor::RED);
$output->newLine();
// Use suggestion engine for alternatives
$suggestions = $this->getSuggestionEngine()->suggestCommand($commandName);
if ($suggestions->hasSuggestions()) {
$output->writeLine("Did you mean:", ConsoleColor::YELLOW);
$output->writeLine($suggestions->formatForDisplay(), ConsoleColor::WHITE);
}
return ExitCode::COMMAND_NOT_FOUND;
}
$baseCommand = $this->getCommandList()->get($commandName);
$similar = $this->getSuggestionEngine()->suggestSimilarCommands($baseCommand, 10);
$output->writeLine("🔗 Commands similar to '{$commandName}':", ConsoleColor::BRIGHT_CYAN);
$output->newLine();
if (empty($similar)) {
$output->writeLine("No similar commands found.", ConsoleColor::YELLOW);
return ExitCode::SUCCESS;
}
foreach ($similar as $rel) {
$percentage = round($rel['similarity'] * 100);
$output->writeLine("📌 {$rel['command']->name} ({$percentage}% similarity)", ConsoleColor::BRIGHT_WHITE);
$output->writeLine(" {$rel['command']->description}", ConsoleColor::WHITE);
$output->writeLine(" Relation: {$rel['relation']->getDisplayText()}", ConsoleColor::GRAY);
$output->newLine();
}
return ExitCode::SUCCESS;
}
#[ConsoleCommand('help:interactive', 'Interactive help system')]
public function interactiveHelp(ConsoleInput $input, ConsoleOutputInterface $output): ExitCode
{
$output->writeLine("🤝 Interactive Help System", ConsoleColor::BRIGHT_CYAN);
$output->newLine();
$output->writeLine("What would you like to do?", ConsoleColor::BRIGHT_YELLOW);
$output->writeLine("1. Find a command for a specific task", ConsoleColor::WHITE);
$output->writeLine("2. Explore commands by category", ConsoleColor::WHITE);
$output->writeLine("3. Search commands by keyword", ConsoleColor::WHITE);
$output->writeLine("4. Get help with a specific command", ConsoleColor::WHITE);
$output->writeLine("5. See all available commands", ConsoleColor::WHITE);
$output->newLine();
$output->writeLine("Examples:", ConsoleColor::CYAN);
$output->writeLine(" php console.php suggest database", ConsoleColor::GRAY);
$output->writeLine(" php console.php commands:by-category db", ConsoleColor::GRAY);
$output->writeLine(" php console.php commands:search migration", ConsoleColor::GRAY);
$output->writeLine(" php console.php help db:migrate", ConsoleColor::GRAY);
$output->writeLine(" php console.php help", ConsoleColor::GRAY);
return ExitCode::SUCCESS;
}
private function showSpecificCategory(string $category, ConsoleOutputInterface $output): ExitCode
{
$categories = $this->categorizeCommands();
if (! isset($categories[$category])) {
$output->writeLine("❌ Category '{$category}' not found.", ConsoleColor::RED);
$output->newLine();
$availableCategories = array_keys($categories);
$output->writeLine("Available categories:", ConsoleColor::YELLOW);
foreach ($availableCategories as $cat) {
$output->writeLine("{$cat}", ConsoleColor::WHITE);
}
return ExitCode::COMMAND_NOT_FOUND;
}
$commands = $categories[$category];
$output->writeLine("📁 Commands in category '{$category}' (" . count($commands) . " commands)", ConsoleColor::BRIGHT_CYAN);
$output->newLine();
foreach ($commands as $command) {
$output->writeLine("📌 {$command->name}", ConsoleColor::BRIGHT_WHITE);
$output->writeLine(" {$command->description}", ConsoleColor::WHITE);
$output->newLine();
}
return ExitCode::SUCCESS;
}
private function categorizeCommands(): array
{
$categories = [];
foreach ($this->getCommandList()->getAllCommands() as $command) {
$parts = explode(':', $command->name);
$category = $parts[0];
if (! isset($categories[$category])) {
$categories[$category] = [];
}
$categories[$category][] = $command;
}
ksort($categories);
return $categories;
}
private function searchCommandsByKeyword(string $keyword): array
{
$matches = [];
$keyword = strtolower($keyword);
foreach ($this->getCommandList()->getAllCommands() as $command) {
$reasons = [];
// Check command name
if (str_contains(strtolower($command->name), $keyword)) {
$reasons[] = 'command name';
}
// Check description
if (str_contains(strtolower($command->description), $keyword)) {
$reasons[] = 'description';
}
// Check individual words
$commandWords = preg_split('/[:\-_\s]+/', strtolower($command->name));
$descWords = preg_split('/\s+/', strtolower($command->description));
if (in_array($keyword, $commandWords, true)) {
$reasons[] = 'command word';
}
if (in_array($keyword, $descWords, true)) {
$reasons[] = 'description word';
}
if (! empty($reasons)) {
$matches[] = [
'command' => $command,
'reason' => implode(', ', $reasons),
];
}
}
return $matches;
}
private function showAlternativeHelp(ConsoleOutputInterface $output): void
{
$output->writeLine("💡 Try these alternatives:", ConsoleColor::CYAN);
$output->writeLine(" • Use 'help' to see all available commands", ConsoleColor::WHITE);
$output->writeLine(" • Use 'commands:by-category' to browse by category", ConsoleColor::WHITE);
$output->writeLine(" • Use 'commands:search <keyword>' to search by keyword", ConsoleColor::WHITE);
$output->writeLine(" • Use 'help:interactive' for guided help", ConsoleColor::WHITE);
}
private function showSearchHelp(ConsoleOutputInterface $output): void
{
$output->writeLine("💡 Search tips:", ConsoleColor::CYAN);
$output->writeLine(" • Try simpler keywords (e.g., 'db' instead of 'database')", ConsoleColor::WHITE);
$output->writeLine(" • Try synonyms or related terms", ConsoleColor::WHITE);
$output->writeLine(" • Use 'commands:by-category' to browse categories", ConsoleColor::WHITE);
$output->writeLine(" • Use 'help' to see all commands", ConsoleColor::WHITE);
}
}

View File

@@ -0,0 +1,114 @@
<?php
declare(strict_types=1);
namespace App\Framework\Console\Commands;
use App\Framework\Attributes\ConsoleCommand;
use App\Framework\Config\Environment;
use App\Framework\Config\EnvKey;
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\FileWatcher;
use App\Framework\Http\SseStream;
use Psr\Log\LoggerInterface;
/**
* Console command for running the Hot Reload server
*/
#[ConsoleCommand(
name: 'dev:hot-reload',
description: 'Start the Hot Reload server for development'
)]
final readonly class HotReloadCommand
{
public function __construct(
private Environment $environment,
private ?LoggerInterface $logger = null
) {
}
public function execute(ConsoleInput $input, ConsoleOutput $output): ExitCode
{
// Check if we're in development mode
if (! $this->environment->getBool(EnvKey::APP_DEBUG, false)) {
$output->error('Hot Reload is only available in development mode');
$output->writeln('Set APP_DEBUG=true in your .env file');
return ExitCode::GENERAL_ERROR;
}
$output->title('🔥 Hot Reload Server');
$output->writeln('Starting file watcher for hot reload...');
// Show configuration
$output->section('Configuration');
$output->writeln([
'• Base Path: ' . dirname(__DIR__, 3),
'• Poll Interval: 500ms',
'• Watching: PHP, CSS, JS, Templates',
'• Ignoring: vendor/, var/, storage/, tests/, public/',
]);
// Create components
$fileWatcher = new FileWatcher(FilePath::fromString(dirname(__DIR__, 3)));
$sseStream = new SseStream();
// Create and configure hot reload server
$hotReloadServer = new HotReloadServer($fileWatcher, $sseStream, $this->logger);
// Register shutdown handler
register_shutdown_function(function () use ($hotReloadServer, $output) {
$output->writeln('');
$output->info('Shutting down Hot Reload server...');
$hotReloadServer->stop();
});
// Handle signals for graceful shutdown
if (extension_loaded('pcntl')) {
pcntl_signal(SIGINT, function () use ($hotReloadServer, $output) {
$output->writeln('');
$output->info('Received interrupt signal, shutting down...');
$hotReloadServer->stop();
exit(0);
});
pcntl_signal(SIGTERM, function () use ($hotReloadServer, $output) {
$output->writeln('');
$output->info('Received termination signal, shutting down...');
$hotReloadServer->stop();
exit(0);
});
}
$output->success('Hot Reload server is running!');
$output->writeln([
'',
'Open your browser and navigate to your application.',
'The browser will automatically reload when files change.',
'',
'Press Ctrl+C to stop the server.',
'',
]);
// This would normally start the watcher, but we need to adjust it
// to work without blocking the main thread in a real implementation
$output->comment('Note: File watching implementation needs to be run in a separate process.');
$output->comment('For now, use with the SSE endpoint: /dev/hot-reload');
// Keep the command running
while (true) {
sleep(1);
// Process signals if available
if (extension_loaded('pcntl')) {
pcntl_signal_dispatch();
}
}
return ExitCode::SUCCESS;
}
}

View File

@@ -0,0 +1,257 @@
<?php
declare(strict_types=1);
namespace App\Framework\Console\Commands;
use App\Framework\Console\CommandGroup;
use App\Framework\Console\ConsoleColor;
use App\Framework\Console\ConsoleCommand;
use App\Framework\Console\ConsoleInput;
use App\Framework\Console\ConsoleOutputInterface;
use App\Framework\Console\ExitCode;
use App\Framework\Performance\Contracts\PerformanceCollectorInterface;
use App\Framework\Performance\PerformanceCategory;
#[CommandGroup(
name: 'Performance',
description: 'Console performance monitoring and analysis commands',
icon: '📊',
priority: 80
)]
final readonly class PerformanceStatsCommand
{
public function __construct(
private PerformanceCollectorInterface $performanceCollector
) {
}
#[ConsoleCommand('perf:console', 'Show console command performance statistics')]
public function showConsoleStats(ConsoleInput $input, ConsoleOutputInterface $output): ExitCode
{
$output->writeLine('📊 Console Performance Statistics', ConsoleColor::BRIGHT_CYAN);
$output->newLine();
$consoleMetrics = $this->performanceCollector->getMetrics(PerformanceCategory::CONSOLE);
if (empty($consoleMetrics)) {
$output->writeLine('No console performance metrics available.', ConsoleColor::YELLOW);
$output->writeLine('Performance monitoring may not be enabled or no commands have been executed yet.', ConsoleColor::GRAY);
return ExitCode::SUCCESS;
}
$this->displayPerformanceSummary($output, $consoleMetrics);
$output->newLine();
$this->displayCommandBreakdown($output, $consoleMetrics);
$output->newLine();
$this->displayMemoryUsage($output, $consoleMetrics);
$output->newLine();
$this->displayErrorStats($output, $consoleMetrics);
return ExitCode::SUCCESS;
}
#[ConsoleCommand('perf:console:clear', 'Clear console performance metrics')]
public function clearConsoleStats(ConsoleInput $input, ConsoleOutputInterface $output): ExitCode
{
$output->writeLine('🧹 Clearing Console Performance Metrics', ConsoleColor::BRIGHT_YELLOW);
$output->newLine();
$consoleMetrics = $this->performanceCollector->getMetrics(PerformanceCategory::CONSOLE);
$metricsCount = count($consoleMetrics);
// Clear console-specific metrics
foreach ($consoleMetrics as $metric) {
// Note: Actual clearing would depend on the performance collector implementation
// This is a demonstration of the interface
}
$output->writeLine("✅ Cleared {$metricsCount} console performance metrics", ConsoleColor::GREEN);
return ExitCode::SUCCESS;
}
#[ConsoleCommand('perf:console:export', 'Export console performance metrics to JSON')]
public function exportConsoleStats(string $filename = 'console-performance.json'): ExitCode
{
$consoleMetrics = $this->performanceCollector->getMetrics(PerformanceCategory::CONSOLE);
$exportData = [
'timestamp' => date('c'),
'total_metrics' => count($consoleMetrics),
'metrics' => array_map(fn ($metric) => $metric->toArray(), $consoleMetrics),
'summary' => $this->generateSummaryData($consoleMetrics),
];
file_put_contents($filename, json_encode($exportData, JSON_PRETTY_PRINT));
return ExitCode::SUCCESS;
}
private function displayPerformanceSummary(ConsoleOutputInterface $output, array $metrics): void
{
$summary = $this->generateSummaryData($metrics);
$output->writeLine('🎯 Performance Summary', ConsoleColor::BRIGHT_WHITE);
$output->writeLine('─────────────────────', ConsoleColor::GRAY);
$output->writeLine(sprintf('Total Commands Executed: %d', $summary['total_executions']), ConsoleColor::WHITE);
$output->writeLine(
sprintf('Success Rate: %.1f%%', $summary['success_rate']),
$summary['success_rate'] >= 95 ? ConsoleColor::GREEN :
($summary['success_rate'] >= 85 ? ConsoleColor::YELLOW : ConsoleColor::RED)
);
$output->writeLine(sprintf('Average Execution Time: %.2fms', $summary['average_duration']), ConsoleColor::WHITE);
$output->writeLine(sprintf('Average Memory Usage: %.2fMB', $summary['average_memory']), ConsoleColor::WHITE);
}
private function displayCommandBreakdown(ConsoleOutputInterface $output, array $metrics): void
{
$output->writeLine('📋 Command Usage Breakdown', ConsoleColor::BRIGHT_WHITE);
$output->writeLine('─────────────────────────', ConsoleColor::GRAY);
$commandStats = $this->getCommandStats($metrics);
if (empty($commandStats)) {
$output->writeLine('No command statistics available.', ConsoleColor::GRAY);
return;
}
// Sort by execution count
arsort($commandStats);
foreach (array_slice($commandStats, 0, 10) as $command => $count) {
$output->writeLine(sprintf(' %s: %d executions', $command, $count), ConsoleColor::WHITE);
}
if (count($commandStats) > 10) {
$output->writeLine(sprintf(' ... and %d more commands', count($commandStats) - 10), ConsoleColor::GRAY);
}
}
private function displayMemoryUsage(ConsoleOutputInterface $output, array $metrics): void
{
$output->writeLine('💾 Memory Usage Analysis', ConsoleColor::BRIGHT_WHITE);
$output->writeLine('───────────────────────', ConsoleColor::GRAY);
$memoryMetrics = array_filter($metrics, fn ($metric) => str_contains($metric->getKey(), 'memory'));
if (empty($memoryMetrics)) {
$output->writeLine('No memory usage data available.', ConsoleColor::GRAY);
return;
}
$totalMemory = 0;
$peakMemory = 0;
$count = 0;
foreach ($memoryMetrics as $metric) {
if (str_contains($metric->getKey(), 'memory_usage')) {
$totalMemory += $metric->getValue();
$count++;
} elseif (str_contains($metric->getKey(), 'peak_memory')) {
$peakMemory = max($peakMemory, $metric->getValue());
}
}
if ($count > 0) {
$averageMemory = $totalMemory / $count;
$output->writeLine(sprintf('Average Memory Usage: %.2f MB', $averageMemory), ConsoleColor::WHITE);
}
if ($peakMemory > 0) {
$output->writeLine(
sprintf('Peak Memory Usage: %.2f MB', $peakMemory),
$peakMemory > 100 ? ConsoleColor::RED : ($peakMemory > 50 ? ConsoleColor::YELLOW : ConsoleColor::GREEN)
);
}
}
private function displayErrorStats(ConsoleOutputInterface $output, array $metrics): void
{
$output->writeLine('❌ Error Statistics', ConsoleColor::BRIGHT_WHITE);
$output->writeLine('─────────────────', ConsoleColor::GRAY);
$errorMetrics = array_filter($metrics, fn ($metric) => str_contains($metric->getKey(), 'error'));
if (empty($errorMetrics)) {
$output->writeLine('No errors recorded! 🎉', ConsoleColor::GREEN);
return;
}
$errorsByType = [];
$totalErrors = 0;
foreach ($errorMetrics as $metric) {
$context = $metric->getContext();
$errorType = $context['error_type'] ?? 'Unknown';
$errorsByType[$errorType] = ($errorsByType[$errorType] ?? 0) + $metric->getValue();
$totalErrors += $metric->getValue();
}
$output->writeLine(
sprintf('Total Errors: %d', $totalErrors),
$totalErrors > 0 ? ConsoleColor::RED : ConsoleColor::GREEN
);
if (! empty($errorsByType)) {
$output->writeLine('Error Types:', ConsoleColor::WHITE);
foreach ($errorsByType as $type => $count) {
$output->writeLine(sprintf(' %s: %d', $type, $count), ConsoleColor::RED);
}
}
}
private function generateSummaryData(array $metrics): array
{
$totalExecutions = 0;
$totalErrors = 0;
$totalDuration = 0;
$totalMemory = 0;
$durationCount = 0;
$memoryCount = 0;
foreach ($metrics as $metric) {
$key = $metric->getKey();
if (str_contains($key, '_executions')) {
$totalExecutions += $metric->getValue();
} elseif (str_contains($key, '_error')) {
$totalErrors += $metric->getValue();
} elseif (str_contains($key, 'console_command_') && ! str_contains($key, '_')) {
$totalDuration += $metric->getTotalDuration();
$durationCount++;
} elseif (str_contains($key, 'memory_usage')) {
$totalMemory += $metric->getValue();
$memoryCount++;
}
}
return [
'total_executions' => $totalExecutions,
'total_errors' => $totalErrors,
'success_rate' => $totalExecutions > 0 ? (($totalExecutions - $totalErrors) / $totalExecutions) * 100 : 100,
'average_duration' => $durationCount > 0 ? $totalDuration / $durationCount : 0,
'average_memory' => $memoryCount > 0 ? $totalMemory / $memoryCount : 0,
];
}
private function getCommandStats(array $metrics): array
{
$commandStats = [];
foreach ($metrics as $metric) {
if (str_contains($metric->getKey(), '_executions')) {
$context = $metric->getContext();
$commandName = $context['command_name'] ?? 'unknown';
$commandStats[$commandName] = ($commandStats[$commandName] ?? 0) + $metric->getValue();
}
}
return $commandStats;
}
}

View File

@@ -0,0 +1,384 @@
<?php
declare(strict_types=1);
namespace App\Framework\Console\Commands;
use App\Framework\Console\CommandGroup;
use App\Framework\Console\ConsoleColor;
use App\Framework\Console\ConsoleCommand;
use App\Framework\Console\ConsoleInput;
use App\Framework\Console\ConsoleOutputInterface;
use App\Framework\Console\ExitCode;
use App\Framework\Performance\Contracts\PerformanceCollectorInterface;
use App\Framework\Performance\PerformanceCategory;
#[CommandGroup(
name: 'Performance',
description: 'Console performance monitoring and analysis commands',
icon: '📊',
priority: 80
)]
final readonly class PerformanceTrendsCommand
{
public function __construct(
private PerformanceCollectorInterface $performanceCollector
) {
}
#[ConsoleCommand('perf:trends', 'Show console performance trends and insights')]
public function showPerformanceTrends(ConsoleInput $input, ConsoleOutputInterface $output): ExitCode
{
$output->writeLine('📈 Console Performance Trends', ConsoleColor::BRIGHT_GREEN);
$output->newLine();
$consoleMetrics = $this->performanceCollector->getMetrics(PerformanceCategory::CONSOLE);
if (empty($consoleMetrics)) {
$output->writeLine('No performance data available for trend analysis.', ConsoleColor::YELLOW);
return ExitCode::SUCCESS;
}
$this->displayExecutionTrends($output, $consoleMetrics);
$output->newLine();
$this->displayMemoryTrends($output, $consoleMetrics);
$output->newLine();
$this->displayCommandFrequency($output, $consoleMetrics);
$output->newLine();
$this->displayPerformanceInsights($output, $consoleMetrics);
return ExitCode::SUCCESS;
}
#[ConsoleCommand('perf:benchmark', 'Run performance benchmarks for console commands')]
public function runBenchmarks(ConsoleInput $input, ConsoleOutputInterface $output, int $iterations = 5): ExitCode
{
$output->writeLine('🏃 Console Performance Benchmarks', ConsoleColor::BRIGHT_BLUE);
$output->writeLine(sprintf('Running %d iterations of each command...', $iterations), ConsoleColor::GRAY);
$output->newLine();
// This would require integration with the command registry to run actual benchmarks
$output->writeLine('⚠️ Benchmark functionality requires command registry integration', ConsoleColor::YELLOW);
$output->writeLine('This is a placeholder for future implementation.', ConsoleColor::GRAY);
return ExitCode::SUCCESS;
}
#[ConsoleCommand('perf:health', 'Check console performance health status')]
public function checkPerformanceHealth(ConsoleInput $input, ConsoleOutputInterface $output): ExitCode
{
$output->writeLine('🏥 Console Performance Health Check', ConsoleColor::BRIGHT_MAGENTA);
$output->newLine();
$consoleMetrics = $this->performanceCollector->getMetrics(PerformanceCategory::CONSOLE);
$healthScore = $this->calculateHealthScore($consoleMetrics);
$this->displayHealthScore($output, $healthScore);
$output->newLine();
$this->displayHealthDetails($output, $consoleMetrics, $healthScore);
$output->newLine();
$this->displayHealthRecommendations($output, $healthScore);
return ExitCode::SUCCESS;
}
private function displayExecutionTrends(ConsoleOutputInterface $output, array $metrics): void
{
$output->writeLine('⏱️ Execution Time Trends', ConsoleColor::BRIGHT_WHITE);
$output->writeLine('─────────────────────────', ConsoleColor::GRAY);
$executionMetrics = array_filter(
$metrics,
fn ($metric) =>
str_contains($metric->getKey(), 'console_command_') &&
! str_contains($metric->getKey(), '_error') &&
! str_contains($metric->getKey(), '_memory')
);
if (empty($executionMetrics)) {
$output->writeLine('No execution time data available.', ConsoleColor::GRAY);
return;
}
$fastCommands = 0;
$mediumCommands = 0;
$slowCommands = 0;
$totalDuration = 0;
$commandCount = 0;
foreach ($executionMetrics as $metric) {
$avgDuration = $metric->getAverageDuration();
$totalDuration += $metric->getTotalDuration();
$commandCount++;
if ($avgDuration < 100) {
$fastCommands++;
} elseif ($avgDuration < 1000) {
$mediumCommands++;
} else {
$slowCommands++;
}
}
$output->writeLine(sprintf('Fast Commands (<100ms): %d', $fastCommands), ConsoleColor::GREEN);
$output->writeLine(sprintf('Medium Commands (100-1000ms): %d', $mediumCommands), ConsoleColor::YELLOW);
$output->writeLine(sprintf('Slow Commands (>1000ms): %d', $slowCommands), ConsoleColor::RED);
if ($commandCount > 0) {
$avgOverall = $totalDuration / $commandCount;
$output->writeLine(sprintf('Overall Average: %.2fms', $avgOverall), ConsoleColor::WHITE);
}
}
private function displayMemoryTrends(ConsoleOutputInterface $output, array $metrics): void
{
$output->writeLine('💾 Memory Usage Trends', ConsoleColor::BRIGHT_WHITE);
$output->writeLine('────────────────────', ConsoleColor::GRAY);
$memoryMetrics = array_filter($metrics, fn ($metric) => str_contains($metric->getKey(), 'memory'));
if (empty($memoryMetrics)) {
$output->writeLine('No memory usage data available.', ConsoleColor::GRAY);
return;
}
$lowMemory = 0;
$mediumMemory = 0;
$highMemory = 0;
$totalMemory = 0;
$count = 0;
foreach ($memoryMetrics as $metric) {
$memory = $metric->getValue();
$totalMemory += $memory;
$count++;
if ($memory < 10) {
$lowMemory++;
} elseif ($memory < 50) {
$mediumMemory++;
} else {
$highMemory++;
}
}
$output->writeLine(sprintf('Low Memory Usage (<10MB): %d commands', $lowMemory), ConsoleColor::GREEN);
$output->writeLine(sprintf('Medium Memory Usage (10-50MB): %d commands', $mediumMemory), ConsoleColor::YELLOW);
$output->writeLine(sprintf('High Memory Usage (>50MB): %d commands', $highMemory), ConsoleColor::RED);
if ($count > 0) {
$avgMemory = $totalMemory / $count;
$output->writeLine(sprintf('Average Memory Usage: %.2fMB', $avgMemory), ConsoleColor::WHITE);
}
}
private function displayCommandFrequency(ConsoleOutputInterface $output, array $metrics): void
{
$output->writeLine('📊 Command Usage Frequency', ConsoleColor::BRIGHT_WHITE);
$output->writeLine('─────────────────────────', ConsoleColor::GRAY);
$executionCounts = [];
foreach ($metrics as $metric) {
if (str_contains($metric->getKey(), '_executions')) {
$context = $metric->getContext();
$commandName = $context['command_name'] ?? 'unknown';
$executionCounts[$commandName] = ($executionCounts[$commandName] ?? 0) + $metric->getValue();
}
}
if (empty($executionCounts)) {
$output->writeLine('No execution frequency data available.', ConsoleColor::GRAY);
return;
}
arsort($executionCounts);
$topCommands = array_slice($executionCounts, 0, 5, true);
foreach ($topCommands as $command => $count) {
$bar = str_repeat('█', min(20, intval($count / max($executionCounts) * 20)));
$output->writeLine(sprintf(' %-20s %s (%d)', $command, $bar, $count), ConsoleColor::CYAN);
}
}
private function displayPerformanceInsights(ConsoleOutputInterface $output, array $metrics): void
{
$output->writeLine('💡 Performance Insights', ConsoleColor::BRIGHT_WHITE);
$output->writeLine('──────────────────────', ConsoleColor::GRAY);
$insights = $this->generateInsights($metrics);
if (empty($insights)) {
$output->writeLine('No specific insights available.', ConsoleColor::GRAY);
return;
}
foreach ($insights as $insight) {
$output->writeLine(" {$insight}", ConsoleColor::WHITE);
}
}
private function calculateHealthScore(array $metrics): array
{
$score = 100;
$issues = [];
// Check error rate
$totalExecutions = 0;
$totalErrors = 0;
foreach ($metrics as $metric) {
if (str_contains($metric->getKey(), '_executions')) {
$totalExecutions += $metric->getValue();
} elseif (str_contains($metric->getKey(), '_error')) {
$totalErrors += $metric->getValue();
}
}
$errorRate = $totalExecutions > 0 ? ($totalErrors / $totalExecutions) * 100 : 0;
if ($errorRate > 10) {
$score -= 30;
$issues[] = 'High error rate detected';
} elseif ($errorRate > 5) {
$score -= 15;
$issues[] = 'Moderate error rate detected';
}
// Check for slow commands
$slowCommandCount = 0;
foreach ($metrics as $metric) {
if (str_contains($metric->getKey(), 'console_command_') &&
! str_contains($metric->getKey(), '_error') &&
! str_contains($metric->getKey(), '_memory')) {
if ($metric->getAverageDuration() > 5000) {
$slowCommandCount++;
}
}
}
if ($slowCommandCount > 3) {
$score -= 20;
$issues[] = 'Multiple slow commands detected';
} elseif ($slowCommandCount > 0) {
$score -= 10;
$issues[] = 'Some slow commands detected';
}
// Check memory usage
$highMemoryCount = 0;
foreach ($metrics as $metric) {
if (str_contains($metric->getKey(), 'memory_usage') && $metric->getValue() > 100) {
$highMemoryCount++;
}
}
if ($highMemoryCount > 0) {
$score -= 15;
$issues[] = 'High memory usage detected';
}
return [
'score' => max(0, $score),
'issues' => $issues,
'total_executions' => $totalExecutions,
'error_rate' => $errorRate,
'slow_commands' => $slowCommandCount,
'high_memory_commands' => $highMemoryCount,
];
}
private function displayHealthScore(ConsoleOutputInterface $output, array $health): void
{
$score = $health['score'];
$color = $score >= 90 ? ConsoleColor::GREEN :
($score >= 70 ? ConsoleColor::YELLOW : ConsoleColor::RED);
$status = $score >= 90 ? 'Excellent' :
($score >= 70 ? 'Good' :
($score >= 50 ? 'Fair' : 'Poor'));
$output->writeLine(sprintf('Health Score: %d/100 (%s)', $score, $status), $color);
}
private function displayHealthDetails(ConsoleOutputInterface $output, array $metrics, array $health): void
{
$output->writeLine('📋 Health Details', ConsoleColor::BRIGHT_WHITE);
$output->writeLine('───────────────', ConsoleColor::GRAY);
$output->writeLine(sprintf('Total Executions: %d', $health['total_executions']), ConsoleColor::WHITE);
$output->writeLine(
sprintf('Error Rate: %.2f%%', $health['error_rate']),
$health['error_rate'] > 5 ? ConsoleColor::RED : ConsoleColor::GREEN
);
$output->writeLine(
sprintf('Slow Commands: %d', $health['slow_commands']),
$health['slow_commands'] > 0 ? ConsoleColor::YELLOW : ConsoleColor::GREEN
);
$output->writeLine(
sprintf('High Memory Commands: %d', $health['high_memory_commands']),
$health['high_memory_commands'] > 0 ? ConsoleColor::YELLOW : ConsoleColor::GREEN
);
}
private function displayHealthRecommendations(ConsoleOutputInterface $output, array $health): void
{
if (empty($health['issues'])) {
$output->writeLine('🎉 No performance issues detected!', ConsoleColor::GREEN);
return;
}
$output->writeLine('⚠️ Recommendations', ConsoleColor::BRIGHT_WHITE);
$output->writeLine('─────────────────', ConsoleColor::GRAY);
foreach ($health['issues'] as $issue) {
$output->writeLine("{$issue}", ConsoleColor::YELLOW);
}
}
private function generateInsights(array $metrics): array
{
$insights = [];
// Analyze command patterns
$commandCounts = [];
foreach ($metrics as $metric) {
if (str_contains($metric->getKey(), '_executions')) {
$context = $metric->getContext();
$commandName = $context['command_name'] ?? 'unknown';
$commandCounts[$commandName] = ($commandCounts[$commandName] ?? 0) + $metric->getValue();
}
}
if (! empty($commandCounts)) {
$mostUsed = array_keys($commandCounts, max($commandCounts))[0];
$insights[] = "Most frequently used command: {$mostUsed}";
if (count($commandCounts) > 5) {
$insights[] = "You're using a diverse set of commands (" . count($commandCounts) . " different commands)";
}
}
// Memory insights
$memoryMetrics = array_filter($metrics, fn ($metric) => str_contains($metric->getKey(), 'memory_usage'));
if (! empty($memoryMetrics)) {
$totalMemory = array_sum(array_map(fn ($metric) => $metric->getValue(), $memoryMetrics));
$avgMemory = $totalMemory / count($memoryMetrics);
if ($avgMemory < 5) {
$insights[] = "Memory usage is very efficient (avg: " . number_format($avgMemory, 1) . "MB)";
} elseif ($avgMemory > 50) {
$insights[] = "Memory usage is higher than expected (avg: " . number_format($avgMemory, 1) . "MB)";
}
}
return $insights;
}
}

View File

@@ -0,0 +1,301 @@
<?php
declare(strict_types=1);
namespace App\Framework\Console\Commands;
use App\Framework\Console\CommandGroup;
use App\Framework\Console\ConsoleColor;
use App\Framework\Console\ConsoleCommand;
use App\Framework\Console\ConsoleInput;
use App\Framework\Console\ConsoleOutputInterface;
use App\Framework\Console\ExitCode;
use App\Framework\Console\Progress\LongRunning;
use App\Framework\Console\Progress\ProgressAwareOutput;
use App\Framework\Console\Progress\ProgressType;
#[CommandGroup(
name: 'Demo',
description: 'Demonstration commands for framework features',
icon: '🎯',
priority: 100
)]
final readonly class ProgressDemoCommand
{
#[ConsoleCommand('demo:progress', 'Demonstrate progress tracking features')]
public function demoProgress(ConsoleInput $input, ConsoleOutputInterface $output): ExitCode
{
$progressOutput = new ProgressAwareOutput($output);
$output->writeLine("🎯 Progress Tracking Demonstration", ConsoleColor::BRIGHT_CYAN);
$output->newLine();
// Demo 1: Basic Progress Tracker
$output->writeLine("1. Basic Progress Tracker:", ConsoleColor::BRIGHT_YELLOW);
$this->demoProgressTracker($progressOutput);
$output->newLine();
// Demo 2: Spinner Progress
$output->writeLine("2. Spinner Progress (indeterminate):", ConsoleColor::BRIGHT_YELLOW);
$this->demoSpinnerProgress($progressOutput);
$output->newLine();
// Demo 3: Progress Bar
$output->writeLine("3. Simple Progress Bar:", ConsoleColor::BRIGHT_YELLOW);
$this->demoProgressBar($progressOutput);
$output->newLine();
// Demo 4: Nested Progress
$output->writeLine("4. Nested Progress Operations:", ConsoleColor::BRIGHT_YELLOW);
$this->demoNestedProgress($progressOutput);
$output->writeLine("✅ All progress demos completed!", ConsoleColor::GREEN);
return ExitCode::SUCCESS;
}
#[ConsoleCommand('demo:migration', 'Simulate database migration with progress')]
public function demoMigration(ConsoleInput $input, ConsoleOutputInterface $output): ExitCode
{
$progressOutput = new ProgressAwareOutput($output);
$output->writeLine("📦 Simulating Database Migration", ConsoleColor::BRIGHT_CYAN);
$output->newLine();
$migrationFiles = [
'2024_01_01_create_users_table.php',
'2024_01_02_create_posts_table.php',
'2024_01_03_add_user_indexes.php',
'2024_01_04_create_comments_table.php',
'2024_01_05_add_foreign_keys.php',
];
return $progressOutput->withProgress(
count($migrationFiles),
'Running Migrations',
function ($progress) use ($migrationFiles) {
foreach ($migrationFiles as $i => $file) {
$progress->setTask("Processing {$file}");
// Simulate migration steps
usleep(500000); // 0.5 seconds
$progress->advance(1);
}
return ExitCode::SUCCESS;
}
);
}
#[ConsoleCommand('demo:backup', 'Simulate backup process with progress')]
public function demoBackup(ConsoleInput $input, ConsoleOutputInterface $output): ExitCode
{
$progressOutput = new ProgressAwareOutput($output);
$output->writeLine("💾 Simulating Database Backup", ConsoleColor::BRIGHT_CYAN);
$output->newLine();
return $progressOutput->withSpinner(
'Creating Backup',
function ($spinner) {
$tasks = [
'Connecting to database',
'Analyzing tables',
'Exporting users table',
'Exporting posts table',
'Exporting comments table',
'Compressing backup file',
'Verifying backup integrity',
];
foreach ($tasks as $task) {
$spinner->setTask($task);
usleep(800000); // 0.8 seconds
$spinner->advance();
}
$spinner->finish('Backup completed successfully');
return ExitCode::SUCCESS;
}
);
}
private function demoProgressTracker(ProgressAwareOutput $output): void
{
$progress = $output->createProgress(10, 'Processing Items');
for ($i = 1; $i <= 10; $i++) {
usleep(200000); // 0.2 seconds
$progress->advance(1, "Processing item {$i}");
}
$progress->finish('All items processed');
}
private function demoSpinnerProgress(ProgressAwareOutput $output): void
{
$spinner = $output->createSpinner('Analyzing Data');
$tasks = ['Loading', 'Analyzing', 'Computing', 'Finalizing'];
foreach ($tasks as $task) {
$spinner->setTask($task);
usleep(600000); // 0.6 seconds
$spinner->advance();
}
$spinner->finish('Analysis complete');
}
private function demoProgressBar(ProgressAwareOutput $output): void
{
$bar = $output->createProgressBar(40);
for ($i = 0; $i <= 100; $i += 10) {
$bar->render($i / 100, "Step {$i}%");
usleep(150000); // 0.15 seconds
}
$bar->finish('Simple progress complete');
}
private function demoNestedProgress(ProgressAwareOutput $output): void
{
$mainProgress = $output->createProgress(3, 'Main Operation');
// Phase 1
$mainProgress->setTask('Phase 1: Initialization');
$subProgress = $output->createProgress(5, 'Initializing');
for ($i = 1; $i <= 5; $i++) {
$subProgress->advance(1, "Init step {$i}");
usleep(100000);
}
$subProgress->finish();
$mainProgress->advance(1);
$output->newLine();
// Phase 2
$mainProgress->setTask('Phase 2: Processing');
$subProgress = $output->createProgress(8, 'Processing');
for ($i = 1; $i <= 8; $i++) {
$subProgress->advance(1, "Process step {$i}");
usleep(100000);
}
$subProgress->finish();
$mainProgress->advance(1);
$output->newLine();
// Phase 3
$mainProgress->setTask('Phase 3: Cleanup');
$subProgress = $output->createProgress(3, 'Cleaning Up');
for ($i = 1; $i <= 3; $i++) {
$subProgress->advance(1, "Cleanup step {$i}");
usleep(100000);
}
$subProgress->finish();
$mainProgress->advance(1);
$mainProgress->finish('Nested operation complete');
}
#[ConsoleCommand('demo:auto-progress', 'Demonstrate automatic progress detection')]
#[LongRunning(estimatedSteps: 15, progressType: ProgressType::TRACKER, title: 'Auto Progress Demo')]
public function demoAutoProgress(ConsoleInput $input, ConsoleOutputInterface $output): ExitCode
{
// This command will automatically get progress tracking via ProgressMiddleware
// The middleware detects the LongRunning attribute and sets up progress
$output->writeLine("🤖 Automatic Progress Detection Demo", ConsoleColor::BRIGHT_CYAN);
$output->writeLine("This command uses the #[LongRunning] attribute for automatic progress", ConsoleColor::GRAY);
$output->newLine();
// Simulate a long-running operation
$steps = [
'Initializing system',
'Loading configuration',
'Connecting to database',
'Reading user data',
'Processing records batch 1',
'Processing records batch 2',
'Processing records batch 3',
'Validating data integrity',
'Generating reports',
'Optimizing indexes',
'Creating backups',
'Sending notifications',
'Cleaning temporary files',
'Updating statistics',
'Finalizing operation',
];
// Get progress from ProgressAwareOutput if available
if ($output instanceof ProgressAwareOutput) {
$progress = $output->getCurrentProgress();
if ($progress) {
foreach ($steps as $i => $step) {
$progress->advance(1, $step);
usleep(300000); // 0.3 seconds per step
}
$progress->finish('Auto progress demo completed');
} else {
$output->writeLine("No automatic progress detected, running without progress...", ConsoleColor::YELLOW);
foreach ($steps as $step) {
$output->writeLine("$step", ConsoleColor::GRAY);
usleep(300000);
}
}
} else {
$output->writeLine("Standard output - no progress tracking available", ConsoleColor::YELLOW);
}
$output->writeLine("✅ Auto progress demo completed!", ConsoleColor::GREEN);
return ExitCode::SUCCESS;
}
#[ConsoleCommand('demo:spinner-auto', 'Demonstrate automatic spinner progress')]
#[LongRunning(progressType: ProgressType::SPINNER, title: 'Processing Data')]
public function demoSpinnerAuto(ConsoleInput $input, ConsoleOutputInterface $output): ExitCode
{
// This will automatically get a spinner via ProgressMiddleware
$output->writeLine("🌀 Automatic Spinner Demo", ConsoleColor::BRIGHT_CYAN);
$output->writeLine("This command uses the #[LongRunning] attribute with SPINNER type", ConsoleColor::GRAY);
$output->newLine();
$tasks = [
'Connecting to API',
'Fetching data',
'Processing results',
'Analyzing patterns',
'Generating insights',
'Preparing output',
];
if ($output instanceof ProgressAwareOutput) {
$spinner = $output->getCurrentProgress();
if ($spinner) {
foreach ($tasks as $task) {
$spinner->setTask($task);
usleep(800000); // 0.8 seconds per task
$spinner->advance();
}
$spinner->finish('Data processing completed');
} else {
foreach ($tasks as $task) {
$output->writeLine("$task", ConsoleColor::GRAY);
usleep(800000);
}
}
}
$output->writeLine("✅ Spinner demo completed!", ConsoleColor::GREEN);
return ExitCode::SUCCESS;
}
}

View File

@@ -0,0 +1,254 @@
<?php
declare(strict_types=1);
namespace App\Framework\Console\Commands;
use App\Framework\Console\CommandGroup;
use App\Framework\Console\ConsoleColor;
use App\Framework\Console\ConsoleCommand;
use App\Framework\Console\ConsoleInput;
use App\Framework\Console\ConsoleOutputInterface;
use App\Framework\Console\ExitCode;
use App\Framework\Performance\Contracts\PerformanceCollectorInterface;
use App\Framework\Performance\PerformanceCategory;
#[CommandGroup(
name: 'Performance',
description: 'Console performance monitoring and analysis commands',
icon: '📊',
priority: 80
)]
final readonly class SlowCommandsCommand
{
public function __construct(
private PerformanceCollectorInterface $performanceCollector
) {
}
#[ConsoleCommand('perf:slow-commands', 'Show slowest console commands')]
public function showSlowCommands(ConsoleInput $input, ConsoleOutputInterface $output, int $limit = 10, float $threshold = 1000.0): ExitCode
{
$output->writeLine('🐌 Slowest Console Commands', ConsoleColor::BRIGHT_YELLOW);
$output->writeLine(sprintf('Threshold: %.0fms | Showing top %d', $threshold, $limit), ConsoleColor::GRAY);
$output->newLine();
$consoleMetrics = $this->performanceCollector->getMetrics(PerformanceCategory::CONSOLE);
$slowCommands = $this->findSlowCommands($consoleMetrics, $threshold);
if (empty($slowCommands)) {
$output->writeLine('🎉 No slow commands found!', ConsoleColor::GREEN);
$output->writeLine(sprintf('All commands executed faster than %.0fms', $threshold), ConsoleColor::GRAY);
return ExitCode::SUCCESS;
}
// Sort by duration (descending)
usort($slowCommands, fn ($a, $b) => $b['duration'] <=> $a['duration']);
$slowCommands = array_slice($slowCommands, 0, $limit);
$this->displaySlowCommandsTable($output, $slowCommands);
$output->newLine();
$this->displayPerformanceRecommendations($output, $slowCommands);
return ExitCode::SUCCESS;
}
#[ConsoleCommand('perf:watch-slow', 'Monitor for slow commands in real-time')]
public function watchSlowCommands(ConsoleInput $input, ConsoleOutputInterface $output, float $threshold = 5000.0): ExitCode
{
$output->writeLine('👀 Monitoring Slow Commands', ConsoleColor::BRIGHT_CYAN);
$output->writeLine(sprintf('Threshold: %.0fms | Press Ctrl+C to stop', $threshold), ConsoleColor::GRAY);
$output->newLine();
$lastMetricsCount = 0;
while (true) {
$consoleMetrics = $this->performanceCollector->getMetrics(PerformanceCategory::CONSOLE);
$currentMetricsCount = count($consoleMetrics);
// Check if new metrics were added
if ($currentMetricsCount > $lastMetricsCount) {
$newMetrics = array_slice($consoleMetrics, $lastMetricsCount);
$this->checkForSlowCommands($output, $newMetrics, $threshold);
$lastMetricsCount = $currentMetricsCount;
}
usleep(500000); // Wait 0.5 seconds
}
}
#[ConsoleCommand('perf:profile-command', 'Profile a specific command execution')]
public function profileCommand(ConsoleInput $input, ConsoleOutputInterface $output, string $commandName, string ...$args): ExitCode
{
$output->writeLine("🔍 Profiling Command: {$commandName}", ConsoleColor::BRIGHT_BLUE);
$output->newLine();
// Get baseline metrics
$beforeMetrics = $this->getCommandMetrics($commandName);
$output->writeLine('📊 Baseline metrics captured', ConsoleColor::GRAY);
$output->writeLine("Execute the command now: php console.php {$commandName} " . implode(' ', $args), ConsoleColor::YELLOW);
$output->writeLine('Press Enter when command execution is complete...', ConsoleColor::GRAY);
// Wait for user input
fgets(STDIN);
// Get after metrics
$afterMetrics = $this->getCommandMetrics($commandName);
$this->displayProfilingResults($output, $commandName, $beforeMetrics, $afterMetrics);
return ExitCode::SUCCESS;
}
private function findSlowCommands(array $metrics, float $threshold): array
{
$slowCommands = [];
foreach ($metrics as $metric) {
$key = $metric->getKey();
// Look for command timing metrics
if (str_contains($key, 'console_command_') && ! str_contains($key, '_error') && ! str_contains($key, '_memory')) {
$duration = $metric->getTotalDuration();
if ($duration >= $threshold) {
$context = $metric->getContext();
$commandName = $context['command_name'] ?? $this->extractCommandNameFromKey($key);
$slowCommands[] = [
'command' => $commandName,
'duration' => $duration,
'executions' => $metric->getCount(),
'average_duration' => $metric->getAverageDuration(),
'memory_usage' => $this->getMemoryUsageForCommand($metrics, $commandName),
];
}
}
}
return $slowCommands;
}
private function displaySlowCommandsTable(ConsoleOutputInterface $output, array $slowCommands): void
{
$output->writeLine('┌─────────────────────────────────────────────────────────────────────┐', ConsoleColor::GRAY);
$output->writeLine('│ Command │ Duration │ Executions │ Avg │ Memory │', ConsoleColor::GRAY);
$output->writeLine('├─────────────────────────────────────────────────────────────────────┤', ConsoleColor::GRAY);
foreach ($slowCommands as $command) {
$commandName = str_pad(substr($command['command'], 0, 25), 26);
$duration = str_pad(sprintf('%.0fms', $command['duration']), 8);
$executions = str_pad((string) $command['executions'], 10);
$average = str_pad(sprintf('%.0fms', $command['average_duration']), 6);
$memory = str_pad(sprintf('%.1fMB', $command['memory_usage']), 6);
$color = $command['duration'] > 10000 ? ConsoleColor::RED :
($command['duration'] > 5000 ? ConsoleColor::YELLOW : ConsoleColor::WHITE);
$output->writeLine("{$commandName}{$duration}{$executions}{$average}{$memory}", $color);
}
$output->writeLine('└─────────────────────────────────────────────────────────────────────┘', ConsoleColor::GRAY);
}
private function displayPerformanceRecommendations(ConsoleOutputInterface $output, array $slowCommands): void
{
$output->writeLine('💡 Performance Recommendations', ConsoleColor::BRIGHT_WHITE);
$output->writeLine('─────────────────────────────', ConsoleColor::GRAY);
$recommendations = [];
foreach ($slowCommands as $command) {
if ($command['duration'] > 30000) {
$recommendations[] = "🔴 {$command['command']}: Consider breaking into smaller operations or adding progress indicators";
} elseif ($command['duration'] > 10000) {
$recommendations[] = "🟡 {$command['command']}: Investigate for optimization opportunities";
}
if ($command['memory_usage'] > 100) {
$recommendations[] = "💾 {$command['command']}: High memory usage - consider streaming or batch processing";
}
}
if (empty($recommendations)) {
$output->writeLine('All commands are performing within acceptable limits! ✅', ConsoleColor::GREEN);
} else {
foreach ($recommendations as $recommendation) {
$output->writeLine(" {$recommendation}", ConsoleColor::WHITE);
}
}
}
private function checkForSlowCommands(ConsoleOutputInterface $output, array $newMetrics, float $threshold): void
{
foreach ($newMetrics as $metric) {
$duration = $metric->getTotalDuration();
if ($duration >= $threshold) {
$context = $metric->getContext();
$commandName = $context['command_name'] ?? 'unknown';
$timestamp = date('H:i:s');
$output->writeLine(
sprintf('[%s] 🐌 Slow command detected: %s (%.0fms)', $timestamp, $commandName, $duration),
ConsoleColor::RED
);
}
}
}
private function getCommandMetrics(string $commandName): array
{
$allMetrics = $this->performanceCollector->getMetrics(PerformanceCategory::CONSOLE);
return array_filter($allMetrics, function ($metric) use ($commandName) {
return str_contains($metric->getKey(), $commandName);
});
}
private function displayProfilingResults(ConsoleOutputInterface $output, string $commandName, array $before, array $after): void
{
$output->writeLine("📈 Profiling Results for: {$commandName}", ConsoleColor::BRIGHT_GREEN);
$output->writeLine('───────────────────────────────────────', ConsoleColor::GRAY);
$newMetrics = array_diff_key($after, $before);
if (empty($newMetrics)) {
$output->writeLine('No new metrics detected for this command execution.', ConsoleColor::YELLOW);
return;
}
foreach ($newMetrics as $metric) {
$output->writeLine(sprintf(' %s: %s', $metric->getKey(), $metric->getFormattedValue()), ConsoleColor::WHITE);
}
}
private function extractCommandNameFromKey(string $key): string
{
if (preg_match('/console_command_(.+)/', $key, $matches)) {
return $matches[1];
}
return 'unknown';
}
private function getMemoryUsageForCommand(array $metrics, string $commandName): float
{
foreach ($metrics as $metric) {
if (str_contains($metric->getKey(), "console_memory_usage") &&
str_contains($metric->getKey(), $commandName)) {
return $metric->getValue();
}
}
return 0.0;
}
}

View File

@@ -0,0 +1,281 @@
<?php
declare(strict_types=1);
namespace App\Framework\Console\Components;
use App\Framework\Console\CommandGroupRegistry;
use App\Framework\Console\CommandHistory;
use App\Framework\Console\ConsoleColor;
use App\Framework\Console\ConsoleOutputInterface;
use App\Framework\Console\ExitCode;
use App\Framework\Console\Screen\CursorControlCode;
use App\Framework\Console\Screen\ScreenControlCode;
use App\Framework\Console\SimpleWorkflowExecutor;
use App\Framework\Console\TuiView;
use App\Framework\DI\Container;
use App\Framework\Discovery\Results\DiscoveryRegistry;
/**
* Main TUI orchestrator - coordinates specialized components
* Refactored to follow Single Responsibility Principle
*/
final readonly class ConsoleTUI
{
public function __construct(
private ConsoleOutputInterface $output,
private Container $container,
private DiscoveryRegistry $discoveryRegistry,
private TuiState $state,
private TuiRenderer $renderer,
private TuiInputHandler $inputHandler,
private TuiCommandExecutor $commandExecutor,
private CommandHistory $commandHistory,
private CommandGroupRegistry $groupRegistry,
private SimpleWorkflowExecutor $workflowExecutor
) {
}
/**
* Run the interactive TUI
*/
public function run(): ExitCode
{
$this->initialize();
$this->mainLoop();
$this->cleanup();
return ExitCode::SUCCESS;
}
/**
* Initialize TUI system
*/
private function initialize(): void
{
$this->setupTerminal();
$this->showWelcomeScreen();
$this->loadCommands();
$this->state->setRunning(true);
}
/**
* Main event loop
*/
private function mainLoop(): void
{
while ($this->state->isRunning()) {
$this->renderCurrentView();
$this->handleUserInput();
}
}
/**
* Cleanup and restore terminal
*/
private function cleanup(): void
{
$this->restoreTerminal();
$this->output->writeLine('');
$this->output->writeLine('👋 Console session ended. Goodbye!');
}
/**
* Setup terminal for interactive mode
*/
private function setupTerminal(): void
{
$this->output->screen->setInteractiveMode();
$this->setRawMode(true);
$this->output->write(CursorControlCode::HIDE->format());
// Welcome message
$this->output->write(ScreenControlCode::CLEAR_ALL->format());
$this->output->write(CursorControlCode::POSITION->format(1, 1));
}
/**
* Restore terminal to normal mode
*/
private function restoreTerminal(): void
{
$this->setRawMode(false);
$this->output->write(CursorControlCode::SHOW->format());
$this->output->screen->setInteractiveMode(false);
}
/**
* Load and categorize all available commands
*/
private function loadCommands(): void
{
// Use the new CommandGroupRegistry for organized commands
$categories = $this->groupRegistry->getOrganizedCommands();
$this->state->setCategories($categories);
// Load available workflows
$workflows = $this->groupRegistry->getWorkflows();
$this->state->setWorkflows($workflows);
}
/**
* Render the current view
*/
private function renderCurrentView(): void
{
$this->renderer->render($this->state, $this->commandHistory);
}
/**
* Handle user input
*/
private function handleUserInput(): void
{
$key = $this->readKey();
if ($key !== '') {
// Handle form mode specially
if ($this->state->getCurrentView() === TuiView::FORM) {
$this->handleFormMode();
return;
}
$this->inputHandler->handleInput($key, $this->state, $this->commandHistory);
}
}
/**
* Handle form mode interaction
*/
private function handleFormMode(): void
{
$command = $this->state->getSelectedCommandForForm();
if ($command) {
$this->commandExecutor->startInteractiveForm($command, $this->state);
}
}
/**
* Read a single key from input (including multi-byte escape sequences)
* Enhanced for PHPStorm terminal compatibility
*/
private function readKey(): string
{
$key = fgetc(STDIN);
if ($key === false) {
return '';
}
// Handle escape sequences (arrow keys, etc.)
if ($key === "\033") {
$sequence = $key;
// Use non-blocking read for better compatibility
$originalBlocking = stream_get_meta_data(STDIN)['blocked'] ?? true;
stream_set_blocking(STDIN, false);
// Read the next character with small timeout
$next = fgetc(STDIN);
if ($next === false) {
// Wait a bit and try again (PHPStorm sometimes needs this)
usleep(10000); // 10ms
$next = fgetc(STDIN);
}
if ($next !== false) {
$sequence .= $next;
// If it's a bracket, read more
if ($next === '[') {
$third = fgetc(STDIN);
if ($third === false) {
usleep(10000); // 10ms
$third = fgetc(STDIN);
}
if ($third !== false) {
$sequence .= $third;
// Some sequences have more characters (like Page Up/Down)
if (in_array($third, ['5', '6', '3', '1', '2', '4'])) {
$fourth = fgetc(STDIN);
if ($fourth === false) {
usleep(5000); // 5ms
$fourth = fgetc(STDIN);
}
if ($fourth !== false) {
$sequence .= $fourth;
}
}
}
}
}
// Restore blocking mode
stream_set_blocking(STDIN, $originalBlocking);
return $sequence;
}
return $key;
}
/**
* Show welcome screen and wait for user input
*/
private function showWelcomeScreen(): void
{
$this->output->write(ScreenControlCode::CLEAR_ALL->format());
$this->output->write(CursorControlCode::POSITION->format(1, 1));
$this->output->writeLine('');
$this->output->writeLine('🚀 Welcome to Interactive Console', ConsoleColor::BRIGHT_CYAN);
$this->output->writeLine('═══════════════════════════════════', ConsoleColor::GRAY);
$this->output->writeLine('');
$this->output->writeLine('Navigate through categories and commands using:');
$this->output->writeLine(' ↑/↓ Arrow Keys - Navigate up and down');
$this->output->writeLine(' Enter - Select/Execute');
$this->output->writeLine(' / - Search commands');
$this->output->writeLine(' R - Command history');
$this->output->writeLine(' Q/Esc - Quit');
$this->output->writeLine('');
$this->output->writeLine('Press any key to continue...', ConsoleColor::YELLOW);
// Wait for user input
$this->readKey();
}
/**
* Set terminal raw mode with PHPStorm compatibility
*/
private function setRawMode(bool $enabled): void
{
if (! function_exists('shell_exec')) {
return;
}
// Detect PHPStorm/JetBrains terminal
$isPhpStorm = getenv('TERMINAL_EMULATOR') === 'JetBrains-JediTerm' ||
! empty(getenv('IDE_PROJECT_ROOTS')) ||
strpos(getenv('TERM') ?: '', 'jetbrains') !== false;
if ($enabled) {
if ($isPhpStorm) {
// PHPStorm-optimized settings
shell_exec('stty raw -echo min 1 time 0 2>/dev/null');
} else {
// Standard terminal settings
shell_exec('stty -icanon -echo 2>/dev/null');
}
} else {
if ($isPhpStorm) {
// PHPStorm-optimized restore
shell_exec('stty -raw echo 2>/dev/null');
} else {
// Standard terminal restore
shell_exec('stty icanon echo 2>/dev/null');
}
}
}
}

View File

@@ -0,0 +1,546 @@
<?php
declare(strict_types=1);
namespace App\Framework\Console\Components;
use App\Framework\Console\CommandHelp;
use App\Framework\Console\CommandHelpGenerator;
use App\Framework\Console\CommandHistory;
use App\Framework\Console\CommandRegistry;
use App\Framework\Console\CommandValidator;
use App\Framework\Console\ConsoleColor;
use App\Framework\Console\ConsoleCommand;
use App\Framework\Console\ConsoleOutputInterface;
use App\Framework\Console\InteractiveForm;
use App\Framework\Console\Screen\CursorControlCode;
use App\Framework\Console\ValidationResult;
use App\Framework\DI\Container;
use App\Framework\Discovery\Results\DiscoveryRegistry;
use App\Framework\Discovery\ValueObjects\DiscoveredAttribute;
/**
* Handles command execution, validation and help for the TUI
*/
final readonly class TuiCommandExecutor
{
public function __construct(
private ConsoleOutputInterface $output,
private CommandRegistry $commandRegistry,
private Container $container,
private DiscoveryRegistry $discoveryRegistry,
private CommandHistory $commandHistory,
private CommandValidator $commandValidator,
private CommandHelpGenerator $helpGenerator,
private string $scriptName = 'console'
) {
}
/**
* Execute the currently selected command
*/
public function executeSelectedCommand(object $command): void
{
// Handle DiscoveredAttribute objects
if ($command instanceof DiscoveredAttribute) {
$attribute = $command->createAttributeInstance();
if ($attribute !== null) {
$this->executeCommand($attribute->name);
}
} else {
// Fallback for legacy objects
$this->executeCommand($command->name ?? '');
}
}
/**
* Execute a command by name
*/
public function executeCommand(string $commandName): void
{
$this->commandHistory->addToHistory($commandName);
$this->exitInteractiveMode();
$this->output->writeLine('');
$this->output->writeLine("Executing: $commandName", ConsoleColor::CYAN);
// Validate command before execution
$this->validateBeforeExecution($commandName);
$this->output->writeLine(str_repeat('=', 50));
try {
$args = [$commandName]; // Remove script name, just pass command arguments
$exitCode = $this->commandRegistry->executeCommand($commandName, $args, $this->output);
if ($exitCode->value === 0) {
$this->output->writeLine(str_repeat('=', 50));
$this->output->writeLine('✅ Command completed successfully', ConsoleColor::GREEN);
} else {
$this->output->writeLine(str_repeat('=', 50));
$this->output->writeLine('❌ Command failed with exit code: ' . $exitCode->value, ConsoleColor::RED);
}
} catch (\Exception $e) {
$this->output->writeLine(str_repeat('=', 50));
$this->output->writeLine('❌ Error: ' . $e->getMessage(), ConsoleColor::RED);
}
$this->waitForKeyPress();
}
/**
* Execute command with parameters from interactive form
*/
public function executeCommandWithParameters(string $commandName, array $parameters): void
{
$this->commandHistory->addToHistory($commandName);
$this->exitInteractiveMode();
$this->output->writeLine('');
$this->output->writeLine("Executing: $commandName", ConsoleColor::CYAN);
// Validate command before execution
$this->validateBeforeExecution($commandName);
if (! empty($parameters)) {
$this->output->writeLine('Parameters:', ConsoleColor::GRAY);
foreach ($parameters as $name => $value) {
$displayValue = is_bool($value) ? ($value ? 'true' : 'false') : $value;
$this->output->writeLine("{$name}: {$displayValue}", ConsoleColor::WHITE);
}
}
$this->output->writeLine(str_repeat('=', 50));
try {
// Create argument array from parameters
$args = [$this->scriptName, $commandName];
foreach ($parameters as $value) {
$args[] = (string) $value;
}
$exitCode = $this->commandRegistry->execute($args);
if ($exitCode->value === 0) {
$this->output->writeLine(str_repeat('=', 50));
$this->output->writeLine('✅ Command completed successfully', ConsoleColor::GREEN);
} else {
$this->output->writeLine(str_repeat('=', 50));
$this->output->writeLine('❌ Command failed with exit code: ' . $exitCode->value, ConsoleColor::RED);
}
} catch (\Exception $e) {
$this->output->writeLine(str_repeat('=', 50));
$this->output->writeLine('❌ Error: ' . $e->getMessage(), ConsoleColor::RED);
}
$this->waitForKeyPress();
}
/**
* Validate the currently selected command
*/
public function validateSelectedCommand(object $command): void
{
// Handle DiscoveredAttribute objects
if ($command instanceof DiscoveredAttribute) {
$attribute = $command->createAttributeInstance();
if ($attribute !== null) {
$this->validateCommand($attribute->name);
}
} else {
// Fallback for legacy objects
$this->validateCommand($command->name ?? '');
}
}
/**
* Validate a command by name
*/
public function validateCommand(string $commandName): void
{
$this->exitInteractiveMode();
$this->output->writeLine('');
$this->output->writeLine("🔍 Validating Command: {$commandName}", ConsoleColor::CYAN);
$this->output->writeLine(str_repeat('=', 50));
try {
$commandObject = $this->findCommandObject($commandName);
if (! $commandObject) {
$this->output->writeLine('❌ Command object not found for validation', ConsoleColor::RED);
} else {
$validationResult = $this->commandValidator->validateCommand($commandObject);
$this->displayValidationResult($validationResult);
// Show additional statistics
$this->output->writeLine('Validation Summary:', ConsoleColor::BRIGHT_YELLOW);
$this->output->writeLine(" • Total Issues: {$validationResult->getTotalIssueCount()}", ConsoleColor::WHITE);
$this->output->writeLine(" • Errors: " . count($validationResult->getErrors()), ConsoleColor::RED);
$this->output->writeLine(" • Warnings: " . count($validationResult->getWarnings()), ConsoleColor::YELLOW);
$this->output->writeLine(
" • Critical Issues: " . ($validationResult->hasCriticalErrors() ? 'Yes' : 'No'),
$validationResult->hasCriticalErrors() ? ConsoleColor::RED : ConsoleColor::GREEN
);
}
} catch (\Exception $e) {
$this->output->writeLine("❌ Validation failed: {$e->getMessage()}", ConsoleColor::RED);
}
$this->output->writeLine('');
$this->waitForKeyPress();
}
/**
* Show detailed help for the currently selected command
*/
public function showSelectedCommandHelp(object $command): void
{
// Handle DiscoveredAttribute objects
if ($command instanceof DiscoveredAttribute) {
$attribute = $command->createAttributeInstance();
if ($attribute !== null) {
$this->showCommandHelp($attribute->name);
}
} else {
// Fallback for legacy objects
$this->showCommandHelp($command->name ?? '');
}
}
/**
* Show help for a command by name
*/
public function showCommandHelp(string $commandName): void
{
$this->exitInteractiveMode();
$this->output->writeLine('');
$this->output->writeLine("📖 Command Help: {$commandName}", ConsoleColor::BRIGHT_CYAN);
$this->output->writeLine(str_repeat('=', 70));
try {
$commandObject = $this->findCommandObject($commandName);
if (! $commandObject) {
$this->output->writeLine('❌ Command object not found for help generation', ConsoleColor::RED);
$this->output->writeLine('');
$this->output->writeLine('Basic Information:', ConsoleColor::YELLOW);
$this->output->writeLine(" Name: {$commandName}", ConsoleColor::WHITE);
} else {
$help = $this->helpGenerator->generateHelp($commandObject);
$this->displayFormattedHelp($help);
}
} catch (\Exception $e) {
$this->output->writeLine("❌ Help generation failed: {$e->getMessage()}", ConsoleColor::RED);
}
$this->output->writeLine('');
$this->waitForKeyPress();
}
/**
* Show quick help overview for all commands
*/
public function showAllCommandsHelp(): void
{
$this->exitInteractiveMode();
$this->output->writeLine('');
$this->output->writeLine('📚 All Commands Help Overview', ConsoleColor::BRIGHT_CYAN);
$this->output->writeLine(str_repeat('=', 70));
try {
// Get all commands from discovery registry
$commands = $this->discoveryRegistry->getResults(ConsoleCommand::class);
$helpData = [];
foreach ($commands as $commandData) {
$commandObject = $this->container->get($commandData['class']);
$quickHelp = $this->helpGenerator->generateQuickHelp($commandObject);
$helpData[] = [
'name' => $commandData['attribute']->name,
'category' => $this->inferCategory($commandData['attribute']->name),
'summary' => $quickHelp,
];
}
// Group by category and display
$groupedHelp = $this->groupByCategory($helpData);
foreach ($groupedHelp as $category => $commands) {
$this->output->writeLine('');
$this->output->writeLine("📁 {$category}", ConsoleColor::BRIGHT_YELLOW);
$this->output->writeLine(str_repeat('-', 30), ConsoleColor::GRAY);
foreach ($commands as $command) {
$this->output->writeLine(" {$command['name']}", ConsoleColor::BRIGHT_WHITE);
$this->output->writeLine(" {$command['summary']}", ConsoleColor::GRAY);
}
}
$this->output->writeLine('');
$this->output->writeLine('💡 Navigation Tips:', ConsoleColor::BRIGHT_YELLOW);
$this->output->writeLine(' • Use H key on any command to see detailed help', ConsoleColor::GRAY);
$this->output->writeLine(' • Use V key to validate command signatures', ConsoleColor::GRAY);
$this->output->writeLine(' • Use Space to open interactive parameter forms', ConsoleColor::GRAY);
$this->output->writeLine(' • Press F1 anytime to see this overview', ConsoleColor::GRAY);
} catch (\Exception $e) {
$this->output->writeLine("❌ Failed to generate help overview: {$e->getMessage()}", ConsoleColor::RED);
}
$this->output->writeLine('');
$this->waitForKeyPress();
}
/**
* Start interactive form for command parameters
*/
public function startInteractiveForm(object $command, TuiState $state): void
{
try {
$form = InteractiveForm::forCommand($command, $this->output);
$parameters = $form->run();
if ($form->isCompleted() && ! empty($parameters)) {
// Handle DiscoveredAttribute objects
if ($command instanceof DiscoveredAttribute) {
$attribute = $command->createAttributeInstance();
if ($attribute !== null) {
$this->executeCommandWithParameters($attribute->name, $parameters);
}
} else {
// Fallback for legacy objects
$this->executeCommandWithParameters($command->name ?? '', $parameters);
}
} else {
$this->output->writeLine('');
$this->output->writeLine('Form cancelled.', ConsoleColor::YELLOW);
$this->waitForKeyPress();
}
} catch (\Exception $e) {
$this->output->writeLine('');
$this->output->writeLine('❌ Error creating form: ' . $e->getMessage(), ConsoleColor::RED);
$this->waitForKeyPress();
}
$state->resetFormState();
}
/**
* Find command object by name
*/
public function findCommandObject(string $commandName): ?object
{
try {
$commands = $this->discoveryRegistry->attributes()->get(ConsoleCommand::class);
foreach ($commands as $commandData) {
// Handle both array and DiscoveredAttribute object formats
$attribute = null;
$className = null;
if (is_array($commandData) && isset($commandData['attribute'])) {
$attribute = $commandData['attribute'];
$className = $commandData['class'] ?? null;
} elseif ($commandData instanceof \App\Framework\Discovery\ValueObjects\DiscoveredAttribute) {
$attribute = $commandData->createAttributeInstance();
$className = $commandData->className;
}
if ($attribute && $attribute->name === $commandName && $className) {
return $this->container->get($className);
}
}
} catch (\Exception) {
return null;
}
return null;
}
/**
* Validate command before execution
*/
private function validateBeforeExecution(string $commandName): void
{
try {
$commandObject = $this->findCommandObject($commandName);
if ($commandObject) {
$validationResult = $this->commandValidator->validateCommand($commandObject);
$this->displayValidationResult($validationResult);
if (! $validationResult->isValid()) {
$this->output->writeLine('❌ Command validation failed. Execution aborted.', ConsoleColor::RED);
$this->waitForKeyPress();
return;
}
}
} catch (\Exception $e) {
$this->output->writeLine("⚠️ Could not validate command: {$e->getMessage()}", ConsoleColor::YELLOW);
}
}
/**
* Display validation result with colored output
*/
private function displayValidationResult(ValidationResult $result): void
{
if ($result->isValid() && empty($result->getWarnings())) {
$this->output->writeLine('✅ Command validation passed', ConsoleColor::GREEN);
return;
}
// Show validation status
if ($result->isValid()) {
$this->output->writeLine('✅ Command validation passed with warnings', ConsoleColor::YELLOW);
} else {
$this->output->writeLine('❌ Command validation failed', ConsoleColor::RED);
}
// Show errors
foreach ($result->getErrors() as $error) {
$severity = strtoupper($error['severity']);
$this->output->writeLine(" ERROR [{$severity}]: {$error['message']}", ConsoleColor::RED);
if (isset($error['suggestion'])) {
$this->output->writeLine(" 💡 {$error['suggestion']}", ConsoleColor::GRAY);
}
}
// Show warnings
foreach ($result->getWarnings() as $warning) {
$severity = strtoupper($warning['severity']);
$this->output->writeLine(" WARN [{$severity}]: {$warning['message']}", ConsoleColor::YELLOW);
if (isset($warning['suggestion'])) {
$this->output->writeLine(" 💡 {$warning['suggestion']}", ConsoleColor::GRAY);
}
}
$this->output->writeLine('');
}
/**
* Display formatted help with colors
*/
private function displayFormattedHelp(CommandHelp $help): void
{
$sections = $help->formatAsColoredText();
foreach ($sections as $section) {
$color = match ($section['color']) {
'BRIGHT_CYAN' => ConsoleColor::BRIGHT_CYAN,
'BRIGHT_YELLOW' => ConsoleColor::BRIGHT_YELLOW,
'BRIGHT_WHITE' => ConsoleColor::BRIGHT_WHITE,
'BRIGHT_GREEN' => ConsoleColor::BRIGHT_GREEN,
'WHITE' => ConsoleColor::WHITE,
'GRAY' => ConsoleColor::GRAY,
'YELLOW' => ConsoleColor::YELLOW,
'RED' => ConsoleColor::RED,
default => ConsoleColor::WHITE
};
$this->output->writeLine($section['text'], $color);
}
// Add interactive elements info
$this->output->writeLine('');
$this->output->writeLine('💡 Interactive Options:', ConsoleColor::BRIGHT_YELLOW);
$this->output->writeLine(' • Press Space to open parameter form', ConsoleColor::GRAY);
$this->output->writeLine(' • Press V to validate command signature', ConsoleColor::GRAY);
$this->output->writeLine(' • Press Enter to execute with defaults', ConsoleColor::GRAY);
}
/**
* Exit interactive mode
*/
private function exitInteractiveMode(): void
{
$this->setRawMode(false);
$this->output->write(CursorControlCode::SHOW->format());
$this->output->screen->setInteractiveMode(false);
}
/**
* Wait for key press and return to TUI
*/
private function waitForKeyPress(): void
{
$this->output->writeLine('Press any key to continue...', ConsoleColor::GRAY);
$this->readKey();
$this->enterInteractiveMode();
}
/**
* Enter interactive mode
*/
private function enterInteractiveMode(): void
{
$this->output->screen->setInteractiveMode(true);
$this->setRawMode(true);
$this->output->write(CursorControlCode::HIDE->format());
}
/**
* Read a single key
*/
private function readKey(): string
{
return fgetc(STDIN) ?: '';
}
/**
* Set terminal raw mode
*/
private function setRawMode(bool $enabled): void
{
if (! function_exists('shell_exec')) {
return;
}
if ($enabled) {
shell_exec('stty -icanon -echo');
} else {
shell_exec('stty icanon echo');
}
}
/**
* Infer category from command name
*/
private function inferCategory(string $commandName): string
{
if (str_contains($commandName, ':')) {
return ucfirst(explode(':', $commandName)[0]);
}
return match (true) {
str_starts_with($commandName, 'test') => 'Testing',
str_starts_with($commandName, 'demo') => 'Demo',
str_starts_with($commandName, 'make') => 'Generator',
str_starts_with($commandName, 'db') => 'Database',
str_starts_with($commandName, 'mcp') => 'MCP',
default => 'General'
};
}
/**
* Group help data by category
*/
private function groupByCategory(array $helpData): array
{
$grouped = [];
foreach ($helpData as $item) {
$category = $item['category'];
if (! isset($grouped[$category])) {
$grouped[$category] = [];
}
$grouped[$category][] = $item;
}
return $grouped;
}
}

View File

@@ -0,0 +1,492 @@
<?php
declare(strict_types=1);
namespace App\Framework\Console\Components;
use App\Framework\Console\CommandHistory;
use App\Framework\Console\HistoryTab;
use App\Framework\Console\TuiKeyCode;
use App\Framework\Console\TuiView;
use App\Framework\Discovery\ValueObjects\DiscoveredAttribute;
/**
* Handles all keyboard input and navigation for the TUI
*/
final readonly class TuiInputHandler
{
public function __construct(
private TuiCommandExecutor $commandExecutor
) {
}
/**
* Handle keyboard input based on current view
*/
public function handleInput(string $key, TuiState $state, CommandHistory $history): void
{
// Global shortcuts first
if ($this->handleGlobalShortcuts($key, $state)) {
return;
}
// View-specific handling
match ($state->getCurrentView()) {
TuiView::CATEGORIES => $this->handleCategoriesInput($key, $state),
TuiView::COMMANDS => $this->handleCommandsInput($key, $state),
TuiView::SEARCH => $this->handleSearchInput($key, $state),
TuiView::HISTORY => $this->handleHistoryInput($key, $state, $history),
TuiView::FORM => $this->handleFormInput($key, $state),
TuiView::DASHBOARD => $this->handleDashboardInput($key, $state),
TuiView::HELP => $this->handleHelpInput($key, $state),
};
}
/**
* Handle global shortcuts available in all views
*/
private function handleGlobalShortcuts(string $key, TuiState $state): bool
{
switch ($key) {
case 'q':
case 'Q':
case TuiKeyCode::ESCAPE->value:
$state->setRunning(false);
return true;
case 'd':
case 'D':
$state->setCurrentView(TuiView::DASHBOARD);
return true;
case 'h':
case 'H':
$state->setCurrentView(TuiView::HELP);
return true;
case '/':
case 's':
case 'S':
$state->setCurrentView(TuiView::SEARCH);
$state->resetSearchState();
return true;
case 'r':
case 'R':
$state->setCurrentView(TuiView::HISTORY);
$state->setSelectedHistoryItem(0);
return true;
case TuiKeyCode::F1->value:
$this->commandExecutor->showAllCommandsHelp();
return true;
case 'c':
case 'C':
if ($state->getCurrentView() !== TuiView::CATEGORIES) {
$state->setCurrentView(TuiView::CATEGORIES);
return true;
}
break;
}
return false;
}
/**
* Handle input in categories view
*/
private function handleCategoriesInput(string $key, TuiState $state): void
{
switch ($key) {
case TuiKeyCode::ARROW_UP->value:
$state->navigateUp();
break;
case TuiKeyCode::ARROW_DOWN->value:
$state->navigateDown();
break;
case TuiKeyCode::ENTER->value:
$state->setCurrentView(TuiView::COMMANDS);
$state->setSelectedCommand(0);
break;
}
}
/**
* Handle input in commands view
*/
private function handleCommandsInput(string $key, TuiState $state): void
{
switch ($key) {
case TuiKeyCode::ARROW_UP->value:
$state->navigateUp();
break;
case TuiKeyCode::ARROW_DOWN->value:
$state->navigateDown();
break;
case TuiKeyCode::ENTER->value:
$command = $state->getCurrentCommand();
if ($command) {
$this->commandExecutor->executeSelectedCommand($command);
}
break;
case TuiKeyCode::SPACE->value:
$command = $state->getCurrentCommand();
if ($command) {
$state->setSelectedCommandForForm($command);
$state->setCurrentView(TuiView::FORM);
}
break;
case 'v':
case 'V':
$command = $state->getCurrentCommand();
if ($command) {
$this->commandExecutor->validateSelectedCommand($command);
}
break;
case 'h':
case 'H':
$command = $state->getCurrentCommand();
if ($command) {
$this->commandExecutor->showSelectedCommandHelp($command);
}
break;
case TuiKeyCode::ARROW_LEFT->value:
case TuiKeyCode::BACKSPACE->value:
$state->setCurrentView(TuiView::CATEGORIES);
break;
}
}
/**
* Handle input in search view
*/
private function handleSearchInput(string $key, TuiState $state): void
{
switch ($key) {
case TuiKeyCode::ESCAPE->value:
$state->setCurrentView(TuiView::CATEGORIES);
$state->resetSearchState();
break;
case TuiKeyCode::ENTER->value:
$command = $state->getCurrentSearchResult();
if ($command) {
// Handle DiscoveredAttribute objects
if ($command instanceof DiscoveredAttribute) {
$attribute = $command->createAttributeInstance();
if ($attribute !== null) {
$this->commandExecutor->executeCommand($attribute->name);
}
} else {
$this->commandExecutor->executeCommand($command->name);
}
}
break;
case TuiKeyCode::SPACE->value:
$command = $state->getCurrentSearchResult();
if ($command) {
$state->setSelectedCommandForForm($command);
$state->setCurrentView(TuiView::FORM);
}
break;
case 'h':
case 'H':
$command = $state->getCurrentSearchResult();
if ($command) {
// Handle DiscoveredAttribute objects
if ($command instanceof DiscoveredAttribute) {
$attribute = $command->createAttributeInstance();
if ($attribute !== null) {
$this->commandExecutor->showCommandHelp($attribute->name);
}
} else {
$this->commandExecutor->showCommandHelp($command->name);
}
}
break;
case 'v':
case 'V':
$command = $state->getCurrentSearchResult();
if ($command) {
// Handle DiscoveredAttribute objects
if ($command instanceof DiscoveredAttribute) {
$attribute = $command->createAttributeInstance();
if ($attribute !== null) {
$this->commandExecutor->validateCommand($attribute->name);
}
} else {
$this->commandExecutor->validateCommand($command->name);
}
}
break;
case TuiKeyCode::ARROW_UP->value:
$state->navigateUp();
break;
case TuiKeyCode::ARROW_DOWN->value:
$state->navigateDown();
break;
case TuiKeyCode::BACKSPACE->value:
case "\x7f": // DEL key
$query = $state->getSearchQuery();
if (strlen($query) > 0) {
$newQuery = substr($query, 0, -1);
$state->setSearchQuery($newQuery);
$this->updateSearchResults($state);
}
break;
default:
// Add character to search query
if (strlen($key) === 1 && ctype_print($key)) {
$newQuery = $state->getSearchQuery() . $key;
$state->setSearchQuery($newQuery);
$this->updateSearchResults($state);
}
break;
}
}
/**
* Handle input in history view
*/
private function handleHistoryInput(string $key, TuiState $state, CommandHistory $history): void
{
switch ($key) {
case TuiKeyCode::ARROW_UP->value:
$state->navigateUp();
break;
case TuiKeyCode::ARROW_DOWN->value:
$state->navigateDown();
break;
case TuiKeyCode::ARROW_LEFT->value:
$this->switchHistoryTab($state, -1);
break;
case TuiKeyCode::ARROW_RIGHT->value:
$this->switchHistoryTab($state, 1);
break;
case TuiKeyCode::ENTER->value:
$items = $this->getHistoryItems($state, $history);
if (! empty($items) && isset($items[$state->getSelectedHistoryItem()])) {
$command = $items[$state->getSelectedHistoryItem()]['command'];
$history->addToHistory($command);
$this->commandExecutor->executeCommand($command);
}
break;
case TuiKeyCode::SPACE->value:
$items = $this->getHistoryItems($state, $history);
if (! empty($items) && isset($items[$state->getSelectedHistoryItem()])) {
$commandName = $items[$state->getSelectedHistoryItem()]['command'];
$commandObject = $this->commandExecutor->findCommandObject($commandName);
if ($commandObject) {
$state->setSelectedCommandForForm($commandObject);
$state->setCurrentView(TuiView::FORM);
}
}
break;
case 'h':
case 'H':
$items = $this->getHistoryItems($state, $history);
if (! empty($items) && isset($items[$state->getSelectedHistoryItem()])) {
$commandName = $items[$state->getSelectedHistoryItem()]['command'];
$this->commandExecutor->showCommandHelp($commandName);
}
break;
case 'v':
case 'V':
$items = $this->getHistoryItems($state, $history);
if (! empty($items) && isset($items[$state->getSelectedHistoryItem()])) {
$commandName = $items[$state->getSelectedHistoryItem()]['command'];
$this->commandExecutor->validateCommand($commandName);
}
break;
case 'f':
case 'F':
$items = $this->getHistoryItems($state, $history);
if (! empty($items) && isset($items[$state->getSelectedHistoryItem()])) {
$command = $items[$state->getSelectedHistoryItem()]['command'];
$history->toggleFavorite($command);
}
break;
case 'c':
case 'C':
if ($state->getHistoryTab() === HistoryTab::FAVORITES) {
$history->clearFavorites();
} else {
$history->clearHistory();
}
break;
case TuiKeyCode::ESCAPE->value:
case TuiKeyCode::BACKSPACE->value:
$state->setCurrentView(TuiView::CATEGORIES);
break;
}
}
/**
* Handle input in form view
*/
private function handleFormInput(string $key, TuiState $state): void
{
switch ($key) {
case TuiKeyCode::ESCAPE->value:
$state->resetFormState();
$state->setCurrentView(TuiView::CATEGORIES);
break;
}
}
/**
* Handle input in dashboard view
*/
private function handleDashboardInput(string $key, TuiState $state): void
{
switch ($key) {
case TuiKeyCode::ESCAPE->value:
$state->setCurrentView(TuiView::CATEGORIES);
break;
}
}
/**
* Handle input in help view
*/
private function handleHelpInput(string $key, TuiState $state): void
{
switch ($key) {
case TuiKeyCode::ESCAPE->value:
$state->setCurrentView(TuiView::CATEGORIES);
break;
}
}
/**
* Update search results based on current query
*/
private function updateSearchResults(TuiState $state): void
{
$query = strtolower($state->getSearchQuery());
if (empty($query)) {
$state->setSearchResults([]);
return;
}
$results = [];
$categories = $state->getCategories();
foreach ($categories as $category) {
foreach ($category['commands'] as $command) {
// Handle DiscoveredAttribute objects
if ($command instanceof DiscoveredAttribute) {
$attribute = $command->createAttributeInstance();
if ($attribute === null) {
continue;
}
$commandName = strtolower($attribute->name);
$commandDescription = strtolower($attribute->description ?? '');
} else {
// Fallback for legacy objects
$commandName = strtolower($command->name ?? '');
$commandDescription = strtolower($command->description ?? '');
}
if (str_contains($commandName, $query) || str_contains($commandDescription, $query)) {
$results[] = $command;
}
}
}
$state->setSearchResults($results);
}
/**
* Switch between history tabs
*/
private function switchHistoryTab(TuiState $state, int $direction): void
{
$tabs = [HistoryTab::RECENT, HistoryTab::FREQUENT, HistoryTab::FAVORITES];
$currentIndex = array_search($state->getHistoryTab(), $tabs);
$newIndex = ($currentIndex + $direction + count($tabs)) % count($tabs);
$state->setHistoryTab($tabs[$newIndex]);
}
/**
* Get history items based on current tab
*/
private function getHistoryItems(TuiState $state, CommandHistory $history): array
{
return match ($state->getHistoryTab()) {
HistoryTab::RECENT => $history->getRecentHistory(10),
HistoryTab::FREQUENT => $history->getFrequentCommands(10),
HistoryTab::FAVORITES => $history->getFavorites(),
};
}
}

View File

@@ -0,0 +1,446 @@
<?php
declare(strict_types=1);
namespace App\Framework\Console\Components;
use App\Framework\Console\CommandHistory;
use App\Framework\Console\ConsoleColor;
use App\Framework\Console\ConsoleOutputInterface;
use App\Framework\Console\HistoryTab;
use App\Framework\Console\Screen\CursorControlCode;
use App\Framework\Console\Screen\ScreenControlCode;
use App\Framework\Console\TuiView;
use App\Framework\Discovery\ValueObjects\DiscoveredAttribute;
/**
* Handles all rendering and display logic for the TUI
*/
final readonly class TuiRenderer
{
public function __construct(
private ConsoleOutputInterface $output
) {
}
/**
* Render the current view based on state
*/
public function render(TuiState $state, CommandHistory $history): void
{
$this->clearScreen();
$this->renderHeader();
match ($state->getCurrentView()) {
TuiView::CATEGORIES => $this->renderCategories($state),
TuiView::COMMANDS => $this->renderCommands($state),
TuiView::SEARCH => $this->renderSearch($state),
TuiView::HISTORY => $this->renderHistory($state, $history),
TuiView::FORM => $this->renderForm($state),
TuiView::DASHBOARD => $this->renderDashboard($state),
TuiView::HELP => $this->renderHelp($state),
};
}
/**
* Clear screen and reset cursor
*/
private function clearScreen(): void
{
$this->output->write(ScreenControlCode::CLEAR_ALL->format());
$this->output->write(CursorControlCode::POSITION->format(1, 1));
}
/**
* Render header with title
*/
private function renderHeader(): void
{
$this->output->writeLine('');
$this->output->writeLine('🚀 Interactive Console - Modern TUI', ConsoleColor::BRIGHT_CYAN);
$this->output->writeLine(str_repeat('═', 60), ConsoleColor::GRAY);
$this->output->writeLine('');
}
/**
* Render categories view
*/
private function renderCategories(TuiState $state): void
{
$this->output->writeLine('📂 Select Category:', ConsoleColor::BRIGHT_YELLOW);
$this->output->writeLine('');
$categories = $state->getCategories();
foreach ($categories as $index => $category) {
$isSelected = $index === $state->getSelectedCategory();
$this->renderCategoryItem($category, $isSelected);
}
$this->output->newLine();
$this->renderNavigationBar([
"↑/↓: Navigate",
"Enter: Select",
"/: Search",
"R: History",
"D: Dashboard",
"F1: Help",
"Q: Quit",
]);
}
/**
* Render a category item
*/
private function renderCategoryItem(array $category, bool $isSelected): void
{
$icon = $category['icon'] ?? '📁';
$name = $category['name'];
$count = count($category['commands']);
$prefix = $isSelected ? '▶ ' : ' ';
$color = $isSelected ? ConsoleColor::BRIGHT_WHITE : ConsoleColor::WHITE;
$this->output->writeLine(
"{$prefix}{$icon} {$name} ({$count} commands)",
$color
);
}
/**
* Render commands view
*/
private function renderCommands(TuiState $state): void
{
$category = $state->getCurrentCategory();
if (! $category) {
return;
}
$icon = $category['icon'] ?? '📁';
$this->output->writeLine("📂 {$icon} {$category['name']} Commands:", ConsoleColor::BRIGHT_YELLOW);
$this->output->writeLine('');
foreach ($category['commands'] as $index => $command) {
$isSelected = $index === $state->getSelectedCommand();
$this->renderCommandItem($command, $isSelected);
}
$this->output->newLine();
$this->renderNavigationBar([
"↑/↓: Navigate",
"Enter: Execute",
"Space: Parameters",
"H: Help",
"V: Validate",
"/: Search",
"R: History",
"←: Back",
"Q: Quit",
]);
}
/**
* Render a command item
*/
private function renderCommandItem(object $command, bool $isSelected): void
{
$prefix = $isSelected ? '▶ ' : ' ';
$color = $isSelected ? ConsoleColor::BRIGHT_WHITE : ConsoleColor::WHITE;
// Handle DiscoveredAttribute objects
if ($command instanceof DiscoveredAttribute) {
$attribute = $command->createAttributeInstance();
if ($attribute === null) {
return;
}
$commandName = $attribute->name;
$commandDescription = $attribute->description ?? '';
} else {
// Fallback for legacy objects
$commandName = $command->name ?? 'Unknown Command';
$commandDescription = $command->description ?? '';
}
$this->output->writeLine("{$prefix}{$commandName}", $color);
if (! empty($commandDescription)) {
$descColor = ConsoleColor::GRAY;
$this->output->writeLine(" {$commandDescription}", $descColor);
}
}
/**
* Render search view
*/
private function renderSearch(TuiState $state): void
{
$this->output->writeLine('🔍 Search Commands:', ConsoleColor::BRIGHT_YELLOW);
$this->output->writeLine('');
// Search box
$query = $state->getSearchQuery();
$this->output->writeLine("Search: {$query}_", ConsoleColor::BRIGHT_WHITE);
$this->output->writeLine('');
// Results
$results = $state->getSearchResults();
if (empty($results)) {
if (! empty($query)) {
$this->output->writeLine('No commands found matching your search.', ConsoleColor::YELLOW);
} else {
$this->output->writeLine('Start typing to search commands...', ConsoleColor::GRAY);
}
} else {
$resultCount = count($results);
$this->output->writeLine("Found {$resultCount} result(s):", ConsoleColor::WHITE);
$this->output->writeLine('');
foreach ($results as $index => $command) {
$isSelected = $index === $state->getSelectedSearchResult();
$this->renderCommandItem($command, $isSelected);
}
$current = $state->getSelectedSearchResult() + 1;
$total = count($results);
if ($total > 1) {
$this->output->writeLine('');
$this->output->writeLine(" Showing result $current of $total", ConsoleColor::GRAY);
}
}
// Navigation shortcuts
$shortcuts = [
'Type: Search',
'Enter: Execute',
'Space: Parameters',
'H: Help',
'V: Validate',
'R: History',
'Esc: Back',
'D: Dashboard',
'Q: Exit',
];
$this->renderNavigationBar($shortcuts);
}
/**
* Render history view
*/
private function renderHistory(TuiState $state, CommandHistory $history): void
{
$this->output->writeLine('📚 Command History:', ConsoleColor::BRIGHT_YELLOW);
$this->output->writeLine('');
// Tab navigation
$this->renderHistoryTabs($state->getHistoryTab());
// History items
$items = $this->getHistoryItems($state->getHistoryTab(), $history);
if (empty($items)) {
$this->output->writeLine('No history available.', ConsoleColor::YELLOW);
} else {
foreach ($items as $index => $item) {
$isSelected = $index === $state->getSelectedHistoryItem();
$this->renderHistoryItem($item, $isSelected, $history);
}
}
$this->output->writeLine('');
$this->renderNavigationHelp([
"↑/↓: Navigate",
"←/→: Switch tabs",
"Enter: Execute command",
"Space: Parameters",
"H: Help",
"V: Validate",
"F: Toggle favorite",
"C: Clear history",
"/: Search",
"Q: Quit",
]);
}
/**
* Render history tabs
*/
private function renderHistoryTabs(HistoryTab $activeTab): void
{
$tabs = [
HistoryTab::RECENT,
HistoryTab::FREQUENT,
HistoryTab::FAVORITES,
];
$tabDisplay = [];
foreach ($tabs as $tab) {
$isActive = $tab === $activeTab;
$color = $isActive ? ConsoleColor::BRIGHT_WHITE : ConsoleColor::GRAY;
$prefix = $isActive ? '[' : ' ';
$suffix = $isActive ? ']' : ' ';
$tabDisplay[] = [
'text' => "{$prefix}{$tab->getIcon()} {$tab->getTitle()}{$suffix}",
'color' => $color,
];
}
foreach ($tabDisplay as $tab) {
$this->output->write($tab['text'], $tab['color']);
}
$this->output->writeLine('');
$this->output->writeLine('');
}
/**
* Render history item
*/
private function renderHistoryItem(array $item, bool $isSelected, CommandHistory $history): void
{
$prefix = $isSelected ? '▶ ' : ' ';
$color = $isSelected ? ConsoleColor::BRIGHT_WHITE : ConsoleColor::WHITE;
$command = $item['command'];
$isFavorite = $history->isFavorite($command);
$favoriteIcon = $isFavorite ? '⭐' : ' ';
$this->output->writeLine("{$prefix}{$favoriteIcon}{$command}", $color);
// Additional info based on type
$infoColor = ConsoleColor::GRAY;
if (isset($item['count'])) {
$this->output->writeLine(" Used {$item['count']} times", $infoColor);
}
if (isset($item['timestamp'])) {
$timeAgo = $this->timeAgo($item['timestamp']);
$this->output->writeLine(" {$timeAgo}", $infoColor);
}
}
/**
* Render form view placeholder
*/
private function renderForm(TuiState $state): void
{
$this->output->writeLine('📝 Interactive Form', ConsoleColor::BRIGHT_YELLOW);
$this->output->writeLine('Form functionality handled by InteractiveForm class', ConsoleColor::GRAY);
}
/**
* Render dashboard view
*/
private function renderDashboard(TuiState $state): void
{
$this->output->writeLine('📊 System Dashboard', ConsoleColor::BRIGHT_YELLOW);
$this->output->writeLine('');
$this->output->writeLine('Console system status and metrics', ConsoleColor::WHITE);
$this->output->writeLine('');
$this->renderNavigationBar([
"Categories: C",
"Search: /",
"History: R",
"Help: F1",
"Back: Esc",
"Quit: Q",
]);
}
/**
* Render help view
*/
private function renderHelp(TuiState $state): void
{
$this->output->writeLine('❓ Help & Shortcuts', ConsoleColor::BRIGHT_YELLOW);
$this->output->writeLine('');
$helpSections = [
'Navigation' => [
'↑/↓ Arrow Keys' => 'Navigate up and down in lists',
'←/→ Arrow Keys' => 'Navigate left/right, switch tabs',
'Enter' => 'Execute selected command',
'Esc' => 'Go back to previous view',
],
'Commands' => [
'Space' => 'Open interactive parameter form',
'H' => 'Show detailed command help',
'V' => 'Validate command signature',
'F1' => 'Show all commands help overview',
],
'Views' => [
'/' => 'Search commands',
'R' => 'Command history',
'D' => 'Dashboard',
'C' => 'Categories (from other views)',
],
'History' => [
'F' => 'Toggle command as favorite',
'C' => 'Clear command history',
],
];
foreach ($helpSections as $section => $items) {
$this->output->writeLine($section . ':', ConsoleColor::BRIGHT_WHITE);
foreach ($items as $key => $description) {
$this->output->writeLine(" {$key} - {$description}", ConsoleColor::WHITE);
}
$this->output->writeLine('');
}
$this->renderNavigationBar([
"Back: Esc",
"Categories: C",
"Quit: Q",
]);
}
/**
* Render navigation bar
*/
private function renderNavigationBar(array $items): void
{
$this->output->writeLine(str_repeat('─', 60), ConsoleColor::GRAY);
$this->output->writeLine(implode(' | ', $items), ConsoleColor::BRIGHT_BLUE);
}
/**
* Render navigation help
*/
private function renderNavigationHelp(array $items): void
{
$this->output->writeLine(str_repeat('─', 60), ConsoleColor::GRAY);
$this->output->writeLine(implode(' | ', $items), ConsoleColor::BRIGHT_BLUE);
}
/**
* Get history items based on tab
*/
private function getHistoryItems(HistoryTab $tab, CommandHistory $history): array
{
return match ($tab) {
HistoryTab::RECENT => $history->getRecentHistory(10),
HistoryTab::FREQUENT => $history->getFrequentCommands(10),
HistoryTab::FAVORITES => $history->getFavorites(),
};
}
/**
* Calculate time ago
*/
private function timeAgo(int $timestamp): string
{
$diff = time() - $timestamp;
if ($diff < 60) {
return "{$diff}s ago";
}
if ($diff < 3600) {
return floor($diff / 60) . "m ago";
}
if ($diff < 86400) {
return floor($diff / 3600) . "h ago";
}
return floor($diff / 86400) . "d ago";
}
}

View File

@@ -0,0 +1,321 @@
<?php
declare(strict_types=1);
namespace App\Framework\Console\Components;
use App\Framework\Console\HistoryTab;
use App\Framework\Console\TuiView;
/**
* Manages the state of the Terminal User Interface
*/
final class TuiState
{
private array $categories = [];
private int $selectedCategory = 0;
private int $selectedCommand = 0;
private TuiView $currentView = TuiView::CATEGORIES;
private bool $running = false;
// Search state
private string $searchQuery = '';
private array $searchResults = [];
private int $selectedSearchResult = 0;
// History state
private int $selectedHistoryItem = 0;
private HistoryTab $historyTab = HistoryTab::RECENT;
// Form state
private ?object $selectedCommandForForm = null;
private bool $formMode = false;
public function __construct()
{
}
// Categories
public function setCategories(array $categories): void
{
$this->categories = $categories;
}
public function getCategories(): array
{
return $this->categories;
}
public function getSelectedCategory(): int
{
return $this->selectedCategory;
}
public function setSelectedCategory(int $index): void
{
$this->selectedCategory = max(0, min($index, count($this->categories) - 1));
}
public function getCurrentCategory(): ?array
{
return $this->categories[$this->selectedCategory] ?? null;
}
// Commands
public function getSelectedCommand(): int
{
return $this->selectedCommand;
}
public function setSelectedCommand(int $index): void
{
$category = $this->getCurrentCategory();
if ($category) {
$maxIndex = count($category['commands']) - 1;
$this->selectedCommand = max(0, min($index, $maxIndex));
}
}
public function getCurrentCommand(): ?object
{
$category = $this->getCurrentCategory();
return $category['commands'][$this->selectedCommand] ?? null;
}
// View management
public function getCurrentView(): TuiView
{
return $this->currentView;
}
public function setCurrentView(TuiView $view): void
{
$this->currentView = $view;
}
public function isRunning(): bool
{
return $this->running;
}
public function setRunning(bool $running): void
{
$this->running = $running;
}
// Search
public function getSearchQuery(): string
{
return $this->searchQuery;
}
public function setSearchQuery(string $query): void
{
$this->searchQuery = $query;
}
public function getSearchResults(): array
{
return $this->searchResults;
}
public function setSearchResults(array $results): void
{
$this->searchResults = $results;
$this->selectedSearchResult = 0;
}
public function getSelectedSearchResult(): int
{
return $this->selectedSearchResult;
}
public function setSelectedSearchResult(int $index): void
{
$this->selectedSearchResult = max(0, min($index, count($this->searchResults) - 1));
}
public function getCurrentSearchResult(): ?object
{
return $this->searchResults[$this->selectedSearchResult] ?? null;
}
// History
public function getSelectedHistoryItem(): int
{
return $this->selectedHistoryItem;
}
public function setSelectedHistoryItem(int $index): void
{
$this->selectedHistoryItem = max(0, $index);
}
public function getHistoryTab(): HistoryTab
{
return $this->historyTab;
}
public function setHistoryTab(HistoryTab $tab): void
{
$this->historyTab = $tab;
$this->selectedHistoryItem = 0;
}
// Form
public function getSelectedCommandForForm(): ?object
{
return $this->selectedCommandForForm;
}
public function setSelectedCommandForForm(?object $command): void
{
$this->selectedCommandForForm = $command;
}
public function isFormMode(): bool
{
return $this->formMode;
}
public function setFormMode(bool $formMode): void
{
$this->formMode = $formMode;
}
// Navigation helpers
public function navigateUp(): void
{
match ($this->currentView) {
TuiView::CATEGORIES => $this->setSelectedCategory($this->selectedCategory - 1),
TuiView::COMMANDS => $this->setSelectedCommand($this->selectedCommand - 1),
TuiView::SEARCH => $this->setSelectedSearchResult($this->selectedSearchResult - 1),
TuiView::HISTORY => $this->setSelectedHistoryItem($this->selectedHistoryItem - 1),
default => null
};
}
public function navigateDown(): void
{
match ($this->currentView) {
TuiView::CATEGORIES => $this->setSelectedCategory($this->selectedCategory + 1),
TuiView::COMMANDS => $this->setSelectedCommand($this->selectedCommand + 1),
TuiView::SEARCH => $this->setSelectedSearchResult($this->selectedSearchResult + 1),
TuiView::HISTORY => $this->setSelectedHistoryItem($this->selectedHistoryItem + 1),
default => null
};
}
public function resetSearchState(): void
{
$this->searchQuery = '';
$this->searchResults = [];
$this->selectedSearchResult = 0;
}
public function resetFormState(): void
{
$this->selectedCommandForForm = null;
$this->formMode = false;
}
// Workflow-related state
private array $workflows = [];
private int $selectedWorkflow = 0;
private bool $showWorkflows = false;
private array $workflowProgress = [];
/**
* Set available workflows
*/
public function setWorkflows(array $workflows): void
{
$this->workflows = $workflows;
}
/**
* Get available workflows
*/
public function getWorkflows(): array
{
return $this->workflows;
}
/**
* Set selected workflow
*/
public function setSelectedWorkflow(int $index): void
{
$this->selectedWorkflow = max(0, min($index, count($this->workflows) - 1));
}
/**
* Get selected workflow
*/
public function getSelectedWorkflow(): int
{
return $this->selectedWorkflow;
}
/**
* Get current workflow
*/
public function getCurrentWorkflow(): ?array
{
return $this->workflows[$this->selectedWorkflow] ?? null;
}
/**
* Set workflow visibility
*/
public function setShowWorkflows(bool $show): void
{
$this->showWorkflows = $show;
}
/**
* Check if workflows should be shown
*/
public function shouldShowWorkflows(): bool
{
return $this->showWorkflows;
}
/**
* Set workflow progress
*/
public function setWorkflowProgress(string $workflowName, array $progress): void
{
$this->workflowProgress[$workflowName] = $progress;
}
/**
* Get workflow progress
*/
public function getWorkflowProgress(string $workflowName): array
{
return $this->workflowProgress[$workflowName] ?? [];
}
/**
* Reset workflow state
*/
public function resetWorkflowState(): void
{
$this->selectedWorkflow = 0;
$this->showWorkflows = false;
$this->workflowProgress = [];
}
}

View File

@@ -5,12 +5,21 @@ declare(strict_types=1);
namespace App\Framework\Console;
use App\Framework\Config\AppConfig;
use App\Framework\Console\Components\InteractiveMenu;
use App\Framework\Console\Components\ConsoleTUI;
use App\Framework\Console\Components\TuiCommandExecutor;
use App\Framework\Console\Components\TuiInputHandler;
use App\Framework\Console\Components\TuiRenderer;
use App\Framework\Console\Components\TuiState;
use App\Framework\Console\ErrorRecovery\CommandSuggestionEngine;
use App\Framework\Console\ErrorRecovery\ConsoleErrorHandler;
use App\Framework\Console\ErrorRecovery\ErrorRecoveryService;
use App\Framework\Console\Exceptions\CommandNotFoundException;
use App\Framework\DI\Container;
use App\Framework\Discovery\Results\DiscoveryRegistry;
use App\Framework\Exception\ErrorCode;
use App\Framework\Exception\FrameworkException;
use App\Framework\Logging\Logger;
use App\Framework\Logging\ValueObjects\LogContext;
use Throwable;
final class ConsoleApplication
@@ -21,6 +30,8 @@ final class ConsoleApplication
private CommandRegistry $commandRegistry;
private ConsoleErrorHandler $errorHandler;
public function __construct(
private readonly Container $container,
private readonly string $scriptName = 'console',
@@ -37,6 +48,7 @@ final class ConsoleApplication
try {
$this->initializeCommandRegistry();
$this->initializeErrorHandler();
} catch (Throwable $e) {
// Log the original error for debugging
error_log("Console initialization failed: " . $e->getMessage());
@@ -49,6 +61,22 @@ final class ConsoleApplication
}
}
private function initializeErrorHandler(): void
{
// Create error recovery components
$suggestionEngine = new CommandSuggestionEngine($this->commandRegistry->getCommandList());
$recoveryService = new ErrorRecoveryService(
$suggestionEngine,
$this->commandRegistry->getCommandList(),
$this->commandRegistry
);
// Get logger if available
$logger = $this->container->has(Logger::class) ? $this->container->get(Logger::class) : null;
$this->errorHandler = new ConsoleErrorHandler($recoveryService, $logger);
}
private function setupSignalHandlers(): void
{
if (function_exists('pcntl_signal')) {
@@ -83,7 +111,12 @@ final class ConsoleApplication
// Fallback: Force fresh discovery if no commands found
if ($this->commandRegistry->getCommandList()->count() === 0) {
error_log("ConsoleApplication: No commands found, forcing fresh discovery...");
if ($this->container->has(Logger::class)) {
$logger = $this->container->get(Logger::class);
$logger->warning('No commands found, forcing fresh discovery...', LogContext::withData([
'component' => 'ConsoleApplication',
]));
}
// Force fresh discovery
$bootstrapper = new \App\Framework\Discovery\DiscoveryServiceBootstrapper(
@@ -103,8 +136,14 @@ final class ConsoleApplication
// Re-initialize command registry with fresh discovery
$this->commandRegistry = new CommandRegistry($this->container, $freshRegistry);
error_log("ConsoleApplication: Fresh discovery completed, commands found: " .
count($freshRegistry->attributes->get(\App\Framework\Console\ConsoleCommand::class)));
if ($this->container->has(Logger::class)) {
$logger = $this->container->get(Logger::class);
$commandCount = count($freshRegistry->attributes->get(\App\Framework\Console\ConsoleCommand::class));
$logger->info('Fresh discovery completed', LogContext::withData([
'commands_found' => $commandCount,
'component' => 'ConsoleApplication',
]));
}
}
}
@@ -128,32 +167,47 @@ final class ConsoleApplication
}
if (count($argv) < 2) {
$this->showHelp();
return ExitCode::SUCCESS->value;
// Launch interactive TUI when no arguments provided
return $this->launchInteractiveTUI();
}
$commandName = $argv[1];
$arguments = array_slice($argv, 2);
// Handle TUI launch flags
if (in_array($commandName, ['--interactive', '--tui', '-i'])) {
return $this->launchInteractiveTUI();
}
// Handle built-in commands
if (in_array($commandName, ['help', '--help', '-h'])) {
$this->showHelp();
// Spezifische Command-Hilfe
if (! empty($arguments) && isset($arguments[0])) {
$this->showCommandHelp($arguments[0]);
} else {
$this->showHelp();
}
return ExitCode::SUCCESS->value;
}
$commandList = $this->commandRegistry->getCommandList();
if (! $commandList->has($commandName)) {
$this->output->writeError("Kommando '{$commandName}' nicht gefunden.");
$this->suggestSimilarCommands($commandName);
$this->showHelp();
return ExitCode::COMMAND_NOT_FOUND->value;
// Prüfe ob es ein direktes Kommando ist
if ($commandList->has($commandName)) {
return $this->executeCommand($commandName, $arguments)->value;
}
return $this->executeCommand($commandName, $arguments)->value;
// Prüfe ob es eine Kategorie ist
$categories = $this->categorizeCommands($commandList);
if (isset($categories[$commandName])) {
$this->showCategoryCommands($commandName, $categories[$commandName]);
return ExitCode::SUCCESS->value;
}
// Command/Kategorie nicht gefunden
return $this->errorHandler->handleCommandNotFound($commandName, $this->output)->value;
} catch (Throwable $e) {
$this->output->writeError("Critical error: " . $e->getMessage());
@@ -196,20 +250,6 @@ final class ConsoleApplication
}, $argv);
}
private function suggestSimilarCommands(string $commandName): void
{
$commandList = $this->commandRegistry->getCommandList();
$suggestions = $commandList->findSimilar($commandName);
if (! empty($suggestions)) {
$this->output->writeLine("Meinten Sie vielleicht:", ConsoleColor::CYAN);
foreach ($suggestions as $suggestion) {
$this->output->writeLine(" {$suggestion}");
}
$this->output->newLine();
}
}
/**
* @param array<int, string> $arguments
*/
@@ -232,45 +272,19 @@ final class ConsoleApplication
return $this->commandRegistry->executeCommand($commandName, $arguments, $this->output);
} catch (CommandNotFoundException $e) {
$this->output->writeError("Kommando nicht gefunden: " . $e->getMessage());
return ExitCode::COMMAND_NOT_FOUND;
return $this->errorHandler->handleCommandNotFound($commandName, $this->output);
} catch (FrameworkException $e) {
$this->output->writeError("Framework error: " . $e->getMessage());
// Handle specific framework error codes
return match($e->getErrorCode()) {
ErrorCode::VAL_INVALID_INPUT => ExitCode::INVALID_INPUT,
ErrorCode::AUTH_UNAUTHORIZED => ExitCode::PERMISSION_DENIED,
ErrorCode::CON_INVALID_COMMAND_STRUCTURE => ExitCode::SOFTWARE_ERROR,
ErrorCode::CON_COMMAND_EXECUTION_FAILED => ExitCode::SOFTWARE_ERROR,
default => ExitCode::GENERAL_ERROR
};
return $this->errorHandler->handleCommandExecutionError($commandName, $e, $this->output);
} catch (\InvalidArgumentException $e) {
$this->output->writeError("Invalid arguments: " . $e->getMessage());
$this->showCommandUsage($commandName);
return ExitCode::INVALID_INPUT;
return $this->errorHandler->handleValidationError($commandName, $e->getMessage(), $this->output);
} catch (\RuntimeException $e) {
$this->output->writeError("Runtime error: " . $e->getMessage());
return ExitCode::SOFTWARE_ERROR;
return $this->errorHandler->handleCommandExecutionError($commandName, $e, $this->output);
} catch (Throwable $e) {
$this->output->writeError("Unexpected error: " . $e->getMessage());
$config = $this->container->get(AppConfig::class);
// In development, show stack trace
if ($config->isDevelopment()) {
$this->output->writeLine("Stack trace:", ConsoleColor::RED);
$this->output->writeLine($e->getTraceAsString());
}
return ExitCode::GENERAL_ERROR;
return $this->errorHandler->handleUnexpectedError($commandName, $e, $this->output);
} finally {
// Reset window title after command execution
$this->output->writeWindowTitle($this->title);
@@ -302,30 +316,285 @@ final class ConsoleApplication
private function showHelp(): void
{
$this->output->writeLine("Verfügbare Kommandos:", ConsoleColor::BRIGHT_CYAN);
$this->output->writeLine("Console Commands", ConsoleColor::BRIGHT_CYAN);
$this->output->newLine();
$commandList = $this->commandRegistry->getCommandList();
if ($commandList->isEmpty()) {
$this->output->writeLine(" Keine Kommandos verfügbar.", ConsoleColor::YELLOW);
} else {
$menu = new InteractiveMenu($this->output);
$menu->setTitle("Kommandos");
foreach ($commandList as $command) {
$description = $command->description ?: 'Keine Beschreibung verfügbar';
return;
}
$menu->addItem($command->name, function () use ($command) {
return $this->executeCommand($command->name, [])->value;
}, $description);
// Kategorisiere Commands
$categories = $this->categorizeCommands($commandList);
// Zeige Kategorien-Übersicht
$this->showCategoryOverview($categories);
$this->output->newLine();
$this->output->writeLine("Verwendung:", ConsoleColor::BRIGHT_YELLOW);
$this->output->writeLine(" php {$this->scriptName} # Interaktive TUI starten");
$this->output->writeLine(" php {$this->scriptName} --interactive # Interaktive TUI explizit starten");
$this->output->writeLine(" php {$this->scriptName} <kategorie> # Commands einer Kategorie anzeigen");
$this->output->writeLine(" php {$this->scriptName} <kommando> [argumente] # Kommando direkt ausführen");
$this->output->writeLine(" php {$this->scriptName} help <kommando> # Hilfe für spezifisches Kommando");
$this->output->newLine();
$this->output->writeLine("Hinweis:", ConsoleColor::CYAN);
$this->output->writeLine(" Ohne Argumente wird automatisch die interaktive TUI gestartet.");
$this->output->writeLine(" Die TUI bietet eine grafische Navigation durch alle verfügbaren Commands.");
}
/**
* Kategorisiert Commands basierend auf ihrem Präfix
*/
private function categorizeCommands(CommandList $commandList): array
{
$categories = [];
foreach ($commandList as $command) {
$parts = explode(':', $command->name);
$category = $parts[0];
if (! isset($categories[$category])) {
$categories[$category] = [];
}
$this->output->writeLine(" " . $menu->showInteractive());
$categories[$category][] = $command;
}
// Sortiere Kategorien
ksort($categories);
return $categories;
}
/**
* Zeigt eine übersichtliche Kategorien-Übersicht
*/
private function showCategoryOverview(array $categories): void
{
$this->output->writeLine("Verfügbare Kategorien:", ConsoleColor::BRIGHT_YELLOW);
$this->output->newLine();
$categoryInfo = [
'db' => 'Database operations (migrations, health checks)',
'errors' => 'Error management and analytics',
'backup' => 'Backup and restore operations',
'secrets' => 'Secret management and encryption',
'cache' => 'Cache management operations',
'demo' => 'Demo and example commands',
'logs' => 'Log management and rotation',
'alerts' => 'Alert system management',
'mcp' => 'Model Context Protocol server',
'make' => 'Code generation commands',
'docs' => 'Documentation generation',
'openapi' => 'OpenAPI specification generation',
'static' => 'Static file generation',
'redis' => 'Redis cache operations',
'routes' => 'Route management',
'discovery' => 'Framework discovery system',
];
foreach ($categories as $category => $commands) {
$count = count($commands);
$description = $categoryInfo[$category] ?? 'Various commands';
$categoryName = str_pad($category, 12);
$commandCount = str_pad("({$count} commands)", 15);
$this->output->writeLine(
" {$categoryName} {$commandCount} {$description}",
ConsoleColor::WHITE
);
}
$this->output->newLine();
$this->output->writeLine("Für Commands einer Kategorie: php {$this->scriptName} <kategorie>", ConsoleColor::CYAN);
}
/**
* Zeigt Commands einer spezifischen Kategorie
*/
private function showCategoryCommands(string $category, array $commands): void
{
$this->output->writeLine("Commands der Kategorie '{$category}':", ConsoleColor::BRIGHT_CYAN);
$this->output->newLine();
foreach ($commands as $command) {
$description = $command->description ?: 'Keine Beschreibung verfügbar';
$commandName = str_pad($command->name, 25);
$this->output->writeLine(" {$commandName} {$description}", ConsoleColor::WHITE);
}
$this->output->newLine();
$this->output->writeLine("Verwendung:", ConsoleColor::BRIGHT_YELLOW);
$this->output->writeLine(" php {$this->scriptName} <kommando> [argumente]");
$this->output->writeLine(" php {$this->scriptName} help <kommando> # Für detaillierte Hilfe");
}
/**
* Zeigt detaillierte Hilfe für ein spezifisches Kommando
*/
private function showCommandHelp(string $commandName): void
{
$commandList = $this->commandRegistry->getCommandList();
if (! $commandList->has($commandName)) {
$this->errorHandler->handleCommandNotFound($commandName, $this->output);
return;
}
$command = $commandList->get($commandName);
$this->output->writeLine("Kommando: {$command->name}", ConsoleColor::BRIGHT_CYAN);
$this->output->newLine();
if ($command->description) {
$this->output->writeLine("Beschreibung:", ConsoleColor::BRIGHT_YELLOW);
$this->output->writeLine(" {$command->description}");
$this->output->newLine();
}
// Versuche Parameter-Informationen anzuzeigen
try {
$this->showCommandParameters($command);
} catch (Throwable $e) {
// Fallback zu Standard-Verwendung
$this->output->writeLine("Verwendung:", ConsoleColor::BRIGHT_YELLOW);
$this->output->writeLine(" php {$this->scriptName} {$command->name} [argumente]");
}
}
/**
* Zeigt Parameter-Informationen für ein Kommando
*/
private function showCommandParameters(ConsoleCommand $command): void
{
try {
// Hole die DiscoveredAttribute für das Command
$discoveredAttribute = $this->commandRegistry->getDiscoveredAttribute($command->name);
// Hole Reflection Information
$reflection = new \ReflectionMethod($discoveredAttribute->className->toString(), $discoveredAttribute->methodName->toString());
// Prüfe ob es moderne Parameter-Parsing verwendet
if ($this->commandRegistry->usesReflectionParameters($reflection)) {
// Nutze den CommandParameterResolver für moderne Parameter-Hilfe
$resolver = $this->container->resolve(CommandParameterResolver::class);
$help = $resolver->generateMethodHelp($reflection, $command->name);
$this->output->writeLine($help);
} else {
// Fallback für Legacy-Commands
$this->output->writeLine("Verwendung:", ConsoleColor::BRIGHT_YELLOW);
$this->output->writeLine(" php {$this->scriptName} {$command->name} [argumente]");
}
} catch (\Throwable $e) {
// Fallback bei Fehlern
$this->output->writeLine("Verwendung:", ConsoleColor::BRIGHT_YELLOW);
$this->output->writeLine(" php {$this->scriptName} {$command->name} [argumente]");
}
}
/**
* Startet die interaktive TUI
*/
private function launchInteractiveTUI(): int
{
try {
// Prüfe ob Terminal kompatibel ist
if (! $this->isTerminalCompatible()) {
$this->output->writeError("Interactive TUI requires a compatible terminal.");
$this->output->writeLine("Use 'php {$this->scriptName} help' for command-line help.");
return ExitCode::SOFTWARE_ERROR->value;
}
// Get DiscoveryRegistry for TUI components
$discoveryRegistry = $this->container->get(DiscoveryRegistry::class);
// Create CommandHistory
$commandHistory = new CommandHistory();
// Create new services
$groupRegistry = new CommandGroupRegistry($discoveryRegistry);
$workflowExecutor = new SimpleWorkflowExecutor($this->commandRegistry, $groupRegistry, $this->output);
// Create TUI components
$state = new TuiState();
$renderer = new TuiRenderer($this->output);
$commandExecutor = new TuiCommandExecutor(
$this->output,
$this->commandRegistry,
$this->container,
$discoveryRegistry,
$commandHistory,
new CommandValidator(),
new CommandHelpGenerator(new ParameterInspector()),
$this->scriptName
);
$inputHandler = new TuiInputHandler($commandExecutor);
// Erstelle TUI Instanz
$tui = new ConsoleTUI(
$this->output,
$this->container,
$discoveryRegistry,
$state,
$renderer,
$inputHandler,
$commandExecutor,
$commandHistory,
$groupRegistry,
$workflowExecutor
);
// Starte TUI
return $tui->run()->value;
} catch (Throwable $e) {
$this->output->writeError("Failed to launch interactive TUI: " . $e->getMessage());
$config = $this->container->get(AppConfig::class);
if ($config->isDevelopment()) {
$this->output->writeLine("Stack trace:", ConsoleColor::RED);
$this->output->writeLine($e->getTraceAsString());
}
// Fallback to help
$this->output->newLine();
$this->output->writeLine("Falling back to command-line help:", ConsoleColor::YELLOW);
$this->showHelp();
return ExitCode::SOFTWARE_ERROR->value;
}
}
/**
* Prüft ob das Terminal für TUI kompatibel ist
*/
private function isTerminalCompatible(): bool
{
// Prüfe ob wir in einem Terminal sind
if (! function_exists('posix_isatty') || ! posix_isatty(STDOUT)) {
return false;
}
// Prüfe TERM environment variable
$term = $_SERVER['TERM'] ?? '';
if (empty($term) || $term === 'dumb') {
return false;
}
// Prüfe ob das Terminal interaktiv ist
if (! stream_isatty(STDIN) || ! stream_isatty(STDOUT)) {
return false;
}
return true;
}
}

View File

@@ -11,7 +11,41 @@ final readonly class ConsoleCommand
{
public function __construct(
public string $name,
public string $description = ''
public string $description = '',
public array $aliases = [],
public bool $hidden = false
) {
}
/**
* Get command name
*/
public function getName(): string
{
return $this->name;
}
/**
* Get command description
*/
public function getDescription(): string
{
return $this->description;
}
/**
* Get command aliases
*/
public function getAliases(): array
{
return $this->aliases;
}
/**
* Check if command is hidden
*/
public function isHidden(): bool
{
return $this->hidden;
}
}

View File

@@ -4,9 +4,9 @@ declare(strict_types=1);
namespace App\Framework\Console;
namespace App\Framework\Console;
use App\Framework\Console\Components\InteractiveMenu;
use App\Framework\Console\ValueObjects\ChoiceOptions;
use App\Framework\Console\ValueObjects\MenuOptions;
final class ConsoleInput implements ConsoleInputInterface
{
@@ -16,10 +16,20 @@ final class ConsoleInput implements ConsoleInputInterface
private ?ConsoleOutputInterface $output = null;
public function __construct(array $arguments, ?ConsoleOutputInterface $output = null)
private ?ArgumentParser $parser = null;
private ?ParsedArguments $parsedArguments = null;
public function __construct(array $arguments, ?ConsoleOutputInterface $output = null, ?ArgumentParser $parser = null)
{
$this->parseArguments($arguments);
$this->output = $output ?? new ConsoleOutput();
$this->parser = $parser;
// Use enhanced parser if available
if ($this->parser) {
$this->parsedArguments = $this->parser->parse($arguments);
}
}
private function parseArguments(array $arguments): void
@@ -102,13 +112,30 @@ final class ConsoleInput implements ConsoleInputInterface
/**
* Zeigt ein einfaches Auswahlmenü.
*/
/**
* @deprecated Use choiceFromOptions() with MenuOptions instead for better type safety
*/
public function choice(string $question, array $choices, mixed $default = null): mixed
{
$menuOptions = MenuOptions::fromArray($choices);
return $this->choiceFromOptions($question, $menuOptions, $default);
}
/**
* Present choices using MenuOptions Value Object
*/
public function choiceFromOptions(string $question, MenuOptions $options, mixed $default = null): mixed
{
$menu = new InteractiveMenu($this->output);
$menu->setTitle($question);
foreach ($choices as $key => $choice) {
$menu->addItem($choice, null, $key);
foreach ($options as $option) {
if ($option->isSeparator()) {
$menu->addSeparator();
} else {
$menu->addItem($option->label, null, $option->key);
}
}
return $menu->showSimple();
@@ -117,16 +144,29 @@ final class ConsoleInput implements ConsoleInputInterface
/**
* Zeigt ein interaktives Menü mit Pfeiltasten-Navigation.
*/
/**
* @deprecated Use menuFromOptions() with MenuOptions instead for better type safety
*/
public function menu(string $title, array $items): mixed
{
$menuOptions = MenuOptions::fromArray($items);
return $this->menuFromOptions($title, $menuOptions);
}
/**
* Show interactive menu using MenuOptions Value Object
*/
public function menuFromOptions(string $title, MenuOptions $options): mixed
{
$menu = new InteractiveMenu($this->output);
$menu->setTitle($title);
foreach ($items as $key => $item) {
if ($item === '---') {
foreach ($options as $option) {
if ($option->isSeparator()) {
$menu->addSeparator();
} else {
$menu->addItem($item, null, $key);
$menu->addItem($option->label, null, $option->key);
}
}
@@ -136,28 +176,175 @@ final class ConsoleInput implements ConsoleInputInterface
/**
* Ermöglicht die Mehrfachauswahl aus einer Liste von Optionen.
*/
/**
* @deprecated Use multiSelectFromOptions() with ChoiceOptions instead for better type safety
*/
public function multiSelect(string $question, array $options): array
{
$choiceOptions = ChoiceOptions::fromArray($options);
$selected = $this->multiSelectFromOptions($question, $choiceOptions);
return $selected->getSelectedValues();
}
/**
* Multi-select using ChoiceOptions Value Object
*/
public function multiSelectFromOptions(string $question, ChoiceOptions $options): ChoiceOptions
{
$this->output->writeLine($question, ConsoleColor::BRIGHT_CYAN);
$this->output->writeLine("Mehrfachauswahl mit Komma getrennt (z.B. 1,3,5):");
foreach ($options as $key => $option) {
$this->output->writeLine(" " . ($key + 1) . ": {$option}");
$displayOptions = $options->toArray();
foreach ($displayOptions as $key => $option) {
$this->output->writeLine(" " . ($key + 1) . ": {$option->label}");
}
$this->output->write("Ihre Auswahl: ", ConsoleColor::BRIGHT_CYAN);
$input = trim(fgets(STDIN));
$selected = [];
$selectedValues = [];
$indices = explode(',', $input);
foreach ($indices as $index) {
$index = (int)trim($index) - 1;
if (isset($options[$index])) {
$selected[] = $options[$index];
if (isset($displayOptions[$index])) {
$selectedValues[] = $displayOptions[$index]->value;
}
}
return $selected;
return $options->selectByValues($selectedValues);
}
// ==========================================
// Enhanced Argument Parsing Methods
// ==========================================
/**
* Get parsed arguments (enhanced parser required)
*/
public function getParsedArguments(): ParsedArguments
{
if (! $this->parsedArguments) {
throw new \RuntimeException('Enhanced parsing not available. Provide ArgumentParser in constructor.');
}
return $this->parsedArguments;
}
/**
* Check if enhanced parsing is available
*/
public function hasEnhancedParsing(): bool
{
return $this->parsedArguments !== null;
}
/**
* Get typed argument value (enhanced parser required)
*/
public function getTyped(string $name): mixed
{
return $this->getParsedArguments()->getTyped($name);
}
/**
* Get required argument (enhanced parser required)
*/
public function require(string $name): mixed
{
return $this->getParsedArguments()->require($name);
}
/**
* Get string argument with type safety
*/
public function getString(string $name): string
{
if ($this->hasEnhancedParsing()) {
return $this->getParsedArguments()->getString($name);
}
// Fallback to legacy parsing
return (string) $this->getOption($name, '');
}
/**
* Get integer argument with type safety
*/
public function getInt(string $name): int
{
if ($this->hasEnhancedParsing()) {
return $this->getParsedArguments()->getInt($name);
}
// Fallback to legacy parsing
return (int) $this->getOption($name, 0);
}
/**
* Get boolean argument with type safety
*/
public function getBool(string $name): bool
{
if ($this->hasEnhancedParsing()) {
return $this->getParsedArguments()->getBool($name);
}
// Fallback to legacy parsing
return (bool) $this->getOption($name, false);
}
/**
* Get array argument with type safety
*/
public function getArray(string $name): array
{
if ($this->hasEnhancedParsing()) {
return $this->getParsedArguments()->getArray($name);
}
// Fallback to legacy parsing
$value = $this->getOption($name);
if (is_string($value)) {
return array_map('trim', explode(',', $value));
}
return is_array($value) ? $value : [];
}
/**
* Check if argument has a value
*/
public function hasValue(string $name): bool
{
if ($this->hasEnhancedParsing()) {
return $this->getParsedArguments()->hasValue($name);
}
// Fallback to legacy parsing
return $this->hasOption($name) && $this->getOption($name) !== null;
}
/**
* Validate all required arguments are present (enhanced parser only)
*/
public function validate(): void
{
if ($this->hasEnhancedParsing()) {
$this->getParsedArguments()->validate();
}
}
/**
* Set argument parser (for late binding)
*/
public function setArgumentParser(ArgumentParser $parser): void
{
$this->parser = $parser;
// Re-parse arguments with enhanced parser
$rawArguments = array_merge($this->arguments, array_keys($this->options));
$this->parsedArguments = $parser->parse($rawArguments);
}
}

View File

@@ -9,7 +9,7 @@ use App\Framework\Console\Components\InteractiveMenu;
final readonly class DemoCommand
{
#[ConsoleCommand('demo:hello', 'Zeigt eine einfache Hallo-Welt-Nachricht')]
public function hello(ConsoleInput $input, ConsoleOutputInterface $output): int
public function hello(ConsoleInput $input, ConsoleOutputInterface $output): ExitCode
{
$output->writeWindowTitle('Help Title', 2);
@@ -21,11 +21,11 @@ final readonly class DemoCommand
$output->writeInfo('Aktuelle Zeit: ' . date('Y-m-d H:i:s'));
}
return 0;
return ExitCode::SUCCESS;
}
#[ConsoleCommand('demo:colors', 'Zeigt alle verfügbaren Farben')]
public function colors(ConsoleInput $input, ConsoleOutputInterface $output): int
public function colors(ConsoleInput $input, ConsoleOutputInterface $output): ExitCode
{
$output->writeLine('Verfügbare Farben:', ConsoleColor::BRIGHT_WHITE);
$output->newLine();
@@ -53,11 +53,11 @@ final readonly class DemoCommand
$output->writeWarning('Warnung-Nachricht');
$output->writeInfo('Info-Nachricht');
return 0;
return ExitCode::SUCCESS;
}
#[ConsoleCommand('demo:interactive', 'Interaktive Demo mit Benutzereingaben')]
public function interactive(ConsoleInput $input, ConsoleOutputInterface $output): int
public function interactive(ConsoleInput $input, ConsoleOutputInterface $output): ExitCode
{
$output->writeLine('Interaktive Demo', ConsoleColor::BRIGHT_CYAN);
$output->newLine();
@@ -73,11 +73,11 @@ final readonly class DemoCommand
$output->writeWarning('Vorgang abgebrochen.');
}
return 0;
return ExitCode::SUCCESS;
}
#[ConsoleCommand('demo:menu', 'Zeigt ein interaktives Menü')]
public function menu(ConsoleInput $input, ConsoleOutputInterface $output): int
public function menu(ConsoleInput $input, ConsoleOutputInterface $output): ExitCode
{
$menu = new InteractiveMenu($output);
@@ -108,11 +108,11 @@ final readonly class DemoCommand
$output->writeInfo("Menü-Rückgabe: {$result}");
}
return 0;
return ExitCode::SUCCESS;
}
#[ConsoleCommand('demo:simple-menu', 'Zeigt ein einfaches Nummern-Menü')]
public function simpleMenu(ConsoleInput $input, ConsoleOutputInterface $output): int
public function simpleMenu(ConsoleInput $input, ConsoleOutputInterface $output): ExitCode
{
$menu = new InteractiveMenu($output);
@@ -125,11 +125,11 @@ final readonly class DemoCommand
$output->writeSuccess("Sie haben gewählt: {$result}");
return 0;
return ExitCode::SUCCESS;
}
#[ConsoleCommand('demo:wizard', 'Zeigt einen Setup-Wizard')]
public function wizard(ConsoleInput $input, ConsoleOutputInterface $output): int
public function wizard(ConsoleInput $input, ConsoleOutputInterface $output): ExitCode
{
$output->writeInfo('🧙 Setup-Wizard gestartet');
$output->newLine();
@@ -175,7 +175,7 @@ final readonly class DemoCommand
$output->writeWarning('❌ Konfiguration wurde nicht gespeichert.');
}
return 0;
return ExitCode::SUCCESS;
}
/**

View File

@@ -8,6 +8,7 @@ use App\Framework\Console\ConsoleCommand;
use App\Framework\Console\ConsoleInput;
use App\Framework\Console\ConsoleOutput;
use App\Framework\Console\ConsoleStyle;
use App\Framework\Console\ExitCode;
use App\Framework\Console\Screen\ScreenType;
use App\Framework\Core\ValueObjects\Duration;
use App\Framework\DateTime\Timer;
@@ -20,7 +21,7 @@ final class ScreenDemoCommand
}
#[ConsoleCommand('demo:screen', 'Zeigt die verschiedenen Screen-Management-Funktionen')]
public function __invoke(ConsoleInput $input, ConsoleOutput $output): int
public function __invoke(ConsoleInput $input, ConsoleOutput $output): ExitCode
{
// Aktiviere interaktiven Modus
$output->screen->setInteractiveMode(true);
@@ -58,7 +59,7 @@ final class ScreenDemoCommand
$output->writeError('Ungültige Auswahl!');
}
return 0;
return ExitCode::SUCCESS;
}
private function demoCursor(ConsoleInput $input, ConsoleOutput $output): void

View File

@@ -0,0 +1,21 @@
<?php
declare(strict_types=1);
namespace App\Framework\Console\ErrorRecovery;
enum CommandRelation: string
{
case SAME_CATEGORY = 'category';
case SIMILAR_FUNCTION = 'function';
case RELATED = 'related';
public function getDisplayText(): string
{
return match ($this) {
self::SAME_CATEGORY => 'same category',
self::SIMILAR_FUNCTION => 'similar function',
self::RELATED => 'related command'
};
}
}

View File

@@ -0,0 +1,56 @@
<?php
declare(strict_types=1);
namespace App\Framework\Console\ErrorRecovery;
use App\Framework\Console\ConsoleCommand;
final readonly class CommandSuggestion
{
public function __construct(
public ConsoleCommand $command,
public float $similarity,
public SuggestionReason $reason
) {
}
public function getFormattedSuggestion(): string
{
$percentage = round($this->similarity * 100);
$reasonText = $this->reason->getDisplayText();
return sprintf(
'%s (%d%% match - %s)',
$this->command->name,
$percentage,
$reasonText
);
}
public function getDetailedSuggestion(): string
{
return sprintf(
"Command: %s\nDescription: %s\nMatch: %.1f%% (%s)",
$this->command->name,
$this->command->description,
$this->similarity * 100,
$this->reason->getDisplayText()
);
}
public function isHighConfidence(): bool
{
return $this->similarity >= 0.8;
}
public function isMediumConfidence(): bool
{
return $this->similarity >= 0.5 && $this->similarity < 0.8;
}
public function isLowConfidence(): bool
{
return $this->similarity < 0.5;
}
}

View File

@@ -0,0 +1,221 @@
<?php
declare(strict_types=1);
namespace App\Framework\Console\ErrorRecovery;
use App\Framework\Console\CommandList;
use App\Framework\Console\ConsoleCommand;
final readonly class CommandSuggestionEngine
{
public function __construct(
private CommandList $commandList,
private int $maxSuggestions = 5,
private float $minSimilarity = 0.3
) {
}
public function suggestCommand(string $inputCommand): CommandSuggestions
{
$suggestions = [];
$commands = $this->commandList->getAllCommands();
foreach ($commands as $command) {
$similarity = $this->calculateSimilarity($inputCommand, $command->name);
if ($similarity >= $this->minSimilarity) {
$suggestions[] = new CommandSuggestion(
command: $command,
similarity: $similarity,
reason: $this->determineSuggestionReason($inputCommand, $command->name, $similarity)
);
}
}
// Sort by similarity (highest first)
usort($suggestions, fn ($a, $b) => $b->similarity <=> $a->similarity);
// Limit to max suggestions
$suggestions = array_slice($suggestions, 0, $this->maxSuggestions);
return new CommandSuggestions($inputCommand, $suggestions);
}
public function suggestCommandsByCategory(string $category): array
{
$commands = $this->commandList->getAllCommands();
$categoryCommands = [];
foreach ($commands as $command) {
if ($this->matchesCategory($command, $category)) {
$categoryCommands[] = $command;
}
}
return $categoryCommands;
}
public function suggestSimilarCommands(ConsoleCommand $baseCommand, int $limit = 3): array
{
$commands = $this->commandList->getAllCommands();
$suggestions = [];
foreach ($commands as $command) {
if ($command->name === $baseCommand->name) {
continue;
}
$similarity = $this->calculateCommandSimilarity($baseCommand, $command);
if ($similarity > 0.4) {
$suggestions[] = [
'command' => $command,
'similarity' => $similarity,
'relation' => $this->determineCommandRelation($baseCommand, $command),
];
}
}
usort($suggestions, fn ($a, $b) => $b['similarity'] <=> $a['similarity']);
return array_slice($suggestions, 0, $limit);
}
private function calculateSimilarity(string $input, string $command): float
{
// Exact match
if (strtolower($input) === strtolower($command)) {
return 1.0;
}
// Prefix match
if (str_starts_with(strtolower($command), strtolower($input))) {
return 0.9;
}
// Contains match
if (str_contains(strtolower($command), strtolower($input))) {
return 0.7;
}
// Levenshtein distance similarity
$levenshtein = levenshtein(strtolower($input), strtolower($command));
$maxLength = max(strlen($input), strlen($command));
if ($maxLength === 0) {
return 0.0;
}
$levenshteinSimilarity = 1 - ($levenshtein / $maxLength);
// Word-based similarity for commands with colons
$wordSimilarity = $this->calculateWordSimilarity($input, $command);
return max($levenshteinSimilarity, $wordSimilarity);
}
private function calculateWordSimilarity(string $input, string $command): float
{
$inputWords = preg_split('/[:\-_\s]+/', strtolower($input));
$commandWords = preg_split('/[:\-_\s]+/', strtolower($command));
if (empty($inputWords) || empty($commandWords)) {
return 0.0;
}
$matches = 0;
$total = max(count($inputWords), count($commandWords));
foreach ($inputWords as $inputWord) {
foreach ($commandWords as $commandWord) {
if ($inputWord === $commandWord) {
$matches++;
break;
} elseif (str_starts_with($commandWord, $inputWord) || str_starts_with($inputWord, $commandWord)) {
$matches += 0.7;
break;
}
}
}
return $matches / $total;
}
private function calculateCommandSimilarity(ConsoleCommand $base, ConsoleCommand $other): float
{
$namesimilarity = $this->calculateSimilarity($base->name, $other->name);
$descriptionSimilarity = $this->calculateDescriptionSimilarity($base->description, $other->description);
return ($namesimilarity * 0.7) + ($descriptionSimilarity * 0.3);
}
private function calculateDescriptionSimilarity(string $desc1, string $desc2): float
{
$words1 = array_filter(preg_split('/\s+/', strtolower($desc1)));
$words2 = array_filter(preg_split('/\s+/', strtolower($desc2)));
if (empty($words1) || empty($words2)) {
return 0.0;
}
$commonWords = array_intersect($words1, $words2);
$totalWords = array_unique(array_merge($words1, $words2));
return count($commonWords) / count($totalWords);
}
private function determineSuggestionReason(string $input, string $command, float $similarity): SuggestionReason
{
if ($similarity >= 0.9) {
return SuggestionReason::EXACT_MATCH;
}
if (str_starts_with(strtolower($command), strtolower($input))) {
return SuggestionReason::PREFIX_MATCH;
}
if (str_contains(strtolower($command), strtolower($input))) {
return SuggestionReason::PARTIAL_MATCH;
}
if ($this->calculateWordSimilarity($input, $command) > 0.5) {
return SuggestionReason::WORD_SIMILARITY;
}
return SuggestionReason::FUZZY_MATCH;
}
private function determineCommandRelation(ConsoleCommand $base, ConsoleCommand $other): CommandRelation
{
$baseNamespace = $this->extractNamespace($base->name);
$otherNamespace = $this->extractNamespace($other->name);
if ($baseNamespace === $otherNamespace && ! empty($baseNamespace)) {
return CommandRelation::SAME_CATEGORY;
}
if ($this->calculateDescriptionSimilarity($base->description, $other->description) > 0.3) {
return CommandRelation::SIMILAR_FUNCTION;
}
return CommandRelation::RELATED;
}
private function matchesCategory(ConsoleCommand $command, string $category): bool
{
$namespace = $this->extractNamespace($command->name);
return strtolower($namespace) === strtolower($category) ||
str_contains(strtolower($command->description), strtolower($category));
}
private function extractNamespace(string $commandName): string
{
$parts = explode(':', $commandName);
return count($parts) > 1 ? $parts[0] : '';
}
}

View File

@@ -0,0 +1,92 @@
<?php
declare(strict_types=1);
namespace App\Framework\Console\ErrorRecovery;
final readonly class CommandSuggestions
{
/**
* @param string $originalInput
* @param CommandSuggestion[] $suggestions
*/
public function __construct(
public string $originalInput,
public array $suggestions
) {
}
public function hasSuggestions(): bool
{
return ! empty($this->suggestions);
}
public function getBestSuggestion(): ?CommandSuggestion
{
return $this->suggestions[0] ?? null;
}
public function getHighConfidenceSuggestions(): array
{
return array_filter($this->suggestions, fn ($suggestion) => $suggestion->isHighConfidence());
}
public function getMediumConfidenceSuggestions(): array
{
return array_filter($this->suggestions, fn ($suggestion) => $suggestion->isMediumConfidence());
}
public function getLowConfidenceSuggestions(): array
{
return array_filter($this->suggestions, fn ($suggestion) => $suggestion->isLowConfidence());
}
public function formatForDisplay(): string
{
if (! $this->hasSuggestions()) {
return "No similar commands found for '{$this->originalInput}'.";
}
$output = "Did you mean one of these commands?\n\n";
$highConfidence = $this->getHighConfidenceSuggestions();
$mediumConfidence = $this->getMediumConfidenceSuggestions();
$lowConfidence = $this->getLowConfidenceSuggestions();
if (! empty($highConfidence)) {
$output .= "Strong matches:\n";
foreach ($highConfidence as $suggestion) {
$output .= "{$suggestion->getFormattedSuggestion()}\n";
}
$output .= "\n";
}
if (! empty($mediumConfidence)) {
$output .= "Possible matches:\n";
foreach ($mediumConfidence as $suggestion) {
$output .= "{$suggestion->getFormattedSuggestion()}\n";
}
$output .= "\n";
}
if (! empty($lowConfidence) && empty($highConfidence) && empty($mediumConfidence)) {
$output .= "Potential matches:\n";
foreach ($lowConfidence as $suggestion) {
$output .= "{$suggestion->getFormattedSuggestion()}\n";
}
$output .= "\n";
}
return rtrim($output);
}
public function count(): int
{
return count($this->suggestions);
}
public function isEmpty(): bool
{
return empty($this->suggestions);
}
}

View File

@@ -0,0 +1,219 @@
<?php
declare(strict_types=1);
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\FrameworkException;
use App\Framework\Logging\Logger;
use App\Framework\Logging\ValueObjects\LogContext;
final readonly class ConsoleErrorHandler
{
public function __construct(
private ErrorRecoveryService $recoveryService,
private ?Logger $logger = null
) {
}
public function handleCommandNotFound(string $command, ConsoleOutputInterface $output): ExitCode
{
$this->logError("Command not found: {$command}");
$this->recoveryService->handleCommandNotFound($command, $output);
return ExitCode::COMMAND_NOT_FOUND;
}
public function handleCommandExecutionError(
string $command,
\Throwable $error,
ConsoleOutputInterface $output
): ExitCode {
$this->logError("Command execution error in '{$command}': " . $error->getMessage(), [
'command' => $command,
'error_type' => get_class($error),
'trace' => $error->getTraceAsString(),
]);
if ($error instanceof FrameworkException) {
return $this->handleFrameworkException($command, $error, $output);
}
$this->recoveryService->handleCommandExecutionError($command, $error, $output);
return $this->determineExitCode($error);
}
public function handleValidationError(
string $command,
string $validationError,
ConsoleOutputInterface $output
): ExitCode {
$this->logError("Validation error in '{$command}': {$validationError}");
$this->recoveryService->handleArgumentError($command, $validationError, $output);
return ExitCode::INVALID_INPUT;
}
public function handlePermissionError(
string $command,
ConsoleOutputInterface $output
): ExitCode {
$this->logError("Permission denied for command: {$command}");
$this->recoveryService->handlePermissionError($command, $output);
return ExitCode::PERMISSION_DENIED;
}
public function handleUnexpectedError(
string $command,
\Throwable $error,
ConsoleOutputInterface $output
): ExitCode {
$this->logError("Unexpected error in '{$command}': " . $error->getMessage(), [
'command' => $command,
'error_type' => get_class($error),
'file' => $error->getFile(),
'line' => $error->getLine(),
'trace' => $error->getTraceAsString(),
]);
$output->writeLine("💥 An unexpected error occurred:", ConsoleColor::RED);
$output->writeLine(" {$error->getMessage()}", ConsoleColor::RED);
$output->newLine();
$output->writeLine("🔍 Debug information:", ConsoleColor::GRAY);
$output->writeLine(" Error: " . get_class($error), ConsoleColor::GRAY);
$output->writeLine(" File: {$error->getFile()}:{$error->getLine()}", ConsoleColor::GRAY);
$output->newLine();
$this->recoveryService->handleGeneralError($command, $error, $output);
return ExitCode::GENERAL_ERROR;
}
public function handleGracefulShutdown(string $reason, ConsoleOutputInterface $output): ExitCode
{
$this->logInfo("Graceful shutdown: {$reason}");
$output->writeLine("🛑 Operation interrupted: {$reason}", ConsoleColor::YELLOW);
$output->writeLine(" The command was safely terminated.", ConsoleColor::GRAY);
$output->newLine();
return ExitCode::INTERRUPTED;
}
private function handleFrameworkException(
string $command,
FrameworkException $exception,
ConsoleOutputInterface $output
): ExitCode {
$errorCode = $exception->getErrorCode();
return match ($errorCode) {
ErrorCode::CON_COMMAND_NOT_FOUND => $this->handleCommandNotFound($command, $output),
ErrorCode::CON_INVALID_ARGUMENTS => $this->handleValidationError(
$command,
$exception->getMessage(),
$output
),
ErrorCode::AUTH_UNAUTHORIZED,
ErrorCode::AUTH_INSUFFICIENT_PRIVILEGES => $this->handlePermissionError($command, $output),
ErrorCode::DB_CONNECTION_FAILED,
ErrorCode::DB_QUERY_FAILED => $this->handleDatabaseError($command, $exception, $output),
ErrorCode::HTTP_RATE_LIMIT_EXCEEDED => $this->handleRateLimitError($command, $exception, $output),
default => $this->handleGeneralFrameworkError($command, $exception, $output)
};
}
private function handleDatabaseError(
string $command,
FrameworkException $exception,
ConsoleOutputInterface $output
): ExitCode {
$output->writeLine("🗄️ Database error in command '{$command}':", ConsoleColor::RED);
$output->writeLine(" {$exception->getMessage()}", ConsoleColor::RED);
$output->newLine();
$output->writeLine("💡 Database troubleshooting:", ConsoleColor::CYAN);
$output->writeLine(" • Check database connection settings", ConsoleColor::WHITE);
$output->writeLine(" • Verify database server is running", ConsoleColor::WHITE);
$output->writeLine(" • Check network connectivity", ConsoleColor::WHITE);
$output->writeLine(" • Verify database credentials", ConsoleColor::WHITE);
$output->newLine();
return ExitCode::DATABASE_ERROR;
}
private function handleRateLimitError(
string $command,
FrameworkException $exception,
ConsoleOutputInterface $output
): ExitCode {
$output->writeLine("⏳ Rate limit exceeded for command '{$command}':", ConsoleColor::YELLOW);
$output->writeLine(" {$exception->getMessage()}", ConsoleColor::YELLOW);
$output->newLine();
if ($exception->hasRetryAfter()) {
$retryAfter = $exception->getRetryAfter();
$output->writeLine("🕐 You can retry this command in {$retryAfter} seconds.", ConsoleColor::CYAN);
} else {
$output->writeLine("🕐 Please wait before retrying this command.", ConsoleColor::CYAN);
}
$output->newLine();
return ExitCode::RATE_LIMITED;
}
private function handleGeneralFrameworkError(
string $command,
FrameworkException $exception,
ConsoleOutputInterface $output
): ExitCode {
$this->recoveryService->handleCommandExecutionError($command, $exception, $output);
return $this->determineExitCode($exception);
}
private function determineExitCode(\Throwable $error): ExitCode
{
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,
default => ExitCode::GENERAL_ERROR
};
}
return match (true) {
$error instanceof \ArgumentCountError,
$error instanceof \TypeError => ExitCode::INVALID_INPUT,
$error instanceof \Error => ExitCode::FATAL_ERROR,
default => ExitCode::GENERAL_ERROR
};
}
private function logError(string $message, array $context = []): void
{
$this->logger?->error($message, LogContext::withData($context));
}
private function logInfo(string $message, array $context = []): void
{
$this->logger?->info($message, LogContext::withData($context));
}
}

View File

@@ -0,0 +1,51 @@
<?php
declare(strict_types=1);
namespace App\Framework\Console\ErrorRecovery;
final readonly class ErrorRecoveryAnalysis
{
/**
* @param \Throwable $error
* @param string[] $recoveryOptions
*/
public function __construct(
public \Throwable $error,
public array $recoveryOptions
) {
}
public function hasRecoveryOptions(): bool
{
return ! empty($this->recoveryOptions);
}
public function getRecoveryOptions(): array
{
return $this->recoveryOptions;
}
public function getErrorType(): string
{
return get_class($this->error);
}
public function getErrorMessage(): string
{
return $this->error->getMessage();
}
public function isRecoverable(): bool
{
return $this->hasRecoveryOptions() &&
! $this->isCriticalError();
}
private function isCriticalError(): bool
{
return $this->error instanceof \Error ||
str_contains($this->error->getMessage(), 'Fatal') ||
str_contains($this->error->getMessage(), 'Critical');
}
}

View File

@@ -0,0 +1,269 @@
<?php
declare(strict_types=1);
namespace App\Framework\Console\ErrorRecovery;
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\FrameworkException;
final readonly class ErrorRecoveryService
{
public function __construct(
private CommandSuggestionEngine $suggestionEngine,
private CommandList $commandList,
private CommandRegistry $commandRegistry
) {
}
public function handleCommandNotFound(string $command, ConsoleOutputInterface $output): void
{
$output->writeLine("❌ Command '{$command}' not found.", ConsoleColor::RED);
$output->newLine();
$suggestions = $this->suggestionEngine->suggestCommand($command);
if ($suggestions->hasSuggestions()) {
$output->writeLine($suggestions->formatForDisplay(), ConsoleColor::YELLOW);
$this->showAdditionalHelp($command, $output);
} else {
$this->showGeneralHelp($output);
}
}
public function handleCommandExecutionError(
string $command,
\Throwable $error,
ConsoleOutputInterface $output
): void {
$output->writeLine("💥 Error executing command '{$command}':", ConsoleColor::RED);
$output->writeLine(" {$error->getMessage()}", ConsoleColor::RED);
$output->newLine();
$recovery = $this->analyzeError($error);
if ($recovery->hasRecoveryOptions()) {
$output->writeLine("🔧 Possible solutions:", ConsoleColor::CYAN);
foreach ($recovery->getRecoveryOptions() as $option) {
$output->writeLine("{$option}", ConsoleColor::WHITE);
}
$output->newLine();
}
$this->suggestRelatedCommands($command, $output);
}
public function handleArgumentError(
string $command,
string $argumentError,
ConsoleOutputInterface $output
): void {
$output->writeLine("⚠️ Argument error in command '{$command}':", ConsoleColor::YELLOW);
$output->writeLine(" {$argumentError}", ConsoleColor::YELLOW);
$output->newLine();
try {
$help = $this->commandRegistry->generateCommandHelp($command);
$output->writeLine("💡 Command usage:", ConsoleColor::CYAN);
$output->writeLine($help, ConsoleColor::WHITE);
} catch (\Exception $e) {
$output->writeLine("Unable to generate help for this command.", ConsoleColor::GRAY);
}
}
public function handlePermissionError(
string $command,
ConsoleOutputInterface $output
): void {
$output->writeLine("🔒 Permission denied for command '{$command}'.", ConsoleColor::RED);
$output->newLine();
$output->writeLine("💡 Try one of these solutions:", ConsoleColor::CYAN);
$output->writeLine(" • Check if you have the required permissions", ConsoleColor::WHITE);
$output->writeLine(" • Run the command with appropriate privileges", ConsoleColor::WHITE);
$output->writeLine(" • Contact your administrator if permissions are required", ConsoleColor::WHITE);
$output->newLine();
$this->suggestAlternativeCommands($command, $output);
}
public function handleGeneralError(
string $command,
\Throwable $error,
ConsoleOutputInterface $output
): void {
$output->writeLine("❌ Unexpected error in command '{$command}':", ConsoleColor::RED);
$output->writeLine(" {$error->getMessage()}", ConsoleColor::RED);
$output->newLine();
$output->writeLine("🔍 Debugging steps:", ConsoleColor::CYAN);
$output->writeLine(" • Check the command syntax and arguments", ConsoleColor::WHITE);
$output->writeLine(" • Verify all required dependencies are available", ConsoleColor::WHITE);
$output->writeLine(" • Check system resources and permissions", ConsoleColor::WHITE);
$output->writeLine(" • Try running with verbose output for more details", ConsoleColor::WHITE);
$output->newLine();
if ($error instanceof FrameworkException) {
$this->handleFrameworkError($error, $output);
}
}
private function analyzeError(\Throwable $error): ErrorRecoveryAnalysis
{
$options = [];
if ($error instanceof FrameworkException) {
$options = match ($error->getErrorCode()) {
ErrorCode::CON_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 => [
'Check the command usage with "help {command}"',
'Verify all required arguments are provided',
'Check argument types and formats',
],
ErrorCode::DB_CONNECTION_FAILED => [
'Check database connection settings',
'Verify database server is running',
'Check network connectivity to database',
],
ErrorCode::SYSTEM_DEPENDENCY_MISSING => [
'Check if all required dependencies are installed',
'Verify service configuration',
'Check for missing environment variables',
],
default => [
'Check the error message for specific details',
'Verify system configuration',
'Try the command with different parameters',
]
};
} else {
$options = match (true) {
str_contains($error->getMessage(), 'Permission denied') => [
'Check file and directory permissions',
'Run with appropriate user privileges',
'Verify write access to required directories',
],
str_contains($error->getMessage(), 'Connection') => [
'Check network connectivity',
'Verify service endpoints are accessible',
'Check firewall and proxy settings',
],
str_contains($error->getMessage(), 'Memory') => [
'Increase PHP memory limit',
'Optimize command for large datasets',
'Process data in smaller batches',
],
default => [
'Check the command syntax and arguments',
'Verify system configuration',
'Try running the command again',
]
};
}
return new ErrorRecoveryAnalysis($error, $options);
}
private function handleFrameworkError(FrameworkException $error, ConsoleOutputInterface $output): void
{
if ($error->hasRetryAfter()) {
$retryAfter = $error->getRetryAfter();
$output->writeLine("⏳ This operation can be retried after {$retryAfter} seconds.", ConsoleColor::YELLOW);
$output->newLine();
}
$context = $error->getContext();
if (! empty($context)) {
$output->writeLine("🔍 Error context:", ConsoleColor::GRAY);
foreach ($context as $key => $value) {
if (is_string($value) || is_numeric($value)) {
$output->writeLine(" {$key}: {$value}", ConsoleColor::GRAY);
}
}
$output->newLine();
}
}
private function showAdditionalHelp(string $command, ConsoleOutputInterface $output): void
{
$output->newLine();
$output->writeLine("💡 Additional help:", ConsoleColor::CYAN);
$output->writeLine(" • Use 'help' to see all available commands", ConsoleColor::WHITE);
$output->writeLine(" • Use 'help {command}' to get detailed help for a specific command", ConsoleColor::WHITE);
// Suggest commands from same category
$namespace = $this->extractNamespace($command);
if (! empty($namespace)) {
$categoryCommands = $this->suggestionEngine->suggestCommandsByCategory($namespace);
if (! empty($categoryCommands)) {
$output->writeLine(
" • Other '{$namespace}' commands: " .
implode(', ', array_slice(array_map(fn ($cmd) => $cmd->name, $categoryCommands), 0, 3)),
ConsoleColor::WHITE
);
}
}
}
private function showGeneralHelp(ConsoleOutputInterface $output): void
{
$output->writeLine("💡 Available help:", ConsoleColor::CYAN);
$output->writeLine(" • Use 'help' to see all available commands", ConsoleColor::WHITE);
$output->writeLine(" • Use 'list' to browse commands by category", ConsoleColor::WHITE);
$output->writeLine(" • Check command spelling and try again", ConsoleColor::WHITE);
}
private function suggestRelatedCommands(string $command, ConsoleOutputInterface $output): void
{
try {
$commandObj = $this->commandList->get($command);
$related = $this->suggestionEngine->suggestSimilarCommands($commandObj, 3);
if (! empty($related)) {
$output->writeLine("🔗 Related commands:", ConsoleColor::CYAN);
foreach ($related as $rel) {
$output->writeLine(
"{$rel['command']->name} - {$rel['relation']->getDisplayText()}",
ConsoleColor::WHITE
);
}
$output->newLine();
}
} catch (\Exception $e) {
// Command not found, skip related suggestions
}
}
private function suggestAlternativeCommands(string $command, ConsoleOutputInterface $output): void
{
$namespace = $this->extractNamespace($command);
if (! empty($namespace)) {
$alternatives = $this->suggestionEngine->suggestCommandsByCategory($namespace);
if (! empty($alternatives)) {
$output->writeLine("🔀 Alternative commands in '{$namespace}' category:", ConsoleColor::CYAN);
foreach (array_slice($alternatives, 0, 5) as $alt) {
$output->writeLine("{$alt->name} - {$alt->description}", ConsoleColor::WHITE);
}
$output->newLine();
}
}
}
private function extractNamespace(string $commandName): string
{
$parts = explode(':', $commandName);
return count($parts) > 1 ? $parts[0] : '';
}
}

View File

@@ -0,0 +1,36 @@
<?php
declare(strict_types=1);
namespace App\Framework\Console\ErrorRecovery;
enum SuggestionReason: string
{
case EXACT_MATCH = 'exact';
case PREFIX_MATCH = 'prefix';
case PARTIAL_MATCH = 'partial';
case WORD_SIMILARITY = 'words';
case FUZZY_MATCH = 'fuzzy';
public function getDisplayText(): string
{
return match ($this) {
self::EXACT_MATCH => 'exact match',
self::PREFIX_MATCH => 'starts with input',
self::PARTIAL_MATCH => 'contains input',
self::WORD_SIMILARITY => 'similar words',
self::FUZZY_MATCH => 'similar spelling'
};
}
public function getPriority(): int
{
return match ($this) {
self::EXACT_MATCH => 100,
self::PREFIX_MATCH => 90,
self::PARTIAL_MATCH => 70,
self::WORD_SIMILARITY => 60,
self::FUZZY_MATCH => 40
};
}
}

View File

@@ -0,0 +1,126 @@
<?php
declare(strict_types=1);
namespace App\Framework\Console\Examples;
use App\Framework\Console\ArgumentParser;
use App\Framework\Console\ConsoleCommand;
use App\Framework\Console\ConsoleInput;
use App\Framework\Console\ConsoleOutputInterface;
use App\Framework\Console\ExitCode;
use App\Framework\Console\HelpGenerator;
use App\Framework\Core\ValueObjects\Email;
/**
* Example command demonstrating the enhanced parameter parsing system
*/
final readonly class CreateUserCommand
{
private ArgumentParser $parser;
public function __construct()
{
// Define command arguments using the fluent builder
$this->parser = ArgumentParser::create()
->requiredString('email', 'User email address')
->requiredString('name', 'Full name of the user')
->choice('role', ['user', 'admin', 'moderator'], false, 'user', 'User role')
->flag('force', 'f', 'Skip confirmation prompts')
->flag('notify', null, 'Send welcome email to user')
->integer('quota', false, 100, 'Storage quota in MB')
->build();
}
#[ConsoleCommand('user:create', 'Create a new user account')]
public function execute(ConsoleInput $input, ConsoleOutputInterface $output): ExitCode
{
try {
// Set the enhanced parser for this command
$input->setArgumentParser($this->parser);
// Validate all required arguments
$input->validate();
// Get typed arguments with automatic validation
$email = new Email($input->getString('email'));
$name = $input->getString('name');
$role = $input->getString('role');
$force = $input->getBool('force');
$notify = $input->getBool('notify');
$quota = $input->getInt('quota');
// Display what we're about to do
$output->writeLine("Creating user with the following details:");
$output->writeLine(" Email: {$email->toString()}");
$output->writeLine(" Name: {$name}");
$output->writeLine(" Role: {$role}");
$output->writeLine(" Quota: {$quota} MB");
$output->writeLine(" Notify: " . ($notify ? 'Yes' : 'No'));
$output->newLine();
// Confirm unless forced
if (! $force && ! $input->confirm('Create this user?', false)) {
$output->writeInfo('User creation cancelled.');
return ExitCode::SUCCESS;
}
// Simulate user creation
$output->writeSuccess("User '{$name}' created successfully!");
if ($notify) {
$output->writeInfo("Welcome email sent to {$email->toString()}");
}
return ExitCode::SUCCESS;
} catch (\InvalidArgumentException $e) {
$output->writeError($e->getMessage());
$this->showHelp($output);
return ExitCode::INVALID_INPUT;
} catch (\Exception $e) {
$output->writeError("Failed to create user: " . $e->getMessage());
return ExitCode::GENERAL_ERROR;
}
}
#[ConsoleCommand('user:create:help', 'Show help for user:create command')]
public function showHelp(ConsoleInput $input, ConsoleOutputInterface $output): ExitCode
{
$helpGenerator = new HelpGenerator($output);
$helpGenerator->display(
'user:create',
$this->parser->getDefinitions(),
'Create a new user account with email, name, and optional settings.'
);
return ExitCode::SUCCESS;
}
/**
* Example usage methods to demonstrate all features
*/
public function getExampleUsages(): array
{
return [
// Basic usage
'php console user:create john@example.com "John Doe"',
// With role
'php console user:create john@example.com "John Doe" --role=admin',
// With short flags
'php console user:create john@example.com "John Doe" -f --notify',
// With quota
'php console user:create john@example.com "John Doe" --quota=500 --role=moderator',
// All options
'php console user:create john@example.com "John Doe" --role=admin --force --notify --quota=1000',
];
}
}

View File

@@ -0,0 +1,51 @@
<?php
declare(strict_types=1);
namespace App\Framework\Console\Examples;
/**
* User role enumeration for console commands
*/
enum UserRole: string
{
case USER = 'user';
case ADMIN = 'admin';
case MODERATOR = 'moderator';
/**
* Get human-readable description
*/
public function getDescription(): string
{
return match($this) {
self::USER => 'Regular user with basic permissions',
self::ADMIN => 'Administrator with full system access',
self::MODERATOR => 'Moderator with content management permissions',
};
}
/**
* Get all role values as array
*/
public static function getValues(): array
{
return array_column(self::cases(), 'value');
}
/**
* Check if role has admin privileges
*/
public function hasAdminPrivileges(): bool
{
return $this === self::ADMIN;
}
/**
* Check if role can moderate content
*/
public function canModerate(): bool
{
return in_array($this, [self::ADMIN, self::MODERATOR], true);
}
}

View File

@@ -0,0 +1,197 @@
<?php
declare(strict_types=1);
namespace App\Framework\Console\Examples;
use App\Framework\Console\CommandGroup;
use App\Framework\Console\CommandWorkflow;
use App\Framework\Console\ConsoleColor;
use App\Framework\Console\ConsoleCommand;
use App\Framework\Console\ConsoleInput;
use App\Framework\Console\ConsoleOutputInterface;
use App\Framework\Console\ExitCode;
/**
* Demo commands to showcase the new command groups and workflow system
*/
#[CommandGroup(
name: 'Workflow Demo',
description: 'Demo commands showcasing workflow and group features',
icon: '🎭',
priority: 100,
tags: ['demo', 'workflow', 'showcase']
)]
final readonly class WorkflowDemoCommand
{
#[ConsoleCommand('workflow:setup', 'Set up a complete development environment')]
#[CommandWorkflow(
name: 'Development Setup',
description: 'Complete development environment setup workflow',
steps: [
[
'command' => 'workflow:check-system',
'description' => 'Check system requirements',
'optional' => false,
],
[
'command' => 'workflow:install-deps',
'description' => 'Install dependencies',
'optional' => false,
'retryCount' => 2,
],
[
'command' => 'workflow:setup-db',
'description' => 'Set up database',
'optional' => false,
'environment' => ['DB_SETUP' => 'true'],
],
[
'command' => 'workflow:seed-data',
'description' => 'Seed initial data',
'optional' => true,
'condition' => 'development',
],
],
prerequisites: ['database'],
stopOnError: true,
rollbackSteps: [
[
'command' => 'workflow:cleanup-db',
'description' => 'Clean up database on failure',
],
]
)]
public function setupWorkflow(ConsoleInput $input, ConsoleOutputInterface $output): ExitCode
{
$output->writeLine('🚀 Development setup workflow started!', ConsoleColor::BRIGHT_GREEN);
$output->writeLine('This workflow will be handled by the WorkflowExecutor.', ConsoleColor::GRAY);
return ExitCode::SUCCESS;
}
#[ConsoleCommand('workflow:check-system', 'Check system requirements')]
public function checkSystem(ConsoleInput $input, ConsoleOutputInterface $output): ExitCode
{
$output->writeLine('🔍 Checking system requirements...', ConsoleColor::CYAN);
$checks = [
'PHP Version' => PHP_VERSION_ID >= 80200,
'Extensions' => extension_loaded('pdo'),
'Memory Limit' => (int)ini_get('memory_limit') >= 128,
'Disk Space' => disk_free_space('.') > 1024 * 1024 * 100, // 100MB
];
foreach ($checks as $check => $result) {
$status = $result ? '✅' : '❌';
$color = $result ? ConsoleColor::GREEN : ConsoleColor::RED;
$output->writeLine(" {$status} {$check}", $color);
}
return ExitCode::SUCCESS;
}
#[ConsoleCommand('workflow:install-deps', 'Install project dependencies')]
public function installDependencies(ConsoleInput $input, ConsoleOutputInterface $output): ExitCode
{
$output->writeLine('📦 Installing dependencies...', ConsoleColor::CYAN);
$steps = [
'Composer dependencies',
'NPM packages',
'Framework assets',
];
foreach ($steps as $step) {
$output->writeLine(" Installing {$step}...", ConsoleColor::WHITE);
usleep(500000); // Simulate installation time
$output->writeLine("{$step} installed", ConsoleColor::GREEN);
}
return ExitCode::SUCCESS;
}
#[ConsoleCommand('workflow:setup-db', 'Set up database and run migrations')]
public function setupDatabase(ConsoleInput $input, ConsoleOutputInterface $output): ExitCode
{
$output->writeLine('🗄️ Setting up database...', ConsoleColor::CYAN);
$steps = [
'Create database',
'Run migrations',
'Set up indexes',
];
foreach ($steps as $step) {
$output->writeLine(" {$step}...", ConsoleColor::WHITE);
usleep(300000); // Simulate operation time
$output->writeLine("{$step} completed", ConsoleColor::GREEN);
}
return ExitCode::SUCCESS;
}
#[ConsoleCommand('workflow:seed-data', 'Seed initial development data')]
public function seedData(ConsoleInput $input, ConsoleOutputInterface $output): ExitCode
{
$output->writeLine('🌱 Seeding development data...', ConsoleColor::CYAN);
$datasets = [
'Test users',
'Sample content',
'Configuration data',
];
foreach ($datasets as $dataset) {
$output->writeLine(" Seeding {$dataset}...", ConsoleColor::WHITE);
usleep(200000); // Simulate seeding time
$output->writeLine("{$dataset} seeded", ConsoleColor::GREEN);
}
return ExitCode::SUCCESS;
}
#[ConsoleCommand('workflow:cleanup-db', 'Clean up database (rollback operation)')]
public function cleanupDatabase(ConsoleInput $input, ConsoleOutputInterface $output): ExitCode
{
$output->writeLine('🧹 Cleaning up database...', ConsoleColor::YELLOW);
$steps = [
'Drop test tables',
'Clear cache',
'Reset connections',
];
foreach ($steps as $step) {
$output->writeLine(" {$step}...", ConsoleColor::WHITE);
usleep(100000); // Simulate cleanup time
$output->writeLine("{$step} completed", ConsoleColor::GREEN);
}
return ExitCode::SUCCESS;
}
#[ConsoleCommand('workflow:status', 'Show workflow and group information')]
public function showStatus(ConsoleInput $input, ConsoleOutputInterface $output): ExitCode
{
$output->writeLine('📋 Workflow System Status', ConsoleColor::BRIGHT_CYAN);
$output->newLine();
$output->writeLine('Command Groups:', ConsoleColor::BRIGHT_WHITE);
$output->writeLine(' • This command belongs to: "Workflow Demo" group', ConsoleColor::WHITE);
$output->writeLine(' • Group icon: 🎭', ConsoleColor::WHITE);
$output->writeLine(' • Group priority: 100', ConsoleColor::WHITE);
$output->writeLine(' • Group tags: demo, workflow, showcase', ConsoleColor::WHITE);
$output->newLine();
$output->writeLine('Available Workflows:', ConsoleColor::BRIGHT_WHITE);
$output->writeLine(' • Development Setup (workflow:setup)', ConsoleColor::WHITE);
$output->writeLine(' - 4 steps with prerequisites and rollback', ConsoleColor::GRAY);
$output->writeLine(' - Stops on error with automatic cleanup', ConsoleColor::GRAY);
$output->newLine();
$output->writeLine('💡 Try running "workflow:setup" to see the workflow in action!', ConsoleColor::BRIGHT_YELLOW);
return ExitCode::SUCCESS;
}
}

View File

@@ -31,6 +31,8 @@ enum ExitCode: int
case PROTOCOL_ERROR = 76;
case NO_PERMISSION = 77;
case CONFIG_ERROR = 78;
case DATABASE_ERROR = 79;
case RATE_LIMITED = 80;
case PERMISSION_DENIED = 126;
case INTERRUPTED = 130;
@@ -56,6 +58,8 @@ enum ExitCode: int
self::PROTOCOL_ERROR => 'Protokoll-Fehler',
self::NO_PERMISSION => 'Keine Berechtigung',
self::CONFIG_ERROR => 'Konfigurationsfehler',
self::DATABASE_ERROR => 'Datenbankfehler',
self::RATE_LIMITED => 'Rate-Limit erreicht',
self::PERMISSION_DENIED => 'Zugriff verweigert',
self::INTERRUPTED => 'Unterbrochen durch Signal (SIGINT/SIGTERM)',
};

View File

@@ -0,0 +1,104 @@
<?php
declare(strict_types=1);
namespace App\Framework\Console\Health\Checks;
use App\Framework\Console\Analytics\AnalyticsService;
use App\Framework\Console\Health\HealthCheck;
use App\Framework\Console\Health\HealthCheckResult;
use DateTimeImmutable;
final readonly class AnalyticsHealthCheck implements HealthCheck
{
public function __construct(
private AnalyticsService $analyticsService
) {
}
public function getName(): string
{
return 'Analytics System';
}
public function getDescription(): string
{
return 'Checks if command analytics collection and storage is working properly';
}
public function check(): HealthCheckResult
{
try {
$commands = $this->analyticsService->getAllCommandNames();
$commandCount = count($commands);
if ($commandCount === 0) {
return HealthCheckResult::warning(
'No analytics data found',
['command_count' => 0],
'Analytics collection may not be enabled or no commands have been executed'
);
}
// Check recent activity (last 24 hours)
$since = new DateTimeImmutable('-24 hours');
$recentActivity = false;
foreach ($commands as $commandName) {
$stats = $this->analyticsService->getCommandStatistics($commandName, $since);
if ($stats->totalExecutions > 0) {
$recentActivity = true;
break;
}
}
$details = ['total_commands_tracked' => $commandCount];
if (! $recentActivity) {
return HealthCheckResult::warning(
'No recent analytics activity detected',
$details,
'Check if analytics collection middleware is properly registered'
);
}
// Check for commands with high failure rates
$failingCommands = $this->analyticsService->getMostFailingCommands(5, $since);
$highFailureCommands = array_filter(
$failingCommands,
fn ($cmd) => $cmd['failure_rate']->getValue() > 50
);
if (! empty($highFailureCommands)) {
$commandNames = array_column($highFailureCommands, 'command_name');
return HealthCheckResult::warning(
'High failure rates detected for some commands',
array_merge($details, [
'high_failure_commands' => $commandNames,
'recent_activity' => true,
]),
'Investigate and fix failing commands'
);
}
return HealthCheckResult::healthy(
"Analytics system is healthy, tracking {$commandCount} commands with recent activity",
array_merge($details, ['recent_activity' => true])
);
} catch (\Throwable $e) {
return HealthCheckResult::unhealthy(
'Analytics check failed: ' . $e->getMessage(),
['error' => $e->getMessage()],
'Check database connection and analytics table structure'
);
}
}
public function isRequired(): bool
{
return false; // Analytics is optional
}
}

View File

@@ -0,0 +1,96 @@
<?php
declare(strict_types=1);
namespace App\Framework\Console\Health\Checks;
use App\Framework\Console\CommandRegistry;
use App\Framework\Console\Health\HealthCheck;
use App\Framework\Console\Health\HealthCheckResult;
final readonly class CommandRegistryHealthCheck implements HealthCheck
{
public function __construct(
private CommandRegistry $commandRegistry
) {
}
public function getName(): string
{
return 'Command Registry';
}
public function getDescription(): string
{
return 'Checks if console commands are properly registered and discoverable';
}
public function check(): HealthCheckResult
{
try {
$commands = $this->commandRegistry->getAllCommands();
$commandCount = count($commands);
if ($commandCount === 0) {
return HealthCheckResult::warning(
'No commands registered',
['command_count' => 0],
'Ensure command classes are properly discovered and have #[ConsoleCommand] attributes'
);
}
// Check for duplicate command names
$commandNames = array_map(fn ($cmd) => $cmd->getName(), $commands);
$uniqueNames = array_unique($commandNames);
if (count($commandNames) !== count($uniqueNames)) {
$duplicates = array_diff_assoc($commandNames, $uniqueNames);
return HealthCheckResult::unhealthy(
'Duplicate command names detected',
[
'command_count' => $commandCount,
'duplicate_names' => array_values($duplicates),
],
'Rename commands to have unique names'
);
}
// Check for commands without descriptions
$commandsWithoutDescriptions = array_filter(
$commands,
fn ($cmd) => empty($cmd->getDescription())
);
if (! empty($commandsWithoutDescriptions)) {
$names = array_map(fn ($cmd) => $cmd->getName(), $commandsWithoutDescriptions);
return HealthCheckResult::warning(
'Some commands lack descriptions',
[
'command_count' => $commandCount,
'commands_without_descriptions' => $names,
],
'Add descriptions to improve usability'
);
}
return HealthCheckResult::healthy(
"Command registry is healthy with {$commandCount} commands",
['command_count' => $commandCount]
);
} catch (\Throwable $e) {
return HealthCheckResult::unhealthy(
'Command registry check failed: ' . $e->getMessage(),
['error' => $e->getMessage()],
'Check command discovery configuration and attribute scanning'
);
}
}
public function isRequired(): bool
{
return true;
}
}

View File

@@ -0,0 +1,129 @@
<?php
declare(strict_types=1);
namespace App\Framework\Console\Health\Checks;
use App\Framework\Console\Health\HealthCheck;
use App\Framework\Console\Health\HealthCheckResult;
use App\Framework\Core\ValueObjects\Bytes;
final readonly class PerformanceHealthCheck implements HealthCheck
{
public function getName(): string
{
return 'System Performance';
}
public function getDescription(): string
{
return 'Checks system performance metrics and resource availability';
}
public function check(): HealthCheckResult
{
$details = [];
$warnings = [];
// Memory usage check
$memoryUsage = Bytes::fromBytes(memory_get_usage(true));
$memoryLimit = $this->parseMemoryLimit(ini_get('memory_limit'));
$memoryPercentage = $memoryLimit->getBytes() > 0 ?
($memoryUsage->getBytes() / $memoryLimit->getBytes()) * 100 : 0;
$details['memory_usage'] = $memoryUsage->toHuman();
$details['memory_limit'] = $memoryLimit->toHuman();
$details['memory_percentage'] = round($memoryPercentage, 2);
if ($memoryPercentage > 80) {
$warnings[] = "High memory usage: {$details['memory_percentage']}%";
}
// Execution time limit check
$timeLimit = ini_get('max_execution_time');
$details['max_execution_time'] = $timeLimit === '0' ? 'unlimited' : $timeLimit . 's';
if ($timeLimit !== '0' && (int) $timeLimit < 300) {
$warnings[] = "Low execution time limit: {$timeLimit}s";
}
// Disk space check (if possible)
$diskSpace = disk_free_space('.');
if ($diskSpace !== false) {
$totalSpace = disk_total_space('.');
$diskPercentage = $totalSpace > 0 ? (($totalSpace - $diskSpace) / $totalSpace) * 100 : 0;
$details['disk_free'] = Bytes::fromBytes($diskSpace)->toHuman();
$details['disk_usage_percentage'] = round($diskPercentage, 2);
if ($diskPercentage > 90) {
$warnings[] = "Low disk space: {$details['disk_usage_percentage']}% used";
}
}
// PHP version check
$phpVersion = PHP_VERSION;
$details['php_version'] = $phpVersion;
if (version_compare($phpVersion, '8.1', '<')) {
$warnings[] = "Outdated PHP version: {$phpVersion}";
}
// Extension checks
$requiredExtensions = ['pdo', 'json', 'mbstring'];
$missingExtensions = [];
foreach ($requiredExtensions as $extension) {
if (! extension_loaded($extension)) {
$missingExtensions[] = $extension;
}
}
if (! empty($missingExtensions)) {
$warnings[] = "Missing PHP extensions: " . implode(', ', $missingExtensions);
}
$details['loaded_extensions'] = array_intersect($requiredExtensions, get_loaded_extensions());
if (! empty($warnings)) {
return HealthCheckResult::warning(
'Performance issues detected: ' . implode('; ', $warnings),
$details,
'Consider increasing resource limits or updating system configuration'
);
}
return HealthCheckResult::healthy(
'System performance is good',
$details
);
}
public function isRequired(): bool
{
return true;
}
private function parseMemoryLimit(string $limit): Bytes
{
if ($limit === '-1') {
return Bytes::fromBytes(0); // Unlimited
}
$limit = strtolower(trim($limit));
$multiplier = 1;
if (str_ends_with($limit, 'g')) {
$multiplier = 1024 * 1024 * 1024;
$limit = substr($limit, 0, -1);
} elseif (str_ends_with($limit, 'm')) {
$multiplier = 1024 * 1024;
$limit = substr($limit, 0, -1);
} elseif (str_ends_with($limit, 'k')) {
$multiplier = 1024;
$limit = substr($limit, 0, -1);
}
return Bytes::fromBytes((int) $limit * $multiplier);
}
}

View File

@@ -0,0 +1,201 @@
<?php
declare(strict_types=1);
namespace App\Framework\Console\Health\Commands;
use App\Framework\Console\Attributes\ConsoleCommand;
use App\Framework\Console\ExitCode;
use App\Framework\Console\Health\HealthService;
use App\Framework\Console\Health\HealthStatus;
use App\Framework\Console\Input\ConsoleInput;
use App\Framework\Console\Layout\ResponsiveOutput;
use App\Framework\Console\Output\ConsoleOutput;
final readonly class HealthCheckCommand
{
public function __construct(
private HealthService $healthService
) {
}
#[ConsoleCommand(
name: 'health:check',
description: 'Run console system health checks'
)]
public function checkHealth(ConsoleInput $input, ConsoleOutput $output): ExitCode
{
$checkName = $input->getArgument('check');
$requiredOnly = $input->hasOption('required');
$verbose = $input->hasOption('verbose');
$json = $input->hasOption('json');
if ($checkName) {
return $this->runSingleCheck($checkName, $output, $verbose, $json);
}
if ($requiredOnly) {
return $this->runRequiredChecks($output, $verbose, $json);
}
return $this->runAllChecks($output, $verbose, $json);
}
#[ConsoleCommand(
name: 'health:list',
description: 'List available health checks'
)]
public function listChecks(ConsoleInput $input, ConsoleOutput $output): ExitCode
{
$checks = $this->healthService->getAvailableChecks();
$responsiveOutput = ResponsiveOutput::create($output);
$output->writeLine("<yellow>Available Health Checks:</yellow>\n");
// Prepare table data
$headers = ['Name', 'Required', 'Description'];
$rows = [];
foreach ($checks as $check) {
$rows[] = [
$check['name'],
$check['required'] ? 'Yes' : 'No',
$check['description'],
];
}
$responsiveOutput->writeTable($headers, $rows);
$output->writeLine('');
$responsiveOutput->writeList([
'health:check' => 'Run all checks',
'health:check --required' => 'Run only required checks',
'health:check "Check Name"' => 'Run specific check',
'health:check --json' => 'Output as JSON',
], 'Usage:');
return ExitCode::SUCCESS;
}
private function runSingleCheck(string $checkName, ConsoleOutput $output, bool $verbose, bool $json): ExitCode
{
$result = $this->healthService->runCheck($checkName);
if ($result === null) {
$output->writeLine("<red>Health check '{$checkName}' not found.</red>");
return ExitCode::NOT_FOUND;
}
if ($json) {
$output->writeLine(json_encode($result->toArray(), JSON_PRETTY_PRINT));
return ExitCode::SUCCESS;
}
$this->displaySingleResult($checkName, $result, $output, $verbose);
return $result->isUnhealthy() ? ExitCode::GENERAL_ERROR : ExitCode::SUCCESS;
}
private function runRequiredChecks(ConsoleOutput $output, bool $verbose, bool $json): ExitCode
{
$report = $this->healthService->runRequiredChecks();
if ($json) {
$output->writeLine(json_encode($report->toArray(), JSON_PRETTY_PRINT));
return ExitCode::SUCCESS;
}
$this->displayReport($report, $output, $verbose, 'Required Health Checks');
return $report->isUnhealthy() ? ExitCode::GENERAL_ERROR : ExitCode::SUCCESS;
}
private function runAllChecks(ConsoleOutput $output, bool $verbose, bool $json): ExitCode
{
$report = $this->healthService->runAllChecks();
if ($json) {
$output->writeLine(json_encode($report->toArray(), JSON_PRETTY_PRINT));
return ExitCode::SUCCESS;
}
$this->displayReport($report, $output, $verbose, 'Console System Health Check');
return $report->isUnhealthy() ? ExitCode::GENERAL_ERROR : ExitCode::SUCCESS;
}
private function displayReport($report, ConsoleOutput $output, bool $verbose, string $title): void
{
$statusColor = $report->overallStatus->getColor();
$statusSymbol = $report->overallStatus->getSymbol();
$output->writeLine("<{$statusColor}>{$title}</>");
$output->writeLine("<{$statusColor}>{$statusSymbol} Overall Status: {$report->overallStatus->value}</>");
$output->writeLine('');
$output->writeLine("Summary:");
$output->writeLine(" Total Checks: {$report->getCheckCount()}");
$output->writeLine(" <green>Healthy: {$report->getHealthyCount()}</green>");
$output->writeLine(" <yellow>Warnings: {$report->getWarningCount()}</yellow>");
$output->writeLine(" <red>Unhealthy: {$report->getUnhealthyCount()}</red>");
$output->writeLine('');
// Show unhealthy checks first
$unhealthyChecks = $report->getUnhealthyChecks();
if (! empty($unhealthyChecks)) {
$output->writeLine('<red>Failed Checks:</red>');
foreach ($unhealthyChecks as $name => $result) {
$this->displaySingleResult($name, $result, $output, $verbose);
}
$output->writeLine('');
}
// Show warning checks
$warningChecks = $report->getWarningChecks();
if (! empty($warningChecks)) {
$output->writeLine('<yellow>Warning Checks:</yellow>');
foreach ($warningChecks as $name => $result) {
$this->displaySingleResult($name, $result, $output, $verbose);
}
$output->writeLine('');
}
// Show healthy checks only in verbose mode
if ($verbose) {
$healthyChecks = $report->getHealthyChecks();
if (! empty($healthyChecks)) {
$output->writeLine('<green>Healthy Checks:</green>');
foreach ($healthyChecks as $name => $result) {
$this->displaySingleResult($name, $result, $output, $verbose);
}
}
}
}
private function displaySingleResult(string $name, $result, ConsoleOutput $output, bool $verbose): void
{
$color = $result->status->getColor();
$symbol = $result->status->getSymbol();
$output->writeLine("<{$color}>{$symbol} {$name}: {$result->message}</>");
if ($verbose && ! empty($result->details)) {
foreach ($result->details as $key => $value) {
$displayValue = is_array($value) ? implode(', ', $value) : $value;
$output->writeLine(" {$key}: {$displayValue}");
}
}
if ($result->recommendation) {
$output->writeLine(" 💡 Recommendation: {$result->recommendation}");
}
if ($verbose || $result->status !== HealthStatus::HEALTHY) {
$output->writeLine('');
}
}
}

View File

@@ -0,0 +1,16 @@
<?php
declare(strict_types=1);
namespace App\Framework\Console\Health;
interface HealthCheck
{
public function getName(): string;
public function getDescription(): string;
public function check(): HealthCheckResult;
public function isRequired(): bool;
}

View File

@@ -0,0 +1,56 @@
<?php
declare(strict_types=1);
namespace App\Framework\Console\Health;
final readonly class HealthCheckResult
{
public function __construct(
public HealthStatus $status,
public string $message,
public array $details = [],
public ?string $recommendation = null
) {
}
public static function healthy(string $message = 'OK', array $details = []): self
{
return new self(HealthStatus::HEALTHY, $message, $details);
}
public static function warning(string $message, array $details = [], ?string $recommendation = null): self
{
return new self(HealthStatus::WARNING, $message, $details, $recommendation);
}
public static function unhealthy(string $message, array $details = [], ?string $recommendation = null): self
{
return new self(HealthStatus::UNHEALTHY, $message, $details, $recommendation);
}
public function isHealthy(): bool
{
return $this->status === HealthStatus::HEALTHY;
}
public function isWarning(): bool
{
return $this->status === HealthStatus::WARNING;
}
public function isUnhealthy(): bool
{
return $this->status === HealthStatus::UNHEALTHY;
}
public function toArray(): array
{
return [
'status' => $this->status->value,
'message' => $this->message,
'details' => $this->details,
'recommendation' => $this->recommendation,
];
}
}

View File

@@ -0,0 +1,32 @@
<?php
declare(strict_types=1);
namespace App\Framework\Console\Health;
use App\Framework\Console\Health\Checks\AnalyticsHealthCheck;
use App\Framework\Console\Health\Checks\CommandRegistryHealthCheck;
use App\Framework\Console\Health\Checks\PerformanceHealthCheck;
use App\Framework\DI\Attributes\Initializer;
use App\Framework\DI\Container;
final readonly class HealthInitializer
{
#[Initializer]
public function initialize(Container $container): void
{
// Register individual health checks
$container->singleton(CommandRegistryHealthCheck::class);
$container->singleton(AnalyticsHealthCheck::class);
$container->singleton(PerformanceHealthCheck::class);
// Register health service with all checks
$container->singleton(HealthService::class, function (Container $container) {
return new HealthService([
$container->get(CommandRegistryHealthCheck::class),
$container->get(PerformanceHealthCheck::class),
$container->get(AnalyticsHealthCheck::class),
]);
});
}
}

View File

@@ -0,0 +1,80 @@
<?php
declare(strict_types=1);
namespace App\Framework\Console\Health;
final readonly class HealthReport
{
/**
* @param HealthStatus $overallStatus
* @param array<string, HealthCheckResult> $results
*/
public function __construct(
public HealthStatus $overallStatus,
public array $results
) {
}
public function isHealthy(): bool
{
return $this->overallStatus === HealthStatus::HEALTHY;
}
public function hasWarnings(): bool
{
return $this->overallStatus === HealthStatus::WARNING;
}
public function isUnhealthy(): bool
{
return $this->overallStatus === HealthStatus::UNHEALTHY;
}
public function getHealthyChecks(): array
{
return array_filter($this->results, fn ($result) => $result->isHealthy());
}
public function getWarningChecks(): array
{
return array_filter($this->results, fn ($result) => $result->isWarning());
}
public function getUnhealthyChecks(): array
{
return array_filter($this->results, fn ($result) => $result->isUnhealthy());
}
public function getCheckCount(): int
{
return count($this->results);
}
public function getHealthyCount(): int
{
return count($this->getHealthyChecks());
}
public function getWarningCount(): int
{
return count($this->getWarningChecks());
}
public function getUnhealthyCount(): int
{
return count($this->getUnhealthyChecks());
}
public function toArray(): array
{
return [
'overall_status' => $this->overallStatus->value,
'total_checks' => $this->getCheckCount(),
'healthy_checks' => $this->getHealthyCount(),
'warning_checks' => $this->getWarningCount(),
'unhealthy_checks' => $this->getUnhealthyCount(),
'results' => array_map(fn ($result) => $result->toArray(), $this->results),
];
}
}

View File

@@ -0,0 +1,81 @@
<?php
declare(strict_types=1);
namespace App\Framework\Console\Health;
final readonly class HealthService
{
/**
* @param HealthCheck[] $healthChecks
*/
public function __construct(
private array $healthChecks
) {
}
public function runAllChecks(): HealthReport
{
$results = [];
$overallStatus = HealthStatus::HEALTHY;
foreach ($this->healthChecks as $check) {
$result = $check->check();
$results[$check->getName()] = $result;
// Determine overall status
if ($result->isUnhealthy() && $check->isRequired()) {
$overallStatus = HealthStatus::UNHEALTHY;
} elseif ($result->isWarning() && $overallStatus === HealthStatus::HEALTHY) {
$overallStatus = HealthStatus::WARNING;
}
}
return new HealthReport($overallStatus, $results);
}
public function runCheck(string $checkName): ?HealthCheckResult
{
foreach ($this->healthChecks as $check) {
if ($check->getName() === $checkName) {
return $check->check();
}
}
return null;
}
public function getAvailableChecks(): array
{
return array_map(function (HealthCheck $check) {
return [
'name' => $check->getName(),
'description' => $check->getDescription(),
'required' => $check->isRequired(),
];
}, $this->healthChecks);
}
public function runRequiredChecks(): HealthReport
{
$results = [];
$overallStatus = HealthStatus::HEALTHY;
foreach ($this->healthChecks as $check) {
if (! $check->isRequired()) {
continue;
}
$result = $check->check();
$results[$check->getName()] = $result;
if ($result->isUnhealthy()) {
$overallStatus = HealthStatus::UNHEALTHY;
} elseif ($result->isWarning() && $overallStatus === HealthStatus::HEALTHY) {
$overallStatus = HealthStatus::WARNING;
}
}
return new HealthReport($overallStatus, $results);
}
}

View File

@@ -0,0 +1,30 @@
<?php
declare(strict_types=1);
namespace App\Framework\Console\Health;
enum HealthStatus: string
{
case HEALTHY = 'healthy';
case WARNING = 'warning';
case UNHEALTHY = 'unhealthy';
public function getColor(): string
{
return match ($this) {
self::HEALTHY => 'green',
self::WARNING => 'yellow',
self::UNHEALTHY => 'red',
};
}
public function getSymbol(): string
{
return match ($this) {
self::HEALTHY => '✓',
self::WARNING => '⚠',
self::UNHEALTHY => '✗',
};
}
}

View File

@@ -0,0 +1,305 @@
<?php
declare(strict_types=1);
namespace App\Framework\Console;
/**
* Generates help text for console commands based on their argument definitions
*/
final readonly class HelpGenerator
{
public function __construct(
private ConsoleOutputInterface $output
) {
}
/**
* Generate help text for a command
*
* @param ArgumentDefinition[] $definitions
*/
public function generate(string $commandName, array $definitions, string $description = ''): string
{
$help = [];
// Command description
if (! empty($description)) {
$help[] = $description;
$help[] = '';
}
// Usage line
$help[] = $this->generateUsageLine($commandName, $definitions);
$help[] = '';
// Arguments section
$positionalArgs = $this->getPositionalArguments($definitions);
if (! empty($positionalArgs)) {
$help[] = ConsoleColor::BRIGHT_YELLOW->toAnsi() . 'Arguments:' . ConsoleColor::RESET->toAnsi();
foreach ($positionalArgs as $definition) {
$help[] = $this->formatArgumentHelp($definition);
}
$help[] = '';
}
// Options section
$options = $this->getOptions($definitions);
if (! empty($options)) {
$help[] = ConsoleColor::BRIGHT_YELLOW->toAnsi() . 'Options:' . ConsoleColor::RESET->toAnsi();
foreach ($options as $definition) {
$help[] = $this->formatOptionHelp($definition);
}
$help[] = '';
}
// Examples
$examples = $this->generateExamples($commandName, $definitions);
if (! empty($examples)) {
$help[] = ConsoleColor::BRIGHT_YELLOW->toAnsi() . 'Examples:' . ConsoleColor::RESET->toAnsi();
foreach ($examples as $example) {
$help[] = ' ' . ConsoleColor::CYAN->toAnsi() . $example . ConsoleColor::RESET->toAnsi();
}
}
return implode(PHP_EOL, $help);
}
/**
* Display help text directly to console output
*
* @param ArgumentDefinition[] $definitions
*/
public function display(string $commandName, array $definitions, string $description = ''): void
{
$help = $this->generate($commandName, $definitions, $description);
$this->output->writeLine($help);
}
/**
* Generate usage line
*
* @param ArgumentDefinition[] $definitions
*/
private function generateUsageLine(string $commandName, array $definitions): string
{
$usage = "Usage: php console {$commandName}";
// Add positional arguments
$positionalArgs = $this->getPositionalArguments($definitions);
foreach ($positionalArgs as $definition) {
if ($definition->required) {
$usage .= " <{$definition->name}>";
} else {
$usage .= " [{$definition->name}]";
}
}
// Add options indicator
$options = $this->getOptions($definitions);
if (! empty($options)) {
$usage .= ' [options]';
}
return ConsoleColor::BRIGHT_CYAN->toAnsi() . $usage . ConsoleColor::RESET->toAnsi();
}
/**
* Format help for positional argument
*/
private function formatArgumentHelp(ArgumentDefinition $definition): string
{
$name = str_pad($definition->name, 20);
$description = $definition->description;
if ($definition->default !== null) {
$defaultString = $this->convertValueToString($definition->default);
$description .= " [default: {$defaultString}]";
}
if (! empty($definition->allowedValues)) {
$description .= " [choices: " . implode(', ', $definition->allowedValues) . "]";
}
return " {$name} {$description}";
}
/**
* Format help for option
*/
private function formatOptionHelp(ArgumentDefinition $definition): string
{
$names = $this->formatOptionNames($definition);
$paddedNames = str_pad($names, 25);
$description = $definition->description;
if ($definition->type !== ArgumentType::BOOLEAN) {
$description .= " ({$definition->type->getDescription()})";
}
if ($definition->default !== null && $definition->default !== false) {
$defaultStr = $this->convertValueToString($definition->default);
$description .= " [default: {$defaultStr}]";
}
if (! empty($definition->allowedValues)) {
$description .= " [choices: " . implode(', ', $definition->allowedValues) . "]";
}
if ($definition->required) {
$description .= ConsoleColor::RED->toAnsi() . ' (required)' . ConsoleColor::RESET->toAnsi();
}
return " {$paddedNames} {$description}";
}
/**
* Format option names (both long and short)
*/
private function formatOptionNames(ArgumentDefinition $definition): string
{
$names = "--{$definition->name}";
if ($definition->shortName) {
$names = "-{$definition->shortName}, {$names}";
}
if ($definition->type !== ArgumentType::BOOLEAN) {
if (! empty($definition->allowedValues)) {
$names .= "=" . implode('|', $definition->allowedValues);
} else {
$names .= "={$definition->type->getExample()}";
}
}
return $names;
}
/**
* Get positional arguments from definitions
*
* @param ArgumentDefinition[] $definitions
* @return ArgumentDefinition[]
*/
private function getPositionalArguments(array $definitions): array
{
return array_filter($definitions, function ($definition) {
return $definition->type !== ArgumentType::BOOLEAN && $definition->shortName === null;
});
}
/**
* Get options (flags and named options) from definitions
*
* @param ArgumentDefinition[] $definitions
* @return ArgumentDefinition[]
*/
private function getOptions(array $definitions): array
{
return array_filter($definitions, function ($definition) {
return $definition->type === ArgumentType::BOOLEAN || $definition->shortName !== null;
});
}
/**
* Generate example usage
*
* @param ArgumentDefinition[] $definitions
* @return string[]
*/
private function generateExamples(string $commandName, array $definitions): array
{
$examples = [];
// Basic example with required arguments
$requiredArgs = array_filter($definitions, fn ($def) => $def->required);
if (! empty($requiredArgs)) {
$example = "php console {$commandName}";
foreach ($requiredArgs as $definition) {
if ($definition->type === ArgumentType::BOOLEAN) {
$example .= " --{$definition->name}";
} else {
$example .= " {$definition->type->getExample()}";
}
}
$examples[] = $example;
}
// Example with options
$optionalOptions = array_filter($definitions, fn ($def) => ! $def->required && $def->type !== ArgumentType::BOOLEAN);
if (! empty($optionalOptions)) {
$example = "php console {$commandName}";
// Add required args first
foreach ($requiredArgs as $definition) {
if ($definition->type !== ArgumentType::BOOLEAN) {
$example .= " {$definition->type->getExample()}";
}
}
// Add some optional options
$counter = 0;
foreach ($optionalOptions as $definition) {
if ($counter >= 2) {
break;
} // Limit examples
if ($definition->shortName) {
$example .= " -{$definition->shortName} {$definition->type->getExample()}";
} else {
$example .= " --{$definition->name}={$definition->type->getExample()}";
}
$counter++;
}
if ($example !== "php console {$commandName}") {
$examples[] = $example;
}
}
return $examples;
}
/**
* Generate quick help summary for command list
*/
public function generateQuickHelp(string $commandName, string $description): string
{
$name = str_pad($commandName, 20);
return " {$name} {$description}";
}
/**
* Convert value to string for display
*/
private function convertValueToString(mixed $value): string
{
if (is_string($value)) {
return $value;
}
if ($value instanceof \BackedEnum) {
return (string) $value->value;
}
if ($value instanceof \UnitEnum) {
return $value->name;
}
if (is_bool($value)) {
return $value ? 'true' : 'false';
}
if (is_scalar($value)) {
return (string) $value;
}
if (method_exists($value, '__toString')) {
return (string) $value;
}
return get_class($value);
}
}

View File

@@ -0,0 +1,51 @@
<?php
declare(strict_types=1);
namespace App\Framework\Console;
/**
* History tab types for console TUI
*/
enum HistoryTab: string
{
case RECENT = 'recent';
case FREQUENT = 'frequent';
case FAVORITES = 'favorites';
/**
* Get display title for the tab
*/
public function getTitle(): string
{
return match($this) {
self::RECENT => 'Recent Commands',
self::FREQUENT => 'Frequently Used',
self::FAVORITES => 'Favorite Commands',
};
}
/**
* Get the icon for the tab
*/
public function getIcon(): string
{
return match($this) {
self::RECENT => '🕐',
self::FREQUENT => '📊',
self::FAVORITES => '⭐',
};
}
/**
* Get short label for tab navigation
*/
public function getShortLabel(): string
{
return match($this) {
self::RECENT => 'Recent',
self::FREQUENT => 'Frequent',
self::FAVORITES => 'Favorites',
};
}
}

View File

@@ -0,0 +1,432 @@
<?php
declare(strict_types=1);
namespace App\Framework\Console;
/**
* Interactive form builder for command parameters
*/
final class InteractiveForm
{
private array $fields = [];
private array $values = [];
private array $errors = [];
private int $currentField = 0;
private bool $completed = false;
public function __construct(
private readonly ConsoleOutputInterface $output,
private readonly ParameterInspector $inspector
) {
}
/**
* Create form from command parameters
*/
public static function forCommand(
object $command,
ConsoleOutputInterface $output,
string $methodName = '__invoke'
): self {
$inspector = new ParameterInspector();
$form = new self($output, $inspector);
$parameters = $inspector->inspectCommand($command, $methodName);
foreach ($parameters as $parameter) {
$form->addField($parameter);
}
return $form;
}
/**
* Add a field to the form
*/
public function addField(array $fieldConfig): self
{
$this->fields[] = array_merge([
'name' => '',
'description' => '',
'type' => ['name' => 'string'],
'required' => true,
'default' => null,
'validation_rules' => [],
'input_type' => 'text',
], $fieldConfig);
return $this;
}
/**
* Run the interactive form
*/
public function run(): array
{
if (empty($this->fields)) {
return [];
}
$this->output->writeLine('');
$this->output->writeLine('📝 Command Parameters', ConsoleColor::BRIGHT_CYAN);
$this->output->writeLine('Fill in the required parameters for this command:', ConsoleColor::GRAY);
$this->output->writeLine('');
while (! $this->completed && $this->currentField < count($this->fields)) {
$field = $this->fields[$this->currentField];
$this->processField($field);
}
if ($this->completed) {
$this->showSummary();
}
return $this->values;
}
/**
* Process a single form field
*/
private function processField(array $field): void
{
$fieldName = $field['name'];
// Show field header
$this->renderFieldHeader($field);
// Show current value if exists
if (isset($this->values[$fieldName])) {
$this->output->writeLine("Current value: {$this->values[$fieldName]}", ConsoleColor::GRAY);
}
// Show error if exists
if (isset($this->errors[$fieldName])) {
$this->output->writeLine("{$this->errors[$fieldName]}", ConsoleColor::RED);
}
// Get input based on field type
$value = $this->getFieldInput($field);
// Handle special commands
if ($value === ':back' && $this->currentField > 0) {
$this->currentField--;
return;
}
if ($value === ':skip' && ! $field['required']) {
$this->values[$fieldName] = $field['default'];
$this->currentField++;
return;
}
if ($value === ':exit') {
$this->completed = true;
return;
}
// Validate input
$validation = $this->validateField($field, $value);
if ($validation['valid']) {
$this->values[$fieldName] = $validation['value'];
unset($this->errors[$fieldName]);
$this->currentField++;
if ($this->currentField >= count($this->fields)) {
$this->completed = true;
}
} else {
$this->errors[$fieldName] = $validation['error'];
}
$this->output->writeLine('');
}
/**
* Render field header with information
*/
private function renderFieldHeader(array $field): void
{
$fieldNum = $this->currentField + 1;
$totalFields = count($this->fields);
$required = $field['required'] ? '*' : '';
$this->output->writeLine("Field {$fieldNum}/{$totalFields}: {$field['description']}{$required}", ConsoleColor::BRIGHT_YELLOW);
// Show type information
$type = $this->getDisplayType($field);
$this->output->writeLine("Type: {$type}", ConsoleColor::GRAY);
// Show default value
if ($field['default'] !== null) {
$default = is_bool($field['default']) ? ($field['default'] ? 'true' : 'false') : $field['default'];
$this->output->writeLine("Default: {$default}", ConsoleColor::GRAY);
}
// Show navigation help
$help = [];
if ($this->currentField > 0) {
$help[] = ':back - Previous field';
}
if (! $field['required']) {
$help[] = ':skip - Use default';
}
$help[] = ':exit - Cancel form';
if (! empty($help)) {
$this->output->writeLine('Commands: ' . implode(', ', $help), ConsoleColor::DARK_GRAY);
}
$this->output->write('> ');
}
/**
* Get input for a field based on its type
*/
private function getFieldInput(array $field): string
{
switch ($field['input_type']) {
case 'boolean':
return $this->getBooleanInput($field);
case 'number':
case 'decimal':
return $this->getNumericInput($field);
case 'password':
return $this->getPasswordInput($field);
case 'file':
return $this->getFileInput($field);
case 'list':
return $this->getListInput($field);
default:
return $this->getTextInput($field);
}
}
/**
* Get boolean input (y/n)
*/
private function getBooleanInput(array $field): string
{
$this->output->write('(y/n): ');
$input = trim(fgets(STDIN) ?: '');
if (in_array(strtolower($input), ['y', 'yes', 'true', '1'])) {
return 'true';
} elseif (in_array(strtolower($input), ['n', 'no', 'false', '0'])) {
return 'false';
}
return $input; // Will be validated later
}
/**
* Get numeric input
*/
private function getNumericInput(array $field): string
{
if (isset($field['validation_rules']['min']) || isset($field['validation_rules']['max'])) {
$min = $field['validation_rules']['min'] ?? 'no limit';
$max = $field['validation_rules']['max'] ?? 'no limit';
$this->output->write("(range: {$min} - {$max}): ");
}
return trim(fgets(STDIN) ?: '');
}
/**
* Get password input (hidden)
*/
private function getPasswordInput(array $field): string
{
$this->output->write('(hidden): ');
// Disable echo for password input
if (function_exists('system')) {
system('stty -echo');
$input = trim(fgets(STDIN) ?: '');
system('stty echo');
$this->output->writeLine('');
return $input;
}
// Fallback for environments where stty is not available
return trim(fgets(STDIN) ?: '');
}
/**
* Get file path input
*/
private function getFileInput(array $field): string
{
$this->output->write('(file path): ');
return trim(fgets(STDIN) ?: '');
}
/**
* Get list input (comma-separated)
*/
private function getListInput(array $field): string
{
$this->output->write('(comma-separated): ');
return trim(fgets(STDIN) ?: '');
}
/**
* Get text input
*/
private function getTextInput(array $field): string
{
return trim(fgets(STDIN) ?: '');
}
/**
* Validate field input
*/
private function validateField(array $field, string $value): array
{
$rules = $field['validation_rules'];
// Check required
if ($rules['required'] ?? false && empty($value)) {
return ['valid' => false, 'error' => 'This field is required'];
}
// Allow empty values for optional fields
if (empty($value) && ! ($rules['required'] ?? false)) {
return ['valid' => true, 'value' => $field['default']];
}
// Type validations
switch ($rules['type'] ?? 'string') {
case 'integer':
if (! ctype_digit($value) && ! is_numeric($value)) {
return ['valid' => false, 'error' => 'Must be a valid number'];
}
$intValue = (int) $value;
if (isset($rules['min']) && $intValue < $rules['min']) {
return ['valid' => false, 'error' => "Must be at least {$rules['min']}"];
}
if (isset($rules['max']) && $intValue > $rules['max']) {
return ['valid' => false, 'error' => "Must be at most {$rules['max']}"];
}
return ['valid' => true, 'value' => $intValue];
case 'number':
if (! is_numeric($value)) {
return ['valid' => false, 'error' => 'Must be a valid number'];
}
return ['valid' => true, 'value' => (float) $value];
case 'boolean':
if (! in_array(strtolower($value), ['true', 'false', 'y', 'n', 'yes', 'no', '1', '0'])) {
return ['valid' => false, 'error' => 'Must be true/false or y/n'];
}
$boolValue = in_array(strtolower($value), ['true', 'y', 'yes', '1']);
return ['valid' => true, 'value' => $boolValue];
case 'string':
// Format validations
if (isset($rules['format'])) {
switch ($rules['format']) {
case 'email':
if (! filter_var($value, FILTER_VALIDATE_EMAIL)) {
return ['valid' => false, 'error' => 'Must be a valid email address'];
}
break;
case 'url':
if (! filter_var($value, FILTER_VALIDATE_URL)) {
return ['valid' => false, 'error' => 'Must be a valid URL'];
}
break;
case 'path':
if (! is_string($value) || empty($value)) {
return ['valid' => false, 'error' => 'Must be a valid file path'];
}
break;
}
}
return ['valid' => true, 'value' => $value];
}
return ['valid' => true, 'value' => $value];
}
/**
* Get display type for field
*/
private function getDisplayType(array $field): string
{
$type = $field['type']['name'] ?? 'string';
$required = $field['required'] ? 'required' : 'optional';
return "{$type} ({$required})";
}
/**
* Show summary of entered values
*/
private function showSummary(): void
{
$this->output->writeLine('');
$this->output->writeLine('📋 Parameter Summary', ConsoleColor::BRIGHT_GREEN);
$this->output->writeLine(str_repeat('─', 40), ConsoleColor::GRAY);
foreach ($this->fields as $field) {
$name = $field['name'];
$value = $this->values[$name] ?? $field['default'] ?? 'not set';
if (is_bool($value)) {
$value = $value ? 'true' : 'false';
}
$this->output->writeLine("{$name}: {$value}", ConsoleColor::WHITE);
}
$this->output->writeLine('');
}
/**
* Get the collected values
*/
public function getValues(): array
{
return $this->values;
}
/**
* Check if form was completed successfully
*/
public function isCompleted(): bool
{
return $this->completed;
}
}

View File

@@ -0,0 +1,141 @@
<?php
declare(strict_types=1);
namespace App\Framework\Console\Layout\Commands;
use App\Framework\Console\Attributes\ConsoleCommand;
use App\Framework\Console\ExitCode;
use App\Framework\Console\Input\ConsoleInput;
use App\Framework\Console\Layout\ResponsiveOutput;
use App\Framework\Console\Layout\TerminalSize;
use App\Framework\Console\Output\ConsoleOutput;
final readonly class LayoutTestCommand
{
#[ConsoleCommand(
name: 'layout:test',
description: 'Test responsive terminal layout features'
)]
public function testLayout(ConsoleInput $input, ConsoleOutput $output): ExitCode
{
$responsiveOutput = ResponsiveOutput::create($output);
$terminalSize = $responsiveOutput->getTerminalSize();
$output->writeLine("<yellow>Responsive Terminal Layout Test</yellow>\n");
// Show terminal info
$responsiveOutput->writeKeyValue($terminalSize->toArray(), 'Terminal Information:');
$output->writeLine('');
// Test responsive table
$output->writeLine('<yellow>Responsive Table Test:</yellow>');
$headers = ['Command', 'Executions', 'Success Rate', 'Avg Time', 'Category'];
$rows = [
['analytics:usage', '156', '98.7%', '1.2s', 'Analytics'],
['health:check', '89', '100%', '0.8s', 'Monitoring'],
['security:whoami', '45', '100%', '0.3s', 'Security'],
['make:migration', '23', '95.7%', '2.1s', 'Database'],
['cache:clear', '78', '100%', '0.5s', 'System'],
];
$responsiveOutput->writeTable($headers, $rows);
$output->writeLine('');
// Test key-value display
$output->writeLine('<yellow>Key-Value Display Test:</yellow>');
$data = [
'Framework Version' => '1.0.0',
'PHP Version' => PHP_VERSION,
'Memory Usage' => memory_get_usage(true) . ' bytes',
'Environment' => $_ENV['APP_ENV'] ?? 'development',
'Debug Mode' => 'Enabled',
];
$responsiveOutput->writeKeyValue($data);
$output->writeLine('');
// Test list display
$features = [
'Terminal size detection',
'Responsive breakpoints',
'Adaptive table layouts',
'Text truncation for small screens',
'Multi-column support for wide terminals',
'Progress bars with terminal-aware sizing',
];
$responsiveOutput->writeList($features, 'Layout Features:');
$output->writeLine('');
// Test progress bar
$output->writeLine('<yellow>Progress Bar Test:</yellow>');
for ($i = 0; $i <= 100; $i += 20) {
$responsiveOutput->writeProgressBar($i, 100, 'Processing');
if ($i < 100) {
usleep(200000); // 0.2 second delay
}
}
$output->writeLine('');
// Test columns (only on wider terminals)
if ($terminalSize->getBreakpoint()->getColumnCount() > 1) {
$output->writeLine('<yellow>Multi-Column Test:</yellow>');
$columns = [
[
'title' => 'Commands',
'items' => ['analytics:usage', 'health:check', 'security:whoami'],
],
[
'title' => 'Status',
'items' => ['Active', 'Active', 'Active'],
],
[
'title' => 'Category',
'items' => ['Analytics', 'Health', 'Security'],
],
];
$responsiveOutput->writeColumns($columns);
} else {
$output->writeLine('<yellow>Multi-Column Test:</yellow> <gray>Skipped (terminal too narrow)</gray>');
}
$output->writeLine('');
$output->writeLine('<green>✓ Responsive layout test completed!</green>');
return ExitCode::SUCCESS;
}
#[ConsoleCommand(
name: 'layout:info',
description: 'Show terminal size and breakpoint information'
)]
public function showInfo(ConsoleInput $input, ConsoleOutput $output): ExitCode
{
$terminalSize = TerminalSize::detect();
$json = $input->hasOption('json');
if ($json) {
$output->writeLine(json_encode($terminalSize->toArray(), JSON_PRETTY_PRINT));
return ExitCode::SUCCESS;
}
$responsiveOutput = ResponsiveOutput::create($output);
$output->writeLine("<yellow>Terminal Information</yellow>\n");
$responsiveOutput->writeKeyValue($terminalSize->toArray());
$output->writeLine('');
$breakpoint = $terminalSize->getBreakpoint();
$output->writeLine("<yellow>Breakpoint Details:</yellow>");
$output->writeLine("Current: {$breakpoint->value} - {$breakpoint->getDescription()}");
$output->writeLine("Column Count: {$breakpoint->getColumnCount()}");
$output->writeLine("Table Width: {$breakpoint->getTableWidth()}");
$output->writeLine("Max Text Length: {$breakpoint->getMaxTextLength()}");
$output->writeLine("Should Truncate: " . ($breakpoint->shouldTruncateText() ? 'Yes' : 'No'));
return ExitCode::SUCCESS;
}
}

View File

@@ -0,0 +1,30 @@
<?php
declare(strict_types=1);
namespace App\Framework\Console\Layout;
use App\Framework\DI\Attributes\Initializer;
use App\Framework\DI\Container;
final readonly class LayoutInitializer
{
#[Initializer]
public function initialize(Container $container): void
{
// Register terminal size detection
$container->singleton(TerminalSize::class, function () {
return TerminalSize::detect();
});
// Register responsive output factory
$container->bind(ResponsiveOutput::class, function (Container $container) {
return function ($output) use ($container) {
return ResponsiveOutput::create(
$output,
$container->get(TerminalSize::class)
);
};
});
}
}

View File

@@ -0,0 +1,183 @@
<?php
declare(strict_types=1);
namespace App\Framework\Console\Layout;
use App\Framework\Console\Output\ConsoleOutput;
final readonly class ResponsiveOutput
{
public function __construct(
private ConsoleOutput $output,
private TerminalSize $terminalSize
) {
}
public function writeTable(array $headers, array $rows): void
{
$table = new ResponsiveTable($headers, $rows, $this->terminalSize);
$lines = $table->render();
foreach ($lines as $line) {
$this->output->writeLine($line);
}
}
public function writeList(array $items, ?string $title = null): void
{
if ($title) {
$this->output->writeLine("<yellow>{$title}</yellow>");
$this->output->writeLine('');
}
$breakpoint = $this->terminalSize->getBreakpoint();
$maxLength = $breakpoint->getMaxTextLength();
foreach ($items as $key => $value) {
if (is_string($key)) {
// Key-value pairs
$displayValue = $this->truncateText($value, $maxLength - strlen($key) - 3);
$this->output->writeLine(" {$key}: {$displayValue}");
} else {
// Simple list
$displayValue = $this->truncateText($value, $maxLength);
$this->output->writeLine("{$displayValue}");
}
}
}
public function writeKeyValue(array $data, ?string $title = null): void
{
if ($title) {
$this->output->writeLine("<yellow>{$title}</yellow>");
$this->output->writeLine('');
}
$breakpoint = $this->terminalSize->getBreakpoint();
$maxKeyLength = min(20, max(array_map('strlen', array_keys($data))));
$maxValueLength = $breakpoint->getMaxTextLength() - $maxKeyLength - 3;
foreach ($data as $key => $value) {
$paddedKey = str_pad($key, $maxKeyLength);
$displayValue = $this->truncateText((string) $value, $maxValueLength);
$this->output->writeLine(" {$paddedKey}: {$displayValue}");
}
}
public function writeColumns(array $columns): void
{
$breakpoint = $this->terminalSize->getBreakpoint();
$columnCount = min($breakpoint->getColumnCount(), count($columns));
if ($columnCount === 1) {
// Single column layout for small terminals
foreach ($columns as $column) {
$this->writeList($column['items'], $column['title'] ?? null);
$this->output->writeLine('');
}
return;
}
$columnWidth = (int) ($this->terminalSize->getUsableWidth() / $columnCount);
$maxRows = max(array_map(fn ($col) => count($col['items']), $columns));
// Write column headers
$headerLine = '';
foreach (array_slice($columns, 0, $columnCount) as $column) {
$title = $column['title'] ?? '';
$headerLine .= str_pad($title, $columnWidth);
}
$this->output->writeLine("<yellow>{$headerLine}</yellow>");
$this->output->writeLine(str_repeat('-', strlen($headerLine)));
// Write column data
for ($row = 0; $row < $maxRows; $row++) {
$rowLine = '';
foreach (array_slice($columns, 0, $columnCount) as $column) {
$item = $column['items'][$row] ?? '';
$truncatedItem = $this->truncateText($item, $columnWidth - 2);
$rowLine .= str_pad($truncatedItem, $columnWidth);
}
$this->output->writeLine($rowLine);
}
}
public function writeProgressBar(int $current, int $total, string $label = ''): void
{
$breakpoint = $this->terminalSize->getBreakpoint();
$barWidth = match ($breakpoint) {
TerminalBreakpoint::EXTRA_SMALL => 20,
TerminalBreakpoint::SMALL => 30,
TerminalBreakpoint::MEDIUM => 40,
TerminalBreakpoint::LARGE => 50,
TerminalBreakpoint::EXTRA_LARGE => 60,
};
$percentage = $total > 0 ? ($current / $total) * 100 : 0;
$filledWidth = (int) (($current / max(1, $total)) * $barWidth);
$bar = str_repeat('█', $filledWidth) . str_repeat('░', $barWidth - $filledWidth);
$percentageText = sprintf('%3.1f%%', $percentage);
if ($label) {
$maxLabelLength = $breakpoint->getMaxTextLength() - $barWidth - 10;
$truncatedLabel = $this->truncateText($label, $maxLabelLength);
$this->output->writeLine("{$truncatedLabel}: [{$bar}] {$percentageText}");
} else {
$this->output->writeLine("[{$bar}] {$percentageText}");
}
}
public function writeStatus(string $status, string $message, array $details = []): void
{
$breakpoint = $this->terminalSize->getBreakpoint();
if ($breakpoint === TerminalBreakpoint::EXTRA_SMALL) {
// Compact status display
$this->output->writeLine("{$status}: {$message}");
if (! empty($details)) {
foreach ($details as $key => $value) {
$this->output->writeLine(" {$key}: {$value}");
}
}
} else {
// Full status display
$maxMessageLength = $breakpoint->getMaxTextLength() - strlen($status) - 3;
$truncatedMessage = $this->truncateText($message, $maxMessageLength);
$this->output->writeLine("{$status}: {$truncatedMessage}");
if (! empty($details)) {
$this->writeKeyValue($details);
}
}
}
public function getTerminalSize(): TerminalSize
{
return $this->terminalSize;
}
public function getBreakpoint(): TerminalBreakpoint
{
return $this->terminalSize->getBreakpoint();
}
private function truncateText(string $text, int $maxLength): string
{
if (strlen($text) <= $maxLength) {
return $text;
}
return substr($text, 0, $maxLength - 3) . '...';
}
public static function create(ConsoleOutput $output, ?TerminalSize $terminalSize = null): self
{
return new self(
$output,
$terminalSize ?? TerminalSize::detect()
);
}
}

View File

@@ -0,0 +1,173 @@
<?php
declare(strict_types=1);
namespace App\Framework\Console\Layout;
final readonly class ResponsiveTable
{
/**
* @param array<string> $headers
* @param array<array<string>> $rows
* @param TerminalSize $terminalSize
*/
public function __construct(
public array $headers,
public array $rows,
public TerminalSize $terminalSize
) {
}
public function render(): array
{
$breakpoint = $this->terminalSize->getBreakpoint();
$maxWidth = $this->terminalSize->getUsableWidth();
if ($breakpoint->shouldTruncateText()) {
return $this->renderCompact($breakpoint);
}
return $this->renderFull($maxWidth);
}
private function renderCompact(TerminalBreakpoint $breakpoint): array
{
$lines = [];
$maxTextLength = $breakpoint->getMaxTextLength();
// For very small terminals, show key-value pairs instead of table
if ($breakpoint === TerminalBreakpoint::EXTRA_SMALL) {
foreach ($this->rows as $index => $row) {
$lines[] = "=== Row " . ($index + 1) . " ===";
foreach ($this->headers as $i => $header) {
$value = $row[$i] ?? '';
$truncatedValue = $this->truncateText($value, $maxTextLength);
$lines[] = "{$header}: {$truncatedValue}";
}
$lines[] = '';
}
return $lines;
}
// For small terminals, show simplified table
$columnWidths = $this->calculateCompactColumnWidths($breakpoint);
// Header
$headerLine = '';
foreach ($this->headers as $i => $header) {
$width = $columnWidths[$i] ?? 15;
$headerLine .= str_pad(substr($header, 0, $width), $width) . ' ';
}
$lines[] = trim($headerLine);
$lines[] = str_repeat('-', min(strlen($headerLine), $maxTextLength));
// Rows
foreach ($this->rows as $row) {
$rowLine = '';
foreach ($row as $i => $cell) {
$width = $columnWidths[$i] ?? 15;
$truncatedCell = $this->truncateText($cell, $width);
$rowLine .= str_pad($truncatedCell, $width) . ' ';
}
$lines[] = trim($rowLine);
}
return $lines;
}
private function renderFull(int $maxWidth): array
{
$lines = [];
$columnWidths = $this->calculateColumnWidths($maxWidth);
// Header
$headerLine = '';
$separatorLine = '';
foreach ($this->headers as $i => $header) {
$width = $columnWidths[$i];
$headerLine .= str_pad($header, $width) . ' | ';
$separatorLine .= str_repeat('-', $width) . '-+-';
}
$lines[] = trim($headerLine, ' | ');
$lines[] = trim($separatorLine, '-+-');
// Rows
foreach ($this->rows as $row) {
$rowLine = '';
foreach ($row as $i => $cell) {
$width = $columnWidths[$i];
$truncatedCell = $this->truncateText($cell, $width);
$rowLine .= str_pad($truncatedCell, $width) . ' | ';
}
$lines[] = trim($rowLine, ' | ');
}
return $lines;
}
private function calculateColumnWidths(int $maxWidth): array
{
$columnCount = count($this->headers);
$separatorSpace = ($columnCount - 1) * 3; // ' | ' between columns
$availableWidth = $maxWidth - $separatorSpace;
// Calculate minimum widths based on headers
$minWidths = array_map('strlen', $this->headers);
// Calculate preferred widths based on content
$preferredWidths = $minWidths;
foreach ($this->rows as $row) {
foreach ($row as $i => $cell) {
$preferredWidths[$i] = max($preferredWidths[$i], strlen($cell));
}
}
// If preferred widths fit, use them
$totalPreferred = array_sum($preferredWidths);
if ($totalPreferred <= $availableWidth) {
return $preferredWidths;
}
// Otherwise, distribute available width proportionally
$weights = [];
foreach ($preferredWidths as $i => $width) {
$weights[$i] = $width / $totalPreferred;
}
$finalWidths = [];
foreach ($weights as $i => $weight) {
$finalWidths[$i] = max($minWidths[$i], (int) ($availableWidth * $weight));
}
return $finalWidths;
}
private function calculateCompactColumnWidths(TerminalBreakpoint $breakpoint): array
{
$maxWidth = $breakpoint->getTableWidth();
$columnCount = count($this->headers);
$baseWidth = (int) ($maxWidth / $columnCount);
return array_fill(0, $columnCount, max(10, $baseWidth));
}
private function truncateText(string $text, int $maxLength): string
{
if (strlen($text) <= $maxLength) {
return $text;
}
return substr($text, 0, $maxLength - 3) . '...';
}
public static function create(array $headers, array $rows, ?TerminalSize $terminalSize = null): self
{
return new self(
$headers,
$rows,
$terminalSize ?? TerminalSize::detect()
);
}
}

View File

@@ -0,0 +1,85 @@
<?php
declare(strict_types=1);
namespace App\Framework\Console\Layout;
enum TerminalBreakpoint: string
{
case EXTRA_SMALL = 'xs'; // < 60 chars
case SMALL = 'sm'; // 60-79 chars
case MEDIUM = 'md'; // 80-119 chars
case LARGE = 'lg'; // 120-159 chars
case EXTRA_LARGE = 'xl'; // >= 160 chars
public function getMinWidth(): int
{
return match ($this) {
self::EXTRA_SMALL => 0,
self::SMALL => 60,
self::MEDIUM => 80,
self::LARGE => 120,
self::EXTRA_LARGE => 160,
};
}
public function getMaxWidth(): ?int
{
return match ($this) {
self::EXTRA_SMALL => 59,
self::SMALL => 79,
self::MEDIUM => 119,
self::LARGE => 159,
self::EXTRA_LARGE => null, // No maximum
};
}
public function getDescription(): string
{
return match ($this) {
self::EXTRA_SMALL => 'Extra Small (< 60 chars)',
self::SMALL => 'Small (60-79 chars)',
self::MEDIUM => 'Medium (80-119 chars)',
self::LARGE => 'Large (120-159 chars)',
self::EXTRA_LARGE => 'Extra Large (160+ chars)',
};
}
public function getColumnCount(): int
{
return match ($this) {
self::EXTRA_SMALL => 1,
self::SMALL => 1,
self::MEDIUM => 2,
self::LARGE => 3,
self::EXTRA_LARGE => 4,
};
}
public function getTableWidth(): int
{
return match ($this) {
self::EXTRA_SMALL => 50,
self::SMALL => 70,
self::MEDIUM => 80,
self::LARGE => 110,
self::EXTRA_LARGE => 140,
};
}
public function shouldTruncateText(): bool
{
return $this === self::EXTRA_SMALL || $this === self::SMALL;
}
public function getMaxTextLength(): int
{
return match ($this) {
self::EXTRA_SMALL => 30,
self::SMALL => 50,
self::MEDIUM => 70,
self::LARGE => 100,
self::EXTRA_LARGE => 120,
};
}
}

View File

@@ -0,0 +1,106 @@
<?php
declare(strict_types=1);
namespace App\Framework\Console\Layout;
final readonly class TerminalSize
{
public function __construct(
public int $width,
public int $height
) {
}
public static function detect(): self
{
$width = 80; // Default fallback
$height = 24; // Default fallback
// Try to get terminal size from stty
if (function_exists('exec') && strtoupper(substr(PHP_OS, 0, 3)) !== 'WIN') {
$output = [];
$return = 0;
exec('stty size 2>/dev/null', $output, $return);
if ($return === 0 && ! empty($output[0])) {
$dimensions = explode(' ', trim($output[0]));
if (count($dimensions) === 2) {
$height = (int) $dimensions[0];
$width = (int) $dimensions[1];
}
}
}
// Try environment variables as fallback
if ($width === 80 || $height === 24) {
$envWidth = $_ENV['COLUMNS'] ?? $_SERVER['COLUMNS'] ?? null;
$envHeight = $_ENV['LINES'] ?? $_SERVER['LINES'] ?? null;
if ($envWidth && is_numeric($envWidth)) {
$width = (int) $envWidth;
}
if ($envHeight && is_numeric($envHeight)) {
$height = (int) $envHeight;
}
}
return new self($width, $height);
}
public function isNarrow(): bool
{
return $this->width < 80;
}
public function isWide(): bool
{
return $this->width >= 120;
}
public function isShort(): bool
{
return $this->height < 24;
}
public function isTall(): bool
{
return $this->height >= 40;
}
public function getBreakpoint(): TerminalBreakpoint
{
return match (true) {
$this->width >= 160 => TerminalBreakpoint::EXTRA_LARGE,
$this->width >= 120 => TerminalBreakpoint::LARGE,
$this->width >= 80 => TerminalBreakpoint::MEDIUM,
$this->width >= 60 => TerminalBreakpoint::SMALL,
default => TerminalBreakpoint::EXTRA_SMALL
};
}
public function getUsableWidth(int $padding = 4): int
{
return max(40, $this->width - $padding); // Minimum 40 chars
}
public function getUsableHeight(int $reserved = 3): int
{
return max(10, $this->height - $reserved); // Minimum 10 lines
}
public function toArray(): array
{
return [
'width' => $this->width,
'height' => $this->height,
'breakpoint' => $this->getBreakpoint()->value,
'usable_width' => $this->getUsableWidth(),
'usable_height' => $this->getUsableHeight(),
'is_narrow' => $this->isNarrow(),
'is_wide' => $this->isWide(),
'is_short' => $this->isShort(),
'is_tall' => $this->isTall(),
];
}
}

View File

@@ -0,0 +1,338 @@
<?php
declare(strict_types=1);
namespace App\Framework\Console;
use ReflectionMethod;
use ReflectionNamedType;
use ReflectionParameter;
use ReflectionUnionType;
/**
* Analyzes method signatures to automatically generate argument definitions
*/
final readonly class MethodSignatureAnalyzer
{
/**
* Generate ArgumentDefinitions from method parameters
*
* @return ArgumentDefinition[]
*/
public function generateArgumentDefinitions(ReflectionMethod $method): array
{
$definitions = [];
foreach ($method->getParameters() as $param) {
$definition = $this->createDefinitionFromParameter($param);
$definitions[$param->getName()] = $definition;
}
return $definitions;
}
/**
* Create ArgumentDefinition from ReflectionParameter
*/
private function createDefinitionFromParameter(ReflectionParameter $param): ArgumentDefinition
{
$type = $this->mapPhpTypeToArgumentType($param->getType());
$required = ! $param->isDefaultValueAvailable();
$default = $param->isDefaultValueAvailable() ? $param->getDefaultValue() : null;
// Extract information from Parameter attribute if present
$parameterAttribute = $this->extractParameterAttribute($param);
$description = $parameterAttribute?->description ?: $this->extractDescriptionFromDocComment($param);
$shortName = $parameterAttribute?->shortName ?: (strlen($param->getName()) === 1 ? $param->getName() : null);
// Handle Enums - extract allowed values from enum cases
$allowedValues = $this->extractEnumValues($param->getType());
return new ArgumentDefinition(
name: $param->getName(),
type: $type,
required: $required,
default: $default,
description: $description,
shortName: $shortName,
allowedValues: $allowedValues
);
}
/**
* Extract Parameter attribute from parameter
*/
private function extractParameterAttribute(ReflectionParameter $param): ?Parameter
{
$attributes = $param->getAttributes(Parameter::class);
if (empty($attributes)) {
return null;
}
return $attributes[0]->newInstance();
}
/**
* Extract enum values if parameter type is an enum
*/
private function extractEnumValues(?\ReflectionType $type): array
{
if (! $type instanceof ReflectionNamedType) {
return [];
}
$typeName = $type->getName();
if (! enum_exists($typeName)) {
return [];
}
// Get all enum cases
$enumValues = [];
foreach ($typeName::cases() as $case) {
if ($case instanceof \BackedEnum) {
$enumValues[] = $case->value;
} else {
$enumValues[] = $case->name;
}
}
return $enumValues;
}
/**
* Map PHP type to ArgumentType
*/
private function mapPhpTypeToArgumentType(?\ReflectionType $type): ArgumentType
{
if (! $type) {
return ArgumentType::STRING; // Default fallback
}
if ($type instanceof ReflectionUnionType) {
// For union types, try to find the most specific type
foreach ($type->getTypes() as $unionType) {
if ($unionType instanceof ReflectionNamedType && ! $unionType->isBuiltin()) {
continue;
}
return $this->mapNamedTypeToArgumentType($unionType);
}
return ArgumentType::STRING;
}
if ($type instanceof ReflectionNamedType) {
return $this->mapNamedTypeToArgumentType($type);
}
return ArgumentType::STRING;
}
/**
* Map ReflectionNamedType to ArgumentType
*/
private function mapNamedTypeToArgumentType(ReflectionNamedType $type): ArgumentType
{
$typeName = $type->getName();
// Handle enums
if (enum_exists($typeName)) {
return ArgumentType::STRING; // Enums are represented as strings
}
return match (strtolower($typeName)) {
'string' => ArgumentType::STRING,
'int', 'integer' => ArgumentType::INTEGER,
'float', 'double' => ArgumentType::FLOAT,
'bool', 'boolean' => ArgumentType::BOOLEAN,
'array' => ArgumentType::ARRAY,
default => $this->mapClassTypeToArgumentType($typeName)
};
}
/**
* Map class types to ArgumentType
*/
private function mapClassTypeToArgumentType(string $className): ArgumentType
{
// Check for framework value objects
return match (true) {
str_contains(strtolower($className), 'email') => ArgumentType::EMAIL,
str_contains(strtolower($className), 'url') => ArgumentType::URL,
default => ArgumentType::STRING
};
}
/**
* Extract parameter description from method docblock
*/
private function extractDescriptionFromDocComment(ReflectionParameter $param): string
{
$method = $param->getDeclaringFunction();
$docComment = $method->getDocComment();
if (! $docComment) {
return $this->generateDefaultDescription($param);
}
$paramName = $param->getName();
// Look for @param annotations
if (preg_match('/@param\s+[^\s]+\s+\$' . preg_quote($paramName) . '\s+(.+?)(?=\n|$)/i', $docComment, $matches)) {
return trim($matches[1]);
}
return $this->generateDefaultDescription($param);
}
/**
* Generate default description based on parameter name and type
*/
private function generateDefaultDescription(ReflectionParameter $param): string
{
$name = $param->getName();
$type = $param->getType();
// Convert camelCase to readable format
$readable = preg_replace('/([a-z])([A-Z])/', '$1 $2', $name);
$readable = ucfirst(strtolower($readable));
if ($type instanceof ReflectionNamedType) {
$typeName = $type->getName();
return match (strtolower($typeName)) {
'bool', 'boolean' => "Enable {$readable}",
'int', 'integer' => "{$readable} value",
'float', 'double' => "{$readable} amount",
'array' => "{$readable} list",
default => $readable
};
}
return $readable;
}
/**
* Analyze method to determine argument order and types
*/
public function analyzeMethodStructure(ReflectionMethod $method): array
{
$structure = [
'positional' => [],
'options' => [],
'flags' => [],
];
foreach ($method->getParameters() as $index => $param) {
$type = $this->mapPhpTypeToArgumentType($param->getType());
$paramInfo = [
'name' => $param->getName(),
'type' => $type,
'position' => $index,
'required' => ! $param->isDefaultValueAvailable(),
'default' => $param->isDefaultValueAvailable() ? $param->getDefaultValue() : null,
];
if ($type === ArgumentType::BOOLEAN) {
$structure['flags'][] = $paramInfo;
} elseif ($param->isDefaultValueAvailable()) {
$structure['options'][] = $paramInfo;
} else {
$structure['positional'][] = $paramInfo;
}
}
return $structure;
}
/**
* Check if method signature is compatible with console command
*/
public function isValidCommandMethod(ReflectionMethod $method): bool
{
// Must be public
if (! $method->isPublic()) {
return false;
}
// Check return type
$returnType = $method->getReturnType();
if ($returnType instanceof ReflectionNamedType) {
$returnTypeName = $returnType->getName();
if (! in_array($returnTypeName, ['int', ExitCode::class], true)) {
return false;
}
}
// Check parameters are compatible
foreach ($method->getParameters() as $param) {
if (! $this->isValidParameterType($param)) {
return false;
}
}
return true;
}
/**
* Check if parameter type is supported
*/
private function isValidParameterType(ReflectionParameter $param): bool
{
$type = $param->getType();
if (! $type) {
return true; // Mixed/untyped is allowed
}
if ($type instanceof ReflectionUnionType) {
// Union types are complex, be conservative
return false;
}
if ($type instanceof ReflectionNamedType) {
$typeName = $type->getName();
$allowedTypes = [
'string', 'int', 'integer', 'float', 'double', 'bool', 'boolean', 'array',
];
return in_array(strtolower($typeName), $allowedTypes, true) ||
$this->isFrameworkValueObject($typeName) ||
$this->isConsoleType($typeName);
}
return false;
}
/**
* Check if type is a framework value object
*/
private function isFrameworkValueObject(string $className): bool
{
// For now, be permissive - can be enhanced later
return str_contains($className, 'Email') ||
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

@@ -0,0 +1,82 @@
<?php
declare(strict_types=1);
namespace App\Framework\Console\Middleware;
use App\Framework\Console\ExitCode;
use App\Framework\Performance\Contracts\PerformanceCollectorInterface;
use App\Framework\Performance\PerformanceCategory;
final readonly class ConsolePerformanceMiddleware
{
public function __construct(
private PerformanceCollectorInterface $collector
) {
}
public function handle(string $commandName, callable $next): ExitCode
{
$metricKey = "console_command_{$commandName}";
$this->collector->startTiming($metricKey, PerformanceCategory::CONSOLE, [
'command_name' => $commandName,
'execution_context' => 'console',
]);
$this->collector->increment('console_command_executions', PerformanceCategory::CONSOLE, 1, [
'command_name' => $commandName,
]);
$memoryStart = memory_get_usage(true);
$peakMemoryStart = memory_get_peak_usage(true);
try {
$result = $next();
$this->collector->recordMetric(
"console_command_{$commandName}_success",
1.0,
PerformanceCategory::CONSOLE,
['command_name' => $commandName, 'status' => 'success']
);
return $result;
} catch (\Throwable $e) {
$this->collector->recordMetric(
"console_command_{$commandName}_error",
1.0,
PerformanceCategory::CONSOLE,
[
'command_name' => $commandName,
'status' => 'error',
'error_type' => get_class($e),
'error_message' => $e->getMessage(),
]
);
throw $e;
} finally {
$this->collector->endTiming($metricKey);
$memoryEnd = memory_get_usage(true);
$peakMemoryEnd = memory_get_peak_usage(true);
$this->collector->recordMetric(
"console_command_{$commandName}_memory_usage",
($memoryEnd - $memoryStart) / 1024 / 1024, // MB
PerformanceCategory::CONSOLE,
['command_name' => $commandName, 'unit' => 'MB']
);
$this->collector->recordMetric(
"console_command_{$commandName}_peak_memory",
($peakMemoryEnd - $peakMemoryStart) / 1024 / 1024, // MB
PerformanceCategory::CONSOLE,
['command_name' => $commandName, 'unit' => 'MB']
);
}
}
}

View File

@@ -0,0 +1,54 @@
<?php
declare(strict_types=1);
namespace App\Framework\Console;
use Attribute;
/**
* Attribute for detailed parameter documentation
*/
#[Attribute(Attribute::TARGET_PARAMETER)]
final readonly class Parameter
{
public function __construct(
public string $description = '',
public ?string $shortName = null,
public ?string $example = null,
public bool $hidden = false
) {
}
/**
* Create a parameter with description
*/
public static function desc(string $description): self
{
return new self($description);
}
/**
* Create a parameter with description and short name
*/
public static function short(string $description, string $shortName): self
{
return new self($description, $shortName);
}
/**
* Create a hidden parameter (for internal use)
*/
public static function hidden(string $description = ''): self
{
return new self($description, hidden: true);
}
/**
* Create parameter with example value
*/
public static function example(string $description, string $example): self
{
return new self($description, example: $example);
}
}

View File

@@ -0,0 +1,244 @@
<?php
declare(strict_types=1);
namespace App\Framework\Console;
use ReflectionMethod;
use ReflectionNamedType;
use ReflectionParameter;
use ReflectionUnionType;
/**
* Analyzes command method signatures to extract parameter information
*/
final readonly class ParameterInspector
{
/**
* Extract parameter information from a command method
*/
public function inspectCommand(object $command, string $methodName = '__invoke'): array
{
try {
$reflection = new ReflectionMethod($command, $methodName);
$parameters = $reflection->getParameters();
$parameterInfo = [];
foreach ($parameters as $index => $parameter) {
// Skip ConsoleInput and ConsoleOutput parameters
if ($this->isFrameworkParameter($parameter)) {
continue;
}
$parameterInfo[] = $this->analyzeParameter($parameter, $index);
}
return $parameterInfo;
} catch (\ReflectionException $e) {
return [];
}
}
/**
* Check if parameter is a framework parameter (ConsoleInput/ConsoleOutput)
*/
private function isFrameworkParameter(ReflectionParameter $parameter): bool
{
$type = $parameter->getType();
if (! $type instanceof ReflectionNamedType) {
return false;
}
$typeName = $type->getName();
return in_array($typeName, [
'App\Framework\Console\ConsoleInput',
'App\Framework\Console\ConsoleOutput',
'App\Framework\Console\Input\ConsoleInput',
'App\Framework\Console\Output\ConsoleOutput',
]);
}
/**
* Analyze a single parameter
*/
private function analyzeParameter(ReflectionParameter $parameter, int $position): array
{
$info = [
'name' => $parameter->getName(),
'position' => $position,
'type' => $this->getParameterType($parameter),
'required' => ! $parameter->isOptional(),
'default' => $parameter->isDefaultValueAvailable() ? $parameter->getDefaultValue() : null,
'description' => $this->generateDescription($parameter),
'validation_rules' => $this->inferValidationRules($parameter),
'input_type' => $this->determineInputType($parameter),
];
return $info;
}
/**
* Get parameter type information
*/
private function getParameterType(ReflectionParameter $parameter): array
{
$type = $parameter->getType();
if (! $type) {
return ['name' => 'mixed', 'nullable' => true];
}
if ($type instanceof ReflectionNamedType) {
return [
'name' => $type->getName(),
'nullable' => $type->allowsNull(),
'builtin' => $type->isBuiltin(),
];
}
if ($type instanceof ReflectionUnionType) {
$types = [];
foreach ($type->getTypes() as $unionType) {
if ($unionType instanceof ReflectionNamedType) {
$types[] = $unionType->getName();
}
}
return [
'name' => implode('|', $types),
'nullable' => $type->allowsNull(),
'union' => true,
];
}
return ['name' => 'mixed', 'nullable' => true];
}
/**
* Generate human-readable description for parameter
*/
private function generateDescription(ReflectionParameter $parameter): string
{
$name = $parameter->getName();
$type = $this->getParameterType($parameter);
// Convert camelCase to readable format
$readable = preg_replace('/([a-z])([A-Z])/', '$1 $2', $name);
$readable = ucfirst(strtolower($readable));
// Add type information
if (isset($type['name']) && $type['name'] !== 'mixed') {
$typeName = $this->getReadableTypeName($type['name']);
return "{$readable} ({$typeName})";
}
return $readable;
}
/**
* Convert technical type names to readable format
*/
private function getReadableTypeName(string $typeName): string
{
return match ($typeName) {
'string' => 'text',
'int' => 'number',
'float' => 'decimal',
'bool' => 'yes/no',
'array' => 'list',
default => basename(str_replace('\\', '/', $typeName))
};
}
/**
* Infer validation rules based on parameter type and name
*/
private function inferValidationRules(ReflectionParameter $parameter): array
{
$rules = [];
$type = $this->getParameterType($parameter);
$name = strtolower($parameter->getName());
// Required validation
if (! $parameter->isOptional()) {
$rules['required'] = true;
}
// Type-based validations
switch ($type['name']) {
case 'int':
$rules['type'] = 'integer';
if (str_contains($name, 'port')) {
$rules['min'] = 1;
$rules['max'] = 65535;
} elseif (str_contains($name, 'count') || str_contains($name, 'limit')) {
$rules['min'] = 0;
}
break;
case 'string':
$rules['type'] = 'string';
if (str_contains($name, 'email')) {
$rules['format'] = 'email';
} elseif (str_contains($name, 'url')) {
$rules['format'] = 'url';
} elseif (str_contains($name, 'path')) {
$rules['format'] = 'path';
}
break;
case 'bool':
$rules['type'] = 'boolean';
break;
case 'float':
$rules['type'] = 'number';
break;
}
return $rules;
}
/**
* Determine the best input type for the parameter
*/
private function determineInputType(ReflectionParameter $parameter): string
{
$type = $this->getParameterType($parameter);
$name = strtolower($parameter->getName());
// Special cases based on parameter name
if (str_contains($name, 'password') || str_contains($name, 'secret')) {
return 'password';
}
if (str_contains($name, 'email')) {
return 'email';
}
if (str_contains($name, 'file') || str_contains($name, 'path')) {
return 'file';
}
if (str_contains($name, 'url')) {
return 'url';
}
// Type-based input types
return match ($type['name']) {
'bool' => 'boolean',
'int' => 'number',
'float' => 'decimal',
'array' => 'list',
default => 'text'
};
}
}

View File

@@ -0,0 +1,312 @@
<?php
declare(strict_types=1);
namespace App\Framework\Console;
use App\Framework\Core\ValueObjects\Email;
use App\Framework\Core\ValueObjects\Url;
/**
* Container for parsed and validated console arguments
*/
final readonly class ParsedArguments
{
public function __construct(
/** @var array<string, mixed> */
private array $arguments,
/** @var array<string, mixed> */
private array $options,
/** @var array<string, ArgumentDefinition> */
private array $definitions
) {
}
/**
* Get argument or option value by name
*/
public function get(string $name): mixed
{
return $this->arguments[$name] ?? $this->options[$name] ?? $this->getDefaultValue($name);
}
/**
* Get required argument or option, throw if missing
*/
public function require(string $name): mixed
{
$value = $this->get($name);
if ($value === null || $value === '') {
throw new \InvalidArgumentException("Required argument '{$name}' is missing");
}
return $value;
}
/**
* Get typed value (cast to appropriate type)
*/
public function getTyped(string $name): mixed
{
$value = $this->get($name);
$definition = $this->getDefinition($name);
if ($value === null || $value === '') {
return $definition?->default;
}
return $definition ? $this->castToType($value, $definition->type) : $value;
}
/**
* Get string value
*/
public function getString(string $name): string
{
return (string) $this->getTyped($name);
}
/**
* Get integer value
*/
public function getInt(string $name): int
{
return (int) $this->getTyped($name);
}
/**
* Get float value
*/
public function getFloat(string $name): float
{
return (float) $this->getTyped($name);
}
/**
* Get boolean value
*/
public function getBool(string $name): bool
{
return (bool) $this->getTyped($name);
}
/**
* Get array value
*/
public function getArray(string $name): array
{
$value = $this->getTyped($name);
return is_array($value) ? $value : [$value];
}
/**
* Get Email value object
*/
public function getEmail(string $name): Email
{
return new Email($this->getString($name));
}
/**
* Get URL value object
*/
public function getUrl(string $name): Url
{
return new Url($this->getString($name));
}
/**
* Check if argument or option exists
*/
public function has(string $name): bool
{
return array_key_exists($name, $this->arguments) ||
array_key_exists($name, $this->options) ||
$this->hasDefinition($name);
}
/**
* Check if argument or option has a non-empty value
*/
public function hasValue(string $name): bool
{
$value = $this->get($name);
return $value !== null && $value !== '';
}
/**
* Get all arguments as array
*/
public function getAllArguments(): array
{
return $this->arguments;
}
/**
* Get all options as array
*/
public function getAllOptions(): array
{
return $this->options;
}
/**
* Get all definitions
*/
public function getDefinitions(): array
{
return $this->definitions;
}
/**
* Validate all required arguments are present
*/
public function validate(): void
{
foreach ($this->definitions as $definition) {
if ($definition->required && ! $this->hasValue($definition->name)) {
throw new \InvalidArgumentException("Required argument '{$definition->name}' is missing");
}
// Validate against allowed values if present
if (! empty($definition->allowedValues)) {
$value = $this->get($definition->name);
if ($value !== null) {
// Convert value to string for comparison (handles enums and objects)
$valueString = $this->convertValueToString($value);
if (! in_array($valueString, $definition->allowedValues, true)) {
throw new \InvalidArgumentException(
"Invalid value '{$valueString}' for '{$definition->name}'. Allowed: " .
implode(', ', $definition->allowedValues)
);
}
}
}
}
}
private function getDefinition(string $name): ?ArgumentDefinition
{
return $this->definitions[$name] ?? null;
}
private function hasDefinition(string $name): bool
{
return array_key_exists($name, $this->definitions);
}
private function getDefaultValue(string $name): mixed
{
return $this->getDefinition($name)?->default;
}
private function castToType(mixed $value, ArgumentType $type): mixed
{
if ($value === null || $value === '') {
return match($type) {
ArgumentType::BOOLEAN => false,
ArgumentType::ARRAY => [],
default => $value
};
}
return match($type) {
ArgumentType::STRING => (string) $value,
ArgumentType::INTEGER => $this->parseInteger($value),
ArgumentType::FLOAT => $this->parseFloat($value),
ArgumentType::BOOLEAN => $this->parseBoolean($value),
ArgumentType::ARRAY => $this->parseArray($value),
ArgumentType::EMAIL => $this->validateEmail($value),
ArgumentType::URL => $this->validateUrl($value),
};
}
private function parseInteger(mixed $value): int
{
if (! is_numeric($value)) {
throw new \InvalidArgumentException("Value '{$value}' is not a valid integer");
}
return (int) $value;
}
private function parseFloat(mixed $value): float
{
if (! is_numeric($value)) {
throw new \InvalidArgumentException("Value '{$value}' is not a valid number");
}
return (float) $value;
}
private function parseBoolean(mixed $value): bool
{
if (is_bool($value)) {
return $value;
}
$lowered = strtolower((string) $value);
return in_array($lowered, ['true', '1', 'yes', 'on'], true);
}
private function parseArray(mixed $value): array
{
if (is_array($value)) {
return $value;
}
return array_map('trim', explode(',', (string) $value));
}
private function validateEmail(mixed $value): string
{
$email = (string) $value;
if (! filter_var($email, FILTER_VALIDATE_EMAIL)) {
throw new \InvalidArgumentException("'{$email}' is not a valid email address");
}
return $email;
}
private function validateUrl(mixed $value): string
{
$url = (string) $value;
if (! filter_var($url, FILTER_VALIDATE_URL)) {
throw new \InvalidArgumentException("'{$url}' is not a valid URL");
}
return $url;
}
/**
* Convert value to string for validation/comparison
*/
private function convertValueToString(mixed $value): string
{
if (is_string($value)) {
return $value;
}
if ($value instanceof \BackedEnum) {
return (string) $value->value;
}
if ($value instanceof \UnitEnum) {
return $value->name;
}
if (is_scalar($value)) {
return (string) $value;
}
if (method_exists($value, '__toString')) {
return (string) $value;
}
return get_class($value);
}
}

View File

@@ -0,0 +1,260 @@
<?php
declare(strict_types=1);
namespace App\Framework\Console\Performance;
use App\Framework\Console\ConsoleOutputInterface;
use App\Framework\Console\ExitCode;
use App\Framework\Performance\Contracts\PerformanceCollectorInterface;
use App\Framework\Performance\PerformanceCategory;
final readonly class ConsolePerformanceCollector
{
public function __construct(
private PerformanceCollectorInterface $collector,
private bool $enabled = true
) {
}
public function measureCommand(
string $commandName,
callable $commandExecutor,
array $arguments = [],
?ConsoleOutputInterface $output = null
): ExitCode {
if (! $this->enabled) {
return $commandExecutor();
}
$metricKey = "console_command_{$commandName}";
$sanitizedCommandName = $this->sanitizeCommandName($commandName);
$this->collector->startTiming($metricKey, PerformanceCategory::CONSOLE, [
'command_name' => $sanitizedCommandName,
'execution_context' => 'console',
'arguments_count' => count($arguments),
]);
$this->collector->increment('console_commands_executed', PerformanceCategory::CONSOLE, 1, [
'command_name' => $sanitizedCommandName,
]);
$memoryStart = memory_get_usage(true);
$peakMemoryStart = memory_get_peak_usage(true);
try {
$result = $commandExecutor();
$this->recordSuccessMetrics($sanitizedCommandName, $result);
return $result;
} catch (\Throwable $e) {
$this->recordErrorMetrics($sanitizedCommandName, $e);
throw $e;
} finally {
$this->collector->endTiming($metricKey);
$this->recordMemoryMetrics($sanitizedCommandName, $memoryStart, $peakMemoryStart);
$this->recordPerformanceWarnings($sanitizedCommandName, $output);
}
}
public function recordParameterResolutionTime(string $commandName, float $durationMs): void
{
if (! $this->enabled) {
return;
}
$this->collector->recordMetric(
"console_parameter_resolution_time",
PerformanceCategory::CONSOLE,
$durationMs,
[
'command_name' => $this->sanitizeCommandName($commandName),
'unit' => 'milliseconds',
]
);
}
public function recordValidationTime(string $commandName, float $durationMs): void
{
if (! $this->enabled) {
return;
}
$this->collector->recordMetric(
"console_validation_time",
PerformanceCategory::CONSOLE,
$durationMs,
[
'command_name' => $this->sanitizeCommandName($commandName),
'unit' => 'milliseconds',
]
);
}
public function recordContainerResolutionTime(string $commandName, string $className, float $durationMs): void
{
if (! $this->enabled) {
return;
}
$this->collector->recordMetric(
"console_container_resolution_time",
PerformanceCategory::CONSOLE,
$durationMs,
[
'command_name' => $this->sanitizeCommandName($commandName),
'class_name' => $className,
'unit' => 'milliseconds',
]
);
}
public function getConsoleMetrics(): array
{
return $this->collector->getMetrics(PerformanceCategory::CONSOLE);
}
public function getCommandMetrics(string $commandName): array
{
$sanitizedCommandName = $this->sanitizeCommandName($commandName);
$allMetrics = $this->getConsoleMetrics();
return array_filter($allMetrics, function ($metric) use ($sanitizedCommandName) {
return str_contains($metric->getKey(), $sanitizedCommandName);
});
}
public function getPerformanceSummary(): array
{
$metrics = $this->getConsoleMetrics();
$totalCommands = 0;
$totalDuration = 0.0;
$totalMemory = 0.0;
$errorCount = 0;
$commandCounts = [];
foreach ($metrics as $metric) {
$context = $metric->getContext();
$commandName = $context['command_name'] ?? 'unknown';
if (str_contains($metric->getKey(), '_executions')) {
$totalCommands += $metric->getValue();
$commandCounts[$commandName] = ($commandCounts[$commandName] ?? 0) + $metric->getValue();
} elseif (str_contains($metric->getKey(), '_error')) {
$errorCount += $metric->getValue();
} elseif (str_contains($metric->getKey(), '_memory_usage')) {
$totalMemory += $metric->getValue();
}
}
return [
'total_commands_executed' => $totalCommands,
'total_errors' => $errorCount,
'success_rate' => $totalCommands > 0 ? (($totalCommands - $errorCount) / $totalCommands) * 100 : 100,
'average_memory_usage_mb' => $totalCommands > 0 ? $totalMemory / $totalCommands : 0,
'command_usage_distribution' => $commandCounts,
'most_used_command' => ! empty($commandCounts) ? array_keys($commandCounts, max($commandCounts))[0] : null,
];
}
private function sanitizeCommandName(string $commandName): string
{
return preg_replace('/[^a-zA-Z0-9_:\-]/', '_', $commandName);
}
private function recordSuccessMetrics(string $commandName, ExitCode $result): void
{
$this->collector->recordMetric(
"console_command_success",
PerformanceCategory::CONSOLE,
1.0,
[
'command_name' => $commandName,
'status' => 'success',
'exit_code' => $result->value,
]
);
$this->collector->increment('console_successful_executions', PerformanceCategory::CONSOLE, 1, [
'command_name' => $commandName,
]);
}
private function recordErrorMetrics(string $commandName, \Throwable $e): void
{
$this->collector->recordMetric(
"console_command_error",
PerformanceCategory::CONSOLE,
1.0,
[
'command_name' => $commandName,
'status' => 'error',
'error_type' => get_class($e),
'error_message' => mb_substr($e->getMessage(), 0, 255), // Limit message length
]
);
$this->collector->increment('console_failed_executions', PerformanceCategory::CONSOLE, 1, [
'command_name' => $commandName,
'error_type' => get_class($e),
]);
}
private function recordMemoryMetrics(string $commandName, int $memoryStart, int $peakMemoryStart): void
{
$memoryEnd = memory_get_usage(true);
$peakMemoryEnd = memory_get_peak_usage(true);
$memoryUsed = ($memoryEnd - $memoryStart) / 1024 / 1024; // MB
$peakMemoryUsed = ($peakMemoryEnd - $peakMemoryStart) / 1024 / 1024; // MB
$this->collector->recordMetric(
"console_memory_usage",
PerformanceCategory::CONSOLE,
$memoryUsed,
[
'command_name' => $commandName,
'unit' => 'MB',
]
);
$this->collector->recordMetric(
"console_peak_memory",
PerformanceCategory::CONSOLE,
$peakMemoryUsed,
[
'command_name' => $commandName,
'unit' => 'MB',
]
);
}
private function recordPerformanceWarnings(string $commandName, ?ConsoleOutputInterface $output): void
{
$metric = $this->collector->getMetric("console_command_{$commandName}");
if ($metric && $output) {
$duration = $metric->getTotalDuration();
// Warn for commands taking longer than 30 seconds
if ($duration > 30000) {
$this->collector->recordMetric(
"console_slow_command",
PerformanceCategory::CONSOLE,
$duration,
[
'command_name' => $commandName,
'duration_ms' => $duration,
'warning_threshold' => 30000,
]
);
}
}
}
}

View File

@@ -0,0 +1,21 @@
<?php
declare(strict_types=1);
namespace App\Framework\Console\Progress;
use Attribute;
/**
* Attribute to mark commands as long-running and enable automatic progress tracking
*/
#[Attribute(Attribute::TARGET_METHOD)]
final readonly class LongRunning
{
public function __construct(
public ?int $estimatedSteps = null,
public ProgressType $progressType = ProgressType::AUTO,
public string $title = 'Processing'
) {
}
}

View File

@@ -0,0 +1,26 @@
<?php
declare(strict_types=1);
namespace App\Framework\Console\Progress;
/**
* Interface for commands that support progress tracking
*/
interface ProgressAwareInterface
{
/**
* Set the progress tracker for this operation
*/
public function setProgressTracker(ProgressInterface $progress): void;
/**
* Get the estimated total steps for this operation
*/
public function getEstimatedSteps(): int;
/**
* Check if this operation supports progress tracking
*/
public function supportsProgress(): bool;
}

View File

@@ -0,0 +1,155 @@
<?php
declare(strict_types=1);
namespace App\Framework\Console\Progress;
use App\Framework\Console\ConsoleColor;
use App\Framework\Console\ConsoleOutputInterface;
use App\Framework\Console\ConsoleStyle;
/**
* Console output wrapper that provides built-in progress tracking
*/
final class ProgressAwareOutput implements ConsoleOutputInterface
{
private readonly ProgressFactory $progressFactory;
private ?ProgressInterface $currentProgress = null;
public function __construct(
private readonly ConsoleOutputInterface $output
) {
$this->progressFactory = new ProgressFactory($this->output);
}
public function writeLine(string $message = '', ?ConsoleColor $color = null): void
{
$this->output->writeLine($message, $color);
}
public function write(string $message, null|ConsoleColor|ConsoleStyle $style = null): void
{
$this->output->write($message, $style);
}
public function writeSuccess(string $message): void
{
$this->output->writeSuccess($message);
}
public function writeError(string $message): void
{
$this->output->writeError($message);
}
public function writeWarning(string $message): void
{
$this->output->writeWarning($message);
}
public function writeInfo(string $message): void
{
$this->output->writeInfo($message);
}
public function newLine(int $count = 1): void
{
$this->output->newLine($count);
}
public function askQuestion(string $question, ?string $default = null): string
{
return $this->output->askQuestion($question, $default);
}
public function writeWindowTitle(string $title, int $mode = 0): void
{
$this->output->writeWindowTitle($title, $mode);
}
public function confirm(string $question, bool $default = false): bool
{
return $this->output->confirm($question, $default);
}
/**
* Create a progress tracker for long-running operations
*/
public function createProgress(int $total, string $title = 'Progress'): ProgressTracker
{
return $this->progressFactory->createTracker($total, $title);
}
/**
* Create a spinner for indeterminate operations
*/
public function createSpinner(string $title = 'Working'): SpinnerProgress
{
return $this->progressFactory->createSpinner($title);
}
/**
* Create a simple progress bar
*/
public function createProgressBar(int $width = 50): ProgressBar
{
return $this->progressFactory->createBar($width);
}
/**
* Execute a callback with progress tracking
*/
public function withProgress(int $total, string $title, callable $callback): mixed
{
$progress = $this->createProgress($total, $title);
try {
return $callback($progress);
} finally {
if (! $progress->isFinished()) {
$progress->finish();
}
}
}
/**
* Execute a callback with spinner progress
*/
public function withSpinner(string $title, callable $callback): mixed
{
$spinner = $this->createSpinner($title);
try {
return $callback($spinner);
} finally {
if (! $spinner->isFinished()) {
$spinner->finish();
}
}
}
/**
* Set the current progress indicator for command use
*/
public function setCurrentProgress(?ProgressInterface $progress): void
{
$this->currentProgress = $progress;
}
/**
* Get the current progress indicator
*/
public function getCurrentProgress(): ?ProgressInterface
{
return $this->currentProgress;
}
/**
* Get the underlying output interface
*/
public function getOutput(): ConsoleOutputInterface
{
return $this->output;
}
}

View File

@@ -0,0 +1,49 @@
<?php
declare(strict_types=1);
namespace App\Framework\Console\Progress;
use App\Framework\Console\ConsoleColor;
use App\Framework\Console\ConsoleOutputInterface;
/**
* Simple progress bar for console operations
*/
final readonly class ProgressBar
{
public function __construct(
private ConsoleOutputInterface $output,
private int $width = 50
) {
}
public function render(float $progress, ?string $message = null): void
{
$progress = max(0.0, min(1.0, $progress));
$completed = (int) round($this->width * $progress);
$remaining = $this->width - $completed;
$bar = '[' . str_repeat('=', $completed) . str_repeat(' ', $remaining) . ']';
$percentage = (int) round($progress * 100);
$output = "{$bar} {$percentage}%";
if ($message) {
$output .= " - {$message}";
}
$this->output->write("\r{$output}", ConsoleColor::CYAN);
}
public function clear(): void
{
$this->output->write("\r" . str_repeat(' ', $this->width + 20) . "\r");
}
public function finish(?string $message = null): void
{
$this->render(1.0, $message);
$this->output->write("\n");
}
}

View File

@@ -0,0 +1,56 @@
<?php
declare(strict_types=1);
namespace App\Framework\Console\Progress;
use App\Framework\Console\ConsoleOutputInterface;
/**
* Factory for creating progress indicators
*/
final readonly class ProgressFactory
{
public function __construct(
private ConsoleOutputInterface $output
) {
}
/**
* Create a progress tracker for determinate operations
*/
public function createTracker(int $total, string $title = 'Progress'): ProgressTracker
{
return new ProgressTracker($this->output, $total, $title);
}
/**
* Create a simple progress bar
*/
public function createBar(int $width = 50): ProgressBar
{
return new ProgressBar($this->output, $width);
}
/**
* Create a spinner for indeterminate operations
*/
public function createSpinner(string $title = 'Working'): SpinnerProgress
{
return new SpinnerProgress($this->output, $title);
}
/**
* Create the appropriate progress indicator based on operation type
*/
public function createFor(string $type, int $total = 0, string $title = 'Progress'): ProgressInterface
{
return match ($type) {
'spinner' => $this->createSpinner($title),
'tracker' => $this->createTracker($total, $title),
default => $total > 0
? $this->createTracker($total, $title)
: $this->createSpinner($title)
};
}
}

View File

@@ -0,0 +1,46 @@
<?php
declare(strict_types=1);
namespace App\Framework\Console\Progress;
/**
* Interface for progress tracking in console operations
*/
interface ProgressInterface
{
/**
* Set the total number of steps for the operation
*/
public function setTotal(int $total): void;
/**
* Advance progress by one or more steps
*/
public function advance(int $step = 1, ?string $task = null): void;
/**
* Set current progress to a specific value
*/
public function setProgress(int $current, ?string $task = null): void;
/**
* Set the current task description
*/
public function setTask(string $task): void;
/**
* Get current progress as percentage (0.0 to 1.0)
*/
public function getProgress(): float;
/**
* Mark the operation as finished
*/
public function finish(?string $message = null): void;
/**
* Check if the operation is finished
*/
public function isFinished(): bool;
}

View File

@@ -0,0 +1,142 @@
<?php
declare(strict_types=1);
namespace App\Framework\Console\Progress;
use App\Framework\Console\ConsoleInput;
use App\Framework\Console\ConsoleOutputInterface;
use App\Framework\Console\ExitCode;
use ReflectionMethod;
/**
* Middleware that automatically provides progress tracking for commands
*/
final readonly class ProgressMiddleware
{
public function handle(
ConsoleInput $input,
ConsoleOutputInterface $output,
callable $next,
ReflectionMethod $method,
object $instance
): ExitCode {
// Check if command supports progress tracking
if ($instance instanceof ProgressAwareInterface) {
return $this->handleProgressAware($input, $output, $next, $instance);
}
// Check if method has progress attributes or hints
if ($this->shouldEnableProgress($method)) {
return $this->handleWithProgress($input, $output, $next, $method);
}
// Default: execute without progress tracking
return $next($input, $output);
}
private function handleProgressAware(
ConsoleInput $input,
ConsoleOutputInterface $output,
callable $next,
ProgressAwareInterface $instance
): ExitCode {
if (! $instance->supportsProgress()) {
return $next($input, $output);
}
$progressOutput = new ProgressAwareOutput($output);
$estimatedSteps = $instance->getEstimatedSteps();
if ($estimatedSteps > 0) {
// Create progress tracker for determinate operations
$progress = $progressOutput->createProgress($estimatedSteps, 'Operation');
$instance->setProgressTracker($progress);
} else {
// Create spinner for indeterminate operations
$spinner = $progressOutput->createSpinner('Working');
$instance->setProgressTracker($spinner);
}
return $next($input, $progressOutput);
}
private function handleWithProgress(
ConsoleInput $input,
ConsoleOutputInterface $output,
callable $next,
ReflectionMethod $method
): ExitCode {
// Wrap output with progress-aware output
$progressOutput = new ProgressAwareOutput($output);
// Try to create progress indicator from attribute
$progress = $this->createProgressFromAttribute($method, $progressOutput);
if ($progress && $progress instanceof ProgressInterface) {
// Store progress in output for command to use
$progressOutput->setCurrentProgress($progress);
}
return $next($input, $progressOutput);
}
private function shouldEnableProgress(ReflectionMethod $method): bool
{
// Check for LongRunning attribute
$longRunningAttributes = $method->getAttributes(LongRunning::class);
if (! empty($longRunningAttributes)) {
return true;
}
// Check for other progress-related attributes
$attributes = $method->getAttributes();
foreach ($attributes as $attribute) {
$attributeName = $attribute->getName();
if (str_contains($attributeName, 'Progress')) {
return true;
}
}
// Check method name patterns
$methodName = $method->getName();
$progressPatterns = [
'migrate', 'backup', 'restore', 'import', 'export',
'sync', 'generate', 'compile', 'process', 'analyze',
];
foreach ($progressPatterns as $pattern) {
if (str_contains(strtolower($methodName), $pattern)) {
return true;
}
}
return false;
}
private function createProgressFromAttribute(
ReflectionMethod $method,
ProgressAwareOutput $output
): ?ProgressInterface {
$longRunningAttributes = $method->getAttributes(LongRunning::class);
if (empty($longRunningAttributes)) {
return null;
}
/** @var LongRunning $longRunning */
$longRunning = $longRunningAttributes[0]->newInstance();
return match ($longRunning->progressType) {
ProgressType::TRACKER => $longRunning->estimatedSteps > 0
? $output->createProgress($longRunning->estimatedSteps, $longRunning->title)
: $output->createProgress(100, $longRunning->title),
ProgressType::SPINNER => $output->createSpinner($longRunning->title),
ProgressType::BAR => $output->createProgressBar(),
ProgressType::NONE => null,
ProgressType::AUTO => $longRunning->estimatedSteps > 0
? $output->createProgress($longRunning->estimatedSteps, $longRunning->title)
: $output->createSpinner($longRunning->title)
};
}
}

View File

@@ -0,0 +1,183 @@
<?php
declare(strict_types=1);
namespace App\Framework\Console\Progress;
use App\Framework\Console\ConsoleColor;
use App\Framework\Console\ConsoleOutputInterface;
/**
* Progress tracking for long-running console operations
*/
final class ProgressTracker implements ProgressInterface
{
private int $total;
private int $current = 0;
private float $startTime;
private ?string $currentTask = null;
private bool $finished = false;
private int $lastRenderedProgress = -1;
public function __construct(
private readonly ConsoleOutputInterface $output,
int $total,
private readonly string $title = 'Progress'
) {
$this->total = max(1, $total);
$this->startTime = microtime(true);
$this->render();
}
public function setTotal(int $total): void
{
$this->total = max(1, $total);
$this->render();
}
public function isFinished(): bool
{
return $this->finished;
}
public function advance(int $step = 1, ?string $task = null): void
{
if ($this->finished) {
return;
}
$this->current = min($this->current + $step, $this->total);
$this->currentTask = $task;
$this->render();
if ($this->current >= $this->total) {
$this->finish();
}
}
public function setProgress(int $current, ?string $task = null): void
{
if ($this->finished) {
return;
}
$this->current = max(0, min($current, $this->total));
$this->currentTask = $task;
$this->render();
if ($this->current >= $this->total) {
$this->finish();
}
}
public function setTask(string $task): void
{
$this->currentTask = $task;
$this->render();
}
public function finish(?string $message = null): void
{
if ($this->finished) {
return;
}
$this->current = $this->total;
$this->finished = true;
$this->render();
$this->output->write("\n");
if ($message) {
$this->output->writeLine("{$message}", ConsoleColor::GREEN);
}
}
public function getProgress(): float
{
return $this->total > 0 ? $this->current / $this->total : 0.0;
}
public function getElapsedTime(): float
{
return microtime(true) - $this->startTime;
}
public function getEstimatedTimeRemaining(): ?float
{
if ($this->current === 0) {
return null;
}
$elapsed = $this->getElapsedTime();
$rate = $this->current / $elapsed;
$remaining = $this->total - $this->current;
return $remaining / $rate;
}
private function render(): void
{
$progressPercent = (int) round($this->getProgress() * 100);
// Avoid unnecessary redraws
if ($progressPercent === $this->lastRenderedProgress && $this->currentTask === null) {
return;
}
$this->lastRenderedProgress = $progressPercent;
// Move cursor to beginning of line
$this->output->write("\r");
// Build progress bar
$barWidth = 30;
$completed = (int) round($barWidth * $this->getProgress());
$remaining = $barWidth - $completed;
$bar = str_repeat('█', $completed) . str_repeat('░', $remaining);
// Format time
$elapsed = $this->formatTime($this->getElapsedTime());
$eta = $this->getEstimatedTimeRemaining();
$etaStr = $eta ? $this->formatTime($eta) : '--:--';
// Build status line
$status = sprintf(
"%s [%s] %d%% (%d/%d) %s | ETA: %s",
$this->title,
$bar,
$progressPercent,
$this->current,
$this->total,
$elapsed,
$etaStr
);
$this->output->write($status, ConsoleColor::CYAN);
// Add current task if available
if ($this->currentTask) {
$this->output->write("\n");
$maxTaskLength = 60;
$truncatedTask = strlen($this->currentTask) > $maxTaskLength
? substr($this->currentTask, 0, $maxTaskLength - 3) . '...'
: $this->currentTask;
$this->output->write("Current: {$truncatedTask}", ConsoleColor::GRAY);
$this->output->write("\033[1A"); // Move cursor up one line
}
}
private function formatTime(float $seconds): string
{
$minutes = (int) ($seconds / 60);
$seconds = (int) ($seconds % 60);
return sprintf('%02d:%02d', $minutes, $seconds);
}
}

View File

@@ -0,0 +1,28 @@
<?php
declare(strict_types=1);
namespace App\Framework\Console\Progress;
/**
* Enum for different types of progress indicators
*/
enum ProgressType: string
{
case AUTO = 'auto';
case TRACKER = 'tracker';
case SPINNER = 'spinner';
case BAR = 'bar';
case NONE = 'none';
public function getDescription(): string
{
return match ($this) {
self::AUTO => 'Automatically select based on operation type',
self::TRACKER => 'Detailed progress tracker with time estimates',
self::SPINNER => 'Spinner for indeterminate operations',
self::BAR => 'Simple progress bar',
self::NONE => 'No progress indication'
};
}
}

View File

@@ -0,0 +1,125 @@
<?php
declare(strict_types=1);
namespace App\Framework\Console\Progress;
use App\Framework\Console\ConsoleColor;
use App\Framework\Console\ConsoleOutputInterface;
/**
* Spinner progress indicator for indeterminate operations
*/
final class SpinnerProgress implements ProgressInterface
{
private const SPINNER_CHARS = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
private int $step = 0;
private bool $finished = false;
private ?string $currentTask = null;
private float $startTime;
public function __construct(
private readonly ConsoleOutputInterface $output,
private readonly string $title = 'Working'
) {
$this->startTime = microtime(true);
$this->render();
}
public function setTotal(int $total): void
{
// Spinner doesn't use total
}
public function advance(int $step = 1, ?string $task = null): void
{
if ($this->finished) {
return;
}
$this->step += $step;
if ($task !== null) {
$this->currentTask = $task;
}
$this->render();
}
public function setProgress(int $current, ?string $task = null): void
{
if ($this->finished) {
return;
}
$this->step = $current;
if ($task !== null) {
$this->currentTask = $task;
}
$this->render();
}
public function setTask(string $task): void
{
$this->currentTask = $task;
$this->render();
}
public function getProgress(): float
{
return $this->finished ? 1.0 : 0.0;
}
public function finish(?string $message = null): void
{
if ($this->finished) {
return;
}
$this->finished = true;
$this->output->write("\r");
if ($message) {
$this->output->writeLine("{$message}", ConsoleColor::GREEN);
} else {
$this->output->writeLine("{$this->title} completed", ConsoleColor::GREEN);
}
}
public function isFinished(): bool
{
return $this->finished;
}
private function render(): void
{
if ($this->finished) {
return;
}
$spinner = self::SPINNER_CHARS[$this->step % count(self::SPINNER_CHARS)];
$elapsed = $this->formatTime(microtime(true) - $this->startTime);
$output = "{$spinner} {$this->title} ({$elapsed})";
if ($this->currentTask) {
$maxTaskLength = 40;
$truncatedTask = strlen($this->currentTask) > $maxTaskLength
? substr($this->currentTask, 0, $maxTaskLength - 3) . '...'
: $this->currentTask;
$output .= " - {$truncatedTask}";
}
$this->output->write("\r{$output}", ConsoleColor::CYAN);
}
private function formatTime(float $seconds): string
{
$minutes = (int) ($seconds / 60);
$seconds = (int) ($seconds % 60);
return sprintf('%02d:%02d', $minutes, $seconds);
}
}

View File

@@ -0,0 +1,19 @@
<?php
declare(strict_types=1);
namespace App\Framework\Console\Security\Attributes;
enum PermissionMode: string
{
case ANY = 'any'; // User needs ANY of the specified permissions
case ALL = 'all'; // User needs ALL of the specified permissions
public function check(array $userPermissions, array $requiredPermissions): bool
{
return match ($this) {
self::ANY => ! empty(array_intersect($userPermissions, $requiredPermissions)),
self::ALL => empty(array_diff($requiredPermissions, $userPermissions))
};
}
}

View File

@@ -0,0 +1,27 @@
<?php
declare(strict_types=1);
namespace App\Framework\Console\Security\Attributes;
use App\Framework\Console\Security\Permission;
use Attribute;
#[Attribute(Attribute::TARGET_METHOD | Attribute::TARGET_CLASS)]
final readonly class RequiresPermission
{
/**
* @param Permission|Permission[] $permissions
* @param PermissionMode $mode
*/
public function __construct(
public Permission|array $permissions,
public PermissionMode $mode = PermissionMode::ANY
) {
}
public function getPermissions(): array
{
return is_array($this->permissions) ? $this->permissions : [$this->permissions];
}
}

View File

@@ -0,0 +1,227 @@
<?php
declare(strict_types=1);
namespace App\Framework\Console\Security\Commands;
use App\Framework\Console\Attributes\ConsoleCommand;
use App\Framework\Console\ExitCode;
use App\Framework\Console\Input\ConsoleInput;
use App\Framework\Console\Layout\ResponsiveOutput;
use App\Framework\Console\Output\ConsoleOutput;
use App\Framework\Console\Security\Attributes\RequiresPermission;
use App\Framework\Console\Security\Permission;
use App\Framework\Console\Security\PermissionCategory;
use App\Framework\Console\Security\SecurityService;
final readonly class SecurityCommand
{
public function __construct(
private SecurityService $securityService
) {
}
#[ConsoleCommand(
name: 'security:whoami',
description: 'Show current user and permissions'
)]
public function whoami(ConsoleInput $input, ConsoleOutput $output): ExitCode
{
$user = $this->securityService->getCurrentUser();
$json = $input->hasOption('json');
if ($json) {
$output->writeLine(json_encode($user->toArray(), JSON_PRETTY_PRINT));
return ExitCode::SUCCESS;
}
$responsiveOutput = ResponsiveOutput::create($output);
$output->writeLine("<yellow>Current Console User</yellow>\n");
// Show user info
$userInfo = [
'ID' => $user->id,
'Name' => $user->name,
'Authenticated' => $this->securityService->isAuthenticated() ? 'Yes' : 'No',
'Admin' => $user->isAdmin() ? 'Yes' : 'No',
'Roles' => implode(', ', $user->roles),
];
$responsiveOutput->writeKeyValue($userInfo);
$output->writeLine('');
// Show permissions by category
$permissionsByCategory = $this->securityService->getUserPermissionsByCategory();
if (empty($permissionsByCategory)) {
$output->writeLine('<red>No permissions assigned</red>');
return ExitCode::SUCCESS;
}
$permissionData = [];
foreach ($permissionsByCategory as $categoryName => $permissions) {
$permissionData[$categoryName] = implode(', ', $permissions);
}
$responsiveOutput->writeKeyValue($permissionData, 'Permissions by Category:');
return ExitCode::SUCCESS;
}
#[ConsoleCommand(
name: 'security:permissions',
description: 'List all available permissions and categories'
)]
public function listPermissions(ConsoleInput $input, ConsoleOutput $output): ExitCode
{
$category = $input->getArgument('category');
$json = $input->hasOption('json');
if ($category) {
return $this->showCategoryPermissions($category, $output, $json);
}
if ($json) {
$data = [];
foreach (PermissionCategory::cases() as $cat) {
$data[$cat->value] = [
'description' => $cat->getDescription(),
'permissions' => array_map(function (Permission $p) {
return [
'name' => $p->value,
'description' => $p->getDescription(),
];
}, $cat->getPermissions()),
];
}
$output->writeLine(json_encode($data, JSON_PRETTY_PRINT));
return ExitCode::SUCCESS;
}
$output->writeLine("<yellow>Permission Categories and Permissions</yellow>\n");
foreach (PermissionCategory::cases() as $category) {
$output->writeLine("<green>{$category->value}</green> - {$category->getDescription()}");
$permissions = $category->getPermissions();
foreach ($permissions as $permission) {
$output->writeLine("{$permission->value} - {$permission->getDescription()}");
}
$output->writeLine('');
}
return ExitCode::SUCCESS;
}
#[ConsoleCommand(
name: 'security:check',
description: 'Check if current user has specific permission'
)]
public function checkPermission(ConsoleInput $input, ConsoleOutput $output): ExitCode
{
$permissionName = $input->getArgument('permission');
if (! $permissionName) {
$output->writeLine('<red>Permission name is required</red>');
return ExitCode::INVALID_ARGUMENTS;
}
try {
$permission = Permission::from($permissionName);
} catch (\ValueError) {
$output->writeLine("<red>Invalid permission: {$permissionName}</red>");
return ExitCode::INVALID_ARGUMENTS;
}
$hasPermission = $this->securityService->checkPermission($permission);
$user = $this->securityService->getCurrentUser();
if ($input->hasOption('json')) {
$output->writeLine(json_encode([
'user' => $user->id,
'permission' => $permission->value,
'has_permission' => $hasPermission,
], JSON_PRETTY_PRINT));
return ExitCode::SUCCESS;
}
$status = $hasPermission ? '<green>✓ GRANTED</green>' : '<red>✗ DENIED</red>';
$output->writeLine("Permission check for '{$permission->value}': {$status}");
if (! $hasPermission) {
$output->writeLine("User '{$user->name}' does not have the required permission.");
}
return $hasPermission ? ExitCode::SUCCESS : ExitCode::PERMISSION_DENIED;
}
#[ConsoleCommand(
name: 'security:test-admin',
description: 'Test command that requires admin permissions'
)]
#[RequiresPermission(Permission::ADMIN)]
public function testAdmin(ConsoleInput $input, ConsoleOutput $output): ExitCode
{
$output->writeLine('<green>✓ Admin access confirmed!</green>');
$output->writeLine('This command is only accessible to users with admin permissions.');
return ExitCode::SUCCESS;
}
#[ConsoleCommand(
name: 'security:test-analytics',
description: 'Test command that requires analytics permissions'
)]
#[RequiresPermission([Permission::ANALYTICS_READ, Permission::ANALYTICS_WRITE])]
public function testAnalytics(ConsoleInput $input, ConsoleOutput $output): ExitCode
{
$output->writeLine('<green>✓ Analytics access confirmed!</green>');
$output->writeLine('This command requires both analytics read and write permissions.');
return ExitCode::SUCCESS;
}
private function showCategoryPermissions(string $categoryName, ConsoleOutput $output, bool $json): ExitCode
{
try {
$category = PermissionCategory::from($categoryName);
} catch (\ValueError) {
$output->writeLine("<red>Invalid category: {$categoryName}</red>");
return ExitCode::INVALID_ARGUMENTS;
}
$permissions = $category->getPermissions();
if ($json) {
$data = [
'category' => $category->value,
'description' => $category->getDescription(),
'permissions' => array_map(function (Permission $p) {
return [
'name' => $p->value,
'description' => $p->getDescription(),
];
}, $permissions),
];
$output->writeLine(json_encode($data, JSON_PRETTY_PRINT));
return ExitCode::SUCCESS;
}
$output->writeLine("<yellow>Category: {$category->value}</yellow>");
$output->writeLine("Description: {$category->getDescription()}\n");
$output->writeLine('<green>Permissions:</green>');
foreach ($permissions as $permission) {
$output->writeLine("{$permission->value} - {$permission->getDescription()}");
}
return ExitCode::SUCCESS;
}
}

View File

@@ -0,0 +1,99 @@
<?php
declare(strict_types=1);
namespace App\Framework\Console\Security;
final readonly class ConsoleUser
{
/**
* @param string $id
* @param string $name
* @param Permission[] $permissions
* @param string[] $roles
*/
public function __construct(
public string $id,
public string $name,
public array $permissions = [],
public array $roles = []
) {
}
public function hasPermission(Permission $permission): bool
{
return in_array($permission, $this->permissions, true);
}
public function hasAnyPermission(array $permissions): bool
{
foreach ($permissions as $permission) {
if ($this->hasPermission($permission)) {
return true;
}
}
return false;
}
public function hasAllPermissions(array $permissions): bool
{
foreach ($permissions as $permission) {
if (! $this->hasPermission($permission)) {
return false;
}
}
return true;
}
public function hasRole(string $role): bool
{
return in_array($role, $this->roles, true);
}
public function isAdmin(): bool
{
return $this->hasPermission(Permission::ADMIN) ||
$this->hasPermission(Permission::SYSTEM_ADMIN);
}
public function getPermissionsByCategory(PermissionCategory $category): array
{
return array_filter(
$this->permissions,
fn (Permission $permission) => $permission->getCategory() === $category
);
}
public static function anonymous(): self
{
return new self(
id: 'anonymous',
name: 'Anonymous User',
permissions: [Permission::READ, Permission::EXECUTE],
roles: ['guest']
);
}
public static function admin(string $id = 'admin', string $name = 'Administrator'): self
{
return new self(
id: $id,
name: $name,
permissions: Permission::cases(), // All permissions
roles: ['admin', 'user']
);
}
public function toArray(): array
{
return [
'id' => $this->id,
'name' => $this->name,
'permissions' => array_map(fn (Permission $p) => $p->value, $this->permissions),
'roles' => $this->roles,
'is_admin' => $this->isAdmin(),
];
}
}

View File

@@ -0,0 +1,96 @@
<?php
declare(strict_types=1);
namespace App\Framework\Console\Security;
use App\Framework\Environment\Environment;
use App\Framework\Environment\EnvKey;
final readonly class EnvironmentUserProvider implements UserProvider
{
public function __construct(
private Environment $environment
) {
}
public function getCurrentUser(): ConsoleUser
{
$userId = $this->environment->get(EnvKey::CONSOLE_USER_ID);
$userName = $this->environment->get(EnvKey::CONSOLE_USER_NAME);
$userRole = $this->environment->get(EnvKey::CONSOLE_USER_ROLE, 'user');
if (! $userId || ! $userName) {
return ConsoleUser::anonymous();
}
$permissions = $this->getPermissionsForRole($userRole);
$roles = [$userRole];
return new ConsoleUser(
id: $userId,
name: $userName,
permissions: $permissions,
roles: $roles
);
}
public function getUserById(string $id): ?ConsoleUser
{
$currentUser = $this->getCurrentUser();
return $currentUser->id === $id ? $currentUser : null;
}
public function authenticateUser(array $credentials): ?ConsoleUser
{
// For environment-based auth, we just return the current user
// In a real implementation, this might validate API keys or tokens
return $this->getCurrentUser();
}
public function isAuthenticated(): bool
{
$userId = $this->environment->get(EnvKey::CONSOLE_USER_ID);
return ! empty($userId) && $userId !== 'anonymous';
}
private function getPermissionsForRole(string $role): array
{
return match (strtolower($role)) {
'admin', 'administrator' => Permission::cases(), // All permissions
'developer', 'dev' => [
Permission::READ,
Permission::WRITE,
Permission::EXECUTE,
Permission::ANALYTICS_READ,
Permission::HEALTH_CHECK,
Permission::DATABASE_READ,
Permission::PERFORMANCE_READ,
],
'operator', 'ops' => [
Permission::READ,
Permission::EXECUTE,
Permission::ANALYTICS_READ,
Permission::HEALTH_CHECK,
Permission::PERFORMANCE_READ,
Permission::CACHE_MANAGE,
],
'readonly', 'reader' => [
Permission::READ,
Permission::ANALYTICS_READ,
Permission::HEALTH_CHECK,
Permission::PERFORMANCE_READ,
],
'guest', 'anonymous' => [
Permission::READ,
Permission::EXECUTE,
],
default => [
Permission::READ,
Permission::EXECUTE,
]
};
}
}

View File

@@ -0,0 +1,120 @@
<?php
declare(strict_types=1);
namespace App\Framework\Console\Security\Middleware;
use App\Framework\Console\ExitCode;
use App\Framework\Console\Input\ConsoleInput;
use App\Framework\Console\Middleware\ConsoleMiddleware;
use App\Framework\Console\Output\ConsoleOutput;
use App\Framework\Console\Security\Attributes\RequiresPermission;
use App\Framework\Console\Security\SecurityException;
use App\Framework\Console\Security\SecurityService;
use App\Framework\Discovery\AttributeScanner;
use ReflectionMethod;
final readonly class SecurityMiddleware implements ConsoleMiddleware
{
public function __construct(
private SecurityService $securityService,
private AttributeScanner $attributeScanner,
private bool $enabled = true
) {
}
public function handle(ConsoleInput $input, ConsoleOutput $output, callable $next): ExitCode
{
if (! $this->enabled) {
return $next($input, $output);
}
try {
$this->checkCommandPermissions($input);
return $next($input, $output);
} catch (SecurityException $e) {
$output->writeLine("<red>Access Denied: {$e->getMessage()}</red>");
if ($output->isVerbose()) {
$output->writeLine("<yellow>Current user:</yellow> {$e->user->name} ({$e->user->id})");
$output->writeLine("<yellow>User permissions:</yellow> " .
implode(', ', array_map(fn ($p) => $p->value, $e->user->permissions)));
$required = is_array($e->requiredPermissions)
? array_map(fn ($p) => $p->value, $e->requiredPermissions)
: [$e->requiredPermissions->value];
$output->writeLine("<yellow>Required permissions:</yellow> " . implode(', ', $required));
}
return ExitCode::PERMISSION_DENIED;
}
}
private function checkCommandPermissions(ConsoleInput $input): void
{
$commandName = $this->extractCommandName($input);
if (! $commandName) {
return; // No command to check
}
// Find the command method and check for RequiresPermission attributes
$commandClasses = $this->attributeScanner->findMethodsWithAttribute(
\App\Framework\Console\Attributes\ConsoleCommand::class
);
foreach ($commandClasses as $class => $methods) {
foreach ($methods as $method) {
$commandAttribute = $this->attributeScanner->getMethodAttribute(
$class,
$method,
\App\Framework\Console\Attributes\ConsoleCommand::class
);
if ($commandAttribute && $commandAttribute->name === $commandName) {
$this->checkMethodPermissions($class, $method);
return;
}
}
}
}
private function checkMethodPermissions(string $class, string $method): void
{
$reflectionMethod = new ReflectionMethod($class, $method);
// Check method-level permissions
$methodAttributes = $reflectionMethod->getAttributes(RequiresPermission::class);
foreach ($methodAttributes as $attribute) {
$requiresPermission = $attribute->newInstance();
if (! $this->securityService->checkRequiresPermissionAttribute($requiresPermission)) {
throw new SecurityException(
"Insufficient permissions for command method",
$requiresPermission->getPermissions(),
$this->securityService->getCurrentUser()
);
}
}
// Check class-level permissions
$classAttributes = $reflectionMethod->getDeclaringClass()->getAttributes(RequiresPermission::class);
foreach ($classAttributes as $attribute) {
$requiresPermission = $attribute->newInstance();
if (! $this->securityService->checkRequiresPermissionAttribute($requiresPermission)) {
throw new SecurityException(
"Insufficient permissions for command class",
$requiresPermission->getPermissions(),
$this->securityService->getCurrentUser()
);
}
}
}
private function extractCommandName(ConsoleInput $input): ?string
{
$arguments = $input->getArguments();
return $arguments[1] ?? null; // First argument after script name
}
}

View File

@@ -0,0 +1,52 @@
<?php
declare(strict_types=1);
namespace App\Framework\Console\Security;
enum Permission: string
{
case READ = 'read';
case WRITE = 'write';
case EXECUTE = 'execute';
case ADMIN = 'admin';
case ANALYTICS_READ = 'analytics.read';
case ANALYTICS_WRITE = 'analytics.write';
case HEALTH_CHECK = 'health.check';
case SYSTEM_ADMIN = 'system.admin';
case DATABASE_READ = 'database.read';
case DATABASE_WRITE = 'database.write';
case PERFORMANCE_READ = 'performance.read';
case CACHE_MANAGE = 'cache.manage';
public function getDescription(): string
{
return match ($this) {
self::READ => 'Read access to commands and data',
self::WRITE => 'Write access to modify data',
self::EXECUTE => 'Execute commands',
self::ADMIN => 'Administrative access',
self::ANALYTICS_READ => 'Read analytics data',
self::ANALYTICS_WRITE => 'Modify analytics data',
self::HEALTH_CHECK => 'Run system health checks',
self::SYSTEM_ADMIN => 'System administration',
self::DATABASE_READ => 'Read database data',
self::DATABASE_WRITE => 'Modify database data',
self::PERFORMANCE_READ => 'Read performance metrics',
self::CACHE_MANAGE => 'Manage cache operations',
};
}
public function getCategory(): PermissionCategory
{
return match ($this) {
self::READ, self::WRITE, self::EXECUTE => PermissionCategory::BASIC,
self::ADMIN, self::SYSTEM_ADMIN => PermissionCategory::ADMIN,
self::ANALYTICS_READ, self::ANALYTICS_WRITE => PermissionCategory::ANALYTICS,
self::HEALTH_CHECK => PermissionCategory::MONITORING,
self::DATABASE_READ, self::DATABASE_WRITE => PermissionCategory::DATABASE,
self::PERFORMANCE_READ => PermissionCategory::PERFORMANCE,
self::CACHE_MANAGE => PermissionCategory::SYSTEM,
};
}
}

View File

@@ -0,0 +1,37 @@
<?php
declare(strict_types=1);
namespace App\Framework\Console\Security;
enum PermissionCategory: string
{
case BASIC = 'basic';
case ADMIN = 'admin';
case ANALYTICS = 'analytics';
case MONITORING = 'monitoring';
case DATABASE = 'database';
case PERFORMANCE = 'performance';
case SYSTEM = 'system';
public function getDescription(): string
{
return match ($this) {
self::BASIC => 'Basic command operations',
self::ADMIN => 'Administrative operations',
self::ANALYTICS => 'Analytics and reporting',
self::MONITORING => 'System monitoring',
self::DATABASE => 'Database operations',
self::PERFORMANCE => 'Performance analysis',
self::SYSTEM => 'System management',
};
}
public function getPermissions(): array
{
return array_filter(
Permission::cases(),
fn (Permission $permission) => $permission->getCategory() === $this
);
}
}

View File

@@ -0,0 +1,50 @@
<?php
declare(strict_types=1);
namespace App\Framework\Console\Security;
use App\Framework\Exception\ErrorCode;
use App\Framework\Exception\FrameworkException;
final class SecurityException extends FrameworkException
{
public function __construct(
string $message,
public readonly Permission|array $requiredPermissions,
public readonly ConsoleUser $user,
?\Throwable $previous = null
) {
parent::__construct($message, 403, $previous);
$this->errorCode = ErrorCode::AUTH_INSUFFICIENT_PERMISSIONS;
$this->withData([
'user_id' => $user->id,
'user_name' => $user->name,
'user_permissions' => array_map(fn (Permission $p) => $p->value, $user->permissions),
'required_permissions' => is_array($requiredPermissions)
? array_map(fn (Permission $p) => $p->value, $requiredPermissions)
: [$requiredPermissions->value],
]);
}
public static function accessDenied(Permission $permission, ConsoleUser $user): self
{
return new self(
"Access denied. Required permission: {$permission->value}",
$permission,
$user
);
}
public static function insufficientPermissions(array $permissions, ConsoleUser $user, string $mode = 'any'): self
{
$permissionNames = array_map(fn (Permission $p) => $p->value, $permissions);
return new self(
"Access denied. Required {$mode} of permissions: " . implode(', ', $permissionNames),
$permissions,
$user
);
}
}

View File

@@ -0,0 +1,44 @@
<?php
declare(strict_types=1);
namespace App\Framework\Console\Security;
use App\Framework\Console\Security\Middleware\SecurityMiddleware;
use App\Framework\DI\Attributes\Initializer;
use App\Framework\DI\Container;
use App\Framework\Environment\Environment;
use App\Framework\Environment\EnvKey;
final readonly class SecurityInitializer
{
public function __construct(
private Environment $environment
) {
}
#[Initializer]
public function initialize(Container $container): void
{
// Register user provider
$container->bind(UserProvider::class, EnvironmentUserProvider::class);
// Register security service
$container->singleton(SecurityService::class, function (Container $container) {
return new SecurityService(
$container->get(UserProvider::class)
);
});
// Register security middleware
$container->singleton(SecurityMiddleware::class, function (Container $container) {
$enabled = $this->environment->getBool(EnvKey::CONSOLE_SECURITY_ENABLED, true);
return new SecurityMiddleware(
securityService: $container->get(SecurityService::class),
attributeScanner: $container->get(\App\Framework\Discovery\AttributeScanner::class),
enabled: $enabled
);
});
}
}

View File

@@ -0,0 +1,108 @@
<?php
declare(strict_types=1);
namespace App\Framework\Console\Security;
use App\Framework\Console\Security\Attributes\PermissionMode;
use App\Framework\Console\Security\Attributes\RequiresPermission;
final readonly class SecurityService
{
public function __construct(
private UserProvider $userProvider
) {
}
public function getCurrentUser(): ConsoleUser
{
return $this->userProvider->getCurrentUser();
}
public function checkPermission(Permission $permission): bool
{
$user = $this->getCurrentUser();
return $user->hasPermission($permission);
}
public function checkPermissions(array $permissions, PermissionMode $mode = PermissionMode::ANY): bool
{
$user = $this->getCurrentUser();
$userPermissions = array_map(fn (Permission $p) => $p->value, $user->permissions);
$requiredPermissions = array_map(fn (Permission $p) => $p->value, $permissions);
return $mode->check($userPermissions, $requiredPermissions);
}
public function checkRequiresPermissionAttribute(RequiresPermission $attribute): bool
{
return $this->checkPermissions($attribute->getPermissions(), $attribute->mode);
}
public function requirePermission(Permission $permission): void
{
if (! $this->checkPermission($permission)) {
throw new SecurityException(
"Access denied. Required permission: {$permission->value}",
$permission,
$this->getCurrentUser()
);
}
}
public function requirePermissions(array $permissions, PermissionMode $mode = PermissionMode::ANY): void
{
if (! $this->checkPermissions($permissions, $mode)) {
$permissionNames = array_map(fn (Permission $p) => $p->value, $permissions);
$modeText = $mode === PermissionMode::ALL ? 'all' : 'any';
throw new SecurityException(
"Access denied. Required {$modeText} of permissions: " . implode(', ', $permissionNames),
$permissions,
$this->getCurrentUser()
);
}
}
public function isAuthenticated(): bool
{
return $this->userProvider->isAuthenticated();
}
public function isAdmin(): bool
{
return $this->getCurrentUser()->isAdmin();
}
public function canAccessCategory(PermissionCategory $category): bool
{
$user = $this->getCurrentUser();
$categoryPermissions = $category->getPermissions();
return $user->hasAnyPermission($categoryPermissions);
}
public function getAccessibleCategories(): array
{
return array_filter(
PermissionCategory::cases(),
fn (PermissionCategory $category) => $this->canAccessCategory($category)
);
}
public function getUserPermissionsByCategory(): array
{
$user = $this->getCurrentUser();
$result = [];
foreach (PermissionCategory::cases() as $category) {
$permissions = $user->getPermissionsByCategory($category);
if (! empty($permissions)) {
$result[$category->value] = array_map(fn (Permission $p) => $p->value, $permissions);
}
}
return $result;
}
}

View File

@@ -0,0 +1,16 @@
<?php
declare(strict_types=1);
namespace App\Framework\Console\Security;
interface UserProvider
{
public function getCurrentUser(): ConsoleUser;
public function getUserById(string $id): ?ConsoleUser;
public function authenticateUser(array $credentials): ?ConsoleUser;
public function isAuthenticated(): bool;
}

Some files were not shown because too many files have changed in this diff Show More