Skip to content

Instantly share code, notes, and snippets.

@Jaykul
Last active October 22, 2020 20:05
Show Gist options
  • Star 1 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save Jaykul/1d218eab4fe2257cbdf87344b68e0673 to your computer and use it in GitHub Desktop.
Save Jaykul/1d218eab4fe2257cbdf87344b68e0673 to your computer and use it in GitHub Desktop.
Select-UniquePath normalizes path variables like PSModulePath and ensures only folders that actually currently exist are in them.

As a general rule, most people can just edit the PSModulePath variable in their Environment Variables ...

However, I am not most people.

I want to test all the things, and I want to use PowerShell 7, 6, and 5.1 interchangeably:

  • I have to use PowerShell 5.1 for most of our work code (we're provisioning Windows 10 / Server 2016 / Server 2019 ... and 5.1 is what's on the box)
  • I want to use PowerShell 7.x (whatever the current release is)
  • I want to test the current "pre-release" version

Additionally, to make matters more confusing ... I sometimes work on Windows and sometimes on Linux (most notably in Azure's CloudShell). I've started mapping my CloudShell profile folder to a drive letter (A: for Azure) on my dev boxes, and loading my the profile Module from there. In those cases, I absolutely must alter the default PSModulePath to include that A: path

In any case, I don't ever want to install multiple copies of the Azure modules and keep them all up to date. One copy works interchangeably in all versions of PowerShell. It would be a waste of time to install that same version three times (once for each version of PowerShell), but that's what Microsoft seems to want me to do.

So here's what I do:

  1. I start with the current PSModulePath
  2. I use a Profile module which I put in that path
  3. I tweak the PSModulePath in a script in that module which I dot-source from the actual $profile script

For the rare case where there are different versions of modules for PowerShell 5.1 and PowerShell 7, I need to make sure the right one loads, so obviously I want to keep both "Modules" folders available, and have each version of PowerShell search its own version-specific path first, and then fall back to the other version's Modules folder. Because the order of paths must be different depending on which version of PowerShell is running, I can't just hardcode the PSModulePath environment variable.

Of course, I won't keep separate per-version profiles, so I calculate all of this in my profile script. Here's what I do:

  1. I prepend the location the script is running from (probably my cloud drive) and the location my profile.ps1 was in.
  2. I include the current PSModulePath (all the paths in the environment variable)
  3. I append all the other possible PSModulePaths!

I add all these paths into a big array, and then I get the right paths for them, and filter out duplicates and non-existent folders.

NOTES:

  1. The main concern is to keep things in order:
    1. User path ($Home) before machine path ($PSHome)
    2. Existing PSModulePath before other versions
    3. current version before other versions
  2. I don't worry about duplicates because Select-UniquePath takes care of it
  3. I don't worry about missing paths, because Select-UniquePath takes care of it
  4. I don't worry about Windows x86, because I never use that.
  5. I don't worry about linux, because I add paths based on $PSScriptRoot, $Profile and $PSHome
Set-Variable ProfileDir (Split-Path $Profile.CurrentUserAllHosts -Parent) -Scope Global -Option AllScope, Constant -ErrorAction SilentlyContinue

$Env:PSModulePath =
    # Prioritize "this" location (e.g. CloudDrive) UNLESS it's ~\projects\modules
    @(if (($ModuleRootParent = Split-Path $PSScriptRoot) -ne "$Home\Projects\Modules") { $ModuleRootParent }) +
    # The normal first location in PSModulePath is the "Modules" folder next to the real profile:
    @(Join-Path $ProfileDir Modules) +
    # After that, whatever is in the environment variable
    @($Env:PSModulePath) +
    # PSHome is where powershell.exe or pwsh.exe lives ... it should already be in the Env:PSModulePath, but just in case:
    @(Join-Path $PSHome Modules) +
    # FINALLY, add the Module paths for other PowerShell versions, because I'm an optimist
    @(Join-Path (Split-Path (Split-Path $PSHome)) *PowerShell\ | Convert-Path | Get-ChildItem -Filter Modules -Directory -Recurse -Depth 2).FullName +
    @(Convert-Path @(
        Split-Path $ProfileDir | Join-Path -ChildPath *PowerShell\Modules
        # These may be duplicate or not exist, but it doesn't matter
        "$Env:ProgramFiles\*PowerShell\Modules"
        "$Env:ProgramFiles\*PowerShell\*\Modules"
        "$Env:SystemRoot\System32\*PowerShell\*\Modules"
    )) +
    # Guarantee my ~\Projects\Modules are there so I can load my dev projects
    @("$Home\Projects\Modules") +
    # To ensure canonical path case, wildcard every path separator and then convert-path
    @() | Select-UniquePath
function Select-UniquePath {
<#
.SYNOPSIS
Select-UniquePath normalizes path variables and ensures only folders that actually currently exist are in them.
.EXAMPLE
$ENV:PATH = $ENV:PATH | Select-UniquePath
#>
[CmdletBinding()]
param(
# If non-full, split path by the delimiter. Defaults to '[IO.Path]::PathSeparator' so you can use this on $Env:Path
[Parameter(Mandatory = $False)]
[AllowNull()]
[string]$Delimiter = [IO.Path]::PathSeparator,
# Paths to folders
[Parameter(Position = 1, Mandatory = $true, ValueFromRemainingArguments = $true, ValueFromPipeline)]
[AllowEmptyCollection()]
[AllowEmptyString()]
[string[]]$Path
)
begin {
Write-Information "Select-UniquePath $Delimiter $Path" -Tags "Trace", "Enter"
[string[]]$Output = @()
}
process {
$Output += $(
# Split and trim trailing slashes to normalize, and drop empty strings
$oldPaths = $Path -split $Delimiter -replace '[\\\/]$' -gt ""
# Remove duplicates that are only different by case on FileSystems that are not case-sensitive
$folders = if ($false -notin (Test-Path $PSCommandPath.ToLowerInvariant(), $PSCommandPath.ToUpperInvariant())) {
# Converting a path with wildcards forces Windows to calculate the ACTUAL case of the path
# But may actually cause the wrong folder to be added in a case-sensitive FileSystems
$oldPaths -replace '(?<!:|\\|/|\*)(\\|/|$)', '*$1'
} else {
$oldPaths
}
# Use Get-Item -Force to ensure we don't loose hidden folders
# e.g. this won't work: Convert-Path C:\programdata*
$newPaths = Get-Item $folders -Force | Convert-Path
# Make sure we didn't add anything that wasn't already there
$newPaths | Where-Object { $_ -iin $oldPaths }
)
}
end {
if ($Delimiter) {
# This is just faster than Select-Object -Unique
[System.Linq.Enumerable]::Distinct($Output) -join $Delimiter
} else {
[System.Linq.Enumerable]::Distinct($Output)
}
Write-Information "Select-UniquePath $Delimiter $Path" -Tags "Trace", "Exit"
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment