From 627e6768641f45070a00d170f29578d39c3d71ef Mon Sep 17 00:00:00 2001 From: Matt Morrissette Date: Mon, 18 Nov 2019 12:00:45 -0800 Subject: [PATCH 01/11] feat: add default value handling --- README.md | 16 ++++++++++++++-- src/__tests__/command.js | 16 ++++++++++++++++ src/__tests__/variable.js | 29 +++++++++++++++++++++++++++++ src/command.js | 7 ++++--- src/variable.js | 16 +++++++++++++--- 5 files changed, 76 insertions(+), 8 deletions(-) diff --git a/README.md b/README.md index f5338fb..a6fa268 100755 --- a/README.md +++ b/README.md @@ -42,6 +42,7 @@ of setting it properly. - [Installation](#installation) - [Usage](#usage) +- [Default Values](#default-values) - [`cross-env` vs `cross-env-shell`](#cross-env-vs-cross-env-shell) - [Windows Issues](#windows-issues) - [Inspiration](#inspiration) @@ -126,6 +127,18 @@ Pay special attention to the **triple backslash** `(\\\)` **before** the **double quotes** `(")` and the **absence** of **single quotes** `(')`. Both of these conditions have to be met in order to work both on Windows and UNIX. +## Default Values + +To reference an environment variable and provide a default value if that +variable is empty or not-defined, use the syntax: + +```bash +${ENV_VAR_NAME:-default value} +``` + +This follows the UNIX standard for environment variables and works for both +`cross-env` and `cross-env-shell`. + ## `cross-env` vs `cross-env-shell` The `cross-env` module exposes two bins: `cross-env` and `cross-env-shell`. The @@ -173,8 +186,6 @@ easier for Windows users. - [`env-cmd`](https://github.com/toddbluhm/env-cmd) - Reads environment variables from a file instead -- [`@naholyr/cross-env`](https://www.npmjs.com/package/@naholyr/cross-env) - - `cross-env` with support for setting default values ## Contributors @@ -208,6 +219,7 @@ Thanks goes to these people ([emoji key][emojis]): Jason Cooke
Jason Cooke

📖 bibo5088
bibo5088

💻 Eric Berry
Eric Berry

🔍 + Matt Morrissette
Matt Morrissette

💻📖⚠️ diff --git a/src/__tests__/command.js b/src/__tests__/command.js index bc96359..9cc2849 100644 --- a/src/__tests__/command.js +++ b/src/__tests__/command.js @@ -79,3 +79,19 @@ test(`normalizes command on windows`, () => { // as `true` for command only expect(commandConvert('./cmd.bat', env, true)).toBe('cmd.bat') }) + +test(`evaluates default values for missing environment variables on windows`, () => { + isWindowsMock.mockReturnValue(true) + // eslint-disable-next-line no-template-curly-in-string + expect(commandConvert('$test1/${foo:-bar}/$test2', env)).toBe( + '%test1%/bar/%test2%', + ) +}) + +test(`evaluates default values for empty environment variables on windows`, () => { + isWindowsMock.mockReturnValue(true) + // eslint-disable-next-line no-template-curly-in-string + expect(commandConvert('$test1/${empty_var:-bang}/$test2', env)).toBe( + '%test1%/bang/%test2%', + ) +}) diff --git a/src/__tests__/variable.js b/src/__tests__/variable.js index 4be7457..d8d05e4 100644 --- a/src/__tests__/variable.js +++ b/src/__tests__/variable.js @@ -8,6 +8,7 @@ const JSON_VALUE = '{\\"foo\\":\\"bar\\"}' beforeEach(() => { process.env.VAR1 = 'value1' process.env.VAR2 = 'value2' + process.env.EMPTY_VAR = '' process.env.JSON_VAR = JSON_VALUE }) @@ -119,3 +120,31 @@ test(`resolves an env variable prefixed with \\\\ on UNIX`, () => { isWindowsMock.mockReturnValue(false) expect(varValueConvert('\\\\$VAR1')).toBe('\\value1') }) + +test(`resolves default value for missing variable on UNIX`, () => { + isWindowsMock.mockReturnValue(false) + // eslint-disable-next-line no-template-curly-in-string + expect(varValueConvert('${UNKNOWN_UNIX_VAR:-defaultUnix}')).toBe( + 'defaultUnix', + ) +}) + +test(`resolves default value for missing variable on windows`, () => { + isWindowsMock.mockReturnValue(true) + // eslint-disable-next-line no-template-curly-in-string + expect(varValueConvert('${UNKNOWN_WINDOWS_VAR:-defaultWindows}')).toBe( + 'defaultWindows', + ) +}) + +test(`resolves default value for empty string variable on UNIX`, () => { + isWindowsMock.mockReturnValue(false) + // eslint-disable-next-line no-template-curly-in-string + expect(varValueConvert('${EMPTY_VAR:-defaultUnix}')).toBe('defaultUnix') +}) + +test(`resolves default value for empty string variable on windows`, () => { + isWindowsMock.mockReturnValue(true) + // eslint-disable-next-line no-template-curly-in-string + expect(varValueConvert('${EMPTY_VAR:-defaultWindows}')).toBe('defaultWindows') +}) diff --git a/src/command.js b/src/command.js index 36100a5..b8de90c 100644 --- a/src/command.js +++ b/src/command.js @@ -15,14 +15,15 @@ function commandConvert(command, env, normalize = false) { if (!isWindows()) { return command } - const envUnixRegex = /\$(\w+)|\${(\w+)}/g // $my_var or ${my_var} - const convertedCmd = command.replace(envUnixRegex, (match, $1, $2) => { + const envUnixRegex = /\$(\w+)|\${(\w+)(?::-(\w*))?}/g // $my_var or ${my_var} or ${my_var:-default value} + const convertedCmd = command.replace(envUnixRegex, (match, $1, $2, $3) => { const varName = $1 || $2 + const defaultValue = $3 || '' // In Windows, non-existent variables are not replaced by the shell, // so for example "echo %FOO%" will literally print the string "%FOO%", as // opposed to printing an empty string in UNIX. See kentcdodds/cross-env#145 // If the env variable isn't defined at runtime, just strip it from the command entirely - return env[varName] ? `%${varName}%` : '' + return env[varName] ? `%${varName}%` : defaultValue }) // Normalization is required for commands with relative paths // For example, `./cmd.bat`. See kentcdodds/cross-env#127 diff --git a/src/variable.js b/src/variable.js index 1848a58..054112a 100644 --- a/src/variable.js +++ b/src/variable.js @@ -38,21 +38,31 @@ function replaceListDelimiters(varValue, varName = '') { * Note that this function is only called with the right-side portion of the * env var assignment, so in that example, this function would transform * the string "$NODE_ENV" into "development" + * + * To specify a default value for a variable, use the ${ENV_VAR_NAME:-default value} syntax. + * * @param {String} varValue Original value of the env variable * @returns {String} Converted value */ function resolveEnvVars(varValue) { - const envUnixRegex = /(\\*)(\$(\w+)|\${(\w+)})/g // $my_var or ${my_var} or \$my_var + const envUnixRegex = /(\\*)(\$(\w+)|\${(\w+)(?::-(\w*))?})/g // $my_var or ${my_var} or \$my_var or ${my_var:-default_value} return varValue.replace( envUnixRegex, - (_, escapeChars, varNameWithDollarSign, varName, altVarName) => { + ( + _, + escapeChars, + varNameWithDollarSign, + varName, + altVarName, + defaultValue, + ) => { // do not replace things preceded by a odd number of \ if (escapeChars.length % 2 === 1) { return varNameWithDollarSign } return ( escapeChars.substr(0, escapeChars.length / 2) + - (process.env[varName || altVarName] || '') + (process.env[varName || altVarName] || defaultValue || '') ) }, ) From 921ac6aa83d88ecdf9cb62327632e1f8545d6d5b Mon Sep 17 00:00:00 2001 From: Matt Morrissette Date: Mon, 18 Nov 2019 15:36:03 -0800 Subject: [PATCH 02/11] feat: add recursive default resolution --- src/__tests__/command.js | 19 ++++++++++++++++--- src/__tests__/variable.js | 34 +++++++++++++++++++++++++++++----- src/command.js | 5 ++--- src/variable.js | 6 ++++-- 4 files changed, 51 insertions(+), 13 deletions(-) diff --git a/src/__tests__/command.js b/src/__tests__/command.js index 9cc2849..2aa8399 100644 --- a/src/__tests__/command.js +++ b/src/__tests__/command.js @@ -1,3 +1,4 @@ +/* eslint-disable no-template-curly-in-string */ const isWindowsMock = require('../is-windows') const commandConvert = require('../command') @@ -59,7 +60,7 @@ test(`leaves embedded variables unchanged when using correct operating system`, test(`converts braced unix-style env variable usage for windows`, () => { isWindowsMock.mockReturnValue(true) - // eslint-disable-next-line no-template-curly-in-string + expect(commandConvert('${test}', env)).toBe('%test%') }) @@ -82,7 +83,6 @@ test(`normalizes command on windows`, () => { test(`evaluates default values for missing environment variables on windows`, () => { isWindowsMock.mockReturnValue(true) - // eslint-disable-next-line no-template-curly-in-string expect(commandConvert('$test1/${foo:-bar}/$test2', env)).toBe( '%test1%/bar/%test2%', ) @@ -90,8 +90,21 @@ test(`evaluates default values for missing environment variables on windows`, () test(`evaluates default values for empty environment variables on windows`, () => { isWindowsMock.mockReturnValue(true) - // eslint-disable-next-line no-template-curly-in-string expect(commandConvert('$test1/${empty_var:-bang}/$test2', env)).toBe( '%test1%/bang/%test2%', ) }) + +test(`evaluates default values recursively for empty environment variables on windows`, () => { + isWindowsMock.mockReturnValue(true) + expect( + commandConvert('$test1/${empty_var:-${missing_var:-bang}}/$test2', env), + ).toBe('%test1%/bang/%test2%') +}) + +test(`evaluates secondary values recursively for environment variables on windows`, () => { + isWindowsMock.mockReturnValue(true) + expect( + commandConvert('$test1/${empty_var:-${test3:-bang}}/$test2', env), + ).toBe('%test1%/%test3%/%test2%') +}) diff --git a/src/__tests__/variable.js b/src/__tests__/variable.js index d8d05e4..64ba86f 100644 --- a/src/__tests__/variable.js +++ b/src/__tests__/variable.js @@ -1,3 +1,4 @@ +/* eslint-disable no-template-curly-in-string */ const isWindowsMock = require('../is-windows') const varValueConvert = require('../variable') @@ -77,7 +78,6 @@ test(`resolves an env variable value`, () => { test(`resolves an env variable value with curly syntax`, () => { isWindowsMock.mockReturnValue(true) - // eslint-disable-next-line no-template-curly-in-string expect(varValueConvert('foo-${VAR1}')).toBe('foo-value1') }) @@ -123,7 +123,6 @@ test(`resolves an env variable prefixed with \\\\ on UNIX`, () => { test(`resolves default value for missing variable on UNIX`, () => { isWindowsMock.mockReturnValue(false) - // eslint-disable-next-line no-template-curly-in-string expect(varValueConvert('${UNKNOWN_UNIX_VAR:-defaultUnix}')).toBe( 'defaultUnix', ) @@ -131,7 +130,6 @@ test(`resolves default value for missing variable on UNIX`, () => { test(`resolves default value for missing variable on windows`, () => { isWindowsMock.mockReturnValue(true) - // eslint-disable-next-line no-template-curly-in-string expect(varValueConvert('${UNKNOWN_WINDOWS_VAR:-defaultWindows}')).toBe( 'defaultWindows', ) @@ -139,12 +137,38 @@ test(`resolves default value for missing variable on windows`, () => { test(`resolves default value for empty string variable on UNIX`, () => { isWindowsMock.mockReturnValue(false) - // eslint-disable-next-line no-template-curly-in-string expect(varValueConvert('${EMPTY_VAR:-defaultUnix}')).toBe('defaultUnix') }) test(`resolves default value for empty string variable on windows`, () => { isWindowsMock.mockReturnValue(true) - // eslint-disable-next-line no-template-curly-in-string expect(varValueConvert('${EMPTY_VAR:-defaultWindows}')).toBe('defaultWindows') }) + +test('resolves default value recursively when primary and secondary doesnt exist in UNIX', () => { + isWindowsMock.mockReturnValue(false) + expect( + varValueConvert('${EMPTY_VAR:-foobar${MISSING_VAR:-defaultUnix}}'), + ).toBe('foobardefaultUnix') +}) + +test('resolves secondary value recursively when primary doesnt exist in UNIX', () => { + isWindowsMock.mockReturnValue(false) + expect(varValueConvert('${EMPTY_VAR:-bang${VAR1:-defaultUnix}}')).toBe( + 'bangvalue1', + ) +}) + +test('resolves default value recursively when primary and secondary doesnt exist in windows', () => { + isWindowsMock.mockReturnValue(true) + expect( + varValueConvert('${EMPTY_VAR:-foobar${MISSING_VAR:-defaultWindows}}'), + ).toBe('foobardefaultWindows') +}) + +test('resolves secondary value recursively when primary doesnt exist in windows', () => { + isWindowsMock.mockReturnValue(true) + expect(varValueConvert('${EMPTY_VAR:-bang${VAR1:-defaultWindows}}')).toBe( + 'bangvalue1', + ) +}) diff --git a/src/command.js b/src/command.js index b8de90c..56f1e0f 100644 --- a/src/command.js +++ b/src/command.js @@ -15,15 +15,14 @@ function commandConvert(command, env, normalize = false) { if (!isWindows()) { return command } - const envUnixRegex = /\$(\w+)|\${(\w+)(?::-(\w*))?}/g // $my_var or ${my_var} or ${my_var:-default value} + const envUnixRegex = /\$(\w+)|\${(\w+)(?::-([\w{}$:-]*))?}/g // $my_var or ${my_var} or ${my_var:-default value} or ${my_var:-${backup_var:-default_value}} const convertedCmd = command.replace(envUnixRegex, (match, $1, $2, $3) => { const varName = $1 || $2 - const defaultValue = $3 || '' // In Windows, non-existent variables are not replaced by the shell, // so for example "echo %FOO%" will literally print the string "%FOO%", as // opposed to printing an empty string in UNIX. See kentcdodds/cross-env#145 // If the env variable isn't defined at runtime, just strip it from the command entirely - return env[varName] ? `%${varName}%` : defaultValue + return env[varName] ? `%${varName}%` : ($3 && commandConvert($3, env)) || '' }) // Normalization is required for commands with relative paths // For example, `./cmd.bat`. See kentcdodds/cross-env#127 diff --git a/src/variable.js b/src/variable.js index 054112a..e0aa4fe 100644 --- a/src/variable.js +++ b/src/variable.js @@ -45,7 +45,7 @@ function replaceListDelimiters(varValue, varName = '') { * @returns {String} Converted value */ function resolveEnvVars(varValue) { - const envUnixRegex = /(\\*)(\$(\w+)|\${(\w+)(?::-(\w*))?})/g // $my_var or ${my_var} or \$my_var or ${my_var:-default_value} + const envUnixRegex = /(\\*)(\$(\w+)|\${(\w+)(?::-([\w{}$:-]*))?})/g // $my_var or ${my_var} or \$my_var or ${my_var:-default_value} or ${my_var:-${backup_var:-default_value}} return varValue.replace( envUnixRegex, ( @@ -62,7 +62,9 @@ function resolveEnvVars(varValue) { } return ( escapeChars.substr(0, escapeChars.length / 2) + - (process.env[varName || altVarName] || defaultValue || '') + (process.env[varName || altVarName] || + (defaultValue && resolveEnvVars(defaultValue)) || + '') ) }, ) From 726a8c2cd8a088b589989c4ca1a1f713c84bc4f5 Mon Sep 17 00:00:00 2001 From: Matt Morrissette Date: Mon, 18 Nov 2019 19:32:56 -0800 Subject: [PATCH 03/11] feat: add PWD resolution (current dir) --- src/__tests__/command.js | 12 ++++++++++++ src/__tests__/variable.js | 12 ++++++++++++ src/command.js | 6 +++++- src/variable.js | 8 +++++--- 4 files changed, 34 insertions(+), 4 deletions(-) diff --git a/src/__tests__/command.js b/src/__tests__/command.js index 2aa8399..4f0821d 100644 --- a/src/__tests__/command.js +++ b/src/__tests__/command.js @@ -108,3 +108,15 @@ test(`evaluates secondary values recursively for environment variables on window commandConvert('$test1/${empty_var:-${test3:-bang}}/$test2', env), ).toBe('%test1%/%test3%/%test2%') }) + +test('resolves the current working directory inside string in windows', () => { + isWindowsMock.mockReturnValue(true) + expect(commandConvert('cd ${PWD}\\ADirectory')).toBe( + `cd ${process.cwd()}\\ADirectory`, + ) +}) + +test('resolves the current working directory inside string in UNIX', () => { + isWindowsMock.mockReturnValue(false) + expect(commandConvert('cd ${PWD}/adir')).toBe('cd ${PWD}/adir') +}) diff --git a/src/__tests__/variable.js b/src/__tests__/variable.js index 64ba86f..429c30a 100644 --- a/src/__tests__/variable.js +++ b/src/__tests__/variable.js @@ -172,3 +172,15 @@ test('resolves secondary value recursively when primary doesnt exist in windows' 'bangvalue1', ) }) + +test('resolves the current working directory inside string in windows', () => { + isWindowsMock.mockReturnValue(true) + expect(varValueConvert('${PWD}\\ADirectory')).toBe( + `${process.cwd()}\\ADirectory`, + ) +}) + +test('resolves the current working directory inside string in UNIX', () => { + isWindowsMock.mockReturnValue(false) + expect(varValueConvert('${PWD}/adir')).toBe(`${process.cwd()}/adir`) +}) diff --git a/src/command.js b/src/command.js index 56f1e0f..cb959da 100644 --- a/src/command.js +++ b/src/command.js @@ -22,7 +22,11 @@ function commandConvert(command, env, normalize = false) { // so for example "echo %FOO%" will literally print the string "%FOO%", as // opposed to printing an empty string in UNIX. See kentcdodds/cross-env#145 // If the env variable isn't defined at runtime, just strip it from the command entirely - return env[varName] ? `%${varName}%` : ($3 && commandConvert($3, env)) || '' + return varName === 'PWD' + ? process.cwd() + : env[varName] + ? `%${varName}%` + : ($3 && commandConvert($3, env)) || '' }) // Normalization is required for commands with relative paths // For example, `./cmd.bat`. See kentcdodds/cross-env#127 diff --git a/src/variable.js b/src/variable.js index e0aa4fe..c57410f 100644 --- a/src/variable.js +++ b/src/variable.js @@ -62,9 +62,11 @@ function resolveEnvVars(varValue) { } return ( escapeChars.substr(0, escapeChars.length / 2) + - (process.env[varName || altVarName] || - (defaultValue && resolveEnvVars(defaultValue)) || - '') + ((varName || altVarName) === 'PWD' + ? process.cwd() + : process.env[varName || altVarName] || + (defaultValue && resolveEnvVars(defaultValue)) || + '') ) }, ) From bbdb5efed3deda4481932f0c07ecc193754de0e6 Mon Sep 17 00:00:00 2001 From: Matt Morrissette Date: Mon, 18 Nov 2019 20:06:52 -0800 Subject: [PATCH 04/11] doc: adding missing README about PWD --- README.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/README.md b/README.md index a6fa268..3ed0517 100755 --- a/README.md +++ b/README.md @@ -127,6 +127,8 @@ Pay special attention to the **triple backslash** `(\\\)` **before** the **double quotes** `(")` and the **absence** of **single quotes** `(')`. Both of these conditions have to be met in order to work both on Windows and UNIX. +Note: `${PWD}` and `$PWD` substitute for the current working directory. + ## Default Values To reference an environment variable and provide a default value if that From 729c2a1e8c41dc5ff3e7fdba3cbd7efd941c9d46 Mon Sep 17 00:00:00 2001 From: Matt Morrissette Date: Mon, 18 Nov 2019 23:35:23 -0800 Subject: [PATCH 05/11] doc: add additional docs about recursive defaults --- README.md | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/README.md b/README.md index 3ed0517..edc7d4c 100755 --- a/README.md +++ b/README.md @@ -138,6 +138,29 @@ variable is empty or not-defined, use the syntax: ${ENV_VAR_NAME:-default value} ``` +Also supports recursive replacement in the default value allowing you to test +multiple environment variables before showing a default + +```bash +# Value of FIRST_VAR_TO_TEST or SECOND_VAR_TO_TEST environment variable if blank or undefined. +# If both are blank or undefined, replace with an empty string +${FIRST_VAR_TO_TEST:-$SECOND_VAR_TO_TEST} +``` + +Can nest as many as you want + +```bash +# Value of FIRST_VAR_TO_TEST, then SECOND_VAR_TO_TEST then THIRD_VAR_TO_TEST environment variables. +# If all are blank or undefined, the string "default value if all are empty or undefined" +${FIRST_VAR_TO_TEST:-${SECOND_VAR_TO_TEST:-${THIRD_VAR_TO_TEST:-default value if all are empty or undefined}}} +``` + +Or even use multiple environment variables in the default values + +```bash +${NODE_DEBUG_OPTIONS:---inspect=$NODE_DEBUG_PORT $NODE_DEFAULT_OPTIONS}` +``` + This follows the UNIX standard for environment variables and works for both `cross-env` and `cross-env-shell`. From 11adee3a350ea278d596b4783e6133f2f2211a92 Mon Sep 17 00:00:00 2001 From: Matt Morrissette Date: Tue, 19 Nov 2019 23:28:14 -0800 Subject: [PATCH 06/11] feat: truly recursive rewrite of default value --- README.md | 2 +- src/__tests__/variable.js | 9 +++ src/bin/cross-env-shell.js | 0 src/bin/cross-env.js | 0 src/command.js | 15 +---- src/env-replace.js | 124 +++++++++++++++++++++++++++++++++++++ src/variable.js | 47 +------------- 7 files changed, 138 insertions(+), 59 deletions(-) mode change 100644 => 100755 src/bin/cross-env-shell.js mode change 100644 => 100755 src/bin/cross-env.js create mode 100644 src/env-replace.js diff --git a/README.md b/README.md index edc7d4c..b992ec7 100755 --- a/README.md +++ b/README.md @@ -158,7 +158,7 @@ ${FIRST_VAR_TO_TEST:-${SECOND_VAR_TO_TEST:-${THIRD_VAR_TO_TEST:-default value if Or even use multiple environment variables in the default values ```bash -${NODE_DEBUG_OPTIONS:---inspect=$NODE_DEBUG_PORT $NODE_DEFAULT_OPTIONS}` +${NODE_DEBUG_OPTIONS:---inspect=$NODE_DEBUG_PORT $NODE_DEFAULT_OPTIONS} ``` This follows the UNIX standard for environment variables and works for both diff --git a/src/__tests__/variable.js b/src/__tests__/variable.js index 429c30a..4d48914 100644 --- a/src/__tests__/variable.js +++ b/src/__tests__/variable.js @@ -184,3 +184,12 @@ test('resolves the current working directory inside string in UNIX', () => { isWindowsMock.mockReturnValue(false) expect(varValueConvert('${PWD}/adir')).toBe(`${process.cwd()}/adir`) }) + +test('resolves a very complex string with defaults and PWD in Windows', () => { + isWindowsMock.mockReturnValue(true) + expect( + varValueConvert( + 'start-${PWD}-${EMPTY_VAR:-${NO_VAR:-$VAR1}}-${NO_VAR:-$VAR2}-${NO_VAR:-${EMPTY_VAR:-value3}}-${EMPTY_VAR:-$PWD}-end', + ), + ).toBe(`start-${process.cwd()}-value1-value2-value3-${process.cwd()}-end`) +}) diff --git a/src/bin/cross-env-shell.js b/src/bin/cross-env-shell.js old mode 100644 new mode 100755 diff --git a/src/bin/cross-env.js b/src/bin/cross-env.js old mode 100644 new mode 100755 diff --git a/src/command.js b/src/command.js index cb959da..c9ab344 100644 --- a/src/command.js +++ b/src/command.js @@ -1,5 +1,6 @@ const path = require('path') const isWindows = require('./is-windows') +const envReplace = require('./env-replace') module.exports = commandConvert @@ -15,19 +16,7 @@ function commandConvert(command, env, normalize = false) { if (!isWindows()) { return command } - const envUnixRegex = /\$(\w+)|\${(\w+)(?::-([\w{}$:-]*))?}/g // $my_var or ${my_var} or ${my_var:-default value} or ${my_var:-${backup_var:-default_value}} - const convertedCmd = command.replace(envUnixRegex, (match, $1, $2, $3) => { - const varName = $1 || $2 - // In Windows, non-existent variables are not replaced by the shell, - // so for example "echo %FOO%" will literally print the string "%FOO%", as - // opposed to printing an empty string in UNIX. See kentcdodds/cross-env#145 - // If the env variable isn't defined at runtime, just strip it from the command entirely - return varName === 'PWD' - ? process.cwd() - : env[varName] - ? `%${varName}%` - : ($3 && commandConvert($3, env)) || '' - }) + const convertedCmd = envReplace(command, env, true) // Normalization is required for commands with relative paths // For example, `./cmd.bat`. See kentcdodds/cross-env#127 // However, it should not be done for command arguments. diff --git a/src/env-replace.js b/src/env-replace.js new file mode 100644 index 0000000..e49b5fc --- /dev/null +++ b/src/env-replace.js @@ -0,0 +1,124 @@ +module.exports = envReplace + +/** + * This will attempt to resolve the value of any env variables that are inside + * this string. For example, it will transform this: + * cross-env FOO=$NODE_ENV BAR=\\$NODE_ENV echo $FOO ${BAR} + * Into this: + * FOO=development BAR=$NODE_ENV echo $FOO + * (Or whatever value the variable NODE_ENV has) + * Note that this function is only called with the right-side portion of the + * env var assignment, so in that example, this function would transform + * the string "$NODE_ENV" into "development" + * + * To specify a default value for a variable, use the ${ENV_VAR_NAME:-default value} syntax. + * + * The default value syntax can be nested to resolve multiple environment variables: + * ${VAR1:-${VAR2:-${VAR3:-default if none set}}} + * + * @param {String} value The command/value to replace + * @param {Object} env optional environment object (defaults to process.env) + * @param {boolean} winEnvReplace If true, replace environment variables with '%NAME%' instead of their actual value + * @returns {String} the value with replacements + **/ +// state machine requires slightly more complexity than normally required +// eslint-disable-next-line complexity +function envReplace(value, env = process.env, winEnvReplace = false) { + let lastDollar = false + let escaped = false + let braceCount = 0 + let startIndex = 0 + const matches = new Set() + for (let i = 0; i < value.length; i++) { + const char = value.charAt(i) + switch (char) { + case '\\': + if (escaped && value.charAt(i + 1) === '$') { + //double escaped $ (special case) + value = `${value.substring(0, i)}${value.substring(i + 1)}` + i-- + } + escaped = !escaped + break + case '$': + lastDollar = true + break + case '{': + if (lastDollar) { + if (braceCount === 0) { + startIndex = i - 1 + } + braceCount++ + } + lastDollar = false + break + case '}': + if (braceCount === 1) { + // Case of ${ENVIRONMENT_VARIABLE_1_NAME:-default value} OR + // ${ENVIRONMENT_VARIABLE_1_NAME:-${ENVIRONMENT_VARIABLE_2_NAME:-default value}} + matches.add( + escaped + ? `\\${value.substring(startIndex, i + 1)}` + : value.substring(startIndex, i + 1), + ) + escaped = false + braceCount = 0 + } else if (braceCount > 0) { + braceCount-- + } + lastDollar = false + break + default: + // Case of $ENVIRONMENT_VARIABLE_1_NAME + if (lastDollar && braceCount === 0) { + const matchedRest = /(\w+).*/g.exec(value.substring(i)) + if (matchedRest) { + const envVarName = matchedRest[1] + i = i + envVarName.length + matches.add(escaped ? `\\$${envVarName}` : `$${envVarName}`) + } + escaped = false + } + lastDollar = false + } + } + for (const match of matches) { + value = replaceMatch(value, match, env, winEnvReplace) + } + return value +} + +function replaceMatch(value, match, env, winEnvReplace) { + if (match.charAt(0) === '\\') { + return value.replace(match, match.substring(1)) + } + + let envVarName = + match.charAt(1) === '{' + ? match.substring(2, match.length - 1) + : match.substring(1) + if (envVarName === 'PWD') { + return value.replace(match, process.cwd()) + } + + let defaultValue = '' + const defSepInd = envVarName.indexOf(':-') + // if there is a default value given (i.e. using the ':-' syntax) + if (defSepInd > 0) { + defaultValue = envReplace( + envVarName.substring(defSepInd + 2), + env, + winEnvReplace, + ) + envVarName = envVarName.substring(0, defSepInd) + } + + if (winEnvReplace) { + return value.replace( + match, + (env[envVarName] && `%${envVarName}%`) || defaultValue, + ) + } else { + return value.replace(match, env[envVarName] || defaultValue) + } +} diff --git a/src/variable.js b/src/variable.js index c57410f..ad7539e 100644 --- a/src/variable.js +++ b/src/variable.js @@ -1,4 +1,5 @@ const isWindows = require('./is-windows') +const envReplace = require('./env-replace') const pathLikeEnvVarWhitelist = new Set(['PATH', 'NODE_PATH']) @@ -28,50 +29,6 @@ function replaceListDelimiters(varValue, varName = '') { }) } -/** - * This will attempt to resolve the value of any env variables that are inside - * this string. For example, it will transform this: - * cross-env FOO=$NODE_ENV BAR=\\$NODE_ENV echo $FOO $BAR - * Into this: - * FOO=development BAR=$NODE_ENV echo $FOO - * (Or whatever value the variable NODE_ENV has) - * Note that this function is only called with the right-side portion of the - * env var assignment, so in that example, this function would transform - * the string "$NODE_ENV" into "development" - * - * To specify a default value for a variable, use the ${ENV_VAR_NAME:-default value} syntax. - * - * @param {String} varValue Original value of the env variable - * @returns {String} Converted value - */ -function resolveEnvVars(varValue) { - const envUnixRegex = /(\\*)(\$(\w+)|\${(\w+)(?::-([\w{}$:-]*))?})/g // $my_var or ${my_var} or \$my_var or ${my_var:-default_value} or ${my_var:-${backup_var:-default_value}} - return varValue.replace( - envUnixRegex, - ( - _, - escapeChars, - varNameWithDollarSign, - varName, - altVarName, - defaultValue, - ) => { - // do not replace things preceded by a odd number of \ - if (escapeChars.length % 2 === 1) { - return varNameWithDollarSign - } - return ( - escapeChars.substr(0, escapeChars.length / 2) + - ((varName || altVarName) === 'PWD' - ? process.cwd() - : process.env[varName || altVarName] || - (defaultValue && resolveEnvVars(defaultValue)) || - '') - ) - }, - ) -} - /** * Converts an environment variable value to be appropriate for the current OS. * @param {String} originalValue Original value of the env variable @@ -79,5 +36,5 @@ function resolveEnvVars(varValue) { * @returns {String} Converted value */ function varValueConvert(originalValue, originalName) { - return resolveEnvVars(replaceListDelimiters(originalValue, originalName)) + return envReplace(replaceListDelimiters(originalValue, originalName)) } From 17940c24c031aaac9dbe72ccae158ba182ad3874 Mon Sep 17 00:00:00 2001 From: Matt Morrissette Date: Tue, 19 Nov 2019 23:44:14 -0800 Subject: [PATCH 07/11] test: add missing tests for coverage --- src/__tests__/variable.js | 40 +++++++++++++++++++++++++++++++++++++++ src/env-replace.js | 4 ++-- 2 files changed, 42 insertions(+), 2 deletions(-) diff --git a/src/__tests__/variable.js b/src/__tests__/variable.js index 4d48914..ccdbbf4 100644 --- a/src/__tests__/variable.js +++ b/src/__tests__/variable.js @@ -106,6 +106,26 @@ test(`does not resolve an env variable prefixed with \\ on Windows`, () => { expect(varValueConvert('\\$VAR1')).toBe('$VAR1') }) +test(`does not resolve a $ without a word after it in UNIX`, () => { + isWindowsMock.mockReturnValue(false) + expect(varValueConvert('hello $ the case')).toBe('hello $ the case') +}) + +test(`does not resolve a $ without a word after it in Windows`, () => { + isWindowsMock.mockReturnValue(true) + expect(varValueConvert('hello $ the case')).toBe('hello $ the case') +}) + +test(`does not resolve an env variable if it isn't prefix with a $ but is in curly braces \\ on UNIX`, () => { + isWindowsMock.mockReturnValue(false) + expect(varValueConvert('{VAR1}')).toBe('{VAR1}') +}) + +test(`does not resolve an env variable if it isn't prefix with a $ but is in curly braces \\ on Windows`, () => { + isWindowsMock.mockReturnValue(true) + expect(varValueConvert('{VAR1}')).toBe('{VAR1}') +}) + test(`does not resolve an env variable prefixed with \\ on UNIX`, () => { isWindowsMock.mockReturnValue(false) expect(varValueConvert('\\$VAR1')).toBe('$VAR1') @@ -121,6 +141,26 @@ test(`resolves an env variable prefixed with \\\\ on UNIX`, () => { expect(varValueConvert('\\\\$VAR1')).toBe('\\value1') }) +test(`does not resolve an env variable prefixed with \\$\{ on Windows`, () => { + isWindowsMock.mockReturnValue(true) + expect(varValueConvert('\\${VAR1}')).toBe('${VAR1}') +}) + +test(`does not resolve an env variable prefixed with \\$\{ on UNIX`, () => { + isWindowsMock.mockReturnValue(false) + expect(varValueConvert('\\${VAR1}')).toBe('${VAR1}') +}) + +test(`resolves an env variable prefixed with \\\\$\{ on Windows`, () => { + isWindowsMock.mockReturnValue(true) + expect(varValueConvert('\\\\${VAR1}')).toBe('\\value1') +}) + +test(`resolves an env variable prefixed with \\\\$\{ on UNIX`, () => { + isWindowsMock.mockReturnValue(false) + expect(varValueConvert('\\\\${VAR1}')).toBe('\\value1') +}) + test(`resolves default value for missing variable on UNIX`, () => { isWindowsMock.mockReturnValue(false) expect(varValueConvert('${UNKNOWN_UNIX_VAR:-defaultUnix}')).toBe( diff --git a/src/env-replace.js b/src/env-replace.js index e49b5fc..6e36690 100644 --- a/src/env-replace.js +++ b/src/env-replace.js @@ -71,8 +71,8 @@ function envReplace(value, env = process.env, winEnvReplace = false) { default: // Case of $ENVIRONMENT_VARIABLE_1_NAME if (lastDollar && braceCount === 0) { - const matchedRest = /(\w+).*/g.exec(value.substring(i)) - if (matchedRest) { + const matchedRest = /^(\w+).*$/g.exec(value.substring(i)) + if (matchedRest !== null) { const envVarName = matchedRest[1] i = i + envVarName.length matches.add(escaped ? `\\$${envVarName}` : `$${envVarName}`) From a560ff440915d3fcd93890479eddd7e3f6049a66 Mon Sep 17 00:00:00 2001 From: Matt Morrissette Date: Tue, 19 Nov 2019 23:51:33 -0800 Subject: [PATCH 08/11] fix: bug in sequential variables --- src/__tests__/variable.js | 20 ++++++++++++++++++++ src/env-replace.js | 2 +- 2 files changed, 21 insertions(+), 1 deletion(-) diff --git a/src/__tests__/variable.js b/src/__tests__/variable.js index ccdbbf4..59abdb7 100644 --- a/src/__tests__/variable.js +++ b/src/__tests__/variable.js @@ -233,3 +233,23 @@ test('resolves a very complex string with defaults and PWD in Windows', () => { ), ).toBe(`start-${process.cwd()}-value1-value2-value3-${process.cwd()}-end`) }) + +test('sequential variables resolves in UNIX', () => { + isWindowsMock.mockReturnValue(false) + expect(varValueConvert('$VAR1$VAR2')).toBe('value1value2') +}) + +test('sequential variables with brackets resolves in UNIX', () => { + isWindowsMock.mockReturnValue(false) + expect(varValueConvert('${VAR1}$VAR2')).toBe('value1value2') +}) + +test('sequential variables resolves in UNIX', () => { + isWindowsMock.mockReturnValue(false) + expect(varValueConvert('$VAR1$VAR2')).toBe('value1value2') +}) + +test('reversed sequential variables with brackets resolves in UNIX', () => { + isWindowsMock.mockReturnValue(false) + expect(varValueConvert('$VAR1${VAR2}')).toBe('value1value2') +}) diff --git a/src/env-replace.js b/src/env-replace.js index 6e36690..ecfe33e 100644 --- a/src/env-replace.js +++ b/src/env-replace.js @@ -74,7 +74,7 @@ function envReplace(value, env = process.env, winEnvReplace = false) { const matchedRest = /^(\w+).*$/g.exec(value.substring(i)) if (matchedRest !== null) { const envVarName = matchedRest[1] - i = i + envVarName.length + i = i + envVarName.length - 1 matches.add(escaped ? `\\$${envVarName}` : `$${envVarName}`) } escaped = false From 0db453b1efc26dabf161d457c900d9110d477005 Mon Sep 17 00:00:00 2001 From: Matt Morrissette Date: Tue, 19 Nov 2019 23:52:56 -0800 Subject: [PATCH 09/11] test: adding additional tests --- src/__tests__/variable.js | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/src/__tests__/variable.js b/src/__tests__/variable.js index 59abdb7..6926796 100644 --- a/src/__tests__/variable.js +++ b/src/__tests__/variable.js @@ -236,20 +236,24 @@ test('resolves a very complex string with defaults and PWD in Windows', () => { test('sequential variables resolves in UNIX', () => { isWindowsMock.mockReturnValue(false) - expect(varValueConvert('$VAR1$VAR2')).toBe('value1value2') + expect(varValueConvert('start-$VAR1$VAR2-end')).toBe('start-value1value2-end') }) test('sequential variables with brackets resolves in UNIX', () => { isWindowsMock.mockReturnValue(false) - expect(varValueConvert('${VAR1}$VAR2')).toBe('value1value2') + expect(varValueConvert('start-${VAR1}$VAR2-end')).toBe( + 'start-value1value2-end', + ) }) test('sequential variables resolves in UNIX', () => { isWindowsMock.mockReturnValue(false) - expect(varValueConvert('$VAR1$VAR2')).toBe('value1value2') + expect(varValueConvert('start-$VAR1$VAR2-end')).toBe('start-value1value2-end') }) test('reversed sequential variables with brackets resolves in UNIX', () => { isWindowsMock.mockReturnValue(false) - expect(varValueConvert('$VAR1${VAR2}')).toBe('value1value2') + expect(varValueConvert('start-$VAR1${VAR2}-end')).toBe( + 'start-value1value2-end', + ) }) From c11b5f94a11ea6fa5eefd136ac7c088640baf8e2 Mon Sep 17 00:00:00 2001 From: Matt Morrissette Date: Wed, 20 Nov 2019 02:47:31 -0800 Subject: [PATCH 10/11] fix: error with escaped } in defaults --- src/__tests__/variable.js | 9 ++++- src/env-replace.js | 85 ++++++++++++++++++++++----------------- 2 files changed, 56 insertions(+), 38 deletions(-) diff --git a/src/__tests__/variable.js b/src/__tests__/variable.js index 6926796..77c01e6 100644 --- a/src/__tests__/variable.js +++ b/src/__tests__/variable.js @@ -146,7 +146,7 @@ test(`does not resolve an env variable prefixed with \\$\{ on Windows`, () => { expect(varValueConvert('\\${VAR1}')).toBe('${VAR1}') }) -test(`does not resolve an env variable prefixed with \\$\{ on UNIX`, () => { +test(`does not resolve an env variable prefixed with on UNIX`, () => { isWindowsMock.mockReturnValue(false) expect(varValueConvert('\\${VAR1}')).toBe('${VAR1}') }) @@ -229,7 +229,7 @@ test('resolves a very complex string with defaults and PWD in Windows', () => { isWindowsMock.mockReturnValue(true) expect( varValueConvert( - 'start-${PWD}-${EMPTY_VAR:-${NO_VAR:-$VAR1}}-${NO_VAR:-$VAR2}-${NO_VAR:-${EMPTY_VAR:-value3}}-${EMPTY_VAR:-$PWD}-end', + 'start-${PWD}-${EMPTY_VAR:-${NO_VAR1:-$VAR1}}-${NO_VAR2:-$VAR2}-${NO_VAR3:-${EMPTY_VAR:-value3}}-${EMPTY_VAR:-$PWD}-end', ), ).toBe(`start-${process.cwd()}-value1-value2-value3-${process.cwd()}-end`) }) @@ -257,3 +257,8 @@ test('reversed sequential variables with brackets resolves in UNIX', () => { 'start-value1value2-end', ) }) + +test('escape closed curly brace in default value in UNIX', () => { + isWindowsMock.mockReturnValue(false) + expect(varValueConvert('${EMPTY_VAR:-${UNKNOWN:-ba\\}r}}')).toBe('ba}r') +}) diff --git a/src/env-replace.js b/src/env-replace.js index ecfe33e..c7e5cc9 100644 --- a/src/env-replace.js +++ b/src/env-replace.js @@ -25,20 +25,16 @@ module.exports = envReplace // eslint-disable-next-line complexity function envReplace(value, env = process.env, winEnvReplace = false) { let lastDollar = false - let escaped = false let braceCount = 0 let startIndex = 0 - const matches = new Set() + let escapeCount = 0 for (let i = 0; i < value.length; i++) { const char = value.charAt(i) switch (char) { case '\\': - if (escaped && value.charAt(i + 1) === '$') { - //double escaped $ (special case) - value = `${value.substring(0, i)}${value.substring(i + 1)}` - i-- + if (braceCount === 0) { + escapeCount++ } - escaped = !escaped break case '$': lastDollar = true @@ -53,18 +49,30 @@ function envReplace(value, env = process.env, winEnvReplace = false) { lastDollar = false break case '}': - if (braceCount === 1) { - // Case of ${ENVIRONMENT_VARIABLE_1_NAME:-default value} OR - // ${ENVIRONMENT_VARIABLE_1_NAME:-${ENVIRONMENT_VARIABLE_2_NAME:-default value}} - matches.add( - escaped - ? `\\${value.substring(startIndex, i + 1)}` - : value.substring(startIndex, i + 1), - ) - escaped = false - braceCount = 0 - } else if (braceCount > 0) { - braceCount-- + if (braceCount > 0) { + if (i > 0 && value.charAt(i - 1) === '\\') { + //ignore for now + } else if (braceCount === 1) { + // Case of ${ENVIRONMENT_VARIABLE_1_NAME:-default value} OR + // ${ENVIRONMENT_VARIABLE_1_NAME:-${ENVIRONMENT_VARIABLE_2_NAME:-default value}} + const prefix = value.substring( + 0, + startIndex - Math.round(escapeCount / 2), + ) + const match = value.substring(startIndex, i + 1) + const suffix = value.length > i + 1 ? value.substring(i + 1) : '' + const replace = + escapeCount % 2 === 1 + ? match + : replaceMatch(match, env, winEnvReplace).replace(/\\}/g, '}') + + value = `${prefix}${replace}${suffix}` + i = replace.length - match.length + escapeCount = 0 + braceCount = 0 + } else { + braceCount-- + } } lastDollar = false break @@ -74,31 +82,39 @@ function envReplace(value, env = process.env, winEnvReplace = false) { const matchedRest = /^(\w+).*$/g.exec(value.substring(i)) if (matchedRest !== null) { const envVarName = matchedRest[1] - i = i + envVarName.length - 1 - matches.add(escaped ? `\\$${envVarName}` : `$${envVarName}`) + const prefix = value.substring( + 0, + i - Math.round(escapeCount / 2) - 1, + ) + const match = value.substring(i - 1, i + envVarName.length) + const suffix = + value.length > i + envVarName.length + ? value.substring(i + envVarName.length) + : '' + const replace = + escapeCount % 2 === 1 + ? match + : replaceMatch(match, env, winEnvReplace) + value = `${prefix}${replace}${suffix}` + i = replace.length - match.length } - escaped = false + } + if (braceCount === 0) { + escapeCount = 0 } lastDollar = false } } - for (const match of matches) { - value = replaceMatch(value, match, env, winEnvReplace) - } return value } -function replaceMatch(value, match, env, winEnvReplace) { - if (match.charAt(0) === '\\') { - return value.replace(match, match.substring(1)) - } - +function replaceMatch(match, env, winEnvReplace) { let envVarName = match.charAt(1) === '{' ? match.substring(2, match.length - 1) : match.substring(1) if (envVarName === 'PWD') { - return value.replace(match, process.cwd()) + return process.cwd() } let defaultValue = '' @@ -114,11 +130,8 @@ function replaceMatch(value, match, env, winEnvReplace) { } if (winEnvReplace) { - return value.replace( - match, - (env[envVarName] && `%${envVarName}%`) || defaultValue, - ) + return (env[envVarName] && `%${envVarName}%`) || defaultValue } else { - return value.replace(match, env[envVarName] || defaultValue) + return env[envVarName] || defaultValue } } From 318f2f766adffd94122551a91b6b78efd1856e69 Mon Sep 17 00:00:00 2001 From: Matt Morrissette Date: Wed, 20 Nov 2019 18:16:08 -0800 Subject: [PATCH 11/11] fix: extreme escape example --- src/__tests__/command.js | 10 ++++++++++ src/__tests__/variable.js | 9 +++++++++ src/env-replace.js | 7 ++++--- 3 files changed, 23 insertions(+), 3 deletions(-) diff --git a/src/__tests__/command.js b/src/__tests__/command.js index 4f0821d..6b4a152 100644 --- a/src/__tests__/command.js +++ b/src/__tests__/command.js @@ -120,3 +120,13 @@ test('resolves the current working directory inside string in UNIX', () => { isWindowsMock.mockReturnValue(false) expect(commandConvert('cd ${PWD}/adir')).toBe('cd ${PWD}/adir') }) + +test('All the escapes are belong to us in Windows', () => { + isWindowsMock.mockReturnValue(true) + expect( + commandConvert( + 'start-\\${ESCAPED:-a-\\}-val}-\\$ESCAPED-\\\\${ESCAPED:-a-\\}-val}-\\\\$test1-end', + env, + ), + ).toBe('start-${ESCAPED:-a-\\}-val}-$ESCAPED-\\a-}-val-\\%test1%-end') +}) diff --git a/src/__tests__/variable.js b/src/__tests__/variable.js index 77c01e6..ae2c33e 100644 --- a/src/__tests__/variable.js +++ b/src/__tests__/variable.js @@ -262,3 +262,12 @@ test('escape closed curly brace in default value in UNIX', () => { isWindowsMock.mockReturnValue(false) expect(varValueConvert('${EMPTY_VAR:-${UNKNOWN:-ba\\}r}}')).toBe('ba}r') }) + +test('All the escapes are belong to us in UNIX', () => { + isWindowsMock.mockReturnValue(false) + expect( + varValueConvert( + 'start-\\${ESCAPED:-a-\\}-val}-\\$ESCAPED-\\\\${ESCAPED:-a-\\}-val}-\\\\$VAR1-end', + ), + ).toBe('start-${ESCAPED:-a-\\}-val}-$ESCAPED-\\a-}-val-\\value1-end') +}) diff --git a/src/env-replace.js b/src/env-replace.js index c7e5cc9..531e156 100644 --- a/src/env-replace.js +++ b/src/env-replace.js @@ -65,9 +65,9 @@ function envReplace(value, env = process.env, winEnvReplace = false) { escapeCount % 2 === 1 ? match : replaceMatch(match, env, winEnvReplace).replace(/\\}/g, '}') - + const beforeLength = value.length value = `${prefix}${replace}${suffix}` - i = replace.length - match.length + i = i - beforeLength + value.length escapeCount = 0 braceCount = 0 } else { @@ -95,8 +95,9 @@ function envReplace(value, env = process.env, winEnvReplace = false) { escapeCount % 2 === 1 ? match : replaceMatch(match, env, winEnvReplace) + const beforeLength = value.length value = `${prefix}${replace}${suffix}` - i = replace.length - match.length + i = i - beforeLength + value.length } } if (braceCount === 0) {