This commit is contained in:
crazywhalecc 2025-11-30 15:35:04 +08:00
parent f6c818d3c0
commit 14bfb4198a
No known key found for this signature in database
GPG Key ID: 1F4BDD59391F2680
179 changed files with 19502 additions and 655 deletions

View File

@ -2,7 +2,7 @@ name: Tests
on:
pull_request:
branches: [ "main" ]
branches: [ "main", "v3" ]
types: [ opened, synchronize, reopened ]
paths:
- 'src/**'

8
.gitignore vendored
View File

@ -1,8 +1,8 @@
.idea
runtime/
docker/libraries/
docker/extensions/
docker/source/
/runtime/
/docker/libraries/
/docker/extensions/
/docker/source/
# Vendor files
/vendor/**

23
bin/spc
View File

@ -1,13 +1,9 @@
#!/usr/bin/env php
<?php
use SPC\ConsoleApplication;
use SPC\exception\ExceptionHandler;
// Load custom php if exists
if (PHP_OS_FAMILY !== 'Windows' && PHP_BINARY !== (__DIR__ . '/php') && file_exists(__DIR__ . '/php') && is_executable(__DIR__ . '/php')) {
pcntl_exec(__DIR__ . '/php', $argv);
}
use StaticPHP\ConsoleApplication;
use StaticPHP\Exception\ExceptionHandler;
use StaticPHP\Exception\SPCException;
if (file_exists(dirname(__DIR__) . '/vendor/autoload.php')) {
// Current: ./bin (git/project mode)
@ -17,11 +13,6 @@ if (file_exists(dirname(__DIR__) . '/vendor/autoload.php')) {
require_once dirname(__DIR__, 3) . '/autoload.php';
}
// 防止 Micro 打包状态下不支持中文的显示(虽然这个项目目前好像没输出过中文?)
if (PHP_OS_FAMILY === 'Windows' && Phar::running()) {
exec('CHCP 65001');
}
// Print deprecation notice on PHP < 8.4, use red and highlight background
if (PHP_VERSION_ID < 80400) {
echo "\e[43mDeprecation Notice: PHP < 8.4 is deprecated, please upgrade your PHP version.\e[0m\n";
@ -29,7 +20,11 @@ if (PHP_VERSION_ID < 80400) {
try {
(new ConsoleApplication())->run();
} catch (Exception $e) {
ExceptionHandler::getInstance()->handle($e);
} catch (SPCException $e) {
ExceptionHandler::handleSPCException($e);
exit(1);
} catch (\Throwable $e) {
ExceptionHandler::handleDefaultException($e);
exit(1);
}

4
bin/spc-debug Executable file
View File

@ -0,0 +1,4 @@
#!/usr/bin/env bash
# This script runs the 'spc' command with Xdebug enabled for debugging purposes.
php -d xdebug.mode=debug -d xdebug.client_host=127.0.0.1 -d xdebug.client_port=9003 -d xdebug.start_with_request=yes "$(dirname "$0")/../bin/spc" "$@"

View File

@ -9,14 +9,15 @@
}
],
"require": {
"php": ">= 8.3",
"php": ">=8.4",
"ext-mbstring": "*",
"ext-zlib": "*",
"laravel/prompts": "^0.1.12",
"laravel/prompts": "~0.1",
"php-di/php-di": "^7.1",
"symfony/console": "^5.4 || ^6 || ^7",
"symfony/process": "^7.2",
"symfony/yaml": "^7.2",
"zhamao/logger": "^1.1.3"
"zhamao/logger": "^1.1.4"
},
"require-dev": {
"captainhook/captainhook-phar": "^5.23",
@ -28,7 +29,9 @@
},
"autoload": {
"psr-4": {
"SPC\\": "src/SPC"
"SPC\\": "src/SPC",
"StaticPHP\\": "src/StaticPHP",
"Package\\": "src/Package"
},
"files": [
"src/globals/defines.php",

836
composer.lock generated

File diff suppressed because it is too large Load Diff

1046
config/artifact.json Normal file

File diff suppressed because it is too large Load Diff

View File

@ -32,9 +32,10 @@
; GNU_ARCH: the GNU arch of the current system. (default: `$(uname -m)`, e.g. `x86_64`, `aarch64`)
; MAC_ARCH: the MAC arch of the current system. (default: `$(uname -m)`, e.g. `x86_64`, `arm64`)
; PKG_CONFIG: (*nix only) static-php-cli will set `$BUILD_BIN_PATH/pkg-config` to PKG_CONFIG.
; SPC_LINUX_DEFAULT_CC: (linux only) the default compiler for linux. (For alpine linux: `gcc`, default: `$GNU_ARCH-linux-musl-gcc`)
; SPC_LINUX_DEFAULT_CXX: (linux only) the default c++ compiler for linux. (For alpine linux: `g++`, default: `$GNU_ARCH-linux-musl-g++`)
; SPC_LINUX_DEFAULT_AR: (linux only) the default archiver for linux. (For alpine linux: `ar`, default: `$GNU_ARCH-linux-musl-ar`)
; SPC_DEFAULT_CC: (*nix only) the default compiler for selected toolchain.
; SPC_DEFAULT_CXX: (*nix only) the default c++ compiler selected toolchain.
; SPC_DEFAULT_AR: (*nix only) the default archiver for selected toolchain.
; SPC_DEFAULT_LD: (*nix only) the default linker for selected toolchain.
; SPC_EXTRA_PHP_VARS: (linux only) the extra vars for building php, used in `configure` and `make` command.
[global]
@ -48,6 +49,12 @@ SPC_SKIP_DOCTOR_CHECK_ITEMS=""
SPC_CMD_VAR_FRANKENPHP_XCADDY_MODULES="--with github.com/dunglas/mercure/caddy --with github.com/dunglas/vulcain/caddy --with github.com/dunglas/caddy-cbrotli"
; The display message for php version output (PHP >= 8.4 available)
PHP_BUILD_PROVIDER="static-php-cli ${SPC_VERSION}"
; Whether to enable log file (if you are using vendor mode)
SPC_ENABLE_LOG_FILE="yes"
; The LOG DIR for spc logs
SPC_LOGS_DIR="${WORKING_DIR}/log"
; Preserve old logs when running new builds
SPC_PRESERVE_LOGS="no"
; EXTENSION_DIR where the built php will look for extension when a .ini instructs to load them
; only useful for builds targeting not pure-static linking
@ -120,11 +127,12 @@ SPC_CMD_VAR_PHP_MAKE_EXTRA_LDFLAGS=""
; Currently we do not support universal and cross-compilation for macOS.
SPC_TARGET=native-macos
; compiler environments
CC=clang
CXX=clang++
AR=ar
LD=ld
CC=${SPC_LINUX_DEFAULT_CC}
CXX=${SPC_LINUX_DEFAULT_CXX}
AR=${SPC_LINUX_DEFAULT_AR}
LD=${SPC_LINUX_DEFAULT_LD}
; default compiler flags, used in CMake toolchain file, openssl and pkg-config build
; this will be added to all CFLAGS and CXXFLAGS for the library builds
SPC_DEFAULT_C_FLAGS="--target=${MAC_ARCH}-apple-darwin -Os"
SPC_DEFAULT_CXX_FLAGS="--target=${MAC_ARCH}-apple-darwin -Os"
SPC_DEFAULT_LD_FLAGS=""
@ -142,8 +150,3 @@ SPC_CMD_PREFIX_PHP_CONFIGURE="./configure --prefix= --with-valgrind=no --enable-
SPC_CMD_VAR_PHP_EMBED_TYPE="static"
; EXTRA_CFLAGS for `configure` and `make` php
SPC_CMD_VAR_PHP_MAKE_EXTRA_CFLAGS="-g -fstack-protector-strong -fpic -fpie -Werror=unknown-warning-option ${SPC_DEFAULT_C_FLAGS}"
[freebsd]
; compiler environments
CC=clang
CXX=clang++

1541
config/pkg.ext.json Normal file

File diff suppressed because it is too large Load Diff

View File

@ -1,105 +0,0 @@
{
"go-xcaddy-aarch64-linux": {
"type": "custom"
},
"go-xcaddy-aarch64-macos": {
"type": "custom"
},
"go-xcaddy-x86_64-linux": {
"type": "custom"
},
"go-xcaddy-x86_64-macos": {
"type": "custom"
},
"musl-toolchain-aarch64-linux": {
"type": "url",
"url": "https://dl.static-php.dev/static-php-cli/deps/musl-toolchain/aarch64-musl-toolchain.tgz"
},
"musl-toolchain-x86_64-linux": {
"type": "url",
"url": "https://dl.static-php.dev/static-php-cli/deps/musl-toolchain/x86_64-musl-toolchain.tgz"
},
"nasm-x86_64-win": {
"type": "url",
"url": "https://dl.static-php.dev/static-php-cli/deps/nasm/nasm-2.16.01-win64.zip",
"extract-files": {
"nasm.exe": "{php_sdk_path}/bin/nasm.exe",
"ndisasm.exe": "{php_sdk_path}/bin/ndisasm.exe"
}
},
"pkg-config-aarch64-linux": {
"type": "ghrel",
"repo": "static-php/static-php-cli-hosted",
"match": "pkg-config-aarch64-linux-musl-1.2.5.txz",
"extract-files": {
"bin/pkg-config": "{pkg_root_path}/bin/pkg-config"
}
},
"pkg-config-aarch64-macos": {
"type": "ghrel",
"repo": "static-php/static-php-cli-hosted",
"match": "pkg-config-aarch64-darwin.txz",
"extract-files": {
"bin/pkg-config": "{pkg_root_path}/bin/pkg-config"
}
},
"pkg-config-x86_64-linux": {
"type": "ghrel",
"repo": "static-php/static-php-cli-hosted",
"match": "pkg-config-x86_64-linux-musl-1.2.5.txz",
"extract-files": {
"bin/pkg-config": "{pkg_root_path}/bin/pkg-config"
}
},
"pkg-config-x86_64-macos": {
"type": "ghrel",
"repo": "static-php/static-php-cli-hosted",
"match": "pkg-config-x86_64-darwin.txz",
"extract-files": {
"bin/pkg-config": "{pkg_root_path}/bin/pkg-config"
}
},
"strawberry-perl-x86_64-win": {
"type": "url",
"url": "https://github.com/StrawberryPerl/Perl-Dist-Strawberry/releases/download/SP_5380_5361/strawberry-perl-5.38.0.1-64bit-portable.zip"
},
"upx-aarch64-linux": {
"type": "ghrel",
"repo": "upx/upx",
"match": "upx.+-arm64_linux\\.tar\\.xz",
"extract-files": {
"upx": "{pkg_root_path}/bin/upx"
}
},
"upx-x86_64-linux": {
"type": "ghrel",
"repo": "upx/upx",
"match": "upx.+-amd64_linux\\.tar\\.xz",
"extract-files": {
"upx": "{pkg_root_path}/bin/upx"
}
},
"upx-x86_64-win": {
"type": "ghrel",
"repo": "upx/upx",
"match": "upx.+-win64\\.zip",
"extract-files": {
"upx.exe": "{pkg_root_path}/bin/upx.exe"
}
},
"zig-aarch64-linux": {
"type": "custom"
},
"zig-aarch64-macos": {
"type": "custom"
},
"zig-x86_64-linux": {
"type": "custom"
},
"zig-x86_64-macos": {
"type": "custom"
},
"zig-x86_64-win": {
"type": "custom"
}
}

989
config/pkg.lib.json Normal file
View File

@ -0,0 +1,989 @@
{
"attr": {
"type": "library",
"artifact": "attr",
"license": {
"type": "file",
"path": "doc/COPYING.LGPL"
}
},
"brotli": {
"type": "library",
"headers": [
"brotli"
],
"pkg-configs": [
"libbrotlicommon",
"libbrotlidec",
"libbrotlienc"
],
"artifact": "brotli",
"license": {
"type": "file",
"path": "LICENSE"
}
},
"bzip2": {
"type": "library",
"headers": [
"bzlib.h"
],
"artifact": "bzip2",
"license": {
"type": "text",
"text": "This program, \"bzip2\", the associated library \"libbzip2\", and all documentation, are copyright (C) 1996-2010 Julian R Seward. All rights reserved. \n\nRedistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met:\n\n 1. Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer.\n 2. The origin of this software must not be misrepresented; you must not claim that you wrote the original software. If you use this software in a product, an acknowledgment in the product documentation would be appreciated but is not required.\n 3. Altered source versions must be plainly marked as such, and must not be misrepresented as being the original software.\n 4. The name of the author may not be used to endorse or promote products derived from this software without specific prior written permission.\n\nTHIS SOFTWARE IS PROVIDED BY THE AUTHOR ``AS IS'' AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.\n\nJulian Seward, jseward@bzip.org bzip2/libbzip2 version 1.0.6 of 6 September 2010\n\nPATENTS: To the best of my knowledge, bzip2 and libbzip2 do not use any patented algorithms. However, I do not have the resources to carry out a patent search. Therefore I cannot give any guarantee of the above statement."
}
},
"curl": {
"type": "library",
"depends@windows": [
"zlib",
"libssh2",
"nghttp2"
],
"depends": [
"openssl",
"zlib"
],
"suggests@windows": [
"brotli",
"zstd"
],
"suggests": [
"libssh2",
"brotli",
"nghttp2",
"nghttp3",
"ngtcp2",
"zstd",
"libcares",
"ldap"
],
"headers": [
"curl"
],
"frameworks": [
"CoreFoundation",
"CoreServices",
"SystemConfiguration"
],
"artifact": "curl",
"license": {
"type": "file",
"path": "COPYING"
}
},
"fastlz": {
"type": "library",
"headers": [
"fastlz/fastlz.h"
],
"artifact": "fastlz",
"license": {
"type": "file",
"path": "LICENSE.MIT"
}
},
"freetype": {
"type": "library",
"depends": [
"zlib"
],
"suggests": [
"libpng",
"bzip2",
"brotli"
],
"headers": [
"freetype2/freetype/freetype.h",
"freetype2/ft2build.h"
],
"artifact": "freetype",
"license": {
"type": "file",
"path": "LICENSE.TXT"
}
},
"gettext": {
"type": "library",
"depends": [
"libiconv"
],
"suggests": [
"ncurses",
"libxml2"
],
"frameworks": [
"CoreFoundation"
],
"artifact": "gettext",
"license": {
"type": "file",
"path": "gettext-runtime/intl/COPYING.LIB"
}
},
"glfw": {
"type": "library",
"frameworks": [
"CoreVideo",
"OpenGL",
"Cocoa",
"IOKit"
],
"artifact": "ext-glfw",
"license": {
"type": "file",
"path": "LICENSE"
}
},
"gmp": {
"type": "library",
"headers": [
"gmp.h"
],
"artifact": "gmp",
"license": {
"type": "text",
"text": "Since version 6, GMP is distributed under the dual licenses, GNU LGPL v3 and GNU GPL v2. These licenses make the library free to use, share, and improve, and allow you to pass on the result. The GNU licenses give freedoms, but also set firm restrictions on the use with non-free programs."
}
},
"gmssl": {
"type": "library",
"frameworks": [
"Security"
],
"artifact": "gmssl",
"license": {
"type": "file",
"path": "LICENSE"
}
},
"grpc": {
"type": "library",
"depends": [
"zlib",
"openssl",
"libcares"
],
"pkg-configs": [
"grpc"
],
"frameworks": [
"CoreFoundation"
],
"artifact": "grpc",
"license": {
"type": "file",
"path": "LICENSE"
}
},
"icu": {
"type": "library",
"pkg-configs": [
"icu-uc",
"icu-i18n",
"icu-io"
],
"artifact": "icu",
"license": {
"type": "file",
"path": "LICENSE"
}
},
"icu-static-win": {
"type": "library",
"headers@windows": [
"unicode"
],
"artifact": "icu-static-win",
"license": {
"type": "text",
"text": "none"
}
},
"imagemagick": {
"type": "library",
"depends": [
"zlib",
"libjpeg",
"libjxl",
"libpng",
"libwebp",
"freetype",
"libtiff",
"libheif",
"bzip2"
],
"suggests": [
"zstd",
"xz",
"libzip",
"libxml2"
],
"pkg-configs": [
"Magick++-7.Q16HDRI",
"MagickCore-7.Q16HDRI",
"MagickWand-7.Q16HDRI"
],
"artifact": "imagemagick",
"license": {
"type": "file",
"path": "LICENSE"
}
},
"imap": {
"type": "library",
"suggests": [
"openssl"
],
"artifact": "imap",
"license": {
"type": "file",
"path": "LICENSE"
}
},
"jbig": {
"type": "library",
"headers": [
"jbig.h",
"jbig85.h",
"jbig_ar.h"
],
"artifact": "jbig",
"license": {
"type": "file",
"path": "COPYING"
}
},
"ldap": {
"type": "library",
"depends": [
"openssl",
"zlib",
"gmp",
"libsodium"
],
"pkg-configs": [
"ldap",
"lber"
],
"artifact": "ldap",
"license": {
"type": "file",
"path": "LICENSE"
}
},
"lerc": {
"type": "library",
"artifact": "lerc",
"license": {
"type": "file",
"path": "LICENSE"
}
},
"libacl": {
"type": "library",
"depends": [
"attr"
],
"artifact": "libacl",
"license": {
"type": "file",
"path": "doc/COPYING.LGPL"
}
},
"libaom": {
"type": "library",
"artifact": "libaom",
"license": {
"type": "file",
"path": "LICENSE"
}
},
"libargon2": {
"type": "library",
"artifact": "libargon2",
"license": {
"type": "file",
"path": "LICENSE"
}
},
"libavif": {
"type": "library",
"artifact": "libavif",
"license": {
"type": "file",
"path": "LICENSE"
}
},
"libcares": {
"type": "library",
"headers": [
"ares.h",
"ares_dns.h",
"ares_nameser.h"
],
"artifact": "libcares",
"license": {
"type": "file",
"path": "LICENSE.md"
}
},
"libde265": {
"type": "library",
"artifact": "libde265",
"license": {
"type": "file",
"path": "COPYING"
}
},
"libedit": {
"type": "library",
"depends": [
"ncurses"
],
"artifact": "libedit",
"license": {
"type": "file",
"path": "COPYING"
}
},
"libevent": {
"type": "library",
"depends": [
"openssl"
],
"artifact": "libevent",
"license": {
"type": "file",
"path": "LICENSE"
}
},
"libffi": {
"type": "library",
"headers@windows": [
"ffi.h",
"fficonfig.h",
"ffitarget.h"
],
"headers": [
"ffi.h",
"ffitarget.h"
],
"artifact": "libffi",
"license": {
"type": "file",
"path": "LICENSE"
}
},
"libffi-win": {
"type": "library",
"headers@windows": [
"ffi.h",
"ffitarget.h",
"fficonfig.h"
],
"artifact": "libffi-win",
"license": {
"type": "file",
"path": "LICENSE"
}
},
"libheif": {
"type": "library",
"depends": [
"libde265",
"libwebp",
"libaom",
"zlib",
"brotli"
],
"artifact": "libheif",
"license": {
"type": "file",
"path": "COPYING"
}
},
"libiconv": {
"type": "library",
"headers": [
"iconv.h",
"libcharset.h",
"localcharset.h"
],
"artifact": "libiconv",
"license": {
"type": "file",
"path": "COPYING.LIB"
}
},
"libiconv-win": {
"type": "library",
"artifact": "libiconv-win",
"license": {
"type": "file",
"path": "source/COPYING"
}
},
"libjpeg": {
"type": "library",
"suggests@windows": [
"zlib"
],
"artifact": "libjpeg",
"license": {
"type": "file",
"path": "LICENSE.md"
}
},
"libjxl": {
"type": "library",
"depends": [
"brotli",
"libjpeg",
"libpng",
"libwebp"
],
"pkg-configs": [
"libjxl",
"libjxl_cms",
"libjxl_threads",
"libhwy"
],
"artifact": "libjxl",
"license": {
"type": "file",
"path": "LICENSE"
}
},
"liblz4": {
"type": "library",
"artifact": "liblz4",
"license": {
"type": "file",
"path": "LICENSE"
}
},
"libmemcached": {
"type": "library",
"artifact": "libmemcached",
"license": {
"type": "file",
"path": "LICENSE"
}
},
"libpng": {
"type": "library",
"depends": [
"zlib"
],
"headers@windows": [
"png.h",
"pngconf.h"
],
"headers": [
"png.h",
"pngconf.h",
"pnglibconf.h"
],
"artifact": "libpng",
"license": {
"type": "file",
"path": "LICENSE"
}
},
"librabbitmq": {
"type": "library",
"depends": [
"openssl"
],
"artifact": "librabbitmq",
"license": {
"type": "file",
"path": "LICENSE"
}
},
"librdkafka": {
"type": "library",
"suggests": [
"curl",
"liblz4",
"openssl",
"zlib",
"zstd"
],
"pkg-configs": [
"rdkafka++-static",
"rdkafka-static"
],
"artifact": "librdkafka",
"license": {
"type": "file",
"path": "LICENSE"
}
},
"libsodium": {
"type": "library",
"artifact": "libsodium",
"license": {
"type": "file",
"path": "LICENSE"
}
},
"libssh2": {
"type": "library",
"depends": [
"openssl"
],
"headers": [
"libssh2.h",
"libssh2_publickey.h",
"libssh2_sftp.h"
],
"artifact": "libssh2",
"license": {
"type": "file",
"path": "COPYING"
}
},
"libtiff": {
"type": "library",
"depends": [
"zlib",
"libjpeg"
],
"suggests": [
"lerc",
"libwebp",
"jbig",
"xz",
"zstd"
],
"artifact": "libtiff",
"license": {
"type": "file",
"path": "LICENSE.md"
}
},
"liburing": {
"type": "library",
"headers@linux": [
"liburing/",
"liburing.h"
],
"pkg-configs": [
"liburing",
"liburing-ffi"
],
"artifact": "liburing",
"license": {
"type": "file",
"path": "COPYING"
}
},
"libuuid": {
"type": "library",
"headers": [
"uuid/uuid.h"
],
"artifact": "libuuid",
"license": {
"type": "file",
"path": "COPYING"
}
},
"libuv": {
"type": "library",
"artifact": "libuv",
"license": [
{
"type": "file",
"path": "LICENSE"
},
{
"type": "file",
"path": "LICENSE-extra"
}
]
},
"libwebp": {
"type": "library",
"pkg-configs": [
"libwebp",
"libwebpdecoder",
"libwebpdemux",
"libwebpmux",
"libsharpyuv"
],
"artifact": "libwebp",
"license": {
"type": "file",
"path": "COPYING"
}
},
"libxml2": {
"type": "library",
"depends@windows": [
"libiconv-win"
],
"depends": [
"libiconv"
],
"suggests@windows": [
"zlib"
],
"suggests": [
"xz",
"zlib"
],
"headers": [
"libxml2"
],
"pkg-configs": [
"libxml-2.0"
],
"artifact": "libxml2",
"license": {
"type": "file",
"path": "Copyright"
}
},
"libxslt": {
"type": "library",
"depends": [
"libxml2"
],
"artifact": "libxslt",
"license": {
"type": "file",
"path": "Copyright"
}
},
"libyaml": {
"type": "library",
"headers": [
"yaml.h"
],
"artifact": "libyaml",
"license": {
"type": "file",
"path": "License"
}
},
"libzip": {
"type": "library",
"depends@windows": [
"zlib",
"bzip2",
"xz"
],
"depends": [
"zlib"
],
"suggests@windows": [
"zstd",
"openssl"
],
"suggests": [
"bzip2",
"xz",
"zstd",
"openssl"
],
"headers": [
"zip.h",
"zipconf.h"
],
"artifact": "libzip",
"license": {
"type": "file",
"path": "LICENSE"
}
},
"mimalloc": {
"type": "library",
"artifact": "mimalloc",
"license": {
"type": "file",
"path": "LICENSE"
}
},
"ncurses": {
"type": "library",
"artifact": "ncurses",
"license": {
"type": "file",
"path": "COPYING"
}
},
"net-snmp": {
"type": "library",
"depends": [
"openssl",
"zlib"
],
"pkg-configs": [
"netsnmp",
"netsnmp-agent"
],
"artifact": "net-snmp",
"license": {
"type": "file",
"path": "COPYING"
}
},
"nghttp2": {
"type": "library",
"depends": [
"zlib",
"openssl"
],
"suggests": [
"libxml2",
"nghttp3",
"ngtcp2"
],
"headers": [
"nghttp2"
],
"artifact": "nghttp2",
"license": {
"type": "file",
"path": "COPYING"
}
},
"nghttp3": {
"type": "library",
"depends": [
"openssl"
],
"headers": [
"nghttp3"
],
"artifact": "nghttp3",
"license": {
"type": "file",
"path": "COPYING"
}
},
"ngtcp2": {
"type": "library",
"depends": [
"openssl"
],
"suggests": [
"nghttp3",
"brotli"
],
"headers": [
"ngtcp2"
],
"artifact": "ngtcp2",
"license": {
"type": "file",
"path": "COPYING"
}
},
"onig": {
"type": "library",
"headers": [
"oniggnu.h",
"oniguruma.h"
],
"artifact": "onig",
"license": {
"type": "file",
"path": "COPYING"
}
},
"openssl": {
"type": "library",
"depends": [
"zlib"
],
"headers": [
"openssl"
],
"artifact": "openssl",
"license": {
"type": "file",
"path": "LICENSE.txt"
}
},
"postgresql": {
"type": "library",
"depends": [
"libiconv",
"libxml2",
"openssl",
"zlib",
"libedit"
],
"suggests": [
"icu",
"libxslt",
"ldap",
"zstd"
],
"pkg-configs": [
"libpq"
],
"artifact": "postgresql",
"license": {
"type": "file",
"path": "COPYRIGHT"
}
},
"postgresql-win": {
"type": "library",
"artifact": "postgresql-win",
"license": {
"type": "text",
"text": "PostgreSQL Database Management System\n(also known as Postgres, formerly as Postgres95)\n\nPortions Copyright (c) 1996-2025, The PostgreSQL Global Development Group\n\nPortions Copyright (c) 1994, The Regents of the University of California\n\nPermission to use, copy, modify, and distribute this software and its\ndocumentation for any purpose, without fee, and without a written\nagreement is hereby granted, provided that the above copyright notice\nand this paragraph and the following two paragraphs appear in all\ncopies.\n\nIN NO EVENT SHALL THE UNIVERSITY OF CALIFORNIA BE LIABLE TO ANY PARTY\nFOR DIRECT, INDIRECT, SPECIAL, INCIDENTAL, OR CONSEQUENTIAL DAMAGES,\nINCLUDING LOST PROFITS, ARISING OUT OF THE USE OF THIS SOFTWARE AND ITS\nDOCUMENTATION, EVEN IF THE UNIVERSITY OF CALIFORNIA HAS BEEN ADVISED OF\nTHE POSSIBILITY OF SUCH DAMAGE.\n\nTHE UNIVERSITY OF CALIFORNIA SPECIFICALLY DISCLAIMS ANY WARRANTIES,\nINCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY\nAND FITNESS FOR A PARTICULAR PURPOSE. THE SOFTWARE PROVIDED HEREUNDER IS\nON AN \"AS IS\" BASIS, AND THE UNIVERSITY OF CALIFORNIA HAS NO OBLIGATIONS\nTO PROVIDE MAINTENANCE, SUPPORT, UPDATES, ENHANCEMENTS, OR MODIFICATIONS."
}
},
"pthreads4w": {
"type": "library",
"artifact": "pthreads4w",
"license": {
"type": "file",
"path": "LICENSE"
}
},
"qdbm": {
"type": "library",
"headers@windows": [
"depot.h"
],
"artifact": "qdbm",
"license": {
"type": "file",
"path": "COPYING"
}
},
"re2c": {
"type": "library",
"artifact": "re2c",
"license": {
"type": "file",
"path": "LICENSE"
}
},
"readline": {
"type": "library",
"depends": [
"ncurses"
],
"artifact": "readline",
"license": {
"type": "file",
"path": "COPYING"
}
},
"snappy": {
"type": "library",
"depends": [
"zlib"
],
"headers": [
"snappy.h",
"snappy-c.h",
"snappy-sinksource.h",
"snappy-stubs-public.h"
],
"artifact": "snappy",
"license": {
"type": "file",
"path": "COPYING"
}
},
"sqlite": {
"type": "library",
"headers": [
"sqlite3.h",
"sqlite3ext.h"
],
"artifact": "sqlite",
"license": {
"type": "text",
"text": "The author disclaims copyright to this source code. In place of\na legal notice, here is a blessing:\n\n * May you do good and not evil.\n * May you find forgiveness for yourself and forgive others.\n * May you share freely, never taking more than you give."
}
},
"tidy": {
"type": "library",
"artifact": "tidy",
"license": {
"type": "file",
"path": "README/LICENSE.md"
}
},
"unixodbc": {
"type": "library",
"depends": [
"libiconv"
],
"artifact": "unixodbc",
"license": {
"type": "file",
"path": "COPYING"
}
},
"watcher": {
"type": "library",
"headers": [
"wtr/watcher-c.h"
],
"artifact": "watcher",
"license": {
"type": "file",
"path": "license"
}
},
"xz": {
"type": "library",
"depends": [
"libiconv"
],
"headers@windows": [
"lzma",
"lzma.h"
],
"headers": [
"lzma"
],
"artifact": "xz",
"license": {
"type": "file",
"path": "COPYING"
}
},
"zlib": {
"type": "library",
"headers": [
"zlib.h",
"zconf.h"
],
"artifact": "zlib",
"license": {
"type": "text",
"text": "(C) 1995-2022 Jean-loup Gailly and Mark Adler\n\nThis software is provided 'as-is', without any express or implied\nwarranty. In no event will the authors be held liable for any damages\narising from the use of this software.\n\nPermission is granted to anyone to use this software for any purpose,\nincluding commercial applications, and to alter it and redistribute it\nfreely, subject to the following restrictions:\n\n1. The origin of this software must not be misrepresented; you must not\n claim that you wrote the original software. If you use this software\n in a product, an acknowledgment in the product documentation would be\n appreciated but is not required.\n2. Altered source versions must be plainly marked as such, and must not be\n misrepresented as being the original software.\n3. This notice may not be removed or altered from any source distribution.\n\nJean-loup Gailly Mark Adler\njloup@gzip.org madler@alumni.caltech.edu"
}
},
"zstd": {
"type": "library",
"headers@windows": [
"zstd.h",
"zstd_errors.h"
],
"headers": [
"zdict.h",
"zstd.h",
"zstd_errors.h"
],
"artifact": "zstd",
"license": {
"type": "file",
"path": "LICENSE"
}
}
}

95
config/pkg.target.json Normal file
View File

@ -0,0 +1,95 @@
{
"vswhere": {
"type": "target",
"artifact": "vswhere"
},
"pkg-config": {
"type": "target",
"static-bins": [
"pkg-config"
],
"artifact": "pkg-config"
},
"php": {
"type": "target",
"artifact": "php-src",
"depends@macos": [
"libxml2"
]
},
"php-cli": {
"type": "virtual-target",
"depends": [
"php"
]
},
"php-micro": {
"type": "virtual-target",
"artifact": "micro",
"depends": [
"php"
]
},
"php-cgi": {
"type": "virtual-target",
"depends": [
"php"
]
},
"php-fpm": {
"type": "virtual-target",
"depends": [
"php"
]
},
"php-embed": {
"type": "virtual-target",
"depends": [
"php"
]
},
"frankenphp": {
"type": "virtual-target",
"artifact": "frankenphp",
"depends": [
"php-embed",
"go-xcaddy"
],
"depends@macos": [
"php-embed",
"go-xcaddy",
"libxml2"
]
},
"go-xcaddy": {
"type": "target",
"artifact": "go-xcaddy",
"static-bins": [
"xcaddy"
]
},
"musl-toolchain": {
"type": "target",
"artifact": "musl-toolchain"
},
"strawberry-perl": {
"type": "target",
"artifact": "strawberry-perl"
},
"upx": {
"type": "target",
"artifact": "upx"
},
"zig": {
"type": "target",
"artifact": "zig"
},
"nasm": {
"type": "target",
"artifact": "nasm"
},
"php-sdk-binary-tools": {
"type": "target",
"artifact": "php-sdk-binary-tools"
}
}

View File

@ -17,3 +17,4 @@ parameters:
- ./src/globals/ext-tests/swoole.php
- ./src/globals/ext-tests/swoole.phpt
- ./src/globals/test-extensions.php
- ./src/SPC/

32
spc.registry.json Normal file
View File

@ -0,0 +1,32 @@
{
"name": "internal",
"autoload": "vendor/autoload.php",
"doctor": {
"psr-4": {
"StaticPHP\\Doctor\\Item": "src/StaticPHP/Doctor/Item"
}
},
"package": {
"psr-4": {
"Package": "src/Package"
},
"config": [
"config/pkg.ext.json",
"config/pkg.lib.json",
"config/pkg.target.json"
]
},
"artifact": {
"config": [
"config/artifact.json"
],
"psr-4": {
"Package\\Artifact": "src/Package/Artifact"
}
},
"command": {
"psr-4": {
"Package\\Command": "src/Package/Command"
}
}
}

View File

@ -0,0 +1,82 @@
<?php
declare(strict_types=1);
namespace Package\Artifact;
use SPC\util\GlobalEnvManager;
use StaticPHP\Artifact\ArtifactDownloader;
use StaticPHP\Artifact\Downloader\DownloadResult;
use StaticPHP\Attribute\Artifact\AfterBinaryExtract;
use StaticPHP\Attribute\Artifact\CustomBinary;
use StaticPHP\Exception\ValidationException;
use StaticPHP\Runtime\SystemTarget;
use StaticPHP\Util\System\LinuxUtil;
class go_xcaddy
{
#[CustomBinary('go-xcaddy', [
'linux-x86_64',
'linux-aarch64',
'macos-x86_64',
'macos-aarch64',
])]
public function downBinary(ArtifactDownloader $downloader): DownloadResult
{
$pkgroot = PKG_ROOT_PATH;
$name = SystemTarget::getCurrentPlatformString();
$arch = match (explode('-', $name)[1]) {
'x86_64' => 'amd64',
'aarch64' => 'arm64',
default => throw new ValidationException('Unsupported architecture: ' . $name),
};
$os = match (explode('-', $name)[0]) {
'linux' => 'linux',
'macos' => 'darwin',
default => throw new ValidationException('Unsupported OS: ' . $name),
};
$hash = match ("{$os}-{$arch}") {
'linux-amd64' => '2852af0cb20a13139b3448992e69b868e50ed0f8a1e5940ee1de9e19a123b613',
'linux-arm64' => '05de75d6994a2783699815ee553bd5a9327d8b79991de36e38b66862782f54ae',
'darwin-amd64' => '5bd60e823037062c2307c71e8111809865116714d6f6b410597cf5075dfd80ef',
'darwin-arm64' => '544932844156d8172f7a28f77f2ac9c15a23046698b6243f633b0a0b00c0749c',
};
$go_version = '1.25.0';
$url = "https://go.dev/dl/go{$go_version}.{$os}-{$arch}.tar.gz";
$path = DOWNLOAD_PATH . DIRECTORY_SEPARATOR . "go{$go_version}.{$os}-{$arch}.tar.gz";
default_shell()->executeCurlDownload($url, $path, retries: $downloader->getRetry());
// verify hash
$file_hash = hash_file('sha256', $path);
if ($file_hash !== $hash) {
throw new ValidationException("Hash mismatch for downloaded go-xcaddy binary. Expected {$hash}, got {$file_hash}");
}
return DownloadResult::archive(basename($path), ['url' => $url, 'version' => $go_version], extract: "{$pkgroot}/go-xcaddy", verified: true, version: $go_version);
}
#[AfterBinaryExtract('go-xcaddy', [
'linux-x86_64',
'linux-aarch64',
'macos-x86_64',
'macos-aarch64',
])]
public function afterExtract(string $target_path): void
{
if (file_exists("{$target_path}/bin/go") && file_exists("{$target_path}/bin/xcaddy")) {
return;
}
$sanitizedPath = getenv('PATH');
if (PHP_OS_FAMILY === 'Linux' && !LinuxUtil::isMuslDist()) {
$sanitizedPath = preg_replace('#(:?/?[^:]*musl[^:]*)#', '', $sanitizedPath);
$sanitizedPath = preg_replace('#^:|:$|::#', ':', $sanitizedPath); // clean up colons
}
shell()->appendEnv([
'PATH' => "{$target_path}/bin:{$sanitizedPath}",
'GOROOT' => "{$target_path}",
'GOBIN' => "{$target_path}/bin",
'GOPATH' => "{$target_path}/go",
])->exec('CC=cc go install github.com/caddyserver/xcaddy/cmd/xcaddy@latest');
GlobalEnvManager::addPathIfNotExists("{$target_path}/bin");
}
}

View File

@ -0,0 +1,123 @@
<?php
declare(strict_types=1);
namespace Package\Command;
use StaticPHP\Artifact\ArtifactCache;
use StaticPHP\Artifact\ArtifactDownloader;
use StaticPHP\Artifact\DownloaderOptions;
use StaticPHP\Command\BaseCommand;
use StaticPHP\DI\ApplicationContext;
use StaticPHP\Package\PackageLoader;
use StaticPHP\Util\FileSystem;
use StaticPHP\Util\InteractiveTerm;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Input\InputArgument;
#[AsCommand('switch-php-version', description: 'Switch downloaded PHP version')]
class SwitchPhpVersionCommand extends BaseCommand
{
protected bool $no_motd = true;
public function configure(): void
{
$this->addArgument(
'php-version',
InputArgument::REQUIRED,
'PHP version (e.g., 8.4, 8.3, 8.2, 8.1, 8.0, 7.4, or specific like 8.4.5)',
);
// Downloader options
$this->getDefinition()->addOptions(DownloaderOptions::getConsoleOptions());
// Additional options
$this->addOption('keep-source', null, null, 'Keep extracted source directory (do not remove source/php-src)');
}
public function handle(): int
{
$php_ver = $this->getArgument('php-version');
// Validate version format
if (!$this->isValidPhpVersion($php_ver)) {
$this->output->writeln("<error>Invalid PHP version '{$php_ver}'!</error>");
$this->output->writeln('<comment>Supported formats: 7.4, 8.0, 8.1, 8.2, 8.3, 8.4, or specific version like 8.4.5</comment>');
return static::FAILURE;
}
$cache = ApplicationContext::get(ArtifactCache::class);
// Check if php-src is already locked
$source_info = $cache->getSourceInfo('php-src');
if ($source_info !== null) {
$current_version = $source_info['version'] ?? 'unknown';
$this->output->writeln("<info>Current PHP version: {$current_version}, removing old PHP source cache...");
// Remove cache entry and optionally the downloaded file
$cache->removeSource('php-src', delete_file: true);
}
// Remove extracted source directory if exists and --keep-source not set
$source_dir = SOURCE_PATH . '/php-src';
if (!$this->getOption('keep-source') && is_dir($source_dir)) {
$this->output->writeln('<info>Removing extracted PHP source directory...</info>');
InteractiveTerm::indicateProgress('Removing: ' . $source_dir);
FileSystem::removeDir($source_dir);
InteractiveTerm::finish('Removed: ' . $source_dir);
}
// Set the PHP version for download
// This defines the version that will be used when resolving php-src artifact
define('SPC_BUILD_PHP_VERSION', $php_ver);
// Download new PHP source
$this->output->writeln("<info>Downloading PHP {$php_ver} source...</info>");
$downloaderOptions = DownloaderOptions::extractFromConsoleOptions($this->input->getOptions());
$downloader = new ArtifactDownloader($downloaderOptions);
// Get php-src artifact from php package
$php_package = PackageLoader::getPackage('php');
$artifact = $php_package->getArtifact();
if ($artifact === null) {
$this->output->writeln('<error>Failed to get php-src artifact!</error>');
return static::FAILURE;
}
$downloader->add($artifact);
$downloader->download();
// Get the new version info
$new_source_info = $cache->getSourceInfo('php-src');
$new_version = $new_source_info['version'] ?? $php_ver;
$this->output->writeln('');
$this->output->writeln("<info>Successfully switched to PHP {$new_version}!</info>");
return static::SUCCESS;
}
/**
* Validate PHP version format.
*
* Accepts:
* - Major.Minor format: 7.4, 8.0, 8.1, 8.2, 8.3, 8.4
* - Full version format: 8.4.5, 8.3.12, etc.
*/
private function isValidPhpVersion(string $version): bool
{
// Check major.minor format (e.g., 8.4)
if (in_array($version, ['7.4', '8.0', '8.1', '8.2', '8.3', '8.4'], true)) {
return true;
}
// Check full version format (e.g., 8.4.5)
if (preg_match('/^\d+\.\d+\.\d+$/', $version)) {
return true;
}
return false;
}
}

