diff --git a/system/CLI/AbstractCommand.php b/system/CLI/AbstractCommand.php index 511eabbbde82..bb03bb27b439 100644 --- a/system/CLI/AbstractCommand.php +++ b/system/CLI/AbstractCommand.php @@ -46,6 +46,7 @@ abstract class AbstractCommand private readonly array $aliases; private readonly bool $hidden; + private readonly bool $headerless; /** * @var list @@ -145,6 +146,7 @@ public function __construct(private readonly Commands $commands) $this->group = $attribute->group; $this->aliases = $attribute->aliases; $this->hidden = $attribute->hidden; + $this->headerless = $attribute->headerless; $this->configure(); $this->provideDefaultOptions(); @@ -185,6 +187,11 @@ public function isHidden(): bool return $this->hidden; } + public function isHeaderless(): bool + { + return $this->headerless; + } + /** * @return list */ diff --git a/system/CLI/Attributes/Command.php b/system/CLI/Attributes/Command.php index 45348275db41..d4219fdabbf1 100644 --- a/system/CLI/Attributes/Command.php +++ b/system/CLI/Attributes/Command.php @@ -45,6 +45,7 @@ public function __construct( public string $group = '', array $aliases = [], public bool $hidden = false, + public bool $headerless = false, ) { if ($name === '') { throw new LogicException(lang('Commands.emptyCommandName')); diff --git a/system/CLI/Commands.php b/system/CLI/Commands.php index fad1aa76a6e5..70a8381e0d6e 100644 --- a/system/CLI/Commands.php +++ b/system/CLI/Commands.php @@ -28,7 +28,7 @@ * Command discovery and execution class. * * @phpstan-type legacy_commands array, file: string, group: string, description: string}> - * @phpstan-type modern_commands array, file: string, group: string, description: string, aliases: list, hidden: bool}> + * @phpstan-type modern_commands array, file: string, group: string, description: string, aliases: list, hidden: bool, headerless: bool}> */ class Commands { @@ -214,6 +214,20 @@ public function isHiddenCommand(string $name): bool return $resolved !== null && $this->modernCommands[$resolved]['hidden']; } + /** + * Checks whether the given command name or alias resolves to a headerless modern command that no legacy command shadows. + */ + public function isHeaderlessCommand(string $name): bool + { + if (isset($this->commands[$name])) { + return false; + } + + $resolved = $this->resolveCommand($name); + + return $resolved !== null && $this->modernCommands[$resolved]['headerless']; + } + /** * @return ($legacy is true ? BaseCommand : AbstractCommand) * @@ -295,7 +309,7 @@ public function discoverCommands() ksort($this->modernCommands); foreach (array_keys(array_intersect_key($this->commands, $this->modernCommands)) as $name) { - CLI::write( + CLI::error( CLI::wrap( lang('Commands.duplicateCommandName', [ $name, @@ -481,6 +495,7 @@ private function registerModernCommand(ReflectionClass $class, string $file): vo 'description' => $attribute->description, 'aliases' => $attribute->aliases, 'hidden' => $attribute->hidden, + 'headerless' => $attribute->headerless, ]; } } diff --git a/system/CLI/Console.php b/system/CLI/Console.php index 67ea91ce0b66..41d8807b9372 100644 --- a/system/CLI/Console.php +++ b/system/CLI/Console.php @@ -48,8 +48,7 @@ public function run(array $tokens = []) $arguments = $parser->getArguments(); $this->options = $parser->getOptions(); - - $this->showHeader($this->hasParameterOption(['no-header'])); + $noHeader = $this->hasParameterOption(['no-header']); if ($this->hasParameterOption(['help', 'h'])) { if ($arguments === []) { @@ -69,6 +68,8 @@ public function run(array $tokens = []) $this->command = array_shift($arguments) ?? self::DEFAULT_COMMAND; + $this->showHeader($noHeader || $commands->isHeaderlessCommand($this->command)); + if ( $this->isInteractive() && ! $commands->hasLegacyCommand($this->command) diff --git a/tests/_support/Commands/Modern/HeaderlessCommand.php b/tests/_support/Commands/Modern/HeaderlessCommand.php new file mode 100644 index 000000000000..fc4bef7490a8 --- /dev/null +++ b/tests/_support/Commands/Modern/HeaderlessCommand.php @@ -0,0 +1,35 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace Tests\Support\Commands\Modern; + +use CodeIgniter\CLI\AbstractCommand; +use CodeIgniter\CLI\Attributes\Command; +use CodeIgniter\CLI\CLI; + +#[Command( + name: 'test:headerless', + description: 'Fixture command exercising commands without the header.', + group: 'Fixtures', + aliases: ['test:quiet'], + headerless: true, +)] +final class HeaderlessCommand extends AbstractCommand +{ + protected function execute(array $arguments, array $options): int + { + CLI::write('Ran test:headerless.'); + + return EXIT_SUCCESS; + } +} diff --git a/tests/_support/Duplicates/HeaderlessDuplicateModern.php b/tests/_support/Duplicates/HeaderlessDuplicateModern.php new file mode 100644 index 000000000000..96fcfa73019b --- /dev/null +++ b/tests/_support/Duplicates/HeaderlessDuplicateModern.php @@ -0,0 +1,36 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace Tests\Support\Duplicates; + +use CodeIgniter\CLI\AbstractCommand; +use CodeIgniter\CLI\Attributes\Command; + +/** + * Headerless modern fixture shadowed by the legacy command of the same name. + * + * @internal + */ +#[Command( + name: 'dup:test', + description: 'Headerless modern fixture that collides with a legacy command of the same name.', + group: 'Duplicates', + headerless: true, +)] +final class HeaderlessDuplicateModern extends AbstractCommand +{ + protected function execute(array $arguments, array $options): int + { + return EXIT_SUCCESS; + } +} diff --git a/tests/system/CLI/AbstractCommandTest.php b/tests/system/CLI/AbstractCommandTest.php index 09ca692a292e..cf3db24fdf7a 100644 --- a/tests/system/CLI/AbstractCommandTest.php +++ b/tests/system/CLI/AbstractCommandTest.php @@ -38,6 +38,7 @@ use ReflectionClass; use ReflectionProperty; use Tests\Support\Commands\Modern\AppAboutCommand; +use Tests\Support\Commands\Modern\HeaderlessCommand; use Tests\Support\Commands\Modern\HiddenCommand; use Tests\Support\Commands\Modern\InteractFixtureCommand; use Tests\Support\Commands\Modern\InteractiveStateProbeCommand; @@ -84,6 +85,7 @@ public function testConstructorSetsNeededProperties(): void $this->assertSame($attribute->description, $command->getDescription()); $this->assertSame($attribute->group, $command->getGroup()); $this->assertSame($attribute->hidden, $command->isHidden()); + $this->assertSame($attribute->headerless, $command->isHeaderless()); $this->assertSame($commands, $command->getCommandRunner()); $this->assertSame('help [options] [--] []', $command->getUsages()[0]); } @@ -93,6 +95,11 @@ public function testHiddenCommandReportsItself(): void $this->assertTrue((new HiddenCommand(new Commands()))->isHidden()); } + public function testHeaderlessCommandReportsItself(): void + { + $this->assertTrue((new HeaderlessCommand(new Commands()))->isHeaderless()); + } + public function testCommandRequiresCommandAttribute(): void { $this->expectException(LogicException::class); diff --git a/tests/system/CLI/Attributes/CommandTest.php b/tests/system/CLI/Attributes/CommandTest.php index 38b1f3ec7b51..5673ffd1eca4 100644 --- a/tests/system/CLI/Attributes/CommandTest.php +++ b/tests/system/CLI/Attributes/CommandTest.php @@ -43,6 +43,7 @@ public function testAttributeAllowsOmittedDescriptionAndGroup(): void $this->assertSame('', $command->group); $this->assertSame([], $command->aliases); $this->assertFalse($command->hidden); + $this->assertFalse($command->headerless); } public function testAttributeExposesHidden(): void @@ -50,6 +51,11 @@ public function testAttributeExposesHidden(): void $this->assertTrue((new Command(name: 'app:about', hidden: true))->hidden); } + public function testAttributeExposesHeaderless(): void + { + $this->assertTrue((new Command(name: 'app:about', headerless: true))->headerless); + } + public function testAttributeExposesAliases(): void { $command = new Command(name: 'app:about', aliases: ['app:ab', 'ab']); diff --git a/tests/system/CLI/CommandsTest.php b/tests/system/CLI/CommandsTest.php index 844d7191a3f3..873fbf0c1ece 100644 --- a/tests/system/CLI/CommandsTest.php +++ b/tests/system/CLI/CommandsTest.php @@ -22,6 +22,7 @@ use CodeIgniter\Exceptions\LogicException; use CodeIgniter\Log\Logger; use CodeIgniter\Test\CIUnitTestCase; +use CodeIgniter\Test\Filters\CITestStreamFilter; use CodeIgniter\Test\ReflectionHelper; use CodeIgniter\Test\StreamFilterTrait; use Config\Services; @@ -37,6 +38,7 @@ use Tests\Support\Commands\Modern\AppAboutCommand; use Tests\Support\Duplicates\DuplicateLegacy; use Tests\Support\Duplicates\DuplicateModern; +use Tests\Support\Duplicates\HeaderlessDuplicateModern; use Tests\Support\Duplicates\HiddenDuplicateModern; use Tests\Support\InvalidCommands\AliasClashCommand; use Tests\Support\InvalidCommands\AliasSecondClashCommand; @@ -283,6 +285,8 @@ public function testDiscoveryWarnsWhenSameCommandNameExistsInBothRegistries(): v CLI::getWidth(), ); + CITestStreamFilter::removeOutputFilter(); + $commands = new Commands(); $this->assertSame("\n{$message}\n", $this->getUndecoratedBuffer()); @@ -392,6 +396,36 @@ public function testIsHiddenCommandIsFalseWhenLegacyCommandShadowsIt(): void $this->assertFalse((new Commands())->isHiddenCommand('dup:test')); } + public function testHeaderlessCommandIsRegisteredWithItsFlag(): void + { + $commands = (new Commands())->getModernCommands(); + + $this->assertTrue($commands['test:headerless']['headerless']); + $this->assertFalse($commands['fixture:aliased']['headerless']); + } + + public function testIsHeaderlessCommand(): void + { + $commands = new Commands(); + + $this->assertTrue($commands->isHeaderlessCommand('test:headerless')); + $this->assertTrue($commands->isHeaderlessCommand('test:quiet')); + $this->assertFalse($commands->isHeaderlessCommand('fixture:aliased')); + $this->assertFalse($commands->isHeaderlessCommand('fixture:alias')); + $this->assertFalse($commands->isHeaderlessCommand('app:info')); + $this->assertFalse($commands->isHeaderlessCommand('app:unknown')); + } + + public function testIsHeaderlessCommandIsFalseWhenLegacyCommandShadowsIt(): void + { + $this->injectFixtureLocator([ + DuplicateLegacy::class => SUPPORTPATH . 'Duplicates/DuplicateLegacy.php', + HeaderlessDuplicateModern::class => SUPPORTPATH . 'Duplicates/HeaderlessDuplicateModern.php', + ]); + + $this->assertFalse((new Commands())->isHeaderlessCommand('dup:test')); + } + public function testHiddenCommandRunsByName(): void { command('fixture:hidden'); diff --git a/tests/system/CLI/ConsoleTest.php b/tests/system/CLI/ConsoleTest.php index e327021a696f..21386766fcac 100644 --- a/tests/system/CLI/ConsoleTest.php +++ b/tests/system/CLI/ConsoleTest.php @@ -24,6 +24,7 @@ use CodeIgniter\Test\Mock\MockInputOutput; use CodeIgniter\Test\StreamFilterTrait; use PHPUnit\Framework\Attributes\CoversClass; +use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\Attributes\Group; /** @@ -99,6 +100,70 @@ public function testHeaderDoesNotShowOnNoHeader(): void ); } + #[DataProvider('provideHeaderlessCommandOmitsHeader')] + public function testHeaderlessCommandOmitsHeader(string $command): void + { + $this->initializeConsole($command); + (new Console())->run(); + + $this->assertSame( + <<<'EOT' + + Ran test:headerless. + + EOT, + $this->getStreamFilterBuffer(), + ); + } + + /** + * @return iterable + */ + public static function provideHeaderlessCommandOmitsHeader(): iterable + { + yield 'name' => ['test:headerless']; + + yield 'alias' => ['test:quiet']; + } + + /** + * @param list $tokens + */ + #[DataProvider('provideHelpForHeaderlessCommandShowsHeader')] + public function testHelpForHeaderlessCommandShowsHeader(array $tokens): void + { + $this->initializeConsole(...$tokens); + (new Console())->run(); + + $this->assertStringContainsString( + sprintf('CodeIgniter v%s Command Line Tool', CodeIgniter::CI_VERSION), + $this->getStreamFilterBuffer(), + ); + } + + /** + * @return iterable}> + */ + public static function provideHelpForHeaderlessCommandShowsHeader(): iterable + { + yield 'help command' => [['help', 'test:headerless']]; + + yield 'help option' => [['test:headerless', '--help']]; + + yield 'help shortcut on alias' => [['test:quiet', '-h']]; + } + + public function testNoHeaderOptionAppliesAlongsideHelpOption(): void + { + $this->initializeConsole('env', '--help', '--no-header'); + (new Console())->run(); + + $this->assertStringNotContainsString( + sprintf('CodeIgniter v%s Command Line Tool', CodeIgniter::CI_VERSION), + $this->getStreamFilterBuffer(), + ); + } + public function testRun(): void { $this->initializeConsole(); diff --git a/user_guide_src/source/changelogs/v4.8.0.rst b/user_guide_src/source/changelogs/v4.8.0.rst index 37637e76d25d..5c20fcbbd54e 100644 --- a/user_guide_src/source/changelogs/v4.8.0.rst +++ b/user_guide_src/source/changelogs/v4.8.0.rst @@ -222,6 +222,9 @@ Commands - Modern commands can now be hidden with ``hidden: true`` on the ``#[Command]`` attribute. A hidden command and its aliases are left out of ``spark list`` and of the suggestions for a mistyped command name, but still run by exact name or alias and still have a ``help`` page. ``AbstractCommand::isHidden()`` and ``Commands::isHiddenCommand()`` report the flag. See :ref:`hidden-commands`. +- Modern commands can now opt out of the spark header with ``headerless: true`` on the ``#[Command]`` attribute, with the same effect as always + passing ``--no-header``. ``help `` and `` --help`` still print the header. ``AbstractCommand::isHeaderless()`` and + ``Commands::isHeaderlessCommand()`` report the flag. See :ref:`commands-without-the-header`. - Every modern command now ships with a ``--no-interaction`` / ``-N`` flag that skips the ``interact()`` hook, plus public ``isInteractive()`` / ``setInteractive()`` methods on ``AbstractCommand``. ``isInteractive()`` also auto-detects piped or CI environments by probing STDIN for a TTY, and the state cascades to sub-commands invoked via ``$this->call(...)``. diff --git a/user_guide_src/source/cli/cli_modern_commands.rst b/user_guide_src/source/cli/cli_modern_commands.rst index 15b1e2e6fe47..0a6b1d0d2a99 100644 --- a/user_guide_src/source/cli/cli_modern_commands.rst +++ b/user_guide_src/source/cli/cli_modern_commands.rst @@ -59,6 +59,8 @@ The attribute holds the command's identity: ``list`` output, and are shown in an ``Aliases:`` section of ``help ``. - ``hidden`` is an optional flag, ``false`` by default, that keeps the command out of listings. See `Hidden Commands`_. +- ``headerless`` is an optional flag, ``false`` by default, that stops spark from printing its header + before the command runs. See `Commands Without the Header`_. The attribute itself validates these constraints at construction time. If you misspell ``name``, you will see the error at discovery rather than at run time. @@ -97,6 +99,24 @@ To check the flag in code, use ``isHidden()`` on a command instance, or .. note:: Hiding a command is not the same as leaving its ``group`` empty. A command with an empty group is never discovered, so it cannot run at all. +.. _commands-without-the-header: + +Commands Without the Header +=========================== + +Spark prints a header with the framework version and the server time before running a command. +A command whose output is read by another program, such as JSON piped to ``jq``, can drop the +header by setting ``headerless: true`` on its ``#[Command]`` attribute: + +.. literalinclude:: cli_modern_commands/017.php + +This has the same effect as always passing ``--no-header``, and it covers the command's aliases +too. ``php spark help app:status`` and ``php spark app:status --help`` still print the header, +since help output is meant for people. + +To check the flag in code, use ``isHeaderless()`` on a command instance, or +``Commands::isHeaderlessCommand()`` with a command name or alias. + ***************** Command Lifecycle ***************** @@ -481,8 +501,8 @@ Coexistence With Legacy Commands Legacy ``BaseCommand`` classes are still supported, and they are discovered alongside modern commands. If the same name is claimed by both a legacy and a -modern command, the legacy one is invoked and a warning is printed once at -discovery time so you can rename or retire one of the two. Any aliases declared +modern command, the legacy one is invoked and a warning is printed to STDERR once +at discovery time so you can rename or retire one of the two. Any aliases declared by the shadowed modern command are dropped at discovery, so they are neither listed nor runnable. Resolve the collision and the modern command, along with its aliases, becomes reachable again. @@ -545,6 +565,11 @@ covered in the sections above and are not listed here. Returns whether the ``#[Command]`` attribute marks the command as hidden. See `Hidden Commands`_. + .. php:method:: isHeaderless(): bool + + Returns whether the ``#[Command]`` attribute opts the command out of the header. + See `Commands Without the Header`_. + .. php:method:: getUsages(): array Returns every usage line registered for the command — the default diff --git a/user_guide_src/source/cli/cli_modern_commands/017.php b/user_guide_src/source/cli/cli_modern_commands/017.php new file mode 100644 index 000000000000..d402ea75f6e1 --- /dev/null +++ b/user_guide_src/source/cli/cli_modern_commands/017.php @@ -0,0 +1,23 @@ + 'ok'], JSON_THROW_ON_ERROR)); + + return EXIT_SUCCESS; + } +} diff --git a/user_guide_src/source/cli/spark_commands.rst b/user_guide_src/source/cli/spark_commands.rst index 3e6b338aa2f2..4355d7ce1a5a 100644 --- a/user_guide_src/source/cli/spark_commands.rst +++ b/user_guide_src/source/cli/spark_commands.rst @@ -109,6 +109,9 @@ You may always pass ``--no-header`` to suppress the header output, helpful for p Your environment is currently set as development. +.. note:: A modern command can also opt out of the header for every run. + See :ref:`commands-without-the-header`. + .. _correcting-a-mistyped-command: Correcting a Mistyped Command