initial commit

This commit is contained in:
boris
2025-09-30 09:24:25 +01:00
committed by boris
parent a783a12c97
commit c7770ea03b
4695 changed files with 525784 additions and 0 deletions

View File

@@ -0,0 +1,131 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Symfony\Flex\Configurator;
use Composer\Composer;
use Composer\IO\IOInterface;
use Symfony\Flex\Lock;
use Symfony\Flex\Options;
use Symfony\Flex\Path;
use Symfony\Flex\Recipe;
use Symfony\Flex\Update\RecipeUpdate;
/**
* @author Fabien Potencier <fabien@symfony.com>
*/
abstract class AbstractConfigurator
{
protected $composer;
protected $io;
protected $options;
protected $path;
public function __construct(Composer $composer, IOInterface $io, Options $options)
{
$this->composer = $composer;
$this->io = $io;
$this->options = $options;
$this->path = new Path($options->get('root-dir'));
}
abstract public function configure(Recipe $recipe, $config, Lock $lock, array $options = []);
abstract public function unconfigure(Recipe $recipe, $config, Lock $lock);
abstract public function update(RecipeUpdate $recipeUpdate, array $originalConfig, array $newConfig): void;
protected function write($messages, $verbosity = IOInterface::VERBOSE)
{
if (!\is_array($messages)) {
$messages = [$messages];
}
foreach ($messages as $i => $message) {
$messages[$i] = ' '.$message;
}
$this->io->writeError($messages, true, $verbosity);
}
protected function isFileMarked(Recipe $recipe, string $file): bool
{
return is_file($file) && str_contains(file_get_contents($file), \sprintf('###> %s ###', $recipe->getName()));
}
protected function markData(Recipe $recipe, string $data): string
{
return "\n".\sprintf('###> %s ###%s%s%s###< %s ###%s', $recipe->getName(), "\n", rtrim($data, "\r\n"), "\n", $recipe->getName(), "\n");
}
protected function isFileXmlMarked(Recipe $recipe, string $file): bool
{
return is_file($file) && str_contains(file_get_contents($file), \sprintf('###+ %s ###', $recipe->getName()));
}
protected function markXmlData(Recipe $recipe, string $data): string
{
return "\n".\sprintf(' <!-- ###+ %s ### -->%s%s%s <!-- ###- %s ### -->%s', $recipe->getName(), "\n", rtrim($data, "\r\n"), "\n", $recipe->getName(), "\n");
}
/**
* @return bool True if section was found and replaced
*/
protected function updateData(string $file, string $data): bool
{
if (!file_exists($file)) {
return false;
}
$contents = file_get_contents($file);
$newContents = $this->updateDataString($contents, $data);
if (null === $newContents) {
return false;
}
file_put_contents($file, $newContents);
return true;
}
/**
* @return string|null returns the updated content if the section was found, null if not found
*/
protected function updateDataString(string $contents, string $data): ?string
{
$pieces = explode("\n", trim($data));
$startMark = trim(reset($pieces));
$endMark = trim(end($pieces));
if (!str_contains($contents, $startMark) || !str_contains($contents, $endMark)) {
return null;
}
$pattern = '/'.preg_quote($startMark, '/').'.*?'.preg_quote($endMark, '/').'/s';
return preg_replace($pattern, trim($data), $contents);
}
protected function extractSection(Recipe $recipe, string $contents): ?string
{
$section = $this->markData($recipe, '----');
$pieces = explode("\n", trim($section));
$startMark = trim(reset($pieces));
$endMark = trim(end($pieces));
$pattern = '/'.preg_quote($startMark, '/').'.*?'.preg_quote($endMark, '/').'/s';
$matches = [];
preg_match($pattern, $contents, $matches);
return $matches[0] ?? null;
}
}

View File

@@ -0,0 +1,274 @@
<?php
namespace Symfony\Flex\Configurator;
use Composer\IO\IOInterface;
use Symfony\Flex\Lock;
use Symfony\Flex\Recipe;
use Symfony\Flex\Update\RecipeUpdate;
/**
* @author Kevin Bond <kevinbond@gmail.com>
* @author Ryan Weaver <ryan@symfonycasts.com>
*/
class AddLinesConfigurator extends AbstractConfigurator
{
private const POSITION_TOP = 'top';
private const POSITION_BOTTOM = 'bottom';
private const POSITION_AFTER_TARGET = 'after_target';
private const VALID_POSITIONS = [
self::POSITION_TOP,
self::POSITION_BOTTOM,
self::POSITION_AFTER_TARGET,
];
/**
* Holds file contents for files that have been loaded.
* This allows us to "change" the contents of a file multiple
* times before we actually write it out.
*
* @var string[]
*/
private $fileContents = [];
public function configure(Recipe $recipe, $config, Lock $lock, array $options = []): void
{
$this->fileContents = [];
$this->executeConfigure($recipe, $config);
foreach ($this->fileContents as $file => $contents) {
$this->write(\sprintf('[add-lines] Patching file "%s"', $this->relativize($file)));
file_put_contents($file, $contents);
}
}
public function unconfigure(Recipe $recipe, $config, Lock $lock): void
{
$this->fileContents = [];
$this->executeUnconfigure($recipe, $config);
foreach ($this->fileContents as $file => $change) {
$this->write(\sprintf('[add-lines] Reverting file "%s"', $this->relativize($file)));
file_put_contents($file, $change);
}
}
public function update(RecipeUpdate $recipeUpdate, array $originalConfig, array $newConfig): void
{
// manually check for "requires", as unconfigure ignores it
$originalConfig = array_filter($originalConfig, function ($item) {
return !isset($item['requires']) || $this->isPackageInstalled($item['requires']);
});
// reset the file content cache
$this->fileContents = [];
$this->executeUnconfigure($recipeUpdate->getOriginalRecipe(), $originalConfig);
$this->executeConfigure($recipeUpdate->getNewRecipe(), $newConfig);
$newFiles = [];
$originalFiles = [];
foreach ($this->fileContents as $file => $contents) {
// set the original file to the current contents
$originalFiles[$this->relativize($file)] = file_get_contents($file);
// and the new file where the old recipe was unconfigured, and the new configured
$newFiles[$this->relativize($file)] = $contents;
}
$recipeUpdate->addOriginalFiles($originalFiles);
$recipeUpdate->addNewFiles($newFiles);
}
public function executeConfigure(Recipe $recipe, $config): void
{
foreach ($config as $patch) {
if (!isset($patch['file'])) {
$this->write(\sprintf('The "file" key is required for the "add-lines" configurator for recipe "%s". Skipping', $recipe->getName()));
continue;
}
if (isset($patch['requires']) && !$this->isPackageInstalled($patch['requires'])) {
continue;
}
if (!isset($patch['content'])) {
$this->write(\sprintf('The "content" key is required for the "add-lines" configurator for recipe "%s". Skipping', $recipe->getName()));
continue;
}
$content = $patch['content'];
$file = $this->path->concatenate([$this->options->get('root-dir'), $this->options->expandTargetDir($patch['file'])]);
$warnIfMissing = isset($patch['warn_if_missing']) && $patch['warn_if_missing'];
if (!is_file($file)) {
$this->write([
\sprintf('Could not add lines to file <info>%s</info> as it does not exist. Missing lines:', $patch['file']),
'<comment>"""</comment>',
$content,
'<comment>"""</comment>',
'',
], $warnIfMissing ? IOInterface::NORMAL : IOInterface::VERBOSE);
continue;
}
if (!isset($patch['position'])) {
$this->write(\sprintf('The "position" key is required for the "add-lines" configurator for recipe "%s". Skipping', $recipe->getName()));
continue;
}
$position = $patch['position'];
if (!\in_array($position, self::VALID_POSITIONS, true)) {
$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()));
continue;
}
if (self::POSITION_AFTER_TARGET === $position && !isset($patch['target'])) {
$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()));
continue;
}
$target = isset($patch['target']) ? $patch['target'] : null;
$newContents = $this->getPatchedContents($file, $content, $position, $target, $warnIfMissing);
$this->fileContents[$file] = $newContents;
}
}
public function executeUnconfigure(Recipe $recipe, $config): void
{
foreach ($config as $patch) {
if (!isset($patch['file'])) {
$this->write(\sprintf('The "file" key is required for the "add-lines" configurator for recipe "%s". Skipping', $recipe->getName()));
continue;
}
// Ignore "requires": the target packages may have just become uninstalled.
// Checking for a "content" match is enough.
$file = $this->path->concatenate([$this->options->get('root-dir'), $this->options->expandTargetDir($patch['file'])]);
if (!is_file($file)) {
continue;
}
if (!isset($patch['content'])) {
$this->write(\sprintf('The "content" key is required for the "add-lines" configurator for recipe "%s". Skipping', $recipe->getName()));
continue;
}
$value = $patch['content'];
$newContents = $this->getUnPatchedContents($file, $value);
$this->fileContents[$file] = $newContents;
}
}
private function getPatchedContents(string $file, string $value, string $position, ?string $target, bool $warnIfMissing): string
{
$fileContents = $this->readFile($file);
if (str_contains($fileContents, $value)) {
return $fileContents; // already includes value, skip
}
switch ($position) {
case self::POSITION_BOTTOM:
$fileContents .= "\n".$value;
break;
case self::POSITION_TOP:
$fileContents = $value."\n".$fileContents;
break;
case self::POSITION_AFTER_TARGET:
$lines = explode("\n", $fileContents);
$targetFound = false;
foreach ($lines as $key => $line) {
if (str_contains($line, $target)) {
array_splice($lines, $key + 1, 0, $value);
$targetFound = true;
break;
}
}
$fileContents = implode("\n", $lines);
if (!$targetFound) {
$this->write([
\sprintf('Could not add lines after "%s" as no such string was found in "%s". Missing lines:', $target, $file),
'<comment>"""</comment>',
$value,
'<comment>"""</comment>',
'',
], $warnIfMissing ? IOInterface::NORMAL : IOInterface::VERBOSE);
}
break;
}
return $fileContents;
}
private function getUnPatchedContents(string $file, $value): string
{
$fileContents = $this->readFile($file);
if (!str_contains($fileContents, $value)) {
return $fileContents; // value already gone!
}
if (str_contains($fileContents, "\n".$value)) {
$value = "\n".$value;
} elseif (str_contains($fileContents, $value."\n")) {
$value .= "\n";
}
$position = strpos($fileContents, $value);
return substr_replace($fileContents, '', $position, \strlen($value));
}
private function isPackageInstalled($packages): bool
{
if (\is_string($packages)) {
$packages = [$packages];
}
$installedRepo = $this->composer->getRepositoryManager()->getLocalRepository();
foreach ($packages as $package) {
$package = explode(':', $package, 2);
$packageName = $package[0];
$constraint = $package[1] ?? '*';
if (null === $installedRepo->findPackage($packageName, $constraint)) {
return false;
}
}
return true;
}
private function relativize(string $path): string
{
$rootDir = $this->options->get('root-dir');
if (str_starts_with($path, $rootDir)) {
$path = substr($path, \strlen($rootDir) + 1);
}
return ltrim($path, '/\\');
}
private function readFile(string $file): string
{
if (isset($this->fileContents[$file])) {
return $this->fileContents[$file];
}
$fileContents = file_get_contents($file);
$this->fileContents[$file] = $fileContents;
return $fileContents;
}
}

