Public/windowsupdate/Install-WindowsUpdate.ps1

#Requires -Version 5.1
function Install-WindowsUpdate {
    <#
        .SYNOPSIS
            Installs available Windows Updates on local or remote computers
 
        .DESCRIPTION
            Scans for available Windows Updates, downloads them if not already cached,
            then installs them using the COM API (Microsoft.Update.Session).
            Internally calls Get-WindowsUpdate to discover available updates, downloads
            any that are not yet cached, then installs each one using IUpdateInstaller.
            A progress bar displays installation status with estimated time remaining.
            Returns detailed results for each update including success/failure status
            and whether a reboot is required.
 
        .PARAMETER ComputerName
            One or more computer names to target. Defaults to the local computer.
            Accepts pipeline input by value and by property name.
 
        .PARAMETER Credential
            Optional PSCredential for authenticating to remote computers.
            Not required for local queries.
 
        .PARAMETER MicrosoftUpdate
            When specified, queries the full Microsoft Update catalog instead of the
            machine's configured source (WSUS, WUFB, or Windows Update).
 
        .PARAMETER KBArticleID
            Optional filter to install only updates matching the specified KB article IDs.
            Accepts one or more KB identifiers with or without the 'KB' prefix.
 
        .PARAMETER Classification
            Optional filter to install only updates matching the specified classifications.
            When not specified, all classifications are installed.
 
        .PARAMETER Product
            Optional filter to install only updates matching the specified product names.
            When not specified, all products are installed.
 
        .PARAMETER IncludeHidden
            When specified, includes updates that have been hidden (declined).
 
        .PARAMETER AcceptEula
            When specified, automatically accepts the End User License Agreement for each
            update before installation. Required for updates whose EULA has not been
            previously accepted.
 
        .PARAMETER AutoReboot
            When specified, automatically restarts the computer after installation if any
            installed update requires a reboot. Use with caution.
 
        .EXAMPLE
            Install-WindowsUpdate -AcceptEula
 
            Installs all available updates on the local computer, accepting EULAs.
 
        .EXAMPLE
            Install-WindowsUpdate -ComputerName 'SRV01' -KBArticleID 'KB5034441' -AcceptEula
 
            Installs a specific update on SRV01, accepting the EULA automatically.
 
        .EXAMPLE
            'SRV01', 'SRV02' | Install-WindowsUpdate -Classification 'Security Updates' -AcceptEula -AutoReboot
 
            Installs security updates on SRV01 and SRV02 with automatic reboot if required.
 
        .OUTPUTS
            PSWinOps.WindowsUpdateInstallResult
            Returns objects with ComputerName, Title, KBArticle, SizeMB, Result, HResult,
            RebootRequired, and Timestamp properties.
 
        .NOTES
            Author: Franck SALLET
            Version: 1.0.0
            Last Modified: 2026-04-08
            Requires: PowerShell 5.1+ / Windows only
            Requires: Administrator privileges for installing updates
 
        .LINK
            https://github.com/k9fr4n/PSWinOps
 
        .LINK
            https://learn.microsoft.com/en-us/windows/win32/api/wuapi/nn-wuapi-iupdateinstaller
    #>

    [CmdletBinding(SupportsShouldProcess = $true, ConfirmImpact = 'High')]
    [OutputType('PSWinOps.WindowsUpdateInstallResult')]
    param(
        [Parameter(Mandatory = $false, ValueFromPipeline = $true, ValueFromPipelineByPropertyName = $true)]
        [ValidateNotNullOrEmpty()]
        [Alias('CN', 'DNSHostName')]
        [string[]]$ComputerName = $env:COMPUTERNAME,

        [Parameter(Mandatory = $false)]
        [ValidateNotNull()]
        [System.Management.Automation.PSCredential]
        [System.Management.Automation.Credential()]
        $Credential,

        [Parameter(Mandatory = $false)]
        [switch]$MicrosoftUpdate,

        [Parameter(Mandatory = $false)]
        [ValidateNotNullOrEmpty()]
        [string[]]$KBArticleID,

        [Parameter(Mandatory = $false)]
        [ValidateNotNullOrEmpty()]
        [string[]]$Classification,

        [Parameter(Mandatory = $false)]
        [ValidateNotNullOrEmpty()]
        [string[]]$Product,

        [Parameter(Mandatory = $false)]
        [switch]$IncludeHidden,

        [Parameter(Mandatory = $false)]
        [switch]$AcceptEula,

        [Parameter(Mandatory = $false)]
        [switch]$AutoReboot
    )

    begin {
        Write-Verbose -Message "[$($MyInvocation.MyCommand)] Starting"

        $resultCodeMap = @{
            0 = 'NotStarted'
            1 = 'InProgress'
            2 = 'Succeeded'
            3 = 'SucceededWithErrors'
            4 = 'Failed'
            5 = 'Aborted'
        }

        $installScriptBlock = {
            param(
                [string]$UpdateIdToInstall,
                [bool]$UseMicrosoftUpdate,
                [bool]$DoAcceptEula
            )

            try {
                $session = New-Object -ComObject 'Microsoft.Update.Session'
                $session.ClientApplicationID = 'PSWinOps'
                $searcher = $session.CreateUpdateSearcher()

                if ($UseMicrosoftUpdate) {
                    $serviceManager = New-Object -ComObject 'Microsoft.Update.ServiceManager'
                    $serviceManager.ClientApplicationID = 'PSWinOps'
                    $service = $serviceManager.AddService2('7971f918-a847-4430-9279-4a52d1efe18d', 7, '')
                    $searcher.ServerSelection = 3
                    $searcher.ServiceID = $service.ServiceID
                }

                $searchResult = $searcher.Search("UpdateID='$UpdateIdToInstall'")

                if ($searchResult.Updates.Count -eq 0) {
                    throw "Update '$UpdateIdToInstall' not found"
                }

                $update = $searchResult.Updates.Item(0)

                if ($DoAcceptEula -and -not $update.EulaAccepted) {
                    $update.AcceptEula()
                }

                if (-not $update.EulaAccepted) {
                    throw "EULA not accepted for '$($update.Title)'. Use -AcceptEula to accept automatically."
                }

                $updateColl = New-Object -ComObject 'Microsoft.Update.UpdateColl'
                $updateColl.Add($update) | Out-Null

                # Download if not already cached
                if (-not $update.IsDownloaded) {
                    $downloader = $session.CreateUpdateDownloader()
                    $downloader.Updates = $updateColl
                    $dlResult = $downloader.Download()
                    if ($dlResult.ResultCode -eq 4 -or $dlResult.ResultCode -eq 5) {
                        throw "Download failed for '$($update.Title)' (HResult: 0x$($dlResult.HResult.ToString('X8')))"
                    }
                }

                # Install
                $installer = $session.CreateUpdateInstaller()
                $installer.Updates = $updateColl
                $installResult = $installer.Install()

                return [PSCustomObject]@{
                    ResultCode     = [int]$installResult.ResultCode
                    HResult        = [int]$installResult.HResult
                    RebootRequired = [bool]$installResult.RebootRequired
                }
            } catch {
                throw "Failed to install update '$UpdateIdToInstall': $_"
            }
        }

        $rebootScriptBlock = {
            Restart-Computer -Force
        }
    }

    process {
        foreach ($computer in $ComputerName) {
            Write-Verbose -Message "[$($MyInvocation.MyCommand)] Processing '$computer'"

            try {
                # Step 1: Scan for available updates using Get-WindowsUpdate
                $getParams = @{ ComputerName = $computer }
                if ($MicrosoftUpdate) {
                    $getParams['MicrosoftUpdate'] = $true
                }
                if ($KBArticleID) {
                    $getParams['KBArticleID'] = $KBArticleID
                }
                if ($Classification) {
                    $getParams['Classification'] = $Classification
                }
                if ($Product) {
                    $getParams['Product'] = $Product
                }
                if ($IncludeHidden) {
                    $getParams['IncludeHidden'] = $true
                }
                if ($PSBoundParameters.ContainsKey('Credential')) {
                    $getParams['Credential'] = $Credential
                }

                $activityLabel = "Install-WindowsUpdate — $computer"

                # Step 1: Scan
                Write-Progress -Activity $activityLabel -Status 'Scanning for available updates...' -PercentComplete 0
                $updates = @(Get-WindowsUpdate @getParams)
                Write-Progress -Activity $activityLabel -Status 'Scan complete' -PercentComplete 0

                if ($updates.Count -eq 0) {
                    Write-Progress -Activity $activityLabel -Completed
                    Write-Verbose -Message "[$($MyInvocation.MyCommand)] No updates to install on '$computer'"
                    continue
                }

                $totalUpdates = $updates.Count
                $totalSizeMB = ($updates | Measure-Object -Property 'SizeMB' -Sum).Sum
                Write-Information -MessageData "[$($MyInvocation.MyCommand)] $computer — $totalUpdates update(s) to install ($([math]::Round($totalSizeMB, 1)) MB)" -InformationAction Continue

                # Step 2: Install each update with progress
                $stopwatch = [System.Diagnostics.Stopwatch]::StartNew()
                $rebootNeeded = $false

                for ($i = 0; $i -lt $totalUpdates; $i++) {
                    $update = $updates[$i]
                    $kbLabel = if ($update.KBArticle) {
                        " ($($update.KBArticle))"
                    } else {
                        ''
                    }

                    # Progress
                    $percentComplete = [math]::Min([math]::Floor($i / $totalUpdates * 100), 99)
                    $elapsedSec = $stopwatch.Elapsed.TotalSeconds
                    $avgPerUpdate = if ($i -gt 0) {
                        $elapsedSec / $i
                    } else {
                        0
                    }
                    $etaSeconds = if ($avgPerUpdate -gt 0) {
                        [int](($totalUpdates - $i) * $avgPerUpdate)
                    } else {
                        -1
                    }

                    $progressParams = @{
                        Activity         = $activityLabel
                        Status           = "($($i + 1)/$totalUpdates) Downloading + installing..."
                        CurrentOperation = "$($update.Title)$kbLabel [$($update.SizeMB) MB]"
                        PercentComplete  = $percentComplete
                    }
                    if ($etaSeconds -ge 0) {
                        $progressParams['SecondsRemaining'] = $etaSeconds
                    }

                    Write-Progress @progressParams

                    if ($PSCmdlet.ShouldProcess("$($update.Title)$kbLabel [$($update.SizeMB) MB]", "Install update on '$computer'")) {
                        $invokeParams = @{
                            ComputerName = $computer
                            ScriptBlock  = $installScriptBlock
                            ArgumentList = @($update.UpdateId, [bool]$MicrosoftUpdate, [bool]$AcceptEula)
                        }
                        if ($PSBoundParameters.ContainsKey('Credential')) {
                            $invokeParams['Credential'] = $Credential
                        }

                        try {
                            $instResult = Invoke-RemoteOrLocal @invokeParams

                            $resultString = if ($resultCodeMap.ContainsKey($instResult.ResultCode)) {
                                $resultCodeMap[$instResult.ResultCode]
                            } else {
                                'Unknown'
                            }

                            if ($instResult.RebootRequired) {
                                $rebootNeeded = $true
                            }

                            $hResultHex = if ($instResult.HResult -ne 0) {
                                '0x{0:X8}' -f $instResult.HResult
                            } else {
                                '0x00000000'
                            }

                            [PSCustomObject]@{
                                PSTypeName     = 'PSWinOps.WindowsUpdateInstallResult'
                                ComputerName   = $computer
                                Title          = $update.Title
                                KBArticle      = $update.KBArticle
                                SizeMB         = $update.SizeMB
                                Result         = $resultString
                                HResult        = $hResultHex
                                RebootRequired = $instResult.RebootRequired
                                Timestamp      = Get-Date -Format 'yyyy-MM-dd HH:mm:ss'
                            }
                        } catch {
                            Write-Error -Message "[$($MyInvocation.MyCommand)] Failed to install '$($update.Title)' on '${computer}': $_"

                            [PSCustomObject]@{
                                PSTypeName     = 'PSWinOps.WindowsUpdateInstallResult'
                                ComputerName   = $computer
                                Title          = $update.Title
                                KBArticle      = $update.KBArticle
                                SizeMB         = $update.SizeMB
                                Result         = 'Failed'
                                HResult        = 'Error'
                                RebootRequired = $false
                                Timestamp      = Get-Date -Format 'yyyy-MM-dd HH:mm:ss'
                            }
                        }
                    }
                }

                Write-Progress -Activity $activityLabel -Completed
                $stopwatch.Stop()
                $elapsed = $stopwatch.Elapsed
                Write-Information -MessageData "[$($MyInvocation.MyCommand)] $computer — Done in $($elapsed.ToString('mm\:ss'))" -InformationAction Continue

                # Handle reboot
                if ($rebootNeeded) {
                    if ($AutoReboot) {
                        if ($PSCmdlet.ShouldProcess($computer, 'Restart computer after update installation')) {
                            Write-Warning -Message "[$($MyInvocation.MyCommand)] Restarting '$computer' as required by installed updates"
                            $rebootParams = @{
                                ComputerName = $computer
                                ScriptBlock  = $rebootScriptBlock
                            }
                            if ($PSBoundParameters.ContainsKey('Credential')) {
                                $rebootParams['Credential'] = $Credential
                            }
                            Invoke-RemoteOrLocal @rebootParams
                        }
                    } else {
                        Write-Warning -Message "[$($MyInvocation.MyCommand)] '$computer' requires a reboot to complete update installation. Use -AutoReboot or restart manually."
                    }
                }
            } catch {
                Write-Error -Message "[$($MyInvocation.MyCommand)] Failed on '${computer}': $_"
            }
        }
    }

    end {
        Write-Verbose -Message "[$($MyInvocation.MyCommand)] Completed"
    }
}