From 83f7abdfca0179d304dcb5d3f74c6f41cd6ed14a Mon Sep 17 00:00:00 2001 From: Imani Manyara Date: Wed, 7 Oct 2026 08:56:02 -0400 Subject: [PATCH] Route deprecated-alias warnings and writer errors to stderr Illuminate\Console\Command::run() wraps the terminal output in an OutputStyle before initialize() and handle() see it, and OutputStyle is not a ConsoleOutputInterface. The instanceof checks in WarnsOnDeprecatedAlias and ConsoleWriter therefore never matched inside a command, so the alias warning became the first line of stdout and ConsoleWriter error(), danger() and toStderr() wrote to stdout too. Both now resolve the error stream through Tools\Support\ErrorOutput, which unwraps the style first. Outputs without an error stream, such as the buffer Artisan::call() uses, are unchanged. The new test drives the real Command::run() path with an output that keeps stdout and stderr apart; it fails on the previous code. --- CHANGELOG.md | 19 +- docs/tools/commands.md | 3 +- docs/tools/writing.md | 4 +- .../Concerns/WarnsOnDeprecatedAlias.php | 10 +- src/Tools/Services/ConsoleWriter.php | 12 +- src/Tools/Support/ErrorOutput.php | 33 +++ .../Tools/Unit/Commands/StderrRoutingTest.php | 193 ++++++++++++++++++ 7 files changed, 257 insertions(+), 17 deletions(-) create mode 100644 src/Tools/Support/ErrorOutput.php create mode 100644 tests/Tools/Unit/Commands/StderrRoutingTest.php diff --git a/CHANGELOG.md b/CHANGELOG.md index a2ac1ec..1b9b5d7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,23 @@ All notable changes to `laranail/console` are documented in this file. The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [Unreleased] + +### Fixed + +- **Deprecated-alias warnings went to stdout, not stderr.** `Illuminate\Console\Command::run()` + wraps the terminal output in an `OutputStyle` before `initialize()` runs, and `OutputStyle` is + not a `ConsoleOutputInterface`, so `WarnsOnDeprecatedAlias` never found the error stream and + the warning became the first line of stdout. A script parsing a bare alias's output got an + extra line. The warning now goes to stderr on a real terminal, as documented; under + `Artisan::call()`, which has no separate error stream, it is still part of the captured + output. +- **`ConsoleWriter` errors went to stdout inside a command**, for the same reason: `error()`, + `danger()` and `toStderr()` are routed to stderr only when the writer can see a console + output, and the writer `InteractsWithConsoleWriter` builds is handed the command's + `OutputStyle`. Both now resolve the error stream through `Tools\Support\ErrorOutput`, which + unwraps the style first. + ## [0.1.5] - 2026-10-05 ### Added @@ -180,4 +197,4 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 Initial public release. -[Unreleased]: https://github.com/laranail/console/compare/v0.1.4...HEAD +[Unreleased]: https://github.com/laranail/console/compare/v0.1.5...HEAD diff --git a/docs/tools/commands.md b/docs/tools/commands.md index 2b796d7..4b5e563 100644 --- a/docs/tools/commands.md +++ b/docs/tools/commands.md @@ -270,7 +270,8 @@ Deprecated: [make:crud] is a deprecated alias and will be removed in the next mi ``` The canonical name and the aliases in `$commandAliases` print nothing. On a real terminal -the line goes to stderr, so piped output is unchanged. +the line goes to stderr, so piped output is unchanged. `Artisan::call()` captures into a single +buffer with no error stream, so there the line is part of `Artisan::output()`. This is the only way a bare generic name may stay registered beside a vendor-scoped one: the warning names the replacement, which makes the alias a migration path rather than a second diff --git a/docs/tools/writing.md b/docs/tools/writing.md index 0bafda1..ef87733 100644 --- a/docs/tools/writing.md +++ b/docs/tools/writing.md @@ -50,7 +50,9 @@ unchanged. Ready-to-use, coloured glyph + message (rendered via [`StatusLine`](widgets.md); glyphs degrade to ASCII without Unicode). `error()` and -`danger()` are written to **stderr**. +`danger()` are written to **stderr** — including from a command's own writer, whose output +Laravel wraps in an `OutputStyle`. An output with no error stream, such as the buffer +`Artisan::call()` uses, receives them instead. ```php $w->success('Deployed'); // ✓ green ([OK] without Unicode) diff --git a/src/Tools/Commands/Concerns/WarnsOnDeprecatedAlias.php b/src/Tools/Commands/Concerns/WarnsOnDeprecatedAlias.php index 677483c..0e0ac5a 100644 --- a/src/Tools/Commands/Concerns/WarnsOnDeprecatedAlias.php +++ b/src/Tools/Commands/Concerns/WarnsOnDeprecatedAlias.php @@ -6,7 +6,7 @@ use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Output\OutputInterface; -use Symfony\Component\Console\Output\ConsoleOutputInterface; +use Simtabi\Laranail\Console\Tools\Support\ErrorOutput; /** * Keeps a command's old names working as DEPRECATED aliases that say so when used. @@ -37,7 +37,9 @@ * token for `php artisan`, `Artisan::call()` and `$this->call()` alike, while `getName()` is * always the canonical name. Symfony calls `initialize()` after binding the input and before * `interact()`, so the warning prints before any prompt. On a real terminal it goes to stderr, - * so piped output is unchanged. + * so piped output is unchanged: the output reaching `initialize()` is Laravel's `OutputStyle` + * wrapper, which {@see ErrorOutput} sees through. Under `Artisan::call()` there is no separate + * error stream, and the warning is captured with the rest of the output. * * @api Stable extension point (SemVer-covered). */ @@ -87,9 +89,7 @@ protected function initialize(InputInterface $input, OutputInterface $output): v return; } - $target = $output instanceof ConsoleOutputInterface ? $output->getErrorOutput() : $output; - - $target->writeln(sprintf( + ErrorOutput::of($output)->writeln(sprintf( 'Deprecated: [%s] is a deprecated alias and will be removed in the next minor after 0.1. Use [%s] instead.', $invokedAs, (string) $this->getName(), diff --git a/src/Tools/Services/ConsoleWriter.php b/src/Tools/Services/ConsoleWriter.php index 810c731..e5d0236 100644 --- a/src/Tools/Services/ConsoleWriter.php +++ b/src/Tools/Services/ConsoleWriter.php @@ -8,9 +8,9 @@ use Simtabi\Laranail\Console\Tools\Support\Symbols; use Symfony\Component\Console\Output\OutputInterface; use Simtabi\Laranail\Console\Tools\Widgets\StatusLine; +use Simtabi\Laranail\Console\Tools\Support\ErrorOutput; use Simtabi\Laranail\Console\Tools\Support\Capabilities; use Symfony\Component\Console\Formatter\OutputFormatter; -use Symfony\Component\Console\Output\ConsoleOutputInterface; use Symfony\Component\Console\Formatter\OutputFormatterStyle; use Symfony\Component\Console\Formatter\OutputFormatterInterface; @@ -362,9 +362,7 @@ private function with(callable $mutate): self private function status(string $status, array $lines): self { $forceErr = $status === 'error' || $status === 'danger'; - $stream = ($forceErr || $this->stderr) && $this->output instanceof ConsoleOutputInterface - ? $this->output->getErrorOutput() - : $this->output; + $stream = $forceErr || $this->stderr ? ErrorOutput::of($this->output) : $this->output; $statusLine = StatusLine::make($this->caps()); @@ -396,11 +394,7 @@ private function caps(): Capabilities private function stream(): OutputInterface { - if ($this->stderr && $this->output instanceof ConsoleOutputInterface) { - return $this->output->getErrorOutput(); - } - - return $this->output; + return $this->stderr ? ErrorOutput::of($this->output) : $this->output; } private function formatter(): OutputFormatterInterface diff --git a/src/Tools/Support/ErrorOutput.php b/src/Tools/Support/ErrorOutput.php new file mode 100644 index 0000000..778138f --- /dev/null +++ b/src/Tools/Support/ErrorOutput.php @@ -0,0 +1,33 @@ +getOutput() : $output; + + return $inner instanceof ConsoleOutputInterface ? $inner->getErrorOutput() : $output; + } +} diff --git a/tests/Tools/Unit/Commands/StderrRoutingTest.php b/tests/Tools/Unit/Commands/StderrRoutingTest.php new file mode 100644 index 0000000..0eac3bf --- /dev/null +++ b/tests/Tools/Unit/Commands/StderrRoutingTest.php @@ -0,0 +1,193 @@ +memory(), decorated: false); + $this->stderr = new StreamOutput($this->memory(), decorated: false); + } + + public function getErrorOutput(): OutputInterface + { + return $this->stderr; + } + + public function setErrorOutput(OutputInterface $error): void + { + $this->stderr = $error; + } + + public function section(): ConsoleSectionOutput + { + throw new LogicException('Sections are not used by these tests.'); + } + + public function stdout(): string + { + return $this->read($this); + } + + public function stderr(): string + { + return $this->stderr instanceof StreamOutput ? $this->read($this->stderr) : ''; + } + + /** @return resource */ + private function memory() + { + $stream = fopen('php://memory', 'w+b'); + + if ($stream === false) { + throw new LogicException('Cannot open php://memory.'); + } + + return $stream; + } + + private function read(StreamOutput $output): string + { + $stream = $output->getStream(); + rewind($stream); + + return (string) stream_get_contents($stream); + } +} + +/** + * A command on the package's base with one bare deprecated alias. + */ +final class StderrAliasCommand extends Command +{ + use SupportsNamespacedNames; + + protected $signature = 'laranail::console-test.stderr-alias'; + + protected $description = 'Deprecated-alias stream test command'; + + /** @var list */ + protected array $deprecatedCommandAliases = ['console-test:stderr-old']; + + public function handle(): int + { + $this->line('handled'); + + return self::SUCCESS; + } +} + +/** + * Emits a ConsoleWriter error line from inside a command, through the command's own output. + */ +final class StderrWriterCommand extends IlluminateCommand +{ + use InteractsWithConsoleWriter; + use SupportsNamespacedNames; + + protected $signature = 'laranail::console-test.stderr-writer'; + + protected $description = 'ConsoleWriter stderr routing test command'; + + public function handle(): int + { + $this->consoleWriter()->line('result'); + $this->consoleWriter()->error('boom'); + $this->consoleWriter()->toStderr()->line('diagnostic'); + + return self::SUCCESS; + } +} + +/** + * Stream routing through the real `Illuminate\Console\Command::run()` path. + * + * `run()` wraps the output it is given in an `OutputStyle` before `initialize()` and `handle()` + * see it, so these tests hand a command an output with a separate error stream and run it the way + * `php artisan` does. A bare Symfony output would skip the wrapper and pass on the defect. + */ +final class StderrRoutingTest extends TestCase +{ + private const string WARNING = 'Deprecated: [console-test:stderr-old] is a deprecated alias and will be removed in the next minor after 0.1. Use [laranail::console-test.stderr-alias] instead.'; + + protected function setUp(): void + { + parent::setUp(); + + $kernel = $this->app->make(Kernel::class); + $kernel->registerCommand(new StderrAliasCommand); + $kernel->registerCommand(new StderrWriterCommand); + } + + protected function tearDown(): void + { + Capabilities::clearFake(); + parent::tearDown(); + } + + public function test_a_deprecated_alias_warns_on_stderr_and_leaves_stdout_unchanged(): void + { + $output = new SplitStreamOutput; + + self::assertSame(0, $this->runCommand('console-test:stderr-old', $output)); + + self::assertSame('handled' . PHP_EOL, $output->stdout()); + self::assertSame(self::WARNING . PHP_EOL, $output->stderr()); + } + + public function test_stdout_of_the_alias_matches_stdout_of_the_canonical_name(): void + { + $byAlias = new SplitStreamOutput; + $byName = new SplitStreamOutput; + + $this->runCommand('console-test:stderr-old', $byAlias); + $this->runCommand('laranail::console-test.stderr-alias', $byName); + + self::assertSame($byName->stdout(), $byAlias->stdout()); + self::assertSame('', $byName->stderr()); + } + + public function test_console_writer_errors_and_to_stderr_reach_stderr_from_a_command(): void + { + Capabilities::fake(unicode: false); + $output = new SplitStreamOutput; + + self::assertSame(0, $this->runCommand('laranail::console-test.stderr-writer', $output)); + + self::assertSame('result' . PHP_EOL, $output->stdout()); + self::assertStringContainsString('boom', $output->stderr()); + self::assertStringContainsString('diagnostic', $output->stderr()); + } + + private function runCommand(string $invokedAs, SplitStreamOutput $output): int + { + $command = Artisan::all()[$invokedAs]; + self::assertInstanceOf(IlluminateCommand::class, $command); + + return $command->run(new ArrayInput(['command' => $invokedAs]), $output); + } +}