This repository demonstrates how to leverage the rich feature set of the get-dotenv
CLI within your own project.
This is a template repository based on my TypeScript NPM Package Template, so if you are starting from scratch, cloning this template is a great place to start!
Good code is configuration-driven. A simple and effective way to manage configuration is to use environment variables managed in dotenv files that look like this:
FEE=fie
FOE=fum
When we need to manage different configurations for multiple environments, this can rapidly get out of hand... especially when some of our configurations are secrets that should never be pushed to a code repository!
get-dotenv
solves this problem by allowing you to segregate your variables into multiple dotenv files can be loaded into process.env
as required. It even supports the dynamic generation or overriding of environment variables based on your own logic!
get-dotenv
also provides an extensible CLI that allows you to do the same thing from the command line. This enables all kinds of powerful automation and orchestration scenarios.
This repository demonstrates how to extend the get-dotenv
CLI with new commands that wrap functions from your own project.
To get started, clone this repository and run npm install
.
The basic structure of the repository mirrors my TypeScript NPM Package Template. See that README for more info.
Find the following files:
└─ get-dotenv-child
├─ .env.local.template
└─ environments
├─ .env.dev.local.template
└─ .env.test.local.template
Copy each of these files and remove the .template
extension from the copy. You should now have:
└─ get-dotenv-child
├─ .env.local
├─ .env.local.template
└─ environments
├─ .env.dev.local
├─ .env.dev.local.template
└─ .env.test.local
└─ .env.test.local.template
The resulting .local
files contain "secrets" for the purpose of this demo, and are gitignored.
P.S. Like those neat directory trees? Try dirtree
!
The TypeScript NPM Package Template exposes a single function foo
that logs a message to the console.
This repository extends the base get-dotenv
CLI with a new command foo
that calls the foo
function from the template.
To see this in action, run the following commands:
# Builds the project.
npm run build
# Creates a local symlink so you can call the CLI without extra gymnastics.
npm link
# Display the CLI help.
getdotenvchild -h
You'll see that the base CLI offers a lot of options for managing environment variables. At the bottom, you'll see this:
Commands:
cmd execute shell command string (default command)
foo [options] Wraps the foo function into a CLI command.
help [command] display help for command
Now run these commands:
getdotenvchild foo
# foo global public
getdotenvchild -e dev foo
# foo dev public
getdotenvchild -e test foo
# foo test public
getdotenvchild foo -t '$SECRET'
# foo test secret
The first three commands pulled the a default environment variable (PUBLIC
) from different contexts and passed it to the foo
function. The last command overrode the default input with a secret value (the SECRET
variable).
You aren't just restricted to custom commands. You can also use the base CLI to execute any shell command. For example:
getdotenvchild -e dev cmd echo %DYNAMIC%
# dynamic dev public (a dynamically generated variable, more on that later)
# cmd is the default command, so you can also just...
getdotenvchild -e dev echo %DYNAMIC%
# dynamic dev public
Finally, an NPM script may need to do something on whatever environment is passed into it. You'll have to pass the environment after the script invocation, so the syntax above won't work. Instead, you can use the -c
flag to pass a command string:
getdotenvchild -c "echo %DYNAMIC%" -e dev
# dynamic dev public
You would then articulate your script in package.json
like this:
{
"scripts": {
"foo": "getdotenvchild -c \"echo %DYNAMIC%\""
}
}
... and you'd execute it like this:
npm run foo -- -e dev # on windows
npm run foo --- -e dev # on linux
But if you are really smart, you'll install @antfu/ni
, which eliminates all kinds of cross-platform nonsense, and you can just do this:
nr foo -e dev
All the activity described above is driven by the following files:
└─ get-dotenv-child
├─ .env
├─ .env.dynamic.js
├─ .env.local
├─ environments
│ ├─ .env.dev
│ ├─ .env.dev.local
│ ├─ .env.test
│ └─ .env.test.local
├─ getdotenv.config.json
└─ src
└─ cli
└─ getdotenvchild
├─ fooCommand.ts
└─ index.ts
P.S. Like those neat directory trees? Try dirtree
!
All of the files beginning with .env
are dotenv files that look like this:
FEE=fie
FOE=fum
.env
comtains global public variables that apply to all environment and may be pushed to the git repository.
Those ending in .local
contain secrets and should not be pushed to the git repository. This is supported by an entry in .gitignore
.
Those with an environment name following .env
(e.g. .env.dev
, env.dev.local
) contain environment-specific values, which augment or override any defined in the global files.
These files may have a different naming convention and be located in any directory; this is specified in the Options section below.
The structure of the CLI and its package configuration follows the same conventions as the underlying template; see that documentation for more info.
The difference here is that this project's CLI uses the get-dotenv
CLI as its base and extends it with a new command, foo
.
The plumbing requires some familiarity with the commander
library but is otherwise very simple. It is fully explained in the comments on two source files in the src/cli
directory.
See Positional & Passthrough Options below for one key gotcha.
There are really three sets of options at work here:
-
The
GetDotenvOptions
object passed togetDotenv
that tells the engine what to load and how. Unless you are callinggetDotenv
programmatically, you don't need to worry about this. -
The
GetDotenvCliGenerateOptions
object passed to your CLI that sets the default configuration for thegetDotenv
options object and also some other stuff. See below for more info. -
The options passed to the CLI at the command line, which can override many the options set above. We'll cover these below as well.
Default options for your CLI can be set in three places, in reverse order of precedence:
-
A
getdotenv.config.json
file in the root of your CLI project. Think of these as the global defaults for your CLI. They ship with your package and are the same for everyone. -
Arguments passed to the
generateGetDotenvCli
function in your CLI'sindex.ts
file. These can override values from your globalgetdotenv.config.json
file, but the main purpose is to define anylogger
object andpreHook
orpostHook
functions, which won't fit in a JSON file. -
When your CLI is installed in another project, the author can override your CLI defaults (except for the
logger
,preHook
, andpostHook
functions) setting options in a localgetdotenv.config.json
file.
As described in A Quick Demo, your CLI can execute arbitrary shell commands, and can thus call itself. When you do this, any options set and variables loaded by the the parent instance are passed down to the child instance.
To avoid repeating myself, the table below also calls out options that can be passed programmatically to the getDotenv
function. In this case, there is no "global" getdotenv.config.json
file, only (optionally) the one in the root of the package that is calling the function.
Option | Type | Description | Set Where? | Default Value |
---|---|---|---|---|
alias |
string |
Cli alias. Should align with the bin property in package.json . |
getdotenv.config.json generateGetDotenvCli |
'getdotenv' |
debug |
boolean | undefined |
Logs CLI internals when true. | getdotenv.config.json generateGetDotenvCli -d, --debug -D, --debug-off |
undefined |
defaultEnv |
string | undefined |
Default target environment (used if env is not provided). |
getdotenv.config.json getDotenv generateGetDotenvCli --default-env <string> |
undefined |
description |
string |
Cli description (appears in CLI help). | getdotenv.config.json generateGetDotenvCli |
'Base CLI.' |
dotenvToken |
string |
Filename token indicating a dotenv file. | getdotenv.config.json getDotenv generateGetDotenvCli --dotenv-token <string> |
'.env' |
dynamicPath |
string | undefined |
Path to JS module default-exporting an object keyed to dynamic variable functions. | getdotenv.config.json getDotenv generateGetDotenvCli --dynamic-path <string> |
undefined |
env |
string | undefined |
Target environment (dotenv expanded). | getDotenv -e, --env <string> |
undefined |
excludeAll |
Exclude all dotenv variables from loading. | -a, --exclude-all -A, --exclude-all-off |
false |
|
excludeDynamic |
boolean | undefined |
Exclude dynamic variables from loading. | getdotenv.config.json getDotenv generateGetDotenvCli -z, --exclude-dynamic -Z, --exclude-dynamic-off |
false |
excludeEnv |
boolean | undefined |
Exclude environment-specific variables from loading. | getdotenv.config.json getDotenv generateGetDotenvCli -n, --exclude-env -N, --exclude-env-off |
false |
excludeGlobal |
boolean | undefined |
Exclude global variables from loading. | getdotenv.config.json getDotenv generateGetDotenvCli -g, --exclude-global -G, --exclude-global-off |
false |
excludePrivate |
boolean | undefined |
Exclude private variables from loading. | getdotenv.config.json getDotenv generateGetDotenvCli -r, --exclude-private -R, --exclude-private-off |
false |
excludePublic |
boolean | undefined |
Exclude public variables from loading. | getdotenv.config.json getDotenv generateGetDotenvCli -u, --exclude-public -U, --exclude-public-off |
false |
importMetaUrl |
string |
import.meta.url value from the module that calls generateGetDotenvCli . |
generateGetDotenvCli |
undefined |
loadProcess |
boolean | undefined |
Load dotenv variables to process.env . |
getdotenv.config.json getDotenv generateGetDotenvCli -p, --load-process -P, --load-process-off |
false |
log |
boolean | undefined |
Log loaded dotenv variables to logger . |
getdotenv.config.json getDotenv generateGetDotenvCli -l, --log -L, --log-off |
false |
logger |
typeof console |
A logger object that implements the console interface. |
getDotenv generateGetDotenvCli |
console |
outputPath |
string | undefined |
If populated, writes consolidated dotenv file to this path (dotenv expanded). | getdotenv.config.json getDotenv generateGetDotenvCli -o, --output-path <string> |
undefined |
paths |
string |
A delimited string of paths to dotenv files. **When passed to getDotenv this should be a string[] . |
getdotenv.config.json getDotenv generateGetDotenvCli --paths <string> |
'./' |
pathsDelimiter |
string |
A delimiter string with which to split paths . Only used if pathsDelimiterPattern is not provided. |
getdotenv.config.json generateGetDotenvCli --paths-delimiter <string> |
' ' |
pathsDelimiterPattern |
string | undefined |
A regular expression pattern with which to split paths . Supersedes pathsDelimiter . |
getdotenv.config.json generateGetDotenvCli --paths-delimiter-pattern <string> |
undefined |
preHook |
# |
A function that mutates inbound options & executes side effects within the getDotenv context before executing CLI commands. |
generateGetDotenvCli |
undefined |
privateToken |
string |
Filename token indicating private variables. | getdotenv.config.json getDotenv generateGetDotenvCli --private-token <string> |
'local' |
postHook |
# |
A function that executes side effects within the getDotenv context after executing CLI commands. |
generateGetDotenvCli |
undefined |
shell |
string | boolean | undefined |
If falsy, Execa will execute commands as Javascript. If true , Execa will execute commands in your OS default shell. Finally, your can specify a shell string. |
getdotenv.config.json generateGetDotenvCli -s, --shell [string] -S, --shell-off |
true |
vars |
string | undefined |
A delimited string of key-value pairs declaratively specifying variables & values to be loaded in addition to any dotenv files (dotenv expanded). When passed to getDotenv this should be a Record<string, string> . |
getdotenv.config.json getDotenv generateGetDotenvCli -v, --vars <string> |
undefined |
varsAssignor |
string |
A string with which to split keys from values in vars . Only used if varsDelimiterPattern is not provided. |
getdotenv.config.json generateGetDotenvCli --vars-assignor <string> |
'=' |
varsAssignorPattern |
string | undefined |
A regular expression pattern with which to split variable names from values in vars . Supersedes varsAssignor . |
getdotenv.config.json generateGetDotenvCli --vars-assignor-pattern <string> |
undefined |
varsDelimiter |
string |
A string with which to split vars into key-value pairs. Only used if varsDelimiterPattern is not provided. |
getdotenv.config.json generateGetDotenvCli --vars-delimiter <string> |
' ' |
varsDelimiterPattern |
string | undefined |
A regular expression pattern with which to split vars into key-value pairs. Supersedes varsDelimiter . |
getdotenv.config.json generateGetDotenvCli --vars-delimiter-pattern <string> |
undefined |
It won't have escaped your notice that this is a TypeScript project. And generally speaking—ts-node aside—you can't run TypeScript directly. You have to compile it first.
So that's the gotcha. If you want to run your CLI, here are your choices (substituting your own project nomenclature as needed):
- Run it locally. You'll need to know the path to the compiled file.
# compile the project
npm run build
# view the cli help
node dist/getdotenvchild.cli.mjs -h
- Link it locally. You can run it from anywhere on your system, but you need a local clone.
# compile the project
npm run build
# link the project
npm link
# view the cli help
getdotenvchild -h
# unlink when done
npm uninstall -g @karmaniverous/get-dotenv-child
- Install it locally. You can run it from inside the project where you installed it.
# compile the project
npm run build
# publish the project
npm run release
# install the package in some other project
npm install @karmaniverous/get-dotenv-child
# view the cli help
npx getdotenvchild -h
- Install it globally. You can run it from anywhere on your system.
# compile the project
npm run build
# publish the project
npm run release
# install the package globally
npm install -g @karmaniverous/get-dotenv-child
# view the cli help
getdotenvchild -h
If you examine the fooCommand
file, you'll see that I employed dotenvExpandFromProcessEnv
to expand the target
option against process.env
.
Why didn't I just use dotenvExpandFromProcessEnv
as the input parser for the target
option?
Great question! 🤣 Here's what that would look like:
// The default value '$PUBLIC' is a placeholder for a value loaded via dotenv.
.option(
'-t, --target <string>',
'the target to foo',
dotenvExpandFromProcessEnv,
'$PUBLIC',
)
It turns out that commander
default option values are not subjected to the provided parsing function. So the configured default value ('$PUBLIC'
) would get passed to your function logic without ever getting parsed.
Ok, so why not just parse the defaut value right there in the option configuration?
Another great question! Here's what that would look like:
// The default value '$PUBLIC' is a placeholder for a value loaded via dotenv.
.option(
'-t, --target <string>',
'the target to foo',
dotenvExpandFromProcessEnv('$PUBLIC'),
)
That won't work either, because commander
will wind up calling dotenvExpandFromProcessEnv
before it runs getDotenv
, therefore before process.env
is populated with your dotenv variables.
So if you intend to expand your options, it makes sense to do so in your action step, which runs after getDotenv
has populated process.env
. If you like, you can expand the entire options
object at once using dotenvExpandAll
The get-dotenv
CLI is based on the commander
library, which supports a rich combination of commands, options, arguments, and subcommands.
For example:
$> getdotenv -l foo -b bar baz
In the above example, getdotenv
is the root command, and -l
is a flag (a boolean option) against that command. foo
is a subcommand; -b bar
is a string option against the foo
subcommand; and baz
is an argument to the foo
subcommand.
By default, the following command line would produce exactly the same execution:
$> getdotenv foo -l -b bar baz
This works so long as the foo
subcommand does not also have a -l
flag. When you're in charge of your entire CLI (and when your CLI is simple), this isn't hard to arrange.
However, when you're building a child CLI, you inherit whatever options & arguments the parent CLI has. This can make it difficult to predict the command line that will be passed to your child CLI. So commander
provides the enablePositionalOptions
and passThroughOptions
features, which constrain the CLI so that options & arguments can only be used adjacent to their parent command/subcommand.
The get-dotenv
parent CLI has a lot of options, so it's a good idea to enable these features in any command you append to it. You can see an example of this in fooCommand
See more great templates & tools on my GitHub Profile!