View File

@ -0,0 +1,27 @@
<?php
declare(strict_types=1);
namespace Package\Library;
use StaticPHP\Attribute\Package\BuildFor;
use StaticPHP\Attribute\Package\Library;
use StaticPHP\Package\LibraryPackage;
use StaticPHP\Runtime\Executor\UnixAutoconfExecutor;
#[Library('libiconv')]
class libiconv
{
#[BuildFor('Darwin')]
public function build(LibraryPackage $package): void
{
UnixAutoconfExecutor::create($package)
->configure(
'--enable-extra-encodings',
'--enable-year2038',
)
->make('install-lib', with_install: false)
->make('install-lib', with_install: false, dir: "{$package->getSourceDir()}/libcharset");
$package->patchLaDependencyPrefix();
}
}

View File

@ -0,0 +1,54 @@
<?php
declare(strict_types=1);
namespace Package\Library;
use StaticPHP\Attribute\Package\BuildFor;
use StaticPHP\Attribute\Package\Library;
use StaticPHP\Package\LibraryPackage;
use StaticPHP\Runtime\Executor\UnixCMakeExecutor;
use StaticPHP\Runtime\SystemTarget;
use StaticPHP\Util\FileSystem;
#[Library('libxml2')]
class libxml2
{
#[BuildFor('Darwin')]
public function build(LibraryPackage $package): void
{
$cmake = UnixCMakeExecutor::create($package)
->optionalPackage(
'zlib',
'-DLIBXML2_WITH_ZLIB=ON ' .
"-DZLIB_LIBRARY={$package->getLibDir()}/libz.a " .
"-DZLIB_INCLUDE_DIR={$package->getIncludeDir()}",
'-DLIBXML2_WITH_ZLIB=OFF',
)
->optionalPackage('xz', ...cmake_boolean_args('LIBXML2_WITH_LZMA'))
->addConfigureArgs(
'-DLIBXML2_WITH_ICONV=ON',
'-DLIBXML2_WITH_ICU=OFF', // optional, but discouraged: https://gitlab.gnome.org/GNOME/libxml2/-/blob/master/README.md
'-DLIBXML2_WITH_PYTHON=OFF',
'-DLIBXML2_WITH_PROGRAMS=OFF',
'-DLIBXML2_WITH_TESTS=OFF',
);
if (SystemTarget::getTargetOS() === 'Linux') {
$cmake->addConfigureArgs('-DIconv_IS_BUILT_IN=OFF');
}
$cmake->build();
FileSystem::replaceFileStr(
BUILD_LIB_PATH . '/pkgconfig/libxml-2.0.pc',
'-lxml2 -liconv',
'-lxml2'
);
FileSystem::replaceFileStr(
BUILD_LIB_PATH . '/pkgconfig/libxml-2.0.pc',
'-lxml2',
'-lxml2 -liconv'
);
}
}

View File

@ -0,0 +1,22 @@
<?php
declare(strict_types=1);
namespace Package\Library;
use StaticPHP\Attribute\Package\BeforeStage;
use StaticPHP\Attribute\Package\Library;
use StaticPHP\Attribute\PatchDescription;
use StaticPHP\Package\TargetPackage;
#[Library('postgresql')]
class postgresql
{
#[BeforeStage('php', 'unix-configure', 'postgresql')]
#[PatchDescription('Patch to avoid explicit_bzero detection issues on some systems')]
public function patchBeforePHPConfigure(TargetPackage $package): void
{
shell()->cd($package->getSourceDir())
->exec('sed -i.backup "s/ac_cv_func_explicit_bzero\" = xyes/ac_cv_func_explicit_bzero\" = x_fake_yes/" ./configure');
}
}

3
src/Package/README.md Normal file
View File

@ -0,0 +1,3 @@
# Package Implementation
This directory contains the implementation of the `Package` module, which provides functionality for managing and manipulating packages within the system.

View File

@ -0,0 +1,24 @@
<?php
declare(strict_types=1);
namespace Package\Target;
use SPC\util\GlobalEnvManager;
use StaticPHP\Attribute\Package\InitPackage;
use StaticPHP\Attribute\Package\Target;
#[Target('go-xcaddy')]
class go_xcaddy
{
#[InitPackage]
public function init(): void
{
if (is_dir(PKG_ROOT_PATH . '/go-xcaddy/bin')) {
GlobalEnvManager::addPathIfNotExists(PKG_ROOT_PATH . '/go-xcaddy/bin');
GlobalEnvManager::putenv('GOROOT=' . PKG_ROOT_PATH . '/go-xcaddy');
GlobalEnvManager::putenv('GOBIN=' . PKG_ROOT_PATH . '/go-xcaddy/bin');
GlobalEnvManager::putenv('GOPATH=' . PKG_ROOT_PATH . '/go-xcaddy/go');
}
}
}

397
src/Package/Target/php.php Normal file
View File

@ -0,0 +1,397 @@
<?php
declare(strict_types=1);
namespace Package\Target;
use StaticPHP\Artifact\ArtifactLoader;
use StaticPHP\Attribute\Package\BeforeStage;
use StaticPHP\Attribute\Package\BuildFor;
use StaticPHP\Attribute\Package\Info;
use StaticPHP\Attribute\Package\InitPackage;
use StaticPHP\Attribute\Package\ResolveBuild;
use StaticPHP\Attribute\Package\Stage;
use StaticPHP\Attribute\Package\Target;
use StaticPHP\Attribute\Package\Validate;
use StaticPHP\Attribute\PatchDescription;
use StaticPHP\DI\ApplicationContext;
use StaticPHP\Exception\SPCException;
use StaticPHP\Exception\WrongUsageException;
use StaticPHP\Package\Package;
use StaticPHP\Package\PackageBuilder;
use StaticPHP\Package\PackageInstaller;
use StaticPHP\Package\PackageLoader;
use StaticPHP\Package\PhpExtensionPackage;
use StaticPHP\Package\TargetPackage;
use StaticPHP\Runtime\SystemTarget;
use StaticPHP\Toolchain\Interface\ToolchainInterface;
use StaticPHP\Toolchain\ToolchainManager;
use StaticPHP\Util\FileSystem;
use StaticPHP\Util\InteractiveTerm;
use StaticPHP\Util\SourcePatcher;
use StaticPHP\Util\SPCConfigUtil;
use StaticPHP\Util\V2CompatLayer;
use Symfony\Component\Console\Input\InputArgument;
use Symfony\Component\Console\Input\InputOption;
use ZM\Logger\ConsoleColor;
#[Target('php')]
#[Target('php-cli')]
#[Target('php-fpm')]
#[Target('php-micro')]
#[Target('php-cgi')]
#[Target('php-embed')]
#[Target('frankenphp')]
class php
{
public static function getPHPVersionID(): int
{
$artifact = ArtifactLoader::getArtifactInstance('php-src');
if (!file_exists("{$artifact->getSourceDir()}/main/php_version.h")) {
throw new WrongUsageException('PHP source files are not available, you need to download them first');
}
$file = file_get_contents("{$artifact->getSourceDir()}/main/php_version.h");
if (preg_match('/PHP_VERSION_ID (\d+)/', $file, $match) !== 0) {
return intval($match[1]);
}
throw new WrongUsageException('PHP version file format is malformed, please remove "./source/php-src" dir and download/extract again');
}
#[InitPackage]
public function init(TargetPackage $package): void
{
// universal build options (may move to base class later)
$package->addBuildOption('with-added-patch', 'P', InputOption::VALUE_IS_ARRAY | InputOption::VALUE_REQUIRED, 'Inject patch script outside');
// basic build argument and options for PHP
$package->addBuildArgument('extensions', InputArgument::REQUIRED, 'Comma-separated list of static extensions to build');
$package->addBuildOption('no-strip', null, null, 'build without strip, keep symbols to debug');
$package->addBuildOption('with-upx-pack', null, null, 'Compress / pack binary using UPX tool (linux/windows only)');
// php configure and extra patch options
$package->addBuildOption('disable-opcache-jit', null, null, 'Disable opcache jit');
$package->addBuildOption('with-config-file-path', null, InputOption::VALUE_REQUIRED, 'Set the path in which to look for php.ini', PHP_OS_FAMILY === 'Windows' ? null : '/usr/local/etc/php');
$package->addBuildOption('with-config-file-scan-dir', null, InputOption::VALUE_REQUIRED, 'Set the directory to scan for .ini files after reading php.ini', PHP_OS_FAMILY === 'Windows' ? null : '/usr/local/etc/php/conf.d');
$package->addBuildOption('with-hardcoded-ini', 'I', InputOption::VALUE_IS_ARRAY | InputOption::VALUE_REQUIRED, 'Patch PHP source code, inject hardcoded INI');
$package->addBuildOption('enable-zts', null, null, 'Enable thread safe support');
// phpmicro build options
if ($package->getName() === 'php' || $package->getName() === 'php-micro') {
$package->addBuildOption('with-micro-fake-cli', null, null, 'Let phpmicro\'s PHP_SAPI use "cli" instead of "micro"');
$package->addBuildOption('without-micro-ext-test', null, null, 'Disable phpmicro with extension test code');
$package->addBuildOption('with-micro-logo', null, InputOption::VALUE_REQUIRED, 'Use custom .ico for micro.sfx (windows only)');
$package->addBuildOption('enable-micro-win32', null, null, 'Enable win32 mode for phpmicro (Windows only)');
}
// frankenphp build options
if ($package->getName() === 'php' || $package->getName() === 'frankenphp') {
$package->addBuildOption('with-frankenphp-app', null, InputOption::VALUE_REQUIRED, 'Path to a folder to be embedded in FrankenPHP');
}
// embed build options
if ($package->getName() === 'php' || $package->getName() === 'php-embed') {
$package->addBuildOption('build-shared', 'D', InputOption::VALUE_REQUIRED, 'Shared extensions to build, comma separated', '');
}
// legacy php target build options
V2CompatLayer::addLegacyBuildOptionsForPhp($package);
if ($package->getName() === 'php') {
$package->addBuildOption('build-micro', null, null, 'Build micro SAPI');
$package->addBuildOption('build-cli', null, null, 'Build cli SAPI');
$package->addBuildOption('build-fpm', null, null, 'Build fpm SAPI (not available on Windows)');
$package->addBuildOption('build-embed', null, null, 'Build embed SAPI (not available on Windows)');
$package->addBuildOption('build-frankenphp', null, null, 'Build FrankenPHP SAPI (not available on Windows)');
$package->addBuildOption('build-cgi', null, null, 'Build cgi SAPI');
$package->addBuildOption('build-all', null, null, 'Build all SAPI');
}
}
#[ResolveBuild]
public function resolveBuild(TargetPackage $package): array
{
// Parse extensions and additional packages for all php-* targets
$static_extensions = parse_extension_list($package->getBuildArgument('extensions'));
$additional_libraries = parse_comma_list($package->getBuildOption('with-libs'));
$additional_packages = parse_comma_list($package->getBuildOption('with-packages'));
$additional_packages = array_merge($additional_libraries, $additional_packages);
$shared_extensions = parse_extension_list($package->getBuildOption('build-shared') ?? []);
$extensions_pkg = array_map(
fn ($x) => "ext-{$x}",
array_values(array_unique([...$static_extensions, ...$shared_extensions]))
);
// get instances
foreach ($extensions_pkg as $extension) {
$extname = substr($extension, 4);
if (!PackageLoader::hasPackage($extension)) {
throw new WrongUsageException("Extension [{$extname}] does not exist. Please check your extension name.");
}
$instance = PackageLoader::getPackage($extension);
if (!$instance instanceof PhpExtensionPackage) {
throw new WrongUsageException("Package [{$extension}] is not a PHP extension package");
}
// set build static/shared
if (in_array($extname, $static_extensions)) {
$instance->setBuildStatic();
}
if (in_array($extname, $shared_extensions)) {
$instance->setBuildShared();
}
}
return [...$extensions_pkg, ...$additional_packages];
}
#[Validate]
public function validate(Package $package): void
{
// frankenphp
if ($package->getName() === 'frankenphp' && $package instanceof TargetPackage) {
if (!$package->getBuildOption('enable-zts')) {
throw new WrongUsageException('FrankenPHP SAPI requires ZTS enabled PHP, build with `--enable-zts`!');
}
// frankenphp doesn't support windows, BSD is currently not supported by static-php-cli
if (!in_array(PHP_OS_FAMILY, ['Linux', 'Darwin'])) {
throw new WrongUsageException('FrankenPHP SAPI is only available on Linux and macOS!');
}
}
// linux does not support loading shared libraries when target is pure static
$embed_type = getenv('SPC_CMD_VAR_PHP_EMBED_TYPE') ?: 'static';
if (SystemTarget::getTargetOS() === 'Linux' && ApplicationContext::get(ToolchainInterface::class)->isStatic() && $embed_type === 'shared') {
throw new WrongUsageException(
'Linux does not support loading shared libraries when linking libc statically. ' .
'Change SPC_CMD_VAR_PHP_EMBED_TYPE to static.'
);
}
}
#[Info]
public function info(Package $package, PackageInstaller $installer): array
{
/** @var TargetPackage $package */
if ($package->getName() !== 'php') {
return [];
}
$sapis = array_filter([
$installer->getBuildPackage('php-cli') ? 'cli' : null,
$installer->getBuildPackage('php-fpm') ? 'fpm' : null,
$installer->getBuildPackage('php-micro') ? 'micro' : null,
$installer->getBuildPackage('php-cgi') ? 'cgi' : null,
$installer->getBuildPackage('php-embed') ? 'embed' : null,
$installer->getBuildPackage('frankenphp') ? 'frankenphp' : null,
]);
$static_extensions = array_filter($installer->getResolvedPackages(), fn ($x) => $x->getType() === 'php-extension');
$shared_extensions = parse_extension_list($package->getBuildOption('build-shared') ?? []);
$install_packages = array_filter($installer->getResolvedPackages(), fn ($x) => $x->getType() !== 'php-extension' && $x->getName() !== 'php' && !str_starts_with($x->getName(), 'php-'));
return [
'Build OS' => SystemTarget::getTargetOS() . ' (' . SystemTarget::getTargetArch() . ')',
'Build Target' => getenv('SPC_TARGET') ?: '',
'Build Toolchain' => ToolchainManager::getToolchainClass(),
'Build SAPI' => implode(', ', $sapis),
'Static Extensions (' . count($static_extensions) . ')' => implode(',', array_map(fn ($x) => substr($x->getName(), 4), $static_extensions)),
'Shared Extensions (' . count($shared_extensions) . ')' => implode(',', $shared_extensions),
'Install Packages (' . count($install_packages) . ')' => implode(',', array_map(fn ($x) => $x->getName(), $install_packages)),
];
}
#[BeforeStage('php', 'build')]
public function beforeBuild(PackageBuilder $builder, Package $package): void
{
// Process -I option
$custom_ini = [];
foreach ($builder->getOption('with-hardcoded-ini', []) as $value) {
[$source_name, $ini_value] = explode('=', $value, 2);
$custom_ini[$source_name] = $ini_value;
logger()->info("Adding hardcoded INI [{$source_name} = {$ini_value}]");
}
if (!empty($custom_ini)) {
SourcePatcher::patchHardcodedINI($package->getSourceDir(), $custom_ini);
}
// Patch StaticPHP version
// detect patch (remove this when 8.3 deprecated)
$file = FileSystem::readFile("{$package->getSourceDir()}/main/main.c");
if (!str_contains($file, 'static-php-cli.version')) {
$version = SPC_VERSION;
logger()->debug('Inserting static-php-cli.version to php-src');
$file = str_replace('PHP_INI_BEGIN()', "PHP_INI_BEGIN()\n\tPHP_INI_ENTRY(\"static-php-cli.version\",\t\"{$version}\",\tPHP_INI_ALL,\tNULL)", $file);
FileSystem::writeFile("{$package->getSourceDir()}/main/main.c", $file);
}
// clean old modules that may conflict with the new php build
FileSystem::removeDir(BUILD_MODULES_PATH);
}
#[BeforeStage('php', 'unix-buildconf')]
#[PatchDescription('Patch configure.ac for musl and musl-toolchain')]
#[PatchDescription('Let php m4 tools use static pkg-config')]
public function patchBeforeBuildconf(TargetPackage $package): void
{
// patch configure.ac for musl and musl-toolchain
$musl = SystemTarget::getTargetOS() === 'Linux' && SystemTarget::getLibc() === 'musl';
FileSystem::backupFile(SOURCE_PATH . '/php-src/configure.ac');
FileSystem::replaceFileStr(
SOURCE_PATH . '/php-src/configure.ac',
'if command -v ldd >/dev/null && ldd --version 2>&1 | grep ^musl >/dev/null 2>&1',
'if ' . ($musl ? 'true' : 'false')
);
// let php m4 tools use static pkg-config
FileSystem::replaceFileStr("{$package->getSourceDir()}/build/php.m4", 'PKG_CHECK_MODULES(', 'PKG_CHECK_MODULES_STATIC(');
}
#[Stage('unix-buildconf')]
public function buildconfForUnix(TargetPackage $package): void
{
InteractiveTerm::setMessage('Building php: ' . ConsoleColor::yellow('./buildconf'));
V2CompatLayer::emitPatchPoint('before-php-buildconf');
shell()->cd($package->getSourceDir())->exec(getenv('SPC_CMD_PREFIX_PHP_BUILDCONF'));
}
#[Stage('unix-configure')]
public function configureForUnix(TargetPackage $package, PackageInstaller $installer): void
{
InteractiveTerm::setMessage('Building php: ' . ConsoleColor::yellow('./configure'));
V2CompatLayer::emitPatchPoint('before-php-configure');
$cmd = getenv('SPC_CMD_PREFIX_PHP_CONFIGURE');
$args = [];
$version_id = self::getPHPVersionID();
// PHP JSON extension is built-in since PHP 8.0
if ($version_id < 80000) {
$args[] = '--enable-json';
}
// zts
if ($package->getBuildOption('enable-zts', false)) {
$args[] = '--enable-zts --disable-zend-signals';
if ($version_id >= 80100 && SystemTarget::getTargetOS() === 'Linux') {
$args[] = '--enable-zend-max-execution-timers';
}
}
// config-file-path and config-file-scan-dir
if ($option = $package->getBuildOption('with-config-file-path', false)) {
$args[] = "--with-config-file-path={$option}";
}
if ($option = $package->getBuildOption('with-config-file-scan-dir', false)) {
$args[] = "--with-config-file-scan-dir={$option}";
}
// perform enable cli options
$args[] = $installer->isBuildPackage('php-cli') ? '--enable-cli' : '--disable-cli';
$args[] = $installer->isBuildPackage('php-fpm') ? '--enable-fpm' : '--disable-fpm';
$args[] = $installer->isBuildPackage('php-micro') ? match (SystemTarget::getTargetOS()) {
'Linux' => '--enable-micro=all-static',
default => '--enable-micro',
} : null;
$args[] = $installer->isBuildPackage('php-cgi') ? '--enable-cgi' : '--disable-cgi';
$embed_type = getenv('SPC_CMD_VAR_PHP_EMBED_TYPE') ?: 'static';
$args[] = $installer->isBuildPackage('php-embed') ? "--enable-embed={$embed_type}" : '--disable-embed';
$args[] = getenv('SPC_EXTRA_PHP_VARS') ?: null;
$args = implode(' ', array_filter($args));
$static_extension_str = $this->makeStaticExtensionString($installer);
// run ./configure with args
$this->seekPhpSrcLogFileOnException(fn () => shell()->cd($package->getSourceDir())->setEnv([
'CFLAGS' => getenv('SPC_CMD_VAR_PHP_MAKE_EXTRA_CFLAGS'),
'CPPFLAGS' => "-I{$package->getIncludeDir()}",
'LDFLAGS' => "-L{$package->getLibDir()} " . getenv('SPC_CMD_VAR_PHP_MAKE_EXTRA_LDFLAGS'),
])->exec("{$cmd} {$args} {$static_extension_str}"), $package->getSourceDir());
}
#[Stage('unix-make')]
public function makeForUnix(TargetPackage $package, PackageInstaller $installer): void
{
V2CompatLayer::emitPatchPoint('before-php-make');
logger()->info('cleaning up php-src build files');
shell()->cd($package->getSourceDir())->exec('make clean');
if ($installer->isBuildPackage('php-cli')) {
$package->runStage('unix-make-cli');
}
if ($installer->isBuildPackage('php-fpm')) {
$package->runStage('unix-make-fpm');
}
if ($installer->isBuildPackage('php-cgi')) {
$package->runStage('unix-make-cgi');
}
}
#[Stage('unix-make-cli')]
public function makeCliForUnix(TargetPackage $package, PackageInstaller $installer, PackageBuilder $builder): void
{
InteractiveTerm::setMessage('Building php: ' . ConsoleColor::yellow('make cli'));
$concurrency = $builder->concurrency;
shell()->cd($package->getSourceDir())
->setEnv($this->makeVars($installer))
->exec("make -j{$concurrency} cli");
}
#[BuildFor('Darwin')]
#[BuildFor('Linux')]
public function build(TargetPackage $package): void
{
// virtual target, do nothing
if ($package->getName() !== 'php') {
return;
}
$package->runStage('unix-buildconf');
$package->runStage('unix-configure');
$package->runStage('unix-make');
}
/**
* Seek php-src/config.log when building PHP, add it to exception.
*/
protected function seekPhpSrcLogFileOnException(callable $callback, string $source_dir): void
{
try {
$callback();
} catch (SPCException $e) {
if (file_exists("{$source_dir}/config.log")) {
$e->addExtraLogFile('php-src config.log', 'php-src.config.log');
copy("{$source_dir}/config.log", SPC_LOGS_DIR . '/php-src.config.log');
}
throw $e;
}
}
private function makeStaticExtensionString(PackageInstaller $installer): string
{
$arg = [];
foreach ($installer->getResolvedPackages() as $package) {
/** @var PhpExtensionPackage $package */
if ($package->getType() !== 'php-extension' || !$package instanceof PhpExtensionPackage) {
continue;
}
// build-shared=true, build-static=false, build-with-php=true
if ($package->isBuildShared() && !$package->isBuildStatic() && $package->isBuildWithPhp()) {
$arg[] = $package->getPhpConfigureArg(SystemTarget::getTargetOS(), true);
} elseif ($package->isBuildStatic()) {
$arg[] = $package->getPhpConfigureArg(SystemTarget::getTargetOS(), false);
}
}
$str = implode(' ', $arg);
logger()->debug("Static extension configure args: {$str}");
return $str;
}
private function makeVars(PackageInstaller $installer): array
{
$config = (new SPCConfigUtil(['libs_only_deps' => true]))->config(array_map(fn ($x) => $x->getName(), $installer->getResolvedPackages()));
$static = ApplicationContext::get(ToolchainInterface::class)->isStatic() ? '-all-static' : '';
$pie = SystemTarget::getTargetOS() === 'Linux' ? '-pie' : '';
return array_filter([
'EXTRA_CFLAGS' => getenv('SPC_CMD_VAR_PHP_MAKE_EXTRA_CFLAGS'),
'EXTRA_LDFLAGS_PROGRAM' => getenv('SPC_CMD_VAR_PHP_MAKE_EXTRA_LDFLAGS') . "{$config['ldflags']} {$static} {$pie}",
'EXTRA_LDFLAGS' => $config['ldflags'],
'EXTRA_LIBS' => $config['libs'],
]);
}
}

View File

@ -0,0 +1,45 @@
<?php
declare(strict_types=1);
namespace Package\Target;
use StaticPHP\Attribute\Package\BuildFor;
use StaticPHP\Attribute\Package\InitPackage;
use StaticPHP\Attribute\Package\Target;
use StaticPHP\DI\ApplicationContext;
use StaticPHP\Package\TargetPackage;
use StaticPHP\Runtime\Executor\UnixAutoconfExecutor;
use StaticPHP\Toolchain\Interface\ToolchainInterface;
#[Target('pkg-config')]
class pkgconfig
{
#[InitPackage]
public function resolveBuild(): void
{
ApplicationContext::set('elephant', true);
}
#[BuildFor('Linux')]
#[BuildFor('Darwin')]
public function build(TargetPackage $package, ToolchainInterface $toolchain): void
{
UnixAutoconfExecutor::create($package)
->appendEnv([
'CFLAGS' => '-Wimplicit-function-declaration -Wno-int-conversion',
'LDFLAGS' => $toolchain->isStatic() ? '--static' : '',
])
->configure(
'--with-internal-glib',
'--disable-host-tool',
'--without-sysroot',
'--without-system-include-path',
'--without-system-library-path',
'--without-pc-path',
)
->make(with_install: 'install-exec');
shell()->exec('strip ' . BUILD_ROOT_PATH . '/bin/pkg-config');
}
}

View File

