Skip to content

glennib/envoke

Repository files navigation

envoke

Declarative environment variables — any source, any shape, any command. Declare your environments, tags, and overrides in one YAML file. envoke composes values from literals, commands, shell scripts, and templates; resolves template dependencies topologically; and renders the result as shell exports, JSON, a Kubernetes ConfigMap, or anything else a Jinja template can express. Then hand the resolved variables to a command:

envoke exec prod -- psql                       # exec with resolved vars overlaid
envoke render prod --output .env               # write a .env file
envoke render prod --format json               # render as JSON
envoke render prod --format k8s-secret         # render as a Kubernetes Secret manifest
envoke render prod --template custom.j2        # render any shape you want

Why envoke?

envoke is a composer and renderer for environment variables, not a secret store. The niche it fills is the intersection of multi-source composition (literals, commands, shell, templates), per-environment / per-tag / per-override variation, and deterministic output to any shape. Pair it with your secret store of choice — fnox, SOPS, 1Password CLI, or Vault — and let envoke handle the composition:

# envoke.yaml
DB_PASS:
  envs:
    prod:
      sh: op read "op://prod/db/password"     # fetched via 1Password CLI
    local:
      literal: devpassword

envoke doesn't hook into the shell like direnv or other .env loaders, but produces structured output and renders flexibly. It also doesn't encrypt or cache, but is composable with tools that do, via the sh and cmd sources.

Installation

With mise (recommended)

mise use -g github:glennib/envoke

From crates.io

cargo install envoke-cli

With cargo-binstall

cargo binstall envoke-cli

With mise (from crates.io)

mise use -g cargo:envoke-cli

From source

cargo install --git https://github.com/glennib/envoke envoke-cli

From GitHub releases

Pre-built binaries are available on the releases page for:

  • Linux (x86_64, aarch64)
  • macOS (x86_64, Apple Silicon)
  • Windows (x86_64)

Quick start

Create an envoke.yaml:

variables:
  DB_HOST:
    default:
      literal: localhost
    envs:
      prod:
        literal: db.example.com

  DB_USER:
    default:
      literal: app

  DB_PASS:
    envs:
      local:
        literal: devpassword
      prod:
        sh: vault kv get -field=password secret/db

  DB_URL:
    default:
      template: "postgresql://{{ DB_USER }}:{{ DB_PASS | urlencode }}@{{ DB_HOST }}/mydb"

Generate variables for an environment:

$ envoke render local
# @generated by `envoke render local` at 2025-06-15T10:30:00+02:00
# Do not edit manually. Modify envoke.yaml instead.

DB_HOST='localhost'
DB_PASS='devpassword'
DB_URL='postgresql://app:devpassword@localhost/mydb'
DB_USER='app'

Note: Output is sorted alphabetically by variable name. All output includes an @generated header with the invocation command and timestamp. Examples below omit this header for brevity.

Or hand them straight to a command — no shell dance required:

envoke exec local -- psql
envoke exec prod -- kubectl apply -f manifest.yaml

Alternatively, source them into your shell:

eval "$(envoke render local --format shell-export)"

Or write them to a file:

envoke render local --output .env

Tip: render has alias r and exec has alias x, so envoke r local, envoke x prod -- psql, etc. also work.

Running commands with resolved variables

The exec subcommand runs a subprocess with the resolved variables overlaid on envoke's own environment. Everything after -- is passed verbatim to the child:

envoke exec prod -- psql
envoke exec prod -- sh -c 'echo "$DATABASE_URL"'
envoke exec local -- npm run dev

Overlay semantics. The child inherits envoke's process environment (PATH, HOME, TERM, SSH_AUTH_SOCK, …) and the resolved variables are layered on top — any inherited variable with the same name as a resolved one is replaced. Variables not declared in envoke.yaml pass through unchanged.

