diff --git a/CHANGELOG.md b/CHANGELOG.md index 4463397..3319046 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,7 +4,7 @@ All notable changes to this project will be documented in this file. See [standa ## [Unreleased](https://github.com/motdotla/dotenv-expand/compare/v11.0.0...master) -## [11.0.0](https://github.com/motdotla/dotenv-expand/compare/v10.0.0...v11.0.0) (2024-02-08) +## [11.0.0](https://github.com/motdotla/dotenv-expand/compare/v10.0.0...v11.0.0) (2024-02-10) ### Added @@ -15,6 +15,7 @@ All notable changes to this project will be documented in this file. See [standa ### Changed - Do not expand prior `process.env` environment variables. NOTE: make sure to see updated README regarding `dotenv.config({ processEnv: {} })` ([#104](https://github.com/motdotla/dotenv-expand/pull/104)) +- 🐞 handle `$var1$var2` ([#103](https://github.com/motdotla/dotenv-expand/issues/103), [#104](https://github.com/motdotla/dotenv-expand/pull/104)) ### Removed diff --git a/lib/main.js b/lib/main.js index 567e7f3..6e42ca9 100644 --- a/lib/main.js +++ b/lib/main.js @@ -1,57 +1,93 @@ 'use strict' -// like String.prototype.search but returns the last index -function _searchLast (str, rgx) { - const matches = Array.from(str.matchAll(rgx)) - return matches.length > 0 ? matches.slice(-1)[0].index : -1 -} - -function _interpolate (value, processEnv, parsed) { - // find the last unescaped dollar sign in the value to evaluate - const lastUnescapedDollarSignIndex = _searchLast(value, /(?!(?<=\\))\$/g) - - // return early unless unescaped dollar sign - if (lastUnescapedDollarSignIndex === -1) { - return value - } - - // This is the right-most group of variables in the string - const rightMostGroup = value.slice(lastUnescapedDollarSignIndex) - - /** - * This finds the inner most variable/group divided - * by variable name and default value (if present) - * ( - * (?!(?<=\\))\$ // only match dollar signs that are not escaped - * {? // optional opening curly brace - * ([\w.]+) // match the variable name - * (?::-([^}\\]*))? // match an optional default value - * }? // optional closing curly brace - * ) - */ - const matchGroup = /((?!(?<=\\))\${?([\w.]+)(?::-([^}\\]*))?}?)/ - const match = rightMostGroup.match(matchGroup) - - if (match != null) { - const [, group, key, defaultValue] = match - const replacementString = processEnv[key] || defaultValue || parsed[key] || '' - const modifiedValue = value.replace(group, replacementString) - - // return early for scenario like processEnv.PASSWORD = 'pas$word' - if (processEnv[key] && modifiedValue === processEnv[key]) { - return modifiedValue - } - - return _interpolate(modifiedValue, processEnv, parsed) - } - - return value -} +// // like String.prototype.search but returns the last index +// function _searchLast (str, rgx) { +// const matches = Array.from(str.matchAll(rgx)) +// return matches.length > 0 ? matches.slice(-1)[0].index : -1 +// } +// +// function _interpolate (value, processEnv, parsed) { +// // find the last unescaped dollar sign in the value to evaluate +// const lastUnescapedDollarSignIndex = _searchLast(value, /(?!(?<=\\))\$/g) +// +// // return early unless unescaped dollar sign +// if (lastUnescapedDollarSignIndex === -1) { +// return value +// } +// +// // This is the right-most group of variables in the string +// const rightMostGroup = value.slice(lastUnescapedDollarSignIndex) +// +// console.log('rightMostGroup', rightMostGroup) +// +// /** +// * This finds the inner most variable/group divided +// * by variable name and default value (if present) +// * ( +// * (?!(?<=\\))\$ // only match dollar signs that are not escaped +// * {? // optional opening curly brace +// * ([\w.]+) // match the variable name +// * (?::-([^}\\]*))? // match an optional default value +// * }? // optional closing curly brace +// * ) +// */ +// const matchGroup = /((?!(?<=\\))\${?([\w.]+)(?::-([^}\\]*))?}?)/ +// const match = rightMostGroup.match(matchGroup) +// +// if (match != null) { +// const [, group, key, defaultValue] = match +// const replacementString = processEnv[key] || defaultValue || parsed[key] || '' +// const modifiedValue = value.replace(group, replacementString) +// +// // return early for scenario like processEnv.PASSWORD = 'pas$word' +// if (processEnv[key] && modifiedValue === processEnv[key]) { +// return modifiedValue +// } +// +// return _interpolate(modifiedValue, processEnv, parsed) +// } +// +// return value +// } function _resolveEscapeSequences (value) { return value.replace(/\\\$/g, '$') } +function interpolate (value, processEnv, parsed) { + // * / + // * (\\)? # is it escaped with a backslash? + // * (\$) # literal $ + // * (?!\() # shouldnt be followed by parenthesis + // * (\{?) # first brace wrap opening + // * ([\w.]+) # key + // * (?::-((?:\$\{(?:\$\{(?:\$\{[^}]*\}|[^}])*}|[^}])*}|[^}])+))? # optional default nested 3 times + // * (\}?) # last brace warp closing + // * /xi + + const SUB_REGEX = /(\\)?(\$)(?!\()(\{?)([\w.]+)(?::-((?:\$\{(?:\$\{(?:\$\{[^}]*\}|[^}])*}|[^}])*}|[^}])+))?(\}?)/gi + + return value.replace(SUB_REGEX, (match, escaped, dollarSign, openBrace, key, defaultValue, closeBrace) => { + if (escaped === '\\') { + return match.slice(1) + } else { + if (processEnv[key]) { + return processEnv[key] + } + + if (defaultValue) { + if (defaultValue.startsWith('$')) { + return interpolate(defaultValue, processEnv, parsed) + } else { + return defaultValue + } + } + + return parsed[key] || '' + } + }) +} + function expand (options) { let processEnv = process.env if (options && options.processEnv != null) { @@ -61,11 +97,11 @@ function expand (options) { for (const key in options.parsed) { let value = options.parsed[key] - // don't interpolate the processEnv value if it exists there already + // don't interpolate if it exists already in processEnv if (Object.prototype.hasOwnProperty.call(processEnv, key)) { value = processEnv[key] } else { - value = _interpolate(value, processEnv, options.parsed) + value = interpolate(value, processEnv, options.parsed) } options.parsed[key] = _resolveEscapeSequences(value) diff --git a/tests/.env.test b/tests/.env.test index f389b4a..32f236b 100644 --- a/tests/.env.test +++ b/tests/.env.test @@ -1,20 +1,23 @@ -NODE_ENV=test BASIC=basic BASIC_EXPAND=$BASIC MACHINE=machine_env MACHINE_EXPAND=$MACHINE -UNDEFINED_EXPAND=$UNDEFINED_ENV_KEY + ESCAPED_EXPAND=\$ESCAPED -DEFINED_EXPAND_WITH_DEFAULT=${MACHINE:-default} -DEFINED_EXPAND_WITH_DEFAULT_NESTED=${MACHINE:-${UNDEFINED_ENV_KEY:-default}} -UNDEFINED_EXPAND_WITH_DEFINED_NESTED=${UNDEFINED_ENV_KEY:-${MACHINE:-default}} -UNDEFINED_EXPAND_WITH_DEFAULT=${UNDEFINED_ENV_KEY:-default} -UNDEFINED_EXPAND_WITH_DEFAULT_NESTED=${UNDEFINED_ENV_KEY:-${UNDEFINED_ENV_KEY_2:-default}} -DEFINED_EXPAND_WITH_DEFAULT_NESTED_TWICE=${UNDEFINED_ENV_KEY:-${MACHINE}${UNDEFINED_ENV_KEY_3:-default}} -UNDEFINED_EXPAND_WITH_DEFAULT_NESTED_TWICE=${UNDEFINED_ENV_KEY:-${UNDEFINED_ENV_KEY_2:-${UNDEFINED_ENV_KEY_3:-default}}} -DEFINED_EXPAND_WITH_DEFAULT_WITH_SPECIAL_CHARACTERS=${MACHINE:-/default/path:with/colon} -UNDEFINED_EXPAND_WITH_DEFAULT_WITH_SPECIAL_CHARACTERS=${UNDEFINED_ENV_KEY:-/default/path:with/colon} -UNDEFINED_EXPAND_WITH_DEFAULT_WITH_SPECIAL_CHARACTERS_NESTED=${UNDEFINED_ENV_KEY:-${UNDEFINED_ENV_KEY_2:-/default/path:with/colon}} + +EXPAND_DEFAULT=${MACHINE:-default} +EXPAND_DEFAULT_NESTED=${MACHINE:-${UNDEFINED:-default}} +EXPAND_DEFAULT_NESTED_TWICE=${UNDEFINED:-${MACHINE}${UNDEFINED:-default}} +EXPAND_DEFAULT_SPECIAL_CHARACTERS=${MACHINE:-/default/path:with/colon} + +UNDEFINED_EXPAND=$UNDEFINED +UNDEFINED_EXPAND_NESTED=${UNDEFINED:-${MACHINE:-default}} +UNDEFINED_EXPAND_DEFAULT=${UNDEFINED:-default} +UNDEFINED_EXPAND_DEFAULT_NESTED=${UNDEFINED:-${UNDEFINED:-default}} +UNDEFINED_EXPAND_DEFAULT_NESTED_TWICE=${UNDEFINED:-${UNDEFINED:-${UNDEFINED:-default}}} +UNDEFINED_EXPAND_DEFAULT_SPECIAL_CHARACTERS=${UNDEFINED:-/default/path:with/colon} +UNDEFINED_EXPAND_DEFAULT_SPECIAL_CHARACTERS_NESTED=${UNDEFINED:-${UNDEFINED_2:-/default/path:with/colon}} + MONGOLAB_DATABASE=heroku_db MONGOLAB_USER=username MONGOLAB_PASSWORD=password @@ -25,12 +28,19 @@ MONGOLAB_URI=mongodb://${MONGOLAB_USER}:${MONGOLAB_PASSWORD}@${MONGOLAB_DOMAIN}: MONGOLAB_USER_RECURSIVELY=${MONGOLAB_USER}:${MONGOLAB_PASSWORD} MONGOLAB_URI_RECURSIVELY=mongodb://${MONGOLAB_USER_RECURSIVELY}@${MONGOLAB_DOMAIN}:${MONGOLAB_PORT}/${MONGOLAB_DATABASE} -WITHOUT_CURLY_BRACES_URI=mongodb://$MONGOLAB_USER:$MONGOLAB_PASSWORD@$MONGOLAB_DOMAIN:$MONGOLAB_PORT/$MONGOLAB_DATABASE -WITHOUT_CURLY_BRACES_USER_RECURSIVELY=$MONGOLAB_USER:$MONGOLAB_PASSWORD -WITHOUT_CURLY_BRACES_URI_RECURSIVELY=mongodb://$MONGOLAB_USER_RECURSIVELY@$MONGOLAB_DOMAIN:$MONGOLAB_PORT/$MONGOLAB_DATABASE -WITHOUT_CURLY_BRACES_UNDEFINED_EXPAND_WITH_DEFAULT_WITH_SPECIAL_CHARACTERS=$UNDEFINED_ENV_KEY:-/default/path:with/colon +NO_CURLY_BRACES_URI=mongodb://$MONGOLAB_USER:$MONGOLAB_PASSWORD@$MONGOLAB_DOMAIN:$MONGOLAB_PORT/$MONGOLAB_DATABASE +NO_CURLY_BRACES_USER_RECURSIVELY=$MONGOLAB_USER:$MONGOLAB_PASSWORD +NO_CURLY_BRACES_URI_RECURSIVELY=mongodb://$MONGOLAB_USER_RECURSIVELY@$MONGOLAB_DOMAIN:$MONGOLAB_PORT/$MONGOLAB_DATABASE +NO_CURLY_BRACES_UNDEFINED_EXPAND_DEFAULT_SPECIAL_CHARACTERS=$UNDEFINED:-/default/path:with/colon POSTGRESQL.BASE.USER=postgres POSTGRESQL.MAIN.USER=${POSTGRESQL.BASE.USER} DOLLAR=$ + +ONE=one +TWO=two +ONETWO=${ONE}${TWO} +ONETWO_SIMPLE=${ONE}$TWO +ONETWO_SIMPLE2=$ONE${TWO} +ONETWO_SUPER_SIMPLE=$ONE$TWO diff --git a/tests/main.js b/tests/main.js index 210f0fe..3cf06bb 100644 --- a/tests/main.js +++ b/tests/main.js @@ -67,7 +67,7 @@ t.test('does not expand environment variables existing already on the machine th t.test('expands missing environment variables to an empty string', ct => { const dotenv = { parsed: { - UNDEFINED_EXPAND: '$UNDEFINED_ENV_KEY' + UNDEFINED_EXPAND: '$UNDEFINED' } } const parsed = dotenvExpand.expand(dotenv).parsed @@ -194,7 +194,7 @@ t.test('expands environment variables existing already on the machine even with const dotenv = require('dotenv').config({ path: 'tests/.env.test', processEnv: {} }) dotenvExpand.expand(dotenv) - ct.equal(process.env.DEFINED_EXPAND_WITH_DEFAULT, 'machine') + ct.equal(process.env.EXPAND_DEFAULT, 'machine') ct.end() }) @@ -205,7 +205,7 @@ t.test('expands environment variables existing already on the machine even with const dotenv = require('dotenv').config({ path: 'tests/.env.test', processEnv: {} }) dotenvExpand.expand(dotenv) - ct.equal(process.env.DEFINED_EXPAND_WITH_DEFAULT_NESTED, 'machine') + ct.equal(process.env.EXPAND_DEFAULT_NESTED, 'machine') ct.end() }) @@ -216,7 +216,7 @@ t.test('expands environment variables undefined with one already on the machine const dotenv = require('dotenv').config({ path: 'tests/.env.test', processEnv: {} }) dotenvExpand.expand(dotenv) - ct.equal(process.env.UNDEFINED_EXPAND_WITH_DEFINED_NESTED, 'machine') + ct.equal(process.env.UNDEFINED_EXPAND_NESTED, 'machine') ct.end() }) @@ -225,7 +225,7 @@ t.test('expands missing environment variables to an empty string but replaces wi const dotenv = require('dotenv').config({ path: 'tests/.env.test', processEnv: {} }) const parsed = dotenvExpand.expand(dotenv).parsed - ct.equal(parsed.UNDEFINED_EXPAND_WITH_DEFAULT, 'default') + ct.equal(parsed.UNDEFINED_EXPAND_DEFAULT, 'default') ct.end() }) @@ -234,7 +234,7 @@ t.test('expands environent variables and concats with default nested', ct => { const dotenv = require('dotenv').config({ path: 'tests/.env.test', processEnv: {} }) const parsed = dotenvExpand.expand(dotenv).parsed - ct.equal(parsed.DEFINED_EXPAND_WITH_DEFAULT_NESTED_TWICE, 'machinedefault') + ct.equal(parsed.EXPAND_DEFAULT_NESTED_TWICE, 'machinedefault') ct.end() }) @@ -243,7 +243,7 @@ t.test('expands missing environment variables to an empty string but replaces wi const dotenv = require('dotenv').config({ path: 'tests/.env.test', processEnv: {} }) const parsed = dotenvExpand.expand(dotenv).parsed - ct.equal(parsed.UNDEFINED_EXPAND_WITH_DEFAULT_NESTED, 'default') + ct.equal(parsed.UNDEFINED_EXPAND_DEFAULT_NESTED, 'default') ct.end() }) @@ -252,7 +252,7 @@ t.test('expands missing environment variables to an empty string but replaces wi const dotenv = require('dotenv').config({ path: 'tests/.env.test', processEnv: {} }) const parsed = dotenvExpand.expand(dotenv).parsed - ct.equal(parsed.UNDEFINED_EXPAND_WITH_DEFAULT_NESTED_TWICE, 'default') + ct.equal(parsed.UNDEFINED_EXPAND_DEFAULT_NESTED_TWICE, 'default') ct.end() }) @@ -290,7 +290,7 @@ t.test('multiple expand', ct => { const dotenv = require('dotenv').config({ path: 'tests/.env.test', processEnv: {} }) const parsed = dotenvExpand.expand(dotenv).parsed - ct.equal(parsed.WITHOUT_CURLY_BRACES_URI, 'mongodb://username:password@abcd1234.mongolab.com:12345/heroku_db') + ct.equal(parsed.NO_CURLY_BRACES_URI, 'mongodb://username:password@abcd1234.mongolab.com:12345/heroku_db') ct.end() }) @@ -299,7 +299,7 @@ t.test('should expand recursively', ct => { const dotenv = require('dotenv').config({ path: 'tests/.env.test', processEnv: {} }) const parsed = dotenvExpand.expand(dotenv).parsed - ct.equal(parsed.WITHOUT_CURLY_BRACES_URI_RECURSIVELY, 'mongodb://username:password@abcd1234.mongolab.com:12345/heroku_db') + ct.equal(parsed.NO_CURLY_BRACES_URI_RECURSIVELY, 'mongodb://username:password@abcd1234.mongolab.com:12345/heroku_db') ct.end() }) @@ -326,7 +326,7 @@ t.test('expands environment variables existing already on the machine even with const dotenv = require('dotenv').config({ path: 'tests/.env.test', processEnv: {} }) const parsed = dotenvExpand.expand(dotenv).parsed - ct.equal(parsed.DEFINED_EXPAND_WITH_DEFAULT_WITH_SPECIAL_CHARACTERS, 'machine') + ct.equal(parsed.EXPAND_DEFAULT_SPECIAL_CHARACTERS, 'machine') ct.end() }) @@ -335,8 +335,8 @@ t.test('should expand with default value correctly', ct => { const dotenv = require('dotenv').config({ path: 'tests/.env.test', processEnv: {} }) const parsed = dotenvExpand.expand(dotenv).parsed - ct.equal(parsed.UNDEFINED_EXPAND_WITH_DEFAULT_WITH_SPECIAL_CHARACTERS, '/default/path:with/colon') - ct.equal(parsed.WITHOUT_CURLY_BRACES_UNDEFINED_EXPAND_WITH_DEFAULT_WITH_SPECIAL_CHARACTERS, '/default/path:with/colon') + ct.equal(parsed.UNDEFINED_EXPAND_DEFAULT_SPECIAL_CHARACTERS, '/default/path:with/colon') + ct.equal(parsed.NO_CURLY_BRACES_UNDEFINED_EXPAND_DEFAULT_SPECIAL_CHARACTERS, '/default/path:with/colon') ct.end() }) @@ -345,7 +345,7 @@ t.test('should expand with default nested value correctly', ct => { const dotenv = require('dotenv').config({ path: 'tests/.env.test', processEnv: {} }) const parsed = dotenvExpand.expand(dotenv).parsed - ct.equal(parsed.UNDEFINED_EXPAND_WITH_DEFAULT_WITH_SPECIAL_CHARACTERS_NESTED, '/default/path:with/colon') + ct.equal(parsed.UNDEFINED_EXPAND_DEFAULT_SPECIAL_CHARACTERS_NESTED, '/default/path:with/colon') ct.end() }) @@ -367,3 +367,15 @@ t.test('handles value of only $', ct => { ct.end() }) + +t.test('handles $one$two', ct => { + const dotenv = require('dotenv').config({ path: 'tests/.env.test', processEnv: {} }) + const parsed = dotenvExpand.expand(dotenv).parsed + + ct.equal(parsed.ONETWO, 'onetwo') + ct.equal(parsed.ONETWO_SIMPLE, 'onetwo') + ct.equal(parsed.ONETWO_SIMPLE2, 'onetwo') + ct.equal(parsed.ONETWO_SUPER_SIMPLE, 'onetwo') + + ct.end() +})