Skip to content

Commit 6cfa8ce

Browse files
committed
feat: add non-interactive session checking to modern commands
1 parent 2db0ed7 commit 6cfa8ce

9 files changed

Lines changed: 494 additions & 40 deletions

File tree

system/CLI/AbstractCommand.php

Lines changed: 122 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -113,6 +113,13 @@ abstract class AbstractCommand
113113
private ?string $lastOptionalArgument = null;
114114
private ?string $lastArrayArgument = null;
115115

116+
/**
117+
* Whether the command is in interactive mode. When `null`, the interactive state is resolved based
118+
* on the presence of the `--no-interaction` option and whether STDIN is a TTY. If boolean, this value
119+
* takes precedence over the flag and TTY detection.
120+
*/
121+
private ?bool $interactive = null;
122+
116123
/**
117124
* @throws InvalidArgumentDefinitionException
118125
* @throws InvalidOptionDefinitionException
@@ -343,6 +350,41 @@ public function hasNegation(string $name): bool
343350
return array_key_exists($name, $this->negations);
344351
}
345352

353+
/**
354+
* Reports whether the command is currently in interactive mode.
355+
*
356+
* Resolution order:
357+
* 1. An explicit `setInteractive()` call wins.
358+
* 2. Otherwise, the command is interactive when STDIN is a TTY.
359+
*
360+
* Non-CLI contexts (e.g., a controller invoking `command()`) don't expose
361+
* `STDIN` at all — those always resolve as non-interactive.
362+
*
363+
* Note: the `--no-interaction` / `-N` flag is folded into the explicit state
364+
* by `run()` before interactive hooks fire, so callers do not need to
365+
* inspect the options array themselves.
366+
*/
367+
public function isInteractive(): bool
368+
{
369+
if ($this->interactive !== null) {
370+
return $this->interactive;
371+
}
372+
373+
return defined('STDIN') && CLI::streamSupports('stream_isatty', \STDIN);
374+
}
375+
376+
/**
377+
* Pins the interactive state, overriding both the `--no-interaction` flag
378+
* and STDIN TTY detection. Typically called from `initialize()` or by
379+
* an outer caller that needs to force a specific mode.
380+
*/
381+
public function setInteractive(bool $interactive): static
382+
{
383+
$this->interactive = $interactive;
384+
385+
return $this;
386+
}
387+
346388
/**
347389
* Runs the command.
348390
*
@@ -377,8 +419,13 @@ final public function run(array $arguments, array $options): int
377419
{
378420
$this->initialize($arguments, $options);
379421

380-
// @todo add interactive mode check
381-
$this->interact($arguments, $options);
422+
if ($this->interactive === null && $this->hasUnboundOption('no-interaction', $options)) {
423+
$this->interactive = false;
424+
}
425+
426+
if ($this->isInteractive()) {
427+
$this->interact($arguments, $options);
428+
}
382429

383430
$this->unboundArguments = $arguments;
384431
$this->unboundOptions = $options;
@@ -447,12 +494,17 @@ abstract protected function execute(array $arguments, array $options): int;
447494
/**
448495
* Calls another command from the current command.
449496
*
450-
* @param list<string> $arguments Parsed arguments from command line.
451-
* @param array<string, list<string>|string|null> $options Parsed options from command line.
497+
* @param list<string> $arguments Parsed arguments from command line.
498+
* @param array<string, list<string>|string|null> $options Parsed options from command line.
499+
* @param bool|null $noInteractionOverride `null` (default) propagates the parent's non-interactive state;
500+
* `true` forces the sub-command non-interactive by injecting
501+
* `--no-interaction`; `false` strips any inherited
502+
* `--no-interaction` so the sub-command resolves its own state
503+
* (TTY detection may still downgrade it).
452504
*/
453-
protected function call(string $command, array $arguments = [], array $options = []): int
505+
protected function call(string $command, array $arguments = [], array $options = [], ?bool $noInteractionOverride = null): int
454506
{
455-
return $this->commands->runCommand($command, $arguments, $options);
507+
return $this->commands->runCommand($command, $arguments, $this->resolveChildInteractiveState($options, $noInteractionOverride));
456508
}
457509

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

