Merge pull request #410 from toddbluhm/feat-recursive-var-expansion

feat: Add --recursive flag to enable env-var nesting
This commit is contained in:
Todd Bluhm 2025-08-06 17:29:23 -08:00 committed by GitHub
commit 3db3c8f005
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
15 changed files with 108 additions and 28 deletions

View File

@ -1,15 +1,18 @@
# Changelog
## 10.1.1 - In Development
## Landed in master
- **Upgrade**: Upgraded dependency `commander` to `5.x`
- **Upgrade**: Upgraded devDependencies `ts-standard`, `sinon`
- **Upgrade**: Upgraded dependency `commander` to `13.x`
- **Upgrade**: Upgraded dependency `cross-spawn` to `7.x`
- **Upgrade**: Upgraded all devDependencies `ts-standard`, `sinon`
- **Feature**: support both `$var` and `${var}` when expanding vars
- **Feature**: Added support for nested env variables with the `--recursive` flag
## 10.1.0
- **Feature**: Added support for expanding vars using the `-x` flag.
Note: only supports `$var` syntax
- **Feature**: Added support for `--silent` flag that ignores env-cmd errors and missing files and
- **Feature**: Added support for `--silent` flag that ignores env-cmd errors and missing files and
only terminates on caught signals
- **Feature**: Added a new `--verbose` flag that prints additional debugging info to `console.info`
- **Upgrade**: Upgraded dependency `commander` to `4.x`

View File

@ -59,15 +59,16 @@ Usage: env-cmd [options] -- <command> [...args]
Options:
-v, --version output the version number
-e, --environments [envs...] The rc file environment(s) to use
-f, --file [path] Custom env file path or .rc file path if '-e' used (default path: ./.env or
./.env-cmdrc.(js|cjs|mjs|json))
-x, --expand-envs Replace $var in args and command with environment variables
-f, --file [path] Custom env file path or .rc file path if '-e' used (default path: ./.env or ./.env-cmdrc.(js|cjs|mjs|json))
-x, --expand-envs Replace $var and ${var} in args and command with environment variables
--recursive Replace $var and ${var} in env file with the referenced environment variable
--fallback Fallback to default env file path, if custom env file path not found
--no-override Do not override existing environment variables
--silent Ignore any env-cmd errors and only fail on executed program failure.
--use-shell Execute the command in a new shell with the given environment
--verbose Print helpful debugging information
-h, --help display help for command
```
## 🔬 Advanced Usage
@ -129,14 +130,14 @@ commands together that share the same environment variables.
```
### Asynchronous env file support
EnvCmd supports reading from asynchronous `.env` files. Instead of using a `.env` file, pass in a `.js`
file that exports either an object or a `Promise` resolving to an object (`{ ENV_VAR_NAME: value, ... }`). Asynchronous `.rc`
files are also supported using `.js` file extension and resolving to an object with top level environment
names (`{ production: { ENV_VAR_NAME: value, ... } }`).
**Terminal**
```sh
./node_modules/.bin/env-cmd -f ./async-file.js -- node index.js
```

7
dist/env-cmd.js vendored
View File

