This commit is contained in:
crazywhalecc
2025-11-30 15:35:04 +08:00
parent f6c818d3c0
commit 14bfb4198a
179 changed files with 19502 additions and 655 deletions

View File

@@ -0,0 +1,188 @@
<?php
declare(strict_types=1);
namespace StaticPHP\Package;
use StaticPHP\Config\PackageConfig;
use StaticPHP\Exception\PatchException;
use StaticPHP\Util\FileSystem;
/**
* Represents a library package with platform-specific build functions.
*/
class LibraryPackage extends Package
{
/** @var array<string, callable> $build_functions Build functions for different OS binding */
protected array $build_functions = [];
/**
* Add a build function for a specific platform.
*
* @param string $platform PHP_OS_FAMILY
* @param callable $func Function to build for the platform
*/
public function addBuildFunction(string $platform, callable $func): void
{
$this->build_functions[$platform] = $func;
if ($platform === PHP_OS_FAMILY) {
$this->addStage('build', $func);
}
}
public function isInstalled(): bool
{
foreach (PackageConfig::get($this->getName(), 'static-libs', []) as $lib) {
if (!file_exists("{$this->getLibDir()}/{$lib}")) {
return false;
}
}
foreach (PackageConfig::get($this->getName(), 'headers', []) as $header) {
if (!file_exists("{$this->getIncludeDir()}/{$header}")) {
return false;
}
}
foreach (PackageConfig::get($this->getName(), 'pkg-configs', []) as $pc) {
if (!file_exists("{$this->getLibDir()}/pkgconfig/{$pc}.pc")) {
return false;
}
}
foreach (PackageConfig::get($this->getName(), 'static-bins', []) as $bin) {
if (!file_exists("{$this->getBinDir()}/{$bin}")) {
return false;
}
}
return true;
}
public function patchLaDependencyPrefix(?array $files = null): void
{
logger()->info("Patching library {$this->name} la files");
$throwOnMissing = true;
if ($files === null) {
$files = PackageConfig::get($this->getName(), 'static-libs', []);
$files = array_map(fn ($name) => str_replace('.a', '.la', $name), $files);
$throwOnMissing = false;
}
foreach ($files as $name) {
$realpath = realpath(BUILD_LIB_PATH . '/' . $name);
if ($realpath === false) {
if ($throwOnMissing) {
throw new PatchException('la dependency patcher', "Cannot find library [{$this->name}] la file [{$name}] !");
}
logger()->warning(message: 'Cannot find library [' . $this->name . '] la file [' . $name . '] !');
continue;
}
logger()->debug('Patching ' . $realpath);
// replace prefix
$file = FileSystem::readFile($realpath);
$file = str_replace(
' /lib/',
' ' . BUILD_LIB_PATH . '/',
$file
);
$file = preg_replace('/^libdir=.*$/m', "libdir='" . BUILD_LIB_PATH . "'", $file);
FileSystem::writeFile($realpath, $file);
}
}
/**
* Get extra CFLAGS for current package.
* You need to define the environment variable in the format of {LIBRARY_NAME}_CFLAGS
* where {LIBRARY_NAME} is the snake_case name of the library.
* For example, for libjpeg, the environment variable should be libjpeg_CFLAGS.
*/
public function getLibExtraCFlags(): string
{
// get environment variable
$env = getenv($this->getSnakeCaseName() . '_CFLAGS') ?: '';
// get default c flags
$arch_c_flags = getenv('SPC_DEFAULT_C_FLAGS') ?: '';
if (!empty(getenv('SPC_DEFAULT_C_FLAGS')) && !str_contains($env, $arch_c_flags)) {
$env .= ' ' . $arch_c_flags;
}
return trim($env);
}
/**
* Get extra CXXFLAGS for current package.
* You need to define the environment variable in the format of {LIBRARY_NAME}_CXXFLAGS
* where {LIBRARY_NAME} is the snake_case name of the library.
* For example, for libjpeg, the environment variable should be libjpeg_CXXFLAGS.
*/
public function getLibExtraCxxFlags(): string
{
// get environment variable
$env = getenv($this->getSnakeCaseName() . '_CXXFLAGS') ?: '';
// get default cxx flags
$arch_cxx_flags = getenv('SPC_DEFAULT_CXX_FLAGS') ?: '';
if (!empty(getenv('SPC_DEFAULT_CXX_FLAGS')) && !str_contains($env, $arch_cxx_flags)) {
$env .= ' ' . $arch_cxx_flags;
}
return trim($env);
}
/**
* Get extra LDFLAGS for current package.
* You need to define the environment variable in the format of {LIBRARY_NAME}_LDFLAGS
* where {LIBRARY_NAME} is the snake_case name of the library.
* For example, for libjpeg, the environment variable should be libjpeg_LDFLAGS.
*/
public function getLibExtraLdFlags(): string
{
// get environment variable
$env = getenv($this->getSnakeCaseName() . '_LDFLAGS') ?: '';
// get default ld flags
$arch_ld_flags = getenv('SPC_DEFAULT_LD_FLAGS') ?: '';
if (!empty(getenv('SPC_DEFAULT_LD_FLAGS')) && !str_contains($env, $arch_ld_flags)) {
$env .= ' ' . $arch_ld_flags;
}
return trim($env);
}
/**
* Get extra LIBS for current package.
* You need to define the environment variable in the format of {LIBRARY_NAME}_LIBS
* where {LIBRARY_NAME} is the snake_case name of the library.
* For example, for libjpeg, the environment variable should be libjpeg_LIBS.
*/
public function getLibExtraLibs(): string
{
return getenv($this->getSnakeCaseName() . '_LIBS') ?: '';
}
/**
* Get the build root path for the package.
*
* TODO: Can be changed to support per-package build root path in the future.
*/
public function getBuildRootPath(): string
{
return BUILD_ROOT_PATH;
}
/**
* Get the include directory for the package.
*
* TODO: Can be changed to support per-package include directory in the future.
*/
public function getIncludeDir(): string
{
return BUILD_INCLUDE_PATH;
}
/**
* Get the library directory for the package.
*
* TODO: Can be changed to support per-package library directory in the future.
*/
public function getLibDir(): string
{
return BUILD_LIB_PATH;
}
public function getBinDir(): string
{
return BUILD_BIN_PATH;
}
}

