ImportDotEnv.psm1
# DotEnv.psm1 # Requires -Version 5.1 using namespace System.IO using namespace System.Management.Automation $script:trueOriginalEnvironmentVariables = @{} # Stores { VarName = OriginalValueOrNull } - a persistent record of pre-module values $script:previousEnvFiles = @() $script:previousWorkingDirectory = $PWD.Path $script:e = [char]27 $script:itemiserA = [char]0x2022 $script:itemiser = [char]0x21B3 $script:boldOn = "$($script:e)[1m" $script:boldOff = "$($script:e)[0m" # Resets all attributes (color, bold, underline etc.) # $DebugPreference = 'Continue' function Get-RelativePath { [CmdletBinding()] param( [Parameter(Mandatory)] [string]$Path, [Parameter(Mandatory)] [string]$BasePath ) try { $absTarget = [System.IO.Path]::GetFullPath($Path) $absBase = [System.IO.Path]::GetFullPath($BasePath) if ($absTarget.Equals($absBase, [System.StringComparison]::OrdinalIgnoreCase)) { return "." } # Ensure BasePath for Uri ends with a directory separator. $uriBaseNormalized = $absBase if (-not $uriBaseNormalized.EndsWith([System.IO.Path]::DirectorySeparatorChar)) { $uriBaseNormalized += [System.IO.Path]::DirectorySeparatorChar } $baseUri = [System.Uri]::new($uriBaseNormalized) $targetUri = [System.Uri]::new($absTarget) $relativeUri = $baseUri.MakeRelativeUri($targetUri) $relativePath = [System.Uri]::UnescapeDataString($relativeUri.ToString()) return $relativePath.Replace('/', [System.IO.Path]::DirectorySeparatorChar) } catch { Write-Warning "Get-RelativePath: Error calculating relative path for Target '$Path' from Base '$BasePath'. Error: $($_.Exception.Message). Falling back to original target path." return $Path } } # Cannot be local as this is mocked in Import-DotEnv tests function Get-EnvFilesUpstream { [CmdletBinding()] param([string]$Directory = ".") try { $resolvedPath = Convert-Path -Path $Directory -ErrorAction Stop } catch { Write-Warning "Get-EnvFilesUpstream: Error resolving path '$Directory'. Error: $($_.Exception.Message). Defaulting to PWD." $resolvedPath = $PWD.Path # Removed unused variable assignment for $currentDirNormalized } $envFiles = [System.Collections.Generic.List[string]]::new() $currentSearchDir = $resolvedPath while ($currentSearchDir) { $envPath = Join-Path $currentSearchDir ".env" if (Test-Path -LiteralPath $envPath -PathType Leaf) { $envFiles.Add($envPath) } $parentDir = Split-Path -Path $currentSearchDir -Parent if ($parentDir -eq $currentSearchDir -or [string]::IsNullOrEmpty($parentDir)) { break } $currentSearchDir = $parentDir } if ($envFiles.Count -gt 0) { $envFiles.Reverse() } return [string[]]$envFiles } function Format-EnvFilePath { [CmdletBinding()] param( [Parameter(Mandatory)] [string]$Path, [Parameter(Mandatory)] [string]$BasePath ) $relativePath = Get-RelativePath -Path $Path -BasePath $BasePath $corePath = Split-Path -Path $relativePath -Parent if (-not [string]::IsNullOrEmpty($corePath)) { $boldCore = "${script:e}[1m${corePath}${script:e}[22m" $relativePath = $relativePath.Replace($corePath, $boldCore) } return $relativePath } function Format-VarHyperlink { param( [string]$VarName, [string]$FilePath, [int]$LineNumber ) # Ensure FilePath is absolute for the hyperlink $absFilePath = try { Resolve-Path -LiteralPath $FilePath -ErrorAction Stop } catch { $FilePath } $fileUrl = "vscode://file/$($absFilePath):${LineNumber}" return "$script:e]8;;$fileUrl$script:e\$VarName$script:e]8;;$script:e\" } # --- Helper function to get effective environment variables from a list of .env files --- function Get-EnvVarsFromFiles { param( [string[]]$Files, [string]$BasePath # BasePath is for context, not directly used in var aggregation here ) function Read-EnvFile { param([string]$FilePath) $vars = @{} if (-not ([System.IO.File]::Exists($FilePath))) { Write-Debug "Parse-EnvFile: File '$FilePath' does not exist." return $vars } try { $lines = [System.IO.File]::ReadLines($FilePath) } catch { Write-Warning "Parse-EnvFile: Error reading file '$FilePath'. Error: $($_.Exception.Message)" return $vars } $lineNumber = 0 foreach ($line in $lines) { $lineNumber++ if ([string]::IsNullOrWhiteSpace($line)) { continue } $trimmed = $line.TrimStart() if ($trimmed.StartsWith('#')) { continue } $split = $line.Split('=', 2) if ($split.Count -eq 2) { $varName = $split[0].Trim() $varValue = $split[1].Trim() $vars[$varName] = @{ Value = $varValue; Line = $lineNumber; SourceFile = $FilePath } } } return $vars } if ($Files.Count -eq 0) { return @{} } if ($Files.Count -eq 1) { # Fast path for a single file. Parse-EnvFile returns the rich structure. return Read-EnvFile -FilePath $Files[0] } # For multiple files, use RunspacePool for parallel parsing. $finalEffectiveVars = @{} $parsedResults = New-Object "object[]" $Files.Count # To store results in order # Define the script that will be run in each runspace. # It includes a minimal Parse-EnvFile definition to ensure it's available and self-contained. $scriptBlockText = @' param([string]$PathToParse) # Minimal Parse-EnvFile definition for use in isolated runspaces function Parse-EnvFileInRunspace { param([string]$LocalFilePath) $localVars = @{} # PowerShell hashtable literal is fine here, it's a PS runspace # Directly use System.IO.File for existence and reading to minimize dependencies if (-not ([System.IO.File]::Exists($LocalFilePath))) { return $localVars } try { $fileLines = [System.IO.File]::ReadLines($LocalFilePath) } catch { # Silently return empty on read error in this isolated context return $localVars } $lineNum = 0 foreach ($txtLine in $fileLines) { $lineNum++ if ([string]::IsNullOrWhiteSpace($txtLine)) { continue } $trimmedTxtLine = $txtLine.TrimStart() if ($trimmedTxtLine.StartsWith('#')) { continue } $parts = $txtLine.Split('=', 2) if ($parts.Count -eq 2) { $name = $parts[0].Trim() $val = $parts[1].Trim() # This structure needs to match what the rest of the module expects $localVars[$name] = @{ Value = $val; Line = $lineNum; SourceFile = $LocalFilePath } } } return $localVars } Parse-EnvFileInRunspace -LocalFilePath $PathToParse '@ # Determine a reasonable number of runspaces. Cap at 8 to avoid excessive resource use. # Fix: [Math]::Min takes only two arguments. Nest calls for three values. $maxRunspaces = [Math]::Min(8, [Math]::Min($Files.Count, ([System.Environment]::ProcessorCount * 2))) $minRunspaces = 1 $iss = [System.Management.Automation.Runspaces.InitialSessionState]::CreateDefault2() # CreateDefault2 is generally good for providing access to common .NET types like System.IO.File $runspacePool = $null $psInstanceTrackers = [System.Collections.Generic.List[object]]::new() try { $runspacePool = [System.Management.Automation.Runspaces.RunspaceFactory]::CreateRunspacePool($minRunspaces, $maxRunspaces, $iss, $Host) $runspacePool.Open() for ($i = 0; $i -lt $Files.Count; $i++) { $fileToParse = $Files[$i] $ps = [PowerShell]::Create() $ps.RunspacePool = $runspacePool $null = $ps.AddScript($scriptBlockText).AddArgument($fileToParse) $asyncResult = $ps.BeginInvoke() $psInstanceTrackers.Add([PSCustomObject]@{ PowerShell = $ps AsyncResult = $asyncResult OriginalIndex = $i FilePath = $fileToParse # For logging/debugging }) } # Wait for all to complete and collect results foreach ($tracker in $psInstanceTrackers) { try { $outputCollection = $tracker.PowerShell.EndInvoke($tracker.AsyncResult) if ($tracker.PowerShell.Streams.Error.Count -gt 0) { foreach($err in $tracker.PowerShell.Streams.Error){ Write-Warning "Error parsing file '$($tracker.FilePath)' in parallel: $($err.ToString())" } $parsedResults[$tracker.OriginalIndex] = @{} } elseif ($null -ne $outputCollection -and $outputCollection.Count -eq 1) { $singleOutput = $outputCollection[0] if ($singleOutput -is [System.Collections.IDictionary]) { # Directly a hashtable $parsedResults[$tracker.OriginalIndex] = $singleOutput } elseif ($singleOutput -is [System.Management.Automation.PSObject] -and $singleOutput.BaseObject -is [System.Collections.IDictionary]) { # PSObject wrapping a hashtable $parsedResults[$tracker.OriginalIndex] = $singleOutput.BaseObject } else { Write-Warning "Unexpected output type from parallel parsing of '$($tracker.FilePath)'. Type: $($singleOutput.GetType().FullName)" $parsedResults[$tracker.OriginalIndex] = @{} } } else { Write-Warning "No output or multiple outputs from parallel parsing of '$($tracker.FilePath)'. Output count: $($outputCollection.Count)" $parsedResults[$tracker.OriginalIndex] = @{} } } catch { Write-Warning "Exception during EndInvoke for file '$($tracker.FilePath)': $($_.Exception.Message)" $parsedResults[$tracker.OriginalIndex] = @{} # Store empty on exception } } } finally { foreach ($tracker in $psInstanceTrackers) { if ($tracker.PowerShell) { $tracker.PowerShell.Dispose() } } if ($runspacePool) { $runspacePool.Close() $runspacePool.Dispose() } } # Sequentially merge the parsed results to ensure correct precedence. foreach ($fileScopedVarsHashtable in $parsedResults) { if ($null -eq $fileScopedVarsHashtable) { continue } # Skip if null (e.g. error during parsing) foreach ($varNameKey in $fileScopedVarsHashtable.Keys) { $finalEffectiveVars[$varNameKey] = $fileScopedVarsHashtable[$varNameKey] } } return $finalEffectiveVars } function Import-DotEnv { [CmdletBinding(DefaultParameterSetName = 'Load', HelpUri = 'https://github.com/CosmicDNA/ImportDotEnv#readme')] param( [Parameter(ParameterSetName = 'Load', Position = 0, ValueFromPipelineByPropertyName = $true)] [string]$Path, [Parameter(ParameterSetName = 'Unload')] [switch]$Unload, [Parameter(ParameterSetName = 'Help')] [switch]$Help, [Parameter(ParameterSetName = 'List')] [switch]$List ) # --- Helper: Parse a single .env line into [name, value] or $null --- function Convert-EnvLine { param([string]$Line) if ([string]::IsNullOrWhiteSpace($Line)) { return $null } $trimmed = $Line.TrimStart() if ($trimmed.StartsWith('#')) { return $null } $split = $Line.Split('=', 2) if ($split.Count -eq 2) { return @($split[0].Trim(), $split[1].Trim()) } return $null } function Get-VarsToRestoreByFileMap { param( [string[]]$Files, [string[]]$VarsToRestore ) function Get-EnvVarNamesFromFile { param([string]$FilePath) if (-not (Test-Path -LiteralPath $FilePath -PathType Leaf)) { return @() } try { return [System.IO.File]::ReadLines($FilePath) | ForEach-Object { $parsed = Convert-EnvLine $_ if ($null -ne $parsed) { $parsed[0] } } | Where-Object { $_ } } catch { Write-Warning "Get-EnvVarNamesFromFile: Error reading file '$FilePath'. Skipping. Error: $($_.Exception.Message)" return @() } } $varsToUnsetByFileMap = @{} foreach ($fileToScan in $Files) { foreach ($parsedVarName in Get-EnvVarNamesFromFile -FilePath $fileToScan) { if ($VarsToRestore -contains $parsedVarName) { if (-not $varsToUnsetByFileMap.ContainsKey($fileToScan)) { $varsToUnsetByFileMap[$fileToScan] = [System.Collections.Generic.List[string]]::new() } $varsToUnsetByFileMap[$fileToScan].Add($parsedVarName) } } } return $varsToUnsetByFileMap } if ($PSCmdlet.ParameterSetName -eq 'Unload') { Write-Debug "MODULE Import-DotEnv: Called with -Unload switch." $varsFromLastLoad = Get-EnvVarsFromFiles -Files $script:previousEnvFiles -BasePath $script:previousWorkingDirectory if ($varsFromLastLoad.Count -gt 0) { Write-Host "`nUnloading active .env configuration(s)..." -ForegroundColor Yellow $allVarsToRestore = $varsFromLastLoad.Keys $varsToRestoreByFileMap = Get-VarsToRestoreByFileMap -Files $script:previousEnvFiles -VarsToRestore $allVarsToRestore $varsCoveredByFileMap = $varsToRestoreByFileMap.Values | ForEach-Object { $_ } | Sort-Object -Unique $varsToRestoreNoFileAssociation = $allVarsToRestore | Where-Object { $varsCoveredByFileMap -notcontains $_ } Restore-EnvVars -VarsToRestoreByFileMap $varsToRestoreByFileMap -VarNames $varsToRestoreNoFileAssociation -TrueOriginalEnvironmentVariables $script:trueOriginalEnvironmentVariables -BasePath $script:previousWorkingDirectory $script:previousEnvFiles = @() $script:previousWorkingDirectory = "STATE_AFTER_EXPLICIT_UNLOAD" Write-Host "Environment restored. Module state reset." -ForegroundColor Green } return } if ($PSCmdlet.ParameterSetName -eq 'Help' -or $Help) { Write-Host @" `e[1mImport-DotEnv Module Help`e[0m This module allows for hierarchical loading and unloading of .env files. It also provides integration with `Set-Location` (cd/sl) to automatically manage environment variables as you navigate directories. `e[1mUsage:`e[0m `e[1mImport-DotEnv`e[0m [-Path <string>] Loads .env files from the specified path (or current directory if no path given) and its parent directories. Variables from deeper .env files take precedence. Automatically unloads variables from previously loaded .env files if they are no longer applicable or have changed. `e[1mImport-DotEnv -Unload`e[0m Unloads all variables set by the module and resets its internal state. `e[1mImport-DotEnv -List`e[0m Lists currently active variables and the .env files defining them. `e[1mImport-DotEnv -Help`e[0m Displays this help message. For `Set-Location` integration, use `Enable-ImportDotEnvCdIntegration` and `Disable-ImportDotEnvCdIntegration`. "@ return } if ($PSCmdlet.ParameterSetName -eq 'List') { Write-Debug "MODULE Import-DotEnv: Called with -List switch." if (-not $script:previousEnvFiles -or $script:previousEnvFiles.Count -eq 0 -or $script:previousWorkingDirectory -eq "STATE_AFTER_EXPLICIT_UNLOAD") { Write-Host "No .env configuration is currently active or managed by ImportDotEnv." -ForegroundColor Magenta return } $effectiveVars = Get-EnvVarsFromFiles -Files $script:previousEnvFiles -BasePath $script:previousWorkingDirectory function Get-VarToFilesMap($files) { $map = @{} foreach ($file in $files) { if (Test-Path -LiteralPath $file -PathType Leaf) { foreach ($line in [System.IO.File]::ReadLines($file)) { $parsed = Convert-EnvLine $line if ($parsed) { $var = $parsed[0] if (-not $map[$var]) { $map[$var] = @() } $map[$var] += $file } } } } $map } $varToFiles = Get-VarToFilesMap $script:previousEnvFiles $outputObjects = $effectiveVars.Keys | Sort-Object | ForEach-Object { $var = $_ $varPlainName = $var # Store plain name for calculations $effectiveVarDetail = $effectiveVars[$var] # Get details of the effective variable (SourceFile, Line) $hyperlinkedName = Format-VarHyperlink -VarName $varPlainName -FilePath $effectiveVarDetail.SourceFile -LineNumber $effectiveVarDetail.Line # For 'Defined In', list all files where the variable name appears $definingFilesPaths = $varToFiles[$var] # This is an array of file paths from Get-VarToFilesMap $definedInDisplay = ($definingFilesPaths | ForEach-Object { " $(Get-RelativePath -Path $_ -BasePath $PWD.Path)" }) -join [Environment]::NewLine [PSCustomObject]@{ NameForOutput = $hyperlinkedName # Always the hyperlinked version NamePlainForCalc = $varPlainName # Always the plain version, for calculations 'Defined In' = $definedInDisplay } } if ($outputObjects) { if ($PSVersionTable.PSVersion.Major -ge 7) { # For PS7+, use NameForOutput (which has hyperlink), Format-Table handles ANSI well. # Ensure the column header is "Name". $outputObjects | Format-Table -Property @{Expression={$_.NameForOutput}; Label="Name"}, 'Defined In' -AutoSize } else { # PS5.1: Manual formatting to try and preserve hyperlinks while maintaining table structure. # This works best in terminals that understand ANSI hyperlinks (like Windows Terminal running PS5.1). # In older conhost.exe, ANSI codes might print literally. $maxPlainNameLength = 0 $nameLengths = $outputObjects | ForEach-Object { $_.NamePlainForCalc.Length } if ($nameLengths) { $maxPlainNameLength = ($nameLengths | Measure-Object -Maximum).Maximum } # Ensure $nameColPaddedWidth is a clean integer for use in format strings. $nameColPaddedWidth = [int]([Math]::Max("Name".Length, $maxPlainNameLength)) $nameHeaderTextPlain = "Name" $definedInHeaderTextPlain = "Defined In" $nameHeaderFormatted = "$($script:boldOn)${nameHeaderTextPlain}$($script:boldOff)" $definedInHeaderFormatted = "$($script:boldOn)${definedInHeaderTextPlain}$($script:boldOff)" Write-Host "" # --- Print Header Titles --- Write-Host -NoNewline $nameHeaderFormatted -ForegroundColor Green # Calculate padding based on the plain text length of the "Name" header $paddingForNameHeader = [Math]::Max(0, $nameColPaddedWidth - $nameHeaderTextPlain.Length) Write-Host -NoNewline (" " * $paddingForNameHeader) Write-Host -NoNewline " " # Column separator Write-Host $definedInHeaderFormatted -ForegroundColor Green # --- Print Header Underlines --- $nameUnderline = "-" * $nameColPaddedWidth # Underline spans the full calculated width of the first column $definedInUnderline = "-" * $definedInHeaderTextPlain.Length # Underline matches the visible text of "Defined In" Write-Host -NoNewline $nameUnderline -ForegroundColor Green Write-Host -NoNewline " " # Column separator Write-Host $definedInUnderline -ForegroundColor Green foreach ($obj in $outputObjects) { $nameToPrint = $obj.NameForOutput # This is the hyperlink string $plainNameActualLength = $obj.NamePlainForCalc.Length # Calculate actual length of the plain name $definedInLines = $obj.'Defined In' -split [Environment]::NewLine Write-Host -NoNewline $nameToPrint $spacesNeededAfterName = [Math]::Max(0, $nameColPaddedWidth - $plainNameActualLength) # Use calculated plain name length Write-Host -NoNewline (" " * $spacesNeededAfterName) Write-Host -NoNewline " " # Column separator Write-Host $definedInLines[0] # First line of "Defined In" # Subsequent lines of "Defined In", correctly indented for ($j = 1; $j -lt $definedInLines.Length; $j++) { Write-Host (" " * ($nameColPaddedWidth + 2)) $definedInLines[$j] # Indent under "Defined In" } } Write-Host "" } } else { Write-Host "No effective variables found in the active configuration." -ForegroundColor Yellow } return } # --- Load Parameter Set Logic (Default) --- Write-Debug "MODULE Import-DotEnv: Called with Path '$Path' (Load set). Current PWD: $($PWD.Path)" if ($PSCmdlet.ParameterSetName -eq 'Load' -and (-not $PSBoundParameters.ContainsKey('Path'))) { $Path = "." } try { $resolvedPath = Convert-Path -Path $Path -ErrorAction Stop } catch { $resolvedPath = $PWD.Path Write-Warning "Import-DotEnv: The specified path '$Path' could not be resolved. Falling back to current directory: '$resolvedPath'. Error: $($_.Exception.Message)" Write-Debug "MODULE Import-DotEnv: Path '$Path' resolved to PWD '$resolvedPath' due to error: $($_.Exception.Message)" } $currentEnvFiles = Get-EnvFilesUpstream -Directory $resolvedPath Write-Debug "MODULE Import-DotEnv: Resolved path '$resolvedPath'. Found $($currentEnvFiles.Count) .env files upstream: $($currentEnvFiles -join ', ')" Write-Debug "MODULE Import-DotEnv: Previous files count: $($script:previousEnvFiles.Count) ('$($script:previousEnvFiles -join ', ')'). Previous PWD: '$($script:previousWorkingDirectory)'" $prevVars = Get-EnvVarsFromFiles -Files $script:previousEnvFiles -BasePath $script:previousWorkingDirectory $currVars = Get-EnvVarsFromFiles -Files $currentEnvFiles -BasePath $resolvedPath # --- Unload Phase: Unset variables that were in prevVars but not in currVars, or if their value changed --- $varsToUnsetOrRestore = @() foreach ($varNameKey in $prevVars.Keys) { if (-not $currVars.ContainsKey($varNameKey) -or $currVars[$varNameKey].Value -ne $prevVars[$varNameKey].Value) { $varsToUnsetOrRestore += $varNameKey } } if ($varsToUnsetOrRestore.Count -gt 0) { $varsToRestoreByFileMap = Get-VarsToRestoreByFileMap -Files $script:previousEnvFiles -VarsToRestore $varsToUnsetOrRestore $varsCoveredByFileMap = $varsToRestoreByFileMap.Values | ForEach-Object { $_ } | Sort-Object -Unique $varsToRestoreNoFileAssociation = $varsToUnsetOrRestore | Where-Object { $varsCoveredByFileMap -notcontains $_ } Restore-EnvVars -VarsToRestoreByFileMap $varsToRestoreByFileMap -VarNames $varsToRestoreNoFileAssociation -TrueOriginalEnvironmentVariables $script:trueOriginalEnvironmentVariables -BasePath $PWD.Path } # --- Load Phase --- if ($currentEnvFiles.Count -gt 0) { foreach ($varNameKey in $currVars.Keys) { if (-not $script:trueOriginalEnvironmentVariables.ContainsKey($varNameKey)) { $currentEnvValue = [Environment]::GetEnvironmentVariable($varNameKey, 'Process') if (-not (Test-Path "Env:\$varNameKey")) { $script:trueOriginalEnvironmentVariables[$varNameKey] = $null } else { $script:trueOriginalEnvironmentVariables[$varNameKey] = $currentEnvValue } } } $varsToReportAsSetOrChanged = [System.Collections.Generic.List[PSCustomObject]]::new() # Changed to PSCustomObject foreach ($varNameKey in $currVars.Keys) { $desiredVarInfo = $currVars[$varNameKey] $desiredValue = $desiredVarInfo.Value $currentValue = [Environment]::GetEnvironmentVariable($varNameKey, 'Process') # Fix: Correctly set empty string as value, not as $null (which unsets) if ($currentValue -ne $desiredValue) { if ($null -eq $desiredValue) { [Environment]::SetEnvironmentVariable($varNameKey, $null) } else { [Environment]::SetEnvironmentVariable($varNameKey, $desiredValue) } } $isNewToSession = (-not $prevVars.ContainsKey($varNameKey)) $hasValueChanged = $false if (-not $isNewToSession -and $prevVars[$varNameKey].Value -ne $desiredValue) { $hasValueChanged = $true } Write-Verbose "Var: '$varNameKey', IsNew: $isNewToSession, HasChanged: $hasValueChanged" if (-not $isNewToSession) { Write-Verbose " PrevValue: '$($prevVars[$varNameKey].Value)', DesiredValue: '$desiredValue'" } if ($isNewToSession -or $hasValueChanged) { $varsToReportAsSetOrChanged.Add([PSCustomObject]@{ # Changed to PSCustomObject Name = $varNameKey Line = $desiredVarInfo.Line SourceFile = $desiredVarInfo.SourceFile }) } } if ($varsToReportAsSetOrChanged.Count -gt 0) { $groupedBySourceFile = $varsToReportAsSetOrChanged | Group-Object -Property SourceFile foreach ($fileGroup in $groupedBySourceFile) { $sourceFilePath = $fileGroup.Name # This is "" in PS5.1 if SourceFile was $null, and $null in PS7+ # If SourceFile was $null (or missing), its group name might be $null or "" # Skip processing for such groups as they don't represent a valid file path. if ([string]::IsNullOrEmpty($sourceFilePath)) { Write-Debug "Skipping report for variables with no valid SourceFile (group name was '$sourceFilePath')." continue } $formattedPath = Format-EnvFilePath -Path $sourceFilePath -BasePath $script:previousWorkingDirectory # Now $sourceFilePath should be a valid path Write-Host "$script:itemiserA Processing .env file ${formattedPath}:" -ForegroundColor Cyan foreach ($varDetail in $fileGroup.Group) { $hyperlink = Format-VarHyperlink -VarName $varDetail.Name -FilePath $varDetail.SourceFile -LineNumber $varDetail.Line Write-Host " $script:itemiser Setting environment variable: " -NoNewline Write-Host $hyperlink -ForegroundColor Green -NoNewline Write-Host " (from line $($varDetail.Line))" } } } } $script:previousEnvFiles = $currentEnvFiles $script:previousWorkingDirectory = $resolvedPath } # This function will be the wrapper for Set-Location function Invoke-ImportDotEnvSetLocationWrapper { [CmdletBinding(DefaultParameterSetName = 'Path', SupportsShouldProcess = $true, ConfirmImpact = 'Medium')] param( [Parameter(ParameterSetName = 'Path', Position = 0, ValueFromPipeline, ValueFromPipelineByPropertyName)] [string]$Path, [Parameter(ParameterSetName = 'LiteralPath', Mandatory, ValueFromPipelineByPropertyName)] [Alias('PSPath')] [string]$LiteralPath, [Parameter()] [switch]$PassThru, [Parameter()] [string]$StackName ) $slArgs = @{} if ($PSCmdlet.ParameterSetName -eq 'Path') { if ($PSBoundParameters.ContainsKey('Path')) { $slArgs.Path = $Path } } elseif ($PSCmdlet.ParameterSetName -eq 'LiteralPath') { $slArgs.LiteralPath = $LiteralPath } if ($PSBoundParameters.ContainsKey('PassThru')) { $slArgs.PassThru = $PassThru } if ($PSBoundParameters.ContainsKey('StackName')) { $slArgs.StackName = $StackName } $CommonParameters = @('Verbose', 'Debug', 'ErrorAction', 'ErrorVariable', 'WarningAction', 'WarningVariable', 'OutBuffer', 'OutVariable', 'PipelineVariable', 'InformationAction', 'InformationVariable', 'WhatIf', 'Confirm') foreach ($commonParam in $CommonParameters) { if ($PSBoundParameters.ContainsKey($commonParam)) { $slArgs[$commonParam] = $PSBoundParameters[$commonParam] } } Microsoft.PowerShell.Management\Set-Location @slArgs Import-DotEnv -Path $PWD.Path } function Enable-ImportDotEnvCdIntegration { [CmdletBinding()] param() $currentModuleForEnable = $MyInvocation.MyCommand.Module if (-not $currentModuleForEnable) { Write-Error "Enable-ImportDotEnvCdIntegration: Module context not found." -ErrorAction Stop } if (-not $currentModuleForEnable.ExportedCommands.ContainsKey('Invoke-ImportDotEnvSetLocationWrapper')) { Write-Error "Enable-ImportDotEnvCdIntegration: Required wrapper 'Invoke-ImportDotEnvSetLocationWrapper' is not exported." -ErrorAction Stop } Write-Host "Enabling ImportDotEnv integration for 'Set-Location', 'cd', and 'sl' commands..." -ForegroundColor Yellow $wrapperFunctionFullName = "$($currentModuleForEnable.Name)\Invoke-ImportDotEnvSetLocationWrapper" $existingSetLocation = Get-Command Set-Location -ErrorAction SilentlyContinue if ($existingSetLocation -and $existingSetLocation.CommandType -eq [System.Management.Automation.CommandTypes]::Alias) { if (Get-Alias -Name Set-Location -ErrorAction SilentlyContinue) { Remove-Item -Path Alias:\Set-Location -Force -ErrorAction SilentlyContinue } } Set-Alias -Name Set-Location -Value $wrapperFunctionFullName -Scope Global -Force -Option ReadOnly,AllScope Import-DotEnv -Path $PWD.Path Write-Host "ImportDotEnv 'Set-Location', 'cd', 'sl' integration enabled!" -ForegroundColor Green } function Disable-ImportDotEnvCdIntegration { [CmdletBinding()] param() Write-Host "Disabling ImportDotEnv integration for 'Set-Location', 'cd', and 'sl'..." -ForegroundColor Yellow $currentModuleName = $MyInvocation.MyCommand.Module.Name if (-not $currentModuleName) { Write-Warning "Disable-ImportDotEnvCdIntegration: Could not determine module name. Assuming 'ImportDotEnv'." $currentModuleName = "ImportDotEnv" } $wrapperFunctionFullName = "$currentModuleName\Invoke-ImportDotEnvSetLocationWrapper" $proxiesRemoved = $false $slCmdInfo = Get-Command "Set-Location" -ErrorAction SilentlyContinue if ($slCmdInfo -and $slCmdInfo.CommandType -eq 'Alias' -and $slCmdInfo.Definition -eq $wrapperFunctionFullName) { Remove-Item -Path Alias:\Set-Location -Force -ErrorAction SilentlyContinue $proxiesRemoved = $true } Remove-Item -Path Alias:\Set-Location -Force -ErrorAction SilentlyContinue Remove-Item "Function:\Global:Set-Location" -Force -ErrorAction SilentlyContinue $finalSetLocation = Get-Command "Set-Location" -ErrorAction SilentlyContinue if ($null -eq $finalSetLocation -or $finalSetLocation.Source -ne "Microsoft.PowerShell.Management" -or $finalSetLocation.CommandType -ne 'Cmdlet') { Write-Warning "Disable-ImportDotEnvCdIntegration: 'Set-Location' may not be correctly restored to the original cmdlet." } if ($proxiesRemoved) { Write-Host "ImportDotEnv 'Set-Location' integration disabled, default command behavior restored." -ForegroundColor Magenta } else { Write-Host "ImportDotEnv 'Set-Location' integration was not active or already disabled." -ForegroundColor Magenta } Write-Host "Active .env variables (if any) remain loaded. Use 'Import-DotEnv -Unload' to unload them." -ForegroundColor Magenta } Export-ModuleMember -Function Import-DotEnv, Enable-ImportDotEnvCdIntegration, Disable-ImportDotEnvCdIntegration, Invoke-ImportDotEnvSetLocationWrapper function Restore-EnvVars { param( [hashtable]$VarsToRestoreByFileMap = $null, [string[]]$VarNames = $null, [hashtable]$TrueOriginalEnvironmentVariables, [string]$BasePath = $PWD.Path ) $restorationActions = @() if ($VarsToRestoreByFileMap) { foreach ($fileKey in $VarsToRestoreByFileMap.Keys) { foreach ($var in $VarsToRestoreByFileMap[$fileKey]) { $restorationActions += [PSCustomObject]@{ VarName = $var; SourceFile = $fileKey } } } } if ($VarNames) { $restorationActions += $VarNames | ForEach-Object { [PSCustomObject]@{ VarName = $_; SourceFile = $null } } } function Restore-EnvVar { param( [string]$VarName, [hashtable]$TrueOriginalEnvironmentVariables, [string]$SourceFile = $null ) function Set-OrUnset-EnvVar { param( [string]$Name, [object]$Value ) if ($null -eq $Value) { [Environment]::SetEnvironmentVariable($Name, $null, 'Process') Remove-Item "Env:\$Name" -Force -ErrorAction SilentlyContinue } else { [Environment]::SetEnvironmentVariable($Name, $Value) } } $originalValue = $TrueOriginalEnvironmentVariables[$VarName] Set-OrUnset-EnvVar -Name $VarName -Value $originalValue $restoredActionText = if ($null -eq $originalValue) { "Unset" } else { "Restored" } $hyperlink = if ($SourceFile) { Format-VarHyperlink -VarName $VarName -FilePath $SourceFile -LineNumber 1 } else { $searchUrl = "vscode://search/search?query=$([System.Uri]::EscapeDataString($VarName))" "$script:e]8;;$searchUrl$script:e\$VarName$script:e]8;;$script:e\" } Write-Host " $script:itemiser $restoredActionText environment variable: " -NoNewline # Write-Host ($hyperlink -ne $null ? $hyperlink : $VarName) -ForegroundColor Yellow $Output = if ($null -ne $hyperlink) { $hyperlink } else { $VarName } Write-Host $Output -ForegroundColor Yellow } $restorationActions | Group-Object SourceFile | ForEach-Object { $fileKey = $_.Name # Write-Host ($fileKey ? "$script:itemiserA Restoring .env file $(Format-EnvFilePath -Path $fileKey -BasePath $BasePath):" : "Restoring environment variables not associated with any .env file:") -ForegroundColor Yellow $envMessage = if ($fileKey) { "$script:itemiserA Restoring .env file $(Format-EnvFilePath -Path $fileKey -BasePath $BasePath):" } else { "Restoring environment variables not associated with any .env file:" } Write-Host $envMessage -ForegroundColor Yellow $_.Group | ForEach-Object { Restore-EnvVar -VarName $_.VarName -TrueOriginalEnvironmentVariables $TrueOriginalEnvironmentVariables -SourceFile $_.SourceFile } } } |