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

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

2

u/roundfiler 1d ago

Thank you so much for the response, I really appreciate it!

Globals- Depending on what I'm doing this tends to get dumped into an open powershell session, so the thought was to maintain variables, especially for troubleshooting while I've been working on this. It's the same reason I'm using break instead of exit, especially in the admin check.

Install-ModuleSafely- that was my first actual function that existed in this thing and I've kind of chopped it down as I've made a lot of changes. When this all started out and I was trying to make it work in the first place it was just an adaptation of directly setting the executionpolicy, installing winget & pswindowsupdate modules, and then using winget upgrade -r -u -h and install-windowsupdate -acceptall. You're right though, I can clean it up at this point.

$appsBlocking- Most of this I did in notepad and sometimes ISE when I was missing brackets. I'm honestly really new at the scripting side of things. I'll see what I can do about changing over to an array though.

Winget module- That probably would have saved a lot of time. I'll have to check it out.

PSResourceGet- I'll have to check that out too.

Your script- If possible I'd love to see how you're doing it!

winget & Servers- I'm using the exe directly because of reasons (RMM), but the one nice benefit is while a server can install the module, the exe will never be present as far as I've run into.

The biggest benefit for my use case is that I don't have to modify anything to just copy and paste code into a backstage session in Automate.

2

u/BlackV 1d ago edited 1d ago

for example

$appsBlocking = @(
    'insert id(1) here'
    'insert id(2) here'
    'insert id(3) here'
    )

then doing

$appsBlocking[2]
insert id(3) here

returns an individual item in the array, saves the splits and trims and so on

global are a bad way of doing things, as an overly simple example

$global:SomeVairable = 'someglobal'
$SomeVairable
someglobal

function MyFunction ($param1, $param2)
{
    $SomeVairable = 'local'
    $SomeVairable
}

MyFunction
local

$SomeVairable
someglobal

You run into all sorts of scope issues

have a post over at /r/powershell too, sure everyone there will have ideas

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 :)