Skip to content

Commit 07a3a44

Browse files
authored
Merge pull request #131 from motdotla/better-expansion
12.0.0 better expansion to match dotenvx's
2 parents 3213210 + a3b31a8 commit 07a3a44

File tree

5 files changed

+109
-78
lines changed

5 files changed

+109
-78
lines changed

CHANGELOG.md

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,19 @@
22

33
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.
44

5-
## [Unreleased](https://github.com/motdotla/dotenv-expand/compare/v11.0.7...master)
5+
## [Unreleased](https://github.com/motdotla/dotenv-expand/compare/v12.0.0...master)
6+
7+
## [12.0.0](https://github.com/motdotla/dotenv-expand/compare/v11.0.7...v12.0.0) (2024-11-16)
8+
9+
### Added
10+
11+
* 🎉 support alternate value expansion (see [usage](https://dotenvx.com/docs/env-file#interpolation)) ([#131](https://github.com/motdotla/dotenv-expand/pull/131))
12+
13+
### Changed
14+
15+
* 🎉 Expansion logic rewritten to match [dotenvx's](https://github.com/dotenvx/dotenvx). (*note: I recommend dotenvx over dotenv-expand when you are ready. I'm putting all my effort there for a unified standard .env implementation that works everywhere and matches bash, docker-compose, and more. In some cases it slightly improves on them. This leads to more reliability for your secrets and config.) ([#131](https://github.com/motdotla/dotenv-expand/pull/131))
16+
* ⚠️ BREAKING: do NOT expand in reverse order. Instead, order your .env file keys from first to last as they depend on each other for expansion - principle of least surprise. ([#131](https://github.com/motdotla/dotenv-expand/pull/131))
17+
* ⚠️ BREAKING: do NOT attempt expansion of process.env. This has always been dangerous (unexpected side effects) and is now removed. process.env should not hold values you want to expand. Put expansion logic in your .env file. If you need this ability, use [dotenvx](https://github.com/dotenvx/dotenvx) by shipping an encrypted .env file with your code - allowing safe expansion at runtime. ([#131](https://github.com/motdotla/dotenv-expand/pull/131))
618

719
## [11.0.7](https://github.com/motdotla/dotenv-expand/compare/v11.0.6...v11.0.7) (2024-11-13)
820

README.md

Lines changed: 4 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
<div align="center">
2-
🎉 announcing <a href="https://github.com/dotenvx/dotenvx">dotenvx</a>. <em><b>better expansion</b>, run anywhere, multi-environment, encrypted envs</em>.
2+
🎉 announcing <a href="https://github.com/dotenvx/dotenvx">dotenvx</a>. <em><b>expansion AND command substitution</b>, multi-environment, encrypted envs, and more</em>.
33
</div>
44

55
&nbsp;
@@ -166,28 +166,13 @@ console.log(process.env.HELLO) // undefined
166166

167167
### What rules does the expansion engine follow?
168168

169-
The expansion engine roughly has the following rules:
170-
171-
* `$KEY` will expand any env with the name `KEY`
172-
* `${KEY}` will expand any env with the name `KEY`
173-
* `\$KEY` will escape the `$KEY` rather than expand
174-
* `${KEY:-default}` will first attempt to expand any env with the name `KEY`. If not one, then it will return `default`
175-
* `${KEY-default}` will first attempt to expand any env with the name `KEY`. If not one, then it will return `default`
176-
177-
You can see a full list of rules [here](https://dotenvx.com/docs/env-file#interpolation).
169+
See a full list of rules [here](https://dotenvx.com/docs/env-file#interpolation).
178170

179171
### How can I avoid expanding pre-existing envs (already in my `process.env`, for example `pas$word`)?
180172

181-
Modify your `dotenv.config` to write to an empty object and pass that to `dotenvExpand.processEnv`.
182-
183-
```js
184-
const dotenv = require('dotenv')
185-
const dotenvExpand = require('dotenv-expand')
186-
187-
const myEnv = dotenv.config({ processEnv: {} }) // prevent writing to `process.env`
173+
As of `v12.0.0` dotenv-expand no longer expands `process.env`.
188174

189-
dotenvExpand.expand(myEnv)
190-
```
175+
If you need this ability, use [dotenvx](https://github.com/dotenvx/dotenvx) by shipping an encrypted .env file with your code - allowing safe expansion at runtime.
191176

192177
## Contributing Guide
193178

lib/main.js

Lines changed: 62 additions & 50 deletions
Original file line numberDiff line numberDiff line change
@@ -1,79 +1,91 @@
11
'use strict'
22

3-
// * /
4-
// * (\\)? # is it escaped with a backslash?
5-
// * (\$) # literal $
6-
// * (?!\() # shouldnt be followed by parenthesis
7-
// * (\{?) # first brace wrap opening
8-
// * ([\w.]+) # key
9-
// * (?::-((?:\$\{(?:\$\{(?:\$\{[^}]*\}|[^}])*}|[^}])*}|[^}])+))? # optional default nested 3 times
10-
// * (\}?) # last brace warp closing
11-
// * /xi
12-
13-
const DOTENV_SUBSTITUTION_REGEX = /(\\)?(\$)(?!\()(\{?)([\w.]+)(?::?-((?:\$\{(?:\$\{(?:\$\{[^}]*\}|[^}])*}|[^}])*}|[^}])+))?(\}?)/gi
14-
153
function _resolveEscapeSequences (value) {
164
return value.replace(/\\\$/g, '$')
175
}
186

19-
function interpolate (value, processEnv, parsed) {
20-
return value.replace(DOTENV_SUBSTITUTION_REGEX, (match, escaped, dollarSign, openBrace, key, defaultValue, closeBrace) => {
21-
if (escaped === '\\') {
22-
return match.slice(1)
23-
} else {
24-
if (processEnv[key]) {
25-
if (processEnv[key] === parsed[key]) {
26-
return processEnv[key]
27-
} else {
28-
// scenario: PASSWORD_EXPAND_NESTED=${PASSWORD_EXPAND}
29-
return interpolate(processEnv[key], processEnv, parsed)
30-
}
31-
}
7+
function expandValue (value, processEnv, runningParsed) {
8+
const env = { ...runningParsed, ...processEnv } // process.env wins
329

33-
if (parsed[key]) {
34-
// avoid recursion from EXPAND_SELF=$EXPAND_SELF
35-
if (parsed[key] !== value) {
36-
return interpolate(parsed[key], processEnv, parsed)
37-
}
38-
}
10+
const regex = /(?<!\\)\${([^{}]+)}|(?<!\\)\$([A-Za-z_][A-Za-z0-9_]*)/g
11+
12+
let result = value
13+
let match
14+
const seen = new Set() // self-referential checker
15+
16+
while ((match = regex.exec(result)) !== null) {
17+
seen.add(result)
18+
19+
const [template, bracedExpression, unbracedExpression] = match
20+
const expression = bracedExpression || unbracedExpression
21+
22+
// match the operators `:+`, `+`, `:-`, and `-`
23+
const opRegex = /(:\+|\+|:-|-)/
24+
// find first match
25+
const opMatch = expression.match(opRegex)
26+
const splitter = opMatch ? opMatch[0] : null
27+
28+
const r = expression.split(splitter)
29+
30+
let defaultValue
31+
let value
32+
33+
const key = r.shift()
34+
35+
if ([':+', '+'].includes(splitter)) {
36+
defaultValue = env[key] ? r.join(splitter) : ''
37+
value = null
38+
} else {
39+
defaultValue = r.join(splitter)
40+
value = env[key]
41+
}
3942

40-
if (defaultValue) {
41-
if (defaultValue.startsWith('$')) {
42-
return interpolate(defaultValue, processEnv, parsed)
43-
} else {
44-
return defaultValue
45-
}
43+
if (value) {
44+
// self-referential check
45+
if (seen.has(value)) {
46+
result = result.replace(template, defaultValue)
47+
} else {
48+
result = result.replace(template, value)
4649
}
50+
} else {
51+
result = result.replace(template, defaultValue)
52+
}
4753

48-
return ''
54+
// if the result equaled what was in process.env and runningParsed then stop expanding
55+
if (result === processEnv[key] && result === runningParsed[key]) {
56+
break
4957
}
50-
})
58+
59+
regex.lastIndex = 0 // reset regex search position to re-evaluate after each replacement
60+
}
61+
62+
return result
5163
}
5264

5365
function expand (options) {
66+
// for use with progressive expansion
67+
const runningParsed = {}
68+
5469
let processEnv = process.env
5570
if (options && options.processEnv != null) {
5671
processEnv = options.processEnv
5772
}
5873

74+
// dotenv.config() ran before this so the assumption is process.env has already been set
5975
for (const key in options.parsed) {
6076
let value = options.parsed[key]
6177

62-
const inProcessEnv = Object.prototype.hasOwnProperty.call(processEnv, key)
63-
if (inProcessEnv) {
64-
if (processEnv[key] === options.parsed[key]) {
65-
// assume was set to processEnv from the .env file if the values match and therefore interpolate
66-
value = interpolate(value, processEnv, options.parsed)
67-
} else {
68-
// do not interpolate - assume processEnv had the intended value even if containing a $.
69-
value = processEnv[key]
70-
}
78+
// short-circuit scenario: process.env was already set prior to the file value
79+
if (processEnv[key] && processEnv[key] !== value) {
80+
value = processEnv[key]
7181
} else {
72-
// not inProcessEnv so assume interpolation for this .env key
73-
value = interpolate(value, processEnv, options.parsed)
82+
value = expandValue(value, processEnv, runningParsed)
7483
}
7584

7685
options.parsed[key] = _resolveEscapeSequences(value)
86+
87+
// for use with progressive expansion
88+
runningParsed[key] = _resolveEscapeSequences(value)
7789
}
7890

7991
for (const processKey in options.parsed) {

tests/.env.test

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -79,3 +79,6 @@ PASSWORD_EXPAND=${PASSWORD}
7979
PASSWORD_EXPAND_SIMPLE=$PASSWORD
8080
PASSWORD_EXPAND_NESTED=${PASSWORD_EXPAND}
8181
PASSWORD_EXPAND_NESTED_NESTED=${PASSWORD_EXPAND_NESTED}
82+
83+
USE_IF_SET=true
84+
ALTERNATE=${USE_IF_SET:+alternate}

tests/main.js

Lines changed: 27 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -404,8 +404,8 @@ t.test('should expand with default value correctly', ct => {
404404

405405
ct.equal(parsed.UNDEFINED_EXPAND_DEFAULT_SPECIAL_CHARACTERS, '/default/path:with/colon')
406406
ct.equal(parsed.UNDEFINED_EXPAND_DEFAULT_SPECIAL_CHARACTERS2, '/default/path:with/colon')
407-
ct.equal(parsed.NO_CURLY_BRACES_UNDEFINED_EXPAND_DEFAULT_SPECIAL_CHARACTERS, '/default/path:with/colon')
408-
ct.equal(parsed.NO_CURLY_BRACES_UNDEFINED_EXPAND_DEFAULT_SPECIAL_CHARACTERS2, '/default/path:with/colon')
407+
ct.equal(parsed.NO_CURLY_BRACES_UNDEFINED_EXPAND_DEFAULT_SPECIAL_CHARACTERS, ':-/default/path:with/colon')
408+
ct.equal(parsed.NO_CURLY_BRACES_UNDEFINED_EXPAND_DEFAULT_SPECIAL_CHARACTERS2, '-/default/path:with/colon')
409409

410410
ct.end()
411411
})
@@ -454,7 +454,7 @@ t.test('handles two dollar signs', ct => {
454454
const dotenv = require('dotenv').config({ path: 'tests/.env.test', processEnv: {} })
455455
const parsed = dotenvExpand.expand(dotenv).parsed
456456

457-
ct.equal(parsed.TWO_DOLLAR_SIGNS, 'abcd$')
457+
ct.equal(parsed.TWO_DOLLAR_SIGNS, 'abcd$$1234')
458458

459459
ct.end()
460460
})
@@ -535,7 +535,7 @@ t.test('expands recursively', ct => {
535535
ct.end()
536536
})
537537

538-
t.test('expands recursively reverse order', ct => {
538+
t.test('CANNOT expand recursively reverse order (ORDER YOUR .env file for least surprise)', ct => {
539539
const dotenv = {
540540
parsed: {
541541
BACKEND_API_HEALTH_CHECK_URL: '${MOCK_SERVER_HOST}/ci-health-check',
@@ -546,8 +546,8 @@ t.test('expands recursively reverse order', ct => {
546546
const parsed = dotenvExpand.expand(dotenv).parsed
547547

548548
ct.equal(parsed.MOCK_SERVER_PORT, '8090')
549-
ct.equal(parsed.MOCK_SERVER_HOST, 'http://localhost:8090')
550-
ct.equal(parsed.BACKEND_API_HEALTH_CHECK_URL, 'http://localhost:8090/ci-health-check')
549+
ct.equal(parsed.MOCK_SERVER_HOST, 'http://localhost:')
550+
ct.equal(parsed.BACKEND_API_HEALTH_CHECK_URL, '/ci-health-check')
551551

552552
ct.end()
553553
})
@@ -571,11 +571,30 @@ t.test('expands recursively but is smart enough to not attempt expansion of a pr
571571
const dotenv = require('dotenv').config({ path: 'tests/.env.test' })
572572
dotenvExpand.expand(dotenv)
573573

574+
ct.equal(process.env.PASSWORD, 'pas$word')
574575
ct.equal(process.env.PASSWORD_EXPAND, 'pas$word')
575576
ct.equal(process.env.PASSWORD_EXPAND_SIMPLE, 'pas$word')
576-
ct.equal(process.env.PASSWORD, 'pas$word')
577-
ct.equal(process.env.PASSWORD_EXPAND_NESTED, 'pas$word')
578577
ct.equal(process.env.PASSWORD_EXPAND_NESTED, 'pas$word')
578+
ct.equal(process.env.PASSWORD_EXPAND_NESTED_NESTED, 'pas$word')
579+
580+
ct.end()
581+
})
582+
583+
t.test('expands alternate logic', ct => {
584+
const dotenv = require('dotenv').config({ path: 'tests/.env.test' })
585+
dotenvExpand.expand(dotenv)
586+
587+
ct.equal(process.env.ALTERNATE, 'alternate')
588+
589+
ct.end()
590+
})
591+
592+
t.test('expands alternate logic when not set', ct => {
593+
process.env.USE_IF_SET = ''
594+
const dotenv = require('dotenv').config({ path: 'tests/.env.test' })
595+
dotenvExpand.expand(dotenv)
596+
597+
ct.equal(process.env.ALTERNATE, '')
579598

580599
ct.end()
581600
})

0 commit comments

Comments
 (0)