Even though I have been using PowerShell for a few good years now, I haven’t really paid much attention to where its modules are stored. When I want a module I just download it using Install-Module
and it magically works.
I mostly develop on PowerShell 7 on macOS or Windows 11. These work fine with Azure Functions too, but with Automation Accounts I usually switch to PowerShell 5.1 and 7.x is still in preview there. But starting yesterday I decided to dip my feet into 7.x there too.
One of the modules I use is PnP.PowerShell for interacting with SharePoint Online. Version 2+ of that module only supports PowerShell 7.x so I have to stick with Version 1.12 for my Runbooks as that’s the last version to support PowerShell 5.1. Not a problem, but now that I am going to have some Runbooks be on PowerShell 7.2 there I wanted to use the newer PnP.PowerShell with those Runbooks and yet not affect the existing stuff.
Oh, and before I forget, these Runbooks are on Hybrid Runbook Workers (HRWs), so I have to install PowerShell 7.x manually, install the modules, and so on.
The one cmdlet I typically use to see modules is Get-Module
. On its own it shows the currently loaded/ imported modules. Adding a -ListAvailable
switch to see everything you have installed. Below, I have 3 modules currently loaded, but 369 available to load.
How does it know where to search for modules, to list the available ones? For that it uses the PSModulePath
environment variable. Here’s what it looks like in PowerShell 5.x on my HRW (the variable contains semi-colon separated paths, so I split along that to output one per line for ease of reading):
1 2 3 4 5 6 |
> $Env:PSModulePath -split (';') C:\Users\xxx\Documents\WindowsPowerShell\Modules C:\Program Files\WindowsPowerShell\Modules C:\Windows\system32\WindowsPowerShell\v1.0\Modules C:\Program Files\Microsoft Monitoring Agent\Agent\PowerShell\ C:\Program Files\Microsoft Monitoring Agent\Agent\AzureAutomation\7.3.1722.0 |
And what about PowerShell 7?
1 2 3 4 5 6 7 8 |
> $Env:PSModulePath -split (';') C:\Users\xxx\Documents\PowerShell\Modules C:\Program Files\PowerShell\Modules c:\program files\powershell\7\Modules C:\Program Files\WindowsPowerShell\Modules C:\Windows\system32\WindowsPowerShell\v1.0\Modules C:\Program Files\Microsoft Monitoring Agent\Agent\PowerShell\ C:\Program Files\Microsoft Monitoring Agent\Agent\AzureAutomation\7.3.1722.0 |
Most paths are common, so PowerShell 7 can simply use the modules of PowerShell 5. Two paths, highlighted, are special to PowerShell 7. The first of these does not exist by default; the other does.
Odd that the first one doesn’t exist, especially as the documentation too says the first path is the one used for AllUsers scoped modules. From the contents of the folder I think the second path contains the default PowerShell 7 modules. For instance, PowerShell 7.3 includes PSReadLine 2.2.6 while PowerShell 5.1 includes PSReadLine 2.0.0 and this way the included modules can be kept separate.
Notice when I ask PowerShell 7 to find the PSReadLine module it lists both (its own, and the PowerShell 5.1 bundled one). It will choose the newer version when importing the module.
On the other hand, PowerShell 5.1 only sees the one it has.
Back to PnP.PowerShell. Here’s what I have currently (same results from both PowerShell).
I had installed these using Install-Module
and as per its docs the modules should have been installed at $env:ProgramFiles\PowerShell\Modules
– a path that doesn’t exist yet (this is the path we saw above in the output of PowerShell 7’s PSModulePath
). But where are these installed currently? At C:\Program Files\WindowsPowerShell\Modules\PnP.PowerShell
. Huh.
This stumped me for a bit until I realized PowerShell 5.1 comes with version 1 of the PowerShellGet
and if I check the docs for PowerShellGet
1.x version of Install-Module
sure enough it installs at $env:ProgramFiles\WindowsPowerShell\Modules
.
I usually update PowerShellGet
on all my machines, so I guess I just missed it on these HRWs. I wonder how the behaviour would be if I update it, because then it would install to $env:ProgramFiles\PowerShell\Modules
and that’s not in PowerShell 5.1’s PSModulePath
.
(Aside: I tested this on a different machine where I updated PowerShellGet
to the latest version.
1 2 3 4 5 6 |
> (Get-InstalledModule PowerShellGet).Version 2.2.5 > Install-Module -Name SpeculationControl > (Get-InstalledModule SpeculationControl).InstalledLocation C:\Program Files\WindowsPowerShell\Modules\SpeculationControl\1.0.18 |
It continues to install to $env:ProgramFiles\WindowsPowerShell\Modules
for PowerShell 5.1. Good).
Back to PnP.PowerShell. I thought I could just do an Install-Module
as I usually do, for PowerShell 7.x, and it will install PnP.PowerShell in a separate path. But that doesn’t work coz it finds the existing one and thinks maybe you want to update it instead.
Nope, I don’t want to update. (I did test this. If I do an Update-Module
it will update the existing module as expected, which is not compatible with PowerShell 5.1 in this case). What happens if I use the -Force
switch? Interestingly, that installs it in $env:ProgramFiles\PowerShell\Modules
.
And since this path is not visible to PowerShell 5.1, it doesn’t see the module. Noice!
It is possible to download and save modules manually. Above, I used Install-Module
to install a module and it installed to its default path. But I could have done this too:
1 2 3 |
# Create the path as it doesn't exist by default New-Item -Type Directory -Path "C:\Program Files\PowerShell\Modules" Find-Module "PnP.PowerShell" | Save-Module -Path "C:\Program Files\PowerShell\Modules" |
In the above snippet, I am saving the module to the path used by Install-Module
, so it sees this one and I can use Update-Module
to later update it. I don’t really need to do the above as I am saving it to the path used by Install-Module
anyway, but this was before I realized the -Force
switch saves it to this path.
What else? Initially I thought the order of paths matters in PSModulePath
. As in, maybe Install-Module
will use the first one available, or PowerShell will use the module in the first path. But nope, PowerShell uses the latest version across all paths; and Install-Module
or Update-Module
does its own thing.
Earlier I mentioned Get-Module
, which shows all loaded modules. To see all modules installed via Install-Module
(or rather, PowerShellGet to be specific), use Get-InstalledModule
. This too behaves differently in PowerShell 5.1 vs PowerShell 7.x – same as Install-Module
. In the former it looks for modules installed in $env:ProgramFiles\WindowsPowerShell\Modules
, in the latter $env:ProgramFiles\PowerShell\Modules
. This is confusing coz you’d do Get-InstalledModule
on PowerShell 7.x for instance, get no results, try to Install-Module
and it will complain that the module exists in a different path!
This stumped me a lot until I figured all this stuff out. This, for instance, is why I thought Install-Module
in PowerShell 7.x will just ignore the paths not used by PowerShell 5.1, but it does not.
Moral of the story: don’t fully trust Get-InstalledModule
.
My Function
As part of all this (including updating my various modules yesterday & today) I wrote a function to check my installed modules and update if needed. Nothing fancy, but here goes in case it helps anyone else.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 |
function InstallUpdate-Module { [CmdletBinding()] param( [Parameter(Position=0,Mandatory=$true)] [string[]]$Modules, [Parameter(Mandatory=$false)] [switch]$AllowPrerelease = $false, [Parameter(Mandatory=$false)] [switch]$Force = $false ) # I use this function as part of my profile and don't want it to run every time I launch a new session. # So the function stores the current execution date to a file and compare it on the next run. # If the current date isn't ahead, the function doesn't run. # Use the -Force switch to override this. $timestampFile = "$($env:TMPDIR)installupdate-module.txt" $presentTimestamp = Get-Date (Get-Date).ToUniversalTime() -Format "yyyy-MM-dd" if (Test-Path -PathType Leaf -Path $timestampFile) { [datetime]$previousTimestamp = Get-Content $timestampFile if ([datetime]$presentTimestamp -le $previousTimestamp -and !$Force) { Write-Host -ForegroundColor Yellow "Not checking for updated modules. Use -Force if needed." return } } # Only update the timestamp file in case of no errors. $noErrors = $true foreach ($module in $modules) { Write-Progress "Checking module $module" try { $repoModule = $null if ($AllowPrerelease) { $repoModule = Find-Module -Name $module -ErrorAction Stop -AllowPrerelease } else { $repoModule = Find-Module -Name $module -ErrorAction Stop } } catch { Write-Warning $_.Exception.Message $noErrors = $false continue } if ($repoModule) { $repoModuleVersion = $repoModule.Version $repoModuleRepository = $repoModule.Repository Write-Host ("Latest version of the {0} module in the PowerShell Gallery is {1}" -f $module, $repoModuleVersion) } try { $installedModule = $null $installedModule = Get-InstalledModule -Name $module -ErrorAction Stop } catch { if ($_.Exception.Message -match "No match was found") { # Isn't installed $installedModule = $null } else { Write-Warning $_.Exception.Message $noErrors = $false continue } } if ($null -ne $installedModule) { # Module is installed, update if needed $installedModuleVersion = $installedModule.Version $installedModuleRepository = $installedModule.Repository $installedModuleLocation = $installedModule.InstalledLocation if ($installedModuleVersion -ne $repoModuleVersion) { Write-Host -ForegroundColor Yellow ("Installed version of module {0} is {1}, needs updating to version {2}" -f $module, $installedModuleVersion, $repoModuleVersion) Write-Progress "Updating module $module" try { Update-Module -Name $module -ErrorAction Stop try { $installedModule2 = $null $installedModule2 = Get-InstalledModule -Name $module -ErrorAction Stop } catch {} $installedModuleVersion2 = $installedModule2.Version if ($null -ne $installedModule2) { Write-Host -ForegroundColor Green ("Updated module {0} to version {1}" -f $module, $installedModuleVersion2) } else { Write-Warning ("Couldn't find the updated module {0} locally; it was previously installed at {1}" -f $module, $installedModuleLocation) $noErrors = $false } } catch { Write-Warning $_.Exception.Message $noErrors = $false } } else { Write-Host -ForegroundColor Green ("Installed version of the {0} module is {1} at {2}" -f $module, $installedModuleVersion, $installedModuleLocation) if ($installedModuleRepository -ne $repoModuleRepository) { Write-Warning ("Installed version of the {0} module is from a different repo: {1}" -f $module, $installedModuleRepository) } } } else { # Module is not installed, so install it Write-Progress "Installing module $module" try { if ($AllowPrerelease) { Install-Module -Name $module -ErrorAction Stop -AllowPrerelease } else { Install-Module -Name $module -ErrorAction Stop } } catch { Write-Warning $_.Exception.Message } try { $installedModule2 = $null $installedModule2 = Get-InstalledModule -Name $module -ErrorAction Stop } catch {} $installedModuleVersion2 = $installedModule2.Version if ($null -ne $installedModule2) { Write-Host ("Updated module {0} to version {1}" -f $module, $installedModuleVersion2) } else { Write-Warning ("Couldn't find the newly installed module {0}" -f $module) $noErrors = $false } } } if ($noErrors) { try { $presentTimestamp | Out-File -FilePath $timestampFile -Force -ErrorAction Stop } catch {} } } |
I then add a line like this to my $PROFILE
file:
1 |
InstallUpdate-Module -Modules "posh-git", "Terminal-Icons","ExchangeOnlineManagement","Microsoft.Graph","Microsoft.Graph.Beta","PnP.PowerShell" |
I have only tested this on my “desktop” machines – so macOS and Windows 11, running PowerShell 7.3. I don’t use PowerShell 5.1 on these so never ran into the inconsistencies with Get-InstalledModule
. The function will misbehave in such environments. 🙃