3
declare(strict_types=1);
6
* This file is part of CodeIgniter 4 framework.
8
* (c) CodeIgniter Foundation <admin@codeigniter.com>
10
* For the full copyright and license information, please view
11
* the LICENSE file that was distributed with this source code.
14
namespace CodeIgniter\CLI;
16
use CodeIgniter\CLI\Exceptions\CLIException;
18
use InvalidArgumentException;
22
* Set of static methods useful for CLI request handling.
24
* Portions of this code were initially from the FuelPHP Framework,
25
* version 1.7.x, and used here under the MIT license they were
26
* originally made available under. Reference: http://fuelphp.com
28
* Some of the code in this class is Windows-specific, and not
29
* possible to test using travis-ci. It has been phpunit-annotated
30
* to prevent messing up code coverage.
32
* @see \CodeIgniter\CLI\CLITest
37
* Is the readline library on the system?
41
* @deprecated 4.4.2 Should be protected, and no longer used.
42
* @TODO Fix to camelCase in the next major version.
44
public static $readline_support = false;
47
* The message displayed at prompts.
51
* @deprecated 4.4.2 Should be protected.
52
* @TODO Fix to camelCase in the next major version.
54
public static $wait_msg = 'Press any key to continue...';
57
* Has the class already been initialized?
61
protected static $initialized = false;
64
* Foreground color list
66
* @var array<string, string>
68
* @TODO Fix to camelCase in the next major version.
70
protected static $foreground_colors = [
72
'dark_gray' => '1;30',
74
'dark_blue' => '0;34',
75
'light_blue' => '1;34',
77
'light_green' => '1;32',
79
'light_cyan' => '1;36',
81
'light_red' => '1;31',
83
'light_purple' => '1;35',
85
'light_yellow' => '1;33',
86
'light_gray' => '0;37',
91
* Background color list
93
* @var array<string, string>
95
* @TODO Fix to camelCase in the next major version.
97
protected static $background_colors = [
105
'light_gray' => '47',
109
* List of array segments.
113
protected static $segments = [];
118
protected static $options = [];
121
* Helps track internally whether the last
122
* output was a "write" or a "print" to
123
* keep the output clean and as expected.
127
protected static $lastWrite;
130
* Height of the CLI window
134
protected static $height;
137
* Width of the CLI window
141
protected static $width;
144
* Whether the current stream supports colored output.
148
protected static $isColored = false;
151
* Input and Output for CLI.
153
protected static ?InputOutput $io = null;
156
* Static "constructor".
160
public static function init()
163
// Readline is an extension for PHP that makes interactivity with PHP
164
// much more bash-like.
165
// http://www.php.net/manual/en/readline.installation.php
166
static::$readline_support = extension_loaded('readline');
168
// clear segments & options to keep testing clean
169
static::$segments = [];
170
static::$options = [];
172
// Check our stream resource for color support
173
static::$isColored = static::hasColorSupport(STDOUT);
175
static::parseCommandLine();
177
static::$initialized = true;
178
} elseif (! defined('STDOUT')) {
179
// If the command is being called from a controller
180
// we need to define STDOUT ourselves
181
// For "! defined('STDOUT')" see: https://github.com/codeigniter4/CodeIgniter4/issues/7047
182
define('STDOUT', 'php://output'); // @codeCoverageIgnore
185
static::resetInputOutput();
189
* Get input from the shell, using readline or the standard STDIN
191
* Named options must be in the following formats:
192
* php index.php user -v --v -name=John --name=John
194
* @param string|null $prefix You may specify a string with which to prompt the user.
196
public static function input(?string $prefix = null): string
198
return static::$io->input($prefix);
202
* Asks the user for input.
207
* $color = CLI::prompt('What is your favorite color?');
209
* // Takes any input, but offers default
210
* $color = CLI::prompt('What is your favourite color?', 'white');
212
* // Will validate options with the in_list rule and accept only if one of the list
213
* $color = CLI::prompt('What is your favourite color?', array('red','blue'));
215
* // Do not provide options but requires a valid email
216
* $email = CLI::prompt('What is your email?', null, 'required|valid_email');
218
* @param string $field Output "field" question
219
* @param list<int|string>|string $options String to a default value, array to a list of options (the first option will be the default value)
220
* @param array|string|null $validation Validation rules
222
* @return string The user input
224
public static function prompt(string $field, $options = null, $validation = null): string
229
if ($validation && ! is_array($validation) && ! is_string($validation)) {
230
throw new InvalidArgumentException('$rules can only be of type string|array');
233
if (! is_array($validation)) {
234
$validation = $validation ? explode('|', $validation) : [];
237
if (is_string($options)) {
238
$extraOutput = ' [' . static::color($options, 'green') . ']';
242
if (is_array($options) && $options !== []) {
244
$extraOutputDefault = static::color((string) $opts[0], 'green');
249
$extraOutput = $extraOutputDefault;
251
$extraOutput = '[' . $extraOutputDefault . ', ' . implode(', ', $opts) . ']';
252
$validation[] = 'in_list[' . implode(', ', $options) . ']';
255
$default = $options[0];
258
static::fwrite(STDOUT, $field . (trim($field) !== '' ? ' ' : '') . $extraOutput . ': ');
260
// Read the input from keyboard.
261
$input = trim(static::$io->input());
262
$input = ($input === '') ? (string) $default : $input;
264
if ($validation !== []) {
265
while (! static::validate('"' . trim($field) . '"', $input, $validation)) {
266
$input = static::prompt($field, $options, $validation);
274
* prompt(), but based on the option's key
276
* @param array|string $text Output "field" text or an one or two value array where the first value is the text before listing the options
277
* and the second value the text before asking to select one option. Provide empty string to omit
278
* @param array $options A list of options (array(key => description)), the first option will be the default value
279
* @param array|string|null $validation Validation rules
281
* @return string The selected key of $options
283
public static function promptByKey($text, array $options, $validation = null): string
285
if (is_string($text)) {
287
} elseif (! is_array($text)) {
288
throw new InvalidArgumentException('$text can only be of type string|array');
291
CLI::isZeroOptions($options);
293
if ($line = array_shift($text)) {
297
CLI::printKeysAndValues($options);
299
return static::prompt(PHP_EOL . array_shift($text), array_keys($options), $validation);
303
* This method is the same as promptByKey(), but this method supports multiple keys, separated by commas.
305
* @param string $text Output "field" text or an one or two value array where the first value is the text before listing the options
306
* and the second value the text before asking to select one option. Provide empty string to omit
307
* @param array $options A list of options (array(key => description)), the first option will be the default value
309
* @return array The selected key(s) and value(s) of $options
311
public static function promptByMultipleKeys(string $text, array $options): array
313
CLI::isZeroOptions($options);
315
$extraOutputDefault = static::color('0', 'green');
320
$extraOutput = $extraOutputDefault;
324
foreach (array_keys($opts) as $key) {
327
$extraOutput = '[' . $extraOutputDefault . ', ' . implode(', ', $optsKey) . ']';
328
$extraOutput = 'You can specify multiple values separated by commas.' . PHP_EOL . $extraOutput;
332
CLI::printKeysAndValues($options);
335
$input = static::prompt($extraOutput);
336
$input = ($input === '') ? '0' : $input; // 0 is default
340
$pattern = preg_match_all('/^\d+(,\d+)*$/', trim($input));
342
// separate input by comma and convert all to an int[]
343
$inputToArray = array_map(static fn ($value) => (int) $value, explode(',', $input));
344
// find max from key of $options
345
$maxOptions = array_key_last($options);
346
// find max from input
347
$maxInput = max($inputToArray);
349
// return the prompt again if $input contain(s) non-numeric character, except a comma.
350
// And if max from $options less than max from input,
351
// it means user tried to access null value in $options
352
if (! $pattern || $maxOptions < $maxInput) {
353
static::error('Please select correctly.');
356
$input = static::prompt($extraOutput);
357
$input = ($input === '') ? '0' : $input;
365
foreach ($options as $key => $description) {
366
foreach ($inputToArray as $inputKey) {
367
if ($key === $inputKey) {
368
$input[$key] = $description;
376
// --------------------------------------------------------------------
377
// Utility for promptBy...
378
// --------------------------------------------------------------------
381
* Validation for $options in promptByKey() and promptByMultipleKeys(). Return an error if $options is an empty array.
383
private static function isZeroOptions(array $options): void
385
if ($options === []) {
386
throw new InvalidArgumentException('No options to select from were provided');
391
* Print each key and value one by one
393
private static function printKeysAndValues(array $options): void
395
// +2 for the square brackets around the key
396
$keyMaxLength = max(array_map(mb_strwidth(...), array_keys($options))) + 2;
398
foreach ($options as $key => $description) {
399
$name = str_pad(' [' . $key . '] ', $keyMaxLength + 4, ' ');
400
CLI::write(CLI::color($name, 'green') . CLI::wrap($description, 125, $keyMaxLength + 4));
404
// --------------------------------------------------------------------
405
// End Utility for promptBy...
406
// --------------------------------------------------------------------
409
* Validate one prompt "field" at a time
411
* @param string $field Prompt "field" output
412
* @param string $value Input value
413
* @param array|string $rules Validation rules
415
protected static function validate(string $field, string $value, $rules): bool
419
$validation = Services::validation(null, false);
420
$validation->setRules([
426
$validation->run([$field => $value]);
428
if ($validation->hasError($field)) {
429
static::error($validation->getError($field));
438
* Outputs a string to the CLI without any surrounding newlines.
439
* Useful for showing repeating elements on a single line.
443
public static function print(string $text = '', ?string $foreground = null, ?string $background = null)
445
if ($foreground || $background) {
446
$text = static::color($text, $foreground, $background);
449
static::$lastWrite = null;
451
static::fwrite(STDOUT, $text);
455
* Outputs a string to the cli on its own line.
459
public static function write(string $text = '', ?string $foreground = null, ?string $background = null)
461
if ($foreground || $background) {
462
$text = static::color($text, $foreground, $background);
465
if (static::$lastWrite !== 'write') {
466
$text = PHP_EOL . $text;
467
static::$lastWrite = 'write';
470
static::fwrite(STDOUT, $text . PHP_EOL);
474
* Outputs an error to the CLI using STDERR instead of STDOUT
478
public static function error(string $text, string $foreground = 'light_red', ?string $background = null)
480
// Check color support for STDERR
481
$stdout = static::$isColored;
482
static::$isColored = static::hasColorSupport(STDERR);
484
if ($foreground || $background) {
485
$text = static::color($text, $foreground, $background);
488
static::fwrite(STDERR, $text . PHP_EOL);
490
// return STDOUT color support
491
static::$isColored = $stdout;
495
* Beeps a certain number of times.
497
* @param int $num The number of times to beep
501
public static function beep(int $num = 1)
503
echo str_repeat("\x07", $num);
507
* Waits a certain number of seconds, optionally showing a wait message and
508
* waiting for a key press.
510
* @param int $seconds Number of seconds
511
* @param bool $countdown Show a countdown or not
515
public static function wait(int $seconds, bool $countdown = false)
517
if ($countdown === true) {
521
static::fwrite(STDOUT, $time . '... ');
527
} elseif ($seconds > 0) {
530
static::write(static::$wait_msg);
531
static::$io->input();
536
* if operating system === windows
538
* @deprecated 4.3.0 Use `is_windows()` instead
540
public static function isWindows(): bool
546
* Enter a number of empty lines
550
public static function newLine(int $num = 1)
552
// Do it once or more, write with empty string gives us a new line
553
for ($i = 0; $i < $num; $i++) {
559
* Clears the screen of output
563
public static function clearScreen()
565
// Unix systems, and Windows with VT100 Terminal support (i.e. Win10)
566
// can handle CSI sequences. For lower than Win10 we just shove in 40 new lines.
567
is_windows() && ! static::streamSupports('sapi_windows_vt100_support', STDOUT)
568
? static::newLine(40)
569
: static::fwrite(STDOUT, "\033[H\033[2J");
573
* Returns the given text with the correct color codes for a foreground and
574
* optionally a background color.
576
* @param string $text The text to color
577
* @param string $foreground The foreground color
578
* @param string|null $background The background color
579
* @param string|null $format Other formatting to apply. Currently only 'underline' is understood
581
* @return string The color coded string
583
public static function color(string $text, string $foreground, ?string $background = null, ?string $format = null): string
585
if (! static::$isColored || $text === '') {
589
if (! array_key_exists($foreground, static::$foreground_colors)) {
590
throw CLIException::forInvalidColor('foreground', $foreground);
593
if ($background !== null && ! array_key_exists($background, static::$background_colors)) {
594
throw CLIException::forInvalidColor('background', $background);
599
// Detect if color method was already in use with this text
600
if (str_contains($text, "\033[0m")) {
601
$pattern = '/\\033\\[0;.+?\\033\\[0m/u';
603
preg_match_all($pattern, $text, $matches);
604
$coloredStrings = $matches[0];
606
// No colored string found. Invalid strings with no `\033[0;??`.
607
if ($coloredStrings === []) {
608
return $newText . self::getColoredText($text, $foreground, $background, $format);
611
$nonColoredText = preg_replace(
613
'<<__colored_string__>>',
616
$nonColoredChunks = preg_split(
617
'/<<__colored_string__>>/u',
621
foreach ($nonColoredChunks as $i => $chunk) {
623
$newText .= self::getColoredText($chunk, $foreground, $background, $format);
626
if (isset($coloredStrings[$i])) {
627
$newText .= $coloredStrings[$i];
631
$newText .= self::getColoredText($text, $foreground, $background, $format);
637
private static function getColoredText(string $text, string $foreground, ?string $background, ?string $format): string
639
$string = "\033[" . static::$foreground_colors[$foreground] . 'm';
641
if ($background !== null) {
642
$string .= "\033[" . static::$background_colors[$background] . 'm';
645
if ($format === 'underline') {
646
$string .= "\033[4m";
649
return $string . $text . "\033[0m";
653
* Get the number of characters in string having encoded characters
654
* and ignores styles set by the color() function
656
public static function strlen(?string $string): int
658
if ($string === null) {
662
foreach (static::$foreground_colors as $color) {
663
$string = strtr($string, ["\033[" . $color . 'm' => '']);
666
foreach (static::$background_colors as $color) {
667
$string = strtr($string, ["\033[" . $color . 'm' => '']);
670
$string = strtr($string, ["\033[4m" => '', "\033[0m" => '']);
672
return mb_strwidth($string);
676
* Checks whether the current stream resource supports or
677
* refers to a valid terminal type device.
679
* @param resource $resource
681
public static function streamSupports(string $function, $resource): bool
683
if (ENVIRONMENT === 'testing') {
684
// In the current setup of the tests we cannot fully check
685
// if the stream supports the function since we are using
687
return function_exists($function);
690
return function_exists($function) && @$function($resource); // @codeCoverageIgnore
694
* Returns true if the stream resource supports colors.
696
* This is tricky on Windows, because Cygwin, Msys2 etc. emulate pseudo
697
* terminals via named pipes, so we can only check the environment.
699
* Reference: https://github.com/composer/xdebug-handler/blob/master/src/Process.php
701
* @param resource $resource
703
public static function hasColorSupport($resource): bool
705
// Follow https://no-color.org/
706
if (isset($_SERVER['NO_COLOR']) || getenv('NO_COLOR') !== false) {
710
if (getenv('TERM_PROGRAM') === 'Hyper') {
715
// @codeCoverageIgnoreStart
716
return static::streamSupports('sapi_windows_vt100_support', $resource)
717
|| isset($_SERVER['ANSICON'])
718
|| getenv('ANSICON') !== false
719
|| getenv('ConEmuANSI') === 'ON'
720
|| getenv('TERM') === 'xterm';
721
// @codeCoverageIgnoreEnd
724
return static::streamSupports('stream_isatty', $resource);
728
* Attempts to determine the width of the viewable CLI window.
730
public static function getWidth(int $default = 80): int
732
if (static::$width === null) {
733
static::generateDimensions();
736
return static::$width ?: $default;
740
* Attempts to determine the height of the viewable CLI window.
742
public static function getHeight(int $default = 32): int
744
if (static::$height === null) {
745
static::generateDimensions();
748
return static::$height ?: $default;
752
* Populates the CLI's dimensions.
756
public static function generateDimensions()
760
// Shells such as `Cygwin` and `Git bash` returns incorrect values
761
// when executing `mode CON`, so we use `tput` instead
762
if (getenv('TERM') || (($shell = getenv('SHELL')) && preg_match('/(?:bash|zsh)(?:\.exe)?$/', $shell))) {
763
static::$height = (int) exec('tput lines');
764
static::$width = (int) exec('tput cols');
768
exec('mode CON', $output, $return);
770
// Look for the next lines ending in ": <number>"
771
// Searching for "Columns:" or "Lines:" will fail on non-English locales
772
if ($return === 0 && $output && preg_match('/:\s*(\d+)\n[^:]+:\s*(\d+)\n/', implode("\n", $output), $matches)) {
773
static::$height = (int) $matches[1];
774
static::$width = (int) $matches[2];
777
} elseif (($size = exec('stty size')) && preg_match('/(\d+)\s+(\d+)/', $size, $matches)) {
778
static::$height = (int) $matches[1];
779
static::$width = (int) $matches[2];
781
static::$height = (int) exec('tput lines');
782
static::$width = (int) exec('tput cols');
784
} catch (Throwable $e) {
785
// Reset the dimensions so that the default values will be returned later.
786
// Then let the developer know of the error.
787
static::$height = null;
788
static::$width = null;
789
log_message('error', (string) $e);
794
* Displays a progress bar on the CLI. You must call it repeatedly
795
* to update it. Set $thisStep = false to erase the progress bar.
797
* @param bool|int $thisStep
801
public static function showProgress($thisStep = 1, int $totalSteps = 10)
803
static $inProgress = false;
805
// restore cursor position when progress is continuing.
806
if ($inProgress !== false && $inProgress <= $thisStep) {
807
static::fwrite(STDOUT, "\033[1A");
809
$inProgress = $thisStep;
811
if ($thisStep !== false) {
812
// Don't allow div by zero or negative numbers....
813
$thisStep = abs($thisStep);
814
$totalSteps = $totalSteps < 1 ? 1 : $totalSteps;
816
$percent = (int) (($thisStep / $totalSteps) * 100);
817
$step = (int) round($percent / 10);
819
// Write the progress bar
820
static::fwrite(STDOUT, "[\033[32m" . str_repeat('#', $step) . str_repeat('.', 10 - $step) . "\033[0m]");
821
// Textual representation...
822
static::fwrite(STDOUT, sprintf(' %3d%% Complete', $percent) . PHP_EOL);
824
static::fwrite(STDOUT, "\007");
829
* Takes a string and writes it to the command line, wrapping to a maximum
830
* width. If no maximum width is specified, will wrap to the window's max
833
* If an int is passed into $pad_left, then all strings after the first
834
* will pad with that many spaces to the left. Useful when printing
835
* short descriptions that need to start on an existing line.
837
public static function wrap(?string $string = null, int $max = 0, int $padLeft = 0): string
839
if ($string === null || $string === '') {
844
$max = self::getWidth();
847
if (self::getWidth() < $max) {
848
$max = self::getWidth();
853
$lines = wordwrap($string, $max, PHP_EOL);
856
$lines = explode(PHP_EOL, $lines);
860
array_walk($lines, static function (&$line) use ($padLeft, &$first): void {
862
$line = str_repeat(' ', $padLeft) . $line;
868
$lines = implode(PHP_EOL, $lines);
874
// --------------------------------------------------------------------
875
// Command-Line 'URI' support
876
// --------------------------------------------------------------------
879
* Parses the command line it was called from and collects all
880
* options and valid segments.
884
protected static function parseCommandLine()
886
$args = $_SERVER['argv'] ?? [];
887
array_shift($args); // scrap invoking program
888
$optionValue = false;
890
foreach ($args as $i => $arg) {
891
// If there's no "-" at the beginning, then
892
// this is probably an argument or an option value
893
if (mb_strpos($arg, '-') !== 0) {
895
// We have already included this in the previous
896
// iteration, so reset this flag
897
$optionValue = false;
899
// Yup, it's a segment
900
static::$segments[] = $arg;
906
$arg = ltrim($arg, '-');
909
if (isset($args[$i + 1]) && mb_strpos($args[$i + 1], '-') !== 0) {
910
$value = $args[$i + 1];
914
static::$options[$arg] = $value;
919
* Returns the command line string portions of the arguments, minus
920
* any options, as a string. This is used to pass along to the main
921
* CodeIgniter application.
923
public static function getURI(): string
925
return implode('/', static::$segments);
929
* Returns an individual segment.
931
* This ignores any options that might have been dispersed between
932
* valid segments in the command:
934
* // segment(3) is 'three', not '-f' or 'anOption'
935
* > php spark one two -f anOption three
937
* **IMPORTANT:** The index here is one-based instead of zero-based.
939
* @return string|null
941
public static function getSegment(int $index)
943
return static::$segments[$index - 1] ?? null;
947
* Returns the raw array of segments found.
949
public static function getSegments(): array
951
return static::$segments;
955
* Gets a single command-line option. Returns TRUE if the option
956
* exists, but doesn't have a value, and is simply acting as a flag.
958
* @return string|true|null
960
public static function getOption(string $name)
962
if (! array_key_exists($name, static::$options)) {
966
// If the option didn't have a value, simply return TRUE
967
// so they know it was set, otherwise return the actual value.
968
$val = static::$options[$name] ?? true;
974
* Returns the raw array of options found.
976
public static function getOptions(): array
978
return static::$options;
982
* Returns the options as a string, suitable for passing along on
983
* the CLI to other commands.
985
* @param bool $useLongOpts Use '--' for long options?
986
* @param bool $trim Trim final string output?
988
public static function getOptionString(bool $useLongOpts = false, bool $trim = false): string
990
if (static::$options === []) {
996
foreach (static::$options as $name => $value) {
997
if ($useLongOpts && mb_strlen($name) > 1) {
998
$out .= "--{$name} ";
1000
$out .= "-{$name} ";
1003
if ($value === null) {
1007
if (mb_strpos($value, ' ') !== false) {
1008
$out .= "\"{$value}\" ";
1009
} elseif ($value !== null) {
1010
$out .= "{$value} ";
1014
return $trim ? trim($out) : $out;
1018
* Returns a well formatted table
1020
* @param array $tbody List of rows
1021
* @param array $thead List of columns
1025
public static function table(array $tbody, array $thead = [])
1027
// All the rows in the table will be here until the end
1030
// We need only indexes and not keys
1031
if ($thead !== []) {
1032
$tableRows[] = array_values($thead);
1035
foreach ($tbody as $tr) {
1036
$tableRows[] = array_values($tr);
1039
// Yes, it really is necessary to know this count
1040
$totalRows = count($tableRows);
1042
// Store all columns lengths
1043
// $all_cols_lengths[row][column] = length
1044
$allColsLengths = [];
1046
// Store maximum lengths by column
1047
// $max_cols_lengths[column] = length
1048
$maxColsLengths = [];
1050
// Read row by row and define the longest columns
1051
for ($row = 0; $row < $totalRows; $row++) {
1052
$column = 0; // Current column index
1054
foreach ($tableRows[$row] as $col) {
1055
// Sets the size of this column in the current row
1056
$allColsLengths[$row][$column] = static::strlen((string) $col);
1058
// If the current column does not have a value among the larger ones
1059
// or the value of this is greater than the existing one
1060
// then, now, this assumes the maximum length
1061
if (! isset($maxColsLengths[$column]) || $allColsLengths[$row][$column] > $maxColsLengths[$column]) {
1062
$maxColsLengths[$column] = $allColsLengths[$row][$column];
1065
// We can go check the size of the next column...
1070
// Read row by row and add spaces at the end of the columns
1071
// to match the exact column length
1072
for ($row = 0; $row < $totalRows; $row++) {
1075
foreach ($tableRows[$row] as $col) {
1076
$diff = $maxColsLengths[$column] - static::strlen((string) $col);
1079
$tableRows[$row][$column] .= str_repeat(' ', $diff);
1089
// Joins columns and append the well formatted rows to the table
1090
for ($row = 0; $row < $totalRows; $row++) {
1091
// Set the table border-top
1095
foreach ($tableRows[$row] as $col) {
1096
$cols .= str_repeat('-', static::strlen((string) $col) + 2) . '+';
1098
$table .= $cols . PHP_EOL;
1101
// Set the columns borders
1102
$table .= '| ' . implode(' | ', $tableRows[$row]) . ' |' . PHP_EOL;
1104
// Set the thead and table borders-bottom
1105
if (($row === 0 && $thead !== []) || ($row + 1 === $totalRows)) {
1106
$table .= $cols . PHP_EOL;
1110
static::write($table);
1114
* While the library is intended for use on CLI commands,
1115
* commands can be called from controllers and elsewhere
1116
* so we need a way to allow them to still work.
1118
* For now, just echo the content, but look into a better
1119
* solution down the road.
1121
* @param resource $handle
1125
protected static function fwrite($handle, string $string)
1127
static::$io->fwrite($handle, $string);
1131
* Testing purpose only
1135
public static function setInputOutput(InputOutput $io): void
1141
* Testing purpose only
1145
public static function resetInputOutput(): void
1147
static::$io = new InputOutput();
1151
// Ensure the class is initialized. Done outside of code coverage
1152
CLI::init(); // @codeCoverageIgnore