├── .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 |
--------------------------------------------------------------------------------