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); + } +}