feat: first impl

This commit is contained in:
2025-08-16 21:48:01 +02:00
parent e60ab9d7bc
commit 209c910f33
14 changed files with 444 additions and 0 deletions

18
composer.lock generated Normal file
View File

@@ -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"
}

26
example.php Normal file
View File

@@ -0,0 +1,26 @@
<?php
use Nih\CommandBuilder\Command;
use Nih\CommandBuilder\Stdio;
require_once __DIR__ . '/vendor/autoload.php';
$output = (new Command('echo'))
->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);

49
src/Child.php Normal file
View File

@@ -0,0 +1,49 @@
<?php
declare(strict_types=1);
namespace Nih\CommandBuilder;
final class Child
{
/**
* @param resource $proc The process handle.
*/
public function __construct(
public readonly ?ChildStdin $stdin,
public readonly ?ChildStdout $stdout,
public readonly ?ChildStderr $stderr,
public $proc,
) {
}
public function id(): int
{
$status = proc_get_status($this->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);
}
}

10
src/ChildStderr.php Normal file
View File

@@ -0,0 +1,10 @@
<?php
declare(strict_types=1);
namespace Nih\CommandBuilder;
final class ChildStderr
{
use StreamReadable;
}

10
src/ChildStdin.php Normal file
View File

@@ -0,0 +1,10 @@
<?php
declare(strict_types=1);
namespace Nih\CommandBuilder;
final class ChildStdin
{
use StreamWritable;
}

10
src/ChildStdout.php Normal file
View File

@@ -0,0 +1,10 @@
<?php
declare(strict_types=1);
namespace Nih\CommandBuilder;
final class ChildStdout
{
use StreamReadable;
}

164
src/Command.php Normal file
View File

@@ -0,0 +1,164 @@
<?php
declare(strict_types=1);
namespace Nih\CommandBuilder;
use RuntimeException;
use Stringable;
use ValueError;
final class Command
{
public readonly string $command;
private array $args = [];
private ?array $envs = null;
private ?string $cwd = null;
private ?Stdio $stdin = null;
private ?Stdio $stdout = null;
private ?Stdio $stderr = null;
public function __construct(string $command)
{
if (strlen($command) === 0) {
throw new ValueError('Empty command');
}
$this->command = $command;
$this->args[] = $this->command;
}
public function arg(string|Stringable $arg): static
{
$this->args[] = (string) $arg;
return $this;
}
/**
* @param array<string|Stringable> $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<string, string|Stringable> $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();
}
}

10
src/CommandException.php Normal file
View File

@@ -0,0 +1,10 @@
<?php
declare(strict_types=1);
namespace Nih\CommandBuilder;
use RuntimeException;
final class CommandException extends RuntimeException
{}

15
src/Output.php Normal file
View File

@@ -0,0 +1,15 @@
<?php
declare(strict_types=1);
namespace Nih\CommandBuilder;
final class Output
{
public function __construct(
public readonly ?string $stdout,
public readonly ?string $stderr,
public readonly int $code,
) {
}
}

32
src/Stdio.php Normal file
View File

@@ -0,0 +1,32 @@
<?php
declare(strict_types=1);
namespace Nih\CommandBuilder;
use Stringable;
abstract class Stdio
{
abstract public function getDescriptorSpec(): array;
public static function file(string|Stringable $file, string $mode): self
{
return new StdioFile((string) $file, $mode);
}
public static function piped(string $mode): self
{
return new StdioPiped($mode);
}
public static function inherit(): null
{
return null;
}
public static function null(): self
{
return new StdioFile('/dev/null', 'a+');
}
}

25
src/StdioFile.php Normal file
View File

@@ -0,0 +1,25 @@
<?php
declare(strict_types=1);
namespace Nih\CommandBuilder;
use ValueError;
final class StdioFile extends Stdio
{
public function __construct(
public readonly string $file,
public readonly string $mode,
) {
match ($mode) {
'r', 'w', 'a', 'x', 'r+', 'w+', 'a+', 'x+' => null,
default => throw new ValueError('Invalid mode: ' . $mode),
};
}
public function getDescriptorSpec(): array
{
return ['file', $this->file, $this->mode];
}
}

16
src/StdioPiped.php Normal file
View File

@@ -0,0 +1,16 @@
<?php
declare(strict_types=1);
namespace Nih\CommandBuilder;
final class StdioPiped extends Stdio
{
public function __construct(public readonly string $mode)
{}
public function getDescriptorSpec(): array
{
return ['pipe', $this->mode];
}
}

28
src/StreamReadable.php Normal file
View File

@@ -0,0 +1,28 @@
<?php
declare(strict_types=1);
namespace Nih\CommandBuilder;
trait StreamReadable
{
/**
* @param resource $stream
*/
public function __construct(private $stream)
{
}
public function read(int $length): ?string
{
return fread($this->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;
}
}

31
src/StreamWritable.php Normal file
View File

@@ -0,0 +1,31 @@
<?php
declare(strict_types=1);
namespace Nih\CommandBuilder;
trait StreamWritable
{
/**
* @param resource $stream
*/
public function __construct(private $stream)
{
}
public function write(string $data, ?int $length = null): ?int
{
$bytes = fwrite($this->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);
}
}