diff --git a/system/CLI/Console.php b/system/CLI/Console.php index 423e41062c93..6f708153b470 100644 --- a/system/CLI/Console.php +++ b/system/CLI/Console.php @@ -285,11 +285,13 @@ private function chooseAlternative(array $alternatives): ?string CLI::error(lang('CLI.commandNotFound', [$this->command])); CLI::newLine(); + $prompt = CLI::getPrompt(); + if (count($alternatives) === 1) { - return CLI::prompt(lang('CLI.altCommandRun', [$alternatives[0]]), ['y', 'n']) === 'y' ? $alternatives[0] : null; + return $prompt->ask(lang('CLI.altCommandRun', [$alternatives[0]]), ['y', 'n']) === 'y' ? $alternatives[0] : null; } - $chosen = (int) CLI::promptByKey(lang('CLI.altCommandSelect'), [...$alternatives, lang('CLI.altCommandNone')]); + $chosen = $prompt->select(lang('CLI.altCommandSelect'), [...$alternatives, lang('CLI.altCommandNone')]); return $alternatives[$chosen] ?? null; } diff --git a/system/CLI/Prompt.php b/system/CLI/Prompt.php index 67d1e0f4d705..1f13f2951a52 100644 --- a/system/CLI/Prompt.php +++ b/system/CLI/Prompt.php @@ -166,8 +166,12 @@ public function askByMultipleKeys(string $text, array $options): array /** * Lets the user pick one option with the arrow keys and returns its key, asking for its number where keys cannot be read. * - * @param array $options - * @param int|string|null $default The key highlighted at the start, or `null` for the first one + * @template TKey of array-key + * + * @param array $options + * @param TKey|null $default The key highlighted at the start, or `null` for the first one + * + * @return TKey * * @throws InvalidArgumentException */ @@ -209,10 +213,12 @@ public function select(string $question, array $options, int|string|null $defaul /** * Lets the user tick options with the arrow keys and the space bar, asking for their numbers where keys cannot be read. * - * @param array $options - * @param list $default The keys ticked at the start + * @template TKey of array-key + * + * @param array $options + * @param list $default The keys ticked at the start * - * @return array The ticked options, in the order of `$options` + * @return array The ticked options, in the order of `$options` * * @throws CLIException When a required answer has no default on an output that cannot ask * @throws InvalidArgumentException diff --git a/system/Commands/Database/CreateDatabase.php b/system/Commands/Database/CreateDatabase.php index ce1cf3159365..d5129989a5ff 100644 --- a/system/Commands/Database/CreateDatabase.php +++ b/system/Commands/Database/CreateDatabase.php @@ -59,7 +59,10 @@ protected function interact(array &$arguments, array &$options): void $ext = $this->getUnboundOption('ext', $options); if (is_string($ext) && ! in_array($ext, self::VALID_EXTENSIONS, true)) { - $options['ext'] = CLI::prompt('Please choose a valid file extension', self::VALID_EXTENSIONS, 'required'); + $options['ext'] = $this->getPrompt()->select( + 'Please choose a valid file extension', + array_combine(self::VALID_EXTENSIONS, self::VALID_EXTENSIONS), + ); } } diff --git a/system/Commands/Database/ShowTableInfo.php b/system/Commands/Database/ShowTableInfo.php index 8bca99ae6a78..f718b26b72a9 100644 --- a/system/Commands/Database/ShowTableInfo.php +++ b/system/Commands/Database/ShowTableInfo.php @@ -108,15 +108,8 @@ protected function interact(array &$arguments, array &$options): void return; } - while (! in_array($arguments[0] ?? '', $tables, true)) { - $tableKey = CLI::promptByKey( - ['Here is the list of your database tables:', 'Which table do you want to see?'], - $tables, - 'required', - ); - CLI::newLine(); - - $arguments[0] = $tables[$tableKey] ?? ''; + if (! in_array($arguments[0] ?? '', $tables, true)) { + $arguments[0] = $tables[$this->getPrompt()->select('Which table do you want to see?', $tables)]; } } diff --git a/system/Commands/Encryption/GenerateKey.php b/system/Commands/Encryption/GenerateKey.php index aafcead42742..09af76d5ecfc 100644 --- a/system/Commands/Encryption/GenerateKey.php +++ b/system/Commands/Encryption/GenerateKey.php @@ -63,7 +63,10 @@ protected function interact(array &$arguments, array &$options): void $prefix = $this->getUnboundOption('prefix', $options); if (is_string($prefix) && ! in_array($prefix, self::VALID_PREFIXES, true)) { - $options['prefix'] = CLI::prompt('Please provide a valid prefix to use.', self::VALID_PREFIXES, 'required'); + $options['prefix'] = $this->getPrompt()->select( + 'Please provide a valid prefix to use.', + array_combine(self::VALID_PREFIXES, self::VALID_PREFIXES), + ); } if ($this->hasUnboundOption('show', $options)) { diff --git a/system/Commands/Encryption/RotateKey.php b/system/Commands/Encryption/RotateKey.php index 0322a659ac52..563256309801 100644 --- a/system/Commands/Encryption/RotateKey.php +++ b/system/Commands/Encryption/RotateKey.php @@ -67,7 +67,10 @@ protected function interact(array &$arguments, array &$options): void $prefix = $this->getUnboundOption('prefix', $options); if (is_string($prefix) && ! in_array($prefix, self::VALID_PREFIXES, true)) { - $options['prefix'] = CLI::prompt('Please provide a valid prefix to use.', self::VALID_PREFIXES, 'required'); + $options['prefix'] = $this->getPrompt()->select( + 'Please provide a valid prefix to use.', + array_combine(self::VALID_PREFIXES, self::VALID_PREFIXES), + ); } if ($this->hasUnboundOption('force', $options)) { diff --git a/system/Commands/Generators/CommandGenerator.php b/system/Commands/Generators/CommandGenerator.php index 15073453e163..2c9a16e6dd55 100644 --- a/system/Commands/Generators/CommandGenerator.php +++ b/system/Commands/Generators/CommandGenerator.php @@ -64,7 +64,10 @@ protected function interact(array &$arguments, array &$options): void return; } - $options['type'] = CLI::prompt(lang('CLI.generator.commandType'), ['basic', 'generator'], 'required'); + $options['type'] = $this->getPrompt()->select( + lang('CLI.generator.commandType'), + ['basic' => 'basic', 'generator' => 'generator'], + ); } protected function execute(array $arguments, array $options): int diff --git a/system/Commands/Generators/ControllerGenerator.php b/system/Commands/Generators/ControllerGenerator.php index 4a53c6258668..a8b4301b94b7 100644 --- a/system/Commands/Generators/ControllerGenerator.php +++ b/system/Commands/Generators/ControllerGenerator.php @@ -58,7 +58,10 @@ protected function interact(array &$arguments, array &$options): void return; } - $options['restful'] = CLI::prompt(lang('CLI.generator.parentClass'), ['controller', 'presenter'], 'required'); + $options['restful'] = $this->getPrompt()->select( + lang('CLI.generator.parentClass'), + ['controller' => 'controller', 'presenter' => 'presenter'], + ); } protected function execute(array $arguments, array $options): int diff --git a/system/Commands/Generators/ModelGenerator.php b/system/Commands/Generators/ModelGenerator.php index 3803bf407f90..b7d3e97b4c0d 100644 --- a/system/Commands/Generators/ModelGenerator.php +++ b/system/Commands/Generators/ModelGenerator.php @@ -67,7 +67,10 @@ protected function interact(array &$arguments, array &$options): void return; } - $options['return'] = CLI::prompt(lang('CLI.generator.returnType'), self::RETURN_TYPES, 'required'); + $options['return'] = $this->getPrompt()->select( + lang('CLI.generator.returnType'), + array_combine(self::RETURN_TYPES, self::RETURN_TYPES), + ); } protected function execute(array $arguments, array $options): int diff --git a/tests/system/CLI/ConsoleTest.php b/tests/system/CLI/ConsoleTest.php index 07284d2ec043..d36fd5285447 100644 --- a/tests/system/CLI/ConsoleTest.php +++ b/tests/system/CLI/ConsoleTest.php @@ -270,7 +270,7 @@ public function testUnknownCommandDeclinedSuggestionExitsWithError(): void public function testUnknownCommandRunsSelectedSuggestion(): void { $this->initializeConsole('clear', '--no-header'); - $io = $this->useInputs('0'); + $io = $this->useInputs('1'); $console = new Console(); $exitCode = $console->run(); @@ -283,12 +283,11 @@ public function testUnknownCommandRunsSelectedSuggestion(): void Command "clear" not found. Select a command to run instead: - [0] cache:clear - [1] debugbar:clear - [2] logs:clear - [3] none of these - - [0, 1, 2, 3]: 0 + [1] cache:clear + [2] debugbar:clear + [3] logs:clear + [4] none of these + Number [1]: 1 Cache cleared using the "file" driver. @@ -300,7 +299,7 @@ public function testUnknownCommandRunsSelectedSuggestion(): void public function testUnknownCommandSelectingNoneExitsWithError(): void { $this->initializeConsole('clear', '--no-header'); - $io = $this->useInputs('3'); + $io = $this->useInputs('4'); $console = new Console(); $exitCode = $console->run(); @@ -313,12 +312,11 @@ public function testUnknownCommandSelectingNoneExitsWithError(): void Command "clear" not found. Select a command to run instead: - [0] cache:clear - [1] debugbar:clear - [2] logs:clear - [3] none of these - - [0, 1, 2, 3]: 3 + [1] cache:clear + [2] debugbar:clear + [3] logs:clear + [4] none of these + Number [1]: 4 EOT, $this->getUndecoratedIoOutput($io), diff --git a/tests/system/Commands/CreateDatabaseTest.php b/tests/system/Commands/CreateDatabaseTest.php index ee6019adb585..88c978c94316 100644 --- a/tests/system/Commands/CreateDatabaseTest.php +++ b/tests/system/Commands/CreateDatabaseTest.php @@ -176,14 +176,18 @@ public function testSqlitePromptsForInvalidExtension(): void } $io = new MockInputOutput(); - $io->setInputs(['db']); + $io->setInputs(['1']); CLI::setInputOutput($io); command('db:create database --ext txt'); $this->assertSame( <<<'EOT' - Please choose a valid file extension [db, sqlite]: db + + Please choose a valid file extension + [1] db + [2] sqlite + Number [1]: 1 Database "database.db" successfully created. diff --git a/tests/system/Commands/Database/ShowTableInfoMockIOTest.php b/tests/system/Commands/Database/ShowTableInfoMockIOTest.php index 06bae7c59173..82b213450b5a 100644 --- a/tests/system/Commands/Database/ShowTableInfoMockIOTest.php +++ b/tests/system/Commands/Database/ShowTableInfoMockIOTest.php @@ -63,24 +63,15 @@ public function testDbTableWithInputs(): void CLI::setInputOutput($io); // User will input "a" (invalid value) and then select db_migrations. - $io->setInputs(['a', (string) $tableIndex]); + $io->setInputs(['a', (string) ($tableIndex + 1)]); command('db:table'); $result = $io->getOutput(); - $this->assertMatchesRegularExpression( - '/Which table do you want to see\? \[[\d,\s]+\]\: a/', - $result, - ); - $this->assertMatchesRegularExpression( - '/The "Which table do you want to see\?" field must be one of: [\d,\s]+./', - $result, - ); - $this->assertMatchesRegularExpression( - '/Which table do you want to see\? \[[\d,\s]+\]\: ' . $tableIndex . '/', - $result, - ); + $this->assertStringContainsString('Which table do you want to see?', $result); + $this->assertMatchesRegularExpression('/Number \[\S+\]: a\n.*Please select correctly\./', $result); + $this->assertMatchesRegularExpression('/Number \[\S+\]: ' . ($tableIndex + 1) . '\n/', $result); $this->assertMatchesRegularExpression( '/Data of "db_migrations" table:/', $result, diff --git a/tests/system/Commands/Encryption/GenerateKeyTest.php b/tests/system/Commands/Encryption/GenerateKeyTest.php index c36d17c05b29..8dc655b3b419 100644 --- a/tests/system/Commands/Encryption/GenerateKeyTest.php +++ b/tests/system/Commands/Encryption/GenerateKeyTest.php @@ -297,7 +297,7 @@ public function testKeyGenerateErrorsOnInvalidPrefixNonInteractively(): void public function testKeyGeneratePromptsForInvalidPrefix(): void { $io = new MockInputOutput(); - $io->setInputs(['hex2bin']); + $io->setInputs(['1']); CLI::setInputOutput($io); command('key:generate --prefix invalid --show'); diff --git a/tests/system/Commands/Encryption/RotateKeyTest.php b/tests/system/Commands/Encryption/RotateKeyTest.php index 8deb6dbf2619..d575c8785d40 100644 --- a/tests/system/Commands/Encryption/RotateKeyTest.php +++ b/tests/system/Commands/Encryption/RotateKeyTest.php @@ -332,13 +332,21 @@ public function testRotateInteractRePromptsForInvalidPrefix(): void $io = new MockInputOutput(); // First input answers the invalid-prefix recovery prompt; second answers the rotate confirmation. - $io->setInputs(['base64', 'y']); + $io->setInputs(['2', 'y']); CLI::setInputOutput($io); command('key:rotate --prefix invalid'); $output = self::getUndecoratedIoOutput($io); - $this->assertStringContainsString('Please provide a valid prefix to use. [hex2bin, base64]: base64', $output); + $this->assertStringContainsString( + <<<'EOT' + Please provide a valid prefix to use. + [1] hex2bin + [2] base64 + Number [1]: 2 + EOT, + $output, + ); $this->assertStringContainsString('Encryption key rotated. 1 previous key retained for decryption fallback.', $output); $contents = (string) file_get_contents($this->envPath); diff --git a/tests/system/Commands/Generators/CommandGeneratorTest.php b/tests/system/Commands/Generators/CommandGeneratorTest.php index 88c959bd9d9a..be679c3c701d 100644 --- a/tests/system/Commands/Generators/CommandGeneratorTest.php +++ b/tests/system/Commands/Generators/CommandGeneratorTest.php @@ -127,7 +127,7 @@ public function testInvalidTypeIsRejectedWhenNotInteractive(): void public function testInvalidTypePromptsWhenInteractive(): void { $io = new MockInputOutput(); - $io->setInputs(['generator']); + $io->setInputs(['2']); CLI::setInputOutput($io); $command = new CommandGenerator(new Commands()); diff --git a/tests/system/Commands/Generators/ControllerGeneratorTest.php b/tests/system/Commands/Generators/ControllerGeneratorTest.php index 4b39c1f19d78..83777b5ae1b0 100644 --- a/tests/system/Commands/Generators/ControllerGeneratorTest.php +++ b/tests/system/Commands/Generators/ControllerGeneratorTest.php @@ -122,7 +122,7 @@ public function testInvalidRestfulTypeIsRejectedWhenNotInteractive(): void public function testInvalidRestfulTypePromptsWhenInteractive(): void { $io = new MockInputOutput(); - $io->setInputs(['presenter']); + $io->setInputs(['2']); CLI::setInputOutput($io); $command = new ControllerGenerator(new Commands()); diff --git a/tests/system/Commands/Generators/ModelGeneratorTest.php b/tests/system/Commands/Generators/ModelGeneratorTest.php index 6636ec7b5480..a955438857ff 100644 --- a/tests/system/Commands/Generators/ModelGeneratorTest.php +++ b/tests/system/Commands/Generators/ModelGeneratorTest.php @@ -206,7 +206,7 @@ public function testInvalidReturnTypeIsRejectedWhenNotInteractive(): void public function testInvalidReturnTypePromptsWhenInteractive(): void { $io = new MockInputOutput(); - $io->setInputs(['object']); + $io->setInputs(['2']); CLI::setInputOutput($io); $command = new ModelGenerator(new Commands()); diff --git a/user_guide_src/source/changelogs/v4.8.0.rst b/user_guide_src/source/changelogs/v4.8.0.rst index 36f3d7856e4c..884588d73193 100644 --- a/user_guide_src/source/changelogs/v4.8.0.rst +++ b/user_guide_src/source/changelogs/v4.8.0.rst @@ -68,6 +68,7 @@ Behavior Changes - **Commands:** The ``db:table`` output was reworded: the data and metadata column headers are now capitalized (e.g. ``Id``, ``Created_at``), the connection summary headers read ``Hostname``, ``Database``, ``Username``, ``DB Driver``, ``DB Prefix``, and ``Port``, and the section titles changed (e.g. ``Data of "users" table:``). Scripts that grep the previous output will need updating. - **Commands:** The ``worker:uninstall`` command now returns ``EXIT_SUCCESS`` (previously ``EXIT_ERROR``) when the user declines the interactive confirmation prompt, since user-initiated cancellation is not a failure. In non-interactive mode without ``--force`` it now aborts with ``EXIT_ERROR`` instead of prompting, and the redundant ``Uninstalling FrankenPHP Worker Mode`` header was dropped. Scripts that branch on the previous exit code or wording will need updating. - **Commands:** The ``phpini:check`` command's invalid-argument error now prints only ``You must specify a correct argument.``. The usage example and the argument listing moved to ``phpini:check --help``, where the argument is now named ``section``. Scripts that grep the previous output will need updating. +- **Commands:** The prompts that pick from fixed options (``db:table``, ``db:create``, ``key:generate``, ``key:rotate``, ``make:command``, ``make:controller``, and ``make:model``) now use ``Prompt::select()``, so where the arrow-key menu is unavailable, as on Windows, the answer is the option's number instead of its value. Option values such as ``--prefix`` still take the value. - **Config:** ``CodeIgniter\Config\BaseService`` is now ``abstract``. It is intended to be extended (as ``Config\Services`` does) and accessed statically; code that instantiated it directly with ``new BaseService()`` will now throw an ``Error``. - **Database:** The Postgre driver's ``$db->error()['code']`` previously always returned ``''``. It now returns the 5-character SQLSTATE string for query and transaction failures (e.g., ``'42P01'``), or ``'08006'`` for connection-level failures. Code that relied on ``$db->error()['code'] === ''`` will need updating. - **Debug:** ``CodeIgniter\Debug\Toolbar\Collectors\BaseCollector`` is now ``abstract``. Custom toolbar collectors must extend it; code that instantiated it directly with ``new BaseCollector()`` will now throw an ``Error``. diff --git a/user_guide_src/source/cli/spark_commands.rst b/user_guide_src/source/cli/spark_commands.rst index f14fc59812e8..6e0ddd6b626d 100644 --- a/user_guide_src/source/cli/spark_commands.rst +++ b/user_guide_src/source/cli/spark_commands.rst @@ -153,9 +153,10 @@ Correcting a Mistyped Command When the command name is not found, **spark** lists the closest matches. On an interactive run it also offers to run one of them: a single match asks for a ``y``/``n`` confirmation, and -several matches present a numbered list with a "none of these" entry. Pressing Enter picks the -highlighted default. Non-interactive runs (``--no-interaction`` / ``-N``, or piped input) only -print the suggestions and exit with an error, as before. +several matches present a menu with a "none of these" entry, picked with the arrow keys (see +:ref:`cli-library-select`). Pressing Enter picks the highlighted entry. Non-interactive runs +(``--no-interaction`` / ``-N``, or piped input) only print the suggestions and exit with +an error, as before. .. code-block:: console @@ -164,6 +165,7 @@ print the suggestions and exit with an error, as before. Command "cache:clea" not found. Run "cache:clear" instead? [y, n]: y + Cache cleared using the "file" driver. Calling Commands diff --git a/user_guide_src/source/testing/cli/001.php b/user_guide_src/source/testing/cli/001.php index c01212049b8c..86763c591d36 100644 --- a/user_guide_src/source/testing/cli/001.php +++ b/user_guide_src/source/testing/cli/001.php @@ -13,25 +13,21 @@ final class DbTableTest extends CIUnitTestCase public function testDbTable(): void { - // Set MockInputOutput to CLI. $io = new MockInputOutput(); CLI::setInputOutput($io); - // User will input "a" (invalid value) and "0". - $io->setInputs(['a', '0']); + $io->setInputs(['a', '1']); command('db:table'); - // Get the whole output string. $output = $io->getOutput(); - $expected = 'Which table do you want to see? [0, 1, 2, 3, 4, 5, 6, 7, 8, 9]: a'; + $expected = 'Please select correctly.'; $this->assertStringContainsString($expected, $output); - $expected = 'Data of Table "db_migrations":'; + $expected = 'Data of "db_migrations" table:'; $this->assertStringContainsString($expected, $output); - // Remove MockInputOutput. CLI::resetInputOutput(); } }