addArgument('craft', null, 'Path to craft.yml file', WORKING_DIR . '/craft.yml'); $this->addOption('libs-only', null, null, 'Build only the libraries needed by the configured extensions (skip PHP and SAPI build).'); PgoContext::registerOptions($this); } public function handle(): int { $craft_file = $this->getArgument('craft'); if (!file_exists($craft_file)) { $this->output->writeln('craft.yml not found, please create one!'); return static::USER_ERROR; } $craft = $this->validateAndParseCraftFile($craft_file); // set verbosity $this->output->setVerbosity($craft['verbosity']); // apply env array_walk($craft['extra-env'], fn ($v, $k) => f_putenv("{$k}={$v}")); // stash craft for doctor checks that depend on what's being built (e.g. frankenphp → go-xcaddy) ApplicationContext::set('craft', $craft); // run doctor if ($craft['craft-options']['doctor']) { $doctor = new Doctor($this->output, FIX_POLICY_AUTOFIX); if ($doctor->checkAll()) { Doctor::markPassed(); $this->output->writeln(''); } else { $this->output->writeln('Doctor check failed, please fix the issues and try again.'); return static::ENVIRONMENT_ERROR; } } // parse download-options to installer's dl options $build_options = $craft['build-options']; if (!$craft['craft-options']['download']) { $build_options['no-download'] = true; } foreach ($craft['download-options'] as $k => $v) { $build_options["dl-{$k}"] = $v; } // parse SAPI foreach ($craft['sapi'] as $name) { $build_options["build-{$name}"] = true; } // merge craft-level packages into with-packages option if ($craft['packages'] !== []) { $existing = parse_comma_list($build_options['with-packages'] ?? ''); $build_options['with-packages'] = implode(',', array_unique(array_merge($existing, $craft['packages']))); } // merge shared-extensions into build-shared option if ($craft['shared-extensions'] !== []) { $existing = parse_extension_list($build_options['build-shared'] ?? ''); $build_options['build-shared'] = implode(',', array_unique(array_merge($existing, $craft['shared-extensions']))); } // clean build if ($craft['clean-build']) { FileSystem::resetDir(BUILD_ROOT_PATH); FileSystem::resetDir(SOURCE_PATH); } $pgo = $this->getOption('libs-only') ? null : PgoContext::tryFromInput($this->input, $craft['sapi'], $build_options); $starttime = microtime(true); // run installer $installer = new PackageInstaller($build_options); ApplicationContext::get(PackageBuilder::class)->setArgument('extensions', implode(',', $craft['extensions'])); if ($this->getOption('libs-only')) { $with_suggests = (bool) ($craft['build-options']['with-suggests'] ?? false); $libs = $this->resolveLibsForExtensions($craft, $with_suggests); if ($libs === []) { $this->output->writeln('No libraries needed for the configured extensions; nothing to do.'); return static::SUCCESS; } foreach ($libs as $lib) { $installer->addBuildPackage($lib); } } else { $installer->addBuildPackage('php'); } $installer->run(true); $usedtime = round(microtime(true) - $starttime, 1); $tag = $pgo !== null ? " (PGO {$pgo->mode})" : ''; $this->output->writeln("\n━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"); $this->output->writeln("✔ BUILD SUCCESSFUL{$tag} ({$usedtime} s)"); $this->output->writeln("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n"); if ($pgo !== null && $pgo->isInstrument()) { $this->output->writeln("Next: exercise the instrumented binary, then re-run craft with --pgo to consume {$pgo->profileRoot}."); } $installer->printBuildPackageOutputs(); return static::SUCCESS; } /** @return list library package names transitively required by the configured extensions */ private function resolveLibsForExtensions(array $craft, bool $include_suggests): array { $exts = array_merge($craft['extensions'], $craft['shared-extensions'] ?? []); $ext_pkgs = array_map(fn ($x) => "ext-{$x}", $exts); $extra = $craft['packages'] ?? []; $resolved = DependencyResolver::resolve( array_merge($ext_pkgs, $extra), include_suggests: $include_suggests, ); $libs = []; foreach ($resolved as $pkg_name) { if (str_starts_with($pkg_name, 'ext-') || !PackageLoader::hasPackage($pkg_name)) { continue; } if (PackageLoader::getPackage($pkg_name)->getType() === 'library') { $libs[] = $pkg_name; } } return $libs; } /** * Validate and parse craft.yml file to array. * * @param string $craft_file craft.yml path * @return array{ * php-version: string, * extensions: array, * shared-extensions: array, * packages: array, * sapi: array, * verbosity: int, * debug: bool, * clean-build: bool, * build-options: array, * download-options: array, * extra-env: array, * craft-options: array{ * doctor: bool, * download: bool, * build: bool * } * } Parsed craft content */ private function validateAndParseCraftFile(string $craft_file): array { $build_options = $this->getApplication()->find('build:php')->getDefinition()->getOptions(); $download_options = $this->getApplication()->find('download')->getDefinition()->getOptions(); try { $craft = Yaml::parseFile($craft_file); } catch (ParseException $e) { throw new ValidationException("Craft file '{$craft_file}' is broken: {$e->getMessage()}"); } if (!is_assoc_array($craft)) { throw new ValidationException("Craft file '{$craft_file}' must be an associative array."); } // check php-version if (isset($craft['php-version']) && !preg_match('/^(\d+)(\.\d+)?(\.\d+)?$/', strval($craft['php-version']))) { throw new ValidationException("Craft file '{$craft_file}' has invalid 'php-version' field, it should be in format of '8.0.0'."); } // check php extensions field if (!isset($craft['extensions'])) { throw new ValidationException("Craft file '{$craft_file}' must have 'extensions' field."); } // parse extension if not list if (is_string($craft['extensions'])) { $craft['extensions'] = parse_extension_list($craft['extensions']); } // check shared-extensions field if (!isset($craft['shared-extensions'])) { $craft['shared-extensions'] = []; } elseif (is_string($craft['shared-extensions'])) { $craft['shared-extensions'] = parse_extension_list($craft['shared-extensions']); } // check libs and additional packages $v2_libs = parse_comma_list($craft['libs'] ?? []); $v3_packages = parse_comma_list($craft['packages'] ?? []); $craft['packages'] = array_merge($v2_libs, $v3_packages); // check PHP SAPI if (!isset($craft['sapi'])) { throw new ValidationException('Craft file "sapi" is required.'); } if (is_string($craft['sapi'])) { $craft['sapi'] = parse_comma_list($craft['sapi']); } // verbosity $verbosity_level = $craft['verbosity'] ?? OutputInterface::VERBOSITY_NORMAL; $debug = $craft['debug'] ?? false; if ($debug) { $verbosity_level = OutputInterface::VERBOSITY_DEBUG; } $craft['verbosity'] = $verbosity_level; // clean-build (if true, reset before all builds) $craft['clean-build'] ??= false; // build-options if (isset($craft['build-options'])) { if (!is_assoc_array($craft['build-options'])) { throw new ValidationException('Craft file "build" options must be an associative array.'); } foreach ($craft['build-options'] as $key => $value) { if (!isset($build_options[$key])) { throw new ValidationException('Craft file "build" option "' . $key . '" is invalid.'); } if ($build_options[$key]->isArray() && !is_array($value)) { throw new ValidationException('Craft file "build" option "' . $key . '" must be an array.'); } } } else { $craft['build-options'] = []; } // download-options if (isset($craft['download-options'])) { if (!is_assoc_array($craft['download-options'])) { throw new ValidationException('Craft file "download" options must be an associative array.'); } foreach ($craft['download-options'] as $key => $value) { if (!isset($download_options[$key])) { throw new ValidationException('Craft file "download" option "' . $key . '" is invalid.'); } if ($download_options[$key]->isArray() && !is_array($value)) { throw new ValidationException('Craft file "download" option "' . $key . '" must be an array.'); } } } else { $craft['download-options'] = []; } // post-parse: parse php-version field to download options if (isset($craft['php-version'])) { $craft['download-options']['with-php'] = strval($craft['php-version']); $craft['download-options']['ignore-cache'] = (($craft['download-options']['ignore-cache'] ?? false) === true ? true : 'php-src'); } // extra-env if (isset($craft['extra-env'])) { if (!is_assoc_array($craft['extra-env'])) { throw new ValidationException('Craft file "extra-env" must be an associative array.'); } } else { $craft['extra-env'] = []; } // craft-options $craft['craft-options']['doctor'] ??= true; $craft['craft-options']['download'] ??= true; return $craft; } }