View File

@@ -0,0 +1,150 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Symfony\Flex\Configurator;
use Symfony\Flex\Lock;
use Symfony\Flex\Recipe;
use Symfony\Flex\Update\RecipeUpdate;
/**
* @author Fabien Potencier <fabien@symfony.com>
*/
class BundlesConfigurator extends AbstractConfigurator
{
public function configure(Recipe $recipe, $bundles, Lock $lock, array $options = [])
{
$this->write('Enabling the package as a Symfony bundle');
$registered = $this->configureBundles($bundles);
$this->dump($this->getConfFile(), $registered);
}
public function unconfigure(Recipe $recipe, $bundles, Lock $lock)
{
$this->write('Disabling the Symfony bundle');
$file = $this->getConfFile();
if (!file_exists($file)) {
return;
}
$registered = $this->load($file);
foreach (array_keys($this->prepareBundles($bundles)) as $class) {
unset($registered[$class]);
}
$this->dump($file, $registered);
}
public function update(RecipeUpdate $recipeUpdate, array $originalConfig, array $newConfig): void
{
$originalBundles = $this->configureBundles($originalConfig, true);
$recipeUpdate->setOriginalFile(
$this->getLocalConfFile(),
$this->buildContents($originalBundles)
);
$newBundles = $this->configureBundles($newConfig, true);
$recipeUpdate->setNewFile(
$this->getLocalConfFile(),
$this->buildContents($newBundles)
);
}
private function configureBundles(array $bundles, bool $resetEnvironments = false): array
{
$file = $this->getConfFile();
$registered = $this->load($file);
$classes = $this->prepareBundles($bundles);
if (isset($classes[$fwb = 'Symfony\Bundle\FrameworkBundle\FrameworkBundle'])) {
foreach ($classes[$fwb] as $env) {
$registered[$fwb][$env] = true;
}
unset($classes[$fwb]);
}
foreach ($classes as $class => $envs) {
// do not override existing configured envs for a bundle
if (!isset($registered[$class]) || $resetEnvironments) {
if ($resetEnvironments) {
// used during calculating an "upgrade"
// here, we want to "undo" the bundle's configuration entirely
// then re-add it fresh, in case some environments have been
// removed in an updated version of the recipe
$registered[$class] = [];
}
foreach ($envs as $env) {
$registered[$class][$env] = true;
}
}
}
return $registered;
}
private function prepareBundles(array $bundles): array
{
foreach ($bundles as $class => $envs) {
$bundles[ltrim($class, '\\')] = $envs;
}
return $bundles;
}
private function load(string $file): array
{
$bundles = file_exists($file) ? (require $file) : [];
if (!\is_array($bundles)) {
$bundles = [];
}
return $bundles;
}
private function dump(string $file, array $bundles)
{
$contents = $this->buildContents($bundles);
if (!is_dir(\dirname($file))) {
mkdir(\dirname($file), 0777, true);
}
file_put_contents($file, $contents);
if (\function_exists('opcache_invalidate')) {
@opcache_invalidate($file);
}
}
private function buildContents(array $bundles): string
{
$contents = "<?php\n\nreturn [\n";
foreach ($bundles as $class => $envs) {
$contents .= " $class::class => [";
foreach ($envs as $env => $value) {
$booleanValue = var_export($value, true);
$contents .= "'$env' => $booleanValue, ";
}
$contents = substr($contents, 0, -2)."],\n";
}
$contents .= "];\n";
return $contents;
}
private function getConfFile(): string
{
return $this->options->get('root-dir').'/'.$this->getLocalConfFile();
}
private function getLocalConfFile(): string
{
return $this->options->expandTargetDir('%CONFIG_DIR%/bundles.php');
}
}

View File

@@ -0,0 +1,73 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Symfony\Flex\Configurator;
use Composer\Factory;
use Composer\Json\JsonFile;
use Composer\Json\JsonManipulator;
use Symfony\Flex\Lock;
use Symfony\Flex\Recipe;
use Symfony\Flex\Update\RecipeUpdate;
/**
* @author Marcin Morawski <marcin@morawskim.pl>
*/
class ComposerCommandsConfigurator extends AbstractConfigurator
{
public function configure(Recipe $recipe, $scripts, Lock $lock, array $options = [])
{
$json = new JsonFile(Factory::getComposerFile());
file_put_contents($json->getPath(), $this->configureScripts($scripts, $json));
}
public function unconfigure(Recipe $recipe, $scripts, Lock $lock)
{
$json = new JsonFile(Factory::getComposerFile());
$manipulator = new JsonManipulator(file_get_contents($json->getPath()));
foreach ($scripts as $key => $command) {
$manipulator->removeSubNode('scripts', $key);
}
file_put_contents($json->getPath(), $manipulator->getContents());
}
public function update(RecipeUpdate $recipeUpdate, array $originalConfig, array $newConfig): void
{
$json = new JsonFile(Factory::getComposerFile());
$jsonPath = $json->getPath();
if (str_starts_with($jsonPath, $recipeUpdate->getRootDir())) {
$jsonPath = substr($jsonPath, \strlen($recipeUpdate->getRootDir()));
}
$jsonPath = ltrim($jsonPath, '/\\');
$recipeUpdate->setOriginalFile(
$jsonPath,
$this->configureScripts($originalConfig, $json)
);
$recipeUpdate->setNewFile(
$jsonPath,
$this->configureScripts($newConfig, $json)
);
}
private function configureScripts(array $scripts, JsonFile $json): string
{
$manipulator = new JsonManipulator(file_get_contents($json->getPath()));
foreach ($scripts as $cmdName => $script) {
$manipulator->addSubNode('scripts', $cmdName, $script);
}
return $manipulator->getContents();
}
}

