PwshSetup.psm1
|
#Requires -Version 5.1 # PwshSetup 0.0.1, built by Publish-SetupModule.ps1. Do not edit this file. # On pwsh 7.2+ it loads the whole module from PwshSetupCore.ps1. # On Windows PowerShell it exports Assert-SetupAdmin and Test-SetupAdmin only. <# .SYNOPSIS Public: Assert-SetupAdmin and Test-SetupAdmin. .DESCRIPTION Stays Windows PowerShell 5.1 syntax and plain ASCII throughout: the build makes this file the module's loader, and the loader is what Windows PowerShell imports. #> $script:RerunVariable = 'PWSHSETUP_RERUN' function Test-SetupAdmin { <# .SYNOPSIS $true when the session runs as administrator. .EXAMPLE if (-not (Test-SetupAdmin)) { 'Not elevated' } #> [CmdletBinding()] [OutputType([bool])] param() $identity = [Security.Principal.WindowsIdentity]::GetCurrent() $principal = New-Object -TypeName 'Security.Principal.WindowsPrincipal' -ArgumentList $identity return $principal.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator) } function Write-SetupBootstrapLog { [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSAvoidUsingWriteHost', '', Justification = 'Runs before PwshAnsi can draw, possibly on Windows PowerShell.')] [CmdletBinding()] param( [Parameter(Mandatory)] [string] $Message ) Write-Host -Object ('[PwshSetup] {0}' -f $Message) -ForegroundColor Yellow } function ConvertTo-SetupPortableArgument { # Switches become booleans: a SwitchParameter does not come back from Import-Clixml as one. [CmdletBinding()] [OutputType([hashtable])] param( [hashtable] $Arguments = @{} ) $bound = @{} foreach ($key in $Arguments.Keys) { $value = $Arguments[$key] if ($value -is [System.Management.Automation.SwitchParameter]) { $value = $value.IsPresent } $bound[$key] = $value } return $bound } function Find-SetupTerminal { # wt.exe on the PATH, or at the per-user WindowsApps alias, or $null. [CmdletBinding()] [OutputType([string])] param() $terminal = Get-Command -Name 'wt.exe' -CommandType Application -ErrorAction SilentlyContinue | Select-Object -First 1 if ($terminal) { return $terminal.Source } if ($env:LOCALAPPDATA) { $alias = Join-Path -Path $env:LOCALAPPDATA -ChildPath 'Microsoft\WindowsApps\wt.exe' if (Test-Path -LiteralPath $alias) { return $alias } } return $null } function Get-SetupTerminalArgument { # Windows Terminal splits its commands at semicolons, and a trailing backslash would escape the closing quote. [CmdletBinding()] [OutputType([string])] param( [Parameter(Mandatory)] [string] $Pwsh, [Parameter(Mandatory)] [string] $EncodedCommand ) $arguments = @() if ($PWD.Provider.Name -eq 'FileSystem') { $directory = $PWD.ProviderPath.Replace(';', '\;') -replace '\\$', '\\' $arguments += @('-d', ('"{0}"' -f $directory)) } $arguments += @(('"{0}"' -f $Pwsh), '-NoProfile', '-ExecutionPolicy', 'Bypass', '-EncodedCommand', $EncodedCommand) return ($arguments -join ' ') } function Get-SetupRerunCommand { # The command the elevated window runs: marks the rerun, restores the folder and the parameters, and keeps the # window open on a failure that ends the script early. [CmdletBinding()] [OutputType([string])] param( [Parameter(Mandatory)] [string] $ScriptPath, [Parameter(Mandatory)] [string] $ArgumentFile, [Parameter(Mandatory)] [string] $Marker ) $quote = { param($Text) "'" + ($Text -replace "'", "''") + "'" } $lines = @(('$env:{0} = {1}' -f $script:RerunVariable, (& $quote $Marker))) if ($PWD.Provider.Name -eq 'FileSystem') { $lines += ('Set-Location -LiteralPath {0}' -f (& $quote $PWD.ProviderPath)) } $lines += @( ('$p = Import-Clixml -LiteralPath {0}' -f (& $quote $ArgumentFile)) ('Remove-Item -LiteralPath {0} -Force' -f (& $quote $ArgumentFile)) 'try' '{' (' & {0} @p' -f (& $quote $ScriptPath)) '}' 'catch' '{' ' Write-Host -Object ($_ | Out-String) -ForegroundColor Red' ' $null = Read-Host -Prompt ''Press Enter to close''' ' exit 1' '}' 'exit $LASTEXITCODE' ) return ($lines -join "`n") } function Assert-SetupAdmin { <# .SYNOPSIS Ensures the calling script runs as administrator. .DESCRIPTION Already elevated: returns at once. Otherwise reruns the calling script elevated, with its parameters, after one UAC prompt: in a new Windows Terminal window when wt.exe is found, in a new console window when not. This window ends there. Call it after Assert-PwshAnsi, so the elevated run starts on pwsh. .PARAMETER Arguments Parameters to pass to the rerun. Defaults to the bound parameters of the calling script. .PARAMETER ScriptPath Script to rerun. Defaults to the calling script. .EXAMPLE Assert-SetupAdmin #> [CmdletBinding()] param( [hashtable] $Arguments, [string] $ScriptPath = $MyInvocation.PSCommandPath ) if (Test-SetupAdmin) { return } if (-not $ScriptPath) { throw 'Assert-SetupAdmin: call it from a script file, or pass -ScriptPath.' } $done = (Get-Item -Path ('env:{0}' -f $script:RerunVariable) -ErrorAction SilentlyContinue).Value if ($done -and (($done -split ',') -contains 'admin')) { throw 'Assert-SetupAdmin: the elevated rerun is still not running as administrator.' } if ($null -eq $Arguments) { $Arguments = @{} $caller = Get-PSCallStack | Select-Object -Skip 1 -First 1 if ($caller) { $bound = $caller.InvocationInfo.BoundParameters foreach ($key in $bound.Keys) { $Arguments[$key] = $bound[$key] } } } $argumentFile = Join-Path -Path ([IO.Path]::GetTempPath()) -ChildPath ('PwshSetup-{0}.clixml' -f [guid]::NewGuid()) ConvertTo-SetupPortableArgument -Arguments $Arguments | Export-Clixml -LiteralPath $argumentFile $marker = (@($done, 'admin') | Where-Object -FilterScript { $_ }) -join ',' $command = Get-SetupRerunCommand -ScriptPath $ScriptPath -ArgumentFile $argumentFile -Marker $marker $encoded = [Convert]::ToBase64String([Text.Encoding]::Unicode.GetBytes($command)) $pwsh = (Get-Process -Id $PID).Path Write-SetupBootstrapLog -Message 'Restarting the script as administrator (UAC)...' try { # Name the local list after the host it is for, so it does not collide with the typed # $Arguments parameter — PowerShell variable names are case-insensitive, so $arguments # would be the same [hashtable] slot and the assignment would throw on type coercion. $terminal = Find-SetupTerminal if ($terminal) { $terminalArguments = Get-SetupTerminalArgument -Pwsh $pwsh -EncodedCommand $encoded Start-Process -FilePath $terminal -ArgumentList $terminalArguments -Verb RunAs -ErrorAction Stop } else { $pwshArguments = @('-NoProfile', '-ExecutionPolicy', 'Bypass', '-EncodedCommand', $encoded) Start-Process -FilePath $pwsh -ArgumentList $pwshArguments -Verb RunAs -ErrorAction Stop } } catch { Remove-Item -LiteralPath $argumentFile -Force -ErrorAction SilentlyContinue throw ('Assert-SetupAdmin: setup needs administrator rights, and elevation was not granted. {0}' -f $_.Exception.Message) } Write-SetupBootstrapLog -Message 'Carrying on in the elevated window.' exit 0 } # Assert-PwshSetup.psm1 # Must stay Windows PowerShell 5.1 syntax throughout. # Exports only Assert-PwshSetup; all other functions are internal helpers with a SetupBoot prefix to # keep them clear of other PwshSetup public and internal functions. $script:SetupBootMinPwsh = [version]'7.2' $script:SetupBootRerunVar = 'PWSHSETUP_RERUN' function ConvertTo-SetupBootEncodedCommand { param([Parameter(Mandatory)][string]$Command) return [Convert]::ToBase64String([Text.Encoding]::Unicode.GetBytes($Command)) } function ConvertTo-SetupBootVersion { param($Value) if (-not $Value) { return $null } $match = [regex]::Match([string]$Value, '^v?(\d+)\.(\d+)\.(\d+)') if (-not $match.Success) { return $null } return [version]('{0}.{1}.{2}' -f $match.Groups[1].Value, $match.Groups[2].Value, $match.Groups[3].Value) } # Yellow: an action that changes the system. DarkYellow (orange): its outcome or a skip. function Write-SetupBootLog { param([Parameter(Mandatory)][string]$Message, [switch]$Outcome) $color = 'Yellow' if ($Outcome) { $color = 'DarkYellow' } Write-Host "[PwshSetup] $Message" -ForegroundColor $color } function Enable-SetupBootTls12 { [Net.ServicePointManager]::SecurityProtocol = [Net.ServicePointManager]::SecurityProtocol -bor [Net.SecurityProtocolType]::Tls12 } # Version of a pwsh executable: file version first (no process start), then a probe. function Get-SetupBootPwshVersion { param([Parameter(Mandatory)][string]$Path) if (-not (Test-Path -LiteralPath $Path)) { return $null } try { $version = ConvertTo-SetupBootVersion (Get-Item -LiteralPath $Path).VersionInfo.FileVersion } catch { $version = $null } if ($version) { return $version } $probe = ConvertTo-SetupBootEncodedCommand '[string]$PSVersionTable.PSVersion' try { return ConvertTo-SetupBootVersion (& $Path -NoProfile -NonInteractive -EncodedCommand $probe 2>$null | Select-Object -Last 1) } catch { return $null } } # Newest pwsh on this machine at or above the minimum, as @{ Path; Version }, or $null. function Find-SetupBootPwsh { $candidates = @(Get-Command pwsh -CommandType Application -All -ErrorAction SilentlyContinue | Select-Object -ExpandProperty Source) if ($env:ProgramFiles) { $candidates += Join-Path $env:ProgramFiles 'PowerShell\7\pwsh.exe' } if ($env:LOCALAPPDATA) { $candidates += Join-Path $env:LOCALAPPDATA 'Microsoft\WindowsApps\pwsh.exe' } $best = $null foreach ($path in ($candidates | Select-Object -Unique)) { $version = Get-SetupBootPwshVersion -Path $path if (-not $version) { continue } if ($version -lt $script:SetupBootMinPwsh) { continue } if ($best -and $version -le $best.Version) { continue } $best = [pscustomobject]@{ Path = $path; Version = $version } } return $best } # Latest stable pwsh release. Throws when it cannot be checked. function Get-SetupBootLatestPwshVersion { Enable-SetupBootTls12 $info = Invoke-RestMethod -Uri 'https://aka.ms/pwsh-buildinfo-stable' -UseBasicParsing -TimeoutSec 10 -ErrorAction Stop return [version]([string]$info.ReleaseTag).TrimStart('v') } function Test-SetupBootElevated { $identity = [Security.Principal.WindowsIdentity]::GetCurrent() $principal = New-Object Security.Principal.WindowsPrincipal $identity return $principal.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator) } # Installs pwsh from the official MSI: Program Files, PATH, Start menu, and Microsoft Update. # Only msiexec is elevated (one UAC prompt), never the script itself. # Restart Manager stays on: other open pwsh windows are closed gracefully, never the PC rebooted. function Install-SetupBootPwsh { param([Parameter(Mandatory)][version]$Version) $arch = $env:PROCESSOR_ARCHITEW6432 if (-not $arch) { $arch = $env:PROCESSOR_ARCHITECTURE } $arch = switch ($arch) { 'ARM64' { 'arm64' } 'x86' { 'x86' } default { 'x64' } } $name = "PowerShell-$Version-win-$arch.msi" $url = "https://github.com/PowerShell/PowerShell/releases/download/v$Version/$name" $msi = Join-Path ([IO.Path]::GetTempPath()) $name $arguments = @('/i', "`"$msi`"", '/quiet', '/norestart', 'ADD_PATH=1', 'USE_MU=1', 'ENABLE_MU=1') Enable-SetupBootTls12 try { Write-SetupBootLog "Downloading $name..." Invoke-WebRequest -Uri $url -OutFile $msi -UseBasicParsing -ErrorAction Stop $start = @{ FilePath = 'msiexec.exe'; ArgumentList = $arguments; Wait = $true; PassThru = $true } if (Test-SetupBootElevated) { Write-SetupBootLog "Running the PowerShell $Version installer..." } else { Write-SetupBootLog "Requesting administrator rights to install PowerShell $Version (UAC)..." $start.Verb = 'RunAs' } $process = Start-Process @start -ErrorAction Stop if ($process.ExitCode -notin 0, 3010) { throw "msiexec exited with $($process.ExitCode)." } if ($process.ExitCode -eq 3010) { Write-SetupBootLog "PowerShell $Version installed; a reboot is needed to finish." -Outcome } else { Write-SetupBootLog "PowerShell $Version installed." -Outcome } } finally { Remove-Item -LiteralPath $msi -Force -ErrorAction SilentlyContinue } } function Test-SetupBootRerunStage { param([Parameter(Mandatory)][string]$Stage) $done = (Get-Item -Path "env:$($script:SetupBootRerunVar)" -ErrorAction SilentlyContinue).Value if (-not $done) { return $false } return ($done -split ',') -contains $Stage } # Windows PowerShell and pwsh each load their own copy of PwshSetup, so the update stage is marked # per edition: an update in one must not stop the other from updating its own copy. function Get-SetupBootModuleStage { return "module-$($PSVersionTable.PSEdition)" } function ConvertTo-SetupBootPortableArgument { param([hashtable]$Arguments = @{}) $bound = @{} foreach ($key in $Arguments.Keys) { $value = $Arguments[$key] if ($value -is [System.Management.Automation.SwitchParameter]) { $value = $value.IsPresent } $bound[$key] = $value } return $bound } # A classic console window: an interactive one that is neither Windows Terminal nor an editor's # terminal. A rerun inside it keeps its font, which lacks the glyphs PwshAnsi draws with. function Test-SetupBootClassicConsole { if ($env:WT_SESSION -or $env:TERM_PROGRAM) { return $false } try { return -not ([Console]::IsInputRedirected -or [Console]::IsOutputRedirected) } catch { return $false } } # wt.exe on the PATH, or at the standard per-user WindowsApps alias, or $null. The alias path is # checked explicitly because a freshly installed Windows Terminal is reachable from a new shell # but not from this already-running one, where PATH was cached at startup. function Find-SetupBootTerminal { $wt = Get-Command -Name 'wt.exe' -CommandType Application -ErrorAction SilentlyContinue | Select-Object -First 1 if ($wt) { return $wt.Source } if ($env:LOCALAPPDATA) { $alias = Join-Path $env:LOCALAPPDATA 'Microsoft\WindowsApps\wt.exe' if (Test-Path -LiteralPath $alias) { return $alias } } return $null } # Installs Windows Terminal for the current user via winget. Appx packages install without # admin, so no UAC prompt is raised. Throws when winget is missing or returns non-zero. function Install-SetupBootWindowsTerminal { $winget = Get-Command -Name 'winget.exe' -CommandType Application -ErrorAction SilentlyContinue | Select-Object -First 1 if (-not $winget) { throw 'winget is not available on this machine.' } $arguments = @( 'install', '--id', 'Microsoft.WindowsTerminal', '--silent', '--accept-source-agreements', '--accept-package-agreements', '--scope', 'user' ) Write-SetupBootLog 'Installing Windows Terminal via winget...' $process = Start-Process -FilePath $winget.Source -ArgumentList $arguments -Wait -PassThru -NoNewWindow -ErrorAction Stop if ($process.ExitCode -ne 0) { throw "winget exited with $($process.ExitCode)." } Write-SetupBootLog 'Windows Terminal installed.' -Outcome } # Windows Terminal arguments for a new tab in the most recent window, started in the current folder. # Windows Terminal splits its commands at semicolons, and a trailing backslash would escape the # closing quote, so both are escaped. function Get-SetupBootTerminalTabArgument { param([Parameter(Mandatory)][string]$Pwsh, [Parameter(Mandatory)][string]$EncodedCommand) $tab = @('-w', '0', 'new-tab') if ($PWD.Provider.Name -eq 'FileSystem') { $directory = $PWD.ProviderPath.Replace(';', '\;') -replace '\\$', '\\' $tab += @('-d', ('"{0}"' -f $directory)) } $tab += @(('"{0}"' -f $Pwsh), '-NoProfile', '-ExecutionPolicy', 'Bypass', '-EncodedCommand', $EncodedCommand) return ($tab -join ' ') } function Invoke-SetupBootRerun { param( [Parameter(Mandatory)][string]$Pwsh, [Parameter(Mandatory)][string]$ScriptPath, [Parameter(Mandatory)][string]$Stage, [hashtable]$Arguments = @{}, [switch]$PauseOnExit ) $bound = ConvertTo-SetupBootPortableArgument $Arguments $clixml = Join-Path ([IO.Path]::GetTempPath()) ('PwshSetup-{0}.clixml' -f [guid]::NewGuid()) $bound | Export-Clixml -LiteralPath $clixml # The stage marker travels inside the command: a Windows Terminal tab is started by the # terminal, not by this process, so it does not inherit this environment. $done = (Get-Item -Path "env:$($script:SetupBootRerunVar)" -ErrorAction SilentlyContinue).Value $marker = (@($done, $Stage) | Where-Object { $_ }) -join ',' $quote = { param($s) "'" + ($s -replace "'", "''") + "'" } $command = @( "`$env:$($script:SetupBootRerunVar) = $(& $quote $marker)" "`$p = Import-Clixml -LiteralPath $(& $quote $clixml)" "Remove-Item -LiteralPath $(& $quote $clixml) -Force" "& $(& $quote $ScriptPath) @p" 'exit $LASTEXITCODE' ) -join "`n" $encoded = ConvertTo-SetupBootEncodedCommand $command # Moving to pwsh from a classic console opens a Windows Terminal tab instead, so the script # draws with a font that has the glyphs. Windows Terminal is installed on first use when it # is missing. The tab runs on its own: this window ends here. $terminal = $null if ($Stage -eq 'pwsh' -and (Test-SetupBootClassicConsole)) { $terminal = Find-SetupBootTerminal if (-not $terminal) { try { Install-SetupBootWindowsTerminal; $terminal = Find-SetupBootTerminal } catch { Write-SetupBootLog "Windows Terminal unavailable, staying in this window: $($_.Exception.Message)" } } } if ($terminal) { Write-SetupBootLog "Restarting the script in a Windows Terminal tab with $Pwsh..." -Outcome Start-Process -FilePath $terminal -ArgumentList (Get-SetupBootTerminalTabArgument -Pwsh $Pwsh -EncodedCommand $encoded) exit 0 } $reason = 'to load the new PwshSetup' if ($Stage -eq 'pwsh') { $reason = "in $Pwsh" } Write-SetupBootLog "Restarting the script $reason..." -Outcome Set-Item -Path "env:$($script:SetupBootRerunVar)" -Value $marker & $Pwsh -NoProfile -ExecutionPolicy Bypass -EncodedCommand $encoded $code = $LASTEXITCODE if ($PauseOnExit) { $null = Read-Host 'Press Enter to close' } exit $code } # Returns the path to a pwsh 7.2+ to rerun under, or $null when the current host already qualifies. # Only installs when no pwsh 7.2+ is found at all; never updates an existing installation. function Update-SetupBootPwsh { if ($PSVersionTable.PSVersion -ge $script:SetupBootMinPwsh) { return $null } $current = Find-SetupBootPwsh if ($current) { return $current.Path } try { $latest = Get-SetupBootLatestPwshVersion Write-SetupBootLog "PowerShell $($script:SetupBootMinPwsh)+ not found. Installing PowerShell $latest..." Install-SetupBootPwsh -Version $latest $installed = Find-SetupBootPwsh if (-not $installed) { throw "PowerShell $latest was installed but cannot be found." } return $installed.Path } catch { throw "PowerShell $($script:SetupBootMinPwsh)+ is missing and could not be installed: $($_.Exception.Message)" } } # $true when a newer PwshSetup was installed and the script must rerun to load it. # Update failures log a yellow warning and return $false; they never throw. function Update-PwshSetupModule { param([Parameter(Mandatory)][version]$Current) if (Test-SetupBootRerunStage (Get-SetupBootModuleStage)) { return $false } $latest = $null try { $latest = [version](Find-Module PwshSetup -ErrorAction Stop).Version if ($latest -le $Current) { return $false } Write-SetupBootLog "Updating PwshSetup $Current -> $latest..." Install-Module PwshSetup -RequiredVersion $latest -Scope CurrentUser -Force -ErrorAction Stop Write-SetupBootLog "PwshSetup $latest installed." -Outcome return $true } catch { if ($latest -and $latest -gt $Current) { Write-SetupBootLog "Failed to update PwshSetup: $($_.Exception.Message) Continuing on $Current." } return $false } } function Assert-PwshSetup { <# .SYNOPSIS Ensures the calling script runs on pwsh 7.2+ with the latest PwshSetup. .DESCRIPTION 1. Unless -SkipUpdate is set, checks the gallery for a newer PwshSetup and reruns the script to load it when one is found. Windows PowerShell and pwsh keep separate copies, so each edition updates its own. 2. On Windows PowerShell, reruns the script on pwsh 7.2+, installing pwsh first when none is present. Already on pwsh 7.2+ with the latest PwshSetup (or -SkipUpdate): returns immediately. PwshSetup gallery check or update fails: logs a warning and continues. .EXAMPLE Assert-PwshSetup .EXAMPLE Assert-PwshSetup -SkipUpdate #> [CmdletBinding()] param( [hashtable]$Arguments, [string]$ScriptPath = $MyInvocation.PSCommandPath, [switch]$SkipUpdate ) if (-not $ScriptPath) { throw 'Assert-PwshSetup: call it from a script file, or pass -ScriptPath.' } if ($null -eq $Arguments) { $Arguments = @{} $caller = Get-PSCallStack | Select-Object -Skip 1 -First 1 if ($caller) { $bound = $caller.InvocationInfo.BoundParameters foreach ($key in $bound.Keys) { $Arguments[$key] = $bound[$key] } } } # The update comes first: on Windows PowerShell the move to pwsh ends this process, and the # pwsh run only ever sees its own copy, so this is the one chance to update this edition's. if (-not $SkipUpdate -and (Update-PwshSetupModule -Current $MyInvocation.MyCommand.Module.Version)) { Invoke-SetupBootRerun -Pwsh (Get-Process -Id $PID).Path -ScriptPath $ScriptPath -Stage (Get-SetupBootModuleStage) -Arguments $Arguments } $pwsh = Update-SetupBootPwsh if ($pwsh) { Invoke-SetupBootRerun -Pwsh $pwsh -ScriptPath $ScriptPath -Stage 'pwsh' -Arguments $Arguments } } if ($PSVersionTable.PSVersion -ge [version]'7.2') { . (Join-Path -Path $PSScriptRoot -ChildPath 'PwshSetupCore.ps1') } # The manifest's FunctionsToExport decides what is public; helpers stay hidden. Export-ModuleMember -Function * |