@ -0,0 +1,544 @@
<?php
declare(strict_types=1);
namespace StaticPHP\Artifact;
use StaticPHP\Config\ArtifactConfig;
use StaticPHP\Config\ConfigValidator;
use StaticPHP\DI\ApplicationContext;
use StaticPHP\Exception\SPCInternalException;
use StaticPHP\Exception\WrongUsageException;
use StaticPHP\Runtime\SystemTarget;
use StaticPHP\Util\FileSystem;
class Artifact
{
public const int FETCH_PREFER_SOURCE = 0;
public const int FETCH_PREFER_BINARY = 1;
public const int FETCH_ONLY_SOURCE = 2;
public const int FETCH_ONLY_BINARY = 3;
protected ?array $config;
/** @var null|callable Bind custom source fetcher callback */
protected mixed $custom_source_callback = null;
/** @var array<string, callable> Bind custom binary fetcher callbacks */
protected mixed $custom_binary_callbacks = [];
/** @var null|callable Bind custom source extract callback (completely takes over extraction) */
protected mixed $source_extract_callback = null;
/** @var null|array{callback: callable, platforms: string[]} Bind custom binary extract callback (completely takes over extraction) */
protected ?array $binary_extract_callback = null;
/** @var array<callable> After source extract hooks */
protected array $after_source_extract_callbacks = [];
/** @var array<array{callback: callable, platforms: string[]}> After binary extract hooks */
protected array $after_binary_extract_callbacks = [];
public function __construct(protected readonly string $name, ?array $config = null)
{
$this->config = $config ?? ArtifactConfig::get($name);
if ($this->config === null) {
throw new WrongUsageException("Artifact '{$name}' not found.");
}
}
public function getName(): string
{
return $this->name;
}
/**
* Checks if the source of an artifact is already downloaded.
*
* @param bool $compare_hash Whether to compare hash of the downloaded source
*/
public function isSourceDownloaded(bool $compare_hash = false): bool
{
return ApplicationContext::get(ArtifactCache::class)->isSourceDownloaded($this->name, $compare_hash);
}
/**
* Checks if the binary of an artifact is already downloaded for the specified target OS.
*
* @param null|string $target_os Target OS platform string, null for current platform
* @param bool $compare_hash Whether to compare hash of the downloaded binary
*/
public function isBinaryDownloaded(?string $target_os = null, bool $compare_hash = false): bool
{
$target_os = $target_os ?? SystemTarget::getCurrentPlatformString();
return ApplicationContext::get(ArtifactCache::class)->isBinaryDownloaded($this->name, $target_os, $compare_hash);
}
public function shouldUseBinary(): bool
{
$platform = SystemTarget::getCurrentPlatformString();
return $this->isBinaryDownloaded($platform) && $this->hasPlatformBinary();
}
/**
* Checks if the source of an artifact is already extracted.
*
* @param bool $compare_hash Whether to compare hash of the extracted source
*/
public function isSourceExtracted(bool $compare_hash = false): bool
{
$target_path = $this->getSourceDir();
if (!is_dir($target_path)) {
return false;
}
if (!$compare_hash) {
return true;
}
// Get expected hash from cache
$cache_info = ApplicationContext::get(ArtifactCache::class)->getSourceInfo($this->name);
if ($cache_info === null) {
return false;
}
$expected_hash = $cache_info['hash'] ?? null;
// Local source: always consider extracted if directory exists
if ($expected_hash === null) {
return true;
}
// Check hash marker file
$hash_file = "{$target_path}/.spc-hash";
if (!file_exists($hash_file)) {
return false;
}
return FileSystem::readFile($hash_file) === $expected_hash;
}
/**
* Checks if the binary of an artifact is already extracted for the specified target OS.
*
* @param null|string $target_os Target OS platform string, null for current platform
* @param bool $compare_hash Whether to compare hash of the extracted binary
*/
public function isBinaryExtracted(?string $target_os = null, bool $compare_hash = false): bool
{
$target_os = $target_os ?? SystemTarget::getCurrentPlatformString();
$extract_config = $this->getBinaryExtractConfig();
$mode = $extract_config['mode'];
// For merge mode, check marker file
if ($mode === 'merge') {
$target_path = $extract_config['path'];
$marker_file = "{$target_path}/.spc-{$this->name}-installed";
if (!file_exists($marker_file)) {
return false;
}
if (!$compare_hash) {
return true;
}
// Get expected hash from cache
$cache_info = ApplicationContext::get(ArtifactCache::class)->getBinaryInfo($this->name, $target_os);
if ($cache_info === null) {
return false;
}
$expected_hash = $cache_info['hash'] ?? null;
if ($expected_hash === null) {
return true; // Local binary
}
$installed_hash = FileSystem::readFile($marker_file);
return $installed_hash === $expected_hash;
}
// For selective mode, cannot reliably check extraction status
if ($mode === 'selective') {
return false;
}
// For standalone mode, check directory and hash
$target_path = $extract_config['path'];
if (!is_dir($target_path)) {
return false;
}
if (!$compare_hash) {
return true;
}
// Get expected hash from cache
$cache_info = ApplicationContext::get(ArtifactCache::class)->getBinaryInfo($this->name, $target_os);
if ($cache_info === null) {
return false;
}
$expected_hash = $cache_info['hash'] ?? null;
// Local binary: always consider extracted if directory exists
if ($expected_hash === null) {
return true;
}
// Check hash marker file
$hash_file = "{$target_path}/.spc-hash";
if (!file_exists($hash_file)) {
return false;
}
return FileSystem::readFile($hash_file) === $expected_hash;
}
/**
* Checks if the artifact has a source defined.
*/
public function hasSource(): bool
{
return isset($this->config['source']) || $this->custom_source_callback !== null;
}
/**
* Checks if the artifact has a local binary defined for the current system target.
*/
public function hasPlatformBinary(): bool
{
$target = SystemTarget::getCurrentPlatformString();
return isset($this->config['binary'][$target]) || isset($this->custom_binary_callbacks[$target]);
}
public function getDownloadConfig(string $type): mixed
{
return $this->config[$type] ?? null;
}
/**
* Get source extraction directory.
*
* Rules:
* 1. If extract is not specified: SOURCE_PATH/{artifact_name}
* 2. If extract is relative path: SOURCE_PATH/{value}
* 3. If extract is absolute path: {value}
* 4. If extract is array (dict): handled by extractor (selective extraction)
*/
public function getSourceDir(): string
{
// defined in config
$extract = $this->config['source']['extract'] ?? null;
if ($extract === null) {
return FileSystem::convertPath(SOURCE_PATH . '/' . $this->name);
}
// Array (dict) mode - return default path, actual handling is in extractor
if (is_array($extract)) {
return FileSystem::convertPath(SOURCE_PATH . '/' . $this->name);
}
// String path
$path = $this->replaceExtractPathVariables($extract);
// Absolute path
if (!FileSystem::isRelativePath($path)) {
return FileSystem::convertPath($path);
}
// Relative path: based on SOURCE_PATH
return FileSystem::convertPath(SOURCE_PATH . '/' . $path);
}
/**
* Get binary extraction directory and mode.
*
* Rules:
* 1. If extract is not specified: PKG_ROOT_PATH (standard mode)
* 2. If extract is "hosted": BUILD_ROOT_PATH (standard mode, for pre-built libraries)
* 3. If extract is relative path: PKG_ROOT_PATH/{value} (standard mode)
* 4. If extract is absolute path: {value} (standard mode)
* 5. If extract is array (dict): selective extraction mode
*
* @return array{path: ?string, mode: 'merge'|'selective'|'standard', files?: array}
*/
public function getBinaryExtractConfig(array $cache_info = []): array
{
if (is_string($cache_info['extract'] ?? null)) {
return ['path' => $this->replaceExtractPathVariables($cache_info['extract']), 'mode' => 'standard'];
}
$platform = SystemTarget::getCurrentPlatformString();
$binary_config = $this->config['binary'][$platform] ?? null;
if ($binary_config === null) {
return ['path' => PKG_ROOT_PATH, 'mode' => 'standard'];
}
$extract = $binary_config['extract'] ?? null;
// Not specified: PKG_ROOT_PATH merge
if ($extract === null) {
return ['path' => PKG_ROOT_PATH, 'mode' => 'standard'];
}
// "hosted" mode: BUILD_ROOT_PATH merge (for pre-built libraries)
if ($extract === 'hosted' || ($binary_config['type'] ?? '') === 'hosted') {
return ['path' => BUILD_ROOT_PATH, 'mode' => 'standard'];
}
// Array (dict) mode: selective extraction
if (is_array($extract)) {
return [
'path' => null,
'mode' => 'selective',
'files' => $extract,
];
}
// String path
$path = $this->replaceExtractPathVariables($extract);
// Absolute path: standalone mode
if (!FileSystem::isRelativePath($path)) {
return ['path' => FileSystem::convertPath($path), 'mode' => 'standard'];
}
// Relative path: PKG_ROOT_PATH/{value} standalone mode
return ['path' => FileSystem::convertPath(PKG_ROOT_PATH . '/' . $path), 'mode' => 'standard'];
}
/**
* Get the binary extraction directory.
* For merge mode, returns the base path.
* For standalone mode, returns the specific directory.
*/
public function getBinaryDir(): string
{
$config = $this->getBinaryExtractConfig();
return $config['path'];
}
/**
* Set custom source fetcher callback.
*/
public function setCustomSourceCallback(callable $callback): void
{
$this->custom_source_callback = $callback;
}
public function getCustomSourceCallback(): ?callable
{
return $this->custom_source_callback ?? null;
}
public function getCustomBinaryCallback(): ?callable
{
$current_platform = SystemTarget::getCurrentPlatformString();
return $this->custom_binary_callbacks[$current_platform] ?? null;
}
public function emitCustomBinary(): void
{
$current_platform = SystemTarget::getCurrentPlatformString();
if (!isset($this->custom_binary_callbacks[$current_platform])) {
throw new SPCInternalException("No custom binary callback defined for artifact '{$this->name}' on target OS '{$current_platform}'.");
}
$callback = $this->custom_binary_callbacks[$current_platform];
ApplicationContext::invoke($callback, [Artifact::class => $this]);
}
/**
* Set custom binary fetcher callback for a specific target OS.
*
* @param string $target_os Target OS platform string (e.g. linux-x86_64)
* @param callable $callback Custom binary fetcher callback
*/
public function setCustomBinaryCallback(string $target_os, callable $callback): void
{
ConfigValidator::validatePlatformString($target_os);
$this->custom_binary_callbacks[$target_os] = $callback;
}
// ==================== Extraction Callbacks ====================
/**
* Set custom source extract callback.
* This callback completely takes over the source extraction process.
*
* Callback signature: function(Artifact $artifact, string $source_file, string $target_path): void
*/
public function setSourceExtractCallback(callable $callback): void
{
$this->source_extract_callback = $callback;
}
/**
* Get the source extract callback.
*/
public function getSourceExtractCallback(): ?callable
{
return $this->source_extract_callback;
}
/**
* Check if a custom source extract callback is set.
*/
public function hasSourceExtractCallback(): bool
{
return $this->source_extract_callback !== null;
}
/**
* Set custom binary extract callback.
* This callback completely takes over the binary extraction process.
*
* Callback signature: function(Artifact $artifact, string $source_file, string $target_path, string $platform): void
*
* @param callable $callback The callback function
* @param string[] $platforms Platform filters (empty = all platforms)
*/
public function setBinaryExtractCallback(callable $callback, array $platforms = []): void
{
$this->binary_extract_callback = [
'callback' => $callback,
'platforms' => $platforms,
];
}
/**
* Get the binary extract callback for current platform.
*
* @return null|callable The callback if set and matches current platform, null otherwise
*/
public function getBinaryExtractCallback(): ?callable
{
if ($this->binary_extract_callback === null) {
return null;
}
$platforms = $this->binary_extract_callback['platforms'];
$current_platform = SystemTarget::getCurrentPlatformString();
// Empty platforms array means all platforms
if (empty($platforms) || in_array($current_platform, $platforms, true)) {
return $this->binary_extract_callback['callback'];
}
return null;
}
/**
* Check if a custom binary extract callback is set for current platform.
*/
public function hasBinaryExtractCallback(): bool
{
return $this->getBinaryExtractCallback() !== null;
}
/**
* Add a callback to run after source extraction completes.
*
* Callback signature: function(string $target_path): void
*/
public function addAfterSourceExtractCallback(callable $callback): void
{
$this->after_source_extract_callbacks[] = $callback;
}
/**
* Add a callback to run after binary extraction completes.
*
* Callback signature: function(string $target_path, string $platform): void
*
* @param callable $callback The callback function
* @param string[] $platforms Platform filters (empty = all platforms)
*/
public function addAfterBinaryExtractCallback(callable $callback, array $platforms = []): void
{
$this->after_binary_extract_callbacks[] = [
'callback' => $callback,
'platforms' => $platforms,
];
}
/**
* Emit all after source extract callbacks.
*
* @param string $target_path The directory where source was extracted
*/
public function emitAfterSourceExtract(string $target_path): void
{
if (empty($this->after_source_extract_callbacks)) {
logger()->debug("No after-source-extract hooks registered for [{$this->name}]");
return;
}
logger()->debug('Executing ' . count($this->after_source_extract_callbacks) . " after-source-extract hook(s) for [{$this->name}]");
foreach ($this->after_source_extract_callbacks as $callback) {
$callback_name = is_array($callback) ? (is_object($callback[0]) ? get_class($callback[0]) : $callback[0]) . '::' . $callback[1] : (is_string($callback) ? $callback : 'Closure');
logger()->debug(" 🪝 Running hook: {$callback_name}");
ApplicationContext::invoke($callback, ['target_path' => $target_path, Artifact::class => $this]);
}
}
/**
* Emit all after binary extract callbacks for the specified platform.
*
* @param null|string $target_path The directory where binary was extracted
* @param string $platform The platform string (e.g., 'linux-x86_64')
*/
public function emitAfterBinaryExtract(?string $target_path, string $platform): void
{
if (empty($this->after_binary_extract_callbacks)) {
logger()->debug("No after-binary-extract hooks registered for [{$this->name}]");
return;
}
$executed = 0;
foreach ($this->after_binary_extract_callbacks as $item) {
$callback_platforms = $item['platforms'];
// Empty platforms array means all platforms
if (empty($callback_platforms) || in_array($platform, $callback_platforms, true)) {
$callback = $item['callback'];
$callback_name = is_array($callback) ? (is_object($callback[0]) ? get_class($callback[0]) : $callback[0]) . '::' . $callback[1] : (is_string($callback) ? $callback : 'Closure');
logger()->debug(" 🪝 Running hook: {$callback_name} (platform: {$platform})");
ApplicationContext::invoke($callback, [
'target_path' => $target_path,
'platform' => $platform,
Artifact::class => $this,
]);
++$executed;
}
}
logger()->debug("Executed {$executed} after-binary-extract hook(s) for [{$this->name}] on platform [{$platform}]");
}
/**
* Replaces variables in the extract path.
*
* @param string $extract the extract path with variables
*/
private function replaceExtractPathVariables(string $extract): string
{
$replacement = [
'{artifact_name}' => $this->name,
'{pkg_root_path}' => PKG_ROOT_PATH,
'{build_root_path}' => BUILD_ROOT_PATH,
'{php_sdk_path}' => getenv('PHP_SDK_PATH') ?: WORKING_DIR . '/php-sdk-binary-tools',
'{working_dir}' => WORKING_DIR,
'{download_path}' => DOWNLOAD_PATH,
'{source_path}' => SOURCE_PATH,
];
return str_replace(array_keys($replacement), array_values($replacement), $extract);
}
}

View File

@ -0,0 +1,305 @@
<?php
declare(strict_types=1);
namespace StaticPHP\Artifact;
use StaticPHP\Artifact\Downloader\DownloadResult;
use StaticPHP\Exception\SPCInternalException;
use StaticPHP\Util\FileSystem;
class ArtifactCache
{
/**
* @var array<string, array{
* source: null|array{
* lock_type: 'binary'|'source',
* cache_type: 'archive'|'file'|'git'|'local',
* filename?: string,
* dirname?: string,
* extract: null|'&custom'|string,
* hash: null|string
* },
* binary: array{
* windows-x86_64?: null|array{
* lock_type: 'binary'|'source',
* cache_type: 'archive'|'file'|'git'|'local',
* filename?: string,
* dirname?: string,
* extract: null|'&custom'|string,
* hash: null|string,
* version?: null|string
* }
* }
* }>
*/
protected array $cache = [];
/**
* @param string $cache_file Lock file position
*/
public function __construct(protected string $cache_file = DOWNLOAD_PATH . '/.cache.json')
{
if (!file_exists($this->cache_file)) {
logger()->debug("Cache file does not exist, creating new one at {$this->cache_file}");
FileSystem::createDir(dirname($this->cache_file));
file_put_contents($this->cache_file, json_encode([]));
} else {
$content = file_get_contents($this->cache_file);
$this->cache = json_decode($content ?: '{}', true) ?? [];
}
}
/**
* Checks if the source of an artifact is already downloaded.
*
* @param string $artifact_name Artifact name
* @param bool $compare_hash Whether to compare hash of the downloaded source
*/
public function isSourceDownloaded(string $artifact_name, bool $compare_hash = false): bool
{
$item = $this->cache[$artifact_name] ?? null;
if ($item === null) {
return false;
}
return $this->isObjectDownloaded($this->cache[$artifact_name]['source'] ?? null, $compare_hash);
}
/**
* Check if the binary of an artifact for target OS is already downloaded.
*
* @param string $artifact_name Artifact name
* @param string $target_os Target OS (accepts {windows|linux|macos}-{x86_64|aarch64})
* @param bool $compare_hash Whether to compare hash of the downloaded binary
*/
public function isBinaryDownloaded(string $artifact_name, string $target_os, bool $compare_hash = false): bool
{
$item = $this->cache[$artifact_name] ?? null;
if ($item === null) {
return false;
}
return $this->isObjectDownloaded($this->cache[$artifact_name]['binary'][$target_os] ?? null, $compare_hash);
}
/**
* Lock the downloaded artifact info into cache.
*
* @param Artifact|string $artifact Artifact instance
* @param 'binary'|'source' $lock_type Lock type ('source'|'binary')
* @param DownloadResult $download_result Download result object
* @param null|string $platform Target platform string for binary lock, null for source lock
*/
public function lock(Artifact|string $artifact, string $lock_type, DownloadResult $download_result, ?string $platform = null): void
{
$artifact_name = $artifact instanceof Artifact ? $artifact->getName() : $artifact;
if (!isset($this->cache[$artifact_name])) {
$this->cache[$artifact_name] = [
'source' => null,
'binary' => [],
];
}
$obj = null;
if ($download_result->cache_type === 'archive') {
$obj = [
'lock_type' => $lock_type,
'cache_type' => 'archive',
'filename' => $download_result->filename,
'extract' => $download_result->extract,
'hash' => sha1_file(DOWNLOAD_PATH . '/' . $download_result->filename),
'version' => $download_result->version,
'config' => $download_result->config,
];
} elseif ($download_result->cache_type === 'file') {
$obj = [
'lock_type' => $lock_type,
'cache_type' => 'file',
'filename' => $download_result->filename,
'extract' => $download_result->extract,
'hash' => sha1_file(DOWNLOAD_PATH . '/' . $download_result->filename),
'version' => $download_result->version,
'config' => $download_result->config,
];
} elseif ($download_result->cache_type === 'git') {
$obj = [
'lock_type' => $lock_type,
'cache_type' => 'git',
'dirname' => $download_result->dirname,
'extract' => $download_result->extract,
'hash' => trim(exec('cd ' . escapeshellarg(DOWNLOAD_PATH . '/' . $download_result->dirname) . ' && ' . SPC_GIT_EXEC . ' rev-parse HEAD')),
'version' => $download_result->version,
'config' => $download_result->config,
];
} elseif ($download_result->cache_type === 'local') {
$obj = [
'lock_type' => $lock_type,
'cache_type' => 'local',
'dirname' => $download_result->dirname,
'extract' => $download_result->extract,
'hash' => null,
'version' => $download_result->version,
'config' => $download_result->config,
];
}
if ($obj === null) {
throw new SPCInternalException("Invalid download result for locking artifact {$artifact_name}");
}
if ($lock_type === 'binary') {
if ($platform === null) {
throw new SPCInternalException("Invalid download result for locking binary artifact {$artifact_name}: platform cannot be null");
}
$obj['platform'] = $platform;
}
if ($lock_type === 'source') {
$this->cache[$artifact_name]['source'] = $obj;
} elseif ($lock_type === 'binary') {
$this->cache[$artifact_name]['binary'][$platform] = $obj;
} else {
throw new SPCInternalException("Invalid lock type '{$lock_type}' for artifact {$artifact_name}");
}
// save cache to file
file_put_contents($this->cache_file, json_encode($this->cache, JSON_PRETTY_PRINT));
}
/**
* Get source cache info for an artifact.
*
* @param string $artifact_name Artifact name
* @return null|array Cache info array or null if not found
*/
public function getSourceInfo(string $artifact_name): ?array
{
return $this->cache[$artifact_name]['source'] ?? null;
}
/**
* Get binary cache info for an artifact on specific platform.
*
* @param string $artifact_name Artifact name
* @param string $platform Platform string (e.g., 'linux-x86_64')
* @return null|array{
* lock_type: 'binary'|'source',
* cache_type: 'archive'|'git'|'local',
* filename?: string,
* extract: null|'&custom'|string,
* hash: null|string,
* dirname?: string,
* version?: null|string
* } Cache info array or null if not found
*/
public function getBinaryInfo(string $artifact_name, string $platform): ?array
{
return $this->cache[$artifact_name]['binary'][$platform] ?? null;
}
/**
* Get the full path to the cached file/directory.
*
* @param array $cache_info Cache info from getSourceInfo() or getBinaryInfo()
* @return string Full path to the cached file or directory
*/
public function getCacheFullPath(array $cache_info): string
{
return match ($cache_info['cache_type']) {
'archive', 'file' => DOWNLOAD_PATH . '/' . $cache_info['filename'],
'git' => DOWNLOAD_PATH . '/' . $cache_info['dirname'],
'local' => $cache_info['dirname'], // local dirname is absolute path
default => throw new SPCInternalException("Unknown cache type: {$cache_info['cache_type']}"),
};
}
/**
* Remove source cache entry for an artifact.
*
* @param string $artifact_name Artifact name
* @param bool $delete_file Whether to also delete the cached file/directory
*/
public function removeSource(string $artifact_name, bool $delete_file = false): void
{
$source_info = $this->getSourceInfo($artifact_name);
if ($source_info === null) {
return;
}
// Optionally delete the actual file/directory
if ($delete_file) {
$path = $this->getCacheFullPath($source_info);
if (in_array($source_info['cache_type'], ['archive', 'file']) && file_exists($path)) {
unlink($path);
logger()->debug("Deleted cached archive: {$path}");
} elseif ($source_info['cache_type'] === 'git' && is_dir($path)) {
FileSystem::removeDir($path);
logger()->debug("Deleted cached git repository: {$path}");
}
}
// Remove from cache
$this->cache[$artifact_name]['source'] = null;
$this->save();
logger()->debug("Removed source cache entry for [{$artifact_name}]");
}
/**
* Remove binary cache entry for an artifact on specific platform.
*
* @param string $artifact_name Artifact name
* @param string $platform Platform string (e.g., 'linux-x86_64')
* @param bool $delete_file Whether to also delete the cached file/directory
*/
public function removeBinary(string $artifact_name, string $platform, bool $delete_file = false): void
{
$binary_info = $this->getBinaryInfo($artifact_name, $platform);
if ($binary_info === null) {
return;
}
// Optionally delete the actual file/directory
if ($delete_file) {
$path = $this->getCacheFullPath($binary_info);
if (in_array($binary_info['cache_type'], ['archive', 'file']) && file_exists($path)) {
unlink($path);
logger()->debug("Deleted cached binary archive: {$path}");
} elseif ($binary_info['cache_type'] === 'git' && is_dir($path)) {
FileSystem::removeDir($path);
logger()->debug("Deleted cached binary git repository: {$path}");
}
}
// Remove from cache
unset($this->cache[$artifact_name]['binary'][$platform]);
$this->save();
logger()->debug("Removed binary cache entry for [{$artifact_name}] on platform [{$platform}]");
}
/**
* Save cache to file.
*/
public function save(): void
{
file_put_contents($this->cache_file, json_encode($this->cache, JSON_PRETTY_PRINT));
}
private function isObjectDownloaded(?array $object, bool $compare_hash = false): bool
{
if ($object === null) {
return false;
}
// check if source is cached and file/dir exists in downloads/ dir
return match ($object['cache_type'] ?? null) {
'archive', 'file' => isset($object['filename']) &&
file_exists(DOWNLOAD_PATH . '/' . $object['filename']) &&
(!$compare_hash || (
isset($object['hash']) &&
sha1_file(DOWNLOAD_PATH . '/' . $object['filename']) === $object['hash']
)),
'git' => isset($object['dirname']) &&
is_dir(DOWNLOAD_PATH . '/' . $object['dirname'] . '/.git') &&
(!$compare_hash || (
isset($object['hash']) &&
trim(exec('cd ' . escapeshellarg(DOWNLOAD_PATH . '/' . $object['dirname']) . ' && ' . SPC_GIT_EXEC . ' rev-parse HEAD')) === $object['hash']
)),
'local' => isset($object['dirname']) &&
is_dir($object['dirname']), // local dirname is absolute path
default => false,
};
}
}

View File