View File

@@ -0,0 +1,79 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Symfony\Flex\Configurator;
use Composer\Factory;
use Composer\Json\JsonFile;
use Composer\Json\JsonManipulator;
use Symfony\Flex\Lock;
use Symfony\Flex\Recipe;
use Symfony\Flex\Update\RecipeUpdate;
/**
* @author Fabien Potencier <fabien@symfony.com>
*/
class ComposerScriptsConfigurator extends AbstractConfigurator
{
public function configure(Recipe $recipe, $scripts, Lock $lock, array $options = [])
{
$json = new JsonFile(Factory::getComposerFile());
file_put_contents($json->getPath(), $this->configureScripts($scripts, $json));
}
public function unconfigure(Recipe $recipe, $scripts, Lock $lock)
{
$json = new JsonFile(Factory::getComposerFile());
$jsonContents = $json->read();
$autoScripts = $jsonContents['scripts']['auto-scripts'] ?? [];
foreach (array_keys($scripts) as $cmd) {
unset($autoScripts[$cmd]);
}
$manipulator = new JsonManipulator(file_get_contents($json->getPath()));
$manipulator->addSubNode('scripts', 'auto-scripts', $autoScripts);
file_put_contents($json->getPath(), $manipulator->getContents());
}
public function update(RecipeUpdate $recipeUpdate, array $originalConfig, array $newConfig): void
{
$json = new JsonFile(Factory::getComposerFile());
$jsonPath = $json->getPath();
if (str_starts_with($jsonPath, $recipeUpdate->getRootDir())) {
$jsonPath = substr($jsonPath, \strlen($recipeUpdate->getRootDir()));
}
$jsonPath = ltrim($jsonPath, '/\\');
$recipeUpdate->setOriginalFile(
$jsonPath,
$this->configureScripts($originalConfig, $json)
);
$recipeUpdate->setNewFile(
$jsonPath,
$this->configureScripts($newConfig, $json)
);
}
private function configureScripts(array $scripts, JsonFile $json): string
{
$jsonContents = $json->read();
$autoScripts = $jsonContents['scripts']['auto-scripts'] ?? [];
$autoScripts = array_merge($autoScripts, $scripts);
$manipulator = new JsonManipulator(file_get_contents($json->getPath()));
$manipulator->addSubNode('scripts', 'auto-scripts', $autoScripts);
return $manipulator->getContents();
}
}

View File

@@ -0,0 +1,164 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Symfony\Flex\Configurator;
use Symfony\Flex\Lock;
use Symfony\Flex\Recipe;
use Symfony\Flex\Update\RecipeUpdate;
/**
* @author Fabien Potencier <fabien@symfony.com>
*/
class ContainerConfigurator extends AbstractConfigurator
{
public function configure(Recipe $recipe, $parameters, Lock $lock, array $options = [])
{
$this->write('Setting parameters');
$contents = $this->configureParameters($parameters);
if (null !== $contents) {
file_put_contents($this->options->get('root-dir').'/'.$this->getServicesPath(), $contents);
}
}
public function unconfigure(Recipe $recipe, $parameters, Lock $lock)
{
$this->write('Unsetting parameters');
$target = $this->options->get('root-dir').'/'.$this->getServicesPath();
$lines = $this->removeParametersFromLines(file($target), $parameters);
file_put_contents($target, implode('', $lines));
}
public function update(RecipeUpdate $recipeUpdate, array $originalConfig, array $newConfig): void
{
$recipeUpdate->setOriginalFile(
$this->getServicesPath(),
$this->configureParameters($originalConfig, true)
);
// for the new file, we need to update any values *and* remove any removed values
$removedParameters = [];
foreach ($originalConfig as $name => $value) {
if (!isset($newConfig[$name])) {
$removedParameters[$name] = $value;
}
}
$updatedFile = $this->configureParameters($newConfig, true);
$lines = $this->removeParametersFromLines(explode("\n", $updatedFile), $removedParameters);
$recipeUpdate->setNewFile(
$this->getServicesPath(),
implode("\n", $lines)
);
}
private function configureParameters(array $parameters, bool $update = false): string
{
$target = $this->options->get('root-dir').'/'.$this->getServicesPath();
$endAt = 0;
$isParameters = false;
$lines = [];
foreach (file($target) as $i => $line) {
$lines[] = $line;
if (!$isParameters && !preg_match('/^parameters:/', $line)) {
continue;
}
if (!$isParameters) {
$isParameters = true;
continue;
}
if (!preg_match('/^\s+.*/', $line) && '' !== trim($line)) {
$endAt = $i - 1;
$isParameters = false;
continue;
}
foreach ($parameters as $key => $value) {
$matches = [];
if (preg_match(\sprintf('/^\s+%s\:/', preg_quote($key, '/')), $line, $matches)) {
if ($update) {
$lines[$i] = substr($line, 0, \strlen($matches[0])).' '.str_replace("'", "''", $value)."\n";
}
unset($parameters[$key]);
}
}
}
if ($parameters) {
$parametersLines = [];
if (!$endAt) {
$parametersLines[] = "parameters:\n";
}
foreach ($parameters as $key => $value) {
if (\is_array($value)) {
$parametersLines[] = \sprintf(" %s:\n%s", $key, $this->dumpYaml(2, $value));
continue;
}
$parametersLines[] = \sprintf(" %s: '%s'%s", $key, str_replace("'", "''", $value), "\n");
}
if (!$endAt) {
$parametersLines[] = "\n";
}
array_splice($lines, $endAt, 0, $parametersLines);
}
return implode('', $lines);
}
private function removeParametersFromLines(array $sourceLines, array $parameters): array
{
$lines = [];
foreach ($sourceLines as $line) {
if ($this->removeParameters(1, $parameters, $line)) {
continue;
}
$lines[] = $line;
}
return $lines;
}
private function removeParameters($level, $params, $line)
{
foreach ($params as $key => $value) {
if (\is_array($value) && $this->removeParameters($level + 1, $value, $line)) {
return true;
}
if (preg_match(\sprintf('/^(\s{%d}|\t{%d})+%s\:/', 4 * $level, $level, preg_quote($key, '/')), $line)) {
return true;
}
}
return false;
}
private function dumpYaml($level, $array): string
{
$line = '';
foreach ($array as $key => $value) {
$line .= str_repeat(' ', $level);
if (!\is_array($value)) {
$line .= \sprintf("%s: '%s'\n", $key, str_replace("'", "''", $value));
continue;
}
$line .= \sprintf("%s:\n", $key).$this->dumpYaml($level + 1, $value);
}
return $line;
}
private function getServicesPath(): string
{
return $this->options->expandTargetDir('%CONFIG_DIR%/services.yaml');
}
}

View File

