├── LICENSE ├── README.md ├── composer.json └── src ├── Command ├── DumpEnvCommand.php ├── InstallRecipesCommand.php ├── RecipesCommand.php └── UpdateRecipesCommand.php ├── Configurator.php ├── Configurator ├── AbstractConfigurator.php ├── AddLinesConfigurator.php ├── BundlesConfigurator.php ├── ComposerCommandsConfigurator.php ├── ComposerScriptsConfigurator.php ├── ContainerConfigurator.php ├── CopyFromPackageConfigurator.php ├── CopyFromRecipeConfigurator.php ├── DockerComposeConfigurator.php ├── DockerfileConfigurator.php ├── DotenvConfigurator.php ├── EnvConfigurator.php ├── GitignoreConfigurator.php └── MakefileConfigurator.php ├── Downloader.php ├── Event └── UpdateEvent.php ├── Flex.php ├── GithubApi.php ├── InformationOperation.php ├── Lock.php ├── Options.php ├── PackageFilter.php ├── PackageJsonSynchronizer.php ├── PackageResolver.php ├── Path.php ├── Recipe.php ├── Response.php ├── ScriptExecutor.php ├── SymfonyBundle.php ├── SymfonyPackInstaller.php ├── Unpack ├── Operation.php └── Result.php ├── Unpacker.php └── Update ├── DiffHelper.php ├── RecipePatch.php ├── RecipePatcher.php └── RecipeUpdate.php /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2016-present Fabien Potencier 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy 4 | of this software and associated documentation files (the "Software"), to deal 5 | in the Software without restriction, including without limitation the rights 6 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 7 | copies of the Software, and to permit persons to whom the Software is furnished 8 | to do so, subject to the following conditions: 9 | 10 | The above copyright notice and this permission notice shall be included in all 11 | copies or substantial portions of the Software. 12 | 13 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 16 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 18 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 19 | THE SOFTWARE. 20 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |

2 | 3 |

