diff --git a/src/Symfony/Component/Console/CHANGELOG.md b/src/Symfony/Component/Console/CHANGELOG.md index 9d3466d70967e..a5f1722baee9f 100644 --- a/src/Symfony/Component/Console/CHANGELOG.md +++ b/src/Symfony/Component/Console/CHANGELOG.md @@ -5,6 +5,7 @@ CHANGELOG ----- * added support for colorization on Windows via ConEmu + * add a method to Dialog Helper to ask for a question and hide the response 2.1.0 ----- diff --git a/src/Symfony/Component/Console/Helper/DialogHelper.php b/src/Symfony/Component/Console/Helper/DialogHelper.php index e501de114f182..26178975c5c14 100644 --- a/src/Symfony/Component/Console/Helper/DialogHelper.php +++ b/src/Symfony/Component/Console/Helper/DialogHelper.php @@ -21,6 +21,8 @@ class DialogHelper extends Helper { private $inputStream; + private static $shell; + private static $stty; /** * Asks a question to the user. @@ -71,6 +73,76 @@ public function askConfirmation(OutputInterface $output, $question, $default = t return !$answer || 'y' == strtolower($answer[0]); } + /** + * Asks a question to the user, the response is hidden + * + * @param OutputInterface $output An Output instance + * @param string|array $question The question + * @param Boolean $fallback In case the response can not be hidden, whether to fallback on non-hidden question or not + * + * @return string The answer + * + * @throws \RuntimeException In case the fallback is deactivated and the response can not be hidden + */ + public function askHiddenResponse(OutputInterface $output, $question, $fallback = true) + { + if (defined('PHP_WINDOWS_VERSION_BUILD')) { + $exe = __DIR__ . '/../../Resources/bin/hiddeninput.exe'; + + // handle code running from a phar + if ('phar:' === substr(__FILE__, 0, 5)) { + $tmpExe = sys_get_temp_dir() . '/../../Resources/bin/hiddeninput.exe'; + copy($exe, $tmpExe); + $exe = $tmpExe; + } + + $output->write($question); + $value = rtrim(shell_exec($exe)); + $output->writeln(''); + + if (isset($tmpExe)) { + unlink($tmpExe); + } + + return $value; + } + + if ($this->hasSttyAvailable()) { + $output->write($question); + + $sttyMode = shell_exec('/usr/bin/env stty -g'); + + shell_exec('/usr/bin/env stty -echo'); + $value = fgets($this->inputStream ?: STDIN, 4096); + shell_exec(sprintf('/usr/bin/env stty %s', $sttyMode)); + + if (false === $value) { + throw new \RuntimeException('Aborted'); + } + + $value = trim($value); + $output->writeln(''); + + return $value; + } + + if (false !== $shell = $this->getShell()) { + $output->write($question); + $readCmd = $shell === 'csh' ? 'set mypassword = $<' : 'read mypassword'; + $command = sprintf("/usr/bin/env %s -c 'stty -echo; %s; stty echo; echo \$mypassword'", $shell, $readCmd); + $value = rtrim(shell_exec($command)); + $output->writeln(''); + + return $value; + } + + if ($fallback) { + return $this->ask($output, $question); + } + + throw new \RuntimeException('Unable to hide the response'); + } + /** * Asks for a value and validates the response. * @@ -80,7 +152,7 @@ public function askConfirmation(OutputInterface $output, $question, $default = t * * @param OutputInterface $output An Output instance * @param string|array $question The question to ask - * @param callback $validator A PHP callback + * @param callable $validator A PHP callback * @param integer $attempts Max number of times to ask before giving up (false by default, which means infinite) * @param string $default The default answer if none is given by the user * @@ -90,21 +162,43 @@ public function askConfirmation(OutputInterface $output, $question, $default = t */ public function askAndValidate(OutputInterface $output, $question, $validator, $attempts = false, $default = null) { - $error = null; - while (false === $attempts || $attempts--) { - if (null !== $error) { - $output->writeln($this->getHelperSet()->get('formatter')->formatBlock($error->getMessage(), 'error')); - } + $that = $this; - $value = $this->ask($output, $question, $default); + $interviewer = function() use ($output, $question, $default, $that) { + return $that->ask($output, $question, $default); + }; - try { - return call_user_func($validator, $value); - } catch (\Exception $error) { - } - } + return $this->validateAttempts($interviewer, $output, $validator, $attempts); + } - throw $error; + /** + * Asks for a value, hide and validates the response. + * + * The validator receives the data to validate. It must return the + * validated data when the data is valid and throw an exception + * otherwise. + * + * @param OutputInterface $output An Output instance + * @param string|array $question The question to ask + * @param callable $validator A PHP callback + * @param integer $attempts Max number of times to ask before giving up (false by default, which means infinite) + * @param Boolean $fallback In case the response can not be hidden, whether to fallback on non-hidden question or not + * + * @return string The response + * + * @throws \Exception When any of the validators return an error + * @throws \RuntimeException In case the fallback is deactivated and the response can not be hidden + * + */ + public function askHiddenResponseAndValidate(OutputInterface $output, $question, $validator, $attempts = false, $fallback = true) + { + $that = $this; + + $interviewer = function() use ($output, $question, $fallback, $that) { + return $that->askHiddenResponse($output, $question, $fallback); + }; + + return $this->validateAttempts($interviewer, $output, $validator, $attempts); } /** @@ -136,4 +230,71 @@ public function getName() { return 'dialog'; } + + /** + * Return a valid unix shell + * + * @return string|false The valid shell name, false in case no valid shell is found + */ + private function getShell() + { + if (null !== self::$shell) { + return self::$shell; + } + + self::$shell = false; + + if (file_exists('/usr/bin/env')) { + // handle other OSs with bash/zsh/ksh/csh if available to hide the answer + $test = "/usr/bin/env %s -c 'echo OK' 2> /dev/null"; + foreach (array('bash', 'zsh', 'ksh', 'csh') as $sh) { + if ('OK' === rtrim(shell_exec(sprintf($test, $sh)))) { + self::$shell = $sh; + break; + } + } + } + + return self::$shell; + } + + private function hasSttyAvailable() + { + if (null !== self::$stty) { + return self::$stty; + } + + exec('/usr/bin/env stty', $output, $exicode); + + return self::$stty = $exicode === 0; + } + + /** + * Validate an attempt + * + * @param callable $interviewer A callable that will ask for a question and return the result + * @param OutputInterface $output An Output instance + * @param callable $validator A PHP callback + * @param integer $attempts Max number of times to ask before giving up ; false will ask infinitely + * + * @return string The validated response + * + * @throws \Exception In case the max number of attempts has been reached and no valid response has been given + */ + private function validateAttempts($interviewer, OutputInterface $output, $validator, $attempts) + { + $error = null; + while (false === $attempts || $attempts--) { + if (null !== $error) { + $output->writeln($this->getHelperSet()->get('formatter')->formatBlock($error->getMessage(), 'error')); + } + + try { + return call_user_func($validator, $interviewer()); + } catch (\Exception $error) { + } + } + + throw $error; + } } diff --git a/src/Symfony/Component/Console/README.md b/src/Symfony/Component/Console/README.md index d81795219d19f..4f234e034d082 100644 --- a/src/Symfony/Component/Console/README.md +++ b/src/Symfony/Component/Console/README.md @@ -41,12 +41,18 @@ output abstractions (so that you can easily unit-test your commands), validation, automatic help messages, ... Tests ---------- +----- You can run the unit tests with the following command: phpunit +Third Party +----------- + +`Resources/bin/hiddeninput.exe` is a third party binary provided within this +component. Find sources and license at https://github.com/Seldaek/hidden-input. + Resources --------- diff --git a/src/Symfony/Component/Console/Resources/bin/hiddeninput.exe b/src/Symfony/Component/Console/Resources/bin/hiddeninput.exe new file mode 100644 index 0000000000000..c8cf65e8d819e Binary files /dev/null and b/src/Symfony/Component/Console/Resources/bin/hiddeninput.exe differ diff --git a/src/Symfony/Component/Console/Tests/Helper/DialogHelperTest.php b/src/Symfony/Component/Console/Tests/Helper/DialogHelperTest.php index a486f620954be..658ab69b4cbc9 100644 --- a/src/Symfony/Component/Console/Tests/Helper/DialogHelperTest.php +++ b/src/Symfony/Component/Console/Tests/Helper/DialogHelperTest.php @@ -31,6 +31,19 @@ public function testAsk() $this->assertEquals('What time is it?', stream_get_contents($output->getStream())); } + public function testAskHiddenResponse() + { + if (defined('PHP_WINDOWS_VERSION_BUILD')) { + $this->markTestSkipped('This test is not supported on Windows'); + } + + $dialog = new DialogHelper(); + + $dialog->setInputStream($this->getInputStream("8AM\n")); + + $this->assertEquals('8AM', $dialog->askHiddenResponse($this->getOutputStream(), 'What time is it?')); + } + public function testAskConfirmation() { $dialog = new DialogHelper();