From 209c910f33e62f5c4f10a83d8597ea257825b036 Mon Sep 17 00:00:00 2001 From: Jonas Kattendick Date: Sat, 16 Aug 2025 21:48:01 +0200 Subject: [PATCH] feat: first impl --- composer.lock | 18 +++++ example.php | 26 +++++++ src/Child.php | 49 ++++++++++++ src/ChildStderr.php | 10 +++ src/ChildStdin.php | 10 +++ src/ChildStdout.php | 10 +++ src/Command.php | 164 +++++++++++++++++++++++++++++++++++++++ src/CommandException.php | 10 +++ src/Output.php | 15 ++++ src/Stdio.php | 32 ++++++++ src/StdioFile.php | 25 ++++++ src/StdioPiped.php | 16 ++++ src/StreamReadable.php | 28 +++++++ src/StreamWritable.php | 31 ++++++++ 14 files changed, 444 insertions(+) create mode 100644 composer.lock create mode 100644 example.php create mode 100644 src/Child.php create mode 100644 src/ChildStderr.php create mode 100644 src/ChildStdin.php create mode 100644 src/ChildStdout.php create mode 100644 src/Command.php create mode 100644 src/CommandException.php create mode 100644 src/Output.php create mode 100644 src/Stdio.php create mode 100644 src/StdioFile.php create mode 100644 src/StdioPiped.php create mode 100644 src/StreamReadable.php create mode 100644 src/StreamWritable.php diff --git a/composer.lock b/composer.lock new file mode 100644 index 0000000..23909e6 --- /dev/null +++ b/composer.lock @@ -0,0 +1,18 @@ +{ + "_readme": [ + "This file locks the dependencies of your project to a known state", + "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", + "This file is @generated automatically" + ], + "content-hash": "e6871b52f4fe4ae525e588a0924fdf18", + "packages": [], + "packages-dev": [], + "aliases": [], + "minimum-stability": "stable", + "stability-flags": {}, + "prefer-stable": false, + "prefer-lowest": false, + "platform": {}, + "platform-dev": {}, + "plugin-api-version": "2.6.0" +} diff --git a/example.php b/example.php new file mode 100644 index 0000000..1933a4c --- /dev/null +++ b/example.php @@ -0,0 +1,26 @@ +arg('-n') + ->arg('-') + ->arg("./ \$wow '''/stdout.txt") + ->stderr(Stdio::null()) + ->output(); + +var_dump($output); + +$child = (new Command('/usr/bin/cat')) + ->stdin(Stdio::piped('r')) + ->spawn(shell: true); + +$child->stdin?->write('Hello, this is pretty cool.'); +$child->stdin?->close(); + +$output = $child->output(); + +var_dump($output); diff --git a/src/Child.php b/src/Child.php new file mode 100644 index 0000000..cd0eb4c --- /dev/null +++ b/src/Child.php @@ -0,0 +1,49 @@ +proc); + return $status['pid']; + } + + public function output(): Output + { + $stdout = $this->stdout?->getContents(); + $stderr = $this->stderr?->getContents(); + $code = proc_close($this->proc); + + return new Output($stdout, $stderr, $code); + } + + public function status(): int + { + return proc_close($this->proc); + } + + public function wait(): void + { + proc_close($this->proc); + } + + public function kill(): bool + { + return proc_terminate($this->proc, 10); + } +} diff --git a/src/ChildStderr.php b/src/ChildStderr.php new file mode 100644 index 0000000..88e3937 --- /dev/null +++ b/src/ChildStderr.php @@ -0,0 +1,10 @@ +command = $command; + $this->args[] = $this->command; + } + + public function arg(string|Stringable $arg): static + { + $this->args[] = (string) $arg; + return $this; + } + + /** + * @param array $args + */ + public function args(array $args): static + { + foreach ($args as $arg) { + $this->args[] = (string) $arg; + } + + return $this; + } + + public function cwd(string|Stringable $cwd): static + { + $this->cwd = (string) $cwd; + return $this; + } + + public function env(string $var, string|Stringable $value): static + { + $this->envs[$var] = escapeshellarg((string) $value); + return $this; + } + + /** + * @param array $envs + */ + public function envs(array $envs): static + { + foreach ($envs as $var => $value) { + $this->envs[$var] = escapeshellarg((string) $value); + } + + return $this; + } + + public function stdin(Stdio $in): static + { + $this->stdin = $in; + return $this; + } + + public function stdout(Stdio $out): static + { + $this->stdout = $out; + return $this; + } + + public function stderr(Stdio $err): static + { + $this->stderr = $err; + return $this; + } + + /** + * @param bool $shell Run the command with or without a shell + */ + public function spawn(bool $shell = true): Child + { + $fd[0] = $this->stdin instanceof Stdio + ? $this->stdin->getDescriptorSpec() + : ['pipe', 'r']; + + $fd[1] = $this->stdout instanceof Stdio + ? $this->stdout->getDescriptorSpec() + : ['pipe', 'w']; + + $fd[2] = $this->stderr instanceof Stdio + ? $this->stderr->getDescriptorSpec() + : ['pipe', 'w']; + + if ($shell) { + $command = implode(' ', array_map(escapeshellarg(...), $this->args)); + } else if (is_executable($this->command)) { + $command = $this->args; + } else { + throw new CommandException(sprintf( + 'Command "%s" is not executable', + $this->command, + )); + } + + $proc = proc_open($command, $fd, $pipes, $this->cwd, $this->envs); + if ($proc === false) { + throw new RuntimeException(); + } + + $stdin = array_key_exists(0, $pipes) + ? new ChildStdin($pipes[0]) + : null; + + $stdout = array_key_exists(1, $pipes) + ? new ChildStdout($pipes[1]) + : null; + + $stderr = array_key_exists(2, $pipes) + ? new ChildStderr($pipes[2]) + : null; + + return new Child($stdin, $stdout, $stderr, $proc); + } + + /** + * @param bool $shell Run the command with or without a shell + */ + public function output(bool $shell = true): Output + { + return $this->spawn($shell)->output(); + } + + /** + * @param bool $shell Run the command with or without a shell + */ + public function status(bool $shell = true): int + { + return $this->spawn($shell)->status(); + } + + /** + * @param bool $shell Run the command with or without a shell + */ + public function wait(bool $shell = true): void + { + $this->spawn($shell)->wait(); + } +} diff --git a/src/CommandException.php b/src/CommandException.php new file mode 100644 index 0000000..0faec8c --- /dev/null +++ b/src/CommandException.php @@ -0,0 +1,10 @@ + null, + default => throw new ValueError('Invalid mode: ' . $mode), + }; + } + + public function getDescriptorSpec(): array + { + return ['file', $this->file, $this->mode]; + } +} diff --git a/src/StdioPiped.php b/src/StdioPiped.php new file mode 100644 index 0000000..16fde2f --- /dev/null +++ b/src/StdioPiped.php @@ -0,0 +1,16 @@ +mode]; + } +} diff --git a/src/StreamReadable.php b/src/StreamReadable.php new file mode 100644 index 0000000..a63ec23 --- /dev/null +++ b/src/StreamReadable.php @@ -0,0 +1,28 @@ +stream, $length) ?: null; + } + + public function getContents(?int $length = null, int $offset = -1): ?string + { + $contents = stream_get_contents($this->stream, $length, $offset); + return $contents === false + ? null + : $contents; + } +} diff --git a/src/StreamWritable.php b/src/StreamWritable.php new file mode 100644 index 0000000..3189b28 --- /dev/null +++ b/src/StreamWritable.php @@ -0,0 +1,31 @@ +stream, $data, $length) ?: null; + return $bytes === false ? null : $bytes; + } + + public function flush(): bool + { + return fflush($this->stream); + } + + public function close(): bool + { + return fclose($this->stream); + } +}