PSPUpdater.psm1
|
Set-StrictMode -Version Latest function Invoke-PSPURestMethod { [CmdletBinding()] param( [Parameter(Mandatory)] [string] $Uri ) $headers = @{ 'User-Agent' = 'PSPUpdater' 'Accept' = 'application/vnd.github+json' } try { Invoke-RestMethod -Uri $Uri -Headers $headers -ErrorAction Stop } catch { throw "PSPUpdater konnte '$Uri' nicht abrufen. $($_.Exception.Message)" } } function Get-PSPUArchitecture { [CmdletBinding()] param() if ($env:PROCESSOR_ARCHITECTURE -eq 'ARM64') { return 'arm64' } if ($env:PROCESSOR_ARCHITEW6432 -eq 'ARM64') { return 'arm64' } switch ($env:PROCESSOR_ARCHITECTURE) { 'AMD64' { return 'x64' } 'x86' { return 'x86' } default { throw "Nicht unterstuetzte Architektur: $($env:PROCESSOR_ARCHITECTURE)" } } } function Get-PSPUReleaseStageFromTag { [CmdletBinding()] param( [Parameter(Mandatory)] [string] $Tag ) switch -Regex ($Tag) { '-rc\.' { return 'rc' } '-beta\.' { return 'beta' } '-alpha\.' { return 'alpha' } '-preview\.' { return 'preview' } default { return 'prerelease' } } } function Get-PSPUInstalledVersions { [CmdletBinding()] param() $searchRoots = @( (Join-Path $env:ProgramFiles 'PowerShell'), (Join-Path $env:LOCALAPPDATA 'Microsoft\powershell'), (Join-Path $env:USERPROFILE 'scoop\apps\pwsh') ) | Where-Object { $_ -and (Test-Path -LiteralPath $_) } $installed = New-Object System.Collections.Generic.List[object] foreach ($root in $searchRoots) { $executables = Get-ChildItem -LiteralPath $root -Filter 'pwsh.exe' -Recurse -File -ErrorAction SilentlyContinue foreach ($exe in $executables) { try { $version = & $exe.FullName -NoLogo -NoProfile -Command '$PSVersionTable.PSVersion.ToString()' if (-not [string]::IsNullOrWhiteSpace($version)) { $installed.Add([pscustomobject]@{ Version = $version.Trim() Path = $exe.FullName }) } } catch { continue } } } $installed | Sort-Object Version -Descending -Unique } function Get-PSPUInstalledMsiProducts { [CmdletBinding()] param() $regPaths = @( 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall\*' 'HKLM:\SOFTWARE\WOW6432Node\Microsoft\Windows\CurrentVersion\Uninstall\*' ) Get-ItemProperty -Path $regPaths -ErrorAction SilentlyContinue | Where-Object { $_.PSObject.Properties['DisplayName'] -and $_.DisplayName -match '^PowerShell \d' -and $_.PSObject.Properties['PSChildName'] -and $_.PSChildName -match '^\{' } | ForEach-Object { [pscustomobject]@{ DisplayName = $_.DisplayName Version = $_.DisplayVersion ProductCode = $_.PSChildName } } | Sort-Object Version -Descending } function New-PSPUChannel { param( [Parameter(Mandatory)] [string] $Key, [Parameter(Mandatory)] [string] $DisplayName, [Parameter(Mandatory)] [string] $Version, [Parameter(Mandatory)] [string] $ReleaseTag, [Parameter(Mandatory)] [string] $DownloadUrl, [Parameter(Mandatory)] [string] $PackageName, [Parameter(Mandatory)] [string] $Description, [string[]] $Aliases = @(), [string] $ReleaseDate = '' ) [pscustomobject]@{ Key = $Key DisplayName = $DisplayName Version = $Version ReleaseDate = $ReleaseDate ReleaseTag = $ReleaseTag DownloadUrl = $DownloadUrl PackageName = $PackageName Description = $Description Aliases = $Aliases } } function Get-PSPUChannels { [CmdletBinding()] param() $architecture = Get-PSPUArchitecture $channels = New-Object System.Collections.Generic.List[object] # Fetch GitHub releases first so stable/LTS can look up their publish dates # from the same payload rather than making extra API calls. $githubReleases = Invoke-PSPURestMethod -Uri 'https://api.github.com/repos/PowerShell/PowerShell/releases?per_page=20' $releaseDateLookup = @{} foreach ($r in $githubReleases) { if ($r.published_at) { $releaseDateLookup[$r.tag_name] = ([datetime]$r.published_at).ToString('yyyy-MM-dd') } } $stableMeta = Invoke-PSPURestMethod -Uri 'https://aka.ms/pwsh-buildinfo-stable' $stableVersion = $stableMeta.ReleaseTag.TrimStart('v') $stablePackage = "PowerShell-$stableVersion-win-$architecture.msi" $stableDate = if ($releaseDateLookup.ContainsKey($stableMeta.ReleaseTag)) { $releaseDateLookup[$stableMeta.ReleaseTag] } else { '' } $channels.Add((New-PSPUChannel ` -Key 'stable' ` -DisplayName 'Stable' ` -Version $stableVersion ` -ReleaseDate $stableDate ` -ReleaseTag $stableMeta.ReleaseTag ` -DownloadUrl "https://github.com/PowerShell/PowerShell/releases/download/$($stableMeta.ReleaseTag)/$stablePackage" ` -PackageName $stablePackage ` -Description 'Neueste stabile PowerShell-Version' ` -Aliases @('default'))) $ltsMeta = Invoke-PSPURestMethod -Uri 'https://aka.ms/pwsh-buildinfo-lts' $ltsVersion = $ltsMeta.ReleaseTag.TrimStart('v') if ($ltsVersion -ne $stableVersion) { $ltsPackage = "PowerShell-$ltsVersion-win-$architecture.msi" $ltsDate = if ($releaseDateLookup.ContainsKey($ltsMeta.ReleaseTag)) { $releaseDateLookup[$ltsMeta.ReleaseTag] } else { '' } $channels.Add((New-PSPUChannel ` -Key 'lts' ` -DisplayName 'LTS' ` -Version $ltsVersion ` -ReleaseDate $ltsDate ` -ReleaseTag $ltsMeta.ReleaseTag ` -DownloadUrl "https://github.com/PowerShell/PowerShell/releases/download/$($ltsMeta.ReleaseTag)/$ltsPackage" ` -PackageName $ltsPackage ` -Description 'Aktuelle Long Term Support Version')) } $latestPrerelease = $null $addedStages = @{} foreach ($release in $githubReleases) { if (-not $release.prerelease -or $release.draft) { continue } $version = $release.tag_name.TrimStart('v') $assetName = "PowerShell-$version-win-$architecture.msi" $asset = $release.assets | Where-Object { $_.name -eq $assetName } | Select-Object -First 1 if (-not $asset) { continue } $stage = Get-PSPUReleaseStageFromTag -Tag $release.tag_name if (-not $latestPrerelease) { $latestPrerelease = $stage } if ($addedStages.ContainsKey($stage)) { continue } $description = switch ($stage) { 'rc' { 'Aktueller Release Candidate' } 'beta' { 'Aktuelle Beta' } 'alpha' { 'Aktuelle Alpha' } 'preview' { 'Aktuelle Preview' } default { 'Aktuelle Vorabversion' } } $displayName = switch ($stage) { 'rc' { 'RC' } 'lts' { 'LTS' } default { $stage.Substring(0, 1).ToUpperInvariant() + $stage.Substring(1) } } $releaseDate = if ($release.published_at) { ([datetime]$release.published_at).ToString('yyyy-MM-dd') } else { '' } $channels.Add((New-PSPUChannel ` -Key $stage ` -DisplayName $displayName ` -Version $version ` -ReleaseDate $releaseDate ` -ReleaseTag $release.tag_name ` -DownloadUrl $asset.browser_download_url ` -PackageName $asset.name ` -Description $description ` -Aliases @())) $addedStages[$stage] = $true } $hasExplicitPreviewChannel = $channels.Key -contains 'preview' if ($latestPrerelease) { $latestChannel = $channels | Where-Object { $_.Key -eq $latestPrerelease } | Select-Object -First 1 if ($latestChannel) { $aliases = @('prerelease') if (-not $hasExplicitPreviewChannel) { $aliases += 'preview' } $latestChannel.Aliases = $aliases } } $dailyMeta = Invoke-PSPURestMethod -Uri 'https://aka.ms/pwsh-buildinfo-daily' $dailyVersion = $dailyMeta.ReleaseTag.TrimStart('v') $dailyDate = if ($dailyMeta.ReleaseTag -match 'daily(\d{4})(\d{2})(\d{2})') { "$($Matches[1])-$($Matches[2])-$($Matches[3])" } else { '' } if ($architecture -eq 'x64') { $dailyPackage = "PowerShell-$dailyVersion-win-$architecture.msi" $channels.Add((New-PSPUChannel ` -Key 'daily' ` -DisplayName 'Daily' ` -Version $dailyVersion ` -ReleaseDate $dailyDate ` -ReleaseTag $dailyMeta.ReleaseTag ` -DownloadUrl "$($dailyMeta.BaseUrl)/$($dailyMeta.ReleaseTag)/$dailyPackage" ` -PackageName $dailyPackage ` -Description 'Letzter taeglicher Build')) } $preferredOrder = @{ stable = 0 lts = 1 rc = 2 beta = 3 alpha = 4 preview = 5 prerelease = 6 daily = 7 } $channels | Sort-Object { if ($preferredOrder.ContainsKey($_.Key)) { $preferredOrder[$_.Key] } else { 99 } } } function Resolve-PSPUSelection { [CmdletBinding()] param( [Parameter(Mandatory)] [object[]] $Channels, [AllowEmptyString()] [string] $Selection ) $selectedValue = if ([string]::IsNullOrWhiteSpace($Selection)) { 'stable' } else { $Selection.Trim() } if ($selectedValue -match '^\d+$') { $index = [int] $selectedValue if ($index -lt 1 -or $index -gt $Channels.Count) { throw "Ungueltige Auswahl '$selectedValue'." } return $Channels[$index - 1] } foreach ($channel in $Channels) { $names = @($channel.Key, $channel.DisplayName, $channel.Version, $channel.ReleaseTag) + $channel.Aliases if ($names | Where-Object { $_ -and $_.Equals($selectedValue, [System.StringComparison]::OrdinalIgnoreCase) }) { return $channel } } throw "Kanal '$selectedValue' wurde nicht gefunden." } function Show-PSPUSelectionMenu { [CmdletBinding()] param( [Parameter(Mandatory)] [object[]] $Channels, [object[]] $InstalledVersions = @() ) Write-Host '' Write-Host 'Verfuegbare PowerShell-Kanaele:' -ForegroundColor Cyan for ($i = 0; $i -lt $Channels.Count; $i++) { $channel = $Channels[$i] $defaultMarker = if ($channel.Key -eq 'stable') { ' (default)' } else { '' } Write-Host ("[{0}] {1,-8} {2,-24} {3,-12} {4}{5}" -f ($i + 1), $channel.Key, $channel.Version, $channel.ReleaseDate, $channel.Description, $defaultMarker) } if ($InstalledVersions.Count -gt 0) { Write-Host '' Write-Host 'Gefundene lokale pwsh-Installationen:' -ForegroundColor DarkCyan foreach ($installed in $InstalledVersions) { Write-Host ("- {0} {1}" -f $installed.Version, $installed.Path) } } Write-Host '' $selection = Read-Host 'Auswahl per Nummer oder Kanalname [stable]' Resolve-PSPUSelection -Channels $Channels -Selection $selection } function Test-PSPUVersionInstalled { [CmdletBinding()] param( [Parameter(Mandatory)] [string] $Version, [Parameter(Mandatory)] [object[]] $InstalledVersions ) $InstalledVersions.Version -contains $Version } function Test-PSPUIsAdministrator { [CmdletBinding()] param() $identity = [Security.Principal.WindowsIdentity]::GetCurrent() $principal = New-Object Security.Principal.WindowsPrincipal $identity $principal.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator) } function Install-PSPUChannel { [CmdletBinding()] param( [Parameter(Mandatory)] [object] $Channel ) $downloadRoot = Join-Path $env:TEMP 'PSPUpdater' $downloadPath = Join-Path $downloadRoot $Channel.PackageName if (-not (Test-Path -LiteralPath $downloadRoot)) { $null = New-Item -Path $downloadRoot -ItemType Directory -Force } Write-Host '' Write-Host "Lade $($Channel.DisplayName) $($Channel.Version) herunter..." -ForegroundColor Cyan Invoke-WebRequest -Uri $Channel.DownloadUrl -OutFile $downloadPath -ErrorAction Stop Write-Host "Starte Installation fuer $($Channel.Version)..." -ForegroundColor Cyan if (Test-PSPUIsAdministrator) { $process = Start-Process 'msiexec.exe' ` -ArgumentList @('/i', "`"$downloadPath`"", '/passive', '/norestart') ` -Wait -PassThru } else { # Start-Process -Verb RunAs on msiexec.exe (requireAdministrator manifest) routes # through the AppInfo UAC broker — the returned handle is the broker, not msiexec, # so -Wait exits immediately and ExitCode is meaningless. # Wrapping in an elevated pwsh (asInvoker manifest + explicit RunAs) gives a direct, # reliable process handle; the pwsh wrapper runs msiexec and forwards its exit code. $psExe = if (Get-Command pwsh -ErrorAction SilentlyContinue) { 'pwsh' } else { 'powershell' } $msiCmd = "msiexec.exe /i `"$downloadPath`" /passive /norestart; exit `$LASTEXITCODE" $process = Start-Process $psExe ` -Verb RunAs ` -ArgumentList @('-NoLogo', '-NoProfile', '-NonInteractive', '-Command', $msiCmd) ` -WindowStyle Hidden ` -Wait -PassThru } switch ($process.ExitCode) { 0 { Write-Host "Installation erfolgreich: $($Channel.DisplayName) $($Channel.Version)" -ForegroundColor Green } 1602 { Write-Warning "Installation abgebrochen." } 3010 { Write-Warning "Installation abgeschlossen. Ein Neustart wird empfohlen." } default { throw "Die MSI-Installation ist mit ExitCode $($process.ExitCode) fehlgeschlagen." } } [pscustomobject]@{ Channel = $Channel.Key Version = $Channel.Version Downloaded = $downloadPath ExitCode = $process.ExitCode } } function Invoke-PSPUMsiUninstall { [CmdletBinding()] param( [Parameter(Mandatory)] [string] $ProductCode, [string] $DisplayName = $ProductCode ) Write-Host "Deinstalliere $DisplayName..." -ForegroundColor Cyan if (Test-PSPUIsAdministrator) { $process = Start-Process 'msiexec.exe' ` -ArgumentList @('/x', $ProductCode, '/passive', '/norestart') ` -Wait -PassThru } else { $psExe = if (Get-Command pwsh -ErrorAction SilentlyContinue) { 'pwsh' } else { 'powershell' } $msiCmd = "msiexec.exe /x $ProductCode /passive /norestart; exit `$LASTEXITCODE" $process = Start-Process $psExe ` -Verb RunAs ` -ArgumentList @('-NoLogo', '-NoProfile', '-NonInteractive', '-Command', $msiCmd) ` -WindowStyle Hidden ` -Wait -PassThru } switch ($process.ExitCode) { 0 { Write-Host "Deinstalliert: $DisplayName" -ForegroundColor Green } 3010 { Write-Warning "Deinstalliert. Ein Neustart wird empfohlen." } default { Write-Warning "Deinstallation fehlgeschlagen (ExitCode $($process.ExitCode))." } } } function Remove-PSPUOldVersions { [CmdletBinding()] param( [Parameter(Mandatory)] [string] $JustInstalledLabel ) $products = @(Get-PSPUInstalledMsiProducts) if ($products.Count -le 1) { return } Write-Host '' Write-Host "Weitere installierte PowerShell-Versionen (soeben installiert: $JustInstalledLabel):" -ForegroundColor Cyan for ($i = 0; $i -lt $products.Count; $i++) { Write-Host ("[{0}] {1,-36} {2}" -f ($i + 1), $products[$i].DisplayName, $products[$i].Version) } Write-Host '' $selection = Read-Host 'Welche Versionen deinstallieren? (Nummern kommagetrennt, Enter zum Ueberspringen)' if ([string]::IsNullOrWhiteSpace($selection)) { return } foreach ($part in ($selection -split ',' | ForEach-Object { $_.Trim() })) { if ($part -notmatch '^\d+$') { continue } $idx = [int] $part if ($idx -lt 1 -or $idx -gt $products.Count) { Write-Warning "Ungueltige Auswahl: $idx" continue } $p = $products[$idx - 1] Invoke-PSPUMsiUninstall -ProductCode $p.ProductCode -DisplayName $p.DisplayName } } function PSPU { [CmdletBinding()] param( [Parameter(Position = 0)] [string] $Channel, [switch] $List, [switch] $Force ) $channels = @(Get-PSPUChannels) $installed = @(Get-PSPUInstalledVersions) if ($List) { for ($i = 0; $i -lt $channels.Count; $i++) { $channels[$i] | Add-Member -NotePropertyName Index -NotePropertyValue ($i + 1) -Force } return $channels } $selected = if ($PSBoundParameters.ContainsKey('Channel')) { Resolve-PSPUSelection -Channels $channels -Selection $Channel } else { Show-PSPUSelectionMenu -Channels $channels -InstalledVersions $installed } if ((-not $Force) -and (Test-PSPUVersionInstalled -Version $selected.Version -InstalledVersions $installed)) { Write-Host '' Write-Host "Version $($selected.Version) ist bereits installiert. Mit -Force kannst du trotzdem neu installieren." -ForegroundColor Yellow return } $installResult = Install-PSPUChannel -Channel $selected if ($installResult.ExitCode -in @(0, 3010)) { Remove-PSPUOldVersions -JustInstalledLabel "$($selected.DisplayName) $($selected.Version)" } } Export-ModuleMember -Function PSPU |