@@ -0,0 +1,168 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Symfony\Flex\Configurator;
use Symfony\Flex\Lock;
use Symfony\Flex\Recipe;
use Symfony\Flex\Update\RecipeUpdate;
/**
* @author Fabien Potencier <fabien@symfony.com>
*/
class CopyFromPackageConfigurator extends AbstractConfigurator
{
public function configure(Recipe $recipe, $config, Lock $lock, array $options = [])
{
$this->write('Copying files from package');
$packageDir = $this->composer->getInstallationManager()->getInstallPath($recipe->getPackage());
$options = array_merge($this->options->toArray(), $options);
$files = $this->getFilesToCopy($config, $packageDir);
foreach ($files as $source => $target) {
$this->copyFile($source, $target, $options);
}
}
public function unconfigure(Recipe $recipe, $config, Lock $lock)
{
$this->write('Removing files from package');
$packageDir = $this->composer->getInstallationManager()->getInstallPath($recipe->getPackage());
$this->removeFiles($config, $packageDir, $this->options->get('root-dir'));
}
public function update(RecipeUpdate $recipeUpdate, array $originalConfig, array $newConfig): void
{
$packageDir = $this->composer->getInstallationManager()->getInstallPath($recipeUpdate->getNewRecipe()->getPackage());
foreach ($originalConfig as $source => $target) {
if (isset($newConfig[$source])) {
// path is in both, we cannot update
$recipeUpdate->addCopyFromPackagePath(
$packageDir.'/'.$source,
$this->options->expandTargetDir($target)
);
unset($newConfig[$source]);
}
// if any paths were removed from the recipe, we'll keep them
}
// any remaining files are new, and we can copy them
foreach ($this->getFilesToCopy($newConfig, $packageDir) as $source => $target) {
if (!file_exists($source)) {
throw new \LogicException(\sprintf('File "%s" does not exist!', $source));
}
$recipeUpdate->setNewFile($target, file_get_contents($source));
}
}
private function getFilesToCopy(array $manifest, string $from): array
{
$files = [];
foreach ($manifest as $source => $target) {
$target = $this->options->expandTargetDir($target);
if ('/' === substr($source, -1)) {
$files = array_merge($files, $this->getFilesForDir($this->path->concatenate([$from, $source]), $target));
continue;
}
$files[$this->path->concatenate([$from, $source])] = $target;
}
return $files;
}
private function removeFiles(array $manifest, string $from, string $to)
{
foreach ($manifest as $source => $target) {
$target = $this->options->expandTargetDir($target);
if ('/' === substr($source, -1)) {
$this->removeFilesFromDir($this->path->concatenate([$from, $source]), $this->path->concatenate([$to, $target]));
} else {
$targetPath = $this->path->concatenate([$to, $target]);
if (file_exists($targetPath)) {
@unlink($targetPath);
$this->write(\sprintf(' Removed <fg=green>"%s"</>', $this->path->relativize($targetPath)));
}
}
}
}
private function getFilesForDir(string $source, string $target): array
{
$iterator = $this->createSourceIterator($source, \RecursiveIteratorIterator::SELF_FIRST);
$files = [];
foreach ($iterator as $item) {
$targetPath = $this->path->concatenate([$target, $iterator->getSubPathName()]);
$files[(string) $item] = $targetPath;
}
return $files;
}
/**
* @param string $source The absolute path to the source file
* @param string $target The relative (to root dir) path to the target
*/
public function copyFile(string $source, string $target, array $options)
{
$target = $this->options->get('root-dir').'/'.$this->options->expandTargetDir($target);
if (is_dir($source)) {
// directory will be created when a file is copied to it
return;
}
if (!$this->options->shouldWriteFile($target, $options['force'] ?? false, $options['assumeYesForPrompts'] ?? false)) {
return;
}
if (!file_exists($source)) {
throw new \LogicException(\sprintf('File "%s" does not exist!', $source));
}
if (!file_exists(\dirname($target))) {
mkdir(\dirname($target), 0777, true);
$this->write(\sprintf(' Created <fg=green>"%s"</>', $this->path->relativize(\dirname($target))));
}
file_put_contents($target, $this->options->expandTargetDir(file_get_contents($source)));
@chmod($target, fileperms($target) | (fileperms($source) & 0111));
$this->write(\sprintf(' Created <fg=green>"%s"</>', $this->path->relativize($target)));
}
private function removeFilesFromDir(string $source, string $target)
{
if (!is_dir($source)) {
return;
}
$iterator = $this->createSourceIterator($source, \RecursiveIteratorIterator::CHILD_FIRST);
foreach ($iterator as $item) {
$targetPath = $this->path->concatenate([$target, $iterator->getSubPathName()]);
if ($item->isDir()) {
// that removes the dir only if it is empty
@rmdir($targetPath);
$this->write(\sprintf(' Removed directory <fg=green>"%s"</>', $this->path->relativize($targetPath)));
} else {
@unlink($targetPath);
$this->write(\sprintf(' Removed <fg=green>"%s"</>', $this->path->relativize($targetPath)));
}
}
}
private function createSourceIterator(string $source, int $mode): \RecursiveIteratorIterator
{
return new \RecursiveIteratorIterator(new \RecursiveDirectoryIterator($source, \RecursiveDirectoryIterator::SKIP_DOTS), $mode);
}
}

View File

@@ -0,0 +1,149 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Symfony\Flex\Configurator;
use Symfony\Flex\Lock;
use Symfony\Flex\Recipe;
use Symfony\Flex\Update\RecipeUpdate;
/**
* @author Fabien Potencier <fabien@symfony.com>
*/
class CopyFromRecipeConfigurator extends AbstractConfigurator
{
public function configure(Recipe $recipe, $config, Lock $lock, array $options = [])
{
$this->write('Copying files from recipe');
$options = array_merge($this->options->toArray(), $options);
$lock->add($recipe->getName(), ['files' => $this->copyFiles($config, $recipe->getFiles(), $options)]);
}
public function unconfigure(Recipe $recipe, $config, Lock $lock)
{
$this->write('Removing files from recipe');
$rootDir = $this->options->get('root-dir');
foreach ($this->options->getRemovableFiles($recipe, $lock) as $file) {
if ('.git' !== $file) { // never remove the main Git directory, even if it was created by a recipe
$this->removeFile($this->path->concatenate([$rootDir, $file]));
}
}
}
public function update(RecipeUpdate $recipeUpdate, array $originalConfig, array $newConfig): void
{
foreach ($recipeUpdate->getOriginalRecipe()->getFiles() as $filename => $data) {
$filename = $this->resolveTargetFolder($filename, $originalConfig);
$recipeUpdate->setOriginalFile($filename, $data['contents']);
}
$files = [];
foreach ($recipeUpdate->getNewRecipe()->getFiles() as $filename => $data) {
$filename = $this->resolveTargetFolder($filename, $newConfig);
$recipeUpdate->setNewFile($filename, $data['contents']);
$files[] = $this->getLocalFilePath($recipeUpdate->getRootDir(), $filename);
}
$recipeUpdate->getLock()->add($recipeUpdate->getPackageName(), ['files' => $files]);
}
/**
* @param array<string, string> $config
*/
private function resolveTargetFolder(string $path, array $config): string
{
foreach ($config as $key => $target) {
if (str_starts_with($path, $key)) {
return $this->options->expandTargetDir($target).substr($path, \strlen($key));
}
}
return $path;
}
private function copyFiles(array $manifest, array $files, array $options): array
{
$copiedFiles = [];
$to = $options['root-dir'] ?? '.';
foreach ($manifest as $source => $target) {
$target = $this->options->expandTargetDir($target);
if ('/' === substr($source, -1)) {
$copiedFiles = array_merge(
$copiedFiles,
$this->copyDir($source, $this->path->concatenate([$to, $target]), $files, $options)
);
} else {
$copiedFiles[] = $this->copyFile($this->path->concatenate([$to, $target]), $files[$source]['contents'], $files[$source]['executable'], $options);
}
}
return $copiedFiles;
}
private function copyDir(string $source, string $target, array $files, array $options): array
{
$copiedFiles = [];
foreach ($files as $file => $data) {
if (str_starts_with($file, $source)) {
$file = $this->path->concatenate([$target, substr($file, \strlen($source))]);
$copiedFiles[] = $this->copyFile($file, $data['contents'], $data['executable'], $options);
}
}
return $copiedFiles;
}
private function copyFile(string $to, string $contents, bool $executable, array $options): string
{
$basePath = $options['root-dir'] ?? '.';
$copiedFile = $this->getLocalFilePath($basePath, $to);
if (!$this->options->shouldWriteFile($to, $options['force'] ?? false, $options['assumeYesForPrompts'] ?? false)) {
return $copiedFile;
}
if (!is_dir(\dirname($to))) {
mkdir(\dirname($to), 0777, true);
}
file_put_contents($to, $this->options->expandTargetDir($contents));
if ($executable) {
@chmod($to, fileperms($to) | 0111);
}
$this->write(\sprintf(' Created <fg=green>"%s"</>', $this->path->relativize($to)));
return $copiedFile;
}
private function removeFile(string $to)
{
if (!file_exists($to)) {
return;
}
@unlink($to);
$this->write(\sprintf(' Removed <fg=green>"%s"</>', $this->path->relativize($to)));
if (0 === \count(glob(\dirname($to).'/*', \GLOB_NOSORT))) {
@rmdir(\dirname($to));
}
}
private function getLocalFilePath(string $basePath, $destination): string
{
return str_replace($basePath.\DIRECTORY_SEPARATOR, '', $destination);
}
}

