Merge branch 'v3' into fable-v3-windows

This commit is contained in:
Marc
2026-07-08 11:45:02 +07:00
committed by GitHub
136 changed files with 4051 additions and 1453 deletions

View File

@@ -644,7 +644,7 @@ class Artifact
'{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',
'{spc_msys2_path}' => getenv('SPC_MSYS2_PATH'),
'{working_dir}' => WORKING_DIR,
'{download_path}' => DOWNLOAD_PATH,
'{source_path}' => SOURCE_PATH,

View File

@@ -26,6 +26,7 @@ use StaticPHP\Exception\DownloaderException;
use StaticPHP\Exception\ExecutionException;
use StaticPHP\Exception\ValidationException;
use StaticPHP\Exception\WrongUsageException;
use StaticPHP\Package\ToolVersionRegistry;
use StaticPHP\Registry\ArtifactLoader;
use StaticPHP\Runtime\Shell\Shell;
use StaticPHP\Runtime\SystemTarget;
@@ -346,12 +347,32 @@ class ArtifactDownloader
}
}
public function checkUpdate(string $artifact_name, bool $prefer_source = false, bool $bare = false): CheckUpdateResult
public function checkUpdate(string $artifact_name, bool $prefer_source = false, bool $bare = false, bool $use_installed = false): CheckUpdateResult
{
$artifact = ArtifactLoader::getArtifactInstance($artifact_name);
if ($artifact === null) {
throw new WrongUsageException("Artifact '{$artifact_name}' not found, please check the name.");
}
// --installed: prefer the version actually installed on disk (tool packages only) as the
// baseline, instead of the download cache. Falls back to the normal cache/bare logic below
// if this artifact has no recorded installed version (not a tool package, or not installed
// via the package installer yet).
if ($use_installed) {
$installed_version = ToolVersionRegistry::get($artifact_name);
if ($installed_version !== null) {
[$first, $second] = $prefer_source
? [fn () => $this->probeSourceCheckUpdate($artifact, $artifact_name, $installed_version), fn () => $this->probeBinaryCheckUpdate($artifact, $artifact_name, $installed_version)]
: [fn () => $this->probeBinaryCheckUpdate($artifact, $artifact_name, $installed_version), fn () => $this->probeSourceCheckUpdate($artifact, $artifact_name, $installed_version)];
$result = $first() ?? $second();
if ($result !== null) {
return $result;
}
return new CheckUpdateResult(old: $installed_version, new: null, needUpdate: false, unsupported: true);
}
logger()->warning("Artifact '{$artifact_name}' has no recorded installed version (not a tool package, or not installed yet); falling back to download-cache based check.");
}
if ($bare) {
[$first, $second] = $prefer_source
? [fn () => $this->probeSourceCheckUpdate($artifact, $artifact_name), fn () => $this->probeBinaryCheckUpdate($artifact, $artifact_name)]
@@ -403,16 +424,17 @@ class ArtifactDownloader
* @param bool $prefer_source Whether to prefer source over binary
* @param bool $bare Check without requiring artifact to be downloaded first
* @param null|callable $onResult Called immediately with (string $name, CheckUpdateResult) as each result arrives
* @param bool $use_installed Prefer the installed (on-disk) version over the download cache (tool packages only)
* @return array<string, CheckUpdateResult> Results keyed by artifact name
*/
public function checkUpdates(array $artifact_names, bool $prefer_source = false, bool $bare = false, ?callable $onResult = null): array
public function checkUpdates(array $artifact_names, bool $prefer_source = false, bool $bare = false, ?callable $onResult = null, bool $use_installed = false): array
{
if ($this->parallel > 1 && count($artifact_names) > 1) {
return $this->checkUpdatesWithConcurrency($artifact_names, $prefer_source, $bare, $onResult);
return $this->checkUpdatesWithConcurrency($artifact_names, $prefer_source, $bare, $onResult, $use_installed);
}
$results = [];
foreach ($artifact_names as $name) {
$result = $this->checkUpdate($name, $prefer_source, $bare);
$result = $this->checkUpdate($name, $prefer_source, $bare, $use_installed);
$results[$name] = $result;
if ($onResult !== null) {
($onResult)($name, $result);
@@ -436,7 +458,7 @@ class ArtifactDownloader
return $this->options[$name] ?? $default;
}
private function checkUpdatesWithConcurrency(array $artifact_names, bool $prefer_source, bool $bare, ?callable $onResult): array
private function checkUpdatesWithConcurrency(array $artifact_names, bool $prefer_source, bool $bare, ?callable $onResult, bool $use_installed = false): array
{
$results = [];
$fiber_pool = [];
@@ -451,8 +473,8 @@ class ArtifactDownloader
// fill pool
while (count($fiber_pool) < $this->parallel && !empty($remaining)) {
$name = array_shift($remaining);
$fiber = new \Fiber(function () use ($name, $prefer_source, $bare) {
return [$name, $this->checkUpdate($name, $prefer_source, $bare)];
$fiber = new \Fiber(function () use ($name, $prefer_source, $bare, $use_installed) {
return [$name, $this->checkUpdate($name, $prefer_source, $bare, $use_installed)];
});
$fiber->start();
$fiber_pool[$name] = $fiber;
@@ -491,12 +513,12 @@ class ArtifactDownloader
return $results;
}
private function probeSourceCheckUpdate(Artifact $artifact, string $artifact_name): ?CheckUpdateResult
private function probeSourceCheckUpdate(Artifact $artifact, string $artifact_name, ?string $old_version = null): ?CheckUpdateResult
{
if (($callback = $artifact->getCustomSourceCheckUpdateCallback()) !== null) {
return ApplicationContext::invoke($callback, [
ArtifactDownloader::class => $this,
'old_version' => null,
'old_version' => $old_version,
]);
}
$config = $artifact->getDownloadConfig('source');
@@ -509,16 +531,16 @@ class ArtifactDownloader
}
/** @var CheckUpdateInterface $dl */
$dl = new $cls();
return $dl->checkUpdate($artifact_name, $config, null, $this);
return $dl->checkUpdate($artifact_name, $config, $old_version, $this);
}
private function probeBinaryCheckUpdate(Artifact $artifact, string $artifact_name): ?CheckUpdateResult
private function probeBinaryCheckUpdate(Artifact $artifact, string $artifact_name, ?string $old_version = null): ?CheckUpdateResult
{
// custom binary callback takes precedence over config-based binary
if (($callback = $artifact->getCustomBinaryCheckUpdateCallback()) !== null) {
return ApplicationContext::invoke($callback, [
ArtifactDownloader::class => $this,
'old_version' => null,
'old_version' => $old_version,
]);
}
$binary_config = $artifact->getDownloadConfig('binary');
@@ -532,7 +554,7 @@ class ArtifactDownloader
}
/** @var CheckUpdateInterface $dl */
$dl = new $cls();
return $dl->checkUpdate($artifact_name, $platform_config, null, $this);
return $dl->checkUpdate($artifact_name, $platform_config, $old_version, $this);
}
private function downloadWithType(Artifact $artifact, int $current, int $total, bool $parallel = false): int

View File

@@ -614,7 +614,7 @@ class ArtifactExtractor
'{source_path}' => SOURCE_PATH,
'{download_path}' => DOWNLOAD_PATH,
'{working_dir}' => WORKING_DIR,
'{php_sdk_path}' => getenv('PHP_SDK_PATH') ?: '',
'{spc_msys2_path}' => getenv('SPC_MSYS2_PATH') ?: '',
];
return str_replace(array_keys($replacement), array_values($replacement), $path);
}

View File

@@ -76,9 +76,10 @@ class DownloadResult
?string $version = null,
array $metadata = [],
?string $downloader = null,
mixed $extract = null,
): DownloadResult {
$cache_type = self::isArchiveFile($filename) ? 'archive' : 'file';
return new self($cache_type, config: $config, filename: $filename, verified: $verified, version: $version, metadata: $metadata, downloader: $downloader);
return new self($cache_type, config: $config, filename: $filename, extract: $extract, verified: $verified, version: $version, metadata: $metadata, downloader: $downloader);
}
/**

View File

@@ -45,7 +45,11 @@ class FileList implements DownloadTypeInterface, CheckUpdateInterface
throw new DownloaderException("Failed to get {$name} file list from {$config['url']}");
}
$versions = [];
logger()->debug('Matched ' . count($matches['version']) . " versions for {$name}");
$cnt = count($matches['version']);
if ($cnt === 0) {
throw new DownloaderException("Failed to get {$name} file list from {$config['url']}: no version parsed");
}
logger()->debug("Matched {$cnt} versions for {$name}");
foreach ($matches['version'] as $i => $version) {
$lower = strtolower($version);
foreach (['alpha', 'beta', 'rc', 'pre', 'nightly', 'snapshot', 'dev'] as $beta) {

View File

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

View File

@@ -54,6 +54,10 @@ abstract class BaseCommand extends Command
}
set_error_handler(static function ($error_no, $error_msg, $error_file, $error_line) {
// Respect the @ suppression operator (error_reporting() returns 0 when @ is used)
if (error_reporting() === 0) {
return true;
}
$tips = [
E_WARNING => ['PHP Warning: ', 'warning'],
E_NOTICE => ['PHP Notice: ', 'notice'],

View File

@@ -23,6 +23,7 @@ class CheckUpdateCommand extends BaseCommand
$this->addArgument('artifact', InputArgument::OPTIONAL, 'The name of the artifact(s) to check for updates, comma-separated (default: all downloaded artifacts)');
$this->addOption('json', null, null, 'Output result in JSON format');
$this->addOption('bare', null, null, 'Check update without requiring the artifact to be downloaded first (old version will be null)');
$this->addOption('installed', null, null, 'Compare against the version actually installed on disk (tool packages only) instead of the download cache; falls back to normal behavior for artifacts with no recorded installed version');
$this->addOption('parallel', 'p', InputOption::VALUE_REQUIRED, 'Number of parallel update checks (default: 10)', 10);
// --with-php option for checking updates with a specific PHP version context
@@ -45,8 +46,9 @@ class CheckUpdateCommand extends BaseCommand
try {
$downloader = new ArtifactDownloader($this->input->getOptions());
$bare = (bool) $this->getOption('bare');
$use_installed = (bool) $this->getOption('installed');
if ($this->getOption('json')) {
$results = $downloader->checkUpdates($artifacts, bare: $bare);
$results = $downloader->checkUpdates($artifacts, bare: $bare, use_installed: $use_installed);
$outputs = [];
foreach ($results as $artifact => $result) {
$outputs[$artifact] = [
@@ -59,7 +61,7 @@ class CheckUpdateCommand extends BaseCommand
$this->output->writeln(json_encode($outputs, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE));
return static::OK;
}
$downloader->checkUpdates($artifacts, bare: $bare, onResult: function (string $artifact, CheckUpdateResult $result) {
$downloader->checkUpdates($artifacts, bare: $bare, use_installed: $use_installed, onResult: function (string $artifact, CheckUpdateResult $result) {
if ($result->unsupported) {
$this->output->writeln("Artifact <info>{$artifact}</info> does not support update checking, <comment>skipped</comment>");
} elseif (!$result->needUpdate) {

View File

@@ -36,6 +36,15 @@ class CraftCommand extends BaseCommand
// set verbosity
$this->output->setVerbosity($craft['verbosity']);
// sync logger level and ApplicationContext debug mode to match the new verbosity
$level = match ($this->output->getVerbosity()) {
OutputInterface::VERBOSITY_VERBOSE => 'info',
OutputInterface::VERBOSITY_VERY_VERBOSE, OutputInterface::VERBOSITY_DEBUG => 'debug',
default => 'warning',
};
logger()->setLevel($level);
ApplicationContext::setDebug($this->output->getVerbosity() >= OutputInterface::VERBOSITY_DEBUG);
// apply env
array_walk($craft['extra-env'], fn ($v, $k) => f_putenv("{$k}={$v}"));
@@ -110,7 +119,7 @@ class CraftCommand extends BaseCommand
* shared-extensions: array<string>,
* packages: array<string>,
* sapi: array<string>,
* verbosity: int,
* verbosity: 128|16|256|32|64|8,
* debug: bool,
* clean-build: bool,
* build-options: array<string, mixed>,
@@ -171,11 +180,16 @@ class CraftCommand extends BaseCommand
}
// verbosity
$verbosity_level = $craft['verbosity'] ?? OutputInterface::VERBOSITY_NORMAL;
$debug = $craft['debug'] ?? false;
if ($debug) {
$verbosity_level = OutputInterface::VERBOSITY_DEBUG;
}
$verbosity_level = $debug
? OutputInterface::VERBOSITY_DEBUG
: match ((int) ($craft['verbosity'] ?? 0)) {
OutputInterface::VERBOSITY_QUIET => OutputInterface::VERBOSITY_QUIET,
OutputInterface::VERBOSITY_VERBOSE => OutputInterface::VERBOSITY_VERBOSE,
OutputInterface::VERBOSITY_VERY_VERBOSE => OutputInterface::VERBOSITY_VERY_VERBOSE,
OutputInterface::VERBOSITY_DEBUG => OutputInterface::VERBOSITY_DEBUG,
default => OutputInterface::VERBOSITY_NORMAL,
};
$craft['verbosity'] = $verbosity_level;
// clean-build (if true, reset before all builds)

View File

@@ -54,8 +54,8 @@ class DumpCapabilitiesCommand extends BaseCommand
{
$result = [];
// library / target / virtual-target
foreach (PackageLoader::getPackages(['library', 'target', 'virtual-target']) as $name => $pkg) {
// library / target / virtual-target / tool
foreach (PackageLoader::getPackages(['library', 'target', 'virtual-target', 'tool']) as $name => $pkg) {
$installable = [];
$artifact = $pkg->getArtifact();
if ($artifact !== null) {

View File

@@ -16,7 +16,7 @@ class GenExtTestMatrixCommand extends BaseCommand
private const array OS_RUNNERS = [
'linux' => ['arch' => 'x86_64', 'runner' => 'ubuntu-latest', 'os_key' => 'Linux'],
'windows' => ['arch' => 'x86_64', 'runner' => 'windows-latest', 'os_key' => 'Windows'],
'windows' => ['arch' => 'x86_64', 'runner' => 'windows-2025', 'os_key' => 'Windows'],
'macos' => ['arch' => 'aarch64', 'runner' => 'macos-15', 'os_key' => 'Darwin'],
];
@@ -60,6 +60,8 @@ class GenExtTestMatrixCommand extends BaseCommand
'glfw',
'imagick',
'intl',
'mongodb',
'gmssl',
];
/**
@@ -105,7 +107,12 @@ class GenExtTestMatrixCommand extends BaseCommand
// Separate into regular and virtual extensions (build-static:false excluded globally)
$all_regular = [];
$all_virtual = [];
$all_libraries = [];
foreach ($all as $pkg_name => $config) {
if (($config['type'] ?? '') === 'library') {
$all_libraries[$pkg_name] = $config;
continue;
}
if (($config['type'] ?? '') !== 'php-extension') {
continue;
}
@@ -152,10 +159,7 @@ class GenExtTestMatrixCommand extends BaseCommand
$raw,
fn ($d) => isset($pool_set[$d]) && $d !== $pkg_name
));
$os_lib_deps[$this->displayName($pkg_name)] = array_values(array_filter(
$raw,
fn ($d) => !str_starts_with($d, 'ext-')
));
$os_lib_deps[$this->displayName($pkg_name)] = $this->collectLibraryDeps($raw, $all_libraries, $os);
}
$all_ext_lib_deps[$os] = $os_lib_deps;
@@ -244,22 +248,23 @@ class GenExtTestMatrixCommand extends BaseCommand
}
}
if (!empty($filter_extensions)) {
$entries = array_values(array_filter($entries, function (array $entry) use ($filter_extensions): bool {
if (!empty($filter_extensions) || !empty($filter_libs)) {
$entries = array_values(array_filter($entries, function (array $entry) use ($filter_extensions, $filter_libs, $all_ext_lib_deps): bool {
$names = explode(',', $entry['extension']);
return count(array_intersect($names, $filter_extensions)) > 0;
}));
}
if (!empty($filter_libs)) {
$entries = array_values(array_filter($entries, function (array $entry) use ($filter_libs, $all_ext_lib_deps): bool {
$names = explode(',', $entry['extension']);
$lib_deps = $all_ext_lib_deps[$entry['os']] ?? [];
foreach ($names as $name) {
if (count(array_intersect($lib_deps[$name] ?? [], $filter_libs)) > 0) {
return true;
if (!empty($filter_extensions) && count(array_intersect($names, $filter_extensions)) > 0) {
return true;
}
if (!empty($filter_libs)) {
$lib_deps = $all_ext_lib_deps[$entry['os']] ?? [];
foreach ($names as $name) {
if (count(array_intersect($lib_deps[$name] ?? [], $filter_libs)) > 0) {
return true;
}
}
}
return false;
}));
}
@@ -298,6 +303,41 @@ class GenExtTestMatrixCommand extends BaseCommand
return str_starts_with($pkg_name, 'ext-') ? substr($pkg_name, 4) : $pkg_name;
}
/**
* Collect direct and transitive library dependencies from a package dependency list.
*
* @param string[] $deps
* @param array<string, mixed[]> $library_configs
* @param array<string, true> $seen
*
* @return string[]
*/
private function collectLibraryDeps(array $deps, array $library_configs, string $platform, array $seen = []): array
{
$collected = [];
foreach ($deps as $dep) {
if (str_starts_with($dep, 'ext-') || isset($seen[$dep])) {
continue;
}
$seen[$dep] = true;
$collected[$dep] = $dep;
if (!isset($library_configs[$dep])) {
continue;
}
$child_deps = array_merge(
$this->resolvePlatformList($library_configs[$dep], 'depends', $platform),
$this->resolvePlatformList($library_configs[$dep], 'suggests', $platform),
);
foreach ($this->collectLibraryDeps($child_deps, $library_configs, $platform, $seen) as $child_dep) {
$collected[$child_dep] = $child_dep;
}
}
return array_values($collected);
}
/**
* Split orphans into batches such that no two conflicting extensions share a batch.
* Uses a greedy graph-coloring approach.

View File

@@ -154,7 +154,7 @@ class TestBotCommand extends BaseCommand
'targets' => array_values($targets),
'gen_matrix_args' => $gen_matrix_args,
'gen_matrix_args_tier2' => $gen_matrix_args_tier2,
'php_versions' => array_values($php_versions),
'php_versions' => $php_versions,
'tier2' => $tier2,
'comment_body' => $comment_body,
];
@@ -253,6 +253,13 @@ class TestBotCommand extends BaseCommand
$fmt($targets),
);
$available_labels = implode(', ', [
'`need-test` (gate)',
'`test/linux` `test/windows` `test/macos` (platform)',
'`test/tier2` (extra arch)',
'`test/php-83` `test/php-84` (PHP version)',
]);
// Case 1: need-test absent → invite the author to add it
if (!$need_test) {
return implode("\n", [
@@ -261,11 +268,9 @@ class TestBotCommand extends BaseCommand
'',
$detected,
'',
'To trigger extension build tests on this PR, add the `need-test` label:',
'To trigger extension build tests on this PR, add the `need-test` label.',
'',
'**Gate**: `need-test`',
'**Platform filter** (optional, default all): `test/linux` `test/windows` `test/macos` · `test/tier2`',
'**PHP version** (optional, default 8.5): `test/php-83` `test/php-84`',
'**Available labels**: ' . $available_labels,
]);
}
@@ -307,6 +312,7 @@ class TestBotCommand extends BaseCommand
'',
$detected,
'**Active labels**: ' . $labels_str,
'**Available labels**: ' . $available_labels,
'**Config**: ' . implode(' + ', $platform_parts) . ' | ' . $php_str,
]);
}

View File

@@ -13,7 +13,7 @@ class DoctorCommand extends BaseCommand
{
public function configure(): void
{
$this->addOption('auto-fix', null, InputOption::VALUE_OPTIONAL, 'Automatically fix failed items (if possible)', false);
$this->addOption('auto-fix', 'y', InputOption::VALUE_OPTIONAL, 'Automatically fix failed items (if possible)', false);
}
public function handle(): int

View File

@@ -23,7 +23,7 @@ class InstallPackageCommand extends BaseCommand
'The package to install (name or path)',
suggestedValues: function (CompletionInput $input) {
$packages = [];
foreach (PackageLoader::getPackages(['target', 'virtual-target']) as $name => $_) {
foreach (PackageLoader::getPackages(['target', 'virtual-target', 'tool']) as $name => $_) {
$packages[] = $name;
}
$val = $input->getCompletionValue();

View File

@@ -4,13 +4,14 @@ declare(strict_types=1);
namespace StaticPHP\Command;
use StaticPHP\Exception\SPCInternalException;
use StaticPHP\Runtime\Shell\Shell;
use StaticPHP\Util\FileSystem;
use StaticPHP\Util\InteractiveTerm;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Helper\QuestionHelper;
use Symfony\Component\Console\Input\InputOption;
use function Laravel\Prompts\confirm;
use Symfony\Component\Console\Question\ConfirmationQuestion;
#[AsCommand('reset')]
class ResetCommand extends BaseCommand
@@ -46,7 +47,11 @@ class ResetCommand extends BaseCommand
// Confirm with user unless --yes is specified
if (!$this->input->getOption('yes')) {
if (!confirm('Are you sure you want to continue?', false)) {
$helper = $this->getHelper('question');
if (!$helper instanceof QuestionHelper) {
throw new SPCInternalException('Question helper not provided');
}
if (!$helper->ask($this->input, $this->output, new ConfirmationQuestion('Are you sure you want to continue? [y/N] ', false))) {
InteractiveTerm::error(message: 'Reset operation cancelled.');
return static::SUCCESS;
}

View File

@@ -38,7 +38,7 @@ class ArtifactConfig
*/
public static function loadFromFile(string $file, string $registry_name): string
{
$content = file_get_contents($file);
$content = @file_get_contents($file);
if ($content === false) {
throw new WrongUsageException("Failed to read artifact config file: {$file}");
}

View File

@@ -19,6 +19,7 @@ enum ConfigType
'php-extension',
'target',
'virtual-target',
'tool',
];
public static function validateLicenseField(mixed $value): bool

View File

@@ -24,6 +24,10 @@ class ConfigValidator
'lang' => ConfigType::STRING,
'frameworks' => ConfigType::LIST_ARRAY, // @
// build-time tool dependency declaration (resolved independently of the library
// dependency graph, see PackageInstaller::collectRequiredTools())
'tools' => ConfigType::LIST_ARRAY, // @
// php-extension type fields
'php-extension' => ConfigType::ASSOC_ARRAY,
'zend-extension' => ConfigType::BOOL,
@@ -44,6 +48,13 @@ class ConfigValidator
'path' => ConfigType::LIST_ARRAY, // @
'env' => ConfigType::ASSOC_ARRAY, // @
'append-env' => ConfigType::ASSOC_ARRAY, // @
// tool type fields (nested under 'tool' key)
'tool' => ConfigType::ASSOC_ARRAY,
'provides' => ConfigType::LIST_ARRAY,
'binary-subdir' => ConfigType::STRING,
'install-root' => ConfigType::STRING,
'min-version' => ConfigType::STRING,
];
public const array PACKAGE_FIELDS = [
@@ -56,6 +67,9 @@ class ConfigValidator
'lang' => false,
'frameworks' => false, // @
// build-time tool dependency declaration
'tools' => false, // @
// php-extension type fields
'php-extension' => false,
@@ -67,6 +81,9 @@ class ConfigValidator
'path' => false, // @
'env' => false, // @
'append-env' => false, // @
// tool fields (nested object)
'tool' => false,
];
public const array SUFFIX_ALLOWED_FIELDS = [
@@ -78,6 +95,7 @@ class ConfigValidator
'path',
'env',
'append-env',
'tools',
];
public const array PHP_EXTENSION_FIELDS = [
@@ -92,6 +110,13 @@ class ConfigValidator
'os' => false,
];
public const array TOOL_FIELDS = [
'provides' => true,
'binary-subdir' => false,
'install-root' => false,
'min-version' => false,
];
public const array ARTIFACT_TYPE_FIELDS = [ // [required_fields, optional_fields]
'filelist' => [['url', 'regex'], ['extract']],
'git' => [['url'], ['extract', 'submodules', 'rev', 'regex']],
@@ -220,8 +245,8 @@ class ConfigValidator
$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'])) {
// check if "library|target|tool" package has artifact field
if (in_array($pkg['type'], ['target', 'library', 'tool']) && !isset($pkg['artifact'])) {
throw new ValidationException("Package [{$name}] in {$config_file_name} of type '{$pkg['type']}' must have an 'artifact' field");
}
@@ -235,6 +260,11 @@ class ConfigValidator
self::validatePhpExtensionFields($name, $pkg);
}
// check if "tool" package has tool specific fields and validate inside
if ($pkg['type'] === 'tool') {
self::validateToolFields($name, $pkg);
}
// check for unknown fields
self::validateNoInvalidFields('package', $name, $pkg, array_keys(self::PACKAGE_FIELD_TYPES));
}
@@ -397,6 +427,29 @@ class ConfigValidator
self::validateNoInvalidFields('php-extension', $name, $pkg['php-extension'], array_keys(self::PHP_EXTENSION_FIELDS));
}
/**
* Validate tool specific fields for tool package type.
*/
private static function validateToolFields(int|string $name, mixed $pkg): void
{
if (!isset($pkg['tool'])) {
throw new ValidationException("Package {$name} of type 'tool' must have a 'tool' field");
}
if (!is_assoc_array($pkg['tool'])) {
throw new ValidationException("Package {$name} [tool] must be an object");
}
foreach (self::TOOL_FIELDS as $field => $required) {
if ($required && !isset($pkg['tool'][$field])) {
throw new ValidationException("Package {$name} [tool] must have required field [{$field}]");
}
if (isset($pkg['tool'][$field])) {
self::validatePackageFieldType($field, $pkg['tool'][$field], $name);
}
}
// check for unknown fields in tool
self::validateNoInvalidFields('tool', $name, $pkg['tool'], array_keys(self::TOOL_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) {

View File

@@ -16,7 +16,7 @@ class PackageConfig
/**
* Load package configurations from a specified directory.
* It will look for files matching the pattern 'pkg.*.json' and 'pkg.json'.
* Only processes .json, .yml, and .yaml files (skips .gitkeep etc.).
*/
public static function loadFromDir(string $dir, string $registry_name): array
{
@@ -28,6 +28,10 @@ class PackageConfig
$files = FileSystem::scanDirFiles($dir, false);
if (is_array($files)) {
foreach ($files as $file) {
$ext = pathinfo($file, PATHINFO_EXTENSION);
if (!in_array($ext, ['json', 'yml', 'yaml'], true)) {
continue;
}
self::loadFromFile($file, $registry_name);
$loaded[] = $file;
}

View File

@@ -32,7 +32,10 @@ use StaticPHP\Command\SPCConfigCommand;
use StaticPHP\Package\TargetPackage;
use StaticPHP\Registry\PackageLoader;
use StaticPHP\Registry\Registry;
use StaticPHP\Util\InteractiveTerm;
use Symfony\Component\Console\Application;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
class ConsoleApplication extends Application
{
@@ -104,4 +107,15 @@ class ConsoleApplication extends Application
{
self::$additional_commands = array_merge(self::$additional_commands, $additional_commands);
}
/**
* Hook Symfony's doRun() to inject the real input/output into InteractiveTerm before
* any command executes. From this point forward, InteractiveTerm uses the configured
* Console (respecting --no-ansi, --verbose, etc.) instead of the lazy STDERR fallback.
*/
public function doRun(InputInterface $input, OutputInterface $output): int
{
InteractiveTerm::init($input, $output);
return parent::doRun($input, $output);
}
}

View File

@@ -79,11 +79,11 @@ class ApplicationContext
/**
* Get a service from the container.
*
* @template T
* @template T of object
*
* @param class-string<T> $id Service identifier
* @param class-string<T>|string $id Service identifier
*
* @return null|T
* @return ($id is class-string<T> ? T : mixed)
*/
public static function get(string $id): mixed
{

View File

@@ -11,11 +11,14 @@ use StaticPHP\Registry\DoctorLoader;
use StaticPHP\Runtime\Shell\Shell;
use StaticPHP\Runtime\SystemTarget;
use StaticPHP\Util\InteractiveTerm;
use Symfony\Component\Console\Helper\QuestionHelper;
use Symfony\Component\Console\Input\ArrayInput;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\ConsoleOutput;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Question\ConfirmationQuestion;
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, public readonly bool $interactive = true)
@@ -125,9 +128,14 @@ readonly class Doctor
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;
if ($this->auto_fix === FIX_POLICY_PROMPT) {
$helper = new QuestionHelper();
$input = ApplicationContext::has(InputInterface::class) ? ApplicationContext::get(InputInterface::class) : new ArrayInput([]);
$output = ApplicationContext::has(OutputInterface::class) ? ApplicationContext::get(OutputInterface::class) : $this->output ?? new ConsoleOutput();
if (!$helper->ask($input, $output, new ConfirmationQuestion('Do you want to try to fix this issue now? [Y/n] ', true))) {
$this->output?->writeln('<comment>You canceled fix.</comment>');
return false;
}
}
// perform fix
InteractiveTerm::indicateProgress("Fixing {$result->getFixItem()} ... ");

View File

@@ -54,13 +54,24 @@ class WindowsToolCheck
return CheckResult::ok();
}
#[CheckItem('if php-sdk-binary-tools are downloaded', limit_os: 'Windows', level: 996)]
public function checkSDK(): ?CheckResult
#[CheckItem('if msys2-build-essentials is installed', limit_os: 'Windows', level: 996)]
public function checkMsys2(): ?CheckResult
{
if (!file_exists(getenv('PHP_SDK_PATH') . DIRECTORY_SEPARATOR . 'phpsdk-starter.bat')) {
return CheckResult::fail('php-sdk-binary-tools not downloaded', 'install-php-sdk');
$marker = PKG_ROOT_PATH . '\msys2-build-essentials\.spc-msys2-initialized';
if (!file_exists($marker)) {
return CheckResult::fail('msys2-build-essentials not installed', 'install-msys2-build-essentials');
}
return CheckResult::ok(getenv('PHP_SDK_PATH'));
return CheckResult::ok(PKG_ROOT_PATH . '\msys2-build-essentials\msys64');
}
#[CheckItem('if 7za.exe is installed', limit_os: 'Windows', level: 999)]
public function check7zaWin(): ?CheckResult
{
$path = FileSystem::convertPath(PKG_ROOT_PATH . '\bin\7za.exe');
if (!file_exists($path)) {
return CheckResult::fail('7za.exe not found', 'install-7za-win');
}
return CheckResult::ok($path);
}
#[CheckItem('if nasm installed', level: 995)]
@@ -112,12 +123,20 @@ class WindowsToolCheck
return true;
}
#[FixItem('install-php-sdk')]
public function installSDK(): bool
#[FixItem('install-msys2-build-essentials')]
public function installMsys2(): bool
{
FileSystem::removeDir(getenv('PHP_SDK_PATH'));
$installer = new PackageInstaller(interactive: false);
$installer->addInstallPackage('php-sdk-binary-tools');
$installer->addInstallPackage('msys2-build-essentials');
$installer->run(true);
return true;
}
#[FixItem('install-7za-win')]
public function install7zaWin(): bool
{
$installer = new PackageInstaller(interactive: false);
$installer->addInstallPackage('7za-win');
$installer->run(true);
return true;
}

View File

@@ -113,60 +113,6 @@ class LibraryPackage extends Package
}
}
/**
* Get extra CFLAGS for current package.
* You need to define the environment variable in the format of {LIBRARY_NAME}_CFLAGS
* where {LIBRARY_NAME} is the snake_case name of the library.
* For example, for libjpeg, the environment variable should be libjpeg_CFLAGS.
*/
public function getLibExtraCFlags(): string
{
// get environment variable
$env = getenv($this->getSnakeCaseName() . '_CFLAGS') ?: '';
// get default c flags
$arch_c_flags = getenv('SPC_DEFAULT_CFLAGS') ?: '';
if (!empty(getenv('SPC_DEFAULT_CFLAGS')) && !str_contains($env, $arch_c_flags)) {
$env .= ' ' . $arch_c_flags;
}
return trim($env);
}
/**
* Get extra CXXFLAGS for current package.
* You need to define the environment variable in the format of {LIBRARY_NAME}_CXXFLAGS
* where {LIBRARY_NAME} is the snake_case name of the library.
* For example, for libjpeg, the environment variable should be libjpeg_CXXFLAGS.
*/
public function getLibExtraCxxFlags(): string
{
// get environment variable
$env = getenv($this->getSnakeCaseName() . '_CXXFLAGS') ?: '';
// get default cxx flags
$arch_cxx_flags = getenv('SPC_DEFAULT_CXXFLAGS') ?: '';
if (!empty(getenv('SPC_DEFAULT_CXXFLAGS')) && !str_contains($env, $arch_cxx_flags)) {
$env .= ' ' . $arch_cxx_flags;
}
return trim($env);
}
/**
* Get extra LDFLAGS for current package.
* You need to define the environment variable in the format of {LIBRARY_NAME}_LDFLAGS
* where {LIBRARY_NAME} is the snake_case name of the library.
* For example, for libjpeg, the environment variable should be libjpeg_LDFLAGS.
*/
public function getLibExtraLdFlags(): string
{
// get environment variable
$env = getenv($this->getSnakeCaseName() . '_LDFLAGS') ?: '';
// get default ld flags
$arch_ld_flags = getenv('SPC_DEFAULT_LDFLAGS') ?: '';
if (!empty(getenv('SPC_DEFAULT_LDFLAGS')) && !str_contains($env, $arch_ld_flags)) {
$env .= ' ' . $arch_ld_flags;
}
return trim($env);
}
/**
* Patch pkgconfig file prefix, exec_prefix, libdir, includedir for correct build.
*
@@ -364,17 +310,6 @@ class LibraryPackage extends Package
return $res['libs'];
}
/**
* Get extra LIBS for current package.
* You need to define the environment variable in the format of {LIBRARY_NAME}_LIBS
* where {LIBRARY_NAME} is the snake_case name of the library.
* For example, for libjpeg, the environment variable should be libjpeg_LIBS.
*/
public function getLibExtraLibs(): string
{
return getenv($this->getSnakeCaseName() . '_LIBS') ?: '';
}
/**
* Get tar compress options from suffix
*

View File

@@ -120,6 +120,20 @@ abstract class Package
return false;
}
/**
* Get the target directory where this package's artifacts should be placed.
*
* Libraries install to BUILD_ROOT_PATH (static-libs, headers, pkg-configs).
* Tools install to PKG_ROOT_PATH (executables).
* Extensions install to php-src/ext/ (shared objects).
*
* Override in subclasses to change the default.
*/
public function getInstallTarget(): string
{
return BUILD_ROOT_PATH;
}
/**
* Add a stage to the package.
*/
@@ -270,6 +284,71 @@ abstract class Package
return $this->getArtifact()?->hasPlatformBinary() ?? false;
}
/**
* Get extra CFLAGS for current package.
* You need to define the environment variable in the format of {PACKAGE_NAME}_CFLAGS
* where {PACKAGE_NAME} is the snake_case name of the package.
* For example, for libjpeg, the environment variable should be libjpeg_CFLAGS.
*/
public function getLibExtraCFlags(): string
{
// get environment variable
$env = getenv($this->getSnakeCaseName() . '_CFLAGS') ?: '';
// get default c flags
$arch_c_flags = getenv('SPC_DEFAULT_CFLAGS') ?: '';
if (!empty(getenv('SPC_DEFAULT_CFLAGS')) && !str_contains($env, $arch_c_flags)) {
$env .= ' ' . $arch_c_flags;
}
return trim($env);
}
/**
* Get extra CXXFLAGS for current package.
* You need to define the environment variable in the format of {PACKAGE_NAME}_CXXFLAGS
* where {PACKAGE_NAME} is the snake_case name of the package.
* For example, for libjpeg, the environment variable should be libjpeg_CXXFLAGS.
*/
public function getLibExtraCxxFlags(): string
{
// get environment variable
$env = getenv($this->getSnakeCaseName() . '_CXXFLAGS') ?: '';
// get default cxx flags
$arch_cxx_flags = getenv('SPC_DEFAULT_CXXFLAGS') ?: '';
if (!empty(getenv('SPC_DEFAULT_CXXFLAGS')) && !str_contains($env, $arch_cxx_flags)) {
$env .= ' ' . $arch_cxx_flags;
}
return trim($env);
}
/**
* Get extra LDFLAGS for current package.
* You need to define the environment variable in the format of {PACKAGE_NAME}_LDFLAGS
* where {PACKAGE_NAME} is the snake_case name of the package.
* For example, for libjpeg, the environment variable should be libjpeg_LDFLAGS.
*/
public function getLibExtraLdFlags(): string
{
// get environment variable
$env = getenv($this->getSnakeCaseName() . '_LDFLAGS') ?: '';
// get default ld flags
$arch_ld_flags = getenv('SPC_DEFAULT_LDFLAGS') ?: '';
if (!empty(getenv('SPC_DEFAULT_LDFLAGS')) && !str_contains($env, $arch_ld_flags)) {
$env .= ' ' . $arch_ld_flags;
}
return trim($env);
}
/**
* Get extra LIBS for current package.
* You need to define the environment variable in the format of {PACKAGE_NAME}_LIBS
* where {PACKAGE_NAME} is the snake_case name of the package.
* For example, for libjpeg, the environment variable should be libjpeg_LIBS.
*/
public function getLibExtraLibs(): string
{
return getenv($this->getSnakeCaseName() . '_LIBS') ?: '';
}
/**
* Get the snake_case name of the package.
*/

View File

@@ -4,6 +4,7 @@ declare(strict_types=1);
namespace StaticPHP\Package;
use StaticPHP\Artifact\ArtifactCache;
use StaticPHP\Config\PackageConfig;
use StaticPHP\DI\ApplicationContext;
use StaticPHP\Exception\SPCException;
@@ -39,8 +40,8 @@ class PackageBuilder
public function buildPackage(Package $package, bool $force = false): int
{
// init build dirs
if (!$package instanceof LibraryPackage) {
throw new SPCInternalException('Please, never try to build non-library packages directly.');
if (!$package instanceof LibraryPackage && !$package instanceof ToolPackage) {
throw new SPCInternalException('Please, never try to build non-library, non-tool packages directly.');
}
FileSystem::createDir($package->getBuildRootPath());
FileSystem::createDir($package->getIncludeDir());
@@ -78,6 +79,14 @@ class PackageBuilder
$this->installLicense($package, $license);
}
}
// Record the installed version for tool packages built from source, so
// `check-update --installed` reflects what's actually on disk (mirrors the
// binary-install recording in PackageInstaller::installBinary()).
if ($package instanceof ToolPackage) {
$source_info = ApplicationContext::get(ArtifactCache::class)->getSourceInfo($package->getName());
ToolVersionRegistry::record($package->getName(), $source_info['version'] ?? null);
}
} catch (SPCException $e) {
// Ensure package information is bound if not already
if ($e->getPackageInfo() === null) {

View File

@@ -11,6 +11,7 @@ use StaticPHP\Artifact\ArtifactExtractor;
use StaticPHP\Artifact\DownloaderOptions;
use StaticPHP\Config\PackageConfig;
use StaticPHP\DI\ApplicationContext;
use StaticPHP\Exception\EnvironmentException;
use StaticPHP\Exception\WrongUsageException;
use StaticPHP\Registry\PackageLoader;
use StaticPHP\Runtime\SystemTarget;
@@ -75,6 +76,9 @@ class PackageInstaller
}
// special check for php target packages
if (in_array($package->getName(), ['php', 'php-cli', 'php-fpm', 'php-micro', 'php-cgi', 'php-embed', 'frankenphp'], true)) {
if (!$package instanceof TargetPackage) {
throw new WrongUsageException("Package '{$package->getName()}' is expected to be a TargetPackage.");
}
$this->handlePhpTargetPackage($package);
return $this;
}
@@ -171,7 +175,7 @@ class PackageInstaller
// These must always download binary (not source), regardless of global prefer-source setting.
$binary_only_packages = array_filter(
$this->packages,
fn ($p) => $p instanceof LibraryPackage
fn ($p) => ($p instanceof LibraryPackage || $p instanceof ToolPackage)
&& !$this->isBuildPackage($p)
&& !$p->hasStage('build')
&& ($p->getArtifact()?->hasPlatformBinary() ?? false)
@@ -209,8 +213,8 @@ class PackageInstaller
$builder = ApplicationContext::get(PackageBuilder::class);
foreach ($this->packages as $package) {
$is_to_build = $this->isBuildPackage($package);
$has_build_stage = $package instanceof LibraryPackage && $package->hasStage('build');
$should_use_binary = $package instanceof LibraryPackage && ($package->getArtifact()?->shouldUseBinary() ?? false);
$has_build_stage = ($package instanceof LibraryPackage || $package instanceof ToolPackage) && $package->hasStage('build');
$should_use_binary = ($package instanceof LibraryPackage || $package instanceof ToolPackage) && ($package->getArtifact()?->shouldUseBinary() ?? false);
$has_source = $package->hasSource();
if (!$is_to_build && $should_use_binary) {
// install binary
@@ -273,6 +277,11 @@ class PackageInstaller
// perform after-install actions and emit post-install events
$this->emitPostInstallEvents();
// Final verification: declared build-time tools should have been auto-installed by the
// pipeline above (they were merged into $this->packages in resolvePackages()). This only
// throws if a tool genuinely failed to become available (e.g. no artifact for this platform).
$this->ensureRequiredTools();
}
public function isBuildPackage(Package|string $package): bool
@@ -350,12 +359,16 @@ class PackageInstaller
}
// Fallback: if the download cache is missing (e.g. download failed or cache was cleared),
// still check whether the files are physically present in buildroot.
// Note: TargetPackage extends LibraryPackage, but target packages (e.g. zig) have no
// Note: TargetPackage extends LibraryPackage, but target packages have no
// static-libs/headers configured, so isInstalled() would trivially return true for them.
// Only apply this fallback to pure library packages.
if ($package instanceof LibraryPackage && !($package instanceof TargetPackage)) {
return $package->isInstalled();
}
// Tool packages: fall back to checking their provided binaries directly on disk.
if ($package instanceof ToolPackage) {
return $package->isInstalled();
}
return false;
}
@@ -504,6 +517,16 @@ class PackageInstaller
}
$status = $extractor->extract($artifact);
// Record the installed version for tool packages, so `check-update --installed` can
// compare against what's actually on disk instead of only the download cache (which
// only reflects the last download and may be stale or cleared). Recorded regardless of
// $status so the registry self-heals if it was deleted separately from the install dir.
if ($package instanceof ToolPackage) {
$cache_info = ApplicationContext::get(ArtifactCache::class)->getBinaryInfo($artifact->getName(), SystemTarget::getCurrentPlatformString());
ToolVersionRegistry::record($artifact->getName(), $cache_info['version'] ?? null);
}
if ($status === SPC_STATUS_ALREADY_EXTRACTED) {
return SPC_STATUS_ALREADY_INSTALLED;
}
@@ -571,6 +594,66 @@ class PackageInstaller
return null;
}
/**
* Collect all tool packages required by the currently resolved packages.
*
* Reads the 'tools' field from each resolved package's YAML config.
* The field supports platform suffixes (tools@windows, tools@linux, etc.)
* resolved automatically by PackageConfig::get().
*
* Tools are NOT part of the library dependency graph — they are
* build-time prerequisites that must be installed before any library
* build begins.
*
* @return string[] Unique tool package names required for this build
*/
public function collectRequiredTools(): array
{
$tools = [];
foreach ($this->packages as $package) {
$deps = PackageConfig::get($package->getName(), 'tools', []);
foreach ((array) $deps as $tool_name) {
$tools[$tool_name] = true;
}
}
return array_keys($tools);
}
/**
* Check that all required tools are installed.
*
* Iterates through tools collected by collectRequiredTools(),
* resolves each to a ToolPackage instance, and checks isInstalled().
*
* @return array{missing: array<string>, installed: array<string>}
*/
public function checkRequiredTools(): array
{
$missing = [];
$installed = [];
foreach ($this->collectRequiredTools() as $tool_name) {
try {
$tool = PackageLoader::getPackage($tool_name);
} catch (WrongUsageException) {
$missing[] = $tool_name;
logger()->warning("Required tool '{$tool_name}' is not registered as a package.");
continue;
}
if (!$tool instanceof ToolPackage) {
logger()->warning("Package '{$tool_name}' is declared as a tool dependency but is not a ToolPackage (type: {$tool->getType()}).");
continue;
}
if ($tool->isInstalled()) {
$installed[] = $tool_name;
} else {
$missing[] = $tool_name;
}
}
return ['missing' => $missing, 'installed' => $installed];
}
/**
* @param Package[] $packages
*/
@@ -594,8 +677,8 @@ class PackageInstaller
*/
private function validatePackageArtifact(Package $package): void
{
// target and library must have at least source or platform binary
if (in_array($package->getType(), ['library', 'target']) && !$package->getArtifact()?->hasSource() && !$package->getArtifact()?->hasPlatformBinary()) {
// target, library and tool packages must have at least source or platform binary
if (in_array($package->getType(), ['library', 'target', 'tool']) && !$package->getArtifact()?->hasSource() && !$package->getArtifact()?->hasPlatformBinary()) {
throw new WrongUsageException("Validation failed: Target package '{$package->getName()}' has no source or current platform (" . SystemTarget::getCurrentPlatformString() . ') binary defined.');
}
}
@@ -628,11 +711,62 @@ class PackageInstaller
$this->packages[$pkg_name] = PackageLoader::getPackage($pkg_name);
}
// Merge in build-time tool packages declared via 'tools'/'tools@platform' fields on the
// packages resolved above. Tools are intentionally NOT part of the library dependency graph
// (DependencyResolver), but still need to ride the normal download/extract/install pipeline
// so they get auto-installed like any other package.
//
// Tools are prepended so they install BEFORE any package that declares them — otherwise a
// library that invokes e.g. jom.exe during its build stage would fail because the tool
// hasn't been extracted yet (tools were appended after the dependency graph in insertion
// order, which is also the build-loop iteration order).
$tool_packages = [];
foreach ($this->collectRequiredTools() as $tool_name) {
if (isset($this->packages[$tool_name])) {
continue;
}
try {
$tool = PackageLoader::getPackage($tool_name);
} catch (WrongUsageException) {
continue; // will be reported as missing by ensureRequiredTools()
}
if ($tool instanceof ToolPackage) {
$tool_packages[$tool_name] = $tool;
}
}
// Prepend: tools first, then the dependency-resolved packages.
$this->packages = [...$tool_packages, ...$this->packages];
foreach ($this->packages as $package) {
$this->injectPackageEnvs($package);
}
}
/**
* Ensure all required tools are installed, throwing if any are missing.
*
* Called at the end of run(), after the normal download/extract/build/install pipeline
* has had a chance to auto-install any tool package merged in resolvePackages(). This is
* a final safety net: it only throws if a declared tool still isn't available afterward
* (e.g. no artifact defined for the current platform, or the tool name doesn't resolve to
* a registered ToolPackage). "General" tools not declared via any package's 'tools' field
* (e.g. zig, musl-toolchain) are not covered here and remain purely Doctor-driven.
*/
private function ensureRequiredTools(): void
{
$status = $this->checkRequiredTools();
if (empty($status['missing'])) {
if (!empty($status['installed'])) {
logger()->info('Required tools: ' . implode(', ', $status['installed']) . ' — all installed.');
}
return;
}
$msg = 'Missing required build tools: ' . implode(', ', $status['missing']) . "\n";
$msg .= "Run 'bin/spc doctor' to check your environment, or install the missing tools manually.";
throw new EnvironmentException($msg);
}
private function injectPackageEnvs(Package $package): void
{
$name = $package->getName();

View File

@@ -0,0 +1,233 @@
<?php
declare(strict_types=1);
namespace StaticPHP\Package;
use StaticPHP\Config\PackageConfig;
use StaticPHP\Runtime\SystemTarget;
use StaticPHP\Util\FileSystem;
/**
* Represents a build-time tool package.
*
* Tool packages are NOT link-time dependencies. They provide executables
* that are needed during the build process (compilers, code generators,
* assemblers, etc.) and are installed into PKG_ROOT_PATH.
*
* Tool packages do NOT produce static-libs, headers, or pkg-config files.
* They are resolved and installed independently from the library dependency graph.
*
* YAML config schema (config/pkg/tool/<name>.yml):
*
* nasm:
* type: tool
* tool:
* provides: [nasm.exe, ndisasm.exe] # executables this tool installs
* binary-subdir: '' # subdirectory under install root (default: '')
* min-version: '2.16' # minimum required version (optional)
*
* Fields nested under 'tool' support the same '@windows'/'@unix'/'@macos'/'@linux' suffix
* overrides as top-level package fields (e.g. 'provides@windows' overrides 'provides' when
* building on Windows), useful when a tool provides differently-named binaries per OS
* (e.g. upx vs upx.exe).
* artifact:
* binary:
* windows-x86_64:
* type: url
* url: 'https://...'
* extract:
* nasm.exe: '{php_sdk_path}/bin/nasm.exe'
*/
class ToolPackage extends Package
{
/**
* Get the build root ('--prefix') for a tool that builds from source.
*
* Unlike LibraryPackage (which installs into BUILD_ROOT_PATH), tool packages that build
* from source (e.g. pkg-config, via UnixAutoconfExecutor/UnixCMakeExecutor) install into
* their own install root (PKG_ROOT_PATH by default), consistent with pre-built tool binaries.
*/
public function getBuildRootPath(): string
{
return $this->getInstallRoot();
}
/**
* Tool packages don't produce headers for other packages to consume. Kept self-contained
* under the tool's own install root so a from-source build never accidentally picks up
* unrelated headers from BUILD_ROOT_PATH.
*/
public function getIncludeDir(): string
{
return $this->getInstallRoot() . DIRECTORY_SEPARATOR . 'include';
}
/**
* Tool packages don't produce libraries for other packages to consume. Kept self-contained
* under the tool's own install root (see getIncludeDir()).
*/
public function getLibDir(): string
{
return $this->getInstallRoot() . DIRECTORY_SEPARATOR . 'lib';
}
/**
* Where this tool's own executables live, i.e. {install-root}/{binary-subdir}.
*/
public function getBinDir(): string
{
return $this->getBinaryDir();
}
/**
* Get the install root directory for this tool.
*
* Defaults to PKG_ROOT_PATH. Override via 'tool.install-root' in YAML
* or via the TOOL_INSTALL_ROOT_{NAME} environment variable.
*/
public function getInstallRoot(): string
{
$env_var = 'TOOL_INSTALL_ROOT_' . strtoupper(str_replace('-', '_', $this->name));
if ($root = getenv($env_var)) {
return $root;
}
$config_root = $this->getToolField('install-root');
if ($config_root !== null) {
return FileSystem::replacePathVariable((string) $config_root);
}
return PKG_ROOT_PATH;
}
/**
* Get the directory where this tool's binaries reside.
*
* This is {install-root}/{binary-subdir}. If binary-subdir is not
* configured, returns the install root directly.
*/
public function getBinaryDir(): string
{
$subdir = $this->getToolField('binary-subdir') ?? '';
if ($subdir === '') {
return $this->getInstallRoot();
}
return $this->getInstallRoot() . DIRECTORY_SEPARATOR . $subdir;
}
/**
* Get the list of executables this tool provides.
*
* Reads from YAML 'tool.provides' field (with '@windows'/'@unix'/'@macos'/'@linux'
* suffix override support). Each entry is a bare filename (e.g. 'nasm.exe'), resolved
* relative to getBinaryDir().
*
* @return string[] Bare executable names (not full paths)
*/
public function getProvides(): array
{
return $this->getToolField('provides') ?? [];
}
/**
* Get the full path to a specific binary provided by this tool.
*
* @param string $name Bare executable name (must be listed in tool.provides).
* If empty, defaults to the first entry in provides.
* @return string Full absolute path to the binary
*/
public function getBinary(string $name = ''): string
{
$provides = $this->getProvides();
if ($name === '') {
$name = $provides[0] ?? throw new \RuntimeException("Tool '{$this->name}' has no 'tool.provides' configured.");
}
if (!in_array($name, $provides, true)) {
throw new \RuntimeException("Binary '{$name}' is not listed in tool.provides for '{$this->name}'. Available: " . implode(', ', $provides));
}
return $this->getBinaryDir() . DIRECTORY_SEPARATOR . $name;
}
/**
* Check whether this tool is installed (all provided binaries exist on disk).
*/
public function isInstalled(): bool
{
return array_all($this->getProvides(), fn ($binary) => file_exists($this->getBinary($binary)));
}
/**
* Get the version currently installed on disk, as recorded by ToolVersionRegistry when this
* tool's binary was last (re-)installed via PackageInstaller::installBinary().
*
* Returns null if the tool was never installed through the package installer (e.g. installed
* manually, or not installed at all), or if its artifact doesn't expose a version string.
* This reflects what's actually on disk, unlike the download cache which only reflects the
* last download and may be stale or cleared.
*/
public function getInstalledVersion(): ?string
{
return ToolVersionRegistry::get($this->name);
}
/**
* Get the minimum required version for this tool, if specified.
*
* Returns null if no version constraint is configured.
*/
public function getMinVersion(): ?string
{
$version = $this->getToolField('min-version');
return $version !== null ? (string) $version : null;
}
/**
* Tools install to PKG_ROOT_PATH (or the configured install-root),
* not BUILD_ROOT_PATH.
*/
public function getInstallTarget(): string
{
return $this->getBinaryDir();
}
/**
* Get the 'tool' sub-config for this package.
*
* Returns the nested array under the 'tool' key in the package YAML,
* or an empty array if not configured.
*
* @return array<string, mixed>
*/
private function getToolConfig(): array
{
$config = PackageConfig::get($this->name);
if (!is_array($config) || !isset($config['tool']) || !is_array($config['tool'])) {
return [];
}
return $config['tool'];
}
/**
* Get a field from the nested 'tool' config block, honoring the same
* '@windows'/'@unix'/'@macos'/'@linux'/'@bsd'/'@freebsd' suffix override priority
* that PackageConfig::get() applies to top-level fields. This lets a tool declare a
* per-OS override, e.g. 'provides' + 'provides@windows', without needing platform-
* specific package config files.
*/
private function getToolField(string $field): mixed
{
$tool = $this->getToolConfig();
$suffixes = match (SystemTarget::getTargetOS()) {
'Windows' => ['@windows', ''],
'Darwin' => ['@macos', '@unix', ''],
'Linux' => ['@linux', '@unix', ''],
'BSD' => ['@freebsd', '@bsd', '@unix', ''],
};
foreach ($suffixes as $suffix) {
$key = "{$field}{$suffix}";
if (isset($tool[$key])) {
return $tool[$key];
}
}
return null;
}
}

View File

@@ -0,0 +1,79 @@
<?php
declare(strict_types=1);
namespace StaticPHP\Package;
use StaticPHP\Runtime\SystemTarget;
/**
* Tracks the version actually installed on disk for tool packages (PKG_ROOT_PATH), separate from
* the download cache (ArtifactCache). Tools are installed once and reused across many builds, so
* the download cache (which only reflects the last download) can drift from what's really on disk
* (e.g. cache cleared, or installed a long time ago via `doctor`). This registry is the source of
* truth for "what version is currently installed", used by ToolPackage::getInstalledVersion() and
* the `check-update --installed` flag.
*
* Backed by a small JSON file at PKG_ROOT_PATH/.spc-tool-versions.json, mirroring the same
* read-once/write-through pattern used by StaticPHP\Artifact\ArtifactCache.
*/
class ToolVersionRegistry
{
/** @var null|array<string, array{version: null|string, platform: string, installed_at: string}> */
private static ?array $data = null;
/**
* Get the recorded installed version for a tool, or null if never recorded (not a tool
* package, not installed yet, or the artifact doesn't expose a version).
*/
public static function get(string $tool_name): ?string
{
self::load();
return self::$data[$tool_name]['version'] ?? null;
}
/**
* Record the version currently installed for a tool. Called after a tool package's binary
* has been (re-)installed, regardless of whether extraction actually ran (keeps the registry
* self-healing if it was deleted separately from the installed files).
*/
public static function record(string $tool_name, ?string $version): void
{
self::load();
self::$data[$tool_name] = [
'version' => $version,
'platform' => SystemTarget::getCurrentPlatformString(),
'installed_at' => date('c'),
];
self::save();
}
private static function getPath(): string
{
return PKG_ROOT_PATH . '/.spc-tool-versions.json';
}
private static function load(): void
{
if (self::$data !== null) {
return;
}
$path = self::getPath();
if (!file_exists($path)) {
self::$data = [];
return;
}
$content = file_get_contents($path);
self::$data = is_string($content) ? (json_decode($content, true) ?: []) : [];
}
private static function save(): void
{
$path = self::getPath();
$dir = dirname($path);
if (!is_dir($dir)) {
@mkdir($dir, 0755, true);
}
file_put_contents($path, json_encode(self::$data, JSON_UNESCAPED_SLASHES | JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE));
}
}

View File

@@ -17,6 +17,7 @@ use StaticPHP\Attribute\Package\PatchBeforeBuild;
use StaticPHP\Attribute\Package\ResolveBuild;
use StaticPHP\Attribute\Package\Stage;
use StaticPHP\Attribute\Package\Target;
use StaticPHP\Attribute\Package\Tool;
use StaticPHP\Attribute\Package\Validate;
use StaticPHP\Config\PackageConfig;
use StaticPHP\DI\ApplicationContext;
@@ -27,6 +28,7 @@ use StaticPHP\Package\Package;
use StaticPHP\Package\PackageInstaller;
use StaticPHP\Package\PhpExtensionPackage;
use StaticPHP\Package\TargetPackage;
use StaticPHP\Package\ToolPackage;
use StaticPHP\Util\FileSystem;
class PackageLoader
@@ -88,6 +90,7 @@ class PackageLoader
'target', 'virtual-target' => new TargetPackage($name, $item['type']),
'library' => new LibraryPackage($name, $item['type']),
'php-extension' => new PhpExtensionPackage($name, $item['type']),
'tool' => new ToolPackage($name, $item['type']),
default => null,
};
if ($pkg !== null) {
@@ -190,7 +193,8 @@ class PackageLoader
$attribute_instance = $attribute->newInstance();
if ($attribute_instance instanceof Target === false &&
$attribute_instance instanceof Library === false &&
$attribute_instance instanceof Extension === false) {
$attribute_instance instanceof Extension === false &&
$attribute_instance instanceof Tool === false) {
// not a package attribute
continue;
}
@@ -216,6 +220,7 @@ class PackageLoader
Target::class => ['target', 'virtual-target'],
Library::class => ['library'],
Extension::class => ['php-extension'],
Tool::class => ['tool'],
default => null,
};
if (!in_array($package_type, $pkg_type_attr, true)) {
@@ -370,7 +375,10 @@ class PackageLoader
// match condition
$installer = ApplicationContext::get(PackageInstaller::class);
$stages = self::$before_stages[$package_name][$stage] ?? [];
foreach ($stages as [$callback, $only_when_package_resolved, $conditionals]) {
foreach ($stages as $entry) {
$callback = $entry[0];
$only_when_package_resolved = $entry[1] ?? null;
$conditionals = $entry[2] ?? [];
if ($only_when_package_resolved !== null && !$installer->isPackageResolved($only_when_package_resolved)) {
continue;
}
@@ -389,7 +397,10 @@ class PackageLoader
$installer = ApplicationContext::get(PackageInstaller::class);
$stages = self::$after_stages[$package_name][$stage] ?? [];
$result = [];
foreach ($stages as [$callback, $only_when_package_resolved, $conditionals]) {
foreach ($stages as $entry) {
$callback = $entry[0];
$only_when_package_resolved = $entry[1] ?? null;
$conditionals = $entry[2] ?? [];
if ($only_when_package_resolved !== null && !$installer->isPackageResolved($only_when_package_resolved)) {
continue;
}
@@ -433,7 +444,9 @@ class PackageLoader
}
$pkg = self::getPackage($package_name);
foreach ($stages as $stage_name => $before_events) {
foreach ($before_events as [$event_callable, $only_when_package_resolved, $conditionals]) {
foreach ($before_events as $entry) {
$event_callable = $entry[0];
$only_when_package_resolved = $entry[1] ?? null;
// check only_when_package_resolved package exists
if ($only_when_package_resolved !== null && !self::hasPackage($only_when_package_resolved)) {
throw new RegistryException("{$event_name} event in package [{$package_name}] for stage [{$stage_name}] has unknown only_when_package_resolved package [{$only_when_package_resolved}].");

View File

@@ -5,12 +5,13 @@ declare(strict_types=1);
namespace StaticPHP\Runtime\Executor;
use StaticPHP\Package\LibraryPackage;
use StaticPHP\Package\ToolPackage;
abstract class Executor
{
public function __construct(protected LibraryPackage $package) {}
public function __construct(protected LibraryPackage|ToolPackage $package) {}
public static function create(LibraryPackage $package): static
public static function create(LibraryPackage|ToolPackage $package): static
{
return new static($package);
}

View File

@@ -10,6 +10,7 @@ use StaticPHP\Exception\SPCInternalException;
use StaticPHP\Package\LibraryPackage;
use StaticPHP\Package\PackageBuilder;
use StaticPHP\Package\PackageInstaller;
use StaticPHP\Package\ToolPackage;
use StaticPHP\Runtime\Shell\UnixShell;
use StaticPHP\Util\InteractiveTerm;
use ZM\Logger\ConsoleColor;
@@ -22,7 +23,7 @@ class UnixAutoconfExecutor extends Executor
protected PackageInstaller $installer;
public function __construct(protected LibraryPackage $package, ?PackageInstaller $installer = null)
public function __construct(protected LibraryPackage|ToolPackage $package, ?PackageInstaller $installer = null)
{
parent::__construct($package);
if ($installer !== null) {
@@ -117,7 +118,7 @@ class UnixAutoconfExecutor extends Executor
/**
* Add configure args.
*/
public function addConfigureArgs(...$args): static
public function addConfigureArgs(string ...$args): static
{
$this->configure_args = [...$this->configure_args, ...$args];
return $this;
@@ -126,7 +127,7 @@ class UnixAutoconfExecutor extends Executor
/**
* Remove some configure args, to bypass the configure option checking for some libs.
*/
public function removeConfigureArgs(...$args): static
public function removeConfigureArgs(string ...$args): static
{
$this->configure_args = array_diff($this->configure_args, $args);
return $this;

View File

@@ -11,6 +11,7 @@ use StaticPHP\Package\LibraryPackage;
use StaticPHP\Package\PackageBuilder;
use StaticPHP\Package\PackageInstaller;
use StaticPHP\Package\TargetPackage;
use StaticPHP\Package\ToolPackage;
use StaticPHP\Runtime\Shell\UnixShell;
use StaticPHP\Runtime\SystemTarget;
use StaticPHP\Util\FileSystem;
@@ -40,7 +41,7 @@ class UnixCMakeExecutor extends Executor
protected PackageInstaller $installer;
public function __construct(protected LibraryPackage $package, ?PackageInstaller $installer = null)
public function __construct(protected LibraryPackage|ToolPackage $package, ?PackageInstaller $installer = null)
{
parent::__construct($package);
if ($installer !== null) {
@@ -135,7 +136,7 @@ class UnixCMakeExecutor extends Executor
/**
* Add configure args.
*/
public function addConfigureArgs(...$args): static
public function addConfigureArgs(string ...$args): static
{
$this->configure_args = [...$this->configure_args, ...$args];
return $this;
@@ -144,7 +145,7 @@ class UnixCMakeExecutor extends Executor
/**
* Remove some configure args, to bypass the configure option checking for some libs.
*/
public function removeConfigureArgs(...$args): static
public function removeConfigureArgs(string ...$args): static
{
$this->ignore_args = [...$this->ignore_args, ...$args];
return $this;

View File

@@ -9,6 +9,7 @@ use StaticPHP\Exception\SPCInternalException;
use StaticPHP\Package\LibraryPackage;
use StaticPHP\Package\PackageBuilder;
use StaticPHP\Package\PackageInstaller;
use StaticPHP\Package\ToolPackage;
use StaticPHP\Runtime\Shell\WindowsCmd;
use StaticPHP\Util\FileSystem;
use StaticPHP\Util\InteractiveTerm;
@@ -35,7 +36,7 @@ class WindowsCMakeExecutor extends Executor
protected PackageInstaller $installer;
public function __construct(protected LibraryPackage $package)
public function __construct(protected LibraryPackage|ToolPackage $package)
{
parent::__construct($this->package);
$this->builder = ApplicationContext::get(PackageBuilder::class);
@@ -99,7 +100,7 @@ class WindowsCMakeExecutor extends Executor
/**
* Add configure args.
*/
public function addConfigureArgs(...$args): static
public function addConfigureArgs(string ...$args): static
{
$this->configure_args = [...$this->configure_args, ...$args];
return $this;
@@ -108,7 +109,7 @@ class WindowsCMakeExecutor extends Executor
/**
* Remove some configure args, to bypass the configure option checking for some libs.
*/
public function removeConfigureArgs(...$args): static
public function removeConfigureArgs(string ...$args): static
{
$this->ignore_args = [...$this->ignore_args, ...$args];
return $this;

View File

@@ -185,12 +185,14 @@ class DefaultShell extends Shell
*/
public function execute7zExtract(string $archive_path, string $target_path): bool
{
$sdk_path = getenv('PHP_SDK_PATH');
if ($sdk_path === false) {
throw new SPCInternalException('PHP_SDK_PATH environment variable is not set');
// 7za.exe is installed by the 7za-win target package into PKG_ROOT_PATH\bin,
// which is added to PATH by MSVCToolchain::initEnv().
$_7z_path = FileSystem::convertPath(PKG_ROOT_PATH . '\bin\7za.exe');
if (!file_exists($_7z_path)) {
throw new SPCInternalException('7za.exe not found. Please install the 7za-win target package.');
}
$_7z = escapeshellarg(FileSystem::convertPath($sdk_path . '/bin/7za.exe'));
$_7z = escapeshellarg(FileSystem::convertPath($_7z_path));
$archive_arg = escapeshellarg(FileSystem::convertPath($archive_path));
$target_arg = escapeshellarg(FileSystem::convertPath($target_path));

View File

@@ -6,6 +6,7 @@ namespace StaticPHP\Runtime\Shell;
use StaticPHP\Exception\SPCInternalException;
use StaticPHP\Package\LibraryPackage;
use StaticPHP\Package\ToolPackage;
use StaticPHP\Runtime\SystemTarget;
use ZM\Logger\ConsoleColor;
@@ -40,9 +41,9 @@ class UnixShell extends Shell
/**
* Init the environment variable that common build will be used.
*
* @param LibraryPackage $library Library package
* @param LibraryPackage|ToolPackage $library Library or tool package
*/
public function initializeEnv(LibraryPackage $library): UnixShell
public function initializeEnv(LibraryPackage|ToolPackage $library): UnixShell
{
$this->setEnv([
'CFLAGS' => $library->getLibExtraCFlags(),

View File

@@ -14,10 +14,14 @@ class MSVCToolchain implements ToolchainInterface
public function initEnv(): void
{
GlobalEnvManager::addPathIfNotExists(PKG_ROOT_PATH . '\bin');
$sdk = getenv('PHP_SDK_PATH');
if ($sdk !== false) {
GlobalEnvManager::addPathIfNotExists($sdk . '\bin');
GlobalEnvManager::addPathIfNotExists($sdk . '\msys2\usr\bin');
// msys2-build-essentials: add MSYS2 usr\bin to PATH so that 7za.exe, make, autoconf, etc. are available.
// This must be done here because msys2-build-essentials is not a dependency of any library package,
// so its path@windows entries are not automatically applied by the package installer at runtime.
$msys2_path = getenv('SPC_MSYS2_PATH') ?: (PKG_ROOT_PATH . '\msys2-build-essentials\msys64');
if (is_dir($msys2_path)) {
GlobalEnvManager::putenv("SPC_MSYS2_PATH={$msys2_path}");
GlobalEnvManager::addPathIfNotExists($msys2_path . '\usr\bin');
GlobalEnvManager::addPathIfNotExists("{$msys2_path}\\usr\\lib\\p7zip");
}
// strawberry-perl
if (is_dir(PKG_ROOT_PATH . '\strawberry-perl')) {

View File

@@ -174,7 +174,7 @@ class FileSystem
public static function convertWinPathToMinGW(string $path): string
{
if (preg_match('/^[A-Za-z]:/', $path)) {
$path = '/' . strtolower($path[0]) . '/' . str_replace('\\', '/', substr($path, 2));
$path = '/' . strtolower($path[0]) . '/' . str_replace('\\', '/', ltrim(substr($path, 2), '\/'));
}
return $path;
}
@@ -411,7 +411,7 @@ class FileSystem
$replacement = [
'{build_root_path}' => BUILD_ROOT_PATH,
'{pkg_root_path}' => PKG_ROOT_PATH,
'{php_sdk_path}' => getenv('PHP_SDK_PATH') ? getenv('PHP_SDK_PATH') : WORKING_DIR . '/php-sdk-binary-tools',
'{spc_msys2_path}' => getenv('SPC_MSYS2_PATH') ?: (PKG_ROOT_PATH . DIRECTORY_SEPARATOR . 'msys2-build-essentials' . DIRECTORY_SEPARATOR . 'msys64'),
'{working_dir}' => WORKING_DIR,
'{download_path}' => DOWNLOAD_PATH,
'{source_path}' => SOURCE_PATH,

View File

@@ -4,7 +4,6 @@ declare(strict_types=1);
namespace StaticPHP\Util;
use StaticPHP\DI\ApplicationContext;
use Symfony\Component\Console\Helper\ProgressIndicator;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\ConsoleOutput;
@@ -15,10 +14,31 @@ class InteractiveTerm
{
private static ?ProgressIndicator $indicator = null;
private static ?OutputInterface $output = null;
private static ?bool $noAnsi = null;
/**
* Initialize with a real Symfony Console input/output (called from ConsoleApplication::doRun()).
* After this call, all output goes through the configured Console, and noAnsi reflects
* the user's --no-ansi flag.
*/
public static function init(InputInterface $input, OutputInterface $output): void
{
self::$output = $output;
try {
self::$noAnsi = (bool) $input->getOption('no-ansi');
} catch (\InvalidArgumentException) {
// Symfony hasn't bound the application-level input definition yet
// (e.g. ArgvInput passed directly to doRun() on some versions).
self::$noAnsi = false;
}
}
public static function notice(string $message, bool $indent = false): void
{
$no_ansi = ApplicationContext::get(InputInterface::class)?->getOption('no-ansi') ?? false;
$output = ApplicationContext::get(OutputInterface::class) ?? new ConsoleOutput();
$no_ansi = self::noAnsi();
$output = self::output();
if ($output->isVerbose()) {
logger()->notice(strip_ansi_colors($message));
} else {
@@ -29,8 +49,8 @@ class InteractiveTerm
public static function success(string $message, bool $indent = false): void
{
$no_ansi = ApplicationContext::get(InputInterface::class)?->getOption('no-ansi') ?? false;
$output = ApplicationContext::get(OutputInterface::class) ?? new ConsoleOutput();
$no_ansi = self::noAnsi();
$output = self::output();
if ($output->isVerbose()) {
logger()->info(strip_ansi_colors($message));
} else {
@@ -41,8 +61,8 @@ class InteractiveTerm
public static function plain(string $message, string $level = 'info'): void
{
$no_ansi = ApplicationContext::get(InputInterface::class)?->getOption('no-ansi') ?? false;
$output = ApplicationContext::get(OutputInterface::class) ?? new ConsoleOutput();
$no_ansi = self::noAnsi();
$output = self::output();
if ($output->isVerbose()) {
match ($level) {
'debug' => logger()->debug(strip_ansi_colors($message)),
@@ -59,8 +79,8 @@ class InteractiveTerm
public static function info(string $message): void
{
$no_ansi = ApplicationContext::get(InputInterface::class)?->getOption('no-ansi') ?? false;
$output = ApplicationContext::get(OutputInterface::class) ?? new ConsoleOutput();
$no_ansi = self::noAnsi();
$output = self::output();
if (!$output->isVerbose()) {
$output->writeln(($no_ansi ? 'strip_ansi_colors' : 'strval')(ConsoleColor::green('▶ ') . $message));
}
@@ -69,8 +89,8 @@ class InteractiveTerm
public static function error(string $message, bool $indent = true): void
{
$no_ansi = ApplicationContext::get(InputInterface::class)?->getOption('no-ansi') ?? false;
$output = ApplicationContext::get(OutputInterface::class) ?? new ConsoleOutput();
$no_ansi = self::noAnsi();
$output = self::output();
if ($output->isVerbose()) {
logger()->error(strip_ansi_colors($message));
} else {
@@ -86,16 +106,16 @@ class InteractiveTerm
public static function setMessage(string $message): void
{
$no_ansi = ApplicationContext::get(InputInterface::class)?->getOption('no-ansi') ?? false;
$no_ansi = self::noAnsi();
self::$indicator?->setMessage(($no_ansi ? 'strip_ansi_colors' : 'strval')($message));
logger()->debug(strip_ansi_colors($message));
}
public static function finish(string $message, bool $status = true): void
{
$no_ansi = ApplicationContext::get(InputInterface::class)?->getOption('no-ansi') ?? false;
$no_ansi = self::noAnsi();
$message = $no_ansi ? strip_ansi_colors($message) : $message;
$output = ApplicationContext::get(OutputInterface::class) ?? new ConsoleOutput();
$output = self::output();
if ($output->isVerbose()) {
if ($status) {
logger()->info($message);
@@ -116,8 +136,8 @@ class InteractiveTerm
public static function indicateProgress(string $message): void
{
$no_ansi = ApplicationContext::get(InputInterface::class)?->getOption('no-ansi') ?? false;
$output = ApplicationContext::get(OutputInterface::class) ?? new ConsoleOutput();
$no_ansi = self::noAnsi();
$output = self::output();
if ($output->isVerbose()) {
logger()->info(strip_ansi_colors($message));
return;
@@ -131,11 +151,41 @@ class InteractiveTerm
logger()->debug(strip_ansi_colors($message));
// if no ansi, use a dot instead of spinner
if ($no_ansi) {
self::$indicator = new ProgressIndicator(ApplicationContext::get(OutputInterface::class), 'verbose', 100, [' •', ' •']);
self::$indicator = new ProgressIndicator(self::output(), 'verbose', 100, [' •', ' •']);
self::$indicator->start(strip_ansi_colors($message));
return;
}
self::$indicator = new ProgressIndicator(ApplicationContext::get(OutputInterface::class), 'verbose', 100, [' ⠏', ' ⠛', ' ⠹', ' ⢸', ' ⣰', ' ⣤', ' ⣆', ' ⡇']);
self::$indicator = new ProgressIndicator(self::output(), 'verbose', 100, [' ⠏', ' ⠛', ' ⠹', ' ⢸', ' ⣰', ' ⣤', ' ⣆', ' ⡇']);
self::$indicator->start($message);
}
/**
* Lazy default initialization used when init() was never called (early-boot errors,
* tests, programmatic usage). Creates a plain STDERR output so error messages are
* visible without depending on the Symfony Console lifecycle.
*/
private static function initDefault(): void
{
if (self::$output !== null) {
return;
}
self::$output = new ConsoleOutput(ConsoleOutput::VERBOSITY_NORMAL, false);
self::$noAnsi = false;
}
private static function noAnsi(): bool
{
if (self::$output === null) {
self::initDefault();
}
return self::$noAnsi ?? false;
}
private static function output(): OutputInterface
{
if (self::$output === null) {
self::initDefault();
}
return self::$output;
}
}

View File

@@ -45,12 +45,16 @@ class SourcePatcher
file_put_contents(SOURCE_PATH . "/{$filename}", file_get_contents($patch_file));
$patch_str = FileSystem::convertPath(SOURCE_PATH . "/{$filename}");
}
$patch_cwd = FileSystem::convertPath($cwd);
$patch_arg = $patch_str;
// Detect if patch is already applied (reverse detection)
$detect_reverse = !$reverse;
$detect_cmd = 'cd ' . escapeshellarg($cwd) . ' && '
. (PHP_OS_FAMILY === 'Windows' ? 'type' : 'cat') . ' ' . escapeshellarg($patch_str)
. ' | patch --dry-run -p1 -s -f ' . ($detect_reverse ? '-R' : '')
$cd_cmd = (PHP_OS_FAMILY === 'Windows' ? 'cd /d ' : 'cd ') . escapeshellarg($patch_cwd);
$detect_cmd = $cd_cmd
. ' && patch --binary --dry-run -p1 -s -f'
. ($detect_reverse ? ' -R' : '')
. ' < ' . escapeshellarg($patch_arg)
. ' > ' . (PHP_OS_FAMILY === 'Windows' ? 'NUL' : '/dev/null') . ' 2>&1';
exec($detect_cmd, $output, $detect_status);
@@ -60,9 +64,10 @@ class SourcePatcher
}
// Apply patch
$apply_cmd = 'cd ' . escapeshellarg($cwd) . ' && '
. (PHP_OS_FAMILY === 'Windows' ? 'type' : 'cat') . ' ' . escapeshellarg($patch_str)
. ' | patch -p1 ' . ($reverse ? '-R' : '');
$apply_cmd = $cd_cmd
. ' && patch --binary -p1'
. ($reverse ? ' -R' : '')
. ' < ' . escapeshellarg($patch_arg);
exec($apply_cmd, $apply_output, $apply_status);
if ($apply_status !== 0) {