This post introduces a solution for automatically setting up and tearing down shell environments for PowerShell in Windows. It is proposed as a potential alternative to the bash-based tool direnv, which, while effective at loading .envrc
files in the current or nearest ancestor directory, has limited compatibility with PowerShell in Windows.
.envrc.ps1
The basic idea is creating a file .envrc.ps1
with two entries: setup and teardown. The setup entry is responsible for setting up the environment up entering the directory, and the teardown entry is used when leaving it.
PowerShell allows code to be executed from a file using the dot command. This means that the entire file can be used as the setup entry. For the teardown entry, a function can be defined within the same file, and I use the function name down
.
The layout of the file .envrc.ps1
looks like:
# setup
$env:GH_TOKEN = "secret"
# teardown
function global:down {
$env:GH_TOKEN = ""
}
The global:
scope before the function name is necessary when this file is loaded in the hook, as I will do later.
The corresponding commands for setup and teardown entries:
- Setup:
. .envrc.ps1
- Teardown:
down
Hook
To hook the setup and teardown entries, I reference this example.
using namespace System;
using namespace System.Management.Automation;
$hook = [EventHandler[LocationChangedEventArgs]] {
param([object] $source, [LocationChangedEventArgs] $eventArgs)
end {
# 1. `down` for $eventArgs.OldPath
# 2. `. .envrc.ps1` for $eventArgs.NewPath
}
};
$currentAction = $ExecutionContext.SessionState.InvokeCommand.LocationChangedAction;
if ($currentAction) {
$ExecutionContext.SessionState.InvokeCommand.LocationChangedAction = [Delegate]::Combine($currentAction, $hook);
} else {
$ExecutionContext.SessionState.InvokeCommand.LocationChangedAction = $hook;
};
To run the code snippet, PowerShell 7 or later is required. You can install it from the repository.
Here is the algorithm for identifying setup and teardown entries:
- Find the nearest
.envrc.ps1
for the old path. - Find the nearest
.envrc.ps1
for the new path. - If the two paths are the same, skip the next step.
- If they differ, invoke the
down
function and remove it if it exists. Then, if found, load the.envrc.ps1
file for the new path.
The full code example to be added into PowerShell $PROFILE
file.
using namespace System;
using namespace System.Management.Automation;
# existing PowerShell profile contents
# skip when version is less than 7
if ($PSVersionTable.PSVersion.Major -lt 7) {
return
}
# find the .envrc.ps1 in the currenct directory or the nearest ancestor directory.
function Find-NearestEnvrc {
param (
[Parameter(Mandatory=$true)]
[string]$StartDir
)
$currentDir = Resolve-Path $StartDir
while ($currentDir -ne "") {
$envrcPath = Join-Path $currentDir ".envrc.ps1"
if (Test-Path $envrcPath) {
return $envrcPath
}
$currentDir = Split-Path $currentDir -Parent
}
return ""
}
# hook the setup and teardown entries
$hook = [EventHandler[LocationChangedEventArgs]] {
param([object] $source, [LocationChangedEventArgs] $eventArgs)
end {
$oldEnvrc = Find-NearestEnvrc $eventArgs.OldPath
$newEnvrc = Find-NearestEnvrc $eventArgs.NewPath
if ($oldEnvrc -ne $newEnvrc) {
Get-Command down -ErrorAction SilentlyContinu
if (Get-Command down -ErrorAction SilentlyContinu) {
down
Remove-Item Function:down
}
if ($newEnvrc -ne "") {
. $newEnvrc
}
}
}
};
$currentAction = $ExecutionContext.SessionState.InvokeCommand.LocationChangedAction;
if ($currentAction) {
$ExecutionContext.SessionState.InvokeCommand.LocationChangedAction = [Delegate]::Combine($currentAction, $hook);
} else {
$ExecutionContext.SessionState.InvokeCommand.LocationChangedAction = $hook;
};
Example Usage
# setup
# set environment variable
$env:GH_TOKEN = "secret"
# load python virtual env
. .venv\Scripts\Activate.ps1
# add a helper function
function global:build {
cargo build
}
# teardown
function global:down {
# unset environment variable
$env:GH_TOKEN = ""
# unload python virtual env
deactivate
# remove the helper function
Remove-Item Function:build
}