├── tools ├── .gitignore ├── psalm │ └── composer.json └── infection │ └── composer.json ├── src ├── Event │ ├── ApplicationStartup.php │ └── ApplicationShutdown.php ├── SymfonyEventDispatcher.php ├── Output │ └── ConsoleBufferedOutput.php ├── ErrorListener.php ├── Command │ ├── Game.php │ └── Serve.php ├── Application.php ├── CommandLoader.php └── ExitCode.php ├── config ├── events-console.php ├── params-console.php └── di-console.php ├── rector.php ├── LICENSE.md ├── CHANGELOG.md ├── composer.json └── README.md /tools/.gitignore: -------------------------------------------------------------------------------- 1 | /*/vendor 2 | /*/composer.lock 3 | -------------------------------------------------------------------------------- /tools/psalm/composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "require-dev": { 3 | "vimeo/psalm": "^5.26.1 || ^6.10" 4 | } 5 | } 6 | -------------------------------------------------------------------------------- /tools/infection/composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "require-dev": { 3 | "infection/infection": "^0.26 || ^0.31.9" 4 | }, 5 | "config": { 6 | "allow-plugins": { 7 | "infection/extension-installer": true 8 | } 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /src/Event/ApplicationStartup.php: -------------------------------------------------------------------------------- 1 | [ 10 | [ErrorListener::class, 'onError'], 11 | ], 12 | ]; 13 | -------------------------------------------------------------------------------- /src/Event/ApplicationShutdown.php: -------------------------------------------------------------------------------- 1 | exitCode; 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /rector.php: -------------------------------------------------------------------------------- 1 | withPaths([ 10 | __DIR__ . '/src', 11 | __DIR__ . '/tests', 12 | ]) 13 | ->withRules([ 14 | InlineConstructorDefaultToPropertyRector::class, 15 | ]) 16 | ->withPhpSets(php80: true); 17 | -------------------------------------------------------------------------------- /config/params-console.php: -------------------------------------------------------------------------------- 1 | [ 11 | 'name' => Application::NAME, 12 | 'version' => Application::VERSION, 13 | 'autoExit' => false, 14 | 'commands' => [ 15 | 'serve' => Serve::class, 16 | '|yii' => Game::class, 17 | ], 18 | 'serve' => [ 19 | 'appRootPath' => null, 20 | 'options' => [ 21 | 'address' => '127.0.0.1', 22 | 'port' => '8080', 23 | 'docroot' => 'public', 24 | 'router' => 'public/index.php', 25 | ], 26 | ], 27 | ], 28 | ]; 29 | -------------------------------------------------------------------------------- /src/SymfonyEventDispatcher.php: -------------------------------------------------------------------------------- 1 | dispatcher->dispatch($event); 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /src/Output/ConsoleBufferedOutput.php: -------------------------------------------------------------------------------- 1 | buffer; 19 | 20 | if ($clearBuffer) { 21 | $this->buffer = ''; 22 | } 23 | 24 | return $content; 25 | } 26 | 27 | protected function doWrite(string $message, bool $newline): void 28 | { 29 | $this->buffer .= $message; 30 | 31 | if ($newline) { 32 | $this->buffer .= PHP_EOL; 33 | } 34 | 35 | parent::doWrite($message, $newline); 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /src/ErrorListener.php: -------------------------------------------------------------------------------- 1 | logger === null) { 21 | return; 22 | } 23 | 24 | $exception = $event->getError(); 25 | $command = $event->getCommand(); 26 | 27 | $message = sprintf( 28 | '%s: %s in %s:%s while running console command "%s".', 29 | $exception::class, 30 | $exception->getMessage(), 31 | $exception->getFile(), 32 | $exception->getLine(), 33 | $command?->getName() ?? 'unknown', 34 | ); 35 | 36 | $this->logger->error($message, ['exception' => $exception]); 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | Copyright © 2008 by Yii Software () 2 | All rights reserved. 3 | 4 | Redistribution and use in source and binary forms, with or without 5 | modification, are permitted provided that the following conditions 6 | are met: 7 | 8 | * Redistributions of source code must retain the above copyright 9 | notice, this list of conditions and the following disclaimer. 10 | * Redistributions in binary form must reproduce the above copyright 11 | notice, this list of conditions and the following disclaimer in 12 | the documentation and/or other materials provided with the 13 | distribution. 14 | * Neither the name of Yii Software nor the names of its 15 | contributors may be used to endorse or promote products derived 16 | from this software without specific prior written permission. 17 | 18 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS 19 | "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT 20 | LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS 21 | FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE 22 | COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, 23 | INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, 24 | BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; 25 | LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER 26 | CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT 27 | LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN 28 | ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE 29 | POSSIBILITY OF SUCH DAMAGE. 30 | -------------------------------------------------------------------------------- /src/Command/Game.php: -------------------------------------------------------------------------------- 1 | title('Welcome to the Guessing Game!'); 28 | 29 | 30 | /** @var QuestionHelper $helper */ 31 | $helper = $this->getHelper('question'); 32 | $question = new Question('Please enter a number between 0 and 100: '); 33 | 34 | while (true) { 35 | $answer = (int) $helper->ask($input, $output, $question); 36 | 37 | if ($answer === $number) { 38 | $io->success('You win! You guessed the number in ' . $steps . ' steps.'); 39 | return 0; 40 | } 41 | 42 | $steps++; 43 | if ($answer > $number) { 44 | $io->warning('Too high!'); 45 | } else { 46 | $io->warning('Too low!'); 47 | } 48 | } 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /config/di-console.php: -------------------------------------------------------------------------------- 1 | [ 17 | 'class' => CommandLoader::class, 18 | '__construct()' => [ 19 | 'commandMap' => $params['yiisoft/yii-console']['commands'], 20 | ], 21 | ], 22 | 23 | Serve::class => [ 24 | 'class' => Serve::class, 25 | '__construct()' => [ 26 | 'appRootPath' => $params['yiisoft/yii-console']['serve']['appRootPath'], 27 | 'options' => $params['yiisoft/yii-console']['serve']['options'], 28 | ], 29 | ], 30 | 31 | Application::class => [ 32 | '__construct()' => [ 33 | $params['yiisoft/yii-console']['name'], 34 | $params['yiisoft/yii-console']['version'], 35 | ], 36 | 'setCommandLoader()' => [Reference::to(CommandLoaderInterface::class)], 37 | 'setDispatcher()' => [Reference::to(SymfonyEventDispatcher::class)], 38 | 'setAutoExit()' => [$params['yiisoft/yii-console']['autoExit']], 39 | 'addOptions()' => [ 40 | new InputOption( 41 | 'config', 42 | null, 43 | InputOption::VALUE_REQUIRED, 44 | 'Set alternative configuration name' 45 | ), 46 | ], 47 | ], 48 | ]; 49 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Yii Console Change Log 2 | 3 | ## 2.4.2 under development 4 | 5 | - no changes in this release. 6 | 7 | ## 2.4.1 December 02, 2025 8 | 9 | - Eng #231: Add PHP 8.5 support (@vjik) 10 | 11 | ## 2.4.0 September 27, 2025 12 | 13 | - Chg #228: Change PHP constraint in `composer.json` to `8.0 - 8.4` (@vjik) 14 | - Enh #226: Check empty string as key in command map in `CommandLoader` validation (@vjik) 15 | - Bug #224: Fix typo in `UNAVAILABLE` exit code reason (@vjik) 16 | - Bug #227: Throw `RuntimeException` in `ServeCommand` when it wasn't possible to find out the current directory (@vjik) 17 | - Bug #230: The `serve` command no longer opens the browser by default (@vjik) 18 | 19 | ## 2.3.0 January 23, 2025 20 | 21 | - Enh #207: Add `--open` option for `serve` command (@xepozz) 22 | - Enh #207: Print possible options for `serve` command (@xepozz) 23 | - Enh #218: Explicitly mark nullable parameters (@alexander-schranz) 24 | 25 | ## 2.2.0 February 17, 2024 26 | 27 | - Enh #194: Allow to use `ErrorListiner` without logger (@vjik) 28 | 29 | ## 2.1.2 December 26, 2023 30 | 31 | - Enh #189: Add support for `symfony/console` of version `^7.0` (@vjik) 32 | 33 | ## 2.1.1 November 05, 2023 34 | 35 | - Chg #185: Rename `params.php` to `params-console.php` (@terabytesoftw) 36 | 37 | ## 2.1.0 May 28, 2023 38 | 39 | - Bug #172: Fix accepting `:` as command name separator, offer using it by default (@samdark) 40 | - Bug #179: Remove duplicate messages about server address (@samdark) 41 | - Enh #180: Enhance output of `serve` command, add `--xdebug` option for `serve` (@xepozz) 42 | 43 | ## 2.0.1 March 31, 2023 44 | 45 | - Bug #175: Fix `serve` under Windows (@samdark) 46 | 47 | ## 2.0.0 February 17, 2023 48 | 49 | - Chg #171: Adapt configuration group names to Yii conventions (@vjik) 50 | - Enh #162: Explicitly add transitive dependencies `psr/event-dispatcher` and `psr/log` (@vjik) 51 | - Enh #163: Add `workers` option to `serve` command with default of two workers under Linux (@xepozz) 52 | - Bug #168: Fix executing the `list` command with namespace (@vjik) 53 | 54 | ## 1.3.0 July 29, 2022 55 | 56 | - Chg: #159: Add collecting console command name to `ApplicationStartup` class (@xepozz) 57 | 58 | ## 1.2.0 July 21, 2022 59 | 60 | - Enh #157: Add config for `serve` command (@dood-) 61 | 62 | ## 1.1.1 July 04, 2022 63 | 64 | - Enh #156: Add support for `symfony/event-dispatcher-contracts` of version `^3.0` (@vjik) 65 | 66 | ## 1.1.0 May 03, 2022 67 | 68 | - Chg #148: Raise the minimum PHP version to 8.0 (@rustamwin) 69 | - Enh #149: Add bash completion for `serve` command, serve at 127.0.0.1 by default (@rustamwin) 70 | 71 | ## 1.0.1 February 11, 2022 72 | 73 | - Enh #141: Add support for version `^6.0` for `symfony/console` package (@devanych) 74 | - Bug #145: Add return type to `Yiisoft\Yii\Console\CommandLoader::get()` method (@devanych) 75 | 76 | ## 1.0.0 November 01, 2021 77 | 78 | - Initial release. 79 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "yiisoft/yii-console", 3 | "type": "library", 4 | "description": "Symfony console wrapper with additional features", 5 | "keywords": [ 6 | "yii", 7 | "console" 8 | ], 9 | "homepage": "https://www.yiiframework.com/", 10 | "license": "BSD-3-Clause", 11 | "support": { 12 | "issues": "https://github.com/yiisoft/yii-console/issues?state=open", 13 | "source": "https://github.com/yiisoft/yii-console", 14 | "forum": "https://www.yiiframework.com/forum/", 15 | "wiki": "https://www.yiiframework.com/wiki/", 16 | "irc": "ircs://irc.libera.chat:6697/yii", 17 | "chat": "https://t.me/yii3en" 18 | }, 19 | "funding": [ 20 | { 21 | "type": "opencollective", 22 | "url": "https://opencollective.com/yiisoft" 23 | }, 24 | { 25 | "type": "github", 26 | "url": "https://github.com/sponsors/yiisoft" 27 | } 28 | ], 29 | "require": { 30 | "php": "8.0 - 8.5", 31 | "psr/container": "^1.0 || ^2.0", 32 | "psr/event-dispatcher": "^1.0", 33 | "psr/log": "^1.0 || ^2.0 || ^3.0", 34 | "symfony/console": "^5.4 || ^6.0 || ^7.0", 35 | "symfony/event-dispatcher-contracts": "^2.2 || ^3.0", 36 | "yiisoft/friendly-exception": "^1.0" 37 | }, 38 | "require-dev": { 39 | "bamarni/composer-bin-plugin": "^1.8.3", 40 | "maglnet/composer-require-checker": "^4.4", 41 | "phpunit/phpunit": "^9.6.22", 42 | "rector/rector": "^2.0.11", 43 | "yiisoft/config": "^1.5", 44 | "yiisoft/di": "^1.2.1", 45 | "yiisoft/test-support": "^3.0.2" 46 | }, 47 | "autoload": { 48 | "psr-4": { 49 | "Yiisoft\\Yii\\Console\\": "src" 50 | } 51 | }, 52 | "autoload-dev": { 53 | "psr-4": { 54 | "Yiisoft\\Yii\\Console\\Tests\\": "tests" 55 | } 56 | }, 57 | "extra": { 58 | "bamarni-bin": { 59 | "bin-links": true, 60 | "target-directory": "tools", 61 | "forward-command": true 62 | }, 63 | "config-plugin-options": { 64 | "source-directory": "config", 65 | "merge-plan-file": "../tests/environment/.merge-plan.php" 66 | }, 67 | "config-plugin": { 68 | "params-console": "params-console.php", 69 | "di-console": "di-console.php", 70 | "events-console": "events-console.php" 71 | } 72 | }, 73 | "config": { 74 | "sort-packages": true, 75 | "allow-plugins": { 76 | "bamarni/composer-bin-plugin": true, 77 | "composer/package-versions-deprecated": true, 78 | "yiisoft/config": true 79 | } 80 | }, 81 | "scripts": { 82 | "test": "phpunit --testdox --no-interaction", 83 | "test-watch": "phpunit-watcher watch" 84 | } 85 | } 86 | -------------------------------------------------------------------------------- /src/Application.php: -------------------------------------------------------------------------------- 1 | dispatcher = $dispatcher; 35 | parent::setDispatcher($dispatcher); 36 | } 37 | 38 | public function start(?ArgvInput $input = null): void 39 | { 40 | if ($this->dispatcher !== null && $input !== null) { 41 | $this->dispatcher->dispatch(new ApplicationStartup($input->getFirstArgument())); 42 | } 43 | } 44 | 45 | public function shutdown(int $exitCode): void 46 | { 47 | if ($this->dispatcher !== null) { 48 | $this->dispatcher->dispatch(new ApplicationShutdown($exitCode)); 49 | } 50 | } 51 | 52 | public function renderThrowable(Throwable $e, OutputInterface $output): void 53 | { 54 | $output->writeln('', OutputInterface::VERBOSITY_QUIET); 55 | 56 | $this->doRenderThrowable($e, $output); 57 | } 58 | 59 | protected function doRenderThrowable(Throwable $e, OutputInterface $output): void 60 | { 61 | parent::doRenderThrowable($e, $output); 62 | 63 | // Friendly Exception support 64 | if ($e instanceof FriendlyExceptionInterface) { 65 | if ($output instanceof StyleInterface) { 66 | $output->title($e->getName()); 67 | if (($solution = $e->getSolution()) !== null) { 68 | $output->note($solution); 69 | } 70 | $output->newLine(); 71 | } else { 72 | $output->writeln('' . $e->getName() . ''); 73 | if (($solution = $e->getSolution()) !== null) { 74 | $output->writeln('' . $solution . ''); 75 | } 76 | $output->writeln(''); 77 | } 78 | } 79 | 80 | $output->writeln($e->getTraceAsString()); 81 | } 82 | 83 | public function addOptions(InputOption $options): void 84 | { 85 | $this 86 | ->getDefinition() 87 | ->addOption($options); 88 | } 89 | 90 | public function extractNamespace(string $name, ?int $limit = null): string 91 | { 92 | return parent::extractNamespace(str_replace('/', ':', $name), $limit); 93 | } 94 | 95 | public function getNamespaces(): array 96 | { 97 | $namespaces = []; 98 | foreach ($this->all() as $command) { 99 | if ($command->isHidden()) { 100 | continue; 101 | } 102 | 103 | $namespaces[] = $this->extractAllNamespaces($command->getName()); 104 | 105 | /** @var string $alias */ 106 | foreach ($command->getAliases() as $alias) { 107 | $namespaces[] = $this->extractAllNamespaces($alias); 108 | } 109 | } 110 | 111 | return array_values(array_unique(array_filter(array_merge([], ...$namespaces)))); 112 | } 113 | 114 | /** 115 | * @return string[] 116 | */ 117 | private function extractAllNamespaces(?string $name): array 118 | { 119 | if ($name === null) { 120 | return []; 121 | } 122 | 123 | $name = str_replace('/', ':', $name); 124 | 125 | $parts = explode(':', $name, -1); 126 | $namespaces = []; 127 | 128 | foreach ($parts as $part) { 129 | if (count($namespaces)) { 130 | $namespaces[] = end($namespaces) . ':' . $part; 131 | } else { 132 | $namespaces[] = $part; 133 | } 134 | } 135 | 136 | return $namespaces; 137 | } 138 | } 139 | -------------------------------------------------------------------------------- /src/CommandLoader.php: -------------------------------------------------------------------------------- 1 | , 24 | * hidden: bool, 25 | * }> 26 | */ 27 | private array $commandMap; 28 | 29 | /** 30 | * @var string[] 31 | */ 32 | private array $commandNames; 33 | 34 | /** 35 | * @param array $commandMap An array with command names as keys and service ids as values. 36 | * 37 | * @psalm-param array> $commandMap 38 | */ 39 | public function __construct(private ContainerInterface $container, array $commandMap) 40 | { 41 | $this->setCommandMap($commandMap); 42 | } 43 | 44 | public function get(string $name): Command 45 | { 46 | if (!$this->has($name)) { 47 | throw new CommandNotFoundException(sprintf('Command "%s" does not exist.', $name)); 48 | } 49 | 50 | $commandName = $this->commandMap[$name]['name']; 51 | $commandAliases = $this->commandMap[$name]['aliases']; 52 | $commandClass = $this->commandMap[$name]['class']; 53 | $commandHidden = $this->commandMap[$name]['hidden']; 54 | 55 | /** 56 | * @see https://github.com/yiisoft/yii-console/issues/229 57 | * @psalm-suppress DeprecatedMethod 58 | */ 59 | $description = $commandClass::getDefaultDescription(); 60 | 61 | if ($description === null) { 62 | return $this->getCommandInstance($name); 63 | } 64 | 65 | return new LazyCommand( 66 | $commandName, 67 | $commandAliases, 68 | $description, 69 | $commandHidden, 70 | fn () => $this->getCommandInstance($name), 71 | ); 72 | } 73 | 74 | public function has(string $name): bool 75 | { 76 | return isset($this->commandMap[$name]); 77 | } 78 | 79 | public function getNames(): array 80 | { 81 | return $this->commandNames; 82 | } 83 | 84 | private function getCommandInstance(string $name): Command 85 | { 86 | $commandName = $this->commandMap[$name]['name']; 87 | $commandClass = $this->commandMap[$name]['class']; 88 | $commandAliases = $this->commandMap[$name]['aliases']; 89 | 90 | /** @var Command $command */ 91 | $command = $this->container->get($commandClass); 92 | 93 | if ($command->getName() !== $commandName) { 94 | $command->setName($commandName); 95 | } 96 | 97 | if ($command->getAliases() !== $commandAliases) { 98 | $command->setAliases($commandAliases); 99 | } 100 | 101 | return $command; 102 | } 103 | 104 | /** 105 | * @psalm-param array> $commandMap 106 | */ 107 | private function setCommandMap(array $commandMap): void 108 | { 109 | $this->commandMap = []; 110 | $this->commandNames = []; 111 | 112 | foreach ($commandMap as $name => $class) { 113 | $aliases = explode('|', $name); 114 | 115 | $hidden = false; 116 | if ($aliases[0] === '') { 117 | $hidden = true; 118 | array_shift($aliases); 119 | } 120 | 121 | $this->validateAliases($aliases); 122 | 123 | $primaryName = array_shift($aliases); 124 | 125 | $item = [ 126 | 'name' => $primaryName, 127 | 'aliases' => $aliases, 128 | 'class' => $class, 129 | 'hidden' => $hidden, 130 | ]; 131 | 132 | $this->commandMap[$primaryName] = $item; 133 | $this->commandNames[] = $primaryName; 134 | 135 | foreach ($aliases as $alias) { 136 | $this->commandMap[$alias] = $item; 137 | } 138 | } 139 | } 140 | 141 | /** 142 | * @psalm-param list $aliases 143 | * 144 | * @psalm-assert non-empty-list $aliases 145 | */ 146 | private function validateAliases(array $aliases): void 147 | { 148 | if (empty($aliases)) { 149 | throw new RuntimeException('Do not allow empty command name or alias.'); 150 | } 151 | foreach ($aliases as $alias) { 152 | if ($alias === '') { 153 | throw new RuntimeException('Do not allow empty command name or alias.'); 154 | } 155 | } 156 | } 157 | } 158 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |

