diff --git a/src/StaticPHP/Command/DumpLicenseCommand.php b/src/StaticPHP/Command/DumpLicenseCommand.php new file mode 100644 index 00000000..d90ecbf9 --- /dev/null +++ b/src/StaticPHP/Command/DumpLicenseCommand.php @@ -0,0 +1,147 @@ +addArgument('artifacts', InputArgument::OPTIONAL, 'Specific artifacts to dump licenses, comma separated, e.g "php-src,openssl,curl"'); + + // v2 compatible options + $this->addOption('for-extensions', 'e', InputOption::VALUE_REQUIRED, 'Dump by extensions (automatically includes php-src), e.g "openssl,mbstring"'); + $this->addOption('for-libs', 'l', InputOption::VALUE_REQUIRED, 'Dump by libraries, e.g "openssl,zlib,curl"'); + + // v3 options + $this->addOption('for-packages', 'p', InputOption::VALUE_REQUIRED, 'Dump by packages, e.g "php,libssl,libcurl"'); + $this->addOption('dump-dir', 'd', InputOption::VALUE_REQUIRED, 'Target directory for dumped licenses', BUILD_ROOT_PATH . '/license'); + $this->addOption('without-suggests', null, null, 'Do not include suggested packages when using --for-extensions or --for-packages'); + } + + public function handle(): int + { + $dumper = new LicenseDumper(); + $dump_dir = $this->getOption('dump-dir'); + $artifacts_to_dump = []; + + // Handle direct artifact argument + if ($artifacts = $this->getArgument('artifacts')) { + $artifacts_to_dump = array_merge($artifacts_to_dump, parse_comma_list($artifacts)); + } + + // Handle --for-extensions option + if ($exts = $this->getOption('for-extensions')) { + $artifacts_to_dump = array_merge( + $artifacts_to_dump, + $this->resolveFromExtensions(parse_extension_list($exts)) + ); + } + + // Handle --for-libs option (v2 compat) + if ($libs = $this->getOption('for-libs')) { + $artifacts_to_dump = array_merge( + $artifacts_to_dump, + $this->resolveFromPackages(parse_comma_list($libs)) + ); + } + + // Handle --for-packages option + if ($packages = $this->getOption('for-packages')) { + $artifacts_to_dump = array_merge( + $artifacts_to_dump, + $this->resolveFromPackages(parse_comma_list($packages)) + ); + } + + // Check if any artifacts to dump + if (empty($artifacts_to_dump)) { + $this->output->writeln('No artifacts specified. Use one of:'); + $this->output->writeln(' - Direct argument: dump-license php-src,openssl,curl'); + $this->output->writeln(' - --for-extensions: dump-license --for-extensions=openssl,mbstring'); + $this->output->writeln(' - --for-libs: dump-license --for-libs=openssl,zlib'); + $this->output->writeln(' - --for-packages: dump-license --for-packages=php,libssl'); + return self::FAILURE; + } + + // Deduplicate artifacts + $artifacts_to_dump = array_values(array_unique($artifacts_to_dump)); + + logger()->info('Dumping licenses for ' . count($artifacts_to_dump) . ' artifact(s)'); + logger()->debug('Artifacts: ' . implode(', ', $artifacts_to_dump)); + + // Add artifacts to dumper + $dumper->addArtifacts($artifacts_to_dump); + + // Dump + $success = $dumper->dump($dump_dir); + + if ($success) { + InteractiveTerm::success('Licenses dumped successfully: ' . $dump_dir); + // $this->output->writeln("✓ Successfully dumped licenses to: {$dump_dir}"); + // $this->output->writeln(" Total artifacts: " . count($artifacts_to_dump) . ''); + return self::SUCCESS; + } + + $this->output->writeln('Failed to dump licenses'); + return self::FAILURE; + } + + /** + * Resolve artifacts from extension names. + * + * @param array $extensions Extension names + * @return array Artifact names + */ + private function resolveFromExtensions(array $extensions): array + { + // Convert extension names to package names + $packages = array_map(fn ($ext) => "ext-{$ext}", $extensions); + + // Automatically include php-related artifacts + array_unshift($packages, 'php'); + array_unshift($packages, 'php-micro'); + array_unshift($packages, 'php-embed'); + array_unshift($packages, 'php-fpm'); + + return $this->resolveFromPackages($packages); + } + + /** + * Resolve artifacts from package names. + * + * @param array $packages Package names + * @return array Artifact names + */ + private function resolveFromPackages(array $packages): array + { + $artifacts = []; + $include_suggests = !$this->getOption('without-suggests'); + + // Resolve package dependencies + $resolved_packages = DependencyResolver::resolve($packages, [], $include_suggests); + + foreach ($resolved_packages as $pkg_name) { + try { + $pkg = PackageLoader::getPackage($pkg_name); + if ($artifact = $pkg->getArtifact()) { + $artifacts[] = $artifact->getName(); + } + } catch (\Throwable $e) { + logger()->debug("Package {$pkg_name} has no artifact or failed to load: {$e->getMessage()}"); + } + } + + return array_unique($artifacts); + } +} diff --git a/src/StaticPHP/ConsoleApplication.php b/src/StaticPHP/ConsoleApplication.php index 9e37698c..bd2e4eb1 100644 --- a/src/StaticPHP/ConsoleApplication.php +++ b/src/StaticPHP/ConsoleApplication.php @@ -12,6 +12,7 @@ use StaticPHP\Command\Dev\ShellCommand; use StaticPHP\Command\Dev\SortConfigCommand; use StaticPHP\Command\DoctorCommand; use StaticPHP\Command\DownloadCommand; +use StaticPHP\Command\DumpLicenseCommand; use StaticPHP\Command\ExtractCommand; use StaticPHP\Command\InstallPackageCommand; use StaticPHP\Command\SPCConfigCommand; @@ -55,6 +56,7 @@ class ConsoleApplication extends Application new BuildLibsCommand(), new ExtractCommand(), new SPCConfigCommand(), + new DumpLicenseCommand(), // dev commands new ShellCommand(), diff --git a/src/StaticPHP/Util/LicenseDumper.php b/src/StaticPHP/Util/LicenseDumper.php new file mode 100644 index 00000000..57fc3aec --- /dev/null +++ b/src/StaticPHP/Util/LicenseDumper.php @@ -0,0 +1,262 @@ + Artifact names to dump */ + private array $artifacts = []; + + /** + * Add artifacts by name. + * + * @param array $artifacts Artifact names + */ + public function addArtifacts(array $artifacts): self + { + $this->artifacts = array_unique(array_merge($this->artifacts, $artifacts)); + return $this; + } + + /** + * Dump all collected artifact licenses to target directory. + * + * @param string $target_dir Target directory path + * @return bool True on success + */ + public function dump(string $target_dir): bool + { + // Create target directory if not exists (don't clean existing files) + if (!is_dir($target_dir)) { + FileSystem::createDir($target_dir); + } else { + logger()->debug("Target directory exists, will append/update licenses: {$target_dir}"); + } + + $license_summary = []; + $dumped_count = 0; + + foreach ($this->artifacts as $artifact_name) { + $artifact = ArtifactLoader::getArtifactInstance($artifact_name); + if ($artifact === null) { + logger()->warning("Artifact not found, skipping: {$artifact_name}"); + continue; + } + + try { + $result = $this->dumpArtifactLicense($artifact, $target_dir, $license_summary); + if ($result) { + ++$dumped_count; + } + } catch (\Throwable $e) { + logger()->warning("Failed to dump license for {$artifact_name}: {$e->getMessage()}"); + } + } + + // Generate LICENSE-SUMMARY.json (read-modify-write) + $this->generateSummary($target_dir, $license_summary); + + logger()->info("Successfully dumped {$dumped_count} license(s) to: {$target_dir}"); + return true; + } + + /** + * Dump license for a single artifact. + * + * @param Artifact $artifact Artifact instance + * @param string $target_dir Target directory + * @param array &$license_summary Summary data to populate + * @return bool True if dumped + * @throws SPCInternalException + */ + private function dumpArtifactLicense(Artifact $artifact, string $target_dir, array &$license_summary): bool + { + $artifact_name = $artifact->getName(); + + // Get metadata from ArtifactConfig + $artifact_config = ArtifactConfig::get($artifact_name); + $config = $artifact_config['metadata'] ?? null; + + if ($config === null) { + logger()->debug("No metadata for artifact: {$artifact_name}"); + return false; + } + + $license_type = $config['license'] ?? null; + $license_files = $config['license-files'] ?? []; + + // Ensure license_files is array + if (is_string($license_files)) { + $license_files = [$license_files]; + } + + if (empty($license_files)) { + logger()->debug("No license files specified for: {$artifact_name}"); + return false; + } + + // Record in summary + $summary_license = $license_type ?? 'Custom'; + if (!isset($license_summary[$summary_license])) { + $license_summary[$summary_license] = []; + } + $license_summary[$summary_license][] = $artifact_name; + + // Dump each license file + $file_count = count($license_files); + $dumped_any = false; + + foreach ($license_files as $index => $license_file_path) { + // Construct output filename + if ($file_count === 1) { + $output_filename = "{$artifact_name}_LICENSE.txt"; + } else { + $output_filename = "{$artifact_name}_LICENSE_{$index}.txt"; + } + + $output_path = "{$target_dir}/{$output_filename}"; + + // Skip if file already exists (avoid duplicate writes) + if (file_exists($output_path)) { + logger()->debug("License file already exists, skipping: {$output_filename}"); + $dumped_any = true; // Still count as dumped + continue; + } + + // Try to read license file from source directory + $license_content = $this->readLicenseFile($artifact, $license_file_path); + if ($license_content === null) { + logger()->warning("License file not found for {$artifact_name}: {$license_file_path}"); + continue; + } + + // Write to target + if (file_put_contents($output_path, $license_content) === false) { + throw new SPCInternalException("Failed to write license file: {$output_path}"); + } + + logger()->info("Dumped license: {$output_filename}"); + $dumped_any = true; + } + + return $dumped_any; + } + + /** + * Read license file content from artifact's source directory. + * + * @param Artifact $artifact Artifact instance + * @param string $license_file_path Relative path to license file + * @return null|string License content, or null if not found + */ + private function readLicenseFile(Artifact $artifact, string $license_file_path): ?string + { + $artifact_name = $artifact->getName(); + + // Try source directory first (if extracted) + if ($artifact->isSourceExtracted()) { + $source_dir = $artifact->getSourceDir(); + $full_path = "{$source_dir}/{$license_file_path}"; + + logger()->debug("Checking license file: {$full_path}"); + if (file_exists($full_path)) { + logger()->info("Reading license from source: {$full_path}"); + return file_get_contents($full_path); + } + } else { + logger()->debug("Artifact source not extracted: {$artifact_name}"); + } + + // Fallback: try SOURCE_PATH directly + $fallback_path = SOURCE_PATH . "/{$artifact_name}/{$license_file_path}"; + logger()->debug("Checking fallback path: {$fallback_path}"); + if (file_exists($fallback_path)) { + logger()->info("Reading license from fallback path: {$fallback_path}"); + return file_get_contents($fallback_path); + } + + logger()->debug("License file not found in any location for {$artifact_name}"); + return null; + } + + /** + * Generate LICENSE-SUMMARY.json file with read-modify-write support. + * + * @param string $target_dir Target directory + * @param array $license_summary License summary data (license_type => [artifacts]) + */ + private function generateSummary(string $target_dir, array $license_summary): void + { + if (empty($license_summary)) { + logger()->debug('No licenses to summarize'); + return; + } + + $summary_file = "{$target_dir}/LICENSE-SUMMARY.json"; + + // Read existing summary if exists + $existing_data = []; + if (file_exists($summary_file)) { + $content = file_get_contents($summary_file); + $existing_data = json_decode($content, true) ?? []; + logger()->debug('Loaded existing LICENSE-SUMMARY.json'); + } + + // Initialize structure + if (!isset($existing_data['artifacts'])) { + $existing_data['artifacts'] = []; + } + if (!isset($existing_data['summary'])) { + $existing_data['summary'] = ['license_types' => []]; + } + + // Merge new license information + foreach ($license_summary as $license_type => $artifacts) { + foreach ($artifacts as $artifact_name) { + // Add/update artifact info + $existing_data['artifacts'][$artifact_name] = [ + 'license' => $license_type, + 'dumped_at' => date('Y-m-d H:i:s'), + ]; + + // Update license_types summary + if (!isset($existing_data['summary']['license_types'][$license_type])) { + $existing_data['summary']['license_types'][$license_type] = []; + } + if (!in_array($artifact_name, $existing_data['summary']['license_types'][$license_type])) { + $existing_data['summary']['license_types'][$license_type][] = $artifact_name; + } + } + } + + // Sort license types and artifacts + ksort($existing_data['summary']['license_types']); + foreach ($existing_data['summary']['license_types'] as &$artifacts) { + sort($artifacts); + } + ksort($existing_data['artifacts']); + + // Update totals + $existing_data['summary']['total_artifacts'] = count($existing_data['artifacts']); + $existing_data['summary']['total_license_types'] = count($existing_data['summary']['license_types']); + $existing_data['summary']['last_updated'] = date('Y-m-d H:i:s'); + + // Write JSON file + file_put_contents( + $summary_file, + json_encode($existing_data, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE) + ); + + logger()->info('Generated LICENSE-SUMMARY.json with ' . $existing_data['summary']['total_artifacts'] . ' artifact(s)'); + } +}