View File

@@ -0,0 +1,162 @@
<?php
declare(strict_types=1);
namespace StaticPHP\Package;
use StaticPHP\Artifact\Artifact;
use StaticPHP\Artifact\ArtifactLoader;
use StaticPHP\Config\PackageConfig;
use StaticPHP\DI\ApplicationContext;
use StaticPHP\Exception\SPCInternalException;
abstract class Package
{
use PackageCallbacksTrait;
/**
* @var array<string, callable> $stages Defined stages for the package
*/
protected array $stages = [];
/**
* @param string $name Name of the package
* @param string $type Type of the package
*/
public function __construct(public readonly string $name, public readonly string $type) {}
/**
* Run a defined stage of the package.
* If the stage is not defined, an exception should be thrown.
*
* @param string $name Name of the stage to run
* @param array $context Additional context to pass to the stage callback
* @return mixed Based on the stage definition, return the result of the stage
*/
public function runStage(string $name, array $context = []): mixed
{
if (!isset($this->stages[$name])) {
throw new SPCInternalException("Stage '{$name}' is not defined for package '{$this->name}'.");
}
// Merge package context with provided context
/** @noinspection PhpDuplicateArrayKeysInspection */
$stageContext = array_merge([
Package::class => $this,
static::class => $this,
], $context);
// emit BeforeStage
$this->emitBeforeStage($name, $stageContext);
$ret = ApplicationContext::invoke($this->stages[$name], $stageContext);
// emit AfterStage
$this->emitAfterStage($name, $stageContext, $ret);
return $ret;
}
public function isInstalled(): bool
{
// By default, assume package is not installed.
return false;
}
/**
* Add a stage to the package.
*
* @param string $name Stage name
* @param callable $stage Stage callable
*/
public function addStage(string $name, callable $stage): void
{
$this->stages[$name] = $stage;
}
/**
* Check if the package has a specific stage defined.
*
* @param string $name Stage name
*/
public function hasStage(string $name): bool
{
return isset($this->stages[$name]);
}
/**
* Get the name of the package.
*/
public function getName(): string
{
return $this->name;
}
/**
* Get the type of the package.
*/
public function getType(): string
{
return $this->type;
}
/**
* Get the artifact associated with the package, or null if none is defined.
*
* @return null|Artifact Artifact instance or null
*/
public function getArtifact(): ?Artifact
{
// find config
$artifact_name = PackageConfig::get($this->name, 'artifact');
return $artifact_name !== null ? ArtifactLoader::getArtifactInstance($artifact_name) : null;
}
/**
* Check if the artifact has source available.
*/
public function hasSource(): bool
{
return $this->getArtifact()?->hasSource() ?? false;
}
/**
* Get source directory of the package.
* If the source artifact is not available, an exception will be thrown.
*/
public function getSourceDir(): string
{
if (($artifact = $this->getArtifact()) && $artifact->hasSource()) {
return $artifact->getSourceDir();
}
throw new SPCInternalException("Source directory for package {$this->name} is not available because the source artifact is missing.");
}
/**
* Check if the package has a binary available for current OS and architecture.
*/
public function hasLocalBinary(): bool
{
return $this->getArtifact()?->hasPlatformBinary() ?? false;
}
/**
* Get the snake_case name of the package.
*/
protected function getSnakeCaseName(): string
{
return str_replace('-', '_', $this->name);
}
private function emitBeforeStage(string $stage, array $stageContext): void
{
foreach (PackageLoader::getBeforeStageCallbacks($this->getName(), $stage) as $callback) {
ApplicationContext::invoke($callback, $stageContext);
}
}
private function emitAfterStage(string $stage, array $stageContext, mixed $return_value): void
{
foreach (PackageLoader::getAfterStageCallbacks($this->getName(), $stage) as $callback) {
ApplicationContext::invoke($callback, array_merge($stageContext, ['return_value' => $return_value]));
}
}
}

View File

@@ -0,0 +1,104 @@
<?php
declare(strict_types=1);
namespace StaticPHP\Package;
use StaticPHP\Config\PackageConfig;
use StaticPHP\DI\ApplicationContext;
use StaticPHP\Exception\SPCInternalException;
use StaticPHP\Exception\WrongUsageException;
use StaticPHP\Runtime\Shell\Shell;
use StaticPHP\Util\FileSystem;
use StaticPHP\Util\GlobalEnvManager;
use StaticPHP\Util\InteractiveTerm;
class PackageBuilder
{
/** @var int make jobs count */
public readonly int $concurrency;
/**
* @param array $options Builder options
*/
public function __construct(protected array $options = [])
{
ApplicationContext::set(PackageBuilder::class, $this);
// apply build toolchain envs
GlobalEnvManager::afterInit();
$this->concurrency = (int) getenv('SPC_CONCURRENCY') ?: 1;
}
public function buildPackage(Package $package, bool $force = false): int
{
// init build dirs
if (!$package instanceof LibraryPackage) {
throw new SPCInternalException('Please, never try to build non-library packages directly.');
}
FileSystem::createDir($package->getBuildRootPath());
FileSystem::createDir($package->getIncludeDir());
FileSystem::createDir($package->getBinDir());
FileSystem::createDir($package->getLibDir());
if (!$package->hasStage('build')) {
throw new WrongUsageException("Package '{$package->name}' does not have a current platform 'build' stage defined.");
}
// validate package should be built
if (!$force) {
return $package->isInstalled() ? SPC_STATUS_ALREADY_BUILT : $this->buildPackage($package, true);
}
// check source is ready
if ($package->getType() !== 'virtual-target' && !is_dir($package->getSourceDir())) {
throw new WrongUsageException("Source directory for package '{$package->name}' does not exist. Please fetch the source before building.");
}
Shell::passthruCallback(function () {
InteractiveTerm::advance();
});
if ($package->getType() !== 'virtual-target') {
// patch before build
$package->patchBeforeBuild();
}
// build
$package->runStage('build');
if ($package->getType() !== 'virtual-target') {
// install license
if (($license = PackageConfig::get($package->getName(), 'license')) !== null) {
$this->installLicense($package, $license);
}
}
return SPC_STATUS_BUILT;
}
public function getOptions(): array
{
return $this->options;
}
public function getOption(string $key, mixed $default = null): mixed
{
return $this->options[$key] ?? $default;
}
private function installLicense(Package $package, array $license): void
{
$dir = BUILD_ROOT_PATH . '/source-licenses/' . $package->getName();
FileSystem::createDir($dir);
if (is_assoc_array($license)) {
$license = [$license];
}
foreach ($license as $index => $item) {
if ($item['type'] === 'text') {
FileSystem::writeFile("{$dir}/{$index}.txt", $item['text']);
} elseif ($item['type'] === 'file') {
FileSystem::copy("{$package->getSourceDir()}/{$item['path']}", "{$dir}/{$index}.txt");
}
}
}
}

