├── .codecov.yml ├── .editorconfig ├── .gitattributes ├── .gitignore ├── .php-cs-fixer.dist.php ├── CHANGELOG.md ├── LICENSE ├── README.md ├── composer.json ├── phpstan-baseline.neon ├── phpstan.neon ├── phpunit.xml.dist └── src ├── Attribute ├── Argument.php └── Option.php ├── AutoName.php ├── CommandRunner.php ├── Configuration └── DocblockConfiguration.php ├── ConfigureWithAttributes.php ├── ConfigureWithDocblocks.php ├── EventListener └── CommandSummarySubscriber.php ├── IO.php ├── Invokable.php ├── InvokableCommand.php ├── InvokableServiceCommand.php ├── RunsCommands.php └── RunsProcesses.php /.codecov.yml: -------------------------------------------------------------------------------- 1 | coverage: 2 | status: 3 | project: 4 | default: 5 | target: auto 6 | threshold: 1% 7 | patch: 8 | default: 9 | target: auto 10 | threshold: 50% 11 | 12 | comment: false 13 | github_checks: 14 | annotations: false 15 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | charset = utf-8 5 | end_of_line = lf 6 | indent_size = 4 7 | indent_style = space 8 | insert_final_newline = true 9 | trim_trailing_whitespace = true 10 | 11 | [*.yml] 12 | indent_size = 2 13 | -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | /.github export-ignore 2 | /tests export-ignore 3 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /composer.lock 2 | /phpunit.xml 3 | /vendor/ 4 | /build/ 5 | /var/ 6 | /.php-cs-fixer.cache 7 | /.phpunit.result.cache 8 | -------------------------------------------------------------------------------- /.php-cs-fixer.dist.php: -------------------------------------------------------------------------------- 1 | createUser($email, $password, $roles); 29 | 30 | $this->runCommand('another:command'); 31 | $this->runProcess('/some/script'); 32 | 33 | $io->success('Created user.'); 34 | } 35 | } 36 | ``` 37 | 38 | ```bash 39 | bin/console create:user kbond p4ssw0rd -r ROLE_EDITOR -r ROLE_ADMIN 40 | 41 | [OK] Created user. 42 | 43 | // Duration: < 1 sec, Peak Memory: 10.0 MiB 44 | ``` 45 | 46 | ## Installation 47 | 48 | ```bash 49 | composer require zenstruck/console-extra 50 | ``` 51 | 52 | ## Usage 53 | 54 | This library is a set of modular features that can be used separately or in combination. 55 | 56 | > [!NOTE] 57 | > To reduce command boilerplate even further, it is recommended to create an abstract base command for your 58 | > app that enables all the features you desire. Then have all your app's commands extend this. 59 | 60 | ### `IO` 61 | 62 | This is a helper object that extends `SymfonyStyle` and implements `InputInterface` (so it implements 63 | `InputInterface`, `OutputInterface`, and `StyleInterface`). 64 | 65 | ```php 66 | use Zenstruck\Console\IO; 67 | 68 | $io = new IO($input, $output); 69 | 70 | $io->getOption('role'); // InputInterface 71 | $io->writeln('a line'); // OutputInterface 72 | $io->success('Created.'); // StyleInterface 73 | 74 | // additional methods 75 | $io->input(); // get the "wrapped" input 76 | $io->output(); // get the "wrapped" output 77 | ``` 78 | 79 | On its own, it isn't very special, but it can be auto-injected into [`Invokable`](#invokable) commands. 80 | 81 | ### `InvokableCommand` 82 | 83 | Extend this class to remove the need for extending `Command::execute()` and just inject what your need 84 | into your command's `__invoke()` method. The following are parameters that can be auto-injected: 85 | 86 | - [`Zenstruck\Console\IO`](#io) 87 | - `Symfony\Component\Console\Style\StyleInterface` 88 | - `Symfony\Component\Console\Input\InputInterface` 89 | - `Symfony\Component\Console\Input\OutputInterface` 90 | - *arguments* (parameter name must match argument name or use the `Zenstruck\Console\Attribute\Argument` attribute) 91 | - *options* (parameter name must match option name or use the `Zenstruck\Console\Attribute\Option` attribute) 92 | 93 | ```php 94 | use Symfony\Component\Console\Command\Command; 95 | use Symfony\Component\Console\Input\InputArgument; 96 | use Symfony\Component\Console\Input\InputOption; 97 | use Zenstruck\Console\InvokableCommand; 98 | use Zenstruck\Console\IO; 99 | 100 | class MyCommand extends InvokableCommand 101 | { 102 | // $username/$roles are the argument/option defined below 103 | public function __invoke(IO $io, string $username, array $roles) 104 | { 105 | $io->success('created.'); 106 | 107 | // even if you don't inject IO, it's available as a method: 108 | $this->io(); // IO 109 | } 110 | 111 | public function configure(): void 112 | { 113 | $this 114 | ->addArgument('username', InputArgument::REQUIRED) 115 | ->addOption('roles', mode: InputOption::VALUE_IS_ARRAY) 116 | ; 117 | } 118 | } 119 | ``` 120 | 121 | You can auto-inject the "raw" input/output: 122 | 123 | ```php 124 | public function __invoke(IO $io, InputInterface $input, OutputInterface $output) 125 | ``` 126 | 127 | No return type (or `void`) implies a `0` status code. You can return an integer if you want to change this: 128 | 129 | ```php 130 | public function __invoke(IO $io): int 131 | { 132 | return $success ? 0 : 1; 133 | } 134 | ``` 135 | 136 | ### `InvokableServiceCommand` 137 | 138 | If using the Symfony Framework, you can take [`InvokableCommand`](#invokablecommand) to the next level by 139 | auto-injecting services into `__invoke()`. This allows your commands to behave like 140 | [Invokable Service Controllers](https://symfony.com/doc/current/controller/service.html#invokable-controllers) 141 | (with `controller.service_arguments`). Instead of a _Request_, you inject [`IO`](#io). 142 | 143 | Have your commands extend `InvokableServiceCommand` and ensure they are auto-wired/configured. 144 | 145 | ```php 146 | use App\Service\UserManager; 147 | use Psr\Log\LoggerInterface; 148 | use Zenstruck\Console\InvokableServiceCommand; 149 | use Zenstruck\Console\IO; 150 | 151 | class CreateUserCommand extends InvokableServiceCommand 152 | { 153 | public function __invoke(IO $io, UserManager $userManager, LoggerInterface $logger): void 154 | { 155 | // access container parameters 156 | $environment = $this->parameter('kernel.environment'); 157 | 158 | // ... 159 | } 160 | } 161 | ``` 162 | 163 | #### Inject with DI Attributes 164 | 165 | You can use any 166 | [DI attribute](https://symfony.com/doc/current/reference/attributes.html#dependency-injection) on 167 | your `__invoke()` parameters: 168 | 169 | ```php 170 | use Symfony\Component\DependencyInjection\Attribute\Autowire; 171 | use Symfony\Component\DependencyInjection\Attribute\Target; 172 | use Symfony\Component\DependencyInjection\Attribute\TaggedIterator; 173 | use Zenstruck\Console\InvokableServiceCommand; 174 | 175 | class SomeCommand extends InvokableServiceCommand 176 | { 177 | public function __invoke( 178 | #[Autowire('@some.service.id')] 179 | SomeService $service, 180 | 181 | #[Autowire('%kernel.environment%')] 182 | string $environment, 183 | 184 | #[Target('githubApi')] 185 | HttpClientInterface $httpClient, 186 | 187 | #[TaggedIterator('app.handler')] 188 | iterable $handlers, 189 | ): void { 190 | // ... 191 | } 192 | } 193 | ``` 194 | 195 | ### Configure with Attributes 196 | 197 | Your commands that extend [`InvokableCommand`](#invokablecommand) or [`InvokableServiceCommand`](#invokableservicecommand) 198 | can configure arguments and options with attributes: 199 | 200 | ```php 201 | use Symfony\Component\Console\Command\Command; 202 | use Symfony\Component\Console\Input\InputArgument; 203 | use Zenstruck\Console\Attribute\Argument; 204 | use Zenstruck\Console\Attribute\Option; 205 | use Zenstruck\Console\InvokableCommand; 206 | 207 | #[Argument('arg1', description: 'Argument 1 description', mode: InputArgument::REQUIRED)] 208 | #[Argument('arg2', description: 'Argument 1 description')] 209 | #[Argument('arg3', suggestions: ['suggestion1', 'suggestion2'])] // for auto-completion 210 | #[Argument('arg4', suggestions: 'suggestionsForArg4')] // use a method on the command to get suggestions 211 | #[Option('option1', description: 'Option 1 description')] 212 | #[Option('option2', suggestions: ['suggestion1', 'suggestion2'])] // for auto-completion 213 | #[Option('option3', suggestions: 'suggestionsForOption3')] // use a method on the command to get suggestions 214 | class MyCommand extends InvokableCommand 215 | { 216 | // ... 217 | 218 | private function suggestionsForArg4(): array 219 | { 220 | return ['suggestion3', 'suggestion4']; 221 | } 222 | 223 | private function suggestionsForOption3(): array 224 | { 225 | return ['suggestion3', 'suggestion4']; 226 | } 227 | } 228 | ``` 229 | 230 | #### Invokable Attributes 231 | 232 | Instead of defining at the class level, you can add the `Option`/`Argument` attributes directly to your 233 | `__invoke()` parameters to define _and_ inject arguments/options: 234 | 235 | ```php 236 | use Symfony\Component\Console\Attribute\AsCommand; 237 | use Symfony\Component\Console\Command\Command; 238 | use Zenstruck\Console\Attribute\Argument; 239 | use Zenstruck\Console\Attribute\Option; 240 | use Zenstruck\Console\InvokableCommand; 241 | 242 | #[AsCommand('my:command')] 243 | class MyCommand extends InvokableCommand 244 | { 245 | public function __invoke( 246 | #[Argument] 247 | string $username, // defined as a required argument (username) 248 | 249 | #[Argument] 250 | string $password = 'p4ssw0rd', // defined as an optional argument (password) with a default (p4ssw0rd) 251 | 252 | #[Option(name: 'role', shortcut: 'r', suggestions: ['ROLE_EDITOR', 'ROLE_REVIEWER'])] 253 | array $roles = [], // defined as an array option that requires values (--r|role[]) 254 | 255 | #[Option(name: 'super-admin')] 256 | bool $superAdmin = false, // defined as a "value-less" option (--super-admin) 257 | 258 | #[Option] 259 | ?bool $force = null, // defined as a "negatable" option (--force/--no-force) 260 | 261 | #[Option] 262 | ?string $name = null, // defined as an option that requires a value (--name=) 263 | ): void { 264 | // ... 265 | } 266 | } 267 | ``` 268 | 269 | > [!NOTE] 270 | > Option/Argument _modes_ and _defaults_ are detected from the parameter's type-hint/default value 271 | > and cannot be defined on the attribute. 272 | 273 | ### `CommandRunner` 274 | 275 | A `CommandRunner` object is available to simplify running commands anywhere (ie controller): 276 | 277 | ```php 278 | use Zenstruck\Console\CommandRunner; 279 | 280 | /** @var \Symfony\Component\Console\Command\Command $command */ 281 | 282 | CommandRunner::for($command)->run(); // int (the status after running the command) 283 | 284 | // pass arguments 285 | CommandRunner::for($command, 'arg --opt')->run(); // int 286 | ``` 287 | 288 | If the application is available, you can use it to run commands: 289 | 290 | ```php 291 | use Zenstruck\Console\CommandRunner; 292 | 293 | /** @var \Symfony\Component\Console\Application $application */ 294 | 295 | CommandRunner::from($application, 'my:command')->run(); 296 | 297 | // pass arguments/options 298 | CommandRunner::from($application, 'my:command arg --opt')->run(); // int 299 | ``` 300 | 301 | If your command is interactive, you can pass inputs: 302 | 303 | ```php 304 | use Zenstruck\Console\CommandRunner; 305 | 306 | /** @var \Symfony\Component\Console\Application $application */ 307 | 308 | CommandRunner::from($application, 'my:command')->run([ 309 | 'foo', // input 1 310 | '', // input 2 () 311 | 'y', // input 3 312 | ]); 313 | ``` 314 | 315 | By default, output is suppressed, you can optionally capture the output: 316 | 317 | ```php 318 | use Zenstruck\Console\CommandRunner; 319 | 320 | /** @var \Symfony\Component\Console\Application $application */ 321 | 322 | $output = new \Symfony\Component\Console\Output\BufferedOutput(); 323 | 324 | CommandRunner::from($application, 'my:command') 325 | ->withOutput($output) // any OutputInterface 326 | ->run() 327 | ; 328 | 329 | $output->fetch(); // string (the output) 330 | ``` 331 | 332 | #### `RunsCommands` 333 | 334 | You can give your [Invokable Commands](#invokablecommand) the ability to run other commands (defined 335 | in the application) by using the `RunsCommands` trait. These _sub-commands_ will use the same 336 | _output_ as the parent command. 337 | 338 | ```php 339 | use Symfony\Component\Console\Command; 340 | use Zenstruck\Console\InvokableCommand; 341 | use Zenstruck\Console\RunsCommands; 342 | 343 | class MyCommand extends InvokableCommand 344 | { 345 | use RunsCommands; 346 | 347 | public function __invoke(): void 348 | { 349 | $this->runCommand('another:command'); // int (sub-command's run status) 350 | 351 | // pass arguments/options 352 | $this->runCommand('another:command arg --opt'); 353 | 354 | // pass inputs for interactive commands 355 | $this->runCommand('another:command', [ 356 | 'foo', // input 1 357 | '', // input 2 () 358 | 'y', // input 3 359 | ]) 360 | } 361 | } 362 | ``` 363 | 364 | ### `RunsProcesses` 365 | 366 | You can give your [Invokable Commands](#invokablecommand) the ability to run other processes (`symfony/process` required) 367 | by using the `RunsProcesses` trait. Standard output from the process is hidden by default but can be shown by 368 | passing `-v` to the _parent command_. Error output is always shown. If the process fails, a `\RuntimeException` 369 | is thrown. 370 | 371 | ```php 372 | use Symfony\Component\Console\Command; 373 | use Symfony\Component\Process\Process; 374 | use Zenstruck\Console\InvokableCommand; 375 | use Zenstruck\Console\RunsProcesses; 376 | 377 | class MyCommand extends InvokableCommand 378 | { 379 | use RunsProcesses; 380 | 381 | public function __invoke(): void 382 | { 383 | $this->runProcess('/some/script'); 384 | 385 | // construct with array 386 | $this->runProcess(['/some/script', 'arg1', 'arg1']); 387 | 388 | // for full control, pass a Process itself 389 | $this->runProcess( 390 | Process::fromShellCommandline('/some/script') 391 | ->setTimeout(900) 392 | ->setWorkingDirectory('/') 393 | ); 394 | } 395 | } 396 | ``` 397 | 398 | ### `CommandSummarySubscriber` 399 | 400 | Add this event subscriber to your `Application`'s event dispatcher to display a summary after every command is run. 401 | The summary includes the duration of the command and peak memory usage. 402 | 403 | If using Symfony, configure it as a service to enable: 404 | 405 | ```yaml 406 | # config/packages/zenstruck_console_extra.yaml 407 | 408 | services: 409 | Zenstruck\Console\EventListener\CommandSummarySubscriber: 410 | autoconfigure: true 411 | ``` 412 | 413 | > [!NOTE] 414 | > This will display a summary after every registered command runs. 415 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "zenstruck/console-extra", 3 | "description": "A modular set of features to reduce configuration boilerplate for your Symfony commands.", 4 | "homepage": "https://github.com/zenstruck/console-extra", 5 | "type": "library", 6 | "license": "MIT", 7 | "keywords": ["console", "symfony", "command"], 8 | "authors": [ 9 | { 10 | "name": "Kevin Bond", 11 | "email": "kevinbond@gmail.com" 12 | } 13 | ], 14 | "require": { 15 | "php": ">=8.1", 16 | "symfony/console": "^6.4|^7.0", 17 | "symfony/deprecation-contracts": "^2.2|^3.0", 18 | "zenstruck/callback": "^1.4.2" 19 | }, 20 | "require-dev": { 21 | "phpdocumentor/reflection-docblock": "^5.2", 22 | "phpstan/phpstan": "^1.4", 23 | "phpunit/phpunit": "^9.5", 24 | "symfony/framework-bundle": "^6.4|^7.0", 25 | "symfony/phpunit-bridge": "^6.2|^7.0", 26 | "symfony/process": "^6.4|^7.0", 27 | "symfony/var-dumper": "^6.4|^7.0", 28 | "zenstruck/console-test": "^1.4" 29 | }, 30 | "conflict": { 31 | "symfony/service-contracts": "<3.2" 32 | }, 33 | "config": { 34 | "preferred-install": "dist", 35 | "sort-packages": true 36 | }, 37 | "autoload": { 38 | "psr-4": { "Zenstruck\\Console\\": "src/" } 39 | }, 40 | "autoload-dev": { 41 | "psr-4": { "Zenstruck\\Console\\Tests\\": "tests/" } 42 | }, 43 | "minimum-stability": "dev", 44 | "prefer-stable": true 45 | } 46 | -------------------------------------------------------------------------------- /phpstan-baseline.neon: -------------------------------------------------------------------------------- 1 | parameters: 2 | ignoreErrors: 3 | - 4 | message: "#^Method Zenstruck\\\\Console\\\\Attribute\\\\Argument\\:\\:__construct\\(\\) has parameter \\$default with no value type specified in iterable type array\\.$#" 5 | count: 1 6 | path: src/Attribute/Argument.php 7 | 8 | - 9 | message: "#^Method Zenstruck\\\\Console\\\\Attribute\\\\Option\\:\\:__construct\\(\\) has parameter \\$default with no value type specified in iterable type array\\.$#" 10 | count: 1 11 | path: src/Attribute/Option.php 12 | 13 | - 14 | message: "#^Method Zenstruck\\\\Console\\\\Attribute\\\\Option\\:\\:__construct\\(\\) has parameter \\$shortcut with no value type specified in iterable type array\\.$#" 15 | count: 1 16 | path: src/Attribute/Option.php 17 | 18 | -------------------------------------------------------------------------------- /phpstan.neon: -------------------------------------------------------------------------------- 1 | includes: 2 | - phpstan-baseline.neon 3 | 4 | parameters: 5 | level: 8 6 | paths: 7 | - src 8 | -------------------------------------------------------------------------------- /phpunit.xml.dist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | ./tests/ 22 | 23 | 24 | 25 | 26 | 27 | ./src/ 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | -------------------------------------------------------------------------------- /src/Attribute/Argument.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Zenstruck\Console\Attribute; 13 | 14 | use Symfony\Component\Console\Command\Command; 15 | use Symfony\Component\Console\Completion\CompletionInput; 16 | use Symfony\Component\Console\Input\InputArgument; 17 | 18 | use function Symfony\Component\String\s; 19 | 20 | /** 21 | * @author Kevin Bond 22 | */ 23 | #[\Attribute(\Attribute::TARGET_CLASS | \Attribute::TARGET_PARAMETER | \Attribute::IS_REPEATABLE)] 24 | class Argument 25 | { 26 | /** 27 | * @see InputArgument::__construct() 28 | * 29 | * @param string[]|string $suggestions 30 | */ 31 | public function __construct( 32 | public ?string $name = null, 33 | private ?int $mode = null, 34 | private string $description = '', 35 | private string|bool|int|float|array|null $default = null, 36 | private array|string $suggestions = [], 37 | ) { 38 | } 39 | 40 | /** 41 | * @internal 42 | * 43 | * @return mixed[]|null 44 | */ 45 | final public static function parseParameter(\ReflectionParameter $parameter, Command $command): ?array 46 | { 47 | if (!$attributes = $parameter->getAttributes(self::class, \ReflectionAttribute::IS_INSTANCEOF)) { 48 | return null; 49 | } 50 | 51 | if (\count($attributes) > 1) { 52 | throw new \LogicException(\sprintf('%s cannot be repeated when used as a parameter attribute.', self::class)); 53 | } 54 | 55 | /** @var self $value */ 56 | $value = $attributes[0]->newInstance(); 57 | 58 | if (!$value->name && $parameter->name !== s($parameter->name)->snake()->replace('_', '-')->toString()) { 59 | trigger_deprecation('zenstruck/console-extra', '1.4', 'Argument names will default to kebab-case in 2.0. Specify the name in #[Argument] explicitly to remove this deprecation.'); 60 | } 61 | 62 | $value->name ??= $parameter->name; 63 | 64 | if ($value->mode) { 65 | throw new \LogicException(\sprintf('Cannot use $mode when using %s as a parameter attribute, this is inferred from the parameter\'s type.', self::class)); 66 | } 67 | 68 | if ($value->default) { 69 | throw new \LogicException(\sprintf('Cannot use $default when using %s as a parameter attribute, this is inferred from the parameter\'s default value.', self::class)); 70 | } 71 | 72 | $value->mode = $parameter->isDefaultValueAvailable() || $parameter->allowsNull() ? InputArgument::OPTIONAL : InputArgument::REQUIRED; 73 | 74 | if ($parameter->getType() instanceof \ReflectionNamedType && 'array' === $parameter->getType()->getName()) { 75 | $value->mode |= InputArgument::IS_ARRAY; 76 | } 77 | 78 | if ($parameter->isDefaultValueAvailable()) { 79 | $value->default = $parameter->getDefaultValue(); 80 | } 81 | 82 | return $value->values($command); 83 | } 84 | 85 | /** 86 | * @internal 87 | * 88 | * @return mixed[] 89 | */ 90 | final public function values(Command $command): array 91 | { 92 | if (!$this->name) { 93 | throw new \LogicException(\sprintf('A $name is required when using %s as a command class attribute.', self::class)); 94 | } 95 | 96 | $suggestions = $this->suggestions; 97 | 98 | if (\is_string($suggestions)) { 99 | $suggestions = \Closure::bind( 100 | fn(CompletionInput $i) => $this->{$suggestions}($i), 101 | $command, 102 | $command, 103 | ); 104 | } 105 | 106 | return [$this->name, $this->mode, $this->description, $this->default, $suggestions]; 107 | } 108 | } 109 | -------------------------------------------------------------------------------- /src/Attribute/Option.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Zenstruck\Console\Attribute; 13 | 14 | use Symfony\Component\Console\Command\Command; 15 | use Symfony\Component\Console\Completion\CompletionInput; 16 | use Symfony\Component\Console\Input\InputOption; 17 | 18 | use function Symfony\Component\String\s; 19 | 20 | /** 21 | * @author Kevin Bond 22 | */ 23 | #[\Attribute(\Attribute::TARGET_CLASS | \Attribute::TARGET_PARAMETER | \Attribute::IS_REPEATABLE)] 24 | class Option 25 | { 26 | /** 27 | * @see InputOption::__construct() 28 | * 29 | * @param string[]|string $suggestions 30 | */ 31 | public function __construct( 32 | public ?string $name = null, 33 | private string|array|null $shortcut = null, 34 | private ?int $mode = null, 35 | private string $description = '', 36 | private string|bool|int|float|array|null $default = null, 37 | private array|string $suggestions = [], 38 | ) { 39 | } 40 | 41 | /** 42 | * @internal 43 | * 44 | * @return mixed[]|null 45 | */ 46 | final public static function parseParameter(\ReflectionParameter $parameter, Command $command): ?array 47 | { 48 | if (!$attributes = $parameter->getAttributes(self::class, \ReflectionAttribute::IS_INSTANCEOF)) { 49 | return null; 50 | } 51 | 52 | if (\count($attributes) > 1) { 53 | throw new \LogicException(\sprintf('%s cannot be repeated when used as a parameter attribute.', self::class)); 54 | } 55 | 56 | /** @var self $value */ 57 | $value = $attributes[0]->newInstance(); 58 | 59 | if (!$value->name && $parameter->name !== s($parameter->name)->snake()->replace('_', '-')->toString()) { 60 | trigger_deprecation('zenstruck/console-extra', '1.4', 'Argument names will default to kebab-case in 2.0. Specify the name in #[Option] explicitly to remove this deprecation.'); 61 | } 62 | 63 | $value->name ??= $parameter->name; 64 | 65 | if ($value->mode) { 66 | throw new \LogicException(\sprintf('Cannot use $mode when using %s as a parameter attribute, this is inferred from the parameter\'s type.', self::class)); 67 | } 68 | 69 | if ($value->default) { 70 | throw new \LogicException(\sprintf('Cannot use $default when using %s as a parameter attribute, this is inferred from the parameter\'s default value.', self::class)); 71 | } 72 | 73 | $name = $parameter->getType() instanceof \ReflectionNamedType ? $parameter->getType()->getName() : null; 74 | 75 | $value->mode = match ($name) { 76 | 'array' => InputOption::VALUE_IS_ARRAY | InputOption::VALUE_REQUIRED, 77 | 'bool' => $parameter->allowsNull() ? InputOption::VALUE_NEGATABLE : InputOption::VALUE_NONE, 78 | default => InputOption::VALUE_REQUIRED, 79 | }; 80 | 81 | if ($value->mode ^ InputOption::VALUE_NONE && $parameter->isDefaultValueAvailable()) { 82 | $value->default = $parameter->getDefaultValue(); 83 | } 84 | 85 | return $value->values($command); 86 | } 87 | 88 | /** 89 | * @internal 90 | * 91 | * @return mixed[] 92 | */ 93 | final public function values(Command $command): array 94 | { 95 | if (!$this->name) { 96 | throw new \LogicException(\sprintf('A $name is required when using %s as a command class attribute.', self::class)); 97 | } 98 | 99 | $suggestions = $this->suggestions; 100 | 101 | if (\is_string($suggestions)) { 102 | $suggestions = \Closure::bind( 103 | fn(CompletionInput $i) => $this->{$suggestions}($i), 104 | $command, 105 | $command, 106 | ); 107 | } 108 | 109 | return [$this->name, $this->shortcut, $this->mode, $this->description, $this->default, $suggestions]; 110 | } 111 | } 112 | -------------------------------------------------------------------------------- /src/AutoName.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Zenstruck\Console; 13 | 14 | use function Symfony\Component\String\u; 15 | 16 | /** 17 | * Uses the class name to auto-generate the command name (with "app:" prefix). 18 | * 19 | * @example GenerateUserReportCommand => app:generate-user-report 20 | * 21 | * @author Kevin Bond 22 | * 23 | * @deprecated 24 | */ 25 | trait AutoName 26 | { 27 | public static function getDefaultName(): string 28 | { 29 | trigger_deprecation('zenstruck/console-extra', '1.2', 'The %s trait is deprecated and will be removed in 2.0. There is no replacement.', AutoName::class); 30 | 31 | if ($name = parent::getDefaultName()) { 32 | return $name; 33 | } 34 | 35 | $class = new \ReflectionClass(static::class); 36 | 37 | if ($class->isAnonymous()) { 38 | throw new \LogicException(\sprintf('Using "%s" with an anonymous class is not supported.', __TRAIT__)); 39 | } 40 | 41 | return u($class->getShortName()) 42 | ->snake() 43 | ->replace('_', '-') 44 | ->beforeLast('-command') 45 | ->prepend(static::autoNamePrefix()) 46 | ->toString(); 47 | } 48 | 49 | /** 50 | * Override to set your own prefix (or none). 51 | */ 52 | protected static function autoNamePrefix(): string 53 | { 54 | return 'app:'; 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /src/CommandRunner.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Zenstruck\Console; 13 | 14 | use Symfony\Component\Console\Application; 15 | use Symfony\Component\Console\Command\Command; 16 | use Symfony\Component\Console\Input\ArrayInput; 17 | use Symfony\Component\Console\Input\Input; 18 | use Symfony\Component\Console\Input\StringInput; 19 | use Symfony\Component\Console\Output\NullOutput; 20 | use Symfony\Component\Console\Output\OutputInterface; 21 | 22 | /** 23 | * @author Kevin Bond 24 | */ 25 | final class CommandRunner 26 | { 27 | private Command $command; 28 | private Input $input; 29 | private ?OutputInterface $output = null; 30 | 31 | private function __construct(Command $command, ?Input $input = null) 32 | { 33 | $this->command = $command; 34 | $this->input = $input ?? new ArrayInput([]); 35 | } 36 | 37 | /** 38 | * @param string|array|null $arguments arg --opt 39 | * ['arg' => 'value', 'opt' => true] 40 | */ 41 | public static function for(Command $command, $arguments = null): self 42 | { 43 | $input = null; 44 | 45 | if (null !== $arguments) { 46 | $input = \is_string($arguments) ? new StringInput($arguments) : new ArrayInput($arguments); 47 | } 48 | 49 | return new self($command, $input); 50 | } 51 | 52 | /** 53 | * @param string|class-string|array $cli my:command arg --opt 54 | * MyCommand::class 55 | * ['command' => 'my:command', 'arg' => 'value'] 56 | */ 57 | public static function from(Application $application, $cli): self 58 | { 59 | if (!$command = \is_string($cli) ? \explode(' ', $cli)[0] : $cli['command'] ?? null) { 60 | throw new \InvalidArgumentException('Unknown command. When using an array for $cli, the "command" key must be set.'); 61 | } 62 | 63 | foreach ($application->all() as $commandObject) { 64 | if ($command === $commandObject::class) { 65 | return new self($commandObject); 66 | } 67 | } 68 | 69 | return self::for($application->find($command), $cli); 70 | } 71 | 72 | /** 73 | * @immutable 74 | */ 75 | public function withOutput(OutputInterface $output): self 76 | { 77 | $self = clone $this; 78 | $self->output = $output; 79 | 80 | return $self; 81 | } 82 | 83 | /** 84 | * @param string[] $inputs Interactive inputs to use for the command 85 | */ 86 | public function run(array $inputs = []): int 87 | { 88 | return $this->command->run($this->createInput($inputs), $this->output ?? new NullOutput()); 89 | } 90 | 91 | /** 92 | * @param string[] $inputs 93 | */ 94 | private function createInput(array $inputs): Input 95 | { 96 | $input = clone $this->input; 97 | $input->setInteractive(false); 98 | 99 | if (!$inputs) { 100 | return $input; 101 | } 102 | 103 | if (!$stream = \fopen('php://memory', 'r+', false)) { 104 | throw new \RuntimeException('Failed to open stream.'); 105 | } 106 | 107 | foreach ($inputs as $value) { 108 | \fwrite($stream, $value.\PHP_EOL); 109 | } 110 | 111 | \rewind($stream); 112 | 113 | $input->setStream($stream); 114 | $input->setInteractive(true); 115 | 116 | return $input; 117 | } 118 | } 119 | -------------------------------------------------------------------------------- /src/Configuration/DocblockConfiguration.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Zenstruck\Console\Configuration; 13 | 14 | use phpDocumentor\Reflection\DocBlock; 15 | use phpDocumentor\Reflection\DocBlockFactory; 16 | use Symfony\Component\Console\Command\Command; 17 | use Symfony\Component\Console\Input\InputArgument; 18 | use Symfony\Component\Console\Input\InputOption; 19 | use Zenstruck\Console\ConfigureWithAttributes; 20 | use Zenstruck\Console\ConfigureWithDocblocks; 21 | 22 | use function Symfony\Component\String\u; 23 | 24 | /** 25 | * @internal 26 | * 27 | * @author Kevin Bond 28 | * 29 | * @template T of Command 30 | * 31 | * @deprecated 32 | */ 33 | final class DocblockConfiguration 34 | { 35 | /** @var array> */ 36 | private static array $instances = []; 37 | private static DocBlockFactory $factory; 38 | private static bool $supportsLazy; 39 | 40 | /** @var \ReflectionClass */ 41 | private \ReflectionClass $class; 42 | private DocBlock $docblock; 43 | 44 | /** @var string[] */ 45 | private array $command; 46 | 47 | /** 48 | * @param class-string $class 49 | */ 50 | private function __construct(string $class) 51 | { 52 | $this->class = new \ReflectionClass($class); 53 | $this->docblock = self::factory()->create($this->class->getDocComment() ?: ' '); // hack to allow empty docblock 54 | 55 | trigger_deprecation('zenstruck/console-extra', '1.1', 'The %s trait is deprecated and will be removed in 2.0. Use %s instead.', ConfigureWithDocblocks::class, ConfigureWithAttributes::class); 56 | } 57 | 58 | /** 59 | * @param class-string $class 60 | * 61 | * @return static 62 | */ 63 | public static function for(string $class): self 64 | { 65 | return self::$instances[$class] ??= new self($class); 66 | } 67 | 68 | public static function supportsLazy(): bool 69 | { 70 | // only 53+ has this method and therefore supports lazy hidden/aliases 71 | return self::$supportsLazy ??= \method_exists(Command::class, 'getDefaultDescription'); 72 | } 73 | 74 | public function name(): ?string 75 | { 76 | $name = $this->command()[0] ?? null; 77 | 78 | if (!$name || self::supportsLazy()) { 79 | // in 5.3+ let Symfony handle lazy aliases/hidden syntax 80 | return $name; 81 | } 82 | 83 | return \explode('|', \ltrim($name, '|'))[0]; 84 | } 85 | 86 | public function description(): ?string 87 | { 88 | return u($this->docblock->getSummary())->replace("\n", ' ')->toString() ?: null; 89 | } 90 | 91 | public function help(): ?string 92 | { 93 | return (string) $this->docblock->getDescription() ?: null; 94 | } 95 | 96 | /** 97 | * @return \Traversable> 98 | */ 99 | public function arguments(): \Traversable 100 | { 101 | $command = $this->command(); 102 | 103 | \array_shift($command); 104 | 105 | // parse arguments from @command tag 106 | foreach ($command as $item) { 107 | if (u($item)->startsWith('--')) { 108 | continue; 109 | } 110 | 111 | try { 112 | yield self::parseArgument($item); 113 | } catch (\LogicException $e) { 114 | throw new \LogicException(\sprintf('"@command" tag has a malformed argument ("%s") in "%s".', $item, $this->class->name)); 115 | } 116 | } 117 | 118 | // parse @argument tags 119 | foreach ($this->docblock->getTagsByName('argument') as $tag) { 120 | try { 121 | yield self::parseArgument($tag); 122 | } catch (\LogicException $e) { 123 | throw new \LogicException(\sprintf('Argument tag "%s" on "%s" is malformed.', $tag->render(), $this->class->name)); 124 | } 125 | } 126 | } 127 | 128 | /** 129 | * @return \Traversable> 130 | */ 131 | public function options(): \Traversable 132 | { 133 | $command = $this->command(); 134 | 135 | \array_shift($command); 136 | 137 | // parse options from @command tag 138 | foreach ($command as $item) { 139 | $item = u($item); 140 | 141 | if (!$item->startsWith('--')) { 142 | continue; 143 | } 144 | 145 | try { 146 | yield self::parseOption($item->after('--')); 147 | } catch (\LogicException $e) { 148 | throw new \LogicException(\sprintf('"@command" tag has a malformed option ("%s") in "%s".', $item, $this->class->name)); 149 | } 150 | } 151 | 152 | // parse @option tags 153 | foreach ($this->docblock->getTagsByName('option') as $tag) { 154 | try { 155 | yield self::parseOption($tag); 156 | } catch (\LogicException $e) { 157 | throw new \LogicException(\sprintf('Option tag "%s" on "%s" is malformed.', $tag->render(), $this->class->name)); 158 | } 159 | } 160 | } 161 | 162 | public function hidden(): bool 163 | { 164 | if ($this->docblock->hasTag('hidden')) { 165 | return true; 166 | } 167 | 168 | // in <5.3 if command name starts with "|", mark as lazy (ie "|my:command") 169 | return !self::supportsLazy() && u($this->command()[0] ?? '')->startsWith('|'); 170 | } 171 | 172 | /** 173 | * @return \Traversable 174 | */ 175 | public function aliases(): \Traversable 176 | { 177 | foreach ($this->docblock->getTagsByName('alias') as $alias) { 178 | yield (string) $alias; 179 | } 180 | 181 | if (self::supportsLazy()) { 182 | // in 5.3+, let Symfony handle alias syntax 183 | return; 184 | } 185 | 186 | // parse aliases from command name (ie "my:command|alias1|alias2") 187 | $aliases = \explode('|', \ltrim($this->command()[0] ?? '', '|')); 188 | 189 | \array_shift($aliases); 190 | 191 | foreach (\array_filter($aliases) as $alias) { 192 | yield $alias; 193 | } 194 | } 195 | 196 | /** 197 | * @return string[] 198 | */ 199 | private function command(): array 200 | { 201 | if (isset($this->command)) { 202 | return $this->command; 203 | } 204 | 205 | if (empty($tags = $this->docblock->getTagsByName('command'))) { 206 | return $this->command = []; 207 | } 208 | 209 | if (\count($tags) > 1) { 210 | throw new \LogicException(\sprintf('"@command" tag can only be used once in "%s".', $this->class->name)); 211 | } 212 | 213 | if (!\preg_match_all('#[\w:?\-|=\[\]]+("[^"]*")?#', $tags[0], $matches)) { 214 | throw new \LogicException(\sprintf('"@command" tag must have a value in "%s".', $this->class->name)); 215 | } 216 | 217 | return $this->command = $matches[0]; 218 | } 219 | 220 | /** 221 | * @return array 222 | */ 223 | private static function parseArgument(string $value): array 224 | { 225 | if (\preg_match('#^(\?)?([\w\-]+)(=([\w\-]+))?(\s+(.+))?$#', $value, $matches)) { 226 | $default = $matches[4] ?? null; 227 | 228 | return [ 229 | $matches[2], // name 230 | $matches[1] || $default ? InputArgument::OPTIONAL : InputArgument::REQUIRED, // mode 231 | $matches[6] ?? '', // description 232 | $default ?: null, // default 233 | ]; 234 | } 235 | 236 | // try matching with quoted default 237 | if (\preg_match('#^([\w\-]+)="([^"]*)"(\s+(.+))?$#', $value, $matches)) { 238 | return [ 239 | $matches[1], // name 240 | InputArgument::OPTIONAL, // mode 241 | $matches[4] ?? '', // description 242 | $matches[2], // default 243 | ]; 244 | } 245 | 246 | // try matching array argument 247 | if (\preg_match('#^(\?)?([\w\-]+)\[\](\s+(.+))?$#', $value, $matches)) { 248 | return [ 249 | $matches[2], // name 250 | InputArgument::IS_ARRAY | ($matches[1] ? InputArgument::OPTIONAL : InputArgument::REQUIRED), // mode 251 | $matches[4] ?? '', // description 252 | ]; 253 | } 254 | 255 | throw new \LogicException(\sprintf('Malformed argument: "%s".', $value)); 256 | } 257 | 258 | /** 259 | * @return array 260 | */ 261 | private static function parseOption(string $value): array 262 | { 263 | if (\preg_match('#^(([\w\-]+)\|)?([\w\-]+)(=([\w\-]+)?)?(\s+(.+))?$#', $value, $matches)) { 264 | $default = $matches[5] ?? null; 265 | 266 | return [ 267 | $matches[3], // name 268 | $matches[2] ?: null, // shortcut 269 | $matches[4] ?? null ? InputOption::VALUE_REQUIRED : InputOption::VALUE_NONE, // mode 270 | $matches[7] ?? '', // description 271 | $default ?: null, // default 272 | ]; 273 | } 274 | 275 | // try matching with quoted default 276 | if (\preg_match('#^(([\w\-]+)\|)?([\w\-]+)="([^"]*)"(\s+(.+))?$#', $value, $matches)) { 277 | return [ 278 | $matches[3], // name 279 | $matches[2] ?: null, // shortcut 280 | InputOption::VALUE_REQUIRED, // mode 281 | $matches[6] ?? '', // description 282 | $matches[4], // default 283 | ]; 284 | } 285 | 286 | // try matching array option 287 | if (\preg_match('#^(([\w\-]+)\|)?([\w\-]+)\[\](\s+(.+))?$#', $value, $matches)) { 288 | return [ 289 | $matches[3], // name 290 | $matches[2] ?: null, // shortcut 291 | InputOption::VALUE_IS_ARRAY | InputOption::VALUE_REQUIRED, // mode 292 | $matches[5] ?? '', // description 293 | ]; 294 | } 295 | 296 | throw new \LogicException(\sprintf('Malformed option: "%s".', $value)); 297 | } 298 | 299 | private static function factory(): DocBlockFactory 300 | { 301 | return self::$factory ??= DocBlockFactory::createInstance(); // @phpstan-ignore-line 302 | } 303 | } 304 | -------------------------------------------------------------------------------- /src/ConfigureWithAttributes.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Zenstruck\Console; 13 | 14 | use Zenstruck\Console\Attribute\Argument; 15 | use Zenstruck\Console\Attribute\Option; 16 | 17 | /** 18 | * @author Kevin Bond 19 | */ 20 | trait ConfigureWithAttributes 21 | { 22 | protected function configure(): void 23 | { 24 | if (InvokableCommand::class !== self::class && $this instanceof InvokableCommand) { // @phpstan-ignore-line 25 | trigger_deprecation('zenstruck/console-extra', '1.4', 'You can safely remove "%s" from "%s".', __TRAIT__, $this::class); 26 | } 27 | 28 | $class = new \ReflectionClass($this); 29 | 30 | foreach ($class->getAttributes(Argument::class, \ReflectionAttribute::IS_INSTANCEOF) as $attribute) { 31 | $this->addArgument(...$attribute->newInstance()->values($this)); 32 | } 33 | 34 | foreach ($class->getAttributes(Option::class, \ReflectionAttribute::IS_INSTANCEOF) as $attribute) { 35 | $this->addOption(...$attribute->newInstance()->values($this)); 36 | } 37 | 38 | try { 39 | $parameters = (new \ReflectionClass(static::class))->getMethod('__invoke')->getParameters(); 40 | } catch (\ReflectionException) { 41 | return; // not using Invokable 42 | } 43 | 44 | foreach ($parameters as $parameter) { 45 | if ($args = Argument::parseParameter($parameter, $this)) { 46 | $this->addArgument(...$args); 47 | 48 | continue; 49 | } 50 | 51 | if ($args = Option::parseParameter($parameter, $this)) { 52 | $this->addOption(...$args); 53 | } 54 | } 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /src/ConfigureWithDocblocks.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Zenstruck\Console; 13 | 14 | use Zenstruck\Console\Configuration\DocblockConfiguration; 15 | 16 | /** 17 | * Fully configure your command using docblock annotations. 18 | * 19 | * - Uses the command class' docblock "summary" to auto-generate 20 | * the command description. 21 | * - Uses the command class' docblock "description" to auto-generate 22 | * the command help. 23 | * - @command tag to set the command name and optionally arguments/options 24 | * - If no @command tag, uses {@see AutoName} 25 | * - @hidden tag to mark the command as "hidden" 26 | * - @alias tags to add command aliases 27 | * - @argument tag to add command arguments 28 | * - @option tag to add command options 29 | * 30 | * Examples: 31 | * 32 | * @command app:my:command 33 | * @alias alias1 34 | * @alias alias2 35 | * @hidden 36 | * 37 | * @argument arg1 First argument is required 38 | * @argument ?arg2 Second argument is optional 39 | * @argument arg3=default Third argument is optional with a default value 40 | * @argument arg4="default with space" Forth argument is "optional" with a default value (with spaces) 41 | * @argument ?arg5[] Fifth argument is an optional array 42 | * 43 | * @option option1 First option (no value) 44 | * @option option2= Second option (value required) 45 | * @option option3=default Third option with default value 46 | * @option option4="default with space" Forth option with "default" value (with spaces) 47 | * @option o|option5[] Fifth option is an array with a shortcut (-o) 48 | * 49 | * You can pack all the above into a single @command tag. It is 50 | * recommended to only do this for very simple commands as it isn't 51 | * as explicit as splitting the tags out. 52 | * 53 | * @command |app:my:command|alias1|alias2 arg1 ?arg2 arg3=default arg4="default with space" ?arg5[] --option1 --option2= --option3=default --option4="default with space" --o|option5[] 54 | * 55 | * @author Kevin Bond 56 | * 57 | * @deprecated 58 | */ 59 | trait ConfigureWithDocblocks 60 | { 61 | use AutoName { getDefaultName as autoDefaultName; } 62 | 63 | public static function getDefaultName(): string 64 | { 65 | $name = parent::getDefaultName() ?: self::docblock()->name() ?: self::autoDefaultName(); 66 | 67 | if (!DocblockConfiguration::supportsLazy()) { 68 | return $name; 69 | } 70 | 71 | if ('|' !== $name[0] && self::docblock()->hidden()) { 72 | $name = '|'.$name; 73 | } 74 | 75 | return \implode('|', \array_merge([$name], \iterator_to_array(self::docblock()->aliases()))); 76 | } 77 | 78 | public static function getDefaultDescription(): ?string 79 | { 80 | if (\method_exists(parent::class, 'getDefaultDescription') && $description = parent::getDefaultDescription()) { 81 | return $description; 82 | } 83 | 84 | return self::docblock()->description(); 85 | } 86 | 87 | /** 88 | * Required to auto-generate the command description from the 89 | * docblock in symfony/console < 5.3. 90 | * 91 | * @see getDefaultDescription() 92 | */ 93 | public function getDescription(): string 94 | { 95 | return parent::getDescription() ?: (string) self::getDefaultDescription(); 96 | } 97 | 98 | public function getHelp(): string 99 | { 100 | return parent::getHelp() ?: (string) self::docblock()->help(); 101 | } 102 | 103 | protected function configure(): void 104 | { 105 | foreach (self::docblock()->arguments() as $argument) { 106 | $this->addArgument(...$argument); 107 | } 108 | 109 | foreach (self::docblock()->options() as $option) { 110 | $this->addOption(...$option); 111 | } 112 | 113 | if (!DocblockConfiguration::supportsLazy() && self::docblock()->hidden()) { 114 | $this->setHidden(true); 115 | } 116 | 117 | if (!DocblockConfiguration::supportsLazy()) { 118 | $this->setAliases(\iterator_to_array(self::docblock()->aliases())); 119 | } 120 | } 121 | 122 | /** 123 | * @internal 124 | * 125 | * @return DocblockConfiguration 126 | */ 127 | private static function docblock(): DocblockConfiguration 128 | { 129 | return DocblockConfiguration::for(static::class); 130 | } 131 | } 132 | -------------------------------------------------------------------------------- /src/EventListener/CommandSummarySubscriber.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Zenstruck\Console\EventListener; 13 | 14 | use Symfony\Component\Console\ConsoleEvents; 15 | use Symfony\Component\Console\Event\ConsoleCommandEvent; 16 | use Symfony\Component\Console\Event\ConsoleTerminateEvent; 17 | use Symfony\Component\Console\Helper\Helper; 18 | use Symfony\Component\Console\Input\InputInterface; 19 | use Symfony\Component\Console\Output\OutputInterface; 20 | use Symfony\Component\EventDispatcher\EventSubscriberInterface; 21 | 22 | /** 23 | * Adds the duration and peak memory used to the end of the command's output. 24 | * 25 | * @example // Duration: 5 secs, Peak Memory: 10.0 MiB 26 | * 27 | * @author Kevin Bond 28 | */ 29 | class CommandSummarySubscriber implements EventSubscriberInterface 30 | { 31 | private int $start; 32 | 33 | final public static function getSubscribedEvents(): array 34 | { 35 | return [ 36 | ConsoleEvents::COMMAND => 'preCommand', 37 | ConsoleEvents::TERMINATE => 'postCommand', 38 | ]; 39 | } 40 | 41 | final public function preCommand(ConsoleCommandEvent $event): void 42 | { 43 | if (!$this->isSupported($event)) { 44 | return; 45 | } 46 | 47 | $this->start = \time(); 48 | } 49 | 50 | final public function postCommand(ConsoleTerminateEvent $event): void 51 | { 52 | if (!isset($this->start)) { 53 | return; 54 | } 55 | 56 | $this->summarize($event->getInput(), $event->getOutput(), \time() - $this->start); 57 | } 58 | 59 | /** 60 | * Override to customize when a summary should be displayed. 61 | */ 62 | protected function isSupported(ConsoleCommandEvent $event): bool 63 | { 64 | return true; 65 | } 66 | 67 | /** 68 | * Override to customize the summary output. 69 | * 70 | * @param int $duration Command duration in seconds 71 | */ 72 | protected function summarize(InputInterface $input, OutputInterface $output, int $duration): void 73 | { 74 | $output->writeln(\sprintf(" // Duration: %s, Peak Memory: %s\n", 75 | Helper::formatTime($duration), 76 | Helper::formatMemory(\memory_get_peak_usage(true)), 77 | )); 78 | } 79 | } 80 | -------------------------------------------------------------------------------- /src/IO.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Zenstruck\Console; 13 | 14 | use Symfony\Component\Console\Helper\ProgressBar; 15 | use Symfony\Component\Console\Helper\Table; 16 | use Symfony\Component\Console\Input\InputDefinition; 17 | use Symfony\Component\Console\Input\InputInterface; 18 | use Symfony\Component\Console\Output\ConsoleOutputInterface; 19 | use Symfony\Component\Console\Output\ConsoleSectionOutput; 20 | use Symfony\Component\Console\Output\OutputInterface; 21 | use Symfony\Component\Console\Style\SymfonyStyle; 22 | 23 | /** 24 | * SymfonyStyle implementation that is also an implementation of 25 | * {@see InputInterface} to help simplify commands. 26 | * 27 | * @author Kevin Bond 28 | */ 29 | class IO extends SymfonyStyle implements InputInterface 30 | { 31 | private InputInterface $input; 32 | private OutputInterface $output; 33 | 34 | final public function __construct(InputInterface $input, OutputInterface $output) 35 | { 36 | parent::__construct($this->input = $input, $this->output = $output); 37 | } 38 | 39 | public function __toString(): string 40 | { 41 | if (!\method_exists($this->input, '__toString')) { 42 | // InputInterface extends \Stringable in 6.1+ 43 | return 'Unsupported...'; 44 | } 45 | 46 | return $this->input->__toString(); 47 | } 48 | 49 | /** 50 | * Helper for {@see ProgressBar::iterate()}. 51 | * 52 | * @param mixed[] $iterable 53 | * 54 | * @return mixed[] 55 | */ 56 | public function progressIterate(iterable $iterable, ?int $max = null): iterable 57 | { 58 | if (\method_exists(parent::class, 'progressIterate')) { 59 | // SymfonyStyle 5.4+ includes this method 60 | yield from parent::progressIterate($iterable, $max); 61 | 62 | return; 63 | } 64 | 65 | yield from $this->createProgressBar()->iterate($iterable, $max); 66 | 67 | $this->newLine(2); 68 | } 69 | 70 | /** 71 | * Create a styled table. Uses {@see ConsoleSectionOutput} if available. 72 | */ 73 | public function createTable(): Table 74 | { 75 | if (\method_exists(parent::class, 'createTable')) { 76 | // SymfonyStyle 5.4+ includes this method 77 | return parent::createTable(); 78 | } 79 | 80 | $style = clone Table::getStyleDefinition('symfony-style-guide'); 81 | $style->setCellHeaderFormat('%s'); 82 | 83 | return (new Table($this->output instanceof ConsoleOutputInterface ? $this->output->section() : $this->output)) 84 | ->setStyle($style) 85 | ; 86 | } 87 | 88 | public function input(): InputInterface 89 | { 90 | return $this->input; 91 | } 92 | 93 | public function output(): OutputInterface 94 | { 95 | return $this->output; 96 | } 97 | 98 | /** 99 | * Override to ensure an instance of IO is returned. 100 | */ 101 | public function getErrorStyle(): self 102 | { 103 | return new static($this->input, $this->getErrorOutput()); 104 | } 105 | 106 | /** 107 | * Alias for {@see getArgument()}. 108 | */ 109 | public function argument(string $name): mixed 110 | { 111 | return $this->getArgument($name); 112 | } 113 | 114 | /** 115 | * Alias for {@see getOption()}. 116 | */ 117 | public function option(string $name): mixed 118 | { 119 | return $this->getOption($name); 120 | } 121 | 122 | public function getFirstArgument(): ?string 123 | { 124 | return $this->input->getFirstArgument(); 125 | } 126 | 127 | /** 128 | * @param string|string[] $values 129 | * @param bool $onlyParams 130 | */ 131 | public function hasParameterOption($values, $onlyParams = false): bool 132 | { 133 | return $this->input->hasParameterOption($values, $onlyParams); 134 | } 135 | 136 | /** 137 | * @param string|string[] $values 138 | * @param mixed $default 139 | * @param bool $onlyParams 140 | */ 141 | public function getParameterOption($values, $default = false, $onlyParams = false): mixed 142 | { 143 | return $this->input->getParameterOption($values, $default, $onlyParams); 144 | } 145 | 146 | public function bind(InputDefinition $definition): void 147 | { 148 | $this->input->bind($definition); 149 | } 150 | 151 | public function validate(): void 152 | { 153 | $this->input->validate(); 154 | } 155 | 156 | /** 157 | * @return array 158 | */ 159 | public function getArguments(): array 160 | { 161 | return $this->input->getArguments(); 162 | } 163 | 164 | /** 165 | * @param string $name 166 | */ 167 | public function getArgument($name): mixed 168 | { 169 | return $this->input->getArgument($name); 170 | } 171 | 172 | /** 173 | * @param string $name 174 | * @param mixed $value 175 | */ 176 | public function setArgument($name, $value): void 177 | { 178 | $this->input->setArgument($name, $value); 179 | } 180 | 181 | /** 182 | * @param string $name 183 | */ 184 | public function hasArgument($name): bool 185 | { 186 | return $this->input->hasArgument($name); 187 | } 188 | 189 | /** 190 | * @return array 191 | */ 192 | public function getOptions(): array 193 | { 194 | return $this->input->getOptions(); 195 | } 196 | 197 | /** 198 | * @param string $name 199 | */ 200 | public function getOption($name): mixed 201 | { 202 | return $this->input->getOption($name); 203 | } 204 | 205 | /** 206 | * @param string $name 207 | * @param mixed $value 208 | */ 209 | public function setOption($name, $value): void 210 | { 211 | $this->input->setOption($name, $value); 212 | } 213 | 214 | /** 215 | * @param string $name 216 | */ 217 | public function hasOption($name): bool 218 | { 219 | return $this->input->hasOption($name); 220 | } 221 | 222 | public function isInteractive(): bool 223 | { 224 | return $this->input->isInteractive(); 225 | } 226 | 227 | /** 228 | * @param bool $interactive 229 | */ 230 | public function setInteractive($interactive): void 231 | { 232 | $this->input->setInteractive($interactive); 233 | } 234 | } 235 | -------------------------------------------------------------------------------- /src/Invokable.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Zenstruck\Console; 13 | 14 | use Symfony\Component\Console\Input\InputInterface; 15 | use Symfony\Component\Console\Output\OutputInterface; 16 | use Zenstruck\Callback; 17 | use Zenstruck\Callback\Argument; 18 | use Zenstruck\Callback\Parameter; 19 | use Zenstruck\Console\Attribute\Argument as ConsoleArgument; 20 | use Zenstruck\Console\Attribute\Option; 21 | 22 | /** 23 | * @author Kevin Bond 24 | */ 25 | trait Invokable 26 | { 27 | /** 28 | * @internal 29 | * 30 | * @var array 31 | */ 32 | private array $argumentFactories = []; 33 | 34 | private IO $io; 35 | 36 | /** 37 | * @param callable(InputInterface,OutputInterface):mixed $factory 38 | */ 39 | public function addArgumentFactory(?string $type, callable $factory): self 40 | { 41 | $this->argumentFactories[$type] = $factory; 42 | 43 | return $this; 44 | } 45 | 46 | protected function initialize(InputInterface $input, OutputInterface $output): void 47 | { 48 | if (InvokableCommand::class !== self::class && $this instanceof InvokableCommand) { // @phpstan-ignore-line 49 | trigger_deprecation('zenstruck/console-extra', '1.4', 'You can safely remove "%s" from "%s".', __TRAIT__, $this::class); 50 | } 51 | 52 | $this->io = ($this->argumentFactories[IO::class] ?? static fn() => new IO($input, $output))($input, $output); 53 | } 54 | 55 | protected function execute(InputInterface $input, OutputInterface $output): int 56 | { 57 | $parameters = \array_map( 58 | function(\ReflectionParameter $parameter) use ($input, $output) { 59 | $type = $parameter->getType(); 60 | 61 | if (null !== $type && !$type instanceof \ReflectionNamedType) { 62 | throw new \LogicException("Union/Intersection types not yet supported for \"{$parameter}\"."); 63 | } 64 | 65 | if ($type instanceof \ReflectionNamedType && isset($this->argumentFactories[$type->getName()])) { 66 | return Parameter::typed( 67 | $type->getName(), 68 | Parameter::factory(fn() => $this->argumentFactories[$type->getName()]($input, $output)), 69 | Argument::EXACT, 70 | ); 71 | } 72 | 73 | if (isset($this->argumentFactories[$key = 'invoke:'.$parameter->name])) { 74 | return $this->argumentFactories[$key](); 75 | } 76 | 77 | if (!$type || $type->isBuiltin()) { 78 | $name = $parameter->name; 79 | 80 | if ($attr = $parameter->getAttributes(ConsoleArgument::class, \ReflectionAttribute::IS_INSTANCEOF)[0] ?? $parameter->getAttributes(Option::class)[0] ?? null) { 81 | $name = $attr->newInstance()->name ?? $name; 82 | } 83 | 84 | if ($input->hasArgument($name)) { 85 | return $input->getArgument($name); 86 | } 87 | 88 | if ($input->hasOption($name)) { 89 | return $input->getOption($name); 90 | } 91 | } 92 | 93 | return Parameter::union( 94 | Parameter::untyped($this->io()), 95 | Parameter::typed(InputInterface::class, $input, Argument::EXACT), 96 | Parameter::typed(OutputInterface::class, $output, Argument::EXACT), 97 | Parameter::typed(IO::class, $this->io(), Argument::COVARIANCE), 98 | Parameter::typed(IO::class, Parameter::factory(fn($class) => new $class($input, $output))), 99 | ); 100 | }, 101 | self::invokeParameters(), 102 | ); 103 | 104 | $return = Callback::createFor($this)->invoke(...$parameters); // @phpstan-ignore-line 105 | 106 | if (null === $return) { 107 | $return = 0; // assume 0 108 | } 109 | 110 | if (!\is_int($return)) { 111 | throw new \LogicException(\sprintf('"%s::__invoke()" must return void|null|int. Got "%s".', static::class, \get_debug_type($return))); 112 | } 113 | 114 | return $return; 115 | } 116 | 117 | protected function io(): IO 118 | { 119 | if (!isset($this->io)) { 120 | throw new \LogicException(\sprintf('Cannot call %s() before running command.', __METHOD__)); 121 | } 122 | 123 | return $this->io; 124 | } 125 | 126 | /** 127 | * @internal 128 | * 129 | * @return array<\ReflectionParameter> 130 | */ 131 | protected static function invokeParameters(): array 132 | { 133 | try { 134 | return (new \ReflectionClass(static::class))->getMethod('__invoke')->getParameters(); 135 | } catch (\ReflectionException) { 136 | throw new \LogicException(\sprintf('"%s" must implement __invoke() to use %s.', static::class, Invokable::class)); 137 | } 138 | } 139 | } 140 | -------------------------------------------------------------------------------- /src/InvokableCommand.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Zenstruck\Console; 13 | 14 | use Symfony\Component\Console\Command\Command; 15 | 16 | /** 17 | * Makes your command "invokable" to reduce boilerplate. 18 | * 19 | * Auto-injects the following objects into __invoke(): 20 | * 21 | * @see IO 22 | * @see InputInterface the "real" input 23 | * @see OutputInterface the "real" output 24 | * 25 | * @author Kevin Bond 26 | */ 27 | abstract class InvokableCommand extends Command 28 | { 29 | use ConfigureWithAttributes, Invokable; 30 | } 31 | -------------------------------------------------------------------------------- /src/InvokableServiceCommand.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Zenstruck\Console; 13 | 14 | use Psr\Container\ContainerInterface; 15 | use Psr\Container\NotFoundExceptionInterface; 16 | use Symfony\Component\Console\Input\InputInterface; 17 | use Symfony\Component\Console\Output\OutputInterface; 18 | use Symfony\Component\Console\Style\StyleInterface; 19 | use Symfony\Component\DependencyInjection\ParameterBag\ParameterBagInterface; 20 | use Symfony\Contracts\Service\Attribute\Required; 21 | use Symfony\Contracts\Service\Attribute\SubscribedService; 22 | use Symfony\Contracts\Service\ServiceSubscriberInterface; 23 | use Zenstruck\Console\Attribute\Argument; 24 | use Zenstruck\Console\Attribute\Option; 25 | 26 | /** 27 | * All the benefits of {@see InvokableCommand} but also allows for auto-injection of 28 | * any service from your Symfony DI container. You can think of it as 29 | * "Invokable Service Controllers" (with 'controller.service_arguments') but 30 | * for commands. Instead of a "Request", you inject {@see IO}. 31 | * 32 | * @author Kevin Bond 33 | */ 34 | abstract class InvokableServiceCommand extends InvokableCommand implements ServiceSubscriberInterface 35 | { 36 | private ContainerInterface $container; 37 | 38 | public static function getSubscribedServices(): array 39 | { 40 | $services = \array_values( 41 | \array_filter( 42 | \array_map( 43 | static function(\ReflectionParameter $parameter) { 44 | if (!$type = $parameter->getType()) { 45 | return null; 46 | } 47 | 48 | if (!$type instanceof \ReflectionNamedType) { 49 | return null; 50 | } 51 | 52 | $name = $type->getName(); 53 | 54 | if (\is_a($name, InputInterface::class, true)) { 55 | return null; 56 | } 57 | 58 | if (\is_a($name, OutputInterface::class, true)) { 59 | return null; 60 | } 61 | 62 | if (\is_a($name, StyleInterface::class, true)) { 63 | return null; 64 | } 65 | 66 | if ($parameter->getAttributes(Option::class, \ReflectionAttribute::IS_INSTANCEOF) || $parameter->getAttributes(Argument::class, \ReflectionAttribute::IS_INSTANCEOF)) { 67 | return null; // don't auto-inject options/arguments 68 | } 69 | 70 | $attributes = \array_map(static fn(\ReflectionAttribute $a) => $a->newInstance(), $parameter->getAttributes()); 71 | 72 | if (!$attributes && $type->isBuiltin()) { 73 | return null; // an attribute (ie Autowire) is required for built-in types 74 | } 75 | 76 | return new SubscribedService('invoke:'.$parameter->name, $name, $type->allowsNull(), $attributes); // @phpstan-ignore-line 77 | }, 78 | self::invokeParameters(), 79 | ), 80 | ), 81 | ); 82 | 83 | return [...$services, ParameterBagInterface::class]; 84 | } 85 | 86 | public function execute(InputInterface $input, OutputInterface $output): int 87 | { 88 | foreach (self::getSubscribedServices() as $serviceId) { 89 | [$serviceId, $optional] = self::parseServiceId($serviceId); 90 | 91 | try { 92 | $value = $this->container()->get($serviceId); 93 | } catch (NotFoundExceptionInterface $e) { 94 | if (!$optional) { 95 | // not optional 96 | throw $e; 97 | } 98 | 99 | // optional 100 | $value = null; 101 | } 102 | 103 | $this->addArgumentFactory($serviceId, static fn() => $value); 104 | } 105 | 106 | return parent::execute($input, $output); 107 | } 108 | 109 | #[Required] 110 | public function setInvokeContainer(ContainerInterface $container): void 111 | { 112 | $this->container = $container; 113 | } 114 | 115 | final protected function parameter(string $name): mixed 116 | { 117 | return $this->container()->get(ParameterBagInterface::class)->get($name); 118 | } 119 | 120 | /** 121 | * @return array{0:string,1:bool} 122 | */ 123 | private static function parseServiceId(string|SubscribedService $service): array 124 | { 125 | if ($service instanceof SubscribedService) { 126 | return [(string) $service->key, $service->nullable]; 127 | } 128 | 129 | return [ 130 | \ltrim($service, '?'), 131 | \str_starts_with($service, '?'), 132 | ]; 133 | } 134 | 135 | private function container(): ContainerInterface 136 | { 137 | if (!isset($this->container)) { 138 | throw new \LogicException(\sprintf('Container not available in "%s", is this class auto-configured?', static::class)); 139 | } 140 | 141 | return $this->container; 142 | } 143 | } 144 | -------------------------------------------------------------------------------- /src/RunsCommands.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Zenstruck\Console; 13 | 14 | use Symfony\Component\Console\Command\Command; 15 | 16 | /** 17 | * @author Kevin Bond 18 | */ 19 | trait RunsCommands 20 | { 21 | /** 22 | * @param string|class-string|array $cli my:command arg --opt 23 | * MyCommand::class 24 | * ['command' => 'my:command', 'arg' => 'value'] 25 | */ 26 | protected function runCommand($cli, array $inputs = []): int 27 | { 28 | if (!$this instanceof Command || !\method_exists($this, 'io')) { 29 | throw new \LogicException(\sprintf('"%s" can only be used with "%s" commands.', __TRAIT__, Invokable::class)); 30 | } 31 | 32 | if (!$application = $this->getApplication()) { 33 | throw new \LogicException('Application not available.'); 34 | } 35 | 36 | return CommandRunner::from($application, $cli)->withOutput($this->io())->run($inputs); 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /src/RunsProcesses.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Zenstruck\Console; 13 | 14 | use Symfony\Component\Console\Command\Command; 15 | use Symfony\Component\Console\Terminal; 16 | use Symfony\Component\Process\Process; 17 | 18 | /** 19 | * @author Kevin Bond 20 | */ 21 | trait RunsProcesses 22 | { 23 | /** 24 | * @param string[]|string|Process $process 25 | */ 26 | protected function runProcess($process): Process 27 | { 28 | if (!\class_exists(Process::class)) { 29 | throw new \LogicException('symfony/process required: composer require symfony/process'); 30 | } 31 | 32 | if (!$this instanceof Command || !\method_exists($this, 'io')) { 33 | throw new \LogicException(\sprintf('"%s" can only be used with "%s" commands.', __TRAIT__, Invokable::class)); 34 | } 35 | 36 | if (!$process instanceof Process) { 37 | $process = \is_string($process) ? Process::fromShellCommandline($process) : new Process($process); 38 | } 39 | 40 | $commandLine = $process->getCommandLine(); 41 | $maxLength = \min((new Terminal())->getWidth(), IO::MAX_LINE_LENGTH) - 48; // account for prefix/decoration length 42 | $last = null; 43 | 44 | if (\mb_strlen($commandLine) > $maxLength) { 45 | $commandLine = \sprintf('%s...%s', 46 | \mb_substr($commandLine, 0, (int) \ceil($maxLength / 2)), 47 | \mb_substr($commandLine, 0 - (int) \floor($maxLength / 2) - 3), // accommodate "..." 48 | ); 49 | } 50 | 51 | $this->io()->comment(\sprintf('Running process: %s', $commandLine)); 52 | 53 | $process->start(); 54 | 55 | foreach ($process as $type => $buffer) { 56 | foreach (\array_filter(\explode("\n", $buffer)) as $line) { 57 | if (Process::ERR === $type || $this->io()->isVerbose()) { 58 | $last = \sprintf('<%s>%s %s', 59 | Process::ERR === $type ? 'error' : 'comment', 60 | \mb_strtoupper($type), 61 | $line, 62 | ); 63 | 64 | $this->io()->text($last); 65 | } 66 | } 67 | } 68 | 69 | if (!$process->isSuccessful()) { 70 | throw new \RuntimeException("Process failed: {$process->getExitCodeText()}."); 71 | } 72 | 73 | if ($last) { 74 | $this->io()->newLine(); 75 | } 76 | 77 | return $process; 78 | } 79 | } 80 | --------------------------------------------------------------------------------