View File

@@ -0,0 +1,404 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Symfony\Flex\Configurator;
use Composer\Composer;
use Composer\Factory;
use Composer\IO\IOInterface;
use Composer\Json\JsonFile;
use Composer\Json\JsonManipulator;
use Symfony\Component\Filesystem\Filesystem;
use Symfony\Flex\Lock;
use Symfony\Flex\Options;
use Symfony\Flex\Recipe;
use Symfony\Flex\Update\RecipeUpdate;
/**
* Adds services and volumes to compose.yaml file.
*
* @author Kévin Dunglas <kevin@dunglas.dev>
*/
class DockerComposeConfigurator extends AbstractConfigurator
{
private $filesystem;
public static $configureDockerRecipes;
public function __construct(Composer $composer, IOInterface $io, Options $options)
{
parent::__construct($composer, $io, $options);
$this->filesystem = new Filesystem();
}
public function configure(Recipe $recipe, $config, Lock $lock, array $options = [])
{
if (!self::shouldConfigureDockerRecipe($this->composer, $this->io, $recipe)) {
return;
}
$this->configureDockerCompose($recipe, $config, $options['force'] ?? false);
$this->write('Docker Compose definitions have been modified. Please run "docker compose up --build" again to apply the changes.');
}
public function unconfigure(Recipe $recipe, $config, Lock $lock)
{
$rootDir = $this->options->get('root-dir');
foreach ($this->normalizeConfig($config) as $file => $extra) {
if (null === $dockerComposeFile = $this->findDockerComposeFile($rootDir, $file)) {
continue;
}
$name = $recipe->getName();
// Remove recipe and add break line
$contents = preg_replace(\sprintf('{%s+###> %s ###.*?###< %s ###%s+}s', "\n", $name, $name, "\n"), \PHP_EOL.\PHP_EOL, file_get_contents($dockerComposeFile), -1, $count);
if (!$count) {
return;
}
foreach ($extra as $key => $value) {
if (0 === preg_match(\sprintf('{^%s:[ \t\r\n]*([ \t]+\w|#)}m', $key), $contents, $matches)) {
$contents = preg_replace(\sprintf('{\n?^%s:[ \t\r\n]*}sm', $key), '', $contents, -1, $count);
}
}
$this->write(\sprintf('Removing Docker Compose entries from "%s"', $dockerComposeFile));
file_put_contents($dockerComposeFile, ltrim($contents, "\n"));
}
$this->write('Docker Compose definitions have been modified. Please run "docker compose up" again to apply the changes.');
}
public function update(RecipeUpdate $recipeUpdate, array $originalConfig, array $newConfig): void
{
if (!self::shouldConfigureDockerRecipe($this->composer, $this->io, $recipeUpdate->getNewRecipe())) {
return;
}
$recipeUpdate->addOriginalFiles(
$this->getContentsAfterApplyingRecipe($recipeUpdate->getRootDir(), $recipeUpdate->getOriginalRecipe(), $originalConfig)
);
$recipeUpdate->addNewFiles(
$this->getContentsAfterApplyingRecipe($recipeUpdate->getRootDir(), $recipeUpdate->getNewRecipe(), $newConfig)
);
}
public static function shouldConfigureDockerRecipe(Composer $composer, IOInterface $io, Recipe $recipe): bool
{
if (null !== self::$configureDockerRecipes) {
return self::$configureDockerRecipes;
}
if (null !== $dockerPreference = $composer->getPackage()->getExtra()['symfony']['docker'] ?? null) {
self::$configureDockerRecipes = filter_var($dockerPreference, \FILTER_VALIDATE_BOOLEAN);
return self::$configureDockerRecipes;
}
if ('install' !== $recipe->getJob()) {
// default to not configuring
return false;
}
if (!isset($_SERVER['SYMFONY_DOCKER'])) {
$answer = self::askDockerSupport($io, $recipe);
} elseif (filter_var($_SERVER['SYMFONY_DOCKER'], \FILTER_VALIDATE_BOOLEAN)) {
$answer = 'p';
} else {
$answer = 'x';
}
if ('n' === $answer) {
self::$configureDockerRecipes = false;
return self::$configureDockerRecipes;
}
if ('y' === $answer) {
self::$configureDockerRecipes = true;
return self::$configureDockerRecipes;
}
// yes or no permanently
self::$configureDockerRecipes = 'p' === $answer;
$json = new JsonFile(Factory::getComposerFile());
$manipulator = new JsonManipulator(file_get_contents($json->getPath()));
$manipulator->addSubNode('extra', 'symfony.docker', self::$configureDockerRecipes);
file_put_contents($json->getPath(), $manipulator->getContents());
return self::$configureDockerRecipes;
}
/**
* Normalizes the config and return the name of the main Docker Compose file if applicable.
*/
private function normalizeConfig(array $config): array
{
foreach ($config as $key => $val) {
// Support for the short recipe syntax that modifies compose.yaml only
if (isset($val[0])) {
return ['compose.yaml' => $config];
}
if (!str_starts_with($key, 'docker-')) {
continue;
}
// If the recipe still use the legacy "docker-compose.yml" names, remove the "docker-" prefix and change the extension
$newKey = pathinfo(substr($key, 7), \PATHINFO_FILENAME).'.yaml';
$config[$newKey] = $val;
unset($config[$key]);
}
return $config;
}
/**
* Finds the Docker Compose file according to these rules: https://docs.docker.com/compose/reference/envvars/#compose_file.
*/
private function findDockerComposeFile(string $rootDir, string $file): ?string
{
if (isset($_SERVER['COMPOSE_FILE'])) {
$filenameToFind = pathinfo($file, \PATHINFO_FILENAME);
$separator = $_SERVER['COMPOSE_PATH_SEPARATOR'] ?? ('\\' === \DIRECTORY_SEPARATOR ? ';' : ':');
$files = explode($separator, $_SERVER['COMPOSE_FILE']);
foreach ($files as $f) {
$filename = pathinfo($f, \PATHINFO_FILENAME);
if ($filename !== $filenameToFind && "docker-$filenameToFind" !== $filename) {
continue;
}
if (!$this->filesystem->isAbsolutePath($f)) {
$f = realpath(\sprintf('%s/%s', $rootDir, $f));
}
if ($this->filesystem->exists($f)) {
return $f;
}
}
}
// COMPOSE_FILE not set, or doesn't contain the file we're looking for
$dir = $rootDir;
do {
if (
$this->filesystem->exists($dockerComposeFile = \sprintf('%s/%s', $dir, $file))
// Test with the ".yml" extension if the file doesn't end up with ".yaml"
|| $this->filesystem->exists($dockerComposeFile = substr($dockerComposeFile, 0, -3).'ml')
// Test with the legacy "docker-" suffix if "compose.ya?ml" doesn't exist
|| $this->filesystem->exists($dockerComposeFile = \sprintf('%s/docker-%s', $dir, $file))
|| $this->filesystem->exists($dockerComposeFile = substr($dockerComposeFile, 0, -3).'ml')
) {
return $dockerComposeFile;
}
$previousDir = $dir;
$dir = \dirname($dir);
} while ($dir !== $previousDir);
return null;
}
private function parse($level, $indent, $services): string
{
$line = '';
foreach ($services as $key => $value) {
$line .= str_repeat(' ', $indent * $level);
if (!\is_array($value)) {
if (\is_string($key)) {
$line .= \sprintf('%s:', $key);
}
$line .= \sprintf("%s\n", $value);
continue;
}
$line .= \sprintf("%s:\n", $key).$this->parse($level + 1, $indent, $value);
}
return $line;
}
private function configureDockerCompose(Recipe $recipe, array $config, bool $update): void
{
$rootDir = $this->options->get('root-dir');
foreach ($this->normalizeConfig($config) as $file => $extra) {
$dockerComposeFile = $this->findDockerComposeFile($rootDir, $file);
if (null === $dockerComposeFile) {
$dockerComposeFile = $rootDir.'/'.$file;
file_put_contents($dockerComposeFile, '');
$this->write(\sprintf(' Created <fg=green>"%s"</>', $file));
}
if (!$update && $this->isFileMarked($recipe, $dockerComposeFile)) {
continue;
}
$this->write(\sprintf('Adding Docker Compose definitions to "%s"', $dockerComposeFile));
$offset = 2;
$node = null;
$endAt = [];
$startAt = [];
$lines = [];
$nodesLines = [];
foreach (file($dockerComposeFile) as $i => $line) {
$lines[] = $line;
$ltrimedLine = ltrim($line, ' ');
if (null !== $node) {
$nodesLines[$node][$i] = $line;
}
// Skip blank lines and comments
if (('' !== $ltrimedLine && str_starts_with($ltrimedLine, '#')) || '' === trim($line)) {
continue;
}
// Extract Docker Compose keys (usually "services" and "volumes")
if (!preg_match('/^[\'"]?([a-zA-Z0-9]+)[\'"]?:\s*$/', $line, $matches)) {
// Detect indentation to use
$offestLine = \strlen($line) - \strlen($ltrimedLine);
if ($offset > $offestLine && 0 !== $offestLine) {
$offset = $offestLine;
}
continue;
}
// Keep end in memory (check break line on previous line)
$endAt[$node] = !$i || '' !== trim($lines[$i - 1]) ? $i : $i - 1;
$node = $matches[1];
if (!isset($nodesLines[$node])) {
$nodesLines[$node] = [];
}
if (!isset($startAt[$node])) {
// the section contents starts at the next line
$startAt[$node] = $i + 1;
}
}
$endAt[$node] = \count($lines) + 1;
foreach ($extra as $key => $value) {
if (isset($endAt[$key])) {
$data = $this->markData($recipe, $this->parse(1, $offset, $value));
$updatedContents = $this->updateDataString(implode('', $nodesLines[$key]), $data);
if (null === $updatedContents) {
// not an update: just add to section
array_splice($lines, $endAt[$key], 0, $data);
continue;
}
$originalEndAt = $endAt[$key];
$length = $endAt[$key] - $startAt[$key];
array_splice($lines, $startAt[$key], $length, ltrim($updatedContents, "\n"));
// reset any start/end positions after this to the new positions
foreach ($startAt as $sectionKey => $at) {
if ($at > $originalEndAt) {
$startAt[$sectionKey] = $at - $length - 1;
}
}
foreach ($endAt as $sectionKey => $at) {
if ($at > $originalEndAt) {
$endAt[$sectionKey] = $at - $length;
}
}
continue;
}
$lines[] = \sprintf("\n%s:", $key);
$lines[] = $this->markData($recipe, $this->parse(1, $offset, $value));
}
file_put_contents($dockerComposeFile, implode('', $lines));
}
}
private function getContentsAfterApplyingRecipe(string $rootDir, Recipe $recipe, array $config): array
{
if (0 === \count($config)) {
return [];
}
$files = array_filter(array_map(function ($file) use ($rootDir) {
return $this->findDockerComposeFile($rootDir, $file);
}, array_keys($config)));
$originalContents = [];
foreach ($files as $file) {
$originalContents[$file] = file_exists($file) ? file_get_contents($file) : null;
}
$this->configureDockerCompose(
$recipe,
$config,
true
);
$updatedContents = [];
foreach ($files as $file) {
$localPath = $file;
if (str_starts_with($file, $rootDir)) {
$localPath = substr($file, \strlen($rootDir) + 1);
}
$localPath = ltrim($localPath, '/\\');
$updatedContents[$localPath] = file_exists($file) ? file_get_contents($file) : null;
}
foreach ($originalContents as $file => $contents) {
if (null === $contents) {
if (file_exists($file)) {
unlink($file);
}
} else {
file_put_contents($file, $contents);
}
}
return $updatedContents;
}
private static function askDockerSupport(IOInterface $io, Recipe $recipe): string
{
$warning = $io->isInteractive() ? 'WARNING' : 'IGNORING';
$io->writeError(\sprintf(' - <warning> %s </> %s', $warning, $recipe->getFormattedOrigin()));
$question = ' The recipe for this package contains some Docker configuration.
This may create/update <comment>compose.yaml</comment> or update <comment>Dockerfile</comment> (if it exists).
Do you want to include Docker configuration from recipes?
[<comment>y</>] Yes
[<comment>n</>] No
[<comment>p</>] Yes permanently, never ask again for this project
[<comment>x</>] No permanently, never ask again for this project
(defaults to <comment>y</>): ';
return $io->askAndValidate(
$question,
function ($value) {
if (null === $value) {
return 'y';
}
$value = strtolower($value[0]);
if (!\in_array($value, ['y', 'n', 'p', 'x'], true)) {
throw new \InvalidArgumentException('Invalid choice.');
}
return $value;
},
null,
'y'
);
}
}