View File

@@ -0,0 +1,89 @@
<?php
declare(strict_types=1);
namespace StaticPHP\Package;
use StaticPHP\DI\ApplicationContext;
use StaticPHP\Util\FileSystem;
/**
* Trait for handling package callbacks (info, validate, etc.)
*/
trait PackageCallbacksTrait
{
protected mixed $info_callback = null;
protected mixed $validate_callback = null;
protected mixed $patch_before_build_callback = null;
public function setInfoCallback(callable $callback): void
{
$this->info_callback = $callback;
}
/**
* Get package info by invoking the info callback.
*
* @return array<string, mixed> Package information
*/
public function getPackageInfo(): array
{
if ($this->info_callback === null) {
return [];
}
// Use CallbackInvoker with current package as context
$result = ApplicationContext::invoke($this->info_callback, [
Package::class => $this,
static::class => $this,
]);
return is_array($result) ? $result : [];
}
public function setValidateCallback(callable $callback): void
{
$this->validate_callback = $callback;
}
public function setPatchBeforeBuildCallback(callable $callback): void
{
$this->patch_before_build_callback = $callback;
}
public function patchBeforeBuild(): void
{
if (file_exists("{$this->getSourceDir()}/.spc-patched")) {
return;
}
if ($this->patch_before_build_callback === null) {
return;
}
// Use CallbackInvoker with current package as context
$ret = ApplicationContext::invoke($this->patch_before_build_callback, [
Package::class => $this,
static::class => $this,
]);
if ($ret === true) {
FileSystem::writeFile("{$this->getSourceDir()}/.spc-patched", 'PATCHED!!!');
}
}
/**
* Validate the package by invoking the validate callback.
*/
public function validatePackage(): void
{
if ($this->validate_callback === null) {
return;
}
// Use CallbackInvoker with current package as context
ApplicationContext::invoke($this->validate_callback, [
Package::class => $this,
static::class => $this,
]);
}
}

View File