Process model. On Unix, envoke replaces itself with the target process via execvp — the child keeps envoke's PID, TTY, and signal disposition, so Ctrl-C and SIGTERM behave exactly as if you had invoked the command directly. On other platforms, envoke spawns the child and forwards its exit code.

Subcommand separation. Output-shaping flags (--output, --template, --format) live on render; the trailing -- <command> lives on exec. Pick the subcommand that matches your intent.

Shell integration with mise

If your project uses mise to manage tools, the envoke-env plugin activates envoke on shell entry — no manual eval or sourcing needed.

# mise.toml
[tools]
"github:glennib/envoke" = "2.0.0"

[plugins]
envoke = "https://github.com/glennib/envoke-env#v2.0.0"

[env]
_.envoke = { fallback_environment = "local", tools = true }

Put the target environment name in .envoke-env (gitignored):

staging

When mise activates, the plugin runs envoke render staging against your envoke.yaml and injects the resolved variables. Switch environments with echo prod > .envoke-env; the plugin watches the file and re-evaluates on the next activation. Tags and overrides can be added on subsequent lines (tag:vault, override:read-replica).

See the envoke-env README for caching, the fallback environment, and other configuration options.

Configuration

The config file (default: envoke.yaml) has a single top-level key variables that maps variable names to their definitions.

Variable definition

Each variable can have:

Field Description
description Optional. Rendered as a # comment above the variable in output.
tags Optional. List of tags for conditional inclusion. Variable is only included when at least one of its tags is passed via --tag. Untagged variables are always included.
default Optional. Fallback source used when the target environment has no entry in envs.
envs Map of environment names to sources.
overrides Optional. Map of override names to alternative source definitions (each with its own default/envs). Activated via --override.

A variable must have either an envs entry matching the target environment or a default. If neither exists, resolution fails with an error.

Source types

Each source specifies exactly one of the following fields:

literal

A fixed string value.

DB_HOST:
  default:
    literal: localhost

cmd

Run a command and capture its stdout (trimmed). The value is a list where the first element is the executable and the rest are arguments.

GIT_SHA:
  default:
    cmd: [git, rev-parse, --short, HEAD]

sh

Run a shell script via sh -c and capture its stdout (trimmed).

TIMESTAMP:
  default:
    sh: date -u +%Y-%m-%dT%H:%M:%SZ

template

A minijinja template string, compatible with Jinja2. Reference other variables with {{ VAR_NAME }}. Dependencies are automatically detected and resolved first via topological sorting.

DB_URL:
  default:
    template: "postgresql://{{ DB_USER }}:{{ DB_PASS }}@{{ DB_HOST }}/{{ DB_NAME }}"

A meta object is available in variable templates with the following fields:

Field Description
meta.environment The target environment name passed to envoke.
API_URL:
  default:
    template: "https://{{ meta.environment }}.example.com/api"
$ envoke render staging
API_URL='https://staging.example.com/api'

All minijinja built-in filters are available (upper, lower, replace, trim, default, join, etc.), plus the following additional filters:

  • urlencode -- percent-encodes special characters for use in URLs.
  • shell_escape -- escapes single quotes for shell safety (' -> '\'').
  • dotenv_escape -- encodes a value as a portable .env token with delimiters included (single-quoted when safe, else double-quoted with conservative escapes).
CONN_STRING:
  default:
    template: "postgresql://{{ USER | urlencode }}:{{ PASS | urlencode }}@localhost/db"

APP_NAME_LOWER:
  default:
    template: "{{ APP_NAME | lower }}"

skip

Omit this variable from the output. Useful for conditionally excluding a variable in certain environments while including it in others.

DEBUG_TOKEN:
  default: skip
  envs:
    local:
      literal: debug-token-value

Environments and defaults

envoke selects the source for each variable by checking the envs map for the target environment. If no match is found, it falls back to default. This lets you define shared defaults and override them per environment:

LOG_LEVEL:
  default:
    literal: info
  envs:
    local:
      literal: debug
    prod:
      literal: warn

