import-dotenv.psm1
|
#Requires -Version 5.0 <# .SYNOPSIS Import-DotEnv PowerShell Module Loads environment variables from .env files in the directory hierarchy. .DESCRIPTION Traverses the directory tree from the current location up to the filesystem root, plus the user's home directory, loading and applying all .env files found along the way. Variable substitution is fully supported with bash-like and PowerShell syntax. .EXAMPLE Import-DotEnv Loads all .env files from current directory up to root and user home directory. #> function Expand-EnvValue { <# .SYNOPSIS Expands variable references in a string value. .DESCRIPTION Handles variable expansion with support for: - $VAR and ${VAR} syntax - Double quotes (expansion enabled) - Single quotes (literal content) - Escape sequences (\n, \t, \\, \") - PowerShell $'...' escape syntax - Windows environment variables .PARAMETER Value The string value to expand. .PARAMETER EnvHash Hashtable of environment variables for lookup. .PARAMETER Depth Current recursion depth (internal use). .PARAMETER MaxDepth Maximum recursion depth to prevent infinite loops (default: 10). .OUTPUTS [string] The expanded value. .EXAMPLE Expand-EnvValue -Value 'prefix_$VAR' -EnvHash @{VAR='value'} # Returns: 'prefix_value' #> param( [string]$value, [hashtable]$envHash, [int]$depth = 0, [int]$maxDepth = 10 ) # Check recursion depth to prevent infinite loops if ($depth -gt $maxDepth) { Write-Warning "Variable expansion exceeded maximum recursion depth ($maxDepth) for value: $value" return $value } $result = "" $i = 0 while ($i -lt $value.Length) { $char = $value[$i] # Handle double quotes - enable variable expansion if ($char -eq '"') { $i++ $quotedContent = "" while ($i -lt $value.Length -and $value[$i] -ne '"') { if ($value[$i] -eq '\' -and $i + 1 -lt $value.Length) { # In bash-compatible mode, only interpret \" as escaped quote # All other backslash sequences are kept literal $nextChar = $value[$i + 1] if ($nextChar -eq '"') { # Escaped quote - interpret it $quotedContent += '"' $i += 2 } else { # Keep backslash literal and continue $quotedContent += $value[$i] $i++ } } else { $quotedContent += $value[$i] $i++ } } # Expand variables in double-quoted content using regex # Variable names must start with letter or underscore: [a-zA-Z_] # Followed by zero or more word characters: \w* $expandedContent = [regex]::Replace($quotedContent, '\$\{?([a-zA-Z_]\w*)\}?', { param($match) $varName = $match.Groups[1].Value if ($envHash.ContainsKey($varName)) { return $envHash[$varName] } # Check Windows environment variables $envVar = Get-Item -Path "Env:$varName" -ErrorAction SilentlyContinue if ($envVar) { return $envVar.Value } return "" }) $result += $expandedContent if ($i -lt $value.Length) { $i++ } # Skip closing quote } # Handle single quotes - prevent variable expansion elseif ($char -eq "'") { $i++ while ($i -lt $value.Length -and $value[$i] -ne "'") { $result += $value[$i] $i++ } if ($i -lt $value.Length) { $i++ } # Skip closing quote } # Handle unquoted content - enable variable expansion else { if ($char -eq '$' -and $i + 1 -lt $value.Length) { $varContent = "" $i++ # Handle PowerShell $'...' escape syntax (e.g., $'\t' for tab) if ($value[$i] -eq "'") { $i++ # Skip opening quote $escapeContent = "" while ($i -lt $value.Length -and $value[$i] -ne "'") { if ($value[$i] -eq '\' -and $i + 1 -lt $value.Length) { $nextChar = $value[$i + 1] switch ($nextChar) { 'n' { $escapeContent += "`n"; $i += 2 } 't' { $escapeContent += "`t"; $i += 2 } '\' { $escapeContent += '\'; $i += 2 } "'" { $escapeContent += "'"; $i += 2 } default { $escapeContent += $value[$i]; $i++ } } } else { $escapeContent += $value[$i] $i++ } } if ($i -lt $value.Length) { $i++ } # Skip closing quote $result += $escapeContent } # Handle ${VAR} syntax elseif ($value[$i] -eq '{') { $i++ while ($i -lt $value.Length -and $value[$i] -ne '}') { $varContent += $value[$i] $i++ } if ($i -lt $value.Length) { $i++ } # Skip closing } # Look up the variable if ($envHash.ContainsKey($varContent)) { $result += $envHash[$varContent] } else { # Check Windows environment variables $envVar = Get-Item -Path "Env:$varContent" -ErrorAction SilentlyContinue if ($envVar) { $result += $envVar.Value } # Undefined variables expand to empty string (bash behavior) } } # Handle $VAR syntax else { # Variable names must start with a letter or underscore, not a digit if ($i -lt $value.Length -and ([char]::IsLetter($value[$i]) -or $value[$i] -eq '_')) { while ($i -lt $value.Length -and ([char]::IsLetterOrDigit($value[$i]) -or $value[$i] -eq '_')) { $varContent += $value[$i] $i++ } # Look up the variable if ($envHash.ContainsKey($varContent)) { $result += $envHash[$varContent] } else { # Check Windows environment variables $envVar = Get-Item -Path "Env:$varContent" -ErrorAction SilentlyContinue if ($envVar) { $result += $envVar.Value } # Undefined variables expand to empty string (bash behavior) } } else { # Not a valid variable name, just a lone $ $result += '$' } } } else { $result += $char $i++ } } } return $result } function Resolve-EnvVariable { <# .SYNOPSIS Recursively resolves variable references in a value. .DESCRIPTION Handles recursive variable expansion with cycle detection. Prevents infinite loops by limiting recursion depth. .PARAMETER Value The value to resolve. .PARAMETER EnvHash Hashtable of environment variables. .PARAMETER Depth Current recursion depth (internal use). .PARAMETER MaxDepth Maximum recursion depth (default: 10). .OUTPUTS [string] The fully resolved value. .EXAMPLE Resolve-EnvVariable -Value '$KEY2' -EnvHash @{KEY1='A'; KEY2='$KEY1'} # Returns: 'A' #> param( [string]$value, [hashtable]$envHash, [int]$depth = 0, [int]$maxDepth = 10 ) # Check if the entire value is single-quoted (preventing any recursion) if ($value -match "^'[^']*'$") { # Single-quoted value - just remove the quotes and return return $value -replace "^'|'$", '' } # First pass: expand variables $expandedValue = Expand-EnvValue $value $envHash $depth $maxDepth # Only recursively expand if: # 1. The value changed from expansion # 2. We haven't exceeded max depth # 3. The ORIGINAL value had variable references in a double-quoted context that could be nested if ($expandedValue -ne $value -and $depth -lt $maxDepth) { # Check if original value has variable syntax that suggests nesting # (e.g., "...$VAR..." where VAR itself might contain references) if ($value -match '"[^"]*\$\{?\w+\}?' -and $expandedValue -match '"[^"]*\$\{?\w+\}?' -and $expandedValue -ne $value) { # Only recursively expand if the expanded value STILL has variable syntax # This handles cases like KEY1=A, KEY2=$KEY1, KEY3=$KEY2 return Resolve-EnvVariable $expandedValue $envHash ($depth + 1) $maxDepth } } return $expandedValue } function ConvertFrom-DotEnvContent { <# .SYNOPSIS Parses .env file content into KEY=VALUE pairs. .DESCRIPTION Handles multiline values with proper quote and continuation handling. Supports: - Multiline quoted strings (preserves literal newlines) - Backslash line continuation - Escaped quotes within quoted strings - Comments and empty lines .PARAMETER Content The full .env file content as a single string. .OUTPUTS [PSCustomObject[]] Array of objects with Key and Value properties. .EXAMPLE $content = Get-Content .env -Raw ConvertFrom-DotEnvContent -Content $content #> param( [string]$content ) $result = @() # Normalize line endings to just \n (handle Windows \r\n) $content = $content -replace "`r`n", "`n" $lines = @($content -split "`n") $i = 0 while ($i -lt $lines.Count) { $line = $lines[$i] $i++ # Skip empty lines and comment lines $trimmed = $line.Trim() if ([string]::IsNullOrWhiteSpace($trimmed) -or $trimmed.StartsWith('#')) { continue } # Check if line contains = if ($trimmed -notmatch '=') { continue } # Extract key (everything before first =) $eqIndex = $trimmed.IndexOf('=') if ($eqIndex -le 0) { continue } $key = $trimmed.Substring(0, $eqIndex).Trim() $valueStart = $eqIndex + 1 $rawValue = $trimmed.Substring($valueStart) # Parse the value, handling quotes and line continuation $value = "" $pos = 0 $inDoubleQuote = $false $inSingleQuote = $false while ($pos -lt $rawValue.Length) { $char = $rawValue[$pos] if ($inSingleQuote) { # In single quotes - no escaping, just look for closing quote if ($char -eq "'") { $inSingleQuote = $false $value += $char } else { $value += $char } $pos++ } elseif ($inDoubleQuote) { # In double quotes - handle escapes and newlines if ($char -eq '\' -and $pos + 1 -lt $rawValue.Length) { $nextChar = $rawValue[$pos + 1] if ($nextChar -eq '"' -or $nextChar -eq '\' -or $nextChar -eq 'n' -or $nextChar -eq 't') { # These are handled by Expand-EnvValue later, just preserve them $value += $char + $nextChar $pos += 2 } else { $value += $char $pos++ } } elseif ($char -eq '"') { $inDoubleQuote = $false $value += $char $pos++ } else { $value += $char $pos++ } } else { # Not in quotes if ($char -eq '"') { $inDoubleQuote = $true $value += $char $pos++ } elseif ($char -eq "'") { $inSingleQuote = $true $value += $char $pos++ } elseif ($char -eq '\' -and $pos + 1 -lt $rawValue.Length) { # Check if this is a line continuation (backslash at end of line) $nextChar = $rawValue[$pos + 1] if ($nextChar -eq 'n' -or $nextChar -eq 't' -or $nextChar -eq '\') { # Escape sequence in unquoted value - preserve it $value += $char + $nextChar $pos += 2 } else { # Just a backslash, could be line continuation $pos++ } } else { $value += $char $pos++ } } } # If we ended in a quote, the quote wasn't closed in this line - we might need multiline # Also check for line continuation (backslash at the very end of the raw value) $needsContinuation = $inDoubleQuote -or $inSingleQuote -or ($rawValue.TrimEnd() -match '\\$') # Read continuation lines while ($needsContinuation -and $i -lt $lines.Count) { $nextLine = $lines[$i] $i++ # Check if current value ends with backslash continuation (bash-like behavior) $hasContinuation = $value -match '\\$' if ($hasContinuation -and !$inSingleQuote) { # Remove trailing backslash and join without newline $value = $value -replace '\\$', '' $value += $nextLine # After joining, recheck if we need to continue $needsContinuation = $inDoubleQuote -or $inSingleQuote -or ($nextLine.TrimEnd() -match '\\$') } else { # Add newline between lines (for multiline quoted strings) $value += "`n" + $nextLine # Check if we've closed the quote or have another continuation $needsContinuation = $inDoubleQuote -or $inSingleQuote } # Check if we've closed the quote if ($inSingleQuote) { $singleQuotePos = $nextLine.IndexOf("'") if ($singleQuotePos -ge 0) { $inSingleQuote = $false $needsContinuation = $false } } elseif ($inDoubleQuote) { # Look for unescaped closing quote $pos = 0 while ($pos -lt $nextLine.Length) { if ($nextLine[$pos] -eq '\' -and $pos + 1 -lt $nextLine.Length) { $pos += 2 } elseif ($nextLine[$pos] -eq '"') { $inDoubleQuote = $false $needsContinuation = $false break } else { $pos++ } } } } # Trim the value and create result object $trimmedValue = $value.Trim() $result += [PSCustomObject]@{ Key = $key Value = $trimmedValue } } return $result } function Import-DotEnv { <# .SYNOPSIS Loads environment variables from .env files in the directory hierarchy. .DESCRIPTION Traverses the directory tree from the current location up to the filesystem root, plus the user's home directory, loading and applying all .env files found along the way. Files are processed in order from root to current directory, so variables in the current directory override those in parent directories. Supports: - Multiline values: "line1\nline2\nline3" or backslash continuation - Variable substitution: KEY2=$KEY1 - Quote handling: "expands" vs 'literal' - Escape sequences: \n, \t, \\, \" - PowerShell syntax: $'\t' - Windows environment variables: $TEMP, $USERPROFILE - Recursive expansion: KEY1=A, KEY2=$KEY1, KEY3=$KEY2 .EXAMPLE Import-DotEnv Loads all .env files from current directory hierarchy. .NOTES The script is automatically invoked when the module is imported. Variables are loaded into the current PowerShell session. #> [CmdletBinding()] param() $ProcessingPaths = [System.Collections.Generic.List[string]]::new() $path = (Get-Item .).FullName $root = (Get-Item .).Root.FullName while ($true) { $ProcessingPaths.Add($path) if ( $path -eq $root) { break } $path = Split-Path $path -Parent } $ProcessingPaths.Reverse() $ProcessingPaths = @($Env:USERPROFILE; $ProcessingPaths) # Build a hashtable of loaded environment variables for variable expansion $envHash = @{} $ProcessingPaths | ForEach-Object { if ( -not (Test-Path "$($_.TrimEnd("\"))\.env") ) { return } # Read entire file as string to support multiline values $fileContent = Get-Content "$($_.TrimEnd("\"))\.env" -Raw -ErrorAction SilentlyContinue if ([string]::IsNullOrEmpty($fileContent)) { return } # Parse the content into KEY=VALUE pairs $pairs = ConvertFrom-DotEnvContent -Content $fileContent # Process each parsed pair $pairs | ForEach-Object { $key = $_.Key $rawValue = $_.Value # Check if value is empty (KEY= without value) - treat as removal if ([string]::IsNullOrWhiteSpace($rawValue)) { # Remove environment variable Remove-Item -Path "Env:$key" -ErrorAction SilentlyContinue # Also remove from hashtable $envHash.Remove($key) } else { # Resolve variables in the value, using current environment + loaded variables $expandedValue = Resolve-EnvVariable $rawValue $envHash # Set environment variable Set-Item -Path "Env:$key" -Value $expandedValue # Also add to our hashtable for subsequent variable references $envHash[$key] = $expandedValue } } } } # Export public functions Export-ModuleMember -Function Import-DotEnv # Load .env files on module import Import-DotEnv |