2 | 3 | Yii 4 | 5 |

Yii Console

6 |
7 |

8 | 9 | [![Latest Stable Version](https://poser.pugx.org/yiisoft/yii-console/v)](https://packagist.org/packages/yiisoft/yii-console) 10 | [![Total Downloads](https://poser.pugx.org/yiisoft/yii-console/downloads)](https://packagist.org/packages/yiisoft/yii-console) 11 | [![Build status](https://github.com/yiisoft/yii-console/actions/workflows/build.yml/badge.svg)](https://github.com/yiisoft/yii-console/actions/workflows/build.yml) 12 | [![Code Coverage](https://codecov.io/gh/yiisoft/yii-console/branch/master/graph/badge.svg)](https://codecov.io/gh/yiisoft/yii-console) 13 | [![Mutation testing badge](https://img.shields.io/endpoint?style=flat&url=https%3A%2F%2Fbadge-api.stryker-mutator.io%2Fgithub.com%2Fyiisoft%2Fyii-console%2Fmaster)](https://dashboard.stryker-mutator.io/reports/github.com/yiisoft/yii-console/master) 14 | [![static analysis](https://github.com/yiisoft/yii-console/workflows/static%20analysis/badge.svg)](https://github.com/yiisoft/yii-console/actions?query=workflow%3A%22static+analysis%22) 15 | [![type-coverage](https://shepherd.dev/github/yiisoft/yii-console/coverage.svg)](https://shepherd.dev/github/yiisoft/yii-console) 16 | 17 | Yii Console package provides a console that could be added to an application. This console is based on 18 | [Symfony Console](https://github.com/symfony/console). The following extra features are added: 19 | 20 | - lazy command loader; 21 | - `SymfonyEventDispatcher` class that allows to use any [PSR-14](https://www.php-fig.org/psr/psr-14/) compatible event 22 | dispatcher with Symfony console; 23 | - `ErrorListener` for logging console errors to any [PSR-3](https://www.php-fig.org/psr/psr-3/) compatible logger; 24 | - console command `serve` that runs PHP built-in web server; 25 | - raises events `ApplicationStartup` and `ApplicationShutdown` in console application; 26 | - class `ExitCode` that contains constants for defining console command exit codes; 27 | - `ConsoleBufferedOutput` that wraps `ConsoleOutput` and buffers console output. 28 | 29 | ## Requirements 30 | 31 | - PHP 8.0 - 8.5. 32 | 33 | ## Installation 34 | 35 | The package could be installed with [Composer](https://getcomposer.org): 36 | 37 | ```shell 38 | composer require yiisoft/yii-console 39 | ``` 40 | 41 | ## General usage 42 | 43 | In case you use one of Yii 3 standard application templates, console could be accessed as `./yii `. 44 | 45 | If not, then in the simplest use case in your console entry script do the following: 46 | 47 | ```php 48 | #!/usr/bin/env php 49 | setCommandLoader(new CommandLoader( 63 | // Any container implementing `Psr\Container\ContainerInterface` for example: 64 | new Container(ContainerConfig::create()), 65 | // An array with command names as keys and service IDs as values: 66 | ['my/custom' => MyCustomCommand::class], 67 | )); 68 | 69 | $app->run(); 70 | ``` 71 | 72 | Since `\Yiisoft\Yii\Console\CommandLoader` uses lazy loading of commands, it's necessary 73 | to specify the name and description in static properties when creating a command: 74 | 75 | ```php 76 | use Symfony\Component\Console\Attribute\AsCommand; 77 | use Symfony\Component\Console\Command\Command; 78 | use Yiisoft\Yii\Console\ExitCode; 79 | 80 | 81 | #[AsCommand( 82 | name: 'my:custom', 83 | description: 'Description of my custom command.' 84 | )] 85 | final class MyCustomCommand extends Command 86 | { 87 | protected function configure(): void 88 | { 89 | // ... 90 | } 91 | 92 | protected function execute(InputInterface $input, OutputInterface $output): int 93 | { 94 | // ... 95 | return ExitCode::OK; 96 | } 97 | } 98 | ``` 99 | 100 | Run the console entry script with your command: 101 | 102 | ```shell 103 | your-console-entry-script my/custom 104 | ``` 105 | 106 | > When naming commands use `:` as a separator. For example: `user:create`, `user:delete`, etc. 107 | 108 | Since the package is based on [Symfony Console component](https://symfony.com/doc/current/components/console.html), 109 | refer to its documentation for details on how to use the binary and create your own commands. 110 | 111 | ### Aliases and hidden commands 112 | 113 | To configure commands, set the names and aliases in `\Yiisoft\Yii\Console\CommandLoader` configuration. 114 | Names and aliases from the command class itself are always ignored. 115 | 116 | The command can be marked as hidden by prefixing its name with `|`. 117 | 118 | ```php 119 | 'yiisoft/yii-console' => [ 120 | 'commands' => [ 121 | 'hello' => Hello::class, // name: 'hello', aliases: [], hidden: false 122 | 'start|run|s|r' => Run::class, // name: 'start', aliases: ['run', 's', 'r'], hidden: false 123 | '|hack|h' => Hack::class, // name: 'hack', aliases: ['h'], hidden: true 124 | ], 125 | ], 126 | ``` 127 | 128 | ### Runs PHP built-in web server 129 | 130 | You can start local built-in web development server using the command: 131 | 132 | ```shell 133 | ./yii serve 134 | ``` 135 | 136 | Your application will be accessible in your web browser at by default. 137 | To configure default settings, set the options in `\Yiisoft\Yii\Console\CommandLoader` configuration. 138 | 139 | ```php 140 | 'yiisoft/yii-console' => [ 141 | 'serve' => [ 142 | 'appRootPath' => null, 143 | 'options' => [ 144 | 'address' => '127.0.0.1', 145 | 'port' => '8080', 146 | 'docroot' => 'public', 147 | 'router' => 'public/index.php', 148 | ], 149 | ], 150 | ], 151 | ``` 152 | 153 | Alternatively, you can pass the settings through the console options. 154 | 155 | > Tip: To run a web server with XDebug enabled, pass `--xdebug 1` to the command. 156 | 157 | To see the available options, run `./yii serve --help`. 158 | 159 | ## Documentation 160 | 161 | - [Internals](docs/internals.md) 162 | 163 | If you need help or have a question, the [Yii Forum](https://forum.yiiframework.com/c/yii-3-0/63) is a good place for that. 164 | You may also check out other [Yii Community Resources](https://www.yiiframework.com/community). 165 | 166 | ## License 167 | 168 | The Yii Console is free software. It is released under the terms of the BSD License. 169 | Please see [`LICENSE`](./LICENSE.md) for more information. 170 | 171 | Maintained by [Yii Software](https://www.yiiframework.com/). 172 | 173 | ## Support the project 174 | 175 | [![Open Collective](https://img.shields.io/badge/Open%20Collective-sponsor-7eadf1?logo=open%20collective&logoColor=7eadf1&labelColor=555555)](https://opencollective.com/yiisoft) 176 | 177 | ## Follow updates 178 | 179 | [![Official website](https://img.shields.io/badge/Powered_by-Yii_Framework-green.svg?style=flat)](https://www.yiiframework.com/) 180 | [![Twitter](https://img.shields.io/badge/twitter-follow-1DA1F2?logo=twitter&logoColor=1DA1F2&labelColor=555555?style=flat)](https://twitter.com/yiiframework) 181 | [![Telegram](https://img.shields.io/badge/telegram-join-1DA1F2?style=flat&logo=telegram)](https://t.me/yii3en) 182 | [![Facebook](https://img.shields.io/badge/facebook-join-1DA1F2?style=flat&logo=facebook&logoColor=ffffff)](https://www.facebook.com/groups/yiitalk) 183 | [![Slack](https://img.shields.io/badge/slack-join-1DA1F2?style=flat&logo=slack)](https://yiiframework.com/go/slack) 184 | -------------------------------------------------------------------------------- /src/ExitCode.php: -------------------------------------------------------------------------------- 1 | isAllowedToPerformAction()) { 18 | * $this->stderr('Error: ' . ExitCode::getReason(ExitCode::NOPERM)); 19 | * return ExitCode::NOPERM; 20 | * } 21 | * 22 | * // do something 23 | * 24 | * return ExitCode::OK; 25 | * } 26 | * ``` 27 | * 28 | * @see https://man.openbsd.org/sysexits 29 | * 30 | * @final See https://github.com/yiisoft/yii-console/issues/225 31 | */ 32 | class ExitCode 33 | { 34 | /** 35 | * The command completed successfully. 36 | * 37 | * @psalm-suppress MissingClassConstType It needs while supported PHP version is <=8.2 38 | */ 39 | public const OK = 0; 40 | 41 | /** 42 | * The command exited with an error code that says nothing about the error. 43 | * 44 | * @psalm-suppress MissingClassConstType It needs while supported PHP version is <=8.2 45 | */ 46 | public const UNSPECIFIED_ERROR = 1; 47 | 48 | /** 49 | * The command was used incorrectly, e.g., with the wrong number of 50 | * arguments, a bad flag, a bad syntax in a parameter, or whatever. 51 | * 52 | * @psalm-suppress MissingClassConstType It needs while supported PHP version is <=8.2 53 | */ 54 | public const USAGE = 64; 55 | 56 | /** 57 | * The input data was incorrect in some way. This should only be used for 58 | * user's data and not system files. 59 | * 60 | * @psalm-suppress MissingClassConstType It needs while supported PHP version is <=8.2 61 | */ 62 | public const DATAERR = 65; 63 | 64 | /** 65 | * An input file (not a system file) did not exist or was not readable. 66 | * This could also include errors like ``No message'' to a mailer (if it 67 | * cared to catch it). 68 | * 69 | * @psalm-suppress MissingClassConstType It needs while supported PHP version is <=8.2 70 | */ 71 | public const NOINPUT = 66; 72 | 73 | /** 74 | * The user specified did not exist. This might be used for mail addresses 75 | * or remote logins. 76 | * 77 | * @psalm-suppress MissingClassConstType It needs while supported PHP version is <=8.2 78 | */ 79 | public const NOUSER = 67; 80 | 81 | /** 82 | * The host specified did not exist. This is used in mail addresses or 83 | * network requests. 84 | * 85 | * @psalm-suppress MissingClassConstType It needs while supported PHP version is <=8.2 86 | */ 87 | public const NOHOST = 68; 88 | 89 | /** 90 | * A service is unavailable. This can occur if a support program or file 91 | * does not exist. This can also be used as a catchall message when 92 | * something you wanted to do does not work, but you do not know why. 93 | * 94 | * @psalm-suppress MissingClassConstType It needs while supported PHP version is <=8.2 95 | */ 96 | public const UNAVAILABLE = 69; 97 | 98 | /** 99 | * An internal software error has been detected. This should be limited to 100 | * non-operating system related errors as possible. 101 | * 102 | * @psalm-suppress MissingClassConstType It needs while supported PHP version is <=8.2 103 | */ 104 | public const SOFTWARE = 70; 105 | 106 | /** 107 | * An operating system error has been detected. This is intended to be 108 | * used for such things as ``cannot fork'', ``cannot create pipe'', or the 109 | * like. It includes things like getuid returning a user that does not 110 | * exist in the passwd file. 111 | * 112 | * @psalm-suppress MissingClassConstType It needs while supported PHP version is <=8.2 113 | */ 114 | public const OSERR = 71; 115 | 116 | /** 117 | * Some system file (e.g., /etc/passwd, /var/run/utx.active, etc.) does not 118 | * exist, cannot be opened, or has some sort of error (e.g., syntax error). 119 | * 120 | * @psalm-suppress MissingClassConstType It needs while supported PHP version is <=8.2 121 | */ 122 | public const OSFILE = 72; 123 | 124 | /** 125 | * A (user specified) output file cannot be created. 126 | * 127 | * @psalm-suppress MissingClassConstType It needs while supported PHP version is <=8.2 128 | */ 129 | public const CANTCREAT = 73; 130 | 131 | /** 132 | * An error occurred while doing I/O on some file. 133 | * 134 | * @psalm-suppress MissingClassConstType It needs while supported PHP version is <=8.2 135 | */ 136 | public const IOERR = 74; 137 | 138 | /** 139 | * Temporary failure, indicating something that is not really an error. In 140 | * sendmail, this means that a mailer (e.g.) could not create a connection, 141 | * and the request should be reattempted later. 142 | * 143 | * @psalm-suppress MissingClassConstType It needs while supported PHP version is <=8.2 144 | */ 145 | public const TEMPFAIL = 75; 146 | 147 | /** 148 | * The remote system returned something that was ``not possible'' during a 149 | * protocol exchange. 150 | * 151 | * @psalm-suppress MissingClassConstType It needs while supported PHP version is <=8.2 152 | */ 153 | public const PROTOCOL = 76; 154 | 155 | /** 156 | * You did not have sufficient permission to perform the operation. This 157 | * is not intended for file system problems, which should use NOINPUT or 158 | * CANTCREAT, but rather for higher level permissions. 159 | * 160 | * @psalm-suppress MissingClassConstType It needs while supported PHP version is <=8.2 161 | */ 162 | public const NOPERM = 77; 163 | 164 | /** 165 | * Something was found in an unconfigured or misconfigured state. 166 | * 167 | * @psalm-suppress MissingClassConstType It needs while supported PHP version is <=8.2 168 | */ 169 | public const CONFIG = 78; 170 | 171 | /** 172 | * @var array A map of reason descriptions for exit codes. 173 | */ 174 | public static array $reasons = [ 175 | self::OK => 'Success', 176 | self::UNSPECIFIED_ERROR => 'Unspecified error', 177 | self::USAGE => 'Incorrect usage, argument or option error', 178 | self::DATAERR => 'Error in input data', 179 | self::NOINPUT => 'Input file not found or unreadable', 180 | self::NOUSER => 'User not found', 181 | self::NOHOST => 'Host not found', 182 | self::UNAVAILABLE => 'A required service is unavailable', 183 | self::SOFTWARE => 'Internal error', 184 | self::OSERR => 'Error making system call or using OS service', 185 | self::OSFILE => 'Error accessing system file', 186 | self::CANTCREAT => 'Cannot create output file', 187 | self::IOERR => 'I/O error', 188 | self::TEMPFAIL => 'Temporary failure', 189 | self::PROTOCOL => 'Unexpected remote service behavior', 190 | self::NOPERM => 'Insufficient permissions', 191 | self::CONFIG => 'Configuration error', 192 | ]; 193 | 194 | /** 195 | * Returns a short reason text for the given exit code. 196 | * 197 | * This method uses {@see $reasons} to determine the reason for an exit code. 198 | * 199 | * @param int $exitCode One of the constants defined in this class. 200 | * 201 | * @return string The reason text, or `"Unknown exit code"` if the code is not listed in {@see $reasons}. 202 | */ 203 | public static function getReason(int $exitCode): string 204 | { 205 | return static::$reasons[$exitCode] ?? 'Unknown exit code'; 206 | } 207 | } 208 | -------------------------------------------------------------------------------- /src/Command/Serve.php: -------------------------------------------------------------------------------- 1 | defaultAddress = $options['address'] ?? '127.0.0.1'; 51 | $this->defaultPort = $options['port'] ?? '8080'; 52 | $this->defaultDocroot = $options['docroot'] ?? 'public'; 53 | $this->defaultRouter = $options['router'] ?? 'public/index.php'; 54 | $this->defaultWorkers = (int) ($options['workers'] ?? 2); 55 | 56 | parent::__construct(); 57 | } 58 | 59 | public function configure(): void 60 | { 61 | $this 62 | ->setHelp( 63 | 'In order to access server from remote machines use 0.0.0.0:8000. That is especially useful when running server in a virtual machine.' 64 | ) 65 | ->addArgument('address', InputArgument::OPTIONAL, 'Host to serve at', $this->defaultAddress) 66 | ->addOption('port', 'p', InputOption::VALUE_OPTIONAL, 'Port to serve at', $this->defaultPort) 67 | ->addOption( 68 | 'docroot', 69 | 't', 70 | InputOption::VALUE_OPTIONAL, 71 | 'Document root to serve from', 72 | $this->defaultDocroot 73 | ) 74 | ->addOption('router', 'r', InputOption::VALUE_OPTIONAL, 'Path to router script', $this->defaultRouter) 75 | ->addOption( 76 | 'workers', 77 | 'w', 78 | InputOption::VALUE_OPTIONAL, 79 | 'Workers number the server will start with', 80 | $this->defaultWorkers 81 | ) 82 | ->addOption('env', 'e', InputOption::VALUE_OPTIONAL, 'It is only used for testing.') 83 | ->addOption('open', 'o', InputOption::VALUE_OPTIONAL, 'Opens the serving server in the default browser.', false) 84 | ->addOption('xdebug', 'x', InputOption::VALUE_OPTIONAL, 'Enables XDEBUG session.', false); 85 | } 86 | 87 | public function complete(CompletionInput $input, CompletionSuggestions $suggestions): void 88 | { 89 | if ($input->mustSuggestArgumentValuesFor('address')) { 90 | $suggestions->suggestValues(['localhost', '127.0.0.1', '0.0.0.0']); 91 | return; 92 | } 93 | } 94 | 95 | protected function execute(InputInterface $input, OutputInterface $output): int 96 | { 97 | $io = new SymfonyStyle($input, $output); 98 | $io->title('Yii3 Development Server'); 99 | $io->writeln('https://yiiframework.com' . "\n"); 100 | 101 | /** @var string $address */ 102 | $address = $input->getArgument('address'); 103 | 104 | /** @var string $router */ 105 | $router = $input->getOption('router'); 106 | $workers = (int) $input->getOption('workers'); 107 | 108 | /** @var string $port */ 109 | $port = $input->getOption('port'); 110 | 111 | /** @var string $docroot */ 112 | $docroot = $input->getOption('docroot'); 113 | 114 | if ($router === $this->defaultRouter && !file_exists($this->defaultRouter)) { 115 | $io->warning( 116 | 'Default router "' . $this->defaultRouter . '" does not exist. Serving without router. URLs with dots may fail.' 117 | ); 118 | $router = null; 119 | } 120 | 121 | /** @var string $env */ 122 | $env = $input->getOption('env'); 123 | 124 | $documentRoot = $this->getRootPath() . DIRECTORY_SEPARATOR . $docroot; 125 | 126 | if (!str_contains($address, ':')) { 127 | $address .= ':' . $port; 128 | } 129 | 130 | if (!is_dir($documentRoot)) { 131 | $io->error("Document root \"$documentRoot\" does not exist."); 132 | return self::EXIT_CODE_NO_DOCUMENT_ROOT; 133 | } 134 | 135 | if ($this->isAddressTaken($address)) { 136 | $io->error("http://$address is taken by another process."); 137 | return self::EXIT_CODE_ADDRESS_TAKEN_BY_ANOTHER_PROCESS; 138 | } 139 | 140 | if ($router !== null && !file_exists($router)) { 141 | $io->error("Routing file \"$router\" does not exist."); 142 | return self::EXIT_CODE_NO_ROUTING_FILE; 143 | } 144 | 145 | $command = []; 146 | 147 | $isLinux = DIRECTORY_SEPARATOR !== '\\'; 148 | 149 | if ($isLinux) { 150 | $command[] = 'PHP_CLI_SERVER_WORKERS=' . $workers; 151 | } 152 | 153 | $xDebugInstalled = extension_loaded('xdebug'); 154 | $xDebugEnabled = $isLinux && $xDebugInstalled && $input->hasOption('xdebug') && $input->getOption('xdebug') === null; 155 | 156 | if ($xDebugEnabled) { 157 | $command[] = 'XDEBUG_MODE=debug XDEBUG_TRIGGER=yes'; 158 | } 159 | $outputTable = []; 160 | $outputTable[] = ['PHP', PHP_VERSION]; 161 | 162 | if ($xDebugInstalled) { 163 | /** 164 | * @var string $xDebugVersion Here we know that xDebug is installed, so `phpversion` will not return false. 165 | */ 166 | $xDebugVersion = phpversion('xdebug'); 167 | $xDebugLine = sprintf( 168 | '%s, %s', 169 | $xDebugVersion, 170 | $xDebugEnabled ? ' Enabled ' : ' Disabled ', 171 | ); 172 | } else { 173 | $xDebugLine = 'Not installed'; 174 | } 175 | $outputTable[] = ['xDebug', $xDebugLine, '--xdebug']; 176 | 177 | $outputTable[] = ['Workers', $isLinux ? $workers : 'Not supported', '--workers, -w']; 178 | $outputTable[] = ['Address', $address]; 179 | $outputTable[] = ['Document root', $documentRoot, '--docroot, -t']; 180 | $outputTable[] = ($router ? ['Routing file', $router, '--router, -r'] : []); 181 | 182 | $io->table(['Configuration', null, 'Options'], $outputTable); 183 | 184 | $command[] = '"' . PHP_BINARY . '"' . " -S $address -t \"$documentRoot\" $router"; 185 | $command = implode(' ', $command); 186 | 187 | $output->writeln([ 188 | 'Executing: ', 189 | sprintf('%s', $command), 190 | ], OutputInterface::VERBOSITY_VERBOSE); 191 | 192 | $io->success('Quit the server with CTRL-C or COMMAND-C.'); 193 | 194 | if ($env === 'test') { 195 | return ExitCode::OK; 196 | } 197 | 198 | $openInBrowser = $input->hasOption('open') && $input->getOption('open') === null; 199 | 200 | if ($openInBrowser) { 201 | passthru('open http://' . $address); 202 | } 203 | passthru($command, $result); 204 | 205 | return $result; 206 | } 207 | 208 | /** 209 | * @param string $address The server address. 210 | * 211 | * @return bool If address is already in use. 212 | */ 213 | private function isAddressTaken(string $address): bool 214 | { 215 | [$hostname, $port] = explode(':', $address); 216 | $fp = @fsockopen($hostname, (int) $port, $errno, $errstr, 3); 217 | 218 | if ($fp === false) { 219 | return false; 220 | } 221 | 222 | fclose($fp); 223 | return true; 224 | } 225 | 226 | private function getRootPath(): string 227 | { 228 | if ($this->appRootPath !== null) { 229 | return rtrim($this->appRootPath, DIRECTORY_SEPARATOR); 230 | } 231 | 232 | $currentDirectory = getcwd(); 233 | if ($currentDirectory === false) { 234 | throw new RuntimeException('Failed to get current working directory.'); 235 | } 236 | 237 | return $currentDirectory; 238 | } 239 | } 240 | --------------------------------------------------------------------------------