Skip to content
Open
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
25 changes: 20 additions & 5 deletions system/CLI/AbstractCommand.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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<string> $arguments Parsed arguments from command line.
* @param array<string, list<string|null>|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;

Expand Down Expand Up @@ -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.
*
Expand Down
23 changes: 23 additions & 0 deletions system/CLI/Exceptions/CommandNotAvailableException.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
<?php

declare(strict_types=1);

/**
* This file is part of CodeIgniter 4 framework.
*
* (c) CodeIgniter Foundation <admin@codeigniter.com>
*
* 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
{
}
1 change: 1 addition & 0 deletions system/Language/en/Commands.php
Original file line number Diff line number Diff line change
Expand Up @@ -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.',
Expand Down
56 changes: 56 additions & 0 deletions tests/_support/Commands/Modern/UnavailableFixtureCommand.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
<?php

declare(strict_types=1);

/**
* This file is part of CodeIgniter 4 framework.
*
* (c) CodeIgniter Foundation <admin@codeigniter.com>
*
* 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;
}
}
45 changes: 45 additions & 0 deletions tests/system/CLI/AbstractCommandTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;

/**
Expand All @@ -60,6 +62,7 @@ protected function resetAll(): void
CLI::reset();

InteractiveStateProbeCommand::reset();
UnavailableFixtureCommand::reset();
}

private function getUndecoratedBuffer(): string
Expand Down Expand Up @@ -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, list<string|null>|string|null> $options
*/
Expand Down
23 changes: 23 additions & 0 deletions tests/system/Commands/HelpCommandTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
8 changes: 8 additions & 0 deletions tests/system/Commands/ListCommandsTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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());
Expand Down
2 changes: 1 addition & 1 deletion user_guide_src/source/changelogs/v4.8.0.rst
Original file line number Diff line number Diff line change
Expand Up @@ -183,7 +183,7 @@ Commands
========

- Added a new attribute-based command style built on :php:class:`AbstractCommand <CodeIgniter\\CLI\\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
Expand Down
30 changes: 26 additions & 4 deletions user_guide_src/source/cli/cli_modern_commands.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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.
Comment thread
patel-vansh marked this conversation as resolved.

******************
Inside execute()
******************
Expand Down
23 changes: 23 additions & 0 deletions user_guide_src/source/cli/cli_modern_commands/013.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
<?php

namespace App\Commands;

use CodeIgniter\CLI\AbstractCommand;
use CodeIgniter\CLI\Attributes\Command;

#[Command(name: 'dev:only', description: 'A dev only command', group: 'Dev')]
class DevOnly extends AbstractCommand
{
protected function isAvailable(): bool
{
// Only allow this command in the development environment
return ENVIRONMENT === 'development';
}

protected function execute(array $arguments, array $options): int
{
// ...

return EXIT_SUCCESS;
}
}
Loading