Tags

Tags gate variables behind explicit opt-in. The typical use case: your config includes a VAULT_SECRET whose value is fetched by an expensive sh: vault kv get … command. You don't want that command to run every time someone runs envoke local during day-to-day development — only when they actually need the secret. Tag it with vault, and the variable is included only when --tag vault is passed.

Untagged variables are always included. Tagged variables are only included when at least one of their tags is passed via --tag. This keeps expensive resolvers (vault lookups, cloud API calls, slow shell scripts) out of the hot path.

variables:
  DB_HOST:
    default:
      literal: localhost

  VAULT_SECRET:
    tags: [vault]
    envs:
      prod:
        sh: vault kv get -field=secret secret/app
      local:
        literal: dev-secret

  OAUTH_CLIENT_ID:
    tags: [oauth]
    envs:
      prod:
        sh: vault kv get -field=client_id secret/oauth
      local:
        literal: local-client-id
# Without --tag, only untagged variables are included:
$ envoke render local
DB_HOST='localhost'

# Include vault-tagged variables (and all untagged ones):
$ envoke render local --tag vault
DB_HOST='localhost'
VAULT_SECRET='dev-secret'

# Include everything:
$ envoke render local --tag vault --tag oauth
DB_HOST='localhost'
OAUTH_CLIENT_ID='local-client-id'
VAULT_SECRET='dev-secret'

Variables without tags are always included regardless of which --tag flags are passed. Tagged variables require explicit opt-in.

Overrides

Overrides let you point a single variable at a different source without duplicating an entire environment. Classic example: you have a prod environment, and occasionally you want DATABASE_HOST to point at a read-replica instead of the primary — but everything else (port, credentials, cache strategy) should stay identical. Creating a whole prod-read-replica environment duplicates ten other variables for the sake of one. Instead, declare a read-replica override on DATABASE_HOST and activate it with --override read-replica:

envoke exec prod -- psql                            # primary
envoke exec prod --override read-replica -- psql    # same env, replica host

Overrides are the third dimension alongside environments and tags. A variable can declare named overrides, each with its own default/envs sources. Activate them with --override:

variables:
  DATABASE_HOST:
    default:
      literal: localhost
    envs:
      prod:
        literal: 172.10.0.1
    overrides:
      read-replica:
        default:
          literal: localhost-ro
        envs:
          prod:
            literal: 172.10.0.2

  CACHE_STRATEGY:
    envs:
      prod:
        literal: lru
    overrides:
      aggressive-cache:
        envs:
          prod:
            literal: lfu-with-prefetch

  DATABASE_PORT:
    default:
      literal: "5432"
    # No overrides -- unaffected by --override flag
# Base values:
$ envoke render prod
CACHE_STRATEGY='lru'
DATABASE_HOST='172.10.0.1'
DATABASE_PORT='5432'

# Activate an override:
$ envoke render prod --override read-replica
CACHE_STRATEGY='lru'
DATABASE_HOST='172.10.0.2'
DATABASE_PORT='5432'

# Multiple overrides on disjoint variables:
$ envoke render prod --override read-replica --override aggressive-cache
CACHE_STRATEGY='lfu-with-prefetch'
DATABASE_HOST='172.10.0.2'
DATABASE_PORT='5432'

When an override is active for a variable, the source is selected using a 4-level fallback chain:

  1. Override envs[environment]
  2. Override default
  3. Base envs[environment]
  4. Base default

Variables without a matching override definition are unaffected and use the normal base fallback. If multiple active overrides are defined on the same variable, envoke reports an error. Unknown override names (not defined on any variable) produce a warning on stderr.

CLI usage

envoke [GLOBAL OPTIONS] <SUBCOMMAND>

Subcommands

