Skip to main content

envdirx

日本語版 README

envdirx is a CLI that keeps the DJB envdir convention: one file per environment variable. set stores plaintext by default; set -c encrypts. Plaintext and encrypted entries can coexist, and run reads both. An encrypted entry named GOG_KEYRING_PASSWORD has printable encrypted:B... Base64 text inside its file, not a GOG_KEYRING_PASSWORD= line. Existing binary envdirx:v1: entries remain readable; new writes use the printable format with one trailing LF. Readers also accept no trailing LF; embedded or repeated newlines and CRLF are rejected. It serves a similar purpose to dotenvx, but its ciphertext and keys are not compatible.

Requires Python 3.13 or newer on POSIX. The default envdir is ./.envs relative to the current working directory. Put -d/--directory DIRECTORY before the subcommand. Only mkdir creates the envdir; other commands require it to exist.

Licensed under the MIT License.

Install and quick start

From this repository, while preparing the first PyPI release:

uv sync
mkdir -p ~/.envdirx-keys
uv run envdirx mkdir
uv run envdirx keygen -K ~/.envdirx-keys
printf 'plain-value' | uv run envdirx set AAA
printf 'example-token' | uv run envdirx set -c API_TOKEN
uv run envdirx get AAA > /dev/null
uv run envdirx run -- sh -c 'test -n "$AAA" && test -n "$API_TOKEN"'

After publication, install with uv tool install envdirx and replace uv run envdirx with envdirx. Do not put real secrets on a shell command line: supply them from a file or standard input instead of printf literals.

Command Action
envdirx --version / envdirx -V Print the installed envdirx version and one newline, then exit 0. Like -d, it goes before any subcommand and needs no envdir or key.
envdirx [-d D] mkdir Create the envdir and missing parents; a new envdir has mode 0700. An existing directory is left unchanged; a non-directory fails with status 111.
envdirx [-d D] keygen (-k KEYFILE | -K KEYDIR) Create a key pair and the .envdirx.key symlink.
envdirx [-d D] set [-c] NAME Store stdin unchanged as plaintext, or encrypt with -c.
envdirx [-d D] get [--key KEY] NAME Write the original bytes to stdout.
envdirx [-d D] encrypt (NAME [NAME ...] | --all) Encrypt existing plaintext entries in place, using only the public key.
envdirx [-d D] decrypt [--key KEY] (NAME [NAME ...] | --all) Restore encrypted entries to plaintext files, using the private key.
envdirx [-d D] run [--key KEY] -- COMMAND [ARGS...] Execute a command with the envdir applied.

For an explicit directory and key file:

uv run envdirx -d service.env mkdir
uv run envdirx -d service.env keygen -k "$HOME/service.env.key"
printf 'example-token\n' | uv run envdirx -d service.env set -c API_TOKEN
uv run envdirx -d service.env run --key "$HOME/service.env.key" -- sh -c 'test "$API_TOKEN" = example-token'

Quote the sh -c program with single quotes so the calling shell does not expand the variables.

Storing and reading values

set NAME atomically stores stdin byte-for-byte as a mode-0600 plaintext file. It does not read a key or key pointer. Plaintext values beginning with envdirx: or encrypted: are reserved for ciphertext formats and are rejected with status 111 without changing the existing entry; use set -c for such values. set -c NAME needs only .envdirx.pub, not the private key.

get NAME writes plaintext or the decrypted original bytes without adding a newline. Unlike run, it does not trim the first line or convert NUL bytes. Empty entries return zero bytes. get can expose secrets to terminals and logs; direct its output carefully. Missing or invalid entries, unsupported envdirx: or encrypted: formats, ciphertext bound to a different name, and invalid keys fail with status 111 and no value on stdout. --key overrides the pointer for encrypted entries only.

Encrypting and decrypting files

