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 shell
  • es 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

FieldTypePurpose
profilestableName: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

FieldTypePurpose
variablestableVariable:value mapping to export
pre_exportarraySide effects to run before exporting variables
post_exportarraySide 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 TypeDescription
literalLiteral static value
fileLoad values from a file
commandExecute a shell command

Common Fields

All value sources support the following common fields:

OptionTypeDefaultDescription
multipleboolean, string[]falseLoad a VARIABLE=value mapping, instead of just a value; Pass a list of variables to only load some. See more
sensitivebooleanfalseHide value in console output

Type-Specific Fields

Each source type has its own set of available fields:

Value Source TypeFieldTypeDefaultDescription
literalvaluestringRequiredStatic value to export
filepathstringRequiredPath to the file, relative to the config file in which this is defined
commandcommandstringRequiredCommand to execute in a subshell; the output of the command will be exported
commandcwdstringnullDirectory 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!