diff --git a/Command/Command.php b/Command/Command.php index 6d6522869..34c0ef350 100644 --- a/Command/Command.php +++ b/Command/Command.php @@ -427,8 +427,6 @@ public function setName(string $name) * This feature should be used only when creating a long process command, * like a daemon. * - * PHP 5.5+ or the proctitle PECL library is required - * * @return $this */ public function setProcessTitle(string $title) diff --git a/Helper/QuestionHelper.php b/Helper/QuestionHelper.php index 87437250a..2fb71c7f1 100644 --- a/Helper/QuestionHelper.php +++ b/Helper/QuestionHelper.php @@ -34,6 +34,7 @@ class QuestionHelper extends Helper private $inputStream; private static $shell; private static $stty = true; + private static $stdinIsInteractive; /** * Asks a question to the user. @@ -107,6 +108,11 @@ private function doAsk(OutputInterface $output, Question $question) $inputStream = $this->inputStream ?: STDIN; $autocomplete = $question->getAutocompleterCallback(); + if (\function_exists('sapi_windows_cp_set')) { + // Codepage used by cmd.exe on Windows to allow special characters (éàüñ). + sapi_windows_cp_set(1252); + } + if (null === $autocomplete || !self::$stty || !Terminal::hasSttyAvailable()) { $ret = false; if ($question->isHidden()) { @@ -417,33 +423,26 @@ private function getHiddenResponse(OutputInterface $output, $inputStream, bool $ if (self::$stty && Terminal::hasSttyAvailable()) { $sttyMode = shell_exec('stty -g'); - shell_exec('stty -echo'); - $value = fgets($inputStream, 4096); - shell_exec(sprintf('stty %s', $sttyMode)); + } elseif ($this->isInteractiveInput($inputStream)) { + throw new RuntimeException('Unable to hide the response.'); + } - if (false === $value) { - throw new MissingInputException('Aborted.'); - } - if ($trimmable) { - $value = trim($value); - } - $output->writeln(''); + $value = fgets($inputStream, 4096); - return $value; + if (self::$stty && Terminal::hasSttyAvailable()) { + shell_exec(sprintf('stty %s', $sttyMode)); } - if (false !== $shell = $this->getShell()) { - $readCmd = 'csh' === $shell ? 'set mypassword = $<' : 'read -r mypassword'; - $command = sprintf("/usr/bin/env %s -c 'stty -echo; %s; stty echo; echo \$mypassword' 2> /dev/null", $shell, $readCmd); - $sCommand = shell_exec($command); - $value = $trimmable ? rtrim($sCommand) : $sCommand; - $output->writeln(''); - - return $value; + if (false === $value) { + throw new MissingInputException('Aborted.'); } + if ($trimmable) { + $value = trim($value); + } + $output->writeln(''); - throw new RuntimeException('Unable to hide the response.'); + return $value; } /** @@ -471,52 +470,35 @@ private function validateAttempts(callable $interviewer, OutputInterface $output throw $e; } catch (\Exception $error) { } - - $attempts = $attempts ?? -(int) $this->isTty(); } throw $error; } - /** - * Returns a valid unix shell. - * - * @return string|bool The valid shell name, false in case no valid shell is found - */ - private function getShell() + private function isInteractiveInput($inputStream): bool { - if (null !== self::$shell) { - return self::$shell; + if ('php://stdin' !== (stream_get_meta_data($inputStream)['uri'] ?? null)) { + return false; } - 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 (['bash', 'zsh', 'ksh', 'csh'] as $sh) { - if ('OK' === rtrim(shell_exec(sprintf($test, $sh)))) { - self::$shell = $sh; - break; - } - } + if (null !== self::$stdinIsInteractive) { + return self::$stdinIsInteractive; } - return self::$shell; - } - - private function isTty(): bool - { - $inputStream = !$this->inputStream && \defined('STDIN') ? STDIN : $this->inputStream; - if (\function_exists('stream_isatty')) { - return stream_isatty($inputStream); + return self::$stdinIsInteractive = stream_isatty(fopen('php://stdin', 'r')); } if (\function_exists('posix_isatty')) { - return posix_isatty($inputStream); + return self::$stdinIsInteractive = posix_isatty(fopen('php://stdin', 'r')); } - return true; + if (!\function_exists('exec')) { + return self::$stdinIsInteractive = true; + } + + exec('stty 2> /dev/null', $output, $status); + + return self::$stdinIsInteractive = 1 !== $status; } } diff --git a/Terminal.php b/Terminal.php index b8cc04e2e..5e5a3c2f7 100644 --- a/Terminal.php +++ b/Terminal.php @@ -66,6 +66,11 @@ public static function hasSttyAvailable() return self::$stty; } + // skip check if exec function is disabled + if (!\function_exists('exec')) { + return false; + } + exec('stty 2>&1', $output, $exitcode); return self::$stty = 0 === $exitcode; diff --git a/Tests/Helper/QuestionHelperTest.php b/Tests/Helper/QuestionHelperTest.php index ddb0c90e1..094559e8c 100644 --- a/Tests/Helper/QuestionHelperTest.php +++ b/Tests/Helper/QuestionHelperTest.php @@ -11,6 +11,7 @@ namespace Symfony\Component\Console\Tests\Helper; +use Symfony\Component\Console\Application; use Symfony\Component\Console\Exception\InvalidArgumentException; use Symfony\Component\Console\Formatter\OutputFormatter; use Symfony\Component\Console\Helper\FormatterHelper; @@ -21,6 +22,7 @@ use Symfony\Component\Console\Question\ConfirmationQuestion; use Symfony\Component\Console\Question\Question; use Symfony\Component\Console\Terminal; +use Symfony\Component\Console\Tester\ApplicationTester; /** * @group tty @@ -727,21 +729,36 @@ public function testAskThrowsExceptionOnMissingInputWithValidator() $dialog->ask($this->createStreamableInputInterfaceMock($this->getInputStream('')), $this->createOutputInterface(), $question); } - public function testAskThrowsExceptionFromValidatorEarlyWhenTtyIsMissing() + public function testQuestionValidatorRepeatsThePrompt() { - $this->expectException('Exception'); - $this->expectExceptionMessage('Bar, not Foo'); + $tries = 0; + $application = new Application(); + $application->setAutoExit(false); + $application->register('question') + ->setCode(function ($input, $output) use (&$tries) { + $question = new Question('This is a promptable question'); + $question->setValidator(function ($value) use (&$tries) { + ++$tries; + if (!$value) { + throw new \Exception(); + } - $output = $this->getMockBuilder('\Symfony\Component\Console\Output\OutputInterface')->getMock(); - $output->expects($this->once())->method('writeln'); + return $value; + }); + + (new QuestionHelper())->ask($input, $output, $question); - (new QuestionHelper())->ask( - $this->createStreamableInputInterfaceMock($this->getInputStream('Foo'), true), - $output, - (new Question('Q?'))->setHidden(true)->setValidator(function ($input) { - throw new \Exception("Bar, not $input"); + return 0; }) - ); + ; + + $tester = new ApplicationTester($application); + $tester->setInputs(['', 'not-empty']); + + $statusCode = $tester->run(['command' => 'question'], ['interactive' => true]); + + $this->assertSame(2, $tries); + $this->assertSame($statusCode, 0); } public function testEmptyChoices() @@ -784,7 +801,7 @@ public function testTraversableAutocomplete() $this->assertEquals('FooBundle', $dialog->ask($this->createStreamableInputInterfaceMock($inputStream), $this->createOutputInterface(), $question)); } - public function testDisableSttby() + public function testDisableStty() { if (!Terminal::hasSttyAvailable()) { $this->markTestSkipped('`stty` is required to test autocomplete functionality');