@@ -0,0 +1,534 @@
<?php
declare(strict_types=1);
namespace StaticPHP\Package;
use StaticPHP\Artifact\Artifact;
use StaticPHP\Artifact\ArtifactCache;
use StaticPHP\Artifact\ArtifactDownloader;
use StaticPHP\Artifact\ArtifactExtractor;
use StaticPHP\Artifact\DownloaderOptions;
use StaticPHP\DI\ApplicationContext;
use StaticPHP\Exception\WrongUsageException;
use StaticPHP\Util\DependencyResolver;
use StaticPHP\Util\FileSystem;
use StaticPHP\Util\InteractiveTerm;
use StaticPHP\Util\V2CompatLayer;
use ZM\Logger\ConsoleColor;
/**
* PackageInstaller is responsible for installing packages within the StaticPHP framework.
*/
class PackageInstaller
{
/** @var array<string, Package> Resolved package list */
protected array $packages = [];
/** @var array<string, Package> Packages to be built from source */
protected array $build_packages = [];
/** @var array<string, Package> Packages to be installed */
protected array $install_packages = [];
/** @var array<string, array<string>> Unresolved target additional dependencies defined in #[ResolveBuild] */
protected array $target_additional_dependencies = [];
/** @var bool Whether to download missing sources automatically */
protected bool $download = true;
public function __construct(protected array $options = [])
{
ApplicationContext::set(PackageInstaller::class, $this);
$builder = new PackageBuilder($options);
ApplicationContext::set(PackageBuilder::class, $builder);
ApplicationContext::set('patch_point', '');
// Check for no-download option
if (!empty($options['no-download'])) {
$this->download = false;
}
}
/**
* Add a package to the build list.
* This means the package will be built from source.
*/
public function addBuildPackage(LibraryPackage|string|TargetPackage $package): static
{
if (is_string($package)) {
$package = PackageLoader::getPackage($package);
}
// special check for php target packages
if (in_array($package->getName(), ['php', 'php-cli', 'php-fpm', 'php-micro', 'php-cgi', 'php-embed', 'frankenphp'], true)) {
$this->handlePhpTargetPackage($package);
return $this;
}
if (!$package->hasStage('build')) {
throw new WrongUsageException("Target package '{$package->getName()}' does not define build process for current OS: " . PHP_OS_FAMILY . '.');
}
$this->build_packages[$package->getName()] = $package;
return $this;
}
/**
* @param string $name Package name
* @return null|Package The build package instance or null if not found
*/
public function getBuildPackage(string $name): ?Package
{
return $this->build_packages[$name] ?? null;
}
/**
* Add a package to the installation list.
* This means the package will try to install binary artifacts first.
* If no artifacts found, it will fallback to build from source.
*/
public function addInstallPackage(LibraryPackage|string $package): static
{
if (is_string($package)) {
$package = PackageLoader::getPackage($package);
}
$this->install_packages[$package->getName()] = $package;
return $this;
}
/**
* Set whether to download packages before installation.
*/
public function setDownload(bool $download = true): static
{
$this->download = $download;
return $this;
}
/**
* Run the package installation process.
*/
public function run(bool $interactive = true, bool $disable_delay_msg = false): void
{
// resolve input, make dependency graph
$this->resolvePackages();
if ($interactive && !$disable_delay_msg) {
// show install or build options in terminal with beautiful output
$this->printInstallerInfo();
InteractiveTerm::notice('Build process will start after 2s ...');
sleep(2);
echo PHP_EOL;
}
// check download
if ($this->download) {
$downloaderOptions = DownloaderOptions::extractFromConsoleOptions($this->options, 'dl');
$downloader = new ArtifactDownloader([...$downloaderOptions, 'source-only' => implode(',', array_map(fn ($x) => $x->getName(), $this->build_packages))]);
$downloader->addArtifacts($this->getArtifacts())->download($interactive);
} else {
logger()->notice('Skipping download (--no-download option enabled)');
}
// extract sources
$this->extractSourceArtifacts(interactive: $interactive);
// validate packages
foreach ($this->packages as $package) {
// 1. call validate package
$package->validatePackage();
}
// build/install packages
if ($interactive) {
InteractiveTerm::notice('Building/Installing packages ...');
keyboard_interrupt_register(function () {
InteractiveTerm::finish('Build/Install process interrupted by user!', false);
exit(130);
});
}
$builder = ApplicationContext::get(PackageBuilder::class);
foreach ($this->packages as $package) {
if ($this->isBuildPackage($package) || $package instanceof LibraryPackage && $package->hasStage('build')) {
if ($interactive) {
InteractiveTerm::indicateProgress('Building package: ' . ConsoleColor::yellow($package->getName()));
}
try {
/** @var LibraryPackage $package */
$status = $builder->buildPackage($package, $this->isBuildPackage($package));
} catch (\Throwable $e) {
if ($interactive) {
InteractiveTerm::finish('Building package failed: ' . ConsoleColor::red($package->getName()), false);
echo PHP_EOL;
}
throw $e;
}
if ($interactive) {
InteractiveTerm::finish('Built package: ' . ConsoleColor::green($package->getName()) . ($status === SPC_STATUS_ALREADY_BUILT ? ' (already built, skipped)' : ''));
}
} elseif ($package instanceof LibraryPackage && $package->getArtifact()->shouldUseBinary()) {
// install binary
if ($interactive) {
InteractiveTerm::indicateProgress('Installing package: ' . ConsoleColor::yellow($package->getName()));
}
try {
$status = $this->installBinary($package);
} catch (\Throwable $e) {
if ($interactive) {
InteractiveTerm::finish('Installing binary package failed: ' . ConsoleColor::red($package->getName()), false);
echo PHP_EOL;
}
throw $e;
}
if ($interactive) {
InteractiveTerm::finish('Installed binary package: ' . ConsoleColor::green($package->getName()) . ($status === SPC_STATUS_ALREADY_INSTALLED ? ' (already installed, skipped)' : ''));
}
} elseif ($package instanceof LibraryPackage) {
throw new WrongUsageException("Package '{$package->getName()}' cannot be installed: no build stage defined and no binary artifact available for current OS.");
}
}
}
public function isBuildPackage(Package|string $package): bool
{
return isset($this->build_packages[is_string($package) ? $package : $package->getName()]);
}
/**
* Get all resolved packages.
*
* @return array<string, Package>
*/
public function getResolvedPackages(): array
{
return $this->packages;
}
public function isPackageBeingResolved(string $package_name): bool
{
return isset($this->packages[$package_name]);
}
/**
* Returns the download status of all artifacts for the resolved packages.
*
* @return array<string, array{
* source-downloaded: bool,
* binary-downloaded: bool,
* has-source: bool,
* has-binary: bool
* }> artifact name => [source downloaded, binary downloaded]
*/
public function getArtifactDownloadStatus(): array
{
$download_status = [];
foreach ($this->getResolvedPackages() as $package) {
if (($artifact = $package->getArtifact()) !== null && !isset($download_status[$artifact->getName()])) {
// [0: source, 1: binary for current OS]
$download_status[$artifact->getName()] = [
'source-downloaded' => $artifact->isSourceDownloaded(),
'binary-downloaded' => $artifact->isBinaryDownloaded(),
'has-source' => $artifact->hasSource(),
'has-binary' => $artifact->hasPlatformBinary(),
];
$download_status[$artifact->getName()] = [$artifact->isSourceDownloaded(), $artifact->isBinaryDownloaded()];
}
}
return $download_status;
}
/**
* Get all artifacts from resolved and build packages.
*
* @return Artifact[]
*/
public function getArtifacts(): array
{
$artifacts = [];
foreach ($this->getResolvedPackages() as $package) {
// Validate package artifacts
$this->validatePackageArtifact($package);
if (($artifact = $package->getArtifact()) !== null && !in_array($artifact, $artifacts, true)) {
$artifacts[] = $artifact;
}
}
// add target artifacts
foreach ($this->build_packages as $package) {
// Validate package artifacts
$this->validatePackageArtifact($package);
if (($artifact = $package->getArtifact()) !== null && !in_array($artifact, $artifacts, true)) {
$artifacts[] = $artifact;
}
}
return $artifacts;
}
/**
* Extract all artifacts for resolved packages.
*/
public function extractSourceArtifacts(bool $interactive = true): void
{
$packages = array_values($this->packages);
$cache = ApplicationContext::get(ArtifactCache::class);
$extractor = new ArtifactExtractor($cache);
// Collect all unique artifacts
$artifacts = [];
$pkg_artifact_map = [];
foreach ($packages as $package) {
$artifact = $package->getArtifact();
if ($artifact !== null && !isset($artifacts[$artifact->getName()]) && (!$artifact->shouldUseBinary() || $this->isBuildPackage($package))) {
$pkg_artifact_map[$package->getName()] = $artifact->getName();
$artifacts[$artifact->getName()] = $artifact;
}
}
// Sort: php-src should be extracted first (extensions depend on it)
uksort($artifacts, function (string $a, string $b): int {
if ($a === 'php-src') {
return -1;
}
if ($b === 'php-src') {
return 1;
}
return 0;
});
if (count($artifacts) === 0) {
return;
}
// Extract each artifact
if ($interactive) {
InteractiveTerm::notice('Extracting source for ' . count($artifacts) . ' artifacts: ' . implode(',', array_map(fn ($x) => ConsoleColor::yellow($x->getName()), $artifacts)) . ' ...');
InteractiveTerm::indicateProgress('Extracting artifacts');
}
try {
V2CompatLayer::beforeExtsExtractHook();
foreach ($artifacts as $artifact) {
if ($interactive) {
InteractiveTerm::setMessage('Extracting source: ' . ConsoleColor::green($artifact->getName()));
}
if (($pkg = array_search($artifact->getName(), $pkg_artifact_map, true)) !== false) {
V2CompatLayer::beforeLibExtractHook($pkg);
}
$extractor->extract($artifact, true);
if (($pkg = array_search($artifact->getName(), $pkg_artifact_map, true)) !== false) {
V2CompatLayer::afterLibExtractHook($pkg);
}
}
V2CompatLayer::afterExtsExtractHook();
if ($interactive) {
InteractiveTerm::finish('Extracted all sources successfully.');
echo PHP_EOL;
}
} catch (\Throwable $e) {
if ($interactive) {
InteractiveTerm::finish('Artifact extraction failed!', false);
echo PHP_EOL;
}
throw $e;
}
}
public function installBinary(Package $package): int
{
$extractor = new ArtifactExtractor(ApplicationContext::get(ArtifactCache::class));
$artifact = $package->getArtifact();
if ($artifact === null || !$artifact->shouldUseBinary()) {
throw new WrongUsageException("Package '{$package->getName()}' does not have a binary artifact to install.");
}
$status = $extractor->extract($artifact);
if ($status === SPC_STATUS_ALREADY_EXTRACTED) {
return SPC_STATUS_ALREADY_INSTALLED;
}
// perform package after-install actions
$this->performAfterInstallActions($package);
return SPC_STATUS_INSTALLED;
}
public function getPackage(string $package_name): ?Package
{
return $this->packages[$package_name] ?? null;
}
/**
* Validate that a package has required artifacts.
*
* @throws WrongUsageException if target/library package has no source or platform binary
*/
private function validatePackageArtifact(Package $package): void
{
// target and library must have at least source or platform binary
if (in_array($package->getType(), ['library', 'target']) && !$package->getArtifact()?->hasSource() && !$package->getArtifact()?->hasPlatformBinary()) {
throw new WrongUsageException("Validation failed: Target package '{$package->getName()}' has no source or platform binary defined.");
}
}
private function resolvePackages(): void
{
$pkgs = [];
foreach ($this->build_packages as $package) {
// call #[ResolveBuild] annotation methods if defined
if ($package instanceof TargetPackage && is_array($deps = $package->_emitResolveBuild($this))) {
$this->target_additional_dependencies[$package->getName()] = $deps;
}
$pkgs[] = $package->getName();
}
// gather install packages
foreach ($this->install_packages as $package) {
$pkgs[] = $package->getName();
}
// resolve dependencies string
$resolved_packages = DependencyResolver::resolve(
$pkgs,
$this->target_additional_dependencies,
$this->options['with-suggests'] ?? false
);
foreach ($resolved_packages as $pkg_name) {
$this->packages[$pkg_name] = PackageLoader::getPackage($pkg_name);
}
}
private function handlePhpTargetPackage(TargetPackage $package): void
{
// process 'php' target
if ($package->getName() === 'php') {
logger()->warning("Building 'php' target is deprecated, please use specific targets like 'build:php-cli' instead.");
$added = false;
if ($package->getBuildOption('build-all') || $package->getBuildOption('build-cli')) {
$cli = PackageLoader::getPackage('php-cli');
$this->build_packages[$cli->getName()] = $cli;
$added = true;
}
if ($package->getBuildOption('build-all') || $package->getBuildOption('build-fpm')) {
$fpm = PackageLoader::getPackage('php-fpm');
$this->build_packages[$fpm->getName()] = $fpm;
$added = true;
}
if ($package->getBuildOption('build-all') || $package->getBuildOption('build-micro')) {
$micro = PackageLoader::getPackage('php-micro');
$this->build_packages[$micro->getName()] = $micro;
$added = true;
}
if ($package->getBuildOption('build-all') || $package->getBuildOption('build-cgi')) {
$cgi = PackageLoader::getPackage('php-cgi');
$this->build_packages[$cgi->getName()] = $cgi;
$added = true;
}
if ($package->getBuildOption('build-all') || $package->getBuildOption('build-embed')) {
$embed = PackageLoader::getPackage('php-embed');
$this->build_packages[$embed->getName()] = $embed;
$added = true;
}
if ($package->getBuildOption('build-all') || $package->getBuildOption('build-frankenphp')) {
$frankenphp = PackageLoader::getPackage('frankenphp');
$this->build_packages[$frankenphp->getName()] = $frankenphp;
$added = true;
}
$this->build_packages[$package->getName()] = $package;
if (!$added) {
throw new WrongUsageException(
"No SAPI target specified to build. Please use '--build-cli', '--build-fpm', '--build-micro', " .
"'--build-cgi', '--build-embed', '--build-frankenphp' or '--build-all' options."
);
}
} else {
// process specific php sapi targets
$this->build_packages['php'] = PackageLoader::getPackage('php');
$this->build_packages[$package->getName()] = $package;
}
}
private function printInstallerInfo(): void
{
InteractiveTerm::notice('Installation summary:');
$summary['Packages to be built'] = implode(',', array_map(fn ($x) => $x->getName(), array_values($this->build_packages)));
$summary['Packages to be installed'] = implode(',', array_map(fn ($x) => $x->getName(), array_values($this->packages)));
$summary['Artifacts to be downloaded'] = implode(',', array_map(fn ($x) => $x->getName(), $this->getArtifacts()));
$this->printArrayInfo(array_filter($summary));
echo PHP_EOL;
foreach ($this->build_packages as $package) {
$info = $package->getPackageInfo();
if ($info === []) {
continue;
}
InteractiveTerm::notice("{$package->getName()} build options:");
// calculate space count for every line
$this->printArrayInfo($info);
echo PHP_EOL;
}
}
private function printArrayInfo(array $info): void
{
$maxlen = 0;
foreach ($info as $k => $v) {
$maxlen = max(strlen($k), $maxlen);
}
foreach ($info as $k => $v) {
if (is_string($v)) {
InteractiveTerm::plain(" {$k}: " . str_pad('', $maxlen - strlen($k)) . ConsoleColor::yellow($v));
} elseif (is_array($v) && !is_assoc_array($v)) {
$first = array_shift($v);
InteractiveTerm::plain(" {$k}: " . str_pad('', $maxlen - strlen($k)) . ConsoleColor::yellow($first));
foreach ($v as $vs) {
InteractiveTerm::plain(str_pad('', $maxlen + 4) . ConsoleColor::yellow($vs));
}
}
}
}
private function performAfterInstallActions(Package $package): void
{
// ----------- perform post-install actions from extracted .package.{pkg_name}.postinstall.json -----------
$root_dir = ($package->getArtifact()?->getBinaryDir() ?? '') !== '' ? $package->getArtifact()?->getBinaryDir() : null;
if ($root_dir !== null) {
$action_json = "{$root_dir}/.package.{$package->getName()}.postinstall.json";
if (is_file($action_json)) {
$action_json = json_decode(file_get_contents($action_json), true);
if (!is_array($action_json)) {
throw new WrongUsageException("Invalid post-install action JSON format for package '{$package->getName()}'.");
}
$placeholders = get_pack_replace();
foreach ($action_json as $action) {
$action_name = $action['action'] ?? '';
switch ($action_name) {
// replace-path: => files: [relative_path1, relative_path2]
case 'replace-path':
$files = $action['files'] ?? [];
foreach ($files as $file) {
$filepath = $root_dir . "/{$file}";
FileSystem::replaceFileStr($filepath, array_values($placeholders), array_keys($placeholders));
}
break;
// replace-to-env: => file: "relative_path", search: "SEARCH_STR", replace-env: "ENV_VAR_NAME"
case 'replace-to-env':
$file = $action['file'] ?? '';
$search = $action['search'] ?? '';
$env_var = $action['replace-env'] ?? '';
$replace = getenv($env_var) ?: '';
$filepath = $root_dir . "/{$file}";
FileSystem::replaceFileStr($filepath, $search, $replace);
break;
default:
throw new WrongUsageException("Unknown post-install action '{$action_name}' for package '{$package->getName()}'.");
}
}
// remove the action file after processing
unlink($root_dir . "/.package.{$package->getName()}.postinstall.json");
}
}
}
}

