Skip to content

Commit a754da4

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

9 files changed

Lines changed: 472 additions & 40 deletions

File tree

system/CLI/AbstractCommand.php

Lines changed: 100 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,34 @@ 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+
* Note: the `--no-interaction` / `-N` flag is folded into the explicit state
361+
* by `run()` before interactive hooks fire, so callers do not need to
362+
* inspect the options array themselves.
363+
*/
364+
public function isInteractive(): bool
365+
{
366+
return $this->interactive ?? CLI::streamSupports('stream_isatty', STDIN);
367+
}
368+
369+
/**
370+
* Pins the interactive state, overriding both the `--no-interaction` flag
371+
* and STDIN TTY detection. Typically called from `initialize()` or by
372+
* an outer caller that needs to force a specific mode.
373+
*/
374+
public function setInteractive(bool $interactive): static
375+
{
376+
$this->interactive = $interactive;
377+
378+
return $this;
379+
}
380+
346381
/**
347382
* Runs the command.
348383
*
@@ -377,8 +412,13 @@ final public function run(array $arguments, array $options): int
377412
{
378413
$this->initialize($arguments, $options);
379414

380-
// @todo add interactive mode check
381-
$this->interact($arguments, $options);
415+
if ($this->interactive === null && $this->hasUnboundOption('no-interaction', $options)) {
416+
$this->interactive = false;
417+
}
418+
419+
if ($this->isInteractive()) {
420+
$this->interact($arguments, $options);
421+
}
382422

383423
$this->unboundArguments = $arguments;
384424
$this->unboundOptions = $options;
@@ -447,12 +487,17 @@ abstract protected function execute(array $arguments, array $options): int;
447487
/**
448488
* Calls another command from the current command.
449489
*
450-
* @param list<string> $arguments Parsed arguments from command line.
451-
* @param array<string, list<string>|string|null> $options Parsed options from command line.
490+
* @param list<string> $arguments Parsed arguments from command line.
491+
* @param array<string, list<string>|string|null> $options Parsed options from command line.
492+
* @param bool|null $noInteractionOverride `null` (default) propagates the parent's non-interactive state;
493+
* `true` forces the sub-command non-interactive by injecting
494+
* `--no-interaction`; `false` strips any inherited
495+
* `--no-interaction` so the sub-command resolves its own state
496+
* (TTY detection may still downgrade it).
452497
*/
453-
protected function call(string $command, array $arguments = [], array $options = []): int
498+
protected function call(string $command, array $arguments = [], array $options = [], ?bool $noInteractionOverride = null): int
454499
{
455-
return $this->commands->runCommand($command, $arguments, $options);
500+
return $this->commands->runCommand($command, $arguments, $this->resolveChildInteractiveState($options, $noInteractionOverride));
456501
}
457502

458503
/**
@@ -613,7 +658,55 @@ protected function provideDefaultOptions(): void
613658
{
614659
$this
615660
->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.'));
661+
->addOption(new Option(name: 'no-header', description: 'Do not display the banner when running the command.'))
662+
->addOption(new Option(name: 'no-interaction', shortcut: 'N', description: 'Do not ask any interactive questions.'));
663+
}
664+
665+
/**
666+
* Reconciles the caller's explicit intent (`$noInteractionOverride`) with
667+
* the parent command's own interactive state to produce the `$options`
668+
* that `call()` should hand to the sub-command.
669+
*
670+
* - `null` (default) propagates the parent's non-interactive state by
671+
* adding `--no-interaction` when the parent itself is non-interactive.
672+
* If the caller already supplied `--no-interaction` under any of its
673+
* aliases, their value is preserved.
674+
* - `true` forces the sub-command non-interactive regardless of the
675+
* parent, again deferring to a caller-supplied value if present.
676+
* - `false` strips any inherited or propagated `--no-interaction` so the
677+
* sub-command resolves its own state. TTY detection can still force
678+
* non-interactive if STDIN is not a TTY.
679+
*
680+
* @param array<string, list<string|null>|string|null> $options
681+
*
682+
* @return array<string, list<string|null>|string|null>
683+
*/
684+
private function resolveChildInteractiveState(array $options, ?bool $noInteractionOverride): array
685+
{
686+
if ($noInteractionOverride === false) {
687+
$definition = $this->optionsDefinition['no-interaction'];
688+
689+
$aliases = array_filter(
690+
[$definition->name, $definition->shortcut, $definition->negation],
691+
static fn (?string $alias): bool => $alias !== null,
692+
);
693+
694+
foreach ($aliases as $alias) {
695+
unset($options[$alias]);
696+
}
697+
698+
return $options;
699+
}
700+
701+
if ($this->hasUnboundOption('no-interaction', $options)) {
702+
return $options;
703+
}
704+
705+
if ($noInteractionOverride === true || ! $this->isInteractive()) {
706+
$options['no-interaction'] = null; // simulate --no-interaction being passed
707+
}
708+
709+
return $options;
617710
}
618711

619712
/**

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)