4 | 5 | [Symfony Flex][1] helps developers create [Symfony][2] applications, from the most 6 | simple micro-style projects to the more complex ones with dozens of 7 | dependencies. 8 | 9 | [1]: https://symfony.com/doc/current/setup/flex.html 10 | [2]: https://symfony.com 11 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "symfony/flex", 3 | "type": "composer-plugin", 4 | "description": "Composer plugin for Symfony", 5 | "license": "MIT", 6 | "authors": [ 7 | { 8 | "name": "Fabien Potencier", 9 | "email": "fabien.potencier@gmail.com" 10 | } 11 | ], 12 | "minimum-stability": "dev", 13 | "require": { 14 | "php": ">=8.0", 15 | "composer-plugin-api": "^2.1" 16 | }, 17 | "require-dev": { 18 | "composer/composer": "^2.1", 19 | "symfony/dotenv": "^5.4|^6.0", 20 | "symfony/filesystem": "^5.4|^6.0", 21 | "symfony/phpunit-bridge": "^5.4|^6.0", 22 | "symfony/process": "^5.4|^6.0" 23 | }, 24 | "conflict": { 25 | "composer/semver": "<1.7.2" 26 | }, 27 | "autoload": { 28 | "psr-4": { 29 | "Symfony\\Flex\\": "src" 30 | } 31 | }, 32 | "extra": { 33 | "class": "Symfony\\Flex\\Flex" 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /src/Command/DumpEnvCommand.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Symfony\Flex\Command; 13 | 14 | use Composer\Command\BaseCommand; 15 | use Composer\Config; 16 | use Symfony\Component\Console\Input\InputArgument; 17 | use Symfony\Component\Console\Input\InputInterface; 18 | use Symfony\Component\Console\Input\InputOption; 19 | use Symfony\Component\Console\Output\OutputInterface; 20 | use Symfony\Component\Dotenv\Dotenv; 21 | use Symfony\Flex\Options; 22 | 23 | class DumpEnvCommand extends BaseCommand 24 | { 25 | private $config; 26 | private $options; 27 | 28 | public function __construct(Config $config, Options $options) 29 | { 30 | $this->config = $config; 31 | $this->options = $options; 32 | 33 | parent::__construct(); 34 | } 35 | 36 | protected function configure() 37 | { 38 | $this->setName('symfony:dump-env') 39 | ->setAliases(['dump-env']) 40 | ->setDescription('Compiles .env files to .env.local.php.') 41 | ->setDefinition([ 42 | new InputArgument('env', InputArgument::OPTIONAL, 'The application environment to dump .env files for - e.g. "prod".'), 43 | ]) 44 | ->addOption('empty', null, InputOption::VALUE_NONE, 'Ignore the content of .env files') 45 | ; 46 | } 47 | 48 | protected function execute(InputInterface $input, OutputInterface $output): int 49 | { 50 | $runtime = $this->options->get('runtime') ?? []; 51 | $envKey = $runtime['env_var_name'] ?? 'APP_ENV'; 52 | 53 | if ($env = $input->getArgument('env') ?? $runtime['env'] ?? null) { 54 | $_SERVER[$envKey] = $env; 55 | } 56 | 57 | $path = $this->options->get('root-dir').'/'.($runtime['dotenv_path'] ?? '.env'); 58 | 59 | if (!$env || !$input->getOption('empty')) { 60 | $vars = $this->loadEnv($path, $env, $runtime); 61 | $env = $vars[$envKey]; 62 | } 63 | 64 | if ($input->getOption('empty')) { 65 | $vars = [$envKey => $env]; 66 | } 67 | 68 | $vars = var_export($vars, true); 69 | $vars = <<getIO()->writeError('Successfully dumped .env files in .env.local.php'); 80 | 81 | return 0; 82 | } 83 | 84 | private function loadEnv(string $path, ?string $env, array $runtime): array 85 | { 86 | if (!file_exists($autoloadFile = $this->config->get('vendor-dir').'/autoload.php')) { 87 | throw new \RuntimeException(\sprintf('Please run "composer install" before running this command: "%s" not found.', $autoloadFile)); 88 | } 89 | 90 | require $autoloadFile; 91 | 92 | if (!class_exists(Dotenv::class)) { 93 | throw new \RuntimeException('Please run "composer require symfony/dotenv" to load the ".env" files configuring the application.'); 94 | } 95 | 96 | $envKey = $runtime['env_var_name'] ?? 'APP_ENV'; 97 | $globalsBackup = [$_SERVER, $_ENV]; 98 | unset($_SERVER[$envKey]); 99 | $_ENV = [$envKey => $env]; 100 | $_SERVER['SYMFONY_DOTENV_VARS'] = implode(',', array_keys($_SERVER)); 101 | putenv('SYMFONY_DOTENV_VARS='.$_SERVER['SYMFONY_DOTENV_VARS']); 102 | 103 | try { 104 | if (method_exists(Dotenv::class, 'usePutenv')) { 105 | $dotenv = new Dotenv(); 106 | } else { 107 | $dotenv = new Dotenv(false); 108 | } 109 | 110 | if (!$env && file_exists($p = "$path.local")) { 111 | $env = $_ENV[$envKey] = $dotenv->parse(file_get_contents($p), $p)[$envKey] ?? null; 112 | } 113 | 114 | if (!$env) { 115 | throw new \RuntimeException(\sprintf('Please provide the name of the environment either by passing it as command line argument or by defining the "%s" variable in the ".env.local" file.', $envKey)); 116 | } 117 | 118 | $testEnvs = $runtime['test_envs'] ?? ['test']; 119 | 120 | if (method_exists($dotenv, 'loadEnv')) { 121 | $dotenv->loadEnv($path, $envKey, 'dev', $testEnvs); 122 | } else { 123 | // fallback code in case your Dotenv component is not 4.2 or higher (when loadEnv() was added) 124 | $dotenv->load(file_exists($path) || !file_exists($p = "$path.dist") ? $path : $p); 125 | 126 | if (!\in_array($env, $testEnvs, true) && file_exists($p = "$path.local")) { 127 | $dotenv->load($p); 128 | } 129 | 130 | if (file_exists($p = "$path.$env")) { 131 | $dotenv->load($p); 132 | } 133 | 134 | if (file_exists($p = "$path.$env.local")) { 135 | $dotenv->load($p); 136 | } 137 | } 138 | 139 | unset($_ENV['SYMFONY_DOTENV_VARS']); 140 | $env = $_ENV; 141 | } finally { 142 | list($_SERVER, $_ENV) = $globalsBackup; 143 | } 144 | 145 | return $env; 146 | } 147 | } 148 | -------------------------------------------------------------------------------- /src/Command/InstallRecipesCommand.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Symfony\Flex\Command; 13 | 14 | use Composer\Command\BaseCommand; 15 | use Composer\DependencyResolver\Operation\InstallOperation; 16 | use Composer\Util\ProcessExecutor; 17 | use Symfony\Component\Console\Exception\RuntimeException; 18 | use Symfony\Component\Console\Input\InputArgument; 19 | use Symfony\Component\Console\Input\InputInterface; 20 | use Symfony\Component\Console\Input\InputOption; 21 | use Symfony\Component\Console\Output\OutputInterface; 22 | use Symfony\Flex\Event\UpdateEvent; 23 | use Symfony\Flex\Flex; 24 | 25 | class InstallRecipesCommand extends BaseCommand 26 | { 27 | /** @var Flex */ 28 | private $flex; 29 | private $rootDir; 30 | private $dotenvPath; 31 | 32 | public function __construct(/* cannot be type-hinted */ $flex, string $rootDir, string $dotenvPath = '.env') 33 | { 34 | $this->flex = $flex; 35 | $this->rootDir = $rootDir; 36 | $this->dotenvPath = $dotenvPath; 37 | 38 | parent::__construct(); 39 | } 40 | 41 | protected function configure() 42 | { 43 | $this->setName('symfony:recipes:install') 44 | ->setAliases(['recipes:install', 'symfony:sync-recipes', 'sync-recipes', 'fix-recipes']) 45 | ->setDescription('Installs or reinstalls recipes for already installed packages.') 46 | ->addArgument('packages', InputArgument::IS_ARRAY | InputArgument::OPTIONAL, 'Recipes that should be installed.') 47 | ->addOption('force', null, InputOption::VALUE_NONE, 'Overwrite existing files when a new version of a recipe is available') 48 | ->addOption('reset', null, InputOption::VALUE_NONE, 'Reset all recipes back to their initial state (should be combined with --force)') 49 | ->addOption('yes', null, InputOption::VALUE_NONE, "Answer prompt questions with 'yes' for all questions.") 50 | ; 51 | } 52 | 53 | protected function execute(InputInterface $input, OutputInterface $output): int 54 | { 55 | $win = '\\' === \DIRECTORY_SEPARATOR; 56 | $force = (bool) $input->getOption('force'); 57 | 58 | if ($force && !@is_executable(strtok(exec($win ? 'where git' : 'command -v git'), \PHP_EOL))) { 59 | throw new RuntimeException('Cannot run "sync-recipes --force": git not found.'); 60 | } 61 | 62 | $symfonyLock = $this->flex->getLock(); 63 | $composer = $this->getComposer(); 64 | $locker = $composer->getLocker(); 65 | $lockData = $locker->getLockData(); 66 | 67 | $packages = []; 68 | $totalPackages = []; 69 | foreach ($lockData['packages'] as $pkg) { 70 | $totalPackages[] = $pkg['name']; 71 | if ($force || !$symfonyLock->has($pkg['name'])) { 72 | $packages[] = $pkg['name']; 73 | } 74 | } 75 | foreach ($lockData['packages-dev'] as $pkg) { 76 | $totalPackages[] = $pkg['name']; 77 | if ($force || !$symfonyLock->has($pkg['name'])) { 78 | $packages[] = $pkg['name']; 79 | } 80 | } 81 | 82 | $io = $this->getIO(); 83 | 84 | if (!$io->isVerbose()) { 85 | $io->writeError([ 86 | 'Run command with -v to see more details', 87 | '', 88 | ]); 89 | } 90 | 91 | if ($targetPackages = $input->getArgument('packages')) { 92 | if ($invalidPackages = array_diff($targetPackages, $totalPackages)) { 93 | $io->writeError(\sprintf('Cannot update: some packages are not installed: %s', implode(', ', $invalidPackages))); 94 | 95 | return 1; 96 | } 97 | 98 | if ($packagesRequiringForce = array_diff($targetPackages, $packages)) { 99 | $io->writeError(\sprintf('Recipe(s) already installed for: %s', implode(', ', $packagesRequiringForce))); 100 | $io->writeError('Re-run the command with --force to re-install the recipes.'); 101 | $io->writeError(''); 102 | } 103 | 104 | $packages = array_diff($targetPackages, $packagesRequiringForce); 105 | } 106 | 107 | if (!$packages) { 108 | $io->writeError('No recipes to install.'); 109 | 110 | return 0; 111 | } 112 | 113 | $composer = $this->getComposer(); 114 | $installedRepo = $composer->getRepositoryManager()->getLocalRepository(); 115 | 116 | $operations = []; 117 | foreach ($packages as $package) { 118 | if (null === $pkg = $installedRepo->findPackage($package, '*')) { 119 | $io->writeError(\sprintf('Package %s is not installed', $package)); 120 | 121 | return 1; 122 | } 123 | 124 | $operations[] = new InstallOperation($pkg); 125 | } 126 | 127 | $dotenvFile = $this->dotenvPath; 128 | $dotenvPath = $this->rootDir.'/'.$dotenvFile; 129 | 130 | if ($createEnvLocal = $force && file_exists($dotenvPath) && file_exists($dotenvPath.'.dist') && !file_exists($dotenvPath.'.local')) { 131 | rename($dotenvPath, $dotenvPath.'.local'); 132 | $pipes = []; 133 | proc_close(proc_open(\sprintf('git mv %s %s > %s 2>&1 || %s %1$s %2$s', ProcessExecutor::escape($dotenvFile.'.dist'), ProcessExecutor::escape($dotenvFile), $win ? 'NUL' : '/dev/null', $win ? 'rename' : 'mv'), $pipes, $pipes, $this->rootDir)); 134 | if (file_exists($this->rootDir.'/phpunit.xml.dist') || file_exists($this->rootDir.'/phpunit.dist.xml')) { 135 | touch($dotenvPath.'.test'); 136 | } 137 | } 138 | 139 | $this->flex->update(new UpdateEvent($force, (bool) $input->getOption('reset'), (bool) $input->getOption('yes')), $operations); 140 | 141 | if ($force) { 142 | $output = [ 143 | '', 144 | ' ', 145 | ' Files have been reset to the latest version of the recipe. ', 146 | ' ', 147 | '', 148 | ' * Use git diff to inspect the changes.', 149 | '', 150 | ' Not all of the changes will be relevant to your app: you now', 151 | ' need to selectively add or revert them using e.g. a combination', 152 | ' of git add -p and git checkout -p', 153 | '', 154 | ]; 155 | 156 | if ($createEnvLocal) { 157 | $output[] = ' Dotenv files have been renamed: .env -> .env.local and .env.dist -> .env'; 158 | $output[] = ' See https://symfony.com/doc/current/configuration/dot-env-changes.html'; 159 | $output[] = ''; 160 | } 161 | 162 | $output[] = ' * Use git checkout . to revert the changes.'; 163 | $output[] = ''; 164 | 165 | if ($createEnvLocal) { 166 | $root = '.' !== $this->rootDir ? $this->rootDir.'/' : ''; 167 | $output[] = ' To revert the changes made to .env files, run'; 168 | $output[] = \sprintf(' git mv %s %s && %s %s %1$s', ProcessExecutor::escape($root.$dotenvFile), ProcessExecutor::escape($root.$dotenvFile.'.dist'), $win ? 'rename' : 'mv', ProcessExecutor::escape($root.$dotenvFile.'.local')); 169 | $output[] = ''; 170 | } 171 | 172 | $output[] = ' New (untracked) files can be inspected using git clean --dry-run'; 173 | $output[] = ' Add the new files you want to keep using git add'; 174 | $output[] = ' then delete the rest using git clean --force'; 175 | $output[] = ''; 176 | 177 | $io->write($output); 178 | } 179 | 180 | return 0; 181 | } 182 | } 183 | -------------------------------------------------------------------------------- /src/Command/RecipesCommand.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Symfony\Flex\Command; 13 | 14 | use Composer\Command\BaseCommand; 15 | use Composer\Downloader\TransportException; 16 | use Composer\Package\Package; 17 | use Composer\Util\HttpDownloader; 18 | use Symfony\Component\Console\Input\InputArgument; 19 | use Symfony\Component\Console\Input\InputInterface; 20 | use Symfony\Component\Console\Input\InputOption; 21 | use Symfony\Component\Console\Output\OutputInterface; 22 | use Symfony\Flex\GithubApi; 23 | use Symfony\Flex\InformationOperation; 24 | use Symfony\Flex\Lock; 25 | use Symfony\Flex\Recipe; 26 | 27 | /** 28 | * @author Maxime Hélias 29 | */ 30 | class RecipesCommand extends BaseCommand 31 | { 32 | /** @var \Symfony\Flex\Flex */ 33 | private $flex; 34 | 35 | private Lock $symfonyLock; 36 | private GithubApi $githubApi; 37 | 38 | public function __construct(/* cannot be type-hinted */ $flex, Lock $symfonyLock, HttpDownloader $downloader) 39 | { 40 | $this->flex = $flex; 41 | $this->symfonyLock = $symfonyLock; 42 | $this->githubApi = new GithubApi($downloader); 43 | 44 | parent::__construct(); 45 | } 46 | 47 | protected function configure() 48 | { 49 | $this->setName('symfony:recipes') 50 | ->setAliases(['recipes']) 51 | ->setDescription('Shows information about all available recipes.') 52 | ->setDefinition([ 53 | new InputArgument('package', InputArgument::OPTIONAL, 'Package to inspect, if not provided all packages are.'), 54 | ]) 55 | ->addOption('outdated', 'o', InputOption::VALUE_NONE, 'Show only recipes that are outdated') 56 | ; 57 | } 58 | 59 | protected function execute(InputInterface $input, OutputInterface $output): int 60 | { 61 | $installedRepo = $this->getComposer()->getRepositoryManager()->getLocalRepository(); 62 | 63 | // Inspect one or all packages 64 | $package = $input->getArgument('package'); 65 | if (null !== $package) { 66 | $packages = [strtolower($package)]; 67 | } else { 68 | $locker = $this->getComposer()->getLocker(); 69 | $lockData = $locker->getLockData(); 70 | 71 | // Merge all packages installed 72 | $packages = array_column(array_merge($lockData['packages'], $lockData['packages-dev']), 'name'); 73 | $packages = array_unique(array_merge($packages, array_keys($this->symfonyLock->all()))); 74 | } 75 | 76 | $operations = []; 77 | foreach ($packages as $name) { 78 | $pkg = $installedRepo->findPackage($name, '*'); 79 | 80 | if (!$pkg && $this->symfonyLock->has($name)) { 81 | $pkgVersion = $this->symfonyLock->get($name)['version']; 82 | $pkg = new Package($name, $pkgVersion, $pkgVersion); 83 | } elseif (!$pkg) { 84 | $this->getIO()->writeError(\sprintf('Package %s is not installed', $name)); 85 | 86 | continue; 87 | } 88 | 89 | $operations[] = new InformationOperation($pkg); 90 | } 91 | 92 | $recipes = $this->flex->fetchRecipes($operations, false); 93 | ksort($recipes); 94 | 95 | $nbRecipe = \count($recipes); 96 | if ($nbRecipe <= 0) { 97 | $this->getIO()->writeError('No recipe found'); 98 | 99 | return 1; 100 | } 101 | 102 | // Display the information about a specific recipe 103 | if (1 === $nbRecipe) { 104 | $this->displayPackageInformation(current($recipes)); 105 | 106 | return 0; 107 | } 108 | 109 | $outdated = $input->getOption('outdated'); 110 | 111 | $write = []; 112 | $hasOutdatedRecipes = false; 113 | foreach ($recipes as $name => $recipe) { 114 | $lockRef = $this->symfonyLock->get($name)['recipe']['ref'] ?? null; 115 | 116 | $additional = null; 117 | if (null === $lockRef && null !== $recipe->getRef()) { 118 | $additional = '(recipe not installed)'; 119 | } elseif ($recipe->getRef() !== $lockRef && !$recipe->isAuto()) { 120 | $additional = '(update available)'; 121 | } 122 | 123 | if ($outdated && null === $additional) { 124 | continue; 125 | } 126 | 127 | $hasOutdatedRecipes = true; 128 | $write[] = \sprintf(' * %s %s', $name, $additional); 129 | } 130 | 131 | // Nothing to display 132 | if (!$hasOutdatedRecipes) { 133 | return 0; 134 | } 135 | 136 | $this->getIO()->write(array_merge([ 137 | '', 138 | ' ', 139 | \sprintf(' %s recipes. ', $outdated ? ' Outdated' : 'Available'), 140 | ' ', 141 | '', 142 | ], $write, [ 143 | '', 144 | 'Run:', 145 | ' * composer recipes vendor/package to see details about a recipe.', 146 | ' * composer recipes:update vendor/package to update that recipe.', 147 | '', 148 | ])); 149 | 150 | if ($outdated) { 151 | return 1; 152 | } 153 | 154 | return 0; 155 | } 156 | 157 | private function displayPackageInformation(Recipe $recipe) 158 | { 159 | $io = $this->getIO(); 160 | $recipeLock = $this->symfonyLock->get($recipe->getName()); 161 | 162 | $lockRef = $recipeLock['recipe']['ref'] ?? null; 163 | $lockRepo = $recipeLock['recipe']['repo'] ?? null; 164 | $lockFiles = $recipeLock['files'] ?? null; 165 | $lockBranch = $recipeLock['recipe']['branch'] ?? null; 166 | $lockVersion = $recipeLock['recipe']['version'] ?? $recipeLock['version'] ?? null; 167 | 168 | if ('master' === $lockBranch && \in_array($lockRepo, ['github.com/symfony/recipes', 'github.com/symfony/recipes-contrib'])) { 169 | $lockBranch = 'main'; 170 | } 171 | 172 | $status = 'up to date'; 173 | if ($recipe->isAuto()) { 174 | $status = 'auto-generated recipe'; 175 | } elseif (null === $lockRef && null !== $recipe->getRef()) { 176 | $status = 'recipe not installed'; 177 | } elseif ($recipe->getRef() !== $lockRef) { 178 | $status = 'update available'; 179 | } 180 | 181 | $gitSha = null; 182 | $commitDate = null; 183 | if (null !== $lockRef && null !== $lockRepo) { 184 | try { 185 | $recipeCommitData = $this->githubApi->findRecipeCommitDataFromTreeRef( 186 | $recipe->getName(), 187 | $lockRepo, 188 | $lockBranch ?? '', 189 | $lockVersion, 190 | $lockRef 191 | ); 192 | $gitSha = $recipeCommitData ? $recipeCommitData['commit'] : null; 193 | $commitDate = $recipeCommitData ? $recipeCommitData['date'] : null; 194 | } catch (TransportException $exception) { 195 | $io->writeError('Error downloading exact git sha for installed recipe.'); 196 | } 197 | } 198 | 199 | $io->write('name : '.$recipe->getName()); 200 | $io->write('version : '.($lockVersion ?? 'n/a')); 201 | $io->write('status : '.$status); 202 | if (!$recipe->isAuto() && null !== $lockVersion) { 203 | $recipeUrl = \sprintf( 204 | 'https://%s/tree/%s/%s/%s', 205 | $lockRepo, 206 | // if something fails, default to the branch as the closest "sha" 207 | $gitSha ?? $lockBranch, 208 | $recipe->getName(), 209 | $lockVersion 210 | ); 211 | 212 | $io->write('installed recipe : '.$recipeUrl); 213 | } 214 | 215 | if ($lockRef !== $recipe->getRef()) { 216 | $io->write('latest recipe : '.$recipe->getURL()); 217 | } 218 | 219 | if ($lockRef !== $recipe->getRef() && null !== $lockVersion) { 220 | $historyUrl = \sprintf( 221 | 'https://%s/commits/%s/%s', 222 | $lockRepo, 223 | $lockBranch, 224 | $recipe->getName() 225 | ); 226 | 227 | // show commits since one second after the currently-installed recipe 228 | if (null !== $commitDate) { 229 | $historyUrl .= '?since='; 230 | $historyUrl .= (new \DateTime($commitDate)) 231 | ->setTimezone(new \DateTimeZone('UTC')) 232 | ->modify('+1 seconds') 233 | ->format('Y-m-d\TH:i:s\Z'); 234 | } 235 | 236 | $io->write('recipe history : '.$historyUrl); 237 | } 238 | 239 | if (null !== $lockFiles) { 240 | $io->write('files : '); 241 | $io->write(''); 242 | 243 | $tree = $this->generateFilesTree($lockFiles); 244 | 245 | $this->displayFilesTree($tree); 246 | } 247 | 248 | if ($lockRef !== $recipe->getRef()) { 249 | $io->write([ 250 | '', 251 | 'Update this recipe by running:', 252 | \sprintf('composer recipes:update %s', $recipe->getName()), 253 | ]); 254 | } 255 | } 256 | 257 | private function generateFilesTree(array $files): array 258 | { 259 | $tree = []; 260 | foreach ($files as $file) { 261 | $path = explode('/', $file); 262 | 263 | $tree = array_merge_recursive($tree, $this->addNode($path)); 264 | } 265 | 266 | return $tree; 267 | } 268 | 269 | private function addNode(array $node): array 270 | { 271 | $current = array_shift($node); 272 | 273 | $subTree = []; 274 | if (null !== $current) { 275 | $subTree[$current] = $this->addNode($node); 276 | } 277 | 278 | return $subTree; 279 | } 280 | 281 | /** 282 | * Note : We do not display file modification information with Configurator like ComposerScripts, Container, DockerComposer, Dockerfile, Env, Gitignore and Makefile. 283 | */ 284 | private function displayFilesTree(array $tree) 285 | { 286 | end($tree); 287 | $endKey = key($tree); 288 | foreach ($tree as $dir => $files) { 289 | $treeBar = '├'; 290 | $total = \count($files); 291 | if (0 === $total || $endKey === $dir) { 292 | $treeBar = '└'; 293 | } 294 | 295 | $info = \sprintf( 296 | '%s──%s', 297 | $treeBar, 298 | $dir 299 | ); 300 | $this->writeTreeLine($info); 301 | 302 | $treeBar = str_replace('└', ' ', $treeBar); 303 | 304 | $this->displayTree($files, $treeBar); 305 | } 306 | } 307 | 308 | private function displayTree(array $tree, $previousTreeBar = '├', $level = 1) 309 | { 310 | $previousTreeBar = str_replace('├', '│', $previousTreeBar); 311 | $treeBar = $previousTreeBar.' ├'; 312 | 313 | $i = 0; 314 | $total = \count($tree); 315 | 316 | foreach ($tree as $dir => $files) { 317 | ++$i; 318 | if ($i === $total) { 319 | $treeBar = $previousTreeBar.' └'; 320 | } 321 | 322 | $info = \sprintf( 323 | '%s──%s', 324 | $treeBar, 325 | $dir 326 | ); 327 | $this->writeTreeLine($info); 328 | 329 | $treeBar = str_replace('└', ' ', $treeBar); 330 | 331 | $this->displayTree($files, $treeBar, $level + 1); 332 | } 333 | } 334 | 335 | private function writeTreeLine($line) 336 | { 337 | $io = $this->getIO(); 338 | if (!$io->isDecorated()) { 339 | $line = str_replace(['└', '├', '──', '│'], ['`-', '|-', '-', '|'], $line); 340 | } 341 | 342 | $io->write($line); 343 | } 344 | } 345 | -------------------------------------------------------------------------------- /src/Command/UpdateRecipesCommand.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Symfony\Flex\Command; 13 | 14 | use Composer\Command\BaseCommand; 15 | use Composer\IO\IOInterface; 16 | use Composer\Package\Package; 17 | use Composer\Package\PackageInterface; 18 | use Composer\Util\ProcessExecutor; 19 | use Symfony\Component\Console\Exception\RuntimeException; 20 | use Symfony\Component\Console\Input\InputArgument; 21 | use Symfony\Component\Console\Input\InputInterface; 22 | use Symfony\Component\Console\Output\OutputInterface; 23 | use Symfony\Flex\Configurator; 24 | use Symfony\Flex\Downloader; 25 | use Symfony\Flex\Flex; 26 | use Symfony\Flex\GithubApi; 27 | use Symfony\Flex\InformationOperation; 28 | use Symfony\Flex\Lock; 29 | use Symfony\Flex\Recipe; 30 | use Symfony\Flex\Update\RecipePatcher; 31 | use Symfony\Flex\Update\RecipeUpdate; 32 | 33 | class UpdateRecipesCommand extends BaseCommand 34 | { 35 | /** @var Flex */ 36 | private $flex; 37 | private $downloader; 38 | private $configurator; 39 | private $rootDir; 40 | private $githubApi; 41 | private $processExecutor; 42 | 43 | public function __construct(/* cannot be type-hinted */ $flex, Downloader $downloader, $httpDownloader, Configurator $configurator, string $rootDir) 44 | { 45 | $this->flex = $flex; 46 | $this->downloader = $downloader; 47 | $this->configurator = $configurator; 48 | $this->rootDir = $rootDir; 49 | $this->githubApi = new GithubApi($httpDownloader); 50 | 51 | parent::__construct(); 52 | } 53 | 54 | protected function configure() 55 | { 56 | $this->setName('symfony:recipes:update') 57 | ->setAliases(['recipes:update']) 58 | ->setDescription('Updates an already-installed recipe to the latest version.') 59 | ->addArgument('package', InputArgument::OPTIONAL, 'Recipe that should be updated.') 60 | ; 61 | } 62 | 63 | protected function execute(InputInterface $input, OutputInterface $output): int 64 | { 65 | $win = '\\' === \DIRECTORY_SEPARATOR; 66 | $runtimeExceptionClass = class_exists(RuntimeException::class) ? RuntimeException::class : \RuntimeException::class; 67 | if (!@is_executable(strtok(exec($win ? 'where git' : 'command -v git'), \PHP_EOL))) { 68 | throw new $runtimeExceptionClass('Cannot run "recipes:update": git not found.'); 69 | } 70 | 71 | $io = $this->getIO(); 72 | if (!$this->isIndexClean($io)) { 73 | $io->write([ 74 | ' Cannot run recipes:update: Your git index contains uncommitted changes.', 75 | ' Please commit or stash them and try again!', 76 | ]); 77 | 78 | return 1; 79 | } 80 | 81 | $packageName = $input->getArgument('package'); 82 | $symfonyLock = $this->flex->getLock(); 83 | if (!$packageName) { 84 | $packageName = $this->askForPackage($io, $symfonyLock); 85 | 86 | if (null === $packageName) { 87 | $io->writeError('All packages appear to be up-to-date!'); 88 | 89 | return 0; 90 | } 91 | } 92 | 93 | if (!$symfonyLock->has($packageName)) { 94 | $io->writeError([ 95 | 'Package not found inside symfony.lock. It looks like it\'s not installed?', 96 | \sprintf('Try running composer recipes:install %s --force -v to re-install the recipe.', $packageName), 97 | ]); 98 | 99 | return 1; 100 | } 101 | 102 | $packageLockData = $symfonyLock->get($packageName); 103 | if (!isset($packageLockData['recipe'])) { 104 | $io->writeError([ 105 | 'It doesn\'t look like this package had a recipe when it was originally installed.', 106 | 'To install the latest version of the recipe, if there is one, run:', 107 | \sprintf(' composer recipes:install %s --force -v', $packageName), 108 | ]); 109 | 110 | return 1; 111 | } 112 | 113 | $recipeRef = $packageLockData['recipe']['ref'] ?? null; 114 | $recipeVersion = $packageLockData['recipe']['version'] ?? null; 115 | if (!$recipeRef || !$recipeVersion) { 116 | $io->writeError([ 117 | 'The version of the installed recipe was not saved into symfony.lock.', 118 | 'This is possible if it was installed by an old version of Symfony Flex.', 119 | 'Update the recipe by re-installing the latest version with:', 120 | \sprintf(' composer recipes:install %s --force -v', $packageName), 121 | ]); 122 | 123 | return 1; 124 | } 125 | 126 | $installedRepo = $this->getComposer()->getRepositoryManager()->getLocalRepository(); 127 | $package = $installedRepo->findPackage($packageName, '*') ?? new Package($packageName, $packageLockData['version'], $packageLockData['version']); 128 | $originalRecipe = $this->getRecipe($package, $recipeRef, $recipeVersion); 129 | 130 | if (null === $originalRecipe) { 131 | $io->writeError([ 132 | 'The original recipe version you have installed could not be found, it may be too old.', 133 | 'Update the recipe by re-installing the latest version with:', 134 | \sprintf(' composer recipes:install %s --force -v', $packageName), 135 | ]); 136 | 137 | return 1; 138 | } 139 | 140 | $newRecipe = $this->getRecipe($package); 141 | 142 | if ($newRecipe->getRef() === $originalRecipe->getRef()) { 143 | $io->write(\sprintf('This recipe for %s is already at the latest version.', $packageName)); 144 | 145 | return 0; 146 | } 147 | 148 | $io->write([ 149 | \sprintf(' Updating recipe for %s...', $packageName), 150 | '', 151 | ]); 152 | 153 | $recipeUpdate = new RecipeUpdate($originalRecipe, $newRecipe, $symfonyLock, $this->rootDir); 154 | $this->configurator->populateUpdate($recipeUpdate); 155 | $originalComposerJsonHash = $this->flex->getComposerJsonHash(); 156 | $patcher = new RecipePatcher($this->rootDir, $io); 157 | 158 | try { 159 | $patch = $patcher->generatePatch($recipeUpdate->getOriginalFiles(), $recipeUpdate->getNewFiles()); 160 | $hasConflicts = !$patcher->applyPatch($patch); 161 | } catch (\Throwable $throwable) { 162 | $io->writeError([ 163 | 'There was an error applying the recipe update patch', 164 | $throwable->getMessage(), 165 | '', 166 | 'Update the recipe by re-installing the latest version with:', 167 | \sprintf(' composer recipes:install %s --force -v', $packageName), 168 | ]); 169 | 170 | return 1; 171 | } 172 | 173 | $symfonyLock->add($packageName, $newRecipe->getLock()); 174 | $this->flex->finish($this->rootDir, $originalComposerJsonHash); 175 | 176 | // stage symfony.lock, as all patched files with already be staged 177 | $cmdOutput = ''; 178 | $this->getProcessExecutor()->execute('git add symfony.lock', $cmdOutput, $this->rootDir); 179 | 180 | $io->write([ 181 | ' ', 182 | ' Yes! Recipe updated! ', 183 | ' ', 184 | '', 185 | ]); 186 | 187 | if ($hasConflicts) { 188 | $io->write([ 189 | ' The recipe was updated but with one or more conflicts.', 190 | ' Run git status to see them.', 191 | ' After resolving, commit your changes like normal.', 192 | ]); 193 | } else { 194 | if (!$patch->getPatch()) { 195 | // no changes were required 196 | $io->write([ 197 | ' No files were changed as a result of the update.', 198 | ]); 199 | } else { 200 | $io->write([ 201 | ' Run git status or git diff --cached to see the changes.', 202 | ' When you\'re ready, commit these changes like normal.', 203 | ]); 204 | } 205 | } 206 | 207 | if (0 !== \count($recipeUpdate->getCopyFromPackagePaths())) { 208 | $io->write([ 209 | '', 210 | ' NOTE:', 211 | ' This recipe copies the following paths from the bundle into your app:', 212 | ]); 213 | foreach ($recipeUpdate->getCopyFromPackagePaths() as $source => $target) { 214 | $io->write(\sprintf(' * %s => %s', $source, $target)); 215 | } 216 | $io->write([ 217 | '', 218 | ' The recipe updater has no way of knowing if these files have changed since you originally installed the recipe.', 219 | ' And so, no updates were made to these paths.', 220 | ]); 221 | } 222 | 223 | if (0 !== \count($patch->getRemovedPatches())) { 224 | if (1 === \count($patch->getRemovedPatches())) { 225 | $notes = [ 226 | \sprintf(' The file %s was not updated because it doesn\'t exist in your app.', array_keys($patch->getRemovedPatches())[0]), 227 | ]; 228 | } else { 229 | $notes = [' The following files were not updated because they don\'t exist in your app:']; 230 | foreach ($patch->getRemovedPatches() as $filename => $contents) { 231 | $notes[] = \sprintf(' * %s', $filename); 232 | } 233 | } 234 | $io->write([ 235 | '', 236 | ' NOTE:', 237 | ]); 238 | $io->write($notes); 239 | $io->write(''); 240 | if ($io->askConfirmation(' Would you like to save the "diff" to a file so you can review it? (Y/n) ')) { 241 | $patchFilename = str_replace('/', '.', $packageName).'.updates-for-deleted-files.patch'; 242 | file_put_contents($this->rootDir.'/'.$patchFilename, implode("\n", $patch->getRemovedPatches())); 243 | $io->write([ 244 | '', 245 | \sprintf(' Saved diff to %s', $patchFilename), 246 | ]); 247 | } 248 | } 249 | 250 | if ($patch->getPatch()) { 251 | $io->write(''); 252 | $io->write(' Calculating CHANGELOG...', false); 253 | $changelog = $this->generateChangelog($originalRecipe); 254 | $io->write("\r", false); // clear current line 255 | if ($changelog) { 256 | $io->write($changelog); 257 | } else { 258 | $io->write('No CHANGELOG could be calculated.'); 259 | } 260 | } 261 | 262 | return 0; 263 | } 264 | 265 | private function getRecipe(PackageInterface $package, ?string $recipeRef = null, ?string $recipeVersion = null): ?Recipe 266 | { 267 | $operation = new InformationOperation($package); 268 | if (null !== $recipeRef) { 269 | $operation->setSpecificRecipeVersion($recipeRef, $recipeVersion); 270 | } 271 | $recipes = $this->downloader->getRecipes([$operation]); 272 | 273 | if (0 === \count($recipes['manifests'] ?? [])) { 274 | return null; 275 | } 276 | 277 | return new Recipe( 278 | $package, 279 | $package->getName(), 280 | $operation->getOperationType(), 281 | $recipes['manifests'][$package->getName()], 282 | $recipes['locks'][$package->getName()] ?? [] 283 | ); 284 | } 285 | 286 | private function generateChangelog(Recipe $originalRecipe): ?array 287 | { 288 | $recipeData = $originalRecipe->getLock()['recipe'] ?? null; 289 | if (null === $recipeData) { 290 | return null; 291 | } 292 | 293 | if (!isset($recipeData['ref']) || !isset($recipeData['repo']) || !isset($recipeData['branch']) || !isset($recipeData['version'])) { 294 | return null; 295 | } 296 | 297 | $currentRecipeVersionData = $this->githubApi->findRecipeCommitDataFromTreeRef( 298 | $originalRecipe->getName(), 299 | $recipeData['repo'], 300 | $recipeData['branch'], 301 | $recipeData['version'], 302 | $recipeData['ref'] 303 | ); 304 | 305 | if (!$currentRecipeVersionData) { 306 | return null; 307 | } 308 | 309 | $recipeVersions = $this->githubApi->getVersionsOfRecipe( 310 | $recipeData['repo'], 311 | $recipeData['branch'], 312 | $originalRecipe->getName() 313 | ); 314 | if (!$recipeVersions) { 315 | return null; 316 | } 317 | 318 | $newerRecipeVersions = array_filter($recipeVersions, function ($version) use ($recipeData) { 319 | return version_compare($version, $recipeData['version'], '>'); 320 | }); 321 | 322 | $newCommits = $currentRecipeVersionData['new_commits']; 323 | foreach ($newerRecipeVersions as $newerRecipeVersion) { 324 | $newCommits = array_merge( 325 | $newCommits, 326 | $this->githubApi->getCommitDataForPath($recipeData['repo'], $originalRecipe->getName().'/'.$newerRecipeVersion, $recipeData['branch']) 327 | ); 328 | } 329 | 330 | $newCommits = array_unique($newCommits); 331 | asort($newCommits); 332 | 333 | $pullRequests = []; 334 | foreach ($newCommits as $commit => $date) { 335 | $pr = $this->githubApi->getPullRequestForCommit($commit, $recipeData['repo']); 336 | if ($pr) { 337 | $pullRequests[$pr['number']] = $pr; 338 | } 339 | } 340 | 341 | $lines = []; 342 | // borrowed from symfony/console's OutputFormatterStyle 343 | $handlesHrefGracefully = 'JetBrains-JediTerm' !== getenv('TERMINAL_EMULATOR') 344 | && (!getenv('KONSOLE_VERSION') || (int) getenv('KONSOLE_VERSION') > 201100); 345 | foreach ($pullRequests as $number => $data) { 346 | $url = $data['url']; 347 | if ($handlesHrefGracefully) { 348 | $url = "\033]8;;$url\033\\$number\033]8;;\033\\"; 349 | } 350 | $lines[] = \sprintf(' * %s (PR %s)', $data['title'], $url); 351 | } 352 | 353 | return $lines; 354 | } 355 | 356 | private function askForPackage(IOInterface $io, Lock $symfonyLock): ?string 357 | { 358 | $installedRepo = $this->getComposer()->getRepositoryManager()->getLocalRepository(); 359 | 360 | $operations = []; 361 | foreach ($symfonyLock->all() as $name => $lock) { 362 | if (isset($lock['recipe']['ref'])) { 363 | $package = $installedRepo->findPackage($name, '*') ?? new Package($name, $lock['version'], $lock['version']); 364 | $operations[] = new InformationOperation($package); 365 | } 366 | } 367 | 368 | $recipes = $this->flex->fetchRecipes($operations, false); 369 | ksort($recipes); 370 | 371 | $outdatedRecipes = []; 372 | foreach ($recipes as $name => $recipe) { 373 | $lockRef = $symfonyLock->get($name)['recipe']['ref'] ?? null; 374 | 375 | if (null !== $lockRef && $recipe->getRef() !== $lockRef && !$recipe->isAuto()) { 376 | $outdatedRecipes[] = $name; 377 | } 378 | } 379 | 380 | if (0 === \count($outdatedRecipes)) { 381 | return null; 382 | } 383 | 384 | $question = 'Which outdated recipe would you like to update? (default: 0)'; 385 | 386 | $choice = $io->select( 387 | $question, 388 | $outdatedRecipes, 389 | 0 390 | ); 391 | 392 | return $outdatedRecipes[$choice]; 393 | } 394 | 395 | private function isIndexClean(IOInterface $io): bool 396 | { 397 | $output = ''; 398 | 399 | $this->getProcessExecutor()->execute('git status --porcelain --untracked-files=no', $output, $this->rootDir); 400 | if ('' !== trim($output)) { 401 | return false; 402 | } 403 | 404 | return true; 405 | } 406 | 407 | private function getProcessExecutor(): ProcessExecutor 408 | { 409 | if (null === $this->processExecutor) { 410 | $this->processExecutor = new ProcessExecutor($this->getIO()); 411 | } 412 | 413 | return $this->processExecutor; 414 | } 415 | } 416 | -------------------------------------------------------------------------------- /src/Configurator.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Symfony\Flex; 13 | 14 | use Composer\Composer; 15 | use Composer\IO\IOInterface; 16 | use Symfony\Flex\Configurator\AbstractConfigurator; 17 | use Symfony\Flex\Update\RecipeUpdate; 18 | 19 | /** 20 | * @author Fabien Potencier 21 | */ 22 | class Configurator 23 | { 24 | private $composer; 25 | private $io; 26 | private $options; 27 | private $configurators; 28 | private $postInstallConfigurators; 29 | private $cache; 30 | 31 | public function __construct(Composer $composer, IOInterface $io, Options $options) 32 | { 33 | $this->composer = $composer; 34 | $this->io = $io; 35 | $this->options = $options; 36 | // ordered list of configurators 37 | $this->configurators = [ 38 | 'bundles' => Configurator\BundlesConfigurator::class, 39 | 'copy-from-recipe' => Configurator\CopyFromRecipeConfigurator::class, 40 | 'copy-from-package' => Configurator\CopyFromPackageConfigurator::class, 41 | 'env' => Configurator\EnvConfigurator::class, 42 | 'dotenv' => Configurator\DotenvConfigurator::class, 43 | 'container' => Configurator\ContainerConfigurator::class, 44 | 'makefile' => Configurator\MakefileConfigurator::class, 45 | 'composer-scripts' => Configurator\ComposerScriptsConfigurator::class, 46 | 'composer-commands' => Configurator\ComposerCommandsConfigurator::class, 47 | 'gitignore' => Configurator\GitignoreConfigurator::class, 48 | 'dockerfile' => Configurator\DockerfileConfigurator::class, 49 | 'docker-compose' => Configurator\DockerComposeConfigurator::class, 50 | ]; 51 | $this->postInstallConfigurators = [ 52 | 'add-lines' => Configurator\AddLinesConfigurator::class, 53 | ]; 54 | } 55 | 56 | public function install(Recipe $recipe, Lock $lock, array $options = []) 57 | { 58 | $manifest = $recipe->getManifest(); 59 | foreach (array_keys($this->configurators) as $key) { 60 | if (isset($manifest[$key])) { 61 | $this->get($key)->configure($recipe, $manifest[$key], $lock, $options); 62 | } 63 | } 64 | } 65 | 66 | /** 67 | * Run after all recipes have been installed to run post-install configurators. 68 | */ 69 | public function postInstall(Recipe $recipe, Lock $lock, array $options = []) 70 | { 71 | $manifest = $recipe->getManifest(); 72 | foreach (array_keys($this->postInstallConfigurators) as $key) { 73 | if (isset($manifest[$key])) { 74 | $this->get($key)->configure($recipe, $manifest[$key], $lock, $options); 75 | } 76 | } 77 | } 78 | 79 | public function populateUpdate(RecipeUpdate $recipeUpdate): void 80 | { 81 | $originalManifest = $recipeUpdate->getOriginalRecipe()->getManifest(); 82 | $newManifest = $recipeUpdate->getNewRecipe()->getManifest(); 83 | $allConfigurators = array_merge($this->configurators, $this->postInstallConfigurators); 84 | foreach (array_keys($allConfigurators) as $key) { 85 | if (!isset($originalManifest[$key]) && !isset($newManifest[$key])) { 86 | continue; 87 | } 88 | 89 | $this->get($key)->update($recipeUpdate, $originalManifest[$key] ?? [], $newManifest[$key] ?? []); 90 | } 91 | } 92 | 93 | public function unconfigure(Recipe $recipe, Lock $lock) 94 | { 95 | $manifest = $recipe->getManifest(); 96 | 97 | $allConfigurators = array_merge($this->configurators, $this->postInstallConfigurators); 98 | 99 | foreach (array_keys($allConfigurators) as $key) { 100 | if (isset($manifest[$key])) { 101 | $this->get($key)->unconfigure($recipe, $manifest[$key], $lock); 102 | } 103 | } 104 | } 105 | 106 | private function get($key): AbstractConfigurator 107 | { 108 | if (!isset($this->configurators[$key]) && !isset($this->postInstallConfigurators[$key])) { 109 | throw new \InvalidArgumentException(\sprintf('Unknown configurator "%s".', $key)); 110 | } 111 | 112 | if (isset($this->cache[$key])) { 113 | return $this->cache[$key]; 114 | } 115 | 116 | $class = isset($this->configurators[$key]) ? $this->configurators[$key] : $this->postInstallConfigurators[$key]; 117 | 118 | return $this->cache[$key] = new $class($this->composer, $this->io, $this->options); 119 | } 120 | } 121 | -------------------------------------------------------------------------------- /src/Configurator/AbstractConfigurator.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Symfony\Flex\Configurator; 13 | 14 | use Composer\Composer; 15 | use Composer\IO\IOInterface; 16 | use Symfony\Flex\Lock; 17 | use Symfony\Flex\Options; 18 | use Symfony\Flex\Path; 19 | use Symfony\Flex\Recipe; 20 | use Symfony\Flex\Update\RecipeUpdate; 21 | 22 | /** 23 | * @author Fabien Potencier 24 | */ 25 | abstract class AbstractConfigurator 26 | { 27 | protected $composer; 28 | protected $io; 29 | protected $options; 30 | protected $path; 31 | 32 | public function __construct(Composer $composer, IOInterface $io, Options $options) 33 | { 34 | $this->composer = $composer; 35 | $this->io = $io; 36 | $this->options = $options; 37 | $this->path = new Path($options->get('root-dir')); 38 | } 39 | 40 | abstract public function configure(Recipe $recipe, $config, Lock $lock, array $options = []); 41 | 42 | abstract public function unconfigure(Recipe $recipe, $config, Lock $lock); 43 | 44 | abstract public function update(RecipeUpdate $recipeUpdate, array $originalConfig, array $newConfig): void; 45 | 46 | protected function write($messages, $verbosity = IOInterface::VERBOSE) 47 | { 48 | if (!\is_array($messages)) { 49 | $messages = [$messages]; 50 | } 51 | foreach ($messages as $i => $message) { 52 | $messages[$i] = ' '.$message; 53 | } 54 | $this->io->writeError($messages, true, $verbosity); 55 | } 56 | 57 | protected function isFileMarked(Recipe $recipe, string $file): bool 58 | { 59 | return is_file($file) && str_contains(file_get_contents($file), \sprintf('###> %s ###', $recipe->getName())); 60 | } 61 | 62 | protected function markData(Recipe $recipe, string $data): string 63 | { 64 | return "\n".\sprintf('###> %s ###%s%s%s###< %s ###%s', $recipe->getName(), "\n", rtrim($data, "\r\n"), "\n", $recipe->getName(), "\n"); 65 | } 66 | 67 | protected function isFileXmlMarked(Recipe $recipe, string $file): bool 68 | { 69 | return is_file($file) && str_contains(file_get_contents($file), \sprintf('###+ %s ###', $recipe->getName())); 70 | } 71 | 72 | protected function markXmlData(Recipe $recipe, string $data): string 73 | { 74 | return "\n".\sprintf(' %s%s%s %s', $recipe->getName(), "\n", rtrim($data, "\r\n"), "\n", $recipe->getName(), "\n"); 75 | } 76 | 77 | /** 78 | * @return bool True if section was found and replaced 79 | */ 80 | protected function updateData(string $file, string $data): bool 81 | { 82 | if (!file_exists($file)) { 83 | return false; 84 | } 85 | 86 | $contents = file_get_contents($file); 87 | 88 | $newContents = $this->updateDataString($contents, $data); 89 | if (null === $newContents) { 90 | return false; 91 | } 92 | 93 | file_put_contents($file, $newContents); 94 | 95 | return true; 96 | } 97 | 98 | /** 99 | * @return string|null returns the updated content if the section was found, null if not found 100 | */ 101 | protected function updateDataString(string $contents, string $data): ?string 102 | { 103 | $pieces = explode("\n", trim($data)); 104 | $startMark = trim(reset($pieces)); 105 | $endMark = trim(end($pieces)); 106 | 107 | if (!str_contains($contents, $startMark) || !str_contains($contents, $endMark)) { 108 | return null; 109 | } 110 | 111 | $pattern = '/'.preg_quote($startMark, '/').'.*?'.preg_quote($endMark, '/').'/s'; 112 | 113 | return preg_replace($pattern, trim($data), $contents); 114 | } 115 | 116 | protected function extractSection(Recipe $recipe, string $contents): ?string 117 | { 118 | $section = $this->markData($recipe, '----'); 119 | 120 | $pieces = explode("\n", trim($section)); 121 | $startMark = trim(reset($pieces)); 122 | $endMark = trim(end($pieces)); 123 | 124 | $pattern = '/'.preg_quote($startMark, '/').'.*?'.preg_quote($endMark, '/').'/s'; 125 | 126 | $matches = []; 127 | preg_match($pattern, $contents, $matches); 128 | 129 | return $matches[0] ?? null; 130 | } 131 | } 132 | -------------------------------------------------------------------------------- /src/Configurator/AddLinesConfigurator.php: -------------------------------------------------------------------------------- 1 | 12 | * @author Ryan Weaver 13 | */ 14 | class AddLinesConfigurator extends AbstractConfigurator 15 | { 16 | private const POSITION_TOP = 'top'; 17 | private const POSITION_BOTTOM = 'bottom'; 18 | private const POSITION_AFTER_TARGET = 'after_target'; 19 | 20 | private const VALID_POSITIONS = [ 21 | self::POSITION_TOP, 22 | self::POSITION_BOTTOM, 23 | self::POSITION_AFTER_TARGET, 24 | ]; 25 | 26 | /** 27 | * Holds file contents for files that have been loaded. 28 | * This allows us to "change" the contents of a file multiple 29 | * times before we actually write it out. 30 | * 31 | * @var string[] 32 | */ 33 | private $fileContents = []; 34 | 35 | public function configure(Recipe $recipe, $config, Lock $lock, array $options = []): void 36 | { 37 | $this->fileContents = []; 38 | $this->executeConfigure($recipe, $config); 39 | 40 | foreach ($this->fileContents as $file => $contents) { 41 | $this->write(\sprintf('[add-lines] Patching file "%s"', $this->relativize($file))); 42 | file_put_contents($file, $contents); 43 | } 44 | } 45 | 46 | public function unconfigure(Recipe $recipe, $config, Lock $lock): void 47 | { 48 | $this->fileContents = []; 49 | $this->executeUnconfigure($recipe, $config); 50 | 51 | foreach ($this->fileContents as $file => $change) { 52 | $this->write(\sprintf('[add-lines] Reverting file "%s"', $this->relativize($file))); 53 | file_put_contents($file, $change); 54 | } 55 | } 56 | 57 | public function update(RecipeUpdate $recipeUpdate, array $originalConfig, array $newConfig): void 58 | { 59 | // manually check for "requires", as unconfigure ignores it 60 | $originalConfig = array_filter($originalConfig, function ($item) { 61 | return !isset($item['requires']) || $this->isPackageInstalled($item['requires']); 62 | }); 63 | 64 | // reset the file content cache 65 | $this->fileContents = []; 66 | $this->executeUnconfigure($recipeUpdate->getOriginalRecipe(), $originalConfig); 67 | $this->executeConfigure($recipeUpdate->getNewRecipe(), $newConfig); 68 | $newFiles = []; 69 | $originalFiles = []; 70 | foreach ($this->fileContents as $file => $contents) { 71 | // set the original file to the current contents 72 | $originalFiles[$this->relativize($file)] = file_get_contents($file); 73 | // and the new file where the old recipe was unconfigured, and the new configured 74 | $newFiles[$this->relativize($file)] = $contents; 75 | } 76 | $recipeUpdate->addOriginalFiles($originalFiles); 77 | $recipeUpdate->addNewFiles($newFiles); 78 | } 79 | 80 | public function executeConfigure(Recipe $recipe, $config): void 81 | { 82 | foreach ($config as $patch) { 83 | if (!isset($patch['file'])) { 84 | $this->write(\sprintf('The "file" key is required for the "add-lines" configurator for recipe "%s". Skipping', $recipe->getName())); 85 | 86 | continue; 87 | } 88 | 89 | if (isset($patch['requires']) && !$this->isPackageInstalled($patch['requires'])) { 90 | continue; 91 | } 92 | 93 | if (!isset($patch['content'])) { 94 | $this->write(\sprintf('The "content" key is required for the "add-lines" configurator for recipe "%s". Skipping', $recipe->getName())); 95 | 96 | continue; 97 | } 98 | $content = $patch['content']; 99 | 100 | $file = $this->path->concatenate([$this->options->get('root-dir'), $this->options->expandTargetDir($patch['file'])]); 101 | $warnIfMissing = isset($patch['warn_if_missing']) && $patch['warn_if_missing']; 102 | if (!is_file($file)) { 103 | $this->write([ 104 | \sprintf('Could not add lines to file %s as it does not exist. Missing lines:', $patch['file']), 105 | '"""', 106 | $content, 107 | '"""', 108 | '', 109 | ], $warnIfMissing ? IOInterface::NORMAL : IOInterface::VERBOSE); 110 | 111 | continue; 112 | } 113 | 114 | if (!isset($patch['position'])) { 115 | $this->write(\sprintf('The "position" key is required for the "add-lines" configurator for recipe "%s". Skipping', $recipe->getName())); 116 | 117 | continue; 118 | } 119 | $position = $patch['position']; 120 | if (!\in_array($position, self::VALID_POSITIONS, true)) { 121 | $this->write(\sprintf('The "position" key must be one of "%s" for the "add-lines" configurator for recipe "%s". Skipping', implode('", "', self::VALID_POSITIONS), $recipe->getName())); 122 | 123 | continue; 124 | } 125 | 126 | if (self::POSITION_AFTER_TARGET === $position && !isset($patch['target'])) { 127 | $this->write(\sprintf('The "target" key is required when "position" is "%s" for the "add-lines" configurator for recipe "%s". Skipping', self::POSITION_AFTER_TARGET, $recipe->getName())); 128 | 129 | continue; 130 | } 131 | $target = isset($patch['target']) ? $patch['target'] : null; 132 | 133 | $newContents = $this->getPatchedContents($file, $content, $position, $target, $warnIfMissing); 134 | $this->fileContents[$file] = $newContents; 135 | } 136 | } 137 | 138 | public function executeUnconfigure(Recipe $recipe, $config): void 139 | { 140 | foreach ($config as $patch) { 141 | if (!isset($patch['file'])) { 142 | $this->write(\sprintf('The "file" key is required for the "add-lines" configurator for recipe "%s". Skipping', $recipe->getName())); 143 | 144 | continue; 145 | } 146 | 147 | // Ignore "requires": the target packages may have just become uninstalled. 148 | // Checking for a "content" match is enough. 149 | 150 | $file = $this->path->concatenate([$this->options->get('root-dir'), $this->options->expandTargetDir($patch['file'])]); 151 | if (!is_file($file)) { 152 | continue; 153 | } 154 | 155 | if (!isset($patch['content'])) { 156 | $this->write(\sprintf('The "content" key is required for the "add-lines" configurator for recipe "%s". Skipping', $recipe->getName())); 157 | 158 | continue; 159 | } 160 | $value = $patch['content']; 161 | 162 | $newContents = $this->getUnPatchedContents($file, $value); 163 | $this->fileContents[$file] = $newContents; 164 | } 165 | } 166 | 167 | private function getPatchedContents(string $file, string $value, string $position, ?string $target, bool $warnIfMissing): string 168 | { 169 | $fileContents = $this->readFile($file); 170 | 171 | if (str_contains($fileContents, $value)) { 172 | return $fileContents; // already includes value, skip 173 | } 174 | 175 | switch ($position) { 176 | case self::POSITION_BOTTOM: 177 | $fileContents .= "\n".$value; 178 | 179 | break; 180 | case self::POSITION_TOP: 181 | $fileContents = $value."\n".$fileContents; 182 | 183 | break; 184 | case self::POSITION_AFTER_TARGET: 185 | $lines = explode("\n", $fileContents); 186 | $targetFound = false; 187 | foreach ($lines as $key => $line) { 188 | if (str_contains($line, $target)) { 189 | array_splice($lines, $key + 1, 0, $value); 190 | $targetFound = true; 191 | 192 | break; 193 | } 194 | } 195 | $fileContents = implode("\n", $lines); 196 | 197 | if (!$targetFound) { 198 | $this->write([ 199 | \sprintf('Could not add lines after "%s" as no such string was found in "%s". Missing lines:', $target, $file), 200 | '"""', 201 | $value, 202 | '"""', 203 | '', 204 | ], $warnIfMissing ? IOInterface::NORMAL : IOInterface::VERBOSE); 205 | } 206 | 207 | break; 208 | } 209 | 210 | return $fileContents; 211 | } 212 | 213 | private function getUnPatchedContents(string $file, $value): string 214 | { 215 | $fileContents = $this->readFile($file); 216 | 217 | if (!str_contains($fileContents, $value)) { 218 | return $fileContents; // value already gone! 219 | } 220 | 221 | if (str_contains($fileContents, "\n".$value)) { 222 | $value = "\n".$value; 223 | } elseif (str_contains($fileContents, $value."\n")) { 224 | $value .= "\n"; 225 | } 226 | 227 | $position = strpos($fileContents, $value); 228 | 229 | return substr_replace($fileContents, '', $position, \strlen($value)); 230 | } 231 | 232 | private function isPackageInstalled($packages): bool 233 | { 234 | if (\is_string($packages)) { 235 | $packages = [$packages]; 236 | } 237 | 238 | $installedRepo = $this->composer->getRepositoryManager()->getLocalRepository(); 239 | 240 | foreach ($packages as $packageName) { 241 | if (null === $installedRepo->findPackage($packageName, '*')) { 242 | return false; 243 | } 244 | } 245 | 246 | return true; 247 | } 248 | 249 | private function relativize(string $path): string 250 | { 251 | $rootDir = $this->options->get('root-dir'); 252 | if (str_starts_with($path, $rootDir)) { 253 | $path = substr($path, \strlen($rootDir) + 1); 254 | } 255 | 256 | return ltrim($path, '/\\'); 257 | } 258 | 259 | private function readFile(string $file): string 260 | { 261 | if (isset($this->fileContents[$file])) { 262 | return $this->fileContents[$file]; 263 | } 264 | 265 | $fileContents = file_get_contents($file); 266 | $this->fileContents[$file] = $fileContents; 267 | 268 | return $fileContents; 269 | } 270 | } 271 | -------------------------------------------------------------------------------- /src/Configurator/BundlesConfigurator.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Symfony\Flex\Configurator; 13 | 14 | use Symfony\Flex\Lock; 15 | use Symfony\Flex\Recipe; 16 | use Symfony\Flex\Update\RecipeUpdate; 17 | 18 | /** 19 | * @author Fabien Potencier 20 | */ 21 | class BundlesConfigurator extends AbstractConfigurator 22 | { 23 | public function configure(Recipe $recipe, $bundles, Lock $lock, array $options = []) 24 | { 25 | $this->write('Enabling the package as a Symfony bundle'); 26 | $registered = $this->configureBundles($bundles); 27 | $this->dump($this->getConfFile(), $registered); 28 | } 29 | 30 | public function unconfigure(Recipe $recipe, $bundles, Lock $lock) 31 | { 32 | $this->write('Disabling the Symfony bundle'); 33 | $file = $this->getConfFile(); 34 | if (!file_exists($file)) { 35 | return; 36 | } 37 | 38 | $registered = $this->load($file); 39 | foreach (array_keys($this->prepareBundles($bundles)) as $class) { 40 | unset($registered[$class]); 41 | } 42 | $this->dump($file, $registered); 43 | } 44 | 45 | public function update(RecipeUpdate $recipeUpdate, array $originalConfig, array $newConfig): void 46 | { 47 | $originalBundles = $this->configureBundles($originalConfig, true); 48 | $recipeUpdate->setOriginalFile( 49 | $this->getLocalConfFile(), 50 | $this->buildContents($originalBundles) 51 | ); 52 | 53 | $newBundles = $this->configureBundles($newConfig, true); 54 | $recipeUpdate->setNewFile( 55 | $this->getLocalConfFile(), 56 | $this->buildContents($newBundles) 57 | ); 58 | } 59 | 60 | private function configureBundles(array $bundles, bool $resetEnvironments = false): array 61 | { 62 | $file = $this->getConfFile(); 63 | $registered = $this->load($file); 64 | $classes = $this->prepareBundles($bundles); 65 | if (isset($classes[$fwb = 'Symfony\Bundle\FrameworkBundle\FrameworkBundle'])) { 66 | foreach ($classes[$fwb] as $env) { 67 | $registered[$fwb][$env] = true; 68 | } 69 | unset($classes[$fwb]); 70 | } 71 | foreach ($classes as $class => $envs) { 72 | // do not override existing configured envs for a bundle 73 | if (!isset($registered[$class]) || $resetEnvironments) { 74 | if ($resetEnvironments) { 75 | // used during calculating an "upgrade" 76 | // here, we want to "undo" the bundle's configuration entirely 77 | // then re-add it fresh, in case some environments have been 78 | // removed in an updated version of the recipe 79 | $registered[$class] = []; 80 | } 81 | 82 | foreach ($envs as $env) { 83 | $registered[$class][$env] = true; 84 | } 85 | } 86 | } 87 | 88 | return $registered; 89 | } 90 | 91 | private function prepareBundles(array $bundles): array 92 | { 93 | foreach ($bundles as $class => $envs) { 94 | $bundles[ltrim($class, '\\')] = $envs; 95 | } 96 | 97 | return $bundles; 98 | } 99 | 100 | private function load(string $file): array 101 | { 102 | $bundles = file_exists($file) ? (require $file) : []; 103 | if (!\is_array($bundles)) { 104 | $bundles = []; 105 | } 106 | 107 | return $bundles; 108 | } 109 | 110 | private function dump(string $file, array $bundles) 111 | { 112 | $contents = $this->buildContents($bundles); 113 | 114 | if (!is_dir(\dirname($file))) { 115 | mkdir(\dirname($file), 0777, true); 116 | } 117 | 118 | file_put_contents($file, $contents); 119 | 120 | if (\function_exists('opcache_invalidate')) { 121 | @opcache_invalidate($file); 122 | } 123 | } 124 | 125 | private function buildContents(array $bundles): string 126 | { 127 | $contents = " $envs) { 129 | $contents .= " $class::class => ["; 130 | foreach ($envs as $env => $value) { 131 | $booleanValue = var_export($value, true); 132 | $contents .= "'$env' => $booleanValue, "; 133 | } 134 | $contents = substr($contents, 0, -2)."],\n"; 135 | } 136 | $contents .= "];\n"; 137 | 138 | return $contents; 139 | } 140 | 141 | private function getConfFile(): string 142 | { 143 | return $this->options->get('root-dir').'/'.$this->getLocalConfFile(); 144 | } 145 | 146 | private function getLocalConfFile(): string 147 | { 148 | return $this->options->expandTargetDir('%CONFIG_DIR%/bundles.php'); 149 | } 150 | } 151 | -------------------------------------------------------------------------------- /src/Configurator/ComposerCommandsConfigurator.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Symfony\Flex\Configurator; 13 | 14 | use Composer\Factory; 15 | use Composer\Json\JsonFile; 16 | use Composer\Json\JsonManipulator; 17 | use Symfony\Flex\Lock; 18 | use Symfony\Flex\Recipe; 19 | use Symfony\Flex\Update\RecipeUpdate; 20 | 21 | /** 22 | * @author Marcin Morawski 23 | */ 24 | class ComposerCommandsConfigurator extends AbstractConfigurator 25 | { 26 | public function configure(Recipe $recipe, $scripts, Lock $lock, array $options = []) 27 | { 28 | $json = new JsonFile(Factory::getComposerFile()); 29 | 30 | file_put_contents($json->getPath(), $this->configureScripts($scripts, $json)); 31 | } 32 | 33 | public function unconfigure(Recipe $recipe, $scripts, Lock $lock) 34 | { 35 | $json = new JsonFile(Factory::getComposerFile()); 36 | 37 | $manipulator = new JsonManipulator(file_get_contents($json->getPath())); 38 | foreach ($scripts as $key => $command) { 39 | $manipulator->removeSubNode('scripts', $key); 40 | } 41 | 42 | file_put_contents($json->getPath(), $manipulator->getContents()); 43 | } 44 | 45 | public function update(RecipeUpdate $recipeUpdate, array $originalConfig, array $newConfig): void 46 | { 47 | $json = new JsonFile(Factory::getComposerFile()); 48 | $jsonPath = $json->getPath(); 49 | if (str_starts_with($jsonPath, $recipeUpdate->getRootDir())) { 50 | $jsonPath = substr($jsonPath, \strlen($recipeUpdate->getRootDir())); 51 | } 52 | $jsonPath = ltrim($jsonPath, '/\\'); 53 | 54 | $recipeUpdate->setOriginalFile( 55 | $jsonPath, 56 | $this->configureScripts($originalConfig, $json) 57 | ); 58 | $recipeUpdate->setNewFile( 59 | $jsonPath, 60 | $this->configureScripts($newConfig, $json) 61 | ); 62 | } 63 | 64 | private function configureScripts(array $scripts, JsonFile $json): string 65 | { 66 | $manipulator = new JsonManipulator(file_get_contents($json->getPath())); 67 | foreach ($scripts as $cmdName => $script) { 68 | $manipulator->addSubNode('scripts', $cmdName, $script); 69 | } 70 | 71 | return $manipulator->getContents(); 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /src/Configurator/ComposerScriptsConfigurator.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Symfony\Flex\Configurator; 13 | 14 | use Composer\Factory; 15 | use Composer\Json\JsonFile; 16 | use Composer\Json\JsonManipulator; 17 | use Symfony\Flex\Lock; 18 | use Symfony\Flex\Recipe; 19 | use Symfony\Flex\Update\RecipeUpdate; 20 | 21 | /** 22 | * @author Fabien Potencier 23 | */ 24 | class ComposerScriptsConfigurator extends AbstractConfigurator 25 | { 26 | public function configure(Recipe $recipe, $scripts, Lock $lock, array $options = []) 27 | { 28 | $json = new JsonFile(Factory::getComposerFile()); 29 | 30 | file_put_contents($json->getPath(), $this->configureScripts($scripts, $json)); 31 | } 32 | 33 | public function unconfigure(Recipe $recipe, $scripts, Lock $lock) 34 | { 35 | $json = new JsonFile(Factory::getComposerFile()); 36 | 37 | $jsonContents = $json->read(); 38 | $autoScripts = $jsonContents['scripts']['auto-scripts'] ?? []; 39 | foreach (array_keys($scripts) as $cmd) { 40 | unset($autoScripts[$cmd]); 41 | } 42 | 43 | $manipulator = new JsonManipulator(file_get_contents($json->getPath())); 44 | $manipulator->addSubNode('scripts', 'auto-scripts', $autoScripts); 45 | 46 | file_put_contents($json->getPath(), $manipulator->getContents()); 47 | } 48 | 49 | public function update(RecipeUpdate $recipeUpdate, array $originalConfig, array $newConfig): void 50 | { 51 | $json = new JsonFile(Factory::getComposerFile()); 52 | $jsonPath = $json->getPath(); 53 | if (str_starts_with($jsonPath, $recipeUpdate->getRootDir())) { 54 | $jsonPath = substr($jsonPath, \strlen($recipeUpdate->getRootDir())); 55 | } 56 | $jsonPath = ltrim($jsonPath, '/\\'); 57 | 58 | $recipeUpdate->setOriginalFile( 59 | $jsonPath, 60 | $this->configureScripts($originalConfig, $json) 61 | ); 62 | $recipeUpdate->setNewFile( 63 | $jsonPath, 64 | $this->configureScripts($newConfig, $json) 65 | ); 66 | } 67 | 68 | private function configureScripts(array $scripts, JsonFile $json): string 69 | { 70 | $jsonContents = $json->read(); 71 | $autoScripts = $jsonContents['scripts']['auto-scripts'] ?? []; 72 | $autoScripts = array_merge($autoScripts, $scripts); 73 | 74 | $manipulator = new JsonManipulator(file_get_contents($json->getPath())); 75 | $manipulator->addSubNode('scripts', 'auto-scripts', $autoScripts); 76 | 77 | return $manipulator->getContents(); 78 | } 79 | } 80 | -------------------------------------------------------------------------------- /src/Configurator/ContainerConfigurator.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Symfony\Flex\Configurator; 13 | 14 | use Symfony\Flex\Lock; 15 | use Symfony\Flex\Recipe; 16 | use Symfony\Flex\Update\RecipeUpdate; 17 | 18 | /** 19 | * @author Fabien Potencier 20 | */ 21 | class ContainerConfigurator extends AbstractConfigurator 22 | { 23 | public function configure(Recipe $recipe, $parameters, Lock $lock, array $options = []) 24 | { 25 | $this->write('Setting parameters'); 26 | $contents = $this->configureParameters($parameters); 27 | 28 | if (null !== $contents) { 29 | file_put_contents($this->options->get('root-dir').'/'.$this->getServicesPath(), $contents); 30 | } 31 | } 32 | 33 | public function unconfigure(Recipe $recipe, $parameters, Lock $lock) 34 | { 35 | $this->write('Unsetting parameters'); 36 | $target = $this->options->get('root-dir').'/'.$this->getServicesPath(); 37 | $lines = $this->removeParametersFromLines(file($target), $parameters); 38 | file_put_contents($target, implode('', $lines)); 39 | } 40 | 41 | public function update(RecipeUpdate $recipeUpdate, array $originalConfig, array $newConfig): void 42 | { 43 | $recipeUpdate->setOriginalFile( 44 | $this->getServicesPath(), 45 | $this->configureParameters($originalConfig, true) 46 | ); 47 | 48 | // for the new file, we need to update any values *and* remove any removed values 49 | $removedParameters = []; 50 | foreach ($originalConfig as $name => $value) { 51 | if (!isset($newConfig[$name])) { 52 | $removedParameters[$name] = $value; 53 | } 54 | } 55 | 56 | $updatedFile = $this->configureParameters($newConfig, true); 57 | $lines = $this->removeParametersFromLines(explode("\n", $updatedFile), $removedParameters); 58 | 59 | $recipeUpdate->setNewFile( 60 | $this->getServicesPath(), 61 | implode("\n", $lines) 62 | ); 63 | } 64 | 65 | private function configureParameters(array $parameters, bool $update = false): string 66 | { 67 | $target = $this->options->get('root-dir').'/'.$this->getServicesPath(); 68 | $endAt = 0; 69 | $isParameters = false; 70 | $lines = []; 71 | foreach (file($target) as $i => $line) { 72 | $lines[] = $line; 73 | if (!$isParameters && !preg_match('/^parameters:/', $line)) { 74 | continue; 75 | } 76 | if (!$isParameters) { 77 | $isParameters = true; 78 | continue; 79 | } 80 | if (!preg_match('/^\s+.*/', $line) && '' !== trim($line)) { 81 | $endAt = $i - 1; 82 | $isParameters = false; 83 | continue; 84 | } 85 | foreach ($parameters as $key => $value) { 86 | $matches = []; 87 | if (preg_match(\sprintf('/^\s+%s\:/', preg_quote($key, '/')), $line, $matches)) { 88 | if ($update) { 89 | $lines[$i] = substr($line, 0, \strlen($matches[0])).' '.str_replace("'", "''", $value)."\n"; 90 | } 91 | 92 | unset($parameters[$key]); 93 | } 94 | } 95 | } 96 | 97 | if ($parameters) { 98 | $parametersLines = []; 99 | if (!$endAt) { 100 | $parametersLines[] = "parameters:\n"; 101 | } 102 | foreach ($parameters as $key => $value) { 103 | if (\is_array($value)) { 104 | $parametersLines[] = \sprintf(" %s:\n%s", $key, $this->dumpYaml(2, $value)); 105 | continue; 106 | } 107 | $parametersLines[] = \sprintf(" %s: '%s'%s", $key, str_replace("'", "''", $value), "\n"); 108 | } 109 | if (!$endAt) { 110 | $parametersLines[] = "\n"; 111 | } 112 | array_splice($lines, $endAt, 0, $parametersLines); 113 | } 114 | 115 | return implode('', $lines); 116 | } 117 | 118 | private function removeParametersFromLines(array $sourceLines, array $parameters): array 119 | { 120 | $lines = []; 121 | foreach ($sourceLines as $line) { 122 | if ($this->removeParameters(1, $parameters, $line)) { 123 | continue; 124 | } 125 | $lines[] = $line; 126 | } 127 | 128 | return $lines; 129 | } 130 | 131 | private function removeParameters($level, $params, $line) 132 | { 133 | foreach ($params as $key => $value) { 134 | if (\is_array($value) && $this->removeParameters($level + 1, $value, $line)) { 135 | return true; 136 | } 137 | if (preg_match(\sprintf('/^(\s{%d}|\t{%d})+%s\:/', 4 * $level, $level, preg_quote($key, '/')), $line)) { 138 | return true; 139 | } 140 | } 141 | 142 | return false; 143 | } 144 | 145 | private function dumpYaml($level, $array): string 146 | { 147 | $line = ''; 148 | foreach ($array as $key => $value) { 149 | $line .= str_repeat(' ', $level); 150 | if (!\is_array($value)) { 151 | $line .= \sprintf("%s: '%s'\n", $key, str_replace("'", "''", $value)); 152 | continue; 153 | } 154 | $line .= \sprintf("%s:\n", $key).$this->dumpYaml($level + 1, $value); 155 | } 156 | 157 | return $line; 158 | } 159 | 160 | private function getServicesPath(): string 161 | { 162 | return $this->options->expandTargetDir('%CONFIG_DIR%/services.yaml'); 163 | } 164 | } 165 | -------------------------------------------------------------------------------- /src/Configurator/CopyFromPackageConfigurator.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Symfony\Flex\Configurator; 13 | 14 | use Symfony\Flex\Lock; 15 | use Symfony\Flex\Recipe; 16 | use Symfony\Flex\Update\RecipeUpdate; 17 | 18 | /** 19 | * @author Fabien Potencier 20 | */ 21 | class CopyFromPackageConfigurator extends AbstractConfigurator 22 | { 23 | public function configure(Recipe $recipe, $config, Lock $lock, array $options = []) 24 | { 25 | $this->write('Copying files from package'); 26 | $packageDir = $this->composer->getInstallationManager()->getInstallPath($recipe->getPackage()); 27 | $options = array_merge($this->options->toArray(), $options); 28 | 29 | $files = $this->getFilesToCopy($config, $packageDir); 30 | foreach ($files as $source => $target) { 31 | $this->copyFile($source, $target, $options); 32 | } 33 | } 34 | 35 | public function unconfigure(Recipe $recipe, $config, Lock $lock) 36 | { 37 | $this->write('Removing files from package'); 38 | $packageDir = $this->composer->getInstallationManager()->getInstallPath($recipe->getPackage()); 39 | $this->removeFiles($config, $packageDir, $this->options->get('root-dir')); 40 | } 41 | 42 | public function update(RecipeUpdate $recipeUpdate, array $originalConfig, array $newConfig): void 43 | { 44 | $packageDir = $this->composer->getInstallationManager()->getInstallPath($recipeUpdate->getNewRecipe()->getPackage()); 45 | foreach ($originalConfig as $source => $target) { 46 | if (isset($newConfig[$source])) { 47 | // path is in both, we cannot update 48 | $recipeUpdate->addCopyFromPackagePath( 49 | $packageDir.'/'.$source, 50 | $this->options->expandTargetDir($target) 51 | ); 52 | 53 | unset($newConfig[$source]); 54 | } 55 | 56 | // if any paths were removed from the recipe, we'll keep them 57 | } 58 | 59 | // any remaining files are new, and we can copy them 60 | foreach ($this->getFilesToCopy($newConfig, $packageDir) as $source => $target) { 61 | if (!file_exists($source)) { 62 | throw new \LogicException(\sprintf('File "%s" does not exist!', $source)); 63 | } 64 | 65 | $recipeUpdate->setNewFile($target, file_get_contents($source)); 66 | } 67 | } 68 | 69 | private function getFilesToCopy(array $manifest, string $from): array 70 | { 71 | $files = []; 72 | foreach ($manifest as $source => $target) { 73 | $target = $this->options->expandTargetDir($target); 74 | if ('/' === substr($source, -1)) { 75 | $files = array_merge($files, $this->getFilesForDir($this->path->concatenate([$from, $source]), $target)); 76 | 77 | continue; 78 | } 79 | 80 | $files[$this->path->concatenate([$from, $source])] = $target; 81 | } 82 | 83 | return $files; 84 | } 85 | 86 | private function removeFiles(array $manifest, string $from, string $to) 87 | { 88 | foreach ($manifest as $source => $target) { 89 | $target = $this->options->expandTargetDir($target); 90 | if ('/' === substr($source, -1)) { 91 | $this->removeFilesFromDir($this->path->concatenate([$from, $source]), $this->path->concatenate([$to, $target])); 92 | } else { 93 | $targetPath = $this->path->concatenate([$to, $target]); 94 | if (file_exists($targetPath)) { 95 | @unlink($targetPath); 96 | $this->write(\sprintf(' Removed "%s"', $this->path->relativize($targetPath))); 97 | } 98 | } 99 | } 100 | } 101 | 102 | private function getFilesForDir(string $source, string $target): array 103 | { 104 | $iterator = $this->createSourceIterator($source, \RecursiveIteratorIterator::SELF_FIRST); 105 | $files = []; 106 | foreach ($iterator as $item) { 107 | $targetPath = $this->path->concatenate([$target, $iterator->getSubPathName()]); 108 | 109 | $files[(string) $item] = $targetPath; 110 | } 111 | 112 | return $files; 113 | } 114 | 115 | /** 116 | * @param string $source The absolute path to the source file 117 | * @param string $target The relative (to root dir) path to the target 118 | */ 119 | public function copyFile(string $source, string $target, array $options) 120 | { 121 | $target = $this->options->get('root-dir').'/'.$this->options->expandTargetDir($target); 122 | if (is_dir($source)) { 123 | // directory will be created when a file is copied to it 124 | return; 125 | } 126 | 127 | if (!$this->options->shouldWriteFile($target, $options['force'] ?? false, $options['assumeYesForPrompts'] ?? false)) { 128 | return; 129 | } 130 | 131 | if (!file_exists($source)) { 132 | throw new \LogicException(\sprintf('File "%s" does not exist!', $source)); 133 | } 134 | 135 | if (!file_exists(\dirname($target))) { 136 | mkdir(\dirname($target), 0777, true); 137 | $this->write(\sprintf(' Created "%s"', $this->path->relativize(\dirname($target)))); 138 | } 139 | 140 | file_put_contents($target, $this->options->expandTargetDir(file_get_contents($source))); 141 | @chmod($target, fileperms($target) | (fileperms($source) & 0111)); 142 | $this->write(\sprintf(' Created "%s"', $this->path->relativize($target))); 143 | } 144 | 145 | private function removeFilesFromDir(string $source, string $target) 146 | { 147 | if (!is_dir($source)) { 148 | return; 149 | } 150 | $iterator = $this->createSourceIterator($source, \RecursiveIteratorIterator::CHILD_FIRST); 151 | foreach ($iterator as $item) { 152 | $targetPath = $this->path->concatenate([$target, $iterator->getSubPathName()]); 153 | if ($item->isDir()) { 154 | // that removes the dir only if it is empty 155 | @rmdir($targetPath); 156 | $this->write(\sprintf(' Removed directory "%s"', $this->path->relativize($targetPath))); 157 | } else { 158 | @unlink($targetPath); 159 | $this->write(\sprintf(' Removed "%s"', $this->path->relativize($targetPath))); 160 | } 161 | } 162 | } 163 | 164 | private function createSourceIterator(string $source, int $mode): \RecursiveIteratorIterator 165 | { 166 | return new \RecursiveIteratorIterator(new \RecursiveDirectoryIterator($source, \RecursiveDirectoryIterator::SKIP_DOTS), $mode); 167 | } 168 | } 169 | -------------------------------------------------------------------------------- /src/Configurator/CopyFromRecipeConfigurator.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Symfony\Flex\Configurator; 13 | 14 | use Symfony\Flex\Lock; 15 | use Symfony\Flex\Recipe; 16 | use Symfony\Flex\Update\RecipeUpdate; 17 | 18 | /** 19 | * @author Fabien Potencier 20 | */ 21 | class CopyFromRecipeConfigurator extends AbstractConfigurator 22 | { 23 | public function configure(Recipe $recipe, $config, Lock $lock, array $options = []) 24 | { 25 | $this->write('Copying files from recipe'); 26 | $options = array_merge($this->options->toArray(), $options); 27 | 28 | $lock->add($recipe->getName(), ['files' => $this->copyFiles($config, $recipe->getFiles(), $options)]); 29 | } 30 | 31 | public function unconfigure(Recipe $recipe, $config, Lock $lock) 32 | { 33 | $this->write('Removing files from recipe'); 34 | $rootDir = $this->options->get('root-dir'); 35 | 36 | foreach ($this->options->getRemovableFiles($recipe, $lock) as $file) { 37 | if ('.git' !== $file) { // never remove the main Git directory, even if it was created by a recipe 38 | $this->removeFile($this->path->concatenate([$rootDir, $file])); 39 | } 40 | } 41 | } 42 | 43 | public function update(RecipeUpdate $recipeUpdate, array $originalConfig, array $newConfig): void 44 | { 45 | foreach ($recipeUpdate->getOriginalRecipe()->getFiles() as $filename => $data) { 46 | $filename = $this->resolveTargetFolder($filename, $originalConfig); 47 | $recipeUpdate->setOriginalFile($filename, $data['contents']); 48 | } 49 | 50 | $files = []; 51 | foreach ($recipeUpdate->getNewRecipe()->getFiles() as $filename => $data) { 52 | $filename = $this->resolveTargetFolder($filename, $newConfig); 53 | $recipeUpdate->setNewFile($filename, $data['contents']); 54 | 55 | $files[] = $this->getLocalFilePath($recipeUpdate->getRootDir(), $filename); 56 | } 57 | 58 | $recipeUpdate->getLock()->add($recipeUpdate->getPackageName(), ['files' => $files]); 59 | } 60 | 61 | /** 62 | * @param array $config 63 | */ 64 | private function resolveTargetFolder(string $path, array $config): string 65 | { 66 | foreach ($config as $key => $target) { 67 | if (str_starts_with($path, $key)) { 68 | return $this->options->expandTargetDir($target).substr($path, \strlen($key)); 69 | } 70 | } 71 | 72 | return $path; 73 | } 74 | 75 | private function copyFiles(array $manifest, array $files, array $options): array 76 | { 77 | $copiedFiles = []; 78 | $to = $options['root-dir'] ?? '.'; 79 | 80 | foreach ($manifest as $source => $target) { 81 | $target = $this->options->expandTargetDir($target); 82 | if ('/' === substr($source, -1)) { 83 | $copiedFiles = array_merge( 84 | $copiedFiles, 85 | $this->copyDir($source, $this->path->concatenate([$to, $target]), $files, $options) 86 | ); 87 | } else { 88 | $copiedFiles[] = $this->copyFile($this->path->concatenate([$to, $target]), $files[$source]['contents'], $files[$source]['executable'], $options); 89 | } 90 | } 91 | 92 | return $copiedFiles; 93 | } 94 | 95 | private function copyDir(string $source, string $target, array $files, array $options): array 96 | { 97 | $copiedFiles = []; 98 | foreach ($files as $file => $data) { 99 | if (str_starts_with($file, $source)) { 100 | $file = $this->path->concatenate([$target, substr($file, \strlen($source))]); 101 | $copiedFiles[] = $this->copyFile($file, $data['contents'], $data['executable'], $options); 102 | } 103 | } 104 | 105 | return $copiedFiles; 106 | } 107 | 108 | private function copyFile(string $to, string $contents, bool $executable, array $options): string 109 | { 110 | $basePath = $options['root-dir'] ?? '.'; 111 | $copiedFile = $this->getLocalFilePath($basePath, $to); 112 | 113 | if (!$this->options->shouldWriteFile($to, $options['force'] ?? false, $options['assumeYesForPrompts'] ?? false)) { 114 | return $copiedFile; 115 | } 116 | 117 | if (!is_dir(\dirname($to))) { 118 | mkdir(\dirname($to), 0777, true); 119 | } 120 | 121 | file_put_contents($to, $this->options->expandTargetDir($contents)); 122 | if ($executable) { 123 | @chmod($to, fileperms($to) | 0111); 124 | } 125 | 126 | $this->write(\sprintf(' Created "%s"', $this->path->relativize($to))); 127 | 128 | return $copiedFile; 129 | } 130 | 131 | private function removeFile(string $to) 132 | { 133 | if (!file_exists($to)) { 134 | return; 135 | } 136 | 137 | @unlink($to); 138 | $this->write(\sprintf(' Removed "%s"', $this->path->relativize($to))); 139 | 140 | if (0 === \count(glob(\dirname($to).'/*', \GLOB_NOSORT))) { 141 | @rmdir(\dirname($to)); 142 | } 143 | } 144 | 145 | private function getLocalFilePath(string $basePath, $destination): string 146 | { 147 | return str_replace($basePath.\DIRECTORY_SEPARATOR, '', $destination); 148 | } 149 | } 150 | -------------------------------------------------------------------------------- /src/Configurator/DockerComposeConfigurator.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Symfony\Flex\Configurator; 13 | 14 | use Composer\Composer; 15 | use Composer\Factory; 16 | use Composer\IO\IOInterface; 17 | use Composer\Json\JsonFile; 18 | use Composer\Json\JsonManipulator; 19 | use Symfony\Component\Filesystem\Filesystem; 20 | use Symfony\Flex\Lock; 21 | use Symfony\Flex\Options; 22 | use Symfony\Flex\Recipe; 23 | use Symfony\Flex\Update\RecipeUpdate; 24 | 25 | /** 26 | * Adds services and volumes to compose.yaml file. 27 | * 28 | * @author Kévin Dunglas 29 | */ 30 | class DockerComposeConfigurator extends AbstractConfigurator 31 | { 32 | private $filesystem; 33 | 34 | public static $configureDockerRecipes; 35 | 36 | public function __construct(Composer $composer, IOInterface $io, Options $options) 37 | { 38 | parent::__construct($composer, $io, $options); 39 | 40 | $this->filesystem = new Filesystem(); 41 | } 42 | 43 | public function configure(Recipe $recipe, $config, Lock $lock, array $options = []) 44 | { 45 | if (!self::shouldConfigureDockerRecipe($this->composer, $this->io, $recipe)) { 46 | return; 47 | } 48 | 49 | $this->configureDockerCompose($recipe, $config, $options['force'] ?? false); 50 | 51 | $this->write('Docker Compose definitions have been modified. Please run "docker compose up --build" again to apply the changes.'); 52 | } 53 | 54 | public function unconfigure(Recipe $recipe, $config, Lock $lock) 55 | { 56 | $rootDir = $this->options->get('root-dir'); 57 | foreach ($this->normalizeConfig($config) as $file => $extra) { 58 | if (null === $dockerComposeFile = $this->findDockerComposeFile($rootDir, $file)) { 59 | continue; 60 | } 61 | 62 | $name = $recipe->getName(); 63 | // Remove recipe and add break line 64 | $contents = preg_replace(\sprintf('{%s+###> %s ###.*?###< %s ###%s+}s', "\n", $name, $name, "\n"), \PHP_EOL.\PHP_EOL, file_get_contents($dockerComposeFile), -1, $count); 65 | if (!$count) { 66 | return; 67 | } 68 | 69 | foreach ($extra as $key => $value) { 70 | if (0 === preg_match(\sprintf('{^%s:[ \t\r\n]*([ \t]+\w|#)}m', $key), $contents, $matches)) { 71 | $contents = preg_replace(\sprintf('{\n?^%s:[ \t\r\n]*}sm', $key), '', $contents, -1, $count); 72 | } 73 | } 74 | 75 | $this->write(\sprintf('Removing Docker Compose entries from "%s"', $dockerComposeFile)); 76 | file_put_contents($dockerComposeFile, ltrim($contents, "\n")); 77 | } 78 | 79 | $this->write('Docker Compose definitions have been modified. Please run "docker compose up" again to apply the changes.'); 80 | } 81 | 82 | public function update(RecipeUpdate $recipeUpdate, array $originalConfig, array $newConfig): void 83 | { 84 | if (!self::shouldConfigureDockerRecipe($this->composer, $this->io, $recipeUpdate->getNewRecipe())) { 85 | return; 86 | } 87 | 88 | $recipeUpdate->addOriginalFiles( 89 | $this->getContentsAfterApplyingRecipe($recipeUpdate->getRootDir(), $recipeUpdate->getOriginalRecipe(), $originalConfig) 90 | ); 91 | 92 | $recipeUpdate->addNewFiles( 93 | $this->getContentsAfterApplyingRecipe($recipeUpdate->getRootDir(), $recipeUpdate->getNewRecipe(), $newConfig) 94 | ); 95 | } 96 | 97 | public static function shouldConfigureDockerRecipe(Composer $composer, IOInterface $io, Recipe $recipe): bool 98 | { 99 | if (null !== self::$configureDockerRecipes) { 100 | return self::$configureDockerRecipes; 101 | } 102 | 103 | if (null !== $dockerPreference = $composer->getPackage()->getExtra()['symfony']['docker'] ?? null) { 104 | self::$configureDockerRecipes = $dockerPreference; 105 | 106 | return self::$configureDockerRecipes; 107 | } 108 | 109 | if ('install' !== $recipe->getJob()) { 110 | // default to not configuring 111 | return false; 112 | } 113 | 114 | if (!isset($_SERVER['SYMFONY_DOCKER'])) { 115 | $answer = self::askDockerSupport($io, $recipe); 116 | } elseif (filter_var($_SERVER['SYMFONY_DOCKER'], \FILTER_VALIDATE_BOOLEAN)) { 117 | $answer = 'p'; 118 | } else { 119 | $answer = 'x'; 120 | } 121 | 122 | if ('n' === $answer) { 123 | self::$configureDockerRecipes = false; 124 | 125 | return self::$configureDockerRecipes; 126 | } 127 | if ('y' === $answer) { 128 | self::$configureDockerRecipes = true; 129 | 130 | return self::$configureDockerRecipes; 131 | } 132 | 133 | // yes or no permanently 134 | self::$configureDockerRecipes = 'p' === $answer; 135 | $json = new JsonFile(Factory::getComposerFile()); 136 | $manipulator = new JsonManipulator(file_get_contents($json->getPath())); 137 | $manipulator->addSubNode('extra', 'symfony.docker', self::$configureDockerRecipes); 138 | file_put_contents($json->getPath(), $manipulator->getContents()); 139 | 140 | return self::$configureDockerRecipes; 141 | } 142 | 143 | /** 144 | * Normalizes the config and return the name of the main Docker Compose file if applicable. 145 | */ 146 | private function normalizeConfig(array $config): array 147 | { 148 | foreach ($config as $key => $val) { 149 | // Support for the short recipe syntax that modifies compose.yaml only 150 | if (isset($val[0])) { 151 | return ['compose.yaml' => $config]; 152 | } 153 | 154 | if (!str_starts_with($key, 'docker-')) { 155 | continue; 156 | } 157 | 158 | // If the recipe still use the legacy "docker-compose.yml" names, remove the "docker-" prefix and change the extension 159 | $newKey = pathinfo(substr($key, 7), \PATHINFO_FILENAME).'.yaml'; 160 | $config[$newKey] = $val; 161 | unset($config[$key]); 162 | } 163 | 164 | return $config; 165 | } 166 | 167 | /** 168 | * Finds the Docker Compose file according to these rules: https://docs.docker.com/compose/reference/envvars/#compose_file. 169 | */ 170 | private function findDockerComposeFile(string $rootDir, string $file): ?string 171 | { 172 | if (isset($_SERVER['COMPOSE_FILE'])) { 173 | $filenameToFind = pathinfo($file, \PATHINFO_FILENAME); 174 | $separator = $_SERVER['COMPOSE_PATH_SEPARATOR'] ?? ('\\' === \DIRECTORY_SEPARATOR ? ';' : ':'); 175 | 176 | $files = explode($separator, $_SERVER['COMPOSE_FILE']); 177 | foreach ($files as $f) { 178 | $filename = pathinfo($f, \PATHINFO_FILENAME); 179 | if ($filename !== $filenameToFind && "docker-$filenameToFind" !== $filename) { 180 | continue; 181 | } 182 | 183 | if (!$this->filesystem->isAbsolutePath($f)) { 184 | $f = realpath(\sprintf('%s/%s', $rootDir, $f)); 185 | } 186 | 187 | if ($this->filesystem->exists($f)) { 188 | return $f; 189 | } 190 | } 191 | } 192 | 193 | // COMPOSE_FILE not set, or doesn't contain the file we're looking for 194 | $dir = $rootDir; 195 | do { 196 | if ( 197 | $this->filesystem->exists($dockerComposeFile = \sprintf('%s/%s', $dir, $file)) 198 | // Test with the ".yml" extension if the file doesn't end up with ".yaml" 199 | || $this->filesystem->exists($dockerComposeFile = substr($dockerComposeFile, 0, -3).'ml') 200 | // Test with the legacy "docker-" suffix if "compose.ya?ml" doesn't exist 201 | || $this->filesystem->exists($dockerComposeFile = \sprintf('%s/docker-%s', $dir, $file)) 202 | || $this->filesystem->exists($dockerComposeFile = substr($dockerComposeFile, 0, -3).'ml') 203 | ) { 204 | return $dockerComposeFile; 205 | } 206 | 207 | $previousDir = $dir; 208 | $dir = \dirname($dir); 209 | } while ($dir !== $previousDir); 210 | 211 | return null; 212 | } 213 | 214 | private function parse($level, $indent, $services): string 215 | { 216 | $line = ''; 217 | foreach ($services as $key => $value) { 218 | $line .= str_repeat(' ', $indent * $level); 219 | if (!\is_array($value)) { 220 | if (\is_string($key)) { 221 | $line .= \sprintf('%s:', $key); 222 | } 223 | $line .= \sprintf("%s\n", $value); 224 | continue; 225 | } 226 | $line .= \sprintf("%s:\n", $key).$this->parse($level + 1, $indent, $value); 227 | } 228 | 229 | return $line; 230 | } 231 | 232 | private function configureDockerCompose(Recipe $recipe, array $config, bool $update): void 233 | { 234 | $rootDir = $this->options->get('root-dir'); 235 | foreach ($this->normalizeConfig($config) as $file => $extra) { 236 | $dockerComposeFile = $this->findDockerComposeFile($rootDir, $file); 237 | if (null === $dockerComposeFile) { 238 | $dockerComposeFile = $rootDir.'/'.$file; 239 | file_put_contents($dockerComposeFile, ''); 240 | $this->write(\sprintf(' Created "%s"', $file)); 241 | } 242 | 243 | if (!$update && $this->isFileMarked($recipe, $dockerComposeFile)) { 244 | continue; 245 | } 246 | 247 | $this->write(\sprintf('Adding Docker Compose definitions to "%s"', $dockerComposeFile)); 248 | 249 | $offset = 2; 250 | $node = null; 251 | $endAt = []; 252 | $startAt = []; 253 | $lines = []; 254 | $nodesLines = []; 255 | foreach (file($dockerComposeFile) as $i => $line) { 256 | $lines[] = $line; 257 | $ltrimedLine = ltrim($line, ' '); 258 | if (null !== $node) { 259 | $nodesLines[$node][$i] = $line; 260 | } 261 | 262 | // Skip blank lines and comments 263 | if (('' !== $ltrimedLine && str_starts_with($ltrimedLine, '#')) || '' === trim($line)) { 264 | continue; 265 | } 266 | 267 | // Extract Docker Compose keys (usually "services" and "volumes") 268 | if (!preg_match('/^[\'"]?([a-zA-Z0-9]+)[\'"]?:\s*$/', $line, $matches)) { 269 | // Detect indentation to use 270 | $offestLine = \strlen($line) - \strlen($ltrimedLine); 271 | if ($offset > $offestLine && 0 !== $offestLine) { 272 | $offset = $offestLine; 273 | } 274 | continue; 275 | } 276 | 277 | // Keep end in memory (check break line on previous line) 278 | $endAt[$node] = !$i || '' !== trim($lines[$i - 1]) ? $i : $i - 1; 279 | $node = $matches[1]; 280 | if (!isset($nodesLines[$node])) { 281 | $nodesLines[$node] = []; 282 | } 283 | if (!isset($startAt[$node])) { 284 | // the section contents starts at the next line 285 | $startAt[$node] = $i + 1; 286 | } 287 | } 288 | $endAt[$node] = \count($lines) + 1; 289 | 290 | foreach ($extra as $key => $value) { 291 | if (isset($endAt[$key])) { 292 | $data = $this->markData($recipe, $this->parse(1, $offset, $value)); 293 | $updatedContents = $this->updateDataString(implode('', $nodesLines[$key]), $data); 294 | if (null === $updatedContents) { 295 | // not an update: just add to section 296 | array_splice($lines, $endAt[$key], 0, $data); 297 | 298 | continue; 299 | } 300 | 301 | $originalEndAt = $endAt[$key]; 302 | $length = $endAt[$key] - $startAt[$key]; 303 | array_splice($lines, $startAt[$key], $length, ltrim($updatedContents, "\n")); 304 | 305 | // reset any start/end positions after this to the new positions 306 | foreach ($startAt as $sectionKey => $at) { 307 | if ($at > $originalEndAt) { 308 | $startAt[$sectionKey] = $at - $length - 1; 309 | } 310 | } 311 | foreach ($endAt as $sectionKey => $at) { 312 | if ($at > $originalEndAt) { 313 | $endAt[$sectionKey] = $at - $length; 314 | } 315 | } 316 | 317 | continue; 318 | } 319 | 320 | $lines[] = \sprintf("\n%s:", $key); 321 | $lines[] = $this->markData($recipe, $this->parse(1, $offset, $value)); 322 | } 323 | 324 | file_put_contents($dockerComposeFile, implode('', $lines)); 325 | } 326 | } 327 | 328 | private function getContentsAfterApplyingRecipe(string $rootDir, Recipe $recipe, array $config): array 329 | { 330 | if (0 === \count($config)) { 331 | return []; 332 | } 333 | 334 | $files = array_filter(array_map(function ($file) use ($rootDir) { 335 | return $this->findDockerComposeFile($rootDir, $file); 336 | }, array_keys($config))); 337 | 338 | $originalContents = []; 339 | foreach ($files as $file) { 340 | $originalContents[$file] = file_exists($file) ? file_get_contents($file) : null; 341 | } 342 | 343 | $this->configureDockerCompose( 344 | $recipe, 345 | $config, 346 | true 347 | ); 348 | 349 | $updatedContents = []; 350 | foreach ($files as $file) { 351 | $localPath = $file; 352 | if (str_starts_with($file, $rootDir)) { 353 | $localPath = substr($file, \strlen($rootDir) + 1); 354 | } 355 | $localPath = ltrim($localPath, '/\\'); 356 | $updatedContents[$localPath] = file_exists($file) ? file_get_contents($file) : null; 357 | } 358 | 359 | foreach ($originalContents as $file => $contents) { 360 | if (null === $contents) { 361 | if (file_exists($file)) { 362 | unlink($file); 363 | } 364 | } else { 365 | file_put_contents($file, $contents); 366 | } 367 | } 368 | 369 | return $updatedContents; 370 | } 371 | 372 | private static function askDockerSupport(IOInterface $io, Recipe $recipe): string 373 | { 374 | $warning = $io->isInteractive() ? 'WARNING' : 'IGNORING'; 375 | $io->writeError(\sprintf(' - %s %s', $warning, $recipe->getFormattedOrigin())); 376 | $question = ' The recipe for this package contains some Docker configuration. 377 | 378 | This may create/update compose.yaml or update Dockerfile (if it exists). 379 | 380 | Do you want to include Docker configuration from recipes? 381 | [y] Yes 382 | [n] No 383 | [p] Yes permanently, never ask again for this project 384 | [x] No permanently, never ask again for this project 385 | (defaults to y): '; 386 | 387 | return $io->askAndValidate( 388 | $question, 389 | function ($value) { 390 | if (null === $value) { 391 | return 'y'; 392 | } 393 | $value = strtolower($value[0]); 394 | if (!\in_array($value, ['y', 'n', 'p', 'x'], true)) { 395 | throw new \InvalidArgumentException('Invalid choice.'); 396 | } 397 | 398 | return $value; 399 | }, 400 | null, 401 | 'y' 402 | ); 403 | } 404 | } 405 | -------------------------------------------------------------------------------- /src/Configurator/DockerfileConfigurator.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Symfony\Flex\Configurator; 13 | 14 | use Symfony\Flex\Lock; 15 | use Symfony\Flex\Recipe; 16 | use Symfony\Flex\Update\RecipeUpdate; 17 | 18 | /** 19 | * Adds commands to a Dockerfile. 20 | * 21 | * @author Kévin Dunglas 22 | */ 23 | class DockerfileConfigurator extends AbstractConfigurator 24 | { 25 | public function configure(Recipe $recipe, $config, Lock $lock, array $options = []) 26 | { 27 | if (!DockerComposeConfigurator::shouldConfigureDockerRecipe($this->composer, $this->io, $recipe)) { 28 | return; 29 | } 30 | 31 | $this->configureDockerfile($recipe, $config, $options['force'] ?? false); 32 | } 33 | 34 | public function unconfigure(Recipe $recipe, $config, Lock $lock) 35 | { 36 | if (!file_exists($dockerfile = $this->options->get('root-dir').'/Dockerfile')) { 37 | return; 38 | } 39 | 40 | $name = $recipe->getName(); 41 | $contents = preg_replace(\sprintf('{%s+###> %s ###.*?###< %s ###%s+}s', "\n", $name, $name, "\n"), "\n", file_get_contents($dockerfile), -1, $count); 42 | if (!$count) { 43 | return; 44 | } 45 | 46 | $this->write('Removing Dockerfile entries'); 47 | file_put_contents($dockerfile, ltrim($contents, "\n")); 48 | } 49 | 50 | public function update(RecipeUpdate $recipeUpdate, array $originalConfig, array $newConfig): void 51 | { 52 | if (!DockerComposeConfigurator::shouldConfigureDockerRecipe($this->composer, $this->io, $recipeUpdate->getNewRecipe())) { 53 | return; 54 | } 55 | 56 | $recipeUpdate->setOriginalFile( 57 | 'Dockerfile', 58 | $this->getContentsAfterApplyingRecipe($recipeUpdate->getOriginalRecipe(), $originalConfig) 59 | ); 60 | 61 | $recipeUpdate->setNewFile( 62 | 'Dockerfile', 63 | $this->getContentsAfterApplyingRecipe($recipeUpdate->getNewRecipe(), $newConfig) 64 | ); 65 | } 66 | 67 | private function configureDockerfile(Recipe $recipe, array $config, bool $update, bool $writeOutput = true): void 68 | { 69 | $dockerfile = $this->options->get('root-dir').'/Dockerfile'; 70 | if (!file_exists($dockerfile) || (!$update && $this->isFileMarked($recipe, $dockerfile))) { 71 | return; 72 | } 73 | 74 | if ($writeOutput) { 75 | $this->write('Adding Dockerfile entries'); 76 | } 77 | 78 | $data = ltrim($this->markData($recipe, implode("\n", $config)), "\n"); 79 | if ($this->updateData($dockerfile, $data)) { 80 | // done! Existing spot updated 81 | return; 82 | } 83 | 84 | $lines = []; 85 | foreach (file($dockerfile) as $line) { 86 | $lines[] = $line; 87 | if (!preg_match('/^###> recipes ###$/', $line)) { 88 | continue; 89 | } 90 | 91 | $lines[] = $data; 92 | } 93 | 94 | file_put_contents($dockerfile, implode('', $lines)); 95 | } 96 | 97 | private function getContentsAfterApplyingRecipe(Recipe $recipe, array $config): ?string 98 | { 99 | if (0 === \count($config)) { 100 | return null; 101 | } 102 | 103 | $dockerfile = $this->options->get('root-dir').'/Dockerfile'; 104 | $originalContents = file_exists($dockerfile) ? file_get_contents($dockerfile) : null; 105 | 106 | $this->configureDockerfile( 107 | $recipe, 108 | $config, 109 | true, 110 | false 111 | ); 112 | 113 | $updatedContents = file_exists($dockerfile) ? file_get_contents($dockerfile) : null; 114 | 115 | if (null === $originalContents) { 116 | if (file_exists($dockerfile)) { 117 | unlink($dockerfile); 118 | } 119 | } else { 120 | file_put_contents($dockerfile, $originalContents); 121 | } 122 | 123 | return $updatedContents; 124 | } 125 | } 126 | -------------------------------------------------------------------------------- /src/Configurator/DotenvConfigurator.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Symfony\Flex\Configurator; 13 | 14 | use Symfony\Flex\Lock; 15 | use Symfony\Flex\Recipe; 16 | use Symfony\Flex\Update\RecipeUpdate; 17 | 18 | class DotenvConfigurator extends AbstractConfigurator 19 | { 20 | public function configure(Recipe $recipe, $vars, Lock $lock, array $options = []) 21 | { 22 | foreach ($vars as $suffix => $vars) { 23 | $configurator = new EnvConfigurator($this->composer, $this->io, $this->options, $suffix); 24 | $configurator->configure($recipe, $vars, $lock, $options); 25 | } 26 | } 27 | 28 | public function unconfigure(Recipe $recipe, $vars, Lock $lock) 29 | { 30 | foreach ($vars as $suffix => $vars) { 31 | $configurator = new EnvConfigurator($this->composer, $this->io, $this->options, $suffix); 32 | $configurator->unconfigure($recipe, $vars, $lock); 33 | } 34 | } 35 | 36 | public function update(RecipeUpdate $recipeUpdate, array $originalConfig, array $newConfig): void 37 | { 38 | foreach ($originalConfig as $suffix => $vars) { 39 | $configurator = new EnvConfigurator($this->composer, $this->io, $this->options, $suffix); 40 | $configurator->update($recipeUpdate, $vars, $newConfig[$suffix] ?? []); 41 | } 42 | 43 | foreach ($newConfig as $suffix => $vars) { 44 | if (!isset($originalConfig[$suffix])) { 45 | $configurator = new EnvConfigurator($this->composer, $this->io, $this->options, $suffix); 46 | $configurator->update($recipeUpdate, [], $vars); 47 | } 48 | } 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /src/Configurator/EnvConfigurator.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Symfony\Flex\Configurator; 13 | 14 | use Composer\Composer; 15 | use Composer\IO\IOInterface; 16 | use Symfony\Flex\Lock; 17 | use Symfony\Flex\Options; 18 | use Symfony\Flex\Recipe; 19 | use Symfony\Flex\Update\RecipeUpdate; 20 | 21 | /** 22 | * @author Fabien Potencier 23 | */ 24 | class EnvConfigurator extends AbstractConfigurator 25 | { 26 | private string $suffix; 27 | 28 | public function __construct(Composer $composer, IOInterface $io, Options $options, string $suffix = '') 29 | { 30 | parent::__construct($composer, $io, $options); 31 | $this->suffix = $suffix; 32 | } 33 | 34 | public function configure(Recipe $recipe, $vars, Lock $lock, array $options = []) 35 | { 36 | $this->write('Adding environment variable defaults'.('' === $this->suffix ? '' : ' ('.$this->suffix.')')); 37 | 38 | $this->configureEnvDist($recipe, $vars, $options['force'] ?? false); 39 | 40 | if ('' !== $this->suffix) { 41 | return; 42 | } 43 | 44 | if (!file_exists($this->options->get('root-dir').'/'.($this->options->get('runtime')['dotenv_path'] ?? '.env').'.test')) { 45 | $this->configurePhpUnit($recipe, $vars, $options['force'] ?? false); 46 | } 47 | } 48 | 49 | public function unconfigure(Recipe $recipe, $vars, Lock $lock) 50 | { 51 | $this->unconfigureEnvFiles($recipe, $vars); 52 | $this->unconfigurePhpUnit($recipe, $vars); 53 | } 54 | 55 | public function update(RecipeUpdate $recipeUpdate, array $originalConfig, array $newConfig): void 56 | { 57 | $recipeUpdate->addOriginalFiles( 58 | $this->getContentsAfterApplyingRecipe($recipeUpdate->getRootDir(), $recipeUpdate->getOriginalRecipe(), $originalConfig) 59 | ); 60 | 61 | $recipeUpdate->addNewFiles( 62 | $this->getContentsAfterApplyingRecipe($recipeUpdate->getRootDir(), $recipeUpdate->getNewRecipe(), $newConfig) 63 | ); 64 | } 65 | 66 | private function configureEnvDist(Recipe $recipe, $vars, bool $update) 67 | { 68 | $dotenvPath = $this->options->get('runtime')['dotenv_path'] ?? '.env'; 69 | $files = '' === $this->suffix ? [$dotenvPath.'.dist', $dotenvPath] : [$dotenvPath.'.'.$this->suffix]; 70 | 71 | foreach ($files as $file) { 72 | $env = $this->options->get('root-dir').'/'.$file; 73 | if (!is_file($env)) { 74 | continue; 75 | } 76 | 77 | if (!$update && $this->isFileMarked($recipe, $env)) { 78 | continue; 79 | } 80 | 81 | $data = ''; 82 | foreach ($vars as $key => $value) { 83 | $existingValue = $update ? $this->findExistingValue($key, $env, $recipe) : null; 84 | $value = $this->evaluateValue($value, $existingValue); 85 | if ('#' === $key[0] && is_numeric(substr($key, 1))) { 86 | if ('' === $value) { 87 | $data .= "#\n"; 88 | } else { 89 | $data .= '# '.$value."\n"; 90 | } 91 | 92 | continue; 93 | } 94 | 95 | $value = $this->options->expandTargetDir($value); 96 | if (false !== strpbrk($value, " \t\n&!\"")) { 97 | $value = '"'.str_replace(['\\', '"', "\t", "\n"], ['\\\\', '\\"', '\t', '\n'], $value).'"'; 98 | } 99 | $data .= "$key=$value\n"; 100 | } 101 | $data = $this->markData($recipe, $data); 102 | 103 | if (!$this->updateData($env, $data)) { 104 | file_put_contents($env, $data, \FILE_APPEND); 105 | } 106 | } 107 | } 108 | 109 | private function configurePhpUnit(Recipe $recipe, $vars, bool $update) 110 | { 111 | foreach (['phpunit.xml.dist', 'phpunit.dist.xml', 'phpunit.xml'] as $file) { 112 | $phpunit = $this->options->get('root-dir').'/'.$file; 113 | if (!is_file($phpunit)) { 114 | continue; 115 | } 116 | 117 | if (!$update && $this->isFileXmlMarked($recipe, $phpunit)) { 118 | continue; 119 | } 120 | 121 | $data = ''; 122 | foreach ($vars as $key => $value) { 123 | $value = $this->evaluateValue($value); 124 | if ('#' === $key[0]) { 125 | if (is_numeric(substr($key, 1))) { 126 | $doc = new \DOMDocument(); 127 | $data .= ' '.$doc->saveXML($doc->createComment(' '.$value.' '))."\n"; 128 | } else { 129 | $value = $this->options->expandTargetDir($value); 130 | $doc = new \DOMDocument(); 131 | $fragment = $doc->createElement('env'); 132 | $fragment->setAttribute('name', substr($key, 1)); 133 | $fragment->setAttribute('value', $value); 134 | $data .= ' '.str_replace(['<', '/>'], [''], $doc->saveXML($fragment))."\n"; 135 | } 136 | } else { 137 | $value = $this->options->expandTargetDir($value); 138 | $doc = new \DOMDocument(); 139 | $fragment = $doc->createElement('env'); 140 | $fragment->setAttribute('name', $key); 141 | $fragment->setAttribute('value', $value); 142 | $data .= ' '.$doc->saveXML($fragment)."\n"; 143 | } 144 | } 145 | $data = $this->markXmlData($recipe, $data); 146 | 147 | if (!$this->updateData($phpunit, $data)) { 148 | file_put_contents($phpunit, preg_replace('{^(\s+)}m', $data.'$1', file_get_contents($phpunit))); 149 | } 150 | } 151 | } 152 | 153 | private function unconfigureEnvFiles(Recipe $recipe, $vars) 154 | { 155 | $dotenvPath = $this->options->get('runtime')['dotenv_path'] ?? '.env'; 156 | $files = '' === $this->suffix ? [$dotenvPath, $dotenvPath.'.dist'] : [$dotenvPath.'.'.$this->suffix]; 157 | 158 | foreach ($files as $file) { 159 | $env = $this->options->get('root-dir').'/'.$file; 160 | if (!file_exists($env)) { 161 | continue; 162 | } 163 | 164 | $contents = preg_replace(\sprintf('{%s*###> %s ###.*###< %s ###%s+}s', "\n", $recipe->getName(), $recipe->getName(), "\n"), "\n", file_get_contents($env), -1, $count); 165 | if (!$count) { 166 | continue; 167 | } 168 | 169 | $this->write(\sprintf('Removing environment variables from %s', $file)); 170 | file_put_contents($env, $contents); 171 | } 172 | } 173 | 174 | private function unconfigurePhpUnit(Recipe $recipe, $vars) 175 | { 176 | foreach (['phpunit.dist.xml', 'phpunit.xml.dist', 'phpunit.xml'] as $file) { 177 | $phpunit = $this->options->get('root-dir').'/'.$file; 178 | if (!is_file($phpunit)) { 179 | continue; 180 | } 181 | 182 | $contents = preg_replace(\sprintf('{%s*\s+.*%s+}s', "\n", $recipe->getName(), $recipe->getName(), "\n"), "\n", file_get_contents($phpunit), -1, $count); 183 | if (!$count) { 184 | continue; 185 | } 186 | 187 | $this->write(\sprintf('Removing environment variables from %s', $file)); 188 | file_put_contents($phpunit, $contents); 189 | } 190 | } 191 | 192 | /** 193 | * Evaluates expressions like %generate(secret)%. 194 | * 195 | * If $originalValue is passed, and the value contains an expression. 196 | * the $originalValue is used. 197 | */ 198 | private function evaluateValue($value, ?string $originalValue = null) 199 | { 200 | if ('%generate(secret)%' === $value) { 201 | if (null !== $originalValue) { 202 | return $originalValue; 203 | } 204 | 205 | return $this->generateRandomBytes(); 206 | } 207 | if (preg_match('~^%generate\(secret,\s*([0-9]+)\)%$~', $value, $matches)) { 208 | if (null !== $originalValue) { 209 | return $originalValue; 210 | } 211 | 212 | return $this->generateRandomBytes($matches[1]); 213 | } 214 | 215 | return $value; 216 | } 217 | 218 | private function generateRandomBytes($length = 16) 219 | { 220 | return bin2hex(random_bytes($length)); 221 | } 222 | 223 | private function getContentsAfterApplyingRecipe(string $rootDir, Recipe $recipe, array $vars): array 224 | { 225 | $dotenvPath = $this->options->get('runtime')['dotenv_path'] ?? '.env'; 226 | $files = '' === $this->suffix ? [$dotenvPath, $dotenvPath.'.dist', 'phpunit.dist.xml', 'phpunit.xml.dist', 'phpunit.xml'] : [$dotenvPath.'.'.$this->suffix]; 227 | 228 | if (0 === \count($vars)) { 229 | return array_fill_keys($files, null); 230 | } 231 | 232 | $originalContents = []; 233 | foreach ($files as $file) { 234 | $originalContents[$file] = file_exists($rootDir.'/'.$file) ? file_get_contents($rootDir.'/'.$file) : null; 235 | } 236 | 237 | $this->configureEnvDist( 238 | $recipe, 239 | $vars, 240 | true 241 | ); 242 | 243 | if ('' === $this->suffix && !file_exists($rootDir.'/'.$dotenvPath.'.test')) { 244 | $this->configurePhpUnit( 245 | $recipe, 246 | $vars, 247 | true 248 | ); 249 | } 250 | 251 | $updatedContents = []; 252 | foreach ($files as $file) { 253 | $updatedContents[$file] = file_exists($rootDir.'/'.$file) ? file_get_contents($rootDir.'/'.$file) : null; 254 | } 255 | 256 | foreach ($originalContents as $file => $contents) { 257 | if (null === $contents) { 258 | if (file_exists($rootDir.'/'.$file)) { 259 | unlink($rootDir.'/'.$file); 260 | } 261 | } else { 262 | file_put_contents($rootDir.'/'.$file, $contents); 263 | } 264 | } 265 | 266 | return $updatedContents; 267 | } 268 | 269 | /** 270 | * Attempts to find the existing value of an environment variable. 271 | */ 272 | private function findExistingValue(string $var, string $filename, Recipe $recipe): ?string 273 | { 274 | if (!file_exists($filename)) { 275 | return null; 276 | } 277 | 278 | $contents = file_get_contents($filename); 279 | $section = $this->extractSection($recipe, $contents); 280 | if (!$section) { 281 | return null; 282 | } 283 | 284 | $lines = explode("\n", $section); 285 | foreach ($lines as $line) { 286 | if (!str_starts_with($line, \sprintf('%s=', $var))) { 287 | continue; 288 | } 289 | 290 | return trim(substr($line, \strlen($var) + 1)); 291 | } 292 | 293 | return null; 294 | } 295 | } 296 | -------------------------------------------------------------------------------- /src/Configurator/GitignoreConfigurator.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Symfony\Flex\Configurator; 13 | 14 | use Symfony\Flex\Lock; 15 | use Symfony\Flex\Recipe; 16 | use Symfony\Flex\Update\RecipeUpdate; 17 | 18 | /** 19 | * @author Fabien Potencier 20 | */ 21 | class GitignoreConfigurator extends AbstractConfigurator 22 | { 23 | public function configure(Recipe $recipe, $vars, Lock $lock, array $options = []) 24 | { 25 | $this->write('Adding entries to .gitignore'); 26 | 27 | $this->configureGitignore($recipe, $vars, $options['force'] ?? false); 28 | } 29 | 30 | public function unconfigure(Recipe $recipe, $vars, Lock $lock) 31 | { 32 | $file = $this->options->get('root-dir').'/.gitignore'; 33 | if (!file_exists($file)) { 34 | return; 35 | } 36 | 37 | $contents = preg_replace(\sprintf('{%s*###> %s ###.*###< %s ###%s+}s', "\n", $recipe->getName(), $recipe->getName(), "\n"), "\n", file_get_contents($file), -1, $count); 38 | if (!$count) { 39 | return; 40 | } 41 | 42 | $this->write('Removing entries in .gitignore'); 43 | file_put_contents($file, ltrim($contents, "\r\n")); 44 | } 45 | 46 | public function update(RecipeUpdate $recipeUpdate, array $originalConfig, array $newConfig): void 47 | { 48 | $recipeUpdate->setOriginalFile( 49 | '.gitignore', 50 | $this->getContentsAfterApplyingRecipe($recipeUpdate->getRootDir(), $recipeUpdate->getOriginalRecipe(), $originalConfig) 51 | ); 52 | 53 | $recipeUpdate->setNewFile( 54 | '.gitignore', 55 | $this->getContentsAfterApplyingRecipe($recipeUpdate->getRootDir(), $recipeUpdate->getNewRecipe(), $newConfig) 56 | ); 57 | } 58 | 59 | private function configureGitignore(Recipe $recipe, array $vars, bool $update) 60 | { 61 | $gitignore = $this->options->get('root-dir').'/.gitignore'; 62 | if (!$update && $this->isFileMarked($recipe, $gitignore)) { 63 | return; 64 | } 65 | 66 | $data = ''; 67 | foreach ($vars as $value) { 68 | $value = $this->options->expandTargetDir($value); 69 | $data .= "$value\n"; 70 | } 71 | $data = "\n".ltrim($this->markData($recipe, $data), "\r\n"); 72 | 73 | if (!$this->updateData($gitignore, $data)) { 74 | file_put_contents($gitignore, $data, \FILE_APPEND); 75 | } 76 | } 77 | 78 | private function getContentsAfterApplyingRecipe(string $rootDir, Recipe $recipe, $vars): ?string 79 | { 80 | if (0 === \count($vars)) { 81 | return null; 82 | } 83 | 84 | $file = $rootDir.'/.gitignore'; 85 | $originalContents = file_exists($file) ? file_get_contents($file) : null; 86 | 87 | $this->configureGitignore( 88 | $recipe, 89 | $vars, 90 | true 91 | ); 92 | 93 | $updatedContents = file_exists($file) ? file_get_contents($file) : null; 94 | 95 | if (null === $originalContents) { 96 | if (file_exists($file)) { 97 | unlink($file); 98 | } 99 | } else { 100 | file_put_contents($file, $originalContents); 101 | } 102 | 103 | return $updatedContents; 104 | } 105 | } 106 | -------------------------------------------------------------------------------- /src/Configurator/MakefileConfigurator.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Symfony\Flex\Configurator; 13 | 14 | use Symfony\Flex\Lock; 15 | use Symfony\Flex\Recipe; 16 | use Symfony\Flex\Update\RecipeUpdate; 17 | 18 | /** 19 | * @author Fabien Potencier 20 | */ 21 | class MakefileConfigurator extends AbstractConfigurator 22 | { 23 | public function configure(Recipe $recipe, $definitions, Lock $lock, array $options = []) 24 | { 25 | $this->write('Adding Makefile entries'); 26 | 27 | $this->configureMakefile($recipe, $definitions, $options['force'] ?? false); 28 | } 29 | 30 | public function unconfigure(Recipe $recipe, $vars, Lock $lock) 31 | { 32 | if (!file_exists($makefile = $this->options->get('root-dir').'/Makefile')) { 33 | return; 34 | } 35 | 36 | $contents = preg_replace(\sprintf('{%s*###> %s ###.*###< %s ###%s+}s', "\n", $recipe->getName(), $recipe->getName(), "\n"), "\n", file_get_contents($makefile), -1, $count); 37 | if (!$count) { 38 | return; 39 | } 40 | 41 | $this->write(\sprintf('Removing Makefile entries from %s', $makefile)); 42 | if (!trim($contents)) { 43 | @unlink($makefile); 44 | } else { 45 | file_put_contents($makefile, ltrim($contents, "\r\n")); 46 | } 47 | } 48 | 49 | public function update(RecipeUpdate $recipeUpdate, array $originalConfig, array $newConfig): void 50 | { 51 | $recipeUpdate->setOriginalFile( 52 | 'Makefile', 53 | $this->getContentsAfterApplyingRecipe($recipeUpdate->getRootDir(), $recipeUpdate->getOriginalRecipe(), $originalConfig) 54 | ); 55 | 56 | $recipeUpdate->setNewFile( 57 | 'Makefile', 58 | $this->getContentsAfterApplyingRecipe($recipeUpdate->getRootDir(), $recipeUpdate->getNewRecipe(), $newConfig) 59 | ); 60 | } 61 | 62 | private function configureMakefile(Recipe $recipe, array $definitions, bool $update) 63 | { 64 | $makefile = $this->options->get('root-dir').'/Makefile'; 65 | if (!$update && $this->isFileMarked($recipe, $makefile)) { 66 | return; 67 | } 68 | 69 | $data = $this->options->expandTargetDir(implode("\n", $definitions)); 70 | $data = $this->markData($recipe, $data); 71 | $data = "\n".ltrim($data, "\r\n"); 72 | 73 | if (!file_exists($makefile)) { 74 | $envKey = $this->options->get('runtime')['env_var_name'] ?? 'APP_ENV'; 75 | $dotenvPath = $this->options->get('runtime')['dotenv_path'] ?? '.env'; 76 | file_put_contents( 77 | $this->options->get('root-dir').'/Makefile', 78 | <<updateData($makefile, $data)) { 93 | file_put_contents($makefile, $data, \FILE_APPEND); 94 | } 95 | } 96 | 97 | private function getContentsAfterApplyingRecipe(string $rootDir, Recipe $recipe, array $definitions): ?string 98 | { 99 | if (0 === \count($definitions)) { 100 | return null; 101 | } 102 | 103 | $file = $rootDir.'/Makefile'; 104 | $originalContents = file_exists($file) ? file_get_contents($file) : null; 105 | 106 | $this->configureMakefile( 107 | $recipe, 108 | $definitions, 109 | true 110 | ); 111 | 112 | $updatedContents = file_exists($file) ? file_get_contents($file) : null; 113 | 114 | if (null === $originalContents) { 115 | if (file_exists($file)) { 116 | unlink($file); 117 | } 118 | } else { 119 | file_put_contents($file, $originalContents); 120 | } 121 | 122 | return $updatedContents; 123 | } 124 | } 125 | -------------------------------------------------------------------------------- /src/Event/UpdateEvent.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Symfony\Flex\Event; 13 | 14 | use Composer\Script\Event; 15 | use Composer\Script\ScriptEvents; 16 | 17 | class UpdateEvent extends Event 18 | { 19 | private $force; 20 | private $reset; 21 | private $assumeYesForPrompts; 22 | 23 | public function __construct(bool $force, bool $reset, bool $assumeYesForPrompts) 24 | { 25 | $this->name = ScriptEvents::POST_UPDATE_CMD; 26 | $this->force = $force; 27 | $this->reset = $reset; 28 | $this->assumeYesForPrompts = $assumeYesForPrompts; 29 | } 30 | 31 | public function force(): bool 32 | { 33 | return $this->force; 34 | } 35 | 36 | public function reset(): bool 37 | { 38 | return $this->reset; 39 | } 40 | 41 | public function assumeYesForPrompts(): bool 42 | { 43 | return $this->assumeYesForPrompts; 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /src/GithubApi.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Symfony\Flex; 13 | 14 | use Composer\Util\HttpDownloader; 15 | use Composer\Util\RemoteFilesystem; 16 | 17 | class GithubApi 18 | { 19 | /** @var HttpDownloader|RemoteFilesystem */ 20 | private $downloader; 21 | 22 | public function __construct($downloader) 23 | { 24 | $this->downloader = $downloader; 25 | } 26 | 27 | /** 28 | * Attempts to find data about when the recipe was installed. 29 | * 30 | * Returns an array containing: 31 | * commit: The git sha of the last commit of the recipe 32 | * date: The date of the commit 33 | * new_commits: An array of commit sha's in this recipe's directory+version since the commit 34 | * The key is the sha & the value is the date 35 | */ 36 | public function findRecipeCommitDataFromTreeRef(string $package, string $repo, string $branch, string $version, string $lockRef): ?array 37 | { 38 | $repositoryName = $this->getRepositoryName($repo); 39 | if (!$repositoryName) { 40 | return null; 41 | } 42 | 43 | $recipePath = \sprintf('%s/%s', $package, $version); 44 | $commitsData = $this->requestGitHubApi(\sprintf( 45 | 'https://api.github.com/repos/%s/commits?path=%s&sha=%s', 46 | $repositoryName, 47 | $recipePath, 48 | $branch 49 | )); 50 | 51 | $commitShas = []; 52 | foreach ($commitsData as $commitData) { 53 | $commitShas[$commitData['sha']] = $commitData['commit']['committer']['date']; 54 | // go back the commits one-by-one 55 | $treeUrl = $commitData['commit']['tree']['url'].'?recursive=true'; 56 | 57 | // fetch the full tree, then look for the tree for the package path 58 | $treeData = $this->requestGitHubApi($treeUrl); 59 | foreach ($treeData['tree'] as $treeItem) { 60 | if ($treeItem['path'] !== $recipePath) { 61 | continue; 62 | } 63 | 64 | if ($treeItem['sha'] === $lockRef) { 65 | // remove *this* commit from the new commits list 66 | array_pop($commitShas); 67 | 68 | return [ 69 | // shorten for brevity 70 | 'commit' => substr($commitData['sha'], 0, 7), 71 | 'date' => $commitData['commit']['committer']['date'], 72 | 'new_commits' => $commitShas, 73 | ]; 74 | } 75 | } 76 | } 77 | 78 | return null; 79 | } 80 | 81 | public function getVersionsOfRecipe(string $repo, string $branch, string $recipePath): ?array 82 | { 83 | $repositoryName = $this->getRepositoryName($repo); 84 | if (!$repositoryName) { 85 | return null; 86 | } 87 | 88 | $url = \sprintf( 89 | 'https://api.github.com/repos/%s/contents/%s?ref=%s', 90 | $repositoryName, 91 | $recipePath, 92 | $branch 93 | ); 94 | $contents = $this->requestGitHubApi($url); 95 | $versions = []; 96 | foreach ($contents as $fileData) { 97 | if ('dir' !== $fileData['type']) { 98 | continue; 99 | } 100 | 101 | $versions[] = $fileData['name']; 102 | } 103 | 104 | return $versions; 105 | } 106 | 107 | public function getCommitDataForPath(string $repo, string $path, string $branch): array 108 | { 109 | $repositoryName = $this->getRepositoryName($repo); 110 | if (!$repositoryName) { 111 | return []; 112 | } 113 | 114 | $commitsData = $this->requestGitHubApi(\sprintf( 115 | 'https://api.github.com/repos/%s/commits?path=%s&sha=%s', 116 | $repositoryName, 117 | $path, 118 | $branch 119 | )); 120 | 121 | $data = []; 122 | foreach ($commitsData as $commitData) { 123 | $data[$commitData['sha']] = $commitData['commit']['committer']['date']; 124 | } 125 | 126 | return $data; 127 | } 128 | 129 | public function getPullRequestForCommit(string $commit, string $repo): ?array 130 | { 131 | $data = $this->requestGitHubApi('https://api.github.com/search/issues?q='.$commit.'+is:pull-request'); 132 | 133 | if (0 === \count($data['items'])) { 134 | return null; 135 | } 136 | 137 | $repositoryName = $this->getRepositoryName($repo); 138 | if (!$repositoryName) { 139 | return null; 140 | } 141 | 142 | $bestItem = null; 143 | foreach ($data['items'] as $item) { 144 | // make sure the PR referenced isn't from a different repository 145 | if (!str_contains($item['html_url'], \sprintf('%s/pull', $repositoryName))) { 146 | continue; 147 | } 148 | 149 | if (null === $bestItem) { 150 | $bestItem = $item; 151 | 152 | continue; 153 | } 154 | 155 | // find the first PR to reference - avoids rare cases where an invalid 156 | // PR that references *many* commits is first 157 | // e.g. https://api.github.com/search/issues?q=a1a70353f64f405cfbacfc4ce860af623442d6e5 158 | if ($item['number'] < $bestItem['number']) { 159 | $bestItem = $item; 160 | } 161 | } 162 | 163 | if (!$bestItem) { 164 | return null; 165 | } 166 | 167 | return [ 168 | 'number' => $bestItem['number'], 169 | 'url' => $bestItem['html_url'], 170 | 'title' => $bestItem['title'], 171 | ]; 172 | } 173 | 174 | private function requestGitHubApi(string $path) 175 | { 176 | $contents = $this->downloader->get($path)->getBody(); 177 | 178 | return json_decode($contents, true); 179 | } 180 | 181 | /** 182 | * Converts the "repo" stored in symfony.lock to a repository name. 183 | * 184 | * For example: "github.com/symfony/recipes" => "symfony/recipes" 185 | */ 186 | private function getRepositoryName(string $repo): ?string 187 | { 188 | // only supports public repository placement 189 | if (!str_starts_with($repo, 'github.com')) { 190 | return null; 191 | } 192 | 193 | $parts = explode('/', $repo); 194 | if (3 !== \count($parts)) { 195 | return null; 196 | } 197 | 198 | return implode('/', [$parts[1], $parts[2]]); 199 | } 200 | } 201 | -------------------------------------------------------------------------------- /src/InformationOperation.php: -------------------------------------------------------------------------------- 1 | 10 | */ 11 | class InformationOperation implements OperationInterface 12 | { 13 | private $package; 14 | private $recipeRef; 15 | private $version; 16 | 17 | public function __construct(PackageInterface $package) 18 | { 19 | $this->package = $package; 20 | } 21 | 22 | /** 23 | * Call to get information about a specific version of a recipe. 24 | * 25 | * Both $recipeRef and $version would normally come from the symfony.lock file. 26 | */ 27 | public function setSpecificRecipeVersion(string $recipeRef, string $version) 28 | { 29 | $this->recipeRef = $recipeRef; 30 | $this->version = $version; 31 | } 32 | 33 | /** 34 | * Returns package instance. 35 | * 36 | * @return PackageInterface 37 | */ 38 | public function getPackage() 39 | { 40 | return $this->package; 41 | } 42 | 43 | public function getRecipeRef(): ?string 44 | { 45 | return $this->recipeRef; 46 | } 47 | 48 | public function getVersion(): ?string 49 | { 50 | return $this->version; 51 | } 52 | 53 | public function getJobType() 54 | { 55 | return 'information'; 56 | } 57 | 58 | /** 59 | * @return string 60 | */ 61 | public function getOperationType() 62 | { 63 | return 'information'; 64 | } 65 | 66 | /** 67 | * @return string 68 | */ 69 | public function show($lock) 70 | { 71 | $pretty = method_exists($this->package, 'getFullPrettyVersion') ? $this->package->getFullPrettyVersion() : $this->formatVersion($this->package); 72 | 73 | return 'Information '.$this->package->getPrettyName().' ('.$pretty.')'; 74 | } 75 | 76 | public function __toString() 77 | { 78 | return $this->show(false); 79 | } 80 | 81 | /** 82 | * Compatibility for Composer 1.x, not needed in Composer 2. 83 | */ 84 | public function getReason() 85 | { 86 | return null; 87 | } 88 | } 89 | -------------------------------------------------------------------------------- /src/Lock.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Symfony\Flex; 13 | 14 | use Composer\Json\JsonFile; 15 | 16 | /** 17 | * @author Fabien Potencier 18 | */ 19 | class Lock 20 | { 21 | private $json; 22 | private $lock = []; 23 | private $changed = false; 24 | 25 | public function __construct($lockFile) 26 | { 27 | $this->json = new JsonFile($lockFile); 28 | if ($this->json->exists()) { 29 | $this->lock = $this->json->read(); 30 | } 31 | } 32 | 33 | public function has($name): bool 34 | { 35 | return \array_key_exists($name, $this->lock); 36 | } 37 | 38 | public function add($name, $data) 39 | { 40 | $current = $this->lock[$name] ?? []; 41 | $this->lock[$name] = array_merge($current, $data); 42 | $this->changed = true; 43 | } 44 | 45 | public function get($name) 46 | { 47 | return $this->lock[$name] ?? null; 48 | } 49 | 50 | public function set($name, $data) 51 | { 52 | if (!\array_key_exists($name, $this->lock) || $data !== $this->lock[$name]) { 53 | $this->lock[$name] = $data; 54 | $this->changed = true; 55 | } 56 | } 57 | 58 | public function remove($name) 59 | { 60 | if (\array_key_exists($name, $this->lock)) { 61 | unset($this->lock[$name]); 62 | $this->changed = true; 63 | } 64 | } 65 | 66 | public function write() 67 | { 68 | if (!$this->changed) { 69 | return; 70 | } 71 | 72 | if ($this->lock) { 73 | ksort($this->lock); 74 | $this->json->write($this->lock); 75 | } elseif ($this->json->exists()) { 76 | @unlink($this->json->getPath()); 77 | } 78 | } 79 | 80 | public function delete() 81 | { 82 | @unlink($this->json->getPath()); 83 | } 84 | 85 | public function all(): array 86 | { 87 | return $this->lock; 88 | } 89 | } 90 | -------------------------------------------------------------------------------- /src/Options.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Symfony\Flex; 13 | 14 | use Composer\IO\IOInterface; 15 | use Composer\Util\ProcessExecutor; 16 | 17 | /** 18 | * @author Fabien Potencier 19 | */ 20 | class Options 21 | { 22 | private $options; 23 | private $writtenFiles = []; 24 | private $io; 25 | private $lockData; 26 | 27 | public function __construct(array $options = [], ?IOInterface $io = null, ?Lock $lock = null) 28 | { 29 | $this->options = $options; 30 | $this->io = $io; 31 | $this->lockData = $lock?->all() ?? []; 32 | } 33 | 34 | public function get(string $name) 35 | { 36 | return $this->options[$name] ?? null; 37 | } 38 | 39 | public function expandTargetDir(string $target): string 40 | { 41 | $result = preg_replace_callback('{%(.+?)%}', function ($matches) { 42 | $option = str_replace('_', '-', strtolower($matches[1])); 43 | if (!isset($this->options[$option])) { 44 | return $matches[0]; 45 | } 46 | 47 | return rtrim($this->options[$option], '/'); 48 | }, $target); 49 | 50 | $phpunitDistFiles = [ 51 | 'phpunit.xml.dist' => true, 52 | 'phpunit.dist.xml' => true, 53 | ]; 54 | 55 | $rootDir = $this->get('root-dir'); 56 | 57 | if (null === $rootDir || !isset($phpunitDistFiles[$result]) || !is_dir($rootDir) || file_exists($rootDir.'/'.$result)) { 58 | return $result; 59 | } 60 | 61 | unset($phpunitDistFiles[$result]); 62 | $otherPhpunitDistFile = key($phpunitDistFiles); 63 | 64 | return file_exists($rootDir.'/'.$otherPhpunitDistFile) ? $otherPhpunitDistFile : $result; 65 | } 66 | 67 | public function shouldWriteFile(string $file, bool $overwrite, bool $skipQuestion): bool 68 | { 69 | if (isset($this->writtenFiles[$file])) { 70 | return false; 71 | } 72 | $this->writtenFiles[$file] = true; 73 | 74 | if (!file_exists($file)) { 75 | return true; 76 | } 77 | 78 | if (!$overwrite) { 79 | return false; 80 | } 81 | 82 | if (!filesize($file)) { 83 | return true; 84 | } 85 | 86 | if ($skipQuestion) { 87 | return true; 88 | } 89 | 90 | exec('git status --short --ignored --untracked-files=all -- '.ProcessExecutor::escape($file).' 2>&1', $output, $status); 91 | 92 | if (0 !== $status) { 93 | return $this->io && $this->io->askConfirmation(\sprintf('Cannot determine the state of the "%s" file, overwrite anyway? [y/N] ', $file), false); 94 | } 95 | 96 | if (empty($output[0]) || preg_match('/^[ AMDRCU][ D][ \t]/', $output[0])) { 97 | return true; 98 | } 99 | 100 | $name = basename($file); 101 | $name = \strlen($output[0]) - \strlen($name) === strrpos($output[0], $name) ? substr($output[0], 3) : $name; 102 | 103 | return $this->io && $this->io->askConfirmation(\sprintf('File "%s" has uncommitted changes, overwrite? [y/N] ', $name), false); 104 | } 105 | 106 | public function getRemovableFiles(Recipe $recipe, Lock $lock): array 107 | { 108 | if (null === $removableFiles = $this->lockData[$recipe->getName()]['files'] ?? null) { 109 | $removableFiles = []; 110 | foreach (array_keys($recipe->getFiles()) as $source => $target) { 111 | if (str_ends_with($source, '/')) { 112 | $removableFiles[] = $this->expandTargetDir($target); 113 | } 114 | } 115 | } 116 | 117 | unset($this->lockData[$recipe->getName()]); 118 | $lockedFiles = array_count_values(array_merge(...array_column($lock->all(), 'files'))); 119 | 120 | $nonRemovableFiles = []; 121 | foreach ($removableFiles as $i => $file) { 122 | if (isset($lockedFiles[$file])) { 123 | $nonRemovableFiles[] = $file; 124 | unset($removableFiles[$i]); 125 | } 126 | } 127 | 128 | if ($nonRemovableFiles && $this->io) { 129 | $this->io?->writeError(' The following files are still referenced by other recipes, you might need to adjust them manually:'); 130 | foreach ($nonRemovableFiles as $file) { 131 | $this->io?->writeError(' - '.$file); 132 | } 133 | } 134 | 135 | return array_values($removableFiles); 136 | } 137 | 138 | public function toArray(): array 139 | { 140 | return $this->options; 141 | } 142 | } 143 | -------------------------------------------------------------------------------- /src/PackageFilter.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Symfony\Flex; 13 | 14 | use Composer\IO\IOInterface; 15 | use Composer\Package\AliasPackage; 16 | use Composer\Package\PackageInterface; 17 | use Composer\Package\RootPackageInterface; 18 | use Composer\Semver\Constraint\Constraint; 19 | use Composer\Semver\Intervals; 20 | use Composer\Semver\VersionParser; 21 | 22 | /** 23 | * @author Nicolas Grekas 24 | */ 25 | class PackageFilter 26 | { 27 | private $versions; 28 | private $versionParser; 29 | private $symfonyRequire; 30 | private $symfonyConstraints; 31 | private $downloader; 32 | private $io; 33 | 34 | public function __construct(IOInterface $io, string $symfonyRequire, Downloader $downloader) 35 | { 36 | $this->versionParser = new VersionParser(); 37 | $this->symfonyRequire = $symfonyRequire; 38 | $this->symfonyConstraints = $this->versionParser->parseConstraints($symfonyRequire); 39 | $this->downloader = $downloader; 40 | $this->io = $io; 41 | } 42 | 43 | /** 44 | * @param PackageInterface[] $data 45 | * @param PackageInterface[] $lockedPackages 46 | * 47 | * @return PackageInterface[] 48 | */ 49 | public function removeLegacyPackages(array $data, RootPackageInterface $rootPackage, array $lockedPackages): array 50 | { 51 | if (!$this->symfonyConstraints || !$data) { 52 | return $data; 53 | } 54 | 55 | $lockedVersions = []; 56 | foreach ($lockedPackages as $package) { 57 | $lockedVersions[$package->getName()] = [$package->getVersion()]; 58 | if ($package instanceof AliasPackage) { 59 | $lockedVersions[$package->getName()][] = $package->getAliasOf()->getVersion(); 60 | } 61 | } 62 | 63 | $rootConstraints = []; 64 | foreach ($rootPackage->getRequires() + $rootPackage->getDevRequires() as $name => $link) { 65 | $rootConstraints[$name] = $link->getConstraint(); 66 | } 67 | 68 | $knownVersions = null; 69 | $filteredPackages = []; 70 | $symfonyPackages = []; 71 | $oneSymfony = false; 72 | foreach ($data as $package) { 73 | $name = $package->getName(); 74 | $versions = [$package->getVersion()]; 75 | if ($package instanceof AliasPackage) { 76 | $versions[] = $package->getAliasOf()->getVersion(); 77 | } 78 | 79 | if ('symfony/symfony' !== $name && ( 80 | array_intersect($versions, $lockedVersions[$name] ?? []) 81 | || (($knownVersions ??= $this->getVersions()) && !isset($knownVersions['splits'][$name])) 82 | || (isset($rootConstraints[$name]) && !Intervals::haveIntersections($this->symfonyConstraints, $rootConstraints[$name])) 83 | || ('symfony/psr-http-message-bridge' === $name && 6.4 > $versions[0]) 84 | )) { 85 | $filteredPackages[] = $package; 86 | continue; 87 | } 88 | 89 | if (null !== $alias = $package->getExtra()['branch-alias'][$package->getVersion()] ?? null) { 90 | $versions[] = $this->versionParser->normalize($alias); 91 | } 92 | 93 | foreach ($versions as $version) { 94 | if ($this->symfonyConstraints->matches(new Constraint('==', $version))) { 95 | $filteredPackages[] = $package; 96 | $oneSymfony = $oneSymfony || 'symfony/symfony' === $name; 97 | continue 2; 98 | } 99 | } 100 | 101 | if ('symfony/symfony' === $name) { 102 | $symfonyPackages[] = $package; 103 | } elseif (null !== $this->io) { 104 | $this->io->writeError(\sprintf('Restricting packages listed in "symfony/symfony" to "%s"', $this->symfonyRequire)); 105 | $this->io = null; 106 | } 107 | } 108 | 109 | if ($symfonyPackages && !$oneSymfony) { 110 | $filteredPackages = array_merge($filteredPackages, $symfonyPackages); 111 | } 112 | 113 | return $filteredPackages; 114 | } 115 | 116 | private function getVersions(): array 117 | { 118 | if (null !== $this->versions) { 119 | return $this->versions; 120 | } 121 | 122 | $versions = $this->downloader->getVersions(); 123 | $this->downloader = null; 124 | $okVersions = []; 125 | 126 | if (!isset($versions['splits'])) { 127 | throw new \LogicException('The Flex index is missing a "splits" entry. Did you forget to add "flex://defaults" in the "extra.symfony.endpoint" array of your composer.json?'); 128 | } 129 | foreach ($versions['splits'] as $name => $vers) { 130 | foreach ($vers as $i => $v) { 131 | if (!isset($okVersions[$v])) { 132 | $okVersions[$v] = false; 133 | $w = '.x' === substr($v, -2) ? $versions['next'] : $v; 134 | 135 | for ($j = 0; $j < 60; ++$j) { 136 | if ($this->symfonyConstraints->matches(new Constraint('==', $w.'.'.$j.'.0'))) { 137 | $okVersions[$v] = true; 138 | break; 139 | } 140 | } 141 | } 142 | 143 | if (!$okVersions[$v]) { 144 | unset($vers[$i]); 145 | } 146 | } 147 | 148 | if (!$vers || $vers === $versions['splits'][$name]) { 149 | unset($versions['splits'][$name]); 150 | } 151 | } 152 | 153 | return $this->versions = $versions; 154 | } 155 | } 156 | -------------------------------------------------------------------------------- /src/PackageResolver.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Symfony\Flex; 13 | 14 | use Composer\Factory; 15 | use Composer\Package\Version\VersionParser; 16 | use Composer\Repository\PlatformRepository; 17 | use Composer\Semver\Constraint\MatchAllConstraint; 18 | 19 | /** 20 | * @author Fabien Potencier 21 | */ 22 | class PackageResolver 23 | { 24 | private static $SYMFONY_VERSIONS = ['lts', 'previous', 'stable', 'next', 'dev']; 25 | private $downloader; 26 | 27 | public function __construct(Downloader $downloader) 28 | { 29 | $this->downloader = $downloader; 30 | } 31 | 32 | public function resolve(array $arguments = [], bool $isRequire = false): array 33 | { 34 | // first pass split on : and = to resolve package names 35 | $packages = []; 36 | foreach ($arguments as $i => $argument) { 37 | if ((false !== $pos = strpos($argument, ':')) || (false !== $pos = strpos($argument, '='))) { 38 | $package = $this->resolvePackageName(substr($argument, 0, $pos), $i, $isRequire); 39 | $version = substr($argument, $pos + 1); 40 | $packages[] = $package.':'.$version; 41 | } else { 42 | $packages[] = $this->resolvePackageName($argument, $i, $isRequire); 43 | } 44 | } 45 | 46 | // second pass to resolve versions 47 | $versionParser = new VersionParser(); 48 | $requires = []; 49 | $toGuess = []; 50 | foreach ($versionParser->parseNameVersionPairs($packages) as $package) { 51 | $version = $this->parseVersion($package['name'], $package['version'] ?? '', $isRequire); 52 | if ('' !== $version) { 53 | unset($toGuess[$package['name']]); 54 | } elseif (!isset($requires[$package['name']])) { 55 | $toGuess[$package['name']] = new MatchAllConstraint(); 56 | } 57 | $requires[$package['name']] = $package['name'].$version; 58 | } 59 | 60 | if ($toGuess && $isRequire) { 61 | foreach ($this->downloader->getSymfonyPacks($toGuess) as $package) { 62 | $requires[$package] .= ':*'; 63 | } 64 | } 65 | 66 | return array_values($requires); 67 | } 68 | 69 | public function parseVersion(string $package, string $version, bool $isRequire): string 70 | { 71 | $guess = 'guess' === ($version ?: 'guess'); 72 | 73 | if (!str_starts_with($package, 'symfony/')) { 74 | return $guess ? '' : ':'.$version; 75 | } 76 | 77 | $versions = $this->downloader->getVersions(); 78 | 79 | if (!isset($versions['splits'][$package])) { 80 | return $guess ? '' : ':'.$version; 81 | } 82 | 83 | if ($guess || '*' === $version) { 84 | try { 85 | $config = @json_decode(file_get_contents(Factory::getComposerFile()), true); 86 | } finally { 87 | if (!$isRequire || !isset($config['extra']['symfony']['require'])) { 88 | return ''; 89 | } 90 | } 91 | $version = $config['extra']['symfony']['require']; 92 | } elseif ('dev' === $version) { 93 | $version = '^'.$versions['dev-name'].'@dev'; 94 | } elseif ('next' === $version) { 95 | $version = '^'.$versions[$version].'@dev'; 96 | } elseif (\in_array($version, self::$SYMFONY_VERSIONS, true)) { 97 | $version = '^'.$versions[$version]; 98 | } 99 | 100 | return ':'.$version; 101 | } 102 | 103 | private function resolvePackageName(string $argument, int $position, bool $isRequire): string 104 | { 105 | $skippedPackages = ['mirrors', 'nothing', '']; 106 | 107 | if (!$isRequire) { 108 | $skippedPackages[] = 'lock'; 109 | } 110 | 111 | if (str_contains($argument, '/') || preg_match(PlatformRepository::PLATFORM_PACKAGE_REGEX, $argument) || preg_match('{(?<=[a-z0-9_/-])\*|\*(?=[a-z0-9_/-])}i', $argument) || \in_array($argument, $skippedPackages)) { 112 | return $argument; 113 | } 114 | 115 | $aliases = $this->downloader->getAliases(); 116 | 117 | if (isset($aliases[$argument])) { 118 | $argument = $aliases[$argument]; 119 | } else { 120 | // is it a version or an alias that does not exist? 121 | try { 122 | $versionParser = new VersionParser(); 123 | $versionParser->parseConstraints($argument); 124 | } catch (\UnexpectedValueException $e) { 125 | // is it a special Symfony version? 126 | if (!\in_array($argument, self::$SYMFONY_VERSIONS, true)) { 127 | $this->throwAlternatives($argument, $position); 128 | } 129 | } 130 | } 131 | 132 | return $argument; 133 | } 134 | 135 | /** 136 | * @throws \UnexpectedValueException 137 | */ 138 | private function throwAlternatives(string $argument, int $position) 139 | { 140 | $alternatives = []; 141 | foreach ($this->downloader->getAliases() as $alias => $package) { 142 | $lev = levenshtein($argument, $alias); 143 | if ($lev <= \strlen($argument) / 3 || ('' !== $argument && str_contains($alias, $argument))) { 144 | $alternatives[$package][] = $alias; 145 | } 146 | } 147 | 148 | // First position can only be a package name, not a version 149 | if ($alternatives || 0 === $position) { 150 | $message = \sprintf('"%s" is not a valid alias.', $argument); 151 | if ($alternatives) { 152 | if (1 === \count($alternatives)) { 153 | $message .= " Did you mean this:\n"; 154 | } else { 155 | $message .= " Did you mean one of these:\n"; 156 | } 157 | foreach ($alternatives as $package => $aliases) { 158 | $message .= \sprintf(" \"%s\", supported aliases: \"%s\"\n", $package, implode('", "', $aliases)); 159 | } 160 | } 161 | } else { 162 | $message = \sprintf('Could not parse version constraint "%s".', $argument); 163 | } 164 | 165 | throw new \UnexpectedValueException($message); 166 | } 167 | } 168 | -------------------------------------------------------------------------------- /src/Path.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Symfony\Flex; 13 | 14 | /** 15 | * @internal 16 | */ 17 | class Path 18 | { 19 | private $workingDirectory; 20 | 21 | public function __construct($workingDirectory) 22 | { 23 | $this->workingDirectory = $workingDirectory; 24 | } 25 | 26 | public function relativize(string $absolutePath): string 27 | { 28 | $relativePath = str_replace($this->workingDirectory, '.', $absolutePath); 29 | 30 | return is_dir($absolutePath) ? rtrim($relativePath, '/').'/' : $relativePath; 31 | } 32 | 33 | public function concatenate(array $parts): string 34 | { 35 | $first = array_shift($parts); 36 | 37 | return array_reduce($parts, function (string $initial, string $next): string { 38 | return rtrim($initial, '/').'/'.ltrim($next, '/'); 39 | }, $first); 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /src/Recipe.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Symfony\Flex; 13 | 14 | use Composer\Package\PackageInterface; 15 | 16 | /** 17 | * @author Fabien Potencier 18 | */ 19 | class Recipe 20 | { 21 | private $package; 22 | private $name; 23 | private $job; 24 | private $data; 25 | private $lock; 26 | 27 | public function __construct(PackageInterface $package, string $name, string $job, array $data, array $lock = []) 28 | { 29 | $this->package = $package; 30 | $this->name = $name; 31 | $this->job = $job; 32 | $this->data = $data; 33 | $this->lock = $lock; 34 | } 35 | 36 | public function getPackage(): PackageInterface 37 | { 38 | return $this->package; 39 | } 40 | 41 | public function getName(): string 42 | { 43 | return $this->name; 44 | } 45 | 46 | public function getJob(): string 47 | { 48 | return $this->job; 49 | } 50 | 51 | public function getManifest(): array 52 | { 53 | if (!isset($this->data['manifest'])) { 54 | throw new \LogicException(\sprintf('Manifest is not available for recipe "%s".', $this->name)); 55 | } 56 | 57 | return $this->data['manifest']; 58 | } 59 | 60 | public function getFiles(): array 61 | { 62 | return $this->data['files'] ?? []; 63 | } 64 | 65 | public function getOrigin(): string 66 | { 67 | return $this->data['origin'] ?? ''; 68 | } 69 | 70 | public function getFormattedOrigin(): string 71 | { 72 | if (!$this->getOrigin()) { 73 | return ''; 74 | } 75 | 76 | // symfony/translation:3.3@github.com/symfony/recipes:branch 77 | if (!preg_match('/^([^:]++):([^@]++)@(.+)$/', $this->getOrigin(), $matches)) { 78 | return $this->getOrigin(); 79 | } 80 | 81 | return \sprintf('%s (>=%s): From %s', $matches[1], $matches[2], 'auto-generated recipe' === $matches[3] ? ''.$matches[3].'' : $matches[3]); 82 | } 83 | 84 | public function getURL(): string 85 | { 86 | if (!$this->data['origin']) { 87 | return ''; 88 | } 89 | 90 | // symfony/translation:3.3@github.com/symfony/recipes:branch 91 | if (!preg_match('/^([^:]++):([^@]++)@([^:]++):(.+)$/', $this->data['origin'], $matches)) { 92 | // that excludes auto-generated recipes, which is what we want 93 | return ''; 94 | } 95 | 96 | return \sprintf('https://%s/tree/%s/%s/%s', $matches[3], $matches[4], $matches[1], $matches[2]); 97 | } 98 | 99 | public function isContrib(): bool 100 | { 101 | return $this->data['is_contrib'] ?? false; 102 | } 103 | 104 | public function getRef() 105 | { 106 | return $this->lock['recipe']['ref'] ?? null; 107 | } 108 | 109 | public function isAuto(): bool 110 | { 111 | return !isset($this->lock['recipe']); 112 | } 113 | 114 | public function getVersion(): string 115 | { 116 | return $this->lock['recipe']['version'] ?? $this->lock['version']; 117 | } 118 | 119 | public function getLock(): array 120 | { 121 | return $this->lock; 122 | } 123 | } 124 | -------------------------------------------------------------------------------- /src/Response.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Symfony\Flex; 13 | 14 | /** 15 | * @author Fabien Potencier 16 | */ 17 | class Response implements \JsonSerializable 18 | { 19 | private $body; 20 | private $origHeaders; 21 | private $headers; 22 | private $code; 23 | 24 | /** 25 | * @param mixed $body The response as JSON 26 | */ 27 | public function __construct($body, array $headers = [], int $code = 200) 28 | { 29 | $this->body = $body; 30 | $this->origHeaders = $headers; 31 | $this->headers = $this->parseHeaders($headers); 32 | $this->code = $code; 33 | } 34 | 35 | public function getStatusCode(): int 36 | { 37 | return $this->code; 38 | } 39 | 40 | public function getHeader(string $name): string 41 | { 42 | return $this->headers[strtolower($name)][0] ?? ''; 43 | } 44 | 45 | public function getHeaders(string $name): array 46 | { 47 | return $this->headers[strtolower($name)] ?? []; 48 | } 49 | 50 | public function getBody() 51 | { 52 | return $this->body; 53 | } 54 | 55 | public function getOrigHeaders(): array 56 | { 57 | return $this->origHeaders; 58 | } 59 | 60 | public static function fromJson(array $json): self 61 | { 62 | $response = new self($json['body']); 63 | $response->headers = $json['headers']; 64 | 65 | return $response; 66 | } 67 | 68 | #[\ReturnTypeWillChange] 69 | public function jsonSerialize() 70 | { 71 | return ['body' => $this->body, 'headers' => $this->headers]; 72 | } 73 | 74 | private function parseHeaders(array $headers): array 75 | { 76 | $values = []; 77 | foreach (array_reverse($headers) as $header) { 78 | if (preg_match('{^([^:]++):\s*(.+?)\s*$}i', $header, $match)) { 79 | $values[strtolower($match[1])][] = $match[2]; 80 | } elseif (preg_match('{^HTTP/}i', $header)) { 81 | break; 82 | } 83 | } 84 | 85 | return $values; 86 | } 87 | } 88 | -------------------------------------------------------------------------------- /src/ScriptExecutor.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Symfony\Flex; 13 | 14 | use Composer\Composer; 15 | use Composer\EventDispatcher\ScriptExecutionException; 16 | use Composer\IO\IOInterface; 17 | use Composer\Semver\Constraint\MatchAllConstraint; 18 | use Composer\Util\ProcessExecutor; 19 | use Symfony\Component\Console\Output\OutputInterface; 20 | use Symfony\Component\Console\Output\StreamOutput; 21 | use Symfony\Component\Process\PhpExecutableFinder; 22 | 23 | /** 24 | * @author Fabien Potencier 25 | */ 26 | class ScriptExecutor 27 | { 28 | private $composer; 29 | private $io; 30 | private $options; 31 | private $executor; 32 | 33 | public function __construct(Composer $composer, IOInterface $io, Options $options, ?ProcessExecutor $executor = null) 34 | { 35 | $this->composer = $composer; 36 | $this->io = $io; 37 | $this->options = $options; 38 | $this->executor = $executor ?: new ProcessExecutor(); 39 | } 40 | 41 | /** 42 | * @throws ScriptExecutionException if the executed command returns a non-0 exit code 43 | */ 44 | public function execute(string $type, string $cmd, array $arguments = []) 45 | { 46 | $parsedCmd = $this->options->expandTargetDir($cmd); 47 | if (null === $expandedCmd = $this->expandCmd($type, $parsedCmd, $arguments)) { 48 | return; 49 | } 50 | 51 | $cmdOutput = new StreamOutput(fopen('php://temp', 'rw'), OutputInterface::VERBOSITY_VERBOSE, $this->io->isDecorated()); 52 | $outputHandler = function ($type, $buffer) use ($cmdOutput) { 53 | $cmdOutput->write($buffer, false, OutputInterface::OUTPUT_RAW); 54 | }; 55 | 56 | $this->io->writeError(\sprintf('Executing script %s', $parsedCmd), $this->io->isVerbose()); 57 | $exitCode = $this->executor->execute($expandedCmd, $outputHandler); 58 | 59 | $code = 0 === $exitCode ? ' [OK]' : ' [KO]'; 60 | 61 | if ($this->io->isVerbose()) { 62 | $this->io->writeError(\sprintf('Executed script %s %s', $cmd, $code)); 63 | } else { 64 | $this->io->writeError($code); 65 | } 66 | 67 | if (0 !== $exitCode) { 68 | $this->io->writeError(' [KO]'); 69 | $this->io->writeError(\sprintf('Script %s returned with error code %s', $cmd, $exitCode)); 70 | fseek($cmdOutput->getStream(), 0); 71 | foreach (explode("\n", stream_get_contents($cmdOutput->getStream())) as $line) { 72 | $this->io->writeError('!! '.$line); 73 | } 74 | 75 | throw new ScriptExecutionException($cmd, $exitCode); 76 | } 77 | } 78 | 79 | private function expandCmd(string $type, string $cmd, array $arguments) 80 | { 81 | switch ($type) { 82 | case 'symfony-cmd': 83 | return $this->expandSymfonyCmd($cmd, $arguments); 84 | case 'php-script': 85 | return $this->expandPhpScript($cmd, $arguments); 86 | case 'script': 87 | return $cmd; 88 | default: 89 | throw new \InvalidArgumentException(\sprintf('Invalid symfony/flex auto-script in composer.json: "%s" is not a valid type of command.', $type)); 90 | } 91 | } 92 | 93 | private function expandSymfonyCmd(string $cmd, array $arguments) 94 | { 95 | $repo = $this->composer->getRepositoryManager()->getLocalRepository(); 96 | if (!$repo->findPackage('symfony/console', new MatchAllConstraint())) { 97 | $this->io->writeError(\sprintf('Skipping "%s" (needs symfony/console to run).', $cmd)); 98 | 99 | return null; 100 | } 101 | 102 | $console = ProcessExecutor::escape($this->options->get('root-dir').'/'.$this->options->get('bin-dir').'/console'); 103 | if ($this->io->isDecorated()) { 104 | $console .= ' --ansi'; 105 | } 106 | 107 | return $this->expandPhpScript($console.' '.$cmd, $arguments); 108 | } 109 | 110 | private function expandPhpScript(string $cmd, array $scriptArguments): string 111 | { 112 | $phpFinder = new PhpExecutableFinder(); 113 | if (!$php = $phpFinder->find(false)) { 114 | throw new \RuntimeException('The PHP executable could not be found, add it to your PATH and try again.'); 115 | } 116 | 117 | $arguments = $phpFinder->findArguments(); 118 | 119 | if ($env = (string) getenv('COMPOSER_ORIGINAL_INIS')) { 120 | $paths = explode(\PATH_SEPARATOR, $env); 121 | $ini = array_shift($paths); 122 | } else { 123 | $ini = php_ini_loaded_file(); 124 | } 125 | 126 | if ($ini) { 127 | $arguments[] = '--php-ini='.$ini; 128 | } 129 | 130 | if ($memoryLimit = (string) getenv('COMPOSER_MEMORY_LIMIT')) { 131 | $arguments[] = "-d memory_limit={$memoryLimit}"; 132 | } 133 | 134 | $phpArgs = implode(' ', array_map([ProcessExecutor::class, 'escape'], $arguments)); 135 | $scriptArgs = implode(' ', array_map([ProcessExecutor::class, 'escape'], $scriptArguments)); 136 | 137 | return ProcessExecutor::escape($php).($phpArgs ? ' '.$phpArgs : '').' '.$cmd.($scriptArgs ? ' '.$scriptArgs : ''); 138 | } 139 | } 140 | -------------------------------------------------------------------------------- /src/SymfonyBundle.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Symfony\Flex; 13 | 14 | use Composer\Composer; 15 | use Composer\Package\PackageInterface; 16 | 17 | /** 18 | * @author Fabien Potencier 19 | */ 20 | class SymfonyBundle 21 | { 22 | private $package; 23 | private $operation; 24 | private $vendorDir; 25 | 26 | public function __construct(Composer $composer, PackageInterface $package, string $operation) 27 | { 28 | $this->package = $package; 29 | $this->operation = $operation; 30 | $this->vendorDir = rtrim($composer->getConfig()->get('vendor-dir'), '/'); 31 | } 32 | 33 | public function getClassNames(): array 34 | { 35 | $uninstall = 'uninstall' === $this->operation; 36 | $classes = []; 37 | $autoload = $this->package->getAutoload(); 38 | $isSyliusPlugin = 'sylius-plugin' === $this->package->getType(); 39 | foreach (['psr-4' => true, 'psr-0' => false] as $psr => $isPsr4) { 40 | if (!isset($autoload[$psr])) { 41 | continue; 42 | } 43 | 44 | foreach ($autoload[$psr] as $namespace => $paths) { 45 | if (!\is_array($paths)) { 46 | $paths = [$paths]; 47 | } 48 | foreach ($paths as $path) { 49 | foreach ($this->extractClassNames($namespace, $isSyliusPlugin) as $class) { 50 | // we only check class existence on install as we do have the code available 51 | // in contrast to uninstall operation 52 | if (!$uninstall && !$this->isBundleClass($class, $path, $isPsr4)) { 53 | continue; 54 | } 55 | 56 | $classes[] = $class; 57 | } 58 | } 59 | } 60 | } 61 | 62 | return $classes; 63 | } 64 | 65 | private function extractClassNames(string $namespace, bool $isSyliusPlugin): array 66 | { 67 | $namespace = trim($namespace, '\\'); 68 | $class = $namespace.'\\'; 69 | $parts = explode('\\', $namespace); 70 | $suffix = $parts[\count($parts) - 1]; 71 | $endOfWord = substr($suffix, -6); 72 | 73 | if ($isSyliusPlugin) { 74 | if ('Bundle' !== $endOfWord && 'Plugin' !== $endOfWord) { 75 | $suffix .= 'Bundle'; 76 | } 77 | } elseif ('Bundle' !== $endOfWord) { 78 | $suffix .= 'Bundle'; 79 | } 80 | 81 | $classes = [$class.$suffix]; 82 | $acc = ''; 83 | foreach (\array_slice($parts, 0, -1) as $part) { 84 | if ('Bundle' === $part || ($isSyliusPlugin && 'Plugin' === $part)) { 85 | continue; 86 | } 87 | $classes[] = $class.$part.$suffix; 88 | $acc .= $part; 89 | $classes[] = $class.$acc.$suffix; 90 | } 91 | 92 | return array_unique($classes); 93 | } 94 | 95 | private function isBundleClass(string $class, string $path, bool $isPsr4): bool 96 | { 97 | $classPath = ($this->vendorDir ? $this->vendorDir.'/' : '').$this->package->getPrettyName().'/'.$path.'/'; 98 | $parts = explode('\\', $class); 99 | $class = $parts[\count($parts) - 1]; 100 | if (!$isPsr4) { 101 | $classPath .= str_replace('\\', '', implode('/', \array_slice($parts, 0, -1))).'/'; 102 | } 103 | $classPath .= str_replace('\\', '/', $class).'.php'; 104 | 105 | if (!file_exists($classPath)) { 106 | return false; 107 | } 108 | 109 | // heuristic that should work in almost all cases 110 | $classContents = file_get_contents($classPath); 111 | 112 | return str_contains($classContents, 'Symfony\Component\HttpKernel\Bundle\Bundle') 113 | || str_contains($classContents, 'Symfony\Component\HttpKernel\Bundle\AbstractBundle'); 114 | } 115 | } 116 | -------------------------------------------------------------------------------- /src/SymfonyPackInstaller.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Symfony\Flex; 13 | 14 | use Composer\Installer\MetapackageInstaller; 15 | 16 | class SymfonyPackInstaller extends MetapackageInstaller 17 | { 18 | public function supports($packageType): bool 19 | { 20 | return 'symfony-pack' === $packageType; 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /src/Unpack/Operation.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Symfony\Flex\Unpack; 13 | 14 | class Operation 15 | { 16 | private $packages = []; 17 | private $unpack; 18 | private $sort; 19 | 20 | public function __construct(bool $unpack, bool $sort) 21 | { 22 | $this->unpack = $unpack; 23 | $this->sort = $sort; 24 | } 25 | 26 | public function addPackage(string $name, string $version, bool $dev) 27 | { 28 | $this->packages[] = [ 29 | 'name' => $name, 30 | 'version' => $version, 31 | 'dev' => $dev, 32 | ]; 33 | } 34 | 35 | public function getPackages(): array 36 | { 37 | return $this->packages; 38 | } 39 | 40 | public function shouldUnpack(): bool 41 | { 42 | return $this->unpack; 43 | } 44 | 45 | public function shouldSort(): bool 46 | { 47 | return $this->sort; 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /src/Unpack/Result.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Symfony\Flex\Unpack; 13 | 14 | use Composer\Package\PackageInterface; 15 | 16 | class Result 17 | { 18 | private $unpacked = []; 19 | private $required = []; 20 | 21 | public function addUnpacked(PackageInterface $package): bool 22 | { 23 | $name = $package->getName(); 24 | 25 | if (!isset($this->unpacked[$name])) { 26 | $this->unpacked[$name] = $package; 27 | 28 | return true; 29 | } 30 | 31 | return false; 32 | } 33 | 34 | /** 35 | * @return PackageInterface[] 36 | */ 37 | public function getUnpacked(): array 38 | { 39 | return $this->unpacked; 40 | } 41 | 42 | public function addRequired(string $package) 43 | { 44 | $this->required[] = $package; 45 | } 46 | 47 | /** 48 | * @return string[] 49 | */ 50 | public function getRequired(): array 51 | { 52 | // we need at least one package for the command to work properly 53 | return $this->required ?: ['symfony/flex']; 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /src/Unpacker.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Symfony\Flex; 13 | 14 | use Composer\Composer; 15 | use Composer\Config\JsonConfigSource; 16 | use Composer\Factory; 17 | use Composer\IO\IOInterface; 18 | use Composer\Json\JsonFile; 19 | use Composer\Json\JsonManipulator; 20 | use Composer\Package\Locker; 21 | use Composer\Package\Version\VersionSelector; 22 | use Composer\Repository\CompositeRepository; 23 | use Composer\Repository\RepositorySet; 24 | use Composer\Semver\VersionParser; 25 | use Symfony\Flex\Unpack\Operation; 26 | use Symfony\Flex\Unpack\Result; 27 | 28 | class Unpacker 29 | { 30 | private $composer; 31 | private $resolver; 32 | private $versionParser; 33 | 34 | public function __construct(Composer $composer, PackageResolver $resolver) 35 | { 36 | $this->composer = $composer; 37 | $this->resolver = $resolver; 38 | $this->versionParser = new VersionParser(); 39 | } 40 | 41 | public function unpack(Operation $op, ?Result $result = null, &$links = [], bool $devRequire = false): Result 42 | { 43 | if (null === $result) { 44 | $result = new Result(); 45 | } 46 | 47 | $localRepo = $this->composer->getRepositoryManager()->getLocalRepository(); 48 | foreach ($op->getPackages() as $package) { 49 | $pkg = $localRepo->findPackage($package['name'], '*'); 50 | $pkg = $pkg ?? $this->composer->getRepositoryManager()->findPackage($package['name'], $package['version'] ?: '*'); 51 | 52 | // not unpackable or no --unpack flag or empty packs (markers) 53 | if ( 54 | null === $pkg 55 | || 'symfony-pack' !== $pkg->getType() 56 | || !$op->shouldUnpack() 57 | || 0 === \count($pkg->getRequires()) + \count($pkg->getDevRequires()) 58 | ) { 59 | $result->addRequired($package['name'].($package['version'] ? ':'.$package['version'] : '')); 60 | 61 | continue; 62 | } 63 | 64 | if (!$result->addUnpacked($pkg)) { 65 | continue; 66 | } 67 | 68 | $requires = []; 69 | foreach ($pkg->getRequires() as $link) { 70 | $requires[$link->getTarget()] = $link; 71 | } 72 | $devRequires = $pkg->getDevRequires(); 73 | 74 | foreach ($devRequires as $i => $link) { 75 | if (!isset($requires[$link->getTarget()])) { 76 | throw new \RuntimeException(\sprintf('Symfony pack "%s" must duplicate all entries from "require-dev" into "require" but entry "%s" was not found.', $package['name'], $link->getTarget())); 77 | } 78 | $devRequires[$i] = $requires[$link->getTarget()]; 79 | unset($requires[$link->getTarget()]); 80 | } 81 | 82 | $versionSelector = null; 83 | foreach ([$requires, $devRequires] as $dev => $requires) { 84 | $dev = $dev ?: $devRequire ?: $package['dev']; 85 | 86 | foreach ($requires as $link) { 87 | if ('php' === $linkName = $link->getTarget()) { 88 | continue; 89 | } 90 | 91 | $constraint = $link->getPrettyConstraint(); 92 | $constraint = substr($this->resolver->parseVersion($linkName, $constraint, true), 1) ?: $constraint; 93 | 94 | if ($subPkg = $localRepo->findPackage($linkName, '*')) { 95 | if ('symfony-pack' === $subPkg->getType()) { 96 | $subOp = new Operation(true, $op->shouldSort()); 97 | $subOp->addPackage($subPkg->getName(), $constraint, $dev); 98 | $result = $this->unpack($subOp, $result, $links, $dev); 99 | continue; 100 | } 101 | 102 | if ('*' === $constraint) { 103 | if (null === $versionSelector) { 104 | $pool = new RepositorySet($this->composer->getPackage()->getMinimumStability(), $this->composer->getPackage()->getStabilityFlags()); 105 | $pool->addRepository(new CompositeRepository($this->composer->getRepositoryManager()->getRepositories())); 106 | $versionSelector = new VersionSelector($pool); 107 | } 108 | 109 | $constraint = $versionSelector->findRecommendedRequireVersion($subPkg); 110 | } 111 | } 112 | 113 | $linkType = $dev ? 'require-dev' : 'require'; 114 | $constraint = $this->versionParser->parseConstraints($constraint); 115 | 116 | if (isset($links[$linkName])) { 117 | $links[$linkName]['constraints'][] = $constraint; 118 | if ('require' === $linkType) { 119 | $links[$linkName]['type'] = 'require'; 120 | } 121 | } else { 122 | $links[$linkName] = [ 123 | 'type' => $linkType, 124 | 'name' => $linkName, 125 | 'constraints' => [$constraint], 126 | ]; 127 | } 128 | } 129 | } 130 | } 131 | 132 | if (1 < \func_num_args()) { 133 | return $result; 134 | } 135 | 136 | $jsonPath = Factory::getComposerFile(); 137 | $jsonContent = file_get_contents($jsonPath); 138 | $jsonStored = json_decode($jsonContent, true); 139 | $jsonManipulator = new JsonManipulator($jsonContent); 140 | 141 | foreach ($result->getUnpacked() as $pkg) { 142 | $localRepo->removePackage($pkg); 143 | $localRepo->setDevPackageNames(array_diff($localRepo->getDevPackageNames(), [$pkg->getName()])); 144 | $jsonManipulator->removeSubNode('require', $pkg->getName()); 145 | $jsonManipulator->removeSubNode('require-dev', $pkg->getName()); 146 | } 147 | 148 | foreach ($links as $link) { 149 | // nothing to do, package is already present in the "require" section 150 | if (isset($jsonStored['require'][$link['name']])) { 151 | continue; 152 | } 153 | 154 | if (isset($jsonStored['require-dev'][$link['name']])) { 155 | // nothing to do, package is already present in the "require-dev" section 156 | if ('require-dev' === $link['type']) { 157 | continue; 158 | } 159 | 160 | // removes package from "require-dev", because it will be moved to "require" 161 | // save stored constraint 162 | $link['constraints'][] = $this->versionParser->parseConstraints($jsonStored['require-dev'][$link['name']]); 163 | $jsonManipulator->removeSubNode('require-dev', $link['name']); 164 | } 165 | 166 | $constraint = end($link['constraints']); 167 | 168 | if (!$jsonManipulator->addLink($link['type'], $link['name'], $constraint->getPrettyString(), $op->shouldSort())) { 169 | throw new \RuntimeException(\sprintf('Unable to unpack package "%s".', $link['name'])); 170 | } 171 | } 172 | 173 | file_put_contents($jsonPath, $jsonManipulator->getContents()); 174 | 175 | return $result; 176 | } 177 | 178 | public function updateLock(Result $result, IOInterface $io): void 179 | { 180 | $json = new JsonFile(Factory::getComposerFile()); 181 | $manipulator = new JsonConfigSource($json); 182 | $locker = $this->composer->getLocker(); 183 | $lockData = $locker->getLockData(); 184 | 185 | foreach ($result->getUnpacked() as $package) { 186 | $manipulator->removeLink('require-dev', $package->getName()); 187 | foreach ($lockData['packages-dev'] as $i => $pkg) { 188 | if ($package->getName() === $pkg['name']) { 189 | unset($lockData['packages-dev'][$i]); 190 | } 191 | } 192 | $manipulator->removeLink('require', $package->getName()); 193 | foreach ($lockData['packages'] as $i => $pkg) { 194 | if ($package->getName() === $pkg['name']) { 195 | unset($lockData['packages'][$i]); 196 | } 197 | } 198 | } 199 | $jsonContent = file_get_contents($json->getPath()); 200 | $lockData['packages'] = array_values($lockData['packages']); 201 | $lockData['packages-dev'] = array_values($lockData['packages-dev']); 202 | $lockData['content-hash'] = Locker::getContentHash($jsonContent); 203 | $lockFile = new JsonFile(substr($json->getPath(), 0, -4).'lock', null, $io); 204 | 205 | $lockFile->write($lockData); 206 | 207 | $locker = new Locker($io, $lockFile, $this->composer->getInstallationManager(), $jsonContent); 208 | $this->composer->setLocker($locker); 209 | 210 | $localRepo = $this->composer->getRepositoryManager()->getLocalRepository(); 211 | $localRepo->write($localRepo->getDevMode() ?? true, $this->composer->getInstallationManager()); 212 | } 213 | } 214 | -------------------------------------------------------------------------------- /src/Update/DiffHelper.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Symfony\Flex\Update; 13 | 14 | class DiffHelper 15 | { 16 | public static function removeFilesFromPatch(string $patch, array $files, array &$removedPatches): string 17 | { 18 | foreach ($files as $filename) { 19 | $start = strpos($patch, \sprintf('diff --git a/%s b/%s', $filename, $filename)); 20 | if (false === $start) { 21 | throw new \LogicException(\sprintf('Could not find file "%s" in the patch.', $filename)); 22 | } 23 | 24 | $end = strpos($patch, 'diff --git a/', $start + 1); 25 | $contentBefore = substr($patch, 0, $start); 26 | if (false === $end) { 27 | // last patch in the file 28 | $removedPatches[$filename] = rtrim(substr($patch, $start), "\n"); 29 | $patch = rtrim($contentBefore, "\n"); 30 | 31 | continue; 32 | } 33 | 34 | $removedPatches[$filename] = rtrim(substr($patch, $start, $end - $start), "\n"); 35 | $patch = $contentBefore.substr($patch, $end); 36 | } 37 | 38 | // valid patches end with a blank line 39 | if ($patch && "\n" !== substr($patch, \strlen($patch) - 1, 1)) { 40 | $patch .= "\n"; 41 | } 42 | 43 | return $patch; 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /src/Update/RecipePatch.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Symfony\Flex\Update; 13 | 14 | class RecipePatch 15 | { 16 | private $patch; 17 | private $blobs; 18 | private $deletedFiles; 19 | private $removedPatches; 20 | 21 | public function __construct(string $patch, array $blobs, array $deletedFiles, array $removedPatches = []) 22 | { 23 | $this->patch = $patch; 24 | $this->blobs = $blobs; 25 | $this->deletedFiles = $deletedFiles; 26 | $this->removedPatches = $removedPatches; 27 | } 28 | 29 | public function getPatch(): string 30 | { 31 | return $this->patch; 32 | } 33 | 34 | public function getBlobs(): array 35 | { 36 | return $this->blobs; 37 | } 38 | 39 | public function getDeletedFiles(): array 40 | { 41 | return $this->deletedFiles; 42 | } 43 | 44 | /** 45 | * Patches for modified files that were removed because the file 46 | * has been deleted in the user's project. 47 | */ 48 | public function getRemovedPatches(): array 49 | { 50 | return $this->removedPatches; 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /src/Update/RecipePatcher.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Symfony\Flex\Update; 13 | 14 | use Composer\IO\IOInterface; 15 | use Composer\Util\ProcessExecutor; 16 | use Symfony\Component\Filesystem\Exception\IOException; 17 | use Symfony\Component\Filesystem\Filesystem; 18 | 19 | class RecipePatcher 20 | { 21 | private $rootDir; 22 | private $filesystem; 23 | private $io; 24 | private $processExecutor; 25 | 26 | public function __construct(string $rootDir, IOInterface $io) 27 | { 28 | $this->rootDir = $rootDir; 29 | $this->filesystem = new Filesystem(); 30 | $this->io = $io; 31 | $this->processExecutor = new ProcessExecutor($io); 32 | } 33 | 34 | /** 35 | * Applies the patch. If it fails unexpectedly, an exception will be thrown. 36 | * 37 | * @return bool returns true if fully successful, false if conflicts were encountered 38 | */ 39 | public function applyPatch(RecipePatch $patch): bool 40 | { 41 | $withConflicts = $this->_applyPatchFile($patch); 42 | 43 | foreach ($patch->getDeletedFiles() as $deletedFile) { 44 | if (file_exists($this->rootDir.'/'.$deletedFile)) { 45 | $this->execute(\sprintf('git rm %s', ProcessExecutor::escape($deletedFile)), $this->rootDir); 46 | } 47 | } 48 | 49 | return $withConflicts; 50 | } 51 | 52 | public function generatePatch(array $originalFiles, array $newFiles): RecipePatch 53 | { 54 | $ignoredFiles = $this->getIgnoredFiles(array_keys($originalFiles) + array_keys($newFiles)); 55 | 56 | // null implies "file does not exist" 57 | $originalFiles = array_filter($originalFiles, function ($file, $fileName) use ($ignoredFiles) { 58 | return null !== $file && !\in_array($fileName, $ignoredFiles); 59 | }, \ARRAY_FILTER_USE_BOTH); 60 | 61 | $newFiles = array_filter($newFiles, function ($file, $fileName) use ($ignoredFiles) { 62 | return null !== $file && !\in_array($fileName, $ignoredFiles); 63 | }, \ARRAY_FILTER_USE_BOTH); 64 | 65 | $deletedFiles = []; 66 | // find removed files & record that they are deleted 67 | // unset them from originalFiles to avoid unnecessary blobs being added 68 | foreach ($originalFiles as $file => $contents) { 69 | if (!isset($newFiles[$file])) { 70 | $deletedFiles[] = $file; 71 | unset($originalFiles[$file]); 72 | } 73 | } 74 | 75 | // If a file is being modified, but does not exist in the current project, 76 | // it cannot be patched. We generate the diff for these, but then remove 77 | // it from the patch (and optionally report this diff to the user). 78 | $modifiedFiles = array_intersect_key(array_keys($originalFiles), array_keys($newFiles)); 79 | $deletedModifiedFiles = []; 80 | foreach ($modifiedFiles as $modifiedFile) { 81 | if (!file_exists($this->rootDir.'/'.$modifiedFile) && $originalFiles[$modifiedFile] !== $newFiles[$modifiedFile]) { 82 | $deletedModifiedFiles[] = $modifiedFile; 83 | } 84 | } 85 | 86 | // Use git binary to get project path from repository root 87 | $prefix = trim($this->execute('git rev-parse --show-prefix', $this->rootDir)); 88 | $tmpPath = sys_get_temp_dir().'/_flex_recipe_update'.uniqid(mt_rand(), true); 89 | $this->filesystem->mkdir($tmpPath); 90 | 91 | try { 92 | $this->execute('git init', $tmpPath); 93 | $this->execute('git config commit.gpgsign false', $tmpPath); 94 | $this->execute('git config user.name "Flex Updater"', $tmpPath); 95 | $this->execute('git config user.email ""', $tmpPath); 96 | 97 | $blobs = []; 98 | if (\count($originalFiles) > 0) { 99 | $this->writeFiles($originalFiles, $tmpPath); 100 | $this->execute('git add -A', $tmpPath); 101 | $this->execute('git commit -n -m "original files"', $tmpPath); 102 | 103 | $blobs = $this->generateBlobs($originalFiles, $tmpPath); 104 | } 105 | 106 | $this->writeFiles($newFiles, $tmpPath); 107 | $this->execute('git add -A', $tmpPath); 108 | 109 | $patchString = $this->execute(\sprintf('git diff --cached --src-prefix "a/%s" --dst-prefix "b/%s"', $prefix, $prefix), $tmpPath); 110 | $removedPatches = []; 111 | $patchString = DiffHelper::removeFilesFromPatch($patchString, $deletedModifiedFiles, $removedPatches); 112 | 113 | return new RecipePatch( 114 | $patchString, 115 | $blobs, 116 | $deletedFiles, 117 | $removedPatches 118 | ); 119 | } finally { 120 | try { 121 | $this->filesystem->remove($tmpPath); 122 | } catch (IOException $e) { 123 | // this can sometimes fail due to git file permissions 124 | // if that happens, just leave it: we're in the temp directory anyways 125 | } 126 | } 127 | } 128 | 129 | private function writeFiles(array $files, string $directory): void 130 | { 131 | foreach ($files as $filename => $contents) { 132 | $path = $directory.'/'.$filename; 133 | if (null === $contents) { 134 | if (file_exists($path)) { 135 | unlink($path); 136 | } 137 | 138 | continue; 139 | } 140 | 141 | if (!file_exists(\dirname($path))) { 142 | $this->filesystem->mkdir(\dirname($path)); 143 | } 144 | file_put_contents($path, $contents); 145 | } 146 | } 147 | 148 | private function execute(string $command, string $cwd): string 149 | { 150 | $output = ''; 151 | $statusCode = $this->processExecutor->execute($command, $output, $cwd); 152 | 153 | if (0 !== $statusCode) { 154 | throw new \LogicException(\sprintf('Command "%s" failed: "%s". Output: "%s".', $command, $this->processExecutor->getErrorOutput(), $output)); 155 | } 156 | 157 | return $output; 158 | } 159 | 160 | /** 161 | * Adds git blobs for each original file. 162 | * 163 | * For patching to work, each original file & contents needs to be 164 | * available to git as a blob. This is because the patch contains 165 | * the ref to the original blob, and git uses that to find the 166 | * original file (which is needed for the 3-way merge). 167 | */ 168 | private function addMissingBlobs(array $blobs): array 169 | { 170 | $addedBlobs = []; 171 | foreach ($blobs as $hash => $contents) { 172 | $blobPath = $this->getBlobPath($this->rootDir, $hash); 173 | if (file_exists($blobPath)) { 174 | continue; 175 | } 176 | 177 | $addedBlobs[] = $blobPath; 178 | if (!file_exists(\dirname($blobPath))) { 179 | $this->filesystem->mkdir(\dirname($blobPath)); 180 | } 181 | file_put_contents($blobPath, $contents); 182 | } 183 | 184 | return $addedBlobs; 185 | } 186 | 187 | private function generateBlobs(array $originalFiles, string $originalFilesRoot): array 188 | { 189 | $addedBlobs = []; 190 | foreach ($originalFiles as $filename => $contents) { 191 | // if the file didn't originally exist, no blob needed 192 | if (!file_exists($originalFilesRoot.'/'.$filename)) { 193 | continue; 194 | } 195 | 196 | $hash = trim($this->execute('git hash-object '.ProcessExecutor::escape($filename), $originalFilesRoot)); 197 | $addedBlobs[$hash] = file_get_contents($this->getBlobPath($originalFilesRoot, $hash)); 198 | } 199 | 200 | return $addedBlobs; 201 | } 202 | 203 | private function getBlobPath(string $gitRoot, string $hash): string 204 | { 205 | $gitDir = trim($this->execute('git rev-parse --absolute-git-dir', $gitRoot)); 206 | 207 | $hashStart = substr($hash, 0, 2); 208 | $hashEnd = substr($hash, 2); 209 | 210 | return $gitDir.'/objects/'.$hashStart.'/'.$hashEnd; 211 | } 212 | 213 | private function _applyPatchFile(RecipePatch $patch) 214 | { 215 | if (!$patch->getPatch()) { 216 | // nothing to do! 217 | return true; 218 | } 219 | 220 | $addedBlobs = $this->addMissingBlobs($patch->getBlobs()); 221 | 222 | $patchPath = $this->rootDir.'/_flex_recipe_update.patch'; 223 | file_put_contents($patchPath, $patch->getPatch()); 224 | 225 | try { 226 | $this->execute('git update-index --refresh', $this->rootDir); 227 | 228 | $output = ''; 229 | $statusCode = $this->processExecutor->execute('git apply "_flex_recipe_update.patch" -3', $output, $this->rootDir); 230 | 231 | if (0 === $statusCode) { 232 | // successful with no conflicts 233 | return true; 234 | } 235 | 236 | if (str_contains($this->processExecutor->getErrorOutput(), 'with conflicts')) { 237 | // successful with conflicts 238 | return false; 239 | } 240 | 241 | throw new \LogicException('Error applying the patch: '.$this->processExecutor->getErrorOutput()); 242 | } finally { 243 | unlink($patchPath); 244 | // clean up any temporary blobs 245 | foreach ($addedBlobs as $filename) { 246 | unlink($filename); 247 | } 248 | } 249 | } 250 | 251 | private function getIgnoredFiles(array $fileNames): array 252 | { 253 | $args = implode(' ', array_map([ProcessExecutor::class, 'escape'], $fileNames)); 254 | $output = ''; 255 | $this->processExecutor->execute(\sprintf('git check-ignore %s', $args), $output, $this->rootDir); 256 | 257 | return $this->processExecutor->splitLines($output); 258 | } 259 | } 260 | -------------------------------------------------------------------------------- /src/Update/RecipeUpdate.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Symfony\Flex\Update; 13 | 14 | use Symfony\Flex\Lock; 15 | use Symfony\Flex\Recipe; 16 | 17 | class RecipeUpdate 18 | { 19 | private $originalRecipe; 20 | private $newRecipe; 21 | private $lock; 22 | private $rootDir; 23 | 24 | /** @var string[] */ 25 | private $originalRecipeFiles = []; 26 | /** @var string[] */ 27 | private $newRecipeFiles = []; 28 | private $copyFromPackagePaths = []; 29 | 30 | public function __construct(Recipe $originalRecipe, Recipe $newRecipe, Lock $lock, string $rootDir) 31 | { 32 | $this->originalRecipe = $originalRecipe; 33 | $this->newRecipe = $newRecipe; 34 | $this->lock = $lock; 35 | $this->rootDir = $rootDir; 36 | } 37 | 38 | public function getOriginalRecipe(): Recipe 39 | { 40 | return $this->originalRecipe; 41 | } 42 | 43 | public function getNewRecipe(): Recipe 44 | { 45 | return $this->newRecipe; 46 | } 47 | 48 | public function getLock(): Lock 49 | { 50 | return $this->lock; 51 | } 52 | 53 | public function getRootDir(): string 54 | { 55 | return $this->rootDir; 56 | } 57 | 58 | public function getPackageName(): string 59 | { 60 | return $this->originalRecipe->getName(); 61 | } 62 | 63 | public function setOriginalFile(string $filename, ?string $contents): void 64 | { 65 | $this->originalRecipeFiles[$filename] = $contents; 66 | } 67 | 68 | public function setNewFile(string $filename, ?string $contents): void 69 | { 70 | $this->newRecipeFiles[$filename] = $contents; 71 | } 72 | 73 | public function addOriginalFiles(array $files) 74 | { 75 | foreach ($files as $file => $contents) { 76 | if (null === $contents) { 77 | continue; 78 | } 79 | 80 | $this->setOriginalFile($file, $contents); 81 | } 82 | } 83 | 84 | public function addNewFiles(array $files) 85 | { 86 | foreach ($files as $file => $contents) { 87 | if (null === $contents) { 88 | continue; 89 | } 90 | 91 | $this->setNewFile($file, $contents); 92 | } 93 | } 94 | 95 | public function getOriginalFiles(): array 96 | { 97 | return $this->originalRecipeFiles; 98 | } 99 | 100 | public function getNewFiles(): array 101 | { 102 | return $this->newRecipeFiles; 103 | } 104 | 105 | public function getCopyFromPackagePaths(): array 106 | { 107 | return $this->copyFromPackagePaths; 108 | } 109 | 110 | public function addCopyFromPackagePath(string $source, string $target) 111 | { 112 | $this->copyFromPackagePaths[$source] = $target; 113 | } 114 | } 115 | --------------------------------------------------------------------------------