From 246ebd3005eaed1e4cb0a512ffbb67b71b92b669 Mon Sep 17 00:00:00 2001 From: "John Paul E. Balandan, CPA" Date: Sun, 25 Oct 2026 20:33:06 +0800 Subject: [PATCH] fix: stop prompts from asking forever when the input ends --- system/CLI/CLI.php | 9 ++++ system/CLI/Exceptions/CLIException.php | 11 ++-- system/CLI/InputOutput.php | 19 ++++++- system/Language/en/CLI.php | 1 + system/Test/Mock/MockInputOutput.php | 8 +++ system/Test/PhpStreamWrapper.php | 8 +++ tests/system/CLI/CLITest.php | 56 +++++++++++++++++++++ user_guide_src/source/changelogs/v4.7.6.rst | 2 + 8 files changed, 109 insertions(+), 5 deletions(-) diff --git a/system/CLI/CLI.php b/system/CLI/CLI.php index 2f5aa9f68844..d04a4e5d1f2f 100644 --- a/system/CLI/CLI.php +++ b/system/CLI/CLI.php @@ -261,6 +261,10 @@ public static function prompt(string $field, $options = null, $validation = null if ($validation !== []) { while (! static::validate('"' . trim($field) . '"', $input, $validation)) { + if (static::$io->isAtEnd()) { + throw CLIException::forInputEnded(); + } + $input = static::prompt($field, $options, $validation); } } @@ -339,6 +343,11 @@ public static function promptByMultipleKeys(string $text, array $options): array } static::error('Please select correctly.'); + + if (static::$io->isAtEnd()) { + throw CLIException::forInputEnded(); + } + CLI::newLine(); } } diff --git a/system/CLI/Exceptions/CLIException.php b/system/CLI/Exceptions/CLIException.php index 304b2ea4f444..cccd96b7f9b6 100644 --- a/system/CLI/Exceptions/CLIException.php +++ b/system/CLI/Exceptions/CLIException.php @@ -16,9 +16,6 @@ use CodeIgniter\Exceptions\DebugTraceableTrait; use CodeIgniter\Exceptions\RuntimeException; -/** - * CLIException - */ class CLIException extends RuntimeException { use DebugTraceableTrait; @@ -33,4 +30,12 @@ public static function forInvalidColor(string $type, string $color) { return new static(lang('CLI.invalidColor', [$type, $color])); } + + /** + * Thrown when a prompt must ask again but the input has ended. + */ + public static function forInputEnded(): static + { + return new static(lang('CLI.inputEnded')); + } } diff --git a/system/CLI/InputOutput.php b/system/CLI/InputOutput.php index 893b98a24595..30cd67adc0d4 100644 --- a/system/CLI/InputOutput.php +++ b/system/CLI/InputOutput.php @@ -23,6 +23,8 @@ class InputOutput */ private readonly bool $readlineSupport; + private bool $atEnd = false; + public function __construct() { // Readline is an extension for PHP that makes interactivity with PHP @@ -51,7 +53,8 @@ public function input(?string $prefix = null): string self::fwrite(STDOUT, $prefix ?? ''); } - $input = readline($prompt); + $input = readline($prompt); + $this->atEnd = $input === false && (! defined('STDIN') || ! stream_isatty(STDIN)); return $input === false ? '' : $input; // @codeCoverageIgnoreEnd @@ -60,7 +63,9 @@ public function input(?string $prefix = null): string // self:: skips MockInputOutput's fwrite override, whose filter bookkeeping must not nest inside input(). self::fwrite(STDOUT, $prefix ?? ''); - $input = fgets(fopen('php://stdin', 'rb')); + $stdin = fopen('php://stdin', 'rb'); + $input = fgets($stdin); + $this->atEnd = $input === false && ! stream_isatty($stdin); if ($input === false) { return ''; @@ -69,6 +74,16 @@ public function input(?string $prefix = null): string return $input; } + /** + * Whether the last `input()` found the end of an input other than a terminal, + * so that no later read can bring an answer. At a terminal, `Ctrl+D` ends only + * the current read, and `input()` returns an empty string as for an empty line. + */ + public function isAtEnd(): bool + { + return $this->atEnd; + } + /** * While the library is intended for use on CLI commands, * commands can be called from controllers and elsewhere diff --git a/system/Language/en/CLI.php b/system/Language/en/CLI.php index 822186dba926..c2155c0f08bd 100644 --- a/system/Language/en/CLI.php +++ b/system/Language/en/CLI.php @@ -53,6 +53,7 @@ 'helpDescription' => 'Description:', 'helpOptions' => 'Options:', 'helpUsage' => 'Usage:', + 'inputEnded' => 'The input ended before a valid answer was given.', 'invalidColor' => 'Invalid "{0}" color: "{1}".', 'namespaceNotDefined' => 'Namespace "{0}" is not defined.', 'signals' => [ diff --git a/system/Test/Mock/MockInputOutput.php b/system/Test/Mock/MockInputOutput.php index 9bf8a4f54810..6f6b49674d5e 100644 --- a/system/Test/Mock/MockInputOutput.php +++ b/system/Test/Mock/MockInputOutput.php @@ -160,6 +160,14 @@ public function input(?string $prefix = null): string return $input; } + /** + * Whether the scripted answers have run out, so that a scripted empty answer before the last stays an empty line. + */ + public function isAtEnd(): bool + { + return $this->inputs === []; + } + public function fwrite($handle, string $string): void { $this->addStreamFilters(); diff --git a/system/Test/PhpStreamWrapper.php b/system/Test/PhpStreamWrapper.php index 927821e8b4dd..65930a66cd81 100644 --- a/system/Test/PhpStreamWrapper.php +++ b/system/Test/PhpStreamWrapper.php @@ -60,6 +60,14 @@ public function stream_open(): bool return true; } + /** + * Returns `false`, since the stream has no descriptor. + */ + public function stream_cast(): false + { + return false; + } + /** * @return string */ diff --git a/tests/system/CLI/CLITest.php b/tests/system/CLI/CLITest.php index 2557adbc41b1..de58b6cede9b 100644 --- a/tests/system/CLI/CLITest.php +++ b/tests/system/CLI/CLITest.php @@ -13,6 +13,7 @@ namespace CodeIgniter\CLI; +use CodeIgniter\CLI\Exceptions\CLIException; use CodeIgniter\Config\Services; use CodeIgniter\Exceptions\RuntimeException; use CodeIgniter\Superglobals; @@ -448,6 +449,61 @@ public function testPromptByMultipleKeysDefaultsToTheFirstKey(): void $this->assertStringNotContainsString('Please select correctly.', $io->getOutput()); } + public function testInputReportsTheEndOfTheInput(): void + { + $io = new InputOutput(); + + PhpStreamWrapper::register(); + PhpStreamWrapper::setContent('red'); + $io->input(); + $this->assertFalse($io->isAtEnd()); + + PhpStreamWrapper::setContent(''); + $io->input(); + $this->assertTrue($io->isAtEnd()); + + PhpStreamWrapper::restore(); + } + + /** + * @param list $inputs + * @param callable(): mixed $prompt + */ + #[DataProvider('providePromptsStopAskingWhenTheInputEnds')] + public function testPromptsStopAskingWhenTheInputEnds(array $inputs, callable $prompt): void + { + $io = new MockInputOutput(); + $io->setInputs($inputs); + CLI::setInputOutput($io); + + $this->expectException(CLIException::class); + $this->expectExceptionMessage('The input ended before a valid answer was given.'); + + $prompt(); + } + + /** + * @return iterable, callable(): mixed}> + */ + public static function providePromptsStopAskingWhenTheInputEnds(): iterable + { + yield 'prompt' => [[''], static fn (): string => CLI::prompt('Name', null, 'required')]; + + yield 'promptByKey' => [['c'], static fn (): string => CLI::promptByKey('Pick:', ['a' => 'Apple', 'b' => 'Banana'], 'in_list[b]')]; + + yield 'promptByMultipleKeys' => [['9'], static fn (): array => CLI::promptByMultipleKeys('Pick:', ['a' => 'Apple', 'b' => 'Banana'])]; + } + + public function testMockInputOutputKeepsAnEmptyAnswerBeforeTheLastAsAnEmptyLine(): void + { + $io = new MockInputOutput(); + $io->setInputs(['', 'red']); + CLI::setInputOutput($io); + + $this->assertSame('red', CLI::prompt('Color', null, 'required')); + $this->assertTrue($io->isAtEnd()); + } + public function testNewLine(): void { $this->expectOutputString(''); diff --git a/user_guide_src/source/changelogs/v4.7.6.rst b/user_guide_src/source/changelogs/v4.7.6.rst index 4c36d122395f..0301001713b6 100644 --- a/user_guide_src/source/changelogs/v4.7.6.rst +++ b/user_guide_src/source/changelogs/v4.7.6.rst @@ -18,6 +18,7 @@ BREAKING Message Changes *************** +- Added the ``CLI.inputEnded`` language string. - Added the ``Format.invalidXMLValue`` language string. ******* @@ -35,6 +36,7 @@ Bugs Fixed ********** - **CLI:** Fixed a bug where ``CLI::promptByMultipleKeys()`` accepted a key missing from the options when it was below the last key, returning an empty selection, rejected a key above the last one when the options were not in key order, and offered ``0`` as the default when the first key was not ``0``. +- **CLI:** Fixed a bug where a prompt asked again forever when its input was piped or redirected and ran out before a valid answer: ``CLI::prompt()`` and ``CLI::promptByKey()`` whose default fails the validation rules, and ``CLI::promptByMultipleKeys()`` whose options have string keys. They now throw a ``CLIException``. The new ``InputOutput::isAtEnd()`` tells that end of the input from an empty line. Pressing Ctrl+D at a terminal still asks again. - **Commands:** Fixed a bug where generator commands did not sort the imports of a template with CRLF line endings. - **Format:** Fixed ``XMLFormatter`` so that an array key that becomes empty after sanitization no longer triggers a ``ValueError``; the fallback tag name ``item`` is used instead. Values that cannot be converted to a string, such as objects not implementing ``Stringable``, now throw a ``FormatException`` instead of a fatal ``Error``. - **Helpers:** Fixed a regression in v4.7.5 where ``set_value()`` threw a ``TypeError`` when the default value was not a string or an array, such as an integer, a float, ``null``, or a ``Stringable`` object. Such values are now converted to a string.