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
129 changes: 122 additions & 7 deletions system/CLI/AbstractCommand.php
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,13 @@ abstract class AbstractCommand
private ?string $lastOptionalArgument = null;
private ?string $lastArrayArgument = null;

/**
* Whether the command is in interactive mode. When `null`, the interactive state is resolved based
* on the presence of the `--no-interaction` option and whether STDIN is a TTY. If boolean, this value
* takes precedence over the flag and TTY detection.
*/
private ?bool $interactive = null;

/**
* @throws InvalidArgumentDefinitionException
* @throws InvalidOptionDefinitionException
Expand Down Expand Up @@ -343,6 +350,41 @@ public function hasNegation(string $name): bool
return array_key_exists($name, $this->negations);
}

/**
* Reports whether the command is currently in interactive mode.
*
* Resolution order:
* 1. An explicit `setInteractive()` call wins.
* 2. Otherwise, the command is interactive when STDIN is a TTY.
*
* Non-CLI contexts (e.g., a controller invoking `command()`) don't expose
* `STDIN` at all — those always resolve as non-interactive.
*
* Note: the `--no-interaction` / `-N` flag is folded into the explicit state
* by `run()` before interactive hooks fire, so callers do not need to
* inspect the options array themselves.
*/
public function isInteractive(): bool
{
if ($this->interactive !== null) {
return $this->interactive;
}

return defined('STDIN') && CLI::streamSupports('stream_isatty', \STDIN);
}

/**
* Pins the interactive state, overriding both the `--no-interaction` flag
* and STDIN TTY detection. Typically called from `initialize()` or by
* an outer caller that needs to force a specific mode.
*/
public function setInteractive(bool $interactive): static
{
$this->interactive = $interactive;

return $this;
}

/**
* Runs the command.
*
Expand Down Expand Up @@ -377,8 +419,13 @@ final public function run(array $arguments, array $options): int
{
$this->initialize($arguments, $options);

// @todo add interactive mode check
$this->interact($arguments, $options);
if ($this->interactive === null && $this->hasUnboundOption('no-interaction', $options)) {
$this->interactive = false;
}

if ($this->isInteractive()) {
$this->interact($arguments, $options);
}

$this->unboundArguments = $arguments;
$this->unboundOptions = $options;
Expand Down Expand Up @@ -447,12 +494,17 @@ abstract protected function execute(array $arguments, array $options): int;
/**
* Calls another command from the current command.
*
* @param list<string> $arguments Parsed arguments from command line.
* @param array<string, list<string>|string|null> $options Parsed options from command line.
* @param list<string> $arguments Parsed arguments from command line.
* @param array<string, list<string>|string|null> $options Parsed options from command line.
* @param bool|null $noInteractionOverride `null` (default) propagates the parent's non-interactive state;
* `true` forces the sub-command non-interactive by injecting
* `--no-interaction`; `false` strips any inherited
* `--no-interaction` so the sub-command resolves its own state
* (TTY detection may still downgrade it).
*/
protected function call(string $command, array $arguments = [], array $options = []): int
protected function call(string $command, array $arguments = [], array $options = [], ?bool $noInteractionOverride = null): int
{
return $this->commands->runCommand($command, $arguments, $options);
return $this->commands->runCommand($command, $arguments, $this->resolveChildInteractiveState($options, $noInteractionOverride));
}

/**
Expand Down Expand Up @@ -609,11 +661,74 @@ protected function getValidatedOption(string $name): array|bool|string|null
return $this->validatedOptions[$name];
}

/**
* Registers the options that the framework injects into every modern
* command. Every option registered here is load-bearing:
*
* - `--help` / `-h`: `Console` detects it and routes to the `help` command.
* - `--no-header`: `Console` strips it before rendering the banner.
* - `--no-interaction` / `-N`: `run()` folds it into the interactive state
* and `resolveChildInteractiveState()` reads it to drive the `call()` cascade.
*
* Subclasses that override this hook should re-register these options or
* accept that the corresponding framework features will be broken for
* the subclass.
*/
protected function provideDefaultOptions(): void
{
$this
->addOption(new Option(name: 'help', shortcut: 'h', description: 'Display help for the given command.'))
->addOption(new Option(name: 'no-header', description: 'Do not display the banner when running the command.'));
->addOption(new Option(name: 'no-header', description: 'Do not display the banner when running the command.'))
->addOption(new Option(name: 'no-interaction', shortcut: 'N', description: 'Do not ask any interactive questions.'));
}