View File

@@ -0,0 +1,125 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Symfony\Flex\Configurator;
use Symfony\Flex\Lock;
use Symfony\Flex\Recipe;
use Symfony\Flex\Update\RecipeUpdate;
/**
* Adds commands to a Dockerfile.
*
* @author Kévin Dunglas <dunglas@gmail.com>
*/
class DockerfileConfigurator extends AbstractConfigurator
{
public function configure(Recipe $recipe, $config, Lock $lock, array $options = [])
{
if (!DockerComposeConfigurator::shouldConfigureDockerRecipe($this->composer, $this->io, $recipe)) {
return;
}
$this->configureDockerfile($recipe, $config, $options['force'] ?? false);
}
public function unconfigure(Recipe $recipe, $config, Lock $lock)
{
if (!file_exists($dockerfile = $this->options->get('root-dir').'/Dockerfile')) {
return;
}
$name = $recipe->getName();
$contents = preg_replace(\sprintf('{%s+###> %s ###.*?###< %s ###%s+}s', "\n", $name, $name, "\n"), "\n", file_get_contents($dockerfile), -1, $count);
if (!$count) {
return;
}
$this->write('Removing Dockerfile entries');
file_put_contents($dockerfile, ltrim($contents, "\n"));
}
public function update(RecipeUpdate $recipeUpdate, array $originalConfig, array $newConfig): void
{
if (!DockerComposeConfigurator::shouldConfigureDockerRecipe($this->composer, $this->io, $recipeUpdate->getNewRecipe())) {
return;
}
$recipeUpdate->setOriginalFile(
'Dockerfile',
$this->getContentsAfterApplyingRecipe($recipeUpdate->getOriginalRecipe(), $originalConfig)
);
$recipeUpdate->setNewFile(
'Dockerfile',
$this->getContentsAfterApplyingRecipe($recipeUpdate->getNewRecipe(), $newConfig)
);
}
private function configureDockerfile(Recipe $recipe, array $config, bool $update, bool $writeOutput = true): void
{
$dockerfile = $this->options->get('root-dir').'/Dockerfile';
if (!file_exists($dockerfile) || (!$update && $this->isFileMarked($recipe, $dockerfile))) {
return;
}
if ($writeOutput) {
$this->write('Adding Dockerfile entries');
}
$data = ltrim($this->markData($recipe, implode("\n", $config)), "\n");
if ($this->updateData($dockerfile, $data)) {
// done! Existing spot updated
return;
}
$lines = [];
foreach (file($dockerfile) as $line) {
$lines[] = $line;
if (!preg_match('/^###> recipes ###$/', $line)) {
continue;
}
$lines[] = $data;
}
file_put_contents($dockerfile, implode('', $lines));
}
private function getContentsAfterApplyingRecipe(Recipe $recipe, array $config): ?string
{
if (0 === \count($config)) {
return null;
}
$dockerfile = $this->options->get('root-dir').'/Dockerfile';
$originalContents = file_exists($dockerfile) ? file_get_contents($dockerfile) : null;
$this->configureDockerfile(
$recipe,
$config,
true,
false
);
$updatedContents = file_exists($dockerfile) ? file_get_contents($dockerfile) : null;
if (null === $originalContents) {
if (file_exists($dockerfile)) {
unlink($dockerfile);
}
} else {
file_put_contents($dockerfile, $originalContents);
}
return $updatedContents;
}
}

View File

