diff --git a/README.md b/README.md index a4a13ee..36e7083 100644 --- a/README.md +++ b/README.md @@ -17,6 +17,7 @@ composer require utopia-php/console require_once __DIR__.'/vendor/autoload.php'; use Utopia\Console; +use Utopia\Command; Console::success('Ready to work!'); @@ -28,7 +29,11 @@ if ($answer !== 'y') { } $output = ''; -$exitCode = Console::execute('php -r "echo \"Hello\";"', '', $output, 3); +$stderr = ''; +$command = new Command(PHP_BINARY) + ->option('-r', 'echo "Hello";'); + +$exitCode = Console::execute($command, '', $output, $stderr, 3); Console::log("Command returned {$exitCode} with: {$output}"); ``` @@ -45,17 +50,65 @@ Console::error('Red log'); // stderr ### Execute Commands -`Console::execute()` returns the exit code and writes the combined stdout/stderr output into the third argument. Pass a timeout (in seconds) to stop long-running processes and an optional progress callback to stream intermediate output. +`Console::execute()` returns the exit code and writes stdout and stderr into the referenced output variables. Pass a timeout (in seconds) to stop long-running processes and an optional progress callback to stream intermediate output. Prefer `Utopia\Command` or argv arrays when you want structured command building. ```php +$command = new Command(PHP_BINARY) + ->option('-r', 'fwrite(STDOUT, "success\\n");'); + $output = ''; $input = ''; -$exitCode = Console::execute('>&1 echo "success"', $input, $output, 3); +$stderr = ''; +$exitCode = Console::execute($command, $input, $output, $stderr, 3); echo $exitCode; // 0 echo $output; // "success\n" ``` +### Build Commands + +Use `flag()` for switches without a value, `option()` for keys that take a value, and `argument()` for positional arguments. + +```php +$command = new Command('tar') + ->flag('-cz') + ->option('-f', 'archive.tar.gz') + ->option('-C', '/tmp/project') + ->argument('.'); +``` + +### Compose Commands + +Use the static helpers when you need shell operators such as pipes, `&&`, `||`, grouping, or redirects. + +```php +$pipeline = Command::pipe( + new Command('ps')->flag('-ef'), + new Command('grep')->argument('php-fpm'), + new Command('wc')->flag('-l'), +); + +$deploy = Command::and( + Command::group( + Command::or( + new Command('build'), + new Command('build:fallback'), + ) + ), + new Command('publish'), +); + +$logs = Command::appendStdout( + Command::pipe( + new Command('cat')->argument('app.log'), + new Command('grep')->argument('ERROR'), + ), + 'errors.log', +); +``` + +Plain commands execute in argv mode. Composed, grouped, and redirected commands execute through shell syntax. + ### Create a Daemon Use `Console::loop()` to build daemons without tight loops. The helper sleeps between iterations and periodically triggers garbage collection. @@ -72,7 +125,7 @@ Console::loop(function () { ## System Requirements -Utopia Console requires PHP 7.4 or later. We recommend using the latest PHP version whenever possible. +Utopia Console requires PHP 8.0 or later. We recommend using the latest PHP version whenever possible. ## License diff --git a/composer.json b/composer.json index 0afe6d4..b98e5b3 100755 --- a/composer.json +++ b/composer.json @@ -14,7 +14,8 @@ "psr-4": {"Utopia\\": "src/"} }, "require": { - "php": ">=8.0" + "php": ">=8.0", + "utopia-php/validators": "^0.1.0" }, "require-dev": { "phpunit/phpunit": "^9.3", diff --git a/composer.lock b/composer.lock index 8f1ddab..46b0723 100644 --- a/composer.lock +++ b/composer.lock @@ -4,25 +4,71 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "a068f575add4bafb95e49a4a079fd14f", - "packages": [], + "content-hash": "8d8396fe41bfaaf6debe5e1470646740", + "packages": [ + { + "name": "utopia-php/validators", + "version": "0.1.0", + "source": { + "type": "git", + "url": "https://github.com/utopia-php/validators.git", + "reference": "5c57d5b6cf964f8981807c1d3ea8df620c869080" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/utopia-php/validators/zipball/5c57d5b6cf964f8981807c1d3ea8df620c869080", + "reference": "5c57d5b6cf964f8981807c1d3ea8df620c869080", + "shasum": "" + }, + "require": { + "php": ">=8.0" + }, + "require-dev": { + "laravel/pint": "1.*", + "phpstan/phpstan": "1.*", + "phpunit/phpunit": "11.*" + }, + "type": "library", + "autoload": { + "psr-4": { + "Utopia\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "A lightweight collection of reusable validators for Utopia projects", + "keywords": [ + "php", + "utopia", + "validation", + "validator" + ], + "support": { + "issues": "https://github.com/utopia-php/validators/issues", + "source": "https://github.com/utopia-php/validators/tree/0.1.0" + }, + "time": "2025-11-18T11:05:46+00:00" + } + ], "packages-dev": [ { "name": "doctrine/instantiator", - "version": "2.2.x-dev", + "version": "2.0.x-dev", "source": { "type": "git", "url": "https://github.com/doctrine/instantiator.git", - "reference": "78f5d718c7f52afc31e59434f625b34c52875d72" + "reference": "7be2ebd072deac210cb57eb2776aac19f4d5d1ad" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/doctrine/instantiator/zipball/78f5d718c7f52afc31e59434f625b34c52875d72", - "reference": "78f5d718c7f52afc31e59434f625b34c52875d72", + "url": "https://api.github.com/repos/doctrine/instantiator/zipball/7be2ebd072deac210cb57eb2776aac19f4d5d1ad", + "reference": "7be2ebd072deac210cb57eb2776aac19f4d5d1ad", "shasum": "" }, "require": { - "php": "^8.4" + "php": "^8.1" }, "require-dev": { "doctrine/coding-standard": "^14", @@ -58,7 +104,7 @@ ], "support": { "issues": "https://github.com/doctrine/instantiator/issues", - "source": "https://github.com/doctrine/instantiator/tree/2.2.x" + "source": "https://github.com/doctrine/instantiator/tree/2.0.x" }, "funding": [ { @@ -74,7 +120,7 @@ "type": "tidelift" } ], - "time": "2026-01-14T07:50:32+00:00" + "time": "2026-01-04T22:42:35+00:00" }, { "name": "laravel/pint", @@ -209,12 +255,12 @@ "source": { "type": "git", "url": "https://github.com/nikic/PHP-Parser.git", - "reference": "8c360e27327c8bd29e1c57721574709d0d706118" + "reference": "50f0d9c9d0e3cff1163c959c50aaaaa4a7115f08" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/nikic/PHP-Parser/zipball/8c360e27327c8bd29e1c57721574709d0d706118", - "reference": "8c360e27327c8bd29e1c57721574709d0d706118", + "url": "https://api.github.com/repos/nikic/PHP-Parser/zipball/50f0d9c9d0e3cff1163c959c50aaaaa4a7115f08", + "reference": "50f0d9c9d0e3cff1163c959c50aaaaa4a7115f08", "shasum": "" }, "require": { @@ -260,7 +306,7 @@ "issues": "https://github.com/nikic/PHP-Parser/issues", "source": "https://github.com/nikic/PHP-Parser/tree/master" }, - "time": "2025-12-06T20:24:35+00:00" + "time": "2026-02-26T13:20:22+00:00" }, { "name": "phar-io/manifest", @@ -386,8 +432,8 @@ "version": "1.12.x-dev", "dist": { "type": "zip", - "url": "https://api.github.com/repos/phpstan/phpstan/zipball/2770dcdf5078d0b0d53f94317e06affe88419aa8", - "reference": "2770dcdf5078d0b0d53f94317e06affe88419aa8", + "url": "https://api.github.com/repos/phpstan/phpstan/zipball/37982d6fc7cbb746dda7773530cda557cdf119e1", + "reference": "37982d6fc7cbb746dda7773530cda557cdf119e1", "shasum": "" }, "require": { @@ -432,20 +478,20 @@ "type": "github" } ], - "time": "2025-09-30T10:16:31+00:00" + "time": "2026-02-28T20:30:03+00:00" }, { "name": "phpunit/php-code-coverage", - "version": "9.2.x-dev", + "version": "9.2.32", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/php-code-coverage.git", - "reference": "653bca7ea05439961818f429a2a49ec8a8c7d2fb" + "reference": "85402a822d1ecf1db1096959413d35e1c37cf1a5" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/php-code-coverage/zipball/653bca7ea05439961818f429a2a49ec8a8c7d2fb", - "reference": "653bca7ea05439961818f429a2a49ec8a8c7d2fb", + "url": "https://api.github.com/repos/sebastianbergmann/php-code-coverage/zipball/85402a822d1ecf1db1096959413d35e1c37cf1a5", + "reference": "85402a822d1ecf1db1096959413d35e1c37cf1a5", "shasum": "" }, "require": { @@ -502,27 +548,15 @@ "support": { "issues": "https://github.com/sebastianbergmann/php-code-coverage/issues", "security": "https://github.com/sebastianbergmann/php-code-coverage/security/policy", - "source": "https://github.com/sebastianbergmann/php-code-coverage/tree/9.2" + "source": "https://github.com/sebastianbergmann/php-code-coverage/tree/9.2.32" }, "funding": [ { "url": "https://github.com/sebastianbergmann", "type": "github" - }, - { - "url": "https://liberapay.com/sebastianbergmann", - "type": "liberapay" - }, - { - "url": "https://thanks.dev/u/gh/sebastianbergmann", - "type": "thanks_dev" - }, - { - "url": "https://tidelift.com/funding/github/packagist/phpunit/php-code-coverage", - "type": "tidelift" } ], - "time": "2025-11-26T14:28:02+00:00" + "time": "2024-08-22T04:23:01+00:00" }, { "name": "phpunit/php-file-iterator", @@ -771,12 +805,12 @@ "source": { "type": "git", "url": "https://github.com/sebastianbergmann/phpunit.git", - "reference": "f9f31a35f726beaab98d1efe3b640e9b40c80baf" + "reference": "6fd565bf20d294f964f4cb5162c1615cfe273347" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/f9f31a35f726beaab98d1efe3b640e9b40c80baf", - "reference": "f9f31a35f726beaab98d1efe3b640e9b40c80baf", + "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/6fd565bf20d294f964f4cb5162c1615cfe273347", + "reference": "6fd565bf20d294f964f4cb5162c1615cfe273347", "shasum": "" }, "require": { @@ -854,27 +888,11 @@ }, "funding": [ { - "url": "https://phpunit.de/sponsors.html", - "type": "custom" - }, - { - "url": "https://github.com/sebastianbergmann", - "type": "github" - }, - { - "url": "https://liberapay.com/sebastianbergmann", - "type": "liberapay" - }, - { - "url": "https://thanks.dev/u/gh/sebastianbergmann", - "type": "thanks_dev" - }, - { - "url": "https://tidelift.com/funding/github/packagist/phpunit/phpunit", - "type": "tidelift" + "url": "https://phpunit.de/sponsoring.html", + "type": "other" } ], - "time": "2026-02-01T08:24:05+00:00" + "time": "2026-04-14T06:54:51+00:00" }, { "name": "sebastian/cli-parser", @@ -1045,7 +1063,7 @@ }, { "name": "sebastian/comparator", - "version": "4.0.x-dev", + "version": "4.0.10", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/comparator.git", @@ -1317,7 +1335,7 @@ }, { "name": "sebastian/exporter", - "version": "4.0.x-dev", + "version": "4.0.8", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/exporter.git", @@ -1894,12 +1912,12 @@ "source": { "type": "git", "url": "https://github.com/PHPCSStandards/PHP_CodeSniffer.git", - "reference": "9c42a808cf53f4ab9b531af2023ef1815699938a" + "reference": "42687919b65b7542282d315c451702639d58bb72" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/PHPCSStandards/PHP_CodeSniffer/zipball/9c42a808cf53f4ab9b531af2023ef1815699938a", - "reference": "9c42a808cf53f4ab9b531af2023ef1815699938a", + "url": "https://api.github.com/repos/PHPCSStandards/PHP_CodeSniffer/zipball/42687919b65b7542282d315c451702639d58bb72", + "reference": "42687919b65b7542282d315c451702639d58bb72", "shasum": "" }, "require": { @@ -1965,7 +1983,7 @@ "type": "thanks_dev" } ], - "time": "2026-02-05T23:49:55+00:00" + "time": "2026-04-06T01:04:07+00:00" }, { "name": "swoole/ide-helper", @@ -2069,5 +2087,5 @@ "php": ">=8.0" }, "platform-dev": {}, - "plugin-api-version": "2.6.0" + "plugin-api-version": "2.9.0" } diff --git a/src/Command.php b/src/Command.php new file mode 100644 index 0000000..e6562f6 --- /dev/null +++ b/src/Command.php @@ -0,0 +1,238 @@ +'; + + private const REDIRECT_APPEND_STDOUT = '>>'; + + private const REDIRECT_INPUT = '<'; + + private string $type = self::TYPE_PLAIN; + + /** + * @var array + */ + protected array $arguments = []; + + /** + * @var array + */ + private array $commands = []; + + private ?string $operator = null; + + private ?self $command = null; + + private ?string $redirect = null; + + private ?string $redirectTarget = null; + + public function __construct(string $executable) + { + $this->arguments[] = $this->normalize($executable, 'Command executable'); + } + + public static function pipe(Command ...$commands): self + { + return self::compose(self::OPERATOR_PIPE, $commands); + } + + public static function and(Command ...$commands): self + { + return self::compose(self::OPERATOR_AND, $commands); + } + + public static function or(Command ...$commands): self + { + return self::compose(self::OPERATOR_OR, $commands); + } + + public static function group(Command $command): self + { + $expression = new self('true'); + $expression->type = self::TYPE_GROUP; + $expression->arguments = []; + $expression->command = $command; + + return $expression; + } + + public static function redirectStdout(Command $command, string|Stringable $path): self + { + return self::redirect(self::REDIRECT_STDOUT, $command, $path); + } + + public static function appendStdout(Command $command, string|Stringable $path): self + { + return self::redirect(self::REDIRECT_APPEND_STDOUT, $command, $path); + } + + public static function redirectInput(Command $command, string|Stringable $path): self + { + return self::redirect(self::REDIRECT_INPUT, $command, $path); + } + + public function flag(string $key): self + { + $this->ensurePlain(); + + if (! \preg_match('/^-[A-Za-z0-9]+$|^--[A-Za-z0-9][A-Za-z0-9_-]*$/', $key)) { + throw new InvalidArgumentException('Invalid command flag: '.$key); + } + + $this->arguments[] = $key; + + return $this; + } + + public function option(string $key, string|int|float|Stringable $value, Validator|callable|null $validator = null): self + { + $this->ensurePlain(); + + if (! \preg_match('/^-[A-Za-z0-9]$|^--[A-Za-z0-9][A-Za-z0-9_-]*$/', $key)) { + throw new InvalidArgumentException('Invalid command option: '.$key); + } + + $argument = $this->normalize($value, 'Command option value'); + $this->validate($argument, $validator ?? new Text(0)); + + $this->arguments[] = $key; + $this->arguments[] = $argument; + + return $this; + } + + public function argument(string|int|float|Stringable $value, Validator|callable|null $validator = null): self + { + $this->ensurePlain(); + + $argument = $this->normalize($value, 'Command argument'); + $this->validate($argument, $validator ?? new Text(0)); + + $this->arguments[] = $argument; + + return $this; + } + + /** + * @return array + */ + public function toArray(): array + { + if (! $this->isPlain()) { + throw new InvalidArgumentException('Only plain commands can be converted to an array'); + } + + return $this->arguments; + } + + public function toString(): string + { + return match ($this->type) { + self::TYPE_PLAIN => \implode(' ', \array_map(static fn (string $argument): string => \escapeshellarg($argument), $this->arguments)), + self::TYPE_COMPOSITE => \implode(' '.$this->operator.' ', \array_map(static fn (self $command): string => $command->toString(), $this->commands)), + self::TYPE_GROUP => '( '.$this->command?->toString().' )', + self::TYPE_REDIRECT => $this->command?->toString().' '.$this->redirect.' '.\escapeshellarg($this->redirectTarget ?? ''), + default => throw new InvalidArgumentException('Unsupported command type: '.$this->type), + }; + } + + public function __toString(): string + { + return $this->toString(); + } + + public function isPlain(): bool + { + return $this->type === self::TYPE_PLAIN; + } + + /** + * @param array $commands + */ + private static function compose(string $operator, array $commands): self + { + if (\count($commands) < 2) { + throw new InvalidArgumentException('Composed commands require at least two commands'); + } + + $expression = new self('true'); + $expression->type = self::TYPE_COMPOSITE; + $expression->arguments = []; + $expression->operator = $operator; + $expression->commands = \array_values($commands); + + return $expression; + } + + private static function redirect(string $redirect, Command $command, string|Stringable $path): self + { + $expression = new self('true'); + $expression->type = self::TYPE_REDIRECT; + $expression->arguments = []; + $expression->command = $command; + $expression->redirect = $redirect; + $expression->redirectTarget = $expression->normalize($path, 'Command redirect target'); + + return $expression; + } + + private function ensurePlain(): void + { + if (! $this->isPlain()) { + throw new InvalidArgumentException('Flags, options, and arguments can only be added to plain commands'); + } + } + + private function normalize(string|int|float|Stringable $value, string $context): string + { + $value = (string) $value; + + if ($value === '') { + throw new InvalidArgumentException($context.' cannot be empty'); + } + + return $value; + } + + /** + * @param Validator|callable $validator + */ + private function validate(string $argument, Validator|callable $validator): void + { + if ($validator instanceof Validator) { + if (! $validator->isValid($argument)) { + throw new InvalidArgumentException('Invalid command argument: '.$argument.' ('.$validator->getDescription().')'); + } + + return; + } + + $isValid = (bool) $validator($argument); + + if (! $isValid) { + throw new InvalidArgumentException('Invalid command argument: '.$argument); + } + } +} diff --git a/src/Console.php b/src/Console.php index 893b119..4801bc5 100644 --- a/src/Console.php +++ b/src/Console.php @@ -124,7 +124,7 @@ public static function exit(int $status = 0): void * * This function was inspired by: https://stackoverflow.com/a/13287902/2299554 * - * @param array|string $cmd + * @param Command|array|string $cmd * @param string $stdin * @param string $stdout Stdout contents (by reference). * @param string $stderr Stderr contents (by reference). @@ -132,8 +132,14 @@ public static function exit(int $status = 0): void * @param callable|null $onProgress * @return int */ - public static function execute(array|string $cmd, string $stdin, string &$stdout, string &$stderr, int $timeout = -1, ?callable $onProgress = null): int + public static function execute(Command|array|string $cmd, string $stdin, string &$stdout, string &$stderr, int $timeout = -1, ?callable $onProgress = null): int { + if ($cmd instanceof Command) { + $cmd = $cmd->isPlain() + ? $cmd->toArray() + : $cmd->toString(); + } + // If the $cmd is passed as string, it will be wrapped into a subshell by \proc_open // Forward stdout and exit codes from the subshell. if (is_string($cmd)) { @@ -182,12 +188,15 @@ public static function execute(array|string $cmd, string $stdin, string &$stdout return 1; } - if (! \proc_get_status($process)['running']) { + $procStatus = \proc_get_status($process); + if (! $procStatus['running']) { \fclose($pipes[1]); \fclose($pipes[2]); \proc_close($process); - $exitCode = (int) str_replace("\n", '', $status); + $exitCode = ($status !== '') + ? (int) str_replace("\n", '', $status) + : $procStatus['exitcode']; return $exitCode; } diff --git a/tests/Console/ConsoleTest.php b/tests/Console/ConsoleTest.php index ed5f18c..cf0fb59 100644 --- a/tests/Console/ConsoleTest.php +++ b/tests/Console/ConsoleTest.php @@ -2,7 +2,9 @@ namespace Utopia\Console\Tests; +use InvalidArgumentException; use PHPUnit\Framework\TestCase; +use Utopia\Command; use Utopia\Console; class ConsoleTest extends TestCase @@ -19,15 +21,87 @@ public function testLogs(): void public function testExecuteBasic(): void { + $command = (new Command(PHP_BINARY)) + ->option('-r', "echo 'hello world';"); $output = ''; $stderr = ''; $input = ''; - $code = Console::execute('php -r "echo \'hello world\';"', $input, $output, $stderr, 10); + $code = Console::execute($command, $input, $output, $stderr, 10); $this->assertEquals('hello world', $output); $this->assertEquals(0, $code); } + public function testCommandToArray(): void + { + $command = (new Command('tar')) + ->flag('-cz') + ->option('-f', 'archive.tar.gz') + ->option('-C', '/tmp/project') + ->argument('.'); + + $this->assertSame(['tar', '-cz', '-f', 'archive.tar.gz', '-C', '/tmp/project', '.'], $command->toArray()); + } + + public function testCommandToStringEscapesArguments(): void + { + $command = (new Command('php')) + ->option('-r', "echo 'hello'; rm -rf /"); + + $this->assertSame("'php' '-r' 'echo '\''hello'\''; rm -rf /'", $command->toString()); + } + + public function testCommandDefaultValidatorRejectsEmptyArgument(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Command argument cannot be empty'); + + (new Command('git'))->argument(''); + } + + public function testCommandValidatorSuccess(): void + { + $command = (new Command('git')) + ->argument('checkout') + ->argument('develop', fn (string $value): bool => in_array($value, ['main', 'develop', 'staging'], true)); + + $this->assertSame(['git', 'checkout', 'develop'], $command->toArray()); + } + + public function testCommandValidatorFailure(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Invalid command argument: feature/test; rm -rf /'); + + (new Command('git')) + ->argument('checkout') + ->argument('feature/test; rm -rf /', fn (string $value): bool => preg_match('/^[A-Za-z0-9._\/-]+$/', $value) === 1); + } + + public function testCommandRejectsInvalidFlag(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Invalid command flag: verbose'); + + (new Command('git'))->flag('verbose'); + } + + public function testCommandRejectsInvalidOption(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Invalid command option: -cz'); + + (new Command('tar'))->option('-cz', 'archive.tar.gz'); + } + + public function testCommandRejectsEmptyRedirectTarget(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Command redirect target cannot be empty'); + + Command::redirectStdout(new Command('php'), ''); + } + public function testExecuteArray(): void { $output = ''; @@ -70,12 +144,13 @@ public function testExecuteEnvVariables(): void public function testExecuteStream(): void { + $command = (new Command(PHP_BINARY)) + ->option('-r', 'for ($i = 1; $i <= 5; $i++) { echo $i; usleep(1000000); }'); $output = ''; $stderr = ''; $input = ''; - $outputStream = ''; - $code = Console::execute('printf 1 && sleep 1 && printf 2 && sleep 1 && printf 3 && sleep 1 && printf 4 && sleep 1 && printf 5', $input, $output, $stderr, 10, function ($output) use (&$outputStream) { + $code = Console::execute($command, $input, $output, $stderr, 10, function ($output) use (&$outputStream) { $outputStream .= $output; }); @@ -86,10 +161,12 @@ public function testExecuteStream(): void public function testExecuteStdOut(): void { + $command = (new Command(PHP_BINARY)) + ->option('-r', 'fwrite(STDOUT, "success\n");'); $output = ''; $stderr = ''; $input = ''; - $code = Console::execute('>&1 echo "success"', $input, $output, $stderr, 3); + $code = Console::execute($command, $input, $output, $stderr, 3); $this->assertEquals("success\n", $output); $this->assertEquals('', $stderr); @@ -98,10 +175,12 @@ public function testExecuteStdOut(): void public function testExecuteStdErr(): void { + $command = (new Command(PHP_BINARY)) + ->option('-r', 'fwrite(STDERR, "error\n");'); $output = ''; $stderr = ''; $input = ''; - $code = Console::execute('>&2 echo "error"', $input, $output, $stderr, 3); + $code = Console::execute($command, $input, $output, $stderr, 3); $this->assertEquals('', $output); $this->assertEquals("error\n", $stderr); @@ -110,18 +189,22 @@ public function testExecuteStdErr(): void public function testExecuteExitCode(): void { + $command = (new Command(PHP_BINARY)) + ->option('-r', "echo 'hello world'; exit(2);"); $output = ''; $stderr = ''; $input = ''; - $code = Console::execute('php -r "echo \'hello world\'; exit(2);"', $input, $output, $stderr, 10); + $code = Console::execute($command, $input, $output, $stderr, 10); $this->assertEquals('hello world', $output); $this->assertEquals(2, $code); + $command = (new Command(PHP_BINARY)) + ->option('-r', "echo 'hello world'; exit(100);"); $output = ''; $stderr = ''; $input = ''; - $code = Console::execute('php -r "echo \'hello world\'; exit(100);"', $input, $output, $stderr, 10); + $code = Console::execute($command, $input, $output, $stderr, 10); $this->assertEquals('hello world', $output); $this->assertEquals(100, $code); @@ -129,18 +212,22 @@ public function testExecuteExitCode(): void public function testExecuteTimeout(): void { + $command = (new Command(PHP_BINARY)) + ->option('-r', "sleep(1); echo 'hello world'; exit(0);"); $output = ''; $stderr = ''; $input = ''; - $code = Console::execute('php -r "sleep(1); echo \'hello world\'; exit(0);"', $input, $output, $stderr, 3); + $code = Console::execute($command, $input, $output, $stderr, 3); $this->assertEquals('hello world', $output); $this->assertEquals(0, $code); + $command = (new Command(PHP_BINARY)) + ->option('-r', "sleep(4); echo 'hello world'; exit(0);"); $output = ''; $stderr = ''; $input = ''; - $code = Console::execute('php -r "sleep(4); echo \'hello world\'; exit(0);"', $input, $output, $stderr, 3); + $code = Console::execute($command, $input, $output, $stderr, 3); $this->assertEquals('', $output); $this->assertEquals(1, $code); @@ -149,10 +236,12 @@ public function testExecuteTimeout(): void public function testLoop(): void { $file = __DIR__.'/../resources/loop.php'; + $command = (new Command(PHP_BINARY)) + ->argument($file); $input = ''; $output = ''; $stderr = ''; - $code = Console::execute('php '.$file, $input, $output, $stderr, 30); + $code = Console::execute($command, $input, $output, $stderr, 30); $lines = explode("\n", $output); @@ -160,4 +249,314 @@ public function testLoop(): void $this->assertLessThan(50, count($lines)); $this->assertEquals(1, $code); } + + public function testCommandCompositionToString(): void + { + $command = Command::and( + Command::group( + Command::or( + new Command('build'), + new Command('build:fallback') + ) + ), + new Command('publish') + ); + + $this->assertSame("( 'build' || 'build:fallback' ) && 'publish'", $command->toString()); + } + + public function testCommandPipeToString(): void + { + $command = Command::pipe( + (new Command('ps'))->flag('-ef'), + (new Command('grep'))->argument('php-fpm'), + (new Command('wc'))->flag('-l') + ); + + $this->assertSame("'ps' '-ef' | 'grep' 'php-fpm' | 'wc' '-l'", $command->toString()); + } + + public function testCommandRedirectsToString(): void + { + $command = Command::appendStdout( + Command::pipe( + (new Command('cat'))->argument('app.log'), + (new Command('grep'))->argument('ERROR') + ), + 'errors.log' + ); + + $this->assertSame("'cat' 'app.log' | 'grep' 'ERROR' >> 'errors.log'", $command->toString()); + } + + public function testNestedCommandExpressionToString(): void + { + $command = Command::redirectStdout( + Command::group( + Command::and( + Command::or( + new Command('build'), + new Command('build:fallback') + ), + new Command('publish') + ) + ), + 'deploy.log' + ); + + $this->assertSame("( 'build' || 'build:fallback' && 'publish' ) > 'deploy.log'", $command->toString()); + } + + public function testGroupAnyCommand(): void + { + $command = Command::group(new Command('build')); + + $this->assertSame("( 'build' )", $command->toString()); + } + + public function testCompositeCommandIsNotPlain(): void + { + $this->assertFalse(Command::and(new Command('build'), new Command('publish'))->isPlain()); + } + + public function testGroupedCommandIsNotPlain(): void + { + $this->assertFalse(Command::group(new Command('build'))->isPlain()); + } + + public function testRedirectedCommandIsNotPlain(): void + { + $this->assertFalse(Command::redirectStdout(new Command('build'), 'build.log')->isPlain()); + } + + public function testCompositeCommandCannotBeConvertedToArray(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Only plain commands can be converted to an array'); + + Command::and(new Command('build'), new Command('publish'))->toArray(); + } + + public function testGroupedCommandCannotBeConvertedToArray(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Only plain commands can be converted to an array'); + + Command::group(new Command('build'))->toArray(); + } + + public function testRedirectedCommandCannotBeConvertedToArray(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Only plain commands can be converted to an array'); + + Command::redirectStdout(new Command('build'), 'build.log')->toArray(); + } + + public function testCompositeCommandRequiresAtLeastTwoCommands(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Composed commands require at least two commands'); + + Command::and(new Command('build')); + } + + public function testGroupedCommandRejectsAdditionalFlags(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Flags, options, and arguments can only be added to plain commands'); + + Command::group(new Command('build'))->flag('-v'); + } + + public function testCompositeCommandRejectsAdditionalOptions(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Flags, options, and arguments can only be added to plain commands'); + + Command::and(new Command('build'), new Command('publish'))->option('--env', 'prod'); + } + + public function testRedirectedCommandRejectsAdditionalArguments(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Flags, options, and arguments can only be added to plain commands'); + + Command::redirectStdout(new Command('build'), 'build.log')->argument('extra'); + } + + public function testExecutePipeExpression(): void + { + $command = Command::pipe( + (new Command(PHP_BINARY))->option('-r', 'echo "alpha\nbeta\n";'), + (new Command('grep'))->argument('beta') + ); + $output = ''; + $stderr = ''; + $input = ''; + $code = Console::execute($command, $input, $output, $stderr, 10); + + $this->assertSame("beta\n", $output); + $this->assertSame('', $stderr); + $this->assertSame(0, $code); + } + + public function testExecuteGroupedFallbackExpression(): void + { + $command = Command::and( + Command::group( + Command::or( + (new Command(PHP_BINARY))->option('-r', 'exit(1);'), + (new Command(PHP_BINARY))->option('-r', 'echo "fallback";') + ) + ), + (new Command(PHP_BINARY))->option('-r', 'echo " publish";') + ); + $output = ''; + $stderr = ''; + $input = ''; + $code = Console::execute($command, $input, $output, $stderr, 10); + + $this->assertSame('fallback publish', $output); + $this->assertSame('', $stderr); + $this->assertSame(0, $code); + } + + public function testExecuteAndStopsOnFailure(): void + { + $command = Command::and( + (new Command(PHP_BINARY))->option('-r', 'echo "start"; exit(1);'), + (new Command(PHP_BINARY))->option('-r', 'echo "never";') + ); + $output = ''; + $stderr = ''; + $input = ''; + $code = Console::execute($command, $input, $output, $stderr, 10); + + $this->assertSame('start', $output); + $this->assertSame('', $stderr); + $this->assertSame(1, $code); + } + + public function testExecuteOrStopsAfterSuccess(): void + { + $command = Command::or( + (new Command(PHP_BINARY))->option('-r', 'echo "done";'), + (new Command(PHP_BINARY))->option('-r', 'echo "fallback";') + ); + $output = ''; + $stderr = ''; + $input = ''; + $code = Console::execute($command, $input, $output, $stderr, 10); + + $this->assertSame('done', $output); + $this->assertSame('', $stderr); + $this->assertSame(0, $code); + } + + public function testExecuteGroupedPrecedenceChangesOutcome(): void + { + $command = Command::and( + Command::group( + Command::or( + (new Command(PHP_BINARY))->option('-r', 'exit(1);'), + (new Command(PHP_BINARY))->option('-r', 'echo "fallback";') + ) + ), + (new Command(PHP_BINARY))->option('-r', 'echo " publish";') + ); + $output = ''; + $stderr = ''; + $input = ''; + $code = Console::execute($command, $input, $output, $stderr, 10); + + $this->assertSame('fallback publish', $output); + $this->assertSame(0, $code); + } + + public function testExecuteRedirectStdoutExpression(): void + { + $file = tempnam(sys_get_temp_dir(), 'utopia-console-'); + $this->assertNotFalse($file); + + try { + $command = Command::redirectStdout( + (new Command(PHP_BINARY))->option('-r', 'echo "saved";'), + $file + ); + $output = ''; + $stderr = ''; + $input = ''; + $code = Console::execute($command, $input, $output, $stderr, 10); + + $this->assertSame('', $output); + $this->assertSame('', $stderr); + $this->assertSame(0, $code); + $this->assertSame('saved', file_get_contents($file)); + } finally { + @unlink($file); + } + } + + public function testExecuteAppendStdoutExpression(): void + { + $file = tempnam(sys_get_temp_dir(), 'utopia-console-'); + $this->assertNotFalse($file); + + try { + file_put_contents($file, "first\n"); + + $command = Command::appendStdout( + (new Command(PHP_BINARY))->option('-r', 'echo "second";'), + $file + ); + $output = ''; + $stderr = ''; + $input = ''; + $code = Console::execute($command, $input, $output, $stderr, 10); + + $this->assertSame('', $output); + $this->assertSame('', $stderr); + $this->assertSame(0, $code); + $this->assertSame("first\nsecond", file_get_contents($file)); + } finally { + @unlink($file); + } + } + + public function testExecuteRedirectInputExpression(): void + { + $file = tempnam(sys_get_temp_dir(), 'utopia-console-'); + $this->assertNotFalse($file); + + try { + file_put_contents($file, "delta\nalpha\n"); + + $command = Command::redirectInput( + new Command('sort'), + $file + ); + $output = ''; + $stderr = ''; + $input = ''; + $code = Console::execute($command, $input, $output, $stderr, 10); + + $this->assertSame("alpha\ndelta\n", $output); + $this->assertSame('', $stderr); + $this->assertSame(0, $code); + } finally { + @unlink($file); + } + } + + public function testExecuteStringRemainsCompatible(): void + { + $output = ''; + $stderr = ''; + $input = ''; + $code = Console::execute('php -r "echo \'hello world\';"', $input, $output, $stderr, 10); + + $this->assertSame('hello world', $output); + $this->assertSame(0, $code); + } }