@super-repo/envx 0.2.3-b.1 → 0.2.3-b.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,24 +1,71 @@
1
- # @honeycluster/envx
1
+ # @super-repo/envx
2
2
 
3
- A small wrapper around [`@dotenvx/dotenvx`](https://www.npmjs.com/package/@dotenvx/dotenvx)
4
- for monorepos. Auto-detects the active environment from common
5
- platform signals, loads the matching `.env*` files from the workspace
6
- root and the current package, and runs your command (or your code)
7
- with that environment applied.
3
+ Workspace-aware env-file loader, runner, and encryption tool — built for monorepos.
8
4
 
9
5
  ```sh
10
- pnpm add @honeycluster/envx
6
+ pnpm add @super-repo/envx
11
7
  ```
12
8
 
9
+ ## Tribute
10
+
11
+ This package stands on the shoulders of [`@dotenvx/dotenvx`](https://www.npmjs.com/package/@dotenvx/dotenvx). The encryption format, the `.env.keys` convention, the public-key header layout, and the broad CLI shape are all directly inspired by — and **wire-format compatible with** — dotenvx. We re-use [`eciesjs`](https://www.npmjs.com/package/eciesjs) (the same library dotenvx uses) for asymmetric encryption, so a `.env*` file encrypted by either tool can be decrypted by the other given the matching private key.
12
+
13
+ If you're building a single application, **just use [`@dotenvx/dotenvx`](https://dotenvx.com)**. It's excellent, well-maintained, and battle-tested. This package exists for one specific case: when "load the environment for one app" doesn't capture what you actually need.
14
+
15
+ ## The problem
16
+
17
+ Monorepos break a lot of dotenv-style assumptions:
18
+
19
+ - **Env files live at multiple levels.** Workspace-wide secrets sit at the repo root; per-package overrides sit inside each package; CI injects yet another layer. dotenv's "look in cwd" model produces different results depending on which package directory you happen to be in when you ran the command.
20
+ - **`.env.keys` lives in *one* place, but encrypted `.env*` files live in many.** Without a workspace-aware lookup, every package needs its own copy of the keys file, or a fragile `--env-keys-file ../../../.env.keys` per command.
21
+ - **Cascade resolution wants the workspace root, not cwd.** `--cascade prod` should layer `.env`, `.env.prod`, `.env.local`, and `.env.prod.local` from the *workspace root*, regardless of which subpackage triggered the load. Without this, a Vite build run from `packages/web/` and a Node script run from `apps/api/` see different environments for the same prod target.
22
+ - **Encryption keys want to rotate atomically across the whole stack.** Rotating one package's key while leaving the others stale is a leak waiting to happen. You want one `rotate` command that walks the workspace.
23
+ - **Per-package configuration is a real thing.** Some packages legitimately want a different env-file set than the rest of the monorepo (e.g. a Docker image-builder package that pulls from `vault/` while the rest pull from `.env`).
24
+
25
+ dotenvx handles single-app cases beautifully. envx handles the workspace shape on top of dotenvx's foundation.
26
+
27
+ ## The solution
28
+
29
+ envx adds three things on top of the dotenvx model:
30
+
31
+ 1. **Cwd-first paths with workspace-root fallback.** Both `--dir`/`--env-path` and `--env-keys-file`/`-fk` resolve **relative to where the command is invoked**. If the requested directory or keys file isn't there, envx walks *up* to the nearest workspace root (any ancestor with `pnpm-workspace.yaml`, `package.json#workspaces`, `nx.json`, etc.) and looks again. So `envx encrypt --dir vault` works whether `vault/` lives in your current package or at the workspace root — same command, same intent, no per-package `../../../.env.keys` ceremony.
32
+
33
+ 2. **One `.env.keys` for the workspace by default.** With no `-fk` flag, envx looks for `.env.keys` at cwd; if it's not there, it walks up. So a single keys file at the workspace root serves every encrypted `.env*` across the monorepo. Override per-call with `-fk <path>` whenever you need a different file.
34
+
35
+ 3. **`rotate` walks every encrypted file you point it at.** One command refreshes the asymmetric keypair for every file the resolver picks up — every file in `--dir vault`, every file in a `-e` list, every cascade target. The new keypair lands in both the env file's `ENVX_PUBLIC_KEY*` header and the matching `.env.keys` entry atomically.
36
+
37
+ The CLI shape, the `encrypted:<base64>` ciphertext format, the `ENVX_PUBLIC_KEY*` headers, and the `.env.keys` file layout are all directly compatible with `dotenvx`. envx also reads keys stored under the upstream `DOTENV_PUBLIC_KEY*` / `DOTENV_PRIVATE_KEY*` names so a `.env.keys` produced by either tool decrypts under the other.
38
+
39
+ ## envx vs. dotenvx
40
+
41
+ | capability | `dotenvx` | `envx` |
42
+ | ----------------------------------------- | ---------------------------------------------------------- | ------------------------------------------------------------------------------------- |
43
+ | **Target shape** | Single application | pnpm / yarn / npm workspaces, Nx, Turborepo, Bazel, etc. |
44
+ | **Env-file path resolution** | Relative to `cwd` | Cwd-first; walks up to workspace root (`pnpm-workspace.yaml`, `package.json#workspaces`, `nx.json`, …) on miss |
45
+ | **`.env.keys` default location** | Alongside the env file | Cwd first, then walk-up to workspace root — one keys file serves the whole monorepo |
46
+ | **`--env-keys-file` relative resolution** | Relative to `cwd` | Relative to `cwd`, with walk-up to the workspace root if the file isn't at cwd |
47
+ | **`--dir` flag** | Not present (use `-f <path>` per file) | First-class `--dir` / `-d` / `--env-path`, cwd-first with workspace fallback |
48
+ | **Per-package config** | Single project root | `package.json#envx.config: "<path>"` per package + workspace-level inheritance |
49
+ | **Cascade discovery** | From cwd | From workspace root, then layered with package-local overrides |
50
+ | **Encryption** | ECIES via `eciesjs` (secp256k1 + AES-256-GCM) | **Identical** ECIES via `eciesjs` — wire-format compatible |
51
+ | **Public-key header** | `DOTENV_PUBLIC_KEY*` | `ENVX_PUBLIC_KEY*` (canonical), `DOTENV_PUBLIC_KEY*` accepted as a read fallback |
52
+ | **Private-key var name** | `DOTENV_PRIVATE_KEY*` | `ENVX_PRIVATE_KEY*` (canonical), `DOTENV_PRIVATE_KEY*` accepted as a read fallback |
53
+ | **`rotate` command** | Per-file rotation | Walks every encrypted file the resolver picks up |
54
+ | **Programmatic API** | `dotenvx.config()` / library functions | `envx()` (single call), `import "@super-repo/envx/auto"` side-effect entry |
55
+ | **Auto-environment detection** | Limited | Reads `VERCEL_ENV`, Netlify `CONTEXT`, `NODE_ENV` to pick `.env.<env>` when `--env` is omitted |
56
+ | **Distribution** | npm package | npm package + workspace-private helpers (`envx-libs`, `envx-common`) bundled via Vite |
57
+ | **License** | BSD-3-Clause | MIT |
58
+
59
+ In short: same crypto, same file formats, same CLI vocabulary — different path-resolution layer underneath.
60
+
13
61
  ## Programmatic API
14
62
 
15
- ### `import envx from "@honeycluster/envx"`
63
+ ### `import envx from "@super-repo/envx"`
16
64
 
17
65
  ```ts
18
- import envx from "@honeycluster/envx";
66
+ import envx from "@super-repo/envx";
19
67
 
20
68
  // Three call shapes — all mutate process.env and return it for ergonomics.
21
-
22
69
  const env = envx(); // load .env (auto-detect from NODE_ENV / VERCEL_ENV / NETLIFY)
23
70
  const env = envx("prod"); // cascade-load .env, .env.prod, .env.local, .env.prod.local
24
71
  const env = envx({ // full options
@@ -30,56 +77,34 @@ const env = envx({ // full options
30
77
  });
31
78
  ```
32
79
 
33
- The string form (`envx("prod")`) is shorthand for "scope this load to
34
- the named environment" — it sets `cascade` so `.env.prod` and any local
35
- overrides layer cleanly over `.env`.
80
+ The string form (`envx("prod")`) is shorthand for "scope this load to the named environment" — it sets `cascade` so `.env.prod` and any local overrides layer cleanly over `.env`.
36
81
 
37
- `envx()` discovers `envx.config.{ts,js,json}` and `package.json`'s
38
- `envx.config` automatically; the explicit args you pass override
39
- matching fields from the config. See [`docs/CONFIG.md`](../../docs/CONFIG.md).
82
+ `envx()` discovers `envx.config.{ts,js,json}` and `package.json#envx.config` automatically; explicit args override matching fields from the config.
40
83
 
41
- ### `import "@honeycluster/envx/auto"` — side-effect
84
+ ### Side-effect import
42
85
 
43
86
  ```ts
44
87
  // In your app's entry file:
45
- import "@honeycluster/envx/auto";
88
+ import "@super-repo/envx/auto";
46
89
 
47
90
  // envx() has already run by the time this line executes.
48
91
  const env = process.env;
49
92
  ```
50
93
 
51
- `@honeycluster/envx/config` is an alias of `/auto` (matches the
52
- `dotenv/config` convention).
53
-
54
- ### Lower-level building blocks
55
-
56
- The same helpers the CLI uses are also exported from
57
- `@honeycluster/libs` if you want fine-grained control:
58
-
59
- ```ts
60
- import {
61
- detectEnvironment,
62
- expandCascadePaths,
63
- findWorkspaceRoot,
64
- loadEnv,
65
- resolveEnvPaths,
66
- encryptFiles,
67
- decryptFiles,
68
- expandEnvSrc,
69
- loadDotenvxConfig,
70
- } from "@honeycluster/libs";
71
- ```
94
+ `@super-repo/envx/config` is an alias of `/auto` (matches the `dotenv/config` convention).
72
95
 
73
96
  ## CLI
74
97
 
75
98
  ```sh
76
- envx -- node app.js # load .env (auto-detected) and run
77
- envx --env dev -- pnpm start # load .env.dev
78
- envx print DATABASE_URL # print one variable
79
- envx debug --cascade prod # show resolved paths
80
- envx encrypt -e .env.prod # encrypt values in .env.prod
81
- envx decrypt -e .env.prod -k FOO # decrypt only FOO
82
- envx expand -e vault/.env.prod # decrypt + expand to stdout
99
+ envx -- node app.js # load .env (auto-detected) and run
100
+ envx --env dev -- pnpm start # load .env.dev
101
+ envx --dir vault -- pnpm test # load every .env* in vault/
102
+ envx print DATABASE_URL # print one variable
103
+ envx debug --cascade prod # show resolved paths
104
+ envx encrypt -e .env.prod # encrypt values in .env.prod (writes ./.env.keys)
105
+ envx decrypt -e .env.prod -k FOO # decrypt only FOO
106
+ envx rotate --dir vault # rotate keypair for every vault/.env* file
107
+ envx expand -e vault/.env.prod # decrypt + expand to stdout
83
108
  envx -c ./envx.config.json run -- node app.js
84
109
  ```
85
110
 
@@ -88,7 +113,7 @@ envx -c ./envx.config.json run -- node app.js
88
113
  | bin | source | what it does |
89
114
  | ---------------- | ---------------------------- | ----------------------------------------------- |
90
115
  | `envx` | `dist/cli.js` | This package's CLI (subcommands below) |
91
- | `dotenvx-proxy` | `dist/bin/dotenvx.js` | Passthrough to upstream `@dotenvx/dotenvx` |
116
+ | `dotenvx-proxy` | `dist/bin/dotenvx.js` | Direct passthrough to upstream `@dotenvx/dotenvx` |
92
117
 
93
118
  ### Subcommands
94
119
 
@@ -99,46 +124,397 @@ envx -c ./envx.config.json run -- node app.js
99
124
  | `debug` | show which files would be loaded without loading them |
100
125
  | `encrypt` | encrypt values in one or more `.env*` files (writes `.env.keys` on first run) |
101
126
  | `decrypt` | decrypt values back in place |
127
+ | `rotate` | generate a fresh keypair and re-encrypt every encrypted value in place |
128
+ | `info` | print resolved configuration, auto-detection details, NODE_ENV mappings, and the env files that would load |
102
129
  | `expand` | decrypt (if needed) and expand `${VAR}` / `$VAR` / `${VAR:-default}` / `${VAR:?msg}` |
103
130
 
104
131
  ### Global options
105
132
 
106
- | option | alias | default | description |
107
- | --------------- | ----------- | ----------- | ---------------------------------------------------------------------------------------- |
108
- | `--config` | `-c` | _(auto)_ | Path to an `envx.config.{ts,js,json}`. Discovery order: flag → `package.json` `envx.config` → auto-discover. |
109
- | `--env` | `-e` | `[".env"]` | Files to load (repeatable). Auto-discovered from `--env-path` when omitted. |
110
- | `--env-path` | | _(none)_ | Subdirectory holding env files. When set + `--env` omitted, every `.env*` in the dir is included. |
111
- | `--vault` | `--va` | `false` | Shortcut for `--env-path vault`. |
112
- | `--variables` | `-v` | `[]` | Inline `name=value` overrides (repeatable). |
113
- | `--cascade` | | _(off)_ | Cascade name; expands `.env`, `.env.<c>`, `.env.local`, `.env.<c>.local`. |
114
- | `--override` | `-o` | `false` | Override existing `process.env` values. Conflicts with `--cascade`. |
115
- | `--quiet` | `-q` | `true` | Suppress dotenv's own output. |
116
-
117
- ### Auto-detect rules
118
-
119
- The auto-detected environment becomes the suffix on `.env.<env>`:
120
-
121
- | signal | resolves to |
122
- | ------------------------------------- | --------------- |
123
- | `VERCEL_ENV=production` | `.env.prod` |
124
- | `VERCEL_ENV=preview` (or unset) | `.env.dev` |
125
- | Netlify `CONTEXT=production` | `.env.prod` |
126
- | Netlify `CONTEXT=deploy-preview` / `branch-deploy` | `.env.dev` |
127
- | `NODE_ENV=production` | `.env.prod` |
128
- | `NODE_ENV=development` | `.env.dev` |
129
- | `NODE_ENV=local` | `.env.local` |
130
- | _no signals_ | `.env` |
131
-
132
- Auto-detection only triggers when `--env` is left at its default
133
- (`[".env"]`). Explicit `--env` always wins.
133
+ | option | alias | default | description |
134
+ | ----------------- | ---------------- | -------------------- | ---------------------------------------------------------------------------------------- |
135
+ | `--config` | `-c` | _(auto)_ | Path to an `envx.config.{ts,js,json}`. Discovery: flag → `package.json#envx.config` → walk-up auto-discovery. |
136
+ | `--env` | `-e` | `[".env"]` | Files to load (repeatable). Auto-discovered from `--env-path` when omitted. |
137
+ | `--env-path` | `-d`, `--dir` | _(none)_ | Subdirectory holding env files. Cwd-first; falls back to `<workspace-root>/<dir>` if the cwd path doesn't exist. When set + `--env` omitted, every `.env*` in the resolved dir is included. |
138
+ | `--vault` | `--va` | `false` | Shortcut for `--env-path vault`. |
139
+ | `--env-keys-file` | `-fk` | `<cwd>/.env.keys` | Path to the `.env.keys` file. Cwd-first; walks up to the workspace root if no keys file exists at cwd. |
140
+ | `--variables` | `-v` | `[]` | Inline `name=value` overrides (repeatable). |
141
+ | `--cascade` | | _(off)_ | Cascade name; expands `.env`, `.env.<c>`, `.env.local`, `.env.<c>.local`. |
142
+ | `--override` | `-o` | `false` | Override existing `process.env` values. Conflicts with `--cascade`. |
143
+ | `--quiet` | `-q` | `true` | Suppress dotenv's own output. |
144
+
145
+ ### Defaults
146
+
147
+ Six resolution rules drive every envx invocation. Each is overridable — see [Configuration](#configuration) below for the layered customization map.
148
+
149
+ #### 1. Environment auto-detection
150
+
151
+ When `--env` is left at its default (`[".env"]`), envx auto-detects the deployment environment from well-known platform signals and rewrites the env-file suffix accordingly. Source-of-truth: `detectEnvironment()`.
152
+
153
+ Order of precedence: **Vercel → Netlify `NODE_ENV` unsuffixed**.
154
+
155
+ | signal | detected | env file picked |
156
+ | ------------------------------------------------------- | ---------- | --------------- |
157
+ | `VERCEL` set + `VERCEL_ENV=production` | `prod` | `.env.prod` |
158
+ | `VERCEL` set + any other `VERCEL_ENV` (or unset) | `dev` | `.env.dev` |
159
+ | `NETLIFY` set + `CONTEXT=production` | `prod` | `.env.prod` |
160
+ | `NETLIFY` set + `CONTEXT=deploy-preview` / `branch-deploy` | `dev` | `.env.dev` |
161
+ | `NETLIFY` set + any other `CONTEXT` | `dev` | `.env.dev` |
162
+ | `NODE_ENV=production` | `prod` | `.env.prod` |
163
+ | `NODE_ENV=development` | `dev` | `.env.dev` |
164
+ | `NODE_ENV=local` | `local` | `.env.local` |
165
+ | `NODE_ENV=<other>` (e.g. `staging`, `qa`, `preview`) | `<other>` | `.env.<other>` |
166
+ | _no signals_ | `root` | `.env` |
167
+
168
+ `NODE_ENV` values not in the built-in map (`production`, `development`, `local`) **pass through lowercased** as the suffix. So `NODE_ENV=staging` → `.env.staging`, `NODE_ENV=QA` → `.env.qa`, etc. — no config required.
169
+
170
+ **Customize:**
171
+ - Pass `--env <name>` (or `-e <name>`) to bypass detection entirely; bare names get the `.env.` prefix automatically (`--env staging` → `.env.staging`).
172
+ - Disable detection: set `autoDetect: false` in `envx.config.{ts,js,json}`.
173
+ - Override the `NODE_ENV → suffix` mapping in config:
174
+ ```ts
175
+ // envx.config.ts
176
+ export default {
177
+ nodeEnvMap: {
178
+ // built-ins keep working unless you override them:
179
+ production: "prod",
180
+ development: "dev",
181
+ local: "local",
182
+ // your additions / remappings:
183
+ qa: "qa-prod", // NODE_ENV=qa → .env.qa-prod
184
+ preview: "", // NODE_ENV=preview → .env (no suffix)
185
+ },
186
+ }
187
+ ```
188
+ - Programmatic: `envx({ envFiles: [...] })`, `envx("staging")` (sets `cascade`), or `envx({ autoDetect: false, nodeEnvMap: { ... } })`.
189
+
190
+ Run **`envx info`** to print the resolved settings, the detected environment, the active mapping, and which files would be loaded.
191
+
192
+ #### 2. Cascade expansion order
193
+
194
+ `--cascade <name>` expands the base files into a layered set, **least- to most-specific**, so that the most specific one wins. Source-of-truth: `expandCascadePaths()`.
195
+
196
+ For a base of `.env` and `--cascade prod`:
197
+
198
+ | order | path | role |
199
+ | :---: | ----------------- | --------------------------------- |
200
+ | 1 | `.env.prod.local` | per-developer prod override (gitignored) |
201
+ | 2 | `.env.local` | per-developer override (gitignored) |
202
+ | 3 | `.env.prod` | shared prod values (committable, encryptable) |
203
+ | 4 | `.env` | shared baseline |
204
+
205
+ Without `--cascade`: `.env.local` → `.env` (still least-specific-last).
206
+
207
+ **Customize:**
208
+ - **CLI**: pass `--cascade <name>` to cascade with an explicit env name (e.g. `--cascade prod`).
209
+ - **Config**: set `cascade: true` to cascade using the auto-detected env name. `cascade: false` (or omit) disables.
210
+ - **Programmatic**: `envx({ cascade: true })` for auto-detected, `envx({ cascade: "prod" })` for explicit, `envx("prod")` (string shorthand) is equivalent to the explicit form.
211
+ - `--override` conflicts with cascade — envx exits with an error if both are set.
212
+
213
+ #### 3. Path resolution (cwd-first → workspace-root fallback)
214
+
215
+ Every relative path passed to `--dir`, `--env-path`, `--env-keys-file`, or set in config goes through the same resolver. Source-of-truth: `resolveCwdOrWorkspace()`.
216
+
217
+ ```
218
+ absolute path? → use verbatim
219
+ <cwd>/<rel> exists? → use that
220
+ <workspaceRoot>/<rel>? → use that
221
+ neither? → return <cwd>/<rel> so callers (encrypt, etc.) can create it
222
+ ```
223
+
224
+ The "default `.env.keys`" lookup runs the same resolver against the literal `".env.keys"` — so a single `.env.keys` at the workspace root serves every package by default.
225
+
226
+ **Customize:**
227
+ - Pass an absolute path to `-fk` / `--dir` to skip the walk-up entirely.
228
+ - Set `envKeysFile` / `envPath` in `envx.config.{ts,js,json}`.
229
+ - Programmatic: `envx({ envPath: "vault", envKeysFile: "/abs/path/.env.keys" })`.
230
+
231
+ #### 4. Workspace root detection
232
+
233
+ `findWorkspaceRoot(startDir)` walks up from `startDir` and returns the first directory that matches one of these signals, in order:
234
+
235
+ | pass | signal |
236
+ | :--: | ------------------------------------------------------------ |
237
+ | 1 | `package.json` with a `workspaces` field (npm/yarn) |
238
+ | 1 | `package.json` with a `pnpm.workspaces` field |
239
+ | 2 | `pnpm-workspace.yaml` |
240
+ | 2 | `lerna.json` |
241
+ | 2 | `nx.json` |
242
+ | 2 | `rush.json` |
243
+ | 2 | `yarn.lock` |
244
+ | 2 | `pnpm-lock.yaml` |
245
+
246
+ If nothing matches, `startDir` is returned (so envx degrades to single-package behavior — the cwd-first / fallback resolver still works, the fallback is just a no-op).
247
+
248
+ **Customize:**
249
+ - The detection is currently hardcoded. If you have a non-standard layout, pass absolute paths to `--dir` / `-fk` / `--env` and skip the walk entirely. Reach out if you have a layout that needs a new indicator added.
250
+
251
+ #### 5. Default `--env` / `--env-path` behavior
252
+
253
+ | condition | resolved env files |
254
+ | ------------------------------------------------------ | --------------------------------------------------------------- |
255
+ | `--env` explicitly passed | exactly the files passed (with `.env.` prefix added to bare names) |
256
+ | `envFiles` set in config | the config's list |
257
+ | `--env-path <dir>` set, no `--env` | every `.env*` file inside `<dir>` (sorted, excluding `.env.keys`) |
258
+ | neither set | `[".env"]` (with auto-detection from rule #1) |
259
+
260
+ `--vault` is exact shorthand for `--env-path vault`. The `.env.keys` file is **never** discovered by `listEnvFiles`, so `--dir vault` won't accidentally try to "encrypt" your private keys.
261
+
262
+ **Customize:**
263
+ - `--env <file>` (repeatable) — explicit list.
264
+ - `--env-path <dir>` (or `--dir`, `-d`) — pull every `.env*` from a directory.
265
+ - `envFiles` / `envPath` in config.
266
+ - Programmatic: `envx({ envFiles: [...], envPath: "..." })`.
267
+
268
+ #### 6. CLI variable overrides
269
+
270
+ `-v KEY=VALUE` (repeatable) sets values on `process.env` *after* env files load. Format is `^(\w+)=([\s\S]+)$` — multi-line values are supported, but the key must be a valid bash identifier. Source-of-truth: `validateCmdVariable()`.
271
+
272
+ **Customize:**
273
+ - `-v KEY=VALUE` per-call.
274
+ - Programmatic: `envx({ variables: ["KEY=VALUE", ...] })`.
275
+
276
+ ## Encryption
277
+
278
+ envx uses **asymmetric (ECIES) encryption by default** — exactly the same scheme as `dotenvx`. On first encrypt of a fresh `.env*` file:
279
+
280
+ 1. A fresh secp256k1 keypair is generated.
281
+ 2. The public key gets prepended to the env file as a banner + `ENVX_PUBLIC_KEY*` header (safe to commit).
282
+ 3. The matching private key gets written to `.env.keys` (gitignore this) under `ENVX_PRIVATE_KEY*` with a section banner per env file.
283
+ 4. Every selected value is encrypted with the public key. Since the public key is committed alongside the env file, anyone with read access can encrypt new values; only the private-key holder can decrypt.
284
+
285
+ A typical encrypted `.env.dev`:
286
+
287
+ ```
288
+ #/-------------------[ENVX_PUBLIC_KEY]----------------------/
289
+ #/ public-key encryption for .env files /
290
+ #/ [how it works](https://dotenvx.com/encryption) /
291
+ #/----------------------------------------------------------/
292
+ ENVX_PUBLIC_KEY_DEV="0266287313a6f4d107115d5963640830fcca378ae34a5e3dbf775122fd121f7084"
293
+
294
+ DATABASE_URL=encrypted:Bf3p…
295
+ API_KEY=encrypted:7nQ2…
296
+ ```
297
+
298
+ …and the matching `.env.keys` (gitignored):
299
+
300
+ ```
301
+ #/------------------!ENVX_PRIVATE_KEYS!---------------------/
302
+ #/ private decryption keys. DO NOT commit to source control /
303
+ #/ [how it works](https://dotenvx.com/encryption) /
304
+ #/----------------------------------------------------------/
305
+
306
+ # .env.dev
307
+ ENVX_PRIVATE_KEY_DEV="…64 hex chars…"
308
+
309
+ # .env.prod
310
+ ENVX_PRIVATE_KEY_PROD="…64 hex chars…"
311
+ ```
312
+
313
+ ### Rotating keys
314
+
315
+ ```sh
316
+ envx rotate # rotate keypair for ./.env
317
+ envx rotate --dir vault # rotate every vault/.env* file
318
+ envx rotate -k API_KEY # rotate one variable in place
319
+ ```
320
+
321
+ Rotation generates a fresh keypair, decrypts every value with the old private key, re-encrypts with the new public key, and updates both the env file and `.env.keys` atomically. Files without a public-key header surface an error — run `envx encrypt` against them first to set up the keypair.
322
+
323
+ ### dotenvx-compatible naming
324
+
325
+ envx reads keys under either prefix and writes new ones under the canonical names:
326
+
327
+ | concept | canonical (envx writes this) | dotenvx-compat (envx reads this) |
328
+ | ----------- | ------------------------------- | -------------------------------- |
329
+ | public key | `ENVX_PUBLIC_KEY*` | `DOTENV_PUBLIC_KEY*` |
330
+ | private key | `ENVX_PRIVATE_KEY*` | `DOTENV_PRIVATE_KEY*` |
331
+
332
+ A `.env.keys` produced by upstream dotenvx will Just Work — envx decrypts and re-encrypts in place without renaming the entry, so the diff doesn't ripple.
333
+
334
+ ## Configuration
335
+
336
+ Every default in the previous section can be overridden. There are three precedence layers — highest to lowest:
337
+
338
+ | layer | scope | when to use |
339
+ | ------------------------------------------------------ | --------------------------- | ---------------------------------------------------------- |
340
+ | 1. **Per-invocation overrides** — CLI flag (`--env`, `--dir`, …) **or** programmatic arg (`envx({...})`) | one invocation | ad-hoc overrides, scripts, embedded usage |
341
+ | 2. **The one resolved config file** | the package containing the resolved config | per-package or workspace-shared defaults |
342
+ | 3. **Built-in defaults** | every invocation | the fallbacks documented in [Defaults](#defaults) |
343
+
344
+ `-v KEY=VALUE` is **not** a config layer — it's a post-load mutation of `process.env` that runs after env files are read. See "Variable-value precedence" further down.
345
+
346
+ The "one resolved config file" in layer 2 comes from this discovery walk (first match wins, no merging across files):
347
+
348
+ | step | source | walks up? |
349
+ | :--: | --------------------------------------------------------------- | :-------: |
350
+ | 1 | `--config <path>` flag (CLI only) | n/a |
351
+ | 2 | nearest `package.json#envx.config: "<path>"` from cwd up to `/` | yes |
352
+ | 3 | `envx.config.{ts,mts,js,mjs,cjs,json}` in **cwd only** | no |
353
+ | 4 | (none — fall through to defaults) | n/a |
354
+
355
+ A workspace-root `envx.config.ts` is not auto-discovered from sub-packages — point sub-packages at it via `package.json#envx.config: "../../envx.config.ts"` (or whatever the relative path is).
356
+
357
+ The programmatic API runs the same discovery (minus step 1):
358
+
359
+ ```ts
360
+ import envx from "@super-repo/envx";
361
+
362
+ envx(); // discovery + defaults
363
+ envx("staging"); // shorthand for envx({ cascade: "staging" })
364
+ envx({ ... }); // explicit args play the role of CLI flags (layer 1)
365
+ ```
366
+
367
+ ### How the layers actually merge
368
+
369
+ The precedence table glosses over a real distinction: **settings** (`envFiles`, `cascade`, `envKeysFile`, …) and **variable values** (the contents of `process.env` after envx runs) flow through different rules. Worth knowing the difference if you're debugging a "why is this value what it is?" moment.
370
+
371
+ #### Per-setting merge (independent, top-to-bottom)
372
+
373
+ Each setting in the config-file shape (next section) is resolved **independently**. envx walks the layers for each field on its own — there's no all-or-nothing inheritance.
374
+
375
+ ```
376
+ For each setting field (envFiles, cascade, envPath, envKeysFile, override, quiet, …):
377
+
378
+ 1. Did the CLI flag — or programmatic arg — set this field? → use that, stop
379
+ 2. Is it set in the resolved config file? → use that, stop
380
+ 3. Otherwise → use the built-in default
381
+ ```
382
+
383
+ So you can do this without thinking about layer order:
384
+
385
+ ```sh
386
+ # Config provides envFiles; CLI overrides only `cascade`.
387
+ # Effective settings: envFiles=[".env","vault/.env.prod"], cascade="prod", everything else default.
388
+ envx --cascade prod -- node app.js
389
+ ```
390
+
391
+ with
392
+
393
+ ```ts
394
+ // envx.config.ts
395
+ export default {
396
+ envFiles: [".env", "vault/.env.prod"],
397
+ }
398
+ ```
399
+
400
+ The CLI flag for `cascade` wins for that field; `envFiles` falls through to the config; everything else falls through to defaults.
401
+
402
+ #### Variable-value precedence (after files load)
403
+
404
+ Once envx has resolved settings and loaded the env files, the actual values that end up in `process.env` follow a different order — this is dotenv's contract, with two envx-specific tweaks:
405
+
406
+ ```
407
+ Final value of process.env.SOME_KEY is the FIRST hit, top-down:
408
+
409
+ 1. `-v SOME_KEY=…` passed on the CLI (always wins)
410
+ 2. Existing process.env.SOME_KEY when --override=false (the default)
411
+ OR, when --override=true:
412
+ The most-specific env file's value for SOME_KEY (cascade-wise)
413
+ 3. The next-most-specific env file's value (cascade order)
414
+ 4. … all the way down the cascade
415
+ 5. unset
416
+ ```
417
+
418
+ `-v` is **not** a config layer — it's a post-load mutation of `process.env`. It runs after every env file has been loaded, and it unconditionally overwrites whatever was there. Rule of thumb:
419
+
420
+ - Need to inject one ad-hoc value? `-v KEY=VALUE`.
421
+ - Need to swap a whole environment? `--env`, `--cascade`, or `envFiles` in config.
422
+
423
+ #### `--override` flips one thing only
424
+
425
+ `--override=false` (the default) means **existing `process.env` wins** over what's in the env files. This matches dotenv: if your shell already exports `DATABASE_URL`, env files won't clobber it.
426
+
427
+ `--override=true` means **env files win** over existing `process.env`. Useful when you want a config-driven environment to be authoritative regardless of what's in the parent shell. It's incompatible with `--cascade` (envx exits with an error) — within a cascade chain, the *most-specific* file wins anyway, which is the only ordering that makes sense.
428
+
429
+ ### Config-file shape
430
+
431
+ ```ts
432
+ // envx.config.ts
433
+ export default {
434
+ // ── env-file selection ─────────────────────────────────
435
+ envFiles: [".env", ".env.local"], // bypass auto-detect; explicit list
436
+ envPath: "vault", // workspace-relative subdir (cwd-first, ws-root fallback)
437
+ cascade: true, // boolean: enable layered cascade using the auto-detected env name
438
+ // (CLI `--cascade <name>` still accepts an explicit string and overrides)
439
+ variables: ["DEBUG=1"], // KEY=VALUE overrides applied after load
440
+
441
+ // ── encryption keys ────────────────────────────────────
442
+ envKeysFile: ".env.keys", // overrides the cwd-first / workspace fallback default
443
+
444
+ // ── load behavior ──────────────────────────────────────
445
+ override: false, // false: existing process.env wins
446
+ // true: env files win (incompatible with cascade)
447
+ quiet: true, // suppress dotenv's load-line output
448
+
449
+ // ── auto-detection ─────────────────────────────────────
450
+ autoDetect: true, // false disables Vercel/Netlify/NODE_ENV detection
451
+ nodeEnvMap: { // override the NODE_ENV → suffix mapping
452
+ production: "prod", // (built-ins shown — yours layer on top)
453
+ development: "dev",
454
+ local: "local",
455
+ qa: "qa-prod", // NODE_ENV=qa → .env.qa-prod
456
+ preview: "", // NODE_ENV=preview → .env (empty = no suffix)
457
+ },
458
+
459
+ // ── post-load behavior ─────────────────────────────────
460
+ required: ["DATABASE_URL", "API_KEY"], // fail-fast: exit 1 if any are unset after load
461
+ expand: true, // auto-resolve ${VAR} references against process.env
462
+ defaults: { // applied AFTER files + variables, only for unset keys
463
+ LOG_LEVEL: "info",
464
+ PORT: "3000",
465
+ },
466
+
467
+ // ── advanced ───────────────────────────────────────────
468
+ workspaceRoot: "/abs/path/to/repo", // escape hatch — skip findWorkspaceRoot() walk-up
469
+ }
470
+ ```
471
+
472
+ All fields are optional. Anything you omit falls through to the built-in default for that field.
473
+
474
+ ### Discovery order
475
+
476
+ When `--config <path>` is not passed, envx walks up looking for a config in this order:
477
+
478
+ 1. `<cwd>/envx.config.{ts,js,json,mjs,cjs}` — first hit wins
479
+ 2. `<cwd>/package.json#envx.config: "<path>"` — the string is resolved relative to that `package.json`'s directory
480
+ 3. Each parent directory, up to the workspace root, repeating steps 1 & 2
481
+
482
+ `package.json#envx.config` only accepts a **string path** (not an inline object), so the config is always a single file you can lint, review, and version-control independently from the manifest.
483
+
484
+ ### Programmatic customization
485
+
486
+ Every config field maps 1:1 onto the options accepted by the `envx()` programmatic entry point:
487
+
488
+ ```ts
489
+ import envx from "@super-repo/envx";
490
+
491
+ // Same as `envx --env .env.prod --env-path vault --override`:
492
+ envx({
493
+ envFiles: [".env.prod"],
494
+ envPath: "vault",
495
+ override: true,
496
+ });
497
+
498
+ // String shorthand sets `cascade`:
499
+ envx("staging"); // == envx({ cascade: "staging" })
500
+ ```
501
+
502
+ Programmatic args **override** anything from the discovered config; the config supplies fields you didn't pass.
503
+
504
+ ## Further reading
505
+
506
+ Expanded explanations, worked examples, and recipes ship with the package in [`./docs/`](./docs/):
507
+
508
+ - **[Auto-detection](./docs/auto-detection.md)** — every `VERCEL_ENV` / `CONTEXT` / `NODE_ENV` shape walked through with the file that ends up loading.
509
+ - **[Configuration](./docs/configuration.md)** — full schema, the per-field merge rules, where each field gets read from, and the discovery walk.
510
+ - **[Monorepo recipes](./docs/recipes.md)** — common layouts (single workspace `.env.keys`, vault subdir, per-app overrides, CI patterns) with the exact files and commands.
134
511
 
135
512
  ## Embedding the CLI
136
513
 
137
- The yargs factory is exported under the `./commands` subpath if you
138
- want to register `envx`'s commands inside your own tool:
514
+ The yargs factory is exported under the `./commands` subpath if you want to register envx's commands inside your own tool:
139
515
 
140
516
  ```ts
141
- import { createCli } from "@honeycluster/envx/commands";
517
+ import { createCli } from "@super-repo/envx/commands";
142
518
 
143
519
  createCli(process.argv.slice(2)).parseSync();
144
520
  ```
@@ -146,49 +522,48 @@ createCli(process.argv.slice(2)).parseSync();
146
522
  ## Development
147
523
 
148
524
  ```sh
149
- # From the package root
150
525
  pnpm install
151
- pnpm build # tsc + tsc-alias → dist/
152
- pnpm dev # tsc --watch
526
+ pnpm build # vite library build → dist/
153
527
  pnpm test # vitest unit tests
154
528
  pnpm test:integration # spawn-based subprocess tests
155
- pnpm typecheck # tsc --noEmit
156
- pnpm format # prettier --write src/
157
529
  ```
158
530
 
159
- Tests live in `tests/`. The vitest config is at
160
- `configs/vitest.config.ts`; the `test` script invokes vitest with
161
- `--config` so the layout stays explicit. Integration fixtures live in
162
- `tests/integration/__static__/` — each subdirectory is a self-contained
163
- worked example (read the file content for the canonical demonstration
164
- of every feature).
531
+ Tests live in `tests/`. Integration fixtures live in `tests/integration/__static__/` — each subdirectory is a self-contained worked example.
165
532
 
166
533
  ## Layout
167
534
 
168
535
  ```
169
- packages/core/
536
+ packages/envx/core/
170
537
  ├── src/
171
- │ ├── index.ts # default export: envx() programmatic API
172
- │ ├── auto.ts # `import "@honeycluster/envx/auto"` side-effect entry
173
- │ ├── cli.ts # `envx` bin entry — wires yargs + parses argv
538
+ │ ├── index.ts # default export: envx() programmatic API
539
+ │ ├── auto.ts # `import "@super-repo/envx/auto"` side-effect entry
540
+ │ ├── cli.ts # `envx` bin entry — wires yargs + parses argv
174
541
  │ ├── bin/
175
- │ │ └── dotenvx.ts # `dotenvx-proxy` legacy passthrough
542
+ │ │ └── dotenvx.ts # `dotenvx-proxy` direct passthrough to upstream
176
543
  │ └── commands/
177
- │ ├── index.ts # createCli — registers subcommands + global options
178
- │ ├── run.ts # `run [command..]`
179
- │ ├── print.ts # `print <variable>`
180
- │ ├── debug.ts # `debug`
181
- │ ├── encrypt.ts # `encrypt`
182
- │ ├── decrypt.ts # `decrypt`
183
- └── expand.ts # `expand`
544
+ │ ├── index.ts # createCli — registers subcommands + global options
545
+ │ ├── run.ts # `run [command..]`
546
+ │ ├── print.ts # `print <variable>`
547
+ │ ├── debug.ts # `debug`
548
+ │ ├── encrypt.ts # `encrypt`
549
+ │ ├── decrypt.ts # `decrypt`
550
+ ├── rotate.ts # `rotate`
551
+ │ └── expand.ts # `expand`
184
552
  ├── tests/
185
- │ ├── *.test.ts # unit suites (env, factory, programmatic API)
553
+ │ ├── *.test.ts # unit suites
186
554
  │ └── integration/
187
- │ ├── cli.test.ts # subprocess end-to-end suite
188
- │ └── __static__/ # read-only fixtures (cp'd to tmp per test)
555
+ │ ├── cli.test.ts # subprocess end-to-end suite
556
+ │ └── __static__/ # read-only fixtures (cp'd to tmp per test)
189
557
  ├── configs/
190
558
  │ ├── tsconfig.build.json
559
+ │ ├── vite.config.ts # bundles workspace-private envx-libs/common helpers
191
560
  │ └── vitest.config.ts
192
561
  ├── tsconfig.json
193
562
  └── package.json
194
563
  ```
564
+
565
+ The `envx-libs` and `envx-common` workspace packages are bundled into the published `dist/` via Vite, so this is the only npm package consumers need to install.
566
+
567
+ ## License
568
+
569
+ MIT — same as `@dotenvx/dotenvx`. We're grateful for their work; please go support [`dotenvx.com`](https://dotenvx.com) if you build single-app stuff.