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); // Resolve the actual artifact name: // - string field → named reference (e.g. php → php-src) // - array field → inline artifact, key is package name // - null → no artifact, or may match by package name $artifactField = $pkgConfig['artifact'] ?? null; $artifactName = is_string($artifactField) ? $artifactField : $packageName; $artifactConfig = ArtifactConfig::get($artifactName); $pkgInfo = Registry::getPackageConfigInfo($packageName); $artifactInfo = Registry::getArtifactConfigInfo($artifactName); $annotationInfo = PackageLoader::getPackageAnnotationInfo($packageName); $cacheInfo = $this->resolveCacheInfo($artifactName, $artifactConfig); if ($this->getOption('json')) { return $this->outputJson($packageName, $artifactName, $pkgConfig, $artifactConfig, $pkgInfo, $artifactInfo, $annotationInfo, $cacheInfo); } return $this->outputTerminal($packageName, $pkgConfig, $artifactConfig, $pkgInfo, $artifactInfo, $annotationInfo, $cacheInfo); } private function outputJson(string $name, string $artifactName, array $pkgConfig, ?array $artifactConfig, ?array $pkgInfo, ?array $artifactInfo, ?array $annotationInfo, ?array $cacheInfo): int { $data = [ 'name' => $name, 'registry' => $pkgInfo['registry'] ?? null, 'package_config_file' => $pkgInfo ? $this->toRelativePath($pkgInfo['config']) : null, 'package' => $pkgConfig, ]; if ($artifactConfig !== null) { $data['artifact_name'] = $artifactName !== $name ? $artifactName : null; $data['artifact_config_file'] = $artifactInfo ? $this->toRelativePath($artifactInfo['config']) : null; $data['artifact'] = $this->splitArtifactConfig($artifactConfig); } if ($annotationInfo !== null) { $data['annotations'] = $annotationInfo; } if ($cacheInfo !== null) { $data['cache'] = $cacheInfo; } $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, ?array $annotationInfo, ?array $cacheInfo): 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'; $artifactField = $pkgConfig['artifact'] ?? null; if (is_string($artifactField)) { // Named reference: show the artifact name it points to $this->output->writeln("── Artifact Config ── artifact: {$artifactField} file: {$artifactFile}"); } elseif (is_array($artifactField)) { $this->output->writeln("── Artifact Config ── file: {$artifactFile}"); $this->output->writeln(' (inline in package config)'); } else { $this->output->writeln("── Artifact Config ── file: {$artifactFile}"); } $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(''); } // Annotation section $this->outputAnnotationSection($name, $annotationInfo); // Cache status section $this->outputCacheSection($cacheInfo); return static::SUCCESS; } private function outputAnnotationSection(string $packageName, ?array $annotationInfo): void { if ($annotationInfo === null) { $this->output->writeln('── Annotations ── (no annotation class registered)'); $this->output->writeln(''); return; } $shortClass = $this->classBaseName($annotationInfo['class']); $this->output->writeln("── Annotations ── class: {$shortClass}"); $this->output->writeln(" {$annotationInfo['class']}"); // Method-level hooks $methods = $annotationInfo['methods']; if (!empty($methods)) { $this->output->writeln(''); $this->output->writeln(' Method hooks:'); foreach ($methods as $methodName => $attrs) { $attrList = implode(' ', array_map(fn ($a) => $this->formatAttr($a), $attrs)); $this->output->writeln(" {$methodName}() {$attrList}"); } } // Before-stage hooks targeting this package (inbound) $beforeStages = $annotationInfo['before_stages']; if (!empty($beforeStages)) { $this->output->writeln(''); $this->output->writeln(' Before-stage hooks (inbound):'); foreach ($beforeStages as $stage => $hooks) { foreach ($hooks as $hook) { $source = $this->classBaseName($hook['class']) . '::' . $hook['method'] . '()'; $cond = $hook['only_when'] !== null ? " (only_when: {$hook['only_when']})" : ''; $this->output->writeln(" {$stage} ← {$source}{$cond}"); } } } // After-stage hooks targeting this package (inbound) $afterStages = $annotationInfo['after_stages']; if (!empty($afterStages)) { $this->output->writeln(''); $this->output->writeln(' After-stage hooks (inbound):'); foreach ($afterStages as $stage => $hooks) { foreach ($hooks as $hook) { $source = $this->classBaseName($hook['class']) . '::' . $hook['method'] . '()'; $cond = $hook['only_when'] !== null ? " (only_when: {$hook['only_when']})" : ''; $this->output->writeln(" {$stage} ← {$source}{$cond}"); } } } // Outbound hooks: stages this package's class registers on other packages (exclude self-hooks) $outboundBefore = $annotationInfo['outbound_before_stages'] ?? []; $outboundAfter = $annotationInfo['outbound_after_stages'] ?? []; // Filter out entries targeting the same package — those are already shown inbound $outboundBefore = array_filter($outboundBefore, fn ($pkg) => $pkg !== $packageName, ARRAY_FILTER_USE_KEY); $outboundAfter = array_filter($outboundAfter, fn ($pkg) => $pkg !== $packageName, ARRAY_FILTER_USE_KEY); if (!empty($outboundBefore) || !empty($outboundAfter)) { $this->output->writeln(''); $this->output->writeln(' Hooks on other packages (outbound):'); foreach ($outboundBefore as $targetPkg => $stages) { foreach ($stages as $stage => $hooks) { foreach ($hooks as $hook) { $cond = $hook['only_when'] !== null ? " (only_when: {$hook['only_when']})" : ''; $this->output->writeln(" #[BeforeStage] → {$targetPkg} {$stage} {$hook['method']}(){$cond}"); } } } foreach ($outboundAfter as $targetPkg => $stages) { foreach ($stages as $stage => $hooks) { foreach ($hooks as $hook) { $cond = $hook['only_when'] !== null ? " (only_when: {$hook['only_when']})" : ''; $this->output->writeln(" #[AfterStage] → {$targetPkg} {$stage} {$hook['method']}(){$cond}"); } } } } $this->output->writeln(''); } /** * Format a single attribute entry (from annotation_map) as a colored inline string. * * @param array{attr: string, args: array} $attr */ private function formatAttr(array $attr): string { $name = $attr['attr']; $args = $attr['args']; if (empty($args)) { return "#[{$name}]"; } $argStr = implode(', ', array_map( fn ($v) => is_string($v) ? "'{$v}'" : (string) $v, array_values($args) )); return "#[{$name}({$argStr})]"; } /** Return the trailing class name component without the namespace. */ private function classBaseName(string $fqcn): string { $parts = explode('\\', $fqcn); return end($parts); } /** * 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; } /** * Build cache status data for display/JSON. * Returns null when there is no artifact config for this package. */ private function resolveCacheInfo(string $name, ?array $artifactConfig): ?array { if ($artifactConfig === null) { return null; } $cache = ApplicationContext::get(ArtifactCache::class); $currentPlatform = SystemTarget::getCurrentPlatformString(); $hasSource = array_key_exists('source', $artifactConfig) || array_key_exists('source-mirror', $artifactConfig); $hasBinary = array_key_exists('binary', $artifactConfig) || array_key_exists('binary-mirror', $artifactConfig); return [ 'current_platform' => $currentPlatform, 'has_source' => $hasSource, 'has_binary' => $hasBinary, 'source' => $hasSource ? [ 'downloaded' => $cache->isSourceDownloaded($name), 'info' => $cache->getSourceInfo($name), ] : null, 'binary' => $hasBinary ? $cache->getAllBinaryInfo($name) : null, ]; } private function outputCacheSection(?array $cacheInfo): void { if ($cacheInfo === null) { $this->output->writeln('── Cache Status ── (no artifact config)'); $this->output->writeln(''); return; } $platform = $cacheInfo['current_platform']; $this->output->writeln("── Cache Status ── current platform: {$platform}"); // Source $this->output->writeln(''); $this->output->writeln(' source:'); if (!$cacheInfo['has_source']) { $this->output->writeln(' ─ not applicable'); } elseif ($cacheInfo['source']['downloaded'] && $cacheInfo['source']['info'] !== null) { $this->output->writeln(' ✓ downloaded ' . $this->formatCacheEntry($cacheInfo['source']['info'])); } else { $this->output->writeln(' ✗ not downloaded'); } // Binary $this->output->writeln(''); $this->output->writeln(' binary:'); if (!$cacheInfo['has_binary']) { $this->output->writeln(' ─ not applicable'); } elseif (empty($cacheInfo['binary'])) { $this->output->writeln(" ✗ {$platform} (current — not cached)"); } else { $allBinary = $cacheInfo['binary']; foreach ($allBinary as $binPlatform => $binInfo) { $isCurrent = $binPlatform === $platform; $tag = $isCurrent ? ' (current)' : ''; if ($binInfo !== null) { $this->output->writeln(" ✓ {$binPlatform}{$tag} " . $this->formatCacheEntry($binInfo)); } else { $this->output->writeln(" ✗ {$binPlatform}{$tag}"); } } // Show current platform if not already listed if (!array_key_exists($platform, $allBinary)) { $this->output->writeln(" ✗ {$platform} (current — not cached)"); } } $this->output->writeln(''); } private function formatCacheEntry(array $info): string { $type = $info['cache_type'] ?? '?'; $version = $info['version'] !== null ? " {$info['version']}" : ''; $time = isset($info['time']) ? ' ' . date('Y-m-d H:i', (int) $info['time']) : ''; $file = match ($type) { 'archive', 'file' => isset($info['filename']) ? " {$info['filename']}" : '', 'git', 'local' => isset($info['dirname']) ? " {$info['dirname']}" : '', default => '', }; return "[{$type}]{$version}{$time}{$file}"; } }