664+
/**
665+
* Registers the options that the framework injects into every modern
666+
* command. Every option registered here is load-bearing:
667+
*
668+
* - `--help` / `-h`: `Console` detects it and routes to the `help` command.
669+
* - `--no-header`: `Console` strips it before rendering the banner.
670+
* - `--no-interaction` / `-N`: `run()` folds it into the interactive state
671+
* and `resolveChildInteractiveState()` reads it to drive the `call()` cascade.
672+
*
673+
* Subclasses that override this hook should re-register these options or
674+
* accept that the corresponding framework features will be broken for
675+
* the subclass.
676+
*/
612677
protected function provideDefaultOptions(): void
613678
{
614679
$this
615680
->addOption(new Option(name: 'help', shortcut: 'h', description: 'Display help for the given command.'))
616-
->addOption(new Option(name: 'no-header', description: 'Do not display the banner when running the command.'));
681+
->addOption(new Option(name: 'no-header', description: 'Do not display the banner when running the command.'))
682+
->addOption(new Option(name: 'no-interaction', shortcut: 'N', description: 'Do not ask any interactive questions.'));
683+
}
684+
685+
/**
686+
* Reconciles the caller's explicit intent (`$noInteractionOverride`) with
687+
* the parent command's own interactive state to produce the `$options`
688+
* that `call()` should hand to the sub-command.
689+
*
690+
* - `null` (default) propagates the parent's non-interactive state by
691+
* adding `--no-interaction` when the parent itself is non-interactive.
692+
* If the caller already supplied `--no-interaction` under any of its
693+
* aliases, their value is preserved.
694+
* - `true` forces the sub-command non-interactive regardless of the
695+
* parent, again deferring to a caller-supplied value if present.
696+
* - `false` strips any inherited or propagated `--no-interaction` so the
697+
* sub-command resolves its own state. TTY detection can still force
698+
* non-interactive if STDIN is not a TTY.
699+
*
700+
* @param array<string, list<string|null>|string|null> $options
701+
*
702+
* @return array<string, list<string|null>|string|null>
703+
*/
704+
private function resolveChildInteractiveState(array $options, ?bool $noInteractionOverride): array
705+
{
706+
$this->assertOptionIsDefined('no-interaction');
707+
708+
if ($noInteractionOverride === false) {
709+
$definition = $this->optionsDefinition['no-interaction'];
710+
711+
$aliases = array_filter(
712+
[$definition->name, $definition->shortcut, $definition->negation],
713+
static fn (?string $alias): bool => $alias !== null,
714+
);
715+
716+
foreach ($aliases as $alias) {
717+
unset($options[$alias]);
718+
}
719+
720+
return $options;
721+
}
722+
723+
if ($this->hasUnboundOption('no-interaction', $options)) {
724+
return $options;
725+
}
726+
727+
if ($noInteractionOverride === true || ! $this->isInteractive()) {
728+
$options['no-interaction'] = null; // simulate --no-interaction being passed
729+
}
730+
731+
return $options;
617732
}
618733

619734
/**

system/Commands/Housekeeping/ClearLogs.php

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -51,8 +51,9 @@ protected function execute(array $arguments, array $options): int
5151
if ($options['force'] === false) {
5252
CLI::error('Deleting logs aborted.');
5353

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

5758
return EXIT_ERROR;
5859
}
Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
/**
6+
* This file is part of CodeIgniter 4 framework.
7+
*
8+
* (c) CodeIgniter Foundation <[email protected]>
9+
*
10+
* For the full copyright and license information, please view
11+
* the LICENSE file that was distributed with this source code.
12+
*/
13+
14+
namespace Tests\Support\Commands\Modern;
15+
16+
use CodeIgniter\CLI\AbstractCommand;
17+
use CodeIgniter\CLI\Attributes\Command;
18+
19+
#[Command(name: 'test:probe', description: 'Fixture that records its interactive state so the caller can assert on it.', group: 'Fixtures')]
20+
final class InteractiveStateProbeCommand extends AbstractCommand
21+
{
22+
/**
23+
* Records whether `interact()` fired during the last run — a side-channel
24+
* for asserting on a child fixture created anonymously by `Commands::runCommand()`.
25+
*/
26+
public static bool $interactCalled = false;
27+
28+
public static ?bool $observedInteractive = null;
29+
30+
public static function reset(): void
31+
{
32+
self::$interactCalled = false;
33+
self::$observedInteractive = null;
34+
}
35+
36+
protected function interact(array &$arguments, array &$options): void
37+
{
38+
self::$interactCalled = true;
39+
}
40+
41+
protected function execute(array $arguments, array $options): int
42+
{
43+
self::$observedInteractive = $this->isInteractive();
44+
45+
return EXIT_SUCCESS;
46+
}
47+
}
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
/**
6+
* This file is part of CodeIgniter 4 framework.
7+
*
8+
* (c) CodeIgniter Foundation <[email protected]>
9+
*
10+
* For the full copyright and license information, please view
11+
* the LICENSE file that was distributed with this source code.
12+
*/
13+
14+
namespace Tests\Support\Commands\Modern;
15+
16+
use CodeIgniter\CLI\AbstractCommand;
17+
use CodeIgniter\CLI\Attributes\Command;
18+
19+
#[Command(name: 'test:parent-interact', description: 'Fixture that delegates to test:probe via call().', group: 'Fixtures')]
20+
final class ParentCallsInteractFixtureCommand extends AbstractCommand
21+
{
22+
/**
23+
* Forwarded verbatim as the `$noInteractionOverride` argument of `call()`.
24+
* `null` leaves the default propagation behavior in place.
25+
*/
26+
public ?bool $childNoInteractionOverride = null;
27+
28+
/**
29+
* Forwarded verbatim as the `$options` argument of `call()`. Lets tests
30+
* exercise the resolver's caller-provided-flag code paths.
31+
*
32+
* @var array<string, list<string|null>|string|null>
33+
*/
34+
public array $childOptions = [];
35+
36+
protected function execute(array $arguments, array $options): int
37+
{
38+
return $this->call('test:probe', options: $this->childOptions, noInteractionOverride: $this->childNoInteractionOverride);
39+
}
40+
}

0 commit comments

Comments
 (0)