@ -0,0 +1,665 @@
<?php
declare(strict_types=1);
namespace StaticPHP\Artifact;
use Psr\Log\LogLevel;
use StaticPHP\Artifact\Downloader\DownloadResult;
use StaticPHP\Artifact\Downloader\Type\BitBucketTag;
use StaticPHP\Artifact\Downloader\Type\DownloadTypeInterface;
use StaticPHP\Artifact\Downloader\Type\FileList;
use StaticPHP\Artifact\Downloader\Type\Git;
use StaticPHP\Artifact\Downloader\Type\GitHubRelease;
use StaticPHP\Artifact\Downloader\Type\GitHubTarball;
use StaticPHP\Artifact\Downloader\Type\LocalDir;
use StaticPHP\Artifact\Downloader\Type\PhpRelease;
use StaticPHP\Artifact\Downloader\Type\PIE;
use StaticPHP\Artifact\Downloader\Type\Url;
use StaticPHP\Artifact\Downloader\Type\ValidatorInterface;
use StaticPHP\DI\ApplicationContext;
use StaticPHP\Exception\DownloaderException;
use StaticPHP\Exception\ExecutionException;
use StaticPHP\Exception\SPCException;
use StaticPHP\Exception\ValidationException;
use StaticPHP\Exception\WrongUsageException;
use StaticPHP\Runtime\Shell\Shell;
use StaticPHP\Runtime\SystemTarget;
use StaticPHP\Util\FileSystem;
use StaticPHP\Util\InteractiveTerm;
use Symfony\Component\Console\Output\OutputInterface;
use ZM\Logger\ConsoleColor;
/**
* Artifact Downloader class
*/
class ArtifactDownloader
{
/** @var array<string, Artifact> Artifact objects */
protected array $artifacts = [];
/** @var int Parallel process number (1 and 0 as single-threaded mode) */
protected int $parallel = 1;
protected int $retry = 0;
/** @var array<string, string> Override custom download urls from options */
protected array $custom_urls = [];
/** @var array<string, array{0: string, 1: string}> Override custom git options from options ([branch, git url]) */
protected array $custom_gits = [];
/** @var array<string, string> Override custom local paths from options */
protected array $custom_locals = [];
/** @var int Fetch type preference */
protected int $default_fetch_pref = Artifact::FETCH_PREFER_SOURCE;
/** @var array<string, int> Specific fetch preference */
protected array $fetch_prefs = [];
/** @var array<string>|bool Whether to ignore cache for specific artifacts or all */
protected array|bool $ignore_cache = false;
/** @var bool Whether to enable alternative mirror downloads */
protected bool $alt = true;
private array $_before_files;
/**
* @param array{
* parallel?: int,
* retry?: int,
* custom-url?: array<string>,
* custom-git?: array<string>,
* custom-local?: array<string>,
* prefer-source?: null|bool|string,
* prefer-pre-built?: null|bool|string,
* prefer-binary?: null|bool|string,
* source-only?: null|bool|string,
* binary-only?: null|bool|string,
* ignore-cache?: null|bool|string,
* ignore-cache-sources?: null|bool|string,
* no-alt?: bool,
* no-shallow-clone?: bool
* } $options Downloader options
*/
public function __construct(protected array $options = [])
{
// Allow setting concurrency via options
$this->parallel = max(1, (int) ($options['parallel'] ?? 1));
// Allow setting retry via options
$this->retry = max(0, (int) ($options['retry'] ?? 0));
// Prefer source (default)
if (array_key_exists('prefer-source', $options)) {
if (is_string($options['prefer-source'])) {
$ls = parse_comma_list($options['prefer-source']);
foreach ($ls as $name) {
$this->fetch_prefs[$name] = Artifact::FETCH_PREFER_SOURCE;
}
} elseif ($options['prefer-source'] === null) {
$this->default_fetch_pref = Artifact::FETCH_PREFER_SOURCE;
}
}
// Prefer binary (originally prefer-pre-built)
if (array_key_exists('prefer-binary', $options)) {
if (is_string($options['prefer-binary'])) {
$ls = parse_comma_list($options['prefer-binary']);
foreach ($ls as $name) {
$this->fetch_prefs[$name] = Artifact::FETCH_PREFER_BINARY;
}
} elseif ($options['prefer-binary'] === null) {
$this->default_fetch_pref = Artifact::FETCH_PREFER_BINARY;
}
}
if (array_key_exists('prefer-pre-built', $options)) {
if (is_string($options['prefer-pre-built'])) {
$ls = parse_comma_list($options['prefer-pre-built']);
foreach ($ls as $name) {
$this->fetch_prefs[$name] = Artifact::FETCH_PREFER_BINARY;
}
} elseif ($options['prefer-pre-built'] === null) {
$this->default_fetch_pref = Artifact::FETCH_PREFER_BINARY;
}
}
// Source only
if (array_key_exists('source-only', $options)) {
if (is_string($options['source-only'])) {
$ls = parse_comma_list($options['source-only']);
foreach ($ls as $name) {
$this->fetch_prefs[$name] = Artifact::FETCH_ONLY_SOURCE;
}
} elseif ($options['source-only'] === null) {
$this->default_fetch_pref = Artifact::FETCH_ONLY_SOURCE;
}
}
// Binary only
if (array_key_exists('binary-only', $options)) {
if (is_string($options['binary-only'])) {
$ls = parse_comma_list($options['binary-only']);
foreach ($ls as $name) {
$this->fetch_prefs[$name] = Artifact::FETCH_ONLY_BINARY;
}
} elseif ($options['binary-only'] === null) {
$this->default_fetch_pref = Artifact::FETCH_ONLY_BINARY;
}
}
// Ignore cache
if (array_key_exists('ignore-cache', $options)) {
if (is_string($options['ignore-cache'])) {
$this->ignore_cache = parse_comma_list($options['ignore-cache']);
} elseif ($options['ignore-cache'] === null) {
$this->ignore_cache = true;
}
}
// backward compatibility for ignore-cache-sources
if (array_key_exists('ignore-cache-sources', $options)) {
if (is_string($options['ignore-cache-sources'])) {
$this->ignore_cache = parse_comma_list($options['ignore-cache-sources']);
} elseif ($options['ignore-cache-sources'] === null) {
$this->ignore_cache = true;
}
}
// Allow setting custom urls via options
foreach (($options['custom-url'] ?? []) as $value) {
[$artifact_name, $url] = explode(':', $value, 2);
$this->custom_urls[$artifact_name] = $url;
$this->ignore_cache = match ($this->ignore_cache) {
true => true,
false => [$artifact_name],
default => array_merge($this->ignore_cache, [$artifact_name]),
};
}
// Allow setting custom git options via options
foreach (($options['custom-git'] ?? []) as $value) {
[$artifact_name, $branch, $git_url] = explode(':', $value, 3) + [null, null, null];
$this->custom_gits[$artifact_name] = [$branch ?? 'main', $git_url];
$this->ignore_cache = match ($this->ignore_cache) {
true => true,
false => [$artifact_name],
default => array_merge($this->ignore_cache, [$artifact_name]),
};
}
// Allow setting custom local paths via options
foreach (($options['custom-local'] ?? []) as $value) {
[$artifact_name, $local_path] = explode(':', $value, 2);
$this->custom_locals[$artifact_name] = $local_path;
$this->ignore_cache = match ($this->ignore_cache) {
true => true,
false => [$artifact_name],
default => array_merge($this->ignore_cache, [$artifact_name]),
};
}
// no alt
if (array_key_exists('no-alt', $options) && $options['no-alt'] === true) {
$this->alt = false;
}
// read downloads dir
$this->_before_files = FileSystem::scanDirFiles(DOWNLOAD_PATH, false, true, true) ?: [];
}
/**
* Add an artifact to the download list.
*
* @param Artifact|string $artifact Artifact instance or artifact name
*/
public function add(Artifact|string $artifact): static
{
if (is_string($artifact)) {
$artifact_instance = ArtifactLoader::getArtifactInstance($artifact);
} else {
$artifact_instance = $artifact;
}
if ($artifact_instance === null) {
$name = $artifact;
throw new WrongUsageException("Artifact '{$name}' not found, please check the name.");
}
// only add if not already added
if (!isset($this->artifacts[$artifact_instance->getName()])) {
$this->artifacts[$artifact_instance->getName()] = $artifact_instance;
}
return $this;
}
/**
* Add multiple artifacts to the download list.
*
* @param array<Artifact|string> $artifacts Multiple artifacts to add
*/
public function addArtifacts(array $artifacts): static
{
foreach ($artifacts as $artifact) {
$this->add($artifact);
}
return $this;
}
/**
* Set the concurrency limit for parallel downloads.
*
* @param int $parallel Number of concurrent downloads (default: 3)
*/
public function setParallel(int $parallel): static
{
$this->parallel = max(1, $parallel);
return $this;
}
/**
* Download all artifacts, with optional parallel processing.
*
* @param bool $interactive Enable interactive mode with Ctrl+C handling
*/
public function download(bool $interactive = true): void
{
if ($interactive) {
Shell::passthruCallback(function () {
InteractiveTerm::advance();
});
keyboard_interrupt_register(function () {
echo PHP_EOL;
InteractiveTerm::error('Download cancelled by user.');
// scan changed files
$after_files = FileSystem::scanDirFiles(DOWNLOAD_PATH, false, true, true) ?: [];
$new_files = array_diff($after_files, $this->_before_files);
// remove new files
foreach ($new_files as $file) {
if ($file === '.cache.json') {
continue;
}
logger()->debug("Removing corrupted artifact: {$file}");
$path = DOWNLOAD_PATH . DIRECTORY_SEPARATOR . $file;
if (is_dir($path)) {
FileSystem::removeDir($path);
} elseif (is_file($path)) {
FileSystem::removeFileIfExists($path);
}
}
exit(2);
});
}
$this->applyCustomDownloads();
$count = count($this->artifacts);
$artifacts_str = implode(',', array_map(fn ($x) => '' . ConsoleColor::yellow($x->getName()), $this->artifacts));
// mute the first line if not interactive
if ($interactive) {
InteractiveTerm::notice("Downloading {$count} artifacts: {$artifacts_str} ...");
}
try {
// Create dir
if (!is_dir(DOWNLOAD_PATH)) {
FileSystem::createDir(DOWNLOAD_PATH);
}
logger()->info('Downloading' . implode(', ', array_map(fn ($x) => " '{$x->getName()}'", $this->artifacts)) . " with concurrency {$this->parallel} ...");
// Download artifacts parallely
if ($this->parallel > 1) {
$this->downloadWithConcurrency();
} else {
// normal sequential download
$current = 0;
$skipped = [];
foreach ($this->artifacts as $artifact) {
++$current;
if ($this->downloadWithType($artifact, $current, $count) === SPC_DOWNLOAD_STATUS_SKIPPED) {
$skipped[] = $artifact->getName();
continue;
}
$this->_before_files = FileSystem::scanDirFiles(DOWNLOAD_PATH, false, true, true) ?: [];
}
if ($interactive) {
$skip_msg = !empty($skipped) ? ' (Skipped ' . count($skipped) . ' artifacts for being already downloaded)' : '';
InteractiveTerm::success("Downloaded all {$count} artifacts.{$skip_msg}", true);
echo PHP_EOL;
}
}
} catch (SPCException $e) {
array_map(fn ($x) => InteractiveTerm::error($x), explode("\n", $e->getMessage()));
throw new WrongUsageException();
} finally {
if ($interactive) {
Shell::passthruCallback(null);
keyboard_interrupt_unregister();
}
}
}
public function getRetry(): int
{
return $this->retry;
}
public function getArtifacts(): array
{
return $this->artifacts;
}
public function getOption(string $name, mixed $default = null): mixed
{
return $this->options[$name] ?? $default;
}
private function downloadWithType(Artifact $artifact, int $current, int $total, bool $parallel = false): int
{
$queue = $this->generateQueue($artifact);
// already downloaded
if ($queue === []) {
logger()->debug("Artifact '{$artifact->getName()}' is already downloaded, skipping.");
return SPC_DOWNLOAD_STATUS_SKIPPED;
}
$try = false;
foreach ($queue as $item) {
try {
$instance = null;
$call = match ($item['config']['type']) {
'bitbuckettag' => BitBucketTag::class,
'filelist' => FileList::class,
'git' => Git::class,
'ghrel' => GitHubRelease::class,
'ghtar', 'ghtagtar' => GitHubTarball::class,
'local' => LocalDir::class,
'pie' => PIE::class,
'url' => Url::class,
'php-release' => PhpRelease::class,
default => null,
};
$type_display_name = match (true) {
$item['lock'] === 'source' && ($callback = $artifact->getCustomSourceCallback()) !== null => 'user defined source downloader',
$item['lock'] === 'binary' && ($callback = $artifact->getCustomBinaryCallback()) !== null => 'user defined binary downloader',
default => SPC_DOWNLOAD_TYPE_DISPLAY_NAME[$item['config']['type']] ?? $item['config']['type'],
};
$try_h = $try ? 'Try downloading' : 'Downloading';
logger()->info("{$try_h} artifact '{$artifact->getName()}' {$item['display']} ...");
if ($parallel === false) {
InteractiveTerm::indicateProgress("[{$current}/{$total}] Downloading artifact " . ConsoleColor::green($artifact->getName()) . " {$item['display']} from {$type_display_name} ...");
}
// is valid download type
if ($item['lock'] === 'source' && ($callback = $artifact->getCustomSourceCallback()) !== null) {
$lock = ApplicationContext::invoke($callback, [
Artifact::class => $artifact,
ArtifactDownloader::class => $this,
]);
} elseif ($item['lock'] === 'binary' && ($callback = $artifact->getCustomBinaryCallback()) !== null) {
$lock = ApplicationContext::invoke($callback, [
Artifact::class => $artifact,
ArtifactDownloader::class => $this,
]);
} elseif (is_a($call, DownloadTypeInterface::class, true)) {
$instance = new $call();
$lock = $instance->download($artifact->getName(), $item['config'], $this);
} else {
throw new ValidationException("Artifact has invalid download type '{$item['config']['type']}' for {$item['display']}.");
}
if (!$lock instanceof DownloadResult) {
throw new ValidationException("Artifact {$artifact->getName()} has invalid custom return value. Must be instance of DownloadResult.");
}
// verifying hash if possible
$hash_validator = $instance ?? null;
$verified = $lock->verified;
if ($hash_validator instanceof ValidatorInterface) {
if (!$hash_validator->validate($artifact->getName(), $item['config'], $this, $lock)) {
throw new ValidationException("Hash validation failed for artifact '{$artifact->getName()}' {$item['display']}.");
}
$verified = true;
}
// process lock
ApplicationContext::get(ArtifactCache::class)->lock($artifact, $item['lock'], $lock, SystemTarget::getCurrentPlatformString());
if ($parallel === false) {
$ver = $lock->hasVersion() ? (' (' . ConsoleColor::yellow($lock->version) . ')') : '';
InteractiveTerm::finish('Downloaded ' . ($verified ? 'and verified ' : '') . 'artifact ' . ConsoleColor::green($artifact->getName()) . $ver . " {$item['display']} .");
}
return SPC_DOWNLOAD_STATUS_SUCCESS;
} catch (DownloaderException|ExecutionException $e) {
if ($parallel === false) {
InteractiveTerm::finish("Download artifact {$artifact->getName()} {$item['display']} failed !", false);
InteractiveTerm::error("Failed message: {$e->getMessage()}", true);
}
$try = true;
continue;
} catch (ValidationException $e) {
if ($parallel === false) {
InteractiveTerm::finish("Download artifact {$artifact->getName()} {$item['display']} failed !", false);
InteractiveTerm::error("Validation failed: {$e->getMessage()}");
}
break;
}
}
$vvv = ApplicationContext::isDebug() ? "\nIf the problem persists, consider using `-vvv` to enable verbose mode, and disable parallel downloading for more details." : '';
throw new DownloaderException("Download artifact '{$artifact->getName()}' failed. Please check your internet connection and try again.{$vvv}");
}
private function downloadWithConcurrency(): void
{
$skipped = [];
$fiber_pool = [];
$old_verbosity = null;
$old_debug = null;
try {
$count = count($this->artifacts);
// must mute
$output = ApplicationContext::get(OutputInterface::class);
if ($output->isVerbose()) {
$old_verbosity = $output->getVerbosity();
$old_debug = ApplicationContext::isDebug();
logger()->warning('Parallel download is not supported in verbose mode, I will mute the output temporarily.');
$output->setVerbosity(OutputInterface::VERBOSITY_NORMAL);
ApplicationContext::setDebug(false);
logger()->setLevel(LogLevel::ERROR);
}
$pool_count = $this->parallel;
$downloaded = 0;
$total = count($this->artifacts);
Shell::passthruCallback(function () {
InteractiveTerm::advance();
\Fiber::suspend();
});
InteractiveTerm::indicateProgress("[{$downloaded}/{$total}] Downloading artifacts with concurrency {$this->parallel} ...");
$failed_downloads = [];
while (true) {
// fill pool
while (count($fiber_pool) < $pool_count && ($artifact = array_shift($this->artifacts)) !== null) {
$current = $count - count($this->artifacts);
$fiber = new \Fiber(function () use ($artifact, $current, $count) {
return [$artifact, $this->downloadWithType($artifact, $current, $count, true)];
});
$fiber->start();
$fiber_pool[] = $fiber;
}
// check pool
foreach ($fiber_pool as $index => $fiber) {
if ($fiber->isTerminated()) {
try {
[$artifact, $int] = $fiber->getReturn();
if ($int === SPC_DOWNLOAD_STATUS_SKIPPED) {
$skipped[] = $artifact->getName();
}
} catch (\Throwable $e) {
$artifact_name = 'unknown';
if (isset($artifact)) {
$artifact_name = $artifact->getName();
}
$failed_downloads[] = ['artifact' => $artifact_name, 'error' => $e];
InteractiveTerm::setMessage("[{$downloaded}/{$total}] Download failed: {$artifact_name}");
InteractiveTerm::advance();
}
// remove from pool
unset($fiber_pool[$index]);
++$downloaded;
InteractiveTerm::setMessage("[{$downloaded}/{$total}] Downloading artifacts with concurrency {$this->parallel} ...");
InteractiveTerm::advance();
} else {
$fiber->resume();
}
}
// all done
if (count($this->artifacts) === 0 && count($fiber_pool) === 0) {
if (!empty($failed_downloads)) {
InteractiveTerm::finish('Download completed with ' . count($failed_downloads) . ' failure(s).', false);
foreach ($failed_downloads as $failure) {
InteractiveTerm::error("Failed to download '{$failure['artifact']}': {$failure['error']->getMessage()}");
}
throw new DownloaderException('Failed to download ' . count($failed_downloads) . ' artifact(s). Please check your internet connection and try again.');
}
$skip_msg = !empty($skipped) ? ' (Skipped ' . count($skipped) . ' artifacts for being already downloaded)' : '';
InteractiveTerm::finish("Downloaded all {$total} artifacts.{$skip_msg}");
break;
}
}
} catch (\Throwable $e) {
// throw to all fibers to make them stop
foreach ($fiber_pool as $fiber) {
if (!$fiber->isTerminated()) {
try {
$fiber->throw($e);
} catch (\Throwable) {
// ignore errors when stopping fibers
}
}
}
InteractiveTerm::finish('Parallel download failed !', false);
throw $e;
} finally {
if ($old_verbosity !== null) {
ApplicationContext::get(OutputInterface::class)->setVerbosity($old_verbosity);
logger()->setLevel(match ($old_verbosity) {
OutputInterface::VERBOSITY_VERBOSE => LogLevel::INFO,
OutputInterface::VERBOSITY_VERY_VERBOSE, OutputInterface::VERBOSITY_DEBUG => LogLevel::DEBUG,
default => LogLevel::WARNING,
});
}
if ($old_debug !== null) {
ApplicationContext::setDebug($old_debug);
}
Shell::passthruCallback(null);
}
}
/**
* Generate download queue based on type preference.
*/
private function generateQueue(Artifact $artifact): array
{
/** @var array<array{display: string, lock: string, config: array}> $queue */
$queue = [];
$binary_downloaded = $artifact->isBinaryDownloaded(compare_hash: true);
$source_downloaded = $artifact->isSourceDownloaded(compare_hash: true);
$item_source = ['display' => 'source', 'lock' => 'source', 'config' => $artifact->getDownloadConfig('source')];
$item_source_mirror = ['display' => 'source (mirror)', 'lock' => 'source', 'config' => $artifact->getDownloadConfig('source-mirror')];
// For binary config, handle both array configs and custom callbacks
$binary_config = $artifact->getDownloadConfig('binary');
$has_custom_binary = $artifact->getCustomBinaryCallback() !== null;
$item_binary_config = null;
if (is_array($binary_config)) {
$item_binary_config = $binary_config[SystemTarget::getCurrentPlatformString()] ?? null;
} elseif ($has_custom_binary) {
// For custom binaries, create a dummy config to allow queue generation
$item_binary_config = ['type' => 'custom'];
}
$item_binary = ['display' => 'binary', 'lock' => 'binary', 'config' => $item_binary_config];
$binary_mirror_config = $artifact->getDownloadConfig('binary-mirror');
$item_binary_mirror_config = null;
if (is_array($binary_mirror_config)) {
$item_binary_mirror_config = $binary_mirror_config[SystemTarget::getCurrentPlatformString()] ?? null;
}
$item_binary_mirror = ['display' => 'binary (mirror)', 'lock' => 'binary', 'config' => $item_binary_mirror_config];
$pref = $this->fetch_prefs[$artifact->getName()] ?? $this->default_fetch_pref;
if ($pref === Artifact::FETCH_PREFER_SOURCE) {
$queue[] = $item_source['config'] !== null ? $item_source : null;
$queue[] = $item_source_mirror['config'] !== null && $this->alt ? $item_source_mirror : null;
$queue[] = $item_binary['config'] !== null ? $item_binary : null;
$queue[] = $item_binary_mirror['config'] !== null && $this->alt ? $item_binary_mirror : null;
} elseif ($pref === Artifact::FETCH_PREFER_BINARY) {
$queue[] = $item_binary['config'] !== null ? $item_binary : null;
$queue[] = $item_binary_mirror['config'] !== null && $this->alt ? $item_binary_mirror : null;
$queue[] = $item_source['config'] !== null ? $item_source : null;
$queue[] = $item_source_mirror['config'] !== null && $this->alt ? $item_source_mirror : null;
} elseif ($pref === Artifact::FETCH_ONLY_SOURCE) {
$queue[] = $item_source['config'] !== null ? $item_source : null;
$queue[] = $item_source_mirror['config'] !== null && $this->alt ? $item_source_mirror : null;
} elseif ($pref === Artifact::FETCH_ONLY_BINARY) {
$queue[] = $item_binary['config'] !== null ? $item_binary : null;
$queue[] = $item_binary_mirror['config'] !== null && $this->alt ? $item_binary_mirror : null;
}
// filter nulls
$queue = array_values(array_filter($queue));
// always download
if ($this->ignore_cache === true || is_array($this->ignore_cache) && in_array($artifact->getName(), $this->ignore_cache)) {
// validate: ensure at least one download source is available
if (empty($queue)) {
throw new ValidationException("Artifact '{$artifact->getName()}' does not provide any download source for current platform (" . SystemTarget::getCurrentPlatformString() . ').');
}
return $queue;
}
// check if already downloaded
$has_usable_download = false;
if ($pref === Artifact::FETCH_PREFER_SOURCE) {
// prefer source: check source first, if not available check binary
$has_usable_download = $source_downloaded || $binary_downloaded;
} elseif ($pref === Artifact::FETCH_PREFER_BINARY) {
// prefer binary: check binary first, if not available check source
$has_usable_download = $binary_downloaded || $source_downloaded;
} elseif ($pref === Artifact::FETCH_ONLY_SOURCE) {
// source-only: only check if source is downloaded
$has_usable_download = $source_downloaded;
} elseif ($pref === Artifact::FETCH_ONLY_BINARY) {
// binary-only: only check if binary for current platform is downloaded
$has_usable_download = $binary_downloaded;
}
// if already downloaded, skip
if ($has_usable_download) {
return [];
}
// validate: ensure at least one download source is available
if (empty($queue)) {
if ($pref === Artifact::FETCH_ONLY_SOURCE) {
throw new ValidationException("Artifact '{$artifact->getName()}' does not provide source download, cannot use --source-only mode.");
}
if ($pref === Artifact::FETCH_ONLY_BINARY) {
throw new ValidationException("Artifact '{$artifact->getName()}' does not provide binary download for current platform (" . SystemTarget::getCurrentPlatformString() . '), cannot use --binary-only mode.');
}
// prefer modes should also throw error if no download source available
throw new ValidationException("Validation failed: Artifact '{$artifact->getName()}' does not provide any download source for current platform (" . SystemTarget::getCurrentPlatformString() . ').');
}
return $queue;
}
private function applyCustomDownloads(): void
{
foreach ($this->custom_urls as $artifact_name => $custom_url) {
if (isset($this->artifacts[$artifact_name])) {
$this->artifacts[$artifact_name]->setCustomSourceCallback(function (ArtifactDownloader $downloader) use ($artifact_name, $custom_url) {
return (new Url())->download($artifact_name, ['url' => $custom_url], $downloader);
});
}
}
foreach ($this->custom_gits as $artifact_name => [$branch, $git_url]) {
if (isset($this->artifacts[$artifact_name])) {
$this->artifacts[$artifact_name]->setCustomSourceCallback(function (ArtifactDownloader $downloader) use ($artifact_name, $branch, $git_url) {
return (new Git())->download($artifact_name, ['rev' => $branch, 'url' => $git_url], $downloader);
});
}
}
foreach ($this->custom_locals as $artifact_name => $local_path) {
if (isset($this->artifacts[$artifact_name])) {
$this->artifacts[$artifact_name]->setCustomSourceCallback(function (ArtifactDownloader $downloader) use ($artifact_name, $local_path) {
return (new LocalDir())->download($artifact_name, ['dirname' => $local_path], $downloader);
});
}
}
}
}

View File