Subcommand Alias Purpose
render <ENV> r Resolve variables and print them (or write to a file).
exec <ENV> -- <COMMAND>... x Resolve variables and exec a command with them overlaid.
meta <WHAT> Enumerate names of a config dimension: environments, tags, overrides, or all (prefixed).
schema Print the JSON Schema for envoke.yaml.
completions <SHELL> Print shell completions (bash, zsh, fish, elvish, powershell).

Global options

Usable before or after the subcommand.

Option Description
-c, --config <PATH> Path to config file. Default: envoke.yaml.
-t, --tag <TAG> Only include tagged variables with a matching tag. Repeatable. Untagged variables are always included.
--all-tags Include every tagged variable regardless of its tags. Conflicts with --tag.
-O, --override <NAME> Activate a named override for source selection. Repeatable. Per variable, at most one active override may be defined.
--no-parallel Resolve cmd: and sh: sources serially instead of in parallel.
-q, --quiet Suppress informational messages on stderr.

Global repeatables and the subcommand boundary. --tag and --override are repeatable globals. Specifying them on both sides of the subcommand is a footgun — the occurrences after the subcommand replace (not append to) any occurrences before it. For example, envoke --tag a render prod --tag b results in tags = ["b"], not ["a", "b"]. Pick one side.

render options

Option Description
<ENV> Target environment name (e.g. local, prod). Can also be set via the ENVOKE_ENV environment variable.
-o, --output <PATH> Write output to a file instead of stdout.
-f, --format <FORMAT> Select a built-in output preset: dotenv (default), shell-export, json, yaml, k8s-secret, github-actions, terraform-tfvars. See Output formats. Conflicts with --template.
--template <PATH> Use a custom output template file instead of a preset. See Custom templates.

exec options

Option Description
<ENV> Target environment name. Can also be set via the ENVOKE_ENV environment variable.
-- <COMMAND>... Command to exec with resolved variables overlaid. See Running commands. The -- separator is required.

Environment variables

Variable Description
ENVOKE_ENV Fallback for the <ENV> positional on render and exec.

JSON Schema

Generate a JSON Schema for editor autocompletion and validation:

envoke schema > envoke-schema.json

Use it in your envoke.yaml with a schema comment for editors that support it:

# yaml-language-server: $schema=envoke-schema.json
variables:
  # ...

Alternatively, point directly at the hosted schema without writing a local file:

# yaml-language-server: $schema=https://raw.githubusercontent.com/glennib/envoke/refs/heads/main/envoke.schema.json
variables:
  # ...

How it works

  1. Parse the YAML config file.
  2. Filter out variables excluded by --tag flags (if any).
  3. For each remaining variable, select the source matching the target environment (or the default), applying the override fallback chain if --override flags are active.
  4. Extract template dependencies and topologically sort all variables using Kahn's algorithm.
  5. Resolve values in dependency order -- literals are used as-is, commands and shell scripts are executed, templates are rendered with already-resolved values.
  6. Render output using a built-in or custom Jinja2 template (see Custom templates). The default template produces an @generated header followed by sorted VAR='value' lines in the .env dotenv format.

Circular dependencies and references to undefined variables are detected before any resolution begins and reported as errors.

Output formats

--format <FORMAT> selects a curated built-in preset. The defaults cover the shapes most projects need; for anything else, use --template.

