Introduction
env-select is a command line tool that makes it easy to define reusable shell environments. Its most common use case (but not only) is to manage deployed environments when working with webapps. For example, if you have a webapp that has local, staging, and production environments, you can use env-select to set environment variables corresponding to each environment:
[applications.my_webapp.profiles.local.variables]
PROTOCOL = "http"
HOST = "localhost"
PORT = 3000
[applications.my_webapp.profiles.staging.variables]
PROTOCOL = "https"
HOST = "staging.my.webapp"
PORT = 443
[applications.my_webapp.profiles.production.variables]
PROTOCOL = "https"
HOST = "production.my.webapp"
PORT = 443
env-select integrates with your shell to make it easy to configure environments. There are two possible ways to use env-select's environments:
es set
- Export values to modify your current shelles run
- Run a single command under the environment, without modifying your shell
Read on to Getting Started to learn how to use env-select!
Install
See the artifacts page to download and install es
using your preferred method.
Install Shell Function
While not strictly required, it's highly recommended to install the es
shell function. This wraps the es
binary command, allowing it to automatically modify your current shell environment with the es set
subcommand. Otherwise, you'll have to manually pipe the output of es set
to source
.
If you only plan to use the
es run
command, and don't care about shell tab completions, this is not relevant.
This is necessary because a child process is not allowed to modify its parent's environment. That means the es
process cannot modify the environment of the invoking shell. The wrapping shell function takes the output of es
and runs it in that shell session to update the environment.
Here's how you install it:
Bash
echo 'eval "$(es --shell bash init)"' >> ~/.bashrc
Zsh
echo "source <(es --shell zsh init)" >> ~/.zshrc
Fish
echo "es --shell fish init | source" >> ~/.config/fish/config.fish
Restart your shell afterward to apply changes.
Getting Started
Create the configuration file
Once you've installed env-select, setup is easy. All configuration is defined in the TOML format. Create a file called .env-select.toml
to hold your configuration. This file will apply to to your current directory and all descendent directories. In other words, any time you run the es
command, it will search up the directory tree the .env-select.toml
file.
Here's an example .env-select.toml
file:
[applications.server.profiles.dev]
variables = {SERVICE1 = "dev", SERVICE2 = "also-dev"}
[applications.server.profiles.prd]
variables = {SERVICE1 = "prd", SERVICE2 = "also-prd"}
[applications.db.profiles.dev]
variables = {DATABASE = "dev", DB_USER = "root", DB_PASSWORD = "badpw"}
[applications.db.profiles.stg]
variables = {DATABASE = "stg", DB_USER = "root", DB_PASSWORD = "goodpw"}
[applications.db.profiles.prd]
variables = {DATABASE = "prd", DB_USER = "root", DB_PASSWORD = "greatpw"}
Now, you can easily switch between the defined values with es
.
Select a set of variables
In the config above, we've already predefined an application called server
, which consists of two profiles, dev
and prd
. We can select between those profiles by providing the application name.
> es set server
❯ === dev ===
SERVICE1=dev
SERVICE2=also-dev
=== prd ===
SERVICE1=prd
SERVICE2=also-prd
> echo $SERVICE1 $SERVICE2
dev also-dev
If you know the name of the profile you want to select, you can skip the prompt by providing it directly to the command:
> es set server dev
> echo $SERVICE1 $SERVICE2
dev also-dev
Run a single command
If you want to run only a single command in the modified environment, rather than modify the entire shell, you can use es run
instead of es set
:
# Select the profile to use for the `server` application, then run the command
> es run server -- echo $SERVICE1 $SERVICE2
❯ === dev ===
SERVICE1=dev
SERVICE2=also-dev
=== prd ===
SERVICE1=prd
SERVICE2=also-prd
dev also-dev
# You can also specify the profile name up front
> es run server dev -- echo $SERVICE1 $SERVICE2
dev also-dev
# The surrounding environment is *not* modified
> echo $SERVICE1 $SERVICE2
--
is required to delineate the arguments handled by es
from the command being executed. The executed command is executed in your shell, so you can access shell features such as pipes and aliases.
Key Concepts
env-select operates with a few different building blocks. From largest to smallest (rougly), they are: Application, Profile, Variable Mapping, Value Source and Side Effect.
Application
An application is a group. "Application" in this case is a synonym for "use case" or "purpose". Each profile in an application accomplishes different versions of the same goal. Applications tend to map one-to-one to services or code repositories, but don't necessarily have to.
# dev
SERVICE1=dev
SERVICE2=also-dev
# prd
SERVICE1=prd
SERVICE2=also-prd
See the API reference for more.
Profile
A profile is a set of variable mappings.
SERVICE1=dev
SERVICE2=also-dev
See the API reference for more.
Variable Mapping
A key mapped to a value source. Variables are selected as part of a profile.
SERVICE1=dev
Value Source
A value source is a means of deriving a string for the shell. Typically this is just a literal string: "abc"
, but it can also be a command that will be evaluated to a string at runtime.
dev # Literal
$(echo prd) # Command
See the API reference for more.
Side Effect
A side effect is a pairing of procedures: one to execute during environment, and one during teardown. These are used to perform environment configuration beyond environment variables. An example of a side effect is creating a file during setup, then deleting it during teardown.
See the user guide for more.
Setting Environment Variables
The primary purpose of env-select is to configure environment variables. The most common way to do this is to provide static values:
[applications.server.profiles.dev]
variables = {SERVICE1 = "dev", SERVICE2 = "also-dev"}
[applications.server.profiles.prd]
variables = {SERVICE1 = "prd", SERVICE2 = "also-prd"}
Beyond these simple values, there are several ways to customize how values are computed. Read on to learn more.
Dynamic Values
If your values are not statically known, there are several ways to load dynamic values. Fore more detailed information on each option, see the API reference.
Shell Command
You can specify a shell command to generate a value. This allows you to provide values that can change over time, or secrets that you don't want appearing in the file. For example:
[applications.db.profiles.dev.variables]
DATABASE = "dev"
DB_USER = "root"
DB_PASSWORD = {type = "command", command = "curl https://www.random.org/strings/?format=plain&len=10&num=1&loweralpha=on", sensitive = true}
When the dev
profile is selected for the db
app, the DB_PASSWORD
value will be randomly generated from a URL. The sensitive
field is an optional field that will mask the value in informational logging.
The command is executed in the shell detected by env-select as your default (or the shell passed with --shell
).
File
You can also load values from a file:
[applications.db.profiles.dev.variables]
DATABASE = {type = "file", path = "database.txt"}
database.txt
:
dev
And now run it:
> es run db dev -- printenv
DATABASE=dev
The file path will be relative to the config file in which the path is defined. For example, if you have two .env-select.toml
files:
# ~/code/.env-select.toml
[applications.server.profiles.dev.variables]
SERVICE1 = {type = "file", path = "service1.txt"}
# ~/.env-select.toml
[applications.server.profiles.dev.variables]
SERVICE2 = {type = "file", path = "service2.txt"}
In this scenario, SERVICE1
will be loaded from ~/code/service1.txt
while SERVICE2
will be loaded from ~/service2.txt
, regardless of where env-select is invoked from.
This value source combines well with the multiple
field to load .env
files (see next section).
Multiple Values from a Single Source
If you want to load multiple values from a single source, you can use the multiple = true
flag. This will tell env-select to expect a mapping of VARIABLE=value
as output from the value source, with one entry per line. Whitespace lines and anything preceded by a #
will be ignored (this is the standard .env
file format).
This means that the key associated with this entry in the variables
map will be ignored.
[applications.db.profiles.dev.variables]
DATABASE = "dev"
creds = {type = "file", path = "creds.env", multiple = true}
creds.env
:
DB_USER=root
DB_PASSWORD=hunter2
creds
will now be expanded into multiple variables:
> es run db dev -- printenv
DATABASE=dev
DB_USER=root
DB_PASSWORD=hunter2
Notice the creds
key never appears in the environment; this is just a placeholder. You can use any key you want here.
Filtering Loaded Values
If you want to load only some values from a source, you can filter which are loaded by passing a list of variables to multiple
. This is useful in scenarios where you dump an entire environment. For example:
[applications.db.profiles.dev.variables]
DATABASE = "dev"
creds = {type = "command", command = "ssh me@remote printenv", multiple = ["DB_USER", "DB_PASSWORD"]}
This will only load the DB_USER
and DB_PASSWORD
variables:
> es run db dev -- printenv
DATABASE=dev
DB_USER=root
DB_PASSWORD=hunter2
Adding to the PATH Variable
If you want to modify the PATH
variable, typically you just want to add to it, rather than replace it. Because of this, env-select will treat the variable PATH
specially. It will append to the beginning, using :
as a separator:
[applications.server.profiles.dev.variables]
PATH = "~/.bin"
> printenv PATH
/bin:/usr/bin
> es run server dev -- printenv PATH
~/.bin:/bin:/usr/bin
Load Values from Kubernetes
If you want to load one or more values from a Kubernetes pod, you can do that with the command
value source:
[applications.my-service.profile.dev]
variables.DB_PASSWORD = {type = "command", sensitive = true, command = "kubectl exec -n development api -- printenv DB_PASSWORD"}
Loading multiple variables is easy too:
[applications.my-service.profile.dev]
variables.db_creds = {type = "command", sensitive = true, multiple = ["DB_USERNAME", "DB_PASSWORD"], command = "kubectl exec -n development api -- printenv"}
Inheritance & Cascading Configs
env-select supports two different features that enable sharing configuration: cascading configs and profile inheritance. Cascading configs automatically combines multiple .env-select.toml
files into one config, while profile inheritance allows you to explicitly re-use variable mappings and side effects from other profiles.
Cascading configs
On every execution, env-select will scan the current directory for a file called .env-select.toml
and parse it for a config. In addition to that, it will walk up the directory tree and check each ancestor directory tree for the same file. If multiple files are found, the results will be merged together, down to the profile level only. Lower config files having higher precedence. For example, if we execute es set SERVICE1
in ~/code/
:
# ~/code/.env-select.toml
[applications.server.profiles.dev]
variables = {SERVICE1 = "secret-dev-server", SERVICE2 = "another-secret-dev-server"}
[applications.server.profiles.stg]
variables = {SERVICE1 = "secret-stg-server", SERVICE2 = "another-secret-stg-server"}
# ~/.env-select.toml
[applications.server.profiles.dev]
variables = {SERVICE1 = "dev", SERVICE2 = "also-dev"}
[applications.server.profiles.prd]
variables = {SERVICE1 = "prd", SERVICE2 = "also-prd"}
then our resulting config, at execution time, will look like:
# Note: this config never exists in the file system, only in memory during program execution
# From ~/code/.env-select.toml (higher precedence)
[applications.server.profiles.dev]
variables = {SERVICE1 = "dev", SERVICE2 = "also-dev"}
[applications.server.profiles.prd]
variables = {SERVICE1 = "prd", SERVICE2 = "also-prd"}
# From ~/.env-select.toml (no value in ~/code/.env-select.toml)
[applications.server.profiles.stg]
variables = {SERVICE1 = "secret-stg-server", SERVICE2 = "another-secret-stg-server"}
To see where env-select is loading configs from, and how they are being merged together, run the command with the --verbose
(or -v
) flag.
Profile Inheritance
In addition to top-level merging of multiple config files, env-select also supports inheritance between profiles, via the extends
field on a profile. For example:
[applications.server.profiles.base]
variables = {PROTOCOL = "https"}
[applications.server.profiles.dev]
extends = ["base"]
variables = {SERVICE1 = "dev", SERVICE2 = "also-dev"}
[applications.server.profiles.prd]
extends = ["base"]
variables = {SERVICE1 = "prd", SERVICE2 = "also-prd"}
During execution, env-select will merge each profile with its parent(s):
> es set server
❯ === base ===
PROTOCOL=https
=== dev ===
SERVICE1=dev
SERVICE2=also-dev
PROTOCOL=https
=== prd ===
SERVICE1=prd
SERVICE2=also-prd
PROTOCOL=https
The profile name given in extends
is assumed to be a profile of the same application. To extend a profile from another application, use the format application/profile
:
[applications.common.profiles.base]
variables = {PROTOCOL = "https"}
[applications.server.profiles.dev]
extends = ["common/base"]
variables = {SERVICE1 = "dev", SERVICE2 = "also-dev"}
[applications.server.profiles.prd]
extends = ["common/base"]
variables = {SERVICE1 = "prd", SERVICE2 = "also-prd"}
Multiple Inheritance and Precedence
Each profile can extend multiple parents. If two parents have conflicting values, the left-most parent has precedence:
[applications.server.profiles.base1]
variables = {PROTOCOL = "https"}
[applications.server.profiles.base2]
variables = {PROTOCOL = "http"}
[applications.server.profiles.dev]
extends = ["base1", "base2"]
variables = {SERVICE1 = "dev", SERVICE2 = "also-dev"}
The value from base1
is used:
> es run server dev -- printenv PROTOCOL
https
Inheritance is applied recursively, meaning you can have arbitrarily large inheritance trees, as long as there are no cycles.
Side Effects
Side effects allow you to configure your environment beyond simple environment variables, using imperative commands. Each side effects has two commands: setup and teardown. Additionally, there are two points at which side effects can execute: pre-export (before environment variables are exported) and post-export (with environment variables available). So there are four side effect stages in total (in their order of execution):
- Pre-export setup
- Post-export setup
- Post-export teardown
- Pre-export teardown
The meaning of "setup" and "teardown" varies based on what subcommand you're running: es set
has no teardown stage, as its purpose is to leave the configured environment in place. Currently there is no way to tear down an es set
environment (see #37). For es run
, setup occurs before executing the given command, and teardown occurs after.
While supplying both setup and teardown commands isn't required, it's best practice to revert whatever changes your setup command may have made. You should only omit the teardown function if your setup doesn't leave any lingering changes in the environment.
Examples
Given this config:
[applications.server.profiles.base]
# These commands *cannot* access the constructed environment
pre_export = [
# Native commands - not executed through the shell
{setup = ["touch", "host.txt"], teardown = ["rm", "-f", "host.txt"]}
]
# These commands can use the constructed environment
post_export = [
# Shell command - no teardown needed because the above command handles it
{setup = "echo https://$SERVICE1 > host.txt"}
]
[applications.server.profiles.dev]
extends = ["base"]
variables = {SERVICE1 = "dev", SERVICE2 = "also-dev"}
[applications.server.profiles.prd]
extends = ["base"]
variables = {SERVICE1 = "prd", SERVICE2 = "also-prd"}
This will execute in the followingn order for es set
:
> es set server dev
# 1. Execute pre-export setup (host.txt is created)
# 2. Construct environment
# 3. Execute post-export setup (host URL is written to host.txt)
# 4. Environment is exported to your shell
> echo $SERVICE1
dev
> cat host.txt
https://dev
And for es run
:
> es run server dev -- cat host.txt
# 1. Execute pre-export setup (host.txt is created)
# 2. Construct environment
# 3. Execute post-export setup (host URL is written to host.txt)
# 4. `cat host.txt`
https://dev
# 5. Execute post-export teardown (in this case, nothing)
# 6. Clear constructed environment variables
# 7. Execute pre-export teardown (host.txt is deleted)
> cat host.txt
cat: host.txt: No such file or directory
Ordering
Side effects are executed in their order of definition for setup, and the reverse order for teardown. This is to enable side effects that depend on each other; the dependents are torn down before the parents are.
Inheritance
Inherited side effects are executed before side effects defined in the selected profile during setup, and therefore after during teardown. For profiles with multiple parents, the left-most parent's side effects will execute first.
An example of a config with inheritance:
[applications.server.profiles.base1]
pre_export = [{setup = "echo base1 setup", teardown = "echo base1 teardown"}]
[applications.server.profiles.base2]
pre_export = [{setup = "echo base2 setup", teardown = "echo base2 teardown"}]
[applications.server.profiles.child]
extends = ["base1", "base2"]
pre_export = [{setup = "echo child setup", teardown = "echo child teardown"}]
And how the inheritance would resolve for the child
profile:
[applications.server.profiles.child]
pre_export = [
{setup = "echo base1 setup", teardown = "echo base1 teardown"},
{setup = "echo base2 setup", teardown = "echo base2 teardown"},
{setup = "echo child setup", teardown = "echo child teardown"},
]
Here's the order of command execution:
> es run server child -- echo hello
base1 setup
base2 setup
child setup
hello
child teardown
base2 teardown
base1 teardown
es run
and Shell Interactions
You may want to use es run
in combination with shell features such as quotes, variable expansion, and command substitution. The general rule with es run
is:
The passed command will be executed exactly as the same as if
es run
weren't there, except with a different set of environment variables.
Here's a concrete example:
# We'll use this profile for all examples
[applications.server.profiles.dev]
variables.SERVICE1 = "dev"
variables.SERVICE2 = "also-dev"
# These two invocations of `echo` will look exactly the same to the shell
es run server dev -- echo 'hi'
echo 'hi'
These two commands will behave exactly the same. In other words, if you're not sure how your command will be executed, ignore everything up to and including the --
, and that's what the shell will see.
This allows you to add es run
to any existing shell command without having to mess around with quotes and backslashes. Here's a more complex example where it's handy:
# Note: this is how you escape single quotes in fish. This command may look
# slightly different in other shells.
es run server dev -- psql -c 'select id from products where cost = \'$30\';'
This will pass the exact string select id from products where cost = '$30';
to psql
, without any variable expansion or other shell tomfoolery.
If you encounter any scenarios where the executed command does not behave the same as passing it directly to the shell, please file a bug.
Intentional Variable Expansion
By default, there are two ways to handle variable expansion with es run
: before invoking es
, and not at all:
es run server dev -- echo $SERVICE1 # prints ""
es run server dev -- echo '$SERVICE1' # prints "$SERVICE1"
In the first example, $SERVICE1
is expanded by the shell before es
is executed. In the second example, the single quotes prevent the shell from ever expanding $SERVICE1
.
If you want a variable to be expanded after exporting the profile before executing the command, e.g. if you want to use a variable defined by your profile, you'll need to manually invoke a subshell:
es run server dev -- fish -c 'echo $SERVICE1' # prints "dev"
Application
An application is the highest level of configuration resource in env-select. An application generally corresponds 1:1 with a deployed service or code repository. An application is essentially just a container for profiles. Here are some example application configurations:
[applications.server.profiles.variables.dev]
variables = {SERVICE1 = "dev", SERVICE2 = "also-dev"}
[applications.server.profiles.variables.prd]
variables = {SERVICE1 = "prd", SERVICE2 = "also-prd"}
# This application has no profiles, but is still valid to configure
[applications.empty]
Fields
Field | Type | Purpose |
---|---|---|
profiles | table | Name:profile mapping for all profiles of this application |
Profile
A profile is a collection of variable mappings and side effects. It generally maps to a single environment for a deployed application. Here are some example profiles:
[applications.server.profiles.variables.dev]
variables = {SERVICE1 = "dev", SERVICE2 = "also-dev"}
[applications.server.profiles.variables.prd]
variables = {SERVICE1 = "prd", SERVICE2 = "also-prd"}
# This application has no profiles, but is still valid to configure
[applications.empty]
# These profiles are big, so we can use full table syntax instead of the
# inline syntax. This is purely stylistic; you can make your inline
# tables as big as your heart desires. See https://toml.io/en/v1.0.0#table
[applications.big.profiles.prof1.variables]
VAR1 = "yes"
VAR2 = "yes"
VAR3 = "no"
VAR4 = "no"
VAR5 = "yes"
[applications.big.profiles.prof2.variables]
VAR1 = "no"
VAR2 = "no"
VAR3 = "no"
VAR4 = "yes"
VAR5 = "no"
Disjoint Profiles
Profiles within an app can define differing sets of variables, like so:
[applications.db.profiles.dev]
variables = {DATABASE = "dev", DB_USER = "root"}
[applications.db.profiles.stg]
variables = {DATABASE = "stg", DB_USER = "root", DB_PASSWORD = "goodpw"}
[applications.db.profiles.prd]
variables = {DATABASE = "prd", DB_USER = "root", DB_PASSWORD = "greatpw"}
The dev
profile excludes the DB_PASSWORD
variable. Beware though, whenever you switch to the dev profile, it will simply not output a value for DB_PASSWORD
. That means if you switch from another profile, DB_PASSWORD
will retain its old value! For this reason, it's generally best to define the same set of values for every profile in an app, and just use empty values as appropriate.
Fields
Field | Type | Purpose |
---|---|---|
variables | table | Variable:value mapping to export |
pre_export | array | Side effects to run before exporting variables |
post_export | array | Side effects to run after exporting variables |
Value Source
There are multiple types of value sources. The type used for a value source is determined by the type
field. For example:
# All of these profiles will generate the same exported value for GREETING
# Literal shorthand - most common
[applications.example.profiles.shorthand.variables]
GREETING = "hello"
# Literal expanded form - generally not needed
[applications.example.profiles.literal.variables]
GREETING = {type = "literal", value = "hello"}
[applications.example.profiles.command.variables]
GREETING = {type = "command", command = "echo hello"}
Value Source Types
Value Source Type | Description |
---|---|
literal | Literal static value |
file | Load values from a file |
command | Execute a shell command |
Common Fields
All value sources support the following common fields:
Option | Type | Default | Description |
---|---|---|---|
multiple | boolean , string[] | false | Load a VARIABLE=value mapping, instead of just a value ; Pass a list of variables to only load some. See more |
sensitive | boolean | false | Hide value in console output |
Type-Specific Fields
Each source type has its own set of available fields:
Value Source Type | Field | Type | Default | Description |
---|---|---|---|---|
literal | value | string | Required | Static value to export |
file | path | string | Required | Path to the file, relative to the config file in which this is defined |
command | command | string | Required | Command to execute in a subshell; the output of the command will be exported |
command | cwd | string | null | Directory from which to execute the command. Defaults to the directory from which es was invoked. Paths will be relative to the .env-select.toml file in which this command is defined. |
Shell Support
env-select supports the following shells:
- bash
- zsh
- fish
If you use a different shell and would like support for it, please open an issue and I'll see what I can do!