@super-repo/envx 0.2.3-b.2 → 0.2.3-b.4
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 +970 -104
- package/dist/auto.js.map +1 -1
- package/dist/chunks/commands-D3eQPQO6.js +1502 -0
- package/dist/chunks/commands-D3eQPQO6.js.map +1 -0
- package/dist/chunks/{src-CDuEfaCY.js → src-D0n2wHDg.js} +0 -0
- package/dist/chunks/src-D0n2wHDg.js.map +1 -0
- package/dist/cli.js +1 -1
- package/dist/commands/audit.d.ts +13 -0
- package/dist/commands/audit.d.ts.map +1 -0
- package/dist/commands/bake.d.ts +18 -0
- package/dist/commands/bake.d.ts.map +1 -0
- package/dist/commands/diff.d.ts +16 -0
- package/dist/commands/diff.d.ts.map +1 -0
- package/dist/commands/doctor.d.ts +16 -0
- package/dist/commands/doctor.d.ts.map +1 -0
- package/dist/commands/encrypt.d.ts.map +1 -1
- package/dist/commands/hook.d.ts +18 -0
- package/dist/commands/hook.d.ts.map +1 -0
- package/dist/commands/index.d.ts.map +1 -1
- package/dist/commands/index.js +1 -1
- package/dist/commands/info.d.ts +10 -0
- package/dist/commands/info.d.ts.map +1 -0
- package/dist/commands/rotate.d.ts +13 -0
- package/dist/commands/rotate.d.ts.map +1 -0
- package/dist/commands/run.d.ts.map +1 -1
- package/dist/commands/template.d.ts +13 -0
- package/dist/commands/template.d.ts.map +1 -0
- package/dist/commands/types.d.ts +14 -0
- package/dist/commands/types.d.ts.map +1 -0
- package/dist/commands/watch.d.ts +14 -0
- package/dist/commands/watch.d.ts.map +1 -0
- package/dist/index.d.ts +16 -4
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +57 -28
- package/dist/index.js.map +1 -1
- package/docs/auto-detection.md +217 -0
- package/docs/configuration.md +224 -0
- package/docs/recipes.md +234 -0
- package/package.json +6 -4
- package/dist/chunks/commands-B8vc6UKO.js +0 -354
- package/dist/chunks/commands-B8vc6UKO.js.map +0 -1
- package/dist/chunks/src-CDuEfaCY.js.map +0 -1
package/README.md
CHANGED
|
@@ -1,24 +1,86 @@
|
|
|
1
|
-
# @
|
|
1
|
+
# @super-repo/envx
|
|
2
2
|
|
|
3
|
-
|
|
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 @
|
|
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
|
+
| **Health check** | Not built in | `envx doctor` — workspace, config, keys file, decryptability, required, expand cycles |
|
|
58
|
+
| **`.env.example` sync** | Not built in | `envx template` (and `--check` for CI drift detection) |
|
|
59
|
+
| **Diff between env files** | Not built in | `envx diff` — keys-only-in-a/b + value mismatches (decrypts via `.env.keys`) |
|
|
60
|
+
| **Shell injection** | Not built in | `envx hook bash|zsh|fish|powershell` — eval-able exports for the parent shell |
|
|
61
|
+
| **Typed `process.env`** | Not built in | `envx types` — emits `env.d.ts` from loaded keys (Zod-shape-aware) |
|
|
62
|
+
| **Watch mode** | Not built in | `envx watch -- node app.js` — restart on `.env*` change |
|
|
63
|
+
| **Repo-wide secret audit** | Not built in | `envx audit` — AKIA, ghp_, sk_live_, JWT, OpenAI/Anthropic keys, PEM blocks, … |
|
|
64
|
+
| **Selective encryption** | Per-key flag (`-k`) | Per-key, glob patterns (`--pattern`), and `--secrets` preset |
|
|
65
|
+
| **Schema validation (Zod)** | Not built in | `schema:` field in config — duck-typed `safeParse()` with fail-fast exit |
|
|
66
|
+
| **Named profiles** | Not built in | `profiles: { staging: { ... } }` + `--profile staging` (per-field override) |
|
|
67
|
+
| **External secret refs** | Not built in | `${aws-secrets:my-id}` resolved by user-registered `resolvers:` callbacks |
|
|
68
|
+
| **Secret-provider plugins** | Not built in | `@super-repo/envx-plugins` ships AWS, GCP, Azure, Vault, 1Password, Doppler, Infisical |
|
|
69
|
+
| **Public-variable mirroring** | Not built in | `PUBLIC_*` vars auto-mirror under `VITE_`, `NEXT_PUBLIC_`, `REACT_APP_`, etc. |
|
|
70
|
+
| **Build-time secret bake** | Not built in | `envx bake` resolves all refs into `.env.resolved` for bundlers (Vite/Next/esbuild) |
|
|
71
|
+
| **Runtime / edge secrets** | Not built in | `createSecretRuntime()` — async, TTL-cached, V8-isolate-safe (CF Workers, Vercel Edge) |
|
|
72
|
+
| **License** | BSD-3-Clause | MIT |
|
|
73
|
+
|
|
74
|
+
In short: same crypto, same file formats, same CLI vocabulary — different path-resolution layer underneath.
|
|
75
|
+
|
|
13
76
|
## Programmatic API
|
|
14
77
|
|
|
15
|
-
### `import envx from "@
|
|
78
|
+
### `import envx from "@super-repo/envx"`
|
|
16
79
|
|
|
17
80
|
```ts
|
|
18
|
-
import envx from "@
|
|
81
|
+
import envx from "@super-repo/envx";
|
|
19
82
|
|
|
20
83
|
// Three call shapes — all mutate process.env and return it for ergonomics.
|
|
21
|
-
|
|
22
84
|
const env = envx(); // load .env (auto-detect from NODE_ENV / VERCEL_ENV / NETLIFY)
|
|
23
85
|
const env = envx("prod"); // cascade-load .env, .env.prod, .env.local, .env.prod.local
|
|
24
86
|
const env = envx({ // full options
|
|
@@ -30,56 +92,90 @@ const env = envx({ // full options
|
|
|
30
92
|
});
|
|
31
93
|
```
|
|
32
94
|
|
|
33
|
-
The string form (`envx("prod")`) is shorthand for "scope this load to
|
|
34
|
-
|
|
35
|
-
|
|
95
|
+
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`.
|
|
96
|
+
|
|
97
|
+
`envx()` discovers `envx.config.{ts,js,json}` and `package.json#envx.config` automatically; explicit args override matching fields from the config.
|
|
36
98
|
|
|
37
|
-
|
|
38
|
-
`envx.config` automatically; the explicit args you pass override
|
|
39
|
-
matching fields from the config. See [`docs/CONFIG.md`](../../docs/CONFIG.md).
|
|
99
|
+
#### Programmatic options — full surface
|
|
40
100
|
|
|
41
|
-
|
|
101
|
+
`envx()` accepts everything from `LoadEnvOptions` plus an optional `profile` string. The full shape (`EnvxProgrammaticOptions`):
|
|
42
102
|
|
|
43
103
|
```ts
|
|
44
|
-
|
|
45
|
-
import "@honeycluster/envx/auto";
|
|
104
|
+
import envx, { type EnvxProgrammaticOptions } from "@super-repo/envx";
|
|
46
105
|
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
106
|
+
const opts: EnvxProgrammaticOptions = {
|
|
107
|
+
// env-file selection
|
|
108
|
+
envFiles: ["vault/.env.prod"],
|
|
109
|
+
envPath: "vault",
|
|
110
|
+
cascade: "prod", // string for explicit, true for auto-detected, false to disable
|
|
111
|
+
vault: false, // shortcut for envPath: "vault"
|
|
112
|
+
|
|
113
|
+
// load behavior
|
|
114
|
+
override: false,
|
|
115
|
+
quiet: true,
|
|
116
|
+
variables: ["DEBUG=1"], // post-load mutation; always wins
|
|
117
|
+
|
|
118
|
+
// auto-detection
|
|
119
|
+
autoDetect: true,
|
|
120
|
+
nodeEnvMap: { qa: "qa-prod" },
|
|
121
|
+
|
|
122
|
+
// post-load behavior
|
|
123
|
+
required: ["DATABASE_URL"], // exit 1 if any is unset after load
|
|
124
|
+
expand: true, // resolve ${VAR} references
|
|
125
|
+
defaults: { LOG_LEVEL: "info" }, // fill holes; never overwrite
|
|
50
126
|
|
|
51
|
-
|
|
52
|
-
|
|
127
|
+
// validation + plug-ins
|
|
128
|
+
schema: zodSchema, // any { safeParse(input) → { success, error?, data? } }
|
|
129
|
+
resolvers: { // ${provider:id} → resolver(id)
|
|
130
|
+
"aws-secrets": (id) => fetchSecret(id),
|
|
131
|
+
},
|
|
53
132
|
|
|
54
|
-
|
|
133
|
+
// profile selection (programmatic equivalent of --profile)
|
|
134
|
+
profile: "staging", // throws if config.profiles[name] is missing
|
|
55
135
|
|
|
56
|
-
|
|
57
|
-
|
|
136
|
+
// advanced
|
|
137
|
+
workspaceRoot: "/abs/path", // skip findWorkspaceRoot() walk-up
|
|
138
|
+
};
|
|
139
|
+
|
|
140
|
+
envx(opts);
|
|
141
|
+
```
|
|
142
|
+
|
|
143
|
+
Caller-supplied fields override matching fields from the discovered config **per-field** — anything you omit falls through to config, then to defaults.
|
|
144
|
+
|
|
145
|
+
### Side-effect import
|
|
58
146
|
|
|
59
147
|
```ts
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
resolveEnvPaths,
|
|
66
|
-
encryptFiles,
|
|
67
|
-
decryptFiles,
|
|
68
|
-
expandEnvSrc,
|
|
69
|
-
loadDotenvxConfig,
|
|
70
|
-
} from "@honeycluster/libs";
|
|
148
|
+
// In your app's entry file:
|
|
149
|
+
import "@super-repo/envx/auto";
|
|
150
|
+
|
|
151
|
+
// envx() has already run by the time this line executes.
|
|
152
|
+
const env = process.env;
|
|
71
153
|
```
|
|
72
154
|
|
|
155
|
+
`@super-repo/envx/config` is an alias of `/auto` (matches the `dotenv/config` convention).
|
|
156
|
+
|
|
73
157
|
## CLI
|
|
74
158
|
|
|
75
159
|
```sh
|
|
76
|
-
envx -- node app.js
|
|
77
|
-
envx --env dev -- pnpm start
|
|
78
|
-
envx
|
|
79
|
-
envx
|
|
80
|
-
envx
|
|
81
|
-
envx
|
|
82
|
-
envx
|
|
160
|
+
envx -- node app.js # load .env (auto-detected) and run
|
|
161
|
+
envx --env dev -- pnpm start # load .env.dev
|
|
162
|
+
envx --dir vault -- pnpm test # load every .env* in vault/
|
|
163
|
+
envx print DATABASE_URL # print one variable
|
|
164
|
+
envx debug --cascade prod # show resolved paths
|
|
165
|
+
envx encrypt -e .env.prod # encrypt values in .env.prod (writes ./.env.keys)
|
|
166
|
+
envx encrypt --secrets # encrypt only conventional secret keys (*_SECRET, *_TOKEN, …)
|
|
167
|
+
envx decrypt -e .env.prod -k FOO # decrypt only FOO
|
|
168
|
+
envx rotate --dir vault # rotate keypair for every vault/.env* file
|
|
169
|
+
envx expand -e vault/.env.prod # decrypt + expand to stdout
|
|
170
|
+
envx doctor # health check (workspace, keys, decryptability, required, cycles)
|
|
171
|
+
envx template # write .env.example with values stripped
|
|
172
|
+
envx template --check # CI gate — exit 1 on .env.example drift
|
|
173
|
+
envx diff .env.dev .env.prod # show keys-only-in-a/b + value mismatches
|
|
174
|
+
eval "$(envx hook bash)" # inject loaded env into the parent shell
|
|
175
|
+
envx types # emit env.d.ts for typed process.env access
|
|
176
|
+
envx watch -- node app.js # restart on .env* change
|
|
177
|
+
envx audit # scan repo for plaintext secrets
|
|
178
|
+
envx --profile staging -- node app.js # apply named profile from envx.config.*
|
|
83
179
|
envx -c ./envx.config.json run -- node app.js
|
|
84
180
|
```
|
|
85
181
|
|
|
@@ -88,57 +184,820 @@ envx -c ./envx.config.json run -- node app.js
|
|
|
88
184
|
| bin | source | what it does |
|
|
89
185
|
| ---------------- | ---------------------------- | ----------------------------------------------- |
|
|
90
186
|
| `envx` | `dist/cli.js` | This package's CLI (subcommands below) |
|
|
91
|
-
| `dotenvx-proxy` | `dist/bin/dotenvx.js` |
|
|
187
|
+
| `dotenvx-proxy` | `dist/bin/dotenvx.js` | Direct passthrough to upstream `@dotenvx/dotenvx` |
|
|
92
188
|
|
|
93
189
|
### Subcommands
|
|
94
190
|
|
|
191
|
+
#### Core
|
|
192
|
+
|
|
95
193
|
| command | what it does |
|
|
96
194
|
| --------- | -------------------------------------------------------------------------------------- |
|
|
97
195
|
| `run` | (default) load env files into `process.env` and execute a command |
|
|
98
196
|
| `print` | load env files and print a single variable's value |
|
|
99
197
|
| `debug` | show which files would be loaded without loading them |
|
|
100
|
-
| `encrypt` | encrypt values in one or more `.env*` files (writes `.env.keys` on first run)
|
|
198
|
+
| `encrypt` | encrypt values in one or more `.env*` files (writes `.env.keys` on first run). Selective: `--key`, `--pattern '*_SECRET,*_TOKEN'`, `--secrets` preset |
|
|
101
199
|
| `decrypt` | decrypt values back in place |
|
|
200
|
+
| `rotate` | generate a fresh keypair and re-encrypt every encrypted value in place |
|
|
201
|
+
| `info` | print resolved configuration, auto-detection details, NODE_ENV mappings, and the env files that would load |
|
|
102
202
|
| `expand` | decrypt (if needed) and expand `${VAR}` / `$VAR` / `${VAR:-default}` / `${VAR:?msg}` |
|
|
103
203
|
|
|
204
|
+
#### Workflow + DX
|
|
205
|
+
|
|
206
|
+
| command | what it does |
|
|
207
|
+
| ------------- | ---------------------------------------------------------------------------------------------------------------- |
|
|
208
|
+
| `doctor` | run health checks: workspace + config visible, env files exist, keys file present, every encrypted value decrypts, `required` keys filled after a dry load, expansion has no cycles. Exits non-zero on hard failures. CI-friendly. |
|
|
209
|
+
| `template` | emit a value-stripped `.env.example` from your real env files. `--check` mode exits 1 on drift; `--stdout` prints to stdout; `--annotate` (default on) tags each key with the source file. |
|
|
210
|
+
| `diff <a> <b>` | compare two env files key-by-key. Reports keys-only-in-a, keys-only-in-b, and value mismatches. Decrypts via `.env.keys` so the comparison reflects logical values; values are masked unless `--show-values`. |
|
|
211
|
+
| `hook <shell>` | print eval-able shell exports for the loaded env. Supports `bash`, `zsh`, `fish`, `powershell`/`pwsh` with proper escaping. Use `eval "$(envx hook bash)"` to inject env into the parent shell. |
|
|
212
|
+
| `types` | emit an `env.d.ts` declaring every loaded key on `NodeJS.ProcessEnv`. Keys listed in `required` (or non-optional in a Zod schema) narrow to `string`; everything else is `string \| undefined`. |
|
|
213
|
+
| `watch` | spawn a command and restart it whenever any of the resolved env files change. Debounce + SIGTERM-then-SIGKILL grace for clean restarts. |
|
|
214
|
+
| `audit` | walk the repo looking for plaintext secrets — AWS keys, GitHub PATs, Stripe live/test keys, JWTs, OpenAI / Anthropic keys, Google API keys, Slack tokens, PEM private-key blocks. Findings are redacted; exit 1 on any hit. `--json` for downstream tools. |
|
|
215
|
+
| `bake` | resolve every `${{provider:id}}` ref + decrypt every encrypted value + run defaults / expand / schema / public-mirror, then write a sealed `.env.resolved` for build tools to consume. Output is plaintext — refuses to write under `.git/`; use `--public-only` for client bundles. `--json` for structured tools, `--out=-` for stdout. |
|
|
216
|
+
|
|
104
217
|
### Global options
|
|
105
218
|
|
|
106
|
-
| option
|
|
107
|
-
|
|
|
108
|
-
| `--config`
|
|
109
|
-
| `--env`
|
|
110
|
-
| `--env-path` |
|
|
111
|
-
| `--vault`
|
|
112
|
-
| `--
|
|
113
|
-
| `--
|
|
114
|
-
| `--
|
|
115
|
-
| `--
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
|
130
|
-
|
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
219
|
+
| option | alias | default | description |
|
|
220
|
+
| ----------------- | ---------------- | -------------------- | ---------------------------------------------------------------------------------------- |
|
|
221
|
+
| `--config` | `-c` | _(auto)_ | Path to an `envx.config.{ts,js,json}`. Discovery: flag → `package.json#envx.config` → walk-up auto-discovery. |
|
|
222
|
+
| `--env` | `-e` | `[".env"]` | Files to load (repeatable). Auto-discovered from `--env-path` when omitted. |
|
|
223
|
+
| `--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. |
|
|
224
|
+
| `--vault` | `--va` | `false` | Shortcut for `--env-path vault`. |
|
|
225
|
+
| `--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. |
|
|
226
|
+
| `--variables` | `-v` | `[]` | Inline `name=value` overrides (repeatable). |
|
|
227
|
+
| `--cascade` | | _(off)_ | Cascade name; expands `.env`, `.env.<c>`, `.env.local`, `.env.<c>.local`. |
|
|
228
|
+
| `--override` | `-o` | `false` | Override existing `process.env` values. Conflicts with `--cascade`. |
|
|
229
|
+
| `--quiet` | `-q` | `true` | Suppress dotenv's own output. |
|
|
230
|
+
| `--profile` | `-P` | _(none)_ | Named profile from `profiles` in `envx.config.*` — fields override the base config per-field. |
|
|
231
|
+
|
|
232
|
+
### Defaults
|
|
233
|
+
|
|
234
|
+
Six resolution rules drive every envx invocation. Each is overridable — see [Configuration](#configuration) below for the layered customization map.
|
|
235
|
+
|
|
236
|
+
#### 1. Environment auto-detection
|
|
237
|
+
|
|
238
|
+
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()`.
|
|
239
|
+
|
|
240
|
+
Order of precedence: **Vercel → Netlify → `NODE_ENV` → unsuffixed**.
|
|
241
|
+
|
|
242
|
+
| signal | detected | env file picked |
|
|
243
|
+
| ------------------------------------------------------- | ---------- | --------------- |
|
|
244
|
+
| `VERCEL` set + `VERCEL_ENV=production` | `prod` | `.env.prod` |
|
|
245
|
+
| `VERCEL` set + any other `VERCEL_ENV` (or unset) | `dev` | `.env.dev` |
|
|
246
|
+
| `NETLIFY` set + `CONTEXT=production` | `prod` | `.env.prod` |
|
|
247
|
+
| `NETLIFY` set + `CONTEXT=deploy-preview` / `branch-deploy` | `dev` | `.env.dev` |
|
|
248
|
+
| `NETLIFY` set + any other `CONTEXT` | `dev` | `.env.dev` |
|
|
249
|
+
| `NODE_ENV=production` | `prod` | `.env.prod` |
|
|
250
|
+
| `NODE_ENV=development` | `dev` | `.env.dev` |
|
|
251
|
+
| `NODE_ENV=local` | `local` | `.env.local` |
|
|
252
|
+
| `NODE_ENV=<other>` (e.g. `staging`, `qa`, `preview`) | `<other>` | `.env.<other>` |
|
|
253
|
+
| _no signals_ | `root` | `.env` |
|
|
254
|
+
|
|
255
|
+
`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.
|
|
256
|
+
|
|
257
|
+
**Customize:**
|
|
258
|
+
- Pass `--env <name>` (or `-e <name>`) to bypass detection entirely; bare names get the `.env.` prefix automatically (`--env staging` → `.env.staging`).
|
|
259
|
+
- Disable detection: set `autoDetect: false` in `envx.config.{ts,js,json}`.
|
|
260
|
+
- Override the `NODE_ENV → suffix` mapping in config:
|
|
261
|
+
```ts
|
|
262
|
+
// envx.config.ts
|
|
263
|
+
export default {
|
|
264
|
+
nodeEnvMap: {
|
|
265
|
+
// built-ins keep working unless you override them:
|
|
266
|
+
production: "prod",
|
|
267
|
+
development: "dev",
|
|
268
|
+
local: "local",
|
|
269
|
+
// your additions / remappings:
|
|
270
|
+
qa: "qa-prod", // NODE_ENV=qa → .env.qa-prod
|
|
271
|
+
preview: "", // NODE_ENV=preview → .env (no suffix)
|
|
272
|
+
},
|
|
273
|
+
}
|
|
274
|
+
```
|
|
275
|
+
- Programmatic: `envx({ envFiles: [...] })`, `envx("staging")` (sets `cascade`), or `envx({ autoDetect: false, nodeEnvMap: { ... } })`.
|
|
276
|
+
|
|
277
|
+
Run **`envx info`** to print the resolved settings, the detected environment, the active mapping, and which files would be loaded.
|
|
278
|
+
|
|
279
|
+
#### 2. Cascade expansion order
|
|
280
|
+
|
|
281
|
+
`--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()`.
|
|
282
|
+
|
|
283
|
+
For a base of `.env` and `--cascade prod`:
|
|
284
|
+
|
|
285
|
+
| order | path | role |
|
|
286
|
+
| :---: | ----------------- | --------------------------------- |
|
|
287
|
+
| 1 | `.env.prod.local` | per-developer prod override (gitignored) |
|
|
288
|
+
| 2 | `.env.local` | per-developer override (gitignored) |
|
|
289
|
+
| 3 | `.env.prod` | shared prod values (committable, encryptable) |
|
|
290
|
+
| 4 | `.env` | shared baseline |
|
|
291
|
+
|
|
292
|
+
Without `--cascade`: `.env.local` → `.env` (still least-specific-last).
|
|
293
|
+
|
|
294
|
+
**Customize:**
|
|
295
|
+
- **CLI**: pass `--cascade <name>` to cascade with an explicit env name (e.g. `--cascade prod`).
|
|
296
|
+
- **Config**: set `cascade: true` to cascade using the auto-detected env name. `cascade: false` (or omit) disables.
|
|
297
|
+
- **Programmatic**: `envx({ cascade: true })` for auto-detected, `envx({ cascade: "prod" })` for explicit, `envx("prod")` (string shorthand) is equivalent to the explicit form.
|
|
298
|
+
- `--override` conflicts with cascade — envx exits with an error if both are set.
|
|
299
|
+
|
|
300
|
+
#### 3. Path resolution (cwd-first → workspace-root fallback)
|
|
301
|
+
|
|
302
|
+
Every relative path passed to `--dir`, `--env-path`, `--env-keys-file`, or set in config goes through the same resolver. Source-of-truth: `resolveCwdOrWorkspace()`.
|
|
303
|
+
|
|
304
|
+
```
|
|
305
|
+
absolute path? → use verbatim
|
|
306
|
+
<cwd>/<rel> exists? → use that
|
|
307
|
+
<workspaceRoot>/<rel>? → use that
|
|
308
|
+
neither? → return <cwd>/<rel> so callers (encrypt, etc.) can create it
|
|
309
|
+
```
|
|
310
|
+
|
|
311
|
+
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.
|
|
312
|
+
|
|
313
|
+
**Customize:**
|
|
314
|
+
- Pass an absolute path to `-fk` / `--dir` to skip the walk-up entirely.
|
|
315
|
+
- Set `envKeysFile` / `envPath` in `envx.config.{ts,js,json}`.
|
|
316
|
+
- Programmatic: `envx({ envPath: "vault", envKeysFile: "/abs/path/.env.keys" })`.
|
|
317
|
+
|
|
318
|
+
#### 4. Workspace root detection
|
|
319
|
+
|
|
320
|
+
`findWorkspaceRoot(startDir)` walks up from `startDir` and returns the first directory that matches one of these signals, in order:
|
|
321
|
+
|
|
322
|
+
| pass | signal |
|
|
323
|
+
| :--: | ------------------------------------------------------------ |
|
|
324
|
+
| 1 | `package.json` with a `workspaces` field (npm/yarn) |
|
|
325
|
+
| 1 | `package.json` with a `pnpm.workspaces` field |
|
|
326
|
+
| 2 | `pnpm-workspace.yaml` |
|
|
327
|
+
| 2 | `lerna.json` |
|
|
328
|
+
| 2 | `nx.json` |
|
|
329
|
+
| 2 | `rush.json` |
|
|
330
|
+
| 2 | `yarn.lock` |
|
|
331
|
+
| 2 | `pnpm-lock.yaml` |
|
|
332
|
+
|
|
333
|
+
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).
|
|
334
|
+
|
|
335
|
+
**Customize:**
|
|
336
|
+
- 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.
|
|
337
|
+
|
|
338
|
+
#### 5. Default `--env` / `--env-path` behavior
|
|
339
|
+
|
|
340
|
+
| condition | resolved env files |
|
|
341
|
+
| ------------------------------------------------------ | --------------------------------------------------------------- |
|
|
342
|
+
| `--env` explicitly passed | exactly the files passed (with `.env.` prefix added to bare names) |
|
|
343
|
+
| `envFiles` set in config | the config's list |
|
|
344
|
+
| `--env-path <dir>` set, no `--env` | every `.env*` file inside `<dir>` (sorted, excluding `.env.keys`) |
|
|
345
|
+
| neither set | `[".env"]` (with auto-detection from rule #1) |
|
|
346
|
+
|
|
347
|
+
`--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.
|
|
348
|
+
|
|
349
|
+
**Customize:**
|
|
350
|
+
- `--env <file>` (repeatable) — explicit list.
|
|
351
|
+
- `--env-path <dir>` (or `--dir`, `-d`) — pull every `.env*` from a directory.
|
|
352
|
+
- `envFiles` / `envPath` in config.
|
|
353
|
+
- Programmatic: `envx({ envFiles: [...], envPath: "..." })`.
|
|
354
|
+
|
|
355
|
+
#### 6. CLI variable overrides
|
|
356
|
+
|
|
357
|
+
`-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()`.
|
|
358
|
+
|
|
359
|
+
**Customize:**
|
|
360
|
+
- `-v KEY=VALUE` per-call.
|
|
361
|
+
- Programmatic: `envx({ variables: ["KEY=VALUE", ...] })`.
|
|
362
|
+
|
|
363
|
+
## Encryption
|
|
364
|
+
|
|
365
|
+
envx uses **asymmetric (ECIES) encryption by default** — exactly the same scheme as `dotenvx`. On first encrypt of a fresh `.env*` file:
|
|
366
|
+
|
|
367
|
+
1. A fresh secp256k1 keypair is generated.
|
|
368
|
+
2. The public key gets prepended to the env file as a banner + `ENVX_PUBLIC_KEY*` header (safe to commit).
|
|
369
|
+
3. The matching private key gets written to `.env.keys` (gitignore this) under `ENVX_PRIVATE_KEY*` with a section banner per env file.
|
|
370
|
+
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.
|
|
371
|
+
|
|
372
|
+
A typical encrypted `.env.dev`:
|
|
373
|
+
|
|
374
|
+
```
|
|
375
|
+
#/-------------------[ENVX_PUBLIC_KEY]----------------------/
|
|
376
|
+
#/ public-key encryption for .env files /
|
|
377
|
+
#/ [how it works](https://dotenvx.com/encryption) /
|
|
378
|
+
#/----------------------------------------------------------/
|
|
379
|
+
ENVX_PUBLIC_KEY_DEV="0266287313a6f4d107115d5963640830fcca378ae34a5e3dbf775122fd121f7084"
|
|
380
|
+
|
|
381
|
+
DATABASE_URL=encrypted:Bf3p…
|
|
382
|
+
API_KEY=encrypted:7nQ2…
|
|
383
|
+
```
|
|
384
|
+
|
|
385
|
+
…and the matching `.env.keys` (gitignored):
|
|
386
|
+
|
|
387
|
+
```
|
|
388
|
+
#/------------------!ENVX_PRIVATE_KEYS!---------------------/
|
|
389
|
+
#/ private decryption keys. DO NOT commit to source control /
|
|
390
|
+
#/ [how it works](https://dotenvx.com/encryption) /
|
|
391
|
+
#/----------------------------------------------------------/
|
|
392
|
+
|
|
393
|
+
# .env.dev
|
|
394
|
+
ENVX_PRIVATE_KEY_DEV="…64 hex chars…"
|
|
395
|
+
|
|
396
|
+
# .env.prod
|
|
397
|
+
ENVX_PRIVATE_KEY_PROD="…64 hex chars…"
|
|
398
|
+
```
|
|
399
|
+
|
|
400
|
+
### Rotating keys
|
|
401
|
+
|
|
402
|
+
```sh
|
|
403
|
+
envx rotate # rotate keypair for ./.env
|
|
404
|
+
envx rotate --dir vault # rotate every vault/.env* file
|
|
405
|
+
envx rotate -k API_KEY # rotate one variable in place
|
|
406
|
+
```
|
|
407
|
+
|
|
408
|
+
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.
|
|
409
|
+
|
|
410
|
+
### dotenvx-compatible naming
|
|
411
|
+
|
|
412
|
+
envx reads keys under either prefix and writes new ones under the canonical names:
|
|
413
|
+
|
|
414
|
+
| concept | canonical (envx writes this) | dotenvx-compat (envx reads this) |
|
|
415
|
+
| ----------- | ------------------------------- | -------------------------------- |
|
|
416
|
+
| public key | `ENVX_PUBLIC_KEY*` | `DOTENV_PUBLIC_KEY*` |
|
|
417
|
+
| private key | `ENVX_PRIVATE_KEY*` | `DOTENV_PRIVATE_KEY*` |
|
|
418
|
+
|
|
419
|
+
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.
|
|
420
|
+
|
|
421
|
+
## Configuration
|
|
422
|
+
|
|
423
|
+
Every default in the previous section can be overridden. There are three precedence layers — highest to lowest:
|
|
424
|
+
|
|
425
|
+
| layer | scope | when to use |
|
|
426
|
+
| ------------------------------------------------------ | --------------------------- | ---------------------------------------------------------- |
|
|
427
|
+
| 1. **Per-invocation overrides** — CLI flag (`--env`, `--dir`, …) **or** programmatic arg (`envx({...})`) | one invocation | ad-hoc overrides, scripts, embedded usage |
|
|
428
|
+
| 2. **The one resolved config file** | the package containing the resolved config | per-package or workspace-shared defaults |
|
|
429
|
+
| 3. **Built-in defaults** | every invocation | the fallbacks documented in [Defaults](#defaults) |
|
|
430
|
+
|
|
431
|
+
`-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.
|
|
432
|
+
|
|
433
|
+
The "one resolved config file" in layer 2 comes from this discovery walk (first match wins, no merging across files):
|
|
434
|
+
|
|
435
|
+
| step | source | walks up? |
|
|
436
|
+
| :--: | --------------------------------------------------------------- | :-------: |
|
|
437
|
+
| 1 | `--config <path>` flag (CLI only) | n/a |
|
|
438
|
+
| 2 | nearest `package.json#envx.config: "<path>"` from cwd up to `/` | yes |
|
|
439
|
+
| 3 | `envx.config.{ts,mts,js,mjs,cjs,json}` in **cwd only** | no |
|
|
440
|
+
| 4 | (none — fall through to defaults) | n/a |
|
|
441
|
+
|
|
442
|
+
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).
|
|
443
|
+
|
|
444
|
+
The programmatic API runs the same discovery (minus step 1):
|
|
445
|
+
|
|
446
|
+
```ts
|
|
447
|
+
import envx from "@super-repo/envx";
|
|
448
|
+
|
|
449
|
+
envx(); // discovery + defaults
|
|
450
|
+
envx("staging"); // shorthand for envx({ cascade: "staging" })
|
|
451
|
+
envx({ ... }); // explicit args play the role of CLI flags (layer 1)
|
|
452
|
+
```
|
|
453
|
+
|
|
454
|
+
### How the layers actually merge
|
|
455
|
+
|
|
456
|
+
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.
|
|
457
|
+
|
|
458
|
+
#### Per-setting merge (independent, top-to-bottom)
|
|
459
|
+
|
|
460
|
+
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.
|
|
461
|
+
|
|
462
|
+
```
|
|
463
|
+
For each setting field (envFiles, cascade, envPath, envKeysFile, override, quiet, …):
|
|
464
|
+
|
|
465
|
+
1. Did the CLI flag — or programmatic arg — set this field? → use that, stop
|
|
466
|
+
2. Is it set in the resolved config file? → use that, stop
|
|
467
|
+
3. Otherwise → use the built-in default
|
|
468
|
+
```
|
|
469
|
+
|
|
470
|
+
So you can do this without thinking about layer order:
|
|
471
|
+
|
|
472
|
+
```sh
|
|
473
|
+
# Config provides envFiles; CLI overrides only `cascade`.
|
|
474
|
+
# Effective settings: envFiles=[".env","vault/.env.prod"], cascade="prod", everything else default.
|
|
475
|
+
envx --cascade prod -- node app.js
|
|
476
|
+
```
|
|
477
|
+
|
|
478
|
+
with
|
|
479
|
+
|
|
480
|
+
```ts
|
|
481
|
+
// envx.config.ts
|
|
482
|
+
export default {
|
|
483
|
+
envFiles: [".env", "vault/.env.prod"],
|
|
484
|
+
}
|
|
485
|
+
```
|
|
486
|
+
|
|
487
|
+
The CLI flag for `cascade` wins for that field; `envFiles` falls through to the config; everything else falls through to defaults.
|
|
488
|
+
|
|
489
|
+
#### Variable-value precedence (after files load)
|
|
490
|
+
|
|
491
|
+
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:
|
|
492
|
+
|
|
493
|
+
```
|
|
494
|
+
Final value of process.env.SOME_KEY is the FIRST hit, top-down:
|
|
495
|
+
|
|
496
|
+
1. `-v SOME_KEY=…` passed on the CLI (always wins)
|
|
497
|
+
2. Existing process.env.SOME_KEY when --override=false (the default)
|
|
498
|
+
OR, when --override=true:
|
|
499
|
+
The most-specific env file's value for SOME_KEY (cascade-wise)
|
|
500
|
+
3. The next-most-specific env file's value (cascade order)
|
|
501
|
+
4. … all the way down the cascade
|
|
502
|
+
5. unset
|
|
503
|
+
```
|
|
504
|
+
|
|
505
|
+
`-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:
|
|
506
|
+
|
|
507
|
+
- Need to inject one ad-hoc value? `-v KEY=VALUE`.
|
|
508
|
+
- Need to swap a whole environment? `--env`, `--cascade`, or `envFiles` in config.
|
|
509
|
+
|
|
510
|
+
#### `--override` flips one thing only
|
|
511
|
+
|
|
512
|
+
`--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.
|
|
513
|
+
|
|
514
|
+
`--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.
|
|
515
|
+
|
|
516
|
+
### Config-file shape
|
|
517
|
+
|
|
518
|
+
```ts
|
|
519
|
+
// envx.config.ts
|
|
520
|
+
export default {
|
|
521
|
+
// ── env-file selection ─────────────────────────────────
|
|
522
|
+
envFiles: [".env", ".env.local"], // bypass auto-detect; explicit list
|
|
523
|
+
envPath: "vault", // workspace-relative subdir (cwd-first, ws-root fallback)
|
|
524
|
+
cascade: true, // boolean: enable layered cascade using the auto-detected env name
|
|
525
|
+
// (CLI `--cascade <name>` still accepts an explicit string and overrides)
|
|
526
|
+
variables: ["DEBUG=1"], // KEY=VALUE overrides applied after load
|
|
527
|
+
|
|
528
|
+
// ── encryption keys ────────────────────────────────────
|
|
529
|
+
envKeysFile: ".env.keys", // overrides the cwd-first / workspace fallback default
|
|
530
|
+
|
|
531
|
+
// ── load behavior ──────────────────────────────────────
|
|
532
|
+
override: false, // false: existing process.env wins
|
|
533
|
+
// true: env files win (incompatible with cascade)
|
|
534
|
+
quiet: true, // suppress dotenv's load-line output
|
|
535
|
+
|
|
536
|
+
// ── auto-detection ─────────────────────────────────────
|
|
537
|
+
autoDetect: true, // false disables Vercel/Netlify/NODE_ENV detection
|
|
538
|
+
nodeEnvMap: { // override the NODE_ENV → suffix mapping
|
|
539
|
+
production: "prod", // (built-ins shown — yours layer on top)
|
|
540
|
+
development: "dev",
|
|
541
|
+
local: "local",
|
|
542
|
+
qa: "qa-prod", // NODE_ENV=qa → .env.qa-prod
|
|
543
|
+
preview: "", // NODE_ENV=preview → .env (empty = no suffix)
|
|
544
|
+
},
|
|
545
|
+
|
|
546
|
+
// ── post-load behavior ─────────────────────────────────
|
|
547
|
+
required: ["DATABASE_URL", "API_KEY"], // fail-fast: exit 1 if any are unset after load
|
|
548
|
+
expand: true, // auto-resolve ${VAR} references against process.env
|
|
549
|
+
defaults: { // applied AFTER files + variables, only for unset keys
|
|
550
|
+
LOG_LEVEL: "info",
|
|
551
|
+
PORT: "3000",
|
|
552
|
+
},
|
|
553
|
+
|
|
554
|
+
// ── validation ─────────────────────────────────────────
|
|
555
|
+
schema: z.object({ // any object exposing `safeParse(input) → { success, error?, data? }`
|
|
556
|
+
DATABASE_URL: z.string().url(), // Zod is the obvious choice but envx duck-types — bring your own
|
|
557
|
+
PORT: z.coerce.number(), // coerced values are written back to process.env
|
|
558
|
+
}),
|
|
559
|
+
|
|
560
|
+
// ── named profiles ─────────────────────────────────────
|
|
561
|
+
profiles: { // `--profile staging` (or programmatic `envx({ profile: "staging" })`)
|
|
562
|
+
staging: { // merges this object onto the base config per-field, profile wins
|
|
563
|
+
envFiles: [".env.staging"],
|
|
564
|
+
required: ["DB_URL"],
|
|
565
|
+
},
|
|
566
|
+
prod: {
|
|
567
|
+
envFiles: [".env.prod"],
|
|
568
|
+
cascade: true,
|
|
569
|
+
required: ["DB_URL", "API_KEY"],
|
|
570
|
+
},
|
|
571
|
+
},
|
|
572
|
+
|
|
573
|
+
// ── external secret refs ───────────────────────────────
|
|
574
|
+
resolvers: { // values like `${aws-secrets:my-id}` get passed to the resolver
|
|
575
|
+
"aws-secrets": (id: string) => { // and replaced by its return. Sync — wrap async SDK calls externally.
|
|
576
|
+
return cachedAwsSecret(id); // (e.g. pre-warm a cache before envx() runs in your bootstrap)
|
|
577
|
+
},
|
|
578
|
+
},
|
|
579
|
+
|
|
580
|
+
// ── public-variable mirroring (SSR / client-side bundlers) ─
|
|
581
|
+
publicPrefixes: ["VITE_", "NEXT_PUBLIC_"], // duplicate every PUBLIC_* under each prefix
|
|
582
|
+
publicSource: "PUBLIC_", // (default) the prefix that marks a var as public
|
|
583
|
+
|
|
584
|
+
// ── advanced ───────────────────────────────────────────
|
|
585
|
+
workspaceRoot: "/abs/path/to/repo", // escape hatch — skip findWorkspaceRoot() walk-up
|
|
586
|
+
}
|
|
587
|
+
```
|
|
588
|
+
|
|
589
|
+
All fields are optional. Anything you omit falls through to the built-in default for that field.
|
|
590
|
+
|
|
591
|
+
> **Note:** `schema`, `profiles`, and `resolvers` are TS/JS-only — JSON configs can't carry callable functions. A `package.json#envx.config` reference must point at a `.ts` / `.mts` / `.js` / `.mjs` / `.cjs` file to use them.
|
|
592
|
+
|
|
593
|
+
### Discovery order
|
|
594
|
+
|
|
595
|
+
When `--config <path>` is not passed, envx walks up looking for a config in this order:
|
|
596
|
+
|
|
597
|
+
1. `<cwd>/envx.config.{ts,js,json,mjs,cjs}` — first hit wins
|
|
598
|
+
2. `<cwd>/package.json#envx.config: "<path>"` — the string is resolved relative to that `package.json`'s directory
|
|
599
|
+
3. Each parent directory, up to the workspace root, repeating steps 1 & 2
|
|
600
|
+
|
|
601
|
+
`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.
|
|
602
|
+
|
|
603
|
+
### Programmatic customization
|
|
604
|
+
|
|
605
|
+
Every config field maps 1:1 onto the options accepted by the `envx()` programmatic entry point:
|
|
606
|
+
|
|
607
|
+
```ts
|
|
608
|
+
import envx from "@super-repo/envx";
|
|
609
|
+
|
|
610
|
+
// Same as `envx --env .env.prod --env-path vault --override`:
|
|
611
|
+
envx({
|
|
612
|
+
envFiles: [".env.prod"],
|
|
613
|
+
envPath: "vault",
|
|
614
|
+
override: true,
|
|
615
|
+
});
|
|
616
|
+
|
|
617
|
+
// String shorthand sets `cascade`:
|
|
618
|
+
envx("staging"); // == envx({ cascade: "staging" })
|
|
619
|
+
```
|
|
620
|
+
|
|
621
|
+
Programmatic args **override** anything from the discovered config; the config supplies fields you didn't pass.
|
|
622
|
+
|
|
623
|
+
## Further reading
|
|
624
|
+
|
|
625
|
+
Expanded explanations, worked examples, and recipes ship with the package in [`./docs/`](./docs/):
|
|
626
|
+
|
|
627
|
+
- **[Auto-detection](./docs/auto-detection.md)** — every `VERCEL_ENV` / `CONTEXT` / `NODE_ENV` shape walked through with the file that ends up loading.
|
|
628
|
+
- **[Configuration](./docs/configuration.md)** — full schema, the per-field merge rules, where each field gets read from, and the discovery walk.
|
|
629
|
+
- **[Monorepo recipes](./docs/recipes.md)** — common layouts (single workspace `.env.keys`, vault subdir, per-app overrides, CI patterns) with the exact files and commands.
|
|
630
|
+
|
|
631
|
+
## Library API
|
|
632
|
+
|
|
633
|
+
Beyond `envx()`, the package re-exports the building blocks the CLI uses internally — useful when you need to script the same behavior from your own tool, plugin, or test harness. Everything below is importable from the default entry:
|
|
634
|
+
|
|
635
|
+
```ts
|
|
636
|
+
import {
|
|
637
|
+
// Programmatic loader
|
|
638
|
+
envx,
|
|
639
|
+
type EnvxProgrammaticOptions,
|
|
640
|
+
type LoadEnvOptions,
|
|
641
|
+
|
|
642
|
+
// Audit (plaintext-secret scanner)
|
|
643
|
+
auditFiles,
|
|
644
|
+
BUILT_IN_PATTERNS,
|
|
645
|
+
type AuditFinding,
|
|
646
|
+
type AuditOptions,
|
|
647
|
+
type SecretPattern,
|
|
648
|
+
|
|
649
|
+
// Crypto primitives
|
|
650
|
+
ENCRYPTED_PREFIX,
|
|
651
|
+
generateKeyPair,
|
|
652
|
+
encryptValueAsymmetric,
|
|
653
|
+
decryptValueAsymmetric,
|
|
654
|
+
isEncrypted,
|
|
655
|
+
|
|
656
|
+
// Higher-level operations
|
|
657
|
+
encryptFiles,
|
|
658
|
+
decryptFiles,
|
|
659
|
+
rotateFiles,
|
|
660
|
+
|
|
661
|
+
// Env-file parser (line-preserving)
|
|
662
|
+
parseEnv,
|
|
663
|
+
serializeEnv,
|
|
664
|
+
toRecord,
|
|
665
|
+
|
|
666
|
+
// Variable expansion (cycle-safe)
|
|
667
|
+
expandRecord,
|
|
668
|
+
expandEnvSrc,
|
|
669
|
+
|
|
670
|
+
// Config + path resolution
|
|
671
|
+
loadDotenvxConfig,
|
|
672
|
+
findWorkspaceRoot,
|
|
673
|
+
resolveCwdOrWorkspace,
|
|
674
|
+
resolveEnvPaths,
|
|
675
|
+
detectEnvironment,
|
|
676
|
+
|
|
677
|
+
// Keys-file management
|
|
678
|
+
readKeysFile,
|
|
679
|
+
writeKeysFile,
|
|
680
|
+
defaultKeysPath,
|
|
681
|
+
} from "@super-repo/envx";
|
|
682
|
+
```
|
|
683
|
+
|
|
684
|
+
### Audit programmatically
|
|
685
|
+
|
|
686
|
+
`envx audit` is a thin wrapper around `auditFiles()`. Use it directly to plug envx's secret detection into a pre-commit hook, a CI script, or your own dashboard:
|
|
687
|
+
|
|
688
|
+
```ts
|
|
689
|
+
import { auditFiles, type SecretPattern } from "@super-repo/envx";
|
|
690
|
+
|
|
691
|
+
// Add custom patterns alongside the built-ins.
|
|
692
|
+
const orgPatterns: SecretPattern[] = [
|
|
693
|
+
{ id: "internal-token", label: "ACME internal token", regex: /\bacme_[A-Za-z0-9]{32}\b/ },
|
|
694
|
+
];
|
|
695
|
+
|
|
696
|
+
const { findings, filesScanned } = auditFiles({
|
|
697
|
+
roots: ["packages/", "apps/"],
|
|
698
|
+
ignore: ["fixtures", "snapshots"],
|
|
699
|
+
extra: orgPatterns,
|
|
700
|
+
max: 100, // 0 = unlimited
|
|
701
|
+
});
|
|
702
|
+
|
|
703
|
+
if (findings.length > 0) {
|
|
704
|
+
console.error(`audit: ${findings.length} finding(s) across ${filesScanned} files`);
|
|
705
|
+
process.exit(1);
|
|
706
|
+
}
|
|
707
|
+
```
|
|
708
|
+
|
|
709
|
+
Findings are redacted (`AKIA…MPLE [redacted 20 chars]`) so the report itself never leaks the secret payload.
|
|
710
|
+
|
|
711
|
+
### Custom profiles + resolvers in code
|
|
712
|
+
|
|
713
|
+
When you need conditional behavior in a programmatic call (without a config file):
|
|
714
|
+
|
|
715
|
+
```ts
|
|
716
|
+
import envx from "@super-repo/envx";
|
|
717
|
+
import { z } from "zod";
|
|
718
|
+
|
|
719
|
+
const Schema = z.object({
|
|
720
|
+
DATABASE_URL: z.string().url(),
|
|
721
|
+
PORT: z.coerce.number().int().min(1).max(65535),
|
|
722
|
+
});
|
|
723
|
+
|
|
724
|
+
await preloadAwsSecretsCache(); // resolvers are sync — warm caches up front
|
|
725
|
+
|
|
726
|
+
envx({
|
|
727
|
+
schema: Schema,
|
|
728
|
+
resolvers: {
|
|
729
|
+
"aws-secrets": (id) => awsSecretsCache.get(id),
|
|
730
|
+
"1password": (ref) => onePasswordCache.get(ref),
|
|
731
|
+
},
|
|
732
|
+
required: ["DATABASE_URL", "PORT"],
|
|
733
|
+
expand: true, // ${HOST}/api works after resolvers run
|
|
734
|
+
});
|
|
735
|
+
```
|
|
736
|
+
|
|
737
|
+
Order of operations inside `envx()` once a profile / resolvers / schema are in play:
|
|
738
|
+
|
|
739
|
+
```
|
|
740
|
+
1. resolve workspaceRoot
|
|
741
|
+
2. resolve env file paths
|
|
742
|
+
3. load each env file → mutates process.env (subject to `override`)
|
|
743
|
+
4. apply `variables` → unconditional overwrite
|
|
744
|
+
5. run `resolvers` on ${provider:id} refs
|
|
745
|
+
6. apply `defaults` → only for keys still unset
|
|
746
|
+
7. expand `${VAR}` references (when `expand: true`)
|
|
747
|
+
8. validate against `schema` (when configured) — exits 1 on failure
|
|
748
|
+
9. enforce `required` (any unset → log + exit 1)
|
|
749
|
+
```
|
|
750
|
+
|
|
751
|
+
## Public variables (SSR + client bundlers)
|
|
752
|
+
|
|
753
|
+
Modern frameworks each demand their own prefix for variables that should reach client-side code:
|
|
754
|
+
|
|
755
|
+
| framework | prefix |
|
|
756
|
+
| ----------------- | --------------- |
|
|
757
|
+
| Vite | `VITE_` |
|
|
758
|
+
| Next.js | `NEXT_PUBLIC_` |
|
|
759
|
+
| Create React App | `REACT_APP_` |
|
|
760
|
+
| Nuxt 3 (public) | `NUXT_PUBLIC_` |
|
|
761
|
+
| SvelteKit (public)| `PUBLIC_` |
|
|
762
|
+
| Remix (loader) | _(none)_ |
|
|
763
|
+
|
|
764
|
+
Maintaining the same value under three different keys (`VITE_API_URL`, `NEXT_PUBLIC_API_URL`, `REACT_APP_API_URL`) is the kind of busywork envx exists to delete. Mark a variable "public" once with `PUBLIC_` (or any prefix you configure as `publicSource`), and envx mirrors it under every framework prefix at load time.
|
|
765
|
+
|
|
766
|
+
```ts
|
|
767
|
+
// envx.config.ts
|
|
768
|
+
export default {
|
|
769
|
+
publicPrefixes: ["VITE_", "NEXT_PUBLIC_"],
|
|
770
|
+
// publicSource: "PUBLIC_" // (default)
|
|
771
|
+
}
|
|
772
|
+
```
|
|
773
|
+
|
|
774
|
+
```env
|
|
775
|
+
# .env
|
|
776
|
+
PUBLIC_API_URL=https://api.example.com
|
|
777
|
+
PUBLIC_FEATURE_FLAG=true
|
|
778
|
+
DATABASE_URL=secret-stuff # not mirrored — no PUBLIC_ prefix
|
|
779
|
+
```
|
|
780
|
+
|
|
781
|
+
After `envx -- node app.js`, `process.env` looks like:
|
|
782
|
+
|
|
783
|
+
| key | value | source |
|
|
784
|
+
| ---------------------------- | --------------------------- | --------------- |
|
|
785
|
+
| `PUBLIC_API_URL` | `https://api.example.com` | original |
|
|
786
|
+
| `VITE_API_URL` | `https://api.example.com` | mirror |
|
|
787
|
+
| `NEXT_PUBLIC_API_URL` | `https://api.example.com` | mirror |
|
|
788
|
+
| `PUBLIC_FEATURE_FLAG` | `true` | original |
|
|
789
|
+
| `VITE_FEATURE_FLAG` | `true` | mirror |
|
|
790
|
+
| `NEXT_PUBLIC_FEATURE_FLAG` | `true` | mirror |
|
|
791
|
+
| `DATABASE_URL` | `secret-stuff` | original (kept private — no mirror) |
|
|
792
|
+
|
|
793
|
+
CLI flag for one-off use:
|
|
794
|
+
|
|
795
|
+
```sh
|
|
796
|
+
envx --public-prefix VITE_ --public-prefix NEXT_PUBLIC_ -- pnpm build
|
|
797
|
+
```
|
|
798
|
+
|
|
799
|
+
Mirroring runs **after** `expand`, so `${VAR}` references inside `PUBLIC_*` values resolve before being mirrored. Existing target keys are never overwritten — a `VITE_API_URL` already set in the parent shell wins over the auto-mirror.
|
|
800
|
+
|
|
801
|
+
## Security models — when secrets are fetched
|
|
802
|
+
|
|
803
|
+
Pick one based on where you want plaintext to live:
|
|
804
|
+
|
|
805
|
+
| model | when secrets are fetched | plaintext on disk? | best for |
|
|
806
|
+
| ------------------------------ | --------------------------------------- | ------------------------------- | ---------------------------------------- |
|
|
807
|
+
| **Encrypted-at-rest** (default)| process startup — `envx run --` decrypts via `.env.keys` | ciphertext only, in committed `.env*` | most apps — single binary, fast cold start |
|
|
808
|
+
| **Load-time fetch** (resolvers)| process startup — plugins fetch via `resolvers:` | none — plaintext stays in process memory | server processes, long-running workers |
|
|
809
|
+
| **Build-time bake** | once, in CI — `envx bake` writes `.env.resolved` | yes (in the build artifact) ⚠️ | static SPA bundles, public-only vars |
|
|
810
|
+
| **Runtime fetch** (edge) | per cold start, lazily — `secrets.get()` on first read | none — secrets stay in the source-of-truth | edge functions, serverless, zero-trust |
|
|
811
|
+
|
|
812
|
+
The four models stack — most apps end up using two or more (e.g. encrypted-at-rest for non-secret config, runtime fetch for actual secrets).
|
|
813
|
+
|
|
814
|
+
### Build-time bake (`envx bake`)
|
|
815
|
+
|
|
816
|
+
```sh
|
|
817
|
+
# CI step — resolves every encrypted value + ${provider:id} ref + ${VAR}
|
|
818
|
+
# expansion + schema validation, then writes a sealed file the bundler picks up.
|
|
819
|
+
envx bake --out dist/.env.resolved
|
|
820
|
+
|
|
821
|
+
# Public-only mode for client bundles — only PUBLIC_* keys + their framework mirrors.
|
|
822
|
+
envx bake --public-only --out dist/.env.public
|
|
823
|
+
```
|
|
824
|
+
|
|
825
|
+
⚠️ The output file is **plaintext**. envx writes a `# DO NOT COMMIT` banner and refuses to write under `.git/`, but you must add the path to `.gitignore` yourself. Use `--public-only` whenever the artifact will ship to clients.
|
|
826
|
+
|
|
827
|
+
Build-tool integration:
|
|
828
|
+
|
|
829
|
+
```ts
|
|
830
|
+
// vite.config.ts
|
|
831
|
+
import { defineConfig, loadEnv } from "vite";
|
|
832
|
+
|
|
833
|
+
export default defineConfig(({ mode }) => {
|
|
834
|
+
// Vite's loadEnv reads .env.resolved like any other .env file.
|
|
835
|
+
const env = loadEnv(mode, process.cwd(), ".env.resolved");
|
|
836
|
+
return {
|
|
837
|
+
define: {
|
|
838
|
+
"import.meta.env.VITE_API_URL": JSON.stringify(env.VITE_API_URL),
|
|
839
|
+
},
|
|
840
|
+
};
|
|
841
|
+
});
|
|
842
|
+
```
|
|
843
|
+
|
|
844
|
+
```sh
|
|
845
|
+
# CI script
|
|
846
|
+
envx bake --public-only --out .env.resolved && pnpm vite build
|
|
847
|
+
```
|
|
848
|
+
|
|
849
|
+
### Runtime fetch — edge / serverless
|
|
850
|
+
|
|
851
|
+
For the "secrets never leave the source-of-truth" model, use `createSecretRuntime` from `@super-repo/envx-plugins/runtime`. Async-first, TTL caching, no `fs` / `child_process` dependencies — works in Cloudflare Workers, Vercel Edge, Deno Deploy, AWS Lambda, Bun, Node.
|
|
852
|
+
|
|
853
|
+
```ts
|
|
854
|
+
// src/secrets.ts — module-level singleton, one per worker
|
|
855
|
+
import { createSecretRuntime } from "@super-repo/envx-plugins/runtime";
|
|
856
|
+
import { hcVault } from "@super-repo/envx-plugins/vault";
|
|
857
|
+
|
|
858
|
+
export const secrets = createSecretRuntime({
|
|
859
|
+
providers: [
|
|
860
|
+
hcVault({
|
|
861
|
+
endpoint: env.VAULT_ADDR,
|
|
862
|
+
token: env.VAULT_TOKEN,
|
|
863
|
+
}),
|
|
864
|
+
],
|
|
865
|
+
ttl: 300, // refresh every 5 min
|
|
866
|
+
onFailure: "cache-stale", // serve stale on transient backend failures
|
|
867
|
+
});
|
|
868
|
+
```
|
|
869
|
+
|
|
870
|
+
```ts
|
|
871
|
+
// src/handler.ts (Cloudflare Worker / Vercel Edge / Lambda — same shape)
|
|
872
|
+
import { secrets } from "./secrets";
|
|
873
|
+
|
|
874
|
+
export default async function handler(req: Request): Promise<Response> {
|
|
875
|
+
const dbUrl = await secrets.get("vault:prod/db");
|
|
876
|
+
const apiKey = await secrets.get("vault:prod/api");
|
|
877
|
+
// … handler logic …
|
|
878
|
+
}
|
|
879
|
+
```
|
|
880
|
+
|
|
881
|
+
What you get:
|
|
882
|
+
- **Lazy fetch** — first read triggers the network call. Subsequent reads within the TTL window hit memory.
|
|
883
|
+
- **Single-flight** — N concurrent reads of the same ref coalesce into one fetch. Edge functions handling a burst of requests don't fan out N parallel calls to your secret store.
|
|
884
|
+
- **TTL refresh** — values older than `ttl` seconds are refetched on next read. Set `ttl: Infinity` to cache forever; `ttl: 0` to fetch every read.
|
|
885
|
+
- **Failure modes** — `"throw"` (default), `"cache-stale"` (serve previous value, log warning), or `"cache-error"` (cache the failure for `errorTtl` seconds so you don't hammer a failing backend).
|
|
886
|
+
- **No fs / child_process imports** — the runtime entry point is V8-isolate-safe.
|
|
887
|
+
|
|
888
|
+
### Compatibility note
|
|
889
|
+
|
|
890
|
+
Plugin compatibility for the runtime entry depends on what each provider's transport needs:
|
|
891
|
+
|
|
892
|
+
| provider | edge runtime? | reason |
|
|
893
|
+
| ------------------ | --------------------------- | ----------------------------------- |
|
|
894
|
+
| HashiCorp Vault | ✓ | native `fetch` |
|
|
895
|
+
| Doppler | ✓ | native `fetch` |
|
|
896
|
+
| Infisical | ✓ | native `fetch` |
|
|
897
|
+
| AWS Secrets Manager| ⚠ Node-compat only | `@aws-sdk/client-secrets-manager` uses Node streams |
|
|
898
|
+
| GCP Secret Manager | ⚠ Node-compat only | `@google-cloud/secret-manager` uses gRPC |
|
|
899
|
+
| Azure Key Vault | ⚠ Node-compat only | `@azure/identity` token chain |
|
|
900
|
+
| 1Password (CLI) | ✗ | spawns the `op` binary |
|
|
901
|
+
| 1Password (SDK) | depends on `@1password/sdk` | check the SDK's runtime support |
|
|
902
|
+
|
|
903
|
+
For Cloudflare Workers / Vercel Edge / Deno: prefer Vault, Doppler, Infisical, or a custom HTTP `buildProvider`. AWS / GCP / Azure SDKs run fine in Lambda / Cloud Run / Cloud Functions but won't work in V8-only edges.
|
|
904
|
+
|
|
905
|
+
## Secret-provider plugins
|
|
906
|
+
|
|
907
|
+
Ship as a sibling package: [`@super-repo/envx-plugins`](https://www.npmjs.com/package/@super-repo/envx-plugins). Plugins return a `SecretProvider` you wire into envx's `resolvers:` config so values like `${aws-secrets:my-db}` and `${vault:prod/api}` fetch from the matching backend at load time.
|
|
908
|
+
|
|
909
|
+
```ts
|
|
910
|
+
import envx from "@super-repo/envx";
|
|
911
|
+
import { awsSecrets, autoPreload, asResolvers } from "@super-repo/envx-plugins";
|
|
912
|
+
|
|
913
|
+
const aws = awsSecrets({ region: "us-east-1" });
|
|
914
|
+
|
|
915
|
+
await autoPreload([aws], { envFiles: [".env", "vault/.env.prod"] });
|
|
916
|
+
|
|
917
|
+
envx({ resolvers: asResolvers([aws]) });
|
|
918
|
+
```
|
|
919
|
+
|
|
920
|
+
```env
|
|
921
|
+
DATABASE_URL=${aws-secrets:prod/db}
|
|
922
|
+
```
|
|
923
|
+
|
|
924
|
+
Built-in providers: AWS Secrets Manager, GCP Secret Manager, Azure Key Vault, HashiCorp Vault, 1Password (CLI + SDK), Doppler, Infisical. Each plugin lazy-loads its SDK — install only what you use. See the [`@super-repo/envx-plugins` README](../plugins/README.md) for per-provider setup.
|
|
925
|
+
|
|
926
|
+
## Pre-commit hooks
|
|
927
|
+
|
|
928
|
+
Three of the new commands (`audit`, `doctor`, `template --check`) are designed to compose into a single git pre-commit gate so secrets, broken configs, and stale `.env.example` files never reach the remote.
|
|
929
|
+
|
|
930
|
+
The repo ships a reference `.hooks/pre-commit` you can adopt verbatim or copy into your own setup:
|
|
931
|
+
|
|
932
|
+
```sh
|
|
933
|
+
# Activate the in-repo .hooks/ directory (one-time per clone)
|
|
934
|
+
git config core.hooksPath .hooks
|
|
935
|
+
```
|
|
936
|
+
|
|
937
|
+
That single config line tells git to look for hooks under `<repo>/.hooks/` instead of `.git/hooks/`. The directory is committed (everyone gets the same hooks), no third-party tool is required.
|
|
938
|
+
|
|
939
|
+
The hook itself:
|
|
940
|
+
|
|
941
|
+
```bash
|
|
942
|
+
#!/usr/bin/env bash
|
|
943
|
+
# .hooks/pre-commit
|
|
944
|
+
set -euo pipefail
|
|
945
|
+
[[ "${ENVX_SKIP_HOOK:-}" == "1" ]] && exit 0
|
|
946
|
+
|
|
947
|
+
# Use the workspace binary if available, else a global envx
|
|
948
|
+
if command -v pnpm >/dev/null && [[ -f package.json ]]; then
|
|
949
|
+
ENVX="pnpm exec envx"
|
|
950
|
+
else
|
|
951
|
+
ENVX="envx"
|
|
952
|
+
fi
|
|
953
|
+
|
|
954
|
+
failed=0
|
|
955
|
+
|
|
956
|
+
# 1. Audit staged files for plaintext secrets (AKIA, ghp_, sk_live_, JWTs, …)
|
|
957
|
+
staged=$(git diff --cached --name-only --diff-filter=ACMR | tr '\n' ' ')
|
|
958
|
+
[[ -n "$staged" ]] && ! $ENVX audit $staged && failed=1
|
|
959
|
+
|
|
960
|
+
# 2. Workspace health (skip required-key gate so dev machines don't fail)
|
|
961
|
+
$ENVX doctor --ignore-required || failed=1
|
|
962
|
+
|
|
963
|
+
# 3. .env.example drift gate
|
|
964
|
+
[[ -f .env ]] && ! $ENVX template --check && failed=1
|
|
965
|
+
|
|
966
|
+
exit $failed
|
|
967
|
+
```
|
|
968
|
+
|
|
969
|
+
Bypass options:
|
|
970
|
+
- `ENVX_SKIP_HOOK=1 git commit ...` — skip just the envx hook
|
|
971
|
+
- `git commit --no-verify` — skip every git hook (use sparingly)
|
|
972
|
+
|
|
973
|
+
The full annotated script lives at `.hooks/pre-commit` in this repo. CI should run the same three commands without the `--ignore-required` flag so missing required keys fail builds (not just commits).
|
|
974
|
+
|
|
975
|
+
### Pre-push variant
|
|
976
|
+
|
|
977
|
+
For a stricter gate that runs only on push (so commits stay fast but pushes are guarded):
|
|
978
|
+
|
|
979
|
+
```bash
|
|
980
|
+
#!/usr/bin/env bash
|
|
981
|
+
# .hooks/pre-push — same checks, run against the whole repo, no skip.
|
|
982
|
+
set -euo pipefail
|
|
983
|
+
envx audit && envx doctor && envx template --check
|
|
984
|
+
```
|
|
985
|
+
|
|
986
|
+
### CI-friendly invocations
|
|
987
|
+
|
|
988
|
+
```yaml
|
|
989
|
+
# .github/workflows/secrets.yml
|
|
990
|
+
- run: pnpm exec envx audit --json > audit.json
|
|
991
|
+
- run: pnpm exec envx doctor # fails the build on missing required keys
|
|
992
|
+
- run: pnpm exec envx template --check # fails on .env.example drift
|
|
993
|
+
```
|
|
134
994
|
|
|
135
995
|
## Embedding the CLI
|
|
136
996
|
|
|
137
|
-
The yargs factory is exported under the `./commands` subpath if you
|
|
138
|
-
want to register `envx`'s commands inside your own tool:
|
|
997
|
+
The yargs factory is exported under the `./commands` subpath if you want to register envx's commands inside your own tool:
|
|
139
998
|
|
|
140
999
|
```ts
|
|
141
|
-
import { createCli } from "@
|
|
1000
|
+
import { createCli } from "@super-repo/envx/commands";
|
|
142
1001
|
|
|
143
1002
|
createCli(process.argv.slice(2)).parseSync();
|
|
144
1003
|
```
|
|
@@ -146,49 +1005,56 @@ createCli(process.argv.slice(2)).parseSync();
|
|
|
146
1005
|
## Development
|
|
147
1006
|
|
|
148
1007
|
```sh
|
|
149
|
-
# From the package root
|
|
150
1008
|
pnpm install
|
|
151
|
-
pnpm build #
|
|
152
|
-
pnpm dev # tsc --watch
|
|
1009
|
+
pnpm build # vite library build → dist/
|
|
153
1010
|
pnpm test # vitest unit tests
|
|
154
1011
|
pnpm test:integration # spawn-based subprocess tests
|
|
155
|
-
pnpm typecheck # tsc --noEmit
|
|
156
|
-
pnpm format # prettier --write src/
|
|
157
1012
|
```
|
|
158
1013
|
|
|
159
|
-
Tests live in `tests/`.
|
|
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).
|
|
1014
|
+
Tests live in `tests/`. Integration fixtures live in `tests/integration/__static__/` — each subdirectory is a self-contained worked example.
|
|
165
1015
|
|
|
166
1016
|
## Layout
|
|
167
1017
|
|
|
168
1018
|
```
|
|
169
|
-
packages/core/
|
|
1019
|
+
packages/envx/core/
|
|
170
1020
|
├── src/
|
|
171
|
-
│ ├── index.ts
|
|
172
|
-
│ ├── auto.ts
|
|
173
|
-
│ ├── cli.ts
|
|
1021
|
+
│ ├── index.ts # default export: envx() programmatic API
|
|
1022
|
+
│ ├── auto.ts # `import "@super-repo/envx/auto"` side-effect entry
|
|
1023
|
+
│ ├── cli.ts # `envx` bin entry — wires yargs + parses argv
|
|
174
1024
|
│ ├── bin/
|
|
175
|
-
│ │ └── dotenvx.ts
|
|
1025
|
+
│ │ └── dotenvx.ts # `dotenvx-proxy` direct passthrough to upstream
|
|
176
1026
|
│ └── commands/
|
|
177
|
-
│ ├── index.ts
|
|
178
|
-
│ ├── run.ts
|
|
179
|
-
│ ├── print.ts
|
|
180
|
-
│ ├── debug.ts
|
|
181
|
-
│ ├── encrypt.ts
|
|
182
|
-
│ ├── decrypt.ts
|
|
183
|
-
│
|
|
1027
|
+
│ ├── index.ts # createCli — registers subcommands + global options
|
|
1028
|
+
│ ├── run.ts # `run [command..]` — load env + spawn
|
|
1029
|
+
│ ├── print.ts # `print <variable>` — print one value
|
|
1030
|
+
│ ├── debug.ts # `debug` — show resolved paths
|
|
1031
|
+
│ ├── encrypt.ts # `encrypt` — incl. --pattern / --secrets
|
|
1032
|
+
│ ├── decrypt.ts # `decrypt`
|
|
1033
|
+
│ ├── rotate.ts # `rotate` — refresh keypair across files
|
|
1034
|
+
│ ├── expand.ts # `expand` — resolve ${VAR} on stdout
|
|
1035
|
+
│ ├── info.ts # `info` — print resolved config
|
|
1036
|
+
│ ├── doctor.ts # `doctor` — health checks (CI gate)
|
|
1037
|
+
│ ├── template.ts # `template` — emit/check .env.example
|
|
1038
|
+
│ ├── diff.ts # `diff <a> <b>` — keys + value mismatches
|
|
1039
|
+
│ ├── hook.ts # `hook <shell>` — eval-able shell exports
|
|
1040
|
+
│ ├── types.ts # `types` — emit env.d.ts
|
|
1041
|
+
│ ├── watch.ts # `watch -- <cmd>` — restart on env change
|
|
1042
|
+
│ └── audit.ts # `audit` — plaintext-secret scanner
|
|
184
1043
|
├── tests/
|
|
185
|
-
│ ├── *.test.ts
|
|
1044
|
+
│ ├── *.test.ts # unit suites
|
|
186
1045
|
│ └── integration/
|
|
187
|
-
│ ├── cli.test.ts
|
|
188
|
-
│ └── __static__/
|
|
1046
|
+
│ ├── cli.test.ts # subprocess end-to-end suite
|
|
1047
|
+
│ └── __static__/ # read-only fixtures (cp'd to tmp per test)
|
|
189
1048
|
├── configs/
|
|
190
1049
|
│ ├── tsconfig.build.json
|
|
1050
|
+
│ ├── vite.config.ts # bundles workspace-private envx-libs/common helpers
|
|
191
1051
|
│ └── vitest.config.ts
|
|
192
1052
|
├── tsconfig.json
|
|
193
1053
|
└── package.json
|
|
194
1054
|
```
|
|
1055
|
+
|
|
1056
|
+
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.
|
|
1057
|
+
|
|
1058
|
+
## License
|
|
1059
|
+
|
|
1060
|
+
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.
|