@@ -0,0 +1,50 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Symfony\Flex\Configurator;
use Symfony\Flex\Lock;
use Symfony\Flex\Recipe;
use Symfony\Flex\Update\RecipeUpdate;
class DotenvConfigurator extends AbstractConfigurator
{
public function configure(Recipe $recipe, $vars, Lock $lock, array $options = [])
{
foreach ($vars as $suffix => $vars) {
$configurator = new EnvConfigurator($this->composer, $this->io, $this->options, $suffix);
$configurator->configure($recipe, $vars, $lock, $options);
}
}
public function unconfigure(Recipe $recipe, $vars, Lock $lock)
{
foreach ($vars as $suffix => $vars) {
$configurator = new EnvConfigurator($this->composer, $this->io, $this->options, $suffix);
$configurator->unconfigure($recipe, $vars, $lock);
}
}
public function update(RecipeUpdate $recipeUpdate, array $originalConfig, array $newConfig): void
{
foreach ($originalConfig as $suffix => $vars) {
$configurator = new EnvConfigurator($this->composer, $this->io, $this->options, $suffix);
$configurator->update($recipeUpdate, $vars, $newConfig[$suffix] ?? []);
}
foreach ($newConfig as $suffix => $vars) {
if (!isset($originalConfig[$suffix])) {
$configurator = new EnvConfigurator($this->composer, $this->io, $this->options, $suffix);
$configurator->update($recipeUpdate, [], $vars);
}
}
}
}

View File

@@ -0,0 +1,295 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Symfony\Flex\Configurator;
use Composer\Composer;
use Composer\IO\IOInterface;
use Symfony\Flex\Lock;
use Symfony\Flex\Options;
use Symfony\Flex\Recipe;
use Symfony\Flex\Update\RecipeUpdate;
/**
* @author Fabien Potencier <fabien@symfony.com>
*/
class EnvConfigurator extends AbstractConfigurator
{
private string $suffix;
public function __construct(Composer $composer, IOInterface $io, Options $options, string $suffix = '')
{
parent::__construct($composer, $io, $options);
$this->suffix = $suffix;
}
public function configure(Recipe $recipe, $vars, Lock $lock, array $options = [])
{
$this->write('Adding environment variable defaults'.('' === $this->suffix ? '' : ' ('.$this->suffix.')'));
$this->configureEnvDist($recipe, $vars, $options['force'] ?? false);
if ('' !== $this->suffix) {
return;
}
if (!file_exists($this->options->get('root-dir').'/'.($this->options->get('runtime')['dotenv_path'] ?? '.env').'.test')) {
$this->configurePhpUnit($recipe, $vars, $options['force'] ?? false);
}
}
public function unconfigure(Recipe $recipe, $vars, Lock $lock)
{
$this->unconfigureEnvFiles($recipe, $vars);
$this->unconfigurePhpUnit($recipe, $vars);
}
public function update(RecipeUpdate $recipeUpdate, array $originalConfig, array $newConfig): void
{
$recipeUpdate->addOriginalFiles(
$this->getContentsAfterApplyingRecipe($recipeUpdate->getRootDir(), $recipeUpdate->getOriginalRecipe(), $originalConfig)
);
$recipeUpdate->addNewFiles(
$this->getContentsAfterApplyingRecipe($recipeUpdate->getRootDir(), $recipeUpdate->getNewRecipe(), $newConfig)
);
}
private function configureEnvDist(Recipe $recipe, $vars, bool $update)
{
$dotenvPath = $this->options->get('runtime')['dotenv_path'] ?? '.env';
$files = '' === $this->suffix ? [$dotenvPath.'.dist', $dotenvPath] : [$dotenvPath.'.'.$this->suffix];
foreach ($files as $file) {
$env = $this->options->get('root-dir').'/'.$file;
if (!is_file($env)) {
continue;
}
if (!$update && $this->isFileMarked($recipe, $env)) {
continue;
}
$data = '';
foreach ($vars as $key => $value) {
$existingValue = $update ? $this->findExistingValue($key, $env, $recipe) : null;
$value = $this->evaluateValue($value, $existingValue);
if ('#' === $key[0] && is_numeric(substr($key, 1))) {
if ('' === $value) {
$data .= "#\n";
} else {
$data .= '# '.$value."\n";
}
continue;
}
$value = $this->options->expandTargetDir($value);
if (false !== strpbrk($value, " \t\n&!\"")) {
$value = '"'.str_replace(['\\', '"', "\t", "\n"], ['\\\\', '\\"', '\t', '\n'], $value).'"';
}
$data .= "$key=$value\n";
}
$data = $this->markData($recipe, $data);
if (!$this->updateData($env, $data)) {
file_put_contents($env, $data, \FILE_APPEND);
}
}
}
private function configurePhpUnit(Recipe $recipe, $vars, bool $update)
{
foreach (['phpunit.xml.dist', 'phpunit.dist.xml', 'phpunit.xml'] as $file) {
$phpunit = $this->options->get('root-dir').'/'.$file;
if (!is_file($phpunit)) {
continue;
}
if (!$update && $this->isFileXmlMarked($recipe, $phpunit)) {
continue;
}
$data = '';
foreach ($vars as $key => $value) {
$value = $this->evaluateValue($value);
if ('#' === $key[0]) {
if (is_numeric(substr($key, 1))) {
$doc = new \DOMDocument();
$data .= ' '.$doc->saveXML($doc->createComment(' '.$value.' '))."\n";
} else {
$value = $this->options->expandTargetDir($value);
$doc = new \DOMDocument();
$fragment = $doc->createElement('env');
$fragment->setAttribute('name', substr($key, 1));
$fragment->setAttribute('value', $value);
$data .= ' '.str_replace(['<', '/>'], ['<!-- ', ' -->'], $doc->saveXML($fragment))."\n";
}
} else {
$value = $this->options->expandTargetDir($value);
$doc = new \DOMDocument();
$fragment = $doc->createElement('env');
$fragment->setAttribute('name', $key);
$fragment->setAttribute('value', $value);
$data .= ' '.$doc->saveXML($fragment)."\n";
}
}
$data = $this->markXmlData($recipe, $data);
if (!$this->updateData($phpunit, $data)) {
file_put_contents($phpunit, preg_replace('{^(\s+</php>)}m', $data.'$1', file_get_contents($phpunit)));
}
}
}
private function unconfigureEnvFiles(Recipe $recipe, $vars)
{
$dotenvPath = $this->options->get('runtime')['dotenv_path'] ?? '.env';
$files = '' === $this->suffix ? [$dotenvPath, $dotenvPath.'.dist'] : [$dotenvPath.'.'.$this->suffix];
foreach ($files as $file) {
$env = $this->options->get('root-dir').'/'.$file;
if (!file_exists($env)) {
continue;
}
$contents = preg_replace(\sprintf('{%s*###> %s ###.*###< %s ###%s+}s', "\n", $recipe->getName(), $recipe->getName(), "\n"), "\n", file_get_contents($env), -1, $count);
if (!$count) {
continue;
}
$this->write(\sprintf('Removing environment variables from %s', $file));
file_put_contents($env, $contents);
}
}
private function unconfigurePhpUnit(Recipe $recipe, $vars)
{
foreach (['phpunit.dist.xml', 'phpunit.xml.dist', 'phpunit.xml'] as $file) {
$phpunit = $this->options->get('root-dir').'/'.$file;
if (!is_file($phpunit)) {
continue;
}
$contents = preg_replace(\sprintf('{%s*\s+<!-- ###\+ %s ### -->.*<!-- ###- %s ### -->%s+}s', "\n", $recipe->getName(), $recipe->getName(), "\n"), "\n", file_get_contents($phpunit), -1, $count);
if (!$count) {
continue;
}
$this->write(\sprintf('Removing environment variables from %s', $file));
file_put_contents($phpunit, $contents);
}
}
/**
* Evaluates expressions like %generate(secret)%.
*
* If $originalValue is passed, and the value contains an expression.
* the $originalValue is used.
*/
private function evaluateValue($value, ?string $originalValue = null)
{
if ('%generate(secret)%' === $value) {
if (null !== $originalValue) {
return $originalValue;
}
return $this->generateRandomBytes();
}
if (preg_match('~^%generate\(secret,\s*([0-9]+)\)%$~', $value, $matches)) {
if (null !== $originalValue) {
return $originalValue;
}
return $this->generateRandomBytes($matches[1]);
}
return $value;
}
private function generateRandomBytes($length = 16)
{
return bin2hex(random_bytes($length));
}
private function getContentsAfterApplyingRecipe(string $rootDir, Recipe $recipe, array $vars): array
{
$dotenvPath = $this->options->get('runtime')['dotenv_path'] ?? '.env';
$files = '' === $this->suffix ? [$dotenvPath, $dotenvPath.'.dist', 'phpunit.dist.xml', 'phpunit.xml.dist', 'phpunit.xml'] : [$dotenvPath.'.'.$this->suffix];
if (0 === \count($vars)) {
return array_fill_keys($files, null);
}
$originalContents = [];
foreach ($files as $file) {
$originalContents[$file] = file_exists($rootDir.'/'.$file) ? file_get_contents($rootDir.'/'.$file) : null;
}
$this->configureEnvDist(
$recipe,
$vars,
true
);
if ('' === $this->suffix && !file_exists($rootDir.'/'.$dotenvPath.'.test')) {
$this->configurePhpUnit(
$recipe,
$vars,
true
);
}
$updatedContents = [];
foreach ($files as $file) {
$updatedContents[$file] = file_exists($rootDir.'/'.$file) ? file_get_contents($rootDir.'/'.$file) : null;
}
foreach ($originalContents as $file => $contents) {
if (null === $contents) {
if (file_exists($rootDir.'/'.$file)) {
unlink($rootDir.'/'.$file);
}
} else {
file_put_contents($rootDir.'/'.$file, $contents);
}
}
return $updatedContents;
}
/**
* Attempts to find the existing value of an environment variable.
*/
private function findExistingValue(string $var, string $filename, Recipe $recipe): ?string
{
if (!file_exists($filename)) {
return null;
}
$contents = file_get_contents($filename);
$section = $this->extractSection($recipe, $contents);
if (!$section) {
return null;
}
$lines = explode("\n", $section);
foreach ($lines as $line) {
if (!str_starts_with($line, \sprintf('%s=', $var))) {
continue;
}
return trim(substr($line, \strlen($var) + 1));
}
return null;
}
}

