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
19 changes: 18 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
3 changes: 2 additions & 1 deletion docs/tools/commands.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 3 additions & 1 deletion docs/tools/writing.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
10 changes: 5 additions & 5 deletions src/Tools/Commands/Concerns/WarnsOnDeprecatedAlias.php
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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).
*/
Expand Down Expand Up @@ -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(
'<comment>Deprecated:</comment> [%s] is a deprecated alias and will be removed in the next minor after 0.1. Use [%s] instead.',
$invokedAs,
(string) $this->getName(),
Expand Down
12 changes: 3 additions & 9 deletions src/Tools/Services/ConsoleWriter.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -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());

Expand Down Expand Up @@ -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
Expand Down
33 changes: 33 additions & 0 deletions src/Tools/Support/ErrorOutput.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
<?php

declare(strict_types=1);

namespace Simtabi\Laranail\Console\Tools\Support;

use Illuminate\Console\OutputStyle;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Output\ConsoleOutputInterface;

/**
* Resolves the stderr stream behind an output, seeing through Laravel's command wrapper.
*
* `Illuminate\Console\Command::run()` wraps the terminal's `ConsoleOutput` in an
* {@see OutputStyle} before Symfony calls `initialize()` or `handle()`, and `OutputStyle` does
* not implement {@see ConsoleOutputInterface}. A bare `$output instanceof ConsoleOutputInterface`
* check is therefore false for every output a command hands out, and anything meant for stderr
* lands on stdout. This unwraps the style first.
*
* An output with no separate error stream (a `BufferedOutput`, as `Artisan::call()` uses) is
* returned unchanged, so captured output still contains everything.
*
* @internal
*/
final class ErrorOutput
{
public static function of(OutputInterface $output): OutputInterface
{
$inner = $output instanceof OutputStyle ? $output->getOutput() : $output;

return $inner instanceof ConsoleOutputInterface ? $inner->getErrorOutput() : $output;
}
}
193 changes: 193 additions & 0 deletions tests/Tools/Unit/Commands/StderrRoutingTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,193 @@
<?php

declare(strict_types=1);

namespace Simtabi\Laranail\Console\Tools\Tests\Unit\Commands;

use LogicException;
use Illuminate\Support\Facades\Artisan;
use Illuminate\Contracts\Console\Kernel;
use Symfony\Component\Console\Input\ArrayInput;
use Simtabi\Laranail\Console\Tools\Tests\TestCase;
use Symfony\Component\Console\Output\StreamOutput;
use Illuminate\Console\Command as IlluminateCommand;
use Simtabi\Laranail\Console\Tools\Commands\Command;
use Symfony\Component\Console\Output\OutputInterface;
use Simtabi\Laranail\Console\Tools\Support\Capabilities;
use Symfony\Component\Console\Output\ConsoleSectionOutput;
use Symfony\Component\Console\Output\ConsoleOutputInterface;
use Simtabi\Laranail\Console\Tools\Commands\Concerns\SupportsNamespacedNames;
use Simtabi\Laranail\Console\Tools\Commands\Concerns\InteractsWithConsoleWriter;

/**
* A terminal stand-in: stdout and stderr are separate in-memory streams, as they are for a real
* `ConsoleOutput`, so a test can tell which one a line went to.
*/
final class SplitStreamOutput extends StreamOutput implements ConsoleOutputInterface
{
private OutputInterface $stderr;

public function __construct()
{
parent::__construct($this->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<string> */
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);
}
}
Loading