├── .envrc.sample ├── .github └── pull_request_template.md ├── .gitignore ├── .gitlab-ci.yml ├── .rspec ├── CHANGELOG.md ├── Gemfile ├── LICENSE.txt ├── README.md ├── Rakefile ├── bin ├── bundle ├── console ├── envied ├── rspec └── setup ├── envied.gemspec ├── examples └── extensive_envfile ├── exe └── envied ├── lib ├── envied.rb └── envied │ ├── cli.rb │ ├── coercer.rb │ ├── coercer │ └── envied_string.rb │ ├── configuration.rb │ ├── env_interceptor.rb │ ├── env_proxy.rb │ ├── env_var_extractor.rb │ ├── templates │ ├── Envfile.tt │ └── heroku-env-check.tt │ ├── variable.rb │ └── version.rb └── spec ├── coercer_spec.rb ├── configuration_spec.rb ├── env_var_extractor_spec.rb ├── envied_spec.rb ├── gemspec_spec.rb ├── spec_helper.rb └── variable_spec.rb /.envrc.sample: -------------------------------------------------------------------------------- 1 | # Copy this file to .envrc, adjust values if needed and 2 | # `source .envrc` to apply it. 3 | # Or use [direnv](https://direnv.net/) to auto-(un)load it. 4 | 5 | # using direnv 6 | # PATH_add bin 7 | 8 | # without direnv 9 | # export PATH="$(pwd)/bin:${PATH}" 10 | -------------------------------------------------------------------------------- /.github/pull_request_template.md: -------------------------------------------------------------------------------- 1 | *NOTE* the canonical repository for this project can be found at **https://gitlab.com/envied/envied**. 2 | Please open issues and send patches there. -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /.bundle/ 2 | /.yardoc 3 | /_yardoc/ 4 | /coverage/ 5 | /doc/ 6 | /pkg/ 7 | /spec/reports/ 8 | /spec/examples.txt 9 | /tmp/ 10 | 11 | # rspec failure tracking 12 | .rspec_status 13 | 14 | Gemfile.lock 15 | .envrc 16 | -------------------------------------------------------------------------------- /.gitlab-ci.yml: -------------------------------------------------------------------------------- 1 | .rspec: &rspec 2 | stage: test 3 | tags: 4 | - docker 5 | before_script: 6 | - "echo 'gem: --no-document' > ~/.gemrc" 7 | - gem install bundler 8 | - 'git -v || apk --update add git' 9 | - bundle install -j $(nproc) 10 | script: 11 | - ./bin/rspec 12 | 13 | rspec ruby-2.4: 14 | image: ruby:2.4-alpine 15 | <<: *rspec 16 | 17 | rspec ruby-2.5: 18 | image: ruby:2.5-alpine 19 | <<: *rspec 20 | 21 | rspec ruby-2.6: 22 | image: ruby:2.6-alpine 23 | <<: *rspec 24 | 25 | rspec ruby-2.7: 26 | image: ruby:2.7-rc-alpine 27 | <<: *rspec 28 | 29 | rspec jruby-9.2: 30 | image: jruby:9.2-alpine 31 | <<: *rspec 32 | only: 33 | - master 34 | - tags 35 | 36 | release: 37 | stage: deploy 38 | image: ruby:2.6-alpine 39 | tags: 40 | - docker 41 | script: 42 | - "echo 'gem: --no-document' > ~/.gemrc" 43 | - gem install bundler 44 | - 'git -v || apk --update add git' 45 | - bundle install -j $(nproc) 46 | - 'ruby -ryaml -e "puts YAML.dump({rubygems_api_key: ENV.fetch(%{RUBYGEMS_API_KEY})})" > ~/.gem/credentials' 47 | - chmod 600 ~/.gem/credentials 48 | - bundle exec rake release 49 | only: 50 | - tags 51 | environment: production -------------------------------------------------------------------------------- /.rspec: -------------------------------------------------------------------------------- 1 | --color 2 | --require spec_helper 3 | --format documentation 4 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | ## master / unreleased 2 | 3 | ### Added 4 | 5 | * key alias 6 | 7 | # file: Envfile 8 | key_alias! { Rails.env } 9 | variable :FOO 10 | 11 | The value of ENV['FOO_DEVELOPMENT'] will take precedence over ENV['FOO'] when running in development. 12 | 13 | ## 0.9.3 / 2019-06-29 14 | 15 | * Project moved to GitLab (https://gitlab.com/envied/envied) 16 | * Now requiring Ruby 2.4+ [#48], [#51] 17 | * Removed `coercible` dependency as now all coercion functionality is implemented locally. This is a backwards compatible change. [#49] 18 | * Lots of refactoring and bringing the project up-to-date 19 | 20 | ### Deprecated 21 | 22 | * default values 23 | 24 | See https://gitlab.com/envied/envied/tree/0-9-releases#defaults 25 | 26 | ## 0.9.1 / 2017-07-06 27 | 28 | * Updates `envied check:heroku` to support multiline ENV variables [#42](../../pull/42) 29 | 30 | ## 0.9.0 / 2017-03-01 31 | 32 | * Support multiple groups [#16](../../pull/16) 33 | * Replaced rack dependency with stdlib solution using CGI for supporting hash type 34 | * Now requiring Ruby 2.1.x and up 35 | * Added float type which resolves [#30](../../pull/30) 36 | * Added uri type [#22](../../pull/22) 37 | * Updated extractor to find interpolated ENV vars which fixes [#21](../../pull/21) 38 | * Various typo fixes [#24](../../pull/24) and [#28](../../pull/28) 39 | * Resolved warnings when running tests 40 | 41 | ## 0.8.2 / 2017-02-21 42 | 43 | ### Added 44 | 45 | * Rails 5 support [#25](../../pull/25) 46 | 47 | ### Fixed 48 | 49 | * Envfile: type string is also correctly implied when providing a default. 50 | 51 | ## 0.8.1 / 2014-10-18 52 | 53 | ### Added 54 | 55 | * the `check` task uses ENV['ENVIED_GROUPS'] as default groups-option if present 56 | 57 | ### Fixed 58 | 59 | * Rails: you can use ENVied now in config/application.rb and config/environments/*.rb 60 | 61 | For existing projects: rerun the `init:rails`-task 62 | 63 | * running `check:heroku` takes Heroku's value of ENVIED_GROUPS into account 64 | * calling `enable_defaults!` without arguments now does what it says 65 | 66 | ### Deprecated 67 | 68 | * doing `ENVied.require` in `config/initializers/envied.rb` (as previously generated by `init:rails`-task) 69 | is not recommended anymore. It triggers a deprecation warning. 70 | 71 | It's recommended to rerun the `init:rails`-task. 72 | 73 | ## 0.8.0 / 2014-10-04 74 | 75 | ### Added 76 | 77 | * the `check:heroku:binstub` task uses ENV['HEROKU_APP'] as app-option if present 78 | * the `check` and `check:heroku` task now have a `--quiet` option 79 | 80 | This prevents output on STDOUT for a successful check. 81 | Handy for wrapper-scripts: 82 | 83 | # some bash-script 'ensure-env' 84 | #!/usr/bin/env bash 85 | bundle exec envied check --quiet --groups default && $@ 86 | 87 | $ ./ensure-env echo 'works!' 88 | works! 89 | 90 | * ENVied.require accepts string with groups, e.g. 'default,production' 91 | 92 | This way it's possible to easily require groups using the ENV: 93 | 94 | # config/initializers/envied.rb 95 | ENVied.require(*ENV['ENVIED_GROUPS'] || Rails.groups) 96 | 97 | $ ENVIED_GROUPS='default,production' bin/rails server 98 | 99 | ### Fixed 100 | 101 | * extract-task would report at most one variable per line of code. 102 | * extract-task would not extract variable names with digits 103 | 104 | ### Deprecated 105 | 106 | * titlecase variable types. Use downcased instead: `variable :PORT, :integer` 107 | 108 | ### Removed 109 | 110 | * extract: test/spec-folder are no longer part of the default globs. 111 | 112 | Use the option `--tests` to include it: 113 | 114 | $ bundle exec envied extract --tests 115 | 116 | ## 0.7.2 / 2014-09-07 117 | 118 | ### Added 119 | 120 | * extract-task: see all ENV-variables used in your project. 121 | 122 | $ envied extract 123 | Found 63 occurrences of 45 variables: 124 | BUNDLE_GEMFILE 125 | * config/boot.rb:4 126 | * config/boot.rb:6 127 | ... 128 | 129 | * version-task (i.e. bin/envied --version) 130 | 131 | ## 0.7.1 / 2014-08-29 132 | 133 | * Total refactor (TM). 134 | 135 | * Fix bug in Heroku binstub. 136 | 137 | It checked for group 'default,production' instead of 'default' and 'production'. 138 | 139 | ## 0.7.0 / 2014-08-24 140 | 141 | * Add init:rails-task for setup in Rails applications. 142 | 143 | ## 0.6.3 / 2014-08-22 144 | 145 | * Fix bug: 'false' was not a coercible value. 146 | 147 | ## 0.6.2 / 2014-08-20 148 | 149 | * Add `envied check:heroku` to do a check on your Heroku app. 150 | 151 | * Add `envied check:heroku:binstub` to generate script for convenient 'check:heroku' 152 | 153 | ## 0.6.1 / 2014-08-13 154 | 155 | * Add `envied check` to check whether defined variables are present and valid. 156 | 157 | ## 0.6.0 / 2014-08-13 158 | 159 | * The configuration now lives in `Envfile` by default. 160 | 161 | ## 0.5.0 / 2014-07-02 162 | 163 | * add Array Hash types 164 | 165 | # in env.rb 166 | ENVied.configure { variable :TAGS, :Array; variable :HASH, :Hash } 167 | ENVied.require 168 | 169 | $ HASH=a=1&b=2 TAGS=tag1,tag2 ruby -renvied -r./env.rb -e 'p ENVied.TAGS' 170 | # ["tag1", "tag2"] 171 | $ HASH='a=1&b=2' TAGS=tag1,tag2 ruby -renvied -r./env.rb -e 'p ENVied.HASH' 172 | # {'a' => '1', 'b' => '2'} 173 | 174 | ## 0.4.0 / 2014-05-16 175 | 176 | * groups added 177 | 178 | This allows for more fine-grained requiring. 179 | See the section in the [README](https://github.com/eval/envied/tree/v0.4.0#groups). 180 | 181 | * configuring is now simpler: 182 | 183 | ENVied.configure { variable :RACK_ENV } 184 | # vs 185 | ENVied.configure {|env| env.variable :RACK_ENV } 186 | 187 | * Deprecate `require!`. Use `require` instead. 188 | 189 | Just like requiring groups with Bundler. 190 | 191 | * Deprecate lowercase methods for uppercase ENV-variables. 192 | 193 | `ENV['RACK_ENV']` is no longer accessible as `ENVied.rack_env`, only as `ENVied.RACK_ENV`. 194 | This is not only what you would expect, but it also reduces the chance of clashing with existing class-methods. 195 | 196 | ## 0.3.0 / 2014-03-14 197 | 198 | * defaults need to be enabled explicitly: 199 | 200 | `ENVied.configure(enable_defaults: Rails.env.development?) { ... }` 201 | 202 | ## 0.2.0 / 2014-03-14 203 | 204 | * add defaults 205 | 206 | ## 0.1.0 / 2014-03-13 207 | 208 | * add defaults 209 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | source 'https://rubygems.org' 2 | 3 | # Specify your gem's dependencies in envied.gemspec 4 | gemspec 5 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | Copyright (c) 2014-2019 Gert Goet, ThinkCreate 2 | 3 | MIT License 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining 6 | a copy of this software and associated documentation files (the 7 | "Software"), to deal in the Software without restriction, including 8 | without limitation the rights to use, copy, modify, merge, publish, 9 | distribute, sublicense, and/or sell copies of the Software, and to 10 | permit persons to whom the Software is furnished to do so, subject to 11 | the following conditions: 12 | 13 | The above copyright notice and this permission notice shall be 14 | included in all copies or substantial portions of the Software. 15 | 16 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 17 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 18 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND 19 | NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE 20 | LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 21 | OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION 22 | WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 23 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # ENVied 2 | 3 | ## -- THIS PROJECT IS NO LONGER MAINTAINED -- 4 | See https://github.com/eval/nero for a modern replacement. 5 | 6 | 7 | ### TL;DR ensure presence and type of your app's ENV-variables. 8 | 9 | For the rationale behind this project, see this [blogpost](https://www.gertgoet.com/2014/10/14/envied-or-how-i-stopped-worrying-about-ruby-s-env.html). 10 | 11 | ## Features 12 | 13 | * check for presence and correctness of ENV-variables 14 | * access to typed ENV-variables (integers, booleans etc. instead of just strings) 15 | * check the presence and correctness of a Heroku config 16 | 17 | ## Non-features 18 | 19 | * provide or load ENV-values 20 | 21 | ## Contents 22 | 23 | * [Quickstart](#quickstart) 24 | * [Installation](#installation) 25 | * [Configuration](#configuration) 26 | * [Types](#types) 27 | * [Key alias](#key-alias-unreleased) 28 | * [env-type](#env-type-unreleased) 29 | * [Groups](#groups) 30 | * [Command-line interface](#command-line-interface) 31 | * [Best Practices](#best-practices) 32 | * [FAQ](#faq) 33 | * [Testing](#testing) 34 | * [Development](#development) 35 | * [Contributing](#contributing) 36 | 37 | ## Quickstart 38 | 39 | ### 1) Configure 40 | 41 | After [successful installation](#installation), define some variables in `Envfile`: 42 | 43 | ```ruby 44 | # file: Envfile 45 | variable :FORCE_SSL, :boolean 46 | variable :PORT, :integer 47 | ``` 48 | 49 | ### 2) Check for presence and coercibility 50 | 51 | ```ruby 52 | # during initialization 53 | ENVied.require 54 | ``` 55 | 56 | This will throw an error if: 57 | * one of `ENV['FORCE_SSL']`, `ENV['PORT']` is absent. 58 | * or: their values *cannot* be coerced (resp. to boolean and integer). 59 | 60 | ### 3) Use coerced variables 61 | 62 | Variables accessed via ENVied are of the correct type: 63 | 64 | ```ruby 65 | ENVied.PORT # => 3001 66 | ENVied.FORCE_SSL # => false 67 | ``` 68 | 69 | 89 | 90 | ## Configuration 91 | 92 | ### Types 93 | 94 | The following types are supported: 95 | 96 | * `:array` (e.g. 'tag1,tag2' becomes `['tag1', 'tag2']`) 97 | * `:boolean` (e.g. '0'/'1', 'f'/'t', 'false'/'true', 'off'/'on', 'no'/'yes' for resp. false and true) 98 | * `:date` (e.g. '2014-3-26') 99 | * `:env` (similar to `:string`, but accessible via ENV - see [Key alias](#key-alias-unreleased) for details) 100 | * `:float` 101 | * `:hash` (e.g. 'a=1&b=2' becomes `{'a' => '1', 'b' => '2'}`) 102 | * `:integer` 103 | * `:string` (implied) 104 | * `:symbol` 105 | * `:time` (e.g. '14:00') 106 | * `:uri` (e.g. 'http://www.google.com' becomes result of `URI.parse('http://www.google.com')`) 107 | 108 | 109 | ### Key alias (unreleased) 110 | 111 | By default the value for variable `FOO` should be provided by `ENV['FOO']`. Sometimes though it's convenient to let a different key provide the value, based on some runtime condition. A key-alias will let you do this. 112 | 113 | Consider for example local development where `REDIS_URL` differs between the development and test environment. Normally you'd prepare different shells with different values for `REDIS_URL`: one shell you can run tests in, and other shells where you'd run the console/server etc. This is cumbersome and easy to get wrong. 114 | 115 | With a key alias that's calculated at runtime (e.g. `Rails.env`) you'd set values for both `REDIS_URL_TEST` and `REDIS_URL_DEVELOPMENT` and the right value will be used for test and development. 116 | 117 | Full example: 118 | ``` 119 | # file: Envfile 120 | key_alias! { Rails.env } 121 | 122 | variable :REDIS_URL, :uri 123 | ``` 124 | 125 | Source the following in your environment: 126 | ``` 127 | # file: .envrc 128 | export REDIS_URL_DEVELOPMENT=redis://localhost:6379/0 129 | export REDIS_URL_TEST=redis://localhost:6379/1 130 | ``` 131 | Now commands like `rails console` and `rails test` automatically point to the right redis database. 132 | 133 | Note that `ENV['REDIS_URL']` is still considered but `REDIS_URL_` takes precedence. 134 | Also: any truthy value provided as key_alias is converted to an upcased string. 135 | Finally: this setting is optional. 136 | 137 | 138 | #### env-type (unreleased) 139 | 140 | Variables of type `:env` take the key alias into account when accessing `ENV['FOO']`. 141 | 142 | Say, your application uses `ENV['DATABASE_URL']` (wich you can't change to `ENVied.DATABASE_URL`). Normally this would mean that the key alias has no effect. For env-type variables however, the key alias is taken into account: 143 | 144 | ``` 145 | # file: Envfile 146 | 147 | key_alias! { Rails.env } 148 | 149 | variable :DATABASE_URL, :env 150 | ``` 151 | 152 | The following now works: 153 | ```shell 154 | $ DATABASE_URL_DEVELOPMENT=postgres://localhost/blog_development rails runner "p ENV['DATABASE_URL']" 155 | "postgres://localhost/blog_development" 156 | ``` 157 | 158 | Note: this also works for `ENV.fetch('FOO')`. 159 | Also: no coercion is done (like you would expect when accessing ENV-values directly). 160 | 161 | This means that for Rails applications when you set values for `DATABASE_URL_DEVELOPMENT` and `DATABASE_URL_TEST`, you no longer need a `config/database.yml`. 162 | 163 | 164 | ### Groups 165 | 166 | Groups give you more flexibility to define when variables are needed. 167 | It's similar to groups in a Gemfile: 168 | 169 | ```ruby 170 | # file: Envfile 171 | variable :FORCE_SSL, :boolean 172 | 173 | group :production do 174 | variable :SECRET_KEY_BASE 175 | end 176 | 177 | group :development, :staging do 178 | variable :DEV_KEY 179 | end 180 | ``` 181 | 182 | ```ruby 183 | # For local development you would typically do: 184 | ENVied.require(:default) #=> Only ENV['FORCE_SSL'] is required 185 | # On the production server: 186 | ENVied.require(:default, :production) #=> ...also ENV['SECRET_KEY_BASE'] is required 187 | 188 | # You can also pass it a string with the groups separated by comma's: 189 | ENVied.require('default, production') 190 | 191 | # This allows for easily requiring groups using the ENV: 192 | ENVied.require(ENV['ENVIED_GROUPS']) 193 | # ...then from the prompt: 194 | $ ENVIED_GROUPS='default,production' bin/rails server 195 | 196 | # BTW the following are equivalent: 197 | ENVied.require 198 | ENVied.require(:default) 199 | ENVied.require('default') 200 | ENVied.require(nil) 201 | ``` 202 | 203 | ## Command-line interface 204 | 205 | For help on a specific command, use `envied help `. 206 | 207 | ```bash 208 | $ envied help 209 | Commands: 210 | envied check # Checks whether you environment contains required variables 211 | envied check:heroku # Checks whether a Heroku config contains required variables 212 | envied check:heroku:binstub # Generates a shell script for the check:heroku-task 213 | envied extract # Grep code to find ENV-variables 214 | envied help [COMMAND] # Describe available commands or one specific command 215 | envied init # Generates a default Envfile in the current working directory 216 | envied init:rails # Generate all files needed for a Rails project 217 | envied version, --version, -v # Shows version number 218 | ``` 219 | 220 | ## Best Practices 221 | 222 | Some best practices when using ENVied or working with env-configurable applications in general. 223 | 224 | ### include a .envrc.sample 225 | 226 | While ENVied will warn you when you start an application that is 'under-configured', it won't tell users what good default values are. To solve this add a file to the root of your project that contains sane defaults and instructions: 227 | ``` 228 | # file: .envrc.sample 229 | # copy this file to .envrc and adjust values if needed 230 | # then do `source .envrc` to load 231 | 232 | export DATABASE_URL=postgres://localhost/blog_development 233 | # export FORCE_SSL=true # only needed for production 234 | 235 | # you can find this token on the Heroku-dashboard 236 | export DEPLOY_TOKEN=1234-ABC-5678 237 | ``` 238 | 239 | ### let [direnv](https://direnv.net/) manage your environment 240 | 241 | [direnv](https://direnv.net/) will auto-(un)load values from `.envrc` when you switch folders. 242 | 243 | As a bonus it has some powerful commands in it's [stdlib](https://direnv.net/#man/direnv-stdlib.1). 244 | For example: 245 | ``` 246 | # this adds the project's bin-folder to $PATH 247 | PATH_add bin 248 | # so instead of `./bin/rails -h` you can do `rails -h` from anywhere (deep) in the project 249 | 250 | # the following will use the .envrc.sample as a basis 251 | # when new variables are introduced upstream, you'll automatically use these defaults 252 | if [ -f .envrc.sample ]; then 253 | source_env .envrc.sample 254 | fi 255 | ...your overrides 256 | 257 | # a variant of this is source_up 258 | # an .envrc in a subfolder can load the .envrc from the root of the project and override specific values 259 | # this would allow e.g. for a specific test-environment in the subfolder: 260 | # in my-project/test/.envrc 261 | source_up .envrc 262 | export DATABASE_URL=the-test-db-url 263 | ``` 264 | 265 | 266 | ## FAQ 267 | 268 | ### How to find all ENV-variables my app is currently using? 269 | 270 | ``` 271 | $ bundle exec envied extract 272 | ``` 273 | 274 | This comes in handy when you're not using ENVied yet. It will find all `ENV['KEY']` and `ENV.fetch('KEY')` statements in your project. 275 | 276 | It assumes a standard project layout (see the default value for the globs-option). 277 | 278 | ### How to check the config of a Heroku app? 279 | 280 | The easiest/quickest is to run: 281 | 282 | ``` 283 | $ heroku config --json | bundle exec envied check:heroku 284 | ``` 285 | 286 | This is equivalent to having the heroku config as your local environment and running `envied check:heroku --groups default production`. 287 | 288 | You want to run this right before a deploy to Heroku. This prevents that your app will crash during bootup because ENV-variables are missing from heroku config. 289 | 290 | You can turn the above into a handy binstub like so: 291 | ``` 292 | $ bundle exec envied check:heroku:binstub 293 | # created bin/heroku-env-check 294 | ``` 295 | 296 | This way you can do stuff like: 297 | ``` 298 | $ ./bin/heroku-env-check && git push live master 299 | ``` 300 | 301 | ### What happened to default values?? 302 | 303 | The short version: simplicity, i.e. the best tool for the job. 304 | 305 | In the early days of ENVied it was possible to provide default values for a variable. 306 | While convenient, it had several drawbacks: 307 | - it would introduce a value for ENVied.FOO, while ENV['FOO'] was nil: confusing and a potential source of bugs. 308 | - it hides the fact that an application can actually be configged via the environment. 309 | - it creates an in-process environment which is hard to inspect (as opposed to doing `printenv FOO` in a shell, after or before starting the application). 310 | - there are better ways: e.g. a sample file in a project with a bunch of exports (ie `export FOO=sane-default # and even some documentation`) that someone can source in their shell (see [Best Practices](#best-practices)). 311 | - made the code quite complex. 312 | 313 | As an alternative include a file `.envrc.sample` in the root of your project containing default values (ie `export FOO=bar`) that users can source in their shell. See also [Best Practices](#best-practices). 314 | 315 | 316 | ## Development 317 | 318 | ```bash 319 | $ ./bin/setup 320 | 321 | # run tests 322 | $ ./bin/rspec 323 | 324 | # hack with pry 325 | $ ./bin/console 326 | 327 | # run CLI: 328 | $ ./bin/envied 329 | ``` 330 | 331 | There's a `.envrc.sample` included that can be used in combination with [direnv](http://direnv.net/). 332 | 333 | ## Contributing 334 | 335 | To suggest a new feature, [open an Issue](https://gitlab.com/envied/envied/issues/new) before opening a PR. 336 | 337 | 1. Fork it: https://gitlab.com/envied/envied/-/forks/new 338 | 2. Create your feature branch: `git checkout -b my-new-feature` 339 | 3. Commit your changes: `git commit -am 'Add some feature'` 340 | 4. Push to the branch: `git push origin my-new-feature` 341 | 5. Create a new pull request for your feature branch 342 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | require "bundler/gem_tasks" 2 | require "rspec/core/rake_task" 3 | 4 | RSpec::Core::RakeTask.new(:spec) 5 | 6 | task default: :spec 7 | -------------------------------------------------------------------------------- /bin/bundle: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | # frozen_string_literal: true 3 | 4 | # 5 | # This file was generated by Bundler. 6 | # 7 | # The application 'bundle' is installed as part of a gem, and 8 | # this file is here to facilitate running it. 9 | # 10 | 11 | require "rubygems" 12 | 13 | m = Module.new do 14 | module_function 15 | 16 | def invoked_as_script? 17 | File.expand_path($0) == File.expand_path(__FILE__) 18 | end 19 | 20 | def env_var_version 21 | ENV["BUNDLER_VERSION"] 22 | end 23 | 24 | def cli_arg_version 25 | return unless invoked_as_script? # don't want to hijack other binstubs 26 | return unless "update".start_with?(ARGV.first || " ") # must be running `bundle update` 27 | bundler_version = nil 28 | update_index = nil 29 | ARGV.each_with_index do |a, i| 30 | if update_index && update_index.succ == i && a =~ Gem::Version::ANCHORED_VERSION_PATTERN 31 | bundler_version = a 32 | end 33 | next unless a =~ /\A--bundler(?:[= ](#{Gem::Version::VERSION_PATTERN}))?\z/ 34 | bundler_version = $1 || ">= 0.a" 35 | update_index = i 36 | end 37 | bundler_version 38 | end 39 | 40 | def gemfile 41 | gemfile = ENV["BUNDLE_GEMFILE"] 42 | return gemfile if gemfile && !gemfile.empty? 43 | 44 | File.expand_path("../../Gemfile", __FILE__) 45 | end 46 | 47 | def lockfile 48 | lockfile = 49 | case File.basename(gemfile) 50 | when "gems.rb" then gemfile.sub(/\.rb$/, gemfile) 51 | else "#{gemfile}.lock" 52 | end 53 | File.expand_path(lockfile) 54 | end 55 | 56 | def lockfile_version 57 | return unless File.file?(lockfile) 58 | lockfile_contents = File.read(lockfile) 59 | return unless lockfile_contents =~ /\n\nBUNDLED WITH\n\s{2,}(#{Gem::Version::VERSION_PATTERN})\n/ 60 | Regexp.last_match(1) 61 | end 62 | 63 | def bundler_version 64 | @bundler_version ||= begin 65 | env_var_version || cli_arg_version || 66 | lockfile_version || "#{Gem::Requirement.default}.a" 67 | end 68 | end 69 | 70 | def load_bundler! 71 | ENV["BUNDLE_GEMFILE"] ||= gemfile 72 | 73 | # must dup string for RG < 1.8 compatibility 74 | activate_bundler(bundler_version.dup) 75 | end 76 | 77 | def activate_bundler(bundler_version) 78 | if Gem::Version.correct?(bundler_version) && Gem::Version.new(bundler_version).release < Gem::Version.new("2.0") 79 | bundler_version = "< 2" 80 | end 81 | gem_error = activation_error_handling do 82 | gem "bundler", bundler_version 83 | end 84 | return if gem_error.nil? 85 | require_error = activation_error_handling do 86 | require "bundler/version" 87 | end 88 | return if require_error.nil? && Gem::Requirement.new(bundler_version).satisfied_by?(Gem::Version.new(Bundler::VERSION)) 89 | warn "Activating bundler (#{bundler_version}) failed:\n#{gem_error.message}\n\nTo install the version of bundler this project requires, run `gem install bundler -v '#{bundler_version}'`" 90 | exit 42 91 | end 92 | 93 | def activation_error_handling 94 | yield 95 | nil 96 | rescue StandardError, LoadError => e 97 | e 98 | end 99 | end 100 | 101 | m.load_bundler! 102 | 103 | if m.invoked_as_script? 104 | load Gem.bin_path("bundler", "bundle") 105 | end 106 | -------------------------------------------------------------------------------- /bin/console: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | 3 | require "bundler/setup" 4 | require "envied" 5 | 6 | # You can add fixtures and/or initialization code here to make experimenting 7 | # with your gem easier. You can also use a different console, if you like. 8 | 9 | require 'pry' 10 | Pry.start 11 | -------------------------------------------------------------------------------- /bin/envied: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby -Ilib 2 | 3 | require 'envied' 4 | 5 | ENVied::Cli.start -------------------------------------------------------------------------------- /bin/rspec: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | # frozen_string_literal: true 3 | 4 | # 5 | # This file was generated by Bundler. 6 | # 7 | # The application 'rspec' is installed as part of a gem, and 8 | # this file is here to facilitate running it. 9 | # 10 | 11 | require "pathname" 12 | ENV["BUNDLE_GEMFILE"] ||= File.expand_path("../../Gemfile", 13 | Pathname.new(__FILE__).realpath) 14 | 15 | bundle_binstub = File.expand_path("../bundle", __FILE__) 16 | 17 | if File.file?(bundle_binstub) 18 | if File.read(bundle_binstub, 300) =~ /This file was generated by Bundler/ 19 | load(bundle_binstub) 20 | else 21 | abort("Your `bin/bundle` was not generated by Bundler, so this binstub cannot run. 22 | Replace `bin/bundle` by running `bundle binstubs bundler --force`, then run this command again.") 23 | end 24 | end 25 | 26 | require "rubygems" 27 | require "bundler/setup" 28 | 29 | load Gem.bin_path("rspec-core", "rspec") 30 | -------------------------------------------------------------------------------- /bin/setup: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | set -euo pipefail 3 | IFS=$'\n\t' 4 | set -vx 5 | 6 | bundle install 7 | 8 | # Do any other automated setup that you need to do here 9 | -------------------------------------------------------------------------------- /envied.gemspec: -------------------------------------------------------------------------------- 1 | # coding: utf-8 2 | lib = File.expand_path("../lib", __FILE__) 3 | $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib) 4 | require "envied/version" 5 | 6 | Gem::Specification.new do |spec| 7 | spec.name = "envied" 8 | spec.version = ENVied::VERSION 9 | spec.authors = ["Gert Goet"] 10 | spec.email = ["gert@thinkcreate.nl"] 11 | spec.summary = %q{Ensure presence and type of ENV-variables} 12 | spec.description = %q{Ensure presence and type of your app's ENV-variables.} 13 | spec.homepage = "https://gitlab.com/envied/envied/tree/master#envied" 14 | spec.license = "MIT" 15 | 16 | spec.metadata["homepage_uri"] = spec.homepage 17 | spec.metadata["source_code_uri"] = "https://gitlab.com/envied/envied.git" 18 | spec.metadata["changelog_uri"] = "https://gitlab.com/envied/envied/blob/master/CHANGELOG.md" 19 | 20 | # Specify which files should be added to the gem when it is released. 21 | # The `git ls-files -z` loads the files in the RubyGem that have been added into git. 22 | spec.files = Dir.chdir(File.expand_path('..', __FILE__)) do 23 | `git ls-files -z`.split("\x0").reject { |f| f.match(%r{^(test|spec|features)/}) } 24 | end 25 | spec.bindir = 'exe' 26 | spec.executables = spec.files.grep(%r{^exe/}) { |f| File.basename(f) } 27 | spec.test_files = spec.files.grep(%r{^(test|spec|features)/}) 28 | spec.require_paths = ["lib"] 29 | 30 | spec.required_ruby_version = ">= 2.4" 31 | 32 | spec.add_dependency "thor", "~> 0.20" 33 | 34 | spec.add_development_dependency "bundler", "~> 2.0" 35 | spec.add_development_dependency "pry", "~> 0.12" 36 | spec.add_development_dependency "rake", "~> 12.0" 37 | spec.add_development_dependency "rspec", "~> 3.0" 38 | end 39 | -------------------------------------------------------------------------------- /examples/extensive_envfile: -------------------------------------------------------------------------------- 1 | # -*- mode: ruby -*-¬ 2 | # We allow defaults for local development (and local tests), but want our CI 3 | # to mimic our production as much as possible. 4 | # New developers that don't have RACK_ENV set, will in this way not be presented with a huge 5 | # list of missing variables, as defaults are still enabled. 6 | not_production_nor_ci = ->{ !(ENV['RACK_ENV'] == 'production' || ENV['CI']) } 7 | enable_defaults!(¬_production_nor_ci) 8 | 9 | # Your code will likely not use ENVied.RACK_ENV (better use Rails.env), 10 | # we want it to be present though; heck, we're using it in this file! 11 | variable :RACK_ENV 12 | 13 | variable :FORCE_SSL, :boolean, default: 'false' 14 | variable :PORT, :integer, default: '3000' 15 | 16 | variable :TAGS, :array, default: 'tag1,tag2' 17 | # ENVied.TAGS 18 | # => ['tag1', 'tag2'] 19 | 20 | # generate the default value using the value of PORT: 21 | variable :PUBLIC_HOST_WITH_PORT, :string, default: proc { |envied| 22 | "localhost:#{envied.PORT}" 23 | } 24 | 25 | # Or better yet, use the URI type: 26 | variable :SITE_URI, :uri, default: 'http://localhost:5000/' 27 | # So with that you could now do: 28 | # ``` 29 | # config.action_mailer.default_url_options = { 30 | # protocol: ENVied.SITE_URI.scheme, 31 | # host: ENVied.SITE_URI.host 32 | # } 33 | # config.action_mailer.asset_host = ENVied.SITE_URI.to_s 34 | # ``` 35 | 36 | group :production do 37 | variable :MAIL_PAAS_USERNAME 38 | variable :DATABASE_URL 39 | end 40 | 41 | group :ci do 42 | # ci-only stuff 43 | end 44 | 45 | group :not_ci do 46 | # CI needs no puma-threads, and sidekiq-stuff etc. 47 | # Define that here: 48 | variable :MIN_THREADS, :integer, default: '1' 49 | # more... 50 | end 51 | 52 | # Depending on our situation, we can now require the correct groups in our initialization-file: 53 | # At local machines: 54 | # ENVied.require(:default, :development, :not_ci) or 55 | # ENVied.require(:default, :test, :not_ci) 56 | 57 | # At the server: 58 | # ENVied.require(:default, :production, :not_ci) 59 | 60 | # At CI: 61 | # ENVied.require(:default, :test, :ci) 62 | 63 | # All in one line: 64 | # ENVied.require(:default, ENV['RACK_ENV'], (ENV['CI'] ? :ci : :not_ci)) 65 | -------------------------------------------------------------------------------- /exe/envied: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | require 'envied' 3 | 4 | ENVied::Cli.start 5 | -------------------------------------------------------------------------------- /lib/envied.rb: -------------------------------------------------------------------------------- 1 | require 'envied/version' 2 | require 'envied/cli' 3 | require 'envied/env_proxy' 4 | require 'envied/coercer' 5 | require 'envied/coercer/envied_string' 6 | require 'envied/variable' 7 | require 'envied/configuration' 8 | require 'envied/env_interceptor' 9 | 10 | class ENVied 11 | class << self 12 | attr_reader :env, :config 13 | alias_method :required?, :env 14 | end 15 | 16 | def self.require(*args, **options) 17 | requested_groups = (args && !args.empty?) ? args : ENV['ENVIED_GROUPS'] 18 | env!(requested_groups, options) 19 | error_on_duplicate_variables!(options) 20 | error_on_missing_variables!(options) 21 | error_on_uncoercible_variables!(options) 22 | 23 | intercept_env_vars! 24 | ensure_spring_after_fork_require(args, options) 25 | end 26 | 27 | def self.env!(requested_groups, **options) 28 | @config = options.fetch(:config) { Configuration.load } 29 | @env = EnvProxy.new(@config, groups: required_groups(*requested_groups)) 30 | end 31 | 32 | def self.error_on_duplicate_variables!(options) 33 | var_types_by_name = env.variables.reduce({}) do |acc, v| 34 | (acc[v.name] ||= []).push v.type 35 | acc 36 | end 37 | dups = var_types_by_name.select {|name, types| types.uniq.count > 1 } 38 | 39 | if dups.any? 40 | raise "The following variables are defined more than once with different types: #{dups.keys.join(', ')}" 41 | end 42 | end 43 | 44 | def self.error_on_missing_variables!(**options) 45 | names = env.missing_variables.map(&:name).uniq.sort 46 | if names.any? 47 | msg = "The following environment variables should be set: #{names.join(', ')}." 48 | msg << "\nPlease make sure to stop Spring before retrying." if spring_enabled? && !options[:via_spring] 49 | raise msg 50 | end 51 | end 52 | 53 | def self.error_on_uncoercible_variables!(**options) 54 | errors = env.uncoercible_variables.map do |v| 55 | format("%{name} with %{value} (%{type})", name: v.name, value: env.value_to_coerce(v).inspect, type: v.type) 56 | end 57 | if errors.any? 58 | msg = "The following environment variables are not coercible: #{errors.join(", ")}." 59 | msg << "\nPlease make sure to stop Spring before retrying." if spring_enabled? && !options[:via_spring] 60 | raise msg 61 | end 62 | end 63 | 64 | def self.required_groups(*groups) 65 | splitter = ->(group){ group.is_a?(String) ? group.split(/ *, */) : group } 66 | result = groups.compact.map(&splitter).flatten 67 | result.any? ? result.map(&:to_sym) : [:default] 68 | end 69 | 70 | def self.env_keys_to_intercept 71 | env.variables.select{|v| v.type == :env }.map{|var| var.name.to_s } 72 | end 73 | 74 | def self.intercept_env_vars! 75 | if (keys = env_keys_to_intercept).any? 76 | ENV.instance_eval { @__envied_env_keys = keys } 77 | class << ENV 78 | prepend(ENVied::EnvInterceptor) 79 | end 80 | end 81 | end 82 | private_class_method :intercept_env_vars!, :env_keys_to_intercept 83 | 84 | def self.ensure_spring_after_fork_require(args, **options) 85 | if spring_enabled? && !options[:via_spring] 86 | Spring.after_fork { ENVied.require(args, options.merge(via_spring: true)) } 87 | end 88 | end 89 | 90 | def self.springify(&block) 91 | if defined?(ActiveSupport::Deprecation.warn) && !required? 92 | ActiveSupport::Deprecation.warn(<<~MSG) 93 | It's no longer recommended to `ENVied.require` within ENVied.springify's 94 | block. Please re-run `envied init:rails` to upgrade. 95 | MSG 96 | end 97 | if spring_enabled? 98 | Spring.after_fork(&block) 99 | else 100 | block.call 101 | end 102 | end 103 | 104 | def self.spring_enabled? 105 | defined?(Spring) && Spring.respond_to?(:watcher) 106 | end 107 | 108 | def self.method_missing(method, *args, &block) 109 | respond_to_missing?(method) ? (env && env[method.to_s]) : super 110 | end 111 | 112 | def self.respond_to_missing?(method, include_private = false) 113 | (env && env.has_key?(method)) || super 114 | end 115 | 116 | end 117 | -------------------------------------------------------------------------------- /lib/envied/cli.rb: -------------------------------------------------------------------------------- 1 | require 'thor' 2 | require 'json' 3 | require 'envied/env_var_extractor' 4 | 5 | class ENVied 6 | class Cli < Thor 7 | include Thor::Actions 8 | source_root File.expand_path('../templates', __FILE__) 9 | 10 | desc "version, --version, -v", "Shows version number" 11 | def version 12 | puts ENVied::VERSION 13 | end 14 | map %w(-v --version) => :version 15 | 16 | desc "extract", "Grep code to find ENV-variables" 17 | long_desc <<-LONG 18 | Greps source-files to find all ENV-variables your code is using. 19 | 20 | This task helps you find variables to put in your Envfile. 21 | 22 | By default the test/spec-folders are excluded. Use `--tests` to include them. 23 | LONG 24 | option :globs, type: :array, default: ENVied::EnvVarExtractor.defaults[:globs], banner: "*.* lib/*" 25 | option :tests, type: :boolean, default: false, desc: "include tests/specs" 26 | def extract 27 | globs = options[:globs] 28 | globs << "{test,spec}/*" if options[:tests] 29 | var_occurrences = ENVied::EnvVarExtractor.new(globs: globs).extract 30 | 31 | puts "Found %d occurrences of %d variables:" % [var_occurrences.values.flatten.size, var_occurrences.size] 32 | var_occurrences.sort.each do |var, occs| 33 | puts var 34 | occs.sort_by{|i| i[:path].size }.each do |occ| 35 | puts "* %s:%s" % occ.values_at(:path, :line) 36 | end 37 | puts 38 | end 39 | end 40 | 41 | desc "init", "Generates a default Envfile in the current working directory" 42 | def init 43 | puts "Writing Envfile to #{File.expand_path('Envfile')}" 44 | template("Envfile.tt") 45 | 46 | puts "Add the following snippet (or similar) to your app's initialization:" 47 | puts "ENVied.require(*ENV['ENVIED_GROUPS'] || [:default, ENV['RACK_ENV']])" 48 | end 49 | 50 | desc "init:rails", "Generate all files needed for a Rails project" 51 | define_method "init:rails" do 52 | puts "Writing Envfile to #{File.expand_path('Envfile')}" 53 | template("Envfile.tt") 54 | inject_into_file "config/application.rb", "\nENVied.require(*ENV['ENVIED_GROUPS'] || Rails.groups)", after: /^ *Bundler.require.+$/ 55 | legacy_initializer = Dir['config/initializers/*envied*.rb'].first 56 | if legacy_initializer && File.exists?(legacy_initializer) 57 | puts "Removing 'ENVied.require' from #{legacy_initializer.inspect}." 58 | puts "(you might want to remove the whole file)" 59 | comment_lines legacy_initializer, /ENVied.require/ 60 | end 61 | end 62 | 63 | desc "check", "Checks whether you environment contains required variables" 64 | long_desc <<-LONG 65 | Checks whether required variables are present and valid in your shell. 66 | 67 | On success the process will exit with status 0. 68 | Else the missing/invalid variables will be shown, and the process will exit with status 1. 69 | LONG 70 | option :groups, type: :array, desc: "uses ENV['ENVIED_GROUPS'] as default if present", default: ENV['ENVIED_GROUPS'] || %w(default), banner: 'default production' 71 | option :quiet, type: :boolean, desc: 'Communicate success of the check only via the exit status.' 72 | def check 73 | if rails_project? 74 | require File.expand_path 'config/environment.rb' 75 | end 76 | ENVied.require(*options[:groups]) 77 | unless options[:quiet] 78 | puts "All variables for group(s) #{options[:groups]} are present and valid" 79 | end 80 | end 81 | 82 | desc "check:heroku", "Checks whether a Heroku config contains required variables" 83 | long_desc <<-LONG 84 | Checks the config of your Heroku app against the local Envfile. 85 | 86 | The Heroku config should be piped to this task: 87 | 88 | heroku config --json | bundle exec envied check:heroku 89 | 90 | Use the check:heroku:binstub-task to turn this into a bash-script. 91 | 92 | On success the process will exit with status 0. 93 | Else the missing/invalid variables will be shown, and the process will exit with status 1. 94 | LONG 95 | option :groups, type: :array, default: %w(default production), banner: 'default production' 96 | option :quiet, type: :boolean, desc: 'Communicate success of the check only via the exit status.' 97 | define_method "check:heroku" do 98 | if STDIN.tty? 99 | error "Please pipe to this task i.e. `heroku config --json | bundle exec envied check:heroku`" 100 | exit 1 101 | end 102 | heroku_env = JSON.parse(STDIN.read) 103 | ENV.replace(heroku_env) 104 | 105 | requested_groups = ENV['ENVIED_GROUPS'] || options[:groups] 106 | ENVied.require(*requested_groups) 107 | unless options[:quiet] 108 | puts "All variables for group(s) #{requested_groups} are present and valid in your Heroku app" 109 | end 110 | end 111 | 112 | desc "check:heroku:binstub", "Generates a shell script for the check:heroku-task" 113 | long_desc <<-LONG 114 | Generates a shell script to check the Heroku config against the local Envfile. 115 | 116 | The same as the check:heroku-task, but all in one script (no need to pipe `heroku config --json` to it etc.). 117 | LONG 118 | option :dest, banner: "where to put the script", desc: "Default: bin/-env-check or bin/heroku-env-check" 119 | option :app, banner: "name of Heroku app", desc: "uses ENV['HEROKU_APP'] as default if present", default: ENV['HEROKU_APP'] 120 | option :groups, type: :array, default: %w(default production), banner: 'default production' 121 | define_method "check:heroku:binstub" do 122 | require 'fileutils' 123 | @app = options[:app] 124 | @dest = options[:dest] 125 | @dest ||= File.join(*%W(bin #{(@app || 'heroku')}-env-check)) 126 | @groups = options[:groups] 127 | 128 | full_dest = File.expand_path(@dest) 129 | template("heroku-env-check.tt", full_dest) 130 | FileUtils.chmod 0755, full_dest 131 | end 132 | 133 | no_tasks do 134 | def rails_project? 135 | File.exists?('config/environment.rb') 136 | end 137 | end 138 | end 139 | end 140 | -------------------------------------------------------------------------------- /lib/envied/coercer.rb: -------------------------------------------------------------------------------- 1 | # Responsible for all string to type coercions. 2 | class ENVied::Coercer 3 | 4 | UnsupportedCoercion = Class.new(StandardError) 5 | 6 | # Coerce strings to specific type. 7 | # 8 | # @param string [String] the string to be coerced 9 | # @param type [#to_sym] the type to coerce to 10 | # 11 | # @example 12 | # ENVied::Coercer.new.coerce('1', :Integer) 13 | # # => 1 14 | # 15 | # @return [type] the coerced string. 16 | def coerce(string, type) 17 | unless supported_type?(type) 18 | raise ArgumentError, "The type `#{type.inspect}` is not supported." 19 | end 20 | coercer.public_send("to_#{type.downcase}", string) 21 | end 22 | 23 | def self.supported_types 24 | @supported_types ||= begin 25 | [:env, :hash, :array, :time, :date, :symbol, :boolean, :integer, :string, :uri, :float].sort 26 | end 27 | end 28 | 29 | # Whether or not Coercer can coerce strings to the provided type. 30 | # 31 | # @param type [#to_sym] the type (case insensitive) 32 | # 33 | # @example 34 | # ENVied::Coercer.supported_type?('string') 35 | # # => true 36 | # 37 | # @return [Boolean] whether type is supported. 38 | def self.supported_type?(type) 39 | supported_types.include?(type.to_sym.downcase) 40 | end 41 | 42 | def supported_type?(type) 43 | self.class.supported_type?(type) 44 | end 45 | 46 | def supported_types 47 | self.class.supported_types 48 | end 49 | 50 | def coercer 51 | @coercer ||= ENViedString.new 52 | end 53 | 54 | def coerced?(value) 55 | !value.kind_of?(String) 56 | end 57 | 58 | def coercible?(string, type) 59 | return false unless supported_type?(type) 60 | coerce(string, type) 61 | true 62 | rescue UnsupportedCoercion 63 | false 64 | end 65 | end 66 | -------------------------------------------------------------------------------- /lib/envied/coercer/envied_string.rb: -------------------------------------------------------------------------------- 1 | class ENVied::Coercer::ENViedString 2 | TRUE_VALUES = %w[1 on t true y yes].freeze 3 | FALSE_VALUES = %w[0 off f false n no].freeze 4 | BOOLEAN_MAP = (TRUE_VALUES.product([ true ]) + FALSE_VALUES.product([ false ])).to_h.freeze 5 | 6 | def to_array(str) 7 | str.split(/(? 0.9.2'`) to keep using defaults." 21 | end 22 | 23 | def self.load(**options) 24 | envfile = File.expand_path('Envfile') 25 | new(options).tap do |v| 26 | v.instance_eval(File.read(envfile), envfile) 27 | end 28 | end 29 | 30 | def variable(name, type = :string, **options) 31 | unless coercer.supported_type?(type) 32 | raise ArgumentError, "#{type.inspect} is not a supported type. Should be one of #{coercer.supported_types}" 33 | end 34 | options[:group] = current_group if current_group 35 | variables << ENVied::Variable.new(name, type, options) 36 | end 37 | 38 | def group(*names, &block) 39 | names.each do |name| 40 | @current_group = name.to_sym 41 | yield 42 | end 43 | ensure 44 | @current_group = nil 45 | end 46 | 47 | def variables 48 | @variables ||= [] 49 | end 50 | end 51 | end 52 | -------------------------------------------------------------------------------- /lib/envied/env_interceptor.rb: -------------------------------------------------------------------------------- 1 | class ENVied 2 | module EnvInterceptor 3 | def [](key) 4 | if @__envied_env_keys.include?(key) 5 | ENVied.public_send(key) 6 | else 7 | super 8 | end 9 | end 10 | 11 | def fetch(key, *args) 12 | if @__envied_env_keys.include?(key) 13 | ENVied.public_send(key) 14 | else 15 | super 16 | end 17 | end 18 | end 19 | end 20 | -------------------------------------------------------------------------------- /lib/envied/env_proxy.rb: -------------------------------------------------------------------------------- 1 | class ENVied 2 | # Responsible for anything related to the ENV. 3 | class EnvProxy 4 | attr_reader :config, :coercer, :groups 5 | private :config, :coercer, :groups 6 | 7 | def initialize(config, **options) 8 | @config = config 9 | @coercer = options.fetch(:coercer, ENVied::Coercer.new) 10 | @groups = options.fetch(:groups, []) 11 | end 12 | 13 | def missing_variables 14 | variables.select(&method(:missing?)) 15 | end 16 | 17 | def uncoercible_variables 18 | variables.reject(&method(:coerced?)).reject(&method(:coercible?)) 19 | end 20 | 21 | def [](name) 22 | coerce(variables_by_name[name.to_sym]) 23 | end 24 | 25 | def has_key?(name) 26 | variables_by_name[name.to_sym] 27 | end 28 | 29 | def value_to_coerce(var) 30 | return env_value_of(var) unless env_value_of(var).nil? 31 | end 32 | 33 | def variables 34 | @variables ||= config.variables.select {|v| groups.include?(v.group) } 35 | end 36 | 37 | private 38 | 39 | def coerce(var) 40 | coerced?(var) ? 41 | value_to_coerce(var) : 42 | coercer.coerce(value_to_coerce(var), var.type) 43 | end 44 | 45 | def coerced?(var) 46 | coercer.coerced?(value_to_coerce(var)) 47 | end 48 | 49 | def coercible?(var) 50 | coercer.coercible?(value_to_coerce(var), var.type) 51 | end 52 | 53 | def env_value_of(var) 54 | ENV.values_at(*env_keys(var, key_alias: config.key_alias)).compact.first 55 | end 56 | 57 | def env_keys(var, key_alias: nil) 58 | base_key = var.name.to_s 59 | 60 | result = [base_key] 61 | result.unshift "#{base_key}_#{key_alias}" if key_alias 62 | result 63 | end 64 | 65 | def missing?(var) 66 | value_to_coerce(var).nil? 67 | end 68 | 69 | def variables_by_name 70 | @variables_by_name ||= variables.map {|v| [v.name, v] }.to_h 71 | end 72 | end 73 | end 74 | -------------------------------------------------------------------------------- /lib/envied/env_var_extractor.rb: -------------------------------------------------------------------------------- 1 | class ENVied 2 | class EnvVarExtractor 3 | def self.defaults 4 | @defaults ||= begin 5 | { 6 | extensions: %w(ru thor rake rb yml ruby yaml erb builder markerb haml), 7 | globs: %w(*.* Thorfile Rakefile {app,config,db,lib,script}/*) 8 | } 9 | end 10 | end 11 | 12 | def defaults 13 | self.class.defaults 14 | end 15 | 16 | attr_reader :globs, :extensions 17 | 18 | def initialize(**options) 19 | @globs = options.fetch(:globs, self.defaults[:globs]) 20 | @extensions = options.fetch(:extensions, self.defaults[:extensions]) 21 | end 22 | 23 | def self.extract_from(globs, **options) 24 | new(options.merge(globs: Array(globs))).extract 25 | end 26 | 27 | # Greps all ENV-variables from a line of text. 28 | # Captures 'A' in lines like `ENV['A']`, but also `ENV.fetch('A')`. 29 | # 30 | # @param line [String] the line to grep 31 | # 32 | # @example 33 | # extractor.new.capture_variables("config.force_ssl = ENV['FORCE_SSL']") 34 | # # => ["FORCE_SSL"] 35 | # 36 | # @return [Array] the names o 37 | def capture_variables(line) 38 | line.scan(/ENV(?:\[|\.fetch\()['"]([^'"]+)['"]/).flatten 39 | end 40 | 41 | # Extract all keys recursively from files found via `globs`. 42 | # Any occurrence of `ENV['A']` or `ENV.fetch('A')`, will result 43 | # in 'A' being extracted. 44 | # 45 | # @param globs [Array] the collection of globs 46 | # 47 | # @example 48 | # EnvVarExtractor.new.extract(*%w(app lib)) 49 | # # => {'A' => [{:path => 'app/models/user.rb', :line => 2}, {:path => ..., :line => ...}], 50 | # 'B' => [{:path => 'config/application.rb', :line => 12}]} 51 | # 52 | # @return [ Array Array>}>] the list of items. 53 | def extract(globs = self.globs) 54 | results = Hash.new { |hash, key| hash[key] = [] } 55 | 56 | Array(globs).each do |glob| 57 | Dir.glob(glob).each do |item| 58 | next if File.basename(item)[0] == ?. 59 | 60 | if File.directory?(item) 61 | results.merge!(extract("#{item}/*")) 62 | else 63 | next unless extensions.detect {|ext| File.extname(item)[ext] } 64 | File.readlines(item).each_with_index do |line, ix| 65 | capture_variables(line).each do |variable| 66 | results[variable] << { :path => item, :line => ix.succ } 67 | end 68 | end 69 | end 70 | end 71 | end 72 | 73 | results 74 | end 75 | end 76 | end 77 | -------------------------------------------------------------------------------- /lib/envied/templates/Envfile.tt: -------------------------------------------------------------------------------- 1 | # 2 | # Allow ENV['FOO_'] to provide for ENVied.FOO (with ENV['FOO'] 3 | # as fallback). 4 | # key_alias! { Rails.env } 5 | 6 | # variable :REDIS_URL, :string 7 | # 8 | # group :production do 9 | # variable :SECRET_KEY_BASE 10 | # end 11 | -------------------------------------------------------------------------------- /lib/envied/templates/heroku-env-check.tt: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | # Check the config of a Heroku app against the defined variables in `Envfile` 4 | 5 | <%- if @app %> 6 | HEROKU_APP=<%= @app %> exec heroku config --json | bundle exec envied check:heroku --groups <%= @groups.join(" ") %> 7 | <%- else %> 8 | exec heroku config --json | bundle exec envied check:heroku --groups <%= @groups.join(" ") %> 9 | <%- end %> 10 | -------------------------------------------------------------------------------- /lib/envied/variable.rb: -------------------------------------------------------------------------------- 1 | class ENVied::Variable 2 | attr_reader :name, :type, :group 3 | 4 | def initialize(name, type, **options) 5 | @name = name.to_sym 6 | @type = type.to_sym 7 | @group = options.fetch(:group, :default).to_sym 8 | end 9 | 10 | def ==(other) 11 | self.class == other.class && 12 | [name, type, group] == [other.name, other.type, other.group] 13 | end 14 | end 15 | -------------------------------------------------------------------------------- /lib/envied/version.rb: -------------------------------------------------------------------------------- 1 | class ENVied 2 | VERSION = '0.10.0.alpha3' 3 | end 4 | -------------------------------------------------------------------------------- /spec/coercer_spec.rb: -------------------------------------------------------------------------------- 1 | RSpec.describe ENVied::Coercer do 2 | it { is_expected.to respond_to :coerce } 3 | it { is_expected.to respond_to :coerced? } 4 | it { is_expected.to respond_to :coercible? } 5 | it { is_expected.to respond_to :supported_types } 6 | it { is_expected.to respond_to :supported_type? } 7 | 8 | describe '.supported_types' do 9 | it 'returns a sorted set of supported types' do 10 | expect(described_class.supported_types).to eq %i(array boolean date env float hash integer string symbol time uri) 11 | end 12 | end 13 | 14 | describe '.supported_type?' do 15 | it 'returns true for supported type' do 16 | %i(array boolean date float hash integer string symbol time uri).each do |type| 17 | expect(described_class.supported_type?(type)).to eq true 18 | end 19 | end 20 | 21 | it 'returns false for unsupported type' do 22 | expect(described_class.supported_type?(:fixnum)).to eq false 23 | end 24 | end 25 | 26 | describe '#supported_types' do 27 | it 'calls class method implementation' do 28 | expect(described_class).to receive(:supported_types).and_call_original 29 | described_class.new.supported_types 30 | end 31 | end 32 | 33 | describe '#supported_type?' do 34 | it 'calls class method implementation' do 35 | expect(described_class).to receive(:supported_type?).with(:string).and_call_original 36 | described_class.new.supported_type?(:string) 37 | end 38 | end 39 | 40 | describe '#coerced?' do 41 | let(:coercer) { described_class.new } 42 | 43 | it 'returns true if value has been coerced (not a string)' do 44 | expect(coercer.coerced?(1)).to eq true 45 | end 46 | 47 | it 'returns false if value is not a string' do 48 | expect(coercer.coerced?('1')).to eq false 49 | end 50 | end 51 | 52 | describe '#coercible?' do 53 | let(:coercer) { described_class.new } 54 | 55 | it 'returns false for unsupported type' do 56 | expect(coercer.coercible?('value', :invalid_type)).to eq false 57 | end 58 | 59 | it 'returns false for a failed coercion' do 60 | expect(coercer.coercible?('value', :boolean)).to eq false 61 | end 62 | 63 | it 'returns true for a coercible value' do 64 | expect(coercer.coercible?('value', :string)).to eq true 65 | end 66 | end 67 | 68 | describe '#coerce' do 69 | let(:coercer){ described_class.new } 70 | 71 | def coerce(str, type) 72 | coercer.coerce(str, type) 73 | end 74 | 75 | it 'fails with an invalid type' do 76 | expect { coerce('', :fixnum) }.to raise_error(ArgumentError, "The type `:fixnum` is not supported.") 77 | end 78 | 79 | describe 'to string' do 80 | it 'returns the input untouched' do 81 | expect(coerce('1', :string)).to eq '1' 82 | expect(coerce(' 1', :string)).to eq ' 1' 83 | end 84 | 85 | it 'fails when the value does not respond to #to_str' do 86 | value = Object.new 87 | expect { coerce(value, :string) }.to raise_error(ENVied::Coercer::UnsupportedCoercion) 88 | end 89 | end 90 | 91 | describe 'to integer' do 92 | { 93 | '1' => 1, 94 | '+1' => 1, 95 | '-1' => -1, 96 | '10' => 10, 97 | '100_00' => 100_00, 98 | '1_000_00' => 1_000_00 99 | }.each do |value, integer| 100 | it "converts #{value.inspect} to an integer" do 101 | expect(coerce(value, :integer)).to be_kind_of(Integer) 102 | expect(coerce(value, :integer)).to eq integer 103 | end 104 | end 105 | 106 | it 'fails with an invalid string' do 107 | expect { coerce('non-integer', :integer) }.to raise_error(ENVied::Coercer::UnsupportedCoercion) 108 | end 109 | 110 | it 'fails with a float' do 111 | expect { coerce('1.23', :integer) }.to raise_error(ENVied::Coercer::UnsupportedCoercion) 112 | end 113 | end 114 | 115 | describe 'to float' do 116 | { 117 | '1' => 1.0, 118 | '+1' => 1.0, 119 | '-1' => -1.0, 120 | '1_000.0' => 1_000.0, 121 | '10_000.23' => 10_000.23, 122 | '1_000_000.0' => 1_000_000.0, 123 | '1.0' => 1.0, 124 | '1.234' => 1.234, 125 | '1.0e+1' => 10.0, 126 | '1.0e-1' => 0.1, 127 | '1.0E+1' => 10.0, 128 | '1.0E-1' => 0.1, 129 | '+1.0' => 1.0, 130 | '+1.0e+1' => 10.0, 131 | '+1.0e-1' => 0.1, 132 | '+1.0E+1' => 10.0, 133 | '+1.0E-1' => 0.1, 134 | '-1.0' => -1.0, 135 | '-1.234' => -1.234, 136 | '-1.0e+1' => -10.0, 137 | '-1.0e-1' => -0.1, 138 | '-1.0E+1' => -10.0, 139 | '-1.0E-1' => -0.1, 140 | '.1' => 0.1, 141 | '.1e+1' => 1.0, 142 | '.1e-1' => 0.01, 143 | '.1E+1' => 1.0, 144 | '.1E-1' => 0.01, 145 | '1e1' => 10.0, 146 | '1E+1' => 10.0, 147 | '+1e-1' => 0.1, 148 | '-1E1' => -10.0, 149 | '-1e-1' => -0.1, 150 | }.each do |value, float| 151 | it "converts #{value.inspect} to a float" do 152 | expect(coerce(value, :float)).to be_kind_of(Float) 153 | expect(coerce(value, :float)).to eq float 154 | end 155 | end 156 | 157 | it 'fails with an invalid string' do 158 | expect { coerce('non-float', :float) }.to raise_error(ENVied::Coercer::UnsupportedCoercion) 159 | end 160 | 161 | it 'fails when string starts with e' do 162 | expect { coerce('e1', :float) }.to raise_error(ENVied::Coercer::UnsupportedCoercion) 163 | end 164 | end 165 | 166 | describe 'to boolean' do 167 | %w[ 1 on ON t true T TRUE y yes Y YES ].each do |value| 168 | it "converts #{value.inspect} to `true`" do 169 | expect(coerce(value, :boolean)).to eq true 170 | end 171 | end 172 | 173 | %w[ 0 off OFF f false F FALSE n no N NO ].each do |value| 174 | it "converts #{value.inspect} to `false`" do 175 | expect(coerce(value, :boolean)).to eq false 176 | end 177 | end 178 | 179 | it 'fails with an invalid boolean string' do 180 | expect { coerce('non-boolean', :boolean) }.to raise_error(ENVied::Coercer::UnsupportedCoercion) 181 | end 182 | end 183 | 184 | describe 'to symbol' do 185 | it 'converts strings to symbols' do 186 | expect(coerce('a', :symbol)).to eq :a 187 | expect(coerce('nice_symbol', :symbol)).to eq :nice_symbol 188 | end 189 | end 190 | 191 | describe 'to date' do 192 | it 'converts string to date' do 193 | date = coerce('2019-03-22', :date) 194 | 195 | expect(date).to be_instance_of(Date) 196 | expect(date.year).to eq 2019 197 | expect(date.month).to eq 3 198 | expect(date.day).to eq 22 199 | end 200 | 201 | it 'converts other string formats to date' do 202 | expect(coerce('March 22nd, 2019', :date)).to eq Date.parse('2019-03-22') 203 | expect(coerce('Sat, March 23rd, 2019', :date)).to eq Date.parse('2019-03-23') 204 | end 205 | 206 | it 'fails with an invalid string' do 207 | expect { coerce('non-date', :date) }.to raise_error(ENVied::Coercer::UnsupportedCoercion) 208 | end 209 | end 210 | 211 | describe 'to time' do 212 | it 'converts string to time without time part' do 213 | time = coerce("2019-03-22", :time) 214 | 215 | expect(time).to be_instance_of(Time) 216 | expect(time.year).to eq 2019 217 | expect(time.month).to eq 3 218 | expect(time.day).to eq 22 219 | expect(time.hour).to eq 0 220 | expect(time.min).to eq 0 221 | expect(time.sec).to eq 0 222 | end 223 | 224 | it 'converts string to time with time portion' do 225 | time = coerce("March 22nd, 2019 9:30:55", :time) 226 | 227 | expect(time).to be_instance_of(Time) 228 | expect(time.year).to eq 2019 229 | expect(time.month).to eq 3 230 | expect(time.day).to eq 22 231 | expect(time.hour).to eq 9 232 | expect(time.min).to eq 30 233 | expect(time.sec).to eq 55 234 | end 235 | 236 | it 'fails with an invalid string' do 237 | expect { coerce('2999', :time) }.to raise_error(ENVied::Coercer::UnsupportedCoercion) 238 | end 239 | end 240 | 241 | describe 'to array' do 242 | it 'converts strings to array' do 243 | { 244 | 'a,b' => ['a','b'], 245 | ' a, b' => [' a',' b'], 246 | 'apples,and\, of course\, pears' => ['apples','and, of course, pears'], 247 | }.each do |value, array| 248 | expect(coerce(value, :array)).to eq array 249 | end 250 | end 251 | end 252 | 253 | describe 'to hash' do 254 | it 'converts strings to hashes' do 255 | { 256 | 'a=1' => {'a' => '1'}, 257 | 'a=1&b=2' => {'a' => '1', 'b' => '2'}, 258 | 'a=&b=2' => {'a' => '', 'b' => '2'}, 259 | 'a&b=2' => {'a' => nil, 'b' => '2'}, 260 | }.each do |value, hash| 261 | expect(coerce(value, :hash)).to eq hash 262 | end 263 | end 264 | end 265 | 266 | describe 'to uri' do 267 | it 'converts strings to uris' do 268 | expect(coerce('https://www.google.com', :uri)).to be_a(URI) 269 | expect(coerce('https://www.google.com', :uri).scheme).to eq 'https' 270 | expect(coerce('https://www.google.com', :uri).host).to eq 'www.google.com' 271 | end 272 | end 273 | end 274 | end 275 | -------------------------------------------------------------------------------- /spec/configuration_spec.rb: -------------------------------------------------------------------------------- 1 | RSpec.describe ENVied::Configuration do 2 | it { is_expected.to respond_to :variable } 3 | it { is_expected.to respond_to :group } 4 | 5 | def with_envfile(**options, &block) 6 | @config = ENVied::Configuration.new(options, &block) 7 | end 8 | attr_reader :config 9 | 10 | describe 'variables' do 11 | it 'results in an added variable' do 12 | with_envfile do 13 | variable :foo, :boolean 14 | end 15 | 16 | expect(config.variables).to include ENVied::Variable.new(:foo, :boolean, group: :default) 17 | end 18 | 19 | it 'sets string as default type when no type is given' do 20 | with_envfile do 21 | variable :bar 22 | end 23 | 24 | expect(config.variables).to include ENVied::Variable.new(:bar, :string, default: nil, group: :default) 25 | end 26 | 27 | it 'sets a default value when specified' do 28 | with_envfile do 29 | variable :bar, default: 'bar' 30 | end 31 | 32 | expect(config.variables).to include ENVied::Variable.new(:bar, :string, default: 'bar', group: :default) 33 | end 34 | 35 | it 'sets specific group for variable' do 36 | with_envfile do 37 | group :production do 38 | variable :SECRET_KEY_BASE 39 | end 40 | end 41 | 42 | expect(config.variables).to include ENVied::Variable.new(:SECRET_KEY_BASE, :string, group: :production) 43 | end 44 | 45 | it 'sets the same variable for multiple groups' do 46 | with_envfile do 47 | group :development, :test do 48 | variable :DISABLE_PRY, :boolean, default: 'false' 49 | end 50 | end 51 | 52 | expect(config.variables).to eq [ 53 | ENVied::Variable.new(:DISABLE_PRY, :boolean, default: 'false', group: :development), 54 | ENVied::Variable.new(:DISABLE_PRY, :boolean, default: 'false', group: :test) 55 | ] 56 | end 57 | end 58 | end 59 | -------------------------------------------------------------------------------- /spec/env_var_extractor_spec.rb: -------------------------------------------------------------------------------- 1 | RSpec.describe ENVied::EnvVarExtractor do 2 | 3 | describe "#capture_variables" do 4 | def capture_variables(text) 5 | described_class.new.capture_variables(text) 6 | end 7 | 8 | { 9 | %{self.a = ENV['A']} => %w(A), 10 | %{self.a = ENV["A"]} => %w(A), 11 | %{self.a = ENV.fetch('A')]} => %w(A), 12 | %{self.a = ENV.fetch("A")]} => %w(A), 13 | %{# self.a = ENV["A"]} => %w(A), 14 | %{self.a = ENV["A"] && self.b = ENV["B"]} => %w(A B), 15 | %{self.a = ENV["A3"]} => %w(A3) 16 | }.each do |line, expected| 17 | it "captures #{expected} from #{line.inspect}" do 18 | expect(capture_variables(line)).to contain_exactly(*expected) 19 | end 20 | end 21 | end 22 | end 23 | -------------------------------------------------------------------------------- /spec/envied_spec.rb: -------------------------------------------------------------------------------- 1 | RSpec.describe ENVied do 2 | describe 'class' do 3 | subject { described_class } 4 | it { is_expected.to respond_to :require } 5 | end 6 | 7 | def reset_envied 8 | ENVied.instance_eval do 9 | @env = nil 10 | @config = nil 11 | end 12 | end 13 | 14 | context 'configured' do 15 | 16 | before do 17 | reset_envied 18 | end 19 | 20 | def set_ENV(env = {}) 21 | stub_const("ENV", env) 22 | end 23 | 24 | def configure(**options, &block) 25 | @config = ENVied::Configuration.new(options, &block) 26 | end 27 | 28 | def configured_with(**hash) 29 | @config = ENVied::Configuration.new.tap do |config| 30 | hash.each do |name, type| 31 | config.variable(name, type) 32 | end 33 | end 34 | end 35 | 36 | def envied_require(*args, **options) 37 | options[:config] = @config || ENVied::Configuration.new # Prevent `Configuration.load` from being called 38 | ENVied.require(*args, **options) 39 | end 40 | 41 | it 'does respond to configured variables' do 42 | set_ENV('A' => '1') 43 | configured_with(A: :integer) 44 | envied_require 45 | 46 | expect(described_class).to respond_to :A 47 | end 48 | 49 | it 'does not respond to unconfigured variables' do 50 | set_ENV('A' => '1') 51 | configured_with 52 | envied_require 53 | 54 | expect(described_class).to_not respond_to :B 55 | end 56 | 57 | it 'sets ENVied.config' do 58 | set_ENV('A' => '1') 59 | configured_with(A: :integer) 60 | envied_require 61 | 62 | expect(ENVied.config).to be_instance_of(ENVied::Configuration) 63 | end 64 | 65 | context 'ENV contains not all configured variables' do 66 | before do 67 | set_ENV 68 | configured_with(A: :integer) 69 | end 70 | 71 | specify do 72 | expect { 73 | envied_require 74 | }.to raise_error(RuntimeError, 'The following environment variables should be set: A.') 75 | end 76 | end 77 | 78 | context 'ENV variables are not coercible' do 79 | before do 80 | set_ENV('A' => 'NaN', 'B' => 'invalid') 81 | configured_with(A: :integer, B: :boolean) 82 | end 83 | 84 | specify do 85 | expect { 86 | envied_require 87 | }.to raise_error( 88 | RuntimeError, 89 | 'The following environment variables are not coercible: A with "NaN" (integer), B with "invalid" (boolean).' 90 | ) 91 | end 92 | end 93 | 94 | context 'duplicate variables' do 95 | specify do 96 | set_ENV('A' => 'foo') 97 | configure do 98 | key_alias! { 'TEST' } 99 | 100 | variable :A, :string 101 | variable :B, :string 102 | 103 | variable :A, :env 104 | 105 | group :required do 106 | variable :B, :env 107 | end 108 | end 109 | 110 | expect { 111 | envied_require(:default, :required) 112 | }.to raise_error(RuntimeError, 113 | 'The following variables are defined more than once with different types: A, B') 114 | end 115 | end 116 | 117 | context 'using a key_alias' do 118 | it 'ensures env-key FOO_ takes precedence over FOO' do 119 | set_ENV('A' => 'a-original', 'A_TEST' => 'a-test', 'B' => 'b-original') 120 | configure do 121 | key_alias! { 'TEST' } 122 | 123 | variable :A 124 | variable :B 125 | end 126 | envied_require 127 | 128 | expect(described_class.A).to eq 'a-test' 129 | expect(described_class.B).to eq 'b-original' 130 | end 131 | 132 | it 'is converted in an upcased string' do 133 | set_ENV('A_TEST' => 'UPPERCASED', 'A_test' => 'lowercase') 134 | configure do 135 | key_alias! { :test } 136 | 137 | variable :A 138 | end 139 | envied_require 140 | 141 | expect(described_class.A).to eq 'UPPERCASED' 142 | end 143 | end 144 | 145 | context 'configuring' do 146 | it 'raises error when configuring variable of unknown type' do 147 | expect { 148 | configured_with(A: :fixnum) 149 | }.to raise_error(ArgumentError, ":fixnum is not a supported type. Should be one of #{ENVied::Coercer.supported_types}") 150 | end 151 | end 152 | 153 | describe ".required?" do 154 | it 'returns true-ish if `ENVied.require` was called' do 155 | expect { 156 | envied_require 157 | }.to change { ENVied.required? }.from(nil).to(anything) 158 | end 159 | end 160 | 161 | describe "groups" do 162 | context 'a variable in a group' do 163 | before do 164 | set_ENV 165 | configure do 166 | variable :MORE 167 | 168 | group :foo do 169 | variable :BAR 170 | end 171 | group :moo do 172 | variable :BAT 173 | end 174 | end 175 | end 176 | 177 | it 'is required when requiring the groups passed as a delimited string' do 178 | expect { 179 | envied_require('foo,moo') 180 | }.to raise_error(RuntimeError, 'The following environment variables should be set: BAR, BAT.') 181 | end 182 | 183 | it 'is required when requiring the group' do 184 | [:foo, 'foo'].each do |group| 185 | expect { 186 | envied_require(group) 187 | }.to raise_error(RuntimeError, 'The following environment variables should be set: BAR.') 188 | end 189 | end 190 | 191 | it 'is not required when requiring another group' do 192 | [:bat, 'bat'].each do |group| 193 | expect { 194 | envied_require(group) 195 | }.to_not raise_error 196 | end 197 | end 198 | 199 | it 'will not define variables not part of the default group' do 200 | set_ENV('MORE' => 'yes') 201 | envied_require(:default) 202 | 203 | expect { 204 | described_class.BAR 205 | }.to raise_error(NoMethodError) 206 | end 207 | 208 | it 'takes ENV["ENVIED_GROUPS"] into account when nothing is passed to require' do 209 | set_ENV('ENVIED_GROUPS' => 'foo') 210 | expect { 211 | envied_require 212 | }.to raise_error(RuntimeError, 'The following environment variables should be set: BAR.') 213 | end 214 | 215 | it 'will define variables in the default group when nothing is passed to require' do 216 | set_ENV('MORE' => 'yes') 217 | envied_require 218 | 219 | expect(described_class.MORE).to eq 'yes' 220 | end 221 | 222 | it 'requires variables without a group when requiring the default group' do 223 | [:default, 'default'].each do |group| 224 | expect { 225 | envied_require(group) 226 | }.to raise_error(RuntimeError, 'The following environment variables should be set: MORE.') 227 | end 228 | end 229 | end 230 | 231 | context 'a variable in multiple groups' do 232 | before do 233 | set_ENV 234 | configure do 235 | variable :MORE 236 | 237 | group :foo, :moo do 238 | variable :BAR 239 | end 240 | end 241 | end 242 | 243 | it 'is required when requiring any of the groups' do 244 | expect { 245 | envied_require(:foo) 246 | }.to raise_error(RuntimeError, 'The following environment variables should be set: BAR.') 247 | 248 | expect { 249 | envied_require(:moo) 250 | }.to raise_error(RuntimeError, 'The following environment variables should be set: BAR.') 251 | end 252 | end 253 | 254 | describe 'Hashable' do 255 | before do 256 | set_ENV('FOO' => 'a=1&b=&c', 'BAR' => '') 257 | configure do 258 | variable :FOO, :hash 259 | variable :BAR, :hash 260 | end 261 | envied_require 262 | end 263 | 264 | it 'yields hash from string' do 265 | expect(ENVied.FOO).to eq({ 'a' => '1', 'b' => '', 'c' => nil }) 266 | end 267 | 268 | it 'yields hash from an empty string' do 269 | expect(ENVied.BAR).to eq({}) 270 | end 271 | end 272 | 273 | describe 'Arrayable' do 274 | before do 275 | set_ENV('MORE' => 'a, b, and\, c') 276 | configure do 277 | variable :MORE, :array 278 | end 279 | envied_require 280 | end 281 | 282 | it 'yields array from string' do 283 | expect(ENVied.MORE).to eq ['a',' b',' and, c'] 284 | end 285 | end 286 | 287 | describe 'URIable' do 288 | before do 289 | set_ENV('SITE_URL' => 'https://www.google.com') 290 | configure do 291 | variable :SITE_URL, :uri 292 | end 293 | envied_require 294 | end 295 | 296 | it 'yields a URI from string' do 297 | expect(ENVied.SITE_URL).to be_a URI 298 | expect(ENVied.SITE_URL.scheme).to eq 'https' 299 | expect(ENVied.SITE_URL.host).to eq 'www.google.com' 300 | end 301 | end 302 | end 303 | 304 | describe 'env-type' do 305 | it 'intercepts ENV and applies key_alias' do 306 | set_ENV('FOO_ALIAS' => 'foo_alias', 'BAR' => 'bar', 'BAR_ALIAS' => 'bar_alias') 307 | configure do 308 | key_alias! { 'ALIAS' } 309 | 310 | variable :FOO, :env 311 | 312 | group :not_required do 313 | variable :BAR, :env 314 | end 315 | end 316 | envied_require 317 | 318 | expect(ENV['FOO']).to eq 'foo_alias' 319 | expect(ENV['BAR']).to eq 'bar' 320 | end 321 | end 322 | end 323 | end 324 | -------------------------------------------------------------------------------- /spec/gemspec_spec.rb: -------------------------------------------------------------------------------- 1 | require "open3" 2 | 3 | RSpec.describe 'building envied.gemspec' do 4 | 5 | def build! 6 | @build ||= Open3.capture3("gem build envied.gemspec") 7 | [:stdout, :stderr, :status].zip(@build).to_h 8 | end 9 | 10 | after do 11 | Dir.glob("envied-*.gem").each { |f| File.delete(f) } 12 | end 13 | 14 | it 'yields no warnings' do 15 | expect(build![:stderr]).to be_empty 16 | end 17 | 18 | specify do 19 | expect(build![:status]).to be_success 20 | end 21 | end 22 | -------------------------------------------------------------------------------- /spec/spec_helper.rb: -------------------------------------------------------------------------------- 1 | require "bundler/setup" 2 | require "envied" 3 | 4 | RSpec.configure do |config| 5 | # colorize output on CI 6 | config.tty = true 7 | 8 | # Enable flags like --only-failures and --next-failure 9 | config.example_status_persistence_file_path = ".rspec_status" 10 | 11 | # Disable RSpec exposing methods globally on `Module` and `main` 12 | config.disable_monkey_patching! 13 | 14 | config.expect_with :rspec do |c| 15 | c.syntax = :expect 16 | end 17 | 18 | config.order = :random 19 | end 20 | -------------------------------------------------------------------------------- /spec/variable_spec.rb: -------------------------------------------------------------------------------- 1 | RSpec.describe ENVied::Variable do 2 | def variable(*args) 3 | described_class.new(*args) 4 | end 5 | 6 | describe 'an instance' do 7 | subject { variable(:A, :string) } 8 | it { is_expected.to respond_to :name } 9 | it { is_expected.to respond_to :type } 10 | it { is_expected.to respond_to :group } 11 | it { is_expected.to respond_to :== } 12 | end 13 | end 14 | --------------------------------------------------------------------------------