Sharp tools: direnv
In this article we'll take a look at direnv, a tool I've been using for years to manage environment variables when developing projects. It recently got new features that especially teams can benefit from.
intro
A lot of runtimes and applications can be configured using environment variables. Take git for example: no less than 3 dozen knobs are given to the user.
Same thing goes for a lot of runtimes for programming languages: there's around 7 variables showing up in my env that determine what ruby version is active in my shell and where ruby libraries are to be found and installed.
Probably the application you're building has a bunch of environment variables as well.
Direnv allows you to manage these environment variables on a per-folder basis. As it supports various shells (bash, zsh, tcsh, fish and elvish) and platforms, it's relatively easy to introduce in a team.
.envrc
Direnv is all about .envrc
files. Let's take a simple example:
# ~/projects/some-project/.envrc
export DATABASE_URL=postgres://host:5432/db1
Now when you enter ~/projects/some-project
(or any folder below it) direnv will make sure to update your environment. It will unset the variables once you leave the project.
stdlib
There's all sorts of helper functions from the standard library at your disposal. Take for example prepending a project-folder to $PATH:
# ~/projects/some-project/.envrc
PATH_add bin
Way more intuitive and less error-prone than export PATH=$PWD/bin:$PATH
!
For PATH-like variables you can use path_add
:
path_add GOPATH go
Another variable that is often handy to have is PROJECT_ROOT
, let's add it:
export PROJECT_ROOT=$(expand_path .)
This way scripts don't need to construct paths, but instead can just use $PROJECT_ROOT/some/file
.
We'll see some more functions from the stdlib in the tips below.
customization
While having export DATABASE_URL=postgres://localhost:5432/some_database
in a project's .envrc
is a great way to communicate what environment variables your application expects (and what 'shape' the value should have), the exact value will probably differ between developers on a team.
To allow any value to be overridden locally add the following to the bottom of your .envrc file:
# file: ~/projects/some-project/.envrc
export DATABASE_URL=postgres://localhost:5432/db1
if [[ -f ".envrc.local" ]]; then
source_env ".envrc.local"
fi
# better (see note below):
source_env_if_exists ".envrc.local"
Then in case the defaults don't suffice, a developer can put overrides in .envrc.local
.
UPDATE: I opened an issue with a suggestion to include this idiom in the stdlib. The author kindly delivered; so as of v2.24.0 the above would become source_env_if_exists ".envrc.local"
.
inheriting
By default direnv only considers the first .envrc
it finds going up the folder chain starting at $PWD.
If you want to 'inherit' from a .envrc
higher up use source_up
.
Typically I use this when having multiple projects under a client-folder:
~/projects
└── client
├── project1
└── project2
Now when you need e.g. client-specific git-settings, you'd do:
# file: ~/projects/client/.envrc
export GIT_AUTHOR_EMAIL=user@client.org
# then in ~/projects/client/project1/.envrc ^1
source_up
...other stuff
^1: for the sake of example this is .envrc
, typically you'd treat this as a personal setting and put source_up
in .envrc.local
You might be tempted (as I was before I RTFM) to use this to define global settings in ~/.envrc
. Don't do it, these belong in ~/.config/direnv/direnvrc
or ~/.config/direnv/lib/*.sh
.
CI
The project provides statically linked binaries for a wide variety of platforms, so using the latest version in a CI-job is easy:
# in an alpine-container
$ apk add bash #^1
$ DIRENV_VERSION=2.23.1 sh -c 'wget https://github.com/direnv/direnv/releases/download/v${DIRENV_VERSION}/direnv.linux-amd64 -O /usr/bin/direnv' && chmod +x /usr/bin/direnv
$ direnv allow /path/to/working-directory
# ^2
$ direnv exec /path/to/working-directory setup
^1: bash is required as direnv evaluates the contents of .envrc
files in a bash subshell (and copies environment variables that changed to the active shell).
^2: at this point you might want to source CI-specific values to override the defaults from the .envrc
extending
Extending the standard library with your own functions is easily done by writing a bash function and putting it under ~/.config/direnv/lib/*.sh
.
Say for example that we'd like to have an expressive way to set a specific version of the JDK.
Create ~/.config/direnv/lib/java.sh
containing the following:
#!/usr/bin/env bash
# NOTE this only works on MacOS
use_jdk() {
export JAVA_HOME=$(/usr/libexec/java_home -v"$1");
}
Then in a .envrc
:
use jdk 15 #^1
^1: We're using direnv's helper function use
that delegates to the appropriate use_*
function.
The problem of these custom functions is that they are hard to use in a team-context: in order to use this .envrc
it would require everyone on the team to have the java.sh in their lib-folder.
Though, with the recent introduction of source_url
we can make our .envrc
self-containing again.
The only thing we need to do is store java.sh
somewhere publicly accessible (e.g. as gist) and change our .envrc
:
direnv_version 2.23 #^1
# ^2
source_url "https://gist.githubusercontent.com/eval/049354b2d4ebd1e6f67440e4b98fffc5/raw/939c8b0767579866d6fa2a5cec2f215c82feca5c/java.sh" "sha256-TvMUsEtbn8ruOSNNIfcMNK3XWqXTPtGpKGkV3RARGU4="
use jdk 15
^1: ensure to trigger an error when using older direnv versions that don't support all functions we use in this .envrc
^2: the script will be downloaded and its integrity-hash verified before being sourced.
NOTE: To find the integrity hash for a script, leave it out and follow the instructions that direnv allow
shows.
conclusion
For years I found direnv to be an indispensable tool for development. The recently added capability to include external scripts makes it an even more powerful tool, especially for teams.