diff --git a/WebFiori/Cli/Command.php b/WebFiori/Cli/Command.php
index c98c4e2..59fd0c9 100644
--- a/WebFiori/Cli/Command.php
+++ b/WebFiori/Cli/Command.php
@@ -1,1674 +1,1724 @@
-
- *
optional: A boolean. if set to true, it means that the argument
- * is optional and can be ignored when running the command.
- * default: An optional default value for the argument
- * to use if it is not provided and is optional.
- * description: A description of the argument which
- * will be shown if the command 'help' is executed.
- * values: A set of values that the argument can have. If provided,
- * only the values on the list will be allowed. Note that if null or empty string
- * is in the array, it will be ignored. Also, if boolean values are
- * provided, true will be converted to the string 'y' and false will
- * be converted to the string 'n'.
- *
- *
- * @param string $description A string that describes what does the job
- * do. The description will appear when the command 'help' is executed.
- *
- * @param array $aliases An optional array of aliases for the command.
- */
- public function __construct(string $commandName, array $args = [], string $description = '', array $aliases = []) {
- if (!$this->setName($commandName)) {
- $this->setName('new-command');
- }
- $this->aliases = $aliases;
- $this->addArgs($args);
-
- if (!$this->setDescription($description)) {
- $this->setDescription('');
- }
- }
- /**
- * Add command argument.
- *
- * An argument is a string that comes after the name of the command. The value
- * of an argument can be set using equal sign. For example, if command name
- * is 'do-it' and one argument has the name 'what-to-do', then the full
- * CLI command would be "do-it what-to-do=say-hi". An argument can be
- * also treated as an option.
- *
- * @param string $name The name of the argument. It must be non-empty string
- * and does not contain spaces. Note that if the argument is already added and
- * the developer is trying to add it again, the new options array will override
- * the existing options array.
- *
- * @param array $options An optional array of options. Available options are:
- *
- * - optional: A boolean. if set to true, it means that the argument
- * is optional and can be ignored when running the command.
- * - default: An optional default value for the argument
- * to use if it is not provided and is optional.
- * - description: A description of the argument which
- * will be shown if the command 'help' is executed.
- * - values: A set of values that the argument can have. If provided,
- * only the values on the list will be allowed. Note that if null or empty string
- * is in the array, it will be ignored. Also, if boolean values are
- * provided, true will be converted to the string 'y' and false will
- * be converted to the string 'n'.
- *
- *
- * @return bool If the argument is added, the method will return true.
- * Other than that, the method will return false.
- *
- */
- public function addArg(string $name, array $options = []) : bool {
- $toAdd = Argument::create($name, $options);
-
- if ($toAdd === null) {
- return false;
- }
-
- return $this->addArgument($toAdd);
- }
- /**
- * Adds multiple arguments to the command.
- *
- * @param array $arr An associative array of sub associative arrays. The
- * key of each sub array is argument name. This can also be
- * an array of objects of type 'CommandArgument'. For arrays, Each
- * sub-array can have the following indices:
- *
- * - optional: A boolean. if set to true, it means that the argument
- * is optional and can be ignored when running the command.
- * - default: An optional default value for the argument
- * to use if it is not provided and is optional.
- * - description: A description of the argument which
- * will be shown if the command 'help' is executed.
- * - values: A set of values that the argument can have. If provided,
- * only the values on the list will be allowed. Note that if null or empty string
- * is in the array, it will be ignored. Also, if boolean values are
- * provided, true will be converted to the string 'y' and false will
- * be converted to the string 'n'.
- *
- */
- public function addArgs(array $arr): void {
- $this->commandArgs = [];
-
- foreach ($arr as $optionName => $options) {
- if ($options instanceof Argument) {
- $this->addArgument($options);
- } else {
- $this->addArg($optionName, $options);
- }
- }
- }
- /**
- * Adds new command argument.
- *
- * @param Argument $arg The argument that will be added.
- *
- * @return bool If the argument is added, the method will return true.
- * If not, false is returned. The argument will not be added only if an argument
- * which has same name is added.
- */
- public function addArgument(Argument $arg) : bool {
- if (!$this->hasArg($arg->getName())) {
- $this->commandArgs[] = $arg;
-
- return true;
- }
-
- return false;
- }
- /**
- * Clears the output before or after cursor position.
- *
- * This method will replace the visible characters with spaces.
- * Note that support for this operation depends on terminal support for
- * ANSI escape codes.
- *
- * @param int $numberOfCols Number of columns to clear. The columns that
- * will be cleared are before and after cursor position. They don't include
- * the character at which the cursor is currently pointing to.
- * @param bool $beforeCursor If set to true, the characters which
- * are before the cursor will be cleared. Default is true.
- *
- * @return Command The method will return the instance at which the
- * method is called on.
- *
- */
- public function clear(int $numberOfCols = 1, bool $beforeCursor = true) : Command {
- if ($numberOfCols >= 1 && $beforeCursor) {
- for ($x = 0 ; $x < $numberOfCols ; $x++) {
- $this->moveCursorLeft();
- $this->prints(" ");
- $this->moveCursorLeft();
- }
- $this->moveCursorRight($numberOfCols);
- } else if ($numberOfCols >= 1) {
- $this->moveCursorRight();
-
- for ($x = 0 ; $x < $numberOfCols ; $x++) {
- $this->prints(" ");
- }
- $this->moveCursorLeft($numberOfCols + 1);
- }
-
- return $this;
- }
- /**
- * Clears the whole content of the console.
- *
- * Note that support for this operation depends on terminal support for
- * ANSI escape codes.
- *
- * @return Command The method will return the instance at which the
- * method is called on.
- */
- public function clearConsole() : Command {
- $this->prints("\ec");
-
- return $this;
- }
- /**
- * Clears the line at which the cursor is in and move it back to the start
- * of the line.
- *
- * Note that support for this operation depends on terminal support for
- * ANSI escape codes.
- *
- */
- public function clearLine(): void {
- $this->prints("\e[2K");
- $this->prints("\r");
- }
- /**
- * Asks the user to conform something.
- *
- * This method will display the question and wait for the user to confirm the
- * action by entering 'y' or 'n' in the terminal. If the user give something
- * other than 'Y' or 'n', it will show an error and ask him to confirm
- * again. If a default answer is provided, it will appear in upper case in the
- * terminal. For example, if default is set to true, at the end of the prompt,
- * the string that shows the options would be like '(Y/n)'.
- *
- * @param string $confirmTxt The text of the question of which will be asked.
- *
- * @param bool|null $default Default answer to use if empty input is given.
- * It can be true for 'y' and false for 'n'. Default value is null which
- * means no default will be used.
- *
- * @return bool If the user choose 'y', the method will return true. If
- * he chooses 'n', the method will return false.
- *
- *
- */
- public function confirm(string $confirmTxt, ?bool $default = null) : bool {
- $answer = null;
-
- do {
- if ($default === true) {
- $optionsStr = '(Y/n)';
- } else if ($default === false) {
- $optionsStr = '(y/N)';
- } else {
- $optionsStr = '(y/n)';
- }
- $this->prints(trim($confirmTxt), [
- 'color' => 'gray',
- 'bold' => true
- ]);
- $this->println($optionsStr, [
- 'color' => 'light-blue'
- ]);
-
- $input = strtolower(trim($this->readln()));
-
- if ($input == 'n') {
- $answer = false;
- } else if ($input == 'y') {
- $answer = true;
- } else if (strlen($input) == 0 && $default !== null) {
- return $default === true;
- } else {
- $this->error('Invalid answer. Choose \'y\' or \'n\'.');
- }
- } while ($answer === null);
-
- return $answer;
- }
-
- /**
- * Creates and returns a new progress bar instance.
- *
- * @param int $total Total number of steps
- * @return ProgressBar
- */
- public function createProgressBar(int $total = 100): ProgressBar {
- return new ProgressBar($this->getOutputStream(), $total);
- }
- /**
- * Display a message that represents an error.
- *
- * The message will be prefixed with the string 'Error:' in
- * red.
- *
- * @param string $message The message that will be shown.
- *
- */
- public function error(string $message): void {
- $this->printMsg($message, 'Error', 'light-red');
- }
- /**
- * Execute the command.
- *
- * This method should not be called manually by the developer.
- *
- * @return int If the command is executed, the method will return 0.
- * Other than that, it will return a number which depends on the return value of
- * the method 'Command::exec()'.
- *
- */
- public function excCommand() : int {
- $retVal = -1;
-
- $runner = $this->getOwner();
-
- if ($runner !== null) {
- foreach ($runner->getArgs() as $arg) {
- $this->addArgument($arg);
- }
- }
-
- if ($this->parseArgsHelper()) {
- // Check for help first, before validating required arguments
- if ($this->isArgProvided('help') || $this->isArgProvided('-h')) {
- $help = $runner->getCommandByName('help');
- $help->setArgValue('--command', $this->getName());
- $help->setOwner($runner);
- $help->setOutputStream($runner->getOutputStream());
- $this->removeArgument('help');
-
- return $help->exec();
- } else if ($this->checkIsArgsSetHelper()) {
- $retVal = $this->exec();
- }
-
- }
-
- if ($runner !== null) {
- foreach ($runner->getArgs() as $arg) {
- $this->removeArgument($arg->getName());
- $arg->resetValue();
- }
- }
-
- return $retVal;
- }
- /**
- * Execute the command.
- *
- * The implementation of this method should contain the code that will run
- * when the command is executed.
- *
- * @return int The developer should implement this method in a way it returns 0
- * if the command is executed successfully and return -1 if the
- * command did not execute successfully.
- *
- */
- public abstract function exec() : int;
- /**
- * Execute a registered command using a sub-runner.
- *
- * This method can be used to execute a registered command within the runner
- * using another
- * runner instance which shares argsv, input and output streams with the
- * main runner. It can be used to invoke another command from within a
- * running command.
- *
- * @param string $name The name of the command. It must be a part of
- * registered commands.
- *
- * @param array $additionalArgs An associative array that represents additional arguments
- * to be passed to the command.
- *
- * @return int The method will return an integer that represent exit status
- * code of the command after execution.
- */
- public function execSubCommand(string $name, array $additionalArgs = []) : int {
- $runner = $this->getOwner();
-
- if ($runner === null) {
- return -1;
- }
-
- return $runner->runCommandAsSub($name, $additionalArgs);
- }
- /**
- * Returns an array of aliases for the command.
- *
- * @return array An array of aliases.
- */
- public function getAliases() : array {
- return $this->aliases;
- }
-
- /**
- * Sets the aliases for the command.
- *
- * @param array $aliases An array of aliases.
- */
- public function setAliases(array $aliases): void {
- $this->aliases = $aliases;
- }
-
- /**
- * Adds an alias to the command.
- *
- * @param string $alias The alias to add.
- */
- public function addAlias(string $alias): void {
- if (!in_array($alias, $this->aliases)) {
- $this->aliases[] = $alias;
- }
- }
- /**
- * Returns an object that holds argument info if the command.
- *
- * @param string $name The name of command argument.
- *
- * @return Argument|null If the command has an argument with the
- * given name, it will be returned. Other than that, null is returned.
- */
- public function getArg(string $name): ?Argument {
- foreach ($this->getArgs() as $arg) {
- if ($arg->getName() == $name) {
- return $arg;
- }
- }
-
- return null;
- }
- /**
- * Returns an associative array that contains command args.
- *
- * @return array An associative array. The indices of the array are
- * the names of the arguments and the values are sub-associative arrays.
- * the sub arrays will have the following indices:
- *
- * - optional
- * - description
- * - default
- *
- * Note that the last index might not be set.
- *
- */
- public function getArgs() : array {
- return $this->commandArgs;
- }
- /**
- * Returns an array that contains the names of command arguments.
- *
- * @return array An array of strings.
- */
- public function getArgsNames() : array {
- return array_map(function ($el) {
- return $el->getName();
- }, $this->getArgs());
- }
- /**
- * Returns the value of command option from CLI given its name.
- *
- * @param string $optionName The name of the option.
- *
- * @return string|null If the value of the option is set, the method will
- * return its value as string. If it is not set, the method will return null.
- *
- */
- public function getArgValue(string $optionName): ?string {
- $trimmedOptName = trim($optionName);
- $arg = $this->getArg($trimmedOptName);
-
- if ($arg !== null) {
- $runner = $this->getOwner();
-
- // Always return the set value if it exists, regardless of interactive mode
- if ($arg->getValue() !== null) {
- return $arg->getValue();
- }
-
- return Argument::extractValue($trimmedOptName, $runner);
- }
-
- return null;
- }
- /**
- * Returns the description of the command.
- *
- * The description of the command is a string that describes what does the
- * command do, and it will appear in CLI if the command 'help' is executed.
- *
- * @return string The description of the command. Default return value
- * is '<NO DESCRIPTION>'
- *
- */
- public function getDescription() : string {
- return $this->description;
- }
-
- /**
- * Take an input value from the user.
- *
- * @param string $prompt The string that will be shown to the user. The
- * string must be non-empty.
- *
- * @param string|null $default An optional default value to use in case the user
- * hit "Enter" without entering any value. If null is passed, no default
- * value will be set.
- *
- * @param InputValidator|null $validator A callback that can be used to validate user
- * input. The callback accepts one parameter which is the value that
- * the user has given. If the value is valid, the callback must return true.
- * If the callback returns anything else, it means the value which is given
- * by the user is invalid and this method will ask the user to enter the
- * value again.
- *
- * @return string|null The method will return the value which was taken from the
- * user. If prompt string is empty, null will be returned.
- * Note that if the input has special characters or spaces at the
- * beginning or the end, they will be trimmed.
- *
- */
- public function getInput(string $prompt, ?string $default = null, ?InputValidator $validator = null): ?string {
- $trimmed = trim($prompt);
-
- if (strlen($trimmed) > 0) {
- do {
- $this->prints($trimmed, [
- 'color' => 'gray',
- 'bold' => true
- ]);
-
- if ($default !== null) {
- $this->prints(" Enter = '".$default."'", [
- 'color' => 'light-blue'
- ]);
- }
- $this->println();
- $input = trim($this->readln());
-
- $check = $this->getInputHelper($input, $validator, $default);
-
- if ($check['valid']) {
- return $check['value'];
- }
- } while (true);
- }
-
- return null;
- }
- /**
- * Reads user input with characters masked by a specified character.
- *
- * This method is similar to getInput() but masks the input characters as the user types,
- * making it suitable for sensitive information like passwords, tokens, or secrets.
- * The actual input value is captured but only mask characters are displayed in the terminal.
- *
- * @param string $prompt The prompt message to display to the user. Must be non-empty.
- *
- * @param string $mask The character to display instead of the actual input characters.
- * Default is '*'. Can be any single character or string.
- *
- * @param string|null $default An optional default value to use if the user provides
- * empty input. If provided, it will be shown in the prompt.
- *
- * @param InputValidator|null $validator An optional validator to validate the input.
- * If validation fails, the user will be prompted again.
- *
- * @return string|null Returns the actual input value (not masked) if valid input is provided,
- * or null if the prompt is empty.
- *
- * @since 1.1.0
- */
- public function getMaskedInput(string $prompt, string $mask = '*', ?string $default = null, ?InputValidator $validator = null): ?string {
- $trimmed = trim($prompt);
-
- if (strlen($trimmed) > 0) {
- do {
- $this->prints($trimmed, [
- 'color' => 'gray',
- 'bold' => true
- ]);
-
- if ($default !== null) {
- $this->prints(" Enter = '".$default."'", [
- 'color' => 'light-blue'
- ]);
- }
- $this->println();
- $input = trim($this->readMaskedLine($mask));
-
- $check = $this->getInputHelper($input, $validator, $default);
-
- if ($check['valid']) {
- return $check['value'];
- }
- } while (true);
- }
-
- return null;
- } /**
- * Returns the stream at which the command is sing to read inputs.
- *
- * @return null|InputStream If the stream is set, it will be returned as
- * an object. Other than that, the method will return null.
- *
- */
- public function getInputStream() : InputStream {
- return $this->inputStream;
- }
-
- /**
- * Check if the current input stream supports interactive input.
- *
- * @return bool True if the input stream supports interactive input (real-time user interaction),
- * false otherwise (files, pipes, arrays, etc.)
- */
- public function supportsInteractiveInput(): bool {
- $stream = $this->getInputStream();
-
- // Only StdIn with tty supports true interaction
- if ($stream instanceof Streams\StdIn) {
- return function_exists('posix_isatty') && posix_isatty(STDIN);
- }
-
- // All other stream types are non-interactive
- return false;
- }
- /**
- * Returns the name of the command.
- *
- * The name of the command is a string which is used to call the command
- * from CLI.
- *
- * @return string The name of the command (such as 'v' or 'help'). Default
- * return value is 'new-command'.
- *
- */
- public function getName() : string {
- return $this->commandName;
- }
- /**
- * Returns the stream at which the command is using to send output.
- *
- * @return null|OutputStream If the stream is set, it will be returned as
- * an object. Other than that, the method will return null.
- *
- */
- public function getOutputStream() : OutputStream {
- return $this->outputStream;
- }
- /**
- * Returns the runner which is used to execute the command.
- *
- * @return Runner|null If the command was called using a runner, this method
- * will return an instance that can be used to access runner's properties.
- * If not called through a runner, null is returned.
- */
- public function getOwner(): ?Runner {
- return $this->owner;
- }
- /**
- * Checks if the command has a specific command line argument or not.
- *
- * @param string $argName The name of the command line argument.
- *
- * @return bool If the argument is added to the command, the method will
- * return true. If no argument which has the given name does exist, the method
- * will return false.
- *
- */
- public function hasArg(string $argName) : bool {
- foreach ($this->getArgs() as $arg) {
- if ($arg->getName() == $argName) {
- return true;
- }
- }
-
- return false;
- }
- /**
- * Display a message that represents extra information.
- *
- * The message will be prefixed with the string 'Info:' in
- * blue.
- *
- * @param string $message The message that will be shown.
- *
- */
- public function info(string $message): void {
- $this->printMsg($message, 'Info', 'blue');
- }
- /**
- * Checks if an argument is provided in the CLI or not.
- *
- * The method will not check if the argument has a value or not.
- *
- * @param string $argName The name of the command line argument.
- *
- * @return bool If the argument is provided, the method will return
- * true. Other than that, the method will return false.
- *
- */
- public function isArgProvided(string $argName) : bool {
- $argObj = $this->getArg($argName);
-
- if ($argObj !== null) {
- return $argObj->getValue() !== null;
- }
-
- return false;
- }
- /**
- * Moves the cursor down by specific number of lines.
- *
- * Note that support for this operation depends on terminal support for
- * ANSI escape codes.
- *
- * @param int $lines The number of lines the cursor will be moved. Default
- * value is 1.
- *
- */
- public function moveCursorDown(int $lines = 1): void {
- if ($lines >= 1) {
- $this->prints("\e[".$lines."B");
- }
- }
- /**
- * Moves the cursor to the left by specific number of columns.
- *
- * Note that support for this operation depends on terminal support for
- * ANSI escape codes.
- *
- * @param int $numberOfCols The number of columns the cursor will be moved. Default
- * value is 1.
- *
- */
- public function moveCursorLeft(int $numberOfCols = 1): void {
- if ($numberOfCols >= 1) {
- $this->prints("\e[".$numberOfCols."D");
- }
- }
- /**
- * Moves the cursor to the right by specific number of columns.
- *
- * Note that support for this operation depends on terminal support for
- * ANSI escape codes.
- *
- * @param int $numberOfCols The number of columns the cursor will be moved. Default
- * value is 1.
- *
- */
- public function moveCursorRight(int $numberOfCols = 1): void {
- if ($numberOfCols >= 1) {
- $this->prints("\e[".$numberOfCols."C");
- }
- }
- /**
- * Moves the cursor to specific position in the terminal.
- *
- * If no arguments are supplied to the method, it will move the cursor
- * to the upper-left corner of the screen (line 0, column 0).
- * Note that support for this operation depends on terminal support for
- * ANSI escape codes.
- *
- * @param int $line The number of line at which the cursor will be moved
- * to. If not specified, 0 is used.
- *
- * @param int $col The number of column at which the cursor will be moved
- * to. If not specified, 0 is used.
- *
- */
- public function moveCursorTo(int $line = 0, int $col = 0): void {
- if ($line > -1 && $col > -1) {
- $this->prints("\e[".$line.";".$col."H");
- }
- }
- /**
- * Moves the cursor up by specific number of lines.
- *
- * Note that support for this operation depends on terminal support for
- * ANSI escape codes.
- *
- * @param int $lines The number of lines the cursor will be moved. Default
- * value is 1.
- *
- */
- public function moveCursorUp(int $lines = 1): void {
- if ($lines >= 1) {
- $this->prints("\e[".$lines."A");
- }
- }
- /**
- * Prints an array as a list of items.
- *
- * This method is useful if the developer would like to print out a list
- * of multiple items. Each item will be prefixed with a number that represents
- * its index in the array.
- *
- * @param array $array The array that will be printed.
- *
- */
- public function printList(array $array): void {
- for ($x = 0 ; $x < count($array) ; $x++) {
- $this->prints("- ", [
- 'color' => 'green'
- ]);
- $this->println($array[$x]);
- }
- }
- /**
- * Print out a string and terminates the current line by writing the
- * line separator string.
- *
- * This method will work like the function fprintf(). The difference is that
- * it will print out to the stream at which was specified by the method
- * Command::setOutputStream() and the text can have formatting
- * options. Note that support for output formatting depends on terminal support for
- * ANSI escape codes.
- *
- * @param string $str The string that will be printed.
- *
- * @param mixed $_ One or more extra arguments that can be supplied to the
- * method. The last argument can be an array that contains text formatting options.
- * for available options, check the method Command::formatOutput().
- */
- public function println(string $str = '', ...$_) {
- $argsCount = count($_);
-
- if ($argsCount != 0 && gettype($_[$argsCount - 1]) == 'array') {
- //Last index contains formatting options.
- $_[$argsCount - 1]['ansi'] = $this->isArgProvided('--ansi');
- $str = Formatter::format($str, $_[$argsCount - 1]);
- }
- call_user_func_array([$this->getOutputStream(), 'println'], $this->_createPassArray($str, $_));
- }
- /**
- * Print out a string.
- *
- * This method works exactly like the function 'fprintf()'. The only
- * difference is that the method will print out the output to the stream
- * that was specified using the method Command::setOutputStream() and
- * the method accepts formatting options as last argument to format the output.
- * Note that support for output formatting depends on terminal support for
- * ANSI escape codes.
- *
- * @param string $str The string that will be printed.
- *
- * @param mixed $_ One or more extra arguments that can be supplied to the
- * method. The last argument can be an array that contains text formatting options.
- * for available options, check the method Command::formatOutput().
- *
- */
- public function prints(string $str, ...$_): void {
- $argCount = count($_);
- $formattingOptions = [];
-
- if ($argCount != 0 && gettype($_[$argCount - 1]) == 'array') {
- $formattingOptions = $_[$argCount - 1];
- }
-
- $formattingOptions['ansi'] = $this->isArgProvided('--ansi');
-
- $formattedStr = Formatter::format($str, $formattingOptions);
-
- call_user_func_array([$this->getOutputStream(), 'prints'], $this->_createPassArray($formattedStr, $_));
- }
-
- /**
- * Reads a string of bytes from input stream.
- *
- * This method is used to read specific number of characters from input stream.
- *
- * @return string The method will return the string which was given as input
- * in the input stream.
- *
- */
- public function read(int $bytes = 1) : string {
- return $this->getInputStream()->read($bytes);
- }
- /**
- * Reads and validates class name.
- *
- * @param string|null $suffix An optional string to append to class name.
- *
- * @param string $prompt The text that will be shown to the user as prompt for
- * class name.
- *
- * @param string $errMsg A string to show in case provided class name is
- * not valid.
- *
- * @return string A string that represents a valid class name. If suffix is
- * not null, the method will return the name with the suffix included.
- */
- public function readClassName(string $prompt, ?string $suffix = null, string $errMsg = 'Invalid class name is given.'): ?string {
- return $this->getInput($prompt, null, new InputValidator(function (&$className, $suffix) {
- if ($suffix !== null) {
- $subSuffix = substr($className, strlen($className) - strlen($suffix));
-
- if ($subSuffix != $suffix) {
- $className .= $suffix;
- }
- }
-
- return InputValidator::isValidClassName($className);
- }, $errMsg, [$suffix]));
- }
-
- /**
- * Reads a value as float.
- *
- * @param string $prompt The string that will be shown to the user. The
- * string must be non-empty.
- *
- * @param float|null $default An optional default value to use in case the user
- * hit "Enter" without entering any value. If null is passed, no default
- * value will be set.
- *
- * @return float
- */
- public function readFloat(string $prompt, ?float $default = null) : float {
- $defaultStr = $default !== null ? (string)$default : null;
- $result = $this->getInput($prompt, $defaultStr, new InputValidator(function ($val) {
- return InputValidator::isFloat($val);
- }, 'Provided value is not a floating number!'));
- return (float)$result;
- }
-
- /**
- * Reads the namespace of class and return an instance of it.
- *
- * @param string $prompt The string that will be shown to the user. The
- * string must be non-empty.
- *
- * @param string $errMsg A string to show in case provided namespace is
- * invalid or an instance of the class cannot be created.
- *
- * @return object The method will return an instance of the class.
- *
- * @throws ReflectionException If the method was not able to initiate class instance.
- */
- public function readInstance(string $prompt, string $errMsg = 'Invalid Class!', array $constructorArgs = []): ?object {
- $clazzNs = $this->getInput($prompt, null, new InputValidator(function ($input) {
- if (InputValidator::isClass($input)) {
- return true;
- }
-
- return false;
- }, $errMsg));
-
- $reflection = new ReflectionClass($clazzNs);
-
- return $reflection->newInstanceArgs($constructorArgs);
- }
- /**
- * Reads a value as an integer.
- *
- * @param string $prompt The string that will be shown to the user. The
- * string must be non-empty.
- *
- * @param int $default An optional default value to use in case the user
- * hit "Enter" without entering any value. If null is passed, no default
- * value will be set.
- *
- * @return int
- */
- public function readInteger(string $prompt, ?int $default = null) : int {
- $defaultStr = $default !== null ? (string)$default : null;
- $result = $this->getInput($prompt, $defaultStr, new InputValidator(function ($val) {
- return InputValidator::isInt($val);
- }, 'Provided value is not an integer!'));
- return (int)$result;
- }
- /**
- * Reads one line from input stream.
- *
- * The method will continue to read from input stream till it finds end of
- * line character "\n".
- *
- * @return string The method will return the string which was taken from
- * input stream without the end of line character.
- *
- */
- public function readln() : string {
- return $this->getInputStream()->readLine();
- }
- /**
- * Reads a line from input stream with character masking.
- *
- * This method reads input character by character and displays mask characters
- * instead of the actual input. It handles backspace for character deletion
- * and ignores special keys like ESC and arrow keys.
- *
- * @param string $mask The character to display instead of actual input characters.
- *
- * @return string The actual input string (unmasked).
- *
- * @since 1.1.0
- */
- private function readMaskedLine(string $mask = '*'): string {
- $input = '';
-
- // For testing with ArrayInputStream, read the whole line at once
- if ($this->getInputStream() instanceof \WebFiori\Cli\Streams\ArrayInputStream) {
- $input = $this->getInputStream()->readLine();
- // Simulate masking output for testing
- $this->prints(str_repeat($mask, strlen($input)));
- $this->println();
- return $input;
- }
-
- // Set terminal to raw mode with echo disabled for real-time character reading
- $sttyMode = null;
- if (function_exists('shell_exec') && PHP_OS_FAMILY !== 'Windows') {
- $sttyMode = shell_exec('stty -g 2>/dev/null');
- shell_exec('stty -echo -icanon 2>/dev/null');
- }
-
- try {
- // For real terminal input, read character by character
- while (true) {
- $char = KeysMap::readAndTranslate($this->getInputStream());
-
- if ($char === 'LF' || $char === 'CR' || $char === '') {
- break;
- } elseif ($char === 'BACKSPACE' && strlen($input) > 0) {
- $input = substr($input, 0, -1);
- $this->prints("\x08 \x08"); // Backspace, space, backspace
- } elseif ($char !== 'BACKSPACE' && $char !== 'ESC' && $char !== 'DOWN' && $char !== 'UP' && $char !== 'LEFT' && $char !== 'RIGHT') {
- $input .= $char === 'SPACE' ? ' ' : $char;
- $this->prints($mask);
- }
- }
- } finally {
- // Restore terminal settings
- if ($sttyMode !== null) {
- shell_exec('stty ' . $sttyMode . ' 2>/dev/null');
- }
- }
-
- $this->println();
- return $input;
- }
- /**
- * Reads a string that represents class namespace.
- *
- * @param string $prompt The string that will be shown to the user. The
- * string must be non-empty.
- *
- * @param string $defaultNs A default string that represents default namespace.
- * Note that the method will throw an exception if this parameter does not
- * represent a valid namespace.
- *
- * @param string $errMsg A string that will be shown if provided input does
- * not represent a valid namespace.
- *
- * @return string The method will return a string that represent a valid namespace.
- *
- * @throws IOException If given default namespace does not represent a namespace.
- */
- public function readNamespace(string $prompt, ?string $defaultNs = null, string $errMsg = 'Invalid Namespace!'): ?string {
- if ($defaultNs !== null && !InputValidator::isValidNamespace($defaultNs)) {
- throw new IOException('Provided default namespace is not valid.');
- }
-
- return $this->getInput($prompt, $defaultNs, new InputValidator(function ($input) {
- if (InputValidator::isValidNamespace($input)) {
- return true;
- }
-
- return false;
- }, $errMsg));
- }
- /**
- * Removes an argument from the command given its name.
- *
- * @param string $name The name of the argument that will be removed.
- *
- * @return bool If removed, true is returned. Other than that, false is
- * returned.
- */
- public function removeArgument(string $name) : bool {
- $removed = false;
- $temp = [];
-
- foreach ($this->getArgs() as $arg) {
- if ($arg->getName() !== $name) {
- $temp[] = $arg;
- } else {
- $removed = true;
- }
- }
- $this->commandArgs = $temp;
-
- return $removed;
- }
-
- /**
- * Ask the user to select one of multiple values.
- *
- * This method will display a prompt and wait for the user to select
- * a value from a set of values. If the user give something other than the listed values,
- * it will show an error and ask him to select again. The
- * user can select an answer by typing its text or its number which will appear
- * in the terminal.
- *
- * @param string $prompt The text that will be shown for the user.
- *
- * @param array $choices An indexed array of values to select from.
- *
- * @param int $defaultIndex The index of the default value in case no value
- * is selected and the user hit enter.
- *
- * * @param int $maxTrials The maximum number of trials the user can do to select
- * a value. If -1 is passed, the user can select a value forever.
- *
- * @return string|null The method will return the value which is selected by
- * the user. If choices array is empty, null is returned. Also, null is returned
- * if max trials is reached and it is not -1.
- *
- *
- */
- public function select(string $prompt, array $choices, int $defaultIndex = -1, int $maxTrials = -1): ?string {
- if (count($choices) != 0) {
- $currentTry = 0;
- do {
- $this->println($prompt, [
- 'color' => 'gray',
- 'bold' => true
- ]);
-
- $this->printChoices($choices, $defaultIndex);
- $input = trim($this->readln());
-
- $check = $this->checkSelectedChoice($choices, $defaultIndex, $input);
-
- if ($check !== null) {
- return $check;
- }
- $currentTry++;
-
- if ($currentTry == $maxTrials && $maxTrials > 0) {
- return null;
- }
- } while (true);
- }
-
- return null;
- }
- /**
- * Sets the value of an argument.
- *
- * This method is useful in writing test cases for the commands.
- *
- * @param string $argName The name of the argument.
- *
- * @param string $argValue The value to set.
- *
- * @return bool If the value of the argument is set, the method will return
- * true. If not set, the method will return false. The value of the attribute
- * will be not set in the following cases:
- *
- * - If the argument can have a specific set of values and the given
- * value is not one of them.
- * - The given value is empty string or null.
- *
- *
- */
- public function setArgValue(string $argName, string $argValue = ''): bool {
- $trimmedArgName = trim($argName);
- $argObj = $this->getArg($trimmedArgName);
-
- if ($argObj !== null) {
- return $argObj->setValue($argValue);
- }
-
- return false;
- }
- /**
- * Sets the description of the command.
- *
- * The description of the command is a string that describes what does the
- * command do, and it will appear in CLI if the command 'help' is executed.
- *
- * @param string $str A string that describes the command. It must be non-empty
- * string.
- *
- * @return bool If the description of the command is set, the method will return
- * true. Other than that, the method will return false.
- */
- public function setDescription(string $str) : bool {
- $trimmed = trim($str);
-
- if (strlen($trimmed) > 0) {
- $this->description = $trimmed;
-
- return true;
- }
-
- return false;
- }
- /**
- * Sets the stream at which the command will read input from.
- *
- * @param InputStream $stream An instance that implements an input stream.
- *
- */
- public function setInputStream(InputStream $stream): void {
- $this->inputStream = $stream;
- }
- /**
- * Sets the name of the command.
- *
- * The name of the command is a string which is used to call the command
- * from CLI.
- *
- * @param string $name The name of the command (such as 'v' or 'help').
- * It must be non-empty string and does not contain spaces.
- *
- * @return bool If the name of the command is set, the method will return
- * true. Other than that, the method will return false.
- *
- */
- public function setName(string $name) : bool {
- $trimmed = trim($name);
-
- if (strlen($trimmed) > 0 && !strpos($trimmed, ' ')) {
- $this->commandName = $name;
-
- return true;
- }
-
- return false;
- }
- /**
- * Sets the stream at which the command will send output to.
- *
- * @param OutputStream $stream An instance that implements output stream.
- *
- */
- public function setOutputStream(OutputStream $stream): void {
- $this->outputStream = $stream;
- }
- /**
- * Sets the runner that owns the command.
- *
- * The runner is the instance that will execute the command.
- *
- * @param Runner $owner
- */
- public function setOwner(?Runner $owner = null): void {
- $this->owner = $owner;
- }
- /**
- * Display a message that represents a success status.
- *
- * The message will be prefixed with the string "Success:" in green.
- *
- * @param string $message The message that will be displayed.
- *
- */
- public function success(string $message): void {
- $this->printMsg($message, 'Success', 'light-green');
- }
-
- /**
- * Creates and displays a table with the given data.
- *
- * This method provides a convenient way to display tabular data in CLI applications
- * using the WebFiori CLI Table feature. It supports various table styles, themes,
- * column configuration, and data formatting options.
- *
- * @param array $data The data to display. Can be:
- * - Array of arrays (indexed): [['John', 30], ['Jane', 25]]
- * - Array of associative arrays: [['name' => 'John', 'age' => 30]]
- * @param array $headers Optional headers for the table columns. If not provided
- * and data contains associative arrays, keys will be used as headers.
- * @param array $options Optional configuration options. Use TableOptions constants for keys:
- * - TableOptions::STYLE: Table style ('bordered', 'simple', 'minimal', 'compact', 'markdown')
- * - TableOptions::THEME: Color theme ('default', 'dark', 'light', 'colorful', 'professional', 'minimal')
- * - TableOptions::TITLE: Table title to display above the table
- * - TableOptions::WIDTH: Maximum table width (auto-detected if not specified)
- * - TableOptions::SHOW_HEADERS: Whether to show column headers (default: true)
- * - TableOptions::COLUMNS: Column-specific configuration
- * - TableOptions::COLORIZE: Column colorization rules
- * - TableOptions::AUTO_WIDTH: Auto-calculate column widths (default: true)
- * - TableOptions::SHOW_ROW_SEPARATORS: Show separators between rows (default: false)
- * - TableOptions::SHOW_HEADER_SEPARATOR: Show separator after headers (default: true)
- * - TableOptions::PADDING: Cell padding configuration
- * - TableOptions::WORD_WRAP: Enable word wrapping (default: false)
- * - TableOptions::ELLIPSIS: Truncation string (default: '...')
- * - TableOptions::SORT: Sort configuration
- * - TableOptions::LIMIT: Limit number of rows displayed
- * - TableOptions::FILTER: Filter function for rows
- *
- * @return Command Returns the same instance for method chaining.
- *
- *
- * Example usage:
- * ```php
- * use WebFiori\Cli\Table\TableOptions;
- *
- * // Basic table
- * $this->table([
- * ['John Doe', 30, 'Active'],
- * ['Jane Smith', 25, 'Inactive']
- * ], ['Name', 'Age', 'Status']);
- *
- * // Advanced table with constants
- * $this->table($users, ['Name', 'Status', 'Balance'], [
- * TableOptions::STYLE => 'bordered',
- * TableOptions::THEME => 'colorful',
- * TableOptions::TITLE => 'User Management',
- * TableOptions::COLUMNS => [
- * 'Balance' => ['align' => 'right', 'formatter' => fn($v) => '$' . number_format($v, 2)]
- * ],
- * TableOptions::COLORIZE => [
- * 'Status' => fn($v) => match($v) {
- * 'Active' => ['color' => 'green', 'bold' => true],
- * 'Inactive' => ['color' => 'red'],
- * default => []
- * }
- * ]
- * ]);
- * ```
- */
- public function table(array $data, array $headers = [], array $options = []): Command {
- // Handle empty data
- if (empty($data)) {
- $this->info('No data to display in table.');
-
- return $this;
- }
-
- try {
- // Create table builder instance
- $tableBuilder = TableBuilder::create();
-
- // Set headers
- if (!empty($headers)) {
- $tableBuilder->setHeaders($headers);
- }
-
- // Set data
- $tableBuilder->setData($data);
-
- // Apply style (support both constant and string)
- $style = $options[TableOptions::STYLE] ?? $options['style'] ?? 'bordered';
- $tableBuilder->useStyle($style);
-
- // Apply theme (support both constant and string)
- $theme = $options[TableOptions::THEME] ?? $options['theme'] ?? null;
-
- if ($theme !== null) {
- $themeObj = TableTheme::create($theme);
- $tableBuilder->setTheme($themeObj);
- }
-
- // Set title (support both constant and string)
- $title = $options[TableOptions::TITLE] ?? $options['title'] ?? null;
-
- if ($title !== null) {
- $tableBuilder->setTitle($title);
- }
-
- // Set width (support both constant and string)
- $width = $options[TableOptions::WIDTH] ?? $options['width'] ?? $this->getTerminalWidth();
- $tableBuilder->setMaxWidth($width);
-
- // Configure headers visibility (support both constant and string)
- $showHeaders = $options[TableOptions::SHOW_HEADERS] ?? $options['showHeaders'] ?? true;
- $tableBuilder->showHeaders($showHeaders);
-
- // Configure columns (support both constant and string)
- $columns = $options[TableOptions::COLUMNS] ?? $options['columns'] ?? [];
-
- if (!empty($columns) && is_array($columns)) {
- foreach ($columns as $columnName => $columnConfig) {
- $tableBuilder->configureColumn($columnName, $columnConfig);
- }
- }
-
- // Apply colorization (support both constant and string)
- $colorize = $options[TableOptions::COLORIZE] ?? $options['colorize'] ?? [];
-
- if (!empty($colorize) && is_array($colorize)) {
- foreach ($colorize as $columnName => $colorizer) {
- if (is_callable($colorizer)) {
- $tableBuilder->colorizeColumn($columnName, $colorizer);
- }
- }
- }
-
- // Render and display the table
- $output = $tableBuilder->render();
- $this->prints($output);
- } catch (Exception $e) {
- $this->error('Failed to display table: '.$e->getMessage());
- } catch (Error $e) {
- $this->error('Table display error: '.$e->getMessage());
- }
-
- return $this;
- }
- /**
- * Display a message that represents a warning.
- *
- * The message will be prefixed with the string 'Warning:' in
- * red.
- *
- * @param string $message The message that will be shown.
- *
- */
- public function warning(string $message): void {
- $this->prints('Warning: ', [
- 'color' => 'light-yellow',
- 'bold' => true
- ]);
- $this->println($message);
- }
-
- /**
- * Executes a callback for each item with a progress bar.
- *
- * @param iterable $items Items to iterate over
- * @param callable $callback Callback to execute for each item
- * @param string $message Optional message to display
- * @return void
- */
- public function withProgressBar(iterable $items, callable $callback, string $message = ''): void {
- $items = is_array($items) ? $items : iterator_to_array($items);
- $total = count($items);
-
- $progressBar = $this->createProgressBar($total);
- $progressBar->start($message);
-
- foreach ($items as $key => $item) {
- $callback($item, $key);
- $progressBar->advance();
- }
-
- $progressBar->finish();
- }
-
- private function _createPassArray($string, array $args) : array {
- $retVal = [$string];
-
- foreach ($args as $arg) {
- if (gettype($arg) != 'array') {
- $retVal[] = $arg;
- }
- }
-
- return $retVal;
- }
- private function checkIsArgsSetHelper(): bool {
- $missingMandatory = [];
-
- foreach ($this->commandArgs as $argObj) {
- if (!$argObj->isOptional() && $argObj->getValue() === null && $argObj->getDefault() != '') {
- $argObj->setValue($argObj->getDefault());
- } else if (!$argObj->isOptional() && $argObj->getValue() === null) {
- $missingMandatory[] = $argObj->getName();
- }
- }
-
- if (count($missingMandatory) != 0) {
- $missingStr = 'The following required argument(s) are missing: ';
- $comma = '';
-
- foreach ($missingMandatory as $opt) {
- $missingStr .= $comma."'".$opt."'";
- $comma = ', ';
- }
- $this->error($missingStr);
-
- return false;
- }
-
- return true;
- }
- private function checkSelectedChoice(array $choices, int $defaultIndex, string $input): ?string {
- $retVal = null;
-
- if (in_array($input, $choices)) {
- //Given input is exactly same as one of choices
- $retVal = $input;
- } else if (strlen($input) == 0 && $defaultIndex !== null) {
- //Given input is empty string (enter hit).
- //Return default if specified.
- $retVal = $this->getDefaultChoiceHelper($choices, $defaultIndex);
- } else if (InputValidator::isInt($input)) {
- //Selected option is an index. Search for it and return its value.
- $retVal = $this->getChoiceAtIndex($choices, (int)$input);
- }
-
- if ($retVal === null) {
- $this->error('Invalid answer.');
- }
-
- return $retVal;
- }
- private function getChoiceAtIndex(array $choices, int $input): ?string {
- $index = 0;
-
- foreach ($choices as $choice) {
- if ($index == $input) {
- return $choice;
- }
- $index++;
- }
-
- return null;
- }
- private function getDefaultChoiceHelper(array $choices, int $defaultIndex): ?string {
- $index = 0;
-
- foreach ($choices as $choice) {
- if ($index == $defaultIndex) {
- return $choice;
- }
- $index++;
- }
-
- return null;
- }
- /**
- * Validate user input and show error message if user input is invalid.
- * @param string $input
- * @param InputValidator|null $validator
- * @param string|null $default
- * @return array The method will return an array with two indices, 'valid' and
- * 'value'. The 'valid' index contains a boolean that is set to true if the
- * value is valid. The index 'value' will contain the passed value.
- */
- private function getInputHelper(string &$input, ?InputValidator $validator = null, ?string $default = null) : array {
- $retVal = [
- 'valid' => true
- ];
-
- if (strlen($input) == 0 && $default !== null) {
- $input = $default;
- } else if ($validator !== null) {
- $retVal['valid'] = $validator->isValid($input);
-
- if (!($retVal['valid'] === true)) {
- $this->error($validator->getErrPrompt());
- }
- }
- $retVal['value'] = $input;
-
- return $retVal;
- }
-
- /**
- * Get terminal width for responsive table display.
- *
- * @return int Terminal width in characters, defaults to 80 if unable to detect.
- */
- private function getTerminalWidth(): int {
- // Try to get terminal width using tput
- $width = @exec('tput cols 2>/dev/null');
-
- if (is_numeric($width) && $width > 0) {
- return (int)$width;
- }
-
- // Try environment variable
- $width = getenv('COLUMNS');
-
- if ($width !== false && is_numeric($width) && $width > 0) {
- return (int)$width;
- }
-
- // Try using stty
- $width = @exec('stty size 2>/dev/null | cut -d" " -f2');
-
- if (is_numeric($width) && $width > 0) {
- return (int)$width;
- }
-
- // Default fallback
- return 80;
- }
- private function parseArgsHelper() : bool {
- $options = $this->getArgs();
- $invalidArgsVals = [];
-
- foreach ($options as $argObj) {
- $val = $this->getArgValue($argObj->getName());
-
- if ($val !== null && !$argObj->setValue($val)) {
- $invalidArgsVals[] = $argObj->getName();
- }
- }
-
- if (count($invalidArgsVals) != 0) {
- $invalidStr = 'The following argument(s) have invalid values: ';
- $comma = '';
-
- foreach ($invalidArgsVals as $argName) {
- $invalidStr .= $comma."'".$argName."'";
- $comma = ', ';
- }
- $this->error($invalidStr);
-
- foreach ($invalidArgsVals as $argName) {
- $this->info("Allowed values for the argument '$argName':");
-
- foreach ($this->getArg($argName)->getAllowedValues() as $val) {
- $this->println($val);
- }
- }
-
- return false;
- }
-
- return true;
- }
- private function printChoices(array $choices, int $default): void {
- $index = 0;
-
- foreach ($choices as $choiceTxt) {
- if ($default !== null && $index == $default) {
- $this->prints($index.": ".$choiceTxt, [
- 'color' => 'light-blue',
- 'bold' => 'true'
- ]);
- $this->println(' <--');
- } else {
- $this->println($index.": ".$choiceTxt);
- }
- $index++;
- }
- }
- private function printMsg(string $msg, string $prefix, string $color) {
- $this->prints("$prefix: ", [
- 'color' => $color,
- 'bold' => true,
-
- ]);
- $this->println($msg);
- }
-}
+
+ */
+ private $signalHandlers;
+ /**
+ * Creates new instance of the class.
+ *
+ * @param string $commandName A string that represents the name of the
+ * command such as '-v' or 'help'. If invalid name provided, the
+ * value 'new-command' is used.
+ *
+ * @param array $args An associative array of sub-associative arrays of arguments (or options) which can
+ * be supplied to the command when running it. The
+ * key of each sub array is argument name. Each
+ * sub-array can have the following indices as argument options:
+ *
+ * - optional: A boolean. if set to true, it means that the argument
+ * is optional and can be ignored when running the command.
+ * - default: An optional default value for the argument
+ * to use if it is not provided and is optional.
+ * - description: A description of the argument which
+ * will be shown if the command 'help' is executed.
+ * - values: A set of values that the argument can have. If provided,
+ * only the values on the list will be allowed. Note that if null or empty string
+ * is in the array, it will be ignored. Also, if boolean values are
+ * provided, true will be converted to the string 'y' and false will
+ * be converted to the string 'n'.
+ *
+ *
+ * @param string $description A string that describes what does the job
+ * do. The description will appear when the command 'help' is executed.
+ *
+ * @param array $aliases An optional array of aliases for the command.
+ */
+ public function __construct(string $commandName, array $args = [], string $description = '', array $aliases = []) {
+ if (!$this->setName($commandName)) {
+ $this->setName('new-command');
+ }
+ $this->aliases = $aliases;
+ $this->signalHandlers = [];
+ $this->addArgs($args);
+
+ if (!$this->setDescription($description)) {
+ $this->setDescription('');
+ }
+ }
+
+ /**
+ * Adds an alias to the command.
+ *
+ * @param string $alias The alias to add.
+ */
+ public function addAlias(string $alias): void {
+ if (!in_array($alias, $this->aliases)) {
+ $this->aliases[] = $alias;
+ }
+ }
+ /**
+ * Add command argument.
+ *
+ * An argument is a string that comes after the name of the command. The value
+ * of an argument can be set using equal sign. For example, if command name
+ * is 'do-it' and one argument has the name 'what-to-do', then the full
+ * CLI command would be "do-it what-to-do=say-hi". An argument can be
+ * also treated as an option.
+ *
+ * @param string $name The name of the argument. It must be non-empty string
+ * and does not contain spaces. Note that if the argument is already added and
+ * the developer is trying to add it again, the new options array will override
+ * the existing options array.
+ *
+ * @param array $options An optional array of options. Available options are:
+ *
+ * - optional: A boolean. if set to true, it means that the argument
+ * is optional and can be ignored when running the command.
+ * - default: An optional default value for the argument
+ * to use if it is not provided and is optional.
+ * - description: A description of the argument which
+ * will be shown if the command 'help' is executed.
+ * - values: A set of values that the argument can have. If provided,
+ * only the values on the list will be allowed. Note that if null or empty string
+ * is in the array, it will be ignored. Also, if boolean values are
+ * provided, true will be converted to the string 'y' and false will
+ * be converted to the string 'n'.
+ *
+ *
+ * @return bool If the argument is added, the method will return true.
+ * Other than that, the method will return false.
+ *
+ */
+ public function addArg(string $name, array $options = []) : bool {
+ $toAdd = Argument::create($name, $options);
+
+ if ($toAdd === null) {
+ return false;
+ }
+
+ return $this->addArgument($toAdd);
+ }
+ /**
+ * Adds multiple arguments to the command.
+ *
+ * @param array $arr An associative array of sub associative arrays. The
+ * key of each sub array is argument name. This can also be
+ * an array of objects of type 'CommandArgument'. For arrays, Each
+ * sub-array can have the following indices:
+ *
+ * - optional: A boolean. if set to true, it means that the argument
+ * is optional and can be ignored when running the command.
+ * - default: An optional default value for the argument
+ * to use if it is not provided and is optional.
+ * - description: A description of the argument which
+ * will be shown if the command 'help' is executed.
+ * - values: A set of values that the argument can have. If provided,
+ * only the values on the list will be allowed. Note that if null or empty string
+ * is in the array, it will be ignored. Also, if boolean values are
+ * provided, true will be converted to the string 'y' and false will
+ * be converted to the string 'n'.
+ *
+ */
+ public function addArgs(array $arr): void {
+ $this->commandArgs = [];
+
+ foreach ($arr as $optionName => $options) {
+ if ($options instanceof Argument) {
+ $this->addArgument($options);
+ } else {
+ $this->addArg($optionName, $options);
+ }
+ }
+ }
+ /**
+ * Adds new command argument.
+ *
+ * @param Argument $arg The argument that will be added.
+ *
+ * @return bool If the argument is added, the method will return true.
+ * If not, false is returned. The argument will not be added only if an argument
+ * which has same name is added.
+ */
+ public function addArgument(Argument $arg) : bool {
+ if (!$this->hasArg($arg->getName())) {
+ $this->commandArgs[] = $arg;
+
+ return true;
+ }
+
+ return false;
+ }
+ /**
+ * Clears the output before or after cursor position.
+ *
+ * This method will replace the visible characters with spaces.
+ * Note that support for this operation depends on terminal support for
+ * ANSI escape codes.
+ *
+ * @param int $numberOfCols Number of columns to clear. The columns that
+ * will be cleared are before and after cursor position. They don't include
+ * the character at which the cursor is currently pointing to.
+ * @param bool $beforeCursor If set to true, the characters which
+ * are before the cursor will be cleared. Default is true.
+ *
+ * @return Command The method will return the instance at which the
+ * method is called on.
+ *
+ */
+ public function clear(int $numberOfCols = 1, bool $beforeCursor = true) : Command {
+ if ($numberOfCols >= 1 && $beforeCursor) {
+ for ($x = 0 ; $x < $numberOfCols ; $x++) {
+ $this->moveCursorLeft();
+ $this->prints(" ");
+ $this->moveCursorLeft();
+ }
+ $this->moveCursorRight($numberOfCols);
+ } else if ($numberOfCols >= 1) {
+ $this->moveCursorRight();
+
+ for ($x = 0 ; $x < $numberOfCols ; $x++) {
+ $this->prints(" ");
+ }
+ $this->moveCursorLeft($numberOfCols + 1);
+ }
+
+ return $this;
+ }
+ /**
+ * Clears the whole content of the console.
+ *
+ * Note that support for this operation depends on terminal support for
+ * ANSI escape codes.
+ *
+ * @return Command The method will return the instance at which the
+ * method is called on.
+ */
+ public function clearConsole() : Command {
+ $this->prints("\ec");
+
+ return $this;
+ }
+ /**
+ * Clears the line at which the cursor is in and move it back to the start
+ * of the line.
+ *
+ * Note that support for this operation depends on terminal support for
+ * ANSI escape codes.
+ *
+ */
+ public function clearLine(): void {
+ $this->prints("\e[2K");
+ $this->prints("\r");
+ }
+
+ /**
+ * Removes all registered signal handlers for this command.
+ *
+ * @return Command The method returns same instance for chaining.
+ */
+ public function clearSignalHandlers(): Command {
+ $this->signalHandlers = [];
+
+ return $this;
+ }
+ /**
+ * Asks the user to conform something.
+ *
+ * This method will display the question and wait for the user to confirm the
+ * action by entering 'y' or 'n' in the terminal. If the user give something
+ * other than 'Y' or 'n', it will show an error and ask him to confirm
+ * again. If a default answer is provided, it will appear in upper case in the
+ * terminal. For example, if default is set to true, at the end of the prompt,
+ * the string that shows the options would be like '(Y/n)'.
+ *
+ * @param string $confirmTxt The text of the question of which will be asked.
+ *
+ * @param bool|null $default Default answer to use if empty input is given.
+ * It can be true for 'y' and false for 'n'. Default value is null which
+ * means no default will be used.
+ *
+ * @return bool If the user choose 'y', the method will return true. If
+ * he chooses 'n', the method will return false.
+ *
+ *
+ */
+ public function confirm(string $confirmTxt, ?bool $default = null) : bool {
+ $answer = null;
+
+ do {
+ if ($default === true) {
+ $optionsStr = '(Y/n)';
+ } else if ($default === false) {
+ $optionsStr = '(y/N)';
+ } else {
+ $optionsStr = '(y/n)';
+ }
+ $this->prints(trim($confirmTxt), [
+ 'color' => 'gray',
+ 'bold' => true
+ ]);
+ $this->println($optionsStr, [
+ 'color' => 'light-blue'
+ ]);
+
+ $input = strtolower(trim($this->readln()));
+
+ if ($input == 'n') {
+ $answer = false;
+ } else if ($input == 'y') {
+ $answer = true;
+ } else if (strlen($input) == 0 && $default !== null) {
+ return $default === true;
+ } else {
+ $this->error('Invalid answer. Choose \'y\' or \'n\'.');
+ }
+ } while ($answer === null);
+
+ return $answer;
+ }
+
+ /**
+ * Creates and returns a new progress bar instance.
+ *
+ * @param int $total Total number of steps
+ * @return ProgressBar
+ */
+ public function createProgressBar(int $total = 100): ProgressBar {
+ return new ProgressBar($this->getOutputStream(), $total);
+ }
+ /**
+ * Display a message that represents an error.
+ *
+ * The message will be prefixed with the string 'Error:' in
+ * red.
+ *
+ * @param string $message The message that will be shown.
+ *
+ */
+ public function error(string $message): void {
+ $this->printMsg($message, 'Error', 'light-red');
+ }
+ /**
+ * Execute the command.
+ *
+ * This method should not be called manually by the developer.
+ *
+ * @return int If the command is executed, the method will return 0.
+ * Other than that, it will return a number which depends on the return value of
+ * the method 'Command::exec()'.
+ *
+ */
+ public function excCommand() : int {
+ $retVal = -1;
+
+ $runner = $this->getOwner();
+
+ if ($runner !== null) {
+ foreach ($runner->getArgs() as $arg) {
+ $this->addArgument($arg);
+ }
+ }
+
+ if ($this->parseArgsHelper()) {
+ // Check for help first, before validating required arguments
+ if ($this->isArgProvided('help') || $this->isArgProvided('-h')) {
+ $help = $runner->getCommandByName('help');
+ $help->setArgValue('--command', $this->getName());
+ $help->setOwner($runner);
+ $help->setOutputStream($runner->getOutputStream());
+ $this->removeArgument('help');
+
+ return $help->exec();
+ } else if ($this->checkIsArgsSetHelper()) {
+ $retVal = $this->exec();
+ }
+
+ }
+
+ if ($runner !== null) {
+ foreach ($runner->getArgs() as $arg) {
+ $this->removeArgument($arg->getName());
+ $arg->resetValue();
+ }
+ }
+
+ return $retVal;
+ }
+ /**
+ * Execute the command.
+ *
+ * The implementation of this method should contain the code that will run
+ * when the command is executed.
+ *
+ * @return int The developer should implement this method in a way it returns 0
+ * if the command is executed successfully and return -1 if the
+ * command did not execute successfully.
+ *
+ */
+ public abstract function exec() : int;
+ /**
+ * Execute a registered command using a sub-runner.
+ *
+ * This method can be used to execute a registered command within the runner
+ * using another
+ * runner instance which shares argsv, input and output streams with the
+ * main runner. It can be used to invoke another command from within a
+ * running command.
+ *
+ * @param string $name The name of the command. It must be a part of
+ * registered commands.
+ *
+ * @param array $additionalArgs An associative array that represents additional arguments
+ * to be passed to the command.
+ *
+ * @return int The method will return an integer that represent exit status
+ * code of the command after execution.
+ */
+ public function execSubCommand(string $name, array $additionalArgs = []) : int {
+ $runner = $this->getOwner();
+
+ if ($runner === null) {
+ return -1;
+ }
+
+ return $runner->runCommandAsSub($name, $additionalArgs);
+ }
+ /**
+ * Returns an array of aliases for the command.
+ *
+ * @return array An array of aliases.
+ */
+ public function getAliases() : array {
+ return $this->aliases;
+ }
+ /**
+ * Returns an object that holds argument info if the command.
+ *
+ * @param string $name The name of command argument.
+ *
+ * @return Argument|null If the command has an argument with the
+ * given name, it will be returned. Other than that, null is returned.
+ */
+ public function getArg(string $name): ?Argument {
+ foreach ($this->getArgs() as $arg) {
+ if ($arg->getName() == $name) {
+ return $arg;
+ }
+ }
+
+ return null;
+ }
+ /**
+ * Returns an associative array that contains command args.
+ *
+ * @return array An associative array. The indices of the array are
+ * the names of the arguments and the values are sub-associative arrays.
+ * the sub arrays will have the following indices:
+ *
+ * - optional
+ * - description
+ * - default
+ *
+ * Note that the last index might not be set.
+ *
+ */
+ public function getArgs() : array {
+ return $this->commandArgs;
+ }
+ /**
+ * Returns an array that contains the names of command arguments.
+ *
+ * @return array An array of strings.
+ */
+ public function getArgsNames() : array {
+ return array_map(function ($el) {
+ return $el->getName();
+ }, $this->getArgs());
+ }
+ /**
+ * Returns the value of command option from CLI given its name.
+ *
+ * @param string $optionName The name of the option.
+ *
+ * @return string|null If the value of the option is set, the method will
+ * return its value as string. If it is not set, the method will return null.
+ *
+ */
+ public function getArgValue(string $optionName): ?string {
+ $trimmedOptName = trim($optionName);
+ $arg = $this->getArg($trimmedOptName);
+
+ if ($arg !== null) {
+ $runner = $this->getOwner();
+
+ // Always return the set value if it exists, regardless of interactive mode
+ if ($arg->getValue() !== null) {
+ return $arg->getValue();
+ }
+
+ return Argument::extractValue($trimmedOptName, $runner);
+ }
+
+ return null;
+ }
+ /**
+ * Returns the description of the command.
+ *
+ * The description of the command is a string that describes what does the
+ * command do, and it will appear in CLI if the command 'help' is executed.
+ *
+ * @return string The description of the command. Default return value
+ * is '<NO DESCRIPTION>'
+ *
+ */
+ public function getDescription() : string {
+ return $this->description;
+ }
+
+ /**
+ * Take an input value from the user.
+ *
+ * @param string $prompt The string that will be shown to the user. The
+ * string must be non-empty.
+ *
+ * @param string|null $default An optional default value to use in case the user
+ * hit "Enter" without entering any value. If null is passed, no default
+ * value will be set.
+ *
+ * @param InputValidator|null $validator A callback that can be used to validate user
+ * input. The callback accepts one parameter which is the value that
+ * the user has given. If the value is valid, the callback must return true.
+ * If the callback returns anything else, it means the value which is given
+ * by the user is invalid and this method will ask the user to enter the
+ * value again.
+ *
+ * @return string|null The method will return the value which was taken from the
+ * user. If prompt string is empty, null will be returned.
+ * Note that if the input has special characters or spaces at the
+ * beginning or the end, they will be trimmed.
+ *
+ */
+ public function getInput(string $prompt, ?string $default = null, ?InputValidator $validator = null): ?string {
+ $trimmed = trim($prompt);
+
+ if (strlen($trimmed) > 0) {
+ do {
+ $this->prints($trimmed, [
+ 'color' => 'gray',
+ 'bold' => true
+ ]);
+
+ if ($default !== null) {
+ $this->prints(" Enter = '".$default."'", [
+ 'color' => 'light-blue'
+ ]);
+ }
+ $this->println();
+ $input = trim($this->readln());
+
+ $check = $this->getInputHelper($input, $validator, $default);
+
+ if ($check['valid']) {
+ return $check['value'];
+ }
+ } while (true);
+ }
+
+ return null;
+ }
+ public function getInputStream() : InputStream {
+ return $this->inputStream;
+ }
+ /**
+ * Reads user input with characters masked by a specified character.
+ *
+ * This method is similar to getInput() but masks the input characters as the user types,
+ * making it suitable for sensitive information like passwords, tokens, or secrets.
+ * The actual input value is captured but only mask characters are displayed in the terminal.
+ *
+ * @param string $prompt The prompt message to display to the user. Must be non-empty.
+ *
+ * @param string $mask The character to display instead of the actual input characters.
+ * Default is '*'. Can be any single character or string.
+ *
+ * @param string|null $default An optional default value to use if the user provides
+ * empty input. If provided, it will be shown in the prompt.
+ *
+ * @param InputValidator|null $validator An optional validator to validate the input.
+ * If validation fails, the user will be prompted again.
+ *
+ * @return string|null Returns the actual input value (not masked) if valid input is provided,
+ * or null if the prompt is empty.
+ *
+ * @since 1.1.0
+ */
+ public function getMaskedInput(string $prompt, string $mask = '*', ?string $default = null, ?InputValidator $validator = null): ?string {
+ $trimmed = trim($prompt);
+
+ if (strlen($trimmed) > 0) {
+ do {
+ $this->prints($trimmed, [
+ 'color' => 'gray',
+ 'bold' => true
+ ]);
+
+ if ($default !== null) {
+ $this->prints(" Enter = '".$default."'", [
+ 'color' => 'light-blue'
+ ]);
+ }
+ $this->println();
+ $input = trim($this->readMaskedLine($mask));
+
+ $check = $this->getInputHelper($input, $validator, $default);
+
+ if ($check['valid']) {
+ return $check['value'];
+ }
+ } while (true);
+ }
+
+ return null;
+ } /**
+ * Returns the stream at which the command is sing to read inputs.
+ *
+ * @return null|InputStream If the stream is set, it will be returned as
+ * an object. Other than that, the method will return null.
+ *
+ */
+ /**
+ * Returns the name of the command.
+ *
+ * The name of the command is a string which is used to call the command
+ * from CLI.
+ *
+ * @return string The name of the command (such as 'v' or 'help'). Default
+ * return value is 'new-command'.
+ *
+ */
+ public function getName() : string {
+ return $this->commandName;
+ }
+ /**
+ * Returns the stream at which the command is using to send output.
+ *
+ * @return null|OutputStream If the stream is set, it will be returned as
+ * an object. Other than that, the method will return null.
+ *
+ */
+ public function getOutputStream() : OutputStream {
+ return $this->outputStream;
+ }
+ /**
+ * Returns the runner which is used to execute the command.
+ *
+ * @return Runner|null If the command was called using a runner, this method
+ * will return an instance that can be used to access runner's properties.
+ * If not called through a runner, null is returned.
+ */
+ public function getOwner(): ?Runner {
+ return $this->owner;
+ }
+
+ /**
+ * Returns all signal handlers registered for this command.
+ *
+ * @return array An associative array mapping signal numbers
+ * to their handler callables.
+ */
+ public function getSignalHandlers(): array {
+ return $this->signalHandlers;
+ }
+ /**
+ * Checks if the command has a specific command line argument or not.
+ *
+ * @param string $argName The name of the command line argument.
+ *
+ * @return bool If the argument is added to the command, the method will
+ * return true. If no argument which has the given name does exist, the method
+ * will return false.
+ *
+ */
+ public function hasArg(string $argName) : bool {
+ foreach ($this->getArgs() as $arg) {
+ if ($arg->getName() == $argName) {
+ return true;
+ }
+ }
+
+ return false;
+ }
+ /**
+ * Display a message that represents extra information.
+ *
+ * The message will be prefixed with the string 'Info:' in
+ * blue.
+ *
+ * @param string $message The message that will be shown.
+ *
+ */
+ public function info(string $message): void {
+ $this->printMsg($message, 'Info', 'blue');
+ }
+ /**
+ * Checks if an argument is provided in the CLI or not.
+ *
+ * The method will not check if the argument has a value or not.
+ *
+ * @param string $argName The name of the command line argument.
+ *
+ * @return bool If the argument is provided, the method will return
+ * true. Other than that, the method will return false.
+ *
+ */
+ public function isArgProvided(string $argName) : bool {
+ $argObj = $this->getArg($argName);
+
+ if ($argObj !== null) {
+ return $argObj->getValue() !== null;
+ }
+
+ return false;
+ }
+ /**
+ * Moves the cursor down by specific number of lines.
+ *
+ * Note that support for this operation depends on terminal support for
+ * ANSI escape codes.
+ *
+ * @param int $lines The number of lines the cursor will be moved. Default
+ * value is 1.
+ *
+ */
+ public function moveCursorDown(int $lines = 1): void {
+ if ($lines >= 1) {
+ $this->prints("\e[".$lines."B");
+ }
+ }
+ /**
+ * Moves the cursor to the left by specific number of columns.
+ *
+ * Note that support for this operation depends on terminal support for
+ * ANSI escape codes.
+ *
+ * @param int $numberOfCols The number of columns the cursor will be moved. Default
+ * value is 1.
+ *
+ */
+ public function moveCursorLeft(int $numberOfCols = 1): void {
+ if ($numberOfCols >= 1) {
+ $this->prints("\e[".$numberOfCols."D");
+ }
+ }
+ /**
+ * Moves the cursor to the right by specific number of columns.
+ *
+ * Note that support for this operation depends on terminal support for
+ * ANSI escape codes.
+ *
+ * @param int $numberOfCols The number of columns the cursor will be moved. Default
+ * value is 1.
+ *
+ */
+ public function moveCursorRight(int $numberOfCols = 1): void {
+ if ($numberOfCols >= 1) {
+ $this->prints("\e[".$numberOfCols."C");
+ }
+ }
+ /**
+ * Moves the cursor to specific position in the terminal.
+ *
+ * If no arguments are supplied to the method, it will move the cursor
+ * to the upper-left corner of the screen (line 0, column 0).
+ * Note that support for this operation depends on terminal support for
+ * ANSI escape codes.
+ *
+ * @param int $line The number of line at which the cursor will be moved
+ * to. If not specified, 0 is used.
+ *
+ * @param int $col The number of column at which the cursor will be moved
+ * to. If not specified, 0 is used.
+ *
+ */
+ public function moveCursorTo(int $line = 0, int $col = 0): void {
+ if ($line > -1 && $col > -1) {
+ $this->prints("\e[".$line.";".$col."H");
+ }
+ }
+ /**
+ * Moves the cursor up by specific number of lines.
+ *
+ * Note that support for this operation depends on terminal support for
+ * ANSI escape codes.
+ *
+ * @param int $lines The number of lines the cursor will be moved. Default
+ * value is 1.
+ *
+ */
+ public function moveCursorUp(int $lines = 1): void {
+ if ($lines >= 1) {
+ $this->prints("\e[".$lines."A");
+ }
+ }
+ /**
+ * Registers a signal handler for this command.
+ *
+ * The handler will be active only while this command is executing and will
+ * be automatically removed after the command finishes.
+ *
+ * @param int $signal The signal number (e.g., SIGINT, SIGTERM).
+ *
+ * @param callable $handler The callback to invoke when the signal is received.
+ *
+ * @return Command The method returns same instance for chaining.
+ */
+ public function onSignal(int $signal, callable $handler): Command {
+ $this->signalHandlers[$signal] = $handler;
+
+ return $this;
+ }
+ /**
+ * Prints an array as a list of items.
+ *
+ * This method is useful if the developer would like to print out a list
+ * of multiple items. Each item will be prefixed with a number that represents
+ * its index in the array.
+ *
+ * @param array $array The array that will be printed.
+ *
+ */
+ public function printList(array $array): void {
+ for ($x = 0 ; $x < count($array) ; $x++) {
+ $this->prints("- ", [
+ 'color' => 'green'
+ ]);
+ $this->println($array[$x]);
+ }
+ }
+ /**
+ * Print out a string and terminates the current line by writing the
+ * line separator string.
+ *
+ * This method will work like the function fprintf(). The difference is that
+ * it will print out to the stream at which was specified by the method
+ * Command::setOutputStream() and the text can have formatting
+ * options. Note that support for output formatting depends on terminal support for
+ * ANSI escape codes.
+ *
+ * @param string $str The string that will be printed.
+ *
+ * @param mixed $_ One or more extra arguments that can be supplied to the
+ * method. The last argument can be an array that contains text formatting options.
+ * for available options, check the method Command::formatOutput().
+ */
+ public function println(string $str = '', ...$_) {
+ $argsCount = count($_);
+
+ if ($argsCount != 0 && gettype($_[$argsCount - 1]) == 'array') {
+ //Last index contains formatting options.
+ $_[$argsCount - 1]['ansi'] = $this->isArgProvided('--ansi');
+ $str = Formatter::format($str, $_[$argsCount - 1]);
+ }
+ call_user_func_array([$this->getOutputStream(), 'println'], $this->_createPassArray($str, $_));
+ }
+ /**
+ * Print out a string.
+ *
+ * This method works exactly like the function 'fprintf()'. The only
+ * difference is that the method will print out the output to the stream
+ * that was specified using the method Command::setOutputStream() and
+ * the method accepts formatting options as last argument to format the output.
+ * Note that support for output formatting depends on terminal support for
+ * ANSI escape codes.
+ *
+ * @param string $str The string that will be printed.
+ *
+ * @param mixed $_ One or more extra arguments that can be supplied to the
+ * method. The last argument can be an array that contains text formatting options.
+ * for available options, check the method Command::formatOutput().
+ *
+ */
+ public function prints(string $str, ...$_): void {
+ $argCount = count($_);
+ $formattingOptions = [];
+
+ if ($argCount != 0 && gettype($_[$argCount - 1]) == 'array') {
+ $formattingOptions = $_[$argCount - 1];
+ }
+
+ $formattingOptions['ansi'] = $this->isArgProvided('--ansi');
+
+ $formattedStr = Formatter::format($str, $formattingOptions);
+
+ call_user_func_array([$this->getOutputStream(), 'prints'], $this->_createPassArray($formattedStr, $_));
+ }
+
+ /**
+ * Reads a string of bytes from input stream.
+ *
+ * This method is used to read specific number of characters from input stream.
+ *
+ * @return string The method will return the string which was given as input
+ * in the input stream.
+ *
+ */
+ public function read(int $bytes = 1) : string {
+ return $this->getInputStream()->read($bytes);
+ }
+ /**
+ * Reads and validates class name.
+ *
+ * @param string|null $suffix An optional string to append to class name.
+ *
+ * @param string $prompt The text that will be shown to the user as prompt for
+ * class name.
+ *
+ * @param string $errMsg A string to show in case provided class name is
+ * not valid.
+ *
+ * @return string A string that represents a valid class name. If suffix is
+ * not null, the method will return the name with the suffix included.
+ */
+ public function readClassName(string $prompt, ?string $suffix = null, string $errMsg = 'Invalid class name is given.'): ?string {
+ return $this->getInput($prompt, null, new InputValidator(function (&$className, $suffix) {
+ if ($suffix !== null) {
+ $subSuffix = substr($className, strlen($className) - strlen($suffix));
+
+ if ($subSuffix != $suffix) {
+ $className .= $suffix;
+ }
+ }
+
+ return InputValidator::isValidClassName($className);
+ }, $errMsg, [$suffix]));
+ }
+
+ /**
+ * Reads a value as float.
+ *
+ * @param string $prompt The string that will be shown to the user. The
+ * string must be non-empty.
+ *
+ * @param float|null $default An optional default value to use in case the user
+ * hit "Enter" without entering any value. If null is passed, no default
+ * value will be set.
+ *
+ * @return float
+ */
+ public function readFloat(string $prompt, ?float $default = null) : float {
+ $defaultStr = $default !== null ? (string)$default : null;
+ $result = $this->getInput($prompt, $defaultStr, new InputValidator(function ($val) {
+ return InputValidator::isFloat($val);
+ }, 'Provided value is not a floating number!'));
+
+ return (float)$result;
+ }
+
+ /**
+ * Reads the namespace of class and return an instance of it.
+ *
+ * @param string $prompt The string that will be shown to the user. The
+ * string must be non-empty.
+ *
+ * @param string $errMsg A string to show in case provided namespace is
+ * invalid or an instance of the class cannot be created.
+ *
+ * @return object The method will return an instance of the class.
+ *
+ * @throws ReflectionException If the method was not able to initiate class instance.
+ */
+ public function readInstance(string $prompt, string $errMsg = 'Invalid Class!', array $constructorArgs = []): ?object {
+ $clazzNs = $this->getInput($prompt, null, new InputValidator(function ($input) {
+ if (InputValidator::isClass($input)) {
+ return true;
+ }
+
+ return false;
+ }, $errMsg));
+
+ $reflection = new ReflectionClass($clazzNs);
+
+ return $reflection->newInstanceArgs($constructorArgs);
+ }
+ /**
+ * Reads a value as an integer.
+ *
+ * @param string $prompt The string that will be shown to the user. The
+ * string must be non-empty.
+ *
+ * @param int $default An optional default value to use in case the user
+ * hit "Enter" without entering any value. If null is passed, no default
+ * value will be set.
+ *
+ * @return int
+ */
+ public function readInteger(string $prompt, ?int $default = null) : int {
+ $defaultStr = $default !== null ? (string)$default : null;
+ $result = $this->getInput($prompt, $defaultStr, new InputValidator(function ($val) {
+ return InputValidator::isInt($val);
+ }, 'Provided value is not an integer!'));
+
+ return (int)$result;
+ }
+ /**
+ * Reads one line from input stream.
+ *
+ * The method will continue to read from input stream till it finds end of
+ * line character "\n".
+ *
+ * @return string The method will return the string which was taken from
+ * input stream without the end of line character.
+ *
+ */
+ public function readln() : string {
+ return $this->getInputStream()->readLine();
+ }
+ /**
+ * Reads a string that represents class namespace.
+ *
+ * @param string $prompt The string that will be shown to the user. The
+ * string must be non-empty.
+ *
+ * @param string $defaultNs A default string that represents default namespace.
+ * Note that the method will throw an exception if this parameter does not
+ * represent a valid namespace.
+ *
+ * @param string $errMsg A string that will be shown if provided input does
+ * not represent a valid namespace.
+ *
+ * @return string The method will return a string that represent a valid namespace.
+ *
+ * @throws IOException If given default namespace does not represent a namespace.
+ */
+ public function readNamespace(string $prompt, ?string $defaultNs = null, string $errMsg = 'Invalid Namespace!'): ?string {
+ if ($defaultNs !== null && !InputValidator::isValidNamespace($defaultNs)) {
+ throw new IOException('Provided default namespace is not valid.');
+ }
+
+ return $this->getInput($prompt, $defaultNs, new InputValidator(function ($input) {
+ if (InputValidator::isValidNamespace($input)) {
+ return true;
+ }
+
+ return false;
+ }, $errMsg));
+ }
+ /**
+ * Removes an argument from the command given its name.
+ *
+ * @param string $name The name of the argument that will be removed.
+ *
+ * @return bool If removed, true is returned. Other than that, false is
+ * returned.
+ */
+ public function removeArgument(string $name) : bool {
+ $removed = false;
+ $temp = [];
+
+ foreach ($this->getArgs() as $arg) {
+ if ($arg->getName() !== $name) {
+ $temp[] = $arg;
+ } else {
+ $removed = true;
+ }
+ }
+ $this->commandArgs = $temp;
+
+ return $removed;
+ }
+
+ /**
+ * Ask the user to select one of multiple values.
+ *
+ * This method will display a prompt and wait for the user to select
+ * a value from a set of values. If the user give something other than the listed values,
+ * it will show an error and ask him to select again. The
+ * user can select an answer by typing its text or its number which will appear
+ * in the terminal.
+ *
+ * @param string $prompt The text that will be shown for the user.
+ *
+ * @param array $choices An indexed array of values to select from.
+ *
+ * @param int $defaultIndex The index of the default value in case no value
+ * is selected and the user hit enter.
+ *
+ * * @param int $maxTrials The maximum number of trials the user can do to select
+ * a value. If -1 is passed, the user can select a value forever.
+ *
+ * @return string|null The method will return the value which is selected by
+ * the user. If choices array is empty, null is returned. Also, null is returned
+ * if max trials is reached and it is not -1.
+ *
+ *
+ */
+ public function select(string $prompt, array $choices, int $defaultIndex = -1, int $maxTrials = -1): ?string {
+ if (count($choices) != 0) {
+ $currentTry = 0;
+
+ do {
+ $this->println($prompt, [
+ 'color' => 'gray',
+ 'bold' => true
+ ]);
+
+ $this->printChoices($choices, $defaultIndex);
+ $input = trim($this->readln());
+
+ $check = $this->checkSelectedChoice($choices, $defaultIndex, $input);
+
+ if ($check !== null) {
+ return $check;
+ }
+ $currentTry++;
+
+ if ($currentTry == $maxTrials && $maxTrials > 0) {
+ return null;
+ }
+ } while (true);
+ }
+
+ return null;
+ }
+
+ /**
+ * Sets the aliases for the command.
+ *
+ * @param array $aliases An array of aliases.
+ */
+ public function setAliases(array $aliases): void {
+ $this->aliases = $aliases;
+ }
+ /**
+ * Sets the value of an argument.
+ *
+ * This method is useful in writing test cases for the commands.
+ *
+ * @param string $argName The name of the argument.
+ *
+ * @param string $argValue The value to set.
+ *
+ * @return bool If the value of the argument is set, the method will return
+ * true. If not set, the method will return false. The value of the attribute
+ * will be not set in the following cases:
+ *
+ * - If the argument can have a specific set of values and the given
+ * value is not one of them.
+ * - The given value is empty string or null.
+ *
+ *
+ */
+ public function setArgValue(string $argName, string $argValue = ''): bool {
+ $trimmedArgName = trim($argName);
+ $argObj = $this->getArg($trimmedArgName);
+
+ if ($argObj !== null) {
+ return $argObj->setValue($argValue);
+ }
+
+ return false;
+ }
+ /**
+ * Sets the description of the command.
+ *
+ * The description of the command is a string that describes what does the
+ * command do, and it will appear in CLI if the command 'help' is executed.
+ *
+ * @param string $str A string that describes the command. It must be non-empty
+ * string.
+ *
+ * @return bool If the description of the command is set, the method will return
+ * true. Other than that, the method will return false.
+ */
+ public function setDescription(string $str) : bool {
+ $trimmed = trim($str);
+
+ if (strlen($trimmed) > 0) {
+ $this->description = $trimmed;
+
+ return true;
+ }
+
+ return false;
+ }
+ /**
+ * Sets the stream at which the command will read input from.
+ *
+ * @param InputStream $stream An instance that implements an input stream.
+ *
+ */
+ public function setInputStream(InputStream $stream): void {
+ $this->inputStream = $stream;
+ }
+ /**
+ * Sets the name of the command.
+ *
+ * The name of the command is a string which is used to call the command
+ * from CLI.
+ *
+ * @param string $name The name of the command (such as 'v' or 'help').
+ * It must be non-empty string and does not contain spaces.
+ *
+ * @return bool If the name of the command is set, the method will return
+ * true. Other than that, the method will return false.
+ *
+ */
+ public function setName(string $name) : bool {
+ $trimmed = trim($name);
+
+ if (strlen($trimmed) > 0 && !strpos($trimmed, ' ')) {
+ $this->commandName = $name;
+
+ return true;
+ }
+
+ return false;
+ }
+ /**
+ * Sets the stream at which the command will send output to.
+ *
+ * @param OutputStream $stream An instance that implements output stream.
+ *
+ */
+ public function setOutputStream(OutputStream $stream): void {
+ $this->outputStream = $stream;
+ }
+ /**
+ * Sets the runner that owns the command.
+ *
+ * The runner is the instance that will execute the command.
+ *
+ * @param Runner $owner
+ */
+ public function setOwner(?Runner $owner = null): void {
+ $this->owner = $owner;
+ }
+ /**
+ * Display a message that represents a success status.
+ *
+ * The message will be prefixed with the string "Success:" in green.
+ *
+ * @param string $message The message that will be displayed.
+ *
+ */
+ public function success(string $message): void {
+ $this->printMsg($message, 'Success', 'light-green');
+ }
+
+ /**
+ * Check if the current input stream supports interactive input.
+ *
+ * @return bool True if the input stream supports interactive input (real-time user interaction),
+ * false otherwise (files, pipes, arrays, etc.)
+ */
+ public function supportsInteractiveInput(): bool {
+ $stream = $this->getInputStream();
+
+ // Only StdIn with tty supports true interaction
+ if ($stream instanceof Streams\StdIn) {
+ return function_exists('posix_isatty') && posix_isatty(STDIN);
+ }
+
+ // All other stream types are non-interactive
+ return false;
+ }
+
+ /**
+ * Creates and displays a table with the given data.
+ *
+ * This method provides a convenient way to display tabular data in CLI applications
+ * using the WebFiori CLI Table feature. It supports various table styles, themes,
+ * column configuration, and data formatting options.
+ *
+ * @param array $data The data to display. Can be:
+ * - Array of arrays (indexed): [['John', 30], ['Jane', 25]]
+ * - Array of associative arrays: [['name' => 'John', 'age' => 30]]
+ * @param array $headers Optional headers for the table columns. If not provided
+ * and data contains associative arrays, keys will be used as headers.
+ * @param array $options Optional configuration options. Use TableOptions constants for keys:
+ * - TableOptions::STYLE: Table style ('bordered', 'simple', 'minimal', 'compact', 'markdown')
+ * - TableOptions::THEME: Color theme ('default', 'dark', 'light', 'colorful', 'professional', 'minimal')
+ * - TableOptions::TITLE: Table title to display above the table
+ * - TableOptions::WIDTH: Maximum table width (auto-detected if not specified)
+ * - TableOptions::SHOW_HEADERS: Whether to show column headers (default: true)
+ * - TableOptions::COLUMNS: Column-specific configuration
+ * - TableOptions::COLORIZE: Column colorization rules
+ * - TableOptions::AUTO_WIDTH: Auto-calculate column widths (default: true)
+ * - TableOptions::SHOW_ROW_SEPARATORS: Show separators between rows (default: false)
+ * - TableOptions::SHOW_HEADER_SEPARATOR: Show separator after headers (default: true)
+ * - TableOptions::PADDING: Cell padding configuration
+ * - TableOptions::WORD_WRAP: Enable word wrapping (default: false)
+ * - TableOptions::ELLIPSIS: Truncation string (default: '...')
+ * - TableOptions::SORT: Sort configuration
+ * - TableOptions::LIMIT: Limit number of rows displayed
+ * - TableOptions::FILTER: Filter function for rows
+ *
+ * @return Command Returns the same instance for method chaining.
+ *
+ *
+ * Example usage:
+ * ```php
+ * use WebFiori\Cli\Table\TableOptions;
+ *
+ * // Basic table
+ * $this->table([
+ * ['John Doe', 30, 'Active'],
+ * ['Jane Smith', 25, 'Inactive']
+ * ], ['Name', 'Age', 'Status']);
+ *
+ * // Advanced table with constants
+ * $this->table($users, ['Name', 'Status', 'Balance'], [
+ * TableOptions::STYLE => 'bordered',
+ * TableOptions::THEME => 'colorful',
+ * TableOptions::TITLE => 'User Management',
+ * TableOptions::COLUMNS => [
+ * 'Balance' => ['align' => 'right', 'formatter' => fn($v) => '$' . number_format($v, 2)]
+ * ],
+ * TableOptions::COLORIZE => [
+ * 'Status' => fn($v) => match($v) {
+ * 'Active' => ['color' => 'green', 'bold' => true],
+ * 'Inactive' => ['color' => 'red'],
+ * default => []
+ * }
+ * ]
+ * ]);
+ * ```
+ */
+ public function table(array $data, array $headers = [], array $options = []): Command {
+ // Handle empty data
+ if (empty($data)) {
+ $this->info('No data to display in table.');
+
+ return $this;
+ }
+
+ try {
+ // Create table builder instance
+ $tableBuilder = TableBuilder::create();
+
+ // Set headers
+ if (!empty($headers)) {
+ $tableBuilder->setHeaders($headers);
+ }
+
+ // Set data
+ $tableBuilder->setData($data);
+
+ // Apply style (support both constant and string)
+ $style = $options[TableOptions::STYLE] ?? $options['style'] ?? 'bordered';
+ $tableBuilder->useStyle($style);
+
+ // Apply theme (support both constant and string)
+ $theme = $options[TableOptions::THEME] ?? $options['theme'] ?? null;
+
+ if ($theme !== null) {
+ $themeObj = TableTheme::create($theme);
+ $tableBuilder->setTheme($themeObj);
+ }
+
+ // Set title (support both constant and string)
+ $title = $options[TableOptions::TITLE] ?? $options['title'] ?? null;
+
+ if ($title !== null) {
+ $tableBuilder->setTitle($title);
+ }
+
+ // Set width (support both constant and string)
+ $width = $options[TableOptions::WIDTH] ?? $options['width'] ?? $this->getTerminalWidth();
+ $tableBuilder->setMaxWidth($width);
+
+ // Configure headers visibility (support both constant and string)
+ $showHeaders = $options[TableOptions::SHOW_HEADERS] ?? $options['showHeaders'] ?? true;
+ $tableBuilder->showHeaders($showHeaders);
+
+ // Configure columns (support both constant and string)
+ $columns = $options[TableOptions::COLUMNS] ?? $options['columns'] ?? [];
+
+ if (!empty($columns) && is_array($columns)) {
+ foreach ($columns as $columnName => $columnConfig) {
+ $tableBuilder->configureColumn($columnName, $columnConfig);
+ }
+ }
+
+ // Apply colorization (support both constant and string)
+ $colorize = $options[TableOptions::COLORIZE] ?? $options['colorize'] ?? [];
+
+ if (!empty($colorize) && is_array($colorize)) {
+ foreach ($colorize as $columnName => $colorizer) {
+ if (is_callable($colorizer)) {
+ $tableBuilder->colorizeColumn($columnName, $colorizer);
+ }
+ }
+ }
+
+ // Render and display the table
+ $output = $tableBuilder->render();
+ $this->prints($output);
+ } catch (Exception $e) {
+ $this->error('Failed to display table: '.$e->getMessage());
+ } catch (Error $e) {
+ $this->error('Table display error: '.$e->getMessage());
+ }
+
+ return $this;
+ }
+ /**
+ * Display a message that represents a warning.
+ *
+ * The message will be prefixed with the string 'Warning:' in
+ * red.
+ *
+ * @param string $message The message that will be shown.
+ *
+ */
+ public function warning(string $message): void {
+ $this->prints('Warning: ', [
+ 'color' => 'light-yellow',
+ 'bold' => true
+ ]);
+ $this->println($message);
+ }
+
+ /**
+ * Executes a callback for each item with a progress bar.
+ *
+ * @param iterable $items Items to iterate over
+ * @param callable $callback Callback to execute for each item
+ * @param string $message Optional message to display
+ * @return void
+ */
+ public function withProgressBar(iterable $items, callable $callback, string $message = ''): void {
+ $items = is_array($items) ? $items : iterator_to_array($items);
+ $total = count($items);
+
+ $progressBar = $this->createProgressBar($total);
+ $progressBar->start($message);
+
+ foreach ($items as $key => $item) {
+ $callback($item, $key);
+ $progressBar->advance();
+ }
+
+ $progressBar->finish();
+ }
+
+ private function _createPassArray($string, array $args) : array {
+ $retVal = [$string];
+
+ foreach ($args as $arg) {
+ if (gettype($arg) != 'array') {
+ $retVal[] = $arg;
+ }
+ }
+
+ return $retVal;
+ }
+ private function checkIsArgsSetHelper(): bool {
+ $missingMandatory = [];
+
+ foreach ($this->commandArgs as $argObj) {
+ if (!$argObj->isOptional() && $argObj->getValue() === null && $argObj->getDefault() != '') {
+ $argObj->setValue($argObj->getDefault());
+ } else if (!$argObj->isOptional() && $argObj->getValue() === null) {
+ $missingMandatory[] = $argObj->getName();
+ }
+ }
+
+ if (count($missingMandatory) != 0) {
+ $missingStr = 'The following required argument(s) are missing: ';
+ $comma = '';
+
+ foreach ($missingMandatory as $opt) {
+ $missingStr .= $comma."'".$opt."'";
+ $comma = ', ';
+ }
+ $this->error($missingStr);
+
+ return false;
+ }
+
+ return true;
+ }
+ private function checkSelectedChoice(array $choices, int $defaultIndex, string $input): ?string {
+ $retVal = null;
+
+ if (in_array($input, $choices)) {
+ //Given input is exactly same as one of choices
+ $retVal = $input;
+ } else if (strlen($input) == 0 && $defaultIndex !== null) {
+ //Given input is empty string (enter hit).
+ //Return default if specified.
+ $retVal = $this->getDefaultChoiceHelper($choices, $defaultIndex);
+ } else if (InputValidator::isInt($input)) {
+ //Selected option is an index. Search for it and return its value.
+ $retVal = $this->getChoiceAtIndex($choices, (int)$input);
+ }
+
+ if ($retVal === null) {
+ $this->error('Invalid answer.');
+ }
+
+ return $retVal;
+ }
+ private function getChoiceAtIndex(array $choices, int $input): ?string {
+ $index = 0;
+
+ foreach ($choices as $choice) {
+ if ($index == $input) {
+ return $choice;
+ }
+ $index++;
+ }
+
+ return null;
+ }
+ private function getDefaultChoiceHelper(array $choices, int $defaultIndex): ?string {
+ $index = 0;
+
+ foreach ($choices as $choice) {
+ if ($index == $defaultIndex) {
+ return $choice;
+ }
+ $index++;
+ }
+
+ return null;
+ }
+ /**
+ * Validate user input and show error message if user input is invalid.
+ * @param string $input
+ * @param InputValidator|null $validator
+ * @param string|null $default
+ * @return array The method will return an array with two indices, 'valid' and
+ * 'value'. The 'valid' index contains a boolean that is set to true if the
+ * value is valid. The index 'value' will contain the passed value.
+ */
+ private function getInputHelper(string &$input, ?InputValidator $validator = null, ?string $default = null) : array {
+ $retVal = [
+ 'valid' => true
+ ];
+
+ if (strlen($input) == 0 && $default !== null) {
+ $input = $default;
+ } else if ($validator !== null) {
+ $retVal['valid'] = $validator->isValid($input);
+
+ if (!($retVal['valid'] === true)) {
+ $this->error($validator->getErrPrompt());
+ }
+ }
+ $retVal['value'] = $input;
+
+ return $retVal;
+ }
+
+ /**
+ * Get terminal width for responsive table display.
+ *
+ * @return int Terminal width in characters, defaults to 80 if unable to detect.
+ */
+ private function getTerminalWidth(): int {
+ // Try to get terminal width using tput
+ $width = @exec('tput cols 2>/dev/null');
+
+ if (is_numeric($width) && $width > 0) {
+ return (int)$width;
+ }
+
+ // Try environment variable
+ $width = getenv('COLUMNS');
+
+ if ($width !== false && is_numeric($width) && $width > 0) {
+ return (int)$width;
+ }
+
+ // Try using stty
+ $width = @exec('stty size 2>/dev/null | cut -d" " -f2');
+
+ if (is_numeric($width) && $width > 0) {
+ return (int)$width;
+ }
+
+ // Default fallback
+ return 80;
+ }
+ private function parseArgsHelper() : bool {
+ $options = $this->getArgs();
+ $invalidArgsVals = [];
+
+ foreach ($options as $argObj) {
+ $val = $this->getArgValue($argObj->getName());
+
+ if ($val !== null && !$argObj->setValue($val)) {
+ $invalidArgsVals[] = $argObj->getName();
+ }
+ }
+
+ if (count($invalidArgsVals) != 0) {
+ $invalidStr = 'The following argument(s) have invalid values: ';
+ $comma = '';
+
+ foreach ($invalidArgsVals as $argName) {
+ $invalidStr .= $comma."'".$argName."'";
+ $comma = ', ';
+ }
+ $this->error($invalidStr);
+
+ foreach ($invalidArgsVals as $argName) {
+ $this->info("Allowed values for the argument '$argName':");
+
+ foreach ($this->getArg($argName)->getAllowedValues() as $val) {
+ $this->println($val);
+ }
+ }
+
+ return false;
+ }
+
+ return true;
+ }
+ private function printChoices(array $choices, int $default): void {
+ $index = 0;
+
+ foreach ($choices as $choiceTxt) {
+ if ($default !== null && $index == $default) {
+ $this->prints($index.": ".$choiceTxt, [
+ 'color' => 'light-blue',
+ 'bold' => 'true'
+ ]);
+ $this->println(' <--');
+ } else {
+ $this->println($index.": ".$choiceTxt);
+ }
+ $index++;
+ }
+ }
+ private function printMsg(string $msg, string $prefix, string $color) {
+ $this->prints("$prefix: ", [
+ 'color' => $color,
+ 'bold' => true,
+
+ ]);
+ $this->println($msg);
+ }
+ /**
+ * Reads a line from input stream with character masking.
+ *
+ * This method reads input character by character and displays mask characters
+ * instead of the actual input. It handles backspace for character deletion
+ * and ignores special keys like ESC and arrow keys.
+ *
+ * @param string $mask The character to display instead of actual input characters.
+ *
+ * @return string The actual input string (unmasked).
+ *
+ * @since 1.1.0
+ */
+ private function readMaskedLine(string $mask = '*'): string {
+ $input = '';
+
+ // For testing with ArrayInputStream, read the whole line at once
+ if ($this->getInputStream() instanceof Streams\ArrayInputStream) {
+ $input = $this->getInputStream()->readLine();
+ // Simulate masking output for testing
+ $this->prints(str_repeat($mask, strlen($input)));
+ $this->println();
+
+ return $input;
+ }
+
+ // Set terminal to raw mode with echo disabled for real-time character reading
+ $sttyMode = null;
+
+ if (function_exists('shell_exec') && PHP_OS_FAMILY !== 'Windows') {
+ $sttyMode = shell_exec('stty -g 2>/dev/null');
+ shell_exec('stty -echo -icanon 2>/dev/null');
+ }
+
+ try {
+ // For real terminal input, read character by character
+ while (true) {
+ $char = KeysMap::readAndTranslate($this->getInputStream());
+
+ if ($char === 'LF' || $char === 'CR' || $char === '') {
+ break;
+ } elseif ($char === 'BACKSPACE' && strlen($input) > 0) {
+ $input = substr($input, 0, -1);
+ $this->prints("\x08 \x08"); // Backspace, space, backspace
+ } elseif ($char !== 'BACKSPACE' && $char !== 'ESC' && $char !== 'DOWN' && $char !== 'UP' && $char !== 'LEFT' && $char !== 'RIGHT') {
+ $input .= $char === 'SPACE' ? ' ' : $char;
+ $this->prints($mask);
+ }
+ }
+ } finally {
+ // Restore terminal settings
+ if ($sttyMode !== null) {
+ shell_exec('stty '.$sttyMode.' 2>/dev/null');
+ }
+ }
+
+ $this->println();
+
+ return $input;
+ }
+}
diff --git a/WebFiori/Cli/Runner.php b/WebFiori/Cli/Runner.php
index 56c2b7c..52275a3 100644
--- a/WebFiori/Cli/Runner.php
+++ b/WebFiori/Cli/Runner.php
@@ -1,1173 +1,1288 @@
-commands = [];
- $this->aliases = [];
- $this->globalArgs = [];
- $this->argsV = [];
- $this->isInteractive = false;
- $this->isAnsi = false;
- $this->inputStream = new StdIn();
- $this->outputStream = new StdOut();
- $this->commandExitVal = 0;
- $this->afterRunPool = [];
-
- // Initialize discovery properties
- $this->commandDiscovery = null;
- $this->autoDiscoveryEnabled = false;
- $this->commandsDiscovered = false;
-
- $this->addArg('--ansi', [
- ArgumentOption::OPTIONAL => true,
- ArgumentOption::DESCRIPTION => 'Force the use of ANSI output.'
- ]);
- $this->setBeforeStart(function (Runner $r) {
- if (count($r->getArgsVector()) == 0) {
- $r->setArgsVector($_SERVER['argv']);
- }
- $r->checkIsInteractive();
- });
- $this->register(new HelpCommand(), ['-h']);
- $this->setDefaultCommand('help');
- }
-
- /**
- * Adds a global command argument.
- *
- * An argument is a string that comes after the name of the command. The value
- * of an argument can be set using equal sign. For example, if command name
- * is 'do-it' and one argument has the name 'what-to-do', then the full
- * CLI command would be "do-it what-to-do=say-hi". An argument can be
- * also treated as an option.
- *
- * @param string $name The name of the argument. It must be non-empty string
- * and does not contain spaces.
- *
- * @param array $options An optional array of options. Available options are:
- *
- * - optional: A boolean. if set to true, it means that the argument
- * is optional and can be ignored when running the command.
- * - default: An optional default value for the argument
- * to use if it is not provided and is optional.
- * - description: A description of the argument which
- * will be shown if the command 'help' is executed.
- * - values: A set of values that the argument can have. If provided,
- * only the values on the list will be allowed. Note that if null or empty string
- * is in the array, it will be ignored. Also, if boolean values are
- * provided, true will be converted to the string 'y' and false will
- * be converted to the string 'n'.
- *
- *
- * @return bool If the argument is added, the method will return true.
- * Other than that, the method will return false.
- *
- */
- public function addArg(string $name, array $options = []): bool {
- $toAdd = Argument::create($name, $options);
-
- if ($toAdd === null) {
- return false;
- }
-
- return $this->addArgument($toAdd);
- }
-
- /**
- * Adds an argument to the set of global arguments.
- *
- * Global arguments are set of arguments that will be added automatically
- * to any command which is registered by the runner.
- *
- * @param Argument $arg An object that holds argument info.
- *
- * @return bool If the argument is added, the method will return true.
- * Other than that, false is returned.
- */
- public function addArgument(Argument $arg): bool {
- if (!$this->hasArg($arg->getName())) {
- $this->globalArgs[] = $arg;
-
- return true;
- }
-
- return false;
- }
-
- /**
- * Add a directory path to search for commands.
- *
- * @param string $path Directory path to search
- * @return Runner
- */
- public function addDiscoveryPath(string $path): Runner {
- $this->enableAutoDiscovery();
- $this->commandDiscovery->addSearchPath($path);
-
- return $this;
- }
-
- /**
- * Add multiple discovery paths.
- *
- * @param array $paths Array of directory paths
- * @return Runner
- */
- public function addDiscoveryPaths(array $paths): Runner {
- $this->enableAutoDiscovery();
- $this->commandDiscovery->addSearchPaths($paths);
-
- return $this;
- }
-
- /**
- * Auto-register commands from a directory (convenience method).
- *
- * @param string $path Directory path to search
- * @param array $excludePatterns Optional exclude patterns
- * @return Runner
- */
- public function autoRegister(string $path, array $excludePatterns = []): Runner {
- return $this->addDiscoveryPath($path)
- ->excludePatterns($excludePatterns)
- ->discoverCommands();
- }
-
- /**
- * Clear discovery cache.
- *
- * @return Runner
- */
- public function clearDiscoveryCache(): Runner {
- if ($this->commandDiscovery !== null) {
- $this->commandDiscovery->getCache()->clear();
- }
-
- return $this;
- }
-
- /**
- * Disable auto-discovery of commands.
- *
- * @return Runner
- */
- public function disableAutoDiscovery(): Runner {
- $this->autoDiscoveryEnabled = false;
-
- return $this;
- }
-
- /**
- * Disable discovery caching.
- *
- * @return Runner
- */
- public function disableDiscoveryCache(): Runner {
- if ($this->commandDiscovery !== null) {
- $this->commandDiscovery->getCache()->setEnabled(false);
- }
-
- return $this;
- }
-
- /**
- * Discover and register commands from configured paths.
- *
- * @return Runner
- */
- public function discoverCommands(): Runner {
- if (!$this->autoDiscoveryEnabled || $this->commandsDiscovered) {
- return $this;
- }
-
- $discoveredCommands = $this->commandDiscovery->discover();
-
- foreach ($discoveredCommands as $command) {
- // Check if command implements AutoDiscoverable
- if ($command instanceof AutoDiscoverable && !$command::shouldAutoRegister()) {
- continue;
- }
-
- $this->register($command);
- }
-
- $this->commandsDiscovered = true;
-
- return $this;
- }
-
- /**
- * Enable auto-discovery of commands.
- *
- * @return Runner
- */
- public function enableAutoDiscovery(): Runner {
- $this->autoDiscoveryEnabled = true;
-
- if ($this->commandDiscovery === null) {
- $this->commandDiscovery = new CommandDiscovery();
- }
-
- return $this;
- }
-
- /**
- * Enable discovery caching.
- *
- * @param string $cacheFile Optional cache file path
- * @return Runner
- */
- public function enableDiscoveryCache(string $cacheFile = 'cache/commands.json'): Runner {
- $this->enableAutoDiscovery();
- $this->commandDiscovery->getCache()->setEnabled(true);
- $this->commandDiscovery->getCache()->setCacheFile($cacheFile);
-
- return $this;
- }
-
- /**
- * Add a pattern to exclude files/directories from discovery.
- *
- * @param string $pattern Glob pattern to exclude
- * @return Runner
- */
- public function excludePattern(string $pattern): Runner {
- $this->enableAutoDiscovery();
- $this->commandDiscovery->excludePattern($pattern);
-
- return $this;
- }
-
- /**
- * Add multiple exclude patterns.
- *
- * @param array $patterns Array of glob patterns
- * @return Runner
- */
- public function excludePatterns(array $patterns): Runner {
- $this->enableAutoDiscovery();
- $this->commandDiscovery->excludePatterns($patterns);
-
- return $this;
- }
-
- /**
- * Returns the command which is being executed.
- *
- * @return Command|null If a command is requested and currently in execute
- * stage, the method will return it as an object. If
- * no command is active, the method will return null.
- *
- */
- public function getActiveCommand(): ?Command {
- return $this->activeCommand;
- }
-
- /**
- * Resolve alias conflict interactively by prompting the user.
- *
- * @param string $alias The conflicting alias.
- * @param string $existingCommand The existing command that uses the alias.
- * @param string $newCommand The new command trying to use the alias.
- *
- * @return string The command name chosen by the user.
- * /**
- * Get all registered aliases.
- *
- * @return array An associative array where keys are aliases and values are command names.
- */
- public function getAliases(): array {
- return $this->aliases;
- }
-
- /**
- * Returns an array that contains objects that represents global arguments.
- *
- * @return array An array that contains objects that represents global arguments.
- */
- public function getArgs(): array {
- return $this->globalArgs;
- }
-
- /**
- * Returns an array that contains arguments vector values.
- *
- * @return array Each index will have one part of arguments vector.
- */
- public function getArgsVector(): array {
- return $this->argsV;
- }
-
- /**
- * Returns a registered command given its name.
- *
- * @param string $name The name of the command as specified when it was
- * initialized.
- *
- * @return Command|null If the command is registered, it is returned
- * as an object. Other than that, null is returned.
- */
- public function getCommandByName(string $name): ?Command {
- // First check if it's a direct command name
- if (isset($this->getCommands()[$name])) {
- return $this->getCommands()[$name];
- }
-
- // Then check if it's an alias
- if (isset($this->aliases[$name])) {
- $commandName = $this->aliases[$name];
-
- if (isset($this->getCommands()[$commandName])) {
- return $this->getCommands()[$commandName];
- }
- }
-
- return null;
- }
-
- /**
- * Get the command discovery instance.
- *
- * @return CommandDiscovery|null
- */
- public function getCommandDiscovery(): ?CommandDiscovery {
- return $this->commandDiscovery;
- }
-
- /**
- * Returns an associative array of registered commands.
- *
- * @return array The method will return an associative array.
- * The keys of the array are the names of the commands and the value of the key is
- * an object that holds command information.
- *
- */
- public function getCommands(): array {
- return $this->commands;
- }
-
- /**
- * Return the command which will get executed in case no command name
- * was provided as argument.
- *
- * @return Command|null If set, it will be returned as object.
- * Other than that, null is returned.
- */
- public function getDefaultCommand(): ?Command {
- return $this->defaultCommand;
- }
-
- /**
- * Get discovery cache instance.
- *
- * @return CommandCache|null
- */
- public function getDiscoveryCache(): ?CommandCache {
- return $this->commandDiscovery?->getCache();
- }
-
- /**
- * Returns the stream at which the engine is using to get inputs.
- *
- * @return InputStream The default input stream is 'StdIn'.
- */
- public function getInputStream(): InputStream {
- return $this->inputStream;
- }
-
- /**
- * Returns exit status code of last executed command.
- *
- * @return int For success run, the method should return 0. Other than that,
- * it means the command was executed with an error.
- */
- public function getLastCommandExitStatus(): int {
- return $this->commandExitVal;
- }
-
- /**
- * Returns an array that contain all generated output by executing a command.
- *
- * This method should be only used when testing the execution process of a
- * command The method will return empty array if output stream type
- * is not ArrayOutputStream.
- *
- * @return array An array that contains all output lines which are generated
- * by executing a specific command.
- */
- public function getOutput(): array {
- $outputStream = $this->getOutputStream();
-
- if ($outputStream instanceof ArrayOutputStream) {
- return $outputStream->getOutputArray();
- }
-
- return [];
- }
-
- /**
- * Returns the stream at which the engine is using to send outputs.
- *
- * @return OutputStream The default input stream is 'StdOut'.
- */
- public function getOutputStream(): OutputStream {
- return $this->outputStream;
- }
-
- /**
- * Check if an alias is registered.
- *
- * @param string $alias The alias to check.
- *
- * @return bool True if the alias exists, false otherwise.
- */
- public function hasAlias(string $alias): bool {
- return isset($this->aliases[$alias]);
- }
-
- /**
- * Checks if the runner has specific global argument or not given its name.
- *
- * @param string $name The name of the argument.
- *
- * @return bool If the runner has such argument, true is returned. Other than
- * that, false is returned.
- */
- public function hasArg(string $name): bool {
- foreach ($this->getArgs() as $argObj) {
- if ($argObj->getName() == $name) {
- return true;
- }
- }
-
- return false;
- }
-
- /**
- * Check if auto-discovery is enabled.
- *
- * @return bool
- */
- public function isAutoDiscoveryEnabled(): bool {
- return $this->autoDiscoveryEnabled;
- }
-
- /**
- * Checks if the class is running through command line interface (CLI) or
- * through a web server.
- *
- * @return bool If the class is running through a command line,
- * the method will return true. False if not.
- *
- */
- public static function isCLI(): bool {
- //best way to check if app is running through CLi
- // or in a web server.
- // Did a lot of research on that.
- return http_response_code() === false;
- }
-
- /**
- * Checks if CLI is running in interactive mode or not.
- *
- * @return bool If CLI is running in interactive mode, the method will
- * return true. False otherwise.
- *
- */
- public function isInteractive(): bool {
- return $this->isInteractive;
- }
-
- /**
- * Register new command.
- *
- * @param Command $cliCommand The command that will be registered.
- *
- * @return Runner The method will return the instance at which the method
- * is called on
- *
- */
- public function register(Command $cliCommand, array $aliases = []): Runner {
- if ($cliCommand->getName() != 'help') {
- $helpCommand = $this->getCommandByName('help');
- if ($helpCommand !== null) {
- $cliCommand->addArg($helpCommand->getName(), [
- ArgumentOption::OPTIONAL => true,
- ArgumentOption::DESCRIPTION => 'Display command help.'
- ]);
-
- foreach ($helpCommand->getAliases() as $alias) {
- $cliCommand->addArg($alias, [
- ArgumentOption::OPTIONAL => true
- ]);
- }
- }
- }
- $this->commands[$cliCommand->getName()] = $cliCommand;
-
- // Register runtime aliases
- foreach ($aliases as $alias) {
- $this->registerAlias($alias, $cliCommand->getName());
- }
-
- // Register built-in aliases from command itself
- foreach ($cliCommand->getAliases() as $alias) {
- $this->registerAlias($alias, $cliCommand->getName());
- }
-
- return $this;
- }
-
- /**
- * Removes an argument from the global args set given its name.
- *
- * @param string $name The name of the argument that will be removed.
- *
- * @return bool If removed, true is returned. Other than that, false is
- * returned.
- */
- public function removeArgument(string $name): bool {
- $removed = false;
- $temp = [];
-
- foreach ($this->getArgs() as $arg) {
- if ($arg->getName() !== $name) {
- $temp[] = $arg;
- } else {
- $removed = true;
- }
- }
- $this->globalArgs = $temp;
-
- return $removed;
- }
-
- /**
- * Reset input stream, output stream and, registered commands to default.
- *
- * @return Runner The method will return the instance at which the method
- * is called on
- */
- public function reset(): Runner {
- $this->inputStream = new StdIn();
- $this->outputStream = new StdOut();
- $this->commands = [];
- $this->aliases = [];
-
- // Re-register help command after reset
- $this->register(new HelpCommand());
-
- return $this;
- }
-
- /**
- * Get the command name for a given alias.
- *
- * @param string $alias The alias to resolve.
- *
- * @return string|null The command name if alias exists, null otherwise.
- */
- public function resolveAlias(string $alias): ?string {
- return $this->aliases[$alias] ?? null;
- }
-
- /**
- * Executes a command given as object.
- *
- * @param Command $c The command that will be executed. If null is given,
- * the method will take command name from the array '$args'.
- *
- * @param array $args An optional array that can hold command arguments.
- * The keys of the array should be arguments names and the value of each index
- * is the value of the argument. Note that if the first parameter of the
- * method is null, the first index of the array should hold
- * the name of the command that will be executed.
- *
- * @param bool $ansi If set to true, then the output will render with ANSI escape sequences.
- *
- * @return int The method will return an integer that represents exit status of
- * running the command. Usually, if the command exit with a number other than 0,
- * it means that there was an error in execution.
- */
- public function runCommand(?Command $c = null, array $args = [], bool $ansi = false): int {
- $commandName = null;
-
- if ($c === null) {
- if (count($args) == 0) {
- $c = $this->getDefaultCommand();
- } else {
- if (isset($args[0])) {
- $commandName = filter_var($args[0]);
-
- $c = $this->getCommandByName($commandName);
- } else {
- $c = $this->getDefaultCommand();
- }
- }
-
- if ($c === null) {
- if ($commandName == null) {
- $this->printMsg("No command was specified to run.", 'Info:', 'blue');
-
- return 0;
- } else {
- $this->printMsg("The command '".$commandName."' is not supported.", 'Error:', 'red');
- $this->commandExitVal = -1;
-
- return -1;
- }
- }
- }
-
- if ($ansi) {
- $args[] = '--ansi';
- }
- $this->setArgV($args);
- $this->setActiveCommand($c);
-
- try {
- $this->commandExitVal = $c->excCommand();
- } catch (Throwable $ex) {
- $this->printMsg('An exception was thrown.', 'Error:', 'red');
- $this->printMsg($ex->getMessage(), 'Exception Message:', 'yellow');
- $this->printMsg((string)$ex->getCode(), 'Code:', 'yellow');
- $this->printMsg($ex->getFile(), 'At:', 'yellow');
- $this->printMsg((string)$ex->getLine(), 'Line:', 'yellow');
- $this->printMsg("\n", 'Stack Trace:', 'yellow');
- $this->printMsg("\n".$ex->getTraceAsString());
- $this->commandExitVal = $ex->getCode() == 0 ? -1 : $ex->getCode();
- }
-
- $this->invokeAfterExc();
- $this->setActiveCommand();
-
- return $this->commandExitVal;
- }
-
- /**
- * Execute a registered command using a sub-runner.
- *
- * This method can be used to execute a registered command using another
- * runner instance which shares argsv, input and output streams with the
- * main runner. It can be used to invoke another command from within a
- * running command.
- *
- * @param string $commandName The name of the command. It must be a part of
- * registered commands.
- *
- * @param array $additionalArgs An associative array that represents additional arguments
- * to be passed to the command.
- *
- * @return int The method will return an integer that represent exit status
- * code of the command after execution.
- */
- public function runCommandAsSub(string $commandName, array $additionalArgs = []): int {
- $c = $this->getCommandByName($commandName);
-
- if ($c === null) {
- return -1;
- }
- $subRunner = new Runner();
- $subRunner->setInputStream($this->getInputStream());
- $subRunner->setOutputStream($this->getOutputStream());
- $subRunner->register($c);
- $args = $this->getArgsVector();
- $args[0] = $commandName;
- $code = $subRunner->runCommand(null, array_merge($args, $additionalArgs), $this->isAnsi);
-
- if ($code != 0) {
- if ($this->getActiveCommand() !== null) {
- $this->getActiveCommand()->warning('Command "'.$commandName.'" exited with code '.$code.'.');
- }
- }
-
- return $code;
- }
-
- /**
- * Sets the command which is currently in execution stage.
- *
- * This method is used internally by execution engine to set the command which
- * is being executed.
- *
- * @param Command $c The command which is in execution stage.
- *
- * @return Runner The method will return the instance at which the method
- * is called on
- */
- public function setActiveCommand(?Command $c = null): Runner {
- if ($this->getActiveCommand() !== null) {
- $this->getActiveCommand()->setOwner();
- }
- $this->activeCommand = $c;
-
- if ($this->getActiveCommand() !== null) {
- $this->getActiveCommand()->setOutputStream($this->getOutputStream());
- $this->getActiveCommand()->setInputStream($this->getInputStream());
- $this->getActiveCommand()->setOwner($this);
- }
-
- return $this;
- }
-
- /**
- * Add a function to execute after every command.
- *
- * The method can be used to set multiple callbacks.
- *
- * @param callable $func The function that will be executed after the
- * completion of command execution. The first parameter of the method
- * will always be an instance of 'Runner' (e.g. function (Runner $runner){}).
- *
- * @param array $params Any additional parameters that will be passed to the
- * callback.
- *
- * @return Runner The method will return the instance at which the method
- * is called on
- */
- public function setAfterExecution(callable $func, array $params = []): Runner {
- $this->afterRunPool[] = [
- 'func' => $func,
- 'params' => $params
- ];
-
- return $this;
- }
-
- /**
- * Sets arguments vector to have specific value.
- *
- * This method is mainly used to simulate running the class using an
- * actual terminal. Also, it can be used to set up the test run parameters
- * for testing a command.
- *
- * @param array $argsVector An array that contains arguments vector. Usually,
- * the first argument of the vector is the entry point (such as app.php).
- * The second argument is the name of the command that will get executed
- * and, remaining parts are any additional arguments that the command
- * might use.
- *
- * @return Runner The method will return the instance at which the method
- * is called on
- */
- public function setArgsVector(array $argsVector): Runner {
- $this->argsV = $argsVector;
-
- return $this;
- }
-
- /**
- * Sets a callable to call before start running CLI engine.
- *
- * This can be used to register custom-made commands before running
- * the engine.
- *
- * @param callable $func An executable function. The function will have
- * one parameter which is the runner that the function will be added to.
- *
- * @return Runner The method will return the instance at which the method
- * is called on
- */
- public function setBeforeStart(callable $func): Runner {
- $this->beforeStartPool[] = $func;
-
- return $this;
- }
-
- /**
- * Set a custom command discovery instance.
- *
- * @param CommandDiscovery $discovery
- * @return Runner
- */
- public function setCommandDiscovery(CommandDiscovery $discovery): Runner {
- $this->commandDiscovery = $discovery;
- $this->autoDiscoveryEnabled = true;
-
- return $this;
- }
-
- /**
- * Sets the default command that will be executed in case no command
- * name was provided as argument.
- *
- * @param string $commandName The name of the command that will be set as
- * default command. Note that it must be a registered command.
- *
- * @return Runner The method will return the instance at which the method
- * is called on
- */
- public function setDefaultCommand(string $commandName): Runner {
- $c = $this->getCommandByName($commandName);
-
- if ($c !== null) {
- $this->defaultCommand = $c;
- }
-
- return $this;
- }
-
- /**
- * Enable or disable strict mode for discovery.
- *
- * @param bool $strict
- * @return Runner
- */
- public function setDiscoveryStrictMode(bool $strict): Runner {
- $this->enableAutoDiscovery();
- $this->commandDiscovery->setStrictMode($strict);
-
- return $this;
- }
-
- /**
- * Sets an array as an input for running specific command.
- *
- * This method is used to test the execution process of specific command.
- * The developer can use it to mimic the inputs which could be provided
- * by the user when actually running the command through a terminal.
- * The developer can use the method 'Runner::getOutput()' to get generated
- * output and compare it with expected output.
- *
- * Note that this method will set the input stream to 'ArrayInputStream'
- * and output stream to 'ArrayOutputStream'.
- *
- * @param array $inputs An array that contain lines of inputs.
- *
- * @return Runner The method will return the instance at which the method
- * is called on
- */
- public function setInputs(array $inputs = []): Runner {
- $this->setInputStream(new ArrayInputStream($inputs));
- $this->setOutputStream(new ArrayOutputStream());
-
- return $this;
- }
-
- /**
- * Sets the stream at which the runner will be using to read inputs from.
- *
- * @param InputStream $stream The new stream that will hold inputs.
- *
- * @return Runner The method will return the instance at which the method
- * is called on
- */
- public function setInputStream(InputStream $stream): Runner {
- $this->inputStream = $stream;
-
- return $this;
- }
-
- /**
- * Sets the stream at which the runner will be using to send outputs to.
- *
- * @param OutputStream $stream The new stream that will hold inputs.
- *
- * @return Runner The method will return the instance at which the method
- * is called on
- */
- public function setOutputStream(OutputStream $stream): Runner {
- $this->outputStream = $stream;
-
- return $this;
- }
-
- /**
- * Start command line process.
- *
- * @return int The method will return an integer that represents exit status of
- * the process. Usually, if the process exit with a number other than 0,
- * it means that there was an error in execution.
- */
- public function start(): int {
- foreach ($this->beforeStartPool as $func) {
- call_user_func_array($func, [$this]);
- }
-
- if ($this->isInteractive()) {
- $this->isAnsi = in_array('--ansi', $this->getArgsVector());
- $this->printMsg('Running in interactive mode.', '>>', 'blue');
- $this->printMsg("Type command name or 'exit' to close.", ">>", 'blue');
- $this->printMsg('', '>>', 'blue');
-
- while (true) {
- $args = $this->readInteractive();
- $this->setArgsVector($args);
- $argsCount = count($args);
-
- if ($argsCount == 0) {
- $this->getOutputStream()->println('No input.');
- } else {
- if ($args[0] == 'exit') {
- return 0;
- } else {
- $this->runCommand(null, $args, $this->isAnsi);
- }
- }
- $this->printMsg('', '>>', 'blue');
- }
- } else {
- return $this->run();
- }
- }
-
- private function checkIsInteractive(): void {
- foreach ($this->getArgsVector() as $arg) {
- $this->isInteractive = $arg == '-i' || $this->isInteractive;
- }
- }
-
- private function invokeAfterExc(): void {
- foreach ($this->afterRunPool as $funcArr) {
- call_user_func_array($funcArr['func'], array_merge([$this], $funcArr['params']));
- }
- }
-
- private function printMsg(string $msg, ?string $prefix = null, ?string $color = null): void {
- if ($prefix !== null) {
- $prefix = Formatter::format($prefix, [
- 'color' => $color,
- 'bold' => true,
- 'ansi' => $this->isAnsi
- ]);
- $this->getOutputStream()->prints("$prefix ");
- }
-
- if (strlen($msg) != 0) {
- $this->getOutputStream()->println($msg);
- }
- }
-
- private function readInteractive(): array {
- $input = trim($this->getInputStream()->readLine());
-
- $argsArr = strlen($input) != 0 ? explode(' ', $input) : [];
-
- if (in_array('--ansi', $argsArr)) {
- $argsArr = array_diff($argsArr, ['--ansi']);
- }
-
- // Preprocess help patterns
- $argsArr = $this->preprocessHelpPattern($argsArr);
-
- return $argsArr;
- }
-
- /**
- * Register an alias for a command.
- *
- * @param string $alias The alias to register.
- * @param string $commandName The name of the command the alias points to.
- *
- * @return Runner The method will return the instance at which the method
- * is called on
- */
- private function registerAlias(string $alias, string $commandName): Runner {
- // Check for conflicts
- if (isset($this->aliases[$alias])) {
- $existingCommand = $this->aliases[$alias];
-
- if ($this->isInteractive()) {
- // Interactive mode: prompt user to choose
- $choice = $this->resolveAliasConflictInteractively($alias, $existingCommand, $commandName);
-
- if ($choice === $commandName) {
- $this->aliases[$alias] = $commandName;
- }
- // If user chose existing command, do nothing
- } else {
- // Non-interactive mode: use first-come-first-served (do nothing)
- // Suppress warning if both existing and new command are 'help' (expected duplicate registration)
- if (!($existingCommand === 'help' && $commandName === 'help')) {
- $this->printMsg("Alias '$alias' already exists for command '$existingCommand'. Ignoring new alias for '$commandName'.", 'Warning:', 'yellow');
- }
- }
- } else {
- // No conflict, register the alias
- $this->aliases[$alias] = $commandName;
- }
-
- return $this;
- }
-
- /**
- * Run the command line as single run.
- *
- * @return int
- */
- private function run(): int {
- $argsArr = array_slice($this->getArgsVector(), 1);
-
- if (in_array('--ansi', $argsArr)) {
- $this->isAnsi = true;
- $tempArgs = [];
-
- foreach ($argsArr as $argName => $val) {
- if (gettype($argName) == 'integer') {
- if ($val != '--ansi') {
- $tempArgs[] = $val;
- }
- } else {
- $tempArgs[$argName] = $val;
- }
- }
- $argsArr = $tempArgs;
- }
-
-
- // Preprocess help patterns for non-interactive mode
- $argsArr = $this->preprocessHelpPattern($argsArr);
- if (count($argsArr) == 0) {
- $command = $this->getDefaultCommand();
-
- return $this->runCommand($command, [], $this->isAnsi);
- }
-
- return $this->runCommand(null, $argsArr, $this->isAnsi);
- }
-
- private function setArgV(array $args): void {
- $argV = [];
-
- foreach ($args as $argName => $argVal) {
- if (gettype($argName) == 'integer') {
- $argV[] = $argVal;
- } else {
- $argV[] = $argName.'='.$argVal;
- }
- }
- $this->argsV = $argV;
- }
- /**
- * Preprocesses arguments to handle help patterns like 'command help' or 'command -h'.
- *
- * @param array $args The arguments array to preprocess
- * @return array The preprocessed arguments array
- */
- private function preprocessHelpPattern(array $args): array {
- if (count($args) >= 2) {
- $lastArg = end($args);
-
- // Check if the last argument is 'help' or '-h'
- if ($lastArg === 'help' || $lastArg === '-h') {
- $commandName = $args[0];
-
- // Check if the first argument is a valid command name
- if ($this->getCommandByName($commandName) !== null) {
- // Remove 'help' or '-h' from the end
- array_pop($args);
- // Add it as a proper argument flag
- $args[] = $lastArg;
- }
- }
- }
-
- return $args;
- }
-}
+commands = [];
+ $this->aliases = [];
+ $this->globalArgs = [];
+ $this->argsV = [];
+ $this->isInteractive = false;
+ $this->isAnsi = false;
+ $this->inputStream = new StdIn();
+ $this->outputStream = new StdOut();
+ $this->commandExitVal = 0;
+ $this->afterRunPool = [];
+ $this->signalHandler = null;
+ $this->shutdownRequested = false;
+
+ // Initialize discovery properties
+ $this->commandDiscovery = null;
+ $this->autoDiscoveryEnabled = false;
+ $this->commandsDiscovered = false;
+
+ $this->addArg('--ansi', [
+ ArgumentOption::OPTIONAL => true,
+ ArgumentOption::DESCRIPTION => 'Force the use of ANSI output.'
+ ]);
+ $this->setBeforeStart(function (Runner $r) {
+ if (count($r->getArgsVector()) == 0) {
+ $r->setArgsVector($_SERVER['argv']);
+ }
+ $r->checkIsInteractive();
+ });
+ $this->register(new HelpCommand(), ['-h']);
+ $this->setDefaultCommand('help');
+ }
+
+ /**
+ * Adds a global command argument.
+ *
+ * An argument is a string that comes after the name of the command. The value
+ * of an argument can be set using equal sign. For example, if command name
+ * is 'do-it' and one argument has the name 'what-to-do', then the full
+ * CLI command would be "do-it what-to-do=say-hi". An argument can be
+ * also treated as an option.
+ *
+ * @param string $name The name of the argument. It must be non-empty string
+ * and does not contain spaces.
+ *
+ * @param array $options An optional array of options. Available options are:
+ *
+ * - optional: A boolean. if set to true, it means that the argument
+ * is optional and can be ignored when running the command.
+ * - default: An optional default value for the argument
+ * to use if it is not provided and is optional.
+ * - description: A description of the argument which
+ * will be shown if the command 'help' is executed.
+ * - values: A set of values that the argument can have. If provided,
+ * only the values on the list will be allowed. Note that if null or empty string
+ * is in the array, it will be ignored. Also, if boolean values are
+ * provided, true will be converted to the string 'y' and false will
+ * be converted to the string 'n'.
+ *
+ *
+ * @return bool If the argument is added, the method will return true.
+ * Other than that, the method will return false.
+ *
+ */
+ public function addArg(string $name, array $options = []): bool {
+ $toAdd = Argument::create($name, $options);
+
+ if ($toAdd === null) {
+ return false;
+ }
+
+ return $this->addArgument($toAdd);
+ }
+
+ /**
+ * Adds an argument to the set of global arguments.
+ *
+ * Global arguments are set of arguments that will be added automatically
+ * to any command which is registered by the runner.
+ *
+ * @param Argument $arg An object that holds argument info.
+ *
+ * @return bool If the argument is added, the method will return true.
+ * Other than that, false is returned.
+ */
+ public function addArgument(Argument $arg): bool {
+ if (!$this->hasArg($arg->getName())) {
+ $this->globalArgs[] = $arg;
+
+ return true;
+ }
+
+ return false;
+ }
+
+ /**
+ * Add a directory path to search for commands.
+ *
+ * @param string $path Directory path to search
+ * @return Runner
+ */
+ public function addDiscoveryPath(string $path): Runner {
+ $this->enableAutoDiscovery();
+ $this->commandDiscovery->addSearchPath($path);
+
+ return $this;
+ }
+
+ /**
+ * Add multiple discovery paths.
+ *
+ * @param array $paths Array of directory paths
+ * @return Runner
+ */
+ public function addDiscoveryPaths(array $paths): Runner {
+ $this->enableAutoDiscovery();
+ $this->commandDiscovery->addSearchPaths($paths);
+
+ return $this;
+ }
+
+ /**
+ * Auto-register commands from a directory (convenience method).
+ *
+ * @param string $path Directory path to search
+ * @param array $excludePatterns Optional exclude patterns
+ * @return Runner
+ */
+ public function autoRegister(string $path, array $excludePatterns = []): Runner {
+ return $this->addDiscoveryPath($path)
+ ->excludePatterns($excludePatterns)
+ ->discoverCommands();
+ }
+
+ /**
+ * Clear discovery cache.
+ *
+ * @return Runner
+ */
+ public function clearDiscoveryCache(): Runner {
+ if ($this->commandDiscovery !== null) {
+ $this->commandDiscovery->getCache()->clear();
+ }
+
+ return $this;
+ }
+
+ /**
+ * Disable auto-discovery of commands.
+ *
+ * @return Runner
+ */
+ public function disableAutoDiscovery(): Runner {
+ $this->autoDiscoveryEnabled = false;
+
+ return $this;
+ }
+
+ /**
+ * Disable discovery caching.
+ *
+ * @return Runner
+ */
+ public function disableDiscoveryCache(): Runner {
+ if ($this->commandDiscovery !== null) {
+ $this->commandDiscovery->getCache()->setEnabled(false);
+ }
+
+ return $this;
+ }
+
+ /**
+ * Discover and register commands from configured paths.
+ *
+ * @return Runner
+ */
+ public function discoverCommands(): Runner {
+ if (!$this->autoDiscoveryEnabled || $this->commandsDiscovered) {
+ return $this;
+ }
+
+ $discoveredCommands = $this->commandDiscovery->discover();
+
+ foreach ($discoveredCommands as $command) {
+ // Check if command implements AutoDiscoverable
+ if ($command instanceof AutoDiscoverable && !$command::shouldAutoRegister()) {
+ continue;
+ }
+
+ $this->register($command);
+ }
+
+ $this->commandsDiscovered = true;
+
+ return $this;
+ }
+
+ /**
+ * Enable auto-discovery of commands.
+ *
+ * @return Runner
+ */
+ public function enableAutoDiscovery(): Runner {
+ $this->autoDiscoveryEnabled = true;
+
+ if ($this->commandDiscovery === null) {
+ $this->commandDiscovery = new CommandDiscovery();
+ }
+
+ return $this;
+ }
+
+ /**
+ * Enable discovery caching.
+ *
+ * @param string $cacheFile Optional cache file path
+ * @return Runner
+ */
+ public function enableDiscoveryCache(string $cacheFile = 'cache/commands.json'): Runner {
+ $this->enableAutoDiscovery();
+ $this->commandDiscovery->getCache()->setEnabled(true);
+ $this->commandDiscovery->getCache()->setCacheFile($cacheFile);
+
+ return $this;
+ }
+
+ /**
+ * Enables signal handling with default handlers for SIGINT and SIGTERM.
+ *
+ * Default behavior:
+ * - SIGINT (Ctrl+C): In interactive mode, interrupts the current command
+ * but keeps the app running. In non-interactive mode, exits with code 130.
+ * - SIGTERM: Sets shutdown flag and exits with code 143.
+ *
+ * If pcntl is not available (e.g., on Windows), this method creates the
+ * handler instance but signal registration will be a no-op.
+ *
+ * @return Runner The method returns same instance for chaining.
+ */
+ public function enableSignalHandling(): Runner {
+ $this->signalHandler = new SignalHandler();
+ $this->shutdownRequested = false;
+
+ $this->signalHandler->register(defined('SIGINT') ? SIGINT : 2, function (int $signal) {
+ if ($this->isInteractive()) {
+ $this->commandExitVal = 130;
+ $this->getOutputStream()->println('');
+ $this->printMsg('Command interrupted.', '>>', 'yellow');
+ } else {
+ $this->commandExitVal = 130;
+ $this->shutdownRequested = true;
+ }
+ });
+
+ $this->signalHandler->register(defined('SIGTERM') ? SIGTERM : 15, function (int $signal) {
+ $this->commandExitVal = 143;
+ $this->shutdownRequested = true;
+ });
+
+ $this->signalHandler->enable();
+
+ return $this;
+ }
+
+ /**
+ * Add a pattern to exclude files/directories from discovery.
+ *
+ * @param string $pattern Glob pattern to exclude
+ * @return Runner
+ */
+ public function excludePattern(string $pattern): Runner {
+ $this->enableAutoDiscovery();
+ $this->commandDiscovery->excludePattern($pattern);
+
+ return $this;
+ }
+
+ /**
+ * Add multiple exclude patterns.
+ *
+ * @param array $patterns Array of glob patterns
+ * @return Runner
+ */
+ public function excludePatterns(array $patterns): Runner {
+ $this->enableAutoDiscovery();
+ $this->commandDiscovery->excludePatterns($patterns);
+
+ return $this;
+ }
+
+ /**
+ * Returns the command which is being executed.
+ *
+ * @return Command|null If a command is requested and currently in execute
+ * stage, the method will return it as an object. If
+ * no command is active, the method will return null.
+ *
+ */
+ public function getActiveCommand(): ?Command {
+ return $this->activeCommand;
+ }
+
+ /**
+ * Resolve alias conflict interactively by prompting the user.
+ *
+ * @param string $alias The conflicting alias.
+ * @param string $existingCommand The existing command that uses the alias.
+ * @param string $newCommand The new command trying to use the alias.
+ *
+ * @return string The command name chosen by the user.
+ * /**
+ * Get all registered aliases.
+ *
+ * @return array An associative array where keys are aliases and values are command names.
+ */
+ public function getAliases(): array {
+ return $this->aliases;
+ }
+
+ /**
+ * Returns an array that contains objects that represents global arguments.
+ *
+ * @return array An array that contains objects that represents global arguments.
+ */
+ public function getArgs(): array {
+ return $this->globalArgs;
+ }
+
+ /**
+ * Returns an array that contains arguments vector values.
+ *
+ * @return array Each index will have one part of arguments vector.
+ */
+ public function getArgsVector(): array {
+ return $this->argsV;
+ }
+
+ /**
+ * Returns a registered command given its name.
+ *
+ * @param string $name The name of the command as specified when it was
+ * initialized.
+ *
+ * @return Command|null If the command is registered, it is returned
+ * as an object. Other than that, null is returned.
+ */
+ public function getCommandByName(string $name): ?Command {
+ // First check if it's a direct command name
+ if (isset($this->getCommands()[$name])) {
+ return $this->getCommands()[$name];
+ }
+
+ // Then check if it's an alias
+ if (isset($this->aliases[$name])) {
+ $commandName = $this->aliases[$name];
+
+ if (isset($this->getCommands()[$commandName])) {
+ return $this->getCommands()[$commandName];
+ }
+ }
+
+ return null;
+ }
+
+ /**
+ * Get the command discovery instance.
+ *
+ * @return CommandDiscovery|null
+ */
+ public function getCommandDiscovery(): ?CommandDiscovery {
+ return $this->commandDiscovery;
+ }
+
+ /**
+ * Returns an associative array of registered commands.
+ *
+ * @return array The method will return an associative array.
+ * The keys of the array are the names of the commands and the value of the key is
+ * an object that holds command information.
+ *
+ */
+ public function getCommands(): array {
+ return $this->commands;
+ }
+
+ /**
+ * Return the command which will get executed in case no command name
+ * was provided as argument.
+ *
+ * @return Command|null If set, it will be returned as object.
+ * Other than that, null is returned.
+ */
+ public function getDefaultCommand(): ?Command {
+ return $this->defaultCommand;
+ }
+
+ /**
+ * Get discovery cache instance.
+ *
+ * @return CommandCache|null
+ */
+ public function getDiscoveryCache(): ?CommandCache {
+ return $this->commandDiscovery?->getCache();
+ }
+
+ /**
+ * Returns the stream at which the engine is using to get inputs.
+ *
+ * @return InputStream The default input stream is 'StdIn'.
+ */
+ public function getInputStream(): InputStream {
+ return $this->inputStream;
+ }
+
+ /**
+ * Returns exit status code of last executed command.
+ *
+ * @return int For success run, the method should return 0. Other than that,
+ * it means the command was executed with an error.
+ */
+ public function getLastCommandExitStatus(): int {
+ return $this->commandExitVal;
+ }
+
+ /**
+ * Returns an array that contain all generated output by executing a command.
+ *
+ * This method should be only used when testing the execution process of a
+ * command The method will return empty array if output stream type
+ * is not ArrayOutputStream.
+ *
+ * @return array An array that contains all output lines which are generated
+ * by executing a specific command.
+ */
+ public function getOutput(): array {
+ $outputStream = $this->getOutputStream();
+
+ if ($outputStream instanceof ArrayOutputStream) {
+ return $outputStream->getOutputArray();
+ }
+
+ return [];
+ }
+
+ /**
+ * Returns the stream at which the engine is using to send outputs.
+ *
+ * @return OutputStream The default input stream is 'StdOut'.
+ */
+ public function getOutputStream(): OutputStream {
+ return $this->outputStream;
+ }
+
+ /**
+ * Returns the signal handler instance if signal handling has been enabled.
+ *
+ * @return SignalHandler|null The signal handler instance, or null if not enabled.
+ */
+ public function getSignalHandler(): ?SignalHandler {
+ return $this->signalHandler;
+ }
+
+ /**
+ * Check if an alias is registered.
+ *
+ * @param string $alias The alias to check.
+ *
+ * @return bool True if the alias exists, false otherwise.
+ */
+ public function hasAlias(string $alias): bool {
+ return isset($this->aliases[$alias]);
+ }
+
+ /**
+ * Checks if the runner has specific global argument or not given its name.
+ *
+ * @param string $name The name of the argument.
+ *
+ * @return bool If the runner has such argument, true is returned. Other than
+ * that, false is returned.
+ */
+ public function hasArg(string $name): bool {
+ foreach ($this->getArgs() as $argObj) {
+ if ($argObj->getName() == $name) {
+ return true;
+ }
+ }
+
+ return false;
+ }
+
+ /**
+ * Check if auto-discovery is enabled.
+ *
+ * @return bool
+ */
+ public function isAutoDiscoveryEnabled(): bool {
+ return $this->autoDiscoveryEnabled;
+ }
+
+ /**
+ * Checks if the class is running through command line interface (CLI) or
+ * through a web server.
+ *
+ * @return bool If the class is running through a command line,
+ * the method will return true. False if not.
+ *
+ */
+ public static function isCLI(): bool {
+ //best way to check if app is running through CLi
+ // or in a web server.
+ // Did a lot of research on that.
+ return http_response_code() === false;
+ }
+
+ /**
+ * Checks if CLI is running in interactive mode or not.
+ *
+ * @return bool If CLI is running in interactive mode, the method will
+ * return true. False otherwise.
+ *
+ */
+ public function isInteractive(): bool {
+ return $this->isInteractive;
+ }
+
+ /**
+ * Checks if a shutdown has been requested via signal.
+ *
+ * @return bool True if shutdown was requested, false otherwise.
+ */
+ public function isShutdownRequested(): bool {
+ return $this->shutdownRequested;
+ }
+
+ /**
+ * Register new command.
+ *
+ * @param Command $cliCommand The command that will be registered.
+ *
+ * @return Runner The method will return the instance at which the method
+ * is called on
+ *
+ */
+ public function register(Command $cliCommand, array $aliases = []): Runner {
+ if ($cliCommand->getName() != 'help') {
+ $helpCommand = $this->getCommandByName('help');
+
+ if ($helpCommand !== null) {
+ $cliCommand->addArg($helpCommand->getName(), [
+ ArgumentOption::OPTIONAL => true,
+ ArgumentOption::DESCRIPTION => 'Display command help.'
+ ]);
+
+ foreach ($helpCommand->getAliases() as $alias) {
+ $cliCommand->addArg($alias, [
+ ArgumentOption::OPTIONAL => true
+ ]);
+ }
+ }
+ }
+ $this->commands[$cliCommand->getName()] = $cliCommand;
+
+ // Register runtime aliases
+ foreach ($aliases as $alias) {
+ $this->registerAlias($alias, $cliCommand->getName());
+ }
+
+ // Register built-in aliases from command itself
+ foreach ($cliCommand->getAliases() as $alias) {
+ $this->registerAlias($alias, $cliCommand->getName());
+ }
+
+ return $this;
+ }
+
+ /**
+ * Removes an argument from the global args set given its name.
+ *
+ * @param string $name The name of the argument that will be removed.
+ *
+ * @return bool If removed, true is returned. Other than that, false is
+ * returned.
+ */
+ public function removeArgument(string $name): bool {
+ $removed = false;
+ $temp = [];
+
+ foreach ($this->getArgs() as $arg) {
+ if ($arg->getName() !== $name) {
+ $temp[] = $arg;
+ } else {
+ $removed = true;
+ }
+ }
+ $this->globalArgs = $temp;
+
+ return $removed;
+ }
+
+ /**
+ * Reset input stream, output stream and, registered commands to default.
+ *
+ * @return Runner The method will return the instance at which the method
+ * is called on
+ */
+ public function reset(): Runner {
+ $this->inputStream = new StdIn();
+ $this->outputStream = new StdOut();
+ $this->commands = [];
+ $this->aliases = [];
+
+ // Re-register help command after reset
+ $this->register(new HelpCommand());
+
+ return $this;
+ }
+
+ /**
+ * Get the command name for a given alias.
+ *
+ * @param string $alias The alias to resolve.
+ *
+ * @return string|null The command name if alias exists, null otherwise.
+ */
+ public function resolveAlias(string $alias): ?string {
+ return $this->aliases[$alias] ?? null;
+ }
+
+ /**
+ * Executes a command given as object.
+ *
+ * @param Command $c The command that will be executed. If null is given,
+ * the method will take command name from the array '$args'.
+ *
+ * @param array $args An optional array that can hold command arguments.
+ * The keys of the array should be arguments names and the value of each index
+ * is the value of the argument. Note that if the first parameter of the
+ * method is null, the first index of the array should hold
+ * the name of the command that will be executed.
+ *
+ * @param bool $ansi If set to true, then the output will render with ANSI escape sequences.
+ *
+ * @return int The method will return an integer that represents exit status of
+ * running the command. Usually, if the command exit with a number other than 0,
+ * it means that there was an error in execution.
+ */
+ public function runCommand(?Command $c = null, array $args = [], bool $ansi = false): int {
+ $commandName = null;
+
+ if ($c === null) {
+ if (count($args) == 0) {
+ $c = $this->getDefaultCommand();
+ } else {
+ if (isset($args[0])) {
+ $commandName = filter_var($args[0]);
+
+ $c = $this->getCommandByName($commandName);
+ } else {
+ $c = $this->getDefaultCommand();
+ }
+ }
+
+ if ($c === null) {
+ if ($commandName == null) {
+ $this->printMsg("No command was specified to run.", 'Info:', 'blue');
+
+ return 0;
+ } else {
+ $this->printMsg("The command '".$commandName."' is not supported.", 'Error:', 'red');
+ $this->commandExitVal = -1;
+
+ return -1;
+ }
+ }
+ }
+
+ if ($ansi) {
+ $args[] = '--ansi';
+ }
+ $this->setArgV($args);
+ $this->setActiveCommand($c);
+
+ $this->registerCommandSignalHandlers($c);
+
+ try {
+ $this->commandExitVal = $c->excCommand();
+ } catch (Throwable $ex) {
+ $this->printMsg('An exception was thrown.', 'Error:', 'red');
+ $this->printMsg($ex->getMessage(), 'Exception Message:', 'yellow');
+ $this->printMsg((string)$ex->getCode(), 'Code:', 'yellow');
+ $this->printMsg($ex->getFile(), 'At:', 'yellow');
+ $this->printMsg((string)$ex->getLine(), 'Line:', 'yellow');
+ $this->printMsg("\n", 'Stack Trace:', 'yellow');
+ $this->printMsg("\n".$ex->getTraceAsString());
+ $this->commandExitVal = $ex->getCode() == 0 ? -1 : $ex->getCode();
+ }
+
+ $this->removeCommandSignalHandlers($c);
+ $this->invokeAfterExc();
+ $this->setActiveCommand();
+
+ return $this->commandExitVal;
+ }
+
+ /**
+ * Execute a registered command using a sub-runner.
+ *
+ * This method can be used to execute a registered command using another
+ * runner instance which shares argsv, input and output streams with the
+ * main runner. It can be used to invoke another command from within a
+ * running command.
+ *
+ * @param string $commandName The name of the command. It must be a part of
+ * registered commands.
+ *
+ * @param array $additionalArgs An associative array that represents additional arguments
+ * to be passed to the command.
+ *
+ * @return int The method will return an integer that represent exit status
+ * code of the command after execution.
+ */
+ public function runCommandAsSub(string $commandName, array $additionalArgs = []): int {
+ $c = $this->getCommandByName($commandName);
+
+ if ($c === null) {
+ return -1;
+ }
+ $subRunner = new Runner();
+ $subRunner->setInputStream($this->getInputStream());
+ $subRunner->setOutputStream($this->getOutputStream());
+ $subRunner->register($c);
+ $args = $this->getArgsVector();
+ $args[0] = $commandName;
+ $code = $subRunner->runCommand(null, array_merge($args, $additionalArgs), $this->isAnsi);
+
+ if ($code != 0) {
+ if ($this->getActiveCommand() !== null) {
+ $this->getActiveCommand()->warning('Command "'.$commandName.'" exited with code '.$code.'.');
+ }
+ }
+
+ return $code;
+ }
+
+ /**
+ * Sets the command which is currently in execution stage.
+ *
+ * This method is used internally by execution engine to set the command which
+ * is being executed.
+ *
+ * @param Command $c The command which is in execution stage.
+ *
+ * @return Runner The method will return the instance at which the method
+ * is called on
+ */
+ public function setActiveCommand(?Command $c = null): Runner {
+ if ($this->getActiveCommand() !== null) {
+ $this->getActiveCommand()->setOwner();
+ }
+ $this->activeCommand = $c;
+
+ if ($this->getActiveCommand() !== null) {
+ $this->getActiveCommand()->setOutputStream($this->getOutputStream());
+ $this->getActiveCommand()->setInputStream($this->getInputStream());
+ $this->getActiveCommand()->setOwner($this);
+ }
+
+ return $this;
+ }
+
+ /**
+ * Add a function to execute after every command.
+ *
+ * The method can be used to set multiple callbacks.
+ *
+ * @param callable $func The function that will be executed after the
+ * completion of command execution. The first parameter of the method
+ * will always be an instance of 'Runner' (e.g. function (Runner $runner){}).
+ *
+ * @param array $params Any additional parameters that will be passed to the
+ * callback.
+ *
+ * @return Runner The method will return the instance at which the method
+ * is called on
+ */
+ public function setAfterExecution(callable $func, array $params = []): Runner {
+ $this->afterRunPool[] = [
+ 'func' => $func,
+ 'params' => $params
+ ];
+
+ return $this;
+ }
+
+ /**
+ * Sets arguments vector to have specific value.
+ *
+ * This method is mainly used to simulate running the class using an
+ * actual terminal. Also, it can be used to set up the test run parameters
+ * for testing a command.
+ *
+ * @param array $argsVector An array that contains arguments vector. Usually,
+ * the first argument of the vector is the entry point (such as app.php).
+ * The second argument is the name of the command that will get executed
+ * and, remaining parts are any additional arguments that the command
+ * might use.
+ *
+ * @return Runner The method will return the instance at which the method
+ * is called on
+ */
+ public function setArgsVector(array $argsVector): Runner {
+ $this->argsV = $argsVector;
+
+ return $this;
+ }
+
+ /**
+ * Sets a callable to call before start running CLI engine.
+ *
+ * This can be used to register custom-made commands before running
+ * the engine.
+ *
+ * @param callable $func An executable function. The function will have
+ * one parameter which is the runner that the function will be added to.
+ *
+ * @return Runner The method will return the instance at which the method
+ * is called on
+ */
+ public function setBeforeStart(callable $func): Runner {
+ $this->beforeStartPool[] = $func;
+
+ return $this;
+ }
+
+ /**
+ * Set a custom command discovery instance.
+ *
+ * @param CommandDiscovery $discovery
+ * @return Runner
+ */
+ public function setCommandDiscovery(CommandDiscovery $discovery): Runner {
+ $this->commandDiscovery = $discovery;
+ $this->autoDiscoveryEnabled = true;
+
+ return $this;
+ }
+
+ /**
+ * Sets the default command that will be executed in case no command
+ * name was provided as argument.
+ *
+ * @param string $commandName The name of the command that will be set as
+ * default command. Note that it must be a registered command.
+ *
+ * @return Runner The method will return the instance at which the method
+ * is called on
+ */
+ public function setDefaultCommand(string $commandName): Runner {
+ $c = $this->getCommandByName($commandName);
+
+ if ($c !== null) {
+ $this->defaultCommand = $c;
+ }
+
+ return $this;
+ }
+
+ /**
+ * Enable or disable strict mode for discovery.
+ *
+ * @param bool $strict
+ * @return Runner
+ */
+ public function setDiscoveryStrictMode(bool $strict): Runner {
+ $this->enableAutoDiscovery();
+ $this->commandDiscovery->setStrictMode($strict);
+
+ return $this;
+ }
+
+ /**
+ * Sets an array as an input for running specific command.
+ *
+ * This method is used to test the execution process of specific command.
+ * The developer can use it to mimic the inputs which could be provided
+ * by the user when actually running the command through a terminal.
+ * The developer can use the method 'Runner::getOutput()' to get generated
+ * output and compare it with expected output.
+ *
+ * Note that this method will set the input stream to 'ArrayInputStream'
+ * and output stream to 'ArrayOutputStream'.
+ *
+ * @param array $inputs An array that contain lines of inputs.
+ *
+ * @return Runner The method will return the instance at which the method
+ * is called on
+ */
+ public function setInputs(array $inputs = []): Runner {
+ $this->setInputStream(new ArrayInputStream($inputs));
+ $this->setOutputStream(new ArrayOutputStream());
+
+ return $this;
+ }
+
+ /**
+ * Sets the stream at which the runner will be using to read inputs from.
+ *
+ * @param InputStream $stream The new stream that will hold inputs.
+ *
+ * @return Runner The method will return the instance at which the method
+ * is called on
+ */
+ public function setInputStream(InputStream $stream): Runner {
+ $this->inputStream = $stream;
+
+ return $this;
+ }
+
+ /**
+ * Sets the stream at which the runner will be using to send outputs to.
+ *
+ * @param OutputStream $stream The new stream that will hold inputs.
+ *
+ * @return Runner The method will return the instance at which the method
+ * is called on
+ */
+ public function setOutputStream(OutputStream $stream): Runner {
+ $this->outputStream = $stream;
+
+ return $this;
+ }
+
+ /**
+ * Sets a custom signal handler for a specific signal.
+ *
+ * If signal handling has not been enabled yet, this method will
+ * enable it first.
+ *
+ * @param int $signal The signal number (e.g., SIGINT, SIGTERM).
+ *
+ * @param callable $handler The callback to invoke when the signal is received.
+ *
+ * @return Runner The method returns same instance for chaining.
+ */
+ public function setSignalHandler(int $signal, callable $handler): Runner {
+ if ($this->signalHandler === null) {
+ $this->enableSignalHandling();
+ }
+
+ $this->signalHandler->register($signal, $handler);
+
+ return $this;
+ }
+
+ /**
+ * Start command line process.
+ *
+ * @return int The method will return an integer that represents exit status of
+ * the process. Usually, if the process exit with a number other than 0,
+ * it means that there was an error in execution.
+ */
+ public function start(): int {
+ foreach ($this->beforeStartPool as $func) {
+ call_user_func_array($func, [$this]);
+ }
+
+ if ($this->isInteractive()) {
+ $this->isAnsi = in_array('--ansi', $this->getArgsVector());
+ $this->printMsg('Running in interactive mode.', '>>', 'blue');
+ $this->printMsg("Type command name or 'exit' to close.", ">>", 'blue');
+ $this->printMsg('', '>>', 'blue');
+
+ while (!$this->shutdownRequested) {
+ $args = $this->readInteractive();
+ $this->setArgsVector($args);
+ $argsCount = count($args);
+
+ if ($argsCount == 0) {
+ $this->getOutputStream()->println('No input.');
+ } else {
+ if ($args[0] == 'exit') {
+ return 0;
+ } else {
+ $this->runCommand(null, $args, $this->isAnsi);
+ }
+ }
+ $this->printMsg('', '>>', 'blue');
+ }
+
+ return $this->commandExitVal;
+ } else {
+ return $this->run();
+ }
+ }
+
+ private function checkIsInteractive(): void {
+ foreach ($this->getArgsVector() as $arg) {
+ $this->isInteractive = $arg == '-i' || $this->isInteractive;
+ }
+ }
+
+ private function invokeAfterExc(): void {
+ foreach ($this->afterRunPool as $funcArr) {
+ call_user_func_array($funcArr['func'], array_merge([$this], $funcArr['params']));
+ }
+ }
+ /**
+ * Preprocesses arguments to handle help patterns like 'command help' or 'command -h'.
+ *
+ * @param array $args The arguments array to preprocess
+ * @return array The preprocessed arguments array
+ */
+ private function preprocessHelpPattern(array $args): array {
+ if (count($args) >= 2) {
+ $lastArg = end($args);
+
+ // Check if the last argument is 'help' or '-h'
+ if ($lastArg === 'help' || $lastArg === '-h') {
+ $commandName = $args[0];
+
+ // Check if the first argument is a valid command name
+ if ($this->getCommandByName($commandName) !== null) {
+ // Remove 'help' or '-h' from the end
+ array_pop($args);
+ // Add it as a proper argument flag
+ $args[] = $lastArg;
+ }
+ }
+ }
+
+ return $args;
+ }
+
+ private function printMsg(string $msg, ?string $prefix = null, ?string $color = null): void {
+ if ($prefix !== null) {
+ $prefix = Formatter::format($prefix, [
+ 'color' => $color,
+ 'bold' => true,
+ 'ansi' => $this->isAnsi
+ ]);
+ $this->getOutputStream()->prints("$prefix ");
+ }
+
+ if (strlen($msg) != 0) {
+ $this->getOutputStream()->println($msg);
+ }
+ }
+
+ private function readInteractive(): array {
+ $input = trim($this->getInputStream()->readLine());
+
+ $argsArr = strlen($input) != 0 ? explode(' ', $input) : [];
+
+ if (in_array('--ansi', $argsArr)) {
+ $argsArr = array_diff($argsArr, ['--ansi']);
+ }
+
+ // Preprocess help patterns
+ $argsArr = $this->preprocessHelpPattern($argsArr);
+
+ return $argsArr;
+ }
+
+ /**
+ * Register an alias for a command.
+ *
+ * @param string $alias The alias to register.
+ * @param string $commandName The name of the command the alias points to.
+ *
+ * @return Runner The method will return the instance at which the method
+ * is called on
+ */
+ private function registerAlias(string $alias, string $commandName): Runner {
+ // Check for conflicts
+ if (isset($this->aliases[$alias])) {
+ $existingCommand = $this->aliases[$alias];
+
+ if ($this->isInteractive()) {
+ // Interactive mode: prompt user to choose
+ $choice = $this->resolveAliasConflictInteractively($alias, $existingCommand, $commandName);
+
+ if ($choice === $commandName) {
+ $this->aliases[$alias] = $commandName;
+ }
+ // If user chose existing command, do nothing
+ } else {
+ // Non-interactive mode: use first-come-first-served (do nothing)
+ // Suppress warning if both existing and new command are 'help' (expected duplicate registration)
+ if (!($existingCommand === 'help' && $commandName === 'help')) {
+ $this->printMsg("Alias '$alias' already exists for command '$existingCommand'. Ignoring new alias for '$commandName'.", 'Warning:', 'yellow');
+ }
+ }
+ } else {
+ // No conflict, register the alias
+ $this->aliases[$alias] = $commandName;
+ }
+
+ return $this;
+ }
+
+ private function registerCommandSignalHandlers(Command $c): void {
+ if ($this->signalHandler !== null) {
+ foreach ($c->getSignalHandlers() as $signal => $handler) {
+ $this->signalHandler->register($signal, $handler);
+ }
+ }
+ }
+
+ private function removeCommandSignalHandlers(Command $c): void {
+ if ($this->signalHandler !== null) {
+ foreach ($c->getSignalHandlers() as $signal => $handler) {
+ $this->signalHandler->remove($signal);
+ }
+ $c->clearSignalHandlers();
+ }
+ }
+
+ /**
+ * Run the command line as single run.
+ *
+ * @return int
+ */
+ private function run(): int {
+ $argsArr = array_slice($this->getArgsVector(), 1);
+
+ if (in_array('--ansi', $argsArr)) {
+ $this->isAnsi = true;
+ $tempArgs = [];
+
+ foreach ($argsArr as $argName => $val) {
+ if (gettype($argName) == 'integer') {
+ if ($val != '--ansi') {
+ $tempArgs[] = $val;
+ }
+ } else {
+ $tempArgs[$argName] = $val;
+ }
+ }
+ $argsArr = $tempArgs;
+ }
+
+ // Preprocess help patterns for non-interactive mode
+ $argsArr = $this->preprocessHelpPattern($argsArr);
+
+ if (count($argsArr) == 0) {
+ $command = $this->getDefaultCommand();
+
+ return $this->runCommand($command, [], $this->isAnsi);
+ }
+
+ return $this->runCommand(null, $argsArr, $this->isAnsi);
+ }
+
+ private function setArgV(array $args): void {
+ $argV = [];
+
+ foreach ($args as $argName => $argVal) {
+ if (gettype($argName) == 'integer') {
+ $argV[] = $argVal;
+ } else {
+ $argV[] = $argName.'='.$argVal;
+ }
+ }
+ $this->argsV = $argV;
+ }
+}
diff --git a/WebFiori/Cli/SignalHandler.php b/WebFiori/Cli/SignalHandler.php
new file mode 100644
index 0000000..b576a48
--- /dev/null
+++ b/WebFiori/Cli/SignalHandler.php
@@ -0,0 +1,154 @@
+
+ */
+ private $handlers;
+
+ /**
+ * Creates new instance of the class.
+ */
+ public function __construct() {
+ $this->handlers = [];
+ $this->enabled = false;
+ }
+
+ /**
+ * Disables signal handling by restoring default signal behavior for
+ * all registered signals.
+ *
+ * @return SignalHandler The method returns same instance for chaining.
+ */
+ public function disable(): self {
+ $this->enabled = false;
+
+ if (self::isSupported()) {
+ foreach ($this->handlers as $signal => $handler) {
+ pcntl_signal($signal, SIG_DFL);
+ }
+ }
+
+ return $this;
+ }
+
+ /**
+ * Enables signal handling by activating async signals and installing
+ * all registered handlers.
+ *
+ * If signal handling is not supported, this method does nothing but
+ * still marks the handler as enabled.
+ *
+ * @return SignalHandler The method returns same instance for chaining.
+ */
+ public function enable(): self {
+ $this->enabled = true;
+
+ if (self::isSupported()) {
+ pcntl_async_signals(true);
+
+ foreach ($this->handlers as $signal => $handler) {
+ pcntl_signal($signal, $handler);
+ }
+ }
+
+ return $this;
+ }
+
+ /**
+ * Returns all registered signal handlers.
+ *
+ * @return array An associative array mapping signal numbers
+ * to their handler callables.
+ */
+ public function getHandlers(): array {
+ return $this->handlers;
+ }
+
+ /**
+ * Checks if a handler is registered for a specific signal.
+ *
+ * @param int $signal The signal number to check.
+ *
+ * @return bool True if a handler is registered for the signal.
+ */
+ public function hasHandler(int $signal): bool {
+ return isset($this->handlers[$signal]);
+ }
+
+ /**
+ * Checks if signal handling is currently enabled.
+ *
+ * @return bool True if enabled, false otherwise.
+ */
+ public function isEnabled(): bool {
+ return $this->enabled;
+ }
+
+ /**
+ * Checks if signal handling is supported on the current platform.
+ *
+ * Signal handling requires the pcntl extension which is available
+ * on Unix-like systems (Linux, macOS) but not on Windows.
+ *
+ * @return bool True if signal handling is supported, false otherwise.
+ */
+ public static function isSupported(): bool {
+ return function_exists('pcntl_async_signals');
+ }
+
+ /**
+ * Registers a handler for a specific signal.
+ *
+ * If signal handling is not supported, this method does nothing.
+ *
+ * @param int $signal The signal number (e.g., SIGINT, SIGTERM).
+ *
+ * @param callable $handler The callback to invoke when the signal is received.
+ * The callback receives the signal number as its argument.
+ *
+ * @return SignalHandler The method returns same instance for chaining.
+ */
+ public function register(int $signal, callable $handler): self {
+ $this->handlers[$signal] = $handler;
+
+ if ($this->enabled && self::isSupported()) {
+ pcntl_signal($signal, $handler);
+ }
+
+ return $this;
+ }
+
+ /**
+ * Removes the handler for a specific signal, restoring default behavior.
+ *
+ * @param int $signal The signal number to remove the handler for.
+ *
+ * @return SignalHandler The method returns same instance for chaining.
+ */
+ public function remove(int $signal): self {
+ unset($this->handlers[$signal]);
+
+ if ($this->enabled && self::isSupported()) {
+ pcntl_signal($signal, SIG_DFL);
+ }
+
+ return $this;
+ }
+}
diff --git a/tests/WebFiori/Tests/Cli/SignalHandlerTest.php b/tests/WebFiori/Tests/Cli/SignalHandlerTest.php
new file mode 100644
index 0000000..772a302
--- /dev/null
+++ b/tests/WebFiori/Tests/Cli/SignalHandlerTest.php
@@ -0,0 +1,265 @@
+assertIsBool($result);
+
+ if (function_exists('pcntl_async_signals')) {
+ $this->assertTrue($result);
+ } else {
+ $this->assertFalse($result);
+ }
+ }
+
+ /**
+ * @test
+ */
+ public function testInitialState() {
+ $handler = new SignalHandler();
+ $this->assertFalse($handler->isEnabled());
+ $this->assertEmpty($handler->getHandlers());
+ }
+
+ /**
+ * @test
+ */
+ public function testRegisterHandler() {
+ $handler = new SignalHandler();
+ $callback = function (int $signal) {};
+
+ $result = $handler->register(SIGINT, $callback);
+
+ $this->assertSame($handler, $result);
+ $this->assertTrue($handler->hasHandler(SIGINT));
+ $this->assertCount(1, $handler->getHandlers());
+ $this->assertSame($callback, $handler->getHandlers()[SIGINT]);
+ }
+
+ /**
+ * @test
+ */
+ public function testRegisterMultipleHandlers() {
+ $handler = new SignalHandler();
+ $cb1 = function (int $signal) {};
+ $cb2 = function (int $signal) {};
+
+ $handler->register(SIGINT, $cb1)
+ ->register(SIGTERM, $cb2);
+
+ $this->assertTrue($handler->hasHandler(SIGINT));
+ $this->assertTrue($handler->hasHandler(SIGTERM));
+ $this->assertCount(2, $handler->getHandlers());
+ }
+
+ /**
+ * @test
+ */
+ public function testRegisterOverwritesExisting() {
+ $handler = new SignalHandler();
+ $cb1 = function (int $signal) { return 1; };
+ $cb2 = function (int $signal) { return 2; };
+
+ $handler->register(SIGINT, $cb1);
+ $handler->register(SIGINT, $cb2);
+
+ $this->assertCount(1, $handler->getHandlers());
+ $this->assertSame($cb2, $handler->getHandlers()[SIGINT]);
+ }
+
+ /**
+ * @test
+ */
+ public function testRemoveHandler() {
+ $handler = new SignalHandler();
+ $callback = function (int $signal) {};
+
+ $handler->register(SIGINT, $callback);
+ $result = $handler->remove(SIGINT);
+
+ $this->assertSame($handler, $result);
+ $this->assertFalse($handler->hasHandler(SIGINT));
+ $this->assertEmpty($handler->getHandlers());
+ }
+
+ /**
+ * @test
+ */
+ public function testRemoveNonExistentHandler() {
+ $handler = new SignalHandler();
+ $result = $handler->remove(SIGINT);
+
+ $this->assertSame($handler, $result);
+ $this->assertFalse($handler->hasHandler(SIGINT));
+ }
+
+ /**
+ * @test
+ */
+ public function testEnable() {
+ $handler = new SignalHandler();
+ $result = $handler->enable();
+
+ $this->assertSame($handler, $result);
+ $this->assertTrue($handler->isEnabled());
+ }
+
+ /**
+ * @test
+ */
+ public function testDisable() {
+ $handler = new SignalHandler();
+ $handler->enable();
+ $result = $handler->disable();
+
+ $this->assertSame($handler, $result);
+ $this->assertFalse($handler->isEnabled());
+ }
+
+ /**
+ * @test
+ */
+ public function testEnableWithRegisteredHandlers() {
+ $handler = new SignalHandler();
+ $called = false;
+ $callback = function (int $signal) use (&$called) {
+ $called = true;
+ };
+
+ $handler->register(SIGUSR1, $callback);
+ $handler->enable();
+
+ $this->assertTrue($handler->isEnabled());
+ $this->assertTrue($handler->hasHandler(SIGUSR1));
+
+ // Send SIGUSR1 to current process to verify handler is installed
+ if (SignalHandler::isSupported()) {
+ posix_kill(posix_getpid(), SIGUSR1);
+ $this->assertTrue($called);
+ }
+
+ $handler->disable();
+ }
+
+ /**
+ * @test
+ */
+ public function testRegisterWhileEnabled() {
+ $handler = new SignalHandler();
+ $called = false;
+ $callback = function (int $signal) use (&$called) {
+ $called = true;
+ };
+
+ $handler->enable();
+ $handler->register(SIGUSR1, $callback);
+
+ if (SignalHandler::isSupported()) {
+ posix_kill(posix_getpid(), SIGUSR1);
+ $this->assertTrue($called);
+ }
+
+ $handler->disable();
+ }
+
+ /**
+ * @test
+ */
+ public function testRemoveWhileEnabled() {
+ $handler = new SignalHandler();
+ $called = false;
+ $callback = function (int $signal) use (&$called) {
+ $called = true;
+ };
+
+ $handler->register(SIGUSR1, $callback);
+ $handler->enable();
+ $handler->remove(SIGUSR1);
+
+ // After removal, signal should use default behavior
+ // We won't send the signal as default for SIGUSR1 is termination
+ $this->assertFalse($handler->hasHandler(SIGUSR1));
+
+ $handler->disable();
+ }
+
+ /**
+ * @test
+ */
+ public function testHasHandlerReturnsFalse() {
+ $handler = new SignalHandler();
+ $this->assertFalse($handler->hasHandler(SIGINT));
+ $this->assertFalse($handler->hasHandler(SIGTERM));
+ $this->assertFalse($handler->hasHandler(999));
+ }
+
+ /**
+ * @test
+ */
+ public function testDisableRestoresDefaults() {
+ $handler = new SignalHandler();
+ $called = false;
+ $callback = function (int $signal) use (&$called) {
+ $called = true;
+ };
+
+ $handler->register(SIGUSR1, $callback);
+ $handler->enable();
+ $handler->disable();
+
+ // Handlers array should still contain the handler
+ $this->assertTrue($handler->hasHandler(SIGUSR1));
+ // But it's no longer enabled
+ $this->assertFalse($handler->isEnabled());
+ }
+
+ /**
+ * @test
+ */
+ public function testEnableDisableMultipleTimes() {
+ $handler = new SignalHandler();
+ $callback = function (int $signal) {};
+
+ $handler->register(SIGUSR1, $callback);
+
+ $handler->enable();
+ $this->assertTrue($handler->isEnabled());
+
+ $handler->disable();
+ $this->assertFalse($handler->isEnabled());
+
+ $handler->enable();
+ $this->assertTrue($handler->isEnabled());
+
+ $handler->disable();
+ $this->assertFalse($handler->isEnabled());
+ }
+
+ /**
+ * @test
+ */
+ public function testChaining() {
+ $handler = new SignalHandler();
+ $cb = function (int $signal) {};
+
+ $result = $handler->register(SIGINT, $cb)
+ ->register(SIGTERM, $cb)
+ ->enable();
+
+ $this->assertSame($handler, $result);
+ $this->assertTrue($handler->isEnabled());
+ $this->assertCount(2, $handler->getHandlers());
+
+ $handler->disable();
+ }
+}
diff --git a/tests/WebFiori/Tests/Cli/SignalIntegrationTest.php b/tests/WebFiori/Tests/Cli/SignalIntegrationTest.php
new file mode 100644
index 0000000..e3f684e
--- /dev/null
+++ b/tests/WebFiori/Tests/Cli/SignalIntegrationTest.php
@@ -0,0 +1,487 @@
+println('Running signal test command');
+
+ return 0;
+ }
+}
+
+class SignalIntegrationTest extends CommandTestCase {
+ /**
+ * @test
+ */
+ public function testRunnerEnableSignalHandling() {
+ $runner = new Runner();
+ $runner->reset();
+
+ $result = $runner->enableSignalHandling();
+
+ $this->assertSame($runner, $result);
+ $this->assertNotNull($runner->getSignalHandler());
+ $this->assertInstanceOf(SignalHandler::class, $runner->getSignalHandler());
+ $this->assertTrue($runner->getSignalHandler()->isEnabled());
+ }
+
+ /**
+ * @test
+ */
+ public function testRunnerSignalHandlerDefaultsNull() {
+ $runner = new Runner();
+ $runner->reset();
+
+ $this->assertNull($runner->getSignalHandler());
+ }
+
+ /**
+ * @test
+ */
+ public function testRunnerIsShutdownRequestedDefault() {
+ $runner = new Runner();
+ $runner->reset();
+
+ $this->assertFalse($runner->isShutdownRequested());
+ }
+
+ /**
+ * @test
+ */
+ public function testRunnerSetSignalHandler() {
+ $runner = new Runner();
+ $runner->reset();
+ $called = false;
+
+ $result = $runner->setSignalHandler(SIGUSR1, function (int $signal) use (&$called) {
+ $called = true;
+ });
+
+ $this->assertSame($runner, $result);
+ // setSignalHandler should auto-enable signal handling
+ $this->assertNotNull($runner->getSignalHandler());
+ $this->assertTrue($runner->getSignalHandler()->hasHandler(SIGUSR1));
+
+ // Verify the handler actually fires
+ if (SignalHandler::isSupported()) {
+ posix_kill(posix_getpid(), SIGUSR1);
+ $this->assertTrue($called);
+ }
+
+ $runner->getSignalHandler()->disable();
+ }
+
+ /**
+ * @test
+ */
+ public function testRunnerDefaultSigintHandler() {
+ $runner = new Runner();
+ $runner->reset();
+ $runner->enableSignalHandling();
+
+ $this->assertTrue($runner->getSignalHandler()->hasHandler(SIGINT));
+ $this->assertTrue($runner->getSignalHandler()->hasHandler(SIGTERM));
+
+ $runner->getSignalHandler()->disable();
+ }
+
+ /**
+ * @test
+ */
+ public function testRunnerSigtermSetsShutdown() {
+ $runner = new Runner();
+ $runner->reset();
+ $runner->enableSignalHandling();
+
+ if (SignalHandler::isSupported()) {
+ posix_kill(posix_getpid(), SIGTERM);
+ $this->assertTrue($runner->isShutdownRequested());
+ }
+
+ $runner->getSignalHandler()->disable();
+ }
+
+ /**
+ * @test
+ */
+ public function testRunnerSigintNonInteractive() {
+ $runner = new Runner();
+ $runner->reset();
+ $runner->enableSignalHandling();
+
+ if (SignalHandler::isSupported()) {
+ posix_kill(posix_getpid(), SIGINT);
+ // Non-interactive mode: sets shutdown requested
+ $this->assertTrue($runner->isShutdownRequested());
+ }
+
+ $runner->getSignalHandler()->disable();
+ }
+
+ /**
+ * @test
+ */
+ public function testCommandOnSignal() {
+ $command = new SignalCommandForTest();
+ $called = false;
+
+ $result = $command->onSignal(SIGINT, function (int $signal) use (&$called) {
+ $called = true;
+ });
+
+ $this->assertSame($command, $result);
+ $this->assertCount(1, $command->getSignalHandlers());
+ $this->assertArrayHasKey(SIGINT, $command->getSignalHandlers());
+ }
+
+ /**
+ * @test
+ */
+ public function testCommandOnSignalMultiple() {
+ $command = new SignalCommandForTest();
+ $cb1 = function (int $signal) {};
+ $cb2 = function (int $signal) {};
+
+ $command->onSignal(SIGINT, $cb1)
+ ->onSignal(SIGTERM, $cb2);
+
+ $this->assertCount(2, $command->getSignalHandlers());
+ $this->assertSame($cb1, $command->getSignalHandlers()[SIGINT]);
+ $this->assertSame($cb2, $command->getSignalHandlers()[SIGTERM]);
+ }
+
+ /**
+ * @test
+ */
+ public function testCommandClearSignalHandlers() {
+ $command = new SignalCommandForTest();
+ $command->onSignal(SIGINT, function (int $signal) {});
+ $command->onSignal(SIGTERM, function (int $signal) {});
+
+ $result = $command->clearSignalHandlers();
+
+ $this->assertSame($command, $result);
+ $this->assertEmpty($command->getSignalHandlers());
+ }
+
+ /**
+ * @test
+ */
+ public function testCommandSignalHandlersDefaultEmpty() {
+ $command = new SignalCommandForTest();
+ $this->assertEmpty($command->getSignalHandlers());
+ }
+
+ /**
+ * @test
+ */
+ public function testCommandSignalHandlerRegisteredDuringExecution() {
+ $runner = new Runner();
+ $runner->reset();
+ $runner->enableSignalHandling();
+
+ $handlerRegistered = false;
+ $command = new SignalCommandForTest();
+ $command->onSignal(SIGUSR1, function (int $signal) use (&$handlerRegistered) {
+ $handlerRegistered = true;
+ });
+
+ $runner->register($command);
+ $runner->setInputs([]);
+ $runner->setArgsVector(['main.php', 'signal-test']);
+ $runner->start();
+
+ // After command finishes, handlers should be cleaned up
+ $this->assertEmpty($command->getSignalHandlers());
+
+ $runner->getSignalHandler()->disable();
+ }
+
+ /**
+ * @test
+ */
+ public function testCommandSignalHandlerFires() {
+ if (!SignalHandler::isSupported()) {
+ $this->markTestSkipped('pcntl not available');
+ }
+
+ $runner = new Runner();
+ $runner->reset();
+ $runner->enableSignalHandling();
+
+ $signalReceived = false;
+
+ // Create a command that registers a SIGUSR1 handler and sends itself the signal
+ $command = new class extends Command {
+ public $signalReceived = false;
+
+ public function __construct() {
+ parent::__construct('signal-fire-test', [], 'Test signal firing');
+ }
+
+ public function exec(): int {
+ $this->onSignal(SIGUSR1, function (int $signal) {
+ $this->signalReceived = true;
+ });
+
+ // Re-register with runner (simulate what runner does)
+ $owner = $this->getOwner();
+ if ($owner !== null && $owner->getSignalHandler() !== null) {
+ foreach ($this->getSignalHandlers() as $sig => $handler) {
+ $owner->getSignalHandler()->register($sig, $handler);
+ }
+ }
+
+ posix_kill(posix_getpid(), SIGUSR1);
+
+ return 0;
+ }
+ };
+
+ $runner->register($command);
+ $runner->setInputs([]);
+ $runner->setArgsVector(['main.php', 'signal-fire-test']);
+ $runner->start();
+
+ $this->assertTrue($command->signalReceived);
+
+ $runner->getSignalHandler()->disable();
+ }
+
+ /**
+ * @test
+ */
+ public function testEnableSignalHandlingReturnsSameInstance() {
+ $runner = new Runner();
+ $runner->reset();
+
+ $result = $runner->enableSignalHandling();
+ $this->assertSame($runner, $result);
+
+ $runner->getSignalHandler()->disable();
+ }
+
+ /**
+ * @test
+ */
+ public function testSetSignalHandlerReturnsSameInstance() {
+ $runner = new Runner();
+ $runner->reset();
+
+ $result = $runner->setSignalHandler(SIGUSR1, function (int $signal) {});
+ $this->assertSame($runner, $result);
+
+ $runner->getSignalHandler()->disable();
+ }
+
+ /**
+ * @test
+ */
+ public function testEnableSignalHandlingCalledMultipleTimes() {
+ $runner = new Runner();
+ $runner->reset();
+
+ $runner->enableSignalHandling();
+ $handler1 = $runner->getSignalHandler();
+
+ $runner->enableSignalHandling();
+ $handler2 = $runner->getSignalHandler();
+
+ // Each call creates a new handler
+ $this->assertNotSame($handler1, $handler2);
+ $this->assertTrue($handler2->isEnabled());
+
+ $handler2->disable();
+ }
+
+ /**
+ * @test
+ */
+ public function testCustomSignalHandlerOverridesDefault() {
+ $runner = new Runner();
+ $runner->reset();
+ $runner->enableSignalHandling();
+
+ $customCalled = false;
+ $runner->setSignalHandler(SIGINT, function (int $signal) use (&$customCalled) {
+ $customCalled = true;
+ });
+
+ if (SignalHandler::isSupported()) {
+ posix_kill(posix_getpid(), SIGINT);
+ $this->assertTrue($customCalled);
+ // The custom handler replaced the default, so shutdown should NOT be set
+ // unless the custom handler sets it
+ $this->assertFalse($runner->isShutdownRequested());
+ }
+
+ $runner->getSignalHandler()->disable();
+ }
+
+ /**
+ * @test
+ */
+ public function testCommandWithoutSignalHandlersRunsNormally() {
+ $runner = new Runner();
+ $runner->reset();
+ $runner->enableSignalHandling();
+
+ $command = new SignalCommandForTest();
+ $runner->register($command);
+ $runner->setInputs([]);
+ $runner->setArgsVector(['main.php', 'signal-test']);
+ $exitCode = $runner->start();
+
+ $this->assertEquals(0, $exitCode);
+ $output = $runner->getOutput();
+ $this->assertContains("Running signal test command\n", $output);
+
+ $runner->getSignalHandler()->disable();
+ }
+
+ /**
+ * @test
+ */
+ public function testSignalHandlingWithoutEnabling() {
+ // When signal handling is not enabled, command signal handlers
+ // should just be ignored (no crash)
+ $runner = new Runner();
+ $runner->reset();
+
+ $command = new SignalCommandForTest();
+ $command->onSignal(SIGINT, function (int $signal) {});
+
+ $runner->register($command);
+ $runner->setInputs([]);
+ $runner->setArgsVector(['main.php', 'signal-test']);
+ $exitCode = $runner->start();
+
+ $this->assertEquals(0, $exitCode);
+ // Signal handlers should NOT be cleared when there's no signal handler on runner
+ $this->assertCount(1, $command->getSignalHandlers());
+ }
+
+ /**
+ * @test
+ */
+ public function testInteractiveModeShutdownFlag() {
+ $runner = new Runner();
+ $runner->reset();
+ $runner->enableSignalHandling();
+
+ // Register a command
+ $command = new SignalCommandForTest();
+ $runner->register($command);
+
+ // Simulate interactive mode where shutdown is requested immediately
+ // by providing "exit" as user input
+ $runner->setInputs(['exit']);
+ $runner->setArgsVector(['main.php', '-i']);
+ $exitCode = $runner->start();
+
+ $this->assertEquals(0, $exitCode);
+
+ $runner->getSignalHandler()->disable();
+ }
+
+ /**
+ * @test
+ */
+ public function testInteractiveModeSigintInterruptsCommand() {
+ if (!SignalHandler::isSupported()) {
+ $this->markTestSkipped('pcntl not available');
+ }
+
+ $runner = new Runner();
+ $runner->reset();
+ $runner->enableSignalHandling();
+
+ $command = new SignalCommandForTest();
+ $runner->register($command);
+
+ // Simulate interactive mode with exit
+ $runner->setInputs(['exit']);
+ $runner->setArgsVector(['main.php', '-i']);
+ $exitCode = $runner->start();
+ $this->assertEquals(0, $exitCode);
+
+ // Test the SIGINT handler in non-interactive mode directly
+ $runner3 = new Runner();
+ $runner3->reset();
+ $runner3->enableSignalHandling();
+ $runner3->register(new SignalCommandForTest());
+
+ // Send SIGINT directly (not inside a command)
+ posix_kill(posix_getpid(), SIGINT);
+ $this->assertTrue($runner3->isShutdownRequested());
+ $this->assertEquals(130, $runner3->getLastCommandExitStatus());
+
+ $runner3->getSignalHandler()->disable();
+ }
+
+ /**
+ * @test
+ */
+ public function testSigintInInteractiveMode() {
+ if (!SignalHandler::isSupported()) {
+ $this->markTestSkipped('pcntl not available');
+ }
+
+ // Test SIGINT in interactive mode by creating a command that sends SIGINT
+ // while runner is in interactive mode
+ $sigintCommand = new class extends Command {
+ public function __construct() {
+ parent::__construct('send-sigint', [], 'Sends SIGINT to self');
+ }
+
+ public function exec(): int {
+ posix_kill(posix_getpid(), SIGINT);
+
+ return 0;
+ }
+ };
+
+ $runner = new Runner();
+ $runner->reset();
+ $runner->enableSignalHandling();
+ $runner->register($sigintCommand);
+
+ // Interactive mode: send-sigint, then exit
+ $runner->setInputs(['send-sigint', 'exit']);
+ $runner->setArgsVector(['main.php', '-i']);
+ $exitCode = $runner->start();
+
+ // SIGINT in interactive mode should NOT kill the app
+ $this->assertEquals(0, $exitCode);
+
+ // Output should contain the interruption message
+ $output = $runner->getOutput();
+ $found = false;
+
+ foreach ($output as $line) {
+ if (strpos($line, 'Command interrupted') !== false) {
+ $found = true;
+
+ break;
+ }
+ }
+ $this->assertTrue($found, 'Expected "Command interrupted" message in output');
+
+ $runner->getSignalHandler()->disable();
+ }
+}