A few days ago, I posted an article that demonstrated a number of PowerShell techniques and concepts that you could use to clear out obsolete locations in your %PATH% environment variable. For those of you new to my blog I want to make sure you understand that I often use a scenario, such as this one, to demonstrate and teach PowerShell. Don't assume that any functions or code samples are complete and production-ready. \After posting, I heard from a number of people suggesting the SetEnvironmentVariable() method from the .NET Framework - especially because I used the framework to discover the path splitter. So let's go back to the problem at hand and explore this approach.
ManageEngine ADManager Plus - Download Free Trial
Exclusive offer on ADManager Plus for US and UK regions. Claim now!
Cmdlets First
I have a PowerShell scripting philosophy that I espouse, especially for beginners. If there is a cmdlet you can you to achieve a task, use it over the .NET Framework. By this I mean, use the Get-Date cmdlet to get the current date and time and not [datetime]::Now. There are a few reasons.
First, I don't want PowerShell beginners to feel the need to be .NET developers to write scripts and functions. The whole point of PowerShell is that there is a ton of abstraction so that scripters do not have to know the .NET Framework. Cmdlets are easier to discover. They are documented. And most importantly in the scenario we are using, should support -WhatIf. If you run a .NET method, there is no built-in safety. It runs. However, you can add support for -WhatIf which I'll get to in a bit.
Yes, at some point you may turn to the .NET Framework for performance gains or accomplish something where there is no cmdlet. But this is after you have gained some experience. And don't forget that you can't Pester test the .NET Framework directly. There is no way to mock [Environment]::SetEnvironmentVariable(). There's nothing wrong with jumping to the .NET Framework. I just think you need a very good reason and make sure you add plenty of code documentation.
Exploring a .NET Class
With all this in mind, let me step through the process of building some PowerShell tooling using the [system.environment] class. We will assume that you read about somewhere or saw it in a Stack Overflow question. You can, and probably should do a search for system.enviroment which should get you to a Microsoft documentation page. Or you can try exploring it in PowerShell. At a prompt type [System.Environment]:: and press Space. You can cycle through static properties and methods. Or if you have the PSReadline module loads, press Ctrl+Space
By the way, technically you don't need to include the System prefix. If you just type [Environment]:: PowerShell knows you mean System.Environment.
Looking at the list there is something called GetEnvironmentVariable. PowerShell may insert an open parenthesis which is your clue that this is a method. If you are using PSReadline, you can see the method definition. Or just press Enter without ().
The output indicates there are two ways to use this method, each resulting in a string. The first takes the name of a variable. That seems simple enough to test.
The second method takes a 2nd parameter. If you see a type that isn't something typical like string, int or boolean, there's a good chance it is an enumeration. You can use the same technique to discover possible values.
And that makes sense because you can have User or System environment variables. Let's try.
I didn't need to specifically cast "User" or "Machine". Because it is the second parameter, PowerShell knows what it should be.
Testing %PATH%
With this in mind let's turn to validating paths. Here a few commands that will be the basis of function.
[System.Environment]::GetEnvironmentVariable("PATH", "User") -split ";" | where-object { $_ -AND -Not (Test-Path $_) }
[System.Environment]::GetEnvironmentVariable("PATH", "Machine") -split ";" | where-object {$_ -AND -Not (Test-Path $_) }
My Where-Object statement is also filtering out blanks which might happen if the variable ends with a semi-colon.
I could just focus on string values - like paths that don't exist - but I always like to think about rich objects in the pipeline. Things that I might consume and re-use in ways I may have not even thought about yet. With that in mind, I wrote this function.
Function Get-EnvPath {
[cmdletbinding()]
[OutputType("myEnvPath")]
Param(
[ValidateSet("All","User","System")]
[string]$Scope = "All"
)
#get the path separator character specific to this operating system
$splitter = [System.IO.Path]::PathSeparator
if ($scope -ne "System") {
Write-Verbose "Validating USER paths"
#filter out blanks if path ends in a splitter
[System.Environment]::GetEnvironmentVariable("PATH", "User") -split $splitter |
Where-Object { $_ } | Foreach-Object {
# create a custom object based on each path
Write-Verbose " $_"
[pscustomobject]@{
PSTypeName = "myEnvPath"
Scope = "User"
Computername = [System.Environment]::MachineName
UserName = [System.Environment]::UserName
Target = "User"
Path = $_
Exists = Test-Path $_
}
} #foreach
}
if ($Scope -ne "User") {
Write-Verbose "Validating MACHINE paths"
[System.Environment]::GetEnvironmentVariable("PATH", "Machine") -split $splitter |
Where-Object { $_ } | Foreach-Object {
# create a custom object based on each path
Write-Verbose " $_"
[pscustomobject]@{
PSTypeName = "myEnvPath"
Scope = "System"
Computername = [System.Environment]::MachineName
UserName = [System.Environment]::UserName
Target = "Machine"
Path = $_
Exists = Test-Path $_
}
} #foreach
}
} #end function
I debated on the appropriate verb. In the previous article, I used the Test verb. But the more I thought about it I realized what I was really doing was getting a bunch of objects that reflected that state of each location in %PATH%. An argument could be made that the Test-Path functionality should be handled separately, but since I know this is the main reason I'm writing this function I don't mind including it here. Now I have an easy to use function.
Remediating the %PATH% Variable
The second half of the challenge to remove the bad locations. This is where I can use the SetEnvironmentVariable() method. If you use the techniques I showed, you'll discover that you need to provide the name of the variable, the new path, and the target. Something like this:
[System.Environment]::SetEnvironmentVariable("PATH",$revised,"User")
However, as I mentioned there's no support for -WhatIf or other safety checks. If I run that command PowerShell will happily do it. I want to keep that in mind. I also want to create a function that can consume objects from the pipeline. Let me show you what I came up with and then we'll discuss.
Function Repair-EnvPath {
[cmdletbinding(SupportsShouldProcess)]
Param(
[Parameter(Position = 0, Mandatory, ValueFromPipelineByPropertyName)]
[string]$Path,
[Parameter(Mandatory, ValueFromPipelineByPropertyName)]
[ValidateSet("User","Machine")]
[string]$Target
)
Begin {
Write-Verbose "Starting $($myinvocation.MyCommand)"
#get the path separator character specific to this operating system
$splitter = [System.IO.Path]::PathSeparator
}
Process {
Write-Verbose "Removing $path from Target %PATH% setting"
#get current values as an array
$paths = [system.environment]::GetEnvironmentVariable("PATH", $Target) -split $splitter
$Corrected = (($paths).where( { $_ -ne $Path })) -join $splitter
Write-Verbose "Setting a new value of $corrected"
#add code support for -WhatIf
if ($PSCmdlet.ShouldProcess("$Target %Path% variable", "Remove $Path")) {
[System.Environment]::SetEnvironmentVariable("PATH", $Corrected, $Target)
}
}
End {
Write-Verbose "Ending $($myinvocation.MyCommand)"
}
}
Again, I tried to choose a meaningful verb. An argument could be made that it should be Set-EnvPath to go along with Get-EnvPath. Or maybe Update-EnvPath. To me, I am using this command to repair a problem.
The first thing to notice is in [cmdletbinding()]. I am telling PowerShell to include support for -WhatIf automatically in this function. I can run Repair-EnvPath -Whatif and the ShouldProcess directive will be passed to any cmdlets in the function that also supports -WhatIf. However, the majority of code in the function is native .NET. I'll have to handle it myself.
The function takes two mandatory parameters, a path string, and environment target. I've gone ahead and included a validation test on the latter. This means the user can only enter User or Machine. As an added bonus, PowerShell will present those as tab-complete options.
Notice that the parameter names are identical to some of the properties of the Get-EnvPath function. Because I've told PowerShell use bind those parameters by property name when the function sees an object with a property of Path it will use that value. The same goes for Target.
The Process scriptblock is getting all of the paths from the given target and splitting them into an array.
$paths = [system.environment]::GetEnvironmentVariable("PATH", $Target) -split $splitter
Then I can build a new string of paths that don't include the "bad" path.
$Corrected = (($paths).where( { $_ -ne $Path })) -join $splitter
Now for the final step. I need to invoke the SetEnvironmentVariable method, but I need to add support for -WhatIf.
Write Your Own WhatIf
When writing a PowerShell script or function, you have a built-in object referenced by $PSCmdlet. This object has a method called ShouldProcess(). To use, create an IF statement. The code in the IF block will run if -Whatif is not detected.
If ($PSCmdlet.ShouldProcess("target")) {
#do something serious
}
If PowerShell detects that the person running your script used -WhatIf, then PowerShell will use the parameters in the ShouldProcess method to create the message you see: "What if: Performing the operation <your command> on target <your target>:" So typically all you need to do is provide a value for the target such as a path, name or whatever you are modifying. However, you can take a bit more control as there are a few other parameters options.
I often will also define the action. That's what I am doing in my code.
if ($PSCmdlet.ShouldProcess("$Target %Path% variable", "Remove $Path")) {
[System.Environment]::SetEnvironmentVariable("PATH", $Corrected, $Target)
}
As you can see, you can be creative with the target and action values.
Don't forget that you need the SupportsShouldProcess directive in [cmdletbinding()] AND the If statement. By the way, you can have as many WhatIf statements as you need in your code.
Cleaning Up My Paths
With all that in mind, here's how I can use my commands.
Notice that they were written to work together. I might consider modifying the Get-EnvPath function to include a filtering option for Target as well as if the path exists. When creating PowerShell tools you need to think about who will use it, how, and their expectations. Since I am most likely to use this tool to clean up obsolete paths, it might be nice to include a filtering option so I don't have to remember to use the intermediate Where-Object expression. But I'll leave that to you if you are interested. Otherwise, time to clean up.
You can probably even think of some enhancements to Repair-EnvPath such as providing feedback about what is getting deleted or adding a -Passthru parameter. Again, I'll leave that fun for you.
I still prefer to use cmdlets and native PowerShell wherever possible, but the .NET Framework can fill in the gaps. It is up to you to use it responsibly.
1 thought on “More PowerShell Adventures in Cleaning Your Path”
Comments are closed.