@chrisrivera/envsync
v3.0.2
Published
Sync a repo's gitignored .env files across your machines, end-to-end encrypted under a key chris-rivera.dev has never seen.
Downloads
2,013
Maintainers
Readme
@chrisrivera/envsync
Sync a repo's gitignored .env files across your machines, end-to-end
encrypted under a key chris-rivera.dev has never seen.
git clone https://github.com/you/your-repo && cd your-repo
bunx @chrisrivera/envsyncThat is the whole thing. The first run signs you in through your browser and asks once for your recovery key; every run after that on that machine prompts for nothing.
What it syncs
Exactly the .env* files under the project root that git ignores and does
not track. Computed, never configured — so a committed .env.example, or an
encrypted .env some other tool checked in, is excluded by construction rather
than by a rule you have to remember to write.
A file git tracks is excluded and named in the output, never carried. Git already syncs it, and a second source of truth for the same bytes is a divergence waiting to happen.
What the server can see
Ciphertext, and metadata: project slugs, file counts, byte sizes, timestamps,
device labels. Not filenames, and not a single value. The vault key is 256
CSPRNG bits that exist only on your machines and in your password manager;
chris-rivera.dev stores an HMAC of a constant under it, which verifies a key
it can never guess.
There is no reset. Lose the recovery key with no machine still holding
vault.key and the data is gone — by construction, not by policy. Run
envsync export <dir> onto an encrypted drive the day you set this up.
It never prints a secret
There is no get, no --stdout, no run -- and no --show-values.
envsync status and --dry-run print paths, byte counts and key names. The
job is to put a 0600 file on disk and stop.
The vault
envsync push creates the vault if the account has none: it is the machine with
the files, it cannot seal them without a key, and it already chains login the
same way. Registering the verifier is a write, so this needs the write-scoped
credential only push carries. The ceremony is not shortened by being chained —
the recovery key is still shown once and still has to be acknowledged — and
without a terminal it refuses rather than printing a recovery key into a log.
envsync vault init is still there when you want the step to be explicit.
envsync pull never creates one. A vault that does not exist has no versions,
so creating one mid-pull would hand you an empty vault and nothing to pull —
and it would generate the one unrecoverable secret in this tool as a side effect
of a read, on the machine least likely to be the one that should hold it.
Pulling onto a machine that already has a .env
pull never overwrites. A file that differs from the vault is left exactly as
it is, and the vault's copy is written beside it:
.env.local 1.9 kB sidecar gitignored
→ .env.local.envsync, yours left as it isMerge what you want out of it and delete it. The two versions of a .env are
rarely one right and one wrong — one machine has the key you added this morning,
the other has the one somebody rotated last week — so the merge belongs to the
person who knows which is which.
--force overwrites instead (the previous contents go to backups/ first), and
--keep writes nothing at all. Sidecars are gitignored or they are not written:
add *.envsync to .gitignore, since the .env*.local pattern that protects
.env.local does not cover .env.local.envsync.
Commands
envsync pull, acquiring whatever it needs on the way
envsync pull [--project <p>] [--force] [--keep] [--dry-run] [--version <n>]
envsync push [--project <p>] [-m <note>] [--yes] [--dry-run]
envsync status
envsync login [--device] [--no-device] [--write] [--label <n>] [--timeout <s>]
envsync logout [--all]
envsync init [--name <slug>] [--project <id>]
envsync link <projectId> | envsync unlink
envsync projects [ls | archive <slug>]
envsync history [--limit 20]
envsync devices [ls | revoke <id> | revoke --all-others]
envsync vault [init | unlock | rotate | id]
envsync export <dir> [--yes]
envsync doctorGlobal flags: --json --quiet --no-color --host <url>
--config-dir <path>.
Environment: ENVSYNC_TOKEN ENVSYNC_VAULT_KEY ENVSYNC_PROJECT
ENVSYNC_HOST ENVSYNC_CONFIG_DIR ENVSYNC_NO_BROWSER NO_COLOR.
Exit codes
| Code | Meaning | | ---- | ---------------------------------------------------------------------------- | | 0 | ok | | 1 | refused, or a file was deliberately skipped | | 2 | usage, or no project resolves here | | 3 | authentication required and nobody could be asked | | 4 | forbidden: token scope, or this deployment's owner gate | | 5 | network, or the server failed | | 6 | refused for safety: not gitignored, sidecar not gitignored, version conflict | | 7 | the vault key is missing, wrong, or opens nothing |
With --json, stdout carries one object and nothing else — failures included:
{
"ok": false,
"code": 3,
"error": "auth_required",
"hint": "run `envsync login`"
}Everything a human reads goes to stderr, in every mode, so
envsync status --json | jq works inside a script.
Credential storage
| Platform | Directory |
| -------- | ---------------------------------------- |
| Windows | %APPDATA%\envsync\ |
| macOS | ~/Library/Application Support/envsync/ |
| other | ${XDG_CONFIG_HOME:-~/.config}/envsync/ |
auth.json, vault.key, config.json, backups/. Directory 0700, files 0600.
Plain files, not the OS keychain, and envsync status says so in those
words. Windows Credential Manager caps a credential blob at 2560 bytes, a
keyring binding means a compiled native dependency (which breaks the
zero-install bunx promise), and a silent fallback to plaintext is worse
than an honest plaintext file — because people believe the marketing. On
Windows the real protection is an icacls pass, and envsync doctor reports
whether it took.
Requirements
Node 22 or newer. Zero runtime dependencies: node: builtins and fetch.
The floor was Node 20 until it reached end of life on 2026-04-30. Every release runs the built bundle on the floor before publishing, so the number above is tested rather than claimed.
Staying current
bunx @chrisrivera/envsync does not necessarily run the newest release. bunx
keeps a per-package project under your temp directory with its own lockfile,
named for the spec rather than the version:
$TMPDIR/bunx-<uid>-@chrisrivera/envsync@latest/bun.lockOnce that lockfile pins a version, the bare command reuses it until bun's
cached registry data expires. Adding @latest changes nothing, because
envsync@latest is the spec already locked.
So every run checks npm for a newer release and prints a line on stderr if you are behind, with an exact version in the command it suggests:
envsync <newest> is out. You are running <yours>.
bunx @chrisrivera/envsync@<newest>
The version is pinned on purpose: bunx caches the unpinned command.A pinned spec gets its own directory and resolves fresh, which is why the notice never tells you to re-run the command you just ran.
To pin without reading the notice first, resolve the version and hand bunx an
exact spec in one command — this repo's syncenv:check script does exactly
that:
bunx @chrisrivera/envsync@$(npm view @chrisrivera/envsync version) checkThe check writes nothing, stores nothing, and gives up after 1.5 seconds. It
runs alongside your command rather than in front of it, so a working network
costs you nothing and a broken one costs you the timeout, once, after the work
is already done. It is silent under --json and --quiet, and a registry that
is unreachable, slow, or behind a captive portal is silence rather than an
error. Nothing it does can fail your command.
Pinning
The next thing you type into this CLI is your recovery key, so pin the version if running whatever npm currently serves bothers you:
bunx @chrisrivera/envsync@<version>envsync --version prints the one you are on, and
the npm page lists what
has shipped. A real number is deliberately not written here: this file is
inside the published tarball, so any version named in it is the version you
already have, and it goes stale the moment the next release lands.
The CLI is never the only copy of your keys. That is what envsync export is
for.
License
MIT
