Public/Update-Everything.ps1

function Update-Everything {

    <#
    .SYNOPSIS
        One-shot maintenance script that updates everything on a Windows laptop
        through the package managers and update channels it can find.
 
    .DESCRIPTION
        Each update channel runs as an isolated step, so one failure does not stop
        the rest. Every step writes its own log, and the run ends with a summary
        table and a result object.
 
        Steps cover winget and the Microsoft Store, Windows Update through
        PSWindowsUpdate, Microsoft 365 Apps, Defender signatures, PowerShell
        modules and help, Chocolatey, Scoop, Python, uv, pip, pipx, npm, Deno,
        Bun, pnpm, rustup, cargo binaries, Go binaries, dotnet, GitHub CLI
        extensions, the Azure and Google Cloud CLIs, conda, the WSL kernel,
        PowerShell 7 itself and the Windows Terminal default profile. The README
        lists what each runs.
 
        Presence of an executable is not proof a feature is installed, and package
        managers routinely return non-zero for "nothing to do", so steps that
        drive an external tool probe for it first, and each step decides which
        of its tool's exit codes are routine. Steps
        capture every stream (*>&1); only errors raised by PowerShell itself are
        counted, and those mark a step Warning rather than OK.
 
        FailedCount is returned rather than exited with, so calling this from a
        session does not end that session.
 
        Policy note: winget runs here with --accept-package-agreements, which
        accepts vendor licence agreements on your behalf for every package it
        touches. That is deliberate for unattended runs, but it is a real consent
        decision -- drop the flag if you would rather approve each one by hand.
 
    .PARAMETER IncludeWindowsUpdate
        Install pending updates via the PSWindowsUpdate module, scanning Microsoft
        Update so Office and other Microsoft products are covered alongside the OS
        and drivers. Default: $true. Requires admin and may require a reboot.
 
    .PARAMETER IncludePowerShell7
        Install PowerShell 7 if missing, or upgrade it to the latest release.
        Default: $true. A machine-wide MSI install/upgrade requires admin.
 
    .PARAMETER SetPwshTerminalDefault
        Point Windows Terminal's default profile at PowerShell 7. Default: $true.
        Per-user change; silently skipped if Windows Terminal is not installed.
 
    .PARAMETER IncludePowerShellModules
        Update every installed PowerShell module. Default: $true.
 
        Set it to $false for a run that should leave modules alone, which is
        usually because one is pinned to a version something else depends on.
        The step is reported as skipped rather than dropped.
 
        -ExcludeTag PowerShell is the broader hammer: it also skips the gallery
        trust, the gallery tooling, help, PowerShell 7 and the Terminal default.
 
    .PARAMETER AutoReboot
        Allow Windows Update to reboot automatically if required. Default: $false
 
    .PARAMETER IncludePrerelease
        Include prerelease/preview builds where the tool supports it. Default: $false.
        Currently applies to PowerShell module updates (Update-PSResource -Prerelease
        / Update-Module -AllowPrerelease).
 
    .PARAMETER UpdateGlobalNpm
        Update global npm packages in addition to npm itself. Default: $false
 
    .PARAMETER SkipElevation
        Do not relaunch elevated. Steps marked as requiring administrator are
        reported as skipped with that reason rather than failing on a permissions
        error. Useful for unattended/standard-user runs.
 
    .PARAMETER AllowInstall
        Which missing components this run may install for the first time. Updating
        something already present never needs approval; installing something new
        always does.
 
        This is an update script, not an installer. Left unset, nothing new is
        installed: an interactive run asks before each one, and a non-interactive
        run (a scheduled task) declines and reports the step as skipped, because
        there is nobody there to ask.
 
        Accepts any of:
          All approve everything below
          PowerShell7 install PowerShell 7 via winget (machine-wide MSI)
          PSWindowsUpdate install the PSWindowsUpdate module (AllUsers scope)
          NuGetProvider install or refresh the NuGet package provider
                           (the first bootstrap is CurrentUser; a refresh is
                           AllUsers when elevated, otherwise CurrentUser)
          BurntToast install the BurntToast module for -Notify (CurrentUser)
          PowerShellGet replace the PowerShellGet 1.0.0.1 Windows ships with
                           2.x, which can accept module licenses and can see
                           modules installed by newer versions (AllUsers when
                           elevated, otherwise CurrentUser)
          PSResourceGet install Microsoft.PowerShell.PSResourceGet, the
                           current gallery client. Changes which client every
                           later module update uses (AllUsers when elevated,
                           otherwise CurrentUser)
 
        NuGetProvider also covers refreshing a provider that is already present.
        There is no update command for a package provider, so moving one forward
        means installing the newer version, and that asks.
 
        A scheduled task cannot prompt, so pass the approvals it should have:
            -AllowInstall PSWindowsUpdate,BurntToast
 
    .PARAMETER PromptBeforeRun
        Pause before doing anything and offer a way out: run now, skip this run, or
        wait -DelayMinutes and then run. Intended for a scheduled run that may land
        while you are in the middle of something.
 
        The prompt takes the default (run now) after -PromptTimeoutSeconds, so an
        unattended machine is never left waiting on an answer nobody is there to
        give. If the run cannot prompt at all -- a hidden window, redirected input --
        it says so and starts immediately rather than blocking.
 
    .PARAMETER PromptTimeoutSeconds
        How long the -PromptBeforeRun prompt waits before starting the run anyway.
        Default: 60.
 
    .PARAMETER DelayMinutes
        How long the "wait, then run" answer waits. Default: 60.
 
    .PARAMETER Notify
        Show Windows toast notifications when the run finishes: a summary, and a
        separate urgent notification when a restart is required. Off by default,
        because an interactive run already prints everything to the console. Meant
        for scheduled runs, where the summary would otherwise go unseen.
 
        Requires the BurntToast module (https://github.com/Windos/BurntToast) and an
        interactive desktop session. Either being absent degrades to no
        notifications; it never fails the run.
 
        That check happens before the first update step, not at the end, so the
        warning lands at the top of the transcript rather than after a long run.
        The reason is repeated in the closing summary, because by then the original
        warning has scrolled well out of sight.
 
    .PARAMETER UpdateSelf
        Update this module, and run nothing else: a shortcut for Install-Module
        UpdateEverything -Force, or for the Main installer with
        -UpdateSelfSource Main. Default: $false. -Tag and -ExcludeTag are
        ignored for the run, and elevation is never requested, because a
        CurrentUser install needs none.
 
        The new version takes effect on the *next* run. This module is already
        loaded by the time the step runs: the files on disk are replaced, and
        the functions executing now stay on the code already in memory.
 
        Off by default. From Main it fetches and runs an installer from a
        branch, which is a supply-chain decision rather than a routine update,
        and a scheduled task that quietly followed main would be making that
        decision on every run.
 
    .PARAMETER Tag
        Run only the steps carrying one of these tags. Everything else is
        reported as skipped with that reason, so the summary still accounts for
        every step.
 
        A step carries one or more of: Windows, Microsoft, PowerShell,
        PackageManager, Python, Node, DotNet, Rust, Go, Cloud, Git, Self,
        Inventory.
 
            Update-Everything -Tag Python
            Update-Everything -Tag Inventory report what is installed, update nothing
 
    .PARAMETER ExcludeTag
        Run everything except the steps carrying one of these tags. Takes the
        same values as -Tag.
 
            Update-Everything -ExcludeTag Python
 
        Both may be given at once, and exclusion wins. That makes two scheduled
        tasks able to divide the work between them: one that updates everything
        but Python daily, and one that updates only Python monthly, for a
        toolchain something else depends on being held steady.
 
    .PARAMETER UpdateSelfSource
        Where -UpdateSelf gets this module from. Default: Gallery.
 
          Gallery the newest published release, through Update-Module
          Main the development head, by fetching Install.ps1 from GitHub
 
        Gallery is the default because a published release is what most people
        want. Main is for tracking a fix that has landed but not shipped, and
        for testing.
 
        Gallery cannot move a copy that PowerShellGet did not install -- one put
        there by the GitHub installer, for instance. The run says so and names
        the way forward rather than failing.
 
    .PARAMETER LogRetentionDays
        Delete logs and settings.json backups in the log directory older than this
        many days. Default: 30. Set to 0 to keep everything.
 
    .OUTPUTS
        An object describing the run:
 
          Ran $false when nothing was attempted, for example because
                         the session could not become Administrator
          Reason why it did not run, when Ran is $false
          Elevated whether the run had administrator rights
          Steps one record per step, with Status, Seconds and Log
          OkCount, WarningCount, SkippedCount, FailedCount
          RebootPending whether Windows is waiting on a restart
          RebootReason what is holding the restart
          LogDirectory where the logs went
          MainLog the transcript for this run
 
        FailedCount is what a scheduled task turns into an exit code. Warning
        steps completed and do not count.
 
    .NOTES
        Elevation is checked before it is requested. A standard user gets a clear
        explanation and a result with Ran = $false rather than a UAC prompt that
        cannot succeed. Run with -SkipElevation to perform the steps that do not
        need admin.
 
        Execution policy: the elevated relaunch passes -ExecutionPolicy Bypass.
        Importing the module obeys whatever policy is in force, so on a machine
        set to AllSigned or Restricted, start the session with:
            pwsh -NoProfile -ExecutionPolicy Bypass -Command "Import-Module UpdateEverything; Update-Everything"
    #>

    [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSAvoidUsingWriteHost', '', Justification = 'Write-Host is the user interface of a console maintenance tool. Its output is progress a person watches, not data a caller consumes, and the summary uses colour to separate failures from noise.')]
    [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseShouldProcessForStateChangingFunctions', '', Justification = 'Every action that changes the machine is already gated: -PromptBeforeRun offers a way out, -AllowInstall gates first-time installs, and each step reports what it did. A -WhatIf that ran nothing would duplicate -SkipElevation without adding safety.')]
    [CmdletBinding()]
    [OutputType([pscustomobject])]
    param(
        [bool]   $IncludeWindowsUpdate   = $true,
        [bool]   $IncludePowerShell7     = $true,
        [bool]   $SetPwshTerminalDefault = $true,
        [bool]   $IncludePowerShellModules = $true,
        [switch] $AutoReboot,
        [switch] $IncludePrerelease,
        [switch] $UpdateGlobalNpm,
        [switch] $SkipElevation,
        [switch] $PromptBeforeRun,
        [ValidateRange(5, 3600)]
        [int]    $PromptTimeoutSeconds    = 60,
        [ValidateRange(1, 1440)]
        [int]    $DelayMinutes            = 60,
        [switch] $Notify,
        [ValidateSet('All', 'PowerShell7', 'PSWindowsUpdate', 'NuGetProvider', 'BurntToast', 'PowerShellGet', 'PSResourceGet')]
        [string[]] $AllowInstall = @(),
        [ValidateSet('Windows', 'Microsoft', 'PowerShell', 'PackageManager', 'Python', 'Node', 'DotNet', 'Rust', 'Go', 'Cloud', 'Git', 'Self', 'Inventory')]
        [string[]] $Tag = @(),
        [ValidateSet('Windows', 'Microsoft', 'PowerShell', 'PackageManager', 'Python', 'Node', 'DotNet', 'Rust', 'Go', 'Cloud', 'Git', 'Self', 'Inventory')]
        [string[]] $ExcludeTag = @(),
        [ValidateRange(0, 3650)]
        [int]    $LogRetentionDays       = 30,
        [switch] $UpdateSelf,
        [ValidateSet('Gallery', 'Main')]
        [string] $UpdateSelfSource = 'Gallery'
    )

    # State shared with the private helpers is assigned with $script:. Inside a
    # module a plain assignment is function-scoped, so Invoke-Step would read an
    # unset $script:logDir and fail on every step. TLS is left to the OS.
    $originalOutputEncoding = Initialize-ConsoleEncoding

    # ---------------------------------------------------------------------------
    # 1. Logging
    #
    # Set up before the elevation decision, so every way this run can decline to
    # start still leaves a log behind.
    # ---------------------------------------------------------------------------
    $script:logDir = Get-UpdateLogDirectory

    # One stamp shared by the transcript and every step log, so a single run's files
    # sort together and can be pruned as a unit.
    $script:runStamp = '{0:yyyyMMdd-HHmmss}' -f (Get-Date)
    $mainLog  = Join-Path $logDir "Update-Everything-$runStamp.log"

    # Asked before pruning and before the transcript starts: pruning can empty the
    # directory, and the transcript would find its own log.
    $isFirstRun = -not @(Get-ChildItem -LiteralPath $logDir -Filter 'Update-Everything-*.log' `
        -File -ErrorAction SilentlyContinue).Count

    # Step logs rotate per run rather than being appended to, so old ones are
    # pruned by age, as are stale Windows Terminal settings backups.
    if ($LogRetentionDays -gt 0) {
        $cutoff = (Get-Date).AddDays(-$LogRetentionDays)
        Get-ChildItem -LiteralPath $logDir -File -ErrorAction SilentlyContinue |
            Where-Object { ($_.Name -like '*.log' -or $_.Name -like '*.json.bak') -and $_.LastWriteTime -lt $cutoff } |
            Remove-Item -Force -ErrorAction SilentlyContinue
    }

    # A transcript is a nice-to-have: it fails if one is already running or the
    # path is not writable, and that must not kill the run.
    $transcriptRunning = $false
    try {
        Start-Transcript -Path $mainLog -Append -ErrorAction Stop | Out-Null
        $transcriptRunning = $true
    } catch {
        Write-Warning "Transcript unavailable ($($_.Exception.Message)); per-step logs are unaffected."
    }

    # -UpdateSelf is a shortcut: update this module and run nothing else. The
    # self step needs no elevation -- the gallery path is Install-Module -Scope
    # CurrentUser and the Main installer writes to the user's modules -- so the
    # run skips the UAC prompt and narrows the tag filter to Self.
    if ($UpdateSelf) {
        if ($Tag.Count -or $ExcludeTag.Count) {
            Write-Warning '-UpdateSelf updates only this module; -Tag and -ExcludeTag are ignored for this run.'
        }
        $Tag = @('Self')
        $ExcludeTag = @()
        $SkipElevation = $true
    }

    $script:isAdmin = Test-IsAdministrator
    if (-not $isAdmin -and -not $SkipElevation) {
        # Ask whether elevation is possible before asking for it, or a standard user
        # gets a UAC prompt they can never satisfy, reported as their refusal.
        $elevation = Test-ElevationCapability

        # Said before the attempt, so a refused prompt reads as the warned-about thing.
        if ($elevation.Caution) { Write-Warning $elevation.Caution }

        if (-not $elevation.CanElevate) {
            Write-Warning "Cannot run elevated: $($elevation.Reason)"
            Write-Warning 'Nothing has been changed. Re-run with -SkipElevation to run the steps that do not need administrator rights.'

            if ($transcriptRunning) { try { Stop-Transcript | Out-Null } catch { Write-Verbose "Transcript already stopped." } }
            try { [Console]::OutputEncoding = $originalOutputEncoding } catch { Write-Verbose "Could not restore the console encoding." }

            return (New-UpdateEverythingResult -Ran $false -Reason $elevation.Reason `
                -LogDirectory $logDir -MainLog $mainLog)
        }

        # Close the transcript before handing off and reopen it afterwards. The child
        # starts its own; stamps have one-second resolution, so two open on the same
        # path would have -Append report success and lose the child's content.
        #
        # The note is written before the close: if the child hangs, this transcript is
        # all there is, and it has to name the log the real work went to.
        $childNote = "Handing off to an elevated run. Its transcript is a separate Update-Everything-*.log in $logDir, stamped when it starts."
        Write-Host $childNote -ForegroundColor Yellow

        if ($transcriptRunning) {
            try { Stop-Transcript | Out-Null } catch { Write-Verbose "Transcript already stopped." }
            $transcriptRunning = $false
        }

        # A module function must not kill its caller's session, so the child's outcome
        # comes back as a result rather than an exit.
        $child = Invoke-SelfElevation -BoundParameters $PSBoundParameters

        try {
            Start-Transcript -Path $mainLog -Append -ErrorAction Stop | Out-Null
            $transcriptRunning = $true
        } catch {
            Write-Verbose "Could not reopen the transcript to record the elevated run's outcome."
        }
        Write-Host "Elevated run finished with exit code $child." -ForegroundColor Green

        if ($transcriptRunning) { try { Stop-Transcript | Out-Null } catch { Write-Verbose "Transcript already stopped." } }
        try { [Console]::OutputEncoding = $originalOutputEncoding } catch { Write-Verbose "Could not restore the console encoding." }

        return (New-UpdateEverythingResult -Ran $false -Elevated `
            -Reason "Re-ran elevated in a separate window, which finished with exit code $child." `
            -FailedCount ([int] $child) `
            -LogDirectory $logDir -MainLog $mainLog)
    }

    if (-not $isAdmin) {
        Write-Warning 'Running without administrator rights. Steps that require admin will be skipped and listed in the summary.'
    }

    # Offer a way out before anything is touched: after the transcript starts so the
    # decision is on record, before the install checks so skipping costs nothing.
    if ($PromptBeforeRun) {
        if (-not (Test-CanPrompt)) {
            # A hidden window or redirected input cannot answer, and starting
            # anyway beats blocking until the task time limit kills the run.
            Write-Warning 'PromptBeforeRun was requested, but this run cannot prompt (no interactive console, or input is redirected). Starting immediately.'
        } else {
            switch (Request-RunDecision -TimeoutSeconds $PromptTimeoutSeconds -DelayMinutes $DelayMinutes) {
                'Skip' {
                    Write-Host 'Skipped at your request. Nothing was changed.' -ForegroundColor Yellow
                    if ($transcriptRunning) { try { Stop-Transcript | Out-Null } catch { Write-Verbose "Transcript already stopped." } }
                    try { [Console]::OutputEncoding = $originalOutputEncoding } catch { Write-Verbose "Could not restore the console encoding." }
                    # Ran $false, not a failure: the next scheduled run stands.
                    return (New-UpdateEverythingResult -Ran $false -Elevated:$isAdmin `
                        -Reason 'Skipped at the pre-run prompt.' -LogDirectory $logDir -MainLog $mainLog)
                }
                'Delay' {
                    Write-Host "Waiting ${DelayMinutes} minute(s) before starting. Close this window to cancel." -ForegroundColor Yellow
                    Start-Sleep -Seconds ($DelayMinutes * 60)
                    Write-Host 'Resuming.' -ForegroundColor Green
                }
            }
        }
    }

    # Answers to install prompts, remembered for the run so a component is asked
    # about at most once.
    $script:InstallDecision = @{}

    # Resolved before any work starts, so a missing prerequisite is reported at the
    # top of the transcript and BurntToast is installed before it is needed.
    $script:NotificationsAvailable = $false
    $notificationStatus = $null
    if ($Notify) {
        $notificationStatus = Initialize-NotificationSupport -Approved $AllowInstall
        $script:NotificationsAvailable = $notificationStatus.Available
    }

    $script:Results = [System.Collections.Generic.List[object]]::new()

    # Read by Invoke-Step, which decides per step. Script scope for the same reason
    # as $script:isAdmin: a step action runs inside Invoke-Step, not here.
    $script:TagFilter        = $Tag
    $script:ExcludeTagFilter = $ExcludeTag

    if ($Tag -or $ExcludeTag) {
        $selection = @()
        if ($Tag)        { $selection += "only $($Tag -join ', ')" }
        if ($ExcludeTag) { $selection += "excluding $($ExcludeTag -join ', ')" }
        Write-Host "Step selection: $($selection -join ', '). Everything else is reported as skipped." -ForegroundColor Cyan
    }

    # winget exit codes that mean "nothing to do" rather than "failed".
    # 0x8A15002B (-1978335189) APPINSTALLER_CLI_ERROR_UPDATE_NOT_APPLICABLE
    # 0x8A150014 (-1978335212) APPINSTALLER_CLI_ERROR_NO_APPLICATIONS_FOUND
    $WingetNothingToDo = @(-1978335189, -1978335212)

    # The version that produced this log, so a transcript can be read against the
    # code that made it. The running module, not the highest installed.
    $script:RunningVersion = $MyInvocation.MyCommand.Module.Version

    Write-Host "Maintenance run started $(Get-Date) | Admin: $isAdmin | Main Log: $mainLog" -ForegroundColor Green
    if ($script:RunningVersion) {
        Write-Host "UpdateEverything $script:RunningVersion" -ForegroundColor Green
    }

    # ---------------------------------------------------------------------------
    # 1a. What this machine actually has
    # ---------------------------------------------------------------------------
    Invoke-Step -Name 'Inventory' -Tag 'Inventory' -Action {
        $inventory = @(Get-UpdateToolInventory)
        $present = @($inventory | Where-Object Present)
        $absent = @($inventory | Where-Object { -not $_.Present })

        Write-Output "$($present.Count) of $($inventory.Count) tools present."
        Write-Output ''

        # Aligned by hand to control the widths: the step pipeline renders
        # Format-Table records through Out-String -Stream, but with widths of its
        # own choosing.
        $nameWidth = 0
        $ownerWidth = 0
        foreach ($tool in $present) {
            if ($tool.Name.Length -gt $nameWidth) { $nameWidth = $tool.Name.Length }
            if ("$($tool.Owner)".Length -gt $ownerWidth) { $ownerWidth = "$($tool.Owner)".Length }
        }
        foreach ($tool in $present) {
            Write-Output (" {0,-$nameWidth} {1,-$ownerWidth} {2}" -f $tool.Name, $tool.Owner, $tool.Version)
        }

        if ($absent.Count) {
            Write-Output ''
            Write-Output "Not installed: $(($absent.Name | Sort-Object) -join ', ')"
            Write-Output 'Their steps report Skipped, which is the expected result rather than a fault.'
        }

        # Compared against the gallery here rather than in the banner: it costs a
        # network call a machine with no network should not pay before the run
        # starts, and -ExcludeTag Inventory turns it off.
        if ($script:RunningVersion) {
            Write-Output ''
            Write-Output (Format-SelfVersionStatus -Running $script:RunningVersion `
                -Status (Get-GalleryModuleStatus -Name 'UpdateEverything'))
        }

        # More than one executable of a name on PATH means the first on PATH runs
        # and the rest are updated by nobody, or by a manager that does not own
        # the copy in use. Places preserves that resolution order.
        $duplicated = @($present | Where-Object { $_.Copies -gt 1 })
        if ($duplicated.Count) {
            Write-Output ''
            foreach ($tool in $duplicated) {
                Write-Warning "$($tool.Name) is installed in $($tool.Copies) places; the first is the one that runs: $($tool.Places -join '; ')"
            }
        }
    }

    # ---------------------------------------------------------------------------
    # 1b. The module itself
    # ---------------------------------------------------------------------------
    if ($UpdateSelf) {
        Invoke-Step -Name 'UpdateEverything (self)' -Tag 'Self' -Action {
            if ($UpdateSelfSource -eq 'Gallery') {
                $status = Get-GalleryModuleStatus -Name 'UpdateEverything'

                if (-not $status.Available) {
                    Write-Output "UpdateEverything $($status.Installed) is installed; the gallery could not be asked about it."
                } elseif (-not $status.Updatable) {
                    # A copy the GitHub installer put there was not installed by
                    # PowerShellGet, so Update-Module refuses it. -UpdateSelfSource
                    # Main is the matching path.
                    Write-Output "UpdateEverything $($status.Installed) was not installed from the gallery, so Update-Module cannot move it. The published version is $($status.Available)."
                    Write-Output 'Use -UpdateSelfSource Main to track the branch it came from, or Install-Module UpdateEverything -Force to switch to the gallery copy.'
                } elseif (-not $status.NeedsUpdate) {
                    Write-Output "UpdateEverything $($status.Installed) is the newest published release."
                } else {
                    Write-Output "UpdateEverything $($status.Installed) -> $($status.Available)..."
                    Update-Module -Name 'UpdateEverything' -Force -Confirm:$false -ErrorAction Stop
                    Write-Output 'Updated. The new version loads on the next run; this one continues on the code already in memory.'
                }
                return
            }

            # Main. Reinstalls regardless of version: the module version does not move
            # with every commit, so a version check cannot decide this.
            $uri = 'https://raw.githubusercontent.com/briankronberg/UpdateEverything/main/Install.ps1'
            $installer = Join-Path ([System.IO.Path]::GetTempPath()) "Install-UpdateEverything-$script:runStamp.ps1"

            try {
                Write-Output "Fetching the installer from $uri"
                Invoke-WebRequest -Uri $uri -OutFile $installer -UseBasicParsing -ErrorAction Stop
                Unblock-File -LiteralPath $installer -ErrorAction SilentlyContinue

                # A child process, and powershell rather than the current host: the
                # installer imports the module when it finishes, which here would
                # re-import files replaced underneath it. powershell.exe is always
                # present; pwsh is not.
                & powershell.exe -NoProfile -ExecutionPolicy Bypass -File $installer -Force
                if ($LASTEXITCODE -ne 0) {
                    throw "The installer exited with code $LASTEXITCODE."
                }

                # This module is already loaded, so the functions running now stay on
                # the code in memory however new the files on disk are.
                Write-Output 'Updated. The new version loads on the next run; this one continues on the code already in memory.'
            } finally {
                Remove-Item -LiteralPath $installer -Force -ErrorAction SilentlyContinue
            }
        }
    } else {
        Add-SkippedStep -Name 'UpdateEverything (self)' -Reason 'not requested (-UpdateSelf)'
    }

    # ---------------------------------------------------------------------------
    # 2. winget (apps from winget + Microsoft Store sources)
    # ---------------------------------------------------------------------------
    Invoke-Step -Name 'winget self-update' -RequiresCommand 'winget' -Tag 'Windows', 'PackageManager' -Action {
        # Refresh the indexes first. A stale index makes upgrade --all quietly miss
        # packages rather than fail loudly.
        winget source update --disable-interactivity
        if ($LASTEXITCODE -ne 0) {
            Write-Output "winget source update returned $LASTEXITCODE; continuing (a stale index is not fatal)."
            $global:LASTEXITCODE = 0
        }

        # 'upgrade --all' does not reliably update App Installer (winget itself),
        # so ask for it by ID.
        winget upgrade --id Microsoft.AppInstaller --exact --source winget --silent `
            --accept-source-agreements --accept-package-agreements --disable-interactivity
        if ($WingetNothingToDo -contains $LASTEXITCODE) {
            Write-Output 'App Installer (winget) is already at the latest version.'
            $global:LASTEXITCODE = 0
        }
    }

    Invoke-Step -Name 'winget (all sources)' -RequiresCommand 'winget' -Tag 'Windows', 'PackageManager' -Action {
        # Passed through in one pass rather than echoed afterwards, because a winget
        # upgrade runs for minutes and the console should show it happening. The
        # table winget prints first is the "before" list.
        $captured = [System.Collections.Generic.List[string]]::new()

        winget upgrade --all --include-unknown --silent `
            --accept-source-agreements --accept-package-agreements --disable-interactivity 2>&1 |
            ForEach-Object { $captured.Add("$_"); $_ }

        $code = $LASTEXITCODE
        $global:LASTEXITCODE = 0

        # upgrade --all returns non-zero for routine reasons: nothing applicable, or a
        # subset (pinned, Store-sourced, running) failing while the rest succeed.
        # Report the code rather than failing the run; Write-Error marks it Warning.
        if ($code -ne 0 -and $WingetNothingToDo -notcontains $code) {

            # Which ones, and whether anything can be done. The exit code says only
            # that something did not upgrade.
            $text = $captured -join "`n"
            $before = @(Get-WingetUpgradeTable -Text $text)
            $after = @(Get-WingetUpgradeTable -Text (
                winget upgrade --include-unknown --disable-interactivity 2>&1 | Out-String))
            $global:LASTEXITCODE = 0

            $leftover = @(Get-WingetLeftover -Before $before -After $after -Output $text)

            $attempted = @($leftover | Where-Object { $_.Attempted })
            $skipped   = @($leftover | Where-Object { -not $_.Attempted -and $_.Listed })
            $appeared  = @($leftover | Where-Object { -not $_.Attempted -and -not $_.Listed })

            if ($attempted.Count) {
                Write-Output ''
                Write-Output 'Still out of date after this run:'
                foreach ($package in $attempted) {
                    Write-Output (" {0} {1} -> {2} ({3})" -f $package.Id, $package.Version, $package.Available, $package.Reason)
                }
            }

            # Information, not a problem: these do not change until the vendor ships
            # something applicable, and a person who reads them as failures learns
            # to skim past the ones that are.
            if ($skipped.Count) {
                Write-Output ''
                Write-Output 'Not upgradable on this machine, and expected to stay that way:'
                foreach ($package in $skipped) {
                    Write-Output (" {0} {1} -> {2} ({3})" -f $package.Id, $package.Version, $package.Available, $package.Reason)
                }
            }

            # Listed for the first time by the closing table: nothing was tried
            # and nothing is known to block it, so the next run picks it up.
            if ($appeared.Count) {
                Write-Output ''
                Write-Output 'Newly listed during this run; the next run picks these up:'
                foreach ($package in $appeared) {
                    Write-Output (" {0} {1} -> {2}" -f $package.Id, $package.Version, $package.Available)
                }
            }

            # Raised only for something actually tried. A package winget declined is
            # not a fault of this run.
            if ($attempted.Count) {
                Write-Error ('winget could not upgrade {0} package(s): {1}. Exit code {2} (0x{2:X8}).' -f
                    $attempted.Count, (($attempted.Id) -join ', '), $code)
            } elseif (-not $leftover.Count) {
                Write-Error ('winget upgrade --all returned {0} (0x{0:X8}); one or more packages may not have upgraded.' -f $code)
            } else {
                Write-Output ''
                Write-Output 'Nothing this run could have upgraded was left behind.'
            }
        } elseif ($code -ne 0) {
            Write-Output 'winget reports nothing left to upgrade.'
        }
    }

    # ---------------------------------------------------------------------------
    # A manager runs before the tools it may own. The uv step asks
    # Get-ToolInstallSource and skips a managed copy, so for it this order is
    # presentation rather than protection -- but the sequence should read the
    # way the dependency runs.
    # ---------------------------------------------------------------------------
    # 2b. Chocolatey and Scoop, before the toolchains they may own
    # ---------------------------------------------------------------------------
    # 1641 and 3010 are the MSI "reboot initiated" / "reboot required" codes, which
    # Chocolatey passes straight through on an otherwise successful upgrade.
    Invoke-Step -Name 'Chocolatey' -RequiresCommand 'choco' -AllowedExitCodes 1641, 3010 -Tag 'PackageManager' -Action {
        choco upgrade all -y
    }

    Invoke-Step -Name 'Scoop' -RequiresCommand 'scoop' -Tag 'PackageManager' -Action {
        # Three phases independently: a failure in one should not hide the others.
        $phases = @(
            @{ Label = 'scoop update (scoop itself + buckets)'; Args = @('update') },
            @{ Label = 'scoop update * (installed apps)';       Args = @('update', '*') },
            @{ Label = 'scoop cleanup * (old versions)';        Args = @('cleanup', '*') }
        )
        foreach ($phase in $phases) {
            Write-Output "-> $($phase.Label)"
            $phaseArgs = $phase.Args
            & scoop @phaseArgs
            if ($LASTEXITCODE -ne 0) {
                Write-Warning "$($phase.Label) returned $LASTEXITCODE; continuing with the next phase."
                $global:LASTEXITCODE = 0
            }
        }
    }

    # ---------------------------------------------------------------------------
    # 2c. PowerShell 7 (install if missing, else upgrade to the latest release)
    # ---------------------------------------------------------------------------
    if ($IncludePowerShell7) {
        Invoke-Step -Name 'PowerShell 7 (latest)' -RequiresCommand 'winget' -RequiresAdmin -Tag 'PowerShell', 'Windows' -Action {
            $id  = 'Microsoft.PowerShell'
            $exe = Join-Path $env:ProgramFiles 'PowerShell\7\pwsh.exe'

            # By exit code, not text: winget localises output and truncates the Id
            # column to the console width. 0 = found.
            winget list --id $id --exact --accept-source-agreements --disable-interactivity
            $isInstalled = ($LASTEXITCODE -eq 0)
            $global:LASTEXITCODE = 0

            # Second opinion: the MSI drops pwsh.exe here whatever winget believes.
            if (-not $isInstalled -and (Test-Path -LiteralPath $exe)) {
                Write-Output 'winget does not list PowerShell 7, but pwsh.exe is present; treating as installed.'
                $isInstalled = $true
            }

            if (-not $isInstalled) {
                if (-not (Approve-Install -Component 'PowerShell7' -Approved $AllowInstall `
                        -Description 'PowerShell 7 is not installed. This would install it machine-wide via winget (MSI package, into C:\Program Files\PowerShell\7).')) {
                    Stop-StepAsSkipped -Reason 'installing PowerShell 7 was not approved'
                }

                Write-Output 'PowerShell 7 not detected; installing the MSI package via winget...'
                # --installer-type wix forces the MSI (the Microsoft.PowerShell winget
                # package defaults to MSIX from PowerShell 7.6) so it installs to
                # C:\Program Files\PowerShell\7 where Terminal expects it.
                winget install --id $id --exact --source winget --installer-type wix `
                    --accept-source-agreements --accept-package-agreements --disable-interactivity
                if ($LASTEXITCODE -ne 0) { throw "winget install returned $LASTEXITCODE" }
            } else {
                Write-Output 'PowerShell 7 present; checking for an upgrade...'
                winget upgrade --id $id --exact --include-unknown `
                    --accept-source-agreements --accept-package-agreements --disable-interactivity
                if ($WingetNothingToDo -contains $LASTEXITCODE) {
                    Write-Output 'PowerShell 7 is already at the latest version.'
                    $global:LASTEXITCODE = 0
                } elseif ($LASTEXITCODE -ne 0) {
                    throw "winget upgrade returned $LASTEXITCODE"
                }
            }

            if (Test-Path -LiteralPath $exe) {
                $reported = & $exe -NoProfile -Command '$PSVersionTable.PSVersion.ToString()'
                if ($LASTEXITCODE -ne 0) { $global:LASTEXITCODE = 0 }
                Write-Output "Installed pwsh version: $reported"
            } else {
                # The upgrade branch cannot convert an already-packaged PowerShell, so
                # say what it costs. An MSIX pwsh works for everything except elevating
                # and being named in a scheduled task, which this module needs.
                Write-Output "Note: $exe not found, so PowerShell 7 here is the MSIX package. It runs normally, but Windows will not elevate it and its path changes at every update, so scheduled tasks cannot rely on it."
                Write-Output 'To switch: winget install --id Microsoft.PowerShell --exact --source winget --installer-type wix'
            }
        }
    } else {
        Add-SkippedStep -Name 'PowerShell 7 (latest)'
    }

    # ---------------------------------------------------------------------------
    # 2d. Microsoft 365 Apps (click-to-run)
    # ---------------------------------------------------------------------------
    Invoke-Step -Name 'Microsoft 365 Apps' -Tag 'Microsoft' -Action {
        $roots = @($env:ProgramFiles, ${env:ProgramFiles(x86)}) | Where-Object { $_ }
        # Indexed rather than Select-Object -First 1, which stops the upstream pipeline
        # and has *>&1 record "TerminatingError(): The pipeline has been stopped."
        $found = @($roots |
            ForEach-Object { Join-Path $_ 'Common Files\Microsoft Shared\ClickToRun\OfficeC2RClient.exe' } |
            Where-Object { Test-Path -LiteralPath $_ })
        $c2r = if ($found.Count) { $found[0] } else { $null }

        if (-not $c2r) {
            Stop-StepAsSkipped -Reason 'OfficeC2RClient.exe is not present, so there is no click-to-run Office install'
        }

        # The "Update Now" button without the prompts. C2R hands the work to its
        # background service and returns, so exit 0 means requested, not applied.
        & $c2r /update user updatepromptuser=false displaylevel=false
        if ($LASTEXITCODE -ne 0) {
            Write-Output "OfficeC2RClient returned $LASTEXITCODE; the update request may not have been accepted."
            $global:LASTEXITCODE = 0
        }
        Write-Output "Requested a Microsoft 365 Apps update via $c2r (applied in the background)."
    }

    # ---------------------------------------------------------------------------
    # 3. PowerShell repository trust, modules + help
    # ---------------------------------------------------------------------------
    # PSGallery ships Untrusted, so every module install or update stops on the
    # "You are installing the modules from an untrusted repository" prompt, which
    # -ErrorAction SilentlyContinue does not suppress. Trust is stored
    # per-user under LOCALAPPDATA, and UAC keeps the same profile, so setting it
    # once here covers both the elevated and non-elevated run.
    Invoke-Step -Name 'Trust PSGallery' -Tag 'PowerShell' -Action {
        # PowerShellGet v2 pulls the NuGet provider on first use and prompts for it.
        if (-not (Get-PackageProvider -Name NuGet -ErrorAction SilentlyContinue)) {
            if (-not (Approve-Install -Component 'NuGetProvider' -Approved $AllowInstall `
                    -Description 'The NuGet package provider is missing. PowerShellGet needs it to reach the PowerShell Gallery. This would install it for the current user only.')) {
                Stop-StepAsSkipped -Reason 'installing the NuGet provider was not approved'
            }

            Write-Output 'Bootstrapping NuGet package provider...'
            $null = Install-PackageProvider -Name NuGet -MinimumVersion 2.8.5.201 `
                -Force -Scope CurrentUser -ErrorAction Stop
        }

        # PSResourceGet (v3) - backs Update-PSResource below
        if (Get-Command Set-PSResourceRepository -ErrorAction SilentlyContinue) {
            $repo = Get-PSResourceRepository -Name PSGallery -ErrorAction SilentlyContinue
            if (-not $repo) {
                Register-PSResourceRepository -PSGallery -Trusted -ErrorAction Stop
                Write-Output 'Registered PSGallery (PSResourceGet) as Trusted.'
            } elseif (-not $repo.Trusted) {
                Set-PSResourceRepository -Name PSGallery -Trusted -Confirm:$false -ErrorAction Stop
                Write-Output 'PSGallery (PSResourceGet) set to Trusted.'
            } else {
                Write-Output 'PSGallery (PSResourceGet) already Trusted.'
            }
        }

        # PowerShellGet (v2) - backs Update-Module and Install-Module PSWindowsUpdate
        if (Get-Command Set-PSRepository -ErrorAction SilentlyContinue) {
            $repo2 = Get-PSRepository -Name PSGallery -ErrorAction SilentlyContinue
            if ($repo2 -and $repo2.InstallationPolicy -ne 'Trusted') {
                Set-PSRepository -Name PSGallery -InstallationPolicy Trusted -ErrorAction Stop
                Write-Output 'PSGallery (PowerShellGet v2) set to Trusted.'
            } else {
                Write-Output 'PSGallery (PowerShellGet v2) already Trusted.'
            }
        }

        # Trust only. Moving PowerShellGet forward belongs to the Gallery tooling step,
        # which handles the 1.0.0.1 Windows ships as one case of a general rule.
    }

    # The tooling every other gallery step runs on. Bootstrapping covers absence;
    # this step is what brings a present copy forward, without which a machine
    # could carry a years-old NuGet provider or a PowerShellGet 2.1 for as long
    # as it lived. Runs before PowerShell modules, which depends on it.
    Invoke-Step -Name 'Gallery tooling' -Tag 'PowerShell' -Action {
        # Not admin-gated, and -Scope AllUsers fails without elevation, so the
        # scope follows what the run actually has.
        $scope = if ($isAdmin) { 'AllUsers' } else { 'CurrentUser' }

        # --- NuGet package provider ------------------------------------------
        #
        # There is no Update-PackageProvider; Install-PackageProvider is the only way
        # forward, so the refresh is an install command fetching a binary assembly. It
        # asks under the same NuGetProvider component as the bootstrap, so one consent
        # covers both. Declining is not fatal: the provider present keeps working.
        $nuget = @(Get-PackageProvider -Name NuGet -ErrorAction SilentlyContinue |
            Sort-Object Version -Descending)
        if (-not $nuget.Count) {
            Write-Output 'No NuGet package provider is present; the Trust PSGallery step reports why.'
        } else {
            $current = $nuget[0].Version

            # SilentlyContinue and a check, not Stop and a catch. PowerShell 7
            # registers no provider bootstrap source, so this cannot be answered
            # there at all. Start-Transcript records a terminating error whether or
            # not it is caught, which would put "TerminatingError(Find-PackageProvider)"
            # in a step that went on to report the right thing. Test-PendingReboot
            # uses SilentlyContinue with Get-ItemProperty for the same reason.
            $newest = $null
            try {
                $candidates = @(Find-PackageProvider -Name NuGet -ErrorAction SilentlyContinue |
                    Sort-Object Version -Descending)
                if ($candidates.Count) { $newest = $candidates[0].Version }
            } catch {
                # A backstop: SilentlyContinue returns nothing for the absent-provider
                # case, so reaching here means a hard failure.
                Write-Verbose "Asking for the newest NuGet provider failed outright: $($_.Exception.Message)"
            }

            if (-not $newest) {
                # Not the same as up to date, and must not read like it.
                Write-Output "NuGet package provider $current is installed; no newer version could be looked up on this host."
            } elseif ($newest -gt $current) {
                if (Approve-Install -Component 'NuGetProvider' -Approved $AllowInstall `
                        -Description "The NuGet package provider is at $current and $newest is available. There is no update command for a provider, so this would install the newer one $(if ($isAdmin) { 'for all users on this machine' } else { 'for the current user only' }).") {
                    $null = Install-PackageProvider -Name NuGet -Force -Scope $scope -ErrorAction Stop
                    Write-Output "NuGet package provider $current -> $newest."
                }
            } else {
                Write-Output "NuGet package provider $current is current."
            }
        }

        # --- PowerShellGet and PSResourceGet ---------------------------------
        #
        # absent an install, and asks
        # present but not ours a shipped copy. Update-Module answers "Module
        # 'X' was not installed by using Install-Module,
        # so it cannot be updated"; moving it forward is
        # a side-by-side install, and asks
        # present and ours an update, and does not ask
        #
        # Windows PowerShell ships PowerShellGet 1.0.0.1 under Program Files and
        # Update-Module refuses it, so the version alone is not the test.
        foreach ($tool in @(
                @{ Module = 'PowerShellGet';                      Component = 'PowerShellGet' }
                @{ Module = 'Microsoft.PowerShell.PSResourceGet'; Component = 'PSResourceGet' }
            )) {
            $name = $tool.Module
            $status = Get-GalleryModuleStatus -Name $name

            if (-not $status.Available) {
                $known = if ($status.Installed) { "$name $($status.Installed) is installed" } else { "$name is not installed" }
                Write-Output "$known; the gallery could not be asked about it."
                continue
            }

            if ($status.Installed -and $status.Updatable -and -not $status.NeedsUpdate) {
                Write-Output "$name $($status.Installed) is current."
                continue
            }

            if ($status.Installed -and $status.Updatable) {
                Write-Output "$name $($status.Installed) -> $($status.Available)..."
                if (Get-Command Update-PSResource -ErrorAction SilentlyContinue) {
                    Update-PSResource -Name $name -TrustRepository -Confirm:$false -ErrorAction Stop
                } else {
                    Update-Module -Name $name -Force -Confirm:$false -ErrorAction Stop
                }

                # The same trap as -UpdateSelf: the module is loaded, so the files on
                # disk are replaced and the cmdlets running now stay on the code in
                # memory.
                Write-Output "$name updated. It loads in the next session; this run continues on the version already in memory."
                continue
            }

            # Nothing left but an install: either absent, or a shipped copy that
            # can only be replaced side by side.
            if ($status.Installed -and $status.Installed -ge $status.Available) {
                Write-Output "$name $($status.Installed) shipped with this host and is not behind the gallery; leaving it alone."
                continue
            }

            $what = if ($status.Installed) {
                "$name $($status.Installed) is the copy this PowerShell shipped with, which cannot be updated in place. Installing $($status.Available) alongside it is the only way forward"
            } else {
                "$name is not installed. It is the current PowerShell Gallery client, and installing it changes which client every later module update uses. This would install $($status.Available)"
            }

            if (Approve-Install -Component $tool.Component -Approved $AllowInstall `
                    -Description "$what $(if ($isAdmin) { 'for all users on this machine' } else { 'for the current user only' }).") {

                Install-Module -Name $name -Force -AllowClobber -Scope $scope `
                    -Confirm:$false -ErrorAction Stop
                Write-Output "Installed $name $($status.Available) ($scope). It loads in the next session."
            }
        }
    }

    if (-not $IncludePowerShellModules) {
        Add-SkippedStep -Name 'PowerShell modules'
    } else {
    Invoke-Step -Name 'PowerShell modules' -Tag 'PowerShell' -Action {
        # Both update commands are silent on success; without this capture the step
        # would report only OK and a duration. Taken either side of the pass, this
        # says what moved.
        $before = Get-ModuleVersionMap

        if (Get-Command Update-PSResource -ErrorAction SilentlyContinue) {
            # -TrustRepository suppresses the prompt for this call even when the
            # Trust PSGallery step could not persist the setting.
            $p = @{
                Name            = '*'
                TrustRepository = $true
                AcceptLicense   = $true
                Confirm         = $false
                ErrorAction     = 'SilentlyContinue'
            }
            if ($IncludePrerelease) { $p.Prerelease = $true }
            Update-PSResource @p
        } elseif (Get-Command Update-Module -ErrorAction SilentlyContinue) {
            $p = @{
                Force       = $true
                Confirm     = $false
                ErrorAction = 'SilentlyContinue'
            }

            # PowerShellGet 1.0.0.1, which Windows PowerShell ships, has no
            # -AcceptLicense, and splatting a parameter that does not exist is a
            # terminating error that would fail this step on 5.1.
            if (Test-ParameterSupport -Command 'Update-Module' -Parameter 'AcceptLicense') {
                $p.AcceptLicense = $true
            } else {
                Write-Output 'PowerShellGet on this host predates -AcceptLicense; continuing without it. A module that requires accepting a license will be skipped rather than prompt.'
            }

            if ($IncludePrerelease) { $p.AllowPrerelease = $true }
            Update-Module @p
        } else {
            Write-Output 'No PowerShellGet/PSResourceGet available; skipping.'
            return
        }

        $after = Get-ModuleVersionMap

        $moved = foreach ($name in ($after.Keys | Sort-Object)) {
            if (-not $before.ContainsKey($name)) {
                " {0} {1} (new)" -f $name, $after[$name]
            } elseif ($after[$name] -gt $before[$name]) {
                " {0} {1} -> {2}" -f $name, $before[$name], $after[$name]
            }
        }

        $moved = @($moved)
        if ($moved.Count) {
            Write-Output ''
            Write-Output "$($moved.Count) module(s) updated:"
            $moved | ForEach-Object { Write-Output $_ }
        } else {
            Write-Output "No modules needed updating. $($after.Count) installed."
        }
    }
    }

    Invoke-Step -Name 'PowerShell help' -Tag 'PowerShell' -Action {
        $helpParams = @{ Force = $true; ErrorAction = 'SilentlyContinue' }

        # -Scope only exists on PS 6+; AllUsers writes under $PSHOME and needs admin.
        if ((Get-Command Update-Help).Parameters.ContainsKey('Scope')) {
            $helpParams.Scope = if ($isAdmin) { 'AllUsers' } else { 'CurrentUser' }
        }
        # Most modules only publish en-US help; pinning the culture avoids a wall of
        # "unable to retrieve the HelpInfo XML" errors under any other UI culture.
        if ((Get-UICulture).Name -ne 'en-US') { $helpParams.UICulture = 'en-US' }

        Update-Help @helpParams
    }

    # ---------------------------------------------------------------------------
    # 4. Python toolchain
    # ---------------------------------------------------------------------------
    Invoke-Step -Name 'Python (Install Manager)' -Tag 'Python' -Action {
        # Skipped rather than OK: a step that did nothing because the tool is absent
        # is not a step that updated something.
        if (Get-Command pymanager -ErrorAction SilentlyContinue) {
            pymanager install --update
        } elseif (Get-Command py -ErrorAction SilentlyContinue) {
            # Two tools answer to py: the Install Manager's alias, which has an
            # install subcommand, and the classic launcher, which treats a bare
            # word as a script path and errors. "py help install" tells them
            # apart without changing anything: the alias exits 0, the launcher
            # cannot open a script named help and does not.
            $null = & py help install 2>&1
            if ($LASTEXITCODE -ne 0) {
                $global:LASTEXITCODE = 0
                Stop-StepAsSkipped -Reason 'py here is the classic launcher, which cannot update runtimes; the Python Install Manager replaces it'
            }
            py install --update
        } else {
            Stop-StepAsSkipped -Reason 'the Python Install Manager is not installed'
        }
    }

    Invoke-Step -Name 'uv' -RequiresCommand 'uv' -Tag 'Python' -Action {
        # Self-update only what nothing else manages. "uv self update" against a uv
        # that scoop or winget installed fights whichever owns it.
        $owner = Get-ToolInstallSource -Name 'uv'
        if ($owner -notin 'Standalone', 'Unknown') {
            Stop-StepAsSkipped -Reason "uv is managed by $owner, which updates it in its own step"
        }

        $output = uv self update 2>&1
        $output

        if ($LASTEXITCODE -ne 0) {
            # uv refuses to self-update when a package manager owns it and says so in
            # its output, which is correct rather than failed. Any other non-zero
            # exit is reported as a real failure.
            if (($output | Out-String) -match 'package manager|self-update.*(disabled|unavailable)') {
                Write-Output 'uv declined to self-update because something else manages it.'
                $global:LASTEXITCODE = 0
            } else {
                Write-Error "uv self update failed with exit code $LASTEXITCODE. uv's own message is in this step's log."
                $global:LASTEXITCODE = 0
            }
        }
    }

    Invoke-Step -Name 'pip' -Tag 'Python' -Action {
        # Never inside an active virtual environment: its packages belong to whatever
        # project made it, and it is both the easiest interpreter to reach from here
        # and the worst one to change.
        if ($env:VIRTUAL_ENV) {
            Stop-StepAsSkipped -Reason "a virtual environment is active ($env:VIRTUAL_ENV), and its packages belong to that project rather than to this machine"
        }

        # Indexed rather than "| Select-Object -First 1": that halts the upstream
        # pipeline, and inside a step action the transcript records the stop as a
        # TerminatingError.
        $found = @('py', 'python' | ForEach-Object {
            Get-Command $_ -CommandType Application -ErrorAction SilentlyContinue
        })
        if (-not $found.Count) {
            Stop-StepAsSkipped -Reason 'no Python interpreter is on PATH'
        }
        $interpreter = $found[0].Source

        $version = (& $interpreter -m pip --version 2>&1 | Out-String).Trim()
        $global:LASTEXITCODE = 0
        if (-not $version -or $version -notmatch '^pip\s') {
            Stop-StepAsSkipped -Reason "pip is not available to $interpreter"
        }
        Write-Output $version

        # python -m pip, never the bare pip.exe. On Windows pip cannot replace its
        # own running executable, so run it through the interpreter instead.
        & $interpreter -m pip install --upgrade pip --disable-pip-version-check
        if ($LASTEXITCODE -ne 0) {
            Write-Error "Could not upgrade pip for $interpreter (exit $LASTEXITCODE). pip's own message is in this step's log."
            $global:LASTEXITCODE = 0
        } else {
            Write-Output ((& $interpreter -m pip --version 2>&1 | Out-String).Trim())
            $global:LASTEXITCODE = 0
        }

        # Only this interpreter's pip. Upgrading pip in every Python on a machine is
        # a larger claim than a maintenance run should make.
        $others = @(py --list 2>&1 | Out-String -Stream | Where-Object { $_ -match '^\s*-V:' -and $_ -notmatch '\*' })
        $global:LASTEXITCODE = 0
        if ($others.Count) {
            Write-Output ''
            Write-Output "$($others.Count) other interpreter(s) are installed and were not changed:"
            $others | ForEach-Object { Write-Output " $($_.Trim())" }
        }

        # Installed packages are left alone; the README's Python section carries
        # the why.
    }

    Invoke-Step -Name 'pipx packages' -RequiresCommand 'pipx' -Tag 'Python' -Action {
        pipx upgrade-all
    }

    # ---------------------------------------------------------------------------
    # 5. Node / npm
    # ---------------------------------------------------------------------------
    # conda updates conda itself in the base environment and nothing else.
    # Environments hold project package sets, and "conda update --all" is the
    # operation the pip step's rule declines: upgrading one package can silently
    # downgrade another's dependency.
    Invoke-Step -Name 'conda' -RequiresCommand 'conda' -Tag 'Python' -Action {
        $output = conda update --name base conda --yes 2>&1
        $output
        if ($LASTEXITCODE -ne 0) {
            Write-Error "conda update failed with exit code $LASTEXITCODE. conda's own message is in this step's log."
            $global:LASTEXITCODE = 0
        }
    }

    Invoke-Step -Name 'npm' -RequiresCommand 'npm' -Tag 'Node' -Action {
        # npm writes progress and deprecation notices to stderr as a matter of
        # course, so judge it by exit code. Output is captured as well as passed
        # through, because the reason npm failed is usually in it.
        $npmOutput = npm install -g npm@latest 2>&1
        $npmOutput
        if ($LASTEXITCODE -ne 0) {
            $npmText = $npmOutput | Out-String

            # EBADENGINE means the newest npm does not support the installed Node,
            # which is a fact about this machine rather than a fault in the update.
            # Reported in full, because the bare exit code does not say which of
            # the two it is.
            if ($npmText -match 'EBADENGINE') {
                $nodeVersion = try { node --version 2>$null } catch { 'unknown' }
                Write-Error ("npm could not update itself: the latest npm does not support the installed Node.js ($nodeVersion). " +
                    'npm reported EBADENGINE and left the existing npm in place, so nothing is broken. ' +
                    'Moving to a Node.js version npm supports (an LTS release) clears this.')
            } else {
                Write-Error "npm self-update failed with exit code $LASTEXITCODE. npm's own message is in this step's log."
            }
            $global:LASTEXITCODE = 0
        }

        if ($UpdateGlobalNpm) {
            npm update -g
            if ($LASTEXITCODE -ne 0) {
                Write-Error "npm update -g failed with exit code $LASTEXITCODE."
                $global:LASTEXITCODE = 0
            }
        } else {
            Write-Output 'Skipping global package upgrade (use -UpdateGlobalNpm to enable).'
        }
    }

    # The three below follow the uv rule: self-update only what nothing else
    # manages, and treat the tool's own refusal as correct rather than failed.
    Invoke-Step -Name 'Deno' -RequiresCommand 'deno' -Tag 'Node' -Action {
        $owner = Get-ToolInstallSource -Name 'deno'
        if ($owner -notin 'Standalone', 'Unknown') {
            Stop-StepAsSkipped -Reason "Deno is managed by $owner, which updates it in its own step"
        }

        $output = deno upgrade 2>&1
        $output

        if ($LASTEXITCODE -ne 0) {
            if (($output | Out-String) -match 'package manager') {
                Write-Output 'Deno declined to upgrade because something else manages it.'
                $global:LASTEXITCODE = 0
            } else {
                Write-Error "deno upgrade failed with exit code $LASTEXITCODE. Deno's own message is in this step's log."
                $global:LASTEXITCODE = 0
            }
        }
    }

    Invoke-Step -Name 'Bun' -RequiresCommand 'bun' -Tag 'Node' -Action {
        $owner = Get-ToolInstallSource -Name 'bun'
        if ($owner -notin 'Standalone', 'Unknown') {
            Stop-StepAsSkipped -Reason "Bun is managed by $owner, which updates it in its own step"
        }

        $output = bun upgrade 2>&1
        $output

        if ($LASTEXITCODE -ne 0) {
            if (($output | Out-String) -match 'package manager') {
                Write-Output 'Bun declined to upgrade because something else manages it.'
                $global:LASTEXITCODE = 0
            } else {
                Write-Error "bun upgrade failed with exit code $LASTEXITCODE. Bun's own message is in this step's log."
                $global:LASTEXITCODE = 0
            }
        }
    }

    Invoke-Step -Name 'pnpm' -RequiresCommand 'pnpm' -Tag 'Node' -Action {
        $owner = Get-ToolInstallSource -Name 'pnpm'
        if ($owner -notin 'Standalone', 'Unknown') {
            Stop-StepAsSkipped -Reason "pnpm is managed by $owner, which updates it in its own step"
        }

        # self-update applies to the standalone install only; a Corepack-managed
        # pnpm is pinned per project and updated through Corepack.
        $output = pnpm self-update 2>&1
        $output

        if ($LASTEXITCODE -ne 0) {
            if (($output | Out-String) -match 'corepack|standalone') {
                Write-Output 'pnpm declined to self-update because Corepack or a package manager manages it.'
                $global:LASTEXITCODE = 0
            } else {
                Write-Error "pnpm self-update failed with exit code $LASTEXITCODE. pnpm's own message is in this step's log."
                $global:LASTEXITCODE = 0
            }
        }
    }

    # ---------------------------------------------------------------------------
    # 6. .NET global tools
    # ---------------------------------------------------------------------------
    Invoke-Step -Name '.NET global tools' -RequiresCommand 'dotnet' -Tag 'DotNet' -Action {
        # dotnet exists for runtime-only installs, and "dotnet --version" fails when
        # there is no SDK or a global.json pins a missing one, so validate the
        # string before casting it.
        $verRaw = (@(dotnet --version 2>&1)[0] | Out-String).Trim()
        $verOk  = ($LASTEXITCODE -eq 0)
        $global:LASTEXITCODE = 0

        # Evaluate the match on its own: with a short-circuited -or, $Matches could
        # still be holding results from an earlier, unrelated match.
        if (-not $verOk) {
            Write-Output "'dotnet --version' failed ('$verRaw'); no usable SDK, skipping global tools."
            return
        }
        if ($verRaw -notmatch '^(\d+)\.') {
            Write-Output "Could not parse an SDK version from '$verRaw'; skipping global tools."
            return
        }

        $major = [int]$Matches[1]
        if ($major -lt 6) {
            Write-Output ".NET global tool update skipped (SDK $verRaw; requires 6 or higher)."
            return
        }

        dotnet tool update --all --global
        if ($LASTEXITCODE -ne 0) {
            # '--all' is not accepted by every SDK that reports 6+. Fall back to
            # enumerating the installed tools and updating them one at a time.
            $global:LASTEXITCODE = 0
            Write-Output "'dotnet tool update --all' was rejected; updating tools individually."

            $tools = @(dotnet tool list --global |
                Select-Object -Skip 2 |
                ForEach-Object { ($_ -split '\s+')[0] } |
                Where-Object { $_ -and $_ -notmatch '^-+$' })
            $global:LASTEXITCODE = 0

            if (-not $tools) {
                Write-Output 'No global .NET tools installed.'
                return
            }
            foreach ($tool in $tools) {
                dotnet tool update --global $tool
                if ($LASTEXITCODE -ne 0) {
                    Write-Warning "Could not update $tool (exit $LASTEXITCODE)."
                    $global:LASTEXITCODE = 0
                }
            }
        }
    }

    Invoke-Step -Name '.NET workloads' -RequiresCommand 'dotnet' -Tag 'DotNet' -Action {
        # No-ops cleanly ("No workloads to update") when none are installed, but a
        # runtime-only install has no workload command at all.
        dotnet workload update
        if ($LASTEXITCODE -ne 0) {
            Write-Output "dotnet workload update returned $LASTEXITCODE (no SDK, or no workloads to update)."
            $global:LASTEXITCODE = 0
        }
    }

    # ---------------------------------------------------------------------------
    # 7. Self-updating tools
    # ---------------------------------------------------------------------------
    Invoke-Step -Name 'rustup' -RequiresCommand 'rustup' -Tag 'Rust' -Action {
        rustup update
    }

    # rustup moves the toolchains; nothing moves the binaries cargo install put
    # on the machine. The cargo-update crate adds the subcommand that does.
    Invoke-Step -Name 'cargo binaries' -RequiresCommand 'cargo' -Tag 'Rust' -Action {
        $null = cargo install-update --version 2>&1
        if ($LASTEXITCODE -ne 0) {
            $global:LASTEXITCODE = 0
            Stop-StepAsSkipped -Reason 'the cargo-update crate is not installed; "cargo install cargo-update" adds the subcommand this step runs'
        }

        # cargo writes progress to stderr as a matter of course; judge by exit code.
        $output = cargo install-update --all 2>&1
        $output
        if ($LASTEXITCODE -ne 0) {
            Write-Error "cargo install-update --all failed with exit code $LASTEXITCODE. cargo's own message is in this step's log."
            $global:LASTEXITCODE = 0
        }
    }

    # go install has no update-everything form; gup is the tool that adds one,
    # updating every binary under GOBIN.
    Invoke-Step -Name 'Go binaries' -RequiresCommand 'go' -Tag 'Go' -Action {
        if (-not (Get-Command gup -CommandType Application -ErrorAction SilentlyContinue)) {
            Stop-StepAsSkipped -Reason 'gup is not installed; "go install github.com/nao1215/gup@latest" adds it'
        }

        $output = gup update 2>&1
        $output
        if ($LASTEXITCODE -ne 0) {
            Write-Error "gup update failed with exit code $LASTEXITCODE. gup's own message is in this step's log."
            $global:LASTEXITCODE = 0
        }
    }

    Invoke-Step -Name 'GitHub CLI extensions' -RequiresCommand 'gh' -Tag 'Git' -Action {
        # 'gh extension upgrade --all' exits non-zero when nothing is installed,
        # which would otherwise fail the step. Check before calling it.
        $installed = (gh extension list 2>&1 | Out-String).Trim()
        $global:LASTEXITCODE = 0
        if (-not $installed) {
            Write-Output 'No gh extensions installed; nothing to upgrade.'
            return
        }
        gh extension upgrade --all
    }

    # ---------------------------------------------------------------------------
    # 7b. Cloud CLIs
    # ---------------------------------------------------------------------------
    # Both talk to a vendor endpoint and can pull a large payload, which is what
    # the Cloud tag is for: -ExcludeTag Cloud drops the pair on a metered or
    # offline machine.

    # az upgrade reruns the MSI on the standard Windows install, so the step
    # needs the elevation it would otherwise stall asking for. It also updates
    # installed az extensions by default.
    Invoke-Step -Name 'Azure CLI' -RequiresCommand 'az' -RequiresAdmin -Tag 'Cloud' -Action {
        $output = az upgrade --yes --only-show-errors 2>&1
        $output
        if ($LASTEXITCODE -ne 0) {
            Write-Error "az upgrade failed with exit code $LASTEXITCODE. az's own message is in this step's log."
            $global:LASTEXITCODE = 0
        }
    }

    Invoke-Step -Name 'Google Cloud CLI' -RequiresCommand 'gcloud' -Tag 'Cloud' -Action {
        # A gcloud that Chocolatey or Scoop installed disables its own component
        # manager, so self-update is only right for the bundled install.
        $owner = Get-ToolInstallSource -Name 'gcloud'
        if ($owner -notin 'Standalone', 'Unknown') {
            Stop-StepAsSkipped -Reason "gcloud is managed by $owner, which updates it in its own step"
        }

        $output = gcloud components update --quiet 2>&1
        $output
        if ($LASTEXITCODE -ne 0) {
            if (($output | Out-String) -match 'component manager is disabled') {
                Write-Output 'gcloud declined to self-update because its component manager is disabled for this install.'
                $global:LASTEXITCODE = 0
            } else {
                Write-Error "gcloud components update failed with exit code $LASTEXITCODE. gcloud's own message is in this step's log."
                $global:LASTEXITCODE = 0
            }
        }
    }

    # ---------------------------------------------------------------------------
    # 8. WSL kernel
    # ---------------------------------------------------------------------------
    Invoke-Step -Name 'WSL kernel' -RequiresCommand 'wsl' -Tag 'Windows' -Action {
        # wsl.exe ships in System32 on every Windows 11 machine, so -RequiresCommand
        # proves nothing here. --status fails when the feature is not actually enabled.
        wsl --status
        if ($LASTEXITCODE -ne 0) {
            $global:LASTEXITCODE = 0
            Write-Output 'wsl.exe is present but WSL is not installed or enabled; skipping.'
            return
        }
        $global:LASTEXITCODE = 0

        wsl --update
        if ($LASTEXITCODE -ne 0) {
            Write-Output "wsl --update returned $LASTEXITCODE (commonly 'no updates available')."
            $global:LASTEXITCODE = 0
        }
    }

    # ---------------------------------------------------------------------------
    # 9. Microsoft Defender signatures
    # ---------------------------------------------------------------------------
    Invoke-Step -Name 'Defender signatures' -RequiresCommand 'Update-MpSignature' -RequiresAdmin -Tag 'Windows', 'Microsoft' -Action {
        # The Defender cmdlets exist even where a third-party AV has taken over and
        # the service is off, so probe before updating.
        try {
            $mp = Get-MpComputerStatus -ErrorAction Stop
        } catch {
            Write-Output "Defender status unavailable ($($_.Exception.Message)); skipping signature update."
            return
        }
        if (-not $mp.AMServiceEnabled) {
            Write-Output 'Defender antimalware service is disabled (third-party AV in use?); skipping.'
            return
        }

        Update-MpSignature -ErrorAction Stop
    }

    # ---------------------------------------------------------------------------
    # 9b. Make PowerShell 7 the default Windows Terminal profile
    # (placed before Windows Update so an AutoReboot can't skip it)
    # ---------------------------------------------------------------------------
    if ($SetPwshTerminalDefault) {
        Invoke-Step -Name 'Windows Terminal default = PowerShell 7' -Tag 'PowerShell', 'Windows' -Action {
            Set-PwshAsWindowsTerminalDefault -LogDir $logDir
        }
    } else {
        Add-SkippedStep -Name 'Windows Terminal default = PowerShell 7'
    }

    # ---------------------------------------------------------------------------
    # 10. Windows Update (OS + drivers) via PSWindowsUpdate
    # ---------------------------------------------------------------------------
    if ($IncludeWindowsUpdate) {
        Invoke-Step -Name 'Windows Update' -RequiresAdmin -Tag 'Windows', 'Microsoft' -Action {
            if (-not $isAdmin) { throw 'Administrator rights required for Windows Update.' }

            if (-not (Get-Module -ListAvailable -Name PSWindowsUpdate)) {
                if (-not (Approve-Install -Component 'PSWindowsUpdate' -Approved $AllowInstall `
                        -Description 'The PSWindowsUpdate module is not installed. Windows Update cannot be driven without it. This would install it from the PowerShell Gallery for all users on this machine.')) {
                    Stop-StepAsSkipped -Reason 'installing PSWindowsUpdate was not approved'
                }

                try {
                    $install = @{
                        Name         = 'PSWindowsUpdate'
                        Force        = $true
                        Scope        = 'AllUsers'
                        AllowClobber = $true
                        Confirm      = $false
                        ErrorAction  = 'Stop'
                    }

                    # Same reason as the module-update step: the PowerShellGet
                    # Windows ships has no -AcceptLicense to splat.
                    if (Test-ParameterSupport -Command 'Install-Module' -Parameter 'AcceptLicense') {
                        $install.AcceptLicense = $true
                    }

                    Install-Module @install
                } catch {
                    throw "Failed to install PSWindowsUpdate module: $_"
                }
            }

            Import-Module PSWindowsUpdate -ErrorAction Stop

            # Windows Update alone offers the OS and drivers; registering Microsoft
            # Update widens the scan to Office and other Microsoft products. Managed
            # devices often block this by policy, so degrade rather than fail.
            $useMicrosoftUpdate = $false
            $muServiceId = '7971f918-a847-4430-9279-4a52d1efe18d'
            try {
                if (Get-WUServiceManager -ErrorAction Stop | Where-Object { $_.ServiceID -eq $muServiceId }) {
                    $useMicrosoftUpdate = $true
                } else {
                    Add-WUServiceManager -ServiceID $muServiceId -AddServiceFlag 7 -Confirm:$false -ErrorAction Stop | Out-Null
                    $useMicrosoftUpdate = $true
                    Write-Output 'Registered the Microsoft Update service.'
                }
            } catch {
                Write-Warning "Could not enable the Microsoft Update service ($($_.Exception.Message)); scanning Windows Update only."
            }

            $params = @{ AcceptAll = $true; Install = $true }
            if ($useMicrosoftUpdate) { $params.MicrosoftUpdate = $true }
            if ($AutoReboot)         { $params.AutoReboot      = $true }
            else                     { $params.IgnoreReboot    = $true }

            Get-WindowsUpdate @params
        }
    } else {
        Add-SkippedStep -Name 'Windows Update'
    }

    # ---------------------------------------------------------------------------
    # 11. Summary + reboot check
    # ---------------------------------------------------------------------------
    Write-Host "`n================ SUMMARY ================" -ForegroundColor Green
    # Out-Host, not a bare pipeline. Inside a function, unassigned pipeline
    # output is the return value, so without it the caller would receive the
    # table's formatting objects alongside the result and the summary would not
    # appear in the run log.
    $Results | Format-Table -AutoSize -Property Step, Status, Seconds | Out-Host

    $failedSteps  = @($Results | Where-Object { $_.Status -eq 'Failed' })
    $warnedSteps  = @($Results | Where-Object { $_.Status -eq 'Warning' })

    if ($failedSteps.Count -or $warnedSteps.Count) {
        Write-Host "`nSteps needing attention:" -ForegroundColor Yellow
        foreach ($r in @($failedSteps) + @($warnedSteps)) {
            Write-Host (" {0,-8} {1,-42} {2}" -f $r.Status, $r.Step, $r.Log)
        }
    }

    $reboot = Test-PendingReboot
    if ($reboot.IsPending) {
        Write-Host "`n[!] A reboot is pending. Restart to finish applying updates." -ForegroundColor Yellow
        foreach ($reason in $reboot.Reasons) { Write-Host " - $reason" -ForegroundColor Yellow }
    } else {
        Write-Host "`n[OK] No pending reboots detected." -ForegroundColor Green
    }

    if ($Notify) {
        $okCount      = @($Results | Where-Object { $_.Status -eq 'OK' }).Count
        $skippedCount = @($Results | Where-Object { $_.Status -eq 'Skipped' }).Count
        $headline     = if ($failedSteps.Count) { "Updates finished with $($failedSteps.Count) failure(s)" } else { 'Updates finished' }

        Send-UpdateNotification -Text @(
            $headline
            "$okCount updated, $($failedSteps.Count) failed, $($warnedSteps.Count) with warnings, $skippedCount skipped."
            "Logs: $logDir"
        )

        # Sent second and separately so it is the one left on screen, and marked
        # urgent so Focus Assist does not hide the fact that the machine is only
        # half-updated until it restarts.
        if ($reboot.IsPending) {
            Send-UpdateNotification -Urgent -UniqueIdentifier 'Update-Everything-Reboot' -Text @(
                'Restart required'
                'Windows needs a restart to finish applying updates.'
                (($reboot.Reasons | Select-Object -First 2) -join '; ')
            )
        }
    }

    # Repeated here because the warning at startup is thousands of lines back by now.
    if ($Notify -and -not $script:NotificationsAvailable) {
        Write-Host ''
        Write-Host '[!] Notifications were requested but could not be sent.' -ForegroundColor Yellow
        Write-Host " Reason: $($notificationStatus.Reason)" -ForegroundColor Yellow
        Write-Host ' The update run itself was unaffected.' -ForegroundColor Yellow
    } elseif (-not $Notify) {
        # State that nothing was asked for. Without this the run says nothing at
        # all about notifications, which reads as a broken feature rather than an
        # unrequested one.
        Write-Host ''
        Write-Host 'Notifications: not requested. Pass -Notify for a toast when the run finishes.' -ForegroundColor DarkGray
    }

    # Said at the end rather than at the start: by here the run has shown what
    # this machine has and has not, which is what makes the suggestion concrete.
    # Once, and only on the first run -- a tool that repeats advice on every run
    # teaches people to skim past its output.
    if ($isFirstRun) {
        Write-Host ''
        Write-Host 'That was the first run on this machine. Initialize-UpdateEverything sets up a' -ForegroundColor Cyan
        Write-Host 'schedule, installs the tools this then keeps updated, and asks before each one.' -ForegroundColor Cyan
    }

    Write-Host "Finished $(Get-Date). Detailed logs saved to: $logDir" -ForegroundColor Green
    if ($failedSteps.Count) {
        Write-Host "$($failedSteps.Count) step(s) failed." -ForegroundColor Red
    }

    if ($transcriptRunning) { try { Stop-Transcript | Out-Null } catch { Write-Verbose "Transcript already stopped." } }
    try { [Console]::OutputEncoding = $originalOutputEncoding } catch { Write-Verbose "Could not restore the console encoding." }

    # Returned rather than exited. A scheduled task turns FailedCount into an
    # exit code itself; a session that called this function keeps running.
    New-UpdateEverythingResult -Ran $true -Elevated:$isAdmin -Steps $Results `
        -RebootPending $reboot.IsPending -RebootReason $reboot.Reasons `
        -LogDirectory $logDir -MainLog $mainLog
}