@ -0,0 +1,619 @@
<?php
declare(strict_types=1);
namespace StaticPHP\Artifact;
use StaticPHP\DI\ApplicationContext;
use StaticPHP\Exception\FileSystemException;
use StaticPHP\Exception\SPCInternalException;
use StaticPHP\Exception\WrongUsageException;
use StaticPHP\Package\Package;
use StaticPHP\Runtime\Shell\Shell;
use StaticPHP\Runtime\SystemTarget;
use StaticPHP\Util\FileSystem;
use StaticPHP\Util\InteractiveTerm;
use StaticPHP\Util\V2CompatLayer;
/**
* ArtifactExtractor is responsible for extracting downloaded artifacts to their target locations.
*
* Extraction rules for source:
* 1. If extract is not specified: SOURCE_PATH/{artifact_name}
* 2. If extract is relative path: SOURCE_PATH/{value}
* 3. If extract is absolute path: {value}
* 4. If extract is array (dict): selective extraction (file mapping)
*
* Extraction rules for binary:
* 1. If extract is not specified: PKG_ROOT_PATH (standard mode)
* 2. If extract is "hosted": BUILD_ROOT_PATH (standard mode, for pre-built libraries)
* 3. If extract is relative path: PKG_ROOT_PATH/{value} (standard mode)
* 4. If extract is absolute path: {value} (standard mode)
* 5. If extract is array (dict): selective extraction mode
*/
class ArtifactExtractor
{
/** @var array<string, bool> Track extracted artifacts to avoid duplicate extraction */
protected array $extracted = [];
public function __construct(
protected ArtifactCache $cache,
protected bool $interactive = true
) {}
/**
* Extract all artifacts for a list of packages.
*
* @param array<Package> $packages Packages to extract artifacts for
* @param bool $force_source If true, always extract source (ignore binary)
*/
public function extractForPackages(array $packages, bool $force_source = false): void
{
// Collect all unique artifacts
$artifacts = [];
foreach ($packages as $package) {
$artifact = $package->getArtifact();
if ($artifact !== null && !isset($artifacts[$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;
});
// Extract each artifact
foreach ($artifacts as $artifact) {
$this->extract($artifact, $force_source);
}
}
/**
* Extract a single artifact.
*
* @param Artifact $artifact The artifact to extract
* @param bool $force_source If true, always extract source (ignore binary)
*/
public function extract(Artifact $artifact, bool $force_source = false): int
{
$name = $artifact->getName();
// Already extracted in this session
if (isset($this->extracted[$name])) {
logger()->debug("Artifact [{$name}] already extracted in this session, skip.");
return SPC_STATUS_ALREADY_EXTRACTED;
}
// Determine: use binary or source?
$use_binary = !$force_source && $artifact->shouldUseBinary();
if ($this->interactive) {
Shell::passthruCallback(function () {
InteractiveTerm::advance();
});
}
try {
V2CompatLayer::beforeExtractHook($artifact);
if ($use_binary) {
$status = $this->extractBinary($artifact);
} else {
$status = $this->extractSource($artifact);
}
V2CompatLayer::afterExtractHook($artifact);
} finally {
if ($this->interactive) {
Shell::passthruCallback(null);
}
}
$this->extracted[$name] = true;
return $status;
}
/**
* Extract source artifact.
*/
protected function extractSource(Artifact $artifact): int
{
$name = $artifact->getName();
$cache_info = $this->cache->getSourceInfo($name);
if ($cache_info === null) {
throw new WrongUsageException("Artifact source [{$name}] not downloaded, please download it first!");
}
$source_file = $this->cache->getCacheFullPath($cache_info);
$target_path = $artifact->getSourceDir();
// Check for custom extract callback
if ($artifact->hasSourceExtractCallback()) {
logger()->info("Extracting source [{$name}] using custom callback...");
$callback = $artifact->getSourceExtractCallback();
ApplicationContext::invoke($callback, [
Artifact::class => $artifact,
'source_file' => $source_file,
'target_path' => $target_path,
]);
// Emit after hooks
$artifact->emitAfterSourceExtract($target_path);
logger()->debug("Emitted after-source-extract hooks for [{$name}]");
return SPC_STATUS_EXTRACTED;
}
// Check for selective extraction (dict mode)
$extract_config = $artifact->getDownloadConfig('source')['extract'] ?? null;
if (is_array($extract_config)) {
$this->doSelectiveExtract($name, $cache_info, $extract_config);
$artifact->emitAfterSourceExtract($target_path);
logger()->debug("Emitted after-source-extract hooks for [{$name}]");
return SPC_STATUS_EXTRACTED;
}
// Standard extraction
$hash = $cache_info['hash'] ?? null;
if ($this->isAlreadyExtracted($target_path, $hash)) {
logger()->debug("Source [{$name}] already extracted at {$target_path}, skip.");
return SPC_STATUS_ALREADY_EXTRACTED;
}
// Remove old directory if hash mismatch
if (is_dir($target_path)) {
logger()->notice("Source [{$name}] hash mismatch, re-extracting...");
FileSystem::removeDir($target_path);
}
logger()->info("Extracting source [{$name}] to {$target_path}...");
$this->doStandardExtract($name, $cache_info, $target_path);
// Emit after hooks
$artifact->emitAfterSourceExtract($target_path);
logger()->debug("Emitted after-source-extract hooks for [{$name}]");
// Write hash marker
if ($hash !== null) {
FileSystem::writeFile("{$target_path}/.spc-hash", $hash);
}
return SPC_STATUS_EXTRACTED;
}
/**
* Extract binary artifact.
*/
protected function extractBinary(Artifact $artifact): int
{
$name = $artifact->getName();
$platform = SystemTarget::getCurrentPlatformString();
$cache_info = $this->cache->getBinaryInfo($name, $platform);
if ($cache_info === null) {
throw new WrongUsageException("Artifact binary [{$name}] for platform [{$platform}] not downloaded!");
}
$source_file = $this->cache->getCacheFullPath($cache_info);
$extract_config = $artifact->getBinaryExtractConfig($cache_info);
$target_path = $extract_config['path'];
// Check for custom extract callback
if ($artifact->hasBinaryExtractCallback()) {
logger()->info("Extracting binary [{$name}] using custom callback...");
$callback = $artifact->getBinaryExtractCallback();
ApplicationContext::invoke($callback, [
Artifact::class => $artifact,
'source_file' => $source_file,
'target_path' => $target_path,
'platform' => $platform,
]);
// Emit after hooks
$artifact->emitAfterBinaryExtract($target_path, $platform);
logger()->debug("Emitted after-binary-extract hooks for [{$name}]");
return SPC_STATUS_EXTRACTED;
}
// Handle different extraction modes
$mode = $extract_config['mode'];
if ($mode === 'selective') {
$this->doSelectiveExtract($name, $cache_info, $extract_config['files']);
$artifact->emitAfterBinaryExtract($target_path, $platform);
logger()->debug("Emitted after-binary-extract hooks for [{$name}]");
return SPC_STATUS_EXTRACTED;
}
$hash = $cache_info['hash'] ?? null;
if ($this->isAlreadyExtracted($target_path, $hash)) {
logger()->debug("Binary [{$name}] already extracted at {$target_path}, skip.");
return SPC_STATUS_ALREADY_EXTRACTED;
}
logger()->info("Extracting binary [{$name}] to {$target_path}...");
$this->doStandardExtract($name, $cache_info, $target_path);
$artifact->emitAfterBinaryExtract($target_path, $platform);
logger()->debug("Emitted after-binary-extract hooks for [{$name}]");
if ($hash !== null && $cache_info['cache_type'] !== 'file') {
FileSystem::writeFile("{$target_path}/.spc-hash", $hash);
}
return SPC_STATUS_EXTRACTED;
}
/**
* Standard extraction: extract entire archive to target directory.
*/
protected function doStandardExtract(string $name, array $cache_info, string $target_path): void
{
$source_file = $this->cache->getCacheFullPath($cache_info);
$cache_type = $cache_info['cache_type'];
// Validate source file exists before extraction
$this->validateSourceFile($name, $source_file, $cache_type);
$this->extractWithType($cache_type, $source_file, $target_path);
}
/**
* Selective extraction: extract specific files to specific locations.
*
* @param string $name Artifact name
* @param array $cache_info Cache info
* @param array<string,string> $file_map Map of source path => destination path
*/
protected function doSelectiveExtract(string $name, array $cache_info, array $file_map): void
{
// Extract to temp directory first
$temp_path = sys_get_temp_dir() . '/spc_extract_' . $name . '_' . bin2hex(random_bytes(8));
try {
logger()->info("Extracting [{$name}] with selective file mapping...");
$source_file = $this->cache->getCacheFullPath($cache_info);
$cache_type = $cache_info['cache_type'];
// Validate source file exists before extraction
$this->validateSourceFile($name, $source_file, $cache_type);
$this->extractWithType($cache_type, $source_file, $temp_path);
// Process file mappings
foreach ($file_map as $src_pattern => $dst_path) {
$dst_path = $this->replacePathVariables($dst_path);
$src_full = "{$temp_path}/{$src_pattern}";
// Handle glob patterns
if (str_contains($src_pattern, '*')) {
$matches = glob($src_full);
if (empty($matches)) {
logger()->warning("No files matched pattern [{$src_pattern}] in [{$name}]");
continue;
}
foreach ($matches as $match) {
$filename = basename($match);
$target = rtrim($dst_path, '/') . '/' . $filename;
$this->copyFileOrDir($match, $target);
}
} else {
// Direct file/directory copy
if (!file_exists($src_full) && !is_dir($src_full)) {
logger()->warning("Source [{$src_pattern}] not found in [{$name}]");
continue;
}
$this->copyFileOrDir($src_full, $dst_path);
}
}
} finally {
// Cleanup temp directory
if (is_dir($temp_path)) {
FileSystem::removeDir($temp_path);
}
}
}
/**
* Check if artifact is already extracted with correct hash.
*/
protected function isAlreadyExtracted(string $path, ?string $expected_hash): bool
{
if (!is_dir($path)) {
return false;
}
// Local source: always re-extract
if ($expected_hash === null) {
return false;
}
$hash_file = "{$path}/.spc-hash";
if (!file_exists($hash_file)) {
return false;
}
return FileSystem::readFile($hash_file) === $expected_hash;
}
/**
* Validate that the source file/directory exists before extraction.
*
* @param string $name Artifact name (for error messages)
* @param string $source_file Path to the source file or directory
* @param string $cache_type Cache type: archive, git, local
*
* @throws WrongUsageException if source file does not exist
*/
protected function validateSourceFile(string $name, string $source_file, string $cache_type): void
{
$converted_path = FileSystem::convertPath($source_file);
switch ($cache_type) {
case 'archive':
if (!file_exists($converted_path)) {
throw new WrongUsageException(
"Artifact [{$name}] source archive not found at: {$converted_path}\n" .
"The file may have been deleted or moved. Please run 'spc download {$name}' to re-download it."
);
}
if (!is_file($converted_path)) {
throw new WrongUsageException(
"Artifact [{$name}] source path exists but is not a file: {$converted_path}\n" .
'Expected an archive file. Please check your downloads directory.'
);
}
break;
case 'file':
if (!file_exists($converted_path)) {
throw new WrongUsageException(
"Artifact [{$name}] source file not found at: {$converted_path}\n" .
"The file may have been deleted or moved. Please run 'spc download {$name}' to re-download it."
);
}
if (!is_file($converted_path)) {
throw new WrongUsageException(
"Artifact [{$name}] source path exists but is not a file: {$converted_path}\n" .
'Expected a regular file. Please check your downloads directory.'
);
}
break;
case 'git':
if (!is_dir($converted_path)) {
throw new WrongUsageException(
"Artifact [{$name}] git repository not found at: {$converted_path}\n" .
"The directory may have been deleted. Please run 'spc download {$name}' to re-clone it."
);
}
// Optionally check for .git directory to ensure it's a valid git repo
if (!is_dir("{$converted_path}/.git")) {
logger()->warning("Artifact [{$name}] directory exists but may not be a valid git repository (missing .git)");
}
break;
case 'local':
if (!file_exists($converted_path) && !is_dir($converted_path)) {
throw new WrongUsageException(
"Artifact [{$name}] local source not found at: {$converted_path}\n" .
'Please ensure the local path is correct and accessible.'
);
}
break;
default:
throw new SPCInternalException("Unknown cache type: {$cache_type}");
}
logger()->debug("Validated source file for [{$name}]: {$converted_path} (type: {$cache_type})");
}
/**
* Copy file or directory to destination.
*/
protected function copyFileOrDir(string $src, string $dst): void
{
$dst_dir = dirname($dst);
if (!is_dir($dst_dir)) {
FileSystem::createDir($dst_dir);
}
if (is_dir($src)) {
FileSystem::copyDir($src, $dst);
} else {
copy($src, $dst);
}
logger()->debug("Copied {$src} -> {$dst}");
}
/**
* Extract source based on cache type.
*
* @param string $cache_type Cache type: archive, git, local
* @param string $source_file Path to source file or directory
* @param string $target_path Target extraction path
*/
protected function extractWithType(string $cache_type, string $source_file, string $target_path): void
{
match ($cache_type) {
'archive' => $this->extractArchive($source_file, $target_path),
'file' => $this->copyFile($source_file, $target_path),
'git' => FileSystem::copyDir(FileSystem::convertPath($source_file), $target_path),
'local' => symlink(FileSystem::convertPath($source_file), $target_path),
default => throw new SPCInternalException("Unknown cache type: {$cache_type}"),
};
}
/**
* Extract archive file to target directory.
*
* Supports: tar, tar.gz, tgz, tar.bz2, tar.xz, txz, zip, exe
*/
protected function extractArchive(string $filename, string $target): void
{
$target = FileSystem::convertPath($target);
$filename = FileSystem::convertPath($filename);
FileSystem::createDir($target);
if (PHP_OS_FAMILY === 'Windows') {
// Use 7za.exe for Windows
$is_txz = str_ends_with($filename, '.txz') || str_ends_with($filename, '.tar.xz');
default_shell()->execute7zExtract($filename, $target, $is_txz);
return;
}
// Unix-like systems: determine compression type
if (str_ends_with($filename, '.tar.gz') || str_ends_with($filename, '.tgz')) {
default_shell()->executeTarExtract($filename, $target, 'gz');
} elseif (str_ends_with($filename, '.tar.bz2')) {
default_shell()->executeTarExtract($filename, $target, 'bz2');
} elseif (str_ends_with($filename, '.tar.xz') || str_ends_with($filename, '.txz')) {
default_shell()->executeTarExtract($filename, $target, 'xz');
} elseif (str_ends_with($filename, '.tar')) {
default_shell()->executeTarExtract($filename, $target, 'none');
} elseif (str_ends_with($filename, '.zip')) {
// Zip requires special handling for strip-components
$this->unzipWithStrip($filename, $target);
} elseif (str_ends_with($filename, '.exe')) {
// exe just copy to target
$dest_file = FileSystem::convertPath("{$target}/" . basename($filename));
FileSystem::copy($filename, $dest_file);
} else {
throw new FileSystemException("Unknown archive format: {$filename}");
}
}
/**
* Unzip file with stripping top-level directory.
*/
protected function unzipWithStrip(string $zip_file, string $extract_path): void
{
$temp_dir = FileSystem::convertPath(sys_get_temp_dir() . '/spc_unzip_' . bin2hex(random_bytes(16)));
$zip_file = FileSystem::convertPath($zip_file);
$extract_path = FileSystem::convertPath($extract_path);
// Extract to temp dir
FileSystem::createDir($temp_dir);
if (PHP_OS_FAMILY === 'Windows') {
default_shell()->execute7zExtract($zip_file, $temp_dir);
} else {
default_shell()->executeUnzip($zip_file, $temp_dir);
}
// Scan first level dirs (relative, not recursive, include dirs)
$contents = FileSystem::scanDirFiles($temp_dir, false, true, true);
if ($contents === false) {
throw new FileSystemException('Cannot scan unzip temp dir: ' . $temp_dir);
}
// If extract path already exists, remove it
if (is_dir($extract_path)) {
FileSystem::removeDir($extract_path);
}
// If only one dir, move its contents to extract_path
$subdir = FileSystem::convertPath("{$temp_dir}/{$contents[0]}");
if (count($contents) === 1 && is_dir($subdir)) {
$this->moveFileOrDir($subdir, $extract_path);
} else {
// Else, if it contains only one dir, strip dir and copy other files
$dircount = 0;
$dir = [];
$top_files = [];
foreach ($contents as $item) {
if (is_dir(FileSystem::convertPath("{$temp_dir}/{$item}"))) {
++$dircount;
$dir[] = $item;
} else {
$top_files[] = $item;
}
}
// Extract dir contents to extract_path
FileSystem::createDir($extract_path);
// Extract move dir
if ($dircount === 1) {
$sub_contents = FileSystem::scanDirFiles("{$temp_dir}/{$dir[0]}", false, true, true);
if ($sub_contents === false) {
throw new FileSystemException("Cannot scan unzip temp sub-dir: {$dir[0]}");
}
foreach ($sub_contents as $sub_item) {
$this->moveFileOrDir(
FileSystem::convertPath("{$temp_dir}/{$dir[0]}/{$sub_item}"),
FileSystem::convertPath("{$extract_path}/{$sub_item}")
);
}
} else {
foreach ($dir as $item) {
$this->moveFileOrDir(
FileSystem::convertPath("{$temp_dir}/{$item}"),
FileSystem::convertPath("{$extract_path}/{$item}")
);
}
}
// Move top-level files to extract_path
foreach ($top_files as $top_file) {
$this->moveFileOrDir(
FileSystem::convertPath("{$temp_dir}/{$top_file}"),
FileSystem::convertPath("{$extract_path}/{$top_file}")
);
}
}
// Clean up temp directory
FileSystem::removeDir($temp_dir);
}
/**
* Move file or directory to destination.
*/
protected function moveFileOrDir(string $source, string $dest): void
{
$source = FileSystem::convertPath($source);
$dest = FileSystem::convertPath($dest);
// Try rename first (fast, atomic)
if (@rename($source, $dest)) {
return;
}
if (is_dir($source)) {
FileSystem::copyDir($source, $dest);
FileSystem::removeDir($source);
} else {
if (!copy($source, $dest)) {
throw new FileSystemException("Failed to copy file from {$source} to {$dest}");
}
if (!unlink($source)) {
throw new FileSystemException("Failed to remove source file: {$source}");
}
}
}
/**
* Replace path variables.
*/
protected function replacePathVariables(string $path): string
{
$replacement = [
'{pkg_root_path}' => PKG_ROOT_PATH,
'{build_root_path}' => BUILD_ROOT_PATH,
'{source_path}' => SOURCE_PATH,
'{download_path}' => DOWNLOAD_PATH,
'{working_dir}' => WORKING_DIR,
];
return str_replace(array_keys($replacement), array_values($replacement), $path);
}
private function copyFile(string $source_file, string $target_path): void
{
FileSystem::createDir(dirname($target_path));
FileSystem::copy(FileSystem::convertPath($source_file), $target_path);
}
}

View File

@ -0,0 +1,190 @@
<?php
declare(strict_types=1);
namespace StaticPHP\Artifact;
use StaticPHP\Attribute\Artifact\AfterBinaryExtract;
use StaticPHP\Attribute\Artifact\AfterSourceExtract;
use StaticPHP\Attribute\Artifact\BinaryExtract;
use StaticPHP\Attribute\Artifact\CustomBinary;
use StaticPHP\Attribute\Artifact\CustomSource;
use StaticPHP\Attribute\Artifact\SourceExtract;
use StaticPHP\Config\ArtifactConfig;
use StaticPHP\Exception\ValidationException;
use StaticPHP\Util\FileSystem;
class ArtifactLoader
{
/** @var null|array<string, Artifact> Artifact instances */
private static ?array $artifacts = null;
public static function initArtifactInstances(): void
{
if (self::$artifacts !== null) {
return;
}
foreach (ArtifactConfig::getAll() as $name => $item) {
$artifact = new Artifact($name, $item);
self::$artifacts[$name] = $artifact;
}
}
public static function getArtifactInstance(string $artifact_name): ?Artifact
{
self::initArtifactInstances();
return self::$artifacts[$artifact_name] ?? null;
}
/**
* Load artifact 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::initArtifactInstances();
$classes = FileSystem::getClassesPsr4($dir, $base_namespace, auto_require: $auto_require);
foreach ($classes as $class) {
self::loadFromClass($class);
}
}
public static function loadFromClass(string $class): void
{
$ref = new \ReflectionClass($class);
$class_instance = $ref->newInstance();
foreach ($ref->getMethods(\ReflectionMethod::IS_PUBLIC) as $method) {
self::processCustomSourceAttribute($ref, $method, $class_instance);
self::processCustomBinaryAttribute($ref, $method, $class_instance);
self::processSourceExtractAttribute($ref, $method, $class_instance);
self::processBinaryExtractAttribute($ref, $method, $class_instance);
self::processAfterSourceExtractAttribute($ref, $method, $class_instance);
self::processAfterBinaryExtractAttribute($ref, $method, $class_instance);
}
}
/**
* Process #[CustomSource] attribute.
*/
private static function processCustomSourceAttribute(\ReflectionClass $ref, \ReflectionMethod $method, object $class_instance): void
{
$attributes = $method->getAttributes(CustomSource::class);
foreach ($attributes as $attribute) {
/** @var CustomSource $instance */
$instance = $attribute->newInstance();
$artifact_name = $instance->artifact_name;
if (isset(self::$artifacts[$artifact_name])) {
self::$artifacts[$artifact_name]->setCustomSourceCallback([$class_instance, $method->getName()]);
} else {
throw new ValidationException("Artifact '{$artifact_name}' not found for #[CustomSource] on '{$ref->getName()}::{$method->getName()}'");
}
}
}
/**
* Process #[CustomBinary] attribute.
*/
private static function processCustomBinaryAttribute(\ReflectionClass $ref, \ReflectionMethod $method, object $class_instance): void
{
$attributes = $method->getAttributes(CustomBinary::class);
foreach ($attributes as $attribute) {
/** @var CustomBinary $instance */
$instance = $attribute->newInstance();
$artifact_name = $instance->artifact_name;
if (isset(self::$artifacts[$artifact_name])) {
foreach ($instance->support_os as $os) {
self::$artifacts[$artifact_name]->setCustomBinaryCallback($os, [$class_instance, $method->getName()]);
}
} else {
throw new ValidationException("Artifact '{$artifact_name}' not found for #[CustomBinary] on '{$ref->getName()}::{$method->getName()}'");
}
}
}
/**
* Process #[SourceExtract] attribute.
* This attribute allows completely taking over the source extraction process.
*/
private static function processSourceExtractAttribute(\ReflectionClass $ref, \ReflectionMethod $method, object $class_instance): void
{
$attributes = $method->getAttributes(SourceExtract::class);
foreach ($attributes as $attribute) {
/** @var SourceExtract $instance */
$instance = $attribute->newInstance();
$artifact_name = $instance->artifact_name;
if (isset(self::$artifacts[$artifact_name])) {
self::$artifacts[$artifact_name]->setSourceExtractCallback([$class_instance, $method->getName()]);
} else {
throw new ValidationException("Artifact '{$artifact_name}' not found for #[SourceExtract] on '{$ref->getName()}::{$method->getName()}'");
}
}
}
/**
* Process #[BinaryExtract] attribute.
* This attribute allows completely taking over the binary extraction process.
*/
private static function processBinaryExtractAttribute(\ReflectionClass $ref, \ReflectionMethod $method, object $class_instance): void
{
$attributes = $method->getAttributes(BinaryExtract::class);
foreach ($attributes as $attribute) {
/** @var BinaryExtract $instance */
$instance = $attribute->newInstance();
$artifact_name = $instance->artifact_name;
if (isset(self::$artifacts[$artifact_name])) {
self::$artifacts[$artifact_name]->setBinaryExtractCallback(
[$class_instance, $method->getName()],
$instance->platforms
);
} else {
throw new ValidationException("Artifact '{$artifact_name}' not found for #[BinaryExtract] on '{$ref->getName()}::{$method->getName()}'");
}
}
}
/**
* Process #[AfterSourceExtract] attribute.
* This attribute registers a hook that runs after source extraction completes.
*/
private static function processAfterSourceExtractAttribute(\ReflectionClass $ref, \ReflectionMethod $method, object $class_instance): void
{
$attributes = $method->getAttributes(AfterSourceExtract::class);
foreach ($attributes as $attribute) {
/** @var AfterSourceExtract $instance */
$instance = $attribute->newInstance();
$artifact_name = $instance->artifact_name;
if (isset(self::$artifacts[$artifact_name])) {
self::$artifacts[$artifact_name]->addAfterSourceExtractCallback([$class_instance, $method->getName()]);
} else {
throw new ValidationException("Artifact '{$artifact_name}' not found for #[AfterSourceExtract] on '{$ref->getName()}::{$method->getName()}'");
}
}
}
/**
* Process #[AfterBinaryExtract] attribute.
* This attribute registers a hook that runs after binary extraction completes.
*/
private static function processAfterBinaryExtractAttribute(\ReflectionClass $ref, \ReflectionMethod $method, object $class_instance): void
{
$attributes = $method->getAttributes(AfterBinaryExtract::class);
foreach ($attributes as $attribute) {
/** @var AfterBinaryExtract $instance */
$instance = $attribute->newInstance();
$artifact_name = $instance->artifact_name;
if (isset(self::$artifacts[$artifact_name])) {
self::$artifacts[$artifact_name]->addAfterBinaryExtractCallback(
[$class_instance, $method->getName()],
$instance->platforms
);
} else {
throw new ValidationException("Artifact '{$artifact_name}' not found for #[AfterBinaryExtract] on '{$ref->getName()}::{$method->getName()}'");
}
}
}
}

View File

@ -0,0 +1,146 @@
<?php
declare(strict_types=1);
namespace StaticPHP\Artifact\Downloader;
use StaticPHP\Exception\DownloaderException;
use StaticPHP\Util\FileSystem;
class DownloadResult
{
/**
* @param string $cache_type Type of cache: 'archive', 'git', or 'local'
* @param null|string $filename Filename for archive type
* @param null|string $dirname Directory name for git/local type
* @param mixed $extract Extraction path or configuration
* @param bool $verified Whether the download has been verified (hash check)
* @param null|string $version Version of the downloaded artifact (e.g., "1.2.3", "v2.0.0")
* @param array $metadata Additional metadata (e.g., commit hash, release notes, etc.)
*/
private function __construct(
public readonly string $cache_type,
public readonly array $config,
public readonly ?string $filename = null,
public readonly ?string $dirname = null,
public mixed $extract = null,
public bool $verified = false,
public readonly ?string $version = null,
public readonly array $metadata = [],
) {
switch ($this->cache_type) {
case 'archive':
$this->filename !== null ?: throw new DownloaderException('Archive download result must have a filename.');
$fn = FileSystem::isRelativePath($this->filename) ? (DOWNLOAD_PATH . DIRECTORY_SEPARATOR . $this->filename) : $this->filename;
file_exists($fn) ?: throw new DownloaderException("Downloaded archive file does not exist: {$fn}");
break;
case 'git':
case 'local':
$this->dirname !== null ?: throw new DownloaderException('Git/local download result must have a dirname.');
$dn = FileSystem::isRelativePath($this->dirname) ? (DOWNLOAD_PATH . DIRECTORY_SEPARATOR . $this->dirname) : $this->dirname;
file_exists($dn) ?: throw new DownloaderException("Downloaded directory does not exist: {$dn}");
break;
}
}
/**
* Create a download result for an archive file.
*
* @param string $filename Filename of the downloaded archive
* @param mixed $extract Extraction path or configuration
* @param bool $verified Whether the archive has been hash-verified
* @param null|string $version Version string of the downloaded artifact
* @param array $metadata Additional metadata
*/
public static function archive(
string $filename,
array $config,
mixed $extract = null,
bool $verified = false,
?string $version = null,
array $metadata = []
): DownloadResult {
return new self('archive', config: $config, filename: $filename, extract: $extract, verified: $verified, version: $version, metadata: $metadata);
}
/**
* Create a download result for a git clone.
*
* @param string $dirname Directory name of the cloned repository
* @param mixed $extract Extraction path or configuration
* @param null|string $version Version string (tag, branch, or commit)
* @param array $metadata Additional metadata (e.g., commit hash)
*/
public static function git(string $dirname, array $config, mixed $extract = null, ?string $version = null, array $metadata = []): DownloadResult
{
return new self('git', config: $config, dirname: $dirname, extract: $extract, version: $version, metadata: $metadata);
}
/**
* Create a download result for a local directory.
*
* @param string $dirname Directory name
* @param mixed $extract Extraction path or configuration
* @param null|string $version Version string if known
* @param array $metadata Additional metadata
*/
public static function local(string $dirname, array $config, mixed $extract = null, ?string $version = null, array $metadata = []): DownloadResult
{
return new self('local', config: $config, dirname: $dirname, extract: $extract, version: $version, metadata: $metadata);
}
/**
* Check if version information is available.
*/
public function hasVersion(): bool
{
return $this->version !== null;
}
/**
* Get a metadata value by key.
*
* @param string $key Metadata key
* @param mixed $default Default value if key doesn't exist
*/
public function getMeta(string $key, mixed $default = null): mixed
{
return $this->metadata[$key] ?? $default;
}
/**
* Create a new DownloadResult with updated version.
* (Immutable pattern - returns a new instance)
*/
public function withVersion(string $version): self
{
return new self(
$this->cache_type,
$this->config,
$this->filename,
$this->dirname,
$this->extract,
$this->verified,
$version,
$this->metadata
);
}
/**
* Create a new DownloadResult with additional metadata.
* (Immutable pattern - returns a new instance)
*/
public function withMeta(string $key, mixed $value): self
{
return new self(
$this->cache_type,
$this->config,
$this->filename,
$this->dirname,
$this->extract,
$this->verified,
$this->version,
array_merge($this->metadata, [$key => $value])
);
}
}

View File

@ -0,0 +1,41 @@
<?php
declare(strict_types=1);
namespace StaticPHP\Artifact\Downloader\Type;
use StaticPHP\Artifact\ArtifactDownloader;
use StaticPHP\Artifact\Downloader\DownloadResult;
use StaticPHP\Exception\DownloaderException;
/** bitbuckettag */
class BitBucketTag implements DownloadTypeInterface
{
public const string BITBUCKET_API_URL = 'https://api.bitbucket.org/2.0/repositories/{repo}/refs/tags';
public const string BITBUCKET_DOWNLOAD_URL = 'https://bitbucket.org/{repo}/get/{version}.tar.gz';
public function download(string $name, array $config, ArtifactDownloader $downloader): DownloadResult
{
logger()->debug("Fetching {$name} API info from bitbucket tag");
$data = default_shell()->executeCurl(str_replace('{repo}', $config['repo'], self::BITBUCKET_API_URL), retries: $downloader->getRetry());
$data = json_decode($data ?: '', true);
$ver = $data['values'][0]['name'] ?? null;
if (!$ver) {
throw new DownloaderException("Failed to get {$name} version from BitBucket API");
}
$download_url = str_replace(['{repo}', '{version}'], [$config['repo'], $ver], self::BITBUCKET_DOWNLOAD_URL);
$headers = default_shell()->executeCurl($download_url, method: 'HEAD', retries: $downloader->getRetry());
preg_match('/^content-disposition:\s+attachment;\s*filename=("?)(?<filename>.+\.tar\.gz)\1/im', $headers, $matches);
if ($matches) {
$filename = $matches['filename'];
} else {
$filename = "{$name}-{$data['tag_name']}.tar.gz";
}
$path = DOWNLOAD_PATH . DIRECTORY_SEPARATOR . $filename;
logger()->debug("Downloading {$name} version {$ver} from BitBucket: {$download_url}");
default_shell()->executeCurlDownload($download_url, $path, retries: $downloader->getRetry());
return DownloadResult::archive($filename, $config, extract: $config['extract'] ?? null);
}
}

View File

@ -0,0 +1,18 @@
<?php
declare(strict_types=1);
namespace StaticPHP\Artifact\Downloader\Type;
use StaticPHP\Artifact\ArtifactDownloader;
use StaticPHP\Artifact\Downloader\DownloadResult;
interface DownloadTypeInterface
{
/**
* @param string $name Download item name
* @param array $config Input configuration for the download
* @param ArtifactDownloader $downloader Downloader instance
*/
public function download(string $name, array $config, ArtifactDownloader $downloader): DownloadResult;
}

View File

@ -0,0 +1,46 @@
<?php
declare(strict_types=1);
namespace StaticPHP\Artifact\Downloader\Type;
use StaticPHP\Artifact\ArtifactDownloader;
use StaticPHP\Artifact\Downloader\DownloadResult;
use StaticPHP\Exception\DownloaderException;
/** filelist */
class FileList implements DownloadTypeInterface
{
public function download(string $name, array $config, ArtifactDownloader $downloader): DownloadResult
{
logger()->debug("Fetching file list from {$config['url']}");
$page = default_shell()->executeCurl($config['url'], retries: $downloader->getRetry());
preg_match_all($config['regex'], $page ?: '', $matches);
if (!$matches) {
throw new DownloaderException("Failed to get {$name} file list from {$config['url']}");
}
$versions = [];
foreach ($matches['version'] as $i => $version) {
$lower = strtolower($version);
foreach (['alpha', 'beta', 'rc', 'pre', 'nightly', 'snapshot', 'dev'] as $beta) {
if (str_contains($lower, $beta)) {
continue 2;
}
}
$versions[$version] = $matches['file'][$i];
}
uksort($versions, 'version_compare');
$filename = end($versions);
$version = array_key_last($versions);
if (isset($config['download-url'])) {
$url = str_replace(['{file}', '{version}'], [$filename, $version], $config['download-url']);
} else {
$url = $config['url'] . $filename;
}
$filename = end($versions);
$path = DOWNLOAD_PATH . DIRECTORY_SEPARATOR . $filename;
logger()->debug("Downloading {$name} from URL: {$url}");
default_shell()->executeCurlDownload($url, $path, retries: $downloader->getRetry());
return DownloadResult::archive($filename, $config, $config['extract'] ?? null);
}
}

View File

@ -0,0 +1,22 @@
<?php
declare(strict_types=1);
namespace StaticPHP\Artifact\Downloader\Type;
use StaticPHP\Artifact\ArtifactDownloader;
use StaticPHP\Artifact\Downloader\DownloadResult;
/** git */
class Git implements DownloadTypeInterface
{
public function download(string $name, array $config, ArtifactDownloader $downloader): DownloadResult
{
$path = DOWNLOAD_PATH . "/{$name}";
logger()->debug("Cloning git repository for {$name} from {$config['url']}");
$shallow = !$downloader->getOption('no-shallow-clone', false);
default_shell()->executeGitClone($config['url'], $config['rev'], $path, $shallow, $config['submodules'] ?? null);
$version = "dev-{$config['rev']}";
return DownloadResult::git($name, $config, extract: $config['extract'] ?? null, version: $version);
}
}

View File

@ -0,0 +1,98 @@
<?php
declare(strict_types=1);
namespace StaticPHP\Artifact\Downloader\Type;
use StaticPHP\Artifact\ArtifactDownloader;
use StaticPHP\Artifact\Downloader\DownloadResult;
use StaticPHP\Exception\DownloaderException;
/** ghrel */
class GitHubRelease implements DownloadTypeInterface, ValidatorInterface
{
use GitHubTokenSetupTrait;
public const string API_URL = 'https://api.github.com/repos/{repo}/releases';
public const string ASSET_URL = 'https://api.github.com/repos/{repo}/releases/assets/{id}';
private string $sha256 = '';
private ?string $version = null;
/**
* Get the latest GitHub release assets for a given repository.
* match_asset is provided, only return the asset that matches the regex.
*/
public function getLatestGitHubRelease(string $name, string $repo, bool $prefer_stable, string $match_asset): array
{
$url = str_replace('{repo}', $repo, self::API_URL);
$headers = $this->getGitHubTokenHeaders();
$data2 = default_shell()->executeCurl($url, headers: $headers);
$data = json_decode($data2 ?: '', true);
if (!is_array($data)) {
throw new DownloaderException("Failed to get GitHub release API info for {$repo} from {$url}");
}
foreach ($data as $release) {
if ($prefer_stable && $release['prerelease'] === true) {
continue;
}
foreach ($release['assets'] as $asset) {
if (preg_match("|{$match_asset}|", $asset['name'])) {
if (isset($asset['id'], $asset['name'])) {
// store ghrel asset array (id: ghrel.{$repo}.{stable|unstable}.{$match_asset})
if ($asset['digest'] !== null && str_starts_with($asset['digest'], 'sha256:')) {
$this->sha256 = substr($asset['digest'], 7);
}
$this->version = $release['tag_name'] ?? null;
return $asset;
}
throw new DownloaderException("Failed to get asset name and id for {$repo}");
}
}
}
throw new DownloaderException("No suitable GitHub release found for {$repo}");
}
public function download(string $name, array $config, ArtifactDownloader $downloader): DownloadResult
{
logger()->debug("Fetching GitHub release for {$name} from {$config['repo']}");
if (!isset($config['match'])) {
throw new DownloaderException("GitHubRelease downloader requires 'match' config for {$name}");
}
$rel = $this->getLatestGitHubRelease($name, $config['repo'], $config['prefer-stable'] ?? true, $config['match']);
// download file using curl
$asset_url = str_replace(['{repo}', '{id}'], [$config['repo'], $rel['id']], self::ASSET_URL);
$headers = array_merge(
$this->getGitHubTokenHeaders(),
['Accept: application/octet-stream']
);
$filename = $rel['name'];
$path = DOWNLOAD_PATH . DIRECTORY_SEPARATOR . $filename;
logger()->debug("Downloading {$name} asset from URL: {$asset_url}");
default_shell()->executeCurlDownload($asset_url, $path, headers: $headers, retries: $downloader->getRetry());
return DownloadResult::archive($filename, $config, extract: $config['extract'] ?? null, version: $this->version);
}
public function validate(string $name, array $config, ArtifactDownloader $downloader, DownloadResult $result): bool
{
if ($result->cache_type != 'archive') {
logger()->warning("GitHub release validator only supports archive download type for {$name} .");
return false;
}
if ($this->sha256 !== '') {
$calculated_hash = hash_file('sha256', DOWNLOAD_PATH . DIRECTORY_SEPARATOR . $result->filename);
if ($this->sha256 !== $calculated_hash) {
logger()->error("Hash mismatch for downloaded GitHub release asset of {$name}: expected {$this->sha256}, got {$calculated_hash}");
return false;
}
logger()->debug("Hash verified for downloaded GitHub release asset of {$name}");
return true;
}
logger()->debug("No sha256 digest found for GitHub release asset of {$name}, skipping hash validation");
return true;
}
}

View File

@ -0,0 +1,78 @@
<?php
declare(strict_types=1);
namespace StaticPHP\Artifact\Downloader\Type;
use StaticPHP\Artifact\ArtifactDownloader;
use StaticPHP\Artifact\Downloader\DownloadResult;
use StaticPHP\Exception\DownloaderException;
/** ghtar */
/** ghtagtar */
class GitHubTarball implements DownloadTypeInterface
{
use GitHubTokenSetupTrait;
public const string API_URL = 'https://api.github.com/repos/{repo}/{rel_type}';
private ?string $version = null;
/**
* Get the GitHub tarball URL for a given repository and release type.
* If match_url is provided, only return the tarball that matches the regex.
* Otherwise, return the first tarball found.
*/
public function getGitHubTarballInfo(string $name, string $repo, string $rel_type, bool $prefer_stable = true, ?string $match_url = null, ?string $basename = null): array
{
$url = str_replace(['{repo}', '{rel_type}'], [$repo, $rel_type], self::API_URL);
$data = default_shell()->executeCurl($url, headers: $this->getGitHubTokenHeaders());
$data = json_decode($data ?: '', true);
if (!is_array($data)) {
throw new DownloaderException("Failed to get GitHub tarball URL for {$repo} from {$url}");
}
$url = null;
foreach ($data as $rel) {
if (($rel['prerelease'] ?? false) === true && $prefer_stable) {
continue;
}
if ($match_url === null) {
$url = $rel['tarball_url'] ?? null;
$version = $rel['tag_name'] ?? null;
break;
}
if (preg_match("|{$match_url}|", $rel['tarball_url'] ?? '')) {
$url = $rel['tarball_url'];
$version = $rel['tag_name'] ?? null;
break;
}
}
if (!$url) {
throw new DownloaderException("No suitable GitHub tarball found for {$repo}");
}
$this->version = $version ?? null;
$head = default_shell()->executeCurl($url, 'HEAD', headers: $this->getGitHubTokenHeaders()) ?: '';
preg_match('/^content-disposition:\s+attachment;\s*filename=("?)(?<filename>.+\.tar\.gz)\1/im', $head, $matches);
if ($matches) {
$filename = $matches['filename'];
} else {
$basename = $basename ?? basename($repo);
$filename = "{$basename}-" . ($rel_type === 'releases' ? $data['tag_name'] : $data['name']) . '.tar.gz';
}
return [$url, $filename];
}
public function download(string $name, array $config, ArtifactDownloader $downloader): DownloadResult
{
logger()->debug("Downloading GitHub tarball for {$name} from {$config['repo']}");
$rel_type = match ($config['type']) {
'ghtar' => 'releases',
'ghtagtar' => 'tags',
default => throw new DownloaderException("Invalid GitHubTarball type for {$name}"),
};
[$url, $filename] = $this->getGitHubTarballInfo($name, $config['repo'], $rel_type, $config['prefer-stable'] ?? true, $config['match'] ?? null, $name);
$path = DOWNLOAD_PATH . "/{$filename}";
default_shell()->executeCurlDownload($url, $path, headers: $this->getGitHubTokenHeaders());
return DownloadResult::archive($filename, $config, $config['extract'] ?? null, version: $this->version);
}
}

View File

@ -0,0 +1,22 @@
<?php
declare(strict_types=1);
namespace StaticPHP\Artifact\Downloader\Type;
trait GitHubTokenSetupTrait
{
public function getGitHubTokenHeaders(): array
{
// GITHUB_TOKEN support
if (($token = getenv('GITHUB_TOKEN')) !== false && ($user = getenv('GITHUB_USER')) !== false) {
logger()->debug("Using 'GITHUB_TOKEN' with user {$user} for authentication");
return ['Authorization: Basic ' . base64_encode("{$user}:{$token}")];
}
if (($token = getenv('GITHUB_TOKEN')) !== false) {
logger()->debug("Using 'GITHUB_TOKEN' for authentication");
return ["Authorization: Bearer {$token}"];
}
return [];
}
}

View File

@ -0,0 +1,18 @@
<?php
declare(strict_types=1);
namespace StaticPHP\Artifact\Downloader\Type;
use StaticPHP\Artifact\ArtifactDownloader;
use StaticPHP\Artifact\Downloader\DownloadResult;
/** local */
class LocalDir implements DownloadTypeInterface
{
public function download(string $name, array $config, ArtifactDownloader $downloader): DownloadResult
{
logger()->debug("Using local source directory for {$name} from {$config['dirname']}");
return DownloadResult::local($config['dirname'], $config, extract: $config['extract'] ?? null);
}
}

View File

@ -0,0 +1,47 @@
<?php
declare(strict_types=1);
namespace StaticPHP\Artifact\Downloader\Type;
use StaticPHP\Artifact\ArtifactDownloader;
use StaticPHP\Artifact\Downloader\DownloadResult;
use StaticPHP\Exception\DownloaderException;
/** pie */
class PIE implements DownloadTypeInterface
{
public const string PACKAGIST_URL = 'https://repo.packagist.org/p2/';
public function download(string $name, array $config, ArtifactDownloader $downloader): DownloadResult
{
$packagist_url = self::PACKAGIST_URL . "{$config['repo']}.json";
logger()->debug("Fetching {$name} source from packagist index: {$packagist_url}");
$data = default_shell()->executeCurl($packagist_url, retries: $downloader->getRetry());
if ($data === false) {
throw new DownloaderException("Failed to fetch packagist index for {$name} from {$packagist_url}");
}
$data = json_decode($data, true);
if (!isset($data['packages'][$config['repo']]) || !is_array($data['packages'][$config['repo']])) {
throw new DownloaderException("failed to find {$name} repo info from packagist");
}
// get the first version
$first = $data['packages'][$config['repo']][0] ?? [];
// check 'type' => 'php-ext' or contains 'php-ext' key
if (!isset($first['php-ext'])) {
throw new DownloaderException("failed to find {$name} php-ext info from packagist, maybe not a php extension package");
}
// get download link from dist
$dist_url = $first['dist']['url'] ?? null;
$dist_type = $first['dist']['type'] ?? null;
if (!$dist_url || !$dist_type) {
throw new DownloaderException("failed to find {$name} dist info from packagist");
}
$name = str_replace('/', '_', $config['repo']);
$version = $first['version'] ?? 'unknown';
$filename = "{$name}-{$version}." . ($dist_type === 'zip' ? 'zip' : 'tar.gz');
$path = DOWNLOAD_PATH . DIRECTORY_SEPARATOR . $filename;
default_shell()->executeCurlDownload($dist_url, $path, retries: $downloader->getRetry());
return DownloadResult::archive($filename, $config, $config['extract'] ?? null);
}
}

View File

@ -0,0 +1,76 @@
<?php
declare(strict_types=1);
namespace StaticPHP\Artifact\Downloader\Type;
use StaticPHP\Artifact\ArtifactDownloader;
use StaticPHP\Artifact\Downloader\DownloadResult;
use StaticPHP\Exception\DownloaderException;
class PhpRelease implements DownloadTypeInterface, ValidatorInterface
{
public const string PHP_API = 'https://www.php.net/releases/index.php?json&version={version}';
public const string DOWNLOAD_URL = 'https://www.php.net/distributions/php-{version}.tar.xz';
private ?string $sha256 = '';
public function download(string $name, array $config, ArtifactDownloader $downloader): DownloadResult
{
$phpver = $downloader->getOption('with-php', '8.4');
// Handle 'git' version to clone from php-src repository
if ($phpver === 'git') {
$this->sha256 = null;
return (new Git())->download($name, ['url' => 'https://github.com/php/php-src.git', 'rev' => 'master'], $downloader);
}
// Fetch PHP release info first
$info = default_shell()->executeCurl(str_replace('{version}', $phpver, self::PHP_API), retries: $downloader->getRetry());
if ($info === false) {
throw new DownloaderException("Failed to fetch PHP release info for version {$phpver}");
}
$info = json_decode($info, true);
if (!is_array($info) || !isset($info['version'])) {
throw new DownloaderException("Invalid PHP release info received for version {$phpver}");
}
$version = $info['version'];
foreach ($info['source'] as $source) {
if (str_ends_with($source['filename'], '.tar.xz')) {
$this->sha256 = $source['sha256'];
$filename = $source['filename'];
break;
}
}
if (!isset($filename)) {
throw new DownloaderException("No suitable source tarball found for PHP version {$version}");
}
$url = str_replace('{version}', $version, self::DOWNLOAD_URL);
logger()->debug("Downloading PHP release {$version} from {$url}");
$path = DOWNLOAD_PATH . "/{$filename}";
default_shell()->executeCurlDownload($url, $path, retries: $downloader->getRetry());
return DownloadResult::archive($filename, config: $config, extract: $config['extract'] ?? null, version: $version);
}
public function validate(string $name, array $config, ArtifactDownloader $downloader, DownloadResult $result): bool
{
if ($this->sha256 === null) {
logger()->debug('Php-src is downloaded from non-release source, skipping validation.');
return true;
}
if ($this->sha256 === '') {
logger()->error("No SHA256 checksum available for validation of {$name}.");
return false;
}
$path = DOWNLOAD_PATH . "/{$result->filename}";
$hash = hash_file('sha256', $path);
if ($hash !== $this->sha256) {
logger()->error("SHA256 checksum mismatch for {$name}: expected {$this->sha256}, got {$hash}");
return false;
}
logger()->debug("SHA256 checksum validated successfully for {$name}.");
return true;
}
}

View File

@ -0,0 +1,23 @@
<?php
declare(strict_types=1);
namespace StaticPHP\Artifact\Downloader\Type;
use StaticPHP\Artifact\ArtifactDownloader;
use StaticPHP\Artifact\Downloader\DownloadResult;
/** url */
class Url implements DownloadTypeInterface
{
public function download(string $name, array $config, ArtifactDownloader $downloader): DownloadResult
{
$url = $config['url'];
$filename = $config['filename'] ?? basename($url);
$path = DOWNLOAD_PATH . DIRECTORY_SEPARATOR . $filename;
logger()->debug("Downloading {$name} from URL: {$url}");
$version = $config['version'] ?? null;
default_shell()->executeCurlDownload($url, $path, retries: $downloader->getRetry());
return DownloadResult::archive($filename, config: $config, extract: $config['extract'] ?? null, version: $version);
}
}

View File

@ -0,0 +1,13 @@
<?php
declare(strict_types=1);
namespace StaticPHP\Artifact\Downloader\Type;
use StaticPHP\Artifact\ArtifactDownloader;
use StaticPHP\Artifact\Downloader\DownloadResult;
interface ValidatorInterface
{
public function validate(string $name, array $config, ArtifactDownloader $downloader, DownloadResult $result): bool;
}

View File

@ -0,0 +1,103 @@
<?php
declare(strict_types=1);
namespace StaticPHP\Artifact;
use Symfony\Component\Console\Input\InputOption;
/**
* Downloader options definition and extraction helper.
* Used to share download-related options between DownloadCommand and BuildTargetCommand.
*/
class DownloaderOptions
{
/**
* Option keys used by the downloader.
*/
private const array OPTION_KEYS = [
'with-php',
'parallel',
'retry',
'prefer-source',
'prefer-binary',
'prefer-pre-built',
'source-only',
'binary-only',
'ignore-cache',
'ignore-cache-sources',
'no-alt',
'no-shallow-clone',
'custom-url',
'custom-git',
'custom-local',
];
/**
* Returns all downloader-related InputOption definitions.
*
* @param string $prefix Optional prefix for option names (e.g., 'dl' becomes '--dl-parallel')
* @return InputOption[]
*/
public static function getConsoleOptions(string $prefix = ''): array
{
$p = $prefix ? "{$prefix}-" : '';
$shortP = $prefix ? null : 'P'; // Disable short options when using prefix
$shortR = $prefix ? null : 'R';
$shortp = $prefix ? null : 'p';
$shortU = $prefix ? null : 'U';
$shortG = $prefix ? null : 'G';
$shortL = $prefix ? null : 'L';
return [
// php version option
new InputOption("{$p}with-php", null, InputOption::VALUE_REQUIRED, 'PHP version in major.minor format (default 8.4)', '8.4'),
// download preference options
new InputOption("{$p}prefer-source", null, InputOption::VALUE_OPTIONAL, 'Prefer source downloads when both source and binary are available', false),
new InputOption("{$p}prefer-binary", $shortp, InputOption::VALUE_OPTIONAL, 'Prefer binary downloads when both source and binary are available', false),
new InputOption("{$p}source-only", null, InputOption::VALUE_OPTIONAL, 'Only download source artifacts, skip binary artifacts', false),
new InputOption("{$p}binary-only", null, InputOption::VALUE_OPTIONAL, 'Only download binary artifacts, skip source artifacts', false),
// download behavior options
new InputOption("{$p}parallel", $shortP, InputOption::VALUE_REQUIRED, 'Number of parallel downloads (default 1)', '1'),
new InputOption("{$p}retry", $shortR, InputOption::VALUE_REQUIRED, 'Number of download retries on failure (default 0)', '0'),
new InputOption("{$p}ignore-cache", null, InputOption::VALUE_OPTIONAL, 'Ignore some caches when downloading, comma separated, e.g "php-src,curl,openssl"', false),
new InputOption("{$p}no-alt", null, null, 'Do not use alternative mirror download artifacts for sources'),
new InputOption("{$p}no-shallow-clone", null, null, 'Do not clone shallowly repositories when downloading sources'),
// custom overrides
new InputOption("{$p}custom-url", $shortU, InputOption::VALUE_IS_ARRAY | InputOption::VALUE_REQUIRED, 'Specify custom source download url, e.g "php-src:https://example.com/php.tar.gz"'),
new InputOption("{$p}custom-git", $shortG, InputOption::VALUE_IS_ARRAY | InputOption::VALUE_REQUIRED, 'Specify custom source git url, e.g "php-src:master:https://github.com/php/php-src.git"'),
new InputOption("{$p}custom-local", $shortL, InputOption::VALUE_IS_ARRAY | InputOption::VALUE_REQUIRED, 'Specify custom local source path, e.g "php-src:/path/to/php-src"'),
// deprecated options (for backward compatibility)
new InputOption("{$p}prefer-pre-built", null, null, 'Deprecated, use `--' . $p . 'prefer-binary` instead'),
new InputOption("{$p}ignore-cache-sources", null, InputOption::VALUE_OPTIONAL, 'Deprecated, use `--' . $p . 'ignore-cache` instead', false),
];
}
/**
* Extract downloader-related options from console input options array.
* Handles both prefixed and non-prefixed options.
*
* @param array $allOptions All options from InputInterface::getOptions()
* @param string $prefix The prefix used when defining options (empty for DownloadCommand)
* @return array Options array suitable for ArtifactDownloader constructor
*/
public static function extractFromConsoleOptions(array $allOptions, string $prefix = ''): array
{
$result = [];
$p = $prefix ? "{$prefix}-" : '';
foreach (self::OPTION_KEYS as $key) {
$prefixedKey = $p . $key;
if (array_key_exists($prefixedKey, $allOptions)) {
// Store with non-prefixed key for ArtifactDownloader
$result[$key] = $allOptions[$prefixedKey];
}
}
return $result;
}
}

View File

@ -0,0 +1,63 @@
<?php
declare(strict_types=1);
namespace StaticPHP\Attribute\Artifact;
/**
* Attribute to register a hook that runs after binary extraction completes.
*
* This is useful for post-extraction tasks like:
* - Setting executable permissions
* - Creating symlinks
* - Platform-specific binary setup
* - Verifying binary integrity
*
* The callback method signature should be:
* ```php
* function(string $target_path, string $platform): void
* ```
*
* - `$target_path`: The directory where binary was extracted
* - `$platform`: The current platform string (e.g., 'linux-x86_64')
*
* Multiple hooks can be registered for the same artifact using IS_REPEATABLE.
* Use the `$platforms` parameter to filter which platforms the hook should run on.
*
* @example
* ```php
* #[AfterBinaryExtract('zig')]
* public function setupZig(string $target_path, string $platform): void
* {
* // Setup zig after extraction (runs on all platforms)
* chmod("{$target_path}/zig", 0755);
* }
*
* #[AfterBinaryExtract('pkg-config', ['linux-x86_64', 'linux-aarch64'])]
* public function setupPkgConfigLinux(string $target_path): void
* {
* // Linux-specific setup for pkg-config
* symlink("{$target_path}/bin/pkg-config", "/usr/local/bin/pkg-config");
* }
*
* #[AfterBinaryExtract('openssl', ['darwin-aarch64'])]
* public function patchOpensslMacM1(string $target_path): void
* {
* // macOS ARM64 specific patches
* }
* ```
*/
#[\Attribute(\Attribute::TARGET_METHOD | \Attribute::IS_REPEATABLE)]
readonly class AfterBinaryExtract
{
/**
* @param string $artifact_name The name of the artifact this hook applies to
* @param string[] $platforms Platform filters (empty array means all platforms).
* Valid values: 'linux-x86_64', 'linux-aarch64', 'darwin-x86_64',
* 'darwin-aarch64', 'windows-x86_64', etc.
*/
public function __construct(
public string $artifact_name,
public array $platforms = []
) {}
}

View File

@ -0,0 +1,50 @@
<?php
declare(strict_types=1);
namespace StaticPHP\Attribute\Artifact;
/**
* Attribute to register a hook that runs after source extraction completes.
*
* This is useful for post-extraction tasks like:
* - Patching source files
* - Removing unnecessary directories (tests, docs, etc.)
* - Applying platform-specific fixes
* - Renaming or reorganizing files
*
* The callback method signature should be:
* ```php
* function(string $target_path): void
* ```
*
* - `$target_path`: The directory where source was extracted
*
* Multiple hooks can be registered for the same artifact using IS_REPEATABLE.
*
* @example
* ```php
* #[AfterSourceExtract('php-src')]
* public function patchPhpSrc(string $target_path): void
* {
* // Apply patches after php-src is extracted
* FileSystem::replaceFileStr("{$target_path}/configure", 'old', 'new');
* }
*
* #[AfterSourceExtract('openssl')]
* public function cleanupOpenssl(string $target_path): void
* {
* // Remove unnecessary directories
* FileSystem::removeDir("{$target_path}/test");
* FileSystem::removeDir("{$target_path}/fuzz");
* }
* ```
*/
#[\Attribute(\Attribute::TARGET_METHOD | \Attribute::IS_REPEATABLE)]
readonly class AfterSourceExtract
{
/**
* @param string $artifact_name The name of the artifact this hook applies to
*/
public function __construct(public string $artifact_name) {}
}

View File

@ -0,0 +1,45 @@
<?php
declare(strict_types=1);
namespace StaticPHP\Attribute\Artifact;
/**
* Attribute to completely take over the binary extraction process for an artifact.
*
* When this attribute is applied to a method, the standard extraction logic is bypassed,
* and the annotated method is responsible for extracting the binary files.
*
* The callback method signature should be:
* ```php
* function(Artifact $artifact, string $source_file, string $target_path, string $platform): void
* ```
*
* - `$artifact`: The artifact instance being extracted
* - `$source_file`: Path to the downloaded archive or directory
* - `$target_path`: The resolved target extraction path from config
* - `$platform`: The current platform string (e.g., 'linux-x86_64', 'darwin-aarch64')
*
* @example
* ```php
* #[BinaryExtract('special-tool', ['linux-x86_64', 'linux-aarch64'])]
* public function extractSpecialTool(Artifact $artifact, string $source_file, string $target_path): void
* {
* // Custom binary extraction logic
* }
* ```
*/
#[\Attribute(\Attribute::TARGET_METHOD)]
readonly class BinaryExtract
{
/**
* @param string $artifact_name The name of the artifact this extraction handler applies to
* @param string[] $platforms Platform filters (empty array means all platforms).
* Valid values: 'linux-x86_64', 'linux-aarch64', 'darwin-x86_64',
* 'darwin-aarch64', 'windows-x86_64', etc.
*/
public function __construct(
public string $artifact_name,
public array $platforms = []
) {}
}

View File

@ -0,0 +1,11 @@
<?php
declare(strict_types=1);
namespace StaticPHP\Attribute\Artifact;
#[\Attribute(\Attribute::TARGET_METHOD | \Attribute::IS_REPEATABLE)]
class CustomBinary
{
public function __construct(public string $artifact_name, public array $support_os) {}
}

View File

@ -0,0 +1,11 @@
<?php
declare(strict_types=1);
namespace StaticPHP\Attribute\Artifact;
#[\Attribute(\Attribute::TARGET_METHOD | \Attribute::IS_REPEATABLE)]
class CustomSource
{
public function __construct(public string $artifact_name) {}
}

View File

@ -0,0 +1,39 @@
<?php
declare(strict_types=1);
namespace StaticPHP\Attribute\Artifact;
/**
* Attribute to completely take over the source extraction process for an artifact.
*
* When this attribute is applied to a method, the standard extraction logic is bypassed,
* and the annotated method is responsible for extracting the source files.
*
* The callback method signature should be:
* ```php
* function(Artifact $artifact, string $source_file, string $target_path): void
* ```
*
* - `$artifact`: The artifact instance being extracted
* - `$source_file`: Path to the downloaded archive or directory
* - `$target_path`: The resolved target extraction path from config
*
* @example
* ```php
* #[SourceExtract('weird-package')]
* public function extractWeirdPackage(Artifact $artifact, string $source_file, string $target_path): void
* {
* // Custom extraction logic
* shell_exec("tar -xf {$source_file} -C {$target_path} --strip-components=2");
* }
* ```
*/
#[\Attribute(\Attribute::TARGET_METHOD)]
readonly class SourceExtract
{
/**
* @param string $artifact_name The name of the artifact this extraction handler applies to
*/
public function __construct(public string $artifact_name) {}
}

View File

@ -0,0 +1,18 @@
<?php
declare(strict_types=1);
namespace StaticPHP\Attribute\Doctor;
#[\Attribute(\Attribute::TARGET_METHOD | \Attribute::IS_REPEATABLE)]
class CheckItem
{
public mixed $callback = null;
public function __construct(
public string $item_name,
public ?string $limit_os = null,
public int $level = 100,
public bool $manual = false,
) {}
}

View File

@ -0,0 +1,14 @@
<?php
declare(strict_types=1);
namespace StaticPHP\Attribute\Doctor;
/**
* Indicate a method is a fix item for doctor check.
*/
#[\Attribute(\Attribute::TARGET_METHOD)]
class FixItem
{
public function __construct(public string $name) {}
}

View File

@ -0,0 +1,11 @@
<?php
declare(strict_types=1);
namespace StaticPHP\Attribute\Doctor;
#[\Attribute(\Attribute::TARGET_CLASS | \Attribute::TARGET_METHOD)]
class OptionalCheck
{
public function __construct(public array $check) {}
}

View File

@ -0,0 +1,14 @@
<?php
declare(strict_types=1);
namespace StaticPHP\Attribute\Package;
/**
* Indicates that the annotated method should be executed after a specific stage of the build process for a given package.
*/
#[\Attribute(\Attribute::TARGET_METHOD | \Attribute::IS_REPEATABLE)]
readonly class AfterStage
{
public function __construct(public string $package_name, public string $stage, public ?string $only_when_package_resolved = null) {}
}

View File

@ -0,0 +1,14 @@
<?php
declare(strict_types=1);
namespace StaticPHP\Attribute\Package;
/**
* Indicates that the annotated method should be executed before a specific stage of the build process for a given package.
*/
#[\Attribute(\Attribute::TARGET_METHOD | \Attribute::IS_REPEATABLE)]
readonly class BeforeStage
{
public function __construct(public string $package_name, public string $stage, public ?string $only_when_package_resolved = null) {}
}

View File

@ -0,0 +1,17 @@
<?php
declare(strict_types=1);
namespace StaticPHP\Attribute\Package;
/**
* Mark a method as building for a specific OS.
*/
#[\Attribute(\Attribute::TARGET_METHOD | \Attribute::IS_REPEATABLE)]
readonly class BuildFor
{
/**
* @param 'Darwin'|'Linux'|'Windows' $os The operating system to build for PHP_OS_FAMILY
*/
public function __construct(public string $os) {}
}

View File

@ -0,0 +1,14 @@
<?php
declare(strict_types=1);
namespace StaticPHP\Attribute\Package;
/**
* Indicates a custom configure argument for PHP build process.
*/
#[\Attribute(\Attribute::TARGET_METHOD | \Attribute::IS_REPEATABLE)]
readonly class CustomPhpConfigureArg
{
public function __construct(public string $os = '') {}
}

View File

@ -0,0 +1,14 @@
<?php
declare(strict_types=1);
namespace StaticPHP\Attribute\Package;
/**
* Indicates that the annotated class defines a PHP extension.
*/
#[\Attribute(\Attribute::TARGET_CLASS | \Attribute::IS_REPEATABLE)]
readonly class Extension
{
public function __construct(public string $name) {}
}

View File

@ -0,0 +1,8 @@
<?php
declare(strict_types=1);
namespace StaticPHP\Attribute\Package;
#[\Attribute(\Attribute::TARGET_METHOD | \Attribute::IS_REPEATABLE)]
class Info {}

View File

@ -0,0 +1,8 @@
<?php
declare(strict_types=1);
namespace StaticPHP\Attribute\Package;
#[\Attribute(\Attribute::TARGET_METHOD)]
class InitPackage {}

View File

@ -0,0 +1,14 @@
<?php
declare(strict_types=1);
namespace StaticPHP\Attribute\Package;
/**
* Indicates that the annotated class defines a library package.
*/
#[\Attribute(\Attribute::TARGET_CLASS | \Attribute::IS_REPEATABLE)]
readonly class Library
{
public function __construct(public string $name) {}
}

View File

@ -0,0 +1,11 @@
<?php
declare(strict_types=1);
namespace StaticPHP\Attribute\Package;
#[\Attribute(\Attribute::TARGET_METHOD | \Attribute::IS_REPEATABLE)]
readonly class PatchBeforeBuild
{
public function __construct() {}
}

View File

@ -0,0 +1,11 @@
<?php
declare(strict_types=1);
namespace StaticPHP\Attribute\Package;
/**
* Indicates that the annotated method is responsible for initializing the build process for a target package.
*/
#[\Attribute(\Attribute::TARGET_METHOD)]
class ResolveBuild {}

View File

@ -0,0 +1,14 @@
<?php
declare(strict_types=1);
namespace StaticPHP\Attribute\Package;
/**
* Indicates that the annotated method defines a specific stage in a package.
*/
#[\Attribute(\Attribute::TARGET_METHOD | \Attribute::IS_REPEATABLE)]
readonly class Stage
{
public function __construct(public string $name) {}
}

View File

@ -0,0 +1,14 @@
<?php
declare(strict_types=1);
namespace StaticPHP\Attribute\Package;
/**
* Indicates that the annotated class defines a target.
*/
#[\Attribute(\Attribute::TARGET_CLASS | \Attribute::IS_REPEATABLE)]
readonly class Target
{
public function __construct(public string $name) {}
}

View File

@ -0,0 +1,8 @@
<?php
declare(strict_types=1);
namespace StaticPHP\Attribute\Package;
#[\Attribute(\Attribute::TARGET_METHOD | \Attribute::IS_REPEATABLE)]
class Validate {}

View File

@ -0,0 +1,11 @@
<?php
declare(strict_types=1);
namespace StaticPHP\Attribute;
#[\Attribute(\Attribute::TARGET_METHOD | \Attribute::IS_REPEATABLE)]
readonly class PatchDescription
{
public function __construct(public string $description) {}
}

View File

@ -0,0 +1,180 @@
<?php
declare(strict_types=1);
namespace StaticPHP\Command;
use StaticPHP\DI\ApplicationContext;
use StaticPHP\Exception\ExceptionHandler;
use StaticPHP\Exception\SPCException;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
abstract class BaseCommand extends Command
{
/**
* The message of the day (MOTD) displayed when the command is run.
* You can customize this to show your application's name and version if you are using SPC in vendor mode.
*/
public static string $motd = ' ____ _ _ _ ____ _ _ ____
/ ___|| |_ __ _| |_(_) ___| _ \| | | | _ \
\___ \| __/ _` | __| |/ __| |_) | |_| | |_) |
___) | || (_| | |_| | (__| __/| _ | __/
|____/ \__\__,_|\__|_|\___|_| |_| |_|_| v{version}
';
protected bool $no_motd = false;
protected InputInterface $input;
protected OutputInterface $output;
public function __construct(?string $name = null)
{
parent::__construct($name);
$this->addOption('debug', null, null, '(deprecated) Enable debug mode');
$this->addOption('no-motd', null, null, 'Disable motd');
}
public function initialize(InputInterface $input, OutputInterface $output): void
{
$this->input = $input;
$this->output = $output;
// Bind command context to ApplicationContext
ApplicationContext::bindCommandContext($input, $output);
if ($input->getOption('no-motd')) {
$this->no_motd = true;
}
set_error_handler(static function ($error_no, $error_msg, $error_file, $error_line) {
$tips = [
E_WARNING => ['PHP Warning: ', 'warning'],
E_NOTICE => ['PHP Notice: ', 'notice'],
E_USER_ERROR => ['PHP Error: ', 'error'],
E_USER_WARNING => ['PHP Warning: ', 'warning'],
E_USER_NOTICE => ['PHP Notice: ', 'notice'],
E_RECOVERABLE_ERROR => ['PHP Recoverable Error: ', 'error'],
E_DEPRECATED => ['PHP Deprecated: ', 'notice'],
E_USER_DEPRECATED => ['PHP User Deprecated: ', 'notice'],
];
$level_tip = $tips[$error_no] ?? ['PHP Unknown: ', 'error'];
$error = $level_tip[0] . $error_msg . ' in ' . $error_file . ' on ' . $error_line;
logger()->{$level_tip[1]}($error);
// 如果 return false 则错误会继续递交给 PHP 标准错误处理
return true;
});
$version = $this->getVersionWithCommit();
if (!$this->no_motd) {
echo str_replace('{version}', $version, self::$motd);
}
}
abstract public function handle(): int;
protected function execute(InputInterface $input, OutputInterface $output): int
{
try {
// handle verbose option
$level = match ($this->output->getVerbosity()) {
OutputInterface::VERBOSITY_VERBOSE => 'info',
OutputInterface::VERBOSITY_VERY_VERBOSE, OutputInterface::VERBOSITY_DEBUG => 'debug',
default => 'warning',
};
logger()->setLevel($level);
// ansi
if ($this->input->getOption('no-ansi')) {
logger()->setDecorated(false);
}
// Set debug mode in ApplicationContext
$isDebug = $this->output->getVerbosity() >= OutputInterface::VERBOSITY_VERY_VERBOSE;
ApplicationContext::setDebug($isDebug);
// show raw argv list for logger()->debug
logger()->debug('argv: ' . implode(' ', $_SERVER['argv']));
return $this->handle();
} /* @noinspection PhpRedundantCatchClauseInspection */ catch (SPCException $e) {
// Handle SPCException and log it
ExceptionHandler::handleSPCException($e);
return static::FAILURE;
} catch (\Throwable $e) {
// Handle any other exceptions
ExceptionHandler::handleDefaultException($e);
return static::FAILURE;
}
}
protected function getOption(string $name): mixed
{
return $this->input->getOption($name);
}
protected function getArgument(string $name): mixed
{
return $this->input->getArgument($name);
}
/**
* Get version string with git commit short ID if available.
*/
private function getVersionWithCommit(): string
{
$version = $this->getApplication()->getVersion();
// Don't show commit ID when running in phar
if (\Phar::running()) {
return $version;
}
$commitId = $this->getGitCommitShortId();
if ($commitId) {
return "{$version} ({$commitId})";
}
return $version;
}
/**
* Get git commit short ID without executing git command.
*/
private function getGitCommitShortId(): ?string
{
try {
$gitDir = ROOT_DIR . '/.git';
if (!is_dir($gitDir)) {
return null;
}
$headFile = $gitDir . '/HEAD';
if (!file_exists($headFile)) {
return null;
}
$head = trim(file_get_contents($headFile));
// If HEAD contains 'ref:', it's a branch reference
if (str_starts_with($head, 'ref: ')) {
$ref = substr($head, 5);
$refFile = $gitDir . '/' . $ref;
if (file_exists($refFile)) {
$commit = trim(file_get_contents($refFile));
return substr($commit, 0, 7);
}
} else {
// HEAD contains the commit hash directly (detached HEAD)
return substr($head, 0, 7);
}
} catch (\Throwable) {
// Silently fail if we can't read git info
}
return null;
}
}

View File

@ -0,0 +1,29 @@
<?php
declare(strict_types=1);
namespace StaticPHP\Command;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Input\InputArgument;
#[AsCommand('build:libs')]
class BuildLibsCommand extends BaseCommand
{
public function configure()
{
$this->addArgument('libraries', InputArgument::REQUIRED, 'The library packages will be compiled, comma separated');
}
public function handle(): int
{
$libs = parse_comma_list($this->input->getArgument('libraries'));
$installer = new \StaticPHP\Package\PackageInstaller($this->input->getOptions());
foreach ($libs as $lib) {
$installer->addBuildPackage($lib);
}
$installer->run();
return static::SUCCESS;
}
}

View File

@ -0,0 +1,56 @@
<?php
declare(strict_types=1);
namespace StaticPHP\Command;
use StaticPHP\Artifact\DownloaderOptions;
use StaticPHP\Package\PackageInstaller;
use StaticPHP\Package\PackageLoader;
use StaticPHP\Util\V2CompatLayer;
use Symfony\Component\Console\Input\InputOption;
class BuildTargetCommand extends BaseCommand
{
public function __construct(private readonly string $target, ?string $description = null)
{
parent::__construct("build:{$target}");
if ($target === 'php') {
$this->setAliases(['build']);
}
$this->setDescription($description ?? "Build {$target} target from source");
$pkg = PackageLoader::getTargetPackage($target);
$this->getDefinition()->addOptions($pkg->_exportBuildOptions());
$this->getDefinition()->addArguments($pkg->_exportBuildArguments());
// Builder options
$this->getDefinition()->addOptions([
new InputOption('with-suggests', ['L', 'E'], null, 'Resolve and install suggested packages as well'),
new InputOption('with-packages', null, InputOption::VALUE_REQUIRED, 'add additional packages to install/build, comma separated', ''),
new InputOption('no-download', null, null, 'Skip downloading artifacts (use existing cached files)'),
...V2CompatLayer::getLegacyBuildOptions(),
]);
// Downloader options (with 'dl-' prefix to avoid conflicts)
$this->getDefinition()->addOptions(DownloaderOptions::getConsoleOptions('dl'));
}
public function handle(): int
{
// resolve legacy options to new options
V2CompatLayer::convertOptions($this->input);
$starttime = microtime(true);
// run installer
$installer = new PackageInstaller($this->input->getOptions());
$installer->addBuildPackage($this->target);
$installer->run();
$usedtime = round(microtime(true) - $starttime, 1);
$this->output->writeln("\n━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━");
$this->output->writeln("<info>✔ BUILD SUCCESSFUL ({$usedtime} s)</info>");
$this->output->writeln("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n");
return static::SUCCESS;
}
}

View File

@ -0,0 +1,34 @@
<?php
declare(strict_types=1);
namespace StaticPHP\Command;
use StaticPHP\Doctor\Doctor;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Input\InputOption;
#[AsCommand('doctor', 'Diagnose whether the current environment can compile normally')]
class DoctorCommand extends BaseCommand
{
public function configure(): void
{
$this->addOption('auto-fix', null, InputOption::VALUE_OPTIONAL, 'Automatically fix failed items (if possible)', false);
}
public function handle(): int
{
$fix_policy = match ($this->input->getOption('auto-fix')) {
'never' => FIX_POLICY_DIE,
true, null => FIX_POLICY_AUTOFIX,
default => FIX_POLICY_PROMPT,
};
$doctor = new Doctor($this->output, $fix_policy);
if ($doctor->checkAll()) {
$this->output->writeln('<info>Doctor check complete !</info>');
return static::SUCCESS;
}
return static::FAILURE;
}
}

View File

@ -0,0 +1,116 @@
<?php
declare(strict_types=1);
namespace StaticPHP\Command;
use StaticPHP\Artifact\ArtifactDownloader;
use StaticPHP\Artifact\DownloaderOptions;
use StaticPHP\Package\PackageLoader;
use StaticPHP\Util\DependencyResolver;
use StaticPHP\Util\FileSystem;
use StaticPHP\Util\InteractiveTerm;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Input\InputArgument;
use Symfony\Component\Console\Input\InputOption;
#[AsCommand('download')]
class DownloadCommand extends BaseCommand
{
public function configure(): void
{
$this->addArgument('artifacts', InputArgument::OPTIONAL, 'Specific artifacts to download, comma separated, e.g "php-src,openssl,curl"');
// 2.x compatible options
$this->addOption('shallow-clone', null, null, '(deprecated) Clone shallowly repositories when downloading sources');
$this->addOption('for-extensions', 'e', InputOption::VALUE_REQUIRED, 'Fetch by extensions, e.g "openssl,mbstring"');
$this->addOption('for-libs', 'l', InputOption::VALUE_REQUIRED, 'Fetch by libraries, e.g "libcares,openssl,onig"');
$this->addOption('without-suggests', null, null, 'Do not fetch suggested sources when using --for-extensions');
// download command specific options
$this->addOption('clean', null, null, 'Clean old download cache and source before fetch');
$this->addOption('for-packages', null, InputOption::VALUE_REQUIRED, 'Fetch by packages, e.g "php,libssl,libcurl"');
// shared downloader options (no prefix for download command)
$this->getDefinition()->addOptions(DownloaderOptions::getConsoleOptions());
}
public function handle(): int
{
// handle --clean option
if ($this->getOption('clean')) {
return $this->handleClean();
}
$downloader = new ArtifactDownloader(DownloaderOptions::extractFromConsoleOptions($this->input->getOptions()));
// arguments
if ($artifacts = $this->getArgument('artifacts')) {
$artifacts = parse_comma_list($artifacts);
$downloader->addArtifacts($artifacts);
}
// for-extensions
$packages = [];
if ($exts = $this->getOption('for-extensions')) {
$packages = array_map(fn ($x) => "ext-{$x}", parse_extension_list($exts));
// when using for-extensions, also include php package
array_unshift($packages, 'php');
array_unshift($packages, 'php-micro');
array_unshift($packages, 'php-embed');
array_unshift($packages, 'php-fpm');
}
// for-libs / for-packages
if ($libs = $this->getOption('for-libs')) {
$packages = array_merge($packages, parse_comma_list($libs));
}
if ($libs = $this->getOption('for-packages')) {
$packages = array_merge($packages, parse_comma_list($libs));
}
// resolve package dependencies and get artifacts directly
$resolved = DependencyResolver::resolve($packages, [], !$this->getOption('without-suggests'));
foreach ($resolved as $pkg_name) {
$pkg = PackageLoader::getPackage($pkg_name);
if ($artifact = $pkg->getArtifact()) {
$downloader->add($artifact);
}
}
$starttime = microtime(true);
$downloader->download();
$endtime = microtime(true);
$elapsed = round($endtime - $starttime);
$this->output->writeln('');
$this->output->writeln('<info>Download completed in ' . $elapsed . ' s.</info>');
return static::SUCCESS;
}
private function handleClean(): int
{
logger()->warning('You are doing some operations that are not recoverable:');
logger()->warning('- Removing directory: ' . SOURCE_PATH);
logger()->warning('- Removing directory: ' . DOWNLOAD_PATH);
logger()->warning('- Removing directory: ' . BUILD_ROOT_PATH);
logger()->alert('I will remove these directories after 5 seconds!');
sleep(5);
if (is_dir(SOURCE_PATH)) {
InteractiveTerm::indicateProgress('Removing: ' . SOURCE_PATH);
FileSystem::removeDir(SOURCE_PATH);
InteractiveTerm::finish('Removed: ' . SOURCE_PATH);
}
if (is_dir(DOWNLOAD_PATH)) {
InteractiveTerm::indicateProgress('Removing: ' . DOWNLOAD_PATH);
FileSystem::removeDir(DOWNLOAD_PATH);
InteractiveTerm::finish('Removed: ' . DOWNLOAD_PATH);
}
if (is_dir(BUILD_ROOT_PATH)) {
InteractiveTerm::indicateProgress('Removing: ' . BUILD_ROOT_PATH);
FileSystem::removeDir(BUILD_ROOT_PATH);
InteractiveTerm::finish('Removed: ' . BUILD_ROOT_PATH);
}
InteractiveTerm::notice('Clean completed.');
return static::SUCCESS;
}
}

View File

@ -0,0 +1,108 @@
<?php
declare(strict_types=1);
namespace StaticPHP\Command;
use StaticPHP\Artifact\ArtifactCache;
use StaticPHP\Artifact\ArtifactExtractor;
use StaticPHP\Artifact\ArtifactLoader;
use StaticPHP\DI\ApplicationContext;
use StaticPHP\Package\PackageLoader;
use StaticPHP\Util\DependencyResolver;
use StaticPHP\Util\InteractiveTerm;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Input\InputArgument;
use Symfony\Component\Console\Input\InputOption;
use ZM\Logger\ConsoleColor;
#[AsCommand('extract')]
class ExtractCommand extends BaseCommand
{
public function configure(): void
{
$this->setDescription('Extract downloaded artifacts to their target locations');
$this->addArgument('artifacts', InputArgument::OPTIONAL, 'Specific artifacts to extract, comma separated, e.g "php-src,openssl,curl"');
$this->addOption('for-extensions', 'e', InputOption::VALUE_REQUIRED, 'Extract artifacts for extensions, e.g "openssl,mbstring"');
$this->addOption('for-libs', 'l', InputOption::VALUE_REQUIRED, 'Extract artifacts for libraries, e.g "libcares,openssl"');
$this->addOption('for-packages', null, InputOption::VALUE_REQUIRED, 'Extract artifacts for packages, e.g "php,libssl,libcurl"');
$this->addOption('without-suggests', null, null, 'Do not include suggested packages when using --for-extensions');
$this->addOption('force-source', null, null, 'Force extract source even if binary is available');
}
public function handle(): int
{
$cache = ApplicationContext::get(ArtifactCache::class);
$extractor = new ArtifactExtractor($cache);
$force_source = (bool) $this->getOption('force-source');
$artifacts = [];
// Direct artifact names
if ($artifact_arg = $this->getArgument('artifacts')) {
$artifact_names = parse_comma_list($artifact_arg);
foreach ($artifact_names as $name) {
$artifact = ArtifactLoader::getArtifactInstance($name);
if ($artifact === null) {
$this->output->writeln("<error>Artifact '{$name}' not found.</error>");
return static::FAILURE;
}
$artifacts[$name] = $artifact;
}
}
// Resolve packages and get their artifacts
$packages = [];
if ($exts = $this->getOption('for-extensions')) {
$packages = array_map(fn ($x) => "ext-{$x}", parse_extension_list($exts));
// Include php package when using for-extensions
array_unshift($packages, 'php');
}
if ($libs = $this->getOption('for-libs')) {
$packages = array_merge($packages, parse_comma_list($libs));
}
if ($pkgs = $this->getOption('for-packages')) {
$packages = array_merge($packages, parse_comma_list($pkgs));
}
if (!empty($packages)) {
$resolved = DependencyResolver::resolve($packages, [], !$this->getOption('without-suggests'));
foreach ($resolved as $pkg_name) {
$pkg = PackageLoader::getPackage($pkg_name);
if ($artifact = $pkg->getArtifact()) {
$artifacts[$artifact->getName()] = $artifact;
}
}
}
if (empty($artifacts)) {
$this->output->writeln('<comment>No artifacts specified. Use artifact names or --for-extensions/--for-libs/--for-packages options.</comment>');
$this->output->writeln('');
$this->output->writeln('Examples:');
$this->output->writeln(' spc extract php-src,openssl');
$this->output->writeln(' spc extract --for-extensions=openssl,mbstring');
$this->output->writeln(' spc extract --for-libs=libcurl,libssl');
return static::SUCCESS;
}
// make php-src always extracted first
uksort($artifacts, fn ($a, $b) => $a === 'php-src' ? -1 : ($b === 'php-src' ? 1 : 0));
try {
InteractiveTerm::notice('Extracting ' . count($artifacts) . ' artifacts: ' . implode(',', array_map(fn ($x) => ConsoleColor::yellow($x->getName()), $artifacts)) . '...');
InteractiveTerm::indicateProgress('Extracting artifacts');
foreach ($artifacts as $artifact) {
InteractiveTerm::setMessage('Extracting artifact: ' . ConsoleColor::green($artifact->getName()));
$extractor->extract($artifact, $force_source);
}
InteractiveTerm::finish('Extracted all artifacts successfully.');
} catch (\Exception $e) {
InteractiveTerm::finish('Extraction failed!', false);
throw $e;
}
return static::SUCCESS;
}
}

View File

@ -0,0 +1,27 @@
<?php
declare(strict_types=1);
namespace StaticPHP\Command;
use StaticPHP\DI\ApplicationContext;
use StaticPHP\Package\PackageInstaller;
use Symfony\Component\Console\Attribute\AsCommand;
#[AsCommand('install-pkg')]
class InstallPackageCommand extends BaseCommand
{
public function configure()
{
$this->addArgument('package', null, 'The package to install (name or path)');
}
public function handle(): int
{
ApplicationContext::set('elephant', true);
$installer = new PackageInstaller([...$this->input->getOptions(), 'dl-prefer-binary' => true]);
$installer->addInstallPackage($this->input->getArgument('package'));
$installer->run(true, true);
return static::SUCCESS;
}
}

View File

@ -0,0 +1,56 @@
<?php
declare(strict_types=1);
namespace StaticPHP\Command;
use StaticPHP\Util\SPCConfigUtil;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Input\InputArgument;
use Symfony\Component\Console\Input\InputOption;
#[AsCommand('spc-config', 'Build dependencies')]
class SPCConfigCommand extends BaseCommand
{
protected bool $no_motd = true;
public function configure(): void
{
$this->addArgument('extensions', InputArgument::OPTIONAL, 'The extensions will be compiled, comma separated');
$this->addOption('with-libs', null, InputOption::VALUE_REQUIRED, 'add additional libraries, comma separated', '');
$this->addOption('with-suggested-libs', 'L', null, 'Build with suggested libs for selected exts and libs');
$this->addOption('with-suggests', null, null, 'Build with suggested packages for selected exts and libs');
$this->addOption('with-suggested-exts', 'E', null, 'Build with suggested extensions for selected exts');
$this->addOption('includes', null, null, 'Add additional include path');
$this->addOption('libs', null, null, 'Add additional libs path');
$this->addOption('libs-only-deps', null, null, 'Output dependent libraries with -l prefix');
$this->addOption('absolute-libs', null, null, 'Output absolute paths for libraries');
$this->addOption('no-php', null, null, 'Link to PHP library');
}
public function handle(): int
{
// transform string to array
$libraries = array_map('trim', array_filter(explode(',', $this->getOption('with-libs'))));
// transform string to array
$extensions = $this->getArgument('extensions') ? parse_extension_list($this->getArgument('extensions')) : [];
$include_suggests = $this->getOption('with-suggests') ?: $this->getOption('with-suggested-libs') || $this->getOption('with-suggested-exts');
$util = new SPCConfigUtil(options: [
'no_php' => $this->getOption('no-php'),
'libs_only_deps' => $this->getOption('libs-only-deps'),
'absolute_libs' => $this->getOption('absolute-libs'),
]);
$packages = array_merge(array_map(fn ($x) => "ext-{$x}", $extensions), $libraries);
$config = $util->config($packages, $include_suggests);
$this->output->writeln(match (true) {
$this->getOption('includes') => $config['cflags'],
$this->getOption('libs-only-deps') => $config['libs'],
$this->getOption('libs') => "{$config['ldflags']} {$config['libs']}",
default => "{$config['cflags']} {$config['ldflags']} {$config['libs']}",
});
return 0;
}
}

View File

@ -0,0 +1,68 @@
<?php
declare(strict_types=1);
namespace StaticPHP\Config;
use StaticPHP\Exception\WrongUsageException;
class ArtifactConfig
{
private static array $artifact_configs = [];
public static function loadFromDir(string $dir): void
{
if (!is_dir($dir)) {
throw new WrongUsageException("Directory {$dir} does not exist, cannot load artifact config.");
}
$files = glob("{$dir}/artifact.*.json");
if (is_array($files)) {
foreach ($files as $file) {
self::loadFromFile($file);
}
}
if (file_exists("{$dir}/artifact.json")) {
self::loadFromFile("{$dir}/artifact.json");
}
}
/**
* Load artifact configurations from a specified JSON file.
*/
public static function loadFromFile(string $file): void
{
$content = file_get_contents($file);
if ($content === false) {
throw new WrongUsageException("Failed to read artifact config file: {$file}");
}
$data = json_decode($content, true);
if (!is_array($data)) {
throw new WrongUsageException("Invalid JSON format in artifact config file: {$file}");
}
ConfigValidator::validateAndLintArtifacts(basename($file), $data);
foreach ($data as $artifact_name => $config) {
self::$artifact_configs[$artifact_name] = $config;
}
}
/**
* Get all loaded artifact configurations.
*
* @return array<string, array> an associative array of artifact configurations
*/
public static function getAll(): array
{
return self::$artifact_configs;
}
/**
* Get the configuration for a specific artifact by name.
*
* @param string $artifact_name the name of the artifact
* @return null|array the configuration array for the specified artifact, or null if not found
*/
public static function get(string $artifact_name): ?array
{
return self::$artifact_configs[$artifact_name] ?? null;
}
}

View File

@ -0,0 +1,52 @@
<?php
declare(strict_types=1);
namespace StaticPHP\Config;
enum ConfigType
{
public const string LIST_ARRAY = 'list_array';
public const string ASSOC_ARRAY = 'assoc_array';
public const string STRING = 'string';
public const string BOOL = 'bool';
public const array PACKAGE_TYPES = [
'library',
'php-extension',
'target',
'virtual-target',
];
public static function validateLicenseField(mixed $value): bool
{
if (is_list_array($value)) {
foreach ($value as $item) {
if (!self::validateLicenseField($item)) {
return false;
}
}
return true;
}
if (!is_assoc_array($value)) {
return false;
}
if (!isset($value['type'])) {
return false;
}
return match ($value['type']) {
'file' => match (true) {
!isset($value['path']), !is_string($value['path']) && !is_array($value['path']) => false,
default => true,
},
'text' => match (true) {
!isset($value['text']), !is_string($value['text']) => false,
default => true,
},
default => false,
};
}
}

View File

@ -0,0 +1,370 @@
<?php
declare(strict_types=1);
namespace StaticPHP\Config;
use StaticPHP\Exception\ValidationException;
class ConfigValidator
{
/**
* Global field type definitions
* Maps field names to their expected types and validation rules
* Note: This only includes fields used in config files (source.json, lib.json, ext.json, pkg.json, pre-built.json)
*/
public const array PACKAGE_FIELD_TYPES = [
// package fields
'type' => ConfigType::STRING,
'depends' => ConfigType::LIST_ARRAY, // @
'suggests' => ConfigType::LIST_ARRAY, // @
'artifact' => ConfigType::STRING,
'license' => [ConfigType::class, 'validateLicenseField'],
'lang' => ConfigType::STRING,
'frameworks' => ConfigType::LIST_ARRAY, // @
// php-extension type fields
'php-extension' => ConfigType::ASSOC_ARRAY,
'zend-extension' => ConfigType::BOOL,
'support' => ConfigType::ASSOC_ARRAY,
'arg-type' => ConfigType::STRING,
'build-shared' => ConfigType::BOOL,
'build-static' => ConfigType::BOOL,
'build-with-php' => ConfigType::BOOL,
'notes' => ConfigType::BOOL,
// library and target fields
'headers' => ConfigType::LIST_ARRAY, // @
'static-libs' => ConfigType::LIST_ARRAY, // @
'pkg-configs' => ConfigType::LIST_ARRAY,
'static-bins' => ConfigType::LIST_ARRAY, // @
];
public const array PACKAGE_FIELDS = [
'type' => true,
'depends' => false, // @
'suggests' => false, // @
'artifact' => false,
'license' => false,
'lang' => false,
'frameworks' => false, // @
// php-extension type fields
'php-extension' => false,
// library and target fields
'headers' => false, // @
'static-libs' => false, // @
'pkg-configs' => false,
'static-bins' => false, // @
];
public const array SUFFIX_ALLOWED_FIELDS = [
'depends',
'suggests',
'headers',
'static-libs',
'static-bins',
];
public const array PHP_EXTENSION_FIELDS = [
'zend-extension' => false,
'support' => false,
'arg-type' => false, // @
'build-shared' => false,
'build-static' => false,
'build-with-php' => false,
'notes' => false,
];
public const array ARTIFACT_TYPE_FIELDS = [ // [required_fields, optional_fields]
'filelist' => [['url', 'regex'], ['extract']],
'git' => [['url', 'rev'], ['extract', 'submodules']],
'ghtagtar' => [['repo'], ['extract', 'prefer-stable', 'match']],
'ghtar' => [['repo'], ['extract', 'prefer-stable', 'match']],
'ghrel' => [['repo', 'match'], ['extract', 'prefer-stable']],
'url' => [['url'], ['filename', 'extract', 'version']],
'bitbuckettag' => [['repo'], ['extract']],
'local' => [['dirname'], ['extract']],
'pie' => [['repo'], ['extract']],
'php-release' => [[], ['extract']],
'custom' => [[], ['func']],
];
/**
* Validate and standardize artifacts configuration data.
*
* @param string $config_file_name Name of the configuration file (for error messages)
* @param mixed $data The configuration data to validate
*/
public static function validateAndLintArtifacts(string $config_file_name, mixed &$data): void
{
if (!is_array($data)) {
throw new ValidationException("{$config_file_name} is broken");
}
foreach ($data as $name => $artifact) {
foreach ($artifact as $k => $v) {
// check source field
if ($k === 'source' || $k === 'source-mirror') {
// source === custom is allowed
if ($v === 'custom') {
continue;
}
// expand string to url type (start with http:// or https://)
if (is_string($v) && (str_starts_with($v, 'http://') || str_starts_with($v, 'https://'))) {
$data[$name][$k] = [
'type' => 'url',
'url' => $v,
];
continue;
}
// source: object with type field
if (is_assoc_array($v)) {
self::validateArtifactObjectField($name, $v);
}
continue;
}
// check binary field
if ($k === 'binary') {
// binary === custom is allowed
if ($v === 'custom') {
$data[$name][$k] = [
'linux-x86_64' => ['type' => 'custom'],
'linux-aarch64' => ['type' => 'custom'],
'windows-x86_64' => ['type' => 'custom'],
'macos-x86_64' => ['type' => 'custom'],
'macos-aarch64' => ['type' => 'custom'],
];
continue;
}
// TODO: expand hosted to static-php hosted download urls
if ($v === 'hosted') {
continue;
}
if (is_assoc_array($v)) {
foreach ($v as $platform => $v_obj) {
self::validatePlatformString($platform);
// expand string to url type (start with http:// or https://)
if (is_string($v_obj) && (str_starts_with($v_obj, 'http://') || str_starts_with($v_obj, 'https://'))) {
$data[$name][$k][$platform] = [
'type' => 'url',
'url' => $v_obj,
];
continue;
}
// binary: object with type field
if (is_assoc_array($v_obj)) {
self::validateArtifactObjectField("{$name}::{$platform}", $v_obj);
}
}
}
}
}
}
}
/**
* Validate packages configuration data.
*
* @param string $config_file_name Name of the configuration file (for error messages)
* @param mixed $data The configuration data to validate
*/
public static function validateAndLintPackages(string $config_file_name, mixed &$data): void
{
if (!is_array($data)) {
throw new ValidationException("{$config_file_name} is broken");
}
foreach ($data as $name => $pkg) {
if (!is_assoc_array($pkg)) {
throw new ValidationException("Package [{$name}] in {$config_file_name} is not a valid associative array");
}
// check if package has valid type
if (!isset($pkg['type']) || !in_array($pkg['type'], ConfigType::PACKAGE_TYPES)) {
throw new ValidationException("Package [{$name}] in {$config_file_name} has invalid or missing 'type' field");
}
// validate basic fields using unified method
self::validatePackageFields($name, $pkg);
// validate list of suffix-allowed fields
$suffixes = ['', '@windows', '@unix', '@macos', '@linux'];
$fields = self::SUFFIX_ALLOWED_FIELDS;
self::validateSuffixAllowedFields($name, $pkg, $fields, $suffixes);
// check if "library|target" package has artifact field for target and library types
if (in_array($pkg['type'], ['target', 'library']) && !isset($pkg['artifact'])) {
throw new ValidationException("Package [{$name}] in {$config_file_name} of type '{$pkg['type']}' must have an 'artifact' field");
}
// check if "php-extension" package has php-extension specific fields and validate inside
if ($pkg['type'] === 'php-extension') {
self::validatePhpExtensionFields($name, $pkg);
}
// check for unknown fields
self::validateNoInvalidFields('package', $name, $pkg, array_keys(self::PACKAGE_FIELD_TYPES));
}
}
/**
* Validate platform string format.
*
* @param string $platform Platform string, like windows-x86_64
*/
public static function validatePlatformString(string $platform): void
{
$valid_platforms = ['windows', 'linux', 'macos'];
$valid_arch = ['x86_64', 'aarch64'];
$parts = explode('-', $platform);
if (count($parts) !== 2) {
throw new ValidationException("Invalid platform format '{$platform}', expected format 'os-arch'");
}
[$os, $arch] = $parts;
if (!in_array($os, $valid_platforms)) {
throw new ValidationException("Invalid platform OS '{$os}' in platform '{$platform}'");
}
if (!in_array($arch, $valid_arch)) {
throw new ValidationException("Invalid platform architecture '{$arch}' in platform '{$platform}'");
}
}
/**
* Validate an artifact download object field.
*
* @param string $item_name Artifact name (for error messages)
* @param array $data Artifact source object data
*/
private static function validateArtifactObjectField(string $item_name, array $data): void
{
if (!isset($data['type']) || !is_string($data['type'])) {
throw new ValidationException("Artifact source object must have a valid 'type' field");
}
$type = $data['type'];
if (!isset(self::ARTIFACT_TYPE_FIELDS[$type])) {
throw new ValidationException("Artifact source object has unknown type '{$type}'");
}
[$required_fields, $optional_fields] = self::ARTIFACT_TYPE_FIELDS[$type];
// check required fields
foreach ($required_fields as $field) {
if (!isset($data[$field])) {
throw new ValidationException("Artifact source object of type '{$type}' must have required field '{$field}'");
}
}
// check for unknown fields
$allowed_fields = array_merge(['type'], $required_fields, $optional_fields);
self::validateNoInvalidFields('artifact object', $item_name, $data, $allowed_fields);
}
/**
* Unified method to validate config fields based on field definitions
*
* @param string $package_name Package name
* @param mixed $pkg The package configuration array
*/
private static function validatePackageFields(string $package_name, mixed $pkg): void
{
foreach (self::PACKAGE_FIELDS as $field => $required) {
if ($required && !isset($pkg[$field])) {
throw new ValidationException("Package {$package_name} must have [{$field}] field");
}
if (isset($pkg[$field])) {
self::validatePackageFieldType($field, $pkg[$field], $package_name);
}
}
}
/**
* Validate a field based on its global type definition
*
* @param string $field Field name
* @param mixed $value Field value
* @param string $package_name Package name (for error messages)
*/
private static function validatePackageFieldType(string $field, mixed $value, string $package_name): void
{
// Check if field exists in FIELD_TYPES
if (!isset(self::PACKAGE_FIELD_TYPES[$field])) {
// Try to strip suffix and check base field name
$suffixes = ['@windows', '@unix', '@macos', '@linux'];
$base_field = $field;
foreach ($suffixes as $suffix) {
if (str_ends_with($field, $suffix)) {
$base_field = substr($field, 0, -strlen($suffix));
break;
}
}
if (!isset(self::PACKAGE_FIELD_TYPES[$base_field])) {
// Unknown field is not allowed - strict validation
throw new ValidationException("Package {$package_name} has unknown field [{$field}]");
}
// Use base field type for validation
$expected_type = self::PACKAGE_FIELD_TYPES[$base_field];
} else {
$expected_type = self::PACKAGE_FIELD_TYPES[$field];
}
match ($expected_type) {
ConfigType::STRING => is_string($value) ?: throw new ValidationException("Package {$package_name} [{$field}] must be string"),
ConfigType::BOOL => is_bool($value) ?: throw new ValidationException("Package {$package_name} [{$field}] must be boolean"),
ConfigType::LIST_ARRAY => is_list_array($value) ?: throw new ValidationException("Package {$package_name} [{$field}] must be a list"),
ConfigType::ASSOC_ARRAY => is_assoc_array($value) ?: throw new ValidationException("Package {$package_name} [{$field}] must be an object"),
default => $expected_type($value) ?: throw new ValidationException("Package {$package_name} [{$field}] has invalid type specification"),
};
}
/**
* Validate that fields with suffixes are list arrays
*/
private static function validateSuffixAllowedFields(int|string $name, mixed $item, array $fields, array $suffixes): void
{
foreach ($fields as $field) {
foreach ($suffixes as $suffix) {
$key = $field . $suffix;
if (isset($item[$key])) {
self::validatePackageFieldType($key, $item[$key], $name);
}
}
}
}
/**
* Validate php-extension specific fields for php-extension package
*/
private static function validatePhpExtensionFields(int|string $name, mixed $pkg): void
{
if (!isset($pkg['php-extension'])) {
return;
}
if (!is_assoc_array($pkg['php-extension'])) {
throw new ValidationException("Package {$name} [php-extension] must be an object");
}
foreach (self::PHP_EXTENSION_FIELDS as $field => $required) {
if (isset($pkg['php-extension'][$field])) {
self::validatePackageFieldType($field, $pkg['php-extension'][$field], $name);
}
}
// check for unknown fields in php-extension
self::validateNoInvalidFields('php-extension', $name, $pkg['php-extension'], array_keys(self::PHP_EXTENSION_FIELDS));
}
private static function validateNoInvalidFields(string $config_type, int|string $item_name, mixed $item_content, array $allowed_fields): void
{
foreach ($item_content as $k => $v) {
// remove suffixes for checking
$base_k = $k;
$suffixes = ['@windows', '@unix', '@macos', '@linux'];
foreach ($suffixes as $suffix) {
if (str_ends_with($k, $suffix)) {
$base_k = substr($k, 0, -strlen($suffix));
break;
}
}
if (!in_array($base_k, $allowed_fields)) {
throw new ValidationException("{$config_type} [{$item_name}] has invalid field [{$base_k}]");
}
}
}
}

View File

@ -0,0 +1,102 @@
<?php
declare(strict_types=1);
namespace StaticPHP\Config;
use StaticPHP\Exception\WrongUsageException;
use StaticPHP\Runtime\SystemTarget;
class PackageConfig
{
private static array $package_configs = [];
/**
* Load package configurations from a specified directory.
* It will look for files matching the pattern 'pkg.*.json' and 'pkg.json'.
*/
public static function loadFromDir(string $dir): void
{
if (!is_dir($dir)) {
throw new WrongUsageException("Directory {$dir} does not exist, cannot load pkg.json config.");
}
$files = glob("{$dir}/pkg.*.json");
if (is_array($files)) {
foreach ($files as $file) {
self::loadFromFile($file);
}
}
if (file_exists("{$dir}/pkg.json")) {
self::loadFromFile("{$dir}/pkg.json");
}
}
/**
* Load package configurations from a specified JSON file.
*
* @param string $file the path to the json package configuration file
*/
public static function loadFromFile(string $file): void
{
$content = file_get_contents($file);
if ($content === false) {
throw new WrongUsageException("Failed to read package config file: {$file}");
}
$data = json_decode($content, true);
if (!is_array($data)) {
throw new WrongUsageException("Invalid JSON format in package config file: {$file}");
}
ConfigValidator::validateAndLintPackages(basename($file), $data);
foreach ($data as $pkg_name => $config) {
self::$package_configs[$pkg_name] = $config;
}
}
/**
* Check if a package configuration exists.
*/
public static function isPackageExists(string $pkg_name): bool
{
return isset(self::$package_configs[$pkg_name]);
}
public static function getAll(): array
{
return self::$package_configs;
}
/**
* Get a specific field from a package configuration.
*
* @param string $pkg_name Package name
* @param null|string $field_name Package config field name
* @param null|mixed $default Default value if field not found
* @return mixed The value of the specified field or the default value
*/
public static function get(string $pkg_name, ?string $field_name = null, mixed $default = null): mixed
{
if (!self::isPackageExists($pkg_name)) {
return $default;
}
// use suffixes to find field
$suffixes = match (SystemTarget::getTargetOS()) {
'Windows' => ['@windows', ''],
'Darwin' => ['@macos', '@unix', ''],
'Linux' => ['@linux', '@unix', ''],
'BSD' => ['@freebsd', '@bsd', '@unix', ''],
};
if ($field_name === null) {
return self::$package_configs[$pkg_name];
}
if (in_array($field_name, ConfigValidator::SUFFIX_ALLOWED_FIELDS)) {
foreach ($suffixes as $suffix) {
$suffixed_field = $field_name . $suffix;
if (isset(self::$package_configs[$pkg_name][$suffixed_field])) {
return self::$package_configs[$pkg_name][$suffixed_field];
}
}
return $default;
}
return self::$package_configs[$pkg_name][$field_name] ?? $default;
}
}

View File

@ -0,0 +1,64 @@
<?php
declare(strict_types=1);
namespace StaticPHP;
use StaticPHP\Command\BuildLibsCommand;
use StaticPHP\Command\BuildTargetCommand;
use StaticPHP\Command\DoctorCommand;
use StaticPHP\Command\DownloadCommand;
use StaticPHP\Command\ExtractCommand;
use StaticPHP\Command\InstallPackageCommand;
use StaticPHP\Command\SPCConfigCommand;
use StaticPHP\Package\PackageLoader;
use StaticPHP\Package\TargetPackage;
use Symfony\Component\Console\Application;
class ConsoleApplication extends Application
{
public const string VERSION = '3.0.0-dev';
private static array $additional_commands = [];
public function __construct()
{
parent::__construct('static-php-cli', self::VERSION);
require_once ROOT_DIR . '/src/bootstrap.php';
/**
* @var string $name
* @var TargetPackage $package
*/
foreach (PackageLoader::getPackages(['target', 'virtual-target']) as $name => $package) {
// only add target that contains artifact.source
if ($package->hasStage('build')) {
logger()->debug("Registering build target command for package: {$name}");
$this->add(new BuildTargetCommand($name));
}
}
$this->addCommands([
new DownloadCommand(),
new DoctorCommand(),
new InstallPackageCommand(),
new BuildLibsCommand(),
new ExtractCommand(),
new SPCConfigCommand(),
]);
// add additional commands from registries
if (!empty(self::$additional_commands)) {
$this->addCommands(self::$additional_commands);
}
}
/**
* @internal
*/
public static function _addAdditionalCommands(array $additional_commands): void
{
self::$additional_commands = array_merge(self::$additional_commands, $additional_commands);
}
}

View File

@ -0,0 +1,195 @@
<?php
declare(strict_types=1);
namespace StaticPHP\DI;
use DI\Container;
use DI\ContainerBuilder;
use Psr\Container\ContainerInterface;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
use function DI\factory;
/**
* ApplicationContext manages the DI container lifecycle and provides
* a centralized access point for dependency injection.
*
* This replaces the scattered spc_container()->set() calls throughout the codebase.
*/
class ApplicationContext
{
private static ?Container $container = null;
private static ?CallbackInvoker $invoker = null;
private static bool $debug = false;
/**
* Initialize the container with configuration.
* Should only be called once at application startup.
*
* @param array $options Initialization options
* - 'debug': Enable debug mode (disables compilation)
* - 'definitions': Additional container definitions
*
* @throws \RuntimeException If already initialized
*/
public static function initialize(array $options = []): Container
{
if (self::$container !== null) {
throw new \RuntimeException('ApplicationContext already initialized. Use reset() first if you need to reinitialize.');
}
$builder = new ContainerBuilder();
$builder->useAutowiring(true);
$builder->useAttributes(true);
// Load default definitions
self::configureDefaults($builder);
// Add custom definitions if provided
if (isset($options['definitions']) && is_array($options['definitions'])) {
$builder->addDefinitions($options['definitions']);
}
// Set debug mode
self::$debug = $options['debug'] ?? false;
self::$container = $builder->build();
self::$invoker = new CallbackInvoker(self::$container);
return self::$container;
}
/**
* Get the container instance.
* If not initialized, initializes with default configuration.
*/
public static function getContainer(): Container
{
if (self::$container === null) {
self::initialize();
}
return self::$container;
}
/**
* Get a service from the container.
*
* @template T
*
* @param class-string<T> $id Service identifier
*
* @return T
*/
public static function get(string $id): mixed
{
return self::getContainer()->get($id);
}
/**
* Check if a service exists in the container.
*/
public static function has(string $id): bool
{
return self::getContainer()->has($id);
}
/**
* Set a service in the container.
* Use sparingly - prefer configuration-based definitions.
*/
public static function set(string $id, mixed $value): void
{
self::getContainer()->set($id, $value);
}
/**
* Bind command-line context to the container.
* Called at the start of each command execution.
*/
public static function bindCommandContext(InputInterface $input, OutputInterface $output): void
{
$container = self::getContainer();
$container->set(InputInterface::class, $input);
$container->set(OutputInterface::class, $output);
self::$debug = $output->isDebug();
}
/**
* Get the callback invoker instance.
*/
public static function getInvoker(): CallbackInvoker
{
if (self::$invoker === null) {
self::$invoker = new CallbackInvoker(self::getContainer());
}
return self::$invoker;
}
/**
* Invoke a callback with automatic dependency injection and context.
*
* @param callable $callback The callback to invoke
* @param array $context Context parameters for injection
*/
public static function invoke(callable $callback, array $context = []): mixed
{
logger()->debug('[INVOKE] ' . (is_array($callback) ? (is_object($callback[0]) ? get_class($callback[0]) : $callback[0]) . '::' . $callback[1] : (is_string($callback) ? $callback : 'Closure')));
return self::getInvoker()->invoke($callback, $context);
}
/**
* Check if debug mode is enabled.
*/
public static function isDebug(): bool
{
return self::$debug;
}
/**
* Set debug mode.
*/
public static function setDebug(bool $debug): void
{
self::$debug = $debug;
}
/**
* Reset the container.
* Primarily used for testing to ensure isolation between tests.
*/
public static function reset(): void
{
self::$container = null;
self::$invoker = null;
self::$debug = false;
}
/**
* Configure default container definitions.
*/
private static function configureDefaults(ContainerBuilder $builder): void
{
$builder->addDefinitions([
// Self-reference for container
ContainerInterface::class => factory(function (Container $c) {
return $c;
}),
Container::class => factory(function (Container $c) {
return $c;
}),
// CallbackInvoker is created separately to avoid circular dependency
CallbackInvoker::class => factory(function (Container $c) {
return new CallbackInvoker($c);
}),
// Command context (set at runtime via bindCommandContext)
InputInterface::class => \DI\value(null),
OutputInterface::class => \DI\value(null),
]);
}
}

View File

@ -0,0 +1,98 @@
<?php
declare(strict_types=1);
namespace StaticPHP\DI;
use DI\Container;
/**
* CallbackInvoker is responsible for invoking callbacks with automatic dependency injection.
* It supports context-based parameter resolution, allowing temporary bindings without polluting the container.
*/
class CallbackInvoker
{
public function __construct(
private Container $container
) {}
/**
* Invoke a callback with automatic dependency injection.
*
* Resolution order for each parameter:
* 1. Context array (by type name)
* 2. Context array (by parameter name)
* 3. Container (by type)
* 4. Default value
* 5. Null (if nullable)
*
* @param callable $callback The callback to invoke
* @param array $context Context parameters (type => value or name => value)
*
* @return mixed The return value of the callback
*
* @throws \RuntimeException If a required parameter cannot be resolved
*/
public function invoke(callable $callback, array $context = []): mixed
{
$reflection = new \ReflectionFunction(\Closure::fromCallable($callback));
$args = [];
foreach ($reflection->getParameters() as $param) {
$type = $param->getType();
$typeName = $type instanceof \ReflectionNamedType ? $type->getName() : null;
$paramName = $param->getName();
// 1. Look up by type name in context
if ($typeName !== null && array_key_exists($typeName, $context)) {
$args[] = $context[$typeName];
continue;
}
// 2. Look up by parameter name in context
if (array_key_exists($paramName, $context)) {
$args[] = $context[$paramName];
continue;
}
// 3. Look up in container by type
if ($typeName !== null && !$this->isBuiltinType($typeName) && $this->container->has($typeName)) {
$args[] = $this->container->get($typeName);
continue;
}
// 4. Use default value if available
if ($param->isDefaultValueAvailable()) {
$args[] = $param->getDefaultValue();
continue;
}
// 5. Allow null if nullable
if ($param->allowsNull()) {
$args[] = null;
continue;
}
// Cannot resolve parameter
throw new \RuntimeException(
"Cannot resolve parameter '{$paramName}'" .
($typeName ? " of type '{$typeName}'" : '') .
' for callback invocation'
);
}
return $callback(...$args);
}
/**
* Check if a type name is a PHP builtin type.
*/
private function isBuiltinType(string $typeName): bool
{
return in_array($typeName, [
'string', 'int', 'float', 'bool', 'array',
'object', 'callable', 'iterable', 'mixed',
'void', 'null', 'false', 'true', 'never',
], true);
}
}

View File

@ -0,0 +1,46 @@
<?php
declare(strict_types=1);
namespace StaticPHP\Doctor;
class CheckResult
{
public function __construct(private readonly bool $ok, private readonly ?string $message = null, private string $fix_item = '', private array $fix_params = []) {}
public static function fail(string $message, string $fix_item = '', array $fix_params = []): CheckResult
{
return new static(false, $message, $fix_item, $fix_params);
}
public static function ok(?string $message = null): CheckResult
{
return new static(true, $message);
}
public function getMessage(): ?string
{
return $this->message;
}
public function getFixItem(): string
{
return $this->fix_item;
}
public function getFixParams(): array
{
return $this->fix_params;
}
public function isOK(): bool
{
return $this->ok;
}
public function setFixItem(string $fix_item = '', array $fix_params = []): void
{
$this->fix_item = $fix_item;
$this->fix_params = $fix_params;
}
}

View File

@ -0,0 +1,161 @@
<?php
declare(strict_types=1);
namespace StaticPHP\Doctor;
use StaticPHP\Attribute\Doctor\CheckItem;
use StaticPHP\DI\ApplicationContext;
use StaticPHP\Exception\SPCException;
use StaticPHP\Runtime\Shell\Shell;
use StaticPHP\Util\InteractiveTerm;
use Symfony\Component\Console\Output\OutputInterface;
use ZM\Logger\ConsoleColor;
use function Laravel\Prompts\confirm;
readonly class Doctor
{
public function __construct(private ?OutputInterface $output = null, private int $auto_fix = FIX_POLICY_PROMPT)
{
// debug shows all loaded doctor items
$items = DoctorLoader::getDoctorItems();
$names = array_map(fn ($i) => $i->item_name, array_map(fn ($x) => $x[0], $items));
logger()->debug("Loaded doctor check items:\n\t" . implode("\n\t", $names));
}
/**
* Check all valid check items.
* @return bool true if all checks passed, false otherwise
*/
public function checkAll(bool $interactive = true): bool
{
if ($interactive) {
InteractiveTerm::notice('Starting doctor checks ...');
}
foreach ($this->getValidCheckList() as $check) {
if (!$this->checkItem($check)) {
return false;
}
}
return true;
}
/**
* Check a single check item.
*
* @param CheckItem|string $check The check item to be checked
* @return bool True if the check passed or was fixed, false otherwise
*/
public function checkItem(CheckItem|string $check): bool
{
if (is_string($check)) {
$found = null;
foreach (DoctorLoader::getDoctorItems() as $item) {
if ($item[0]->item_name === $check) {
$found = $item[0];
break;
}
}
if ($found === null) {
$this->output?->writeln("<error>Check item '{$check}' not found.</error>");
return false;
}
$check = $found;
}
$this->output?->write("Checking <comment>{$check->item_name}</comment> ... ");
// call check
$result = call_user_func($check->callback);
if ($result === null) {
$this->output?->writeln('skipped');
return true;
}
if (!$result instanceof CheckResult) {
$this->output?->writeln('<error>Skipped due to invalid return value</error>');
return true;
}
if ($result->isOK()) {
/* @phpstan-ignore-next-line */
$this->output?->writeln($result->getMessage() ?? (string) ConsoleColor::green('✓'));
return true;
}
$this->output?->writeln('<error>' . $result->getMessage() . '</error>');
// if the check item is not fixable, fail immediately
if ($result->getFixItem() === '') {
$this->output?->writeln('This check item can not be fixed automatically !');
return false;
}
// unknown fix item
if (!DoctorLoader::getFixItem($result->getFixItem())) {
$this->output?->writeln("<error>Internal error: Unknown fix item: {$result->getFixItem()}</error>");
return false;
}
// skip fix
if ($this->auto_fix === FIX_POLICY_DIE) {
$this->output?->writeln('<comment>Auto-fix is disabled. Please fix this issue manually.</comment>');
return false;
}
// prompt for fix
if ($this->auto_fix === FIX_POLICY_PROMPT && !confirm('Do you want to try to fix this issue now?')) {
$this->output?->writeln('<comment>You canceled fix.</comment>');
return false;
}
// perform fix
InteractiveTerm::indicateProgress("Fixing {$result->getFixItem()} ... ");
Shell::passthruCallback(function () {
InteractiveTerm::advance();
});
// $this->output?->writeln("Fixing <comment>{$check->item_name}</comment> ... ");
if ($this->emitFix($result->getFixItem(), $result->getFixParams())) {
InteractiveTerm::finish('Fix applied successfully!');
return true;
}
InteractiveTerm::finish('Failed to apply fix!', false);
return false;
}
private function emitFix(string $fix_item, array $fix_item_params = []): bool
{
keyboard_interrupt_register(function () {
$this->output?->writeln('<error>You cancelled fix</error>');
});
try {
return ApplicationContext::invoke(DoctorLoader::getFixItem($fix_item), $fix_item_params);
} catch (SPCException $e) {
$this->output?->writeln('<error>Fix failed: ' . $e->getMessage() . '</error>');
return false;
} catch (\Throwable $e) {
$this->output?->writeln('<error>Fix failed with an unexpected error: ' . $e->getMessage() . '</error>');
return false;
} finally {
keyboard_interrupt_unregister();
}
}
/**
* Get a list of valid check items for current environment.
*/
private function getValidCheckList(): iterable
{
foreach (DoctorLoader::getDoctorItems() as [$item, $optional]) {
/* @var CheckItem $item */
// optional check
if ($optional !== null && !call_user_func($optional)) {
continue; // skip this when the optional check is false
}
// limit_os check
if ($item->limit_os !== null && $item->limit_os !== PHP_OS_FAMILY) {
continue;
}
// skipped items by env
$skip_items = array_filter(explode(',', getenv('SPC_SKIP_DOCTOR_CHECK_ITEMS') ?: ''));
if (in_array($item->item_name, $skip_items)) {
continue; // skip this item
}
yield $item;
}
}
}

View File

@ -0,0 +1,123 @@
<?php
declare(strict_types=1);
namespace StaticPHP\Doctor;
use StaticPHP\Attribute\Doctor\CheckItem;
use StaticPHP\Attribute\Doctor\FixItem;
use StaticPHP\Attribute\Doctor\OptionalCheck;
use StaticPHP\Util\FileSystem;
class DoctorLoader
{
/**
* @var array<int, array{0: CheckItem, 1: callable}> $doctor_items Loaded doctor check item instances
*/
private static array $doctor_items = [];
/**
* @var array<string, callable> $fix_items loaded doctor fix item instances
*/
private static array $fix_items = [];
/**
* Load doctor check items 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
{
$classes = FileSystem::getClassesPsr4($dir, $base_namespace, auto_require: $auto_require);
foreach ($classes as $class) {
self::loadFromClass($class, false);
}
// sort check items by level
usort(self::$doctor_items, function ($a, $b) {
return $a[0]->level > $b[0]->level ? -1 : ($a[0]->level == $b[0]->level ? 0 : 1);
});
}
/**
* Load doctor check items from a class.
*
* @param string $class Class name to load doctor check items from
* @param bool $sort Whether to re-sort Doctor items (default: true)
*/
public static function loadFromClass(string $class, bool $sort = true): void
{
// passthough to all the functions if #[OptionalCheck] is set on class level
$optional_passthrough = null;
$reflection = new \ReflectionClass($class);
$class_instance = $reflection->newInstance();
// parse #[OptionalCheck]
$optional = $reflection->getAttributes(OptionalCheck::class)[0] ?? null;
if ($optional !== null) {
/** @var OptionalCheck $instance */
$instance = $optional->newInstance();
if (is_callable($instance->check)) {
$optional_passthrough = $instance->check;
}
}
$doctor_items = [];
$fix_item_map = [];
// finx check items and fix items from methods in class
foreach ($reflection->getMethods(\ReflectionMethod::IS_PUBLIC) as $method) {
// passthrough for this method if #[OptionalCheck] is set on method level
$optional = $optional_passthrough ?? null;
foreach ($method->getAttributes(OptionalCheck::class) as $method_attr) {
$optional_check = $method_attr->newInstance();
if (is_callable($optional_check->check)) {
$optional = $optional_check->check;
}
}
// parse #[CheckItem]
foreach ($method->getAttributes(CheckItem::class) as $attr) {
/** @var CheckItem $instance */
$instance = $attr->newInstance();
$instance->callback = [$class_instance, $method->getName()];
// put CheckItem instance and optional check callback (or null) to $doctor_items
$doctor_items[] = [$instance, $optional];
}
// parse #[FixItem]
$fix_item = $method->getAttributes(FixItem::class)[0] ?? null;
if ($fix_item !== null) {
$instance = $fix_item->newInstance();
$fix_item_map[$instance->name] = [$class_instance, $method->getName()];
}
}
// add to array
self::$doctor_items = array_merge(self::$doctor_items, $doctor_items);
self::$fix_items = array_merge(self::$fix_items, $fix_item_map);
if ($sort) {
// sort check items by level
usort(self::$doctor_items, function ($a, $b) {
return $a[0]->level > $b[0]->level ? -1 : ($a[0]->level == $b[0]->level ? 0 : 1);
});
}
}
/**
* Returns loaded doctor check items.
*
* @return array<int, array{0: CheckItem, 1: callable}>
*/
public static function getDoctorItems(): array
{
return self::$doctor_items;
}
public static function getFixItem(string $name): ?callable
{
return self::$fix_items[$name] ?? null;
}
}

View File

@ -0,0 +1,73 @@
<?php
declare(strict_types=1);
namespace StaticPHP\Doctor\Item;
use StaticPHP\Attribute\Doctor\CheckItem;
use StaticPHP\Attribute\Doctor\FixItem;
use StaticPHP\Attribute\Doctor\OptionalCheck;
use StaticPHP\Doctor\CheckResult;
use StaticPHP\Toolchain\MuslToolchain;
use StaticPHP\Toolchain\ZigToolchain;
use StaticPHP\Util\FileSystem;
use StaticPHP\Util\System\LinuxUtil;
#[OptionalCheck([self::class, 'optionalCheck'])]
class LinuxMuslCheck
{
public static function optionalCheck(): bool
{
return getenv('SPC_TOOLCHAIN') === MuslToolchain::class ||
(getenv('SPC_TOOLCHAIN') === ZigToolchain::class && !LinuxUtil::isMuslDist());
}
/** @noinspection PhpUnused */
#[CheckItem('if musl-wrapper is installed', limit_os: 'Linux', level: 800)]
public function checkMusl(): CheckResult
{
$musl_wrapper_lib = sprintf('/lib/ld-musl-%s.so.1', php_uname('m'));
if (file_exists($musl_wrapper_lib) && (file_exists('/usr/local/musl/lib/libc.a') || getenv('SPC_TOOLCHAIN') === ZigToolchain::class)) {
return CheckResult::ok();
}
return CheckResult::fail('musl-wrapper is not installed on your system', 'fix-musl-wrapper');
}
#[CheckItem('if musl-cross-make is installed', limit_os: 'Linux', level: 799)]
public function checkMuslCrossMake(): CheckResult
{
if (getenv('SPC_TOOLCHAIN') === ZigToolchain::class && !LinuxUtil::isMuslDist()) {
return CheckResult::ok();
}
$arch = arch2gnu(php_uname('m'));
$cross_compile_lib = "/usr/local/musl/{$arch}-linux-musl/lib/libc.a";
$cross_compile_gcc = "/usr/local/musl/bin/{$arch}-linux-musl-gcc";
if (file_exists($cross_compile_lib) && file_exists($cross_compile_gcc)) {
return CheckResult::ok();
}
return CheckResult::fail('musl-cross-make is not installed on your system', 'fix-musl-cross-make');
}
#[FixItem('fix-musl-wrapper')]
public function fixMusl(): bool
{
// TODO: implement musl-wrapper installation
// This should:
// 1. Download musl source using Downloader::downloadSource()
// 2. Extract the source using FileSystem::extractSource()
// 3. Apply CVE patches using SourcePatcher::patchFile()
// 4. Build and install musl wrapper
// 5. Add path using putenv instead of editing /etc/profile
return false;
}
#[FixItem('fix-musl-cross-make')]
public function fixMuslCrossMake(): bool
{
// TODO: implement musl-cross-make installation
// This should:
// 1. Install musl-toolchain package using PackageManager::installPackage()
// 2. Copy toolchain files to /usr/local/musl
return false;
}
}

View File

@ -0,0 +1,106 @@
<?php
declare(strict_types=1);
namespace StaticPHP\Doctor\Item;
use StaticPHP\Attribute\Doctor\CheckItem;
use StaticPHP\Attribute\Doctor\FixItem;
use StaticPHP\Doctor\CheckResult;
use StaticPHP\Util\System\MacOSUtil;
class MacOSToolCheck
{
public const array REQUIRED_COMMANDS = [
'curl',
'make',
'bison',
're2c',
'flex',
'pkg-config',
'git',
'autoconf',
'automake',
'tar',
'libtool',
'unzip',
'xz',
'gzip',
'bzip2',
'cmake',
'glibtoolize',
];
#[CheckItem('if homebrew has installed', limit_os: 'Darwin', level: 998)]
public function checkBrew(): ?CheckResult
{
if (($path = MacOSUtil::findCommand('brew')) === null) {
return CheckResult::fail('Homebrew is not installed', 'brew');
}
if ($path !== '/opt/homebrew/bin/brew' && getenv('GNU_ARCH') === 'aarch64') {
return CheckResult::fail('Current homebrew (/usr/local/bin/homebrew) is not installed for M1 Mac, please re-install homebrew in /opt/homebrew/ !');
}
return CheckResult::ok();
}
#[CheckItem('if necessary tools are installed', limit_os: 'Darwin')]
public function checkCliTools(): ?CheckResult
{
$missing = [];
foreach (self::REQUIRED_COMMANDS as $cmd) {
if (MacOSUtil::findCommand($cmd) === null) {
$missing[] = $cmd;
}
}
if (!empty($missing)) {
return CheckResult::fail('missing system commands: ' . implode(', ', $missing), 'build-tools', [$missing]);
}
return CheckResult::ok();
}
#[CheckItem('if bison version is 3.0 or later', limit_os: 'Darwin')]
public function checkBisonVersion(array $command_path = []): ?CheckResult
{
// if the bison command is /usr/bin/bison, it is the system bison that may be too old
if (($bison = MacOSUtil::findCommand('bison', $command_path)) === null) {
return CheckResult::fail('bison is not installed or too old', 'build-tools', [['bison']]);
}
// check version: bison (GNU Bison) x.y(.z)
$version = shell()->execWithResult("{$bison} --version", false);
if (preg_match('/bison \(GNU Bison\) (\d+)\.(\d+)(?:\.(\d+))?/', $version[1][0], $matches)) {
$major = (int) $matches[1];
// major should be 3 or later
if ($major < 3) {
// find homebrew keg-only bison
if ($command_path !== []) {
return CheckResult::fail("Current {$bison} version is too old: " . $matches[0]);
}
return $this->checkBisonVersion(['/opt/homebrew/opt/bison/bin', '/usr/local/opt/bison/bin']);
}
return CheckResult::ok($matches[0]);
}
return CheckResult::fail('bison version cannot be determined');
}
#[FixItem('brew')]
public function fixBrew(): bool
{
shell(true)->exec('/bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)"');
return true;
}
#[FixItem('build-tools')]
public function fixBuildTools(array $missing): bool
{
$replacement = [
'glibtoolize' => 'libtool',
];
foreach ($missing as $cmd) {
if (isset($replacement[$cmd])) {
$cmd = $replacement[$cmd];
}
shell()->exec('brew install --formula ' . escapeshellarg($cmd));
}
return true;
}
}

View File

@ -0,0 +1,23 @@
<?php
declare(strict_types=1);
namespace StaticPHP\Doctor\Item;
use StaticPHP\Attribute\Doctor\CheckItem;
use StaticPHP\Doctor\CheckResult;
use StaticPHP\Util\System\LinuxUtil;
class OSCheck
{
#[CheckItem('if current OS are supported', level: 1000)]
public function checkOS(): ?CheckResult
{
if (!in_array(PHP_OS_FAMILY, ['Darwin', 'Linux', 'Windows'])) {
return CheckResult::fail('Current OS is not supported: ' . PHP_OS_FAMILY);
}
$distro = PHP_OS_FAMILY === 'Linux' ? (' ' . LinuxUtil::getOSRelease()['dist']) : '';
$known_distro = PHP_OS_FAMILY !== 'Linux' || in_array(LinuxUtil::getOSRelease()['dist'], LinuxUtil::getSupportedDistros());
return CheckResult::ok(PHP_OS_FAMILY . ' ' . php_uname('m') . $distro . ', supported' . ($known_distro ? '' : ' (but not tested on this distro)'));
}
}

View File

@ -0,0 +1,53 @@
<?php
declare(strict_types=1);
namespace StaticPHP\Doctor\Item;
use StaticPHP\Attribute\Doctor\CheckItem;
use StaticPHP\Attribute\Doctor\FixItem;
use StaticPHP\Attribute\Doctor\OptionalCheck;
use StaticPHP\DI\ApplicationContext;
use StaticPHP\Doctor\CheckResult;
use StaticPHP\Package\PackageInstaller;
use StaticPHP\Runtime\SystemTarget;
use StaticPHP\Util\PkgConfigUtil;
#[OptionalCheck([self::class, 'optionalCheck'])]
class PkgConfigCheck
{
public static function optionalCheck(): bool
{
return SystemTarget::getTargetOS() !== 'Windows';
}
#[CheckItem('if pkg-config is installed or built', level: 800)]
public function check(): CheckResult
{
if (!($pkgconf = PkgConfigUtil::findPkgConfig())) {
return CheckResult::fail('pkg-config is not installed or built', 'install-pkg-config');
}
return CheckResult::ok($pkgconf);
}
#[CheckItem('if pkg-config is functional', level: 799)]
public function checkFunctional(): CheckResult
{
$pkgconf = PkgConfigUtil::findPkgConfig();
[$ret, $output] = shell()->execWithResult("{$pkgconf} --version", false);
if ($ret !== 0) {
return CheckResult::fail('pkg-config is not functional', 'install-pkg-config');
}
return CheckResult::ok(trim($output[0]));
}
#[FixItem('install-pkg-config')]
public function fix(): bool
{
ApplicationContext::set('elephant', true);
$installer = new PackageInstaller(['dl-prefer-binary' => true]);
$installer->addInstallPackage('pkg-config');
$installer->run(false, true);
return true;
}
}

View File

@ -0,0 +1,35 @@
<?php
declare(strict_types=1);
namespace StaticPHP\Doctor\Item;
use StaticPHP\Attribute\Doctor\CheckItem;
use StaticPHP\Attribute\Doctor\FixItem;
use StaticPHP\Doctor\CheckResult;
class Re2cVersionCheck
{
#[CheckItem('if re2c version >= 1.0.3', limit_os: 'Linux', level: 20)]
#[CheckItem('if re2c version >= 1.0.3', limit_os: 'Darwin', level: 20)]
public function checkRe2cVersion(): ?CheckResult
{
$ver = shell(false)->execWithResult('re2c --version', false);
// match version: re2c X.X(.X)
if ($ver[0] !== 0 || !preg_match('/re2c\s+(\d+\.\d+(\.\d+)?)/', $ver[1][0], $matches)) {
return CheckResult::fail('Failed to get re2c version', 'build-re2c');
}
$version_string = $matches[1];
if (version_compare($version_string, '1.0.3') < 0) {
return CheckResult::fail('re2c version is too low (' . $version_string . ')', 'build-re2c');
}
return CheckResult::ok($version_string);
}
#[FixItem('build-re2c')]
public function buildRe2c(): bool
{
// TODO: implement re2c build process
return false;
}
}

View File

@ -0,0 +1,13 @@
<?php
declare(strict_types=1);
namespace StaticPHP\Exception;
/**
* BuildFailureException is thrown when a build process failed with other reasons.
*
* This exception indicates that the build operation did not complete successfully,
* which may be due to various reasons such as missing built-files, incorrect configurations, etc.
*/
class BuildFailureException extends SPCException {}

View File

@ -0,0 +1,13 @@
<?php
declare(strict_types=1);
namespace StaticPHP\Exception;
/**
* Exception thrown when an error occurs during the downloading process.
*
* This exception is used to indicate that a download operation has failed,
* typically due to network issues, invalid URLs, or other related problems.
*/
class DownloaderException extends SPCException {}

View File

@ -0,0 +1,25 @@
<?php
declare(strict_types=1);
namespace StaticPHP\Exception;
/**
* EnvironmentException is thrown when there is an issue with the environment setup,
* such as missing dependencies or incorrect configurations.
*/
class EnvironmentException extends SPCException
{
public function __construct(string $message, private readonly ?string $solution = null)
{
parent::__construct($message);
}
/**
* Returns the solution for the environment issue.
*/
public function getSolution(): ?string
{
return $this->solution;
}
}

View File

@ -0,0 +1,228 @@
<?php
declare(strict_types=1);
namespace StaticPHP\Exception;
use SPC\builder\BuilderBase;
use SPC\builder\freebsd\BSDBuilder;
use SPC\builder\linux\LinuxBuilder;
use SPC\builder\macos\MacOSBuilder;
use SPC\builder\windows\WindowsBuilder;
use StaticPHP\DI\ApplicationContext;
use ZM\Logger\ConsoleColor;
class ExceptionHandler
{
public const array KNOWN_EXCEPTIONS = [
BuildFailureException::class,
DownloaderException::class,
EnvironmentException::class,
ExecutionException::class,
FileSystemException::class,
InterruptException::class,
PatchException::class,
SPCInternalException::class,
ValidationException::class,
WrongUsageException::class,
];
public const array MINOR_LOG_EXCEPTIONS = [
InterruptException::class,
WrongUsageException::class,
];
/** @var null|BuilderBase Builder binding */
private static ?BuilderBase $builder = null;
/** @var array<string, mixed> Build PHP extra info binding */
private static array $build_php_extra_info = [];
public static function handleSPCException(SPCException $e): void
{
// XXX error: yyy
$head_msg = match ($class = get_class($e)) {
BuildFailureException::class => "✗ Build failed: {$e->getMessage()}",
DownloaderException::class => "✗ Download failed: {$e->getMessage()}",
EnvironmentException::class => "⚠ Environment check failed: {$e->getMessage()}",
ExecutionException::class => "✗ Command execution failed: {$e->getMessage()}",
FileSystemException::class => "✗ File system error: {$e->getMessage()}",
InterruptException::class => "⚠ Build interrupted by user: {$e->getMessage()}",
PatchException::class => "✗ Patch apply failed: {$e->getMessage()}",
SPCInternalException::class => "✗ SPC internal error: {$e->getMessage()}",
ValidationException::class => "⚠ Validation failed: {$e->getMessage()}",
WrongUsageException::class => $e->getMessage(),
default => "✗ Unknown SPC exception {$class}: {$e->getMessage()}",
};
self::logError($head_msg);
// ----------------------------------------
$minor_logs = in_array($class, self::MINOR_LOG_EXCEPTIONS, true);
if ($minor_logs) {
return;
}
self::logError("----------------------------------------\n");
// get the SPCException module
if ($lib_info = $e->getLibraryInfo()) {
self::logError('Failed module: ' . ConsoleColor::yellow("library {$lib_info['library_name']} builder for {$lib_info['os']}"));
} elseif ($ext_info = $e->getExtensionInfo()) {
self::logError('Failed module: ' . ConsoleColor::yellow("shared extension {$ext_info['extension_name']} builder"));
} elseif (self::$builder) {
$os = match (get_class(self::$builder)) {
WindowsBuilder::class => 'Windows',
MacOSBuilder::class => 'macOS',
LinuxBuilder::class => 'Linux',
BSDBuilder::class => 'FreeBSD',
default => 'Unknown OS',
};
self::logError('Failed module: ' . ConsoleColor::yellow("Builder for {$os}"));
} elseif (!in_array($class, self::KNOWN_EXCEPTIONS)) {
self::logError('Failed From: ' . ConsoleColor::yellow('Unknown SPC module ' . $class));
}
// get command execution info
if ($e instanceof ExecutionException) {
self::logError('');
self::logError('Failed command: ' . ConsoleColor::yellow($e->getExecutionCommand()));
if ($cd = $e->getCd()) {
self::logError('Command executed in: ' . ConsoleColor::yellow($cd));
}
if ($env = $e->getEnv()) {
self::logError('Command inline env variables:');
foreach ($env as $k => $v) {
self::logError(ConsoleColor::yellow("{$k}={$v}"), 4);
}
}
}
// validation error
if ($e instanceof ValidationException) {
self::logError('Failed validation module: ' . ConsoleColor::yellow($e->getValidationModuleString()));
}
// environment error
if ($e instanceof EnvironmentException) {
self::logError('Failed environment check: ' . ConsoleColor::yellow($e->getMessage()));
if (($solution = $e->getSolution()) !== null) {
self::logError('Solution: ' . ConsoleColor::yellow($solution));
}
}
// get patch info
if ($e instanceof PatchException) {
self::logError("Failed patch module: {$e->getPatchModule()}");
}
// get internal trace
if ($e instanceof SPCInternalException) {
self::logError('Internal trace:');
self::logError(ConsoleColor::gray("{$e->getTraceAsString()}\n"), 4);
}
// get the full build info if possible
if ($info = ExceptionHandler::$build_php_extra_info) {
self::logError('', output_log: ApplicationContext::isDebug());
self::logError('Build PHP extra info:', output_log: ApplicationContext::isDebug());
self::printArrayInfo($info);
}
// get the full builder options if possible
if ($e->getBuildPHPInfo()) {
$info = $e->getBuildPHPInfo();
self::logError('', output_log: ApplicationContext::isDebug());
self::logError('Builder function: ' . ConsoleColor::yellow($info['builder_function']), output_log: ApplicationContext::isDebug());
}
self::logError("\n----------------------------------------\n");
// convert log file path if in docker
$spc_log_convert = get_display_path(SPC_OUTPUT_LOG);
$shell_log_convert = get_display_path(SPC_SHELL_LOG);
$spc_logs_dir_convert = get_display_path(SPC_LOGS_DIR);
self::logError('⚠ The ' . ConsoleColor::cyan('console output log') . ConsoleColor::red(' is saved in ') . ConsoleColor::cyan($spc_log_convert));
if (file_exists(SPC_SHELL_LOG)) {
self::logError('⚠ The ' . ConsoleColor::cyan('shell output log') . ConsoleColor::red(' is saved in ') . ConsoleColor::cyan($shell_log_convert));
}
if ($e->getExtraLogFiles() !== []) {
foreach ($e->getExtraLogFiles() as $key => $file) {
self::logError("⚠ Log file [{$key}] is saved in: " . ConsoleColor::cyan("{$spc_logs_dir_convert}/{$file}"));
}
}
if (!ApplicationContext::isDebug()) {
self::logError('⚠ If you want to see more details in console, use `--debug` option.');
}
}
public static function handleDefaultException(\Throwable $e): void
{
$class = get_class($e);
$file = $e->getFile();
$line = $e->getLine();
self::logError("✗ Unhandled exception {$class} on {$file} line {$line}:\n\t{$e->getMessage()}\n");
self::logError('Stack trace:');
self::logError(ConsoleColor::gray($e->getTraceAsString()) . PHP_EOL, 4);
self::logError('⚠ Please report this exception to: https://github.com/crazywhalecc/static-php-cli/issues');
}
public static function bindBuilder(?BuilderBase $bind_builder): void
{
self::$builder = $bind_builder;
}
public static function bindBuildPhpExtraInfo(array $build_php_extra_info): void
{
self::$build_php_extra_info = $build_php_extra_info;
}
private static function logError($message, int $indent_space = 0, bool $output_log = true): void
{
$spc_log = fopen(SPC_OUTPUT_LOG, 'a');
$msg = explode("\n", (string) $message);
foreach ($msg as $v) {
$line = str_pad($v, strlen($v) + $indent_space, ' ', STR_PAD_LEFT);
fwrite($spc_log, strip_ansi_colors($line) . PHP_EOL);
if ($output_log) {
echo ConsoleColor::red($line) . PHP_EOL;
}
}
}
/**
* Print array info to console and log.
*/
private static function printArrayInfo(array $info): void
{
$log_output = ApplicationContext::isDebug();
$maxlen = 0;
foreach ($info as $k => $v) {
$maxlen = max(strlen($k), $maxlen);
}
foreach ($info as $k => $v) {
if (is_string($v)) {
if ($v === '') {
self::logError($k . ': ' . str_pad('', $maxlen - strlen($k)) . ConsoleColor::yellow('""'), 4, $log_output);
} else {
self::logError($k . ': ' . str_pad('', $maxlen - strlen($k)) . ConsoleColor::yellow($v), 4, $log_output);
}
} elseif (is_array($v) && !is_assoc_array($v)) {
if ($v === []) {
self::logError($k . ': ' . str_pad('', $maxlen - strlen($k)) . ConsoleColor::yellow('[]'), 4, $log_output);
continue;
}
$first = array_shift($v);
self::logError($k . ': ' . str_pad('', $maxlen - strlen($k)) . ConsoleColor::yellow($first), 4, $log_output);
foreach ($v as $vs) {
self::logError(str_pad('', $maxlen + 2) . ConsoleColor::yellow($vs), 4, $log_output);
}
} elseif (is_bool($v) || is_null($v)) {
self::logError($k . ': ' . str_pad('', $maxlen - strlen($k)) . ConsoleColor::cyan($v === true ? 'true' : ($v === false ? 'false' : 'null')), 4, $log_output);
} else {
self::logError($k . ': ' . str_pad('', $maxlen - strlen($k)) . ConsoleColor::yellow(json_encode($v, JSON_PRETTY_PRINT)), 4, $log_output);
}
}
}
}

View File

@ -0,0 +1,58 @@
<?php
declare(strict_types=1);
namespace StaticPHP\Exception;
use SPC\util\shell\UnixShell;
use SPC\util\shell\WindowsCmd;
/**
* Exception thrown when an error occurs during execution of shell command.
*
* This exception is used to indicate that a command executed by the SPC framework
* has failed, typically due to an error in the command itself or an issue with the environment
* in which it was executed.
*/
class ExecutionException extends SPCException
{
public function __construct(
private readonly string|UnixShell|WindowsCmd $cmd,
$message = '',
$code = 0,
private readonly ?string $cd = null,
private readonly array $env = [],
?\Exception $previous = null
) {
parent::__construct($message, $code, $previous);
}
/**
* Returns the command that caused the execution error.
*
* @return string the command that was executed when the error occurred
*/
public function getExecutionCommand(): string
{
if ($this->cmd instanceof UnixShell || $this->cmd instanceof WindowsCmd) {
return $this->cmd->getLastCommand();
}
return $this->cmd;
}
/**
* Returns the directory in which the command was executed.
*/
public function getCd(): ?string
{
return $this->cd;
}
/**
* Returns the environment variables that were set during the command execution.
*/
public function getEnv(): array
{
return $this->env;
}
}

View File

@ -0,0 +1,7 @@
<?php
declare(strict_types=1);
namespace StaticPHP\Exception;
class FileSystemException extends SPCException {}

View File

@ -0,0 +1,10 @@
<?php
declare(strict_types=1);
namespace StaticPHP\Exception;
/**
* Exception caused by manual intervention.
*/
class InterruptException extends SPCException {}

View File

@ -0,0 +1,25 @@
<?php
declare(strict_types=1);
namespace StaticPHP\Exception;
/**
* PatchException is thrown when there is an issue applying a patch,
* such as a failure in the patch process or conflicts during patching.
*/
class PatchException extends SPCException
{
public function __construct(private readonly string $patch_module, $message, $code = 0, ?\Exception $previous = null)
{
parent::__construct($message, $code, $previous);
}
/**
* Returns the name of the patch module that caused the exception.
*/
public function getPatchModule(): string
{
return $this->patch_module;
}
}

View File

@ -0,0 +1,147 @@
<?php
declare(strict_types=1);
namespace StaticPHP\Exception;
use SPC\builder\BuilderBase;
use SPC\builder\freebsd\library\BSDLibraryBase;
use SPC\builder\LibraryBase;
use SPC\builder\linux\library\LinuxLibraryBase;
use SPC\builder\macos\library\MacOSLibraryBase;
use SPC\builder\windows\library\WindowsLibraryBase;
/**
* Base class for SPC exceptions.
*
* This class serves as the base for all exceptions thrown by the SPC framework.
* It extends the built-in PHP Exception class, allowing for custom exception handling
* and categorization of SPC-related errors.
*/
abstract class SPCException extends \Exception
{
private ?array $library_info = null;
private ?array $extension_info = null;
private ?array $build_php_info = null;
private array $extra_log_files = [];
public function __construct(string $message = '', int $code = 0, ?\Throwable $previous = null)
{
parent::__construct($message, $code, $previous);
$this->loadStackTraceInfo();
}
public function bindExtensionInfo(array $extension_info): void
{
$this->extension_info = $extension_info;
}
public function addExtraLogFile(string $key, string $filename): void
{
$this->extra_log_files[$key] = $filename;
}
/**
* Returns an array containing information about the SPC module.
*
* This method can be overridden by subclasses to provide specific module information.
*
* @return null|array{
* library_name: string,
* library_class: string,
* os: string,
* file: null|string,
* line: null|int,
* } an array containing module information
*/
public function getLibraryInfo(): ?array
{
return $this->library_info;
}
/**
* Returns an array containing information about the PHP build process.
*
* @return null|array{
* builder_function: string,
* file: null|string,
* line: null|int,
* } an array containing PHP build information
*/
public function getBuildPHPInfo(): ?array
{
return $this->build_php_info;
}
/**
* Returns an array containing information about the SPC extension.
*
* This method can be overridden by subclasses to provide specific extension information.
*
* @return null|array{
* extension_name: string,
* extension_class: string,
* file: null|string,
* line: null|int,
* } an array containing extension information
*/
public function getExtensionInfo(): ?array
{
return $this->extension_info;
}
public function getExtraLogFiles(): array
{
return $this->extra_log_files;
}
private function loadStackTraceInfo(): void
{
$trace = $this->getTrace();
foreach ($trace as $frame) {
if (!isset($frame['class'])) {
continue;
}
// Check if the class is a subclass of LibraryBase
if (!$this->library_info && is_a($frame['class'], LibraryBase::class, true)) {
try {
$reflection = new \ReflectionClass($frame['class']);
if ($reflection->hasConstant('NAME')) {
$name = $reflection->getConstant('NAME');
if ($name !== 'unknown') {
$this->library_info = [
'library_name' => $name,
'library_class' => $frame['class'],
'os' => match (true) {
is_a($frame['class'], BSDLibraryBase::class, true) => 'BSD',
is_a($frame['class'], LinuxLibraryBase::class, true) => 'Linux',
is_a($frame['class'], MacOSLibraryBase::class, true) => 'macOS',
is_a($frame['class'], WindowsLibraryBase::class, true) => 'Windows',
default => 'Unknown',
},
'file' => $frame['file'] ?? null,
'line' => $frame['line'] ?? null,
];
continue;
}
}
} catch (\ReflectionException) {
continue;
}
}
// Check if the class is a subclass of BuilderBase and the method is buildPHP
if (!$this->build_php_info && is_a($frame['class'], BuilderBase::class, true)) {
$this->build_php_info = [
'builder_function' => $frame['function'],
'file' => $frame['file'] ?? null,
'line' => $frame['line'] ?? null,
];
}
}
}
}

View File

@ -0,0 +1,12 @@
<?php
declare(strict_types=1);
namespace StaticPHP\Exception;
/**
* Exception for internal errors or vendor wrong usage in SPC.
*
* This exception is thrown when an unexpected internal error or vendor wrong usage occurs within the SPC framework.
*/
class SPCInternalException extends SPCException {}

View File

@ -0,0 +1,61 @@
<?php
declare(strict_types=1);
namespace StaticPHP\Exception;
use SPC\builder\Extension;
/**
* Exception thrown for validation errors in SPC.
*
* This exception is used to indicate that a validation error has occurred,
* typically when input data does not meet the required criteria.
*/
class ValidationException extends SPCException
{
private array|string|null $validation_module = null;
public function __construct(string $message = '', int $code = 0, ?\Throwable $previous = null, array|string|null $validation_module = null)
{
parent::__construct($message, $code, $previous);
// init validation module
if ($validation_module === null) {
foreach ($this->getTrace() as $trace) {
// Extension validate() => "Extension validator"
if (is_a($trace['class'] ?? null, Extension::class, true) && $trace['function'] === 'validate') {
$this->validation_module = 'Extension validator';
break;
}
// Other => "ClassName::functionName"
$this->validation_module = [
'class' => $trace['class'] ?? null,
'function' => $trace['function'],
];
break;
}
} else {
$this->validation_module = $validation_module;
}
}
/**
* Returns the validation module string.
*/
public function getValidationModuleString(): string
{
if ($this->validation_module === null) {
return 'Unknown';
}
if (is_string($this->validation_module)) {
return $this->validation_module;
}
$str = $this->validation_module['class'] ?? null;
if ($str !== null) {
$str .= '::';
}
return ($str ?? '') . $this->validation_module['function'];
}
}

View File

@ -0,0 +1,13 @@
<?php
declare(strict_types=1);
namespace StaticPHP\Exception;
/**
* Exception thrown for incorrect usage of SPC.
*
* This exception is used to indicate that the SPC is being used incorrectly.
* Such as when a command is not supported or an invalid argument is provided.
*/
class WrongUsageException extends SPCException {}

Some files were not shown because too many files have changed in this diff Show More