- Add comprehensive health check system with multiple endpoints - Add Prometheus metrics endpoint - Add production logging configurations (5 strategies) - Add complete deployment documentation suite: * QUICKSTART.md - 30-minute deployment guide * DEPLOYMENT_CHECKLIST.md - Printable verification checklist * DEPLOYMENT_WORKFLOW.md - Complete deployment lifecycle * PRODUCTION_DEPLOYMENT.md - Comprehensive technical reference * production-logging.md - Logging configuration guide * ANSIBLE_DEPLOYMENT.md - Infrastructure as Code automation * README.md - Navigation hub * DEPLOYMENT_SUMMARY.md - Executive summary - Add deployment scripts and automation - Add DEPLOYMENT_PLAN.md - Concrete plan for immediate deployment - Update README with production-ready features All production infrastructure is now complete and ready for deployment.
341 lines
10 KiB
PHP
341 lines
10 KiB
PHP
<?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);
|
|
$camelCaseName = $this->kebabToCamelCase($name);
|
|
$parsed['options'][$camelCaseName] = $this->parseValue($camelCaseName, $value);
|
|
|
|
return $index + 1;
|
|
}
|
|
|
|
// Convert kebab-case to camelCase for definition lookup
|
|
$camelCaseName = $this->kebabToCamelCase($optionPart);
|
|
|
|
// Handle --option value or --flag
|
|
$definition = $this->findDefinitionByName($camelCaseName);
|
|
|
|
if ($definition && $definition->type === ArgumentType::BOOLEAN) {
|
|
// Boolean flag - use camelCase name
|
|
$parsed['flags'][$camelCaseName] = 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'][$camelCaseName] = true;
|
|
|
|
return $index + 1;
|
|
}
|
|
|
|
$parsed['options'][$camelCaseName] = $this->parseValue($camelCaseName, $arguments[$index + 1]);
|
|
|
|
return $index + 2;
|
|
}
|
|
|
|
/**
|
|
* Convert kebab-case to camelCase
|
|
* Example: dry-run -> dryRun
|
|
*/
|
|
private function kebabToCamelCase(string $kebab): string
|
|
{
|
|
return lcfirst(str_replace('-', '', ucwords($kebab, '-')));
|
|
}
|
|
|
|
/**
|
|
* Parse short option (-o or -o value or -abc)
|
|
*/
|
|
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();
|
|
}
|
|
}
|