View File

@@ -0,0 +1,280 @@
<?php
declare(strict_types=1);
namespace StaticPHP\Package;
use StaticPHP\Attribute\Package\AfterStage;
use StaticPHP\Attribute\Package\BeforeStage;
use StaticPHP\Attribute\Package\BuildFor;
use StaticPHP\Attribute\Package\CustomPhpConfigureArg;
use StaticPHP\Attribute\Package\Extension;
use StaticPHP\Attribute\Package\Info;
use StaticPHP\Attribute\Package\InitPackage;
use StaticPHP\Attribute\Package\Library;
use StaticPHP\Attribute\Package\PatchBeforeBuild;
use StaticPHP\Attribute\Package\ResolveBuild;
use StaticPHP\Attribute\Package\Stage;
use StaticPHP\Attribute\Package\Target;
use StaticPHP\Attribute\Package\Validate;
use StaticPHP\Config\PackageConfig;
use StaticPHP\DI\ApplicationContext;
use StaticPHP\Exception\ValidationException;
use StaticPHP\Exception\WrongUsageException;
use StaticPHP\Util\FileSystem;
class PackageLoader
{
/** @var array<string, Package> */
private static ?array $packages = null;
private static array $before_stages = [];
private static array $after_stage = [];
private static array $patch_before_builds = [];
/** @var array<string, true> Track loaded classes to prevent duplicates */
private static array $loaded_classes = [];
public static function initPackageInstances(): void
{
if (self::$packages !== null) {
return;
}
// init packages instance from config
foreach (PackageConfig::getAll() as $name => $item) {
$pkg = match ($item['type']) {
'target', 'virtual-target' => new TargetPackage($name, $item['type']),
'library' => new LibraryPackage($name, $item['type']),
'php-extension' => new PhpExtensionPackage($name, $item['type']),
default => null,
};
if ($pkg !== null) {
self::$packages[$name] = $pkg;
} else {
throw new WrongUsageException("Package [{$name}] has unknown type [{$item['type']}]");
}
}
}
/**
* Load package definitions from PSR-4 directory.
*
* @param string $dir Directory path
* @param string $base_namespace Base namespace for dir's PSR-4 mapping
* @param bool $auto_require Whether to auto-require PHP files (for external plugins not in autoload)
*/
public static function loadFromPsr4Dir(string $dir, string $base_namespace, bool $auto_require = false): void
{
self::initPackageInstances();
$classes = FileSystem::getClassesPsr4($dir, $base_namespace, auto_require: $auto_require);
foreach ($classes as $class) {
self::loadFromClass($class);
}
}
public static function hasPackage(string $name): bool
{
return isset(self::$packages[$name]);
}
/**
* Get a Package instance by its name.
*
* @param string $name The name of the package
* @return Package Returns the Package instance if found, otherwise null
*/
public static function getPackage(string $name): Package
{
if (!isset(self::$packages[$name])) {
throw new WrongUsageException("Package [{$name}] not found.");
}
return self::$packages[$name];
}
public static function getTargetPackage(string $name): TargetPackage
{
$pkg = self::getPackage($name);
if ($pkg instanceof TargetPackage) {
return $pkg;
}
throw new WrongUsageException("Package [{$name}] is not a TargetPackage.");
}
public static function getLibraryPackage(string $name): LibraryPackage
{
$pkg = self::getPackage($name);
if ($pkg instanceof LibraryPackage) {
return $pkg;
}
throw new WrongUsageException("Package [{$name}] is not a LibraryPackage.");
}
/**
* Get all loaded Package instances.
*/
public static function getPackages(array|string|null $type_filter = null): iterable
{
foreach (self::$packages as $name => $package) {
if ($type_filter === null) {
yield $name => $package;
} elseif ($package->getType() === $type_filter) {
yield $name => $package;
} elseif (is_array($type_filter) && in_array($package->getType(), $type_filter, true)) {
yield $name => $package;
}
}
}
/**
* Init package instance from defined classes and attributes.
*
* @internal
*/
public static function loadFromClass(mixed $class): void
{
$refClass = new \ReflectionClass($class);
$class_name = $refClass->getName();
// Skip if already loaded to prevent duplicate registrations
if (isset(self::$loaded_classes[$class_name])) {
return;
}
self::$loaded_classes[$class_name] = true;
$instance_class = $refClass->newInstance();
$attributes = $refClass->getAttributes();
foreach ($attributes as $attribute) {
$pkg = null;
$attribute_instance = $attribute->newInstance();
if ($attribute_instance instanceof Target === false &&
$attribute_instance instanceof Library === false &&
$attribute_instance instanceof Extension === false) {
// not a package attribute
continue;
}
$package_type = PackageConfig::get($attribute_instance->name, 'type');
if ($package_type === null) {
throw new WrongUsageException("Package [{$attribute_instance->name}] not defined in config, please check your config files.");
}
$pkg = self::$packages[$attribute_instance->name];
// validate package type matches
$pkg_type_attr = match ($attribute->getName()) {
Target::class => ['target', 'virtual-target'],
Library::class => ['library'],
Extension::class => ['php-extension'],
default => null,
};
if (!in_array($package_type, $pkg_type_attr, true)) {
throw new ValidationException("Package [{$attribute_instance->name}] type mismatch: config type is [{$package_type}], but attribute type is [" . implode('|', $pkg_type_attr) . '].');
}
if ($pkg !== null && !PackageConfig::isPackageExists($pkg->getName())) {
throw new ValidationException("Package [{$pkg->getName()}] config not found for class {$class}");
}
// init method attributes
$methods = $refClass->getMethods(\ReflectionMethod::IS_PUBLIC);
foreach ($methods as $method) {
$method_attributes = $method->getAttributes();
foreach ($method_attributes as $method_attribute) {
$method_instance = $method_attribute->newInstance();
match ($method_attribute->getName()) {
// #[BuildFor(PHP_OS_FAMILY)]
BuildFor::class => self::addBuildFunction($pkg, $method_instance, [$instance_class, $method->getName()]),
// #[CustomPhpConfigureArg(PHP_OS_FAMILY)]
CustomPhpConfigureArg::class => self::bindCustomPhpConfigureArg($pkg, $method_attribute->newInstance(), [$instance_class, $method->getName()]),
// #[Stage('stage_name')]
Stage::class => $pkg->addStage($method_attribute->newInstance()->name, [$instance_class, $method->getName()]),
// #[InitPackage] (run now with package context)
InitPackage::class => ApplicationContext::invoke([$instance_class, $method->getName()], [
Package::class => $pkg,
$pkg::class => $pkg,
]),
// #[InitBuild]
ResolveBuild::class => $pkg instanceof TargetPackage ? $pkg->setResolveBuildCallback([$instance_class, $method->getName()]) : null,
// #[Info]
Info::class => $pkg->setInfoCallback([$instance_class, $method->getName()]),
// #[Validate]
Validate::class => $pkg->setValidateCallback([$instance_class, $method->getName()]),
// #[PatchBeforeBuild]
PatchBeforeBuild::class => $pkg->setPatchBeforeBuildCallback([$instance_class, $method->getName()]),
default => null,
};
}
}
// register package
self::$packages[$pkg->getName()] = $pkg;
}
// parse non-package available attributes
foreach ($refClass->getMethods(\ReflectionMethod::IS_PUBLIC) as $method) {
$method_attributes = $method->getAttributes();
foreach ($method_attributes as $method_attribute) {
$method_instance = $method_attribute->newInstance();
match ($method_attribute->getName()) {
// #[BeforeStage('package_name', 'stage')] and #[AfterStage('package_name', 'stage')]
BeforeStage::class => self::$before_stages[$method_instance->package_name][$method_instance->stage][] = [[$instance_class, $method->getName()], $method_instance->only_when_package_resolved],
AfterStage::class => self::$after_stage[$method_instance->package_name][$method_instance->stage][] = [[$instance_class, $method->getName()], $method_instance->only_when_package_resolved],
// #[PatchBeforeBuild()
default => null,
};
}
}
}
public static function getBeforeStageCallbacks(string $package_name, string $stage): iterable
{
// match condition
$installer = ApplicationContext::get(PackageInstaller::class);
$stages = self::$before_stages[$package_name][$stage] ?? [];
foreach ($stages as [$callback, $only_when_package_resolved]) {
if ($only_when_package_resolved !== null && !$installer->isPackageBeingResolved($only_when_package_resolved)) {
continue;
}
yield $callback;
}
}
public static function getAfterStageCallbacks(string $package_name, string $stage): array
{
// match condition
$installer = ApplicationContext::get(PackageInstaller::class);
$stages = self::$after_stage[$package_name][$stage] ?? [];
$result = [];
foreach ($stages as [$callback, $only_when_package_resolved]) {
if ($only_when_package_resolved !== null && !$installer->isPackageBeingResolved($only_when_package_resolved)) {
continue;
}
$result[] = $callback;
}
return $result;
}
public static function getPatchBeforeBuildCallbacks(string $package_name): array
{
return self::$patch_before_builds[$package_name] ?? [];
}
/**
* Bind a custom PHP configure argument callback to a php-extension package.
*/
private static function bindCustomPhpConfigureArg(Package $pkg, object $attr, callable $fn): void
{
if (!$pkg instanceof PhpExtensionPackage) {
throw new ValidationException("Class [{$pkg->getName()}] must implement PhpExtensionPackage for CustomPhpConfigureArg attribute.");
}
$pkg->addCustomPhpConfigureArgCallback($attr->os, $fn);
}
private static function addBuildFunction(Package $pkg, object $attr, callable $fn): void
{
if (!$pkg instanceof LibraryPackage) {
throw new ValidationException("Class [{$pkg->getName()}] must implement LibraryPackage for BuildFor attribute.");
}
$pkg->addBuildFunction($attr->os, $fn);
}
}

