Files
hdk-skeleton/installer/OptionalPackages.php
李东云 0740b8bca7 fix(installer): 不建议使用中文描述
Closes #11

Signed-off-by: 李东云 <dongyu.li@luxcreo.ai>
2023-10-13 16:11:40 +08:00

631 lines
22 KiB
PHP
Executable File
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
declare(strict_types=1);
/**
* This file is part of Hyperf.
*
* @link https://www.hyperf.io
* @document https://hyperf.wiki
* @contact group@hyperf.io
* @license https://github.com/hyperf/hyperf/blob/master/LICENSE
*/
namespace Installer;
use Composer\Composer;
use Composer\Factory;
use Composer\IO\IOInterface;
use Composer\Json\JsonFile;
use Composer\Package\BasePackage;
use Composer\Package\Link;
use Composer\Package\RootPackageInterface;
use Composer\Package\Version\VersionParser;
use FilesystemIterator;
use RecursiveDirectoryIterator;
use RecursiveIteratorIterator;
class OptionalPackages
{
/**
* @const string Regular expression for matching package name and version
*/
public const PACKAGE_REGEX = '/^(?P<name>[^:]+\/[^:]+)([:]*)(?P<version>.*)$/';
public string $projectName;
/**
* @var IOInterface
*/
public IOInterface $io;
/**
* Assets to remove during cleanup.
*
* @var string[]
*/
private array $assetsToRemove = [
'.travis.yml',
];
/**
* @var array
*/
private array $config;
/**
* @var Composer
*/
private Composer $composer;
/**
* @var array
*/
private array $composerDefinition;
/**
* @var JsonFile
*/
private JsonFile $composerJson;
/**
* @var Link[]
*/
private array $composerRequires;
/**
* @var Link[]
*/
private array $composerDevRequires;
/**
* @var string[] Dev dependencies to remove after install is complete
*/
private array $devDependencies = [
'composer/composer',
];
/**
* @var string path to this file
*/
private string $installerSource;
/**
* @var string
*/
private string $projectRoot;
/**
* @var RootPackageInterface
*/
private RootPackageInterface $rootPackage;
/**
* @var int[]
*/
private array $stabilityFlags;
/** @var int */
private int $port;
private string $description = '清锋的又一个软件项目';
public function __construct(IOInterface $io, Composer $composer, string $projectRoot = null)
{
$this->io = $io;
$this->composer = $composer;
// Get composer.json location
$composerFile = Factory::getComposerFile();
// Calculate project root from composer.json, if necessary
$this->projectRoot = $projectRoot ?: realpath(dirname($composerFile));
$this->projectRoot = rtrim($this->projectRoot, '/\\') . '/';
// Parse the composer.json
$this->parseComposerDefinition($composer, $composerFile);
// Get optional packages configuration
$this->config = require __DIR__ . '/config.php';
// Source path for this file
$this->installerSource = realpath(__DIR__) . '/';
}
/**
* Parses the composer file and populates internal data.
*/
private function parseComposerDefinition(Composer $composer, string $composerFile): void
{
$this->composerJson = new JsonFile($composerFile);
$this->composerDefinition = $this->composerJson->read();
// Get root package or root alias package
$this->rootPackage = $composer->getPackage();
// Get required packages
$this->composerRequires = $this->rootPackage->getRequires();
$this->composerDevRequires = $this->rootPackage->getDevRequires();
// Get stability flags
$this->stabilityFlags = $this->rootPackage->getStabilityFlags();
}
public function installHyperfScript(): void
{
$default_timezone = ini_get('TIME_ZONE');
$default_timezone = empty($default_timezone) ? 'Asia/Shanghai' : $default_timezone;
$ask[] = "\n <question>What time zone do you want to set up ?</question>\n";
$ask[] = " [<comment>n</comment>] Default time zone for php.ini\n";
$ask[] = " Make your selection or type a time zone name, like Asia/Shanghai ($default_timezone):\n ";
$answer = $this->io->askAndValidate(
implode('', $ask),
function ($value) {
if ($value === 'y' || $value === 'yes') {
throw new \InvalidArgumentException(
'You should type a time zone name, like Asia/Shanghai. Or type n to skip.'
);
}
return trim($value);
},
null,
$default_timezone
);
if ($answer != 'n') {
$content = file_get_contents($this->installerSource . '/resources/bin/hyperf.stub');
$content = str_replace('%TIME_ZONE%', $answer, $content);
$target_path = $this->projectRoot . 'bin/hyperf.php';
if (!is_dir(dirname($target_path))) {
mkdir(dirname($target_path));
}
file_put_contents($this->projectRoot . 'bin/hyperf.php', $content);
}
}
public function setupProject(): void
{
$this->projectName = $this->io->askAndValidate(
"\n <question>这个项目的项目名是什么?</question> (必填项,格式类似 lux-studio)\n ",
function ($value) {
$pattern = '/^\s*lux(-([a-z0-9])+)+\s*$/';
preg_match($pattern, $value, $matches);
if (count($matches) <= 0) {
throw new \InvalidArgumentException('项目名非法!合法格式为:/^lux(-([a-z0-9])+)+$/');
}
return trim($matches[0]);
},
null,
''
);
$this->description = $this->io->askAndValidate(
"\n <question>一句话描述下这个项目吧,中文可能导致报错</question>(默认:{$this->description}\n ",
function ($value) {
return trim($value);
},
null,
$this->description
);
$ports_map = require __DIR__ . '/portMap.php';
$available_ports = array_keys($ports_map);
$max_port = max(...$available_ports);
$default_port = intval($max_port / 10) + 1 . '1';
$this->port = $this->io->askAndValidate(
"\n <question>你想使用哪个端口监听这个项目?</question> (默认:" . $default_port . ") \n ",
function ($input) use ($available_ports, $ports_map) {
if (in_array($input, $available_ports)) {
$error = <<<'ERROR'
端口已使用!
已用端口:
ERROR;
foreach ($ports_map as $port => $info) {
['project' => $project_name, 'type' => $type] = $info;
$error .= <<<ERROR
[:$port] $project_name 的 $type
ERROR;
}
throw new \InvalidArgumentException($error);
}
return (int)$input;
},
null,
$default_port
);
$composer_project_name = sprintf('web-service/%s', $this->projectName);
$this->composerDefinition['name'] = $composer_project_name;
$this->composerDefinition['description'] = $this->description;
}
public function updateDockerEnv(): void
{
$content = file_get_contents($this->projectRoot . 'installer/resources/scripts/docker-env.sh');
$lines = explode(PHP_EOL, $content);
$exports = [
'',
"project=$this->projectName",
"port=$this->port",
];
array_splice($lines, 1, 0, $exports);
file_put_contents($this->projectRoot . 'scripts/docker-env.sh', join(PHP_EOL, $lines));
}
public function updateJenkinsShell(): void
{
$content = file_get_contents($this->projectRoot . 'installer/resources/scripts/jenkins.sh');
$lines = explode(PHP_EOL, $content);
$exports = [
'',
"project=$this->projectName",
];
array_splice($lines, 1, 0, $exports);
file_put_contents($this->projectRoot . 'jenkins.sh', join(PHP_EOL, $lines));
}
/**
* Create data and cache directories, if not present.
*
* Also sets up appropriate permissions.
*/
public function setupRuntimeDir(): void
{
$this->io->write('<info>初始化缓存目录</info>');
$runtimeDir = $this->projectRoot . '/runtime';
if (!is_dir($runtimeDir)) {
mkdir($runtimeDir, 0775, true);
chmod($runtimeDir, 0775);
}
}
/**
* Cleanup development dependencies.
*
* The dev dependencies should be removed from the stability flags,
* require-dev and the composer file.
*/
public function removeDevDependencies(): void
{
$this->io->write('<info>移除安装器依赖</info>');
foreach ($this->devDependencies as $devDependency) {
unset($this->stabilityFlags[$devDependency], $this->composerDevRequires[$devDependency], $this->composerDefinition['require-dev'][$devDependency]);
}
}
/**
* Prompt for each optional installation package.
*
* @codeCoverageIgnore
*/
public function promptForOptionalPackages(): void
{
foreach ($this->config['questions'] as $questionName => $question) {
$this->promptForOptionalPackage($questionName, $question);
}
/*foreach (['sso', 'auth'] as $item) {
if ($this->composerDefinition['extra']['optional-packages'][$item] === 'y') {
foreach ($this->config['questions'][$item]['options']['y']['packages'] ?? [] as $package) {
if ($this->config['packages'][$package]['publish'] ?? false) {
$this->composerDefinition['scripts']['post-root-package-install'][] = '@php bin/hyperf.php vendor:publish '.$package;
}
}
}
}*/
}
/**
* Prompt for a single optional installation package.
*
* @param string $questionName Name of question
* @param array $question Question details from configuration
*/
public function promptForOptionalPackage(string $questionName, array $question): void
{
$defaultOption = $question['default'] ?? 1;
if (isset($this->composerDefinition['extra']['optional-packages'][$questionName])) {
// Skip question, it's already answered
return;
}
// Get answer
$answer = $this->askQuestion($question, $defaultOption);
// Process answer
$this->processAnswer($question, $answer);
// Save user selected option
$this->composerDefinition['extra']['optional-packages'][$questionName] = $answer;
// Update composer definition
$this->composerJson->write($this->composerDefinition);
}
/**
* Prepare and ask questions and return the answer.
*
* @param int|string $defaultOption
* @return bool|int|string
* @codeCoverageIgnore
*/
private function askQuestion(array $question, $defaultOption)
{
// Construct question
$ask = [
sprintf("\n <question>%s</question>\n", $question['question']),
];
$defaultText = $defaultOption;
foreach ($question['options'] as $key => $option) {
$defaultText = ($key === $defaultOption) ? $option['name'] : $defaultText;
$ask[] = sprintf(" [<comment>%s</comment>] %s\n", $key, $option['name']);
}
if ($question['required'] !== true) {
$ask[] = " [<comment>n</comment>] None of the above\n";
}
$ask[] = ($question['custom-package'] === true)
? sprintf(
' Make your selection or type a composer package name and version <comment>(%s)</comment>: ',
$defaultText
)
: sprintf(' Make your selection <comment>(%s)</comment>: ', $defaultText);
while (true) {
// Ask for user input
$answer = $this->io->ask(implode($ask), (string)$defaultOption);
// Handle none of the options
if ($answer === 'n' && $question['required'] !== true) {
return 'n';
}
// Handle numeric options
if (is_numeric($answer) && isset($question['options'][(int)$answer])) {
return (int)$answer;
}
// Handle string options
if (isset($question['options'][$answer])) {
return $answer;
}
// Search for package
if ($question['custom-package'] === true && preg_match(self::PACKAGE_REGEX, $answer, $match)) {
$packageName = $match['name'];
$packageVersion = $match['version'];
if (!$packageVersion) {
$this->io->write('<error>No package version specified</error>');
continue;
}
$this->io->write(sprintf(' - Searching for <info>%s:%s</info>', $packageName, $packageVersion));
$optionalPackage = $this->composer->getRepositoryManager()->findPackage($packageName, $packageVersion);
if ($optionalPackage === null) {
$this->io->write(sprintf('<error>Package not found %s:%s</error>', $packageName, $packageVersion));
continue;
}
return sprintf('%s:%s', $packageName, $packageVersion);
}
$this->io->write('<error>Invalid answer</error>');
}
}
/**
* Process the answer of a question.
*
* @param bool|int|string $answer
*/
public function processAnswer(array $question, $answer): bool
{
if (isset($question['options'][$answer])) {
// Add packages to install
if (isset($question['options'][$answer]['packages'])) {
foreach ($question['options'][$answer]['packages'] as $packageName) {
$packageData = $this->config['packages'][$packageName];
$this->addPackage($packageName, $packageData['version'], $packageData['whitelist'] ?? []);
}
}
// Copy files
if (isset($question['options'][$answer])) {
$force = !empty($question['force']);
foreach ($question['options'][$answer]['resources'] as $resource => $target) {
$this->copyResource($resource, $target, $force);
}
}
return true;
}
if ($question['custom-package'] === true && preg_match(self::PACKAGE_REGEX, (string)$answer, $match)) {
$this->addPackage($match['name'], $match['version'], []);
if (isset($question['custom-package-warning'])) {
$this->io->write(sprintf(' <warning>%s</warning>', $question['custom-package-warning']));
}
return true;
}
return false;
}
/**
* Add a package.
*/
public function addPackage(string $packageName, string $packageVersion, array $whitelist = []): void
{
$this->io->write(
sprintf(
' - Adding package <info>%s</info> (<comment>%s</comment>)',
$packageName,
$packageVersion
)
);
// Get the version constraint
$versionParser = new VersionParser();
$constraint = $versionParser->parseConstraints($packageVersion);
// Create package link
$link = new Link('__root__', $packageName, $constraint, 'requires', $packageVersion);
// Add package to the root package and composer.json requirements
if (in_array($packageName, $this->config['require-dev'], true)) {
unset($this->composerDefinition['require'][$packageName], $this->composerRequires[$packageName]);
$this->composerDefinition['require-dev'][$packageName] = $packageVersion;
$this->composerDevRequires[$packageName] = $link;
} else {
unset($this->composerDefinition['require-dev'][$packageName], $this->composerDevRequires[$packageName]);
$this->composerDefinition['require'][$packageName] = $packageVersion;
$this->composerRequires[$packageName] = $link;
}
// Set package stability if needed
switch (VersionParser::parseStability($packageVersion)) {
case 'dev':
$this->stabilityFlags[$packageName] = BasePackage::STABILITY_DEV;
break;
case 'alpha':
$this->stabilityFlags[$packageName] = BasePackage::STABILITY_ALPHA;
break;
case 'beta':
$this->stabilityFlags[$packageName] = BasePackage::STABILITY_BETA;
break;
case 'RC':
$this->stabilityFlags[$packageName] = BasePackage::STABILITY_RC;
break;
}
// Whitelist packages for the component installer
foreach ($whitelist as $package) {
if (!in_array($package, $this->composerDefinition['extra']['zf']['component-whitelist'], true)) {
$this->composerDefinition['extra']['zf']['component-whitelist'][] = $package;
$this->io->write(sprintf(' - Whitelist package <info>%s</info>', $package));
}
}
}
/**
* Copy a file to its final destination in the skeleton.
*
* @param string $resource resource file
* @param string $target destination
* @param bool $force whether or not to copy over an existing file
*/
public function copyResource(string $resource, string $target, bool $force = false): void
{
// Copy file
if ($force === false && is_file($this->projectRoot . $target)) {
return;
}
$destinationPath = dirname($this->projectRoot . $target);
if (!is_dir($destinationPath)) {
mkdir($destinationPath, 0775, true);
}
$this->io->write(sprintf(' - Copying <info>%s</info>', $target));
copy($this->installerSource . $resource, $this->projectRoot . $target);
}
/**
* Update the root package based on current state.
*/
public function updateRootPackage(): void
{
$this->rootPackage->setRequires($this->composerRequires);
$this->rootPackage->setDevRequires($this->composerDevRequires);
$this->rootPackage->setStabilityFlags($this->stabilityFlags);
$this->rootPackage->setAutoload($this->composerDefinition['autoload']);
$this->rootPackage->setDevAutoload($this->composerDefinition['autoload-dev']);
$this->rootPackage->setExtra($this->composerDefinition['extra'] ?? []);
}
/**
* Remove the installer from the composer definition.
*/
public function removeInstallerFromDefinition(): void
{
$this->io->write('<info>Remove installer</info>');
// Remove installer script autoloading rules
unset(
$this->composerDefinition['autoload']['psr-4']['Installer\\'],
$this->composerDefinition['autoload-dev']['psr-4']['InstallerTest\\'],
$this->composerDefinition['extra']['branch-alias'],
$this->composerDefinition['extra']['hyperf'],
$this->composerDefinition['extra']['optional-packages'],
$this->composerDefinition['scripts']['pre-update-cmd'],
$this->composerDefinition['scripts']['pre-install-cmd'],
$this->composerDefinition['version']
);
}
/**
* Finalize the package.
*
* Writes the current JSON state to composer.json, clears the
* composer.lock file, and cleans up all files specific to the
* installer.
*
* @codeCoverageIgnore
*/
public function finalizePackage(): void
{
// Update composer definition
$this->composerJson->write($this->composerDefinition);
$this->clearComposerLockFile();
$this->cleanUp();
}
/**
* Removes composer.lock file from gitignore.
*
* @codeCoverageIgnore
*/
private function clearComposerLockFile(): void
{
$this->io->write('<info>Removing composer.lock from .gitignore</info>');
$ignoreFile = sprintf('%s/.gitignore', $this->projectRoot);
$content = $this->removeLinesContainingStrings(['composer.lock'], file_get_contents($ignoreFile));
file_put_contents($ignoreFile, $content);
}
/**
* Clean up/remove installer classes and assets.
*
* On completion of install/update, removes the installer classes (including
* this one) and assets (including configuration and templates).
*
* @codeCoverageIgnore
*/
private function cleanUp(): void
{
$this->io->write('<info>Removing Expressive installer classes, configuration, tests and docs</info>');
foreach ($this->assetsToRemove as $target) {
$target = $this->projectRoot . $target;
if (file_exists($target)) {
unlink($target);
}
}
$this->recursiveRmdir($this->installerSource);
}
/**
* Remove lines from string content containing words in array.
*/
public function removeLinesContainingStrings(array $entries, string $content): string
{
$entries = implode(
'|',
array_map(function ($word) {
return preg_quote($word, '/');
}, $entries)
);
return preg_replace('/^.*%na' . $entries . "me.*$(?:\r?\n)?/m", '', $content);
}
/**
* Recursively remove a directory.
*
* @codeCoverageIgnore
*/
private function recursiveRmdir(string $directory): void
{
if (!is_dir($directory)) {
return;
}
$rdi = new RecursiveDirectoryIterator($directory, FilesystemIterator::SKIP_DOTS);
$rii = new RecursiveIteratorIterator($rdi, RecursiveIteratorIterator::CHILD_FIRST);
foreach ($rii as $filename => $fileInfo) {
if ($fileInfo->isDir()) {
rmdir($filename);
continue;
}
unlink($filename);
}
rmdir($directory);
}
}