@dotenvx/dotenvx 2.9.0 → 2.10.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,13 +2,19 @@
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.9.0...main)
5
+ [Unreleased](https://github.com/dotenvx/dotenvx/compare/v2.10.0...main)
6
+
7
+ ## [2.10.0](https://github.com/dotenvx/dotenvx/compare/v2.9.0...v2.10.0) (2026-07-15)
8
+
9
+ ### Added
10
+
11
+ * Add `run --redact` to keep secrets out of command output. ([#894](https://github.com/dotenvx/dotenvx/pull/894))
6
12
 
7
13
  ## [2.9.0](https://github.com/dotenvx/dotenvx/compare/v2.8.0...v2.9.0) (2026-07-14)
8
14
 
9
15
  ### Changed
10
16
 
11
- * BREAKING: `ls` from `@dotenvx/dotenvx` is now async/await.
17
+ * BREAKING: `ls` from `@dotenvx/dotenvx` is now async/await. ([#893](https://github.com/dotenvx/dotenvx/pull/893))
12
18
 
13
19
  ## [2.8.0](https://github.com/dotenvx/dotenvx/compare/v2.7.3...v2.8.0) (2026-07-14)
14
20
 
package/README.md CHANGED
@@ -1471,6 +1471,24 @@ $ dotenvx set HELLO Dotenvx -fk .env.keys -f apps/app1/.env
1471
1471
  $ dotenvx run -fk .env.keys -f apps/app1/.env -- yourcommand
1472
1472
  ```
1473
1473
 
1474
+ </details>
1475
+ <details><summary>`run --redact`</summary><br>
1476
+
1477
+ Redact successfully decrypted values from the command's stdout and stderr. The command still receives the real values; only its output is filtered.
1478
+
1479
+ ```sh
1480
+ $ touch .env
1481
+ $ dotenvx set SECRET super-secret-value
1482
+ $ echo "console.log(process.env.SECRET)" > index.js
1483
+
1484
+ $ dotenvx run --redact --quiet -- node index.js
1485
+ [REDACTED]
1486
+ ```
1487
+
1488
+ Redaction is off by default. It applies only to values that were encrypted, successfully decrypted, and injected. Plaintext values are left unchanged. Matching is exact, so transformed or derived values are not redacted.
1489
+
1490
+ Because redaction filters stdout and stderr, interactive commands that require a TTY may behave differently.
1491
+
1474
1492
  </details>
1475
1493
  <details><summary>`run --mask`</summary><br>
1476
1494
 
@@ -2335,6 +2353,21 @@ $ dotenvx ls -ef '**/.env.prod*'
2335
2353
  └─ .env
2336
2354
  ```
2337
2355
 
2356
+ </details>
2357
+ <details><summary>`ls --json`</summary><br>
2358
+
2359
+ Print all matching `.env` files as a JSON array of absolute filepaths. Progress and summary details are written to stderr, so stdout can be safely piped to another command or file.
2360
+
2361
+ ```sh
2362
+ $ dotenvx ls --json
2363
+ [
2364
+ "/path/to/project/.env",
2365
+ "/path/to/project/apps/backend/.env"
2366
+ ]
2367
+
2368
+ $ dotenvx ls --json > dotenv-files.json
2369
+ ```
2370
+
2338
2371
  </details>
2339
2372
  <details><summary>`genexample`</summary><br>
2340
2373
 
package/package.json CHANGED
@@ -1,5 +1,5 @@
1
1
  {
2
- "version": "2.9.0",
2
+ "version": "2.10.0",
3
3
  "name": "@dotenvx/dotenvx",
4
4
  "description": "a secure dotenv–from the creator of `dotenv`",
5
5
  "author": "@motdotla",
@@ -13,6 +13,8 @@ const resolveEnvKeysFile = require('../../lib/helpers/resolveEnvKeysFile')
13
13
  const mask = require('../../lib/helpers/mask')
14
14
  const maskEnvSrc = require('../../lib/helpers/maskEnvSrc')
15
15
  const maskProcessedEnvs = require('../../lib/helpers/maskProcessedEnvs')
16
+ const decryptedValues = require('../../lib/helpers/decryptedValues')
17
+ const { redactOutput } = require('../../lib/helpers/redactOutput')
16
18
 
17
19
  const { determine } = require('./../../lib/helpers/envResolution')
18
20
 
@@ -53,11 +55,13 @@ async function run () {
53
55
  const options = normalizeDotenvConfigConvention(normalizeDotenvConfigQuiet(this.opts()))
54
56
  const spinnerOptions = typeof this.optsWithGlobals === 'function' ? this.optsWithGlobals() : options
55
57
  const maskEnabled = options.mask !== undefined
58
+ const redactEnabled = options.redact === true
56
59
  let showChar = options.mask
57
60
  if (options.mask === true) {
58
61
  showChar = 6
59
62
  }
60
63
  let commandEnv = process.env
64
+ let sensitiveValues = []
61
65
 
62
66
  let commandArgs = this.args
63
67
  if (commandArgs.length < 1) {
@@ -122,6 +126,10 @@ async function run () {
122
126
  }
123
127
  })
124
128
 
129
+ if (redactEnabled) {
130
+ sensitiveValues = decryptedValues(processedEnvs)
131
+ }
132
+
125
133
  if (maskEnabled) {
126
134
  commandEnv = { ...process.env }
127
135
  maskProcessedEnvs(processedEnvs, commandEnv, showChar)
@@ -156,18 +164,18 @@ async function run () {
156
164
  }
157
165
 
158
166
  // debug parsed
159
- logger.debug(processedEnv.parsed)
167
+ logger.debug(redactOutput(processedEnv.parsed, sensitiveValues))
160
168
 
161
169
  // verbose/debug injected key/value
162
170
  for (const [key, value] of Object.entries(processedEnv.injected || {})) {
163
171
  logger.verbose(`${key} set`)
164
- logger.debug(`${key} set to ${value}`)
172
+ logger.debug(redactOutput(`${key} set to ${value}`, sensitiveValues))
165
173
  }
166
174
 
167
175
  // verbose/debug existed key/value
168
176
  for (const [key, value] of Object.entries(processedEnv.existed || {})) {
169
177
  logger.verbose(`${key} pre-exists (protip: use --overload to override)`)
170
- logger.debug(`${key} pre-exists as ${value} (protip: use --overload to override)`)
178
+ logger.debug(redactOutput(`${key} pre-exists as ${value} (protip: use --overload to override)`, sensitiveValues))
171
179
  }
172
180
  }
173
181
 
@@ -189,7 +197,7 @@ async function run () {
189
197
  process.exit(1)
190
198
  }
191
199
 
192
- await executeCommand(commandArgs, commandEnv)
200
+ await executeCommand(commandArgs, commandEnv, sensitiveValues)
193
201
  }
194
202
 
195
203
  module.exports = run
@@ -70,6 +70,7 @@ program.command('run')
70
70
  .option('--ignore <errorCodes...>', 'error code(s) to ignore (example: --ignore=MISSING_ENV_FILE)')
71
71
  .option('--token <token>', 'set Armor ⛨ token')
72
72
  .option('--mask [characters]', 'inject masked values, optionally setting visible characters')
73
+ .option('--redact', 'redact decrypted values from command output', false)
73
74
  .option('--no-armor', 'disable Dotenvx Armor features')
74
75
  .option('--no-native', 'disable OS secret store features')
75
76
  .action(function (...args) {
@@ -1,11 +1,6 @@
1
1
  const { logger } = require('./../../shared/logger')
2
- const Errors = require('./errors')
3
2
 
4
3
  function catchAndLog (error) {
5
- if (error.code === 'EACCES' || error.code === 'EPERM') {
6
- error = new Errors({ filepath: error.path }).fileNotWritable()
7
- }
8
-
9
4
  const msg = error.messageWithHelp || error.message
10
5
  if (msg) {
11
6
  logger.error(msg)
@@ -0,0 +1,32 @@
1
+ const { encrypted, scan } = require('@dotenvx/primitives')
2
+
3
+ function decryptedValues (processedEnvs) {
4
+ const result = new Set()
5
+
6
+ for (const processedEnv of processedEnvs || []) {
7
+ const src = processedEnv.src || processedEnv.string
8
+ if (!src) continue
9
+
10
+ let rawParsed
11
+ try {
12
+ rawParsed = scan(src).parsed
13
+ } catch (error) {
14
+ continue
15
+ }
16
+
17
+ for (const [key, rawValues] of Object.entries(rawParsed || {})) {
18
+ const rawValue = rawValues[rawValues.length - 1]
19
+ const injectedValue = (processedEnv.injected || {})[key]
20
+
21
+ if (!encrypted(rawValue)) continue
22
+ if (injectedValue === undefined || injectedValue === null || injectedValue === '') continue
23
+ if (encrypted(injectedValue)) continue
24
+
25
+ result.add(`${injectedValue}`)
26
+ }
27
+ }
28
+
29
+ return [...result]
30
+ }
31
+
32
+ module.exports = decryptedValues
@@ -3,8 +3,9 @@ const { which } = require('@dotenvx/tooling')
3
3
  const execute = require('./../../lib/helpers/execute')
4
4
  const { logger } = require('./../../shared/logger')
5
5
  const Errors = require('./errors')
6
+ const { createRedactedStreamWriter, redactOutput } = require('./redactOutput')
6
7
 
7
- async function executeCommand (commandArgs, env) {
8
+ async function executeCommand (commandArgs, env, sensitiveValues = []) {
8
9
  const FORWARD_SIGNAL_GRACE_MS = 1000
9
10
  const FORCE_KILL_GRACE_MS = 1000
10
11
  const signals = [
@@ -130,11 +131,27 @@ async function executeCommand (commandArgs, env) {
130
131
  }
131
132
  }
132
133
 
134
+ const redactStdout = sensitiveValues.length > 0
135
+ const redactStderr = sensitiveValues.length > 0
136
+
133
137
  child = execute.execa(commandArgs[0], commandArgs.slice(1), {
134
- stdio: 'inherit',
138
+ stdio: ['inherit', redactStdout ? 'pipe' : 'inherit', redactStderr ? 'pipe' : 'inherit'],
139
+ buffer: false,
135
140
  env: { ...process.env, ...env }
136
141
  })
137
142
 
143
+ if (redactStdout && child.stdout) {
144
+ const stdoutWriter = createRedactedStreamWriter(process.stdout, sensitiveValues, child.stdout)
145
+ child.stdout.on('data', stdoutWriter.write)
146
+ child.stdout.once('end', stdoutWriter.flush)
147
+ }
148
+
149
+ if (redactStderr && child.stderr) {
150
+ const stderrWriter = createRedactedStreamWriter(process.stderr, sensitiveValues, child.stderr)
151
+ child.stderr.on('data', stderrWriter.write)
152
+ child.stderr.once('end', stderrWriter.flush)
153
+ }
154
+
138
155
  process.on('SIGINT', sigintHandler)
139
156
  process.on('SIGTERM', sigtermHandler)
140
157
 
@@ -157,7 +174,7 @@ async function executeCommand (commandArgs, env) {
157
174
  if (error.code === 'ENOENT') {
158
175
  logger.error(`Unknown command: ${error.command}`)
159
176
  } else {
160
- logger.error(error.message)
177
+ logger.error(redactOutput(error.message, sensitiveValues))
161
178
  }
162
179
  }
163
180
 
@@ -1,4 +1,5 @@
1
1
  const fs = require('fs')
2
+ const Errors = require('./errors')
2
3
 
3
4
  const ENCODING = 'utf8'
4
5
 
@@ -19,11 +20,27 @@ function readFileXSync (filepath, encoding = null) {
19
20
  }
20
21
 
21
22
  function writeFileXSync (filepath, str) {
22
- return fs.writeFileSync(filepath, str, ENCODING) // utf8 always
23
+ try {
24
+ return fs.writeFileSync(filepath, str, ENCODING) // utf8 always
25
+ } catch (error) {
26
+ if (error.code === 'EACCES' || error.code === 'EPERM') {
27
+ throw new Errors({ filepath }).fileNotWritable()
28
+ }
29
+
30
+ throw error
31
+ }
23
32
  }
24
33
 
25
34
  async function writeFileX (filepath, str) {
26
- return fs.promises.writeFile(filepath, str, ENCODING)
35
+ try {
36
+ return await fs.promises.writeFile(filepath, str, ENCODING)
37
+ } catch (error) {
38
+ if (error.code === 'EACCES' || error.code === 'EPERM') {
39
+ throw new Errors({ filepath }).fileNotWritable()
40
+ }
41
+
42
+ throw error
43
+ }
27
44
  }
28
45
 
29
46
  async function exists (filepath) {
@@ -0,0 +1,9 @@
1
+ function redact (str) {
2
+ if (!str || str.length < 1) {
3
+ return ''
4
+ }
5
+
6
+ return '[REDACTED]'
7
+ }
8
+
9
+ module.exports = redact
@@ -0,0 +1,120 @@
1
+ const redact = require('./redact')
2
+ const { StringDecoder } = require('string_decoder')
3
+
4
+ function normalizedValues (values) {
5
+ return [...new Set((values || [])
6
+ .filter(value => value !== undefined && value !== null && `${value}`.length > 0)
7
+ .map(value => `${value}`))]
8
+ .sort((a, b) => b.length - a.length)
9
+ }
10
+
11
+ function redactOutput (value, sensitiveValues) {
12
+ const values = normalizedValues(sensitiveValues)
13
+ if (values.length < 1 || value === undefined || value === null) return value
14
+
15
+ if (Array.isArray(value)) {
16
+ return value.map(item => redactOutput(item, values))
17
+ }
18
+
19
+ if (typeof value === 'object' && Object.getPrototypeOf(value) === Object.prototype) {
20
+ const result = {}
21
+ for (const [key, item] of Object.entries(value)) {
22
+ result[key] = redactOutput(item, values)
23
+ }
24
+ return result
25
+ }
26
+
27
+ if (typeof value !== 'string') return value
28
+
29
+ let result = value
30
+ for (const sensitiveValue of values) {
31
+ result = result.split(sensitiveValue).join(redact(sensitiveValue))
32
+ }
33
+ return result
34
+ }
35
+
36
+ function partialMatchLength (value, sensitiveValues) {
37
+ let longest = 0
38
+
39
+ for (const sensitiveValue of sensitiveValues) {
40
+ const maxLength = Math.min(value.length, sensitiveValue.length - 1)
41
+ for (let length = maxLength; length > longest; length--) {
42
+ if (sensitiveValue.startsWith(value.slice(-length))) {
43
+ longest = length
44
+ break
45
+ }
46
+ }
47
+ }
48
+
49
+ return longest
50
+ }
51
+
52
+ function safeBoundary (value, boundary, sensitiveValues) {
53
+ let result = boundary
54
+ let changed = true
55
+
56
+ while (changed) {
57
+ changed = false
58
+ for (const sensitiveValue of sensitiveValues) {
59
+ let index = value.indexOf(sensitiveValue)
60
+ while (index !== -1) {
61
+ const end = index + sensitiveValue.length
62
+ if (index < result && end > result) {
63
+ result = index
64
+ changed = true
65
+ }
66
+ index = value.indexOf(sensitiveValue, index + 1)
67
+ }
68
+ }
69
+ }
70
+
71
+ return result
72
+ }
73
+
74
+ function createRedactedStreamWriter (stream, sensitiveValues, source) {
75
+ const values = normalizedValues(sensitiveValues)
76
+ const decoder = new StringDecoder('utf8')
77
+ let pending = ''
78
+ let waitingForDrain = false
79
+
80
+ const writeToStream = (value) => {
81
+ if (!value) return
82
+
83
+ const canContinue = stream.write(redactOutput(value, values))
84
+ if (!canContinue && source && !waitingForDrain) {
85
+ waitingForDrain = true
86
+ source.pause()
87
+ stream.once('drain', () => {
88
+ waitingForDrain = false
89
+ source.resume()
90
+ })
91
+ }
92
+ }
93
+
94
+ const flush = () => {
95
+ pending += decoder.end()
96
+ if (!pending) return
97
+ writeToStream(pending)
98
+ pending = ''
99
+ }
100
+
101
+ const write = (chunk) => {
102
+ pending += Buffer.isBuffer(chunk) ? decoder.write(chunk) : `${chunk}`
103
+
104
+ const holdbackLength = partialMatchLength(pending, values)
105
+ let boundary = pending.length - holdbackLength
106
+ boundary = safeBoundary(pending, boundary, values)
107
+
108
+ const output = pending.slice(0, boundary)
109
+ pending = pending.slice(boundary)
110
+
111
+ writeToStream(output)
112
+ }
113
+
114
+ return { write, flush }
115
+ }
116
+
117
+ module.exports = {
118
+ redactOutput,
119
+ createRedactedStreamWriter
120
+ }