maintenance.ps1
|
<#
Module maintenance functions for PSFoundation. Provides Get-PSModule, Remove-PSModule, and Add-PSModule for inspecting, cleaning, and restoring PowerShell modules across PS5.1 and PS7+. #> #Requires -Version 5.0 # ---- Dot-source logging helper (needed when sourced standalone by tools) ---- $_logPath = Join-Path -Path (Split-Path -Path $PSScriptRoot -Parent) -ChildPath 'src/log.ps1' if (Test-Path -LiteralPath $_logPath) { . $_logPath } # ---- Helpers --------------------------------------------------------------- function Resolve-ModuleDirectory { [CmdletBinding()] param( [ValidateSet('CurrentUser', 'AllUsers')] [string]$Scope = 'CurrentUser', [string]$Path ) if ($Path) { return $ExecutionContext.SessionState.Path.GetUnresolvedProviderPathFromPSPath($Path) } $isPS7 = $PSVersionTable.PSVersion.Major -ge 7 if ($Scope -eq 'CurrentUser') { $docs = [Environment]::GetFolderPath('Personal') if ($isPS7) { return Join-Path -Path $docs -ChildPath 'PowerShell\Modules' } else { return Join-Path -Path $docs -ChildPath 'WindowsPowerShell\Modules' } } else { if (-not ([Security.Principal.WindowsPrincipal][Security.Principal.WindowsIdentity]::GetCurrent()).IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)) { Write-Warning 'AllUsers scope typically requires an elevated session.' } if ($isPS7) { return Join-Path -Path $env:ProgramFiles -ChildPath 'PowerShell\Modules' } else { return Join-Path -Path $env:ProgramFiles -ChildPath 'WindowsPowerShell\Modules' } } } function Test-PSResourceGetAvailable { $null -ne (Get-Module -Name 'Microsoft.PowerShell.PSResourceGet' -ListAvailable -ErrorAction SilentlyContinue) } function Ensure-PSResourceGet { [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseApprovedVerbs', '', Justification = 'Ensure- is a descriptive verb for a private helper that guarantees PSResourceGet availability.')] param() if (-not (Test-PSResourceGetAvailable)) { Write-Log -Message 'Installing Microsoft.PowerShell.PSResourceGet...' -Color Yellow try { $null = Install-PackageProvider -Name NuGet -Force -Scope CurrentUser -ErrorAction SilentlyContinue Install-Module -Name 'Microsoft.PowerShell.PSResourceGet' -Repository PSGallery -Force -Scope CurrentUser -ErrorAction Stop } catch { Write-Warning "Failed to install PSResourceGet: $_" } } } # ---- Get-PSModule ---------------------------------------------------------- function Get-PSModule { <# .SYNOPSIS Exports installed PowerShell modules to the pipeline or a JSON file. .DESCRIPTION Lists modules installed at the resolved scope path, filtered by name regex. Uses PSResourceGet on PS7+ whenever available, falling back to PowerShellGet on PS5.1. .PARAMETER Path JSON output file path. When omitted, modules are returned to the pipeline. .PARAMETER Name Regex filter for module name. Defaults to '*' (all). .PARAMETER Scope Module directory scope: CurrentUser (default) or AllUsers. .EXAMPLE Get-PSModule Returns all CurrentUser modules to the pipeline. .EXAMPLE Get-PSModule -Path ./modules.json -Name 'Pester' Exports matching modules to modules.json. #> [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSAvoidUsingCmdletAliases', '', Justification = 'Get-PSResource is an accepted alias for Get-InstalledPSResource.')] [CmdletBinding()] param( [string]$Path, [string]$Name, [ValidateSet('CurrentUser', 'AllUsers')] [string]$Scope = 'CurrentUser' ) $isPS7 = $PSVersionTable.PSVersion.Major -ge 7 $usePSResource = $false if ($isPS7) { Ensure-PSResourceGet $usePSResource = Test-PSResourceGetAvailable } $modulesPath = Resolve-ModuleDirectory -Scope $Scope $results = [System.Collections.Generic.List[PSCustomObject]]::new() if ($usePSResource) { try { $resources = Get-PSResource -Path $modulesPath -Scope $Scope -ErrorAction Stop if ($resources) { foreach ($r in $resources) { if ($Name -and $r.Name -notmatch $Name) { continue } $results.Add([PSCustomObject]@{ Name = $r.Name Version = $r.Version.ToString() Repository = if ($r.Repository) { $r.Repository } else { 'Unknown' } Scope = $Scope InstalledDate = $r.InstalledDate }) } } } catch { Write-Warning "Get-PSResource failed, falling back to Get-Module: $_" $usePSResource = $false } } if (-not $usePSResource) { if (Test-Path -LiteralPath $modulesPath -PathType Container) { $allModules = Get-Module -ListAvailable -ErrorAction SilentlyContinue | Where-Object { $_.ModuleType -ne 'Binary' -and ( ($_.ModuleBase -like "$modulesPath\*") -or ($_.Path -like "$modulesPath\*") ) } if ($allModules) { foreach ($m in $allModules) { if ($Name -and $m.Name -notmatch $Name) { continue } $installedDate = try { $item = Get-Item -LiteralPath $m.ModuleBase -ErrorAction SilentlyContinue $item.CreationTime } catch { $null } $results.Add([PSCustomObject]@{ Name = $m.Name Version = $m.Version.ToString() Repository = if ($m.RepositorySourceLocation) { $m.RepositorySourceLocation } else { 'Unknown' } Scope = $Scope InstalledDate = $installedDate }) } } } } $results = $results | Sort-Object Name, Version if ($Path) { $resolvedPath = $ExecutionContext.SessionState.Path.GetUnresolvedProviderPathFromPSPath($Path) $parent = Split-Path -Path $resolvedPath -Parent if ($parent -and -not (Test-Path -LiteralPath $parent)) { New-Item -Path $parent -ItemType Directory -Force | Out-Null } $results | ConvertTo-Json -Depth 3 | Set-Content -LiteralPath $resolvedPath -Encoding UTF8 Write-Log -Message "Exported $($results.Count) module(s) to $resolvedPath" -Color Green } else { $results } } # ---- Remove-PSModule ------------------------------------------------------- function Remove-PSModule { <# .SYNOPSIS Removes installed PowerShell module versions. .DESCRIPTION Removes old or all versions of modules from the resolved scope path. When -All is used, every matching module version is removed (requires confirmation unless -Force). Otherwise, only older versions beyond -LatestToKeep are removed. Uses PSResourceGet on PS7+ whenever available, falling back to PowerShellGet on PS5.1. .PARAMETER Name Regex filter for module name. Defaults to '*' (all modules). .PARAMETER LatestToKeep Number of newest versions to keep per module. Default: 1. .PARAMETER All Remove every version of every matching module. Overrides -LatestToKeep. Prompts for confirmation unless -Force is also supplied. .PARAMETER Scope Module directory scope: CurrentUser (default) or AllUsers. .PARAMETER Path Override the module directory path instead of resolving from -Scope. .PARAMETER Force Skip confirmation prompts. .EXAMPLE Remove-PSModule -WhatIf Preview which old module versions would be removed. .EXAMPLE Remove-PSModule -All -Force Remove all module versions from the CurrentUser scope without prompting. .EXAMPLE Remove-PSModule -Name 'Pester' -LatestToKeep 2 Keep the two newest Pester versions, remove older ones. #> [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSAvoidUsingCmdletAliases', '', Justification = 'Get-PSResource is an accepted alias for Get-InstalledPSResource.')] [CmdletBinding(SupportsShouldProcess = $true)] param( [string]$Name, [ValidateRange(1, [int]::MaxValue)] [int]$LatestToKeep = 1, [switch]$All, [ValidateSet('CurrentUser', 'AllUsers')] [string]$Scope = 'CurrentUser', [string]$Path, [switch]$Force ) $isPS7 = $PSVersionTable.PSVersion.Major -ge 7 $usePSResource = $false if ($isPS7) { Ensure-PSResourceGet $usePSResource = Test-PSResourceGetAvailable } $modulesPath = Resolve-ModuleDirectory -Scope $Scope -Path $Path # ---- Collect installed modules from the target path ---- $allModules = [System.Collections.Generic.List[PSCustomObject]]::new() if ($usePSResource) { try { $resources = Get-PSResource -Path $modulesPath -Scope $Scope -ErrorAction Stop } catch { Write-Warning "Get-PSResource failed, falling back to Get-Module: $_" $usePSResource = $false } } if ($usePSResource -and $resources) { foreach ($r in $resources) { if ($Name -and $r.Name -notmatch $Name) { continue } $allModules.Add([PSCustomObject]@{ Name = $r.Name Version = $r.Version Path = $modulesPath }) } } else { if (Test-Path -LiteralPath $modulesPath -PathType Container) { Get-Module -ListAvailable -ErrorAction SilentlyContinue | Where-Object { $_.ModuleType -ne 'Binary' -and ( ($_.ModuleBase -like "$modulesPath\*") -or ($_.Path -like "$modulesPath\*") ) -and (-not $Name -or $_.Name -match $Name) } | ForEach-Object { $allModules.Add([PSCustomObject]@{ Name = $_.Name Version = $_.Version Path = $modulesPath }) } } } if ($allModules.Count -eq 0) { $filterMsg = if ($Name) { "matching '$Name'" } else { '' } Write-Log -Message "No modules found $filterMsg in $modulesPath" -Color Yellow return } $grouped = $allModules | Group-Object Name if ($All) { $total = ($grouped | ForEach-Object { $_.Count } | Measure-Object -Sum).Sum $modNames = ($grouped | ForEach-Object { $_.Name }) -join ', ' if (-not $Force -and -not $PSCmdlet.ShouldContinue( "This will remove ALL $total version(s) of [$modNames].`nPath: $modulesPath`nAre you sure?", 'Remove ALL module versions' )) { Write-Log -Message 'Cancelled.' -Color Yellow return } foreach ($mod in $allModules) { if ($PSCmdlet.ShouldProcess("$($mod.Name) v$($mod.Version)", 'Remove')) { Write-Log -Message "Removing $($mod.Name) $($mod.Version)..." -Color Yellow try { if ($usePSResource) { Uninstall-PSResource -Name $mod.Name -Version $mod.Version.ToString() -SkipDependencyCheck -Scope $Scope -ErrorAction Stop } else { Uninstall-Module -Name $mod.Name -RequiredVersion $mod.Version.ToString() -Force -ErrorAction Stop } Write-Log -Message " Removed $($mod.Name) $($mod.Version)" -Color Green } catch { Write-Warning " Failed to remove $($mod.Name) v$($mod.Version): $_" } } } return } foreach ($group in $grouped) { $sorted = $group.Group | Sort-Object Version -Descending $keep = $sorted | Select-Object -First $LatestToKeep $remove = $sorted | Select-Object -Skip $LatestToKeep $latestVersion = $sorted | Select-Object -First 1 | ForEach-Object { $_.Version.ToString() } $keepVersions = ($keep | ForEach-Object { $_.Version.ToString() }) -join ', ' Write-Log -Message "Latest $($group.Name): $latestVersion. Keeping: [$keepVersions]" -Color Green if ($remove.Count -gt 0) { $oldVersions = ($remove | ForEach-Object { $_.Version.ToString() }) -join ', ' Write-Log -Message " Removing old versions of $($group.Name): [$oldVersions]" -Color Yellow foreach ($r in $remove) { if ($PSCmdlet.ShouldProcess("$($r.Name) v$($r.Version)", 'Remove')) { try { if ($usePSResource) { Uninstall-PSResource -Name $r.Name -Version $r.Version.ToString() -SkipDependencyCheck -Scope $Scope -ErrorAction Stop } else { Uninstall-Module -Name $r.Name -RequiredVersion $r.Version.ToString() -Force -ErrorAction Stop } Write-Log -Message " Removed $($r.Name) $($r.Version)" -Color Green } catch { Write-Warning " Failed to remove $($r.Name) v$($r.Version): $_" } } } } } } # ---- Add-PSModule ---------------------------------------------------------- function Add-PSModule { <# .SYNOPSIS Installs PowerShell modules. .DESCRIPTION Installs a single module by name, or restores all modules from a JSON file previously exported by Get-PSModule. Uses PSResourceGet on PS7+ whenever available, falling back to PowerShellGet on PS5.1. .PARAMETER Name Module name to install. Ignored when -FromFile is used. .PARAMETER Version Specific module version to install. Cannot be used with -MinimumVersion. .PARAMETER MinimumVersion Minimum acceptable module version. Installs the newest available version at or above this threshold. Cannot be used with -Version. .PARAMETER Scope Installation scope: CurrentUser (default) or AllUsers. .PARAMETER Force Force reinstallation even if the module is already present. .PARAMETER FromFile JSON file path from a previous Get-PSModule export. All modules in the file are reinstalled at their recorded versions. .EXAMPLE Add-PSModule -Name Pester -Version '5.4.0' Installs Pester 5.4.0 to the CurrentUser scope. .EXAMPLE Add-PSModule -FromFile ./modules.json -Force Restores all modules listed in modules.json, forcing reinstall. #> [CmdletBinding(SupportsShouldProcess = $true)] param( [string]$Name, [string]$Version, [string]$MinimumVersion, [ValidateSet('CurrentUser', 'AllUsers')] [string]$Scope = 'CurrentUser', [switch]$Force, [string]$FromFile ) if ($Version -and $MinimumVersion) { throw '-Version and -MinimumVersion are mutually exclusive.' } $isPS7 = $PSVersionTable.PSVersion.Major -ge 7 $usePSResource = $false if ($isPS7) { Ensure-PSResourceGet $usePSResource = Test-PSResourceGetAvailable } $null = Install-PackageProvider -Name NuGet -Force -Scope CurrentUser -ErrorAction SilentlyContinue # ---- Restore from JSON file ---- if ($FromFile) { $resolvedFile = $ExecutionContext.SessionState.Path.GetUnresolvedProviderPathFromPSPath($FromFile) if (-not (Test-Path -LiteralPath $resolvedFile -PathType Leaf)) { throw "Module export file not found: $resolvedFile" } $moduleList = Get-Content -LiteralPath $resolvedFile -Raw | ConvertFrom-Json foreach ($mod in $moduleList) { $modName = $mod.Name $modVersion = $mod.Version $modScope = if ($mod.Scope) { $mod.Scope } else { $Scope } if ($PSCmdlet.ShouldProcess("$modName v$modVersion", 'Install')) { Write-Log -Message "Installing $modName $modVersion..." -Color Yellow try { if ($usePSResource) { $params = @{ Name = $modName Version = $modVersion Scope = $modScope } Install-PSResource @params -ErrorAction Stop } else { $installParams = @{ Name = $modName RequiredVersion = $modVersion Scope = $modScope Force = $true AllowClobber = $true SkipPublisherCheck = $true } Install-Module @installParams } Write-Log -Message " Installed $modName $modVersion" -Color Green } catch { Write-Warning " Failed to install $modName v${modVersion}: $_" } } } Write-Log -Message "Restored $($moduleList.Count) module(s) from $resolvedFile" -Color Green return } # ---- Install single module ---- if (-not $Name) { throw '-Name is required when -FromFile is not used.' } $displayLabel = if ($Version) { $Version } elseif ($MinimumVersion) { ">= $MinimumVersion" } else { 'latest' } if ($PSCmdlet.ShouldProcess("$Name $displayLabel", 'Install')) { Write-Log -Message "Installing $Name $displayLabel..." -Color Yellow try { if ($usePSResource) { $params = @{ Name = $Name Scope = $Scope } if ($Version) { $params.Version = $Version } elseif ($MinimumVersion) { $params.Version = "[$MinimumVersion,)" } Install-PSResource @params -ErrorAction Stop } else { $installParams = @{ Name = $Name Scope = $Scope Force = $Force AllowClobber = $true SkipPublisherCheck = $true } if ($Version) { $installParams.RequiredVersion = $Version } elseif ($MinimumVersion) { $installParams.MinimumVersion = $MinimumVersion } Install-Module @installParams } Write-Log -Message " Installed $Name $displayLabel" -Color Green } catch { Write-Warning " Failed to install ${Name}: $_" } } } |