Both commands require either one or more names or --all. No target, mixing names with --all, or duplicate names fails with status 2 before reading the directory or key. Names follow the same validation as set/get: ../X, absolute paths, and dotfiles such as .envdirx.pub are rejected without writes (status 111).

  • encrypt changes plaintext files to encrypted files using only .envdirx.pub.
  • decrypt authenticates ciphertext and restores its original bytes, including newlines, NUL bytes and empty files, using only the private key. It does not require the public key. --key overrides .envdirx.key.
  • An explicitly named entry already in the requested state fails with status 111. --all skips entries already in that state and ignores dotfiles. If nothing needs conversion, it succeeds without reading a key. Unsupported or invalid envdirx: or encrypted: formats fail with status 111.

decrypt intentionally leaves secrets as plaintext on disk. Back up sensitive files before converting, then verify the result. Atomic replacement does not securely erase prior plaintext or ciphertext from storage or backups. If a decrypted value begins with reserved prefix envdirx: or encrypted:, decrypt rejects the batch before writing anything (status 111) because get/run would misidentify that plaintext as ciphertext. For such a value, direct get output to a suitably protected file outside the envdir instead.

All selected entries are read, validated, and transformed in memory before the first write. A missing or non-regular entry, invalid format or key, failed authentication, or reserved plaintext prefix leaves all entries unchanged (status 111). Each subsequent file replacement is atomic and mode 0600, but the batch is not transactional: if a later write fails, earlier successful replacements remain. Success writes nothing to stdout; errors contain names and paths, not values.

printf 'old-plain' | uv run envdirx -d service.env set DB_PASSWORD
uv run envdirx -d service.env encrypt --all
uv run envdirx -d service.env run --key "$PWD/service.env.key" -- sh -c 'test "$DB_PASSWORD" = old-plain'

To target one entry and restore it afterward:

uv run envdirx -d app.env mkdir
uv run envdirx -d app.env keygen -k "$PWD/app.env.key"
printf 'db-secret' | uv run envdirx -d app.env set DB_PASSWORD
printf 'visible' | uv run envdirx -d app.env set LOG_LEVEL
uv run envdirx -d app.env encrypt DB_PASSWORD
uv run envdirx -d app.env decrypt DB_PASSWORD
uv run envdirx -d app.env encrypt --all
uv run envdirx -d app.env decrypt --key "$PWD/app.env.key" --all

Do not run decrypt on real data unless restoring those specified files to plaintext is intended.

Keys and .envdirx.key

keygen creates a mode-0600 private key outside the envdir, a mode-0644 raw 32-byte public key at .envdirx.pub, and a symlink .envdirx.key pointing to the fully resolved absolute private-key path. -k KEYFILE selects an exact file; -K KEYDIR stores the private key in an existing directory as <lowercase SHA-256 of raw public key>.key. Exactly one is required. Relative key destinations are relative to the current directory; leading ~/ expands to home.

It never overwrites a private key, public key, or pointer (including broken symlinks); there is no -f/--force or rotation command. It does not create the key's parent directory, refuses a private-key destination inside the resolved envdir, and removes only files it created if an operation fails. Never put the private key in the repository, envdir, or a distribution. Back it up separately: losing it makes encrypted values unrecoverable.

run and get read the private key only for encrypted entries. --key takes precedence over the pointer; its relative paths use the current directory and leading ~/ expands to home. Without it, only .envdirx.key is consulted—there is no fallback to a nearby key. A plaintext-only envdir needs neither a key nor a pointer.

The pointer can be a symlink (whose ~/ is not expanded) or a regular UTF-8 file containing one path. For a regular file, one trailing LF or CRLF is optional, spaces in the path are preserved, and leading ~/ expands to home. Empty or multiline content, NUL, and invalid UTF-8 are rejected. Relative paths in either pointer form resolve against the envdir. The fully resolved private key must be a regular file outside the resolved envdir, owned by the executing user, with no group or other permission bits (for example 0600). Missing, broken, cyclic, or invalid pointers/keys fail with status 111 without starting a child process or revealing values.

For an older envdir using a neighboring service.env.key, add a pointer without re-encrypting:

printf '%s\n' "$PWD/service.env.key" > service.env/.envdirx.key
chmod 600 service.env/.envdirx.key
uv run envdirx -d service.env run -- true

