tools/Install-LegacyPrerequisites.ps1

#Requires -Version 3.0
<#
.SYNOPSIS
    Gets a Windows Server 2012 R2 machine (PowerShell 4.0) ready to run Discover-WindowsServer, with
    consent before every change and NEVER a restart.

.DESCRIPTION
    Discover-WindowsServer needs PowerShell 5.1 or 7. Windows Server 2012 R2 ships PowerShell 4.0.
    This script installs PowerShell 7 (side by side; it does not replace Windows PowerShell) plus the
    Universal C Runtime update PowerShell 7 needs on 2012 R2 (KB2999226), if that is missing.

    Guarantees, because this is meant for client servers:
      * It shows a plan first (what, why, source, size, whether a restart may be needed).
      * It asks "Install <item>? [y/N]" for EACH item. Nothing is installed without an explicit yes.
        -Yes answers the questions in advance for unattended use; it still cannot restart anything.
      * It NEVER restarts the machine. Installers run with /norestart. If Windows reports that a
        restart is pending, the script says so and stops there; you choose when to restart.
      * Every downloaded file must carry a valid Microsoft Authenticode signature or it is refused.
      * -PlanOnly changes nothing at all.

    Offline / no-internet servers: stage the two files on a share or USB and pass -SourceFolder.
    The signature check applies to staged files too.

    Written for PowerShell 3/4 syntax so that it can run on the machine it is preparing.

.PARAMETER SourceFolder
    Folder holding pre-downloaded installers (PowerShell-<version>-win-x64.msi,
    Windows8.1-KB2999226-x64.msu). Files found here are used instead of downloading.
.PARAMETER DownloadFolder
    Where downloads and the log are kept. Default: %TEMP%\dws-prereq.
.PARAMETER PowerShellVersion
    PowerShell 7 release to install. Default 7.4.20 (LTS; runs on 2012 R2).
.PARAMETER PlanOnly
    Show what would be done and exit. Makes no change.
.PARAMETER Yes
    Pre-approve every install in the plan (unattended). Does not allow restarts.

.EXAMPLE
    .\Install-LegacyPrerequisites.ps1 -PlanOnly
.EXAMPLE
    .\Install-LegacyPrerequisites.ps1
.EXAMPLE
    .\Install-LegacyPrerequisites.ps1 -SourceFolder \\fileserver\stage -Yes
#>

[CmdletBinding()]
param(
    [string]$SourceFolder,
    [string]$DownloadFolder = (Join-Path $env:TEMP 'dws-prereq'),
    [string]$PowerShellVersion = '7.4.20',
    [switch]$PlanOnly,
    [switch]$Yes
)

$ErrorActionPreference = 'Stop'
$script:LogFile = $null
function Say([string]$m, [string]$color = 'Gray') { Write-Host $m -ForegroundColor $color; if ($script:LogFile) { try { Add-Content -Path $script:LogFile -Value ("{0} {1}" -f (Get-Date -Format s), $m) } catch { } } }

