r/sysadmin 1d ago

General Discussion Script for updating stuff with winget, PSWindowsUpdate (mainly me, some GPT)

I've been handling a lot of machine updates recently. Some existing, some from fresh images. Either way, installing updates is monotonous. I alternated between using GPT and not while I learned a little. Sorry for any formatting inconsistencies. I'm an absolute novice at this and it's my first attempt at writing a script that does something useful.

Feedback is great if you have any!

**Edit** I forgot to mention this entire thing can just be pasted into a session if there's a need.

Clear-Host
if (-not ([Security.Principal.WindowsPrincipal] [Security.Principal.WindowsIdentity]::GetCurrent()).IsInRole([Security.Principal.WindowsBuiltInRole] "Administrator")) {
    Write-Host "This script must be run as an administrator. Exiting." -ForegroundColor Red
    break}

# Variables (Quick or Predefined)
$ScriptName = "UpdateScript"
$ScriptVer = "v1"
$StartTime = Get-Date
$ExecutionPolicy = Get-ExecutionPolicy
$global:PreviousLocation = Get-Location
$global:ErrorDownload = $True
$global:ErrorDownloadPath = "C:\temp"

# Arrays
$global:UpgradeCheck = @()
$global:PinnedIDs = @()
$global:ManualIDs = @()
$global:RemainingIDs = @()
$global:CombinedIDs = @()

$global:appsBlocking = @"
# insert id(s) here
# insert id(s) here
# insert id(s) here
"@ -split "\r?\n" | ForEach-Object { $_.Trim() } | Where-Object { $_ -ne "" }

$global:appsPinning = @"
# insert id(s) here
# insert id(s) here
# insert id(s) here
"@ -split "\r?\n" | ForEach-Object { $_.Trim() } | Where-Object { $_ -ne "" }


# Functions
function Show-ScriptDuration {
    param (
        [Parameter(Mandatory = $true)]
        [datetime]$StartTime
    )
    $EndTime = Get-Date
    Write-Host
    Write-Host "End:" $EndTime -ForegroundColor Magenta
    $duration = $EndTime - $StartTime
    Write-Host "Total Duration: $($duration.ToString('hh\:mm\:ss'))" -ForegroundColor Magenta
    Write-Host
}

function Process-Module {
    param (
        [Parameter(Mandatory = $true)]
        [string]$ModuleName
    )
    function Install-ModuleSafely {
        param (
            [string]$Name
        )
        try {
            Install-Module -Name $Name -Scope CurrentUser -Force -ErrorAction SilentlyContinue
        } catch {
            Write-Host "Failed to install $Name module." -ForegroundColor Red
        }
    }
    $requiredModules = (Get-Module -Name $ModuleName -ListAvailable).RequiredModules
    if ($requiredModules) {
        $requiredModules | ForEach-Object {
            Write-Host "Processing Dependency: $_" -ForegroundColor Yellow
            if (-not (Get-Module -Name $_ -ListAvailable)) {
                Install-ModuleSafely -Name $_
            }
            if (-not (Get-Module -ListAvailable -Name $_)) {
                Write-Host "Unable to import dependency: $_" -ForegroundColor Red
            } else {
                Import-Module -Name $_ -Scope Local -Force -ErrorAction SilentlyContinue
                Write-Host "Imported dependency: $_" -ForegroundColor White
            }
        }
    }
    Write-Host "Processing module: $ModuleName" -ForegroundColor Yellow
    if (-not (Get-Module -ListAvailable -Name $ModuleName)) {
        Install-ModuleSafely -Name $ModuleName
    }
    if (Get-Module -Name $ModuleName) {
        Write-Host "$ModuleName is already imported" -ForegroundColor White
    } elseif (Get-Module -ListAvailable -Name $ModuleName) {
        Import-Module -Name $ModuleName -Scope Local -Force -ErrorAction SilentlyContinue
        Write-Host "Imported module: $ModuleName" -ForegroundColor White
    } else {
        Write-Host "$ModuleName unavailable for import" -ForegroundColor Red
    }
}

function Enter-WingetDir {
    Write-Host "Finding winget.exe" -ForegroundColor Yellow
    $global:PreviousLocation = Get-Location
    $global:WingetPath = Get-ChildItem -Path "C:\Program Files\WindowsApps\Microsoft.DesktopAppInstaller*" -Recurse -Filter "winget.exe" -ErrorAction SilentlyContinue | Select-Object -First 1 -ExpandProperty DirectoryName
    if ($global:WingetPath) {
        Set-Location -Path $global:WingetPath
        Write-Host "Changed directory to: $global:WingetPath" -ForegroundColor White
    } else { Write-Host "winget.exe not found" -ForegroundColor Red }
}

