dotenvdotenc
v0.2.1
Published
dede: keep gitignored .env* files in sync with committed dotenvx-encrypted .env*.enc twins
Maintainers
Readme
dotenvdotenc — dede
dotenvx underneath, plus a dead-simple script for the two-file workflow.
dotenvx encrypts a .env file in place, so the file you edit and the file you
commit are the same file. dede keeps them apart:
.env.local plaintext, gitignored — you and your framework use this
.env.local.enc dotenvx-encrypted twin — the only thing committed
.env.keys private keys, gitignored (dotenvx convention)dede enc # .env* → .env*.enc (after you edit)
dede dec # .env*.enc → .env* (after you pull)Everything cryptographic and every parse/rewrite is @dotenvx/primitives
(dotenvx's own core). .enc files are ordinary dotenvx files, so dotenvx run -f .env.local.enc -- …
works too. dede adds only what dotenvx lacks for two files: keep them in sync, refuse to clobber.
Install
bun add -d github:snomiao/dotenvdotenc#<commit> # per repo (hooks use this)
bun add -g github:snomiao/dotenvdotenc # optional: `dede` on your PATH
dede setup # .gitignore rules + pre-commit hookRequires Bun. Without a global install, use bunx dede … or node_modules/.bin/dede ….
Commands
dede enc [--force] [file|glob…] plaintext → .enc (default: every .env* in this directory)
dede dec [--force] [file|glob…] .enc → plaintext (default: every .env*.enc in this directory)
dede status [--quiet] [file|glob…] one line per file (--quiet: problems only); exit 1 unless all in sync
dede diff [file|glob…] which keys differ between plaintext and .enc (names only)
dede get NAME [file|glob…] one value from the .enc files, raw (for `KEY=$(dede get KEY) cmd`)
dede guard [--all] pre-commit check (installed by setup)
dede setup add `.env*` / `!.env*.enc` to .gitignore, install the hook
(husky in a JS repo without a hook manager; lefthook/husky/plain git otherwise)Files can be named either way — .env.local or .env.local.enc — and globs work quoted or
shell-expanded: dede enc '.env.*', dede dec .env*. .env.keys, .env.example, .env.sample,
.env.template, .env.vault, .env.me and .envrc are never paired.
How sync decides
dede compares the content of both files (after decrypting). Equal → nothing is written, so an unchanged value keeps its exact ciphertext and a one-value edit is a one-line diff.
When they differ, dede checks which side changed since the last enc/dec in this worktree.
The record lives in .git/dotenvdotenc/state.json: file hashes plus salted per-key hashes, never
values.
| Changed since last sync | dede enc | dede dec |
|---|---|---|
| plaintext only | writes .enc | refuses, naming the edited keys: run dede enc |
| .enc only (you pulled) | refuses, naming the changed keys: run dede dec | writes plaintext |
| both, different keys | merges per key, writes both files | merges per key into the plaintext; then dede enc publishes your edits |
| both, the same key (or comments on both sides) | refuses, naming the keys | refuses, naming the keys |
| unknown (never synced here) | refuses, listing which keys differ | refuses, listing which keys differ |
A merge takes each key from the side that changed it, and keeps the comments and order of the side
whose comments changed. --force overrides a refusal and takes that command's source side. A
missing file on the target side is simply created. Messages name keys, never values.
dede diff shows, per file, which keys differ between the plaintext and its .enc.
dede get NAME decrypts one value straight from the .enc (no plaintext file needed) and writes it
raw to stdout: $VAR and $(…) are never expanded or run (unlike dotenvx get). The last
assignment wins. It prints a secret, so pipe it into a command rather than to a terminal or log.
Keys
dotenvx's convention, nothing new:
.env.local↔DOTENV_PUBLIC_KEY_LOCAL(header of.env.local.enc) andDOTENV_PRIVATE_KEY_LOCAL(.env→ no suffix,.env.development.local→_DEVELOPMENT_LOCAL).- The private key is read from the environment, else from
.env.keysnext to the file — nowhere else. The key that matches the.encheader is used, whatever its name. -fk <path>/--env-keys-file <path>(orDOTENV_KEYS_FILE) points one run at another keys file.dede keyslists every.enchere with the public key it needs and where that key was found (environment,.env.keys, or missing). It never prints a private key.- The first
dede encof a file reuses an existing key by that name, or generates one and appends it to.env.keys(mode 0600). Back it up in the password manager — it is the only copy, and teammates need it to rundede dec.
Several checkouts of one repo (worktrees, submodules)
Keep one real .env.keys and link the others to it, visibly:
cd ../feature-worktree
dede keys link ../main/.env.keys # ./.env.keys -> ../main/.env.keys (a plain symlink)
dede decdotenvx follows the link too. New keys created from a linked checkout are written to the target file.
Submodules are separate checkouts, so link each one (from the superproject):
git submodule foreach 'dede keys link /path/to/.env.keys || true'Backing up keys files
Keys files are .env.keys or .env.keys.<name>. They may hold DOTENV_PRIVATE_KEY_*; any other env
file may not. So a keys file can itself be synced like any env file, encrypted with a key you
choose:
# in a private repo that holds your personal key ME in ./.env.keys
cp ~/src/myproj/.env.keys .env.keys.myproj
dede enc .env.keys.myproj --key ME # commits as .env.keys.myproj.enc, encrypted with ME
cd ~/src/myproj && rm .env.keys && dede keys link ~/vault/.env.keys.myprojOn a new machine: restore ME from the password manager into the vault repo's .env.keys, run
dede dec, then dede keys link from each project. ME becomes the one key to protect.
Guard (pre-commit)
dede guard blocks a commit that stages:
- a new plaintext env file (
.env,.env.local, …) — commit its.encinstead (already-tracked plaintext env files are treated as public config and pass); .env.keys, or any file containing aDOTENV_PRIVATE_KEY…=<64 hex>assignment;- while any
.env*file in the repo is unmanaged: not committed, no.enctwin, and not named*.local(machine-local by convention). Encrypt it withdede enc, or rename it to*.local.dede statuslists these files too; - an
.encfile with a value that is notencrypted:or a line that is neither assignment nor comment. - while a plaintext file has edits not yet encrypted (it changed since its last sync with its
.enc). The message names the keys; rundede encand commit the.enc. A pull that only changed the.encdoes not block commits.
dede setup also installs post-merge / post-checkout hooks that run dede status --quiet, so a
pull or branch switch tells you which files to dede dec (they never block).
dede guard --all checks every tracked file (use it in CI). The guard is a local safety net; a
plaintext secret that reaches a remote must be rotated.
Things to know
- Comments are committed in plaintext (inline
# …too). dede refuses comments that look like credentials — a commented-out assignment with a long value (# OLD_TOKEN=…) or a URL with userinfo (https://user:pw@…) — but it cannot recognise everything, so keep secrets out of comments. - Values are taken literally as dotenvx parses them (quotes,
\nin double quotes). dede never expands$VARor runs$(…); your loader (dotenvx, Next.js, Bun) may when it reads the plaintext. - Formatting survives: quote style,
export, inline comments, duplicates, multiline values. A line that is neither an assignment nor a comment is refused, because it would be committed as-is. - A plaintext env file that is already committed is public config on purpose (e.g. Vite's
.env.productionwithVITE_*values that ship in the bundle anyway):enc/decskip it, and the guard lets edits to it through. Only new plaintext env files are blocked; a new public one can be committed with--no-verify. If a committed file does hold secrets,git rm --cachedit and rotate them. - dede refuses a plaintext file that is not gitignored, and warns when an
.encis gitignored. - Plaintext files are kept at mode 0600 (
enctightens a group/world-readable one). Symlinked plaintext files (e.g..env.local -> .env.dev) are skipped in default and glob runs and refused when named. - Windows: CRLF from
core.autocrlfor an editor is not drift; dede compares and writes LF. File modes do not apply (access is by ACL), anddede keys linkneeds Developer Mode for symlinks (or use-fk). - Exit codes: 0 ok · 1 action needed / blocked · 2 conflict · 3 no key or cannot decrypt · 4 malformed input.
Develop
bun install
bun run test # real CLI against temporary git repos, dummy keys only
bun run typecheckLicense
MIT
