diff --git a/system/CLI/AbstractCommand.php b/system/CLI/AbstractCommand.php index 2f8c4c167f07..511eabbbde82 100644 --- a/system/CLI/AbstractCommand.php +++ b/system/CLI/AbstractCommand.php @@ -45,6 +45,8 @@ abstract class AbstractCommand */ private readonly array $aliases; + private readonly bool $hidden; + /** * @var list */ @@ -142,6 +144,7 @@ public function __construct(private readonly Commands $commands) $this->description = $attribute->description; $this->group = $attribute->group; $this->aliases = $attribute->aliases; + $this->hidden = $attribute->hidden; $this->configure(); $this->provideDefaultOptions(); @@ -177,6 +180,11 @@ public function getAliases(): array return $this->aliases; } + public function isHidden(): bool + { + return $this->hidden; + } + /** * @return list */ diff --git a/system/CLI/Attributes/Command.php b/system/CLI/Attributes/Command.php index d22ff880c2ea..45348275db41 100644 --- a/system/CLI/Attributes/Command.php +++ b/system/CLI/Attributes/Command.php @@ -44,6 +44,7 @@ public function __construct( public string $description = '', public string $group = '', array $aliases = [], + public bool $hidden = false, ) { if ($name === '') { throw new LogicException(lang('Commands.emptyCommandName')); diff --git a/system/CLI/Commands.php b/system/CLI/Commands.php index bf00eec174c9..18218b40f197 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}> + * @phpstan-type modern_commands array, file: string, group: string, description: string, aliases: list, hidden: bool}> */ class Commands { @@ -192,6 +192,20 @@ public function hasModernCommand(string $name): bool return $this->resolveCommand($name) !== null; } + /** + * Checks whether the given command name or alias resolves to a hidden modern command that no legacy command shadows. + */ + public function isHiddenCommand(string $name): bool + { + if (isset($this->commands[$name])) { + return false; + } + + $resolved = $this->resolveCommand($name); + + return $resolved !== null && $this->modernCommands[$resolved]['hidden']; + } + /** * @return ($legacy is true ? BaseCommand : AbstractCommand) * @@ -356,7 +370,7 @@ public function verifyCommand(string $command, array $commands = [], bool $legac } /** - * Finds alternative of `$name` across both legacy and modern commands. + * Finds alternative of `$name` across both legacy and modern commands, skipping hidden ones. * * @param legacy_commands $collection (no longer used) * @@ -372,6 +386,10 @@ public function getCommandAlternatives(string $name, array $collection = []): ar $alternatives = []; foreach (array_keys($this->commands + $this->modernCommands + $this->aliases) as $commandName) { + if ($this->isHiddenCommand($commandName)) { + continue; + } + $lev = levenshtein($name, $commandName); if ($lev <= strlen($commandName) / 3 || str_contains($commandName, $name)) { @@ -454,6 +472,7 @@ private function registerModernCommand(ReflectionClass $class, string $file): vo 'group' => $attribute->group, 'description' => $attribute->description, 'aliases' => $attribute->aliases, + 'hidden' => $attribute->hidden, ]; } } diff --git a/system/Commands/ListCommands.php b/system/Commands/ListCommands.php index a4a011f30a87..84d983042701 100644 --- a/system/Commands/ListCommands.php +++ b/system/Commands/ListCommands.php @@ -46,8 +46,9 @@ private function describeCommandsSimple(): int // Legacy takes precedence on key collision so the listing reflects the // command that would actually be invoked. $runner = $this->getCommandRunner(); - $commands = array_keys( - $runner->getCommands() + $runner->getModernCommands() + $runner->getCommandAliases(), + $commands = array_filter( + array_keys($runner->getCommands() + $runner->getModernCommands() + $runner->getCommandAliases()), + static fn (string $command): bool => ! $runner->isHiddenCommand($command), ); sort($commands); @@ -73,6 +74,10 @@ private function describeCommandsDetailed(): int $all = $runner->getCommands() + $modern; foreach ($all as $command => $details) { + if ($runner->isHiddenCommand($command)) { + continue; + } + $maxPad = max($maxPad, strlen($command) + 4); $entries[] = [$details['group'], $command, $details['description']]; @@ -80,6 +85,10 @@ private function describeCommandsDetailed(): int // Aliases are listed as their own rows under the group of the command they resolve to. foreach ($runner->getCommandAliases() as $alias => $canonical) { + if ($runner->isHiddenCommand($alias)) { + continue; + } + $maxPad = max($maxPad, strlen($alias) + 4); $entries[] = [$modern[$canonical]['group'], $alias, lang('CLI.commandAlias', [$canonical])]; diff --git a/tests/_support/Commands/Modern/HiddenCommand.php b/tests/_support/Commands/Modern/HiddenCommand.php new file mode 100644 index 000000000000..2d9ee071a58f --- /dev/null +++ b/tests/_support/Commands/Modern/HiddenCommand.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: 'fixture:hidden', + description: 'Fixture command exercising hidden commands.', + group: 'Fixtures', + aliases: ['fixture:secret'], + hidden: true, +)] +final class HiddenCommand extends AbstractCommand +{ + protected function execute(array $arguments, array $options): int + { + CLI::write('Ran fixture:hidden.'); + + return EXIT_SUCCESS; + } +} diff --git a/tests/_support/Duplicates/HiddenDuplicateModern.php b/tests/_support/Duplicates/HiddenDuplicateModern.php new file mode 100644 index 000000000000..26b7f7418c60 --- /dev/null +++ b/tests/_support/Duplicates/HiddenDuplicateModern.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; + +/** + * Hidden modern fixture shadowed by the legacy command of the same name. + * + * @internal + */ +#[Command( + name: 'dup:test', + description: 'Hidden modern fixture that collides with a legacy command of the same name.', + group: 'Duplicates', + hidden: true, +)] +final class HiddenDuplicateModern 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 43697dd4fb22..09ca692a292e 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\HiddenCommand; use Tests\Support\Commands\Modern\InteractFixtureCommand; use Tests\Support\Commands\Modern\InteractiveStateProbeCommand; use Tests\Support\Commands\Modern\ParentCallsInteractFixtureCommand; @@ -82,10 +83,16 @@ public function testConstructorSetsNeededProperties(): void $this->assertSame($attribute->name, $command->getName()); $this->assertSame($attribute->description, $command->getDescription()); $this->assertSame($attribute->group, $command->getGroup()); + $this->assertSame($attribute->hidden, $command->isHidden()); $this->assertSame($commands, $command->getCommandRunner()); $this->assertSame('help [options] [--] []', $command->getUsages()[0]); } + public function testHiddenCommandReportsItself(): void + { + $this->assertTrue((new HiddenCommand(new Commands()))->isHidden()); + } + 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 ef8523d09122..38b1f3ec7b51 100644 --- a/tests/system/CLI/Attributes/CommandTest.php +++ b/tests/system/CLI/Attributes/CommandTest.php @@ -42,6 +42,12 @@ public function testAttributeAllowsOmittedDescriptionAndGroup(): void $this->assertSame('', $command->description); $this->assertSame('', $command->group); $this->assertSame([], $command->aliases); + $this->assertFalse($command->hidden); + } + + public function testAttributeExposesHidden(): void + { + $this->assertTrue((new Command(name: 'app:about', hidden: true))->hidden); } public function testAttributeExposesAliases(): void diff --git a/tests/system/CLI/CommandsTest.php b/tests/system/CLI/CommandsTest.php index c208954752d3..7023fb6c844c 100644 --- a/tests/system/CLI/CommandsTest.php +++ b/tests/system/CLI/CommandsTest.php @@ -37,6 +37,7 @@ use Tests\Support\Commands\Modern\AppAboutCommand; use Tests\Support\Duplicates\DuplicateLegacy; use Tests\Support\Duplicates\DuplicateModern; +use Tests\Support\Duplicates\HiddenDuplicateModern; use Tests\Support\InvalidCommands\AliasClashCommand; use Tests\Support\InvalidCommands\AliasSecondClashCommand; use Tests\Support\InvalidCommands\AliasTargetCommand; @@ -349,6 +350,131 @@ public function testRunCommandViaAlias(): void $this->assertStringContainsString('Ran fixture:aliased.', $this->getStreamFilterBuffer()); } + public function testHiddenCommandIsRegisteredWithItsFlag(): void + { + $commands = (new Commands())->getModernCommands(); + + $this->assertTrue($commands['fixture:hidden']['hidden']); + $this->assertFalse($commands['fixture:aliased']['hidden']); + } + + public function testIsHiddenCommand(): void + { + $commands = new Commands(); + + $this->assertTrue($commands->isHiddenCommand('fixture:hidden')); + $this->assertTrue($commands->isHiddenCommand('fixture:secret')); + $this->assertFalse($commands->isHiddenCommand('fixture:aliased')); + $this->assertFalse($commands->isHiddenCommand('fixture:alias')); + $this->assertFalse($commands->isHiddenCommand('app:info')); + $this->assertFalse($commands->isHiddenCommand('app:unknown')); + } + + public function testIsHiddenCommandIsFalseWhenLegacyCommandShadowsIt(): void + { + $this->injectFixtureLocator([ + DuplicateLegacy::class => SUPPORTPATH . 'Duplicates/DuplicateLegacy.php', + HiddenDuplicateModern::class => SUPPORTPATH . 'Duplicates/HiddenDuplicateModern.php', + ]); + + $this->assertFalse((new Commands())->isHiddenCommand('dup:test')); + } + + public function testHiddenCommandRunsByName(): void + { + command('fixture:hidden'); + + $this->assertSame( + <<<'EOT' + + Ran fixture:hidden. + + EOT, + $this->getUndecoratedBuffer(), + ); + } + + public function testHiddenCommandRunsByAlias(): void + { + command('fixture:secret'); + + $this->assertSame( + <<<'EOT' + + Ran fixture:hidden. + + EOT, + $this->getUndecoratedBuffer(), + ); + } + + public function testHiddenCommandRunsThroughModernCall(): void + { + $commands = new Commands(); + $call = $this->getPrivateMethodInvoker(new AliasedCommand($commands), 'call'); + + $this->assertSame(EXIT_SUCCESS, $call('fixture:hidden')); + $this->assertSame(EXIT_SUCCESS, $call('fixture:secret')); + $this->assertSame( + <<<'EOT' + + Ran fixture:hidden. + Ran fixture:hidden. + + EOT, + $this->getUndecoratedBuffer(), + ); + } + + public function testHiddenCommandRunsThroughLegacyCall(): void + { + $commands = new Commands(); + $call = $this->getPrivateMethodInvoker(new AppInfo(service('logger'), $commands), 'call'); + + $this->assertSame(EXIT_SUCCESS, $call('fixture:hidden')); + $this->assertSame(EXIT_SUCCESS, $call('fixture:secret')); + $this->assertSame( + <<<'EOT' + + Ran fixture:hidden. + Ran fixture:hidden. + + EOT, + $this->getUndecoratedBuffer(), + ); + } + + public function testHiddenCommandAndItsAliasesAreNotSuggested(): void + { + $this->assertSame(['fixture:alias', 'fixture:aliased'], (new Commands())->getCommandAlternatives('fixture:')); + } + + public function testMistypedHiddenCommandIsReportedWithoutSuggestion(): void + { + $commands = new Commands(); + + $this->assertSame([], $commands->getCommandAlternatives('fixture:secre')); + $this->assertSame(EXIT_ERROR, $commands->runCommand('fixture:hiddenn', [], [])); + $this->assertSame( + <<<'EOT' + + Command "fixture:hiddenn" not found. + + EOT, + $this->getUndecoratedBuffer(), + ); + } + + public function testLegacyCommandShadowingHiddenModernCommandIsStillSuggested(): void + { + $this->injectFixtureLocator([ + DuplicateLegacy::class => SUPPORTPATH . 'Duplicates/DuplicateLegacy.php', + HiddenDuplicateModern::class => SUPPORTPATH . 'Duplicates/HiddenDuplicateModern.php', + ]); + + $this->assertSame(['dup:test'], (new Commands())->getCommandAlternatives('dup:tes')); + } + public function testAliasClashingWithCommandNameFailsHard(): void { $this->injectFixtureLocator([ diff --git a/tests/system/CLI/ConsoleTest.php b/tests/system/CLI/ConsoleTest.php index 9040a7db70b8..bf722bf30310 100644 --- a/tests/system/CLI/ConsoleTest.php +++ b/tests/system/CLI/ConsoleTest.php @@ -247,6 +247,22 @@ public function testUnknownCommandSelectingNoneExitsWithError(): void ); } + public function testUnknownCommandDoesNotOfferHiddenCommand(): void + { + $this->initializeConsole('fixture:hiddenn', '--no-header'); + $io = $this->useInputs(); + + $this->assertSame(EXIT_ERROR, (new Console())->run()); + $this->assertSame( + <<<'EOT' + + Command "fixture:hiddenn" not found. + + EOT, + $this->getUndecoratedIoOutput($io), + ); + } + public function testUnknownCommandDoesNotPromptWhenNotInteractive(): void { $this->initializeConsole('lst', '--no-header', '--no-interaction'); diff --git a/tests/system/Commands/HelpCommandTest.php b/tests/system/Commands/HelpCommandTest.php index 61c3b281e578..649880c0c925 100644 --- a/tests/system/Commands/HelpCommandTest.php +++ b/tests/system/Commands/HelpCommandTest.php @@ -19,6 +19,7 @@ use CodeIgniter\Test\StreamFilterTrait; use PHPUnit\Framework\Attributes\After; use PHPUnit\Framework\Attributes\Before; +use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\Attributes\Group; /** @@ -179,6 +180,47 @@ public function testDescribeCommandWithAliases(): void ); } + #[DataProvider('provideDescribeHiddenCommand')] + public function testDescribeHiddenCommand(string $command): void + { + command($command); + + $this->assertSame( + <<<'EOT' + + Usage: + fixture:hidden [options] + + Description: + Fixture command exercising hidden commands. + + Aliases: + fixture:secret + + Options: + -h, --help Display help for the given command. + --no-header Do not display the banner when running the command. + -N, --no-interaction Do not ask any interactive questions. + + EOT, + $this->getUndecoratedBuffer(), + ); + } + + /** + * @return iterable + */ + public static function provideDescribeHiddenCommand(): iterable + { + yield 'help command' => ['help fixture:hidden']; + + yield 'help option' => ['fixture:hidden --help']; + + yield 'help shortcut' => ['fixture:hidden -h']; + + yield 'help option on alias' => ['fixture:secret --help']; + } + public function testDescribeCommandViaAliasResolvesToCanonical(): void { command('help fixture:alias'); diff --git a/tests/system/Commands/ListCommandsTest.php b/tests/system/Commands/ListCommandsTest.php index 9683aacc2d64..a6d22b2327bb 100644 --- a/tests/system/Commands/ListCommandsTest.php +++ b/tests/system/Commands/ListCommandsTest.php @@ -26,6 +26,7 @@ use ReflectionClass; use Tests\Support\Duplicates\DuplicateLegacy; use Tests\Support\Duplicates\DuplicateModern; +use Tests\Support\Duplicates\HiddenDuplicateModern; /** * @internal @@ -108,6 +109,57 @@ public function testAliasIsListedInSimpleOutput(): void $this->assertStringContainsString("fa\n", $buffer); } + public function testHiddenCommandIsNotListedInDetailedOutput(): void + { + command('list'); + + $buffer = $this->getUndecoratedBuffer(); + + $this->assertStringContainsString('fixture:aliased', $buffer); + $this->assertStringNotContainsString('fixture:hidden', $buffer); + $this->assertStringNotContainsString('fixture:secret', $buffer); + } + + public function testHiddenCommandIsNotListedInSimpleOutput(): void + { + command('list --simple'); + + $buffer = $this->getUndecoratedBuffer(); + + $this->assertStringContainsString("fixture:aliased\n", $buffer); + $this->assertStringNotContainsString('fixture:hidden', $buffer); + $this->assertStringNotContainsString('fixture:secret', $buffer); + } + + public function testLegacyCommandShadowingHiddenModernCommandIsListedInDetailedOutput(): void + { + $list = new ListCommands($this->discoveredRunnerWithDuplicate(HiddenDuplicateModern::class)); + $this->resetStreamFilterBuffer(); + + $list->run([], []); + + $this->assertMatchesRegularExpression( + '/\n {2}dup:test\s+Legacy fixture that collides with a modern command of the same name\.\n/', + $this->getUndecoratedBuffer(), + ); + } + + public function testLegacyCommandShadowingHiddenModernCommandIsListedInSimpleOutput(): void + { + $list = new ListCommands($this->discoveredRunnerWithDuplicate(HiddenDuplicateModern::class)); + $this->resetStreamFilterBuffer(); + + $list->run([], ['simple' => null]); + + $this->assertSame( + <<<'EOT' + dup:test + + EOT, + $this->getUndecoratedBuffer(), + ); + } + public function testDuplicateCommandNameListedOnceInSimpleOutput(): void { $list = new ListCommands($this->mockRunnerWithDuplicate()); @@ -159,11 +211,13 @@ public function testShadowedAliasIsNotListedInSimpleOutput(): void * Runs real discovery against the colliding legacy/modern `dup:test` * fixtures so the alias suppression in `Commands::registerAliases()` is * exercised end to end, not stubbed. + * + * @param class-string $modernClass */ - private function discoveredRunnerWithDuplicate(): Commands + private function discoveredRunnerWithDuplicate(string $modernClass = DuplicateModern::class): Commands { $legacyFile = (new ReflectionClass(DuplicateLegacy::class))->getFileName(); - $modernFile = (new ReflectionClass(DuplicateModern::class))->getFileName(); + $modernFile = (new ReflectionClass($modernClass))->getFileName(); $locator = $this->getMockBuilder(FileLocator::class) ->setConstructorArgs([service('autoloader')]) @@ -172,7 +226,7 @@ private function discoveredRunnerWithDuplicate(): Commands $locator->method('listFiles')->with('Commands/')->willReturn([$legacyFile, $modernFile]); $locator->expects($this->exactly(2))->method('findQualifiedNameFromPath')->willReturnMap([ [$legacyFile, DuplicateLegacy::class], - [$modernFile, DuplicateModern::class], + [$modernFile, $modernClass], ]); Services::injectMock('locator', $locator); @@ -201,6 +255,7 @@ private function mockRunnerWithDuplicate(): Commands 'group' => 'Duplicates', 'description' => 'Modern dup description', 'aliases' => [], + 'hidden' => false, ], ]); $runner->method('getCommandAliases')->willReturn([]); diff --git a/user_guide_src/source/changelogs/v4.8.0.rst b/user_guide_src/source/changelogs/v4.8.0.rst index 89d0c3c7e154..ae6a171fcfa1 100644 --- a/user_guide_src/source/changelogs/v4.8.0.rst +++ b/user_guide_src/source/changelogs/v4.8.0.rst @@ -219,6 +219,9 @@ Commands - Modern commands can now declare command aliases through an ``aliases`` list on the ``#[Command]`` attribute. Aliases resolve to the command at dispatch (``php spark `` and ``help ``), are listed as their own rows in ``spark list``, and appear in an ``Aliases:`` section of ``help ``. An alias that collides with an existing command name or another alias is rejected at discovery. See :doc:`../cli/cli_modern_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`. - 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 9a7c73729907..8c82b3de4bc4 100644 --- a/user_guide_src/source/cli/cli_modern_commands.rst +++ b/user_guide_src/source/cli/cli_modern_commands.rst @@ -56,6 +56,8 @@ The attribute holds the command's identity: follows the same naming rules as ``name`` and must differ from it. Aliases resolve to the command at dispatch (``php spark `` and ``help `` both work), are listed as their own rows in the ``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`_. 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. @@ -65,6 +67,35 @@ you meant. .. literalinclude:: cli_modern_commands/014.php +.. _hidden-commands: + +Hidden Commands +=============== + +A hidden command is an ordinary command that spark does not advertise. It suits commands +meant for scripts, cron jobs, or deployment tooling, which should stay runnable without +cluttering the list people browse. Hide a command by setting ``hidden: true`` on its +``#[Command]`` attribute: + +.. literalinclude:: cli_modern_commands/016.php + +A hidden command and its aliases are left out of: + +- the ``list`` output, including ``list --simple``; +- the suggestions shown for a mistyped command name, including the offer to run one instead. + +Anything that names the command exactly still works: + +- ``php spark app:reindex``, or any of its aliases; +- ``command('app:reindex')``, and ``call('app:reindex')`` from another command; +- ``php spark help app:reindex``, or ``php spark app:reindex --help``. + +To check the flag in code, use ``isHidden()`` on a command instance, or +``Commands::isHiddenCommand()`` with a command name or alias. + +.. 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. + ***************** Command Lifecycle ***************** @@ -509,6 +540,11 @@ covered in the sections above and are not listed here. Returns the command group declared on the ``#[Command]`` attribute. + .. php:method:: isHidden(): bool + + Returns whether the ``#[Command]`` attribute marks the command as hidden. + See `Hidden Commands`_. + .. 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/016.php b/user_guide_src/source/cli/cli_modern_commands/016.php new file mode 100644 index 000000000000..e20fd1ea2a2a --- /dev/null +++ b/user_guide_src/source/cli/cli_modern_commands/016.php @@ -0,0 +1,22 @@ +` are left out of both lists, but still run + when called by name. + Showing Help ------------