From 73c045712266d6397e9e2c8e15438102840c2a52 Mon Sep 17 00:00:00 2001 From: Torsten Dittmann Date: Tue, 14 Apr 2026 14:03:40 +0400 Subject: [PATCH 1/5] Add Command class for structured command building with validation - Add Utopia\Command class for building shell commands safely - Support per-argument validation using Utopia\Servers\Validator or callables - Add toArray() for safe argv execution and toString() for escaped shell strings - Update Console::execute() to accept Command|array|string - Fix exit code handling for Command/array execution - Add tests for Command functionality - Update README with Command usage examples - Add utopia-php/servers dependency for Validator class --- README.md | 17 +++++- composer.json | 3 +- composer.lock | 108 +++++++++++++++++++++++++++++++++- src/Command.php | 79 +++++++++++++++++++++++++ src/Console.php | 14 +++-- tests/Console/ConsoleTest.php | 89 ++++++++++++++++++++++++---- 6 files changed, 288 insertions(+), 22 deletions(-) create mode 100644 src/Command.php diff --git a/README.md b/README.md index a4a13ee..1b7626c 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,12 @@ if ($answer !== 'y') { } $output = ''; -$exitCode = Console::execute('php -r "echo \"Hello\";"', '', $output, 3); +$stderr = ''; +$command = (new Command(PHP_BINARY)) + ->add('-r') + ->add('echo "Hello";'); + +$exitCode = Console::execute($command, '', $output, $stderr, 3); Console::log("Command returned {$exitCode} with: {$output}"); ``` @@ -45,12 +51,17 @@ 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)) + ->add('-r') + ->add('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" diff --git a/composer.json b/composer.json index 0afe6d4..5cc5a8b 100755 --- a/composer.json +++ b/composer.json @@ -14,7 +14,8 @@ "psr-4": {"Utopia\\": "src/"} }, "require": { - "php": ">=8.0" + "php": ">=8.0", + "utopia-php/servers": "^0.1.0" }, "require-dev": { "phpunit/phpunit": "^9.3", diff --git a/composer.lock b/composer.lock index 8f1ddab..f07a4a6 100644 --- a/composer.lock +++ b/composer.lock @@ -4,8 +4,110 @@ "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": "f4e98dd5da768b18c74c4362ca02b628", + "packages": [ + { + "name": "utopia-php/di", + "version": "0.1.0", + "source": { + "type": "git", + "url": "https://github.com/utopia-php/di.git", + "reference": "22490c95f7ac3898ed1c33f1b1b5dd577305ee31" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/utopia-php/di/zipball/22490c95f7ac3898ed1c33f1b1b5dd577305ee31", + "reference": "22490c95f7ac3898ed1c33f1b1b5dd577305ee31", + "shasum": "" + }, + "require": { + "php": ">=8.2" + }, + "require-dev": { + "laravel/pint": "^1.2", + "phpbench/phpbench": "^1.2", + "phpstan/phpstan": "^1.10", + "phpunit/phpunit": "^9.5.25", + "swoole/ide-helper": "4.8.3" + }, + "type": "library", + "autoload": { + "psr-4": { + "Utopia\\": "src/", + "Tests\\E2E\\": "tests/e2e" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "A simple and lite library for managing dependency injections", + "keywords": [ + "framework", + "http", + "php", + "upf" + ], + "support": { + "issues": "https://github.com/utopia-php/di/issues", + "source": "https://github.com/utopia-php/di/tree/0.1.0" + }, + "time": "2024-08-08T14:35:19+00:00" + }, + { + "name": "utopia-php/servers", + "version": "0.1.1", + "source": { + "type": "git", + "url": "https://github.com/utopia-php/servers.git", + "reference": "fd5c8d32778f265256c1936372a071b944f5ba8a" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/utopia-php/servers/zipball/fd5c8d32778f265256c1936372a071b944f5ba8a", + "reference": "fd5c8d32778f265256c1936372a071b944f5ba8a", + "shasum": "" + }, + "require": { + "php": ">=8.0", + "utopia-php/di": "0.1.*" + }, + "require-dev": { + "laravel/pint": "^0.2.3", + "phpstan/phpstan": "^1.8", + "phpunit/phpunit": "^9.5.5" + }, + "type": "library", + "autoload": { + "psr-4": { + "Utopia\\Servers\\": "src/Servers" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Team Appwrite", + "email": "team@appwrite.io" + } + ], + "description": "A base library for building Utopia style servers.", + "keywords": [ + "framework", + "php", + "servers", + "upf", + "utopia" + ], + "support": { + "issues": "https://github.com/utopia-php/servers/issues", + "source": "https://github.com/utopia-php/servers/tree/0.1.1" + }, + "time": "2024-09-06T02:25:56+00:00" + } + ], "packages-dev": [ { "name": "doctrine/instantiator", @@ -2069,5 +2171,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..938b162 --- /dev/null +++ b/src/Command.php @@ -0,0 +1,79 @@ + + */ + protected array $arguments = []; + + public function __construct(string $executable) + { + $this->arguments[] = $this->normalize($executable, 'Command executable'); + } + + public function add(string|int|float|bool|Stringable $value, Validator|callable|null $validator = null): self + { + $argument = $this->normalize($value, 'Command argument'); + + if ($validator !== null) { + $this->validate($argument, $validator); + } + + $this->arguments[] = $argument; + + return $this; + } + + /** + * @return array + */ + public function toArray(): array + { + return $this->arguments; + } + + public function toString(): string + { + $escaped = array_map(static fn (string $argument): string => escapeshellarg($argument), $this->arguments); + + return implode(' ', $escaped); + } + + protected function normalize(string|int|float|bool|Stringable $value, string $context): string + { + $value = (string) $value; + + if ($value === '') { + throw new InvalidArgumentException($context.' cannot be empty'); + } + + return $value; + } + + /** + * @param Validator|callable $validator + */ + protected 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..1800a9e 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,12 @@ 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->toArray(); + } + // 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)) { @@ -185,9 +189,11 @@ public static function execute(array|string $cmd, string $stdin, string &$stdout if (! \proc_get_status($process)['running']) { \fclose($pipes[1]); \fclose($pipes[2]); - \proc_close($process); + $procCloseCode = \proc_close($process); - $exitCode = (int) str_replace("\n", '', $status); + $exitCode = ($status !== '') + ? (int) str_replace("\n", '', $status) + : $procCloseCode; return $exitCode; } diff --git a/tests/Console/ConsoleTest.php b/tests/Console/ConsoleTest.php index ed5f18c..2fffb81 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 @@ -22,12 +24,51 @@ public function testExecuteBasic(): void $output = ''; $stderr = ''; $input = ''; - $code = Console::execute('php -r "echo \'hello world\';"', $input, $output, $stderr, 10); + $code = Console::execute((new Command(PHP_BINARY)) + ->add('-r') + ->add("echo 'hello world';"), $input, $output, $stderr, 10); $this->assertEquals('hello world', $output); $this->assertEquals(0, $code); } + public function testCommandToArray(): void + { + $command = (new Command('php')) + ->add('-r') + ->add("echo 'hello world';"); + + $this->assertSame(['php', '-r', "echo 'hello world';"], $command->toArray()); + } + + public function testCommandToStringEscapesArguments(): void + { + $command = (new Command('php')) + ->add('-r') + ->add("echo 'hello'; rm -rf /"); + + $this->assertSame("'php' '-r' 'echo '\''hello'\''; rm -rf /'", $command->toString()); + } + + public function testCommandValidatorCallable(): void + { + $command = (new Command('git')) + ->add('checkout') + ->add('feature/test-1', fn (string $value): bool => preg_match('/^[A-Za-z0-9._\/-]+$/', $value) === 1); + + $this->assertSame(['git', 'checkout', 'feature/test-1'], $command->toArray()); + } + + public function testCommandValidatorFailure(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Invalid command argument: feature/test; rm -rf /'); + + (new Command('git')) + ->add('checkout') + ->add('feature/test; rm -rf /', fn (string $value): bool => preg_match('/^[A-Za-z0-9._\/-]+$/', $value) === 1); + } + public function testExecuteArray(): void { $output = ''; @@ -75,9 +116,11 @@ public function testExecuteStream(): void $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) { - $outputStream .= $output; - }); + $code = Console::execute((new Command(PHP_BINARY)) + ->add('-r') + ->add('for ($i = 1; $i <= 5; $i++) { echo $i; usleep(1000000); }'), $input, $output, $stderr, 10, function ($output) use (&$outputStream) { + $outputStream .= $output; + }); $this->assertEquals('12345', $output); $this->assertEquals('12345', $outputStream); @@ -89,7 +132,9 @@ public function testExecuteStdOut(): void $output = ''; $stderr = ''; $input = ''; - $code = Console::execute('>&1 echo "success"', $input, $output, $stderr, 3); + $code = Console::execute((new Command(PHP_BINARY)) + ->add('-r') + ->add('fwrite(STDOUT, "success\n");'), $input, $output, $stderr, 3); $this->assertEquals("success\n", $output); $this->assertEquals('', $stderr); @@ -101,7 +146,9 @@ public function testExecuteStdErr(): void $output = ''; $stderr = ''; $input = ''; - $code = Console::execute('>&2 echo "error"', $input, $output, $stderr, 3); + $code = Console::execute((new Command(PHP_BINARY)) + ->add('-r') + ->add('fwrite(STDERR, "error\n");'), $input, $output, $stderr, 3); $this->assertEquals('', $output); $this->assertEquals("error\n", $stderr); @@ -113,7 +160,9 @@ public function testExecuteExitCode(): void $output = ''; $stderr = ''; $input = ''; - $code = Console::execute('php -r "echo \'hello world\'; exit(2);"', $input, $output, $stderr, 10); + $code = Console::execute((new Command(PHP_BINARY)) + ->add('-r') + ->add("echo 'hello world'; exit(2);"), $input, $output, $stderr, 10); $this->assertEquals('hello world', $output); $this->assertEquals(2, $code); @@ -121,7 +170,9 @@ public function testExecuteExitCode(): void $output = ''; $stderr = ''; $input = ''; - $code = Console::execute('php -r "echo \'hello world\'; exit(100);"', $input, $output, $stderr, 10); + $code = Console::execute((new Command(PHP_BINARY)) + ->add('-r') + ->add("echo 'hello world'; exit(100);"), $input, $output, $stderr, 10); $this->assertEquals('hello world', $output); $this->assertEquals(100, $code); @@ -132,7 +183,9 @@ public function testExecuteTimeout(): void $output = ''; $stderr = ''; $input = ''; - $code = Console::execute('php -r "sleep(1); echo \'hello world\'; exit(0);"', $input, $output, $stderr, 3); + $code = Console::execute((new Command(PHP_BINARY)) + ->add('-r') + ->add("sleep(1); echo 'hello world'; exit(0);"), $input, $output, $stderr, 3); $this->assertEquals('hello world', $output); $this->assertEquals(0, $code); @@ -140,7 +193,9 @@ public function testExecuteTimeout(): void $output = ''; $stderr = ''; $input = ''; - $code = Console::execute('php -r "sleep(4); echo \'hello world\'; exit(0);"', $input, $output, $stderr, 3); + $code = Console::execute((new Command(PHP_BINARY)) + ->add('-r') + ->add("sleep(4); echo 'hello world'; exit(0);"), $input, $output, $stderr, 3); $this->assertEquals('', $output); $this->assertEquals(1, $code); @@ -152,7 +207,8 @@ public function testLoop(): void $input = ''; $output = ''; $stderr = ''; - $code = Console::execute('php '.$file, $input, $output, $stderr, 30); + $code = Console::execute((new Command(PHP_BINARY)) + ->add($file), $input, $output, $stderr, 30); $lines = explode("\n", $output); @@ -160,4 +216,15 @@ public function testLoop(): void $this->assertLessThan(50, count($lines)); $this->assertEquals(1, $code); } + + 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); + } } From 9432f5f44a6eb378a6c9baf4dac6042a2d6e42a2 Mon Sep 17 00:00:00 2001 From: Torsten Dittmann Date: Tue, 14 Apr 2026 19:53:27 +0400 Subject: [PATCH 2/5] Fix PR review comments - Remove bool from union types to avoid confusing error messages - Update PHP version constraint to >=8.2 (required by transitive dependency) - Implement Stringable interface with __toString() method --- composer.json | 2 +- src/Command.php | 11 ++++++++--- 2 files changed, 9 insertions(+), 4 deletions(-) diff --git a/composer.json b/composer.json index 5cc5a8b..2d3feeb 100755 --- a/composer.json +++ b/composer.json @@ -14,7 +14,7 @@ "psr-4": {"Utopia\\": "src/"} }, "require": { - "php": ">=8.0", + "php": ">=8.2", "utopia-php/servers": "^0.1.0" }, "require-dev": { diff --git a/src/Command.php b/src/Command.php index 938b162..fbcafb1 100644 --- a/src/Command.php +++ b/src/Command.php @@ -6,7 +6,7 @@ use Stringable; use Utopia\Servers\Validator; -class Command +class Command implements Stringable { /** * @var array @@ -18,7 +18,7 @@ public function __construct(string $executable) $this->arguments[] = $this->normalize($executable, 'Command executable'); } - public function add(string|int|float|bool|Stringable $value, Validator|callable|null $validator = null): self + public function add(string|int|float|Stringable $value, Validator|callable|null $validator = null): self { $argument = $this->normalize($value, 'Command argument'); @@ -46,7 +46,12 @@ public function toString(): string return implode(' ', $escaped); } - protected function normalize(string|int|float|bool|Stringable $value, string $context): string + public function __toString(): string + { + return $this->toString(); + } + + protected function normalize(string|int|float|Stringable $value, string $context): string { $value = (string) $value; From 8412d3f89ca82c175f3ccc80b46273d4e5012e06 Mon Sep 17 00:00:00 2001 From: Torsten Dittmann Date: Tue, 14 Apr 2026 19:57:13 +0400 Subject: [PATCH 3/5] Fix CI: Remove PHP 8.1 and update composer.lock - Remove PHP 8.1 from test matrix (now requires PHP 8.2+) - Regenerate composer.lock to sync with updated PHP version constraint --- .github/workflows/test.yml | 2 +- composer.lock | 92 +++++++++++++------------------------- 2 files changed, 33 insertions(+), 61 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 5babce5..4294c9e 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -7,7 +7,7 @@ jobs: runs-on: ubuntu-latest strategy: matrix: - php-versions: ['8.1', '8.2', '8.3', 'nightly'] + php-versions: ['8.2', '8.3', 'nightly'] steps: - name: Checkout repository diff --git a/composer.lock b/composer.lock index f07a4a6..ba84d25 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "f4e98dd5da768b18c74c4362ca02b628", + "content-hash": "10bbf2c62c75447480388798150edacf", "packages": [ { "name": "utopia-php/di", @@ -115,12 +115,12 @@ "source": { "type": "git", "url": "https://github.com/doctrine/instantiator.git", - "reference": "78f5d718c7f52afc31e59434f625b34c52875d72" + "reference": "c795a176780bcada3d008d07eef0c2b1b298665c" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/doctrine/instantiator/zipball/78f5d718c7f52afc31e59434f625b34c52875d72", - "reference": "78f5d718c7f52afc31e59434f625b34c52875d72", + "url": "https://api.github.com/repos/doctrine/instantiator/zipball/c795a176780bcada3d008d07eef0c2b1b298665c", + "reference": "c795a176780bcada3d008d07eef0c2b1b298665c", "shasum": "" }, "require": { @@ -138,7 +138,7 @@ "type": "library", "autoload": { "psr-4": { - "Doctrine\\Instantiator\\": "src/Doctrine/Instantiator/" + "Doctrine\\Instantiator\\": "src/" } }, "notification-url": "https://packagist.org/downloads/", @@ -176,7 +176,7 @@ "type": "tidelift" } ], - "time": "2026-01-14T07:50:32+00:00" + "time": "2026-03-20T19:06:08+00:00" }, { "name": "laravel/pint", @@ -311,12 +311,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": { @@ -362,7 +362,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", @@ -488,8 +488,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": { @@ -534,20 +534,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": { @@ -604,27 +604,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", @@ -873,12 +861,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": { @@ -956,27 +944,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", @@ -1147,7 +1119,7 @@ }, { "name": "sebastian/comparator", - "version": "4.0.x-dev", + "version": "4.0.10", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/comparator.git", @@ -1419,7 +1391,7 @@ }, { "name": "sebastian/exporter", - "version": "4.0.x-dev", + "version": "4.0.8", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/exporter.git", @@ -1996,12 +1968,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": { @@ -2067,7 +2039,7 @@ "type": "thanks_dev" } ], - "time": "2026-02-05T23:49:55+00:00" + "time": "2026-04-06T01:04:07+00:00" }, { "name": "swoole/ide-helper", @@ -2168,7 +2140,7 @@ "prefer-stable": false, "prefer-lowest": false, "platform": { - "php": ">=8.0" + "php": ">=8.2" }, "platform-dev": {}, "plugin-api-version": "2.9.0" From 313cacb4e384159d330d0d3e3b92cd7b9e63737b Mon Sep 17 00:00:00 2001 From: Torsten Dittmann Date: Tue, 14 Apr 2026 20:06:16 +0400 Subject: [PATCH 4/5] Fix exit code handling and regenerate lock file with PHP 8.2 - Fix Console::execute() to use proc_get_status()['exitcode'] instead of proc_close() return value, which can be -1 on some systems - Regenerate composer.lock using PHP 8.2 to ensure dependencies are compatible with minimum PHP version --- composer.lock | 16 ++++++++-------- src/Console.php | 7 ++++--- 2 files changed, 12 insertions(+), 11 deletions(-) diff --git a/composer.lock b/composer.lock index ba84d25..ac6a945 100644 --- a/composer.lock +++ b/composer.lock @@ -111,20 +111,20 @@ "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": "c795a176780bcada3d008d07eef0c2b1b298665c" + "reference": "7be2ebd072deac210cb57eb2776aac19f4d5d1ad" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/doctrine/instantiator/zipball/c795a176780bcada3d008d07eef0c2b1b298665c", - "reference": "c795a176780bcada3d008d07eef0c2b1b298665c", + "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", @@ -138,7 +138,7 @@ "type": "library", "autoload": { "psr-4": { - "Doctrine\\Instantiator\\": "src/" + "Doctrine\\Instantiator\\": "src/Doctrine/Instantiator/" } }, "notification-url": "https://packagist.org/downloads/", @@ -160,7 +160,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": [ { @@ -176,7 +176,7 @@ "type": "tidelift" } ], - "time": "2026-03-20T19:06:08+00:00" + "time": "2026-01-04T22:42:35+00:00" }, { "name": "laravel/pint", diff --git a/src/Console.php b/src/Console.php index 1800a9e..3762b81 100644 --- a/src/Console.php +++ b/src/Console.php @@ -186,14 +186,15 @@ public static function execute(Command|array|string $cmd, string $stdin, string return 1; } - if (! \proc_get_status($process)['running']) { + $procStatus = \proc_get_status($process); + if (! $procStatus['running']) { \fclose($pipes[1]); \fclose($pipes[2]); - $procCloseCode = \proc_close($process); + \proc_close($process); $exitCode = ($status !== '') ? (int) str_replace("\n", '', $status) - : $procCloseCode; + : $procStatus['exitcode']; return $exitCode; } From 89a4b05ab74fefc46fa0e545ff1defde3393a5d5 Mon Sep 17 00:00:00 2001 From: Torsten Dittmann Date: Fri, 17 Apr 2026 16:45:19 +0400 Subject: [PATCH 5/5] Refine command composition API and redirects --- .github/workflows/test.yml | 2 +- README.md | 56 ++++- composer.json | 4 +- composer.lock | 94 ++------ src/Command.php | 174 +++++++++++++- src/Console.php | 4 +- tests/Console/ConsoleTest.php | 414 ++++++++++++++++++++++++++++++---- 7 files changed, 611 insertions(+), 137 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 4294c9e..5babce5 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -7,7 +7,7 @@ jobs: runs-on: ubuntu-latest strategy: matrix: - php-versions: ['8.2', '8.3', 'nightly'] + php-versions: ['8.1', '8.2', '8.3', 'nightly'] steps: - name: Checkout repository diff --git a/README.md b/README.md index 1b7626c..36e7083 100644 --- a/README.md +++ b/README.md @@ -30,9 +30,8 @@ if ($answer !== 'y') { $output = ''; $stderr = ''; -$command = (new Command(PHP_BINARY)) - ->add('-r') - ->add('echo "Hello";'); +$command = new Command(PHP_BINARY) + ->option('-r', 'echo "Hello";'); $exitCode = Console::execute($command, '', $output, $stderr, 3); @@ -54,9 +53,8 @@ Console::error('Red log'); // stderr `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)) - ->add('-r') - ->add('fwrite(STDOUT, "success\\n");'); +$command = new Command(PHP_BINARY) + ->option('-r', 'fwrite(STDOUT, "success\\n");'); $output = ''; $input = ''; @@ -67,6 +65,50 @@ 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. @@ -83,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 2d3feeb..b98e5b3 100755 --- a/composer.json +++ b/composer.json @@ -14,8 +14,8 @@ "psr-4": {"Utopia\\": "src/"} }, "require": { - "php": ">=8.2", - "utopia-php/servers": "^0.1.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 ac6a945..46b0723 100644 --- a/composer.lock +++ b/composer.lock @@ -4,108 +4,52 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "10bbf2c62c75447480388798150edacf", + "content-hash": "8d8396fe41bfaaf6debe5e1470646740", "packages": [ { - "name": "utopia-php/di", + "name": "utopia-php/validators", "version": "0.1.0", "source": { "type": "git", - "url": "https://github.com/utopia-php/di.git", - "reference": "22490c95f7ac3898ed1c33f1b1b5dd577305ee31" + "url": "https://github.com/utopia-php/validators.git", + "reference": "5c57d5b6cf964f8981807c1d3ea8df620c869080" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/utopia-php/di/zipball/22490c95f7ac3898ed1c33f1b1b5dd577305ee31", - "reference": "22490c95f7ac3898ed1c33f1b1b5dd577305ee31", + "url": "https://api.github.com/repos/utopia-php/validators/zipball/5c57d5b6cf964f8981807c1d3ea8df620c869080", + "reference": "5c57d5b6cf964f8981807c1d3ea8df620c869080", "shasum": "" }, "require": { - "php": ">=8.2" + "php": ">=8.0" }, "require-dev": { - "laravel/pint": "^1.2", - "phpbench/phpbench": "^1.2", - "phpstan/phpstan": "^1.10", - "phpunit/phpunit": "^9.5.25", - "swoole/ide-helper": "4.8.3" - }, - "type": "library", - "autoload": { - "psr-4": { - "Utopia\\": "src/", - "Tests\\E2E\\": "tests/e2e" - } - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "description": "A simple and lite library for managing dependency injections", - "keywords": [ - "framework", - "http", - "php", - "upf" - ], - "support": { - "issues": "https://github.com/utopia-php/di/issues", - "source": "https://github.com/utopia-php/di/tree/0.1.0" - }, - "time": "2024-08-08T14:35:19+00:00" - }, - { - "name": "utopia-php/servers", - "version": "0.1.1", - "source": { - "type": "git", - "url": "https://github.com/utopia-php/servers.git", - "reference": "fd5c8d32778f265256c1936372a071b944f5ba8a" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/utopia-php/servers/zipball/fd5c8d32778f265256c1936372a071b944f5ba8a", - "reference": "fd5c8d32778f265256c1936372a071b944f5ba8a", - "shasum": "" - }, - "require": { - "php": ">=8.0", - "utopia-php/di": "0.1.*" - }, - "require-dev": { - "laravel/pint": "^0.2.3", - "phpstan/phpstan": "^1.8", - "phpunit/phpunit": "^9.5.5" + "laravel/pint": "1.*", + "phpstan/phpstan": "1.*", + "phpunit/phpunit": "11.*" }, "type": "library", "autoload": { "psr-4": { - "Utopia\\Servers\\": "src/Servers" + "Utopia\\": "src/" } }, "notification-url": "https://packagist.org/downloads/", "license": [ "MIT" ], - "authors": [ - { - "name": "Team Appwrite", - "email": "team@appwrite.io" - } - ], - "description": "A base library for building Utopia style servers.", + "description": "A lightweight collection of reusable validators for Utopia projects", "keywords": [ - "framework", "php", - "servers", - "upf", - "utopia" + "utopia", + "validation", + "validator" ], "support": { - "issues": "https://github.com/utopia-php/servers/issues", - "source": "https://github.com/utopia-php/servers/tree/0.1.1" + "issues": "https://github.com/utopia-php/validators/issues", + "source": "https://github.com/utopia-php/validators/tree/0.1.0" }, - "time": "2024-09-06T02:25:56+00:00" + "time": "2025-11-18T11:05:46+00:00" } ], "packages-dev": [ @@ -2140,7 +2084,7 @@ "prefer-stable": false, "prefer-lowest": false, "platform": { - "php": ">=8.2" + "php": ">=8.0" }, "platform-dev": {}, "plugin-api-version": "2.9.0" diff --git a/src/Command.php b/src/Command.php index fbcafb1..e6562f6 100644 --- a/src/Command.php +++ b/src/Command.php @@ -4,28 +4,132 @@ use InvalidArgumentException; use Stringable; -use Utopia\Servers\Validator; +use Utopia\Validator\Text; class Command implements Stringable { + private const TYPE_PLAIN = 'plain'; + + private const TYPE_COMPOSITE = 'composite'; + + private const TYPE_GROUP = 'group'; + + private const TYPE_REDIRECT = 'redirect'; + + private const OPERATOR_PIPE = '|'; + + private const OPERATOR_AND = '&&'; + + private const OPERATOR_OR = '||'; + + private const REDIRECT_STDOUT = '>'; + + 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 function add(string|int|float|Stringable $value, Validator|callable|null $validator = null): self + public static function pipe(Command ...$commands): self { - $argument = $this->normalize($value, 'Command argument'); + 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; + } - if ($validator !== null) { - $this->validate($argument, $validator); + 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; @@ -36,14 +140,22 @@ public function add(string|int|float|Stringable $value, Validator|callable|null */ 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 { - $escaped = array_map(static fn (string $argument): string => escapeshellarg($argument), $this->arguments); - - return implode(' ', $escaped); + 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 @@ -51,7 +163,49 @@ public function __toString(): string return $this->toString(); } - protected function normalize(string|int|float|Stringable $value, string $context): string + 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; @@ -65,7 +219,7 @@ protected function normalize(string|int|float|Stringable $value, string $context /** * @param Validator|callable $validator */ - protected function validate(string $argument, Validator|callable $validator): void + private function validate(string $argument, Validator|callable $validator): void { if ($validator instanceof Validator) { if (! $validator->isValid($argument)) { diff --git a/src/Console.php b/src/Console.php index 3762b81..4801bc5 100644 --- a/src/Console.php +++ b/src/Console.php @@ -135,7 +135,9 @@ public static function exit(int $status = 0): void 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->toArray(); + $cmd = $cmd->isPlain() + ? $cmd->toArray() + : $cmd->toString(); } // If the $cmd is passed as string, it will be wrapped into a subshell by \proc_open diff --git a/tests/Console/ConsoleTest.php b/tests/Console/ConsoleTest.php index 2fffb81..cf0fb59 100644 --- a/tests/Console/ConsoleTest.php +++ b/tests/Console/ConsoleTest.php @@ -21,12 +21,12 @@ public function testLogs(): void public function testExecuteBasic(): void { + $command = (new Command(PHP_BINARY)) + ->option('-r', "echo 'hello world';"); $output = ''; $stderr = ''; $input = ''; - $code = Console::execute((new Command(PHP_BINARY)) - ->add('-r') - ->add("echo 'hello world';"), $input, $output, $stderr, 10); + $code = Console::execute($command, $input, $output, $stderr, 10); $this->assertEquals('hello world', $output); $this->assertEquals(0, $code); @@ -34,29 +34,38 @@ public function testExecuteBasic(): void public function testCommandToArray(): void { - $command = (new Command('php')) - ->add('-r') - ->add("echo 'hello world';"); + $command = (new Command('tar')) + ->flag('-cz') + ->option('-f', 'archive.tar.gz') + ->option('-C', '/tmp/project') + ->argument('.'); - $this->assertSame(['php', '-r', "echo 'hello world';"], $command->toArray()); + $this->assertSame(['tar', '-cz', '-f', 'archive.tar.gz', '-C', '/tmp/project', '.'], $command->toArray()); } public function testCommandToStringEscapesArguments(): void { $command = (new Command('php')) - ->add('-r') - ->add("echo 'hello'; rm -rf /"); + ->option('-r', "echo 'hello'; rm -rf /"); $this->assertSame("'php' '-r' 'echo '\''hello'\''; rm -rf /'", $command->toString()); } - public function testCommandValidatorCallable(): void + 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')) - ->add('checkout') - ->add('feature/test-1', fn (string $value): bool => preg_match('/^[A-Za-z0-9._\/-]+$/', $value) === 1); + ->argument('checkout') + ->argument('develop', fn (string $value): bool => in_array($value, ['main', 'develop', 'staging'], true)); - $this->assertSame(['git', 'checkout', 'feature/test-1'], $command->toArray()); + $this->assertSame(['git', 'checkout', 'develop'], $command->toArray()); } public function testCommandValidatorFailure(): void @@ -65,8 +74,32 @@ public function testCommandValidatorFailure(): void $this->expectExceptionMessage('Invalid command argument: feature/test; rm -rf /'); (new Command('git')) - ->add('checkout') - ->add('feature/test; rm -rf /', fn (string $value): bool => preg_match('/^[A-Za-z0-9._\/-]+$/', $value) === 1); + ->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 @@ -111,16 +144,15 @@ 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((new Command(PHP_BINARY)) - ->add('-r') - ->add('for ($i = 1; $i <= 5; $i++) { echo $i; usleep(1000000); }'), $input, $output, $stderr, 10, function ($output) use (&$outputStream) { - $outputStream .= $output; - }); + $code = Console::execute($command, $input, $output, $stderr, 10, function ($output) use (&$outputStream) { + $outputStream .= $output; + }); $this->assertEquals('12345', $output); $this->assertEquals('12345', $outputStream); @@ -129,12 +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((new Command(PHP_BINARY)) - ->add('-r') - ->add('fwrite(STDOUT, "success\n");'), $input, $output, $stderr, 3); + $code = Console::execute($command, $input, $output, $stderr, 3); $this->assertEquals("success\n", $output); $this->assertEquals('', $stderr); @@ -143,12 +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((new Command(PHP_BINARY)) - ->add('-r') - ->add('fwrite(STDERR, "error\n");'), $input, $output, $stderr, 3); + $code = Console::execute($command, $input, $output, $stderr, 3); $this->assertEquals('', $output); $this->assertEquals("error\n", $stderr); @@ -157,22 +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((new Command(PHP_BINARY)) - ->add('-r') - ->add("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((new Command(PHP_BINARY)) - ->add('-r') - ->add("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); @@ -180,22 +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((new Command(PHP_BINARY)) - ->add('-r') - ->add("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((new Command(PHP_BINARY)) - ->add('-r') - ->add("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); @@ -204,11 +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((new Command(PHP_BINARY)) - ->add($file), $input, $output, $stderr, 30); + $code = Console::execute($command, $input, $output, $stderr, 30); $lines = explode("\n", $output); @@ -217,6 +250,305 @@ public function testLoop(): void $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 = '';