View File

@@ -0,0 +1,110 @@
<?php
declare(strict_types=1);
namespace StaticPHP\Package;
use StaticPHP\Config\PackageConfig;
use StaticPHP\DI\ApplicationContext;
use StaticPHP\Exception\WrongUsageException;
use StaticPHP\Runtime\SystemTarget;
/**
* Represents a PHP extension package.
*/
class PhpExtensionPackage extends Package
{
/**
* @var array <string, callable> Callbacks for custom PHP configure arguments per OS
*/
protected array $custom_php_configure_arg_callbacks = [];
protected bool $build_shared = false;
protected bool $build_static = false;
protected bool $build_with_php = false;
/**
* @param string $name Name of the php extension
* @param string $type Type of the package, defaults to 'php-extension'
*/
public function __construct(string $name, string $type = 'php-extension', protected array $extension_config = [])
{
// Ensure the package name starts with 'ext-'
if (!str_starts_with($name, 'ext-')) {
$name = "ext-{$name}";
}
if ($this->extension_config === []) {
$this->extension_config = PackageConfig::get($name, 'php-extension', []);
}
parent::__construct($name, $type);
}
public function addCustomPhpConfigureArgCallback(string $os, callable $fn): void
{
if ($os === '') {
foreach (['Linux', 'Windows', 'Darwin'] as $supported_os) {
$this->custom_php_configure_arg_callbacks[$supported_os] = $fn;
}
} else {
$this->custom_php_configure_arg_callbacks[$os] = $fn;
}
}
public function getPhpConfigureArg(string $os, bool $shared): string
{
if (isset($this->custom_php_configure_arg_callbacks[$os])) {
$callback = $this->custom_php_configure_arg_callbacks[$os];
return ApplicationContext::invoke($callback, ['shared' => $shared, static::class => $this, Package::class => $this]);
}
$escapedPath = str_replace("'", '', escapeshellarg(BUILD_ROOT_PATH)) !== BUILD_ROOT_PATH || str_contains(BUILD_ROOT_PATH, ' ') ? escapeshellarg(BUILD_ROOT_PATH) : BUILD_ROOT_PATH;
$name = str_replace('_', '-', substr($this->getName(), 4));
$ext_config = PackageConfig::get($name, 'php-extension', []);
$arg_type = match (SystemTarget::getTargetOS()) {
'Windows' => $ext_config['arg-type@windows'] ?? $ext_config['arg-type'] ?? 'enable',
'Darwin' => $ext_config['arg-type@macos'] ?? $ext_config['arg-type@unix'] ?? $ext_config['arg-type'] ?? 'enable',
'Linux' => $ext_config['arg-type@linux'] ?? $ext_config['arg-type@unix'] ?? $ext_config['arg-type'] ?? 'enable',
default => $ext_config['arg-type'] ?? 'enable',
};
return match ($arg_type) {
'enable' => $shared ? "--enable-{$name}=shared" : "--enable-{$name}",
'enable-path' => $shared ? "--enable-{$name}=shared,{$escapedPath}" : "--enable-{$name}={$escapedPath}",
'with' => $shared ? "--with-{$name}=shared" : "--with-{$name}",
'with-path' => $shared ? "--with-{$name}=shared,{$escapedPath}" : "--with-{$name}={$escapedPath}",
default => throw new WrongUsageException("Unknown argument type '{$arg_type}' for PHP extension '{$name}'"),
};
}
public function setBuildShared(bool $build_shared = true): void
{
$this->build_shared = $build_shared;
}
public function setBuildStatic(bool $build_static = true): void
{
$this->build_static = $build_static;
}
public function setBuildWithPhp(bool $build_with_php = true): void
{
$this->build_with_php = $build_with_php;
}
public function isBuildShared(): bool
{
return $this->build_shared;
}
public function isBuildStatic(): bool
{
return $this->build_static;
}
public function isBuildWithPhp(): bool
{
return $this->build_with_php;
}
}

