diff --git a/.gitignore b/.gitignore
index d33eae53..2a351fcd 100644
--- a/.gitignore
+++ b/.gitignore
@@ -61,3 +61,6 @@ log/
# spc.phar
spc.phar
spc.exe
+
+# dumped files from StaticPHP v3
+/dump-*.json
diff --git a/src/StaticPHP/Artifact/Artifact.php b/src/StaticPHP/Artifact/Artifact.php
index dc602538..6dc35ad5 100644
--- a/src/StaticPHP/Artifact/Artifact.php
+++ b/src/StaticPHP/Artifact/Artifact.php
@@ -237,6 +237,39 @@ class Artifact
return isset($this->config['binary'][$target]) || isset($this->custom_binary_callbacks[$target]);
}
+ /**
+ * Get all platform strings for which a binary is declared (config or custom callback).
+ *
+ * For platforms where the binary type is "custom", a registered custom_binary_callback
+ * is required to consider it truly installable.
+ *
+ * @return string[] e.g. ['linux-x86_64', 'linux-aarch64', 'macos-aarch64']
+ */
+ public function getBinaryPlatforms(): array
+ {
+ $platforms = [];
+ if (isset($this->config['binary']) && is_array($this->config['binary'])) {
+ foreach ($this->config['binary'] as $platform => $platformConfig) {
+ $type = is_array($platformConfig) ? ($platformConfig['type'] ?? '') : '';
+ if ($type === 'custom') {
+ // Only installable if a custom callback has been registered
+ if (isset($this->custom_binary_callbacks[$platform])) {
+ $platforms[] = $platform;
+ }
+ } else {
+ $platforms[] = $platform;
+ }
+ }
+ }
+ // Include custom callbacks for platforms not listed in config at all
+ foreach (array_keys($this->custom_binary_callbacks) as $platform) {
+ if (!in_array($platform, $platforms, true)) {
+ $platforms[] = $platform;
+ }
+ }
+ return $platforms;
+ }
+
public function getDownloadConfig(string $type): mixed
{
return $this->config[$type] ?? null;
diff --git a/src/StaticPHP/Command/Dev/PackageInfoCommand.php b/src/StaticPHP/Command/Dev/PackageInfoCommand.php
new file mode 100644
index 00000000..7c869199
--- /dev/null
+++ b/src/StaticPHP/Command/Dev/PackageInfoCommand.php
@@ -0,0 +1,193 @@
+addArgument('package', InputArgument::REQUIRED, 'Package name to inspect');
+ $this->addOption('json', null, InputOption::VALUE_NONE, 'Output as JSON instead of colored terminal display');
+ }
+
+ public function handle(): int
+ {
+ $packageName = $this->getArgument('package');
+
+ if (!PackageConfig::isPackageExists($packageName)) {
+ $this->output->writeln("Package '{$packageName}' not found.");
+ return static::USER_ERROR;
+ }
+
+ $pkgConfig = PackageConfig::get($packageName);
+ $artifactConfig = ArtifactConfig::get($packageName);
+ $pkgInfo = Registry::getPackageConfigInfo($packageName);
+ $artifactInfo = Registry::getArtifactConfigInfo($packageName);
+
+ if ($this->getOption('json')) {
+ return $this->outputJson($packageName, $pkgConfig, $artifactConfig, $pkgInfo, $artifactInfo);
+ }
+
+ return $this->outputTerminal($packageName, $pkgConfig, $artifactConfig, $pkgInfo, $artifactInfo);
+ }
+
+ private function outputJson(string $name, array $pkgConfig, ?array $artifactConfig, ?array $pkgInfo, ?array $artifactInfo): int
+ {
+ $data = [
+ 'name' => $name,
+ 'registry' => $pkgInfo['registry'] ?? null,
+ 'package_config_file' => $pkgInfo ? $this->toRelativePath($pkgInfo['config']) : null,
+ 'package' => $pkgConfig,
+ ];
+
+ if ($artifactConfig !== null) {
+ $data['artifact_config_file'] = $artifactInfo ? $this->toRelativePath($artifactInfo['config']) : null;
+ $data['artifact'] = $this->splitArtifactConfig($artifactConfig);
+ }
+
+ $this->output->writeln(json_encode($data, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE));
+ return static::SUCCESS;
+ }
+
+ private function outputTerminal(string $name, array $pkgConfig, ?array $artifactConfig, ?array $pkgInfo, ?array $artifactInfo): int
+ {
+ $type = $pkgConfig['type'] ?? 'unknown';
+ $registry = $pkgInfo['registry'] ?? 'unknown';
+ $pkgFile = $pkgInfo ? $this->toRelativePath($pkgInfo['config']) : 'unknown';
+
+ // Header
+ $this->output->writeln('');
+ $this->output->writeln("Package: {$name} Type: {$type} Registry: {$registry}");
+ $this->output->writeln("Config file: {$pkgFile}");
+ $this->output->writeln('');
+
+ // Package config fields (excluding type and artifact which are shown separately)
+ $pkgFields = array_diff_key($pkgConfig, array_flip(['type', 'artifact']));
+ if (!empty($pkgFields)) {
+ $this->output->writeln('── Package Config ──');
+ $this->printYamlBlock($pkgFields, 0);
+ $this->output->writeln('');
+ }
+
+ // Artifact config
+ if ($artifactConfig !== null) {
+ $artifactFile = $artifactInfo ? $this->toRelativePath($artifactInfo['config']) : 'unknown';
+ $this->output->writeln("── Artifact Config ── file: {$artifactFile}");
+
+ // Check if artifact config is inline (embedded in pkg config) or separate
+ $inlineArtifact = $pkgConfig['artifact'] ?? null;
+ if (is_array($inlineArtifact)) {
+ $this->output->writeln(' (inline in package config)');
+ }
+
+ $split = $this->splitArtifactConfig($artifactConfig);
+
+ foreach ($split as $section => $value) {
+ $this->output->writeln('');
+ $this->output->writeln(" [{$section}]");
+ $this->printYamlBlock($value, 4);
+ }
+ $this->output->writeln('');
+ } else {
+ $this->output->writeln('── Artifact Config ── (none)>');
+ $this->output->writeln('');
+ }
+
+ return static::SUCCESS;
+ }
+
+ /**
+ * Split artifact config into logical sections for cleaner display.
+ *
+ * @return array
+ */
+ private function splitArtifactConfig(array $config): array
+ {
+ $sections = [];
+ $sectionOrder = ['source', 'source-mirror', 'binary', 'binary-mirror', 'metadata'];
+ foreach ($sectionOrder as $key) {
+ if (array_key_exists($key, $config)) {
+ $sections[$key] = $config[$key];
+ }
+ }
+ // Any remaining unknown keys
+ foreach ($config as $k => $v) {
+ if (!array_key_exists($k, $sections)) {
+ $sections[$k] = $v;
+ }
+ }
+ return $sections;
+ }
+
+ /**
+ * Print a value as indented YAML-style output with Symfony Console color tags.
+ */
+ private function printYamlBlock(mixed $value, int $indent): void
+ {
+ $pad = str_repeat(' ', $indent);
+ if (!is_array($value)) {
+ $this->output->writeln($pad . $this->colorScalar($value));
+ return;
+ }
+ $isList = array_is_list($value);
+ foreach ($value as $k => $v) {
+ if ($isList) {
+ if (is_array($v)) {
+ $this->output->writeln($pad . '- ');
+ $this->printYamlBlock($v, $indent + 2);
+ } else {
+ $this->output->writeln($pad . '- ' . $this->colorScalar($v));
+ }
+ } else {
+ if (is_array($v)) {
+ $this->output->writeln($pad . "{$k}>:");
+ $this->printYamlBlock($v, $indent + 2);
+ } else {
+ $this->output->writeln($pad . "{$k}>: " . $this->colorScalar($v));
+ }
+ }
+ }
+ }
+
+ private function colorScalar(mixed $v): string
+ {
+ if (is_bool($v)) {
+ return '' . ($v ? 'true' : 'false') . '>';
+ }
+ if (is_int($v) || is_float($v)) {
+ return '' . $v . '>';
+ }
+ if ($v === null) {
+ return 'null>';
+ }
+ // Strings that look like URLs
+ if (is_string($v) && (str_starts_with($v, 'http://') || str_starts_with($v, 'https://'))) {
+ return '' . $v . '>';
+ }
+ return '' . $v . '>';
+ }
+
+ private function toRelativePath(string $absolutePath): string
+ {
+ $normalized = realpath($absolutePath) ?: $absolutePath;
+ $root = rtrim(ROOT_DIR, '/') . '/';
+ if (str_starts_with($normalized, $root)) {
+ return substr($normalized, strlen($root));
+ }
+ return $normalized;
+ }
+}
diff --git a/src/StaticPHP/Package/Package.php b/src/StaticPHP/Package/Package.php
index fd59e98b..1ec1a503 100644
--- a/src/StaticPHP/Package/Package.php
+++ b/src/StaticPHP/Package/Package.php
@@ -138,6 +138,16 @@ abstract class Package
return $this->stages;
}
+ /**
+ * Get the list of OS families that have a registered build function (via #[BuildFor]).
+ *
+ * @return string[] e.g. ['Linux', 'Darwin']
+ */
+ public function getBuildForOSList(): array
+ {
+ return array_keys($this->build_functions);
+ }
+
/**
* Check if the package has a specific stage defined.
*
diff --git a/src/StaticPHP/Package/PhpExtensionPackage.php b/src/StaticPHP/Package/PhpExtensionPackage.php
index 582216d7..07bc6abd 100644
--- a/src/StaticPHP/Package/PhpExtensionPackage.php
+++ b/src/StaticPHP/Package/PhpExtensionPackage.php
@@ -280,6 +280,27 @@ class PhpExtensionPackage extends Package
$builder->deployBinary($soFile, $soFile, false);
}
+ /**
+ * Get per-OS build support status for this php-extension.
+ *
+ * Rules (same as v2):
+ * - OS not listed in 'support' config => 'yes' (fully supported)
+ * - OS listed with 'wip' => 'wip'
+ * - OS listed with 'partial' => 'partial'
+ * - OS listed with 'no' => 'no'
+ *
+ * @return array e.g. ['Linux' => 'yes', 'Darwin' => 'partial', 'Windows' => 'no']
+ */
+ public function getBuildSupportStatus(): array
+ {
+ $exceptions = $this->extension_config['support'] ?? [];
+ $result = [];
+ foreach (['Linux', 'Darwin', 'Windows'] as $os) {
+ $result[$os] = $exceptions[$os] ?? 'yes';
+ }
+ return $result;
+ }
+
/**
* Register default stages if not already defined by attributes.
* This is called after all attributes have been loaded.