From 6d90cc6837dc93aac06c2d5cd0aa8f3de031200e Mon Sep 17 00:00:00 2001 From: lprnmns Date: Fri, 23 Oct 2026 20:47:32 +0300 Subject: [PATCH] fix: restore dotenv-expand behavior for expandEnv --- README.md | 2 +- index.js | 49 +++++++++++++++++++++++++++++++----- test/expand.test.js | 61 +++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 105 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index 03f94a5..9eced53 100644 --- a/README.md +++ b/README.md @@ -141,7 +141,7 @@ const config = envSchema({ schema: S.object().prop('PORT', S.number().default(3000).required()), data: data, // optional, default: process.env dotenv: true, // load .env if it is there, default: false - expandEnv: true, // expand environment variables like $VAR or ${VAR}, default: false + expandEnv: true, // expand environment variables like $VAR, ${VAR}, ${VAR:-default} or ${VAR-default} (\$ for a literal $), default: false }) console.log(config) diff --git a/index.js b/index.js index 98c600e..86bd4a0 100644 --- a/index.js +++ b/index.js @@ -20,14 +20,51 @@ const separator = { } } +// Same syntax as dotenv-expand, which was used before: $VAR, ${VAR}, +// ${VAR:-default} (VAR unset or empty), ${VAR-default} (VAR unset) and \$ +const EXPANSION_RE = /\\\$|\$\{([A-Z_][A-Z0-9_]*)(:?-)([^}]*)\}|\$\{?([A-Z_][A-Z0-9_]*)\}?/gi + function expandVariables (obj) { - // Expand environment variables in the format $VAR or ${VAR} + const raw = Object.assign({}, obj) + const expanded = {} + + // References are resolved against the expanded value of the referenced + // variable, whatever the order of the keys + function expand (key, stack) { + if (Object.prototype.hasOwnProperty.call(expanded, key)) { + return expanded[key] + } + if (stack.includes(key)) { + // circular reference, keep it as written + return undefined + } + const value = raw[key] + if (typeof value !== 'string') { + return value + } + stack.push(key) + const result = interpolate(value, stack) + stack.pop() + expanded[key] = result + return result + } + + function interpolate (value, stack) { + return value.replace(EXPANSION_RE, (match, name, operator, fallback, plainName) => { + if (match === '\\$') { + return '$' + } + const resolved = raw[name || plainName] === undefined ? undefined : expand(name || plainName, stack) + if (operator && (resolved === undefined || (operator === ':-' && resolved === ''))) { + return interpolate(fallback, stack) + } + return resolved === undefined ? match : String(resolved) + }) + } + for (const key in obj) { - const value = obj[key] - if (typeof value === 'string') { - obj[key] = value.replace(/\$\{?([A-Z_][A-Z0-9_]*)\}?/gi, (match, varName) => { - return obj[varName] !== undefined ? obj[varName] : match - }) + if (typeof obj[key] === 'string') { + obj[key] = expand(key, []) } } } diff --git a/test/expand.test.js b/test/expand.test.js index dc858e0..b8a9356 100644 --- a/test/expand.test.js +++ b/test/expand.test.js @@ -1,5 +1,7 @@ 'use strict' +/* eslint-disable no-template-curly-in-string */ + const { test } = require('node:test') const makeTest = require('./make-test') const { join } = require('node:path') @@ -89,6 +91,65 @@ const tests = [ confExpected: { MESSAGE: 'Hello $UNDEFINED_VAR world' } + }, + { + name: 'simple object - ok - expandEnv resolves chained references whatever the key order', + schema: { + type: 'object', + properties: { + DATABASE_URL: { type: 'string' }, + DB_HOST: { type: 'string' } + } + }, + expandEnv: true, + isOk: true, + data: { + DATABASE_URL: 'postgres://$DB_HOST/app', + DB_HOST: '${DB_NAME}.internal', + DB_NAME: 'db' + }, + confExpected: { + DATABASE_URL: 'postgres://db.internal/app', + DB_HOST: 'db.internal' + } + }, + { + name: 'simple object - ok - expandEnv supports default values and escaped dollar signs', + schema: { + type: 'object', + properties: { + UNSET: { type: 'string' }, + EMPTY: { type: 'string' }, + EMPTY_DASH: { type: 'string' }, + SET: { type: 'string' }, + PRICE: { type: 'string' }, + URL: { type: 'string' }, + LOOP: { type: 'string' } + } + }, + expandEnv: true, + isOk: true, + data: { + BLANK: '', + HOST: 'h', + UNSET: '${MISSING:-http://$HOST}', + EMPTY: '${BLANK:-fallback}', + EMPTY_DASH: '${BLANK-fallback}', + SET: '${HOST:-fallback}', + PRICE: '\\$5', + PORT: 3000, + URL: 'http://h:$PORT', + LOOP: 'a$LOOP' + }, + confExpected: { + UNSET: 'http://h', + EMPTY: 'fallback', + EMPTY_DASH: '', + SET: 'h', + PRICE: '$5', + URL: 'http://h:3000', + LOOP: 'a$LOOP' + } } ]