direnv: Convenient project-specific environments
I’m pretty particular about my development tools, and I really dislike any tool that requires careful curation of global state - e.g. ruby gems, python packages, etc. In recent years, things have gotten better. Ruby has bundler
, which keeps a project’s dependencies locally (avoiding any global state). Similarly, python has virtualenv
, which does much the same thing. Tools like rvm
and nvm
allow you to manage multiple versions of the language itself. Notably, the npm
package manager for nodejs fully embraces local dependencies - by default, packages are always installed locally (although the implementation itself is not particularly sane).
The inconvenience with most of these is that they require the developer to do something to “get into” a certain environment - if you try to run your python project’s tests without first activating the correct virtualenv, things will fail pretty badly. Some tools (e.g rvm
) include shell hooks to automatically activate the appropriate environment when you change directories, but they are tool-specific - you’ll need to add a hook in your shell for each tool you use, and I have my doubts that they would cooperate well since they do awful things like overriding the cd
command.
Enter direnv
I was very excited to find out about direnv (github: zimbatm/direnv) a few weeks ago, because I had just been looking for exactly such a tool, and considering writing one myself (I’m rather glad I didn’t have to). The idea is simple: extract all the messy stuff that rvm
, virtualenv
, etc do to manage per-directory environment variables, and put it into a single, general-purpose tool. You place an .envrc
script in the root directory of your project, and you can use whatever tools you need to inside that script to set project-specific environment variables (via export
statements, or by delegating to bundler, virtualenv, etc). direnv
takes care of sandboxing these modifications so that all changes are reversed when you leave the project directory.
Aside from relieving other tools of the arduous work of reimplementing this particular wheel (including individual integration with each shell), direnv is much more extensible than existing language-specific tools - you can (for example) also export credentials like AWS_ACCESS_KEY
, or add project-specific scripts to your $PATH
so you can just run mk
, rather than having to invoke an explicit path like ./tools/mk
.
Of course, few tools get my blessing these days if they don’t play well with ZeroInstall (if I had my way, all of rvm/virtualenv/npm/pip
would be replaced by just using ZeroInstall, but sadly I have yet to convince everyone to do that ;)). A while ago I wrote 0env as a tool for making ZeroInstall dependencies available in your shell, but unlike most tools it encourages you to work in a subshell, rather than altering your current shell session. Some people don’t like this approach, but the benefits (in code simplicity and lack of bugs) were well worth it. Thankfully, you can have your cake and eat it too if you use direnv
. For example, a normal use of 0env
looks like:
$ 0env myproject.xml
[myproject] $ # I'm in a subshell
[myproject] $ exit
$ # back in my original shell
But for convenience, you can make a trivial .envrc that defers all the logic to 0env
:
$ cat .envrc
direnv_load 0env myproject.xml -- direnv dump
Now, every time you cd
into this project directory, direnv will set up whatever environment variables 0env
would have set in the subshell, but it applies them to your current session instead, making sure to revert them when you leave the project directory.
Security concerns:
Obviously, care should be taken when automatically running scripts, since just cloning some code to your computer should not imply that you trust it to run arbitrary code. direnv
is pretty respectable here: an .envrc
will only be loaded once you’ve explicitly allowed it (by calling direnv allow
in the directory). An allow
action records the full path to the .envrc as well as a hash of its current contents - direnv will refuse to run every .envrc
that doesn’t have a matching allow rule for both of these properties (i.e if it’s changed or has been moved / copied).
There are still potential attacks - e.g if I add ./tools
to $PATH
, then someone could create a pull request with add a malicious ls
script in ./tools
. If I check it out locally, neither the .envrc
nor the location has changed, so direnv
would run the .envrc, and then I’d be in trouble then next time I run ls
(I do that a lot). This is pretty hard to avoid in the general case, I think the best approach is to keep the .envrc
simple and as specific as possible, so that there is as most one place where bad things could happen, which you just have to be mindful of (e.g I’d be very cautious of any change which added new files under tools/
in the above example).
Development and contributing
I’m using direnv 2.2.1
, which is barely a week old. It includes both of the features I contributed, which I (obviously ;)) think are important:
- the
direnv_load
command (used above), and - adding the file path to the
direnv allow
rules - previously, a.direnv
would be executed if it had identical contents to an allowed script, regardless of location.
The author (zimbatm) seems friendly and receptive to patches, which makes contributing to direnv pretty painless. It’s written in go
, which I’ve never used before. I’m definitely not a fan of the language’s insistence that error conditions must be implemented by wrapping almost every single function call in an if
block (but which doesn’t even warn you if you completely ignore a function’s returned error value), but aside from that the direnv
code is quite simple and easy to work with. And it’s certainly a huge step up from bash
, which is what it used to be written in, and which many similar tools are written in.