diff --git a/README.md b/README.md index ac22dfc1..afdbe2bd 100644 --- a/README.md +++ b/README.md @@ -24,6 +24,8 @@ vendor/bin/tpc.php project.yml bin/tpc.php project.yml ``` +Linux 环境缺少 `libphp.so` 时,`tpc.php` 可以交互式下载 PHP 源码并自动构建,详见 [自动构建 libphp.so](docs/LIBPHP_INSTALLER.md)。 + ```shell # Ubuntu/Debian sudo apt install libgmp-dev libmpfr-dev libmpdec-dev diff --git a/composer.lock b/composer.lock index 4184972d..2ddd1d67 100644 --- a/composer.lock +++ b/composer.lock @@ -342,16 +342,16 @@ }, { "name": "swoole/phpx", - "version": "v2.3.9", + "version": "v2.3.10", "source": { "type": "git", "url": "https://github.com/swoole/phpx.git", - "reference": "98f96dead31b36510a4d3323a2d1a1bcfcd07a5e" + "reference": "e0f948db951b2d127f7bea644466f72ba2d58d6c" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/swoole/phpx/zipball/98f96dead31b36510a4d3323a2d1a1bcfcd07a5e", - "reference": "98f96dead31b36510a4d3323a2d1a1bcfcd07a5e", + "url": "https://api.github.com/repos/swoole/phpx/zipball/e0f948db951b2d127f7bea644466f72ba2d58d6c", + "reference": "e0f948db951b2d127f7bea644466f72ba2d58d6c", "shasum": "" }, "require": { @@ -381,9 +381,9 @@ ], "support": { "issues": "https://github.com/swoole/phpx/issues", - "source": "https://github.com/swoole/phpx/tree/v2.3.9" + "source": "https://github.com/swoole/phpx/tree/v2.3.10" }, - "time": "2026-06-23T05:59:31+00:00" + "time": "2026-06-26T11:19:07+00:00" }, { "name": "symfony/polyfill-ctype", diff --git a/docs/LIBPHP_INSTALLER.md b/docs/LIBPHP_INSTALLER.md new file mode 100644 index 00000000..6ae7d21a --- /dev/null +++ b/docs/LIBPHP_INSTALLER.md @@ -0,0 +1,51 @@ +# 自动构建 libphp.so + +TypePHP 的可执行文件和共享库模式需要 PHP Embed SAPI 提供的 `libphp.so`。许多 Linux 发行版的 PHP 包只包含 CLI 或 FPM,因此 `tpc.php` 在找不到 `libphp.so` 时会询问是否自动构建一份私有 PHP。 + +该功能只在 Linux 和交互式终端中启用。扩展模式(`-m ext`)不需要 `libphp.so`,不会触发安装器;CI 等非交互环境也不会自动下载、安装软件包或执行 `sudo`。 + +## 使用流程 + +正常执行编译命令即可: + +```bash +vendor/bin/tpc.php project.yml +``` + +缺少 `libphp.so` 时,安装器会依次询问: + +1. 是否自动构建 PHP Embed 库; +2. PHP 版本,默认从 PHP.net 获取最新稳定版 PHP 8.4,也可输入 PHP 8.4/8.5 的具体稳定版本; +3. 安装目录,默认为 `~/.typephp`; +4. 是否通过检测到的 `apt-get`、`dnf` 或 `yum` 安装缺失的开发包。 + +安装器读取当前 `php-config --configure-options`,保留当前 PHP 的扩展配置,替换安装路径并加入 `--enable-embed=shared`。PHP 源码只从 PHP.net 下载,并使用官方发布信息中的 SHA-256 校验。 + +编译完成后主要文件如下: + +```text +~/.typephp/bin/php +~/.typephp/bin/php-config +~/.typephp/lib/libphp.so +~/.typephp/lib/php.ini +~/.typephp/lib/loaded-extensions.txt +``` + +当前 ini 主文件和扫描目录中的配置会被合并。使用相同 PHP 主次版本时,当前 ini 中加载的共享扩展会复制到新扩展目录;跨主次版本时不会复制二进制扩展,不可用的扩展配置会被注释,避免生成的 PHP 无法启动。 + +安装成功后,当前 `tpc.php` 进程会自动将新目录作为 `PHP_HOME` 并继续原来的编译任务。后续也可以显式指定: + +```bash +export PHP_HOME="$HOME/.typephp" +vendor/bin/tpc.php project.yml +``` + +再次选择同一目录和版本时,安装器会询问是否直接复用已有的 `libphp.so`,不会重复执行完整构建。 + +## 非交互环境 + +安装器不会在 CI 中自动确认权限操作。请提前准备 `libphp.so`,然后设置: + +```bash +PHP_HOME=/path/to/php vendor/bin/tpc.php project.yml +``` diff --git a/phpunit/src/Installer/PhpBuildConfigurationTest.php b/phpunit/src/Installer/PhpBuildConfigurationTest.php new file mode 100644 index 00000000..9ff56fd8 --- /dev/null +++ b/phpunit/src/Installer/PhpBuildConfigurationTest.php @@ -0,0 +1,66 @@ + in_array($command, ['dnf', 'yum'], true)); + self::assertSame('dnf', $manager?->command); + } + + public function testPackagesFollowEnabledConfigureOptions(): void + { + $manager = new LinuxPackageManager('apt-get'); + $packages = $manager->packagesForConfigureOptions(['--with-curl', '--enable-mbstring']); + + self::assertContains('build-essential', $packages); + self::assertContains('libcurl4-openssl-dev', $packages); + self::assertContains('libonig-dev', $packages); + self::assertNotContains('libzip-dev', $packages); + } + + public function testMissingPackagesFiltersInstalledPackages(): void + { + $manager = new LinuxPackageManager('apt-get'); + $missing = $manager->missingPackages( + ['make', 're2c'], + static fn(string $package): bool => $package === 'make' + ); + self::assertSame(['re2c'], $missing); + } + + public function testExtensionNamesWithoutSuffixAlsoResolveSharedObjects(): void + { + self::assertSame( + ['/php/extensions/swoole', '/php/extensions/swoole.so'], + LibPhpInstaller::extensionFileCandidates('/php/extensions/swoole') + ); + self::assertSame( + ['/php/extensions/opcache.so'], + LibPhpInstaller::extensionFileCandidates('/php/extensions/opcache.so') + ); + } +} diff --git a/src/Build/SourcePipelineTrait.php b/src/Build/SourcePipelineTrait.php index 9b3b9f52..eedeb34b 100644 --- a/src/Build/SourcePipelineTrait.php +++ b/src/Build/SourcePipelineTrait.php @@ -11,6 +11,8 @@ namespace TypePhp\Build; use TypePhp\Backend\CompilerFactory; use TypePhp\Exception\SyntaxError; use TypePhp\Exception\Unsupported; +use TypePhp\Installer\LibPhpInstaller; +use TypePhp\Platform\Linux; trait SourcePipelineTrait { @@ -59,6 +61,15 @@ trait SourcePipelineTrait public function prepare(string $path): array { $files = $this->getFiles($path); + + if ($this->isBuildModeEmbed() && $this->getPlatform() instanceof Linux) { + try { + (new LibPhpInstaller())->ensure($this->getPhpDir()); + } catch (\Throwable $e) { + $this->error('Unable to install libphp.so: ' . $e->getMessage()); + } + } + $this->validateCompilerToolchain(); // shell_exec 和 define 已通过 php::fn:: 直接调用,无需动态符号表 diff --git a/src/Installer/InteractiveConsole.php b/src/Installer/InteractiveConsole.php new file mode 100644 index 00000000..7d6809f4 --- /dev/null +++ b/src/Installer/InteractiveConsole.php @@ -0,0 +1,30 @@ +ask($question . $suffix, ''))); + return $answer === '' ? $default : in_array($answer, ['y', 'yes'], true); + } + + public function ask(string $question, string $default): string + { + fwrite(STDERR, $question); + $value = trim((string) fgets(STDIN)); + return $value === '' ? $default : $value; + } + + public function write(string $message): void + { + fwrite(STDERR, $message . PHP_EOL); + } +} diff --git a/src/Installer/LibPhpInstaller.php b/src/Installer/LibPhpInstaller.php new file mode 100644 index 00000000..fa75a233 --- /dev/null +++ b/src/Installer/LibPhpInstaller.php @@ -0,0 +1,348 @@ +sourcePhpDir = rtrim($currentPhpDir, '/'); + if (PHP_OS_FAMILY !== 'Linux' || $this->hasLibPhp($currentPhpDir)) { + return $currentPhpDir; + } + if (!$this->console->isInteractive()) { + $this->console->write('libphp.so is missing. Run tpc.php in an interactive terminal to build it automatically, or set PHP_HOME.'); + return null; + } + + $this->console->write("The current PHP installation does not provide libphp.so: {$currentPhpDir}"); + if (!$this->console->confirm('Build a private PHP embed library now?', true)) { + return null; + } + + $release = $this->latestRelease(); + $version = $this->console->ask("PHP version [{$release['version']}]: ", $release['version']); + if (!preg_match('/^8\.[45]\.\d+$/', $version)) { + throw new \RuntimeException('Only stable PHP 8.4.x and 8.5.x versions are supported by the automatic installer'); + } + if ($version !== $release['version']) { + $release = $this->release($version); + } + + $home = getenv('HOME') ?: (string) ($_SERVER['HOME'] ?? ''); + $defaultPrefix = rtrim($home, '/') . '/.typephp'; + $prefix = $this->expandHome($this->console->ask("Install directory [{$defaultPrefix}]: ", $defaultPrefix), $home); + if ($this->hasLibPhp($prefix) && $this->installedVersion($prefix) === $version + && $this->console->confirm("PHP {$version} with libphp.so already exists in {$prefix}; use it?", true)) { + putenv('PHP_HOME=' . $prefix); + $_ENV['PHP_HOME'] = $prefix; + return $prefix; + } + $this->install($release, $prefix); + putenv('PHP_HOME=' . $prefix); + $_ENV['PHP_HOME'] = $prefix; + return $prefix; + } + + public function hasLibPhp(string $prefix): bool + { + return is_file($prefix . '/lib/libphp.so') || is_file($prefix . '/lib/libphp.a'); + } + + public function installedVersion(string $prefix): ?string + { + $phpConfig = $prefix . '/bin/php-config'; + if (!is_executable($phpConfig)) { + return null; + } + $version = trim((string) shell_exec(escapeshellarg($phpConfig) . ' --version 2>/dev/null')); + return preg_match('/^\d+\.\d+\.\d+/', $version, $match) ? $match[0] : null; + } + + /** @return array{version:string,filename:string,sha256:string,url:string} */ + public function latestRelease(): array + { + $releases = $this->fetchReleaseList('8.4'); + uksort($releases, static fn(string $a, string $b): int => version_compare($b, $a)); + foreach ($releases as $version => $info) { + if (preg_match('/^8\.4\.\d+$/', $version)) { + return $this->normalizeRelease($version, $info); + } + } + throw new \RuntimeException('PHP.net did not return a stable PHP 8.4 release'); + } + + /** @return array{version:string,filename:string,sha256:string,url:string} */ + public function release(string $version): array + { + $branch = implode('.', array_slice(explode('.', $version), 0, 2)); + $releases = $this->fetchReleaseList($branch); + if (!isset($releases[$version])) { + throw new \RuntimeException("PHP {$version} was not found in the official release list"); + } + return $this->normalizeRelease($version, $releases[$version]); + } + + private function fetchReleaseList(string $branch): array + { + $json = $this->downloadText(sprintf(self::RELEASE_API, rawurlencode($branch))); + $data = json_decode($json, true, flags: JSON_THROW_ON_ERROR); + if (!is_array($data)) { + throw new \RuntimeException('Invalid release list returned by PHP.net'); + } + return $data; + } + + private function normalizeRelease(string $version, array $info): array + { + foreach ($info['source'] ?? [] as $source) { + $filename = (string) ($source['filename'] ?? ''); + if (str_ends_with($filename, '.tar.xz') && !empty($source['sha256'])) { + return [ + 'version' => $version, + 'filename' => $filename, + 'sha256' => (string) $source['sha256'], + 'url' => 'https://www.php.net/distributions/' . rawurlencode($filename), + ]; + } + } + throw new \RuntimeException("PHP {$version} has no verified tar.xz source archive"); + } + + private function install(array $release, string $prefix): void + { + $workDir = $prefix . '/var/build'; + $archive = $workDir . '/' . $release['filename']; + $sourceDir = $workDir . '/php-' . $release['version']; + $this->mkdir($workDir); + $this->mkdir($prefix . '/lib/conf.d'); + + $configureOptions = $this->currentConfigureOptions(); + $options = PhpBuildConfiguration::derive($configureOptions, $prefix); + $manager = LinuxPackageManager::detect(); + if ($manager !== null) { + $packages = $manager->missingPackages($manager->packagesForConfigureOptions($options)); + $this->console->write('Detected package manager: ' . $manager->command); + if ($packages !== [] && $this->console->confirm('Install missing development packages (' . implode(', ', $packages) . ')?', true)) { + $useSudo = function_exists('posix_geteuid') && posix_geteuid() !== 0; + $refresh = $manager->refreshCommand($useSudo); + if ($refresh !== null) { + $this->run($refresh); + } + $this->run($manager->installCommand($packages, $useSudo)); + } elseif ($packages === []) { + $this->console->write('All detected build dependencies are already installed.'); + } + } else { + $this->console->write('No supported package manager (apt-get/dnf/yum) was found; continuing with existing libraries.'); + } + + if (!is_file($archive) || hash_file('sha256', $archive) !== $release['sha256']) { + $this->console->write('Downloading ' . $release['url']); + $this->downloadFile($release['url'], $archive); + } + if (hash_file('sha256', $archive) !== $release['sha256']) { + throw new \RuntimeException('PHP source archive SHA-256 verification failed'); + } + if (!is_dir($sourceDir)) { + $this->run(['tar', '-xJf', $archive, '-C', $workDir]); + } + + $this->console->write('Configuring PHP with the current installation options plus --enable-embed=shared'); + $this->run([$sourceDir . '/configure', ...$options], $sourceDir); + // PHP is a large build; capping parallelism avoids exhausting memory on + // hosts that expose many CPUs (especially containers and CI runners). + $jobs = min(8, max(1, $this->cpuCount())); + $this->run(['make', '-j' . $jobs], $sourceDir); + $this->run(['make', 'install'], $sourceDir); + if (!$this->hasLibPhp($prefix)) { + throw new \RuntimeException("Build completed but {$prefix}/lib/libphp.so was not created"); + } + $this->writePhpIni($prefix, $sourceDir, $release['version']); + $this->console->write("libphp.so installed successfully in {$prefix}/lib"); + } + + private function currentConfigureOptions(): string + { + $phpConfig = $this->sourcePhpDir !== null && is_executable($this->sourcePhpDir . '/bin/php-config') + ? $this->sourcePhpDir . '/bin/php-config' + : trim((string) shell_exec('command -v php-config 2>/dev/null')); + if ($phpConfig !== '') { + return trim($this->capture([$phpConfig, '--configure-options'])); + } + + $info = $this->capture([PHP_BINARY, '-n', '-i']); + if (preg_match('/^Configure Command =>\s*(.+)$/mi', $info, $match)) { + $words = PhpBuildConfiguration::parseShellWords(trim($match[1])); + if (($words[0] ?? null) === './configure') { + array_shift($words); + } + return implode(' ', array_map('escapeshellarg', $words)); + } + throw new \RuntimeException('Unable to determine the current PHP configure options from php-config or php -i'); + } + + private function writePhpIni(string $prefix, string $sourceDir, string $version): void + { + $chunks = []; + $loaded = php_ini_loaded_file(); + if (is_string($loaded) && is_file($loaded)) { + $chunks[] = file_get_contents($loaded); + } elseif (is_file($sourceDir . '/php.ini-development')) { + $chunks[] = file_get_contents($sourceDir . '/php.ini-development'); + } + $scanned = php_ini_scanned_files(); + if (is_string($scanned) && trim($scanned) !== '') { + foreach (preg_split('/,\s*/', trim($scanned)) as $file) { + if (is_file($file)) { + $chunks[] = PHP_EOL . '; imported from ' . $file . PHP_EOL . file_get_contents($file); + } + } + } + $ini = implode(PHP_EOL, $chunks); + $extensionDir = trim($this->capture([$prefix . '/bin/php-config', '--extension-dir'])); + $branch = implode('.', array_slice(explode('.', $version), 0, 2)); + if ($branch === PHP_MAJOR_VERSION . '.' . PHP_MINOR_VERSION) { + $this->copyConfiguredExtensions($prefix, $extensionDir, $ini); + } else { + $this->console->write('Shared extensions cannot be copied across PHP minor versions; only extensions built from php-src will be enabled.'); + } + $ini = $this->disableUnavailableExtensions($ini, $extensionDir); + if (preg_match('/^\s*extension_dir\s*=.*$/mi', $ini)) { + $ini = preg_replace('/^\s*extension_dir\s*=.*$/mi', 'extension_dir=' . $extensionDir, $ini, 1); + } else { + $ini .= PHP_EOL . 'extension_dir=' . $extensionDir . PHP_EOL; + } + file_put_contents($prefix . '/lib/php.ini', $ini); + } + + private function disableUnavailableExtensions(string $ini, string $extensionDir): string + { + return preg_replace_callback( + '/^(\s*(?:zend_)?extension\s*=\s*["\']?)([^"\'\s;]+)(["\']?.*)$/mi', + static function (array $match) use ($extensionDir): string { + $module = basename($match[2]); + $basePath = rtrim($extensionDir, '/') . '/' . $module; + return self::firstExistingFile(self::extensionFileCandidates($basePath)) !== null + ? $match[1] . $module . $match[3] + : '; disabled by TypePHP (module was not built): ' . $match[0]; + }, + $ini + ); + } + + private function copyConfiguredExtensions(string $prefix, string $targetDirectory, string $ini): void + { + $this->mkdir($targetDirectory); + $manifest = []; + preg_match_all('/^\s*(?:zend_)?extension\s*=\s*["\']?([^"\'\s;]+)["\']?/mi', $ini, $matches); + $currentExtensionDir = (string) ini_get('extension_dir'); + foreach (array_unique($matches[1]) as $configuredPath) { + $sourceBase = str_starts_with($configuredPath, '/') + ? $configuredPath + : rtrim($currentExtensionDir, '/') . '/' . $configuredPath; + $source = self::firstExistingFile(self::extensionFileCandidates($sourceBase)); + if ($source === null) { + continue; + } + $target = $targetDirectory . '/' . basename($source); + if (!is_file($target) && !copy($source, $target)) { + throw new \RuntimeException("Unable to copy loaded extension {$source}"); + } + $manifest[] = $configuredPath . '=' . basename($source); + } + file_put_contents($prefix . '/lib/loaded-extensions.txt', implode(PHP_EOL, $manifest) . PHP_EOL); + } + + public static function extensionFileCandidates(string $path): array + { + return str_ends_with($path, '.so') ? [$path] : [$path, $path . '.so']; + } + + private static function firstExistingFile(array $paths): ?string + { + foreach ($paths as $path) { + if (is_file($path)) { + return $path; + } + } + return null; + } + + private function downloadText(string $url): string + { + $context = stream_context_create(['http' => ['timeout' => 30, 'user_agent' => 'TypePHP/tpc']]); + $data = @file_get_contents($url, false, $context); + if ($data === false) { + $curl = trim((string) shell_exec('command -v curl 2>/dev/null')); + if ($curl !== '') { + return $this->capture([$curl, '--fail', '--location', '--retry', '3', $url]); + } + throw new \RuntimeException("Unable to download {$url}; enable allow_url_fopen or install curl"); + } + return $data; + } + + private function downloadFile(string $url, string $target): void + { + $curl = trim((string) shell_exec('command -v curl 2>/dev/null')); + if ($curl !== '') { + $this->run([$curl, '--fail', '--location', '--retry', '3', '--output', $target, $url]); + return; + } + $data = $this->downloadText($url); + if (file_put_contents($target, $data) === false) { + throw new \RuntimeException("Unable to write {$target}"); + } + } + + private function run(array $command, ?string $cwd = null): void + { + $this->console->write('$ ' . implode(' ', array_map('escapeshellarg', $command))); + $process = proc_open($command, [STDIN, STDOUT, STDERR], $pipes, $cwd); + if (!is_resource($process) || proc_close($process) !== 0) { + throw new \RuntimeException('Command failed: ' . implode(' ', $command)); + } + } + + private function capture(array $command): string + { + $process = proc_open($command, [1 => ['pipe', 'w'], 2 => ['pipe', 'w']], $pipes); + if (!is_resource($process)) { + throw new \RuntimeException('Unable to run command: ' . implode(' ', $command)); + } + $stdout = stream_get_contents($pipes[1]); + $stderr = stream_get_contents($pipes[2]); + fclose($pipes[1]); + fclose($pipes[2]); + if (proc_close($process) !== 0) { + throw new \RuntimeException(trim($stderr)); + } + return $stdout; + } + + private function mkdir(string $directory): void + { + if (!is_dir($directory) && !mkdir($directory, 0755, true) && !is_dir($directory)) { + throw new \RuntimeException("Unable to create directory {$directory}"); + } + } + + private function expandHome(string $path, string $home): string + { + return str_starts_with($path, '~/') ? rtrim($home, '/') . substr($path, 1) : rtrim($path, '/'); + } + + private function cpuCount(): int + { + $count = (int) trim((string) shell_exec('getconf _NPROCESSORS_ONLN 2>/dev/null')); + return $count > 0 ? $count : 1; + } +} diff --git a/src/Installer/LinuxPackageManager.php b/src/Installer/LinuxPackageManager.php new file mode 100644 index 00000000..9c9c5b00 --- /dev/null +++ b/src/Installer/LinuxPackageManager.php @@ -0,0 +1,97 @@ + trim((string) shell_exec('command -v ' . escapeshellarg($command) . ' 2>/dev/null')) !== ''; + foreach (self::COMMANDS as $command) { + if ($commandExists($command)) { + return new self($command); + } + } + return null; + } + + public function packagesForConfigureOptions(array $options): array + { + $text = implode(' ', $options); + $groups = [ + 'base' => [ + 'apt-get' => ['build-essential', 'pkg-config', 'autoconf', 'bison', 're2c', 'libxml2-dev', 'libsqlite3-dev'], + 'dnf' => ['gcc', 'gcc-c++', 'make', 'pkgconf-pkg-config', 'autoconf', 'bison', 're2c', 'libxml2-devel', 'sqlite-devel'], + 'yum' => ['gcc', 'gcc-c++', 'make', 'pkgconfig', 'autoconf', 'bison', 're2c', 'libxml2-devel', 'sqlite-devel'], + ], + 'curl' => ['needle' => '--with-curl', 'apt-get' => ['libcurl4-openssl-dev'], 'dnf' => ['libcurl-devel'], 'yum' => ['libcurl-devel']], + 'openssl' => ['needle' => '--with-openssl', 'apt-get' => ['libssl-dev'], 'dnf' => ['openssl-devel'], 'yum' => ['openssl-devel']], + 'zlib' => ['needle' => '--with-zlib', 'apt-get' => ['zlib1g-dev'], 'dnf' => ['zlib-devel'], 'yum' => ['zlib-devel']], + 'bz2' => ['needle' => '--with-bz2', 'apt-get' => ['libbz2-dev'], 'dnf' => ['bzip2-devel'], 'yum' => ['bzip2-devel']], + 'mbstring' => ['needle' => '--enable-mbstring', 'apt-get' => ['libonig-dev'], 'dnf' => ['oniguruma-devel'], 'yum' => ['oniguruma-devel']], + 'zip' => ['needle' => '--with-zip', 'apt-get' => ['libzip-dev'], 'dnf' => ['libzip-devel'], 'yum' => ['libzip-devel']], + 'readline' => ['needle' => '--with-readline', 'apt-get' => ['libreadline-dev'], 'dnf' => ['readline-devel'], 'yum' => ['readline-devel']], + 'libedit' => ['needle' => '--with-libedit', 'apt-get' => ['libedit-dev'], 'dnf' => ['libedit-devel'], 'yum' => ['libedit-devel']], + 'icu' => ['needle' => '--enable-intl', 'apt-get' => ['libicu-dev'], 'dnf' => ['libicu-devel'], 'yum' => ['libicu-devel']], + 'xslt' => ['needle' => '--with-xsl', 'apt-get' => ['libxslt1-dev'], 'dnf' => ['libxslt-devel'], 'yum' => ['libxslt-devel']], + 'gmp' => ['needle' => '--with-gmp', 'apt-get' => ['libgmp-dev'], 'dnf' => ['gmp-devel'], 'yum' => ['gmp-devel']], + 'sodium' => ['needle' => '--with-sodium', 'apt-get' => ['libsodium-dev'], 'dnf' => ['libsodium-devel'], 'yum' => ['libsodium-devel']], + 'ffi' => ['needle' => '--with-ffi', 'apt-get' => ['libffi-dev'], 'dnf' => ['libffi-devel'], 'yum' => ['libffi-devel']], + 'pgsql' => ['needle' => '--with-pgsql', 'apt-get' => ['libpq-dev'], 'dnf' => ['libpq-devel'], 'yum' => ['libpq-devel']], + 'pdo_pgsql' => ['needle' => '--with-pdo-pgsql', 'apt-get' => ['libpq-dev'], 'dnf' => ['libpq-devel'], 'yum' => ['libpq-devel']], + 'ldap' => ['needle' => '--with-ldap', 'apt-get' => ['libldap2-dev'], 'dnf' => ['openldap-devel'], 'yum' => ['openldap-devel']], + 'gd' => ['needle' => '--enable-gd', 'apt-get' => ['libpng-dev', 'libjpeg-dev', 'libwebp-dev', 'libfreetype6-dev'], 'dnf' => ['libpng-devel', 'libjpeg-turbo-devel', 'libwebp-devel', 'freetype-devel'], 'yum' => ['libpng-devel', 'libjpeg-turbo-devel', 'libwebp-devel', 'freetype-devel']], + ]; + + $packages = $groups['base'][$this->command]; + unset($groups['base']); + foreach ($groups as $group) { + if (str_contains($text, $group['needle'])) { + array_push($packages, ...$group[$this->command]); + } + } + return array_values(array_unique($packages)); + } + + public function installCommand(array $packages, bool $useSudo): array + { + $prefix = $useSudo ? ['sudo'] : []; + $args = match ($this->command) { + 'apt-get' => ['apt-get', 'install', '-y'], + 'dnf' => ['dnf', 'install', '-y'], + 'yum' => ['yum', 'install', '-y'], + }; + return [...$prefix, ...$args, ...$packages]; + } + + public function refreshCommand(bool $useSudo): ?array + { + if ($this->command !== 'apt-get') { + return null; + } + return [...($useSudo ? ['sudo'] : []), 'apt-get', 'update']; + } + + public function missingPackages(array $packages, ?callable $isInstalled = null): array + { + $isInstalled ??= function (string $package): bool { + $command = $this->command === 'apt-get' + ? "dpkg-query -W -f='\${Status}' " . escapeshellarg($package) . ' 2>/dev/null' + : 'rpm -q ' . escapeshellarg($package) . ' 2>/dev/null'; + $output = trim((string) shell_exec($command)); + return $this->command === 'apt-get' + ? str_contains($output, 'install ok installed') + : $output !== ''; + }; + return array_values(array_filter($packages, static fn(string $package): bool => !$isInstalled($package))); + } +} diff --git a/src/Installer/PhpBuildConfiguration.php b/src/Installer/PhpBuildConfiguration.php new file mode 100644 index 00000000..d7b8e3f5 --- /dev/null +++ b/src/Installer/PhpBuildConfiguration.php @@ -0,0 +1,42 @@ += 2 && (($word[0] === "'" && $word[-1] === "'") || ($word[0] === '"' && $word[-1] === '"'))) { + return substr($word, 1, -1); + } + return $word; + }, $matches[0]); + } + + public static function derive(string $configureOptions, string $prefix): array + { + $replace = [ + '--prefix', '--with-config-file-path', '--with-config-file-scan-dir', + '--enable-embed', '--enable-cli', '--disable-cli', '--with-libdir', + ]; + $drop = ['--with-apxs', '--with-apxs2', '--enable-fpm', '--with-fpm-systemd']; + $result = []; + foreach (self::parseShellWords($configureOptions) as $option) { + $name = explode('=', $option, 2)[0]; + if (in_array($name, $replace, true) || in_array($name, $drop, true)) { + continue; + } + $result[] = $option; + } + return [ + '--prefix=' . $prefix, + '--with-config-file-path=' . $prefix . '/lib', + '--with-config-file-scan-dir=' . $prefix . '/lib/conf.d', + '--enable-embed=shared', + '--enable-cli', + ...$result, + ]; + } +}