Format Output shape Typical use
dotenv (default) KEY='value' when safe, else KEY="value" with conservative escapes (\\, \", \$, \n). $ never expands at the consumer. .env files consumed by dotenvy (mise, Rust), godotenv (Docker Compose), python-dotenv, node dotenv
shell-export export KEY='value' Source into a POSIX shell for children to inherit: source <(envoke render local --format shell-export)
json Compact JSON object Feeding structured tools; pipe through jq . for pretty output
yaml YAML mapping (block style) Human-readable config files, yq pipelines
k8s-secret Kubernetes Secret manifest with stringData: envoke render prod --format k8s-secret | kubectl apply -f -
github-actions Heredoc blocks for $GITHUB_ENV - run: envoke render prod --format github-actions >> "$GITHUB_ENV"
terraform-tfvars HCL KEY = "value" envoke render prod --format terraform-tfvars > prod.auto.tfvars

Notes.

  • --format json output is also valid YAML 1.2, so use it when you want compact structured output.
  • k8s-secret derives metadata.name from the environment (lowercased, _ replaced with -). For exotic env names, post-process or use --template.
  • Some .env parsers (e.g. dotenvx) expand $VAR inside double-quoted values. A value like pa$word may not round-trip through those.

Custom templates

If none of the presets fit, supply your own minijinja (Jinja2-compatible) template via --template:

envoke render local --template my-template.j2

Template context

The template receives the following variables:

Name Type Description
variables map of name -> {value, description} Rich access: {{ variables.DB_URL.value }}. Iteration: {% for name, var in variables | items %}. Sorted alphabetically.
v map of name -> value string Flat shorthand: {{ v.DATABASE_URL }}.
meta.timestamp string RFC 3339 timestamp of invocation.
meta.invocation string Full CLI invocation as a single string.
meta.invocation_args list of strings CLI args as individual elements.
meta.environment string Target environment name.
meta.config_file string Path to the config file used.

Filters

All minijinja built-in filters are available (upper, lower, replace, trim, default, join, length, first, last, sort, unique, tojson, etc.), plus these additional filters:

  • shell_escape -- escapes single quotes for shell safety (' -> '\'').
  • dotenv_escape -- encodes a value as a portable .env token with delimiters included (single-quoted when safe, else double-quoted with conservative escapes \\, \", \$, \n; $ is never expanded at the consumer).
  • urlencode -- percent-encodes special characters.

All filters are available in both variable templates (the template source type) and custom output templates.

Example: JSON output

{
{% for name, var in variables | items %}  "{{ name }}": "{{ var.value }}"{% if not loop.last %},{% endif %}
{% endfor %}}
envoke render local --template json.j2

Note: This simplified example does not escape JSON special characters (", \, newlines) in values. For production use, consider a template that handles escaping.

Example: Docker .env format

# Generated for {{ meta.environment }}
{% for name, var in variables | items -%}
{{ name }}={{ v[name] }}
{% endfor -%}

Note: This simplified example does not quote or escape values. Values containing =, #, or whitespace may not parse correctly in all .env implementations.

Example: Kubernetes ConfigMap

apiVersion: v1
kind: ConfigMap
metadata:
  name: {{ meta.environment | lower }}-env
  labels:
    app: myapp
    environment: {{ meta.environment | lower }}
    generated-by: envoke
data:
{% for name, var in variables | items %}  {{ name }}: "{{ var.value }}"
{% endfor %}

This example uses lower and items filters to generate a Kubernetes-compatible manifest directly from your envoke config.

Shell completions

Generate completions for your shell:

# Bash
envoke completions bash > ~/.local/share/bash-completion/completions/envoke

# Zsh
envoke completions zsh > ~/.zfunc/_envoke

# Fish
envoke completions fish > ~/.config/fish/completions/envoke.fish

Development

This project uses mise as a task runner. After installing mise:

mise install       # Install tool dependencies
mise run build     # Build release binary
mise run test      # Run tests (via cargo-nextest)
mise run clippy    # Run lints
mise run fmt       # Format code
mise run ci        # Run all checks (fmt, clippy, test, build)

Run a single test:

cargo nextest run -E 'test(test_name)'

Debugging

envoke uses tracing for diagnostic output. Set the RUST_LOG environment variable to see debug messages on stderr:

RUST_LOG=debug envoke render local

This is useful for troubleshooting tag filtering, override fallback chains, and source resolution order.

License

MIT OR Apache-2.0

About

Resolve variables from literals, commands, scripts, and templates — output as env vars, .env files, or custom formats

Topics

Resources

Stars

Watchers

Forks