diff --git a/CHANGELOG.md b/CHANGELOG.md index e9949a6..1f31d3f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,18 @@ Les versions `0.1.x`/`0.2.0` (juin 2026) correspondent à la phase de prototypag ## [Unreleased] +## [3.0.1] - 2026-10-06 + +### Fixed + +- **An interactive console prompt (`Command::ask()`/`confirm()`/`secret()`/`choice()`/`multiChoice()`) could hang + indefinitely instead of either prompting or falling back to its default** — these relied solely on + `$input->isInteractive()`, which reflects `--no-interaction`/`-n` and Symfony's own best-effort stdin check, + observed returning `true` (i.e. "safe to prompt") in at least one real environment whose stdin would in fact + never produce an answer. Every prompt now also requires `stream_isatty(STDIN)` via a new `canPrompt()` guard, + falling back to its default immediately when either check fails — found while testing `marrow/anvil`'s and + `marrow/ai-context`'s own interactive commands, both updated alongside this. + ## [3.0.0] - 2026-10-06 ### Changed (breaking) diff --git a/src/Console/Command.php b/src/Console/Command.php index 8abd0da..496fd21 100644 --- a/src/Console/Command.php +++ b/src/Console/Command.php @@ -292,23 +292,51 @@ private function insertIntoProviders(string $source, string $fqcn): ?string return substr($source, 0, $openBracket + 1) . $newInner . substr($source, $closeBracket); } + /** + * `$input->isInteractive()` alone isn't reliable in every environment — + * it reflects whether `--no-interaction`/`-n` was passed and Symfony's + * own best-effort stdin check, which has been observed returning true + * (i.e. "safe to prompt") on a stdin that will in fact never produce an + * answer, hanging the command indefinitely. Every prompt below also + * requires a real `stream_isatty(STDIN)` before attempting to read one, + * falling back to its default immediately otherwise — a command that + * wants an unattended run should still pass `--no-interaction`/check its + * own flags first, this is a last-resort safety net, not a substitute. + */ + protected function canPrompt(): bool + { + return $this->input->isInteractive() && stream_isatty(STDIN); + } + protected function ask(string $question, ?string $default = null): string { + if (!$this->canPrompt()) { + return (string) ($default ?? ''); + } return (string) $this->io->ask($question, $default); } protected function confirm(string $question, bool $default = false): bool { + if (!$this->canPrompt()) { + return $default; + } return $this->io->confirm($question, $default); } protected function secret(string $question): string { + if (!$this->canPrompt()) { + return ''; + } return (string) $this->io->askHidden($question); } protected function choice(string $question, array $choices, mixed $default = null): string { + if (!$this->canPrompt()) { + return (string) ($default ?? ($choices[array_key_first($choices)] ?? '')); + } return (string) $this->io->choice($question, $choices, $default); } @@ -322,6 +350,9 @@ protected function choice(string $question, array $choices, mixed $default = nul */ protected function multiChoice(string $question, array $choices, array $default = []): array { + if (!$this->canPrompt()) { + return $default; + } $result = $this->io->choice($question, $choices, $default === [] ? null : implode(',', $default), true); return array_values((array) $result); }