From d674ad8f90bccdacf8b9df3ee6986cc93a9d8d8d Mon Sep 17 00:00:00 2001 From: Hafiz Muhammad Moaz Date: Fri, 28 Aug 2026 04:13:19 +0500 Subject: [PATCH 1/5] feat: add masked() prompt helper that echoes a mask character (fixes #16) --- src/CLI.php | 48 ++++++++++++++++++++++++++ tests/MaskedTest.php | 81 ++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 129 insertions(+) create mode 100644 tests/MaskedTest.php diff --git a/src/CLI.php b/src/CLI.php index a4618ab..c593663 100644 --- a/src/CLI.php +++ b/src/CLI.php @@ -573,6 +573,54 @@ public static function secret(string $question) : string return \trim((string) \fgets(\STDIN)); } + /** + * Prompt a question with a masked answer. + * + * Instead of disabling the terminal echo like secret(), each typed + * character is echoed as the given mask character. On Windows or when + * the required POSIX functions are unavailable, input is still read + * normally and simply not echoed. + * + * @param string $question The question to prompt + * @param string $mask The character echoed for each typed character + * + * @return string The masked answer + */ + public static function masked(string $question, string $mask = '*') : string + { + $question .= ': '; + \fwrite(\STDOUT, $question); + if (static::isWindows() || !\function_exists('shell_exec')) { + return \trim((string) \fgets(\STDIN)); + } + @\shell_exec('stty -echo'); + $answer = ''; + try { + while (true) { + $char = \fgetc(\STDIN); + if ($char === false || $char === "\n") { + break; + } + if ($char === "\r") { + // consume the \n that follows on Windows-style line endings + \fgetc(\STDIN); + break; + } + if ($char === "\x7f" || $char === '\\b') { + $answer = \substr($answer, 0, -1); + \fwrite(\STDOUT, '\\b \\b'); + continue; + } + $answer .= $char; + \fwrite(\STDOUT, $mask); + } + \fwrite(\STDOUT, \PHP_EOL); + return $answer; + } finally { + @\shell_exec('stty echo'); + } + } + /** * Creates a well formatted table. * diff --git a/tests/MaskedTest.php b/tests/MaskedTest.php new file mode 100644 index 0000000..d47b76a --- /dev/null +++ b/tests/MaskedTest.php @@ -0,0 +1,81 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ +namespace Tests\CLI; + +use Framework\CLI\CLI; +use PHPUnit\Framework\TestCase; + +/** + * Class MaskedTest. + */ +final class MaskedTest extends TestCase +{ + /** + * Runs masked() in a real POSIX subprocess: each typed character must be + * echoed as the mask character and the real answer returned. + */ + public function testMaskedEchoesMaskCharactersOnPosix() : void + { + if (CLI::isWindows()) { + self::markTestSkipped('Requires a POSIX terminal for stty based masking.'); + } + $script = <<<'PHP' + use Framework\CLI\CLI; + + $answer = CLI::masked('Token', '#'); + \fwrite(\STDOUT, 'answer=' . $answer); + PHP; + [$exitCode, $output] = $this->runScript($script, "s3cret\n"); + self::assertSame(0, $exitCode); + self::assertStringContainsString('Token: ######', $output); + self::assertStringContainsString('answer=s3cret', $output); + self::assertStringNotContainsString('s3cret', \str_replace('answer=s3cret', '', $output)); + } + + public function testMaskedCustomMaskCharacter() : void + { + if (CLI::isWindows()) { + self::markTestSkipped('Requires a POSIX terminal for stty based masking.'); + } + $script = <<<'PHP' + use Framework\CLI\CLI; + + $answer = CLI::masked('Pin', 'x'); + \fwrite(\STDOUT, 'answer=' . $answer); + PHP; + [$exitCode, $output] = $this->runScript($script, "1234\n"); + self::assertSame(0, $exitCode); + self::assertStringContainsString('Pin: xxxx', $output); + self::assertStringContainsString('answer=1234', $output); + } + + /** + * Run a script reading from a piped STDIN as a real PHP process. + * + * @return array{0:int,1:string} + */ + private function runScript(string $code, string $stdin) : array + { + $autoloader = \dirname(__DIR__) . '/vendor/autoload.php'; + $file = \sys_get_temp_dir() . '/webisters-cli-masked-' . \uniqid() . '.php'; + \file_put_contents( + $file, + '&1'; + \exec($command, $output, $exitCode); + + \unlink($file); + + return [$exitCode, \implode("\n", $output)]; + } +} From aaee745251034af245cdaa09544d414967b34982 Mon Sep 17 00:00:00 2001 From: Hafiz Muhammad Moaz Date: Fri, 28 Aug 2026 04:21:46 +0500 Subject: [PATCH 2/5] fix: simplify masked input handling and validate() return --- src/CLI.php | 21 ++++++++++++++------- src/Command.php | 3 +-- tests/MaskedTest.php | 28 +++++++++++++++++++--------- 3 files changed, 34 insertions(+), 18 deletions(-) diff --git a/src/CLI.php b/src/CLI.php index c593663..0821454 100644 --- a/src/CLI.php +++ b/src/CLI.php @@ -590,25 +590,32 @@ public static function masked(string $question, string $mask = '*') : string { $question .= ': '; \fwrite(\STDOUT, $question); - if (static::isWindows() || !\function_exists('shell_exec')) { + if (static::isWindows() + || !\function_exists('shell_exec') + || !\function_exists('stream_isatty') + || !\stream_isatty(\STDIN) + ) { + // Without an interactive TTY the echo cannot be controlled, + // so fall back to a plain line read. return \trim((string) \fgets(\STDIN)); } - @\shell_exec('stty -echo'); + @\shell_exec('stty -echo 2>/dev/null'); $answer = ''; try { while (true) { $char = \fgetc(\STDIN); - if ($char === false || $char === "\n") { + $code = $char === false ? 0 : \ord($char); + if ($char === false || $code === 10) { break; } - if ($char === "\r") { + if ($code === 13) { // consume the \n that follows on Windows-style line endings \fgetc(\STDIN); break; } - if ($char === "\x7f" || $char === '\\b') { + if ($code === 127 || $code === 8) { $answer = \substr($answer, 0, -1); - \fwrite(\STDOUT, '\\b \\b'); + \fwrite(\STDOUT, \chr(8) . ' ' . \chr(8)); continue; } $answer .= $char; @@ -617,7 +624,7 @@ public static function masked(string $question, string $mask = '*') : string \fwrite(\STDOUT, \PHP_EOL); return $answer; } finally { - @\shell_exec('stty echo'); + @\shell_exec('stty echo 2>/dev/null'); } } diff --git a/src/Command.php b/src/Command.php index 7e051dd..9a14fb2 100644 --- a/src/Command.php +++ b/src/Command.php @@ -333,11 +333,10 @@ public function setOptionDefinitions(array $definitions) : static */ public function validate(array $arguments, array $options) : array { - $errors = \array_merge( + return \array_merge( $this->validateDefinitions($this->argumentDefinitions, $arguments, 'argument'), $this->validateDefinitions($this->optionDefinitions, $options, 'option') ); - return $errors; } /** diff --git a/tests/MaskedTest.php b/tests/MaskedTest.php index d47b76a..2f2183e 100644 --- a/tests/MaskedTest.php +++ b/tests/MaskedTest.php @@ -18,42 +18,52 @@ final class MaskedTest extends TestCase { /** - * Runs masked() in a real POSIX subprocess: each typed character must be - * echoed as the mask character and the real answer returned. + * Runs masked() in a real POSIX subprocess on a pty: each typed character + * must be echoed as the mask character and the real answer returned. */ public function testMaskedEchoesMaskCharactersOnPosix() : void { if (CLI::isWindows()) { self::markTestSkipped('Requires a POSIX terminal for stty based masking.'); } + \exec('command -v script 2>/dev/null', $check, $found); + if ($found !== 0 || $check === []) { + self::markTestSkipped('The script command is required to emulate a TTY.'); + } $script = <<<'PHP' use Framework\CLI\CLI; $answer = CLI::masked('Token', '#'); \fwrite(\STDOUT, 'answer=' . $answer); PHP; - [$exitCode, $output] = $this->runScript($script, "s3cret\n"); + [$exitCode, $output] = $this->runScript($script, 'pty', 's3cret'); + $output = \str_replace("\r", '', $output); self::assertSame(0, $exitCode); self::assertStringContainsString('Token: ######', $output); self::assertStringContainsString('answer=s3cret', $output); self::assertStringNotContainsString('s3cret', \str_replace('answer=s3cret', '', $output)); } - public function testMaskedCustomMaskCharacter() : void + /** + * Without a TTY, masked() reads the line normally: the answer is + * returned but no mask characters are echoed. + */ + public function testMaskedWithoutTtyFallsBackToPlainRead() : void { if (CLI::isWindows()) { - self::markTestSkipped('Requires a POSIX terminal for stty based masking.'); + self::markTestSkipped('Requires a POSIX pipe for the no TTY fallback.'); } $script = <<<'PHP' use Framework\CLI\CLI; - $answer = CLI::masked('Pin', 'x'); + $answer = CLI::masked('Token', '#'); \fwrite(\STDOUT, 'answer=' . $answer); PHP; - [$exitCode, $output] = $this->runScript($script, "1234\n"); + [$exitCode, $output] = $this->runScript($script, 'pipe', 's3cret'); + $output = \str_replace("\r", '', $output); self::assertSame(0, $exitCode); - self::assertStringContainsString('Pin: xxxx', $output); - self::assertStringContainsString('answer=1234', $output); + self::assertStringNotContainsString('######', $output); + self::assertStringContainsString('answer=s3cret', $output); } /** From 631ca98b15eadf19fbd753a41558ee6fb96f1d67 Mon Sep 17 00:00:00 2001 From: Hafiz Muhammad Moaz Date: Fri, 28 Aug 2026 04:24:08 +0500 Subject: [PATCH 3/5] fix: implement pty mode in masked input test harness --- tests/MaskedTest.php | 18 ++++++++++++++---- 1 file changed, 14 insertions(+), 4 deletions(-) diff --git a/tests/MaskedTest.php b/tests/MaskedTest.php index 2f2183e..2f47164 100644 --- a/tests/MaskedTest.php +++ b/tests/MaskedTest.php @@ -67,11 +67,16 @@ public function testMaskedWithoutTtyFallsBackToPlainRead() : void } /** - * Run a script reading from a piped STDIN as a real PHP process. + * Run a script as a real PHP process, either with STDIN as a plain pipe + * or attached to a pseudo terminal (via the script utility) so stty + * based masking can be exercised. + * + * @param string $stdin The text fed to the script on STDIN + * @param string $mode 'pipe' or 'pty' * * @return array{0:int,1:string} */ - private function runScript(string $code, string $stdin) : array + private function runScript(string $code, string $stdin, string $mode = 'pipe') : array { $autoloader = \dirname(__DIR__) . '/vendor/autoload.php'; $file = \sys_get_temp_dir() . '/webisters-cli-masked-' . \uniqid() . '.php'; @@ -80,8 +85,13 @@ private function runScript(string $code, string $stdin) : array '&1'; + $php = \PHP_BINARY . ' ' . \escapeshellarg($file); + if ($mode === 'pty') { + $command = 'printf %s\\\\n ' . \escapeshellarg($stdin) + . ' | script -qec ' . \escapeshellarg($php) . ' /dev/null 2>&1'; + } else { + $command = 'echo ' . \escapeshellarg($stdin) . ' | ' . $php . ' 2>&1'; + } \exec($command, $output, $exitCode); \unlink($file); From 1a08e70b6a325dfdcfee4feceba4eb8bf4d912b5 Mon Sep 17 00:00:00 2001 From: Hafiz Muhammad Moaz Date: Fri, 28 Aug 2026 04:25:47 +0500 Subject: [PATCH 4/5] fix: pass stdin and mode arguments in the correct order --- tests/MaskedTest.php | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/tests/MaskedTest.php b/tests/MaskedTest.php index 2f47164..6ffdd90 100644 --- a/tests/MaskedTest.php +++ b/tests/MaskedTest.php @@ -36,7 +36,7 @@ public function testMaskedEchoesMaskCharactersOnPosix() : void $answer = CLI::masked('Token', '#'); \fwrite(\STDOUT, 'answer=' . $answer); PHP; - [$exitCode, $output] = $this->runScript($script, 'pty', 's3cret'); + [$exitCode, $output] = $this->runScript($script, 's3cret', 'pty'); $output = \str_replace("\r", '', $output); self::assertSame(0, $exitCode); self::assertStringContainsString('Token: ######', $output); @@ -59,7 +59,7 @@ public function testMaskedWithoutTtyFallsBackToPlainRead() : void $answer = CLI::masked('Token', '#'); \fwrite(\STDOUT, 'answer=' . $answer); PHP; - [$exitCode, $output] = $this->runScript($script, 'pipe', 's3cret'); + [$exitCode, $output] = $this->runScript($script, 's3cret', 'pipe'); $output = \str_replace("\r", '', $output); self::assertSame(0, $exitCode); self::assertStringNotContainsString('######', $output); @@ -87,7 +87,7 @@ private function runScript(string $code, string $stdin, string $mode = 'pipe') : $php = \PHP_BINARY . ' ' . \escapeshellarg($file); if ($mode === 'pty') { - $command = 'printf %s\\\\n ' . \escapeshellarg($stdin) + $command = 'printf %s\\\n ' . \escapeshellarg($stdin) . ' | script -qec ' . \escapeshellarg($php) . ' /dev/null 2>&1'; } else { $command = 'echo ' . \escapeshellarg($stdin) . ' | ' . $php . ' 2>&1'; From 195428e81b7d2d81edf5c0b6995024917e490a74 Mon Sep 17 00:00:00 2001 From: Hafiz Muhammad Moaz Date: Fri, 28 Aug 2026 04:28:24 +0500 Subject: [PATCH 5/5] fix: scope no echo assertion to the prompt line --- tests/MaskedTest.php | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tests/MaskedTest.php b/tests/MaskedTest.php index 6ffdd90..10221d9 100644 --- a/tests/MaskedTest.php +++ b/tests/MaskedTest.php @@ -41,7 +41,8 @@ public function testMaskedEchoesMaskCharactersOnPosix() : void self::assertSame(0, $exitCode); self::assertStringContainsString('Token: ######', $output); self::assertStringContainsString('answer=s3cret', $output); - self::assertStringNotContainsString('s3cret', \str_replace('answer=s3cret', '', $output)); + // The answer must not appear as an echo after the prompt. + self::assertStringNotContainsString('Token: s3cret', $output); } /**