439 lines
13 KiB
PHP
439 lines
13 KiB
PHP
<?php
|
||
|
||
declare(strict_types=1);
|
||
|
||
namespace Nih\CommandBuilder;
|
||
|
||
use Stringable;
|
||
use ValueError;
|
||
|
||
/**
|
||
* A process builder, providing fine-grained control over how a new process
|
||
* should be spawned.
|
||
*/
|
||
final class Command implements Stringable
|
||
{
|
||
public readonly string $program;
|
||
|
||
private array $args = [];
|
||
private ?array $environment = null;
|
||
private bool $environmentInherit = true;
|
||
private ?string $cwd = null;
|
||
|
||
private ?Stdio $stdin = null;
|
||
private ?Stdio $stdout = null;
|
||
private ?Stdio $stderr = null;
|
||
|
||
/**
|
||
* Constructs a new Command for launching the program at path program, with
|
||
* the following default configuration:
|
||
*
|
||
* - No arguments to the program
|
||
* - Inherit the current process's environment
|
||
* - Inherit the current process's working directory
|
||
* - Inherit stdin/stdout/stderr for spawn or status, but create pipes for
|
||
* output
|
||
*
|
||
* Builder methods are provided to change these defaults and otherwise
|
||
* configure the process.
|
||
*
|
||
* If program is not an absolute path, the `PATH` will be searched.
|
||
*/
|
||
public function __construct(string $program)
|
||
{
|
||
if (strlen($program) === 0) {
|
||
throw new ValueError('Empty program name');
|
||
}
|
||
|
||
$this->program = $program;
|
||
}
|
||
|
||
/**
|
||
* Adds an argument to pass to the program.
|
||
*
|
||
* Only one argument can be passed per use. So instead of:
|
||
*
|
||
* ```
|
||
* $command->arg('-C /path/to/repo');
|
||
* ```
|
||
*
|
||
* usage would be:
|
||
*
|
||
* ```
|
||
* $command
|
||
* ->arg('-C')
|
||
* ->arg('/path/to/repo');
|
||
* ```
|
||
*
|
||
* To pass multiple arguments see {@see Command::args()}.
|
||
*
|
||
* Note that the arguments are not passed through a shell, but given
|
||
* literally to the program. This means that shell syntax like quotes,
|
||
* escaped characters, word splitting, glob patterns, variable substitution,
|
||
* etc. have no effect.
|
||
*/
|
||
public function arg(string|Stringable $arg): static
|
||
{
|
||
$this->args[] = (string) $arg;
|
||
return $this;
|
||
}
|
||
|
||
/**
|
||
* Adds multiple arguments to pass to the program.
|
||
*
|
||
* To pass a single argument see {@see Command::arg()}.
|
||
*
|
||
* Note that the arguments are not passed through a shell, but given
|
||
* literally to the program. This means that shell syntax like quotes,
|
||
* escaped characters, word splitting, glob patterns, variable substitution,
|
||
* etc. have no effect.
|
||
*
|
||
* @param iterable<string|Stringable> $args
|
||
*/
|
||
public function args(iterable $args): static
|
||
{
|
||
foreach ($args as $arg) {
|
||
$this->args[] = (string) $arg;
|
||
}
|
||
|
||
return $this;
|
||
}
|
||
|
||
/**
|
||
* Inserts or updates an explicit environment variable mapping.
|
||
*
|
||
* This method allows you to add an environment variable mapping to the
|
||
* spawned process or overwrite a previously set value. You can use
|
||
* {@see Command::envs()} to set multiple environment variables
|
||
* simultaneously.
|
||
*
|
||
* Child processes will inherit environment variables from their parent
|
||
* process by default. Environment variables explicitly set using
|
||
* {@see Command::env()} take precedence over inherited variables. You can
|
||
* disable environment variable inheritance entirely using
|
||
* {@see Command::envClear()} or for a single key using
|
||
* {@see Command::envRemove()}.
|
||
*/
|
||
public function env(string $key, string|Stringable $val): static
|
||
{
|
||
$this->environment[$key] = $val;
|
||
return $this;
|
||
}
|
||
|
||
/**
|
||
* Inserts or updates multiple environment variable mappings.
|
||
*
|
||
* This method allows you to add multiple environment variable mappings to
|
||
* the spawned process or overwrite previously set values. You can use
|
||
* {@see Command::env()} to set a single environment variable.
|
||
*
|
||
* Child processes will inherit environment variables from their parent
|
||
* process by default. Environment variables explicitly set using
|
||
* {@see Command::envs()} take precedence over inherited variables. You can
|
||
* disable environment variable inheritance entirely using
|
||
* {@see Command::envClear()} or for a single key using
|
||
* {@see Command::envRemove()}.
|
||
*
|
||
* @param iterable<string, string|Stringable> $vars
|
||
*/
|
||
public function envs(iterable $vars): static
|
||
{
|
||
foreach ($vars as $key => $val) {
|
||
$this->environment[$key] = (string) $val;
|
||
}
|
||
|
||
return $this;
|
||
}
|
||
|
||
/**
|
||
* Removes an explicitly set environment variable and prevents inheriting it
|
||
* from a parent process.
|
||
*
|
||
* This method will remove the explicit value of an environment variable set
|
||
* via {@see Command::env()} or {@see Command::envs()}. In addition, it will
|
||
* prevent the spawned child process from inheriting that environment
|
||
* variable from its parent process.
|
||
*
|
||
* After calling {@see Command::envRemove()}, the value associated with its
|
||
* key from {@see Command::getEnvs()} will be `NULL`.
|
||
*
|
||
* To clear all explicitly set environment variables and disable all
|
||
* environment variable inheritance, you can use {@see Command::envClear()}.
|
||
*/
|
||
public function envRemove(string $key): static
|
||
{
|
||
$this->environment[$key] = null;
|
||
return $this;
|
||
}
|
||
|
||
/**
|
||
* Clears all explicitly set environment variables and prevents inheriting
|
||
* any parent process environment variables.
|
||
*
|
||
* This method will remove all explicitly added environment variables set
|
||
* via {@see Command::env()} or {@see Command::envs()}. In addition, it will
|
||
* prevent the spawned child process from inheriting any environment
|
||
* variable from its parent process.
|
||
*
|
||
* After calling {@see Command::envClear()}, the array from
|
||
* {@see Command::getEnvs()} will be empty.
|
||
*
|
||
* You can use {@see Command::envRemove()} to clear a single mapping.
|
||
*/
|
||
public function envClear(): static
|
||
{
|
||
$this->environmentInherit = false;
|
||
$this->environment = [];
|
||
return $this;
|
||
}
|
||
|
||
/**
|
||
* Sets the working directory for the child process.
|
||
*/
|
||
public function currentDir(string|Stringable $dir): static
|
||
{
|
||
$this->cwd = (string) $dir;
|
||
return $this;
|
||
}
|
||
|
||
/**
|
||
* Configuration for the child process’s standard input (stdin) handle.
|
||
*
|
||
* Defaults to inherit when used with spawn or status, and defaults to piped
|
||
* when used with output.
|
||
*/
|
||
public function stdin(Stdio $stdin): static
|
||
{
|
||
$stdin = match ($stdin->type) {
|
||
Stdio::INHERIT => Stdio::stream(STDIN),
|
||
Stdio::PIPE => new Stdio(Stdio::PIPE, ['pipe', 'r']),
|
||
default => $stdin,
|
||
};
|
||
|
||
$this->stdin = $stdin;
|
||
return $this;
|
||
}
|
||
|
||
/**
|
||
* Configuration for the child process’s standard output (stdout) handle.
|
||
*
|
||
* Defaults to inherit when used with spawn or status, and defaults to piped
|
||
* when used with output.
|
||
*/
|
||
public function stdout(Stdio $stdout): static
|
||
{
|
||
$stdout = match ($stdout->type) {
|
||
Stdio::INHERIT => Stdio::stream(STDOUT),
|
||
Stdio::PIPE => new Stdio(Stdio::PIPE, ['pipe', 'w']),
|
||
default => $stdout,
|
||
};
|
||
|
||
$this->stdout = $stdout;
|
||
return $this;
|
||
}
|
||
|
||
/**
|
||
* Configuration for the child process’s standard error (stderr) handle.
|
||
*
|
||
* Defaults to inherit when used with spawn or status, and defaults to piped
|
||
* when used with output.
|
||
*/
|
||
public function stderr(Stdio $stderr): static
|
||
{
|
||
$stderr = match ($stderr->type) {
|
||
Stdio::INHERIT => Stdio::stream(STDERR),
|
||
Stdio::PIPE => new Stdio(Stdio::PIPE, ['pipe', 'w']),
|
||
default => $stderr,
|
||
};
|
||
|
||
$this->stderr = $stderr;
|
||
return $this;
|
||
}
|
||
|
||
// TODO: Allow capturing arbitrary descriptors (proc_open supports this)?
|
||
// public function descriptor(int $fd, Stdio $stdio): static
|
||
// {
|
||
// match ($fd) {
|
||
// 0 => $this->stdin($stdio),
|
||
// 1 => $this->stdout($stdio),
|
||
// 2 => $this->stderr($stdio),
|
||
// default => $this->fd[$fd] = $stdio,
|
||
// };
|
||
//
|
||
// return $this;
|
||
// }
|
||
|
||
/**
|
||
* Executes the command as a child process, returning a handle to it.
|
||
*
|
||
* By default, stdin, stdout and stderr are inherited from the parent.
|
||
*
|
||
* @param bool $shell Run the command with or without a shell
|
||
*/
|
||
public function spawn(): Child
|
||
{
|
||
return $this->spawnWithDescriptorSpec([
|
||
$this->stdin instanceof Stdio
|
||
? $this->stdin->descriptorSpec
|
||
: STDIN,
|
||
$this->stdout instanceof Stdio
|
||
? $this->stdout->descriptorSpec
|
||
: STDOUT,
|
||
$this->stderr instanceof Stdio
|
||
? $this->stderr->descriptorSpec
|
||
: STDERR,
|
||
]);
|
||
}
|
||
|
||
/**
|
||
* Executes a command as a child process, waiting for it to finish and
|
||
* collecting its status.
|
||
*
|
||
* By default, stdin, stdout and stderr are inherited from the parent.
|
||
*
|
||
* @param bool $shell Run the command with or without a shell
|
||
*/
|
||
public function status(): ExitStatus
|
||
{
|
||
return $this->spawn()->wait();
|
||
}
|
||
|
||
/**
|
||
* Executes the command as a child process, waiting for it to finish and
|
||
* collecting all of its output.
|
||
*
|
||
* By default, stdout and stderr are captured (and used to provide the
|
||
* resulting output). Stdin is not inherited from the parent.
|
||
*
|
||
* @param bool $shell Run the command with or without a shell
|
||
*/
|
||
public function output(): Output
|
||
{
|
||
return $this->spawnWithDescriptorSpec([
|
||
$this->stdin instanceof Stdio
|
||
? $this->stdin->descriptorSpec
|
||
: ['pipe', 'r'],
|
||
$this->stdout instanceof Stdio
|
||
? $this->stdout->descriptorSpec
|
||
: ['pipe', 'w'],
|
||
$this->stderr instanceof Stdio
|
||
? $this->stderr->descriptorSpec
|
||
: ['pipe', 'w'],
|
||
])->waitWithOutput();
|
||
}
|
||
|
||
/**
|
||
* Returns the path to the program that was given to the constructor.
|
||
*/
|
||
public function getProgram(): string
|
||
{
|
||
return $this->program;
|
||
}
|
||
|
||
/**
|
||
* Returns the arguments that will be passed to the program.
|
||
*
|
||
* This does not include the path to the program as the first argument.
|
||
*/
|
||
public function getArgs(): array
|
||
{
|
||
return $this->args;
|
||
}
|
||
|
||
/**
|
||
* Returns the environment variables set for the child process.
|
||
*
|
||
* Environment variables explicitly set using {@see Command::env(),
|
||
* {@see Command::envs()}, and {@see Command::envRemove} can be retrieved
|
||
* with this method.
|
||
*
|
||
* Note that this output does not include environment variables inherited
|
||
* from the parent process.
|
||
*/
|
||
public function getEnvs(): array
|
||
{
|
||
return $this->environment ?? [];
|
||
}
|
||
|
||
/**
|
||
* Returns the working directory for the child process.
|
||
*
|
||
* This returns `NULL` if the working directory will not be changed.
|
||
*/
|
||
public function getCurrentDir(): ?string
|
||
{
|
||
return $this->cwd;
|
||
}
|
||
|
||
public function __toString(): string
|
||
{
|
||
return implode(' ', [
|
||
escapeshellarg($this->program),
|
||
...array_map(escapeshellarg(...), $this->args)
|
||
]);
|
||
}
|
||
|
||
private function spawnWithDescriptorSpec(array $descriptorSpec): Child
|
||
{
|
||
// Validate stream resources in descriptor spec.
|
||
foreach ($descriptorSpec as $descriptor => $spec) {
|
||
if (!is_array($spec) && !is_resource($spec)) {
|
||
throw new CommandException(sprintf(
|
||
'Descriptor %d is not a valid stream resource: %s',
|
||
$descriptor,
|
||
get_debug_type($spec),
|
||
));
|
||
}
|
||
}
|
||
|
||
// Find executable if path is not absolute.
|
||
$program = $this->program;
|
||
if ($program[0] !== DIRECTORY_SEPARATOR) {
|
||
$path = getenv('PATH');
|
||
if (is_string($path)) {
|
||
foreach (explode(':', $path) as $path) {
|
||
$path = $path . '/' . $program;
|
||
if (is_executable($path)) {
|
||
$program = $path;
|
||
break;
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
if (!is_executable($program)) {
|
||
throw new CommandException(sprintf(
|
||
'Program "%s" is not executable',
|
||
$program,
|
||
));
|
||
}
|
||
|
||
// Handle environment inheritance.
|
||
$environment = $this->environment;
|
||
if (is_array($environment) && $this->environmentInherit) {
|
||
foreach (getenv() as $key => $val) {
|
||
if (!array_key_exists($key, $environment)) {
|
||
$environment[$key] = $val;
|
||
}
|
||
}
|
||
}
|
||
|
||
$proc = proc_open(
|
||
[$program, ...$this->args],
|
||
$descriptorSpec,
|
||
$pipes,
|
||
$this->cwd,
|
||
$environment,
|
||
);
|
||
|
||
if ($proc === false) {
|
||
throw new CommandException(sprintf(
|
||
'Program "%s" failed to start',
|
||
$this->program,
|
||
));
|
||
}
|
||
|
||
return new Child($proc, $pipes);
|
||
}
|
||
}
|