From a81dd6d5c970a4b71eb5f5dc859bc44de44ae40c Mon Sep 17 00:00:00 2001 From: crazywhalecc Date: Sat, 27 Jun 2026 15:39:30 +0800 Subject: [PATCH 1/2] feat(tool): implement tool package support with validation and configuration --- spc.registry.yml | 1 + src/StaticPHP/Attribute/Package/Tool.php | 14 ++ src/StaticPHP/Config/ConfigType.php | 1 + src/StaticPHP/Config/ConfigValidator.php | 50 ++++++- src/StaticPHP/Config/PackageConfig.php | 8 +- src/StaticPHP/Package/Package.php | 14 ++ src/StaticPHP/Package/PackageInstaller.php | 85 ++++++++++++ src/StaticPHP/Package/ToolPackage.php | 151 +++++++++++++++++++++ src/StaticPHP/Registry/PackageLoader.php | 7 +- tests/StaticPHP/Config/ConfigTypeTest.php | 1 + 10 files changed, 327 insertions(+), 5 deletions(-) create mode 100644 src/StaticPHP/Attribute/Package/Tool.php create mode 100644 src/StaticPHP/Package/ToolPackage.php diff --git a/spc.registry.yml b/spc.registry.yml index 98e4bb42c..dbaa6ee06 100644 --- a/spc.registry.yml +++ b/spc.registry.yml @@ -10,6 +10,7 @@ package: - config/pkg/lib/ - config/pkg/target/ - config/pkg/ext/ + - config/pkg/tool/ artifact: config: - config/artifact/ diff --git a/src/StaticPHP/Attribute/Package/Tool.php b/src/StaticPHP/Attribute/Package/Tool.php new file mode 100644 index 000000000..35442d45f --- /dev/null +++ b/src/StaticPHP/Attribute/Package/Tool.php @@ -0,0 +1,14 @@ + 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 = [ @@ -67,6 +74,9 @@ class ConfigValidator 'path' => false, // @ 'env' => false, // @ 'append-env' => false, // @ + + // tool fields (nested object) + 'tool' => false, ]; public const array SUFFIX_ALLOWED_FIELDS = [ @@ -78,6 +88,7 @@ class ConfigValidator 'path', 'env', 'append-env', + 'tools', ]; public const array PHP_EXTENSION_FIELDS = [ @@ -92,6 +103,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 +238,8 @@ public static function validateAndLintPackages(string $config_file_name, mixed & $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 +253,11 @@ public static function validateAndLintPackages(string $config_file_name, mixed & 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 +420,29 @@ private static function validatePhpExtensionFields(int|string $name, mixed $pkg) 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) { diff --git a/src/StaticPHP/Config/PackageConfig.php b/src/StaticPHP/Config/PackageConfig.php index 25b5b4ab5..ba1c8873e 100644 --- a/src/StaticPHP/Config/PackageConfig.php +++ b/src/StaticPHP/Config/PackageConfig.php @@ -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 @@ public static function loadFromDir(string $dir, string $registry_name): array $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; } @@ -46,7 +50,7 @@ public static function loadFromDir(string $dir, string $registry_name): array */ 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 package config file: {$file}"); } diff --git a/src/StaticPHP/Package/Package.php b/src/StaticPHP/Package/Package.php index 1ec1a503e..b3f8ccb6d 100644 --- a/src/StaticPHP/Package/Package.php +++ b/src/StaticPHP/Package/Package.php @@ -120,6 +120,20 @@ public function isInstalled(): bool 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. */ diff --git a/src/StaticPHP/Package/PackageInstaller.php b/src/StaticPHP/Package/PackageInstaller.php index 5652507f1..a54815830 100644 --- a/src/StaticPHP/Package/PackageInstaller.php +++ b/src/StaticPHP/Package/PackageInstaller.php @@ -11,6 +11,7 @@ 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; @@ -167,6 +168,9 @@ public function run(bool $disable_delay_msg = false): void // Early validation: check if packages can be built or installed before downloading $this->validatePackagesBeforeBuild(); + // Check that all required tools are installed before proceeding + $this->ensureRequiredTools(); + // check download if ($this->download) { $downloaderOptions = DownloaderOptions::extractFromConsoleOptions($this->options, 'dl'); @@ -574,6 +578,66 @@ public function getPhpExtensionPackage(string $package_or_ext_name): ?PhpExtensi 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, installed: array} + */ + 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 */ @@ -636,6 +700,27 @@ private function resolvePackages(): void } } + /** + * Ensure all required tools are installed, throwing if any are missing. + * + * Called early in the build pipeline (before download/extract). + * When tools are missing, lists them with install hints. + */ + 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(); diff --git a/src/StaticPHP/Package/ToolPackage.php b/src/StaticPHP/Package/ToolPackage.php new file mode 100644 index 000000000..b78afa911 --- /dev/null +++ b/src/StaticPHP/Package/ToolPackage.php @@ -0,0 +1,151 @@ +.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) + * artifact: + * binary: + * windows-x86_64: + * type: url + * url: 'https://...' + * extract: + * nasm.exe: '{php_sdk_path}/bin/nasm.exe' + */ +class ToolPackage extends Package +{ + use GlobalPathTrait; + + /** + * 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->getToolConfig()['install-root'] ?? null; + 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->getToolConfig()['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. 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->getToolConfig()['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 minimum required version for this tool, if specified. + * + * Returns null if no version constraint is configured. + */ + public function getMinVersion(): ?string + { + $version = $this->getToolConfig()['min-version'] ?? null; + 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 + */ + private function getToolConfig(): array + { + $config = PackageConfig::get($this->name); + if (!is_array($config) || !isset($config['tool']) || !is_array($config['tool'])) { + return []; + } + return $config['tool']; + } +} diff --git a/src/StaticPHP/Registry/PackageLoader.php b/src/StaticPHP/Registry/PackageLoader.php index a61ff0566..acb1ab285 100644 --- a/src/StaticPHP/Registry/PackageLoader.php +++ b/src/StaticPHP/Registry/PackageLoader.php @@ -17,6 +17,7 @@ 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\PackageInstaller; use StaticPHP\Package\PhpExtensionPackage; use StaticPHP\Package\TargetPackage; +use StaticPHP\Package\ToolPackage; use StaticPHP\Util\FileSystem; class PackageLoader @@ -88,6 +90,7 @@ public static function initPackageInstances(): void '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 @@ public static function loadFromClass(mixed $class): void $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 @@ public static function loadFromClass(mixed $class): void 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)) { diff --git a/tests/StaticPHP/Config/ConfigTypeTest.php b/tests/StaticPHP/Config/ConfigTypeTest.php index 0990931ef..bf3d1bc8f 100644 --- a/tests/StaticPHP/Config/ConfigTypeTest.php +++ b/tests/StaticPHP/Config/ConfigTypeTest.php @@ -27,6 +27,7 @@ public function testPackageTypesConstant(): void 'php-extension', 'target', 'virtual-target', + 'tool', ]; $this->assertEquals($expectedTypes, ConfigType::PACKAGE_TYPES); From dd69155539855c38a482e91d7d6121f1efc58a75 Mon Sep 17 00:00:00 2001 From: crazywhalecc Date: Sat, 27 Jun 2026 15:47:46 +0800 Subject: [PATCH 2/2] feat(tool): remove deprecated tool package directory from registry configuration --- spc.registry.yml | 1 - 1 file changed, 1 deletion(-) diff --git a/spc.registry.yml b/spc.registry.yml index dbaa6ee06..98e4bb42c 100644 --- a/spc.registry.yml +++ b/spc.registry.yml @@ -10,7 +10,6 @@ package: - config/pkg/lib/ - config/pkg/target/ - config/pkg/ext/ - - config/pkg/tool/ artifact: config: - config/artifact/