View File

@@ -0,0 +1,142 @@
<?php
declare(strict_types=1);
namespace StaticPHP\Package;
use StaticPHP\DI\ApplicationContext;
use Symfony\Component\Console\Input\InputArgument;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
/**
* Represents a target package in the StaticPHP package management system.
*/
class TargetPackage extends LibraryPackage
{
/**
* @var array<string, InputOption> $build_options Build options for the target package
*/
protected array $build_options = [];
protected array $build_arguments = [];
protected mixed $resolve_build_callback = null;
/**
* Checks if the target is virtual.
*/
public function isVirtualTarget(): bool
{
return $this->type === 'virtual-target';
}
/**
* Adds a build option to the target package.
*
* @param string $name The name of the build option
* @param null|string $shortcut The shortcut for the build option
* @param null|int $mode The mode of the build option
* @param string $description The description of the build option
* @param null|mixed $default The default value of the build option
*/
public function addBuildOption(string $name, ?string $shortcut = null, ?int $mode = InputOption::VALUE_NONE, string $description = '', mixed $default = null): void
{
$this->build_options[$name] = new InputOption($name, $shortcut, $mode, $description, $default);
}
/**
* Adds a build argument to the target package.
*
* @param string $name The name of the build argument
* @param null|int $mode The mode of the build argument
* @param string $description The description of the build argument
* @param null|mixed $default The default value of the build argument
*/
public function addBuildArgument(string $name, ?int $mode = null, string $description = '', mixed $default = null): void
{
$this->build_arguments[$name] = new InputArgument($name, $mode, $description, $default);
}
public function setResolveBuildCallback(callable $callback): static
{
$this->resolve_build_callback = $callback;
return $this;
}
/**
* Get a build option value for the target package.
*
* @param string $key The build option key
* @param null|mixed $default The default value if the option is not set
* @return mixed The value of the build option
*/
public function getBuildOption(string $key, mixed $default = null): mixed
{
$input = ApplicationContext::has(InputInterface::class)
? ApplicationContext::get(InputInterface::class)
: null;
if ($input !== null && $input->hasOption($key)) {
return $input->getOption($key);
}
return $default;
}
/**
* Get a build argument value for the target package.
*
* @param string $key The build argument key
* @return mixed The value of the build argument
*/
public function getBuildArgument(string $key): mixed
{
$input = ApplicationContext::has(InputInterface::class)
? ApplicationContext::get(InputInterface::class)
: null;
if ($input !== null && $input->hasArgument($key)) {
return $input->getArgument($key);
}
return null;
}
/**
* Gets all build options for the target package.
*
* @internal
* @return InputOption[] Get all build options for the target package
*/
public function _exportBuildOptions(): array
{
return $this->build_options;
}
/**
* Gets all build arguments for the target package.
*
* @internal
* @return InputArgument[] Get all build arguments for the target package
*/
public function _exportBuildArguments(): array
{
return $this->build_arguments;
}
/**
* Run the init build callback to prepare its dependencies.
*
* @internal
*/
public function _emitResolveBuild(PackageInstaller $installer): mixed
{
if (!is_callable($this->resolve_build_callback)) {
return null;
}
return ApplicationContext::invoke($this->resolve_build_callback, [
TargetPackage::class => $this,
PackageInstaller::class => $installer,
]);
}
}