|null> */ private array $resolved = []; public function available(): bool { if (!function_exists('proc_open')) { return false; } $disabled = array_map('trim', explode(',', (string) ini_get('disable_functions'))); return !in_array('proc_open', $disabled, true); } /** * Was ein Kindprozess von der Umgebung sehen darf: Pfade, ein Zuhause fuer Composer und Git, * aber kein APP_ENV, das einen Konsolenbefehl gegen die falsche Umgebung laufen liesse. * * @return array */ private function environment(): array { $environment = ['PATH' => getenv('PATH') ?: '', 'SystemRoot' => getenv('SystemRoot') ?: '', 'TEMP' => sys_get_temp_dir(), 'TMP' => sys_get_temp_dir()]; // Composer verlangt HOME (Linux) oder APPDATA (Windows) - ohne sie bricht `composer // install` ab, waehrend `composer --version` noch laeuft. Git braucht HOME fuer .gitconfig. foreach (['HOME', 'USERPROFILE', 'APPDATA', 'LOCALAPPDATA', 'COMPOSER_HOME', 'PATHEXT', 'LANG', 'LC_ALL'] as $name) { $value = getenv($name); if (is_string($value) && '' !== $value) { $environment[$name] = $value; } } if (!isset($environment['HOME']) && !isset($environment['USERPROFILE'])) { // Unter php-fpm ist HOME oft leer; ein Ordner in var/ tut es fuer Composer und Git. $environment['HOME'] = sys_get_temp_dir(); } if (!isset($environment['COMPOSER_HOME'])) { $environment['COMPOSER_HOME'] = $environment['HOME'] ?? sys_get_temp_dir(); } return $environment; } /** * Die PHP-Kommandozeile, mit der Konsolenbefehle laufen. * * Aus einem Webprozess ist PHP_BINARY der Server selbst (php-fpm) oder leer (mod_php) - und * `php-fpm bin/console` startet keinen Befehl. Dann wird die Kommandozeile im Pfad gesucht. */ public function php(): string { if (isset($this->resolved['php'])) { return $this->resolved['php'][0]; } $candidates = []; if ('cli' === \PHP_SAPI && '' !== \PHP_BINARY) { $candidates[] = \PHP_BINARY; } $suffix = '\\' === \DIRECTORY_SEPARATOR ? '.exe' : ''; foreach ([\PHP_BINDIR.'/php'.$suffix, \PHP_BINDIR.'/php'.\PHP_MAJOR_VERSION.'.'.\PHP_MINOR_VERSION.$suffix, 'php'.$suffix, 'php'.\PHP_MAJOR_VERSION.'.'.\PHP_MINOR_VERSION.$suffix, \PHP_BINARY] as $candidate) { if ('' !== $candidate && !\in_array($candidate, $candidates, true)) { $candidates[] = $candidate; } } foreach ($candidates as $candidate) { if ($this->run([$candidate, '-r', 'echo PHP_VERSION;'], sys_get_temp_dir(), 30)['ok']) { $this->resolved['php'] = [$candidate]; return $candidate; } } return $candidates[0] ?? 'php'; } /** * @param list $command * * @return array{ok: bool, exit: int, output: string} */ public function run(array $command, string $cwd, int $timeout = 300): array { if (!$this->available()) { return ['ok' => false, 'exit' => -1, 'output' => 'proc_open ist auf diesem Server abgeschaltet.']; } $pipes = []; $process = @proc_open( $command, [1 => ['pipe', 'w'], 2 => ['pipe', 'w']], $pipes, $cwd, // A minimal, predictable environment: no inherited APP_ENV that would make a console // command run against the wrong environment. $this->environment(), ); if (!is_resource($process)) { return ['ok' => false, 'exit' => -1, 'output' => sprintf('%s konnte nicht gestartet werden.', $command[0])]; } foreach ($pipes as $pipe) { stream_set_blocking($pipe, false); } $output = ''; $deadline = microtime(true) + $timeout; while (true) { $status = proc_get_status($process); foreach ($pipes as $pipe) { $output .= stream_get_contents($pipe) ?: ''; } if (!$status['running']) { break; } if (microtime(true) > $deadline) { proc_terminate($process); foreach ($pipes as $pipe) { fclose($pipe); } proc_close($process); return ['ok' => false, 'exit' => -1, 'output' => trim($output)."\n".sprintf('Abgebrochen nach %d Sekunden.', $timeout)]; } usleep(50000); } foreach ($pipes as $pipe) { $output .= stream_get_contents($pipe) ?: ''; fclose($pipe); } proc_close($process); $exit = $status['exitcode']; return ['ok' => 0 === $exit, 'exit' => $exit, 'output' => trim($output)]; } /** * Whether an executable can be found at all, so a missing one is reported, not guessed at. * * On Windows several of these are batch files, and proc_open without a shell cannot start a * .bat - which reported "composer is not available" on a machine that has it. The variants are * tried in order, and the one that answers is remembered for the next call. */ public function has(string $executable, string $cwd): bool { return null !== $this->resolve($executable, $cwd); } /** The spelling of a command that actually starts here, or null. */ public function resolve(string $executable, string $cwd): ?array { if (array_key_exists($executable, $this->resolved)) { return $this->resolved[$executable]; } $candidates = [[$executable]]; if ('\\' === DIRECTORY_SEPARATOR) { // On Windows composer is a .bat, and CreateProcess cannot start a batch file on its // own - it needs the command interpreter. The argument list stays fixed, nothing from // a request ever reaches it. $candidates[] = [getenv('COMSPEC') ?: 'cmd.exe', '/c', $executable]; } // A phar in the project is the way on hosting where nothing is installed globally. if (is_file($cwd.'/'.$executable.'.phar')) { $candidates[] = [$this->php(), $cwd.'/'.$executable.'.phar']; } foreach ($candidates as $candidate) { if ($this->run([...$candidate, '--version'], $cwd, 30)['ok']) { return $this->resolved[$executable] = $candidate; } } return $this->resolved[$executable] = null; } }