Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
31 changes: 31 additions & 0 deletions src/Console/Command.php
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}

Expand All @@ -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);
}
Expand Down
Loading