Alternatively, if the pointer does not already exist, use ln -s ../service.env.key service.env/.envdirx.key. The *.key rule in .gitignore also excludes .envdirx.key; do not weaken it. Copies that follow symlinks (tar -h, rsync -L, cp -L) can package the private key instead of the pointer. Exclude .envdirx.key from distributions or use a regular-file pointer.

Migration from older CLI versions

init has been removed. Use mkdir followed by keygen; old envdirs with text-file pointers still work and are not rewritten automatically. Before the first 0.1.0 release, positional directory arguments were also removed. Specify -d before the subcommand:

Old New
envdirx set DIR NAME envdirx -d DIR set -c NAME (include -c to retain encryption)
envdirx encrypt DIR [NAME ...] envdirx -d DIR encrypt (NAME [NAME ...] | --all)
envdirx encrypt (implicitly all) envdirx encrypt --all
envdirx run [--key K] DIR -- CMD envdirx -d DIR run [--key K] -- CMD
envdirx set --key K DIR NAME / encrypt --key K ... Omit --key; these operations only use the public key.

Versioning and releases

envdirx --version reports the installed distribution's version; project.version in pyproject.toml is the only place the version is defined. run -- COMMAND --version passes --version to the child unchanged.

Releases are manual and use semantic MAJOR.MINOR.PATCH versions. Before 1.0, incompatible CLI changes increment MINOR, and compatible additions and fixes increment PATCH. To release, run uv version <version> (which updates both pyproject.toml and uv.lock), run the tests, commit, and tag the commit as v<version>. Pushing the tag and publishing the package are separate operator actions.

Runtime behavior

run requires -- followed by a command. Arguments after --, even -d and --key, belong to the child command. Put envdirx's --key before --. Missing -- or command fails without starting a child. run execs the command and inherits its exit status. Following DJB semantics, a zero-byte entry unsets the variable; a nonempty entry uses only the first line, strips trailing spaces and tabs, and converts NUL bytes to newlines. Ciphertext uses an ephemeral X25519 key and ChaCha20-Poly1305; authentication binds the entry to its filename.

uv run python -m unittest discover -s tests -v

ponytail: This is currently for local POSIX environments. It does not defend against another user changing the directory concurrently; add dirfd-based race protection and stronger key management if that threat becomes relevant.

Metadata

Release files for envdirx 0.1.0

For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.

Source distribution (sdist)

Source distribution for envdirx 0.1.0
File Size Uploaded
envdirx-0.1.0.tar.gz 18.1 kB Details

Built distribution (wheel)

Table of built distributions (wheels) for envdirx 0.1.0
File Interpreter ABI Platform
envdirx-0.1.0-py3-none-any.whl Python 3 none any Details

Total release size: 31.8 kB

Release files / envdirx-0.1.0.tar.gz

Download URL envdirx-0.1.0.tar.gz
Size 18.1 kB
Tags Source
SHA-256 checksum
How to use checksums
d6a73be2708a96f5ecb18711315e125fb529273577d45d157e6e097ad4658209
BLAKE2b-256 checksum
How to use checksums
87516975d3ad82c7c7eebd080fc0ccc42dc480388003e89d9c160b5e87e6ade2
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
No
Uploaded via uv/0.11.26 {"installer":{"name":"uv","version":"0.11.26","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"macOS","version":null,"id":null,"libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":null}

Release files / envdirx-0.1.0-py3-none-any.whl

Download URL envdirx-0.1.0-py3-none-any.whl
Size 13.7 kB
Tags Python 3
SHA-256 checksum
How to use checksums
daa95a65249ad77888a8f51bbd3c2f3ca6d67befa9443d0fe86c11860756b8d9
BLAKE2b-256 checksum
How to use checksums
5169d96696317f76ceaa15304f535fc9e69f36b33195505eaf3781370cf7a09b
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
No
Uploaded via uv/0.11.26 {"installer":{"name":"uv","version":"0.11.26","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"macOS","version":null,"id":null,"libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":null}

Release history Release notifications | RSS feed

This release

0.1.0 This release

2 release files

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page