|
14 | 14 | */ |
15 | 15 | class StdinInputReader implements IInputReader |
16 | 16 | { |
| 17 | + /** |
| 18 | + * Maximum number of retry attempts for invalid input. |
| 19 | + * After this many failed attempts, the method will fall back to the default |
| 20 | + * or throw an exception if no default is provided. |
| 21 | + */ |
| 22 | + private const MAX_RETRY_ATTEMPTS = 10; |
| 23 | + |
17 | 24 | /** |
18 | 25 | * @param Output $output Output instance for displaying prompts |
19 | 26 | */ |
@@ -55,67 +62,117 @@ public function secret( string $message ): string |
55 | 62 | { |
56 | 63 | $this->output->write( $message, false ); |
57 | 64 |
|
58 | | - // Only hide input on Unix-like systems |
59 | | - if( strtoupper( substr( PHP_OS, 0, 3 ) ) !== 'WIN' ) { |
60 | | - // Disable terminal echo |
61 | | - system( 'stty -echo' ); |
62 | | - $input = fgets( STDIN ); |
63 | | - // Re-enable terminal echo |
64 | | - system( 'stty echo' ); |
| 65 | + // Check if we can hide input (Unix-like system + TTY) |
| 66 | + $canHideInput = strtoupper( substr( PHP_OS, 0, 3 ) ) !== 'WIN' && $this->isTty(); |
| 67 | + |
| 68 | + if( $canHideInput ) { |
| 69 | + // Disable terminal echo with exception safety |
| 70 | + try { |
| 71 | + system( 'stty -echo' ); |
| 72 | + $input = fgets( STDIN ); |
| 73 | + } finally { |
| 74 | + // Always restore terminal echo, even if an exception occurred |
| 75 | + system( 'stty echo' ); |
| 76 | + } |
| 77 | + |
65 | 78 | // Add newline since user's enter key wasn't echoed |
66 | 79 | $this->output->writeln( '' ); |
67 | 80 | } else { |
68 | | - // On Windows, fall back to visible input |
69 | | - // A proper Windows implementation would use COM or other Windows-specific methods |
| 81 | + // Fall back to visible input (Windows or non-TTY) |
| 82 | + // On Windows, a proper implementation would use COM or other Windows-specific methods |
| 83 | + // For non-TTY (CI/CD, piped input), we just read normally without trying stty |
70 | 84 | $input = fgets( STDIN ); |
71 | 85 | } |
72 | 86 |
|
73 | 87 | return $input !== false ? trim( $input ) : ''; |
74 | 88 | } |
75 | 89 |
|
| 90 | + /** |
| 91 | + * Check if STDIN is a TTY (terminal). |
| 92 | + * |
| 93 | + * This prevents stty errors when running in non-interactive environments |
| 94 | + * like CI/CD pipelines, automated scripts, or with piped input. |
| 95 | + * |
| 96 | + * @return bool True if STDIN is a TTY, false otherwise |
| 97 | + */ |
| 98 | + private function isTty(): bool |
| 99 | + { |
| 100 | + // Use posix_isatty if available (preferred, more reliable) |
| 101 | + if( function_exists( 'posix_isatty' ) ) { |
| 102 | + return @posix_isatty( STDIN ); |
| 103 | + } |
| 104 | + |
| 105 | + // Fall back to stream_isatty (PHP 7.2+) |
| 106 | + if( function_exists( 'stream_isatty' ) ) { |
| 107 | + return @stream_isatty( STDIN ); |
| 108 | + } |
| 109 | + |
| 110 | + // If neither function is available, assume it's not a TTY to be safe |
| 111 | + // This prevents errors in environments where we can't check |
| 112 | + return false; |
| 113 | + } |
| 114 | + |
76 | 115 | /** |
77 | 116 | * @inheritDoc |
78 | 117 | */ |
79 | 118 | public function choice( string $message, array $options, ?string $default = null ): string |
80 | 119 | { |
81 | | - // Display the prompt message |
82 | | - $this->output->writeln( $message ); |
83 | | - $this->output->writeln( '' ); |
84 | | - |
85 | | - // Display options with index numbers |
86 | | - foreach( $options as $index => $option ) { |
87 | | - $marker = ($default === $option) ? '*' : ' '; |
88 | | - $this->output->writeln( " [{$marker}] {$index}. {$option}" ); |
89 | | - } |
| 120 | + $attempts = 0; |
90 | 121 |
|
91 | | - $this->output->writeln( '' ); |
| 122 | + while( $attempts < self::MAX_RETRY_ATTEMPTS ) { |
| 123 | + // Display the prompt message (only on first attempt) |
| 124 | + if( $attempts === 0 ) { |
| 125 | + $this->output->writeln( $message ); |
| 126 | + $this->output->writeln( '' ); |
92 | 127 |
|
93 | | - // Prompt for selection |
94 | | - $prompt = $default !== null ? "Choice [{$default}]: " : "Choice: "; |
95 | | - $response = $this->prompt( $prompt ); |
| 128 | + // Display options with index numbers |
| 129 | + foreach( $options as $index => $option ) { |
| 130 | + $marker = ($default === $option) ? '*' : ' '; |
| 131 | + $this->output->writeln( " [{$marker}] {$index}. {$option}" ); |
| 132 | + } |
96 | 133 |
|
97 | | - // If user just presses enter and there's a default, use it |
98 | | - if( $response === '' && $default !== null ) { |
99 | | - return $default; |
100 | | - } |
| 134 | + $this->output->writeln( '' ); |
| 135 | + } |
101 | 136 |
|
102 | | - // Check if response is a numeric index |
103 | | - if( is_numeric( $response ) ) { |
104 | | - $index = (int)$response; |
105 | | - if( isset( $options[$index] ) ) { |
106 | | - return $options[$index]; |
| 137 | + // Prompt for selection |
| 138 | + $prompt = $default !== null ? "Choice [{$default}]: " : "Choice: "; |
| 139 | + $response = $this->prompt( $prompt ); |
| 140 | + |
| 141 | + // If user just presses enter and there's a default, use it |
| 142 | + if( $response === '' && $default !== null ) { |
| 143 | + return $default; |
107 | 144 | } |
108 | | - } |
109 | 145 |
|
110 | | - // Check if response matches an option exactly |
111 | | - if( in_array( $response, $options, true ) ) { |
112 | | - return $response; |
| 146 | + // Check if response is a numeric index |
| 147 | + if( is_numeric( $response ) ) { |
| 148 | + $index = (int)$response; |
| 149 | + if( isset( $options[$index] ) ) { |
| 150 | + return $options[$index]; |
| 151 | + } |
| 152 | + } |
| 153 | + |
| 154 | + // Check if response matches an option exactly |
| 155 | + if( in_array( $response, $options, true ) ) { |
| 156 | + return $response; |
| 157 | + } |
| 158 | + |
| 159 | + // Invalid choice - increment counter and show error |
| 160 | + $attempts++; |
| 161 | + |
| 162 | + if( $attempts < self::MAX_RETRY_ATTEMPTS ) { |
| 163 | + $this->output->error( "Invalid choice. Please try again." ); |
| 164 | + $this->output->writeln( '' ); |
| 165 | + } |
113 | 166 | } |
114 | 167 |
|
115 | | - // Invalid choice - ask again |
116 | | - $this->output->error( "Invalid choice. Please try again." ); |
117 | | - $this->output->writeln( '' ); |
| 168 | + // Max retries exceeded - fall back to default or throw exception |
| 169 | + if( $default !== null ) { |
| 170 | + $this->output->warning( "Maximum retry attempts exceeded. Using default: {$default}" ); |
| 171 | + return $default; |
| 172 | + } |
118 | 173 |
|
119 | | - return $this->choice( $message, $options, $default ); |
| 174 | + throw new \RuntimeException( |
| 175 | + 'Maximum retry attempts exceeded and no default value provided for choice prompt' |
| 176 | + ); |
120 | 177 | } |
121 | 178 | } |
0 commit comments