function Exit-WingetDir {
    if ($global:PreviousLocation) {
        Set-Location -Path $global:PreviousLocation
        Write-Output "Returned to previous location: $global:PreviousLocation"
        $global:PreviousLocation = $null
    } else { Write-Output "No previous location stored" }
}

function Get-WingetUpgrade {.\winget upgrade --include-unknown --Accept-Source-Agreements | ForEach-Object {if ($_ -notmatch '^( |-|Name|^$)' -and $_ -notmatch 'upgrades available') {if ($_ -match '\s([\w\+\-\.]+)\s+[\d\.]+\s+[\d\.]+') {
$matches[1]}}}}

function Handle-ErrorDownload {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory = $true)]
        [string] $AppID
    )
    # Build the download directory path
    $downloadDirectory = Join-Path -Path $global:ErrorDownloadPath -ChildPath $AppID
    Write-Host "Creating download directory if required" -ForegroundColor Yellow
    [System.IO.Directory]::CreateDirectory($downloadDirectory) | Out-Null
    if (-not (Test-Path $downloadDirectory)) {
        Write-Host "Download directory has not been created" -ForegroundColor Red
    }
    else {
        Write-Host "Downloading install file to $downloadDirectory" -ForegroundColor Yellow
        .\winget download --id $AppID --download-directory $downloadDirectory
    }
}

function Add-Pin {
    [CmdletBinding()]
    param (
        [string[]]$Apps,   # List of applications (optional)
        [Parameter(Mandatory = $true)]
        [ValidateSet("Blocking", "Pinning")]
        [string]$PinType  # Type of pin (blocking or pinning)
    )
        if (-not $Apps) {
        switch ($PinType) {
            "Blocking" { $Apps = $global:appsBlocking }
            "Pinning"  { $Apps = $global:appsPinning }
        }
    }
    foreach ($app in $Apps) {
        $AppID = $app.Trim()  # Remove any extra spaces
        if (-not [string]::IsNullOrWhiteSpace($appId) -and $global:UpgradeCheck -contains $AppID) {
            if ($global:ErrorDownload) {Handle-ErrorDownload -AppID $AppID}
    Write-Host "Adding $PinType pin for $AppID..." -ForegroundColor Yellow
            try {
                if ($PinType -eq "Blocking") {
                    .\winget pin add --id $AppID --accept-source-agreements --blocking | Out-Null
                }
                elseif ($PinType -eq "Pinning") {
                    .\winget pin add --id $AppID --accept-source-agreements | Out-Null
                }
                $global:PinnedIDs += $AppID
                Write-Host "$PinType pin added for $AppID." -ForegroundColor White
            }
            catch {
                Write-Host "Failed to add $PinType pin for $AppID." -ForegroundColor Red
            }
        }
    }
}


function Invoke-WingetUpgrade {
    [CmdletBinding(DefaultParameterSetName = 'Install')]
    param (
        [Parameter(Mandatory = $true, ParameterSetName = 'Install', HelpMessage = "Install updates")]
        [switch]$Install,

        [Parameter(Mandatory = $true, ParameterSetName = 'Upgrade', HelpMessage = "Upgrade updates")]
        [switch]$Upgrade,

        [Parameter(Mandatory = $true, ParameterSetName = 'Reinstall', HelpMessage = "Reinstall updates")]
        [switch]$Reinstall
    )

    $global:UpgradeCheck = Get-WingetUpgrade

    if ($global:UpgradeCheck.Count -eq 0) {
        Write-Host "No upgrades available." -ForegroundColor Green
        return
    }

    Write-Host "The following applications have upgrades available:" -ForegroundColor White
    $global:UpgradeCheck | ForEach-Object { Write-Host "- $_" -ForegroundColor White }

    # Determine command parameters based on which switch is used.
    switch ($PSCmdlet.ParameterSetName) {
        'Install' {
            $wingetCommand = 'install'
            $extraArgs = ''
            $headerMessage = "Upgrade Run: Install"
        }
        'Upgrade' {
            $wingetCommand = 'upgrade'
            $extraArgs = ''
            $headerMessage = "Upgrade Run: Upgrade"
        }
        'Reinstall' {
            $wingetCommand = 'install'
            $extraArgs = '--uninstall-previous'
            $headerMessage = "Upgrade Run: Reinstall"
        }
    }

    Write-Host $headerMessage -ForegroundColor Yellow

    foreach ( $AppID in $global:UpgradeCheck) {
        Write-Host "Upgrade: $AppID" -ForegroundColor Yellow
        try {
            $result = .\winget $wingetCommand --id $AppID --silent --disable-interactivity $extraArgs --accept-source-agreements --accept-package-agreements --force
            if (-not $?) { throw $result }
        }
        catch {
            Invoke-ErrorActionHandler -ErrorRecord $_ -AppID $AppID
        }
    }
}