# ---- 1. What is this machine? -------------------------------------------------
$os   = Get-CimInstance Win32_OperatingSystem
$build = [int]$os.BuildNumber
$psv  = $PSVersionTable.PSVersion
$is64 = [Environment]::Is64BitOperatingSystem
$kernel = (Get-Item (Join-Path $env:windir 'System32\ntoskrnl.exe')).VersionInfo
$kernelRev = 0; try { $kernelRev = [int]($kernel.FileVersion -replace '^\d+\.\d+\.\d+\.(\d+).*', '$1') } catch { }
$ucrtPath = Join-Path $env:windir 'System32\ucrtbase.dll'
$hasUcrt  = Test-Path $ucrtPath
$pwsh     = Join-Path $env:ProgramFiles 'PowerShell\7\pwsh.exe'
$hasPwsh7 = Test-Path $pwsh
$pending  = (Test-Path 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Component Based Servicing\RebootPending') -or
            (Test-Path 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\WindowsUpdate\Auto Update\RebootRequired')

if (-not (Test-Path $DownloadFolder)) { if (-not $PlanOnly) { New-Item -ItemType Directory -Path $DownloadFolder -Force | Out-Null } }
if (-not $PlanOnly) { $script:LogFile = Join-Path $DownloadFolder 'install.log' }

Say ''
Say '=== Discover-WindowsServer: prerequisite check ===' Cyan
Say ("Computer : {0}" -f $env:COMPUTERNAME)
Say ("OS : {0} (build {1}, {2})" -f $os.Caption, $build, $(if ($is64) { '64-bit' } else { '32-bit' }))
Say ("PowerShell: {0} PowerShell 7 installed: {1} Universal C Runtime: {2}" -f $psv, $hasPwsh7, $hasUcrt)
Say ''

if ($psv.Major -ge 5 -and ($psv.Major -gt 5 -or $psv.Minor -ge 1)) {
    Say 'This machine already runs PowerShell 5.1 or newer. Nothing to install.' Green
    Say ("Run: powershell -NoProfile -ExecutionPolicy Bypass -File .\Discover-WindowsServer.ps1 -Mode Fast") Green
    return
}
if ($hasPwsh7) {
    Say 'PowerShell 7 is already installed. Nothing to install.' Green
    Say ("Run: & '{0}' -NoProfile -ExecutionPolicy Bypass -File .\Discover-WindowsServer.ps1 -Mode Fast" -f $pwsh) Green
    return
}
if (-not $is64) { Say 'Refusing: PowerShell 7 needs a 64-bit Windows. Nothing was changed.' Red; return }
if ($build -lt 9200) { Say ('Refusing: Windows build {0} is older than Windows Server 2012. Nothing was changed.' -f $build) Red; return }

# ---- 2. What is missing? ------------------------------------------------------
$msiName = "PowerShell-$PowerShellVersion-win-x64.msi"
$msuName = 'Windows8.1-KB2999226-x64.msu'
$items = @()
if (-not $hasUcrt) {
    if ($build -eq 9600 -and $kernelRev -lt 17031) {
        Say 'The Universal C Runtime update (KB2999226) needs the April 2014 update (KB2919355) first, and this machine does not have it.' Yellow
        Say 'Install KB2919355 via Windows Update or your patch tool (it is a large, restart-requiring update, so this script will not do it), then run this script again. Nothing was changed.' Yellow
        return
    }
    $items += New-Object psobject -Property @{
        Key='ucrt'; Name='Universal C Runtime update (KB2999226)'; File=$msuName; Size='about 1 MB'
        Url='https://download.microsoft.com/download/D/1/3/D13E3150-3BB2-4B22-9D8A-47EE2D609FFF/Windows8.1-KB2999226-x64.msu'
        Why='PowerShell 7 cannot start on 2012 R2 without it.'; Restart='May ask for a restart. This script will NOT restart; you decide when.' }
}
$items += New-Object psobject -Property @{
    Key='pwsh'; Name=("PowerShell $PowerShellVersion (side by side, Windows PowerShell is untouched)"); File=$msiName; Size='about 105 MB'
    Url=("https://github.com/PowerShell/PowerShell/releases/download/v{0}/{1}" -f $PowerShellVersion, $msiName)
    Why='Discover-WindowsServer needs PowerShell 5.1 or 7; this machine has 4.0.'; Restart='No restart needed. Microsoft Update opt-in and remoting are left off.' }

Say 'Plan (nothing has been changed yet):' Cyan
$n = 0
foreach ($it in $items) {
    $n++
    $src = 'download from ' + $it.Url
    if ($SourceFolder -and (Test-Path (Join-Path $SourceFolder $it.File))) { $src = 'use staged file ' + (Join-Path $SourceFolder $it.File) }
    Say (" {0}. {1}" -f $n, $it.Name)
    Say (" why : {0}" -f $it.Why)
    Say (" source : {0} ({1})" -f $src, $it.Size)
    Say (" restart : {0}" -f $it.Restart)
}
if ($pending) { Say 'NOTE: Windows already has a restart pending from earlier changes. This script will not restart the machine.' Yellow }
Say ''
if ($PlanOnly) { Say '-PlanOnly: stopping here. No changes were made.' Green; return }

# ---- 3. Helpers ---------------------------------------------------------------
function Confirm-Item($it) {
    if ($Yes) { Say ("Pre-approved (-Yes): {0}" -f $it.Name); return $true }
    try { $a = Read-Host ("Install {0}? [y/N]" -f $it.Name) } catch { Say 'No interactive prompt available and -Yes was not given. Skipping.' Yellow; return $false }
    return ($a -match '^(y|yes)$')
}
function Get-Installer($it) {
    $local = $null
    if ($SourceFolder -and (Test-Path (Join-Path $SourceFolder $it.File))) { $local = Join-Path $SourceFolder $it.File }
    if (-not $local) {
        $local = Join-Path $DownloadFolder $it.File
        if (-not (Test-Path $local)) {
            Say ("Downloading {0} ..." -f $it.Url)
            [Net.ServicePointManager]::SecurityProtocol = 3072    # TLS 1.2; the default on this .NET is older
            $ProgressPreference = 'SilentlyContinue'
            Invoke-WebRequest -Uri $it.Url -OutFile $local -UseBasicParsing
        }
    }
    $sig = Get-AuthenticodeSignature -FilePath $local
    if ($sig.Status -ne 'Valid' -or $sig.SignerCertificate.Subject -notmatch 'Microsoft') {
        throw ("Refusing {0}: signature is '{1}' (signer: {2}). It was not run." -f $local, $sig.Status, $(if ($sig.SignerCertificate) { $sig.SignerCertificate.Subject } else { 'none' }))
    }
    Say ("Signature OK: {0} (Microsoft, {1} MB)" -f $it.File, [math]::Round((Get-Item $local).Length / 1MB, 1))
    return $local
}
$script:restartPending = $false
function Read-ExitCode($code, $what) {
    switch ($code) {
        0        { Say ("{0}: installed." -f $what) Green; return $true }
        3010     { Say ("{0}: installed. Windows reports a RESTART IS NEEDED to finish it (not done)." -f $what) Yellow; $script:restartPending = $true; return $true }
        1641     { Say ("{0}: installed. A restart was requested by the installer (suppressed; not done)." -f $what) Yellow; $script:restartPending = $true; return $true }
        2359302  { Say ("{0}: already installed." -f $what) Green; return $true }
        default  { Say ("{0}: installer returned {1}. Not treated as success." -f $what, $code) Red; return $false }
    }
}

# ---- 4. Do it, item by item ---------------------------------------------------
$failed = $false
foreach ($it in $items) {
    if (-not (Confirm-Item $it)) { Say ("Skipped: {0}" -f $it.Name) Yellow; $failed = $true; continue }
    try {
        $file = Get-Installer $it
        if ($it.Key -eq 'ucrt') {
            $p = Start-Process -FilePath 'wusa.exe' -ArgumentList ('"{0}" /quiet /norestart' -f $file) -Wait -PassThru
            if (-not (Read-ExitCode $p.ExitCode $it.Name)) { $failed = $true }
        } else {
            $msiArgs = '/i "{0}" /qn /norestart ADD_PATH=1 ENABLE_PSREMOTING=0 USE_MU=0 ENABLE_MU=0 REGISTER_MANIFEST=1' -f $file
            $p = Start-Process -FilePath 'msiexec.exe' -ArgumentList $msiArgs -Wait -PassThru
            if (-not (Read-ExitCode $p.ExitCode $it.Name)) { $failed = $true }
        }
    } catch { Say ("FAILED: {0}: {1}" -f $it.Name, $_.Exception.Message) Red; $failed = $true }
}

# ---- 5. Verify, and say what is left for a human -------------------------------
Say ''
$ok = $false
if (Test-Path $pwsh) {
    try { $v = & $pwsh -NoProfile -Command '$PSVersionTable.PSVersion.ToString()'; if ($v) { Say ("PowerShell 7 starts and reports version {0}." -f $v) Green; $ok = $true } } catch { }
    if (-not $ok) { Say 'PowerShell 7 is installed but did not start. If a restart is pending (below), restart when convenient and run this script again to re-check.' Yellow }
}
if ($script:restartPending -or $pending) {
    Say 'RESTART PENDING: Windows needs a restart to finish what was installed. This script did NOT restart the machine. Restart at a time that suits you.' Yellow
}
if ($ok) {
    Say ''
    Say 'Ready. Run discovery with:' Cyan
    Say (" & '{0}' -NoProfile -ExecutionPolicy Bypass -File .\Discover-WindowsServer.ps1 -Mode Fast" -f $pwsh) White
} elseif (-not $failed) {
    Say 'Installed, but PowerShell 7 could not be verified yet (see above).' Yellow
}
if ($failed) { Say 'One or more steps were skipped or failed; see above. Nothing was restarted.' Yellow }
Say ("Log: {0}" -f $script:LogFile)