/**
* Reconciles the caller's explicit intent (`$noInteractionOverride`) with
* the parent command's own interactive state to produce the `$options`
* that `call()` should hand to the sub-command.
*
* - `null` (default) propagates the parent's non-interactive state by
* adding `--no-interaction` when the parent itself is non-interactive.
* If the caller already supplied `--no-interaction` under any of its
* aliases, their value is preserved.
* - `true` forces the sub-command non-interactive regardless of the
* parent, again deferring to a caller-supplied value if present.
* - `false` strips any inherited or propagated `--no-interaction` so the
* sub-command resolves its own state. TTY detection can still force
* non-interactive if STDIN is not a TTY.
*
* @param array<string, list<string|null>|string|null> $options
*
* @return array<string, list<string|null>|string|null>
*/
private function resolveChildInteractiveState(array $options, ?bool $noInteractionOverride): array
{
$this->assertOptionIsDefined('no-interaction');

if ($noInteractionOverride === false) {
$definition = $this->optionsDefinition['no-interaction'];

$aliases = array_filter(
[$definition->name, $definition->shortcut, $definition->negation],
static fn (?string $alias): bool => $alias !== null,
);

foreach ($aliases as $alias) {
unset($options[$alias]);
}

return $options;
}

if ($this->hasUnboundOption('no-interaction', $options)) {
return $options;
}

if ($noInteractionOverride === true || ! $this->isInteractive()) {
$options['no-interaction'] = null; // simulate --no-interaction being passed
}

return $options;
}

/**
Expand Down
5 changes: 3 additions & 2 deletions system/Commands/Housekeeping/ClearLogs.php
Original file line number Diff line number Diff line change
Expand Up @@ -51,8 +51,9 @@ protected function execute(array $arguments, array $options): int
if ($options['force'] === false) {
CLI::error('Deleting logs aborted.');

// @todo to re-add under non-interactive mode
// CLI::error('If you want, use the "--force" option to force delete all log files.');
if (! $this->isInteractive()) {
CLI::error('If you want, use the "--force" option to force delete all log files.');
}

return EXIT_ERROR;
}
Expand Down
47 changes: 47 additions & 0 deletions tests/_support/Commands/Modern/InteractiveStateProbeCommand.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
<?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:probe', description: 'Fixture that records its interactive state so the caller can assert on it.', group: 'Fixtures')]
final class InteractiveStateProbeCommand extends AbstractCommand
{
/**
* Records whether `interact()` fired during the last run — a side-channel
* for asserting on a child fixture created anonymously by `Commands::runCommand()`.
*/
public static bool $interactCalled = false;

public static ?bool $observedInteractive = null;

public static function reset(): void
{
self::$interactCalled = false;
self::$observedInteractive = null;
}

protected function interact(array &$arguments, array &$options): void
{
self::$interactCalled = true;
}

protected function execute(array $arguments, array $options): int
{
self::$observedInteractive = $this->isInteractive();

return EXIT_SUCCESS;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
<?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:parent-interact', description: 'Fixture that delegates to test:probe via call().', group: 'Fixtures')]
final class ParentCallsInteractFixtureCommand extends AbstractCommand
{
/**
* Forwarded verbatim as the `$noInteractionOverride` argument of `call()`.
* `null` leaves the default propagation behavior in place.
*/
public ?bool $childNoInteractionOverride = null;

/**
* Forwarded verbatim as the `$options` argument of `call()`. Lets tests
* exercise the resolver's caller-provided-flag code paths.
*
* @var array<string, list<string|null>|string|null>
*/
public array $childOptions = [];

protected function execute(array $arguments, array $options): int
{
return $this->call('test:probe', options: $this->childOptions, noInteractionOverride: $this->childNoInteractionOverride);
}
}
Loading
Loading