View File

@@ -0,0 +1,105 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Symfony\Flex\Configurator;
use Symfony\Flex\Lock;
use Symfony\Flex\Recipe;
use Symfony\Flex\Update\RecipeUpdate;
/**
* @author Fabien Potencier <fabien@symfony.com>
*/
class GitignoreConfigurator extends AbstractConfigurator
{
public function configure(Recipe $recipe, $vars, Lock $lock, array $options = [])
{
$this->write('Adding entries to .gitignore');
$this->configureGitignore($recipe, $vars, $options['force'] ?? false);
}
public function unconfigure(Recipe $recipe, $vars, Lock $lock)
{
$file = $this->options->get('root-dir').'/.gitignore';
if (!file_exists($file)) {
return;
}
$contents = preg_replace(\sprintf('{%s*###> %s ###.*###< %s ###%s+}s', "\n", $recipe->getName(), $recipe->getName(), "\n"), "\n", file_get_contents($file), -1, $count);
if (!$count) {
return;
}
$this->write('Removing entries in .gitignore');
file_put_contents($file, ltrim($contents, "\r\n"));
}
public function update(RecipeUpdate $recipeUpdate, array $originalConfig, array $newConfig): void
{
$recipeUpdate->setOriginalFile(
'.gitignore',
$this->getContentsAfterApplyingRecipe($recipeUpdate->getRootDir(), $recipeUpdate->getOriginalRecipe(), $originalConfig)
);
$recipeUpdate->setNewFile(
'.gitignore',
$this->getContentsAfterApplyingRecipe($recipeUpdate->getRootDir(), $recipeUpdate->getNewRecipe(), $newConfig)
);
}
private function configureGitignore(Recipe $recipe, array $vars, bool $update)
{
$gitignore = $this->options->get('root-dir').'/.gitignore';
if (!$update && $this->isFileMarked($recipe, $gitignore)) {
return;
}
$data = '';
foreach ($vars as $value) {
$value = $this->options->expandTargetDir($value);
$data .= "$value\n";
}
$data = "\n".ltrim($this->markData($recipe, $data), "\r\n");
if (!$this->updateData($gitignore, $data)) {
file_put_contents($gitignore, $data, \FILE_APPEND);
}
}
private function getContentsAfterApplyingRecipe(string $rootDir, Recipe $recipe, $vars): ?string
{
if (0 === \count($vars)) {
return null;
}
$file = $rootDir.'/.gitignore';
$originalContents = file_exists($file) ? file_get_contents($file) : null;
$this->configureGitignore(
$recipe,
$vars,
true
);
$updatedContents = file_exists($file) ? file_get_contents($file) : null;
if (null === $originalContents) {
if (file_exists($file)) {
unlink($file);
}
} else {
file_put_contents($file, $originalContents);
}
return $updatedContents;
}
}

View File

@@ -0,0 +1,124 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Symfony\Flex\Configurator;
use Symfony\Flex\Lock;
use Symfony\Flex\Recipe;
use Symfony\Flex\Update\RecipeUpdate;
/**
* @author Fabien Potencier <fabien@symfony.com>
*/
class MakefileConfigurator extends AbstractConfigurator
{
public function configure(Recipe $recipe, $definitions, Lock $lock, array $options = [])
{
$this->write('Adding Makefile entries');
$this->configureMakefile($recipe, $definitions, $options['force'] ?? false);
}
public function unconfigure(Recipe $recipe, $vars, Lock $lock)
{
if (!file_exists($makefile = $this->options->get('root-dir').'/Makefile')) {
return;
}
$contents = preg_replace(\sprintf('{%s*###> %s ###.*###< %s ###%s+}s', "\n", $recipe->getName(), $recipe->getName(), "\n"), "\n", file_get_contents($makefile), -1, $count);
if (!$count) {
return;
}
$this->write(\sprintf('Removing Makefile entries from %s', $makefile));
if (!trim($contents)) {
@unlink($makefile);
} else {
file_put_contents($makefile, ltrim($contents, "\r\n"));
}
}
public function update(RecipeUpdate $recipeUpdate, array $originalConfig, array $newConfig): void
{
$recipeUpdate->setOriginalFile(
'Makefile',
$this->getContentsAfterApplyingRecipe($recipeUpdate->getRootDir(), $recipeUpdate->getOriginalRecipe(), $originalConfig)
);
$recipeUpdate->setNewFile(
'Makefile',
$this->getContentsAfterApplyingRecipe($recipeUpdate->getRootDir(), $recipeUpdate->getNewRecipe(), $newConfig)
);
}
private function configureMakefile(Recipe $recipe, array $definitions, bool $update)
{
$makefile = $this->options->get('root-dir').'/Makefile';
if (!$update && $this->isFileMarked($recipe, $makefile)) {
return;
}
$data = $this->options->expandTargetDir(implode("\n", $definitions));
$data = $this->markData($recipe, $data);
$data = "\n".ltrim($data, "\r\n");
if (!file_exists($makefile)) {
$envKey = $this->options->get('runtime')['env_var_name'] ?? 'APP_ENV';
$dotenvPath = $this->options->get('runtime')['dotenv_path'] ?? '.env';
file_put_contents(
$this->options->get('root-dir').'/Makefile',
<<<EOF
ifndef {$envKey}
include {$dotenvPath}
endif
.DEFAULT_GOAL := help
.PHONY: help
help:
@awk 'BEGIN {FS = ":.*?## "}; /^[a-zA-Z-]+:.*?## .*$$/ {printf "\033[32m%-15s\033[0m %s\\n", $$1, $$2}' Makefile | sort
EOF
);
}
if (!$this->updateData($makefile, $data)) {
file_put_contents($makefile, $data, \FILE_APPEND);
}
}
private function getContentsAfterApplyingRecipe(string $rootDir, Recipe $recipe, array $definitions): ?string
{
if (0 === \count($definitions)) {
return null;
}
$file = $rootDir.'/Makefile';
$originalContents = file_exists($file) ? file_get_contents($file) : null;
$this->configureMakefile(
$recipe,
$definitions,
true
);
$updatedContents = file_exists($file) ? file_get_contents($file) : null;
if (null === $originalContents) {
if (file_exists($file)) {
unlink($file);
}
} else {
file_put_contents($file, $originalContents);
}
return $updatedContents;
}
}