diff --git a/WebFiori/Cli/Attributes/Group.php b/WebFiori/Cli/Attributes/Group.php
new file mode 100644
index 0000000..4cf656a
--- /dev/null
+++ b/WebFiori/Cli/Attributes/Group.php
@@ -0,0 +1,29 @@
+name = $name;
+ }
+}
diff --git a/WebFiori/Cli/Attributes/SingleInstance.php b/WebFiori/Cli/Attributes/SingleInstance.php
new file mode 100644
index 0000000..daefb54
--- /dev/null
+++ b/WebFiori/Cli/Attributes/SingleInstance.php
@@ -0,0 +1,34 @@
+lockPath = $lockPath;
+ $this->exitCode = $exitCode;
+ }
+}
diff --git a/WebFiori/Cli/Command.php b/WebFiori/Cli/Command.php
index c98c4e2..370c7fd 100644
--- a/WebFiori/Cli/Command.php
+++ b/WebFiori/Cli/Command.php
@@ -1,1674 +1,1877 @@
-
- *
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->group = null;
+ $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 only when verbosity is DEBUG (-vv).
+ *
+ * @param string $message The message that will be shown.
+ */
+ public function debug(string $message): void {
+ if ($this->getVerbosityLevel() >= Verbosity::DEBUG) {
+ $this->printMsg($message, 'Debug', 'gray');
+ }
+ }
+ /**
+ * 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;
+ $lockManager = null;
+
+ $runner = $this->getOwner();
+
+ if ($runner !== null) {
+ foreach ($runner->getArgs() as $arg) {
+ $this->addArgument($arg);
+ }
+ }
+
+ // Check for SingleInstance attribute
+ $singleInstance = $this->resolveSingleInstance();
+
+ if ($singleInstance !== null) {
+ $lockManager = new LockManager();
+
+ if (!$lockManager->acquire($this->getName(), $singleInstance->lockPath)) {
+ $this->warning('Command is already running.');
+
+ if ($runner !== null) {
+ foreach ($runner->getArgs() as $arg) {
+ $this->removeArgument($arg->getName());
+ $arg->resetValue();
+ }
+ }
+
+ return $singleInstance->exitCode;
+ }
+ }
+
+ try {
+ 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');
+
+ $retVal = $help->exec();
+ } else if ($this->checkIsArgsSetHelper()) {
+ $retVal = $this->exec();
+ }
+ }
+ } finally {
+ if ($lockManager !== null) {
+ $lockManager->release();
+ }
+ }
+
+ 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;
+ }
+
+ /**
+ * Returns the group this command belongs to.
+ *
+ * @return string|null The group name, or null if ungrouped.
+ */
+ public function getGroup(): ?string {
+ return $this->group;
+ }
+
+ /**
+ * 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. Suppressed in quiet mode.
+ *
+ * @param string $message The message that will be shown.
+ *
+ */
+ public function info(string $message): void {
+ if ($this->getVerbosityLevel() >= Verbosity::NORMAL) {
+ $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->isAnsiEnabled();
+ $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->isAnsiEnabled();
+
+ $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;
+ }
+
+ /**
+ * Resolves the group for this command from attributes or name convention.
+ *
+ * Priority: 1. Explicit setGroup() 2. #[Group] attribute 3. Colon prefix in name
+ */
+ public function resolveGroup(): void {
+ if ($this->group !== null) {
+ return;
+ }
+
+ $ref = new ReflectionClass($this);
+ $attrs = $ref->getAttributes(Attributes\Group::class);
+
+ if (count($attrs) > 0) {
+ $this->group = $attrs[0]->newInstance()->name;
+
+ return;
+ }
+
+ $colonPos = strpos($this->getName(), ':');
+
+ if ($colonPos !== false) {
+ $this->group = substr($this->getName(), 0, $colonPos);
+ }
+ }
+
+ /**
+ * 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 group this command belongs to for help display organization.
+ *
+ * @param string $group The group name.
+ */
+ public function setGroup(string $group): void {
+ $this->group = $group;
+ }
+ /**
+ * 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.
+ * Suppressed in quiet mode.
+ *
+ * @param string $message The message that will be displayed.
+ *
+ */
+ public function success(string $message): void {
+ if ($this->getVerbosityLevel() >= Verbosity::NORMAL) {
+ $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 only when verbosity is VERBOSE (-v) or higher.
+ *
+ * @param string $message The message that will be shown.
+ */
+ public function verbose(string $message): void {
+ if ($this->getVerbosityLevel() >= Verbosity::VERBOSE) {
+ $this->printMsg($message, 'Verbose', 'cyan');
+ }
+ }
+ /**
+ * 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;
+ }
+
+ /**
+ * Returns the current verbosity level from the Runner, or NORMAL as default.
+ *
+ * @return int One of the Verbosity constants.
+ */
+ private function getVerbosityLevel(): int {
+ $owner = $this->getOwner();
+
+ if ($owner !== null) {
+ return $owner->getVerbosity();
+ }
+
+ return Verbosity::NORMAL;
+ }
+
+ /**
+ * Checks if ANSI output is enabled for this command.
+ *
+ * Uses the Runner's resolved ANSI value if available, falls back to
+ * checking if --ansi argument is provided.
+ *
+ * @return bool True if ANSI output should be used.
+ */
+ private function isAnsiEnabled(): bool {
+ $owner = $this->getOwner();
+
+ if ($owner !== null) {
+ return $owner->isAnsi();
+ }
+
+ return $this->isArgProvided('--ansi');
+ }
+
+ 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;
+ }
+
+ /**
+ * Resolves the SingleInstance attribute on this command class.
+ *
+ * @return Attributes\SingleInstance|null The attribute instance, or null if not present.
+ */
+ private function resolveSingleInstance(): ?Attributes\SingleInstance {
+ $ref = new ReflectionClass($this);
+ $attrs = $ref->getAttributes(Attributes\SingleInstance::class);
+
+ if (count($attrs) > 0) {
+ return $attrs[0]->newInstance();
+ }
+
+ return null;
+ }
+}
diff --git a/WebFiori/Cli/Commands/HelpCommand.php b/WebFiori/Cli/Commands/HelpCommand.php
index e7f4eb8..9ccdf4e 100644
--- a/WebFiori/Cli/Commands/HelpCommand.php
+++ b/WebFiori/Cli/Commands/HelpCommand.php
@@ -1,163 +1,208 @@
- [
- ArgumentOption::OPTIONAL => true,
- ArgumentOption::DESCRIPTION => 'An optional command name. If provided, help '
- .'will be specific to the given command only.'
- ],
- '--table' => [
- ArgumentOption::OPTIONAL => true,
- ArgumentOption::DESCRIPTION => 'Display command arguments in table format for better readability.'
- ]
- ], 'Display CLI Help. To display help for specific command, use the argument '
- .'"--command" with this command.', ['-h']);
- }
- /**
- * Execute the command.
- *
- */
- public function exec() : int {
- $regCommands = $this->getOwner()->getCommands();
- $commandName = $this->getArgValue('--command');
- $len = $this->getMaxCommandNameLen();
-
- if ($commandName !== null) {
- if (isset($regCommands[$commandName])) {
- $this->printCommandInfo($regCommands[$commandName], $len, true);
- } else {
- $this->error("Command '$commandName' is not supported.");
- }
- } else {
- $formattingOptions = [
- 'bold' => true,
- 'color' => 'light-yellow'
- ];
- $this->println("Usage:", $formattingOptions);
- $this->println(" command [arg1 arg2=\"val\" arg3...]\n");
- $this->printGlobalArgs($formattingOptions);
- $this->println("Available Commands:", $formattingOptions);
-
- foreach ($regCommands as $commandObj) {
- $this->printCommandInfo($commandObj, $len);
- }
- }
-
- return 0;
- }
- private function getMaxCommandNameLen() : int {
- $len = 0;
-
- foreach ($this->getOwner()->getCommands() as $c) {
- $xLen = strlen($c->getName());
-
- if ($xLen > $len) {
- $len = $xLen;
- }
- }
-
- return $len;
- }
- private function printArg(Argument $argObj, $spaces = 25) {
- $this->prints(" %".$spaces."s:", $argObj->getName(), [
- 'bold' => true,
- 'color' => 'yellow'
- ]);
-
- if ($argObj->isOptional()) {
- $this->prints("[Optional]");
- }
-
- if ($argObj->getDefault() != '') {
- $default = $argObj->getDefault();
- $this->prints("[Default = '$default']");
- }
- $this->println(" %s", $argObj->getDescription());
- }
-
- private function printArgsTable(array $args) {
- $rows = [];
- foreach ($args as $argObj) {
- $name = $argObj->getName();
- $required = $argObj->isOptional() ? 'No' : 'Yes';
- $default = $argObj->getDefault() ?: '-';
- $description = $argObj->getDescription() ?: '';
-
- $rows[] = [$name, $required, $default, $description];
- }
-
- $this->table($rows, ['Argument', 'Required', 'Default', 'Description']);
- }
-
- /**
- * Prints meta information of a specific command.
- *
- * @param Command $cliCommand
- *
- * @param int $len
- *
- * @param bool $withArgs
- */
- private function printCommandInfo(Command $cliCommand, int $len, bool $withArgs = false) {
- $this->prints(" %s", $cliCommand->getName(), [
- 'color' => 'yellow',
- 'bold' => true
- ]);
- $this->prints(': ');
- $spacesCount = $len - strlen($cliCommand->getName()) + 4;
- $this->println(str_repeat(' ', $spacesCount)."%s", $cliCommand->getDescription());
-
- if ($withArgs) {
- $args = array_filter($cliCommand->getArgs(), function($arg) {
- return !in_array($arg->getName(), ['help', '-h']);
- });
-
- if (count($args) != 0) {
- $this->println(" Supported Arguments:", [
- 'bold' => true,
- 'color' => 'light-blue'
- ]);
-
- if ($this->getArgValue('--table') !== null) {
- $this->printArgsTable($args);
- } else {
- foreach ($args as $argObj) {
- $this->printArg($argObj);
- }
- }
- }
- }
- }
- private function printGlobalArgs(array $formattingOptions) {
- $args = $this->getOwner()->getArgs();
-
- if (count($args) != 0) {
- $this->println("Global Arguments:", $formattingOptions);
-
- foreach ($args as $argObj) {
- $this->printArg($argObj, 4);
- }
- }
- }
-}
+ [
+ ArgumentOption::OPTIONAL => true,
+ ArgumentOption::DESCRIPTION => 'An optional command name. If provided, help '
+ .'will be specific to the given command only.'
+ ],
+ '--table' => [
+ ArgumentOption::OPTIONAL => true,
+ ArgumentOption::DESCRIPTION => 'Display command arguments in table format for better readability.'
+ ]
+ ], 'Display CLI Help. To display help for specific command, use the argument '
+ .'"--command" with this command.', ['-h']);
+ }
+ /**
+ * Execute the command.
+ *
+ */
+ public function exec() : int {
+ $regCommands = $this->getOwner()->getCommands();
+ $commandName = $this->getArgValue('--command');
+ $len = $this->getMaxCommandNameLen();
+
+ if ($commandName !== null) {
+ if (isset($regCommands[$commandName])) {
+ $this->printCommandInfo($regCommands[$commandName], $len, true);
+ } else {
+ $this->error("Command '$commandName' is not supported.");
+ }
+ } else {
+ $formattingOptions = [
+ 'bold' => true,
+ 'color' => 'light-yellow'
+ ];
+ $this->println("Usage:", $formattingOptions);
+ $this->println(" command [arg1 arg2=\"val\" arg3...]\n");
+ $this->printGlobalArgs($formattingOptions);
+ $this->println("Available Commands:", $formattingOptions);
+
+ $this->printCommandsGrouped($regCommands, $len, $formattingOptions);
+ }
+
+ return 0;
+ }
+ private function getMaxCommandNameLen() : int {
+ $len = 0;
+
+ foreach ($this->getOwner()->getCommands() as $c) {
+ $xLen = strlen($c->getName());
+
+ if ($xLen > $len) {
+ $len = $xLen;
+ }
+ }
+
+ return $len;
+ }
+ private function printArg(Argument $argObj, $spaces = 25) {
+ $this->prints(" %".$spaces."s:", $argObj->getName(), [
+ 'bold' => true,
+ 'color' => 'yellow'
+ ]);
+
+ if ($argObj->isOptional()) {
+ $this->prints("[Optional]");
+ }
+
+ if ($argObj->getDefault() != '') {
+ $default = $argObj->getDefault();
+ $this->prints("[Default = '$default']");
+ }
+ $this->println(" %s", $argObj->getDescription());
+ }
+
+ private function printArgsTable(array $args) {
+ $rows = [];
+
+ foreach ($args as $argObj) {
+ $name = $argObj->getName();
+ $required = $argObj->isOptional() ? 'No' : 'Yes';
+ $default = $argObj->getDefault() ?: '-';
+ $description = $argObj->getDescription() ?: '';
+
+ $rows[] = [$name, $required, $default, $description];
+ }
+
+ $this->table($rows, ['Argument', 'Required', 'Default', 'Description']);
+ }
+
+ /**
+ * Prints meta information of a specific command.
+ *
+ * @param Command $cliCommand
+ *
+ * @param int $len
+ *
+ * @param bool $withArgs
+ */
+ private function printCommandInfo(Command $cliCommand, int $len, bool $withArgs = false) {
+ $this->prints(" %s", $cliCommand->getName(), [
+ 'color' => 'yellow',
+ 'bold' => true
+ ]);
+ $this->prints(': ');
+ $spacesCount = $len - strlen($cliCommand->getName()) + 4;
+ $this->println(str_repeat(' ', $spacesCount)."%s", $cliCommand->getDescription());
+
+ if ($withArgs) {
+ $args = array_filter($cliCommand->getArgs(), function ($arg) {
+ return !in_array($arg->getName(), ['help', '-h']);
+ });
+
+ if (count($args) != 0) {
+ $this->println(" Supported Arguments:", [
+ 'bold' => true,
+ 'color' => 'light-blue'
+ ]);
+
+ if ($this->getArgValue('--table') !== null) {
+ $this->printArgsTable($args);
+ } else {
+ foreach ($args as $argObj) {
+ $this->printArg($argObj);
+ }
+ }
+ }
+ }
+ }
+
+ private function printCommandsGrouped(array $commands, int $len, array $formattingOptions) {
+ $grouped = [];
+ $ungrouped = [];
+
+ foreach ($commands as $commandObj) {
+ $group = $commandObj->getGroup();
+
+ if ($group !== null) {
+ $grouped[$group][] = $commandObj;
+ } else {
+ $ungrouped[] = $commandObj;
+ }
+ }
+
+ if (count($grouped) == 0) {
+ // No groups — flat list
+ foreach ($ungrouped as $commandObj) {
+ $this->printCommandInfo($commandObj, $len);
+ }
+
+ return;
+ }
+
+ // Print grouped commands first
+ ksort($grouped);
+
+ foreach ($grouped as $groupName => $groupCommands) {
+ $this->println(" %s:", $groupName, [
+ 'bold' => true,
+ 'color' => 'light-blue'
+ ]);
+
+ foreach ($groupCommands as $commandObj) {
+ $this->printCommandInfo($commandObj, $len);
+ }
+ }
+
+ // Print ungrouped commands
+ if (count($ungrouped) > 0) {
+ foreach ($ungrouped as $commandObj) {
+ $this->printCommandInfo($commandObj, $len);
+ }
+ }
+ }
+ private function printGlobalArgs(array $formattingOptions) {
+ $args = $this->getOwner()->getArgs();
+
+ if (count($args) != 0) {
+ $this->println("Global Arguments:", $formattingOptions);
+
+ foreach ($args as $argObj) {
+ $this->printArg($argObj, 4);
+ }
+ }
+ }
+}
diff --git a/WebFiori/Cli/LockManager.php b/WebFiori/Cli/LockManager.php
new file mode 100644
index 0000000..92dd59c
--- /dev/null
+++ b/WebFiori/Cli/LockManager.php
@@ -0,0 +1,103 @@
+handle = null;
+ $this->lockPath = null;
+ }
+
+ /**
+ * Attempts to acquire an exclusive non-blocking lock.
+ *
+ * @param string $commandName The command name used to generate the lock file path.
+ *
+ * @param string|null $customPath Optional custom lock file path.
+ *
+ * @return bool True if the lock was acquired, false otherwise.
+ */
+ public function acquire(string $commandName, ?string $customPath = null): bool {
+ $this->lockPath = $customPath ?? sys_get_temp_dir().'/wfcli-'.$commandName.'.lock';
+
+ $handle = @fopen($this->lockPath, 'w');
+
+ if ($handle === false) {
+ return false;
+ }
+
+ if (!flock($handle, LOCK_EX | LOCK_NB)) {
+ fclose($handle);
+
+ return false;
+ }
+
+ $this->handle = $handle;
+ fwrite($this->handle, (string) getmypid());
+ fflush($this->handle);
+
+ return true;
+ }
+
+ /**
+ * Returns the lock file path.
+ *
+ * @return string|null The path, or null if no lock has been attempted.
+ */
+ public function getLockPath(): ?string {
+ return $this->lockPath;
+ }
+
+ /**
+ * Checks if the lock is currently held.
+ *
+ * @return bool True if a lock is held.
+ */
+ public function isLocked(): bool {
+ return $this->handle !== null;
+ }
+
+ /**
+ * Releases the lock and closes the file handle.
+ */
+ public function release(): void {
+ if ($this->handle !== null) {
+ flock($this->handle, LOCK_UN);
+ fclose($this->handle);
+ $this->handle = null;
+ }
+
+ if ($this->lockPath !== null && file_exists($this->lockPath)) {
+ @unlink($this->lockPath);
+ $this->lockPath = null;
+ }
+ }
+}
diff --git a/WebFiori/Cli/Runner.php b/WebFiori/Cli/Runner.php
index 56c2b7c..be994db 100644
--- a/WebFiori/Cli/Runner.php
+++ b/WebFiori/Cli/Runner.php
@@ -1,1173 +1,1422 @@
-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;
+ $this->verbosity = Verbosity::NORMAL;
+
+ // 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->addArg('--no-color', [
+ ArgumentOption::OPTIONAL => true,
+ ArgumentOption::DESCRIPTION => 'Disable ANSI colored output.'
+ ]);
+ $this->addArg('-q', [
+ ArgumentOption::OPTIONAL => true,
+ ArgumentOption::DESCRIPTION => 'Quiet mode. Suppress non-critical output.'
+ ]);
+ $this->addArg('-v', [
+ ArgumentOption::OPTIONAL => true,
+ ArgumentOption::DESCRIPTION => 'Verbose output.'
+ ]);
+ $this->addArg('-vv', [
+ ArgumentOption::OPTIONAL => true,
+ ArgumentOption::DESCRIPTION => 'Debug output (most verbose).'
+ ]);
+ $this->setBeforeStart(function (Runner $r) {
+ if (count($r->getArgsVector()) == 0) {
+ $r->setArgsVector($_SERVER['argv']);
+ }
+ $r->checkIsInteractive();
+ $r->resolveAnsi();
+ $r->resolveVerbosity();
+ });
+ $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;
+ }
+
+ /**
+ * Returns the current verbosity level.
+ *
+ * @return int One of the Verbosity constants.
+ */
+ public function getVerbosity(): int {
+ return $this->verbosity;
+ }
+
+ /**
+ * 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;
+ }
+
+ /**
+ * Returns whether ANSI output is currently enabled.
+ *
+ * If not explicitly forced via --ansi or --no-color, this checks whether
+ * the output stream is a real terminal (StdOut) and applies TTY detection.
+ *
+ * @return bool True if ANSI output is enabled, false otherwise.
+ */
+ public function isAnsi(): bool {
+ return $this->isAnsi;
+ }
+
+ /**
+ * 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;
+
+ // Resolve group from attribute or name convention
+ $cliCommand->resolveGroup();
+
+ // 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 || in_array('--ansi', $args)) {
+ $this->isAnsi = true;
+ }
+
+ if (in_array('--no-color', $args)) {
+ $this->isAnsi = false;
+ }
+ $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;
+ }
+
+ /**
+ * Sets the verbosity level.
+ *
+ * @param int $level One of the Verbosity constants.
+ *
+ * @return Runner The method returns same instance for chaining.
+ */
+ public function setVerbosity(int $level): Runner {
+ $this->verbosity = $level;
+
+ return $this;
+ }
+
+ /**
+ * Determines if ANSI output should be used based on environment detection.
+ *
+ * Resolution precedence:
+ * 1. NO_COLOR env variable → false
+ * 2. posix_isatty(STDOUT) on Unix → true if TTY
+ * 3. Windows terminal env checks (ANSICON, ConEmuANSI, TERM)
+ * 4. Default → false
+ *
+ * @return bool True if ANSI should be enabled by default.
+ */
+ public static function shouldUseAnsi(): bool {
+ if (getenv('NO_COLOR') !== false || isset($_SERVER['NO_COLOR'])) {
+ return false;
+ }
+
+ if (function_exists('posix_isatty')) {
+ return defined('STDOUT') ? posix_isatty(STDOUT) : false;
+ }
+
+ if (DIRECTORY_SEPARATOR === '\\') {
+ return getenv('ANSICON') !== false
+ || getenv('ConEmuANSI') === 'ON'
+ || getenv('TERM') === 'xterm';
+ }
+
+ return false;
+ }
+
+ /**
+ * 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()) {
+ if (in_array('--no-color', $this->getArgsVector())) {
+ $this->isAnsi = false;
+ } else if (in_array('--ansi', $this->getArgsVector())) {
+ $this->isAnsi = true;
+ }
+ $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) : [];
+
+ $argsArr = $this->removeGlobalFlags($argsArr);
+
+ // 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();
+ }
+ }
+ /**
+ * Removes --ansi and --no-color flags from an arguments array.
+ *
+ * @param array $argsArr The arguments array.
+ *
+ * @return array The filtered arguments array.
+ */
+ private function removeGlobalFlags(array $argsArr): array {
+ $flags = ['--ansi', '--no-color', '-q', '-v', '-vv'];
+ $tempArgs = [];
+
+ foreach ($argsArr as $argName => $val) {
+ if (gettype($argName) == 'integer') {
+ if (!in_array($val, $flags)) {
+ $tempArgs[] = $val;
+ }
+ } else {
+ if (!in_array($argName, $flags)) {
+ $tempArgs[$argName] = $val;
+ }
+ }
+ }
+
+ return $tempArgs;
+ }
+
+ private function resolveAnsi(): void {
+ if ($this->outputStream instanceof StdOut) {
+ $this->isAnsi = self::shouldUseAnsi();
+ }
+ }
+
+ private function resolveVerbosity(): void {
+ $args = $this->getArgsVector();
+
+ if (in_array('-vv', $args)) {
+ $this->verbosity = Verbosity::DEBUG;
+ } else if (in_array('-v', $args)) {
+ $this->verbosity = Verbosity::VERBOSE;
+ } else if (in_array('-q', $args)) {
+ $this->verbosity = Verbosity::QUIET;
+ }
+ }
+
+ /**
+ * Run the command line as single run.
+ *
+ * @return int
+ */
+ private function run(): int {
+ $argsArr = array_slice($this->getArgsVector(), 1);
+
+ if (in_array('--no-color', $argsArr)) {
+ $this->isAnsi = false;
+ } else if (in_array('--ansi', $argsArr)) {
+ $this->isAnsi = true;
+ }
+
+ $argsArr = $this->removeGlobalFlags($argsArr);
+
+ // 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..f3fcd07
--- /dev/null
+++ b/WebFiori/Cli/SignalHandler.php
@@ -0,0 +1,163 @@
+
+ */
+ 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/WebFiori/Cli/Verbosity.php b/WebFiori/Cli/Verbosity.php
new file mode 100644
index 0000000..e1c91f8
--- /dev/null
+++ b/WebFiori/Cli/Verbosity.php
@@ -0,0 +1,37 @@
+println('Hello', ['color' => 'red']);
+
+ return 0;
+ }
+}
+
+class AnsiAutoDetectTest extends CommandTestCase {
+ /**
+ * @test
+ */
+ public function testShouldUseAnsiReturnsBoolean() {
+ $result = Runner::shouldUseAnsi();
+ $this->assertIsBool($result);
+ }
+
+ /**
+ * @test
+ */
+ public function testShouldUseAnsiRespectsNoColorEnv() {
+ putenv('NO_COLOR=1');
+ $this->assertFalse(Runner::shouldUseAnsi());
+ putenv('NO_COLOR');
+ }
+
+ /**
+ * @test
+ */
+ public function testShouldUseAnsiRespectsNoColorServer() {
+ $_SERVER['NO_COLOR'] = '1';
+ $this->assertFalse(Runner::shouldUseAnsi());
+ unset($_SERVER['NO_COLOR']);
+ }
+
+ /**
+ * @test
+ */
+ public function testIsAnsiDefaultFalseWithArrayOutputStream() {
+ $runner = new Runner();
+ $runner->reset();
+ $runner->setInputs([]);
+
+ // With ArrayOutputStream, isAnsi should be false
+ $this->assertFalse($runner->isAnsi());
+ }
+
+ /**
+ * @test
+ */
+ public function testIsAnsiTrueWhenForcedViaFlag() {
+ $runner = new Runner();
+ $runner->reset();
+ $runner->register(new AnsiAutoDetectCommand());
+ $runner->setInputs([]);
+ $runner->runCommand(null, ['ansi-test', '--ansi']);
+
+ $this->assertTrue($runner->isAnsi());
+ }
+
+ /**
+ * @test
+ */
+ public function testIsAnsiFalseWhenNoColorFlag() {
+ $runner = new Runner();
+ $runner->reset();
+ $runner->register(new AnsiAutoDetectCommand());
+ $runner->setInputs([]);
+ $runner->runCommand(null, ['ansi-test', '--no-color']);
+
+ $this->assertFalse($runner->isAnsi());
+ }
+
+ /**
+ * @test
+ */
+ public function testNoColorOverridesAnsi() {
+ // --no-color should take precedence when both are present
+ $runner = new Runner();
+ $runner->reset();
+ $runner->register(new AnsiAutoDetectCommand());
+ $runner->setInputs([]);
+ $runner->runCommand(null, ['ansi-test', '--ansi', '--no-color']);
+
+ $this->assertFalse($runner->isAnsi());
+ }
+
+ /**
+ * @test
+ */
+ public function testOutputHasNoAnsiWhenNoColor() {
+ $output = $this->executeSingleCommand(
+ new AnsiAutoDetectCommand(),
+ ['--no-color']
+ );
+
+ // Output should NOT contain ANSI escape sequences
+ foreach ($output as $line) {
+ $this->assertStringNotContainsString("\e[", $line);
+ }
+ }
+
+ /**
+ * @test
+ */
+ public function testOutputHasAnsiWhenForced() {
+ $output = $this->executeSingleCommand(
+ new AnsiAutoDetectCommand(),
+ ['--ansi']
+ );
+
+ // Output should contain ANSI escape sequences
+ $hasAnsi = false;
+
+ foreach ($output as $line) {
+ if (strpos($line, "\e[") !== false) {
+ $hasAnsi = true;
+
+ break;
+ }
+ }
+ $this->assertTrue($hasAnsi, 'Expected ANSI codes in output when --ansi is forced');
+ }
+
+ /**
+ * @test
+ */
+ public function testDefaultOutputNoAnsiInTestMode() {
+ // Without --ansi, in test mode (ArrayOutputStream), no ANSI
+ $output = $this->executeSingleCommand(new AnsiAutoDetectCommand());
+
+ foreach ($output as $line) {
+ $this->assertStringNotContainsString("\e[", $line);
+ }
+ }
+
+ /**
+ * @test
+ */
+ public function testInteractiveModeNoColorFlag() {
+ $runner = new Runner();
+ $runner->reset();
+ $runner->register(new AnsiAutoDetectCommand());
+ $runner->setArgsVector(['main.php', '-i', '--no-color']);
+ $runner->setInputs(['ansi-test', 'exit']);
+ $runner->start();
+
+ $output = $runner->getOutput();
+
+ foreach ($output as $line) {
+ $this->assertStringNotContainsString("\e[", $line);
+ }
+ }
+
+ /**
+ * @test
+ */
+ public function testInteractiveModeAnsiFlag() {
+ $runner = new Runner();
+ $runner->reset();
+ $runner->register(new AnsiAutoDetectCommand());
+ $runner->setArgsVector(['main.php', '-i', '--ansi']);
+ $runner->setInputs(['ansi-test', 'exit']);
+ $runner->start();
+
+ $output = $runner->getOutput();
+ $hasAnsi = false;
+
+ foreach ($output as $line) {
+ if (strpos($line, "\e[") !== false) {
+ $hasAnsi = true;
+
+ break;
+ }
+ }
+ $this->assertTrue($hasAnsi, 'Expected ANSI codes in interactive mode with --ansi');
+ }
+
+ /**
+ * @test
+ */
+ public function testRunMethodHandlesNoColor() {
+ $runner = new Runner();
+ $runner->reset();
+ $runner->register(new AnsiAutoDetectCommand());
+ $runner->setInputs([]);
+ $runner->setArgsVector(['main.php', 'ansi-test', '--no-color']);
+ $runner->start();
+
+ $output = $runner->getOutput();
+
+ foreach ($output as $line) {
+ $this->assertStringNotContainsString("\e[", $line);
+ }
+ }
+
+ /**
+ * @test
+ */
+ public function testRunMethodHandlesAnsiForce() {
+ $runner = new Runner();
+ $runner->reset();
+ $runner->register(new AnsiAutoDetectCommand());
+ $runner->setInputs([]);
+ $runner->setArgsVector(['main.php', 'ansi-test', '--ansi']);
+ $runner->start();
+
+ $output = $runner->getOutput();
+ $hasAnsi = false;
+
+ foreach ($output as $line) {
+ if (strpos($line, "\e[") !== false) {
+ $hasAnsi = true;
+
+ break;
+ }
+ }
+ $this->assertTrue($hasAnsi);
+ }
+
+ /**
+ * @test
+ */
+ public function testResolveAnsiOnlyAppliesWithStdOut() {
+ $runner = new Runner();
+ $runner->reset();
+
+ // With StdOut, resolveAnsi should apply TTY detection
+ $runner->setOutputStream(new StdOut());
+ // Can't directly call resolveAnsi (private), but we can check behavior
+ // After start() with StdOut, isAnsi depends on actual TTY
+ // We just verify it doesn't crash
+ $this->assertIsBool($runner->isAnsi());
+ }
+
+ /**
+ * @test
+ */
+ public function testNoColorEnvDisablesAnsi() {
+ putenv('NO_COLOR=1');
+
+ $runner = new Runner();
+ $runner->reset();
+ $runner->setOutputStream(new StdOut());
+ $runner->register(new AnsiAutoDetectCommand());
+ $runner->setInputs([]);
+ $runner->setArgsVector(['main.php', 'ansi-test']);
+ $runner->start();
+
+ // Even with StdOut, NO_COLOR env should disable ANSI
+ $output = $runner->getOutput();
+
+ foreach ($output as $line) {
+ $this->assertStringNotContainsString("\e[", $line);
+ }
+
+ putenv('NO_COLOR');
+ }
+
+ /**
+ * @test
+ */
+ public function testCommandIsAnsiEnabledUsesRunner() {
+ $runner = new Runner();
+ $runner->reset();
+
+ $ansiDetected = false;
+ $command = new class extends Command {
+ public $ansiValue = null;
+
+ public function __construct() {
+ parent::__construct('check-ansi', [], 'Check ANSI state');
+ }
+
+ public function exec(): int {
+ // Access owner's isAnsi through println behavior
+ $this->println('test', ['color' => 'red']);
+
+ return 0;
+ }
+ };
+
+ $runner->register($command);
+ $runner->setInputs([]);
+ $runner->runCommand(null, ['check-ansi', '--ansi']);
+
+ $output = $runner->getOutput();
+ $hasAnsi = false;
+
+ foreach ($output as $line) {
+ if (strpos($line, "\e[") !== false) {
+ $hasAnsi = true;
+
+ break;
+ }
+ }
+ $this->assertTrue($hasAnsi);
+ }
+
+ /**
+ * @test
+ */
+ public function testCommandWithoutOwnerFallsBackToArgCheck() {
+ // Command used standalone without Runner
+ $command = new AnsiAutoDetectCommand();
+ $command->setOutputStream(new ArrayOutputStream());
+
+ // Without owner, isAnsiEnabled falls back to isArgProvided
+ // which checks the command's own args
+ $command->excCommand();
+ // No crash = success. Output won't have ANSI since no --ansi arg.
+ $this->assertTrue(true);
+ }
+}
diff --git a/tests/WebFiori/Tests/Cli/CommandAttributesTest.php b/tests/WebFiori/Tests/Cli/CommandAttributesTest.php
new file mode 100644
index 0000000..8f0f2e5
--- /dev/null
+++ b/tests/WebFiori/Tests/Cli/CommandAttributesTest.php
@@ -0,0 +1,252 @@
+println('running');
+
+ return 0;
+ }
+}
+
+#[SingleInstance(lockPath: null, exitCode: 42)]
+class SingleInstanceCustomExitCommand extends Command {
+ public function __construct() {
+ parent::__construct('locked-custom', [], 'Custom exit code');
+ }
+
+ public function exec(): int {
+ $this->println('running');
+
+ return 0;
+ }
+}
+
+#[Group('db')]
+class DbMigrateCommand extends Command {
+ public function __construct() {
+ parent::__construct('db:migrate', [], 'Run database migrations');
+ }
+
+ public function exec(): int {
+ $this->println('migrating');
+
+ return 0;
+ }
+}
+
+#[Group('db')]
+class DbSeedCommand extends Command {
+ public function __construct() {
+ parent::__construct('db:seed', [], 'Seed the database');
+ }
+
+ public function exec(): int {
+ return 0;
+ }
+}
+
+class UngroupedCommand extends Command {
+ public function __construct() {
+ parent::__construct('serve', [], 'Start dev server');
+ }
+
+ public function exec(): int {
+ return 0;
+ }
+}
+
+class ColonGroupCommand extends Command {
+ public function __construct() {
+ parent::__construct('cache:clear', [], 'Clear the cache');
+ }
+
+ public function exec(): int {
+ return 0;
+ }
+}
+
+class CommandAttributesTest extends CommandTestCase {
+ /**
+ * @test
+ */
+ public function testSingleInstanceRunsNormally() {
+ $output = $this->executeSingleCommand(new SingleInstanceCommand());
+ $this->assertContains("running\n", $output);
+ $this->assertEquals(0, $this->getExitCode());
+ }
+
+ /**
+ * @test
+ */
+ public function testSingleInstanceBlocksConcurrent() {
+ // Acquire lock manually, then try to run the command
+ $lm = new \WebFiori\Cli\LockManager();
+ $this->assertTrue($lm->acquire('locked-cmd'));
+
+ $output = $this->executeSingleCommand(new SingleInstanceCommand());
+ $outputStr = implode('', $output);
+
+ $this->assertStringContainsString('already running', $outputStr);
+ $this->assertEquals(1, $this->getExitCode());
+
+ $lm->release();
+ }
+
+ /**
+ * @test
+ */
+ public function testSingleInstanceCustomExitCode() {
+ $lm = new \WebFiori\Cli\LockManager();
+ $this->assertTrue($lm->acquire('locked-custom'));
+
+ $this->executeSingleCommand(new SingleInstanceCustomExitCommand());
+ $this->assertEquals(42, $this->getExitCode());
+
+ $lm->release();
+ }
+
+ /**
+ * @test
+ */
+ public function testSingleInstanceReleasesLockAfterExec() {
+ $this->executeSingleCommand(new SingleInstanceCommand());
+
+ // Lock should be released — can acquire again
+ $lm = new \WebFiori\Cli\LockManager();
+ $this->assertTrue($lm->acquire('locked-cmd'));
+ $lm->release();
+ }
+
+ /**
+ * @test
+ */
+ public function testGroupAttributeResolved() {
+ $cmd = new DbMigrateCommand();
+ $runner = new Runner();
+ $runner->reset();
+ $runner->register($cmd);
+
+ $this->assertEquals('db', $cmd->getGroup());
+ }
+
+ /**
+ * @test
+ */
+ public function testGroupFromColonConvention() {
+ $cmd = new ColonGroupCommand();
+ $runner = new Runner();
+ $runner->reset();
+ $runner->register($cmd);
+
+ $this->assertEquals('cache', $cmd->getGroup());
+ }
+
+ /**
+ * @test
+ */
+ public function testExplicitGroupOverridesAttribute() {
+ $cmd = new DbMigrateCommand();
+ $cmd->setGroup('custom');
+ $runner = new Runner();
+ $runner->reset();
+ $runner->register($cmd);
+
+ // Explicit setGroup should win
+ $this->assertEquals('custom', $cmd->getGroup());
+ }
+
+ /**
+ * @test
+ */
+ public function testUngroupedCommandHasNullGroup() {
+ $cmd = new UngroupedCommand();
+ $runner = new Runner();
+ $runner->reset();
+ $runner->register($cmd);
+
+ $this->assertNull($cmd->getGroup());
+ }
+
+ /**
+ * @test
+ */
+ public function testHelpOutputGrouped() {
+ $runner = new Runner();
+ $runner->reset();
+ $runner->register(new DbMigrateCommand());
+ $runner->register(new DbSeedCommand());
+ $runner->register(new UngroupedCommand());
+ $runner->setInputs([]);
+ $runner->setArgsVector(['main.php', 'help']);
+ $runner->start();
+
+ $output = $runner->getOutput();
+ $outputStr = implode('', $output);
+
+ // Should show group header
+ $this->assertStringContainsString('db:', $outputStr);
+ // Should contain commands
+ $this->assertStringContainsString('db:migrate', $outputStr);
+ $this->assertStringContainsString('db:seed', $outputStr);
+ $this->assertStringContainsString('serve', $outputStr);
+ }
+
+ /**
+ * @test
+ */
+ public function testHelpOutputNoGroupsFlatList() {
+ $runner = new Runner();
+ $runner->reset();
+ $runner->register(new UngroupedCommand());
+ $runner->setInputs([]);
+ $runner->setArgsVector(['main.php', 'help']);
+ $runner->start();
+
+ $output = $runner->getOutput();
+ $outputStr = implode('', $output);
+
+ // No group headers for ungrouped-only output
+ $this->assertStringContainsString('serve', $outputStr);
+ }
+
+ /**
+ * @test
+ */
+ public function testGetGroupDefaultNull() {
+ $cmd = new UngroupedCommand();
+ $this->assertNull($cmd->getGroup());
+ }
+
+ /**
+ * @test
+ */
+ public function testSetGroup() {
+ $cmd = new UngroupedCommand();
+ $cmd->setGroup('mygroup');
+ $this->assertEquals('mygroup', $cmd->getGroup());
+ }
+}
diff --git a/tests/WebFiori/Tests/Cli/LockManagerTest.php b/tests/WebFiori/Tests/Cli/LockManagerTest.php
new file mode 100644
index 0000000..6cd7f3b
--- /dev/null
+++ b/tests/WebFiori/Tests/Cli/LockManagerTest.php
@@ -0,0 +1,104 @@
+assertFalse($lm->isLocked());
+ $this->assertNull($lm->getLockPath());
+ }
+
+ /**
+ * @test
+ */
+ public function testAcquireAndRelease() {
+ $lm = new LockManager();
+ $this->assertTrue($lm->acquire('test-cmd'));
+ $this->assertTrue($lm->isLocked());
+ $this->assertNotNull($lm->getLockPath());
+ $this->assertStringContainsString('wfcli-test-cmd.lock', $lm->getLockPath());
+
+ $lm->release();
+ $this->assertFalse($lm->isLocked());
+ }
+
+ /**
+ * @test
+ */
+ public function testCustomLockPath() {
+ $path = sys_get_temp_dir() . '/custom-test-lock.lock';
+ $lm = new LockManager();
+ $this->assertTrue($lm->acquire('ignored', $path));
+ $this->assertEquals($path, $lm->getLockPath());
+
+ $lm->release();
+ }
+
+ /**
+ * @test
+ */
+ public function testConcurrentAcquireFails() {
+ $lm1 = new LockManager();
+ $lm2 = new LockManager();
+
+ $this->assertTrue($lm1->acquire('concurrent-test'));
+ $this->assertFalse($lm2->acquire('concurrent-test'));
+
+ $lm1->release();
+
+ // Now second can acquire
+ $this->assertTrue($lm2->acquire('concurrent-test'));
+ $lm2->release();
+ }
+
+ /**
+ * @test
+ */
+ public function testReleaseWithoutAcquire() {
+ $lm = new LockManager();
+ // Should not throw
+ $lm->release();
+ $this->assertFalse($lm->isLocked());
+ }
+
+ /**
+ * @test
+ */
+ public function testLockFileContainsPid() {
+ $lm = new LockManager();
+ $lm->acquire('pid-test');
+ $path = $lm->getLockPath();
+ $content = file_get_contents($path);
+ $this->assertEquals((string) getmypid(), $content);
+
+ $lm->release();
+ }
+
+ /**
+ * @test
+ */
+ public function testAcquireFailsOnInvalidPath() {
+ $lm = new LockManager();
+ $result = $lm->acquire('test', '/nonexistent/dir/lock.lock');
+ $this->assertFalse($result);
+ $this->assertFalse($lm->isLocked());
+ }
+}
diff --git a/tests/WebFiori/Tests/Cli/RunnerTest.php b/tests/WebFiori/Tests/Cli/RunnerTest.php
index 8297d32..ad18b2e 100644
--- a/tests/WebFiori/Tests/Cli/RunnerTest.php
+++ b/tests/WebFiori/Tests/Cli/RunnerTest.php
@@ -1,1098 +1,1118 @@
-reset();
- $this->assertTrue($runner->getOutputStream() instanceof StdOut);
- $this->assertTrue($runner->getInputStream() instanceof StdIn);
- $runner->setInputStream(new ArrayInputStream());
- $runner->setOutputStream(new ArrayOutputStream());
- $this->assertFalse($runner->getOutputStream() instanceof StdOut);
- $this->assertFalse($runner->getInputStream() instanceof StdIn);
- $this->assertTrue($runner->getInputStream() instanceof ArrayInputStream);
- $this->assertTrue($runner->getOutputStream() instanceof ArrayOutputStream);
- }
- public function testIsCLI() {
- $this->assertTrue(Runner::isCLI());
- }
- /**
- * @test
- */
- public function testRunner00() {
- $runner = new Runner();
- $this->assertEquals([], $runner->getOutput());
- // Help command is automatically registered
- $this->assertEquals(['help'], array_keys($runner->getCommands()));
- $this->assertFalse($runner->addArg(' '));
- $this->assertFalse($runner->addArg(' invalid name '));
- $this->assertInstanceOf(\WebFiori\Cli\Commands\HelpCommand::class, $runner->getDefaultCommand());
- $this->assertNull($runner->getActiveCommand());
-
- $argObj = new Argument('--ansi');
- $this->assertFalse($runner->addArgument($argObj));
-
- $this->assertTrue($runner->addArg('global-arg', [
- ArgumentOption::OPTIONAL => true
- ]));
- $this->assertEquals(2, count($runner->getArgs()));
- $runner->removeArgument('--ansi');
- $this->assertEquals(1, count($runner->getArgs()));
- $this->assertFalse($runner->hasArg('--ansi'));
- $runner->register(new Command00());
- $this->assertEquals(2, count($runner->getCommands())); // help + super-hero
- $runner->register(new Command00());
- $this->assertEquals(2, count($runner->getCommands())); // Still 2, no duplicates
- $runner->setDefaultCommand('super-hero');
- $runner->setInputs([]);
- $this->assertEquals(0, $runner->runCommand(null, [
- 'name' => 'Ibrahim'
- ]));
- $this->assertEquals([
- "Hello hero Ibrahim\n"
- ], $runner->getOutput());
- }
- /**
- * @test
- */
- public function testRunner01() {
- $runner = new Runner();
- $this->assertEquals(0, $runner->getLastCommandExitStatus());
- $runner->setDefaultCommand('super-hero');
- // Since 'super-hero' is not registered, default remains the help command
- $this->assertInstanceOf(\WebFiori\Cli\Commands\HelpCommand::class, $runner->getDefaultCommand());
- $runner->setInputs([]);
- $this->assertEquals(-1, $runner->runCommand(null, [
- 'do-it',
- '--ansi'
- ]));
- $this->assertEquals(-1, $runner->getLastCommandExitStatus());
- $this->assertEquals([
- "Error: The command 'do-it' is not supported.\n"
- ], $runner->getOutput());
- }
- /**
- * @test
- */
- public function testRunner02() {
- $runner = new Runner();
- $runner->setDefaultCommand('super-hero');
- // Since 'super-hero' is not registered, default remains the help command
- $this->assertInstanceOf(\WebFiori\Cli\Commands\HelpCommand::class, $runner->getDefaultCommand());
- $runner->setInputs([]);
- $this->assertEquals(0, $runner->runCommand());
- $this->assertEquals(0, $runner->getLastCommandExitStatus());
- // Since default command is help, it will show help output instead of "No command" message
- $output = $runner->getOutput();
- $this->assertNotEmpty($output);
- $this->assertStringContainsString('Usage:', $output[0]);
- }
- /**
- * @test
- */
- public function testRunner03() {
- $this->assertEquals([
- "Error: The following argument(s) have invalid values: 'name'\n",
- "Info: Allowed values for the argument 'name':\n",
- "Ibrahim\n",
- "Ali\n"
- ], $this->executeSingleCommand(new Command00(), [
- 'super-hero',
- 'name' => 'Ok'
- ]));
- $this->assertEquals(-1, $this->getExitCode());
- }
- /**
- * @test
- */
- public function testRunner04() {
- $this->assertEquals([
- "\e[1;91mError: \e[0mThe following argument(s) have invalid values: 'name'\n",
- "\e[1;34mInfo: \e[0mAllowed values for the argument 'name':\n",
- "Ibrahim\n",
- "Ali\n"
- ], $this->executeSingleCommand(new Command00(), [
- 'name' => 'Ok',
- '--ansi'
- ]));
- $this->assertEquals(-1, $this->getExitCode());
- }
- /**
- * @test
- */
- public function testRunner05() {
- $runner = new Runner();
- $runner->register(new Command00());
- // Don't register HelpCommand again - it's already automatically registered
- $runner->removeArgument('--ansi');
- $runner->setDefaultCommand('help');
- $runner->setInputs([]);
- $this->assertEquals(0, $runner->runCommand(null, []));
- $this->assertEquals(0, $runner->getLastCommandExitStatus());
- $this->assertEquals([
- "Usage:\n",
- " command [arg1 arg2=\"val\" arg3...]\n\n",
- "Available Commands:\n",
- " help: Display CLI Help. To display help for specific command, use the argument \"--command\" with this command.\n",
- " super-hero: A command to display hero's name.\n"
- ], $runner->getOutput());
- }
- /**
- * @test
- */
- public function testRunner06() {
-
- $this->assertEquals([
- "Usage:\n",
- " command [arg1 arg2=\"val\" arg3...]\n\n",
- "Global Arguments:\n",
- " --ansi:[Optional] Force the use of ANSI output.\n",
- "Available Commands:\n",
- " help: Display CLI Help. To display help for specific command, use the argument \"--command\" with this command.\n",
- " super-hero: A command to display hero's name.\n"
- ], $this->executeMultiCommand([], [], [
- new Command00()
- // Don't register HelpCommand - it's automatically registered
- ], 'help'));
- $this->assertEquals(0, $this->getExitCode());
- }
- /**
- * @test
- */
- public function testRunner07() {
- $runner = new Runner();
- $runner->register(new Command00());
- $runner->setDefaultCommand('help');
- $runner->setInputs([]);
- $this->assertEquals(0, $runner->runCommand(new HelpCommand(), [
- '--ansi'
- ]));
- $this->assertEquals(0, $runner->getLastCommandExitStatus());
- $this->assertEquals([
- "\e[1;93mUsage:\e[0m\n",
- " command [arg1 arg2=\"val\" arg3...]\n\n",
- "\e[1;93mGlobal Arguments:\e[0m\n",
- "\e[1;33m --ansi:\e[0m[Optional] Force the use of ANSI output.\n",
- "\e[1;93mAvailable Commands:\e[0m\n",
- "\e[1;33m help\e[0m: Display CLI Help. To display help for specific command, use the argument \"--command\" with this command.\n",
- "\e[1;33m super-hero\e[0m: A command to display hero's name.\n"
- ], $runner->getOutput());
- }
- /**
- * @test
- */
- public function testRunner08() {
- $runner = new Runner();
- $runner->register(new Command00());
- $runner->setInputs([]);
- $this->assertEquals(0, $runner->runCommand(new HelpCommand(), [
- '--ansi',
- '--command' => 'super-hero'
- ]));
- $this->assertEquals([
- "\e[1;33m super-hero\e[0m: A command to display hero's name.\n",
- "\e[1;94m Supported Arguments:\e[0m\n",
- "\e[1;33m name:\e[0m The name of the hero\n",
- ], $runner->getOutput());
- }
- /**
- * @test
- */
- public function testRunner09() {
- $_SERVER['argv'] = [];
- $runner = new Runner();
- $runner->removeArgument('--ansi');
- $runner->register(new Command00());
- // Don't register HelpCommand - it's automatically registered
- $runner->setDefaultCommand('help');
- $runner->setInputs([]);
- $runner->start();
- $this->assertEquals([
- "Usage:\n",
- " command [arg1 arg2=\"val\" arg3...]\n\n",
- "Available Commands:\n",
- " help: Display CLI Help. To display help for specific command, use the argument \"--command\" with this command.\n",
- " super-hero: A command to display hero's name.\n"
- ], $runner->getOutput());
- $this->assertEquals(0, $runner->getLastCommandExitStatus());
- }
- /**
- * @test
- */
- public function testRunner10() {
- $runner = new Runner();
- $runner->register(new Command00());
- // Don't register HelpCommand - it's automatically registered
- $runner->setInputs([]);
- $runner->setArgsVector([
- 'entry.php',
- 'help',
- '--command' => 'super-hero'
- ]);
- $runner->start();
- $this->assertEquals([
- " super-hero: A command to display hero's name.\n",
- " Supported Arguments:\n",
- " name: The name of the hero\n",
- ], $runner->getOutput());
- }
- /**
- * @test
- */
- public function testRunner11() {
- $runner = new Runner();
- $runner->setBeforeStart(function (Runner $r) {
- $r->setArgsVector([
- 'entry.php',
- 'help',
- '--command' => 'super hero',
- '--ansi'
- ]);
- $r->register(new Command00());
- // Don't register HelpCommand - it's automatically registered
- $r->setInputs([]);
- });
- $runner->start();
- $this->assertEquals([
- "\e[1;91mError: \e[0mCommand 'super hero' is not supported.\n"
- ], $runner->getOutput());
- }
- /**
- * @test
- */
- public function testRunner12() {
-
- $runner = new Runner();
-
- $runner->register(new Command00());
- // Don't register HelpCommand - it's automatically registered
-
- $runner->setArgsVector([
- 'entry.php',
- '-i',
- ]);
- $runner->setInputs([
- 'exit'
- ]);
- $runner->start();
- $this->assertEquals([
- ">> Running in interactive mode.\n",
- ">> Type command name or 'exit' to close.\n",
- ">> "
- ], $runner->getOutput());
- $this->assertEquals(0, $runner->getLastCommandExitStatus());
- }
- /**
- * @test
- */
- public function testRunner13() {
-
- $runner = new Runner();
- $runner->register(new Command00());
- // Don't register HelpCommand - it's automatically registered
-
- $runner->setArgsVector([
- 'entry.php',
- '-i',
- ]);
- $runner->setInputs([
- 'help --ansi',
- 'exit'
- ]);
- $runner->start();
- $this->assertEquals([
- ">> Running in interactive mode.\n",
- ">> Type command name or 'exit' to close.\n",
- ">> Usage:\n",
- " command [arg1 arg2=\"val\" arg3...]\n\n",
- "Global Arguments:\n",
- " --ansi:[Optional] Force the use of ANSI output.\n",
- "Available Commands:\n",
- " help: Display CLI Help. To display help for specific command, use the argument \"--command\" with this command.\n",
- " super-hero: A command to display hero's name.\n",
- ">> ",
- ], $runner->getOutput());
- $this->assertEquals(0, $runner->getLastCommandExitStatus());
- }
- /**
- * @test
- */
- public function testRunner14() {
- $runner = new Runner();
-
- $runner->register(new Command00());
- // Don't register HelpCommand - it's automatically registered
-
- $runner->setArgsVector([
- 'entry.php',
- '-i',
- ]);
- $runner->setInputs([
- 'help --ansi --command=super-hero',
- 'super-hero name=Ibrahim',
- 'exit'
- ]);
- $runner->start();
- $this->assertEquals([
- ">> Running in interactive mode.\n",
- ">> Type command name or 'exit' to close.\n",
- ">> super-hero: A command to display hero's name.\n",
- " Supported Arguments:\n",
- " name: The name of the hero\n",
- ">> Hello hero Ibrahim\n",
- ">> "
- ], $runner->getOutput());
- }
- /**
- * @test
- */
- public function testRunner15() {
- $runner = new Runner();
- $runner->register(new Command00());
- // Don't register HelpCommand - it's automatically registered
- $runner->register(new WithExceptionCommand());
- $runner->setAfterExecution(function (Runner $r) {
- $r->getActiveCommand()->println('Command Exit Status: '.$r->getLastCommandExitStatus());
- });
- $runner->setArgsVector([
- 'entry.php',
- '--ansi',
- '-i',
- ]);
- $runner->setInputs([
- 'help --command=super-hero',
- 'with-exception',
- 'exit'
- ]);
- $runner->start();
- $output = $runner->getOutput();
- // Null out the stack trace content as it can vary
- for ($i = 12; $i < count($output) - 2; $i++) {
- if ($output[$i] !== null && strpos($output[$i], 'Command Exit Status: -1') === false && strpos($output[$i], '>> ') === false) {
- $output[$i] = null;
- }
- }
-
- $this->assertEquals([
- "[1;34m>>[0m Running in interactive mode.\n",
- "[1;34m>>[0m Type command name or 'exit' to close.\n",
- "[1;34m>>[0m [1;33m super-hero[0m: A command to display hero's name.\n",
- "[1;94m Supported Arguments:[0m\n",
- "[1;33m name:[0m The name of the hero\n",
- "Command Exit Status: 0\n",
- "[1;34m>>[0m [1;31mError:[0m An exception was thrown.\n",
- "[1;33mException Message:[0m Call to undefined method WebFiori\Tests\Cli\TestCommands\WithExceptionCommand::notExist()\n",
- "[1;33mCode:[0m 0\n",
- "[1;33mAt:[0m ".ROOT_DIR."tests".DS."WebFiori".DS."Tests".DS."Cli".DS."TestCommands".DS."WithExceptionCommand.php\n",
- "[1;33mLine:[0m 13\n",
- "[1;33mStack Trace:[0m \n\n",
- null,
- "Command Exit Status: -1\n",
- "[1;34m>>[0m ",
- ], $output);
- }
- /**
- * @test
- */
- public function testRunner16() {
- $runner = new Runner();
- $runner->register(new Command01());
- $runner->setInputs([]);
- $this->assertEquals(-1, $runner->runCommand(null, [
- 'show-v'
- ]));
- $this->assertEquals([
- "Error: The following required argument(s) are missing: 'arg-1', 'arg-2'\n"
- ], $runner->getOutput());
- }
- /**
- * @test
- */
- public function testRunner17() {
- $runner = new Runner();
- $runner->register(new Command01());
- $runner->setInputs([]);
- $this->assertEquals(-1, $runner->runCommand(null, [
- 'show-v',
- '--ansi'
- ]));
- $this->assertEquals([
- "\e[1;91mError: \e[0mThe following required argument(s) are missing: 'arg-1', 'arg-2'\n"
- ], $runner->getOutput());
- }
- /**
- * @test
- */
- public function testRunner18() {
- $runner = new Runner();
- $runner->register(new Command01());
- $runner->setInputs([]);
- $runner->setAfterExecution(function (Runner $r) {
- $r->getActiveCommand()->println('Command Exit Status: '.$r->getLastCommandExitStatus());
- });
- $this->assertEquals(0, $runner->runCommand(null, [
- 'show-v',
- 'arg-1' => 'Super Cool Arg',
- 'arg-2' => "First One is Coller",
- ]));
- $this->assertEquals([
- "System version: 1.0.0\n",
- "Super Cool Arg\n",
- "First One is Coller\n",
- "Hello\n",
- "Command Exit Status: 0\n"
- ], $runner->getOutput());
- }
- /**
- * @test
- */
- public function testRunner19() {
- $runner = new Runner();
- $runner->register(new Command00());
- // Don't register HelpCommand - it's automatically registered
- $runner->register(new WithExceptionCommand());
- $runner->setArgsVector([
- 'entry.php',
- '-i',
- ]);
- $runner->setInputs([
- '',
- '',
- 'exit'
- ]);
- $this->assertEquals(0, $runner->start());
- $this->assertEquals([
- ">> Running in interactive mode.\n",
- ">> Type command name or 'exit' to close.\n",
- ">> No input.\n",
- ">> No input.\n",
- ">> "
- ], $runner->getOutput());
- }
- /**
- * @test
- */
- public function testRunner20() {
- $runner = new Runner();
- $runner->register(new Command00());
- // Don't register HelpCommand - it's automatically registered
- $runner->register(new WithExceptionCommand());
- $runner->setArgsVector([
- 'entry.php',
- '--ansi',
- ]);
- $runner->setInputs([
-
- ]);
- $runner->start();
- //$this->assertEquals(0, $runner->start());
- // Since help command is now the default, it will show help output instead of "No command" message
- $output = $runner->getOutput();
- $this->assertNotEmpty($output);
- $this->assertStringContainsString('Usage:', $output[0]);
- }
- /**
- * @test
- */
- public function testRunner21() {
- $runner = new Runner();
- $runner->setArgsVector([
-
- ]);
- $runner->setInputStream(new ArrayInputStream([
-
- ]));
- $runner->setOutputStream(new ArrayOutputStream());
-
- $this->assertEquals([
-
- ], $runner->getOutput());
- $runner->register(new Command00());
- // Don't register HelpCommand - it's automatically registered
- $runner->register(new WithExceptionCommand());
- $runner->setAfterExecution(function (Runner $r) {
- $r->getActiveCommand()->println('Command Exit Status: '.$r->getLastCommandExitStatus());
- });
-
- $runner->setArgsVector([
- 'entry.php',
- 'with-exception',
- ]);
- $runner->setInputs([]);
- $runner->start();
- $output = $runner->getOutput();
- //Removing the trace
- $output[6] = null;
- $this->assertEquals([
- "Error: An exception was thrown.\n",
- "Exception Message: Call to undefined method WebFiori\\Tests\\Cli\\TestCommands\\WithExceptionCommand::notExist()\n",
- "Code: 0\n",
- "At: ".\ROOT_DIR."tests".\DS."WebFiori".\DS."Tests".\DS."Cli".\DS."TestCommands".\DS."WithExceptionCommand.php\n",
- "Line: 13\n",
- "Stack Trace: \n\n",
- null,
- "Command Exit Status: -1\n"
- ], $output);
- }
- public function testRunner22() {
- $runner = new Runner();
- $runner->register(new Command03());
- $runner->setArgsVector([
- 'entry.php',
- 'run-another',
- 'arg-1' => 'Nice',
- 'arg-2' => 'Cool'
- ]);
- $runner->setInputStream(new ArrayInputStream([
-
- ]));
- $runner->setOutputStream(new ArrayOutputStream());
- $exitCode = $runner->start();
- $output = $runner->getOutput();
- $this->assertEquals([
- "Running Sub Command\n",
- "System version: 1.0.0\n",
- "Nice\n",
- "Cool\n",
- "Ur\n",
- "Done\n",
- ], $output);
- }
- /**
- * @test
- */
- public function test00() {
- $runner = new Runner();
- $runner->setInputs([]);
- $runner->setArgsVector([
-
- ]);
- $this->assertEquals([
-
- ], $runner->getOutput());
- }
- /**
- * Test Runner initialization and basic properties
- * @test
- */
- public function testRunnerInitializationEnhanced() {
- $runner = new Runner();
-
- // Test initial state
- $this->assertNull($runner->getActiveCommand());
- $this->assertNotNull($runner->getInputStream());
- $this->assertNotNull($runner->getOutputStream());
- $this->assertEquals(0, $runner->getLastCommandExitStatus());
- $this->assertFalse($runner->isInteractive());
- }
-
- /**
- * Test command registration with aliases
- * @test
- */
- public function testCommandRegistrationWithAliasesEnhanced() {
- $runner = new Runner();
- $command = new TestCommand('test-cmd', [], 'Test command');
-
- // Register command with aliases
- $result = $runner->register($command, ['tc', 'test']);
- $this->assertSame($runner, $result); // Should return self for chaining
-
- // Test command is registered
- $this->assertSame($command, $runner->getCommandByName('test-cmd'));
-
- // Test aliases are registered
- $this->assertTrue($runner->hasAlias('tc'));
- $this->assertTrue($runner->hasAlias('test'));
- $this->assertEquals('test-cmd', $runner->resolveAlias('tc'));
- $this->assertEquals('test-cmd', $runner->resolveAlias('test'));
-
- // Test getting all aliases
- $aliases = $runner->getAliases();
- $this->assertArrayHasKey('tc', $aliases);
- $this->assertArrayHasKey('test', $aliases);
- $this->assertEquals('test-cmd', $aliases['tc']);
- $this->assertEquals('test-cmd', $aliases['test']);
- }
-
- /**
- * Test duplicate command registration
- * @test
- */
- public function testDuplicateCommandRegistrationEnhanced() {
- $runner = new Runner();
- $command1 = new TestCommand('test-cmd', [], 'First command');
- $command2 = new TestCommand('test-cmd', [], 'Second command');
-
- // Register first command
- $runner->register($command1);
- $this->assertSame($command1, $runner->getCommandByName('test-cmd'));
-
- // Register second command with same name (should replace)
- $runner->register($command2);
- $this->assertSame($command2, $runner->getCommandByName('test-cmd'));
- }
-
- /**
- * Test global arguments
- * @test
- */
- public function testGlobalArgumentsEnhanced() {
- $runner = new Runner();
-
- // Add global arguments
- $this->assertTrue($runner->addArg('--global-arg', [
- ArgumentOption::OPTIONAL => true,
- ArgumentOption::DESCRIPTION => 'Global argument'
- ]));
-
- // Test duplicate global argument
- $this->assertFalse($runner->addArg('--global-arg', [])); // Should fail
-
- // Test argument exists
- $this->assertTrue($runner->hasArg('--global-arg'));
- $this->assertFalse($runner->hasArg('--non-existent'));
-
- // Test removing argument
- $this->assertTrue($runner->removeArgument('--global-arg'));
- $this->assertFalse($runner->hasArg('--global-arg'));
-
- // Test removing non-existent argument
- $this->assertFalse($runner->removeArgument('--non-existent'));
- }
-
- /**
- * Test arguments vector handling
- * @test
- */
- public function testArgumentsVectorEnhanced() {
- $runner = new Runner();
-
- $argsVector = ['script.php', 'command', '--arg1=value1', '--arg2', 'value2'];
- $runner->setArgsVector($argsVector);
-
- $this->assertEquals($argsVector, $runner->getArgsVector());
- }
-
- /**
- * Test stream handling
- * @test
- */
- public function testStreamHandlingEnhanced() {
- $runner = new Runner();
-
- // Test setting custom streams
- $customInput = new ArrayInputStream(['test input']);
- $customOutput = new ArrayOutputStream();
-
- $result1 = $runner->setInputStream($customInput);
- $this->assertSame($runner, $result1); // Should return self
- $this->assertSame($customInput, $runner->getInputStream());
-
- $result2 = $runner->setOutputStream($customOutput);
- $this->assertSame($runner, $result2); // Should return self
- $this->assertSame($customOutput, $runner->getOutputStream());
- }
-
- /**
- * Test inputs array handling
- * @test
- */
- public function testInputsArrayHandlingEnhanced() {
- $runner = new Runner();
-
- $inputs = ['input1', 'input2', 'input3'];
- $result = $runner->setInputs($inputs);
- $this->assertSame($runner, $result); // Should return self
-
- // The inputs should be set as ArrayInputStream
- $inputStream = $runner->getInputStream();
- $this->assertInstanceOf(ArrayInputStream::class, $inputStream);
- }
-
- /**
- * Test command execution
- * @test
- */
- public function testCommandExecutionEnhanced() {
- $runner = new Runner();
- $command = new TestCommand('test-cmd');
- $output = new ArrayOutputStream();
-
- $runner->register($command);
- $runner->setOutputStream($output);
-
- // Test running command
- $exitCode = $runner->runCommand($command);
- $this->assertEquals(0, $exitCode); // TestCommand should return 0
- $this->assertEquals(0, $runner->getLastCommandExitStatus());
-
- // Test running with arguments
- $exitCode2 = $runner->runCommand($command, ['--test-arg' => 'value']);
- $this->assertEquals(0, $exitCode2);
-
- // Test running with ANSI
- $exitCode3 = $runner->runCommand($command, [], true);
- $this->assertEquals(0, $exitCode3);
- }
-
- /**
- * Test sub-command execution
- * @test
- */
- public function testSubCommandExecutionEnhanced() {
- $runner = new Runner();
- $runner->setOutputStream(new ArrayOutputStream());
- $mainCommand = new TestCommand('main-cmd');
- $subCommand = new TestCommand('sub-cmd');
-
- $runner->register($mainCommand);
- $runner->register($subCommand);
-
- // Test running sub-command
- $exitCode = $runner->runCommandAsSub('sub-cmd');
- $this->assertEquals(0, $exitCode);
-
- // Test running non-existent sub-command
- $exitCode2 = $runner->runCommandAsSub('non-existent');
- $this->assertEquals(-1, $exitCode2);
- }
-
- /**
- * Test active command management
- * @test
- */
- public function testActiveCommandManagementEnhanced() {
- $runner = new Runner();
- $command = new TestCommand('test-cmd');
-
- // Initially no active command
- $this->assertNull($runner->getActiveCommand());
-
- // Set active command
- $result = $runner->setActiveCommand($command);
- $this->assertSame($runner, $result); // Should return self
- $this->assertSame($command, $runner->getActiveCommand());
-
- // Clear active command
- $runner->setActiveCommand(null);
- $this->assertNull($runner->getActiveCommand());
- }
-
- /**
- * Test callback functionality
- * @test
- */
- public function testCallbacksEnhanced() {
- $runner = new Runner();
- $callbackExecuted = false;
-
- // Test before start callback
- $beforeCallback = function() use (&$callbackExecuted) {
- $callbackExecuted = true;
- };
-
- $result = $runner->setBeforeStart($beforeCallback);
- $this->assertSame($runner, $result); // Should return self
-
- // Test after execution callback
- $afterCallback = function($exitCode, $command) {
- // Callback should receive exit code and command
- $this->assertIsInt($exitCode);
- };
-
- $result2 = $runner->setAfterExecution($afterCallback, ['param1', 'param2']);
- $this->assertSame($runner, $result2); // Should return self
- }
-
- /**
- * Test output collection
- * @test
- */
- public function testOutputCollectionEnhanced() {
- $runner = new Runner();
- $command = new TestCommand('test-cmd');
- $output = new ArrayOutputStream();
-
- $runner->register($command);
- $runner->setOutputStream($output);
-
- // Run command to generate output
- $runner->runCommand($command);
-
- // Test getting output
- $outputArray = $runner->getOutput();
- $this->assertIsArray($outputArray);
- $this->assertNotEmpty($outputArray);
- }
-
- /**
- * Test alias resolution edge cases
- * @test
- */
- public function testAliasResolutionEdgeCasesEnhanced() {
- $runner = new Runner();
-
- // Test resolving non-existent alias
- $this->assertNull($runner->resolveAlias('non-existent'));
-
- // Test resolving actual command name (not alias)
- $command = new TestCommand('test-cmd');
- $runner->register($command);
- $this->assertNull($runner->resolveAlias('test-cmd')); // Should return null for actual command names
- }
-
- /**
- * Test command retrieval edge cases
- * @test
- */
- public function testCommandRetrievalEdgeCasesEnhanced() {
- $runner = new Runner();
-
- // Test getting non-existent command
- $this->assertNull($runner->getCommandByName('non-existent'));
-
- // Test getting command by alias
- $command = new TestCommand('test-cmd');
- $runner->register($command, ['tc']);
-
- // Should find command by alias using getCommandByName (enhanced functionality)
- $this->assertSame($command, $runner->getCommandByName('tc'));
- $this->assertSame($command, $runner->getCommandByName('test-cmd'));
- }
-
- /**
- * Test argument object handling
- * @test
- */
- public function testArgumentObjectHandlingEnhanced() {
- $runner = new Runner();
-
- // Test adding Argument object
- $arg = new Argument('--test-arg');
- $arg->setDescription('Test argument');
-
- $result = $runner->addArgument($arg);
- $this->assertTrue($result);
- $this->assertTrue($runner->hasArg('--test-arg'));
-
- // Test adding duplicate Argument object
- $arg2 = new Argument('--test-arg');
- $result2 = $runner->addArgument($arg2);
- $this->assertFalse($result2); // Should fail for duplicate
- }
-
- /**
- * Test interactive mode detection
- * @test
- */
- public function testInteractiveModeDetectionEnhanced() {
- $runner = new Runner();
-
- // Initially not interactive
- $this->assertFalse($runner->isInteractive());
-
- // Set args vector with -i flag
- $runner->setArgsVector(['script.php', '-i']);
- // Note: The actual interactive detection might depend on the start() method implementation
- }
-
- /**
- * Test command discovery methods (if available)
- * @test
- */
- public function testCommandDiscoveryMethodsEnhanced() {
- $runner = new Runner();
-
- // Test auto-discovery state
- $this->assertFalse($runner->isAutoDiscoveryEnabled()); // Default should be false
-
- // Test enabling auto-discovery
- $result = $runner->enableAutoDiscovery();
- $this->assertSame($runner, $result);
- $this->assertTrue($runner->isAutoDiscoveryEnabled());
-
- // Test disabling auto-discovery
- $result2 = $runner->disableAutoDiscovery();
- $this->assertSame($runner, $result2);
- $this->assertFalse($runner->isAutoDiscoveryEnabled());
-
- // Test exclude patterns
- $result5 = $runner->excludePattern('*Test*');
- $this->assertSame($runner, $result5);
-
- $result6 = $runner->excludePatterns(['*Test*', '*Mock*']);
- $this->assertSame($runner, $result6);
-
- // Test discovery cache
- $result7 = $runner->enableDiscoveryCache('test-cache.json');
- $this->assertSame($runner, $result7);
-
- $result8 = $runner->disableDiscoveryCache();
- $this->assertSame($runner, $result8);
-
- $result9 = $runner->clearDiscoveryCache();
- $this->assertSame($runner, $result9);
-
- // Test strict mode
- $result10 = $runner->setDiscoveryStrictMode(true);
- $this->assertSame($runner, $result10);
-
- $result11 = $runner->setDiscoveryStrictMode(false);
- $this->assertSame($runner, $result11);
- }
- /**
- * Test command help pattern in interactive mode.
- * @test
- */
- public function testCommandHelpInteractive() {
- $runner = new Runner();
- $runner->register(new Command00());
- // Don't register HelpCommand - it's automatically registered
-
- $runner->setArgsVector([
- 'entry.php',
- '-i',
- ]);
- $runner->setInputs([
- 'super-hero help',
- 'exit'
- ]);
- $runner->start();
-
- $output = $runner->getOutput();
-
- // Should show help for super-hero command
- $this->assertContains(">> super-hero: A command to display hero's name.\n", $output);
- $this->assertContains(" Supported Arguments:\n", $output);
- $this->assertEquals(0, $runner->getLastCommandExitStatus());
- }
-
- /**
- * Test command -h pattern in interactive mode.
- * @test
- */
- public function testCommandDashHInteractive() {
- $runner = new Runner();
- $runner->register(new Command00());
- // Don't register HelpCommand - it's automatically registered
-
- $runner->setArgsVector([
- 'entry.php',
- '-i',
- ]);
- $runner->setInputs([
- 'super-hero -h',
- 'exit'
- ]);
- $runner->start();
-
- $output = $runner->getOutput();
-
- // Should show help for super-hero command
- $this->assertContains(">> super-hero: A command to display hero's name.\n", $output);
- $this->assertContains(" Supported Arguments:\n", $output);
- $this->assertEquals(0, $runner->getLastCommandExitStatus());
- }
-
- /**
- * Test command help pattern in non-interactive mode.
- * @test
- */
- public function testCommandHelpNonInteractive() {
- $runner = new Runner();
- $runner->register(new Command00());
- // Don't register HelpCommand - it's automatically registered
- $runner->setInputs([]);
-
- $runner->setArgsVector([
- 'entry.php',
- 'super-hero',
- 'help'
- ]);
- $runner->start();
-
- $output = $runner->getOutput();
-
- // Should show help for super-hero command
- $this->assertContains(" super-hero: A command to display hero's name.\n", $output);
- $this->assertContains(" Supported Arguments:\n", $output);
- $this->assertEquals(0, $runner->getLastCommandExitStatus());
- }
-
- /**
- * Test command -h pattern in non-interactive mode.
- * @test
- */
- public function testCommandDashHNonInteractive() {
- $runner = new Runner();
- $runner->register(new Command00());
- // Don't register HelpCommand - it's automatically registered
- $runner->setInputs([]);
-
- $runner->setArgsVector([
- 'entry.php',
- 'super-hero',
- '-h'
- ]);
- $runner->start();
-
- $output = $runner->getOutput();
-
- // Should show help for super-hero command
- $this->assertContains(" super-hero: A command to display hero's name.\n", $output);
- $this->assertContains(" Supported Arguments:\n", $output);
- $this->assertEquals(0, $runner->getLastCommandExitStatus());
- }
-
- /**
- * Test that invalid command with help doesn't trigger help.
- * @test
- */
- public function testInvalidCommandHelp() {
- $runner = new Runner();
- $runner->register(new Command00());
- // Don't register HelpCommand - it's automatically registered
-
- $runner->setArgsVector([
- 'entry.php',
- '-i',
- ]);
- $runner->setInputs([
- 'invalid-command help',
- 'exit'
- ]);
- $runner->start();
-
- $output = $runner->getOutput();
-
- // Should show error for invalid command, not help
- $this->assertContains(">> Error: The command 'invalid-command' is not supported.\n", $output);
- $this->assertEquals(-1, $runner->getLastCommandExitStatus());
- }
-}
+reset();
+ $this->assertTrue($runner->getOutputStream() instanceof StdOut);
+ $this->assertTrue($runner->getInputStream() instanceof StdIn);
+ $runner->setInputStream(new ArrayInputStream());
+ $runner->setOutputStream(new ArrayOutputStream());
+ $this->assertFalse($runner->getOutputStream() instanceof StdOut);
+ $this->assertFalse($runner->getInputStream() instanceof StdIn);
+ $this->assertTrue($runner->getInputStream() instanceof ArrayInputStream);
+ $this->assertTrue($runner->getOutputStream() instanceof ArrayOutputStream);
+ }
+ public function testIsCLI() {
+ $this->assertTrue(Runner::isCLI());
+ }
+ /**
+ * @test
+ */
+ public function testRunner00() {
+ $runner = new Runner();
+ $this->assertEquals([], $runner->getOutput());
+ // Help command is automatically registered
+ $this->assertEquals(['help'], array_keys($runner->getCommands()));
+ $this->assertFalse($runner->addArg(' '));
+ $this->assertFalse($runner->addArg(' invalid name '));
+ $this->assertInstanceOf(\WebFiori\Cli\Commands\HelpCommand::class, $runner->getDefaultCommand());
+ $this->assertNull($runner->getActiveCommand());
+
+ $argObj = new Argument('--ansi');
+ $this->assertFalse($runner->addArgument($argObj));
+
+ $this->assertTrue($runner->addArg('global-arg', [
+ ArgumentOption::OPTIONAL => true
+ ]));
+ $this->assertEquals(6, count($runner->getArgs()));
+ $runner->removeArgument('--ansi');
+ $this->assertEquals(5, count($runner->getArgs()));
+ $this->assertFalse($runner->hasArg('--ansi'));
+ $runner->register(new Command00());
+ $this->assertEquals(2, count($runner->getCommands())); // help + super-hero
+ $runner->register(new Command00());
+ $this->assertEquals(2, count($runner->getCommands())); // Still 2, no duplicates
+ $runner->setDefaultCommand('super-hero');
+ $runner->setInputs([]);
+ $this->assertEquals(0, $runner->runCommand(null, [
+ 'name' => 'Ibrahim'
+ ]));
+ $this->assertEquals([
+ "Hello hero Ibrahim\n"
+ ], $runner->getOutput());
+ }
+ /**
+ * @test
+ */
+ public function testRunner01() {
+ $runner = new Runner();
+ $this->assertEquals(0, $runner->getLastCommandExitStatus());
+ $runner->setDefaultCommand('super-hero');
+ // Since 'super-hero' is not registered, default remains the help command
+ $this->assertInstanceOf(\WebFiori\Cli\Commands\HelpCommand::class, $runner->getDefaultCommand());
+ $runner->setInputs([]);
+ $this->assertEquals(-1, $runner->runCommand(null, [
+ 'do-it',
+ '--ansi'
+ ]));
+ $this->assertEquals(-1, $runner->getLastCommandExitStatus());
+ $this->assertEquals([
+ "Error: The command 'do-it' is not supported.\n"
+ ], $runner->getOutput());
+ }
+ /**
+ * @test
+ */
+ public function testRunner02() {
+ $runner = new Runner();
+ $runner->setDefaultCommand('super-hero');
+ // Since 'super-hero' is not registered, default remains the help command
+ $this->assertInstanceOf(\WebFiori\Cli\Commands\HelpCommand::class, $runner->getDefaultCommand());
+ $runner->setInputs([]);
+ $this->assertEquals(0, $runner->runCommand());
+ $this->assertEquals(0, $runner->getLastCommandExitStatus());
+ // Since default command is help, it will show help output instead of "No command" message
+ $output = $runner->getOutput();
+ $this->assertNotEmpty($output);
+ $this->assertStringContainsString('Usage:', $output[0]);
+ }
+ /**
+ * @test
+ */
+ public function testRunner03() {
+ $this->assertEquals([
+ "Error: The following argument(s) have invalid values: 'name'\n",
+ "Info: Allowed values for the argument 'name':\n",
+ "Ibrahim\n",
+ "Ali\n"
+ ], $this->executeSingleCommand(new Command00(), [
+ 'super-hero',
+ 'name' => 'Ok'
+ ]));
+ $this->assertEquals(-1, $this->getExitCode());
+ }
+ /**
+ * @test
+ */
+ public function testRunner04() {
+ $this->assertEquals([
+ "\e[1;91mError: \e[0mThe following argument(s) have invalid values: 'name'\n",
+ "\e[1;34mInfo: \e[0mAllowed values for the argument 'name':\n",
+ "Ibrahim\n",
+ "Ali\n"
+ ], $this->executeSingleCommand(new Command00(), [
+ 'name' => 'Ok',
+ '--ansi'
+ ]));
+ $this->assertEquals(-1, $this->getExitCode());
+ }
+ /**
+ * @test
+ */
+ public function testRunner05() {
+ $runner = new Runner();
+ $runner->register(new Command00());
+ // Don't register HelpCommand again - it's already automatically registered
+ $runner->removeArgument('--ansi');
+ $runner->removeArgument('--no-color');
+ $runner->removeArgument('-q');
+ $runner->removeArgument('-v');
+ $runner->removeArgument('-vv');
+ $runner->setDefaultCommand('help');
+ $runner->setInputs([]);
+ $this->assertEquals(0, $runner->runCommand(null, []));
+ $this->assertEquals(0, $runner->getLastCommandExitStatus());
+ $this->assertEquals([
+ "Usage:\n",
+ " command [arg1 arg2=\"val\" arg3...]\n\n",
+ "Available Commands:\n",
+ " help: Display CLI Help. To display help for specific command, use the argument \"--command\" with this command.\n",
+ " super-hero: A command to display hero's name.\n"
+ ], $runner->getOutput());
+ }
+ /**
+ * @test
+ */
+ public function testRunner06() {
+
+ $this->assertEquals([
+ "Usage:\n",
+ " command [arg1 arg2=\"val\" arg3...]\n\n",
+ "Global Arguments:\n",
+ " --ansi:[Optional] Force the use of ANSI output.\n",
+ " --no-color:[Optional] Disable ANSI colored output.\n",
+ " -q:[Optional] Quiet mode. Suppress non-critical output.\n",
+ " -v:[Optional] Verbose output.\n",
+ " -vv:[Optional] Debug output (most verbose).\n",
+ "Available Commands:\n",
+ " help: Display CLI Help. To display help for specific command, use the argument \"--command\" with this command.\n",
+ " super-hero: A command to display hero's name.\n"
+ ], $this->executeMultiCommand([], [], [
+ new Command00()
+ // Don't register HelpCommand - it's automatically registered
+ ], 'help'));
+ $this->assertEquals(0, $this->getExitCode());
+ }
+ /**
+ * @test
+ */
+ public function testRunner07() {
+ $runner = new Runner();
+ $runner->register(new Command00());
+ $runner->setDefaultCommand('help');
+ $runner->setInputs([]);
+ $this->assertEquals(0, $runner->runCommand(new HelpCommand(), [
+ '--ansi'
+ ]));
+ $this->assertEquals(0, $runner->getLastCommandExitStatus());
+ $this->assertEquals([
+ "\e[1;93mUsage:\e[0m\n",
+ " command [arg1 arg2=\"val\" arg3...]\n\n",
+ "\e[1;93mGlobal Arguments:\e[0m\n",
+ "\e[1;33m --ansi:\e[0m[Optional] Force the use of ANSI output.\n",
+ "\e[1;33m --no-color:\e[0m[Optional] Disable ANSI colored output.\n",
+ "\e[1;33m -q:\e[0m[Optional] Quiet mode. Suppress non-critical output.\n",
+ "\e[1;33m -v:\e[0m[Optional] Verbose output.\n",
+ "\e[1;33m -vv:\e[0m[Optional] Debug output (most verbose).\n",
+ "\e[1;93mAvailable Commands:\e[0m\n",
+ "\e[1;33m help\e[0m: Display CLI Help. To display help for specific command, use the argument \"--command\" with this command.\n",
+ "\e[1;33m super-hero\e[0m: A command to display hero's name.\n"
+ ], $runner->getOutput());
+ }
+ /**
+ * @test
+ */
+ public function testRunner08() {
+ $runner = new Runner();
+ $runner->register(new Command00());
+ $runner->setInputs([]);
+ $this->assertEquals(0, $runner->runCommand(new HelpCommand(), [
+ '--ansi',
+ '--command' => 'super-hero'
+ ]));
+ $this->assertEquals([
+ "\e[1;33m super-hero\e[0m: A command to display hero's name.\n",
+ "\e[1;94m Supported Arguments:\e[0m\n",
+ "\e[1;33m name:\e[0m The name of the hero\n",
+ ], $runner->getOutput());
+ }
+ /**
+ * @test
+ */
+ public function testRunner09() {
+ $_SERVER['argv'] = [];
+ $runner = new Runner();
+ $runner->removeArgument('--ansi');
+ $runner->removeArgument('--no-color');
+ $runner->removeArgument('-q');
+ $runner->removeArgument('-v');
+ $runner->removeArgument('-vv');
+ $runner->register(new Command00());
+ // Don't register HelpCommand - it's automatically registered
+ $runner->setDefaultCommand('help');
+ $runner->setInputs([]);
+ $runner->start();
+ $this->assertEquals([
+ "Usage:\n",
+ " command [arg1 arg2=\"val\" arg3...]\n\n",
+ "Available Commands:\n",
+ " help: Display CLI Help. To display help for specific command, use the argument \"--command\" with this command.\n",
+ " super-hero: A command to display hero's name.\n"
+ ], $runner->getOutput());
+ $this->assertEquals(0, $runner->getLastCommandExitStatus());
+ }
+ /**
+ * @test
+ */
+ public function testRunner10() {
+ $runner = new Runner();
+ $runner->register(new Command00());
+ // Don't register HelpCommand - it's automatically registered
+ $runner->setInputs([]);
+ $runner->setArgsVector([
+ 'entry.php',
+ 'help',
+ '--command' => 'super-hero'
+ ]);
+ $runner->start();
+ $this->assertEquals([
+ " super-hero: A command to display hero's name.\n",
+ " Supported Arguments:\n",
+ " name: The name of the hero\n",
+ ], $runner->getOutput());
+ }
+ /**
+ * @test
+ */
+ public function testRunner11() {
+ $runner = new Runner();
+ $runner->setBeforeStart(function (Runner $r) {
+ $r->setArgsVector([
+ 'entry.php',
+ 'help',
+ '--command' => 'super hero',
+ '--ansi'
+ ]);
+ $r->register(new Command00());
+ // Don't register HelpCommand - it's automatically registered
+ $r->setInputs([]);
+ });
+ $runner->start();
+ $this->assertEquals([
+ "\e[1;91mError: \e[0mCommand 'super hero' is not supported.\n"
+ ], $runner->getOutput());
+ }
+ /**
+ * @test
+ */
+ public function testRunner12() {
+
+ $runner = new Runner();
+
+ $runner->register(new Command00());
+ // Don't register HelpCommand - it's automatically registered
+
+ $runner->setArgsVector([
+ 'entry.php',
+ '-i',
+ ]);
+ $runner->setInputs([
+ 'exit'
+ ]);
+ $runner->start();
+ $this->assertEquals([
+ ">> Running in interactive mode.\n",
+ ">> Type command name or 'exit' to close.\n",
+ ">> "
+ ], $runner->getOutput());
+ $this->assertEquals(0, $runner->getLastCommandExitStatus());
+ }
+ /**
+ * @test
+ */
+ public function testRunner13() {
+
+ $runner = new Runner();
+ $runner->register(new Command00());
+ // Don't register HelpCommand - it's automatically registered
+
+ $runner->setArgsVector([
+ 'entry.php',
+ '-i',
+ ]);
+ $runner->setInputs([
+ 'help --ansi',
+ 'exit'
+ ]);
+ $runner->start();
+ $this->assertEquals([
+ ">> Running in interactive mode.\n",
+ ">> Type command name or 'exit' to close.\n",
+ ">> Usage:\n",
+ " command [arg1 arg2=\"val\" arg3...]\n\n",
+ "Global Arguments:\n",
+ " --ansi:[Optional] Force the use of ANSI output.\n",
+ " --no-color:[Optional] Disable ANSI colored output.\n",
+ " -q:[Optional] Quiet mode. Suppress non-critical output.\n",
+ " -v:[Optional] Verbose output.\n",
+ " -vv:[Optional] Debug output (most verbose).\n",
+ "Available Commands:\n",
+ " help: Display CLI Help. To display help for specific command, use the argument \"--command\" with this command.\n",
+ " super-hero: A command to display hero's name.\n",
+ ">> ",
+ ], $runner->getOutput());
+ $this->assertEquals(0, $runner->getLastCommandExitStatus());
+ }
+ /**
+ * @test
+ */
+ public function testRunner14() {
+ $runner = new Runner();
+
+ $runner->register(new Command00());
+ // Don't register HelpCommand - it's automatically registered
+
+ $runner->setArgsVector([
+ 'entry.php',
+ '-i',
+ ]);
+ $runner->setInputs([
+ 'help --ansi --command=super-hero',
+ 'super-hero name=Ibrahim',
+ 'exit'
+ ]);
+ $runner->start();
+ $this->assertEquals([
+ ">> Running in interactive mode.\n",
+ ">> Type command name or 'exit' to close.\n",
+ ">> super-hero: A command to display hero's name.\n",
+ " Supported Arguments:\n",
+ " name: The name of the hero\n",
+ ">> Hello hero Ibrahim\n",
+ ">> "
+ ], $runner->getOutput());
+ }
+ /**
+ * @test
+ */
+ public function testRunner15() {
+ $runner = new Runner();
+ $runner->register(new Command00());
+ // Don't register HelpCommand - it's automatically registered
+ $runner->register(new WithExceptionCommand());
+ $runner->setAfterExecution(function (Runner $r) {
+ $r->getActiveCommand()->println('Command Exit Status: '.$r->getLastCommandExitStatus());
+ });
+ $runner->setArgsVector([
+ 'entry.php',
+ '--ansi',
+ '-i',
+ ]);
+ $runner->setInputs([
+ 'help --command=super-hero',
+ 'with-exception',
+ 'exit'
+ ]);
+ $runner->start();
+ $output = $runner->getOutput();
+ // Null out the stack trace content as it can vary
+ for ($i = 12; $i < count($output) - 2; $i++) {
+ if ($output[$i] !== null && strpos($output[$i], 'Command Exit Status: -1') === false && strpos($output[$i], '>> ') === false) {
+ $output[$i] = null;
+ }
+ }
+
+ $this->assertEquals([
+ "[1;34m>>[0m Running in interactive mode.\n",
+ "[1;34m>>[0m Type command name or 'exit' to close.\n",
+ "[1;34m>>[0m [1;33m super-hero[0m: A command to display hero's name.\n",
+ "[1;94m Supported Arguments:[0m\n",
+ "[1;33m name:[0m The name of the hero\n",
+ "Command Exit Status: 0\n",
+ "[1;34m>>[0m [1;31mError:[0m An exception was thrown.\n",
+ "[1;33mException Message:[0m Call to undefined method WebFiori\Tests\Cli\TestCommands\WithExceptionCommand::notExist()\n",
+ "[1;33mCode:[0m 0\n",
+ "[1;33mAt:[0m ".ROOT_DIR."tests".DS."WebFiori".DS."Tests".DS."Cli".DS."TestCommands".DS."WithExceptionCommand.php\n",
+ "[1;33mLine:[0m 13\n",
+ "[1;33mStack Trace:[0m \n\n",
+ null,
+ "Command Exit Status: -1\n",
+ "[1;34m>>[0m ",
+ ], $output);
+ }
+ /**
+ * @test
+ */
+ public function testRunner16() {
+ $runner = new Runner();
+ $runner->register(new Command01());
+ $runner->setInputs([]);
+ $this->assertEquals(-1, $runner->runCommand(null, [
+ 'show-v'
+ ]));
+ $this->assertEquals([
+ "Error: The following required argument(s) are missing: 'arg-1', 'arg-2'\n"
+ ], $runner->getOutput());
+ }
+ /**
+ * @test
+ */
+ public function testRunner17() {
+ $runner = new Runner();
+ $runner->register(new Command01());
+ $runner->setInputs([]);
+ $this->assertEquals(-1, $runner->runCommand(null, [
+ 'show-v',
+ '--ansi'
+ ]));
+ $this->assertEquals([
+ "\e[1;91mError: \e[0mThe following required argument(s) are missing: 'arg-1', 'arg-2'\n"
+ ], $runner->getOutput());
+ }
+ /**
+ * @test
+ */
+ public function testRunner18() {
+ $runner = new Runner();
+ $runner->register(new Command01());
+ $runner->setInputs([]);
+ $runner->setAfterExecution(function (Runner $r) {
+ $r->getActiveCommand()->println('Command Exit Status: '.$r->getLastCommandExitStatus());
+ });
+ $this->assertEquals(0, $runner->runCommand(null, [
+ 'show-v',
+ 'arg-1' => 'Super Cool Arg',
+ 'arg-2' => "First One is Coller",
+ ]));
+ $this->assertEquals([
+ "System version: 1.0.0\n",
+ "Super Cool Arg\n",
+ "First One is Coller\n",
+ "Hello\n",
+ "Command Exit Status: 0\n"
+ ], $runner->getOutput());
+ }
+ /**
+ * @test
+ */
+ public function testRunner19() {
+ $runner = new Runner();
+ $runner->register(new Command00());
+ // Don't register HelpCommand - it's automatically registered
+ $runner->register(new WithExceptionCommand());
+ $runner->setArgsVector([
+ 'entry.php',
+ '-i',
+ ]);
+ $runner->setInputs([
+ '',
+ '',
+ 'exit'
+ ]);
+ $this->assertEquals(0, $runner->start());
+ $this->assertEquals([
+ ">> Running in interactive mode.\n",
+ ">> Type command name or 'exit' to close.\n",
+ ">> No input.\n",
+ ">> No input.\n",
+ ">> "
+ ], $runner->getOutput());
+ }
+ /**
+ * @test
+ */
+ public function testRunner20() {
+ $runner = new Runner();
+ $runner->register(new Command00());
+ // Don't register HelpCommand - it's automatically registered
+ $runner->register(new WithExceptionCommand());
+ $runner->setArgsVector([
+ 'entry.php',
+ '--ansi',
+ ]);
+ $runner->setInputs([
+
+ ]);
+ $runner->start();
+ //$this->assertEquals(0, $runner->start());
+ // Since help command is now the default, it will show help output instead of "No command" message
+ $output = $runner->getOutput();
+ $this->assertNotEmpty($output);
+ $this->assertStringContainsString('Usage:', $output[0]);
+ }
+ /**
+ * @test
+ */
+ public function testRunner21() {
+ $runner = new Runner();
+ $runner->setArgsVector([
+
+ ]);
+ $runner->setInputStream(new ArrayInputStream([
+
+ ]));
+ $runner->setOutputStream(new ArrayOutputStream());
+
+ $this->assertEquals([
+
+ ], $runner->getOutput());
+ $runner->register(new Command00());
+ // Don't register HelpCommand - it's automatically registered
+ $runner->register(new WithExceptionCommand());
+ $runner->setAfterExecution(function (Runner $r) {
+ $r->getActiveCommand()->println('Command Exit Status: '.$r->getLastCommandExitStatus());
+ });
+
+ $runner->setArgsVector([
+ 'entry.php',
+ 'with-exception',
+ ]);
+ $runner->setInputs([]);
+ $runner->start();
+ $output = $runner->getOutput();
+ //Removing the trace
+ $output[6] = null;
+ $this->assertEquals([
+ "Error: An exception was thrown.\n",
+ "Exception Message: Call to undefined method WebFiori\\Tests\\Cli\\TestCommands\\WithExceptionCommand::notExist()\n",
+ "Code: 0\n",
+ "At: ".\ROOT_DIR."tests".\DS."WebFiori".\DS."Tests".\DS."Cli".\DS."TestCommands".\DS."WithExceptionCommand.php\n",
+ "Line: 13\n",
+ "Stack Trace: \n\n",
+ null,
+ "Command Exit Status: -1\n"
+ ], $output);
+ }
+ public function testRunner22() {
+ $runner = new Runner();
+ $runner->register(new Command03());
+ $runner->setArgsVector([
+ 'entry.php',
+ 'run-another',
+ 'arg-1' => 'Nice',
+ 'arg-2' => 'Cool'
+ ]);
+ $runner->setInputStream(new ArrayInputStream([
+
+ ]));
+ $runner->setOutputStream(new ArrayOutputStream());
+ $exitCode = $runner->start();
+ $output = $runner->getOutput();
+ $this->assertEquals([
+ "Running Sub Command\n",
+ "System version: 1.0.0\n",
+ "Nice\n",
+ "Cool\n",
+ "Ur\n",
+ "Done\n",
+ ], $output);
+ }
+ /**
+ * @test
+ */
+ public function test00() {
+ $runner = new Runner();
+ $runner->setInputs([]);
+ $runner->setArgsVector([
+
+ ]);
+ $this->assertEquals([
+
+ ], $runner->getOutput());
+ }
+ /**
+ * Test Runner initialization and basic properties
+ * @test
+ */
+ public function testRunnerInitializationEnhanced() {
+ $runner = new Runner();
+
+ // Test initial state
+ $this->assertNull($runner->getActiveCommand());
+ $this->assertNotNull($runner->getInputStream());
+ $this->assertNotNull($runner->getOutputStream());
+ $this->assertEquals(0, $runner->getLastCommandExitStatus());
+ $this->assertFalse($runner->isInteractive());
+ }
+
+ /**
+ * Test command registration with aliases
+ * @test
+ */
+ public function testCommandRegistrationWithAliasesEnhanced() {
+ $runner = new Runner();
+ $command = new TestCommand('test-cmd', [], 'Test command');
+
+ // Register command with aliases
+ $result = $runner->register($command, ['tc', 'test']);
+ $this->assertSame($runner, $result); // Should return self for chaining
+
+ // Test command is registered
+ $this->assertSame($command, $runner->getCommandByName('test-cmd'));
+
+ // Test aliases are registered
+ $this->assertTrue($runner->hasAlias('tc'));
+ $this->assertTrue($runner->hasAlias('test'));
+ $this->assertEquals('test-cmd', $runner->resolveAlias('tc'));
+ $this->assertEquals('test-cmd', $runner->resolveAlias('test'));
+
+ // Test getting all aliases
+ $aliases = $runner->getAliases();
+ $this->assertArrayHasKey('tc', $aliases);
+ $this->assertArrayHasKey('test', $aliases);
+ $this->assertEquals('test-cmd', $aliases['tc']);
+ $this->assertEquals('test-cmd', $aliases['test']);
+ }
+
+ /**
+ * Test duplicate command registration
+ * @test
+ */
+ public function testDuplicateCommandRegistrationEnhanced() {
+ $runner = new Runner();
+ $command1 = new TestCommand('test-cmd', [], 'First command');
+ $command2 = new TestCommand('test-cmd', [], 'Second command');
+
+ // Register first command
+ $runner->register($command1);
+ $this->assertSame($command1, $runner->getCommandByName('test-cmd'));
+
+ // Register second command with same name (should replace)
+ $runner->register($command2);
+ $this->assertSame($command2, $runner->getCommandByName('test-cmd'));
+ }
+
+ /**
+ * Test global arguments
+ * @test
+ */
+ public function testGlobalArgumentsEnhanced() {
+ $runner = new Runner();
+
+ // Add global arguments
+ $this->assertTrue($runner->addArg('--global-arg', [
+ ArgumentOption::OPTIONAL => true,
+ ArgumentOption::DESCRIPTION => 'Global argument'
+ ]));
+
+ // Test duplicate global argument
+ $this->assertFalse($runner->addArg('--global-arg', [])); // Should fail
+
+ // Test argument exists
+ $this->assertTrue($runner->hasArg('--global-arg'));
+ $this->assertFalse($runner->hasArg('--non-existent'));
+
+ // Test removing argument
+ $this->assertTrue($runner->removeArgument('--global-arg'));
+ $this->assertFalse($runner->hasArg('--global-arg'));
+
+ // Test removing non-existent argument
+ $this->assertFalse($runner->removeArgument('--non-existent'));
+ }
+
+ /**
+ * Test arguments vector handling
+ * @test
+ */
+ public function testArgumentsVectorEnhanced() {
+ $runner = new Runner();
+
+ $argsVector = ['script.php', 'command', '--arg1=value1', '--arg2', 'value2'];
+ $runner->setArgsVector($argsVector);
+
+ $this->assertEquals($argsVector, $runner->getArgsVector());
+ }
+
+ /**
+ * Test stream handling
+ * @test
+ */
+ public function testStreamHandlingEnhanced() {
+ $runner = new Runner();
+
+ // Test setting custom streams
+ $customInput = new ArrayInputStream(['test input']);
+ $customOutput = new ArrayOutputStream();
+
+ $result1 = $runner->setInputStream($customInput);
+ $this->assertSame($runner, $result1); // Should return self
+ $this->assertSame($customInput, $runner->getInputStream());
+
+ $result2 = $runner->setOutputStream($customOutput);
+ $this->assertSame($runner, $result2); // Should return self
+ $this->assertSame($customOutput, $runner->getOutputStream());
+ }
+
+ /**
+ * Test inputs array handling
+ * @test
+ */
+ public function testInputsArrayHandlingEnhanced() {
+ $runner = new Runner();
+
+ $inputs = ['input1', 'input2', 'input3'];
+ $result = $runner->setInputs($inputs);
+ $this->assertSame($runner, $result); // Should return self
+
+ // The inputs should be set as ArrayInputStream
+ $inputStream = $runner->getInputStream();
+ $this->assertInstanceOf(ArrayInputStream::class, $inputStream);
+ }
+
+ /**
+ * Test command execution
+ * @test
+ */
+ public function testCommandExecutionEnhanced() {
+ $runner = new Runner();
+ $command = new TestCommand('test-cmd');
+ $output = new ArrayOutputStream();
+
+ $runner->register($command);
+ $runner->setOutputStream($output);
+
+ // Test running command
+ $exitCode = $runner->runCommand($command);
+ $this->assertEquals(0, $exitCode); // TestCommand should return 0
+ $this->assertEquals(0, $runner->getLastCommandExitStatus());
+
+ // Test running with arguments
+ $exitCode2 = $runner->runCommand($command, ['--test-arg' => 'value']);
+ $this->assertEquals(0, $exitCode2);
+
+ // Test running with ANSI
+ $exitCode3 = $runner->runCommand($command, [], true);
+ $this->assertEquals(0, $exitCode3);
+ }
+
+ /**
+ * Test sub-command execution
+ * @test
+ */
+ public function testSubCommandExecutionEnhanced() {
+ $runner = new Runner();
+ $runner->setOutputStream(new ArrayOutputStream());
+ $mainCommand = new TestCommand('main-cmd');
+ $subCommand = new TestCommand('sub-cmd');
+
+ $runner->register($mainCommand);
+ $runner->register($subCommand);
+
+ // Test running sub-command
+ $exitCode = $runner->runCommandAsSub('sub-cmd');
+ $this->assertEquals(0, $exitCode);
+
+ // Test running non-existent sub-command
+ $exitCode2 = $runner->runCommandAsSub('non-existent');
+ $this->assertEquals(-1, $exitCode2);
+ }
+
+ /**
+ * Test active command management
+ * @test
+ */
+ public function testActiveCommandManagementEnhanced() {
+ $runner = new Runner();
+ $command = new TestCommand('test-cmd');
+
+ // Initially no active command
+ $this->assertNull($runner->getActiveCommand());
+
+ // Set active command
+ $result = $runner->setActiveCommand($command);
+ $this->assertSame($runner, $result); // Should return self
+ $this->assertSame($command, $runner->getActiveCommand());
+
+ // Clear active command
+ $runner->setActiveCommand(null);
+ $this->assertNull($runner->getActiveCommand());
+ }
+
+ /**
+ * Test callback functionality
+ * @test
+ */
+ public function testCallbacksEnhanced() {
+ $runner = new Runner();
+ $callbackExecuted = false;
+
+ // Test before start callback
+ $beforeCallback = function() use (&$callbackExecuted) {
+ $callbackExecuted = true;
+ };
+
+ $result = $runner->setBeforeStart($beforeCallback);
+ $this->assertSame($runner, $result); // Should return self
+
+ // Test after execution callback
+ $afterCallback = function($exitCode, $command) {
+ // Callback should receive exit code and command
+ $this->assertIsInt($exitCode);
+ };
+
+ $result2 = $runner->setAfterExecution($afterCallback, ['param1', 'param2']);
+ $this->assertSame($runner, $result2); // Should return self
+ }
+
+ /**
+ * Test output collection
+ * @test
+ */
+ public function testOutputCollectionEnhanced() {
+ $runner = new Runner();
+ $command = new TestCommand('test-cmd');
+ $output = new ArrayOutputStream();
+
+ $runner->register($command);
+ $runner->setOutputStream($output);
+
+ // Run command to generate output
+ $runner->runCommand($command);
+
+ // Test getting output
+ $outputArray = $runner->getOutput();
+ $this->assertIsArray($outputArray);
+ $this->assertNotEmpty($outputArray);
+ }
+
+ /**
+ * Test alias resolution edge cases
+ * @test
+ */
+ public function testAliasResolutionEdgeCasesEnhanced() {
+ $runner = new Runner();
+
+ // Test resolving non-existent alias
+ $this->assertNull($runner->resolveAlias('non-existent'));
+
+ // Test resolving actual command name (not alias)
+ $command = new TestCommand('test-cmd');
+ $runner->register($command);
+ $this->assertNull($runner->resolveAlias('test-cmd')); // Should return null for actual command names
+ }
+
+ /**
+ * Test command retrieval edge cases
+ * @test
+ */
+ public function testCommandRetrievalEdgeCasesEnhanced() {
+ $runner = new Runner();
+
+ // Test getting non-existent command
+ $this->assertNull($runner->getCommandByName('non-existent'));
+
+ // Test getting command by alias
+ $command = new TestCommand('test-cmd');
+ $runner->register($command, ['tc']);
+
+ // Should find command by alias using getCommandByName (enhanced functionality)
+ $this->assertSame($command, $runner->getCommandByName('tc'));
+ $this->assertSame($command, $runner->getCommandByName('test-cmd'));
+ }
+
+ /**
+ * Test argument object handling
+ * @test
+ */
+ public function testArgumentObjectHandlingEnhanced() {
+ $runner = new Runner();
+
+ // Test adding Argument object
+ $arg = new Argument('--test-arg');
+ $arg->setDescription('Test argument');
+
+ $result = $runner->addArgument($arg);
+ $this->assertTrue($result);
+ $this->assertTrue($runner->hasArg('--test-arg'));
+
+ // Test adding duplicate Argument object
+ $arg2 = new Argument('--test-arg');
+ $result2 = $runner->addArgument($arg2);
+ $this->assertFalse($result2); // Should fail for duplicate
+ }
+
+ /**
+ * Test interactive mode detection
+ * @test
+ */
+ public function testInteractiveModeDetectionEnhanced() {
+ $runner = new Runner();
+
+ // Initially not interactive
+ $this->assertFalse($runner->isInteractive());
+
+ // Set args vector with -i flag
+ $runner->setArgsVector(['script.php', '-i']);
+ // Note: The actual interactive detection might depend on the start() method implementation
+ }
+
+ /**
+ * Test command discovery methods (if available)
+ * @test
+ */
+ public function testCommandDiscoveryMethodsEnhanced() {
+ $runner = new Runner();
+
+ // Test auto-discovery state
+ $this->assertFalse($runner->isAutoDiscoveryEnabled()); // Default should be false
+
+ // Test enabling auto-discovery
+ $result = $runner->enableAutoDiscovery();
+ $this->assertSame($runner, $result);
+ $this->assertTrue($runner->isAutoDiscoveryEnabled());
+
+ // Test disabling auto-discovery
+ $result2 = $runner->disableAutoDiscovery();
+ $this->assertSame($runner, $result2);
+ $this->assertFalse($runner->isAutoDiscoveryEnabled());
+
+ // Test exclude patterns
+ $result5 = $runner->excludePattern('*Test*');
+ $this->assertSame($runner, $result5);
+
+ $result6 = $runner->excludePatterns(['*Test*', '*Mock*']);
+ $this->assertSame($runner, $result6);
+
+ // Test discovery cache
+ $result7 = $runner->enableDiscoveryCache('test-cache.json');
+ $this->assertSame($runner, $result7);
+
+ $result8 = $runner->disableDiscoveryCache();
+ $this->assertSame($runner, $result8);
+
+ $result9 = $runner->clearDiscoveryCache();
+ $this->assertSame($runner, $result9);
+
+ // Test strict mode
+ $result10 = $runner->setDiscoveryStrictMode(true);
+ $this->assertSame($runner, $result10);
+
+ $result11 = $runner->setDiscoveryStrictMode(false);
+ $this->assertSame($runner, $result11);
+ }
+ /**
+ * Test command help pattern in interactive mode.
+ * @test
+ */
+ public function testCommandHelpInteractive() {
+ $runner = new Runner();
+ $runner->register(new Command00());
+ // Don't register HelpCommand - it's automatically registered
+
+ $runner->setArgsVector([
+ 'entry.php',
+ '-i',
+ ]);
+ $runner->setInputs([
+ 'super-hero help',
+ 'exit'
+ ]);
+ $runner->start();
+
+ $output = $runner->getOutput();
+
+ // Should show help for super-hero command
+ $this->assertContains(">> super-hero: A command to display hero's name.\n", $output);
+ $this->assertContains(" Supported Arguments:\n", $output);
+ $this->assertEquals(0, $runner->getLastCommandExitStatus());
+ }
+
+ /**
+ * Test command -h pattern in interactive mode.
+ * @test
+ */
+ public function testCommandDashHInteractive() {
+ $runner = new Runner();
+ $runner->register(new Command00());
+ // Don't register HelpCommand - it's automatically registered
+
+ $runner->setArgsVector([
+ 'entry.php',
+ '-i',
+ ]);
+ $runner->setInputs([
+ 'super-hero -h',
+ 'exit'
+ ]);
+ $runner->start();
+
+ $output = $runner->getOutput();
+
+ // Should show help for super-hero command
+ $this->assertContains(">> super-hero: A command to display hero's name.\n", $output);
+ $this->assertContains(" Supported Arguments:\n", $output);
+ $this->assertEquals(0, $runner->getLastCommandExitStatus());
+ }
+
+ /**
+ * Test command help pattern in non-interactive mode.
+ * @test
+ */
+ public function testCommandHelpNonInteractive() {
+ $runner = new Runner();
+ $runner->register(new Command00());
+ // Don't register HelpCommand - it's automatically registered
+ $runner->setInputs([]);
+
+ $runner->setArgsVector([
+ 'entry.php',
+ 'super-hero',
+ 'help'
+ ]);
+ $runner->start();
+
+ $output = $runner->getOutput();
+
+ // Should show help for super-hero command
+ $this->assertContains(" super-hero: A command to display hero's name.\n", $output);
+ $this->assertContains(" Supported Arguments:\n", $output);
+ $this->assertEquals(0, $runner->getLastCommandExitStatus());
+ }
+
+ /**
+ * Test command -h pattern in non-interactive mode.
+ * @test
+ */
+ public function testCommandDashHNonInteractive() {
+ $runner = new Runner();
+ $runner->register(new Command00());
+ // Don't register HelpCommand - it's automatically registered
+ $runner->setInputs([]);
+
+ $runner->setArgsVector([
+ 'entry.php',
+ 'super-hero',
+ '-h'
+ ]);
+ $runner->start();
+
+ $output = $runner->getOutput();
+
+ // Should show help for super-hero command
+ $this->assertContains(" super-hero: A command to display hero's name.\n", $output);
+ $this->assertContains(" Supported Arguments:\n", $output);
+ $this->assertEquals(0, $runner->getLastCommandExitStatus());
+ }
+
+ /**
+ * Test that invalid command with help doesn't trigger help.
+ * @test
+ */
+ public function testInvalidCommandHelp() {
+ $runner = new Runner();
+ $runner->register(new Command00());
+ // Don't register HelpCommand - it's automatically registered
+
+ $runner->setArgsVector([
+ 'entry.php',
+ '-i',
+ ]);
+ $runner->setInputs([
+ 'invalid-command help',
+ 'exit'
+ ]);
+ $runner->start();
+
+ $output = $runner->getOutput();
+
+ // Should show error for invalid command, not help
+ $this->assertContains(">> Error: The command 'invalid-command' is not supported.\n", $output);
+ $this->assertEquals(-1, $runner->getLastCommandExitStatus());
+ }
+}
diff --git a/tests/WebFiori/Tests/Cli/SignalHandlerTest.php b/tests/WebFiori/Tests/Cli/SignalHandlerTest.php
new file mode 100644
index 0000000..0dfb7e7
--- /dev/null
+++ b/tests/WebFiori/Tests/Cli/SignalHandlerTest.php
@@ -0,0 +1,275 @@
+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..b42cb09
--- /dev/null
+++ b/tests/WebFiori/Tests/Cli/SignalIntegrationTest.php
@@ -0,0 +1,497 @@
+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();
+ }
+}
diff --git a/tests/WebFiori/Tests/Cli/VerbosityTest.php b/tests/WebFiori/Tests/Cli/VerbosityTest.php
new file mode 100644
index 0000000..c4c5542
--- /dev/null
+++ b/tests/WebFiori/Tests/Cli/VerbosityTest.php
@@ -0,0 +1,268 @@
+error('error msg');
+ $this->warning('warning msg');
+ $this->info('info msg');
+ $this->success('success msg');
+ $this->verbose('verbose msg');
+ $this->debug('debug msg');
+ $this->println('always shown');
+
+ return 0;
+ }
+}
+
+class VerbosityTest extends CommandTestCase {
+ /**
+ * @test
+ */
+ public function testVerbosityConstants() {
+ $this->assertEquals(0, Verbosity::QUIET);
+ $this->assertEquals(1, Verbosity::NORMAL);
+ $this->assertEquals(2, Verbosity::VERBOSE);
+ $this->assertEquals(3, Verbosity::DEBUG);
+ }
+
+ /**
+ * @test
+ */
+ public function testDefaultVerbosityIsNormal() {
+ $runner = new Runner();
+ $runner->reset();
+ $this->assertEquals(Verbosity::NORMAL, $runner->getVerbosity());
+ }
+
+ /**
+ * @test
+ */
+ public function testSetVerbosity() {
+ $runner = new Runner();
+ $runner->reset();
+
+ $result = $runner->setVerbosity(Verbosity::QUIET);
+ $this->assertSame($runner, $result);
+ $this->assertEquals(Verbosity::QUIET, $runner->getVerbosity());
+
+ $runner->setVerbosity(Verbosity::VERBOSE);
+ $this->assertEquals(Verbosity::VERBOSE, $runner->getVerbosity());
+
+ $runner->setVerbosity(Verbosity::DEBUG);
+ $this->assertEquals(Verbosity::DEBUG, $runner->getVerbosity());
+ }
+
+ /**
+ * @test
+ */
+ public function testQuietModeViaFlag() {
+ $runner = new Runner();
+ $runner->reset();
+ $runner->register(new VerbosityTestCommand());
+ $runner->setInputs([]);
+ $runner->setArgsVector(['main.php', 'verb-test', '-q']);
+ $runner->start();
+
+ $output = $runner->getOutput();
+ $outputStr = implode('', $output);
+
+ // error and warning always shown
+ $this->assertStringContainsString('error msg', $outputStr);
+ $this->assertStringContainsString('warning msg', $outputStr);
+ // println always shown
+ $this->assertStringContainsString('always shown', $outputStr);
+ // info and success suppressed
+ $this->assertStringNotContainsString('info msg', $outputStr);
+ $this->assertStringNotContainsString('success msg', $outputStr);
+ // verbose and debug suppressed
+ $this->assertStringNotContainsString('verbose msg', $outputStr);
+ $this->assertStringNotContainsString('debug msg', $outputStr);
+ }
+
+ /**
+ * @test
+ */
+ public function testNormalMode() {
+ $output = $this->executeSingleCommand(new VerbosityTestCommand());
+ $outputStr = implode('', $output);
+
+ // error, warning, info, success shown
+ $this->assertStringContainsString('error msg', $outputStr);
+ $this->assertStringContainsString('warning msg', $outputStr);
+ $this->assertStringContainsString('info msg', $outputStr);
+ $this->assertStringContainsString('success msg', $outputStr);
+ $this->assertStringContainsString('always shown', $outputStr);
+ // verbose and debug suppressed
+ $this->assertStringNotContainsString('verbose msg', $outputStr);
+ $this->assertStringNotContainsString('debug msg', $outputStr);
+ }
+
+ /**
+ * @test
+ */
+ public function testVerboseModeViaFlag() {
+ $runner = new Runner();
+ $runner->reset();
+ $runner->register(new VerbosityTestCommand());
+ $runner->setInputs([]);
+ $runner->setArgsVector(['main.php', 'verb-test', '-v']);
+ $runner->start();
+
+ $output = $runner->getOutput();
+ $outputStr = implode('', $output);
+
+ // everything except debug shown
+ $this->assertStringContainsString('error msg', $outputStr);
+ $this->assertStringContainsString('warning msg', $outputStr);
+ $this->assertStringContainsString('info msg', $outputStr);
+ $this->assertStringContainsString('success msg', $outputStr);
+ $this->assertStringContainsString('verbose msg', $outputStr);
+ $this->assertStringContainsString('always shown', $outputStr);
+ // debug suppressed
+ $this->assertStringNotContainsString('debug msg', $outputStr);
+ }
+
+ /**
+ * @test
+ */
+ public function testDebugModeViaFlag() {
+ $runner = new Runner();
+ $runner->reset();
+ $runner->register(new VerbosityTestCommand());
+ $runner->setInputs([]);
+ $runner->setArgsVector(['main.php', 'verb-test', '-vv']);
+ $runner->start();
+
+ $output = $runner->getOutput();
+ $outputStr = implode('', $output);
+
+ // everything shown
+ $this->assertStringContainsString('error msg', $outputStr);
+ $this->assertStringContainsString('warning msg', $outputStr);
+ $this->assertStringContainsString('info msg', $outputStr);
+ $this->assertStringContainsString('success msg', $outputStr);
+ $this->assertStringContainsString('verbose msg', $outputStr);
+ $this->assertStringContainsString('debug msg', $outputStr);
+ $this->assertStringContainsString('always shown', $outputStr);
+ }
+
+ /**
+ * @test
+ */
+ public function testSetVerbosityProgrammatically() {
+ $runner = new Runner();
+ $runner->reset();
+ $runner->setVerbosity(Verbosity::QUIET);
+ $runner->register(new VerbosityTestCommand());
+ $runner->setInputs([]);
+ $runner->setArgsVector(['main.php', 'verb-test']);
+ $runner->start();
+
+ $output = $runner->getOutput();
+ $outputStr = implode('', $output);
+
+ $this->assertStringContainsString('error msg', $outputStr);
+ $this->assertStringNotContainsString('info msg', $outputStr);
+ }
+
+ /**
+ * @test
+ */
+ public function testFlagOverridesProgrammaticVerbosity() {
+ $runner = new Runner();
+ $runner->reset();
+ $runner->setVerbosity(Verbosity::QUIET);
+ $runner->register(new VerbosityTestCommand());
+ $runner->setInputs([]);
+ // -vv flag should override the programmatic QUIET setting
+ $runner->setArgsVector(['main.php', 'verb-test', '-vv']);
+ $runner->start();
+
+ $output = $runner->getOutput();
+ $outputStr = implode('', $output);
+
+ $this->assertStringContainsString('debug msg', $outputStr);
+ }
+
+ /**
+ * @test
+ */
+ public function testVerbosityFlagsStrippedFromArgs() {
+ $runner = new Runner();
+ $runner->reset();
+ $runner->register(new VerbosityTestCommand());
+ $runner->setInputs([]);
+ $runner->setArgsVector(['main.php', 'verb-test', '-v']);
+ $runner->start();
+
+ // Command should execute successfully (flag not passed as unknown arg)
+ $this->assertEquals(0, $runner->getLastCommandExitStatus());
+ }
+
+ /**
+ * @test
+ */
+ public function testQuietFlagStrippedFromArgs() {
+ $runner = new Runner();
+ $runner->reset();
+ $runner->register(new VerbosityTestCommand());
+ $runner->setInputs([]);
+ $runner->setArgsVector(['main.php', 'verb-test', '-q']);
+ $runner->start();
+
+ $this->assertEquals(0, $runner->getLastCommandExitStatus());
+ }
+
+ /**
+ * @test
+ */
+ public function testCommandWithoutOwnerDefaultsToNormal() {
+ // Command without Runner uses NORMAL verbosity
+ $command = new VerbosityTestCommand();
+ $output = $this->executeSingleCommand($command);
+ $outputStr = implode('', $output);
+
+ $this->assertStringContainsString('info msg', $outputStr);
+ $this->assertStringNotContainsString('verbose msg', $outputStr);
+ }
+
+ /**
+ * @test
+ */
+ public function testInteractiveModeWithQuiet() {
+ $runner = new Runner();
+ $runner->reset();
+ $runner->register(new VerbosityTestCommand());
+ $runner->setArgsVector(['main.php', '-i', '-q']);
+ $runner->setInputs(['verb-test', 'exit']);
+ $runner->start();
+
+ $output = $runner->getOutput();
+ $outputStr = implode('', $output);
+
+ $this->assertStringContainsString('error msg', $outputStr);
+ $this->assertStringNotContainsString('info msg', $outputStr);
+ }
+}