[^:]+\/[^:]+)([:]*)(?P.*)$/'; 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 What time zone do you want to set up ?\n"; $ask[] = " [n] 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 这个项目的项目名是什么? (必填项,格式类似 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 一句话描述下这个项目吧,中文可能导致报错(默认:{$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 你想使用哪个端口监听这个项目? (默认:" . $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 .= <<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('初始化缓存目录'); $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('移除安装器依赖'); 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 %s\n", $question['question']), ]; $defaultText = $defaultOption; foreach ($question['options'] as $key => $option) { $defaultText = ($key === $defaultOption) ? $option['name'] : $defaultText; $ask[] = sprintf(" [%s] %s\n", $key, $option['name']); } if ($question['required'] !== true) { $ask[] = " [n] None of the above\n"; } $ask[] = ($question['custom-package'] === true) ? sprintf( ' Make your selection or type a composer package name and version (%s): ', $defaultText ) : sprintf(' Make your selection (%s): ', $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('No package version specified'); continue; } $this->io->write(sprintf(' - Searching for %s:%s', $packageName, $packageVersion)); $optionalPackage = $this->composer->getRepositoryManager()->findPackage($packageName, $packageVersion); if ($optionalPackage === null) { $this->io->write(sprintf('Package not found %s:%s', $packageName, $packageVersion)); continue; } return sprintf('%s:%s', $packageName, $packageVersion); } $this->io->write('Invalid answer'); } } /** * 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(' %s', $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 %s (%s)', $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 %s', $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 %s', $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('Remove installer'); // 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('Removing composer.lock from .gitignore'); $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('Removing Expressive installer classes, configuration, tests and docs'); 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); } }