Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions src/StaticPHP/Attribute/Package/Tool.php
Original file line number Diff line number Diff line change
@@ -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) {}
}
1 change: 1 addition & 0 deletions src/StaticPHP/Config/ConfigType.php
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ enum ConfigType
'php-extension',
'target',
'virtual-target',
'tool',
];

public static function validateLicenseField(mixed $value): bool
Expand Down
50 changes: 48 additions & 2 deletions src/StaticPHP/Config/ConfigValidator.php
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,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 = [
Expand All @@ -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 = [
Expand All @@ -78,6 +88,7 @@ class ConfigValidator
'path',
'env',
'append-env',
'tools',
];

public const array PHP_EXTENSION_FIELDS = [
Expand All @@ -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']],
Expand Down Expand Up @@ -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");
}

Expand All @@ -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));
}
Expand Down Expand Up @@ -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) {
Expand Down
8 changes: 6 additions & 2 deletions src/StaticPHP/Config/PackageConfig.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
{
Expand All @@ -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;
}
Expand All @@ -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}");
}
Expand Down
14 changes: 14 additions & 0 deletions src/StaticPHP/Package/Package.php
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*/
Expand Down
85 changes: 85 additions & 0 deletions src/StaticPHP/Package/PackageInstaller.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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');
Expand Down Expand Up @@ -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<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
*/
Expand Down Expand Up @@ -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();
Expand Down
Loading
Loading