diff --git a/system/CLI/AbstractCommand.php b/system/CLI/AbstractCommand.php index 46a4d9982786..26edc9218dc5 100644 --- a/system/CLI/AbstractCommand.php +++ b/system/CLI/AbstractCommand.php @@ -15,6 +15,7 @@ use CodeIgniter\CLI\Attributes\Command; use CodeIgniter\CLI\Exceptions\ArgumentCountMismatchException; +use CodeIgniter\CLI\Exceptions\CommandNotAvailableException; use CodeIgniter\CLI\Exceptions\InvalidArgumentDefinitionException; use CodeIgniter\CLI\Exceptions\InvalidOptionDefinitionException; use CodeIgniter\CLI\Exceptions\OptionValueMismatchException; @@ -390,31 +391,37 @@ public function setInteractive(bool $interactive): static * * The lifecycle is: * - * 1. `initialize()` and `interact()` are handed the raw parsed input by reference, in that order. + * 1. Run `isAvailable()` to check if the command can be run in the current environment. + * 2. `initialize()` and `interact()` are handed the raw parsed input by reference, in that order. * Both can mutate the tokens before the framework interprets them against the declared definitions. * Note: the per-run interactive state is captured from `$options` before `initialize()` runs, so * mutating `--no-interaction` from within `initialize()` will not affect this invocation. Use * `setInteractive()` instead. - * 2. The post-hook input is snapshotted into `$unboundArguments` and `$unboundOptions` so the unbound + * 3. The post-hook input is snapshotted into `$unboundArguments` and `$unboundOptions` so the unbound * accessors can report the tokens carried into binding (as opposed to what defaults resolved to). * Any mutations performed in `initialize()` or `interact()` are therefore reflected in the snapshot. - * 3. `bind()` maps the raw tokens onto the declared arguments and options, applying defaults and + * 4. `bind()` maps the raw tokens onto the declared arguments and options, applying defaults and * coercing flag/negation values. - * 4. `validate()` rejects the bound result if it violates any of the declarations — missing required + * 5. `validate()` rejects the bound result if it violates any of the declarations — missing required * argument, unknown option, value/flag mismatches, and so on. - * 5. The bound-and-validated values are snapshotted into `$validatedArguments` / `$validatedOptions` + * 6. The bound-and-validated values are snapshotted into `$validatedArguments` / `$validatedOptions` * and then passed to `execute()`, whose integer return is the command's exit code. * * @param list $arguments Parsed arguments from command line. * @param array|string|null> $options Parsed options from command line. * * @throws ArgumentCountMismatchException + * @throws CommandNotAvailableException * @throws LogicException * @throws OptionValueMismatchException * @throws UnknownOptionException */ final public function run(array $arguments, array $options): int { + if (! $this->isAvailable()) { + throw new CommandNotAvailableException(lang('Commands.notAvailable', [$this->name])); + } + // Reset per-run interactive state from the current options. $this->runtimeInteractive = $this->hasUnboundOption('no-interaction', $options) ? false : null; @@ -449,6 +456,14 @@ protected function configure(): void { } + /** + * Checks whether this command is available to execute in the current environment. + */ + protected function isAvailable(): bool + { + return true; + } + /** * Initializes a command before the arguments and options are bound to their definitions. * diff --git a/system/CLI/Exceptions/CommandNotAvailableException.php b/system/CLI/Exceptions/CommandNotAvailableException.php new file mode 100644 index 000000000000..0afc8899dd04 --- /dev/null +++ b/system/CLI/Exceptions/CommandNotAvailableException.php @@ -0,0 +1,23 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace CodeIgniter\CLI\Exceptions; + +use CodeIgniter\Exceptions\RuntimeException; + +/** + * Exception thrown when a command is found but is not currently available for execution. + */ +final class CommandNotAvailableException extends RuntimeException +{ +} diff --git a/system/Language/en/Commands.php b/system/Language/en/Commands.php index 29e33a9a876f..46320b37d26a 100644 --- a/system/Language/en/Commands.php +++ b/system/Language/en/Commands.php @@ -47,6 +47,7 @@ 'noArgumentsExpected' => 'No arguments expected for "{0}" command. Received: "{1}".', 'nonArrayArgumentWithArrayDefault' => 'Argument "{0}" does not accept an array default value.', 'nonArrayOptionWithArrayValue' => 'Option "--{0}" does not accept an array value.', + 'notAvailable' => 'Command "{0}" is not available in the current environment.', 'optionClashesWithExistingNegation' => 'Option "--{0}" clashes with the negation of negatable option "--{1}".', 'optionNoValueAndNoDefault' => 'Option "--{0}" does not accept a value and cannot have a default value.', 'optionNotAcceptingValue' => 'Option "--{0}" does not accept a value.', diff --git a/tests/_support/Commands/Modern/UnavailableFixtureCommand.php b/tests/_support/Commands/Modern/UnavailableFixtureCommand.php new file mode 100644 index 000000000000..5ac64584f7cc --- /dev/null +++ b/tests/_support/Commands/Modern/UnavailableFixtureCommand.php @@ -0,0 +1,56 @@ + + * + * 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; + +#[Command(name: 'test:unavailable', description: 'Fixture command to test runtime availability checks.', group: 'Tests')] +final class UnavailableFixtureCommand extends AbstractCommand +{ + public static bool $initializeCalled = false; + public static bool $interactCalled = false; + public static bool $executeCalled = false; + public static bool $available = true; + + public static function reset(): void + { + self::$initializeCalled = false; + self::$interactCalled = false; + self::$executeCalled = false; + self::$available = true; + } + + protected function isAvailable(): bool + { + return self::$available; + } + + protected function initialize(array &$arguments, array &$options): void + { + self::$initializeCalled = true; + } + + protected function interact(array &$arguments, array &$options): void + { + self::$interactCalled = true; + } + + protected function execute(array $arguments, array $options): int + { + self::$executeCalled = true; + + return EXIT_SUCCESS; + } +} diff --git a/tests/system/CLI/AbstractCommandTest.php b/tests/system/CLI/AbstractCommandTest.php index 97cc90311754..bc7b673d19e0 100644 --- a/tests/system/CLI/AbstractCommandTest.php +++ b/tests/system/CLI/AbstractCommandTest.php @@ -15,6 +15,7 @@ use CodeIgniter\CLI\Attributes\Command; use CodeIgniter\CLI\Exceptions\ArgumentCountMismatchException; +use CodeIgniter\CLI\Exceptions\CommandNotAvailableException; use CodeIgniter\CLI\Exceptions\InvalidArgumentDefinitionException; use CodeIgniter\CLI\Exceptions\InvalidOptionDefinitionException; use CodeIgniter\CLI\Exceptions\OptionValueMismatchException; @@ -40,6 +41,7 @@ use Tests\Support\Commands\Modern\InteractiveStateProbeCommand; use Tests\Support\Commands\Modern\ParentCallsInteractFixtureCommand; use Tests\Support\Commands\Modern\TestFixtureCommand; +use Tests\Support\Commands\Modern\UnavailableFixtureCommand; use Throwable; /** @@ -60,6 +62,7 @@ protected function resetAll(): void CLI::reset(); InteractiveStateProbeCommand::reset(); + UnavailableFixtureCommand::reset(); } private function getUndecoratedBuffer(): string @@ -930,6 +933,48 @@ public function testCallPreservesCallerFlagWhenForcingNonInteractive(): void $this->assertFalse(InteractiveStateProbeCommand::$observedInteractive); } + public function testRunThrowsWhenCommandIsUnavailable(): void + { + $command = new UnavailableFixtureCommand(new Commands()); + + UnavailableFixtureCommand::$available = false; + + $this->expectException(CommandNotAvailableException::class); + $this->expectExceptionMessage('Command "test:unavailable" is not available in the current environment.'); + + $command->run([], []); + } + + public function testRunExecutesWhenCommandIsAvailable(): void + { + $command = new UnavailableFixtureCommand(new Commands()); + + UnavailableFixtureCommand::$available = true; + + $exitCode = $command->run([], []); + + $this->assertSame(EXIT_SUCCESS, $exitCode); + $this->assertTrue(UnavailableFixtureCommand::$initializeCalled); + $this->assertTrue(UnavailableFixtureCommand::$interactCalled); + $this->assertTrue(UnavailableFixtureCommand::$executeCalled); + } + + public function testRunChecksAvailabilityBeforeInitializeInteractAndExecute(): void + { + $command = new UnavailableFixtureCommand(new Commands()); + + UnavailableFixtureCommand::$available = false; + + try { + $command->run([], []); + $this->fail('Expected CommandNotAvailableException was not thrown.'); + } catch (CommandNotAvailableException) { + $this->assertFalse(UnavailableFixtureCommand::$initializeCalled); + $this->assertFalse(UnavailableFixtureCommand::$interactCalled); + $this->assertFalse(UnavailableFixtureCommand::$executeCalled); + } + } + /** * @param array|string|null> $options */ diff --git a/tests/system/Commands/HelpCommandTest.php b/tests/system/Commands/HelpCommandTest.php index ab916b055739..a8308eca3f01 100644 --- a/tests/system/Commands/HelpCommandTest.php +++ b/tests/system/Commands/HelpCommandTest.php @@ -126,6 +126,29 @@ public function testDescribeSpecificCommand(): void ); } + public function testDescribeUnavailableCommand(): void + { + command('help test:unavailable'); + + $this->assertSame( + <<<'EOT' + + Usage: + test:unavailable [options] + + Description: + Fixture command to test runtime availability checks. + + 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(), + ); + } + public function testDescribeLegacyCommandUsesLegacyShowHelp(): void { // `app:info` is a legacy BaseCommand fixture. Help must take the diff --git a/tests/system/Commands/ListCommandsTest.php b/tests/system/Commands/ListCommandsTest.php index 621f3feb165c..23c7551d5f4d 100644 --- a/tests/system/Commands/ListCommandsTest.php +++ b/tests/system/Commands/ListCommandsTest.php @@ -58,6 +58,14 @@ public function testRunCommandWithSimpleOption(): void $this->assertStringNotContainsString('Clears the current system caches.', $this->getStreamFilterBuffer()); } + public function testUnavailableCommandIsStillListed(): void + { + command('list'); + + $this->assertStringContainsString('test:unavailable', $this->getStreamFilterBuffer()); + $this->assertStringContainsString('Fixture command to test runtime availability checks.', $this->getStreamFilterBuffer()); + } + public function testDuplicateCommandNameListedOnceInSimpleOutput(): void { $list = new ListCommands($this->mockRunnerWithDuplicate()); diff --git a/user_guide_src/source/changelogs/v4.8.0.rst b/user_guide_src/source/changelogs/v4.8.0.rst index 5ed77fd5b64f..db749ba744ed 100644 --- a/user_guide_src/source/changelogs/v4.8.0.rst +++ b/user_guide_src/source/changelogs/v4.8.0.rst @@ -183,7 +183,7 @@ Commands ======== - Added a new attribute-based command style built on :php:class:`AbstractCommand ` and the ``#[Command]`` attribute, - with ``configure()`` / ``initialize()`` / ``interact()`` / ``execute()`` hooks and typed ``Argument`` / ``Option`` definitions. + with ``configure()`` / ``isAvailable()`` / ``initialize()`` / ``interact()`` / ``execute()`` hooks and typed ``Argument`` / ``Option`` definitions. The legacy ``BaseCommand`` style continues to work. See :doc:`../cli/cli_modern_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 diff --git a/user_guide_src/source/cli/cli_modern_commands.rst b/user_guide_src/source/cli/cli_modern_commands.rst index c9d805642149..e3298d92f584 100644 --- a/user_guide_src/source/cli/cli_modern_commands.rst +++ b/user_guide_src/source/cli/cli_modern_commands.rst @@ -68,19 +68,21 @@ this order: extra usage examples. A default ``--help``/ ``-h`` flag, ``--no-header`` flag, and ``--no-interaction``/ ``-N`` flag are added automatically afterwards. -2. ``initialize(array &$arguments, array &$options): void`` receives the raw +2. ``isAvailable(): bool`` is called to check whether the command should execute + (see :ref:`restricting-execution`). +3. ``initialize(array &$arguments, array &$options): void`` receives the raw arguments and options by reference. Useful when your command needs to massage input — for instance, to unfold an alias argument into the canonical form before anything else runs. -3. ``interact(array &$arguments, array &$options): void`` also receives the +4. ``interact(array &$arguments, array &$options): void`` also receives the raw arguments and options by reference. This is where you prompt the user for missing input, set values conditionally, or abort early. This hook is skipped when the command is non-interactive (see :ref:`non-interactive-mode`). -4. **Bind & validate.** The framework maps the raw input to the definitions +5. **Bind & validate.** The framework maps the raw input to the definitions you declared in ``configure()``, applies defaults, and rejects input that violates the definitions (missing required argument, unknown option, array option passed without a value, and so on). -5. ``execute(array $arguments, array $options): int`` receives the bound and +6. ``execute(array $arguments, array $options): int`` receives the bound and validated arguments and options, and returns an exit code. You only have to implement ``execute()``; the other hooks are optional. @@ -223,6 +225,26 @@ parameter of ``call()``: child ``$options`` so the sub-command resolves its own state. Note: TTY detection can still downgrade the sub-command if STDIN is not a TTY. +.. _restricting-execution: + +***************************** +Restricting Command Execution +***************************** + +Sometimes a command should not run in a specific runtime context. For example, +development-only commands may need to be blocked in the ``production`` environment. + +You can override ``isAvailable()`` to decide at runtime whether the command may execute. +By default, this method returns ``true``. + +.. literalinclude:: cli_modern_commands/013.php + +The availability check runs before ``initialize()``, ``interact()``, argument and +option binding, validation, and ``execute()``. If the command is not available, +a ``CommandNotAvailableException`` is thrown immediately. + +.. note:: This method prevents execution of the command, but does not remove it from help output or command discovery. + ****************** Inside execute() ****************** diff --git a/user_guide_src/source/cli/cli_modern_commands/013.php b/user_guide_src/source/cli/cli_modern_commands/013.php new file mode 100644 index 000000000000..a7935131e407 --- /dev/null +++ b/user_guide_src/source/cli/cli_modern_commands/013.php @@ -0,0 +1,23 @@ +