@dotenvx/dotenvx 2.13.0 → 2.15.0

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/CHANGELOG.md CHANGED
@@ -2,7 +2,20 @@
2
2
 
3
3
  All notable changes to this project will be documented in this file. See [standard-version](https://github.com/conventional-changelog/standard-version) for commit guidelines.
4
4
 
5
- [Unreleased](https://github.com/dotenvx/dotenvx/compare/v2.13.0...main)
5
+ [Unreleased](https://github.com/dotenvx/dotenvx/compare/v2.15.0...main)
6
+
7
+ ## [2.15.0](https://github.com/dotenvx/dotenvx/compare/v2.14.0...v2.15.0) (2026-07-20)
8
+
9
+ ### Added
10
+
11
+ * Add support for 1Password `op://` secrets in your .env files. ([#908](https://github.com/dotenvx/dotenvx/pull/908))
12
+
13
+ ## [2.14.0](https://github.com/dotenvx/dotenvx/compare/v2.13.0...v2.14.0) (2026-07-16)
14
+
15
+ ### Added
16
+
17
+ * Add `dotenvx run --validate` for simple env validation against your `.env.example` ([#906](https://github.com/dotenvx/dotenvx/pull/906))
18
+ * Add `dotenvx validate` for a pre-check of validation against your `.env.example` file ([#906](https://github.com/dotenvx/dotenvx/pull/906))
6
19
 
7
20
  ## [2.13.0](https://github.com/dotenvx/dotenvx/compare/v2.12.0...v2.13.0) (2026-07-16)
8
21
 
package/README.md CHANGED
@@ -153,6 +153,19 @@ Hello [REDACTED]
153
153
 
154
154
  see [Codex redaction guide](https://dotenvx.com/docs/cli/run-redact-codex-exec)
155
155
 
156
+ </details>
157
+ <details><summary>1Password 🔐</summary><br>
158
+
159
+ Run with secrets resolved directly from 1Password.
160
+
161
+ ```sh
162
+ $ echo "HELLO=op://Personal/hello/password" > .env
163
+ $ dotenvx run -- sh -c 'echo Hello $HELLO'
164
+ Hello World
165
+ ```
166
+
167
+ see [1Password guide](https://dotenvx.com/docs/secrets-in-1password)
168
+
156
169
  </details>
157
170
  <details><summary>TypeScript 📘</summary><br>
158
171
 
@@ -1156,6 +1169,30 @@ $ dotenvx get GOODBYE
1156
1169
  [MISSING_KEY] missing key (GOODBYE)
1157
1170
  ```
1158
1171
 
1172
+ </details>
1173
+ <details><summary>`run` - 1Password</summary><br>
1174
+
1175
+ Resolve [1Password op://](https://developer.1password.com/docs/cli/secrets-reference-syntax/) directly from your `.env` file.
1176
+
1177
+ ```ini
1178
+ # .env
1179
+ API_KEY=op://Personal/my_api_key/password
1180
+ ```
1181
+
1182
+ Install the [1Password CLI](https://developer.1password.com/docs/cli/get-started/) and authenticate with `op`. Dotenvx automatically reads `op://` values through `op read` before injecting them.
1183
+
1184
+ ```sh
1185
+ $ dotenvx run -- node index.js
1186
+ ```
1187
+
1188
+ Use `--no-1password` to leave `op://` values unresolved.
1189
+
1190
+ ```sh
1191
+ $ dotenvx run --no-1password -- node index.js
1192
+ ```
1193
+
1194
+ The same flag is available for `dotenvx get` and `dotenvx validate`.
1195
+
1159
1196
  </details>
1160
1197
  <details><summary>`run -f <directory>`</summary><br>
1161
1198
 
@@ -1453,6 +1490,31 @@ Hello production
1453
1490
 
1454
1491
  Available log levels are `error, warn, info, verbose, debug, silly` ([source](https://docs.npmjs.com/cli/v8/using-npm/logging#setting-log-levels))
1455
1492
 
1493
+ </details>
1494
+ <details><summary>`run --validate`</summary><br>
1495
+
1496
+ Validate your environment against `.env.example`.
1497
+
1498
+ ```ini
1499
+ # .env.example
1500
+ DATABASE_URL=
1501
+ API_KEY=
1502
+ SENTRY_DSN= # optional
1503
+ ```
1504
+
1505
+ ```sh
1506
+ $ dotenvx run --validate -- node index.js
1507
+ [VALIDATION_FAILED] missing required (DATABASE_URL, API_KEY). fix: [https://github.com/dotenvx/dotenvx/issues/907]
1508
+ ```
1509
+
1510
+ Validation errors are reported without stopping your command. Combine `--validate` with `--strict` to exit with code `1` before the command runs.
1511
+
1512
+ ```sh
1513
+ $ dotenvx run --validate --strict -- node index.js
1514
+ ```
1515
+
1516
+ Any inline comment containing the word `optional` marks that key as optional. If `.env.example` is missing, dotenvx reports `MISSING_ENV_EXAMPLE`. An empty `.env.example` is valid and declares no required variables.
1517
+
1456
1518
  </details>
1457
1519
  <details><summary>`run --strict`</summary><br>
1458
1520
 
@@ -2442,6 +2504,29 @@ $ dotenvx ls --json
2442
2504
  $ dotenvx ls --json > dotenv-files.json
2443
2505
  ```
2444
2506
 
2507
+ </details>
2508
+ <details><summary>`validate`</summary><br>
2509
+
2510
+ Validate `.env` file(s) against `.env.example` without running a command.
2511
+
2512
+ ```ini
2513
+ # .env.example
2514
+ DATABASE_URL=
2515
+ API_KEY=
2516
+ SENTRY_DSN= # optional
2517
+ ```
2518
+
2519
+ ```sh
2520
+ $ dotenvx validate
2521
+ [VALIDATION_FAILED] missing required (DATABASE_URL, API_KEY). fix: [https://github.com/dotenvx/dotenvx/issues/907]
2522
+ ```
2523
+
2524
+ Use `-f` and `-fk` to validate a specific env file and keys file. The command exits with code `1` when validation fails and prints errors to stderr. On success, it exits with code `0`.
2525
+
2526
+ ```sh
2527
+ $ dotenvx validate -f .env.production -fk .env.keys
2528
+ ```
2529
+
2445
2530
  </details>
2446
2531
  <details><summary>`genexample`</summary><br>
2447
2532
 
@@ -2825,9 +2910,10 @@ Commands:
2825
2910
  decrypt decrypt .env file(s)
2826
2911
  keypair [KEY] print public/private keys for .env file(s)
2827
2912
  ls [directory] print all .env files in a tree structure
2913
+ gitignore append to .gitignore
2828
2914
  genexample [directory]
2829
2915
  generate .env.example
2830
- gitignore append to .gitignore
2916
+ validate validate .env file(s) against .env.example
2831
2917
  precommit [directory]
2832
2918
  prevent committing .env files to code
2833
2919
  prebuild [directory]
@@ -3176,6 +3262,30 @@ Turn off [Dotenvx Armor ⛨](https://dotenvx.com/armor) features.
3176
3262
  require('@dotenvx/dotenvx').config({noArmor: true})
3177
3263
  ```
3178
3264
 
3265
+ </details>
3266
+ <details><summary>`config(no1Password:)` - no1Password</summary><br>
3267
+
3268
+ By default, `config()` automatically resolves `op://` values through the installed [1Password CLI](https://developer.1password.com/docs/cli/get-started/).
3269
+
3270
+ ```ini
3271
+ # .env
3272
+ API_KEY=op://Personal/my_api_key/password
3273
+ ```
3274
+
3275
+ ```js
3276
+ // index.js
3277
+ require('@dotenvx/dotenvx').config()
3278
+
3279
+ console.log(process.env.API_KEY)
3280
+ ```
3281
+
3282
+ Set `no1Password` to leave `op://` values unresolved and avoid calling `op`.
3283
+
3284
+ ```js
3285
+ // index.js
3286
+ require('@dotenvx/dotenvx').config({no1Password: true})
3287
+ ```
3288
+
3179
3289
  </details>
3180
3290
  <details><summary>`parse(src)`</summary><br>
3181
3291
 
@@ -3336,7 +3446,6 @@ Set `mask: 0` to fully mask values.
3336
3446
  * [GitHub Actions](https://dotenvx.com/docs/cis/github-actions)
3337
3447
  * [Password Managers](https://dotenvx.com/docs#password-managers)
3338
3448
  * [1Password](https://dotenvx.com/docs/guides/1password)
3339
- * [Bitwarden](https://dotenvx.com/docs/guides/bitwarden)
3340
3449
  * [Background Jobs](https://dotenvx.com/docs#background-jobs)
3341
3450
  * [Trigger.dev](https://dotenvx.com/docs/background-jobs/triggerdotdev)
3342
3451
  * [Package Managers](https://dotenvx.com/docs#package-managers)
package/package.json CHANGED
@@ -1,11 +1,20 @@
1
1
  {
2
- "version": "2.13.0",
2
+ "version": "2.15.0",
3
3
  "name": "@dotenvx/dotenvx",
4
4
  "description": "a secure dotenv–from the creator of `dotenv`",
5
5
  "author": "@motdotla",
6
6
  "keywords": [
7
7
  "dotenv",
8
- "env"
8
+ "env",
9
+ ".env",
10
+ "environment",
11
+ "variables",
12
+ "config",
13
+ "settings",
14
+ "env vars",
15
+ "environment variables",
16
+ "secret-management",
17
+ "secrets"
9
18
  ],
10
19
  "homepage": "https://github.com/dotenvx/dotenvx",
11
20
  "repository": {
@@ -43,7 +52,7 @@
43
52
  },
44
53
  "funding": "https://dotenvx.com",
45
54
  "dependencies": {
46
- "@dotenvx/primitives": "^2.0.0",
55
+ "@dotenvx/primitives": "^2.1.0",
47
56
  "@dotenvx/tooling": "^1.0.2",
48
57
  "yocto-spinner": "^1.2.1"
49
58
  },
@@ -38,6 +38,8 @@ async function get (key) {
38
38
  envKeysFile: resolveEnvKeysFile(options.envKeysFile),
39
39
  noArmor,
40
40
  noKeychain,
41
+ no1Password: options['1password'] === false || options.no1Password === true,
42
+ noBitwarden: options.bitwarden === false || options.noBitwarden === true,
41
43
  onStatus: (text) => {
42
44
  if (spinner && text) {
43
45
  spinner.text = text
@@ -15,6 +15,7 @@ const maskEnvSrc = require('../../lib/helpers/maskEnvSrc')
15
15
  const maskProcessedEnvs = require('../../lib/helpers/maskProcessedEnvs')
16
16
  const redactedValues = require('../../lib/helpers/redactedValues')
17
17
  const { redactOutput } = require('../../lib/helpers/redactOutput')
18
+ const validateEnvExample = require('../../lib/helpers/validateEnvExample')
18
19
 
19
20
  const { determine } = require('./../../lib/helpers/envResolution')
20
21
 
@@ -117,6 +118,8 @@ async function run () {
117
118
  envKeysFile: resolveEnvKeysFile(options.envKeysFile),
118
119
  noArmor,
119
120
  noKeychain,
121
+ no1Password: options['1password'] === false || options.no1Password === true,
122
+ noBitwarden: options.bitwarden === false || options.noBitwarden === true,
120
123
  token: options.token,
121
124
  command: commandArgs,
122
125
  onStatus: (text) => {
@@ -135,6 +138,20 @@ async function run () {
135
138
  maskProcessedEnvs(processedEnvs, commandEnv, showChar)
136
139
  }
137
140
 
141
+ if (options.validate) {
142
+ const error = validateEnvExample(process.env)
143
+
144
+ if (error) {
145
+ if (ignore.includes(error.code)) {
146
+ logger.verbose(`ignored: ${error.message}`)
147
+ } else if (options.strict) {
148
+ throw error
149
+ } else {
150
+ logger.error(error.messageWithHelp || error.message)
151
+ }
152
+ }
153
+ }
154
+
138
155
  for (const processedEnv of processedEnvs) {
139
156
  if (processedEnv.type === 'envFile') {
140
157
  logger.verbose(`loading env from ${processedEnv.filepath} (${path.resolve(processedEnv.filepath)})`)
@@ -0,0 +1,86 @@
1
+ const { logger } = require('./../../shared/logger')
2
+
3
+ const envsResolver = require('./../../lib/resolvers/envs')
4
+ const catchAndLog = require('./../../lib/helpers/catchAndLog')
5
+ const createSpinner = require('../../lib/helpers/createSpinner')
6
+ const Session = require('../../db/session')
7
+ const normalizeDotenvConfigQuiet = require('../../lib/helpers/normalizeDotenvConfigQuiet')
8
+ const normalizeDotenvConfigConvention = require('../../lib/helpers/normalizeDotenvConfigConvention')
9
+ const buildCommandEnvs = require('../../lib/helpers/buildCommandEnvs')
10
+ const resolveEnvKeysFile = require('../../lib/helpers/resolveEnvKeysFile')
11
+ const validateEnvExample = require('../../lib/helpers/validateEnvExample')
12
+
13
+ const { determine } = require('./../../lib/helpers/envResolution')
14
+
15
+ async function validate () {
16
+ const options = normalizeDotenvConfigConvention(normalizeDotenvConfigQuiet(this.opts()))
17
+ const spinnerOptions = typeof this.optsWithGlobals === 'function' ? this.optsWithGlobals() : options
18
+ const spinner = await createSpinner({ ...spinnerOptions, ...options, text: 'validating' })
19
+ const ignore = options.ignore || []
20
+ const validateEnv = { ...process.env }
21
+ let errorCount = 0
22
+
23
+ logger.debug(`options: ${JSON.stringify(options)}`)
24
+
25
+ try {
26
+ let envs = buildCommandEnvs(this.envs, options.convention)
27
+ envs = determine(envs, process.env)
28
+
29
+ const sesh = new Session()
30
+ const noArmor = options.armor === false || (!options.token && (await sesh.noArmor()))
31
+ const noKeychain = options.native === false || options.noNative === true
32
+
33
+ const { processedEnvs } = await envsResolver({
34
+ envs,
35
+ overload: options.overload,
36
+ processEnv: validateEnv,
37
+ envKeysFile: resolveEnvKeysFile(options.envKeysFile),
38
+ noArmor,
39
+ noKeychain,
40
+ no1Password: options['1password'] === false || options.no1Password === true,
41
+ noBitwarden: options.bitwarden === false || options.noBitwarden === true,
42
+ token: options.token,
43
+ onStatus: (text) => {
44
+ if (spinner && text) {
45
+ spinner.text = text
46
+ }
47
+ }
48
+ })
49
+
50
+ for (const processedEnv of processedEnvs) {
51
+ for (const error of processedEnv.errors || []) {
52
+ if (ignore.includes(error.code)) {
53
+ logger.verbose(`ignored: ${error.message}`)
54
+ continue
55
+ }
56
+
57
+ errorCount += 1
58
+ logger.error(error.messageWithHelp || error.message)
59
+ }
60
+ }
61
+
62
+ const validationError = validateEnvExample(validateEnv)
63
+ if (validationError) {
64
+ if (ignore.includes(validationError.code)) {
65
+ logger.verbose(`ignored: ${validationError.message}`)
66
+ } else {
67
+ errorCount += 1
68
+ logger.error(validationError.messageWithHelp || validationError.message)
69
+ }
70
+ }
71
+
72
+ if (spinner) spinner.stop()
73
+
74
+ if (errorCount > 0) {
75
+ process.exit(1)
76
+ } else {
77
+ logger.success('▣ validated')
78
+ }
79
+ } catch (error) {
80
+ if (spinner) spinner.stop()
81
+ catchAndLog(error)
82
+ process.exit(1)
83
+ }
84
+ }
85
+
86
+ module.exports = validate
@@ -66,6 +66,7 @@ program.command('run')
66
66
  .option('-fk, --env-keys-file <path>', 'path(s) to your .env.keys file(s) (default: same path as your env file)', collectEnvKeys)
67
67
  .option('--redact', 'redact injected values except keys ending in _PLAIN', false)
68
68
  .option('-o, --overload', 'override existing env variables (by default, existing env vars take precedence over .env files)')
69
+ .option('--validate', 'validate against .env.example', false)
69
70
  .option('--strict', 'process.exit(1) on any errors', false)
70
71
  .option('--convention <name>', 'load a .env convention (available conventions: [\'nextjs\', \'flow\'])')
71
72
  .option('--ignore <errorCodes...>', 'error code(s) to ignore (example: --ignore=MISSING_ENV_FILE)')
@@ -73,6 +74,8 @@ program.command('run')
73
74
  .option('--mask [characters]', 'inject masked values, optionally setting visible characters')
74
75
  .option('--no-armor', 'disable Dotenvx Armor features')
75
76
  .option('--no-native', 'disable OS secret store features')
77
+ .option('--no-1password', 'disable 1Password secret reference resolution')
78
+ .option('--no-bitwarden', 'disable Bitwarden secret reference resolution')
76
79
  .action(function (...args) {
77
80
  this.envs = envs
78
81
  return require('./actions/run').apply(this, args)
@@ -97,6 +100,8 @@ program.command('get')
97
100
  .option('--format <type>', 'format of the output (json, shell, colon, eval, eval-export)', 'json')
98
101
  .option('--no-armor', 'disable Dotenvx Armor features')
99
102
  .option('--no-native', 'disable OS secret store features')
103
+ .option('--no-1password', 'disable 1Password secret reference resolution')
104
+ .option('--no-bitwarden', 'disable Bitwarden secret reference resolution')
100
105
  .action(function (...args) {
101
106
  this.envs = envs
102
107
  return require('./actions/get').apply(this, args)
@@ -182,13 +187,23 @@ program.command('ls')
182
187
  return require('./actions/ls').apply(this, args)
183
188
  })
184
189
 
185
- // dotenvx genexample
186
- program.command('genexample')
187
- .description('generate .env.example')
188
- .argument('[directory]', 'directory to generate from', '.')
189
- .option('-f, --env-file <paths...>', 'path(s) to your env file(s)', '.env')
190
+ // dotenvx validate
191
+ program.command('validate')
192
+ .description('validate .env file(s) against .env.example')
193
+ .option('-e, --env <strings...>', 'environment variable(s) set as string (example: "HELLO=World")', collectEnvs('env'), [])
194
+ .option('-f, --env-file <path>', 'path(s) to your env file(s)', collectEnvs('envFile'), [])
195
+ .option('-fk, --env-keys-file <path>', 'path(s) to your .env.keys file(s) (default: same path as your env file)', collectEnvKeys)
196
+ .option('-o, --overload', 'override existing env variables (by default, existing env vars take precedence over .env files)')
197
+ .option('--convention <name>', 'load a .env convention (available conventions: [\'nextjs\', \'flow\'])')
198
+ .option('--ignore <errorCodes...>', 'error code(s) to ignore (example: --ignore=MISSING_ENV_FILE)')
199
+ .option('--token <token>', 'set Armor ⛨ token')
200
+ .option('--no-armor', 'disable Dotenvx Armor features')
201
+ .option('--no-native', 'disable OS secret store features')
202
+ .option('--no-1password', 'disable 1Password secret reference resolution')
203
+ .option('--no-bitwarden', 'disable Bitwarden secret reference resolution')
190
204
  .action(function (...args) {
191
- return require('./actions/ext/genexample').apply(this, args)
205
+ this.envs = envs
206
+ return require('./actions/validate').apply(this, args)
192
207
  })
193
208
 
194
209
  // dotenvx gitignore
@@ -200,6 +215,15 @@ program.command('gitignore')
200
215
  return require('./actions/ext/gitignore').apply(this, args)
201
216
  })
202
217
 
218
+ // dotenvx genexample
219
+ program.command('genexample')
220
+ .description('generate .env.example')
221
+ .argument('[directory]', 'directory to generate from', '.')
222
+ .option('-f, --env-file <paths...>', 'path(s) to your env file(s)', '.env')
223
+ .action(function (...args) {
224
+ return require('./actions/ext/genexample').apply(this, args)
225
+ })
226
+
203
227
  // dotenvx precommit
204
228
  program.command('precommit')
205
229
  .description('prevent committing .env files to code')
@@ -1,5 +1,6 @@
1
1
  const FRAMES = ['◇', '⬖', '◆', '⬗']
2
2
  const FRAME_INTERVAL_MS = 80
3
+ let activeSpinner
3
4
 
4
5
  async function createSpinner (options = {}) {
5
6
  const stream = process.stderr
@@ -11,7 +12,7 @@ async function createSpinner (options = {}) {
11
12
  const frames = options.frames || FRAMES
12
13
 
13
14
  const { default: yoctoSpinner } = await import('yocto-spinner')
14
- return yoctoSpinner({
15
+ activeSpinner = yoctoSpinner({
15
16
  text,
16
17
  spinner: {
17
18
  frames,
@@ -19,6 +20,21 @@ async function createSpinner (options = {}) {
19
20
  },
20
21
  stream
21
22
  }).start()
23
+
24
+ return activeSpinner
25
+ }
26
+
27
+ createSpinner.stop = function () {
28
+ if (activeSpinner) activeSpinner.stop()
29
+ activeSpinner = null
30
+ }
31
+
32
+ createSpinner.pause = function () {
33
+ if (activeSpinner) activeSpinner.stop()
34
+ }
35
+
36
+ createSpinner.resume = function () {
37
+ if (activeSpinner) activeSpinner.start()
22
38
  }
23
39
 
24
40
  module.exports = createSpinner
@@ -9,9 +9,12 @@ const ISSUE_BY_CODE = {
9
9
  INVALID_PASSPHRASE: 'try again with the correct passphrase',
10
10
  INVALID_PRIVATE_KEY: 'https://github.com/dotenvx/dotenvx/issues/465',
11
11
  INVALID_PUBLIC_KEY: 'https://github.com/dotenvx/dotenvx/issues/756',
12
+ '1PASSWORD_FAILED': 'https://www.1password.dev/cli/get-started',
13
+ BITWARDEN_FAILED: 'https://bitwarden.com/help/cli/',
12
14
  MALFORMED_ENCRYPTED_DATA: 'https://github.com/dotenvx/dotenvx/issues/467',
13
15
  MISPAIRED_PRIVATE_KEY: 'https://github.com/dotenvx/dotenvx/issues/752',
14
16
  MISSING_DIRECTORY: 'https://github.com/dotenvx/dotenvx/issues/758',
17
+ MISSING_ENV_EXAMPLE: 'https://github.com/dotenvx/dotenvx/issues/905',
15
18
  MISSING_ENV_FILE: 'https://github.com/dotenvx/dotenvx/issues/484',
16
19
  MISSING_ENV_KEYS_FILE: 'https://github.com/dotenvx/dotenvx/issues/775',
17
20
  MISSING_ENV_FILES: 'https://github.com/dotenvx/dotenvx/issues/760',
@@ -22,6 +25,7 @@ const ISSUE_BY_CODE = {
22
25
  MISSING_VALUE: 'https://github.com/dotenvx/dotenvx/issues/864',
23
26
  FILE_NOT_WRITABLE: 'https://github.com/dotenvx/dotenvx/issues/890',
24
27
  PRECOMMIT_HOOK_MODIFY_FAILED: 'try again or report error',
28
+ VALIDATION_FAILED: 'https://github.com/dotenvx/dotenvx/issues/907',
25
29
  WRONG_PRIVATE_KEY: 'https://github.com/dotenvx/dotenvx/issues/466'
26
30
  }
27
31
 
@@ -98,6 +102,30 @@ class Errors {
98
102
  return e
99
103
  }
100
104
 
105
+ onePasswordFailed () {
106
+ const code = '1PASSWORD_FAILED'
107
+ const message = `[${code}] ${this.message}`
108
+ const help = `fix: [${ISSUE_BY_CODE[code]}]`
109
+
110
+ const e = new Error(message)
111
+ e.code = code
112
+ e.help = help
113
+ e.messageWithHelp = `${message}. ${help}`
114
+ return e
115
+ }
116
+
117
+ bitwardenFailed () {
118
+ const code = 'BITWARDEN_FAILED'
119
+ const message = `[${code}] ${this.message}`
120
+ const help = this.help || `fix: [${ISSUE_BY_CODE[code]}]`
121
+
122
+ const e = new Error(message)
123
+ e.code = code
124
+ e.help = help
125
+ e.messageWithHelp = `${message}. ${help}`
126
+ return e
127
+ }
128
+
101
129
  invalidColor () {
102
130
  const code = 'INVALID_COLOR'
103
131
  const message = `[${code}] Invalid color ${this.color}`
@@ -207,6 +235,18 @@ class Errors {
207
235
  return e
208
236
  }
209
237
 
238
+ missingEnvExample () {
239
+ const code = 'MISSING_ENV_EXAMPLE'
240
+ const message = `[${code}] missing .env.example file`
241
+ const help = `fix: [${ISSUE_BY_CODE[code]}]`
242
+
243
+ const e = new Error(message)
244
+ e.code = code
245
+ e.help = help
246
+ e.messageWithHelp = `${message}. ${help}`
247
+ return e
248
+ }
249
+
210
250
  missingEnvKeysFile () {
211
251
  const code = 'MISSING_ENV_KEYS_FILE'
212
252
  const envKeysFilepath = this.envKeysFilepath || '.env.keys'
@@ -305,6 +345,18 @@ class Errors {
305
345
  return e
306
346
  }
307
347
 
348
+ validationFailed () {
349
+ const code = 'VALIDATION_FAILED'
350
+ const message = `[${code}] ${this.message}`
351
+ const help = `fix: [${ISSUE_BY_CODE[code]}]`
352
+
353
+ const e = new Error(message)
354
+ e.code = code
355
+ e.help = help
356
+ e.messageWithHelp = `${message}. ${help}`
357
+ return e
358
+ }
359
+
308
360
  precommitHookModifyFailed () {
309
361
  const code = 'PRECOMMIT_HOOK_MODIFY_FAILED'
310
362
  const message = `[${code}] failed to modify pre-commit hook: ${this.error.message}`
@@ -0,0 +1,149 @@
1
+ const { execFile, execFileSync } = require('child_process')
2
+ const Errors = require('./errors')
3
+ const prompts = require('./prompts')
4
+ const createSpinner = require('./createSpinner')
5
+
6
+ const FIELDS = new Set(['username', 'password', 'uri'])
7
+ const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i
8
+
9
+ function execFileAsync (command, args, options) {
10
+ return new Promise((resolve, reject) => {
11
+ execFile(command, args, options, (error, stdout) => {
12
+ if (error) return reject(error)
13
+ resolve(stdout)
14
+ })
15
+ })
16
+ }
17
+
18
+ function isSecretReference (value) {
19
+ return typeof value === 'string' && value.startsWith('bw://')
20
+ }
21
+
22
+ function parseSecretReference (value) {
23
+ const [itemId, field, ...extra] = value.slice('bw://'.length).split('/')
24
+
25
+ if (!UUID.test(itemId) || !field || extra.length > 0) {
26
+ throw new Error('invalid Bitwarden Password Manager reference')
27
+ }
28
+
29
+ if (!FIELDS.has(field)) {
30
+ throw new Error(`unsupported Bitwarden Password Manager field ${field}`)
31
+ }
32
+
33
+ return { itemId, field }
34
+ }
35
+
36
+ async function session (options) {
37
+ if (options.session) return options.session
38
+
39
+ if (process.stdin.isTTY && process.stderr.isTTY) {
40
+ createSpinner.pause()
41
+ const password = await prompts.password({
42
+ message: 'Bitwarden master password',
43
+ prefix: '◇',
44
+ separator: '='
45
+ }, {
46
+ input: process.stdin,
47
+ output: process.stderr
48
+ })
49
+ createSpinner.resume()
50
+ const passwordEnv = 'DOTENVX_BITWARDEN_PASSWORD'
51
+ options.session = secretValue(await execFileAsync('bw', ['unlock', '--passwordenv', passwordEnv, '--raw'], {
52
+ encoding: 'utf8',
53
+ windowsHide: true,
54
+ stdio: ['ignore', 'pipe', 'pipe'],
55
+ env: { ...process.env, [passwordEnv]: password }
56
+ }))
57
+ return options.session
58
+ }
59
+
60
+ if (!options.session) {
61
+ const error = new Error('Bitwarden Password Manager requires an unlocked BW_SESSION')
62
+ error.code = 'BW_SESSION_MISSING'
63
+ throw error
64
+ }
65
+ }
66
+
67
+ function resolutionError (key, error) {
68
+ let message
69
+ if (error && error.code === 'ENOENT') {
70
+ message = `Bitwarden Password Manager CLI is not installed and could not resolve ${key}`
71
+ } else if (error && error.code === 'BW_SESSION_MISSING') {
72
+ message = `Bitwarden Password Manager is locked and could not resolve ${key}; run 'export BW_SESSION="$(bw unlock --raw)"'`
73
+ } else if (error && error.message && error.message.startsWith('unsupported Bitwarden Password Manager field')) {
74
+ message = `${error.message} for ${key}`
75
+ } else if (error && error.message === 'invalid Bitwarden Password Manager reference') {
76
+ message = `invalid Bitwarden Password Manager reference for ${key}`
77
+ } else {
78
+ message = `Bitwarden Password Manager CLI failed to resolve ${key}`
79
+ }
80
+
81
+ return new Errors({
82
+ message,
83
+ help: 'fix: [https://bitwarden.com/help/cli/]'
84
+ }).bitwardenFailed()
85
+ }
86
+
87
+ function secretValue (stdout) {
88
+ return stdout.replace(/\r?\n$/, '')
89
+ }
90
+
91
+ async function resolveBitwardenPassword (parsed, options = {}) {
92
+ const errors = []
93
+ const unresolved = []
94
+ options.session = options.session || process.env.BW_SESSION
95
+
96
+ for (const [key, value] of Object.entries(parsed)) {
97
+ if (!isSecretReference(value)) continue
98
+
99
+ try {
100
+ const { itemId, field } = parseSecretReference(value)
101
+ const bwSession = await session(options)
102
+ const stdout = await execFileAsync('bw', ['get', field, itemId], {
103
+ encoding: 'utf8',
104
+ windowsHide: true,
105
+ env: { ...process.env, BW_SESSION: bwSession }
106
+ })
107
+ parsed[key] = secretValue(stdout)
108
+ } catch (error) {
109
+ errors.push(resolutionError(key, error))
110
+ unresolved.push(key)
111
+ delete parsed[key]
112
+ }
113
+ }
114
+
115
+ return { errors, unresolved }
116
+ }
117
+
118
+ function resolveBitwardenPasswordSync (parsed) {
119
+ const errors = []
120
+ const unresolved = []
121
+
122
+ for (const [key, value] of Object.entries(parsed)) {
123
+ if (!isSecretReference(value)) continue
124
+
125
+ try {
126
+ const { itemId, field } = parseSecretReference(value)
127
+ if (!process.env.BW_SESSION) {
128
+ const error = new Error('Bitwarden Password Manager requires an unlocked BW_SESSION')
129
+ error.code = 'BW_SESSION_MISSING'
130
+ throw error
131
+ }
132
+ const stdout = execFileSync('bw', ['get', field, itemId], {
133
+ encoding: 'utf8',
134
+ windowsHide: true,
135
+ stdio: ['ignore', 'pipe', 'pipe']
136
+ })
137
+ parsed[key] = secretValue(stdout)
138
+ } catch (error) {
139
+ errors.push(resolutionError(key, error))
140
+ unresolved.push(key)
141
+ delete parsed[key]
142
+ }
143
+ }
144
+
145
+ return { errors, unresolved }
146
+ }
147
+
148
+ module.exports = resolveBitwardenPassword
149
+ module.exports.sync = resolveBitwardenPasswordSync
@@ -0,0 +1,72 @@
1
+ const { execFile, execFileSync } = require('child_process')
2
+ const Errors = require('./errors')
3
+
4
+ function execFileAsync (command, args, options) {
5
+ return new Promise((resolve, reject) => {
6
+ execFile(command, args, options, (error, stdout) => {
7
+ if (error) return reject(error)
8
+ resolve(stdout)
9
+ })
10
+ })
11
+ }
12
+
13
+ function isSecretReference (value) {
14
+ return typeof value === 'string' && value.startsWith('op://')
15
+ }
16
+
17
+ function resolutionError (key, error) {
18
+ const message = error && error.code === 'ENOENT'
19
+ ? `1Password CLI is not installed and could not resolve ${key}`
20
+ : `1Password CLI failed to resolve ${key}`
21
+
22
+ return new Errors({ message }).onePasswordFailed()
23
+ }
24
+
25
+ async function resolveOnePassword (parsed) {
26
+ const errors = []
27
+ const unresolved = []
28
+
29
+ for (const [key, value] of Object.entries(parsed)) {
30
+ if (!isSecretReference(value)) continue
31
+
32
+ try {
33
+ const stdout = await execFileAsync('op', ['read', value, '--no-newline'], {
34
+ encoding: 'utf8',
35
+ windowsHide: true
36
+ })
37
+ parsed[key] = stdout
38
+ } catch (error) {
39
+ errors.push(resolutionError(key, error))
40
+ unresolved.push(key)
41
+ delete parsed[key]
42
+ }
43
+ }
44
+
45
+ return { errors, unresolved }
46
+ }
47
+
48
+ function resolveOnePasswordSync (parsed) {
49
+ const errors = []
50
+ const unresolved = []
51
+
52
+ for (const [key, value] of Object.entries(parsed)) {
53
+ if (!isSecretReference(value)) continue
54
+
55
+ try {
56
+ parsed[key] = execFileSync('op', ['read', value, '--no-newline'], {
57
+ encoding: 'utf8',
58
+ windowsHide: true,
59
+ stdio: ['ignore', 'pipe', 'pipe']
60
+ })
61
+ } catch (error) {
62
+ errors.push(resolutionError(key, error))
63
+ unresolved.push(key)
64
+ delete parsed[key]
65
+ }
66
+ }
67
+
68
+ return { errors, unresolved }
69
+ }
70
+
71
+ module.exports = resolveOnePassword
72
+ module.exports.sync = resolveOnePasswordSync
@@ -0,0 +1,40 @@
1
+ function optionalKeys (comments) {
2
+ const keys = new Set()
3
+
4
+ for (const [key, values] of Object.entries(comments)) {
5
+ if (values.some(comment => comment && /\boptional\b/i.test(comment))) {
6
+ keys.add(key)
7
+ }
8
+ }
9
+
10
+ return keys
11
+ }
12
+
13
+ function validate (example = {}, env = {}, options = {}) {
14
+ const errors = []
15
+ const missingRequired = []
16
+ const optional = optionalKeys(options.comments || {})
17
+
18
+ for (const key of Object.keys(example)) {
19
+ const value = env[key]
20
+ const missing = !Object.prototype.hasOwnProperty.call(env, key) || value.trim() === ''
21
+ if (!optional.has(key) && missing) {
22
+ missingRequired.push(key)
23
+ }
24
+ }
25
+
26
+ if (missingRequired.length > 0) {
27
+ errors.push({
28
+ code: 'MISSING_REQUIRED',
29
+ keys: missingRequired,
30
+ message: `missing required (${missingRequired.join(', ')})`
31
+ })
32
+ }
33
+
34
+ return {
35
+ valid: errors.length === 0,
36
+ errors
37
+ }
38
+ }
39
+
40
+ module.exports = validate
@@ -0,0 +1,24 @@
1
+ const fs = require('fs')
2
+ const { scan } = require('@dotenvx/primitives')
3
+
4
+ const Errors = require('./errors')
5
+ const validate = require('./validate')
6
+
7
+ function validateEnvExample (env = process.env, options = {}) {
8
+ const filepath = options.filepath || '.env.example'
9
+
10
+ if (!fs.existsSync(filepath)) {
11
+ return new Errors().missingEnvExample()
12
+ }
13
+
14
+ const exampleSrc = fs.readFileSync(filepath, 'utf8')
15
+ const { parsed: example, comments } = scan(exampleSrc)
16
+ const validation = validate(example, env, { comments })
17
+
18
+ if (!validation.valid) {
19
+ const message = validation.errors.map(error => error.message).join('; ')
20
+ return new Errors({ message }).validationFailed()
21
+ }
22
+ }
23
+
24
+ module.exports = validateEnvExample
package/src/lib/main.d.ts CHANGED
@@ -194,6 +194,22 @@ export interface DotenvConfigOptions {
194
194
  */
195
195
  noNative?: boolean;
196
196
 
197
+ /**
198
+ * Turn off 1Password secret reference resolution.
199
+ *
200
+ * @default false
201
+ * @example require('@dotenvx/dotenvx').config({ no1Password: true })
202
+ */
203
+ no1Password?: boolean;
204
+
205
+ /**
206
+ * Turn off Bitwarden secret reference resolution.
207
+ *
208
+ * @default false
209
+ * @example require('@dotenvx/dotenvx').config({ noBitwarden: true })
210
+ */
211
+ noBitwarden?: boolean;
212
+
197
213
  }
198
214
 
199
215
  export type DotenvConfigEnv =
@@ -370,6 +386,22 @@ export interface GetOptions {
370
386
  */
371
387
  noNative?: boolean;
372
388
 
389
+ /**
390
+ * Turn off 1Password secret reference resolution.
391
+ *
392
+ * @default false
393
+ * @example require('@dotenvx/dotenvx').get('KEY', { no1Password: true })
394
+ */
395
+ no1Password?: boolean;
396
+
397
+ /**
398
+ * Turn off Bitwarden secret reference resolution.
399
+ *
400
+ * @default false
401
+ * @example require('@dotenvx/dotenvx').get('KEY', { noBitwarden: true })
402
+ */
403
+ noBitwarden?: boolean;
404
+
373
405
  }
374
406
 
375
407
  /**
package/src/lib/main.js CHANGED
@@ -87,6 +87,8 @@ const config = function (options = {}) {
87
87
  envKeysFile,
88
88
  noArmor,
89
89
  noKeychain,
90
+ no1Password: options.no1Password,
91
+ noBitwarden: options.noBitwarden,
90
92
  noSpinner: options.noSpinner,
91
93
  token: options.token
92
94
  })
@@ -331,7 +333,9 @@ const get = async function (key, options = {}) {
331
333
  all: options.all,
332
334
  envKeysFile: options.envKeysFile,
333
335
  noArmor,
334
- noKeychain
336
+ noKeychain,
337
+ no1Password: options.no1Password,
338
+ noBitwarden: options.noBitwarden
335
339
  })
336
340
 
337
341
  if (options.mask !== undefined) {
@@ -12,6 +12,8 @@ const keynames = require('./../conventions/keynames')
12
12
  const providers = require('./../providers')
13
13
  const decryptors = require('./../decryptors')
14
14
  const parseWithDecryptor = require('./../helpers/parseWithDecryptor')
15
+ const resolveOnePassword = require('./../helpers/resolveOnePassword')
16
+ const resolveBitwardenPassword = require('./../helpers/resolveBitwardenPassword')
15
17
 
16
18
  function unresolvedEncryptedErrors (parsed) {
17
19
  const keys = []
@@ -70,7 +72,7 @@ function buildParseOptions ({ processEnv, overload, envKeysFilepath, provider, d
70
72
  return options
71
73
  }
72
74
 
73
- async function injectEnv ({ env, overload, processEnv, envKeysFilepath, provider, decryptor }) {
75
+ async function injectEnv ({ env, overload, processEnv, envKeysFilepath, provider, decryptor, no1Password, noBitwarden }) {
74
76
  const row = {}
75
77
  row.type = TYPE_ENV
76
78
  row.string = env.value
@@ -101,6 +103,21 @@ async function injectEnv ({ env, overload, processEnv, envKeysFilepath, provider
101
103
  row.injected = injected || {}
102
104
  row.existed = existed || {}
103
105
 
106
+ if (!no1Password) {
107
+ const result = await resolveOnePassword(row.injected)
108
+ row.errors.push(...result.errors)
109
+ for (const key of result.unresolved) delete row.parsed[key]
110
+ Object.assign(row.parsed, row.injected)
111
+ }
112
+
113
+ if (!noBitwarden) {
114
+ const passwordResult = await resolveBitwardenPassword(row.injected)
115
+ row.errors.push(...passwordResult.errors)
116
+ for (const key of passwordResult.unresolved) delete row.parsed[key]
117
+
118
+ Object.assign(row.parsed, row.injected)
119
+ }
120
+
104
121
  inject(processEnv, row.parsed)
105
122
  } catch (e) {
106
123
  row.errors = [e]
@@ -109,7 +126,7 @@ async function injectEnv ({ env, overload, processEnv, envKeysFilepath, provider
109
126
  return row
110
127
  }
111
128
 
112
- function injectEnvSync ({ env, overload, processEnv, envKeysFilepath, provider, decryptor }) {
129
+ function injectEnvSync ({ env, overload, processEnv, envKeysFilepath, provider, decryptor, no1Password, noBitwarden }) {
113
130
  const row = {}
114
131
  row.type = TYPE_ENV
115
132
  row.string = env.value
@@ -140,6 +157,21 @@ function injectEnvSync ({ env, overload, processEnv, envKeysFilepath, provider,
140
157
  row.injected = injected || {}
141
158
  row.existed = existed || {}
142
159
 
160
+ if (!no1Password) {
161
+ const result = resolveOnePassword.sync(row.injected)
162
+ row.errors.push(...result.errors)
163
+ for (const key of result.unresolved) delete row.parsed[key]
164
+ Object.assign(row.parsed, row.injected)
165
+ }
166
+
167
+ if (!noBitwarden) {
168
+ const passwordResult = resolveBitwardenPassword.sync(row.injected)
169
+ row.errors.push(...passwordResult.errors)
170
+ for (const key of passwordResult.unresolved) delete row.parsed[key]
171
+
172
+ Object.assign(row.parsed, row.injected)
173
+ }
174
+
143
175
  inject(processEnv, row.parsed)
144
176
  } catch (e) {
145
177
  row.errors = [e]
@@ -148,7 +180,7 @@ function injectEnvSync ({ env, overload, processEnv, envKeysFilepath, provider,
148
180
  return row
149
181
  }
150
182
 
151
- async function injectEnvFile ({ env, overload, processEnv, envKeysFilepath, provider, decryptor, readableFilepaths }) {
183
+ async function injectEnvFile ({ env, overload, processEnv, envKeysFilepath, provider, decryptor, readableFilepaths, no1Password, noBitwarden }) {
152
184
  const row = {}
153
185
  row.type = TYPE_ENV_FILE
154
186
  row.filepath = env.value
@@ -182,6 +214,21 @@ async function injectEnvFile ({ env, overload, processEnv, envKeysFilepath, prov
182
214
  row.errors = decryptErrors(parsed, errors)
183
215
  row.existed = existed || {}
184
216
 
217
+ if (!no1Password) {
218
+ const result = await resolveOnePassword(row.injected)
219
+ row.errors.push(...result.errors)
220
+ for (const key of result.unresolved) delete row.parsed[key]
221
+ Object.assign(row.parsed, row.injected)
222
+ }
223
+
224
+ if (!noBitwarden) {
225
+ const passwordResult = await resolveBitwardenPassword(row.injected)
226
+ row.errors.push(...passwordResult.errors)
227
+ for (const key of passwordResult.unresolved) delete row.parsed[key]
228
+
229
+ Object.assign(row.parsed, row.injected)
230
+ }
231
+
185
232
  inject(processEnv, parsed)
186
233
  } catch (e) {
187
234
  if (e.code === 'ENOENT' || e.code === 'EISDIR') {
@@ -194,7 +241,7 @@ async function injectEnvFile ({ env, overload, processEnv, envKeysFilepath, prov
194
241
  return row
195
242
  }
196
243
 
197
- function injectEnvFileSync ({ env, overload, processEnv, envKeysFilepath, provider, decryptor, readableFilepaths }) {
244
+ function injectEnvFileSync ({ env, overload, processEnv, envKeysFilepath, provider, decryptor, readableFilepaths, no1Password, noBitwarden }) {
198
245
  const row = {}
199
246
  row.type = TYPE_ENV_FILE
200
247
  row.filepath = env.value
@@ -228,6 +275,21 @@ function injectEnvFileSync ({ env, overload, processEnv, envKeysFilepath, provid
228
275
  row.errors = decryptErrors(parsed, errors)
229
276
  row.existed = existed || {}
230
277
 
278
+ if (!no1Password) {
279
+ const result = resolveOnePassword.sync(row.injected)
280
+ row.errors.push(...result.errors)
281
+ for (const key of result.unresolved) delete row.parsed[key]
282
+ Object.assign(row.parsed, row.injected)
283
+ }
284
+
285
+ if (!noBitwarden) {
286
+ const passwordResult = resolveBitwardenPassword.sync(row.injected)
287
+ row.errors.push(...passwordResult.errors)
288
+ for (const key of passwordResult.unresolved) delete row.parsed[key]
289
+
290
+ Object.assign(row.parsed, row.injected)
291
+ }
292
+
231
293
  inject(processEnv, parsed)
232
294
  } catch (e) {
233
295
  if (e.code === 'ENOENT' || e.code === 'EISDIR') {
@@ -247,7 +309,6 @@ async function envs (options = {}) {
247
309
  const envKeysFilepath = options.envKeysFilepath || options.envKeysFile || null
248
310
  const provider = await providers(options)
249
311
  const decryptor = await decryptors(options)
250
-
251
312
  for (const env of options.envs || []) {
252
313
  if (env.type === TYPE_ENV_FILE) {
253
314
  processedEnvs.push(await injectEnvFile({
@@ -257,7 +318,9 @@ async function envs (options = {}) {
257
318
  envKeysFilepath,
258
319
  provider,
259
320
  decryptor,
260
- readableFilepaths
321
+ readableFilepaths,
322
+ no1Password: options.no1Password,
323
+ noBitwarden: options.noBitwarden
261
324
  }))
262
325
  } else if (env.type === TYPE_ENV) {
263
326
  processedEnvs.push(await injectEnv({
@@ -266,7 +329,9 @@ async function envs (options = {}) {
266
329
  processEnv,
267
330
  envKeysFilepath,
268
331
  provider,
269
- decryptor
332
+ decryptor,
333
+ no1Password: options.no1Password,
334
+ noBitwarden: options.noBitwarden
270
335
  }))
271
336
  }
272
337
  }
@@ -294,7 +359,9 @@ function envsSync (options = {}) {
294
359
  envKeysFilepath,
295
360
  provider,
296
361
  decryptor,
297
- readableFilepaths
362
+ readableFilepaths,
363
+ no1Password: options.no1Password,
364
+ noBitwarden: options.noBitwarden
298
365
  }))
299
366
  } else if (env.type === TYPE_ENV) {
300
367
  processedEnvs.push(injectEnvSync({
@@ -303,7 +370,9 @@ function envsSync (options = {}) {
303
370
  processEnv,
304
371
  envKeysFilepath,
305
372
  provider,
306
- decryptor
373
+ decryptor,
374
+ no1Password: options.no1Password,
375
+ noBitwarden: options.noBitwarden
307
376
  }))
308
377
  }
309
378
  }
@@ -51,6 +51,8 @@ function buildOptions (options, processEnv) {
51
51
  envKeysFilepath: options.envKeysFilepath || options.envKeysFile || null,
52
52
  noArmor: options.noArmor,
53
53
  noKeychain: options.noKeychain,
54
+ no1Password: options.no1Password,
55
+ noBitwarden: options.noBitwarden,
54
56
  onStatus: options.onStatus
55
57
  }
56
58
  }