├── .gitignore ├── src └── Companienv │ ├── IO │ ├── Interaction.php │ ├── FileSystem │ │ ├── FileSystem.php │ │ └── NativePhpFileSystem.php │ └── InputOutputInteraction.php │ ├── DotEnv │ ├── ValueFormatter.php │ ├── MissingVariable.php │ ├── Variable.php │ ├── File.php │ ├── Attribute.php │ ├── Parser.php │ └── Block.php │ ├── Extension │ ├── AbstractExtension.php │ ├── Chained.php │ ├── OnlyIf.php │ ├── FileToPropagate.php │ ├── RsaKeys.php │ └── SslCertificate.php │ ├── Composer │ ├── InteractionViaComposer.php │ └── ScriptHandler.php │ ├── Extension.php │ ├── Interaction │ └── AskVariableValues.php │ ├── Application.php │ └── Companion.php ├── .travis.yml ├── tests └── Companienv │ └── IO │ ├── InMemoryFileSystem.php │ └── InMemoryInteraction.php ├── spec └── Companienv │ └── IO │ └── InputOutputInteractionSpec.php ├── bin └── companienv ├── composer.json ├── LICENSE ├── features ├── extensions │ ├── file-to-propagate.feature │ └── only-if.feature ├── bootstrap │ └── FeatureContext.php ├── fill-variables.feature └── blocks.feature └── README.md /.gitignore: -------------------------------------------------------------------------------- 1 | /vendor 2 | /composer.lock 3 | -------------------------------------------------------------------------------- /src/Companienv/IO/Interaction.php: -------------------------------------------------------------------------------- 1 | getName(), $variable->getValue()); 12 | 13 | $this->currentValue = $currentValue; 14 | } 15 | 16 | /** 17 | * @return string|null 18 | */ 19 | public function getCurrentValue() 20 | { 21 | return $this->currentValue; 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: php 2 | 3 | php: 4 | - 7.1 5 | - 7.2 6 | - 7.3 7 | - 7.4 8 | 9 | matrix: 10 | include: 11 | - php: 7.1 12 | env: dependencies=lowest 13 | 14 | sudo: false 15 | 16 | cache: 17 | directories: 18 | - $HOME/.composer/cache 19 | 20 | before_install: 21 | - composer self-update 22 | 23 | before_script: 24 | - travis_retry composer install --no-interaction 25 | - if [ "$dependencies" = "lowest" ]; then travis_retry composer update --prefer-lowest --prefer-stable -n; fi; 26 | 27 | script: 28 | - vendor/bin/phpspec run 29 | - vendor/bin/behat -fprogress 30 | -------------------------------------------------------------------------------- /src/Companienv/DotEnv/Variable.php: -------------------------------------------------------------------------------- 1 | name = $name; 13 | $this->value = $value; 14 | } 15 | 16 | public function getName(): string 17 | { 18 | return $this->name; 19 | } 20 | 21 | public function hasValue(): bool 22 | { 23 | return !empty($this->value); 24 | } 25 | 26 | public function getValue() 27 | { 28 | return $this->value; 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /src/Companienv/DotEnv/File.php: -------------------------------------------------------------------------------- 1 | header = $header; 13 | $this->blocks = $blocks; 14 | } 15 | 16 | /** 17 | * @return Block[] 18 | */ 19 | public function getBlocks(): array 20 | { 21 | return $this->blocks; 22 | } 23 | 24 | /** 25 | * @return Variable[] 26 | */ 27 | public function getAllVariables() : array 28 | { 29 | return array_reduce($this->blocks, function (array $carry, Block $block) { 30 | return array_merge($carry, $block->getVariables()); 31 | }, []); 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /tests/Companienv/IO/InMemoryFileSystem.php: -------------------------------------------------------------------------------- 1 | files[$path] = $contents; 14 | } 15 | 16 | public function exists($path, bool $relative = true) 17 | { 18 | return isset($this->files[$path]); 19 | } 20 | 21 | public function getContents($path, bool $relative = true) 22 | { 23 | if (!$this->exists($path)) { 24 | return false; 25 | } 26 | 27 | return $this->files[$path]; 28 | } 29 | 30 | public function realpath($path) 31 | { 32 | return sys_get_temp_dir().$path; 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /spec/Companienv/IO/InputOutputInteractionSpec.php: -------------------------------------------------------------------------------- 1 | beConstructedWith($input, $output); 16 | } 17 | 18 | function it_will_return_the_default_value() 19 | { 20 | $this->ask('VALUE ?', 'true')->shouldReturn('true'); 21 | } 22 | 23 | function it_will_return_the_default_value_even_if_it_looks_falsy() 24 | { 25 | $this->ask('COUNT ?', '0')->shouldReturn('0'); 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /bin/companienv: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env php 2 | run(); 27 | -------------------------------------------------------------------------------- /src/Companienv/Extension/AbstractExtension.php: -------------------------------------------------------------------------------- 1 | root = $root; 12 | } 13 | 14 | public function write($path, string $contents) 15 | { 16 | file_put_contents($this->realpath($path), $contents); 17 | } 18 | 19 | public function exists($path, bool $relative = true) 20 | { 21 | return file_exists($relative ? $this->realpath($path) : $path); 22 | } 23 | 24 | public function getContents($path, bool $relative = true) 25 | { 26 | return file_get_contents($relative ? $this->realpath($path) : $path); 27 | } 28 | 29 | public function realpath($path) 30 | { 31 | return $this->root.DIRECTORY_SEPARATOR.$path; 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /src/Companienv/DotEnv/Attribute.php: -------------------------------------------------------------------------------- 1 | name = $name; 19 | $this->variableNames = $variableNames; 20 | $this->labels = $labels; 21 | } 22 | 23 | public function getName(): string 24 | { 25 | return $this->name; 26 | } 27 | 28 | public function getVariableNames(): array 29 | { 30 | return $this->variableNames; 31 | } 32 | 33 | public function getLabels() 34 | { 35 | return $this->labels; 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "sroze/companienv", 3 | "description": "Companion for .env files", 4 | "keywords": [ 5 | "dotenv", 6 | ".env", 7 | "configuration" 8 | ], 9 | "type": "library", 10 | "license": "MIT", 11 | "authors": [ 12 | { 13 | "name": "Samuel ROZE", 14 | "email": "samuel.roze@gmail.com" 15 | } 16 | ], 17 | "require": { 18 | "php": ">=7.0", 19 | "symfony/console": "~3.4 || ~4.0 || ~5.0 || ~6.0", 20 | "symfony/dotenv": "~3.4 || ~4.0 || ~5.0 || ~6.0", 21 | "symfony/process": "~3.4 || ~4.0 || ~5.0 || ~6.0", 22 | "jackiedo/dotenv-editor": "~1.0" 23 | }, 24 | "autoload": { 25 | "psr-0": { 26 | "Companienv": "src/" 27 | } 28 | }, 29 | "autoload-dev": { 30 | "psr-0": { 31 | "Companienv": "tests/" 32 | } 33 | }, 34 | "bin": [ 35 | "bin/companienv" 36 | ], 37 | "require-dev": { 38 | "behat/behat": "^3.4", 39 | "phpspec/phpspec": "^4.3 || ^5.0 || ^6.0", 40 | "composer/composer": "^1.0" 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2018 Samuel Rozé 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 | 21 | -------------------------------------------------------------------------------- /src/Companienv/Composer/InteractionViaComposer.php: -------------------------------------------------------------------------------- 1 | io = $io; 15 | } 16 | 17 | public function askConfirmation(string $question): bool 18 | { 19 | if (!$this->io->isInteractive()) { 20 | $this->writeln('Automatically confirmed in non-interactive mode'); 21 | 22 | return true; 23 | } 24 | 25 | return $this->io->askConfirmation($question); 26 | } 27 | 28 | public function ask(string $question, string $default = null): string 29 | { 30 | if (!$this->io->isInteractive()) { 31 | $this->writeln(sprintf('Automatically returned "%s" in non-interactive mode', $default)); 32 | 33 | return $default; 34 | } 35 | 36 | return $this->io->ask($question, $default); 37 | } 38 | 39 | public function writeln($messageOrMessages) 40 | { 41 | return $this->io->write($messageOrMessages); 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /src/Companienv/Composer/ScriptHandler.php: -------------------------------------------------------------------------------- 1 | getComposer()->getPackage()->getExtra(); 16 | 17 | if (isset($extras['companienv-parameters'])) { 18 | $configs = $extras['companienv-parameters']; 19 | } else { 20 | $configs = [['file' => Application::defaultFile(), 'dist-file' => Application::defaultDistributionFile()]]; 21 | } 22 | 23 | $directory = getcwd(); 24 | foreach ($configs as $config) { 25 | $companion = new Companion( 26 | new NativePhpFileSystem($directory), 27 | new InteractionViaComposer($event->getIO()), 28 | new Chained(Application::defaultExtensions()), 29 | $config['file'], 30 | $config['dist-file'] 31 | ); 32 | $companion->fillGaps(); 33 | } 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /src/Companienv/Extension.php: -------------------------------------------------------------------------------- 1 | input = $input; 18 | $this->output = $output; 19 | } 20 | 21 | public function askConfirmation(string $question) : bool 22 | { 23 | return in_array(strtolower($this->ask($question, 'y')), ['y', 'yes']); 24 | } 25 | 26 | public function ask(string $question, string $default = null) : string 27 | { 28 | $answer = (new QuestionHelper())->ask($this->input, $this->output, new Question($question, $default)); 29 | 30 | if (null === $answer || ('' === $answer && $default !== null)) { 31 | return $this->ask($question, $default); 32 | } 33 | 34 | return $answer; 35 | } 36 | 37 | public function writeln($messageOrMessages) 38 | { 39 | return $this->output->writeln($messageOrMessages); 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /src/Companienv/Interaction/AskVariableValues.php: -------------------------------------------------------------------------------- 1 | getDefinedVariablesHash(); 18 | $defaultValue = ($definedVariablesHash[$variable->getName()] ?? $variable->getValue()) ?: $variable->getValue(); 19 | $question = sprintf('%s ? ', $variable->getName()); 20 | 21 | if ($defaultValue !== '') { 22 | $question .= '('.$defaultValue.') '; 23 | } 24 | 25 | return $companion->ask($question, $defaultValue); 26 | } 27 | 28 | /** 29 | * {@inheritdoc} 30 | */ 31 | public function isVariableRequiringValue(Companion $companion, Block $block, Variable $variable, string $currentValue = null) : int 32 | { 33 | return ( 34 | $currentValue === null || ( 35 | $currentValue === '' && $variable->getValue() !== '' 36 | ) 37 | ) ? Extension::VARIABLE_REQUIRED : Extension::ABSTAIN; 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /tests/Companienv/IO/InMemoryInteraction.php: -------------------------------------------------------------------------------- 1 | answers = $answers; 19 | } 20 | 21 | public function askConfirmation(string $question): bool 22 | { 23 | return (bool) $this->ask($question); 24 | } 25 | 26 | public function ask(string $question, string $default = null): string 27 | { 28 | $normalizedKey = trim(strip_tags($question)); 29 | $this->buffer .= trim(strip_tags($question))."\n"; 30 | 31 | if (isset($this->answers[$normalizedKey])) { 32 | return $this->answers[$normalizedKey]; 33 | } 34 | 35 | throw new \RuntimeException(sprintf( 36 | 'No answer for question "%s"', 37 | $normalizedKey 38 | )); 39 | } 40 | 41 | public function writeln($messageOrMessages) 42 | { 43 | if (!is_array($messageOrMessages)) { 44 | $messageOrMessages = [$messageOrMessages]; 45 | } 46 | 47 | $this->buffer .= implode("\n", $messageOrMessages)."\n"; 48 | } 49 | 50 | public function getBuffer(): string 51 | { 52 | return $this->buffer; 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /src/Companienv/Extension/Chained.php: -------------------------------------------------------------------------------- 1 | extensions = $extensions; 23 | } 24 | 25 | /** 26 | * {@inheritdoc} 27 | */ 28 | public function getVariableValue(Companion $companion, Block $block, Variable $variable) 29 | { 30 | foreach ($this->extensions as $extension) { 31 | if (null !== ($value = $extension->getVariableValue($companion, $block, $variable))) { 32 | return $value; 33 | } 34 | } 35 | 36 | return null; 37 | } 38 | 39 | /** 40 | * {@inheritdoc} 41 | */ 42 | public function isVariableRequiringValue(Companion $companion, Block $block, Variable $variable, string $currentValue = null) : int 43 | { 44 | foreach ($this->extensions as $extension) { 45 | if (($vote = $extension->isVariableRequiringValue($companion, $block, $variable, $currentValue)) != Extension::ABSTAIN) { 46 | return $vote; 47 | } 48 | } 49 | 50 | return Extension::ABSTAIN; 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /src/Companienv/Extension/OnlyIf.php: -------------------------------------------------------------------------------- 1 | getAttribute('only-if', $variable))) { 19 | return null; 20 | } 21 | 22 | if (!$this->matchesCondition($companion, $attribute)) { 23 | return $variable->getValue(); 24 | } 25 | } 26 | 27 | /** 28 | * {@inheritdoc} 29 | */ 30 | public function isVariableRequiringValue(Companion $companion, Block $block, Variable $variable, string $currentValue = null) : int 31 | { 32 | if (null === ($attribute = $block->getAttribute('only-if', $variable))) { 33 | return Extension::ABSTAIN; 34 | } 35 | 36 | return $this->matchesCondition($companion, $attribute) 37 | ? Extension::ABSTAIN 38 | : Extension::VARIABLE_SKIP; 39 | } 40 | 41 | private function matchesCondition(Companion $companion, Attribute $attribute) : bool 42 | { 43 | $definedVariablesHash = $companion->getDefinedVariablesHash(); 44 | foreach ($attribute->getLabels() as $otherVariableName => $expectedValue) { 45 | if (isset($definedVariablesHash[$otherVariableName]) && $definedVariablesHash[$otherVariableName] != $expectedValue) { 46 | return false; 47 | } 48 | } 49 | 50 | return true; 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /features/extensions/file-to-propagate.feature: -------------------------------------------------------------------------------- 1 | Feature: 2 | In order to configure my application with a file such as a downloaded service account or a given key 3 | As a user 4 | I want to give the path of my downloaded file to Companienv, so it takes care about the rest 5 | 6 | Scenario: It gets the file and copies it to the right place 7 | Given the file ".env.dist" contains: 8 | """ 9 | ## GitHub 10 | #+file-to-propagate(GITHUB_INTEGRATION_PRIVATE_KEY_PATH) 11 | GITHUB_INTEGRATION_PRIVATE_KEY_PATH=/runtime/keys/github.pem 12 | """ 13 | And the file "/tmp/file-to-propagate" contains: 14 | """ 15 | SOMETHING 16 | """ 17 | When I run the companion with the following answers: 18 | | Let's fix this? (y) | y | 19 | | GITHUB_INTEGRATION_PRIVATE_KEY_PATH: What is the path of your downloaded file? | /tmp/file-to-propagate | 20 | And the file ".env" should contain: 21 | """ 22 | GITHUB_INTEGRATION_PRIVATE_KEY_PATH=/runtime/keys/github.pem 23 | """ 24 | And the file "/runtime/keys/github.pem" should contain: 25 | """ 26 | SOMETHING 27 | """ 28 | 29 | Scenario: It does not consider the variable as missing if we it not included in the "only-if" 30 | Given the file ".env.dist" contains: 31 | """ 32 | ## Y'a know... 33 | # This is a configuration. 34 | #+file-to-propagate(GOOGLE_CLOUD_AUDIT_LOG_SERVICE_ACCOUNT_PATH) 35 | #+only-if(GOOGLE_CLOUD_AUDIT_LOG_SERVICE_ACCOUNT_PATH):(GOOGLE_CLOUD_AUDIT_ENABLED=true) 36 | GOOGLE_CLOUD_AUDIT_ENABLED=false 37 | GOOGLE_CLOUD_AUDIT_LOG_SERVICE_ACCOUNT_PATH=/runtime/keys/google-cloud-audit-log.json 38 | """ 39 | And the file ".env" contains: 40 | """ 41 | GOOGLE_CLOUD_AUDIT_ENABLED=false 42 | GOOGLE_CLOUD_AUDIT_LOG_SERVICE_ACCOUNT_PATH=/runtime/keys/google-cloud-audit-log.json 43 | """ 44 | When I run the companion 45 | Then the companion's output should be empty 46 | -------------------------------------------------------------------------------- /src/Companienv/Extension/FileToPropagate.php: -------------------------------------------------------------------------------- 1 | getAttribute('file-to-propagate', $variable))) { 18 | return null; 19 | } 20 | 21 | $definedVariablesHash = $companion->getDefinedVariablesHash(); 22 | $fileSystem = $companion->getFileSystem(); 23 | 24 | // If the file exists and seems legit, keep the file. 25 | if ($fileSystem->exists($filename = $variable->getValue()) && isset($definedVariablesHash[$variable->getName()])) { 26 | return $definedVariablesHash[$variable->getName()]; 27 | } 28 | 29 | $downloadedFilePath = $companion->ask(''.$variable->getName().': What is the path of your downloaded file? '); 30 | if (!$fileSystem->exists($downloadedFilePath, false)) { 31 | throw new \InvalidArgumentException(sprintf('The file "%s" does not exist', $downloadedFilePath)); 32 | } 33 | 34 | if (false === $fileSystem->write($filename, $fileSystem->getContents($downloadedFilePath, false))) { 35 | throw new \RuntimeException(sprintf( 36 | 'Unable to write into "%s"', 37 | $filename 38 | )); 39 | } 40 | 41 | return $variable->getValue(); 42 | } 43 | 44 | /** 45 | * {@inheritdoc} 46 | */ 47 | public function isVariableRequiringValue(Companion $companion, Block $block, Variable $variable, string $currentValue = null) : int 48 | { 49 | if (null === ($attribute = $block->getAttribute('file-to-propagate', $variable))) { 50 | return Extension::ABSTAIN; 51 | } 52 | 53 | return $companion->getFileSystem()->exists($variable->getValue()) 54 | ? Extension::VARIABLE_REQUIRED 55 | : Extension::ABSTAIN; 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /features/extensions/only-if.feature: -------------------------------------------------------------------------------- 1 | Feature: 2 | In order to present only relevant options to the user 3 | I want to ask values for a given variable only if another variable has a given value 4 | 5 | Scenario: It does ask all the variables if condition is false 6 | Given the file ".env.dist" contains: 7 | """ 8 | ## Development & Audit 9 | # 10 | #+only-if(GOOGLE_CLOUD_AUDIT_LOG_PROJECT_ID):(GOOGLE_CLOUD_AUDIT_ENABLED=true) 11 | #+only-if(GOOGLE_CLOUD_AUDIT_LOG_SERVICE_ACCOUNT_PATH):(GOOGLE_CLOUD_AUDIT_ENABLED=true) 12 | GOOGLE_CLOUD_AUDIT_ENABLED=false 13 | GOOGLE_CLOUD_AUDIT_LOG_PROJECT_ID= 14 | GOOGLE_CLOUD_AUDIT_LOG_SERVICE_ACCOUNT_PATH=/runtime/keys/google-cloud-audit-log.json 15 | """ 16 | When I run the companion with the following answers: 17 | | Let's fix this? (y) | y | 18 | | GOOGLE_CLOUD_AUDIT_ENABLED ? (false) | true | 19 | | GOOGLE_CLOUD_AUDIT_LOG_PROJECT_ID ? | project-id | 20 | | GOOGLE_CLOUD_AUDIT_LOG_SERVICE_ACCOUNT_PATH ? (/runtime/keys/google-cloud-audit-log.json) | /runtime/keys/google-cloud-audit-log.json | 21 | And the file ".env" should contain: 22 | """ 23 | GOOGLE_CLOUD_AUDIT_ENABLED=true 24 | GOOGLE_CLOUD_AUDIT_LOG_PROJECT_ID=project-id 25 | GOOGLE_CLOUD_AUDIT_LOG_SERVICE_ACCOUNT_PATH=/runtime/keys/google-cloud-audit-log.json 26 | """ 27 | 28 | Scenario: It does not ask about the variable if condition is false 29 | Given the file ".env.dist" contains: 30 | """ 31 | ## Development & Audit 32 | # 33 | #+only-if(GOOGLE_CLOUD_AUDIT_LOG_PROJECT_ID):(GOOGLE_CLOUD_AUDIT_ENABLED=true) 34 | #+only-if(GOOGLE_CLOUD_AUDIT_LOG_SERVICE_ACCOUNT_PATH):(GOOGLE_CLOUD_AUDIT_ENABLED=true) 35 | GOOGLE_CLOUD_AUDIT_ENABLED=false 36 | GOOGLE_CLOUD_AUDIT_LOG_PROJECT_ID= 37 | GOOGLE_CLOUD_AUDIT_LOG_SERVICE_ACCOUNT_PATH=/runtime/keys/google-cloud-audit-log.json 38 | """ 39 | When I run the companion with the following answers: 40 | | Let's fix this? (y) | y | 41 | | GOOGLE_CLOUD_AUDIT_ENABLED ? (false) | false | 42 | And the file ".env" should contain: 43 | """ 44 | GOOGLE_CLOUD_AUDIT_ENABLED=false 45 | GOOGLE_CLOUD_AUDIT_LOG_PROJECT_ID= 46 | GOOGLE_CLOUD_AUDIT_LOG_SERVICE_ACCOUNT_PATH=/runtime/keys/google-cloud-audit-log.json 47 | """ 48 | -------------------------------------------------------------------------------- /features/bootstrap/FeatureContext.php: -------------------------------------------------------------------------------- 1 | fileSystem = new InMemoryFileSystem(); 22 | } 23 | 24 | /** 25 | * @Given the file :path contains: 26 | */ 27 | public function theFileContains($path, PyStringNode $string) 28 | { 29 | $this->fileSystem->write($path, $string->getRaw()); 30 | } 31 | 32 | /** 33 | * @When I run the companion with the following answers: 34 | * @When I run the companion 35 | */ 36 | public function iRunTheCompanionWithTheFollowingAnswers(TableNode $table = null) 37 | { 38 | $this->companion = new Companion( 39 | $this->fileSystem, 40 | $this->interaction = new InMemoryInteraction($table !== null ? $table->getRowsHash() : []), 41 | new Chained(Application::defaultExtensions()) 42 | ); 43 | 44 | $this->companion->fillGaps(); 45 | 46 | echo $this->interaction->getBuffer(); 47 | } 48 | 49 | /** 50 | * @Then the file :path should contain: 51 | */ 52 | public function theFileShouldContain($path, PyStringNode $string) 53 | { 54 | $found = trim($this->fileSystem->getContents($path)); 55 | $expected = trim($string->getRaw()); 56 | 57 | if ($found != $expected) { 58 | throw new \RuntimeException(sprintf( 59 | 'Found following instead: %s', 60 | $found 61 | )); 62 | } 63 | } 64 | 65 | /** 66 | * @Then the companion's output will look like that: 67 | */ 68 | public function theCompanionsOutputWillLookLikeThat(PyStringNode $string) 69 | { 70 | $found = strip_tags(trim($this->interaction->getBuffer())); 71 | $expected = trim($string->getRaw()); 72 | 73 | if ($found != $expected) { 74 | throw new \RuntimeException(sprintf( 75 | 'Found the following instead: %s', 76 | function_exists('xdiff_string_diff') ? xdiff_string_diff($expected, $found) : $found 77 | )); 78 | } 79 | } 80 | 81 | /** 82 | * @Then the companion's output should be empty 83 | */ 84 | public function theCompanionsOutputShouldBeEmpty() 85 | { 86 | $found = strip_tags(trim($this->interaction->getBuffer())); 87 | 88 | if (!empty($found)) { 89 | throw new \RuntimeException(sprintf( 90 | 'Found the following instead: %s', 91 | $found 92 | )); 93 | } 94 | } 95 | } 96 | -------------------------------------------------------------------------------- /src/Companienv/Application.php: -------------------------------------------------------------------------------- 1 | rootDirectory = $rootDirectory; 31 | $this->extensions = $extensions !== null ? $extensions : self::defaultExtensions(); 32 | 33 | $this->add(new class([$this, 'companion'], 'companion') extends Command { 34 | private $callable; 35 | 36 | public function __construct(callable $callable, $name) 37 | { 38 | parent::__construct($name); 39 | 40 | $this->callable = $callable; 41 | 42 | $this->addOption('dist-file', null, InputOption::VALUE_REQUIRED, 'Name of the file used as reference', Application::defaultDistributionFile()); 43 | $this->addOption('file', null, InputOption::VALUE_REQUIRED, 'Name of the file used for the values', Application::defaultFile()); 44 | } 45 | 46 | protected function execute(InputInterface $input, OutputInterface $output) 47 | { 48 | $callable = $this->callable; 49 | 50 | return $callable($input, $output); 51 | } 52 | }); 53 | 54 | $this->setDefaultCommand('companion', true); 55 | } 56 | 57 | public function companion(InputInterface $input, OutputInterface $output) 58 | { 59 | $companion = new Companion( 60 | new NativePhpFileSystem($this->rootDirectory), 61 | new InputOutputInteraction($input, $output), 62 | new Chained($this->extensions), 63 | $input->getOption('file'), 64 | $input->getOption('dist-file') 65 | ); 66 | $companion->fillGaps(); 67 | } 68 | 69 | public function registerExtension(Extension $extension) 70 | { 71 | array_unshift($this->extensions, $extension); 72 | } 73 | 74 | public static function defaultExtensions() 75 | { 76 | return [ 77 | new OnlyIf(), 78 | new SslCertificate(), 79 | new RsaKeys(), 80 | new FileToPropagate(), 81 | new AskVariableValues(), 82 | ]; 83 | } 84 | 85 | public static function defaultFile() 86 | { 87 | return '.env'; 88 | } 89 | 90 | public static function defaultDistributionFile() 91 | { 92 | return '.env.dist'; 93 | } 94 | } 95 | -------------------------------------------------------------------------------- /src/Companienv/DotEnv/Parser.php: -------------------------------------------------------------------------------- 1 | getContents($path)) as $number => $line) { 16 | $line = trim($line); 17 | if (empty($line)) { 18 | continue; 19 | } 20 | 21 | if (strpos($line, '#') === 0) { 22 | // We see a title 23 | if (substr($line, 0, 2) == '##') { 24 | $block = new Block(trim($line, '# ')); 25 | $blocks[] = $block; 26 | } elseif (substr($line, 0, 2) == '#~') { 27 | // Ignore this comment. 28 | } elseif ($block !== null) { 29 | if (substr($line, 0, 2) == '#+') { 30 | $block->addAttribute($this->parseAttribute(substr($line, 2))); 31 | } else if (substr($line, 1, 1) == ' ') { 32 | $block->appendToDescription(trim($line, '# ')); 33 | } 34 | } 35 | } elseif (false !== ($firstEquals = strpos($line, '='))) { 36 | if (null === $block) { 37 | $blocks[] = $block = new Block(); 38 | } 39 | 40 | $block->addVariable(new Variable( 41 | substr($line, 0, $firstEquals), 42 | substr($line, $firstEquals + 1) 43 | )); 44 | } else { 45 | throw new \InvalidArgumentException(sprintf( 46 | 'The line %d of the file %s is invalid: %s', 47 | $number, 48 | $path, 49 | $line 50 | )); 51 | } 52 | } 53 | 54 | return new File('', $blocks); 55 | } 56 | 57 | private function parseAttribute(string $string) 58 | { 59 | $variableNameRegex = '[A-Z0-9_]+'; 60 | $valueRegex = '[^\) ]+'; 61 | 62 | if (!preg_match('/^([a-z0-9-]+)\((('.$variableNameRegex.' ?)*)\)(:\((('.$variableNameRegex.'='.$valueRegex.' ?)*)\))?$/', $string, $matches)) { 63 | throw new \RuntimeException(sprintf( 64 | 'Unable to parse the given attribute: %s', 65 | $string 66 | )); 67 | } 68 | 69 | return new Attribute($matches[1], explode(' ', $matches[2]), isset($matches[6]) ? $this->dotEnvMappingToKeyBasedMapping($matches[6]) : []); 70 | } 71 | 72 | private function dotEnvMappingToKeyBasedMapping(string $dotEnvMapping) 73 | { 74 | $mapping = []; 75 | $envMappings = explode(' ', $dotEnvMapping); 76 | 77 | foreach ($envMappings as $envMapping) { 78 | if (false === strpos($envMapping, '=')) { 79 | throw new \RuntimeException(sprintf( 80 | 'Could not parse attribute mapping "%s"', 81 | $dotEnvMapping 82 | )); 83 | } 84 | 85 | list($key, $value) = explode('=', $envMapping); 86 | $mapping[$key] = $value; 87 | } 88 | 89 | return $mapping; 90 | } 91 | } 92 | -------------------------------------------------------------------------------- /src/Companienv/DotEnv/Block.php: -------------------------------------------------------------------------------- 1 | title = $title; 19 | $this->description = $description; 20 | $this->variables = $variables; 21 | $this->attributes = $attributes; 22 | } 23 | 24 | public function appendToDescription(string $string) 25 | { 26 | $this->description .= ($this->description ? ' ' : '') . $string; 27 | } 28 | 29 | public function addVariable(Variable $variable) 30 | { 31 | $this->variables[] = $variable; 32 | } 33 | 34 | public function addAttribute(Attribute $attribute) 35 | { 36 | $this->attributes[] = $attribute; 37 | } 38 | 39 | public function getTitle(): string 40 | { 41 | return $this->title; 42 | } 43 | 44 | public function getDescription(): string 45 | { 46 | return $this->description; 47 | } 48 | 49 | /** 50 | * @return Attribute[] 51 | */ 52 | public function getAttributes(): array 53 | { 54 | return $this->attributes; 55 | } 56 | 57 | /** 58 | * @return Variable[] 59 | */ 60 | public function getVariables(): array 61 | { 62 | return $this->variables; 63 | } 64 | 65 | /** 66 | * Return only the variables that are in the block. 67 | * 68 | * @param Variable[] $variables 69 | * 70 | * @return Variable[] 71 | */ 72 | public function getVariablesInBlock(array $variables) 73 | { 74 | $blockVariableNames = array_map(function (Variable $variable) { 75 | return $variable->getName(); 76 | }, $this->variables); 77 | 78 | return array_filter($variables, function (Variable $variable) use ($blockVariableNames) { 79 | return in_array($variable->getName(), $blockVariableNames); 80 | }); 81 | } 82 | 83 | /** 84 | * @param string $name 85 | * 86 | * @return Variable|null 87 | */ 88 | public function getVariable(string $name) 89 | { 90 | foreach ($this->variables as $variable) { 91 | if ($variable->getName() == $name) { 92 | return $variable; 93 | } 94 | } 95 | 96 | return null; 97 | } 98 | 99 | /** 100 | * @param string $name 101 | * @param Variable|null $forVariable Will return only attribute for the given variables 102 | * 103 | * @return Attribute|null 104 | */ 105 | public function getAttribute(string $name, Variable $forVariable = null) 106 | { 107 | foreach ($this->attributes as $attribute) { 108 | if ( 109 | $attribute->getName() == $name 110 | && ( 111 | $forVariable === null 112 | || in_array($forVariable->getName(), $attribute->getVariableNames()) 113 | ) 114 | ) { 115 | return $attribute; 116 | } 117 | } 118 | 119 | return null; 120 | } 121 | } 122 | -------------------------------------------------------------------------------- /src/Companienv/Extension/RsaKeys.php: -------------------------------------------------------------------------------- 1 | getAttribute('rsa-pair', $variable))) { 21 | return null; 22 | } 23 | 24 | if (isset($this->populatedVariables[$variable->getName()])) { 25 | return $this->populatedVariables[$variable->getName()]; 26 | } 27 | 28 | if (!$companion->askConfirmation(sprintf( 29 | 'Variables %s represents an RSA public/private key. Do you want to automatically generate them? (y) ', 30 | implode(' and ', array_map(function ($variable) { 31 | return ''.$variable.''; 32 | }, $attribute->getVariableNames())) 33 | ))) { 34 | // Ensure we don't ask anymore for this variable pair 35 | foreach ($attribute->getVariableNames() as $variable) { 36 | $this->populatedVariables[$variable] = null; 37 | } 38 | 39 | return null; 40 | } 41 | 42 | $fileSystem = $companion->getFileSystem(); 43 | $passPhrase = $companion->ask('Enter pass phrase to protect the keys: '); 44 | $privateKeyPath = $block->getVariable($privateKeyVariableName = $attribute->getVariableNames()[0])->getValue(); 45 | $publicKeyPath = $block->getVariable($publicKeyVariableName = $attribute->getVariableNames()[1])->getValue(); 46 | 47 | try { 48 | (new Process(sprintf('openssl genrsa -out %s -aes256 -passout pass:%s 4096', $fileSystem->realpath($privateKeyPath), $passPhrase)))->mustRun(); 49 | (new Process(sprintf('openssl rsa -pubout -in %s -out %s -passin pass:%s', $fileSystem->realpath($privateKeyPath), $fileSystem->realpath($publicKeyPath), $passPhrase)))->mustRun(); 50 | } catch (\Symfony\Component\Process\Exception\RuntimeException $e) { 51 | throw new \RuntimeException('Could not have generated the RSA public/private key', $e->getCode(), $e); 52 | } 53 | 54 | $this->populatedVariables[$privateKeyVariableName] = $privateKeyPath; 55 | $this->populatedVariables[$publicKeyVariableName] = $publicKeyPath; 56 | $this->populatedVariables[$attribute->getVariableNames()[2]] = $passPhrase; 57 | 58 | return $this->populatedVariables[$variable->getName()]; 59 | } 60 | 61 | /** 62 | * {@inheritdoc} 63 | */ 64 | public function isVariableRequiringValue(Companion $companion, Block $block, Variable $variable, string $currentValue = null) : int 65 | { 66 | if (null === ($attribute = $block->getAttribute('rsa-pair', $variable))) { 67 | return Extension::ABSTAIN; 68 | } 69 | 70 | $fileSystem = $companion->getFileSystem(); 71 | 72 | return ( 73 | !$fileSystem->exists($block->getVariable($attribute->getVariableNames()[0])->getValue()) 74 | || !$fileSystem->exists($block->getVariable($attribute->getVariableNames()[1])->getValue()) 75 | ) ? Extension::VARIABLE_REQUIRED 76 | : Extension::ABSTAIN; 77 | } 78 | } 79 | -------------------------------------------------------------------------------- /src/Companienv/Extension/SslCertificate.php: -------------------------------------------------------------------------------- 1 | getAttribute('ssl-certificate', $variable))) { 21 | return null; 22 | } 23 | 24 | if (isset($this->populatedVariables[$variable->getName()])) { 25 | return $this->populatedVariables[$variable->getName()]; 26 | } 27 | 28 | if (!$companion->askConfirmation(sprintf( 29 | 'Variables %s represents an SSL certificate. Do you want to automatically generate them? (y) ', 30 | implode(' and ', array_map(function ($variable) { 31 | return ''.$variable.''; 32 | }, $attribute->getVariableNames())) 33 | ))) { 34 | // Ensure we don't ask anymore for this variable pair 35 | foreach ($attribute->getVariableNames() as $variable) { 36 | $this->populatedVariables[$variable] = null; 37 | } 38 | 39 | return null; 40 | } 41 | 42 | $domainName = $companion->ask('Enter the domain name for which to generate the self-signed SSL certificate: '); 43 | $privateKeyPath = $block->getVariable($privateKeyVariableName = $attribute->getVariableNames()[0])->getValue(); 44 | $certificateKeyPath = $block->getVariable($certificateVariableName = $attribute->getVariableNames()[1])->getValue(); 45 | 46 | try { 47 | (new Process(sprintf( 48 | 'openssl req -x509 -nodes -days 3650 -newkey rsa:2048 -keyout %s -out %s -subj "/C=SS/ST=SS/L=SelfSignedCity/O=SelfSignedOrg/CN=%s"', 49 | $companion->getFileSystem()->realpath($privateKeyPath), 50 | $companion->getFileSystem()->realpath($certificateKeyPath), 51 | $domainName 52 | )))->mustRun(); 53 | } catch (\Symfony\Component\Process\Exception\RuntimeException $e) { 54 | throw new \RuntimeException('Could not have generated the SSL certificate: '.$e->getMessage(), $e->getCode(), $e); 55 | } 56 | 57 | $this->populatedVariables[$privateKeyVariableName] = $privateKeyPath; 58 | $this->populatedVariables[$certificateVariableName] = $certificateKeyPath; 59 | $this->populatedVariables[$attribute->getVariableNames()[2]] = $domainName; 60 | 61 | return $this->populatedVariables[$variable->getName()]; 62 | } 63 | 64 | /** 65 | * {@inheritdoc} 66 | */ 67 | public function isVariableRequiringValue(Companion $companion, Block $block, Variable $variable, string $currentValue = null) : int 68 | { 69 | if (null === ($attribute = $block->getAttribute('ssl-certificate', $variable))) { 70 | return false; 71 | } 72 | 73 | $fileSystem = $companion->getFileSystem(); 74 | 75 | return ( 76 | !$fileSystem->exists($block->getVariable($privateKeyVariableName = $attribute->getVariableNames()[0])->getValue()) 77 | || !$fileSystem->exists($block->getVariable($attribute->getVariableNames()[1])->getValue()) 78 | ) ? Extension::VARIABLE_REQUIRED 79 | : Extension::ABSTAIN; 80 | } 81 | } 82 | -------------------------------------------------------------------------------- /features/fill-variables.feature: -------------------------------------------------------------------------------- 1 | Feature: 2 | In order to fill my .env file 3 | As a developer 4 | I want the companion to ask me the values of each missing variable, from a .env.dist file 5 | 6 | Scenario: It asks all the variables if the file is missing 7 | Given the file ".env.dist" contains: 8 | """ 9 | ## Something 10 | MY_VARIABLE=default-value 11 | """ 12 | When I run the companion with the following answers: 13 | | Let's fix this? (y) | y | 14 | | MY_VARIABLE ? (default-value) | my-value | 15 | And the file ".env" should contain: 16 | """ 17 | MY_VARIABLE=my-value 18 | """ 19 | 20 | Scenario: It asks only the missing variables 21 | Given the file ".env.dist" contains: 22 | """ 23 | ## Something 24 | MY_VARIABLE=default-value 25 | A_NEW_VARIABLE= 26 | """ 27 | And the file ".env" contains: 28 | """ 29 | MY_VARIABLE=something-else 30 | 31 | """ 32 | When I run the companion with the following answers: 33 | | Let's fix this? (y) | y | 34 | | A_NEW_VARIABLE ? | value | 35 | And the file ".env" should contain: 36 | """ 37 | MY_VARIABLE=something-else 38 | A_NEW_VARIABLE=value 39 | """ 40 | 41 | Scenario: We ask an empty variable if it has a value 42 | Given the file ".env.dist" contains: 43 | """ 44 | ## Something 45 | MY_VARIABLE=default-value 46 | """ 47 | And the file ".env" contains: 48 | """ 49 | MY_VARIABLE= 50 | """ 51 | When I run the companion with the following answers: 52 | | Let's fix this? (y) | y | 53 | | MY_VARIABLE ? (default-value) | | 54 | And the file ".env" should contain: 55 | """ 56 | MY_VARIABLE= 57 | """ 58 | 59 | Scenario: We do not ask an empty variable if the reference is empty 60 | Given the file ".env.dist" contains: 61 | """ 62 | ## Something 63 | EMPTY_VARIABLE= 64 | """ 65 | And the file ".env" contains: 66 | """ 67 | EMPTY_VARIABLE= 68 | """ 69 | When I run the companion with the following answers: 70 | | Let's fix this? (y) | y | 71 | And the file ".env" should contain: 72 | """ 73 | EMPTY_VARIABLE= 74 | """ 75 | 76 | Scenario: We do not ask for variable than have a falsy value 77 | Given the file ".env.dist" contains: 78 | """ 79 | ## Something 80 | MY_VARIABLE=default-value 81 | """ 82 | And the file ".env" contains: 83 | """ 84 | MY_VARIABLE=0 85 | """ 86 | When I run the companion with the following answers: 87 | | Let's fix this? (y) | y | 88 | And the file ".env" should contain: 89 | """ 90 | MY_VARIABLE=0 91 | """ 92 | 93 | Scenario: It supports variables containing equals sign 94 | Given the file ".env.dist" contains: 95 | """ 96 | ## Something 97 | A_BASE64_VALUE=abc123= 98 | """ 99 | And the file ".env" contains: 100 | """ 101 | A_BASE64_VALUE= 102 | """ 103 | When I run the companion with the following answers: 104 | | Let's fix this? (y) | y | 105 | | A_BASE64_VALUE ? (abc123=) | abc123= | 106 | And the file ".env" should contain: 107 | """ 108 | A_BASE64_VALUE=abc123= 109 | """ 110 | 111 | Scenario: It displays correctly boolean with 1 and 0 112 | Given the file ".env.dist" contains: 113 | """ 114 | ## Something 115 | MY_VARIABLE=0 116 | """ 117 | And the file ".env" contains: 118 | """ 119 | MY_VARIABLE= 120 | """ 121 | When I run the companion with the following answers: 122 | | Let's fix this? (y) | y | 123 | | MY_VARIABLE ? (0) | 0 | 124 | And the file ".env" should contain: 125 | """ 126 | MY_VARIABLE=0 127 | """ 128 | -------------------------------------------------------------------------------- /features/blocks.feature: -------------------------------------------------------------------------------- 1 | Feature: 2 | In order to separate the variables into sensible blocks with explainations 3 | As a user 4 | I want to define my blocks in the .env.dist file 5 | 6 | Scenario: Displays the block title 7 | Given the file ".env.dist" contains: 8 | """ 9 | ## Something 10 | MY_VARIABLE=default-value 11 | """ 12 | When I run the companion with the following answers: 13 | | Let's fix this? (y) | y | 14 | | MY_VARIABLE ? (default-value) | my-value | 15 | Then the companion's output will look like that: 16 | """ 17 | It looks like you are missing some configuration (1 variables). I will help you to sort this out. 18 | Let's fix this? (y) 19 | 20 | Something 21 | 22 | MY_VARIABLE ? (default-value) 23 | """ 24 | 25 | Scenario: Displays the block description 26 | Given the file ".env.dist" contains: 27 | """ 28 | ## Something 29 | # With more details, so it's clearer to the user... 30 | MY_VARIABLE=default-value 31 | """ 32 | When I run the companion with the following answers: 33 | | Let's fix this? (y) | y | 34 | | MY_VARIABLE ? (default-value) | my-value | 35 | Then the companion's output will look like that: 36 | """ 37 | It looks like you are missing some configuration (1 variables). I will help you to sort this out. 38 | Let's fix this? (y) 39 | 40 | Something 41 | With more details, so it's clearer to the user... 42 | 43 | MY_VARIABLE ? (default-value) 44 | """ 45 | 46 | Scenario: Displays the block description (multiline) 47 | Given the file ".env.dist" contains: 48 | """ 49 | ## Something 50 | # With more details, so it's clearer to the user 51 | # and even with extra lines 52 | MY_VARIABLE=default-value 53 | """ 54 | When I run the companion with the following answers: 55 | | Let's fix this? (y) | y | 56 | | MY_VARIABLE ? (default-value) | my-value | 57 | Then the companion's output will look like that: 58 | """ 59 | It looks like you are missing some configuration (1 variables). I will help you to sort this out. 60 | Let's fix this? (y) 61 | 62 | Something 63 | With more details, so it's clearer to the user and even with extra lines 64 | 65 | MY_VARIABLE ? (default-value) 66 | """ 67 | 68 | Scenario: I ignores the commented variables 69 | Given the file ".env.dist" contains: 70 | """ 71 | ## Something 72 | #A_HIDDEN_VARIABLE=it-was-useful 73 | MY_VARIABLE=default-value 74 | """ 75 | When I run the companion with the following answers: 76 | | Let's fix this? (y) | y | 77 | | MY_VARIABLE ? (default-value) | my-value | 78 | Then the companion's output will look like that: 79 | """ 80 | It looks like you are missing some configuration (1 variables). I will help you to sort this out. 81 | Let's fix this? (y) 82 | 83 | Something 84 | 85 | MY_VARIABLE ? (default-value) 86 | """ 87 | 88 | Scenario: We do not require a block 89 | Given the file ".env.dist" contains: 90 | """ 91 | MY_VARIABLE=default-value 92 | """ 93 | When I run the companion with the following answers: 94 | | Let's fix this? (y) | y | 95 | | MY_VARIABLE ? (default-value) | my-value | 96 | Then the companion's output will look like that: 97 | """ 98 | It looks like you are missing some configuration (1 variables). I will help you to sort this out. 99 | Let's fix this? (y) 100 | 101 | MY_VARIABLE ? (default-value) 102 | """ 103 | 104 | Scenario: It uses the package name from Symfony's blocks 105 | Given the file ".env.dist" contains: 106 | """ 107 | # This file is a "template" of which env vars need to be defined for your application 108 | # Copy this file to .env file for development, create environment variables when deploying to production 109 | # https://symfony.com/doc/current/best_practices/configuration.html#infrastructure-related-configuration 110 | 111 | ###> symfony/framework-bundle ### 112 | APP_ENV=dev 113 | APP_SECRET=e84c863b4b602b0907db13261d7d4851 114 | #TRUSTED_PROXIES=127.0.0.1,127.0.0.2 115 | #TRUSTED_HOSTS=localhost,example.com 116 | ###< symfony/framework-bundle ### 117 | 118 | ###> sroze/enqueue-bridge ### 119 | ENQUEUE_DSN=something 120 | ###< sroze/enqueue-bridge ### 121 | """ 122 | When I run the companion with the following answers: 123 | | Let's fix this? (y) | y | 124 | | APP_ENV ? (dev) | my-value | 125 | | APP_SECRET ? (e84c863b4b602b0907db13261d7d4851) | e84c863b4b602b0907db13261d7d4851 | 126 | | ENQUEUE_DSN ? (something) | something | 127 | Then the companion's output will look like that: 128 | """ 129 | It looks like you are missing some configuration (3 variables). I will help you to sort this out. 130 | Let's fix this? (y) 131 | 132 | > symfony/framework-bundle 133 | 134 | APP_ENV ? (dev) 135 | APP_SECRET ? (e84c863b4b602b0907db13261d7d4851) 136 | 137 | > sroze/enqueue-bridge 138 | 139 | ENQUEUE_DSN ? (something) 140 | """ 141 | -------------------------------------------------------------------------------- /src/Companienv/Companion.php: -------------------------------------------------------------------------------- 1 | fileSystem = $fileSystem; 24 | $this->interaction = $interaction; 25 | $this->extension = $extension; 26 | $this->reference = (new Parser())->parse($fileSystem, $distFileName); 27 | $this->envFileName = $envFileName; 28 | } 29 | 30 | public function fillGaps() 31 | { 32 | $missingVariables = $this->getVariablesRequiringValues(); 33 | if (count($missingVariables) == 0) { 34 | return; 35 | } 36 | 37 | $this->interaction->writeln(sprintf( 38 | 'It looks like you are missing some configuration (%d variables). I will help you to sort this out.', 39 | count($missingVariables) 40 | )); 41 | 42 | if (!$this->askConfirmation('Let\'s fix this? (y) ')) { 43 | $this->interaction->writeln([ 44 | '', 45 | 'I let you think about it then. Re-run the command to get started again.', 46 | '' 47 | ]); 48 | 49 | return; 50 | } 51 | 52 | foreach ($this->reference->getBlocks() as $block) { 53 | $this->fillBlockGaps($block, $missingVariables); 54 | } 55 | } 56 | 57 | private function fillBlockGaps(Block $block, array $missingVariables) 58 | { 59 | $variablesInBlock = $block->getVariablesInBlock($missingVariables); 60 | if (count($variablesInBlock) == 0) { 61 | return; 62 | } 63 | 64 | if (!empty($title = $block->getTitle())) { 65 | $this->interaction->writeln([ 66 | '', 67 | '' . $block->getTitle() . '', 68 | ]); 69 | } 70 | 71 | if (!empty($description = $block->getDescription())) { 72 | $this->interaction->writeln($description); 73 | } 74 | 75 | $this->interaction->writeln(''); 76 | 77 | foreach ($block->getVariables() as $variable) { 78 | if (isset($missingVariables[$variable->getName()])) { 79 | $this->writeVariable($variable->getName(), $this->extension->getVariableValue($this, $block, $variable)); 80 | } 81 | } 82 | } 83 | 84 | private function writeVariable(string $name, string $value) 85 | { 86 | if (!$this->fileSystem->exists($this->envFileName)) { 87 | $this->fileSystem->write($this->envFileName, ''); 88 | } 89 | 90 | $variablesInFileHash = $this->getDefinedVariablesHash(); 91 | 92 | $writer = new DotenvWriter(new ValueFormatter()); 93 | $fileContents = $this->fileSystem->getContents($this->envFileName); 94 | $writer->setBuffer($fileContents); 95 | 96 | if (isset($variablesInFileHash[$name])) { 97 | $writer->updateSetter($name, $value); 98 | } else { 99 | $writer->appendSetter($name, $value); 100 | } 101 | 102 | $this->fileSystem->write($this->envFileName, $writer->getBuffer()); 103 | } 104 | 105 | /** 106 | * @return MissingVariable[] 107 | */ 108 | private function getVariablesRequiringValues() 109 | { 110 | $variablesInFile = $this->getDefinedVariablesHash(); 111 | $missingVariables = []; 112 | 113 | foreach ($this->reference->getBlocks() as $block) { 114 | foreach ($block->getVariables() as $variable) { 115 | $currentValue = isset($variablesInFile[$variable->getName()]) ? $variablesInFile[$variable->getName()] : null; 116 | 117 | if ($this->extension->isVariableRequiringValue($this, $block, $variable, $currentValue) === Extension::VARIABLE_REQUIRED) { 118 | $missingVariables[$variable->getName()] = new MissingVariable($variable, $currentValue); 119 | } 120 | } 121 | } 122 | 123 | return $missingVariables; 124 | } 125 | 126 | public function getDefinedVariablesHash() 127 | { 128 | $variablesInFile = []; 129 | if ($this->fileSystem->exists($this->envFileName)) { 130 | $dotEnv = new \Symfony\Component\Dotenv\Dotenv(); 131 | $variablesInFile = $dotEnv->parse($this->fileSystem->getContents($this->envFileName), $this->envFileName); 132 | } 133 | 134 | return $variablesInFile; 135 | } 136 | 137 | public function askConfirmation(string $question) : bool 138 | { 139 | return $this->interaction->askConfirmation($question); 140 | } 141 | 142 | public function ask(string $question, string $default = null) : string 143 | { 144 | return $this->interaction->ask($question, $default); 145 | } 146 | 147 | public function getFileSystem(): FileSystem 148 | { 149 | return $this->fileSystem; 150 | } 151 | } 152 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Companienv 2 | 3 | Your companion for `.env` files. Everybody knows about [12 factor](https://12factor.net/) and [environments variables](https://12factor.net/config) now. 4 | A lot of frameworks such as Symfony [are using a `.env` file](https://symfony.com/doc/current/configuration.html#the-env-file-environment-variables) to configure the application, 5 | but we don't have anything to help users to complete their local `.env` file. 6 | 7 | Companienv will helps you manage the `.env` files, from a reference `.env.dist` version in your code repository. Companienv can: 8 | 9 | - Read and populate default values 10 | - Identify and ask only missing variables 11 | - Ask variables [only if matching some conditions](#only-if-extension) 12 | - [Propagate files](#file-to-propagate-extension) (copy files from somewhere else) 13 | - Generate [public/private RSA keys](#rsa-pair-extension) 14 | - Generate [SSL certificates](#ssl-certificate-extension) 15 | - Much more, via [your own extensions](#your-own-extensions) 16 | 17 | ## Usage 18 | 19 | 1. Require `sroze/companienv` as your project dependency: 20 | ``` 21 | composer req sroze/companienv 22 | ``` 23 | 24 | 2. Run your companion: 25 | ``` 26 | vendor/bin/companienv 27 | ``` 28 | 29 | ### Composer automation 30 | 31 | You can run Companienv automatically after `composer install` or `composer update` commands by configuring the scripts in your `composer.json` file: 32 | 33 | ```json 34 | { 35 | "scripts": { 36 | "post-install-cmd": [ 37 | "Companienv\\Composer\\ScriptHandler::run" 38 | ], 39 | "post-update-cmd": [ 40 | "Companienv\\Composer\\ScriptHandler::run" 41 | ] 42 | } 43 | } 44 | ``` 45 | 46 | By default, the file used as a template is `.env.dist` and the written file is `.env`. You can change these defaults within your `composer.json` file: 47 | 48 | 49 | ```json 50 | { 51 | "extra": { 52 | "companienv-parameters": [ 53 | { 54 | "file": ".env.foo", 55 | "dist-file": ".env.foo.dist" 56 | } 57 | ] 58 | } 59 | } 60 | ``` 61 | 62 | ## The `.env.dist` file 63 | 64 | **All your configuration is directly in your `.env.dist` file, as comments.** The configuration is divided in blocks that 65 | will be displayed to the user for a greater understanding of the configuration. Here are the fondations for Companienv: 66 | 67 | - **Blocks.** They logically group variables together. They are defined by a title (line starting with a double-comment 68 | `##`) and a description (every comment line directly bellow) 69 | - **Attributes.** Defined by a line starting with `#+`, an attribute is associated to one or multiple variables. These 70 | attributes are the entrypoint for extensions. In the example above, it says that the `JWT_*` variables are associated 71 | with an RSA key pair, so Companienv will automatically offer the user to generate one for them. 72 | - **Comments.** Lines starting by `#~` will be ignored by Companienv. 73 | 74 | *Example of `.env.dist.` file* 75 | ``` 76 | # .env.dist 77 | 78 | ## Welcome in the configuration of [my-project] 79 | # 80 | #~ Please run the `bin/start` command. 81 | #~ These lines starting with `~` are not going to be displayed to the user 82 | 83 | ## GitHub 84 | # In order to be able to login with GitHub, you need to create a GitHub application. To get access to the code 85 | # repositories, you need to create a GitHub integration. 86 | # 87 | #+file-to-propagate(GITHUB_INTEGRATION_KEY_PATH) 88 | # 89 | GITHUB_CLIENT_ID= 90 | GITHUB_CLIENT_SECRET= 91 | GITHUB_INTEGRATION_ID= 92 | GITHUB_INTEGRATION_KEY_PATH= 93 | GITHUB_SECRET= 94 | 95 | ## Security 96 | # We need sauce! Well, no, we need an SSL certificate. 97 | # 98 | #+rsa-pair(JWT_PRIVATE_KEY_PATH JWT_PUBLIC_KEY_PATH JWT_PRIVATE_KEY_PASS_PHRASE) 99 | # 100 | JWT_PRIVATE_KEY_PATH=/runtime/keys/jwt-private.pem 101 | JWT_PUBLIC_KEY_PATH=/runtime/keys/jwt-public.pem 102 | JWT_PRIVATE_KEY_PASS_PHRASE= 103 | 104 | ## Another block 105 | # With its (optional) description 106 | AND_OTHER_VARIABLES= 107 | ``` 108 | 109 | ## Built-in extensions 110 | 111 | - [Only if ...](#only-if-extension) 112 | - [Propagate file](#file-to-propagate-extension) 113 | - [RSA keys](#rsa-pair-extension) 114 | - [SSL certificate](#ssl-certificate-extension) 115 | 116 | ### `only-if` extension 117 | 118 | Some of the blocks of your `.env` file might not even be relevant if some other variable was disabling a given feature. 119 | 120 | **Example:** This will only ask for the `INTERCOM_APPLICATION_ID` variable if `INTERCOM_ENABLED` has the value (current 121 | or entered by the user) `true`. 122 | ``` 123 | ## Support & Feedback 124 | # If you would like to allow your users to get some support from you, give you some feedback and this 125 | # sort of things, select the integrations you'd like. 126 | # 127 | #+only-if(INTERCOM_APPLICATION_ID):(INTERCOM_ENABLED=true) 128 | # 129 | INTERCOM_ENABLED=false 130 | INTERCOM_APPLICATION_ID=none 131 | ``` 132 | 133 | ### `file-to-propagate` extension 134 | 135 | Will ask the path of an existing file and copy it to the destination mentioned in the reference. 136 | 137 | **Example:** this will ask the user to give the path of an existing file. It will copy this file to the path 138 | `/runtime/keys/firebase.json`, relative to the root directory of the project. 139 | ```yaml 140 | #+file-to-propagate(FIREBASE_SERVICE_ACCOUNT_PATH) 141 | FIREBASE_SERVICE_ACCOUNT_PATH=/runtime/keys/firebase.json 142 | ``` 143 | 144 | ### `rsa-pair` extension 145 | 146 | If the public/private key pair does not exist, Companienv will offer to generate one for the user. 147 | ```yaml 148 | #+rsa-pair(JWT_PRIVATE_KEY_PATH JWT_PUBLIC_KEY_PATH JWT_PRIVATE_KEY_PASS_PHRASE) 149 | JWT_PRIVATE_KEY_PATH=/runtime/keys/jwt-private.pem 150 | JWT_PUBLIC_KEY_PATH=/runtime/keys/jwt-public.pem 151 | JWT_PRIVATE_KEY_PASS_PHRASE= 152 | ``` 153 | 154 | ### `ssl-certificate-extension` 155 | 156 | Similar to the [RSA keys pair](#rsa-pair-extension): Companienv will offer to generate a self-signed SSL certificate if 157 | it does not exist yet. 158 | 159 | ```yaml 160 | #+ssl-certificate(SSL_CERTIFICATE_PRIVATE_KEY_PATH SSL_CERTIFICATE_CERTIFICATE_PATH SSL_CERTIFICATE_DOMAIN_NAME) 161 | SSL_CERTIFICATE_PRIVATE_KEY_PATH=/runtime/keys/server.key 162 | SSL_CERTIFICATE_CERTIFICATE_PATH=/runtime/keys/server.crt 163 | SSL_CERTIFICATE_DOMAIN_NAME= 164 | ``` 165 | 166 | ## Your own extensions 167 | 168 | You can easily create and use your own extensions with Companienv. In order to do so, you'll have to start Companienv 169 | with your own PHP file and use the `registerExtension` method of the `Application`: 170 | 171 | ```php 172 | use Companienv\Application; 173 | use Companienv\Extension; 174 | 175 | $application = new Application($rootDirectory); 176 | $application->registerExtension(new class() implements Extension { 177 | // Implements the interface... 178 | }); 179 | $application->run(); 180 | ``` 181 | --------------------------------------------------------------------------------