@ -29,6 +29,13 @@ export async function EnvCmd({ command, commandArgs, envFile, rc, options = {},
// Add in the system environment variables to our environment list
env = Object.assign({}, processLib.env, env);
}
if (options.recursive === true) {
for (const key of Object.keys(env)) {
if (env[key] !== undefined) {
env[key] = expandEnvs(env[key], env);
}
}
}
if (options.expandEnvs === true) {
command = expandEnvs(command, env);
commandArgs = commandArgs.map(arg => expandEnvs(arg, env));

View File

@ -1,6 +1,6 @@
import type { Environment } from './types.ts';
/**
* expandEnvs Replaces $var in args and command with environment variables
* expandEnvs Replaces $var and ${var} in args and command with environment variables
* if the environment variable doesn't exist, it leaves it as is.
*/
export declare function expandEnvs(str: string, envs: Environment): string;

7
dist/expand-envs.js vendored
View File

@ -1,11 +1,10 @@
/**
* expandEnvs Replaces $var in args and command with environment variables
* expandEnvs Replaces $var and ${var} in args and command with environment variables
* if the environment variable doesn't exist, it leaves it as is.
*/
export function expandEnvs(str, envs) {
return str.replace(/(?<!\\)\$[a-zA-Z0-9_]+/g, (varName) => {
const varValue = envs[varName.slice(1)];
// const test = 42;
return str.replace(/(?<!\\)\$(\{\w+\}|\w+)?/g, (varName) => {
const varValue = envs[varName.startsWith('${') ? varName.slice(2, varName.length - 1) : varName.slice(1)];
return varValue ?? varName;
});
}

8
dist/parse-args.js vendored
View File

@ -28,6 +28,10 @@ export function parseArgs(args) {
if (parsedCmdOptions.expandEnvs === true) {
expandEnvs = true;
}
let recursive = false;
if (parsedCmdOptions.recursive === true) {
recursive = true;
}
let verbose = false;
if (parsedCmdOptions.verbose === true) {
verbose = true;
@ -65,6 +69,7 @@ export function parseArgs(args) {
rc,
options: {
expandEnvs,
recursive,
noOverride,
silent,
useShell,
@ -83,7 +88,8 @@ export function parseArgsUsingCommander(args) {
.usage('[options] -- <command> [...args]')
.option('-e, --environments [envs...]', 'The rc file environment(s) to use', parseArgList)
.option('-f, --file [path]', 'Custom env file path or .rc file path if \'-e\' used (default path: ./.env or ./.env-cmdrc.(js|cjs|mjs|json))')
.option('-x, --expand-envs', 'Replace $var in args and command with environment variables')
.option('-x, --expand-envs', 'Replace $var and ${var} in args and command with environment variables')
.option('--recursive', 'Replace $var and ${var} in env file with the referenced environment variable')
.option('--fallback', 'Fallback to default env file path, if custom env file path not found')
.option('--no-override', 'Do not override existing environment variables')
.option('--silent', 'Ignore any env-cmd errors and only fail on executed program failure.')

2
dist/types.d.ts vendored
View File

@ -4,6 +4,7 @@ export type RCEnvironment = Partial<Record<string, Environment>>;
export type CommanderOptions = Command<[], {
environments?: true | string[];
expandEnvs?: boolean;
recursive?: boolean;
fallback?: boolean;
file?: true | string;
override?: boolean;
@ -29,6 +30,7 @@ export interface EnvCmdOptions extends GetEnvVarOptions {
commandArgs: string[];
options?: {
expandEnvs?: boolean;
recursive?: boolean;
noOverride?: boolean;
silent?: boolean;
useShell?: boolean;

View File

@ -38,11 +38,12 @@
],
"author": "Todd Bluhm",
"contributors": [
"Anton Versal <ant.ver@gmail.com>",
"Eric Lanehart <eric@pushred.co>",
"Jon Scheiding <jonscheiding@gmail.com>",
"serapath (Alexander Praetorius) <dev@serapath.de>",
"Kyle Hensel <me@kyle.kiwi>",
"Anton Versal <ant.ver@gmail.com>"
"Nicholas Krul <nicholas.krul@gmail.com>",
"serapath (Alexander Praetorius) <dev@serapath.de>"
],
"license": "MIT",
"bugs": {

View File

@ -40,6 +40,14 @@ export async function EnvCmd(
env = Object.assign({}, processLib.env, env)
}
if (options.recursive === true) {
for (const key of Object.keys(env)) {
if (env[key] !== undefined) {
env[key] = expandEnvs(env[key], env)
}
}
}
if (options.expandEnvs === true) {
command = expandEnvs(command, env)
commandArgs = commandArgs.map(arg => expandEnvs(arg, env))

View File

@ -1,13 +1,12 @@
import type { Environment } from './types.ts'
/**
* expandEnvs Replaces $var in args and command with environment variables
* expandEnvs Replaces $var and ${var} in args and command with environment variables
* if the environment variable doesn't exist, it leaves it as is.
*/
export function expandEnvs(str: string, envs: Environment): string {
return str.replace(/(?<!\\)\$[a-zA-Z0-9_]+/g, (varName) => {
const varValue = envs[varName.slice(1)]
// const test = 42;
export function expandEnvs (str: string, envs: Environment): string {
return str.replace(/(?<!\\)\$(\{\w+\}|\w+)?/g, (varName) => {
const varValue = envs[varName.startsWith('${') ? varName.slice(2, varName.length - 1) : varName.slice(1)]
return varValue ?? varName
})
}

View File

@ -33,6 +33,10 @@ export function parseArgs(args: string[]): EnvCmdOptions {
if (parsedCmdOptions.expandEnvs === true) {
expandEnvs = true
}
let recursive = false
if (parsedCmdOptions.recursive === true) {
recursive = true
}
let verbose = false
if (parsedCmdOptions.verbose === true) {
verbose = true
@ -75,6 +79,7 @@ export function parseArgs(args: string[]): EnvCmdOptions {
rc,
options: {
expandEnvs,
recursive,
noOverride,
silent,
useShell,
@ -95,7 +100,8 @@ export function parseArgsUsingCommander(args: string[]): CommanderOptions {
.usage('[options] -- <command> [...args]')
.option('-e, --environments [envs...]', 'The rc file environment(s) to use', parseArgList)
.option('-f, --file [path]', 'Custom env file path or .rc file path if \'-e\' used (default path: ./.env or ./.env-cmdrc.(js|cjs|mjs|json))')
.option('-x, --expand-envs', 'Replace $var in args and command with environment variables')
.option('-x, --expand-envs', 'Replace $var and ${var} in args and command with environment variables')
.option('--recursive', 'Replace $var and ${var} in env file with the referenced environment variable')
.option('--fallback', 'Fallback to default env file path, if custom env file path not found')
.option('--no-override', 'Do not override existing environment variables')
.option('--silent', 'Ignore any env-cmd errors and only fail on executed program failure.')

View File

@ -8,6 +8,7 @@ export type RCEnvironment = Partial<Record<string, Environment>>
export type CommanderOptions = Command<[], {
environments?: true | string[]
expandEnvs?: boolean // Default: false
recursive?: boolean // Default: false
fallback?: boolean // Default false
file?: true | string
override?: boolean // Default: false
@ -37,6 +38,7 @@ export interface EnvCmdOptions extends GetEnvVarOptions {
commandArgs: string[]
options?: {
expandEnvs?: boolean
recursive?: boolean
noOverride?: boolean
silent?: boolean
useShell?: boolean

View File

@ -169,6 +169,35 @@ describe('EnvCmd', (): void => {
},
)
it('should spawn process with args expanded if recursive option is true',
async (): Promise<void> => {
getEnvVarsStub.returns({ PING: 'PONG', recursive: 'PING ${PING}' })
await envCmdLib.EnvCmd({
command: 'node',
commandArgs: [],
envFile: {
filePath: './.env',
fallback: true
},
rc: {
environments: ['dev'],
filePath: './.rc'
},
options: {
recursive: true
}
})
const spawnArgs = spawnStub.args[0]
assert.equal(getEnvVarsStub.callCount, 1, 'getEnvVars must be called once')
assert.equal(spawnStub.callCount, 1)
assert.isAtLeast(expandEnvsSpy.callCount, 3, 'total number of env args')
assert.equal(spawnArgs[0], 'node')
assert.equal(spawnArgs[2].env.recursive, 'PING PONG')
}
)
it('should ignore errors if silent flag provided',
async (): Promise<void> => {
delete process.env.BOB

View File

@ -8,11 +8,22 @@ describe('expandEnvs', (): void => {
dollar: 'money',
PING: 'PONG',
IP1: '127.0.0.1',
THANKSFORALLTHEFISH: 42,
BRINGATOWEL: true,
THANKSFORALLTHEFISH: '42',
BRINGATOWEL: 'true',
}
const args = ['notvar', '$dollar', '\\$notvar', '-4', '$PING', '$IP1', '\\$IP1', '$NONEXIST']
const argsExpanded = ['notvar', 'money', '\\$notvar', '-4', 'PONG', '127.0.0.1', '\\$IP1', '$NONEXIST']
const args = [
'notvar', '$dollar', '\\$notvar', '-4',
'$PING', '$IP1', '\\$IP1', '$NONEXIST',
'${PING}', '${NONEXIST}', '\\${PING}',
'$PING}', '${PING2'
]
const argsExpanded = [
'notvar', 'money', '\\$notvar', '-4',
'PONG', '127.0.0.1', '\\$IP1', '$NONEXIST',
'PONG', '${NONEXIST}', '\\${PING}',
'PONG}', '${PING2'
]
it('should replace environment variables in args', (): void => {
const res = args.map(arg => expandEnvs(arg, envs))

View File

@ -98,6 +98,12 @@ describe('parseArgs', (): void => {
assert.isTrue(res.options!.expandEnvs)
})
it('should parse recursive option', (): void => {
const res = parseArgs(['-f', envFilePath, '--recursive', command, ...commandArgs])
assert.exists(res.envFile)
assert.isTrue(res.options!.recursive)
})
it('should parse silent option', (): void => {
const res = parseArgs(['-f', envFilePath, '--silent', '--', command, ...commandArgs])
assert.exists(res.envFile)