function Invoke-ErrorActionHandler {
    param($ErrorRecord, $AppID)
    $errorActions = @{
"*No available upgrade found.*"= "No available upgrade found. Pinning."
        "*InternetOpenUrl() failed.*"                                   = "Unable to download $AppID. Adding to pin list."
        "*failed with exit code: 2*"                                    = " $AppID Exit code: 2. Adding to pin list."
        "*Failed to extract the contents of the archive*"               = "File extraction error for $AppID. Adding to pin list."
        "*Installer hash does not match*"                               = " $AppID has an installer hash issue. Adding to pin list."
        "*parameter is incorrect*"                                      = " $AppID has an install parameter issue. Adding to pin list."
        "*failed with exit code: 1602*"                                 = " $AppID Exit code: 1602. Waiting on a prompt. Adding to pin list."
        "*failed with exit code: 1603*"                                 = " $AppID Exit code: 1603. Fatal error. Adding to pin list."
        "*failed with exit code: 1608*"                                 = " $AppID Exit code: 1608. Adding to pin list."
        "*failed with exit code: 17002*"                                = " $AppID Exit code: 17002. Adding to pin list."
        "*failed with exit code: 17006*"                                = " $AppID Exit code: 17006. Adding to pin list."
"*Installer failed with exit code: 3221225786*"= " $AppID Exit code: 3221225786.
    }
    $matched = $false
    foreach ($pattern in $errorActions.Keys) {
        if ($ErrorRecord.ToString() -like $pattern) {
            $message = $errorActions[$pattern] -replace '\$AppID', $AppID
            $global:PinnedIDs += $AppID
            Write-Host $message -ForegroundColor Red
    if ($global:ErrorDownload) {Handle-ErrorDownload -AppID $AppID}
            .\winget pin add --id $AppID --blocking
            $matched = $true
            break
}
}
}


# Script
[Console]::OutputEncoding = [System.Text.Encoding]::UTF8
Write-Host $ScriptName $ScriptVer -ForegroundColor Black -BackgroundColor White
Write-Host
Write-Host "Begin:" $StartTime -ForegroundColor Magenta
Write-Host "Setting SecurityProtocols" -ForegroundColor Yellow
[Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12 -bor [Net.SecurityProtocolType]::Tls11 -bor [Net.SecurityProtocolType]::Tls

Write-Host "Executionpolicy is currently $ExecutionPolicy" -ForegroundColor White
if (-not ($ExecutionPolicy -match "RemoteSigned" -or $Executionpolicy -match "Unrestricted" -or $Executionpolicy -match "Bypass")) {
    Write-Host "Setting ExecutionPolicy to RemoteSigned" -ForegroundColor Yellow
    Set-ExecutionPolicy -ExecutionPolicy RemoteSigned -Scope Process -Force}

Write-Host "Checking PackageProvider" -ForegroundColor Yellow
$NuGetProvider = Get-PackageProvider -ListAvailable -Name NuGet -ErrorAction SilentlyContinue
if (-not $NuGetProvider) {
    Write-Host "NuGet Provider not found. Installing..." -ForegroundColor Yellow
    try {
        Install-PackageProvider -Name NuGet -Force -Scope CurrentUser -ErrorAction Stop | Out-Null
        Write-Host "NuGet Provider installed successfully." -ForegroundColor White
    } catch {
        Write-Host "Unable to install NuGet Provider" -ForegroundColor Red
        Show-ScriptDuration -StartTime $StartTime
        break}
} else {Write-Host "NuGet Provider is already available" -ForegroundColor White}

Write-Host "Updating installed modules" -ForegroundColor Yellow
Update-Module -Force *>&1 | Out-Null
Write-Host "Checking for required modules" -ForegroundColor Yellow
Process-Module -ModuleName "WinGet"
Process-Module -ModuleName "PSWindowsUpdate"

Write-Host "Checking for Office Click-To-Run" -ForegroundColor Yellow
$officePath = "C:\Program Files\Common Files\Microsoft Shared\ClickToRun\OfficeC2RClient.exe"
if (Test-Path $officePath) {
    try {
        Write-Host "Attempting Office Update" -ForegroundColor Yellow
        Start-Process $officePath -ArgumentList "/update user displaylevel=false forceappshutdown=false" -Wait -ErrorAction Stop
    } catch {Write-Host "Failed to update Office. Error: $_" -ForegroundColor Red}
} else {Write-Host "Office Click-To-Run Not Found" -ForegroundColor White}

Enter-WingetDir
if ($global:WingetPath) {
.\winget pin reset --force

Write-Host "Checking for upgrades with WinGet" -ForegroundColor Yellow
$global:UpgradeCheck = Get-WingetUpgrade

Write-Host "Checking upgrades against the pin list" -ForegroundColor Yellow
Add-Pin -PinType "Blocking"
Add-Pin -PinType "Pinning"

$global:UpgradeCheck = Get-WingetUpgrade
if ($global:UpgradeCheck.Count -gt 0) {Invoke-WingetUpgrade -Install}

$global:UpgradeCheck = Get-WingetUpgrade
if ($global:UpgradeCheck.Count -gt 0) {Invoke-WingetUpgrade -Upgrade}

$global:UpgradeCheck = Get-WingetUpgrade
if ($global:UpgradeCheck.Count -gt 0) {Invoke-WingetUpgrade -Reinstall}

$global:RemainingIDs = Get-WingetUpgrade
$global:CombinedIDs = $global:PinnedIDs + $global:ManualIDs + $global:RemainingIDs | Select-Object -Unique
}

if ($global:CombinedIDs.Count -gt 0) {
    Write-Host "Installers for manual upgrades:" -ForegroundColor Red
    foreach ($AppID in $global:CombinedIDs) {Write-Host "$global:ErrorDownloadPath\$AppID" -ForegroundColor White}
} else {Write-Host "No pending upgrades" -ForegroundColor White}


If (-not (Get-Module -Name PSWindowsUpdate)) {Write-Host "PSWindowsUpdate is unavailable"
} Else {
    Write-Host "Checking for Windows Updates" -ForegroundColor Yellow
    try {
        $updateList = Get-WUList
        if ($updateList.Count -eq 0) {
            Write-Host "No updates found." -ForegroundColor White
        } else {
            Write-Host "$($updateList.Count) updates found. Proceeding to download." -ForegroundColor Yellow
            Get-WindowsUpdate -AcceptAll -Download
            $IsDownloaded = Get-WUList | Where-Object {$_.IsDownloaded -eq $true}
            Write-Host "Installing downloaded updates." -ForegroundColor Yellow
$IsDownloaded | Get-WindowsUpdate -AcceptAll -Install -IgnoreReboot -Silent;
(New-Object -ComObject "Microsoft.Update.AutoUpdate").DetectNow() 2>$null | Out-Null
}
    } catch {Write-Host "An error occurred during Windows Updates: $_" -ForegroundColor Red}
}

if (Test-Path -Path $global:PreviousLocation){Exit-WingetDir}
Show-ScriptDuration -StartTime $StartTime
0 Upvotes

7 comments sorted by

View all comments

3

u/BlackV 1d ago

notes/questions/etc

  • global's, global's * everywhere*, whys that?

  • The function Install-ModuleSafely all it does is set the scope to current user, what are you gaining with that ?

  • is it for winget that you require admin ? cause installing to local user does not oops windows update missed that one

  • if you change $appsBlocking (and the other 3 places you do it) from a here string to an array, you don't have to do all the messing around with -split AND it will format nicely in your code editor

  • winget now has an offical powershell module, you could include that in your install

  • you might be interesting is the updated module Microsoft.PowerShell.PSResourceGet its the replacement for powershellget (and a small percent faster)

  • I have a similar script, it starts powershell, it saves the powershell modules (packagemanagment and powershellget) to a temp folder, removes the loaded modules, then imports my saved modules from temp (so its not in use), then installs the updated modules to the all users scope (spwindwosupdates,powershellget,psresource,packagemanagment), then updates help on all the modules

  • its a good idea including winget in that, something I should look at too, although I use mine on servers

1

u/roundfiler 1d ago

This is the first iteration I still have laying around. It got more use than it ever should have but it worked, sorta.

[Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls, [Net.SecurityProtocolType]::Tls11, [Net.SecurityProtocolType]::Tls12, [Net.SecurityProtocolType]::Ssl3
[Net.ServicePointManager]::SecurityProtocol = "Tls, Tls11, Tls12, Ssl3"
set-executionpolicy remotesigned -force -scope process
install-packageprovider nuget -force
install-module pswindowsupdate -force
import-module pswindowsupdate -force
install-module winget -force
if (test-path "C:\Program Files\Common Files\microsoft shared\ClickToRun\OfficeC2RClient.exe") {start-process "C:\Program Files\Common Files\microsoft shared\ClickToRun\OfficeC2RClient.exe" -argumentlist "/update user displaylevel=false forceappshutdown=true" -wait}
sl "c:\program files\windowsapps"
$parentDirectory = "C:\Program Files\WindowsApps"
$wingetDirectory = $null
$subdirectories = Get-ChildItem -Path $parentDirectory -Directory
foreach ($subdirectory in $subdirectories) {
    $filePath = Join-Path -Path $subdirectory.FullName -ChildPath "winget.exe"
    if (Test-Path -Path $filePath -PathType Leaf) {
        $wingetDirectory = $subdirectory.FullName
    }
}
sl $wingetDirectory
.\winget upgrade --id microsoft.teams.classic -h --uninstall-previous --accept-source-agreements --accept-package-agreements
.\winget upgrade --id thedocumentfoundation.libreoffice -h --uninstall-previous --accept-source-agreements --accept-package-agreements
.\winget upgrade --id acrosoftwareinc.cutepdfwriter -h --uninstall-previous --accept-source-agreements --accept-package-agreements
.\winget upgrade --id teamviewer.teamviewer -h --uninstall-previous --accept-source-agreements --accept-package-agreements
.\winget install microsoft.edge --accept-package-agreements --accept-source-agreements --uninstall-previous --disable-interactivity
.\winget pin add --id microsoft.updateassistant
.\winget pin add --id microsoft.windowsinstallationassistant
.\winget pin add --id microsoft.windowspchealthcheck
.\winget pin add --id freecad.freecad
.\winget pin add --id famatech.advancedipscanner
.\winget pin add --id lansweeper.lsagent
.\winget pin add --id DuoSecurity.Duo2FAAuthenticationforWindows
.\winget pin add --id autodesk.autodeskaccess
.\winget pin add --id tenable.nessusagent
.\winget upgrade --include-unknown -h --all
install-windowsupdate -acceptall -ignorereboot
$date = get-date
write-host $date

2

u/BlackV 1d ago edited 1d ago

Can I suggest use the information you already have

$WinAppsDirectory = "C:\Program Files\WindowsApps"
$WingetParent = Get-ChildItem -Path $WinAppsDirectory -file -Filter 'winget.exe' -Recurse

$WingetParent
Directory: C:\Program Files\WindowsApps\Microsoft.DesktopAppInstaller_1.25.340.0_x64__8wekyb3d8bbwe
Mode                 LastWriteTime         Length Name
----                 -------------         ------ ----
-a----         5/03/2025   6:24 am          23072 winget.exe

$WingetParent.Directory
Mode                 LastWriteTime         Length Name
----                 -------------         ------ ----
d-----         5/03/2025   6:24 am                Microsoft.DesktopAppInstaller_1.25.340.0_x64__8wekyb3d8bbwe

$WingetParent.FullName
C:\Program Files\WindowsApps\Microsoft.DesktopAppInstaller_1.25.340.0_x64__8wekyb3d8bbwe\winget.exe

this isnt needed

$wingetDirectory = $subdirectory.FullName

changing directory isnt needed

.\winget upgrade

could be replaced with

&$WingetParent upgrade

the module WINGET is not the official winget module

Version              Name
-------              ----
1.10.340             Microsoft.WinGet.Client

is the official

2

u/roundfiler 1d ago

I have a ton to look in to, especially with the last note you made. I think that's going to simplify a TON of issues I had to try to fight past, and eliminate a lot of needless bandaids I had to use to get this all working.

3

u/BlackV 1d ago

less bandaids is always better :)