Skip to content

Commit 3cdb4c5

Browse files
authored
feat: add non-interactive session checking to modern commands (#10134)
1 parent 1efb85b commit 3cdb4c5

9 files changed

Lines changed: 561 additions & 69 deletions

File tree

system/CLI/AbstractCommand.php

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

116+
/**
117+
* Interactive state pinned by `setInteractive()`. When boolean, it takes precedence over
118+
* the per-run flag and TTY detection, and remains in effect across `run()` calls on
119+
* the same instance.
120+
*/
121+
private ?bool $interactive = null;
122+
123+
/**
124+
* Per-run interactive state derived from `--no-interaction` / `-N` in the current `$options`.
125+
*/
126+
private ?bool $runtimeInteractive = null;
127+
116128
/**
117129
* @throws InvalidArgumentDefinitionException
118130
* @throws InvalidOptionDefinitionException
@@ -343,27 +355,55 @@ public function hasNegation(string $name): bool
343355
return array_key_exists($name, $this->negations);
344356
}
345357

358+
/**
359+
* Reports whether the command is currently in interactive mode.
360+
*
361+
* Resolution order:
362+
* 1. An explicit `setInteractive()` call wins.
363+
* 2. Otherwise, the `--no-interaction` / `-N` flag from the current `run()`
364+
* forces non-interactive.
365+
* 3. Otherwise, the command is interactive when STDIN is a TTY.
366+
*
367+
* Non-CLI contexts (e.g., a controller invoking `command()`) don't expose
368+
* `STDIN` at all; those always resolve as non-interactive.
369+
*/
370+
public function isInteractive(): bool
371+
{
372+
return $this->interactive
373+
?? $this->runtimeInteractive
374+
?? (defined('STDIN') && CLI::streamSupports('stream_isatty', \STDIN));
375+
}
376+
377+
/**
378+
* Pins the interactive state, overriding both the `--no-interaction` flag
379+
* and STDIN TTY detection.
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
*
349391
* The lifecycle is:
350392
*
351-
* 1. {@see initialize()} and {@see interact()} are handed the raw parsed
352-
* input by reference, in that order. Both can mutate the tokens before
353-
* the framework interprets them against the declared definitions.
354-
* 2. The post-hook input is snapshotted into `$unboundArguments` and
355-
* `$unboundOptions` so the unbound accessors can report the tokens
356-
* carried into binding (as opposed to what defaults resolved to).
357-
* Any mutations performed in `initialize()` or `interact()` are
358-
* therefore reflected in the snapshot.
359-
* 3. {@see bind()} maps the raw tokens onto the declared arguments and
360-
* options, applying defaults and coercing flag/negation values.
361-
* 4. {@see validate()} rejects the bound result if it violates any of the
362-
* declarations — missing required argument, unknown option, value/flag
363-
* mismatches, and so on.
364-
* 5. The bound-and-validated values are snapshotted into
365-
* `$validatedArguments` / `$validatedOptions` and then passed to
366-
* {@see execute()}, whose integer return is the command's exit code.
393+
* 1. `initialize()` and `interact()` are handed the raw parsed input by reference, in that order.
394+
* Both can mutate the tokens before the framework interprets them against the declared definitions.
395+
* Note: the per-run interactive state is captured from `$options` before `initialize()` runs, so
396+
* mutating `--no-interaction` from within `initialize()` will not affect this invocation. Use
397+
* `setInteractive()` instead.
398+
* 2. The post-hook input is snapshotted into `$unboundArguments` and `$unboundOptions` so the unbound
399+
* accessors can report the tokens carried into binding (as opposed to what defaults resolved to).
400+
* Any mutations performed in `initialize()` or `interact()` are therefore reflected in the snapshot.
401+
* 3. `bind()` maps the raw tokens onto the declared arguments and options, applying defaults and
402+
* coercing flag/negation values.
403+
* 4. `validate()` rejects the bound result if it violates any of the declarations — missing required
404+
* argument, unknown option, value/flag mismatches, and so on.
405+
* 5. The bound-and-validated values are snapshotted into `$validatedArguments` / `$validatedOptions`
406+
* and then passed to `execute()`, whose integer return is the command's exit code.
367407
*
368408
* @param list<string> $arguments Parsed arguments from command line.
369409
* @param array<string, list<string|null>|string|null> $options Parsed options from command line.
@@ -375,10 +415,14 @@ public function hasNegation(string $name): bool
375415
*/
376416
final public function run(array $arguments, array $options): int
377417
{
418+
// Reset per-run interactive state from the current options.
419+
$this->runtimeInteractive = $this->hasUnboundOption('no-interaction', $options) ? false : null;
420+
378421
$this->initialize($arguments, $options);
379422

380-
// @todo add interactive mode check
381-
$this->interact($arguments, $options);
423+
if ($this->isInteractive()) {
424+
$this->interact($arguments, $options);
425+
}
382426

383427
$this->unboundArguments = $arguments;
384428
$this->unboundOptions = $options;
@@ -447,12 +491,17 @@ abstract protected function execute(array $arguments, array $options): int;
447491
/**
448492
* Calls another command from the current command.
449493
*
450-
* @param list<string> $arguments Parsed arguments from command line.
451-
* @param array<string, list<string>|string|null> $options Parsed options from command line.
494+
* @param list<string> $arguments Parsed arguments from command line.
495+
* @param array<string, list<string>|string|null> $options Parsed options from command line.
496+
* @param bool|null $noInteractionOverride `null` (default) propagates the parent's non-interactive state;
497+
* `true` forces the sub-command non-interactive by injecting
498+
* `--no-interaction`; `false` removes any forwarded
499+
* `--no-interaction` from `$options` so the sub-command
500+
* resolves its own state (TTY detection may still downgrade it).
452501
*/
453-
protected function call(string $command, array $arguments = [], array $options = []): int
502+
protected function call(string $command, array $arguments = [], array $options = [], ?bool $noInteractionOverride = null): int
454503
{
455-
return $this->commands->runCommand($command, $arguments, $options);
504+
return $this->commands->runCommand($command, $arguments, $this->resolveChildInteractiveState($options, $noInteractionOverride));
456505
}
457506

458507
/**
@@ -490,13 +539,11 @@ protected function getUnboundOptions(): array
490539
}
491540

492541
/**
493-
* Reads the raw (unbound) value of the option with the given declared name,
494-
* resolving through its shortcut and negation. Returns `null` when the
495-
* option was not provided under any of those aliases.
542+
* Reads the raw (unbound) value of the option with the given declared name, resolving through its
543+
* shortcut and negation. Returns `null` when the option was not provided under any of those aliases.
496544
*
497-
* Inside {@see interact()}, pass the `$options` parameter explicitly because
498-
* the instance state is not yet populated at that point. Elsewhere, omit
499-
* `$options` to read from the instance state.
545+
* Inside `interact()`, pass the `$options` parameter explicitly because the instance state is not yet
546+
* populated at that point. Elsewhere, omit `$options` to read from the instance state.
500547
*
501548
* @param array<string, list<string|null>|string|null>|null $options
502549
*
@@ -528,11 +575,11 @@ protected function getUnboundOption(string $name, ?array $options = null): array
528575
}
529576

530577
/**
531-
* Returns whether the option with the given declared name was provided in
532-
* the raw (unbound) input — under its long name, shortcut, or negation.
578+
* Returns whether the option with the given declared name was provided in the raw (unbound) input —
579+
* under its long name, shortcut, or negation.
533580
*
534-
* Inside {@see interact()}, pass the `$options` parameter explicitly; elsewhere
535-
* omit it to read from instance state.
581+
* Inside `interact()`, pass the `$options` parameter explicitly; elsewhere omit it to read from
582+
* instance state.
536583
*
537584
* @param array<string, list<string|null>|string|null>|null $options
538585
*
@@ -609,11 +656,75 @@ protected function getValidatedOption(string $name): array|bool|string|null
609656
return $this->validatedOptions[$name];
610657
}
611658

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

619730
/**

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 <admin@codeigniter.com>
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. This is 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 <admin@codeigniter.com>
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)