Public/Invoke-WingetFleet.ps1

function Invoke-WingetFleet {
    <#
    .SYNOPSIS
        Execute winget operations across multiple remote machines simultaneously.
 
    .DESCRIPTION
        SCCM-lite for people who hate SCCM. Push package installs, updates, uninstalls,
        and state queries to N machines over WinRM or SSH with parallel execution,
        aggregated reporting, and failure resilience.
 
        Supports machine lists from files, Active Directory OUs, or inline arrays.
        Results are collected into a structured report with per-machine status.
 
    .PARAMETER Computers
        Array of computer names/IPs to target.
 
    .PARAMETER ComputerFile
        Path to a text file with one computer name per line.
 
    .PARAMETER Action
        Operation to perform: Install, Uninstall, Update, UpdateAll, List, State, Query.
 
    .PARAMETER PackageId
        Package ID(s) for Install/Uninstall/Query actions.
 
    .PARAMETER Credential
        PSCredential for remote authentication. Defaults to current user.
 
    .PARAMETER UseSSH
        Use SSH instead of WinRM for remote connectivity.
 
    .PARAMETER ThrottleLimit
        Maximum concurrent remote sessions. Default: 10.
 
    .PARAMETER TimeoutSeconds
        Per-machine operation timeout. Default: 300.
 
    .PARAMETER ExportReport
        Save aggregated results to a JSON report file.
 
    .PARAMETER RetryFailed
        Automatically retry failed machines once.
 
    .EXAMPLE
        Invoke-WingetFleet -Computers "PC01","PC02","PC03" -Action UpdateAll
        Updates all packages on 3 machines in parallel.
 
    .EXAMPLE
        Invoke-WingetFleet -ComputerFile ".\lab-machines.txt" -Action Install -PackageId "Git.Git","Python.Python.3.12"
        Installs Git and Python on all machines in the file.
 
    .EXAMPLE
        Invoke-WingetFleet -Computers "Server01" -Action State -Credential (Get-Credential)
        Queries full package state on Server01 with explicit credentials.
 
    .EXAMPLE
        Invoke-WingetFleet -ComputerFile ".\fleet.txt" -Action List -ExportReport ".\fleet-report.json"
        Lists all packages on every machine and exports a JSON report.
 
    .NOTES
        Author: Matthew Bubb
        Requires: WinRM (Enable-PSRemoting) or SSH configured on target machines.
        WingetBatch module must be available on remote machines for full functionality.
    #>

    [CmdletBinding(DefaultParameterSetName = 'Inline')]
    param(
        [Parameter(ParameterSetName = 'Inline', Mandatory, Position = 0)]
        [Alias('Machines', 'Hosts')]
        [string[]]$Computers,

        [Parameter(ParameterSetName = 'File', Mandatory)]
        [ValidateScript({ Test-Path $_ })]
        [string]$ComputerFile,

        [Parameter(Mandatory)]
        [ValidateSet('Install', 'Uninstall', 'Update', 'UpdateAll', 'List', 'State', 'Query')]
        [string]$Action,

        [Parameter()]
        [string[]]$PackageId,

        [Parameter()]
        [PSCredential]$Credential,

        [switch]$UseSSH,

        [ValidateRange(1, 50)]
        [int]$ThrottleLimit = 10,

        [ValidateRange(30, 3600)]
        [int]$TimeoutSeconds = 300,

        [string]$ExportReport,

        [switch]$RetryFailed
    )

    # --- Resolve computer list ---
    if ($ComputerFile) {
        $Computers = Get-Content -Path $ComputerFile | Where-Object { $_.Trim() -and -not $_.StartsWith('#') } | ForEach-Object { $_.Trim() }
    }

    if (-not $Computers -or $Computers.Count -eq 0) {
        Write-Error "No target computers specified."
        return
    }

    # Validate package IDs for actions that need them
    if ($Action -in @('Install', 'Uninstall', 'Update', 'Query') -and -not $PackageId) {
        Write-Error "Action '$Action' requires -PackageId parameter."
        return
    }

    # --- Banner ---
    Write-Host ""
    Write-Host " ╔══════════════════════════════════════════════════════╗" -ForegroundColor Cyan
    Write-Host " ║ WingetBatch Fleet Operations ║" -ForegroundColor Cyan
    Write-Host " ╚══════════════════════════════════════════════════════╝" -ForegroundColor Cyan
    Write-Host ""
    Write-Host " Targets: " -NoNewline -ForegroundColor DarkGray; Write-Host "$($Computers.Count) machines" -ForegroundColor White
    Write-Host " Action: " -NoNewline -ForegroundColor DarkGray; Write-Host $Action -ForegroundColor Yellow
    if ($PackageId) {
        Write-Host " Packages: " -NoNewline -ForegroundColor DarkGray; Write-Host ($PackageId -join ', ') -ForegroundColor White
    }
    Write-Host " Transport: " -NoNewline -ForegroundColor DarkGray; Write-Host $(if ($UseSSH) { "SSH" } else { "WinRM" }) -ForegroundColor White
    Write-Host " Parallel: " -NoNewline -ForegroundColor DarkGray; Write-Host "$ThrottleLimit concurrent" -ForegroundColor White
    Write-Host ""

    # --- Build remote script block ---
    # Runs on the target machine (Windows PowerShell 5.1 or 7), so it cannot use
    # WingetBatch's private helpers and must stay 5.1-compatible.
    $remoteScript = {
        param($ActionParam, $PackageIds, $TimeoutSec)

        $result = @{
            Computer   = $env:COMPUTERNAME
            Action     = $ActionParam
            Timestamp  = (Get-Date).ToString('o')
            Status     = 'Unknown'
            Packages   = @()
            Errors     = @()
            DurationMs = 0
        }

        $sw = [System.Diagnostics.Stopwatch]::StartNew()

        # WinGet cmdlets report installer failures in .Status instead of throwing
        function Invoke-Op {
            param([string]$Verb, [string]$Id, [string]$Source)
            $params = @{ Id = $Id; MatchOption = 'EqualsCaseInsensitive'; Mode = 'Silent'; ErrorAction = 'Stop' }
            if ($Source) { $params['Source'] = $Source }
            try {
                $r = @(& "Microsoft.WinGet.Client\$Verb-WinGetPackage" @params)
                $status = if ($r.Count -gt 0) { [string]$r[-1].Status } else { 'NoResult' }
                $ok = ($status -eq 'Ok') -or ($Verb -eq 'Update' -and $status -eq 'NoApplicableUpgrade')
                return @{ Ok = $ok; Status = $status; Reboot = ($r.Count -gt 0 -and [bool]$r[-1].RebootRequired) }
            }
            catch {
                return @{ Ok = $false; Status = $_.Exception.Message; Reboot = $false }
            }
        }

        try {
            if (-not (Get-Module -ListAvailable Microsoft.WinGet.Client)) {
                $result.Status = 'Error'
                $result.Errors += "Microsoft.WinGet.Client not available on this machine"
                return ($result | ConvertTo-Json -Depth 5 -Compress)
            }
            Import-Module Microsoft.WinGet.Client -ErrorAction Stop

            switch ($ActionParam) {
                'List' {
                    $pkgs = @(Microsoft.WinGet.Client\Get-WinGetPackage)
                    $result.Packages = @($pkgs | ForEach-Object {
                        @{ Id = $_.Id; Name = $_.Name; Version = $_.InstalledVersion; Source = $_.Source }
                    })
                    $result.Status = 'Success'
                }
                'State' {
                    $pkgs = @(Microsoft.WinGet.Client\Get-WinGetPackage)
                    $result.Packages = @($pkgs | ForEach-Object {
                        @{ Id = $_.Id; Name = $_.Name; Version = $_.InstalledVersion; Source = $_.Source; Available = @($_.AvailableVersions)[0]; UpdateAvailable = [bool]$_.IsUpdateAvailable }
                    })
                    $result.TotalCount = $pkgs.Count
                    $result.UpdatesAvailable = @($pkgs | Where-Object { $_.IsUpdateAvailable }).Count
                    $result.Status = 'Success'
                }
                { $_ -in 'Install', 'Uninstall', 'Update' } {
                    $pastTense = @{ Install = 'Installed'; Uninstall = 'Uninstalled'; Update = 'Updated' }[$ActionParam]
                    foreach ($id in $PackageIds) {
                        $op = Invoke-Op -Verb $ActionParam -Id $id
                        if ($op.Ok) { $result.Packages += @{ Id = $id; Status = $pastTense; RebootRequired = $op.Reboot } }
                        else { $result.Packages += @{ Id = $id; Status = 'Failed'; Error = $op.Status } }
                    }
                }
                'UpdateAll' {
                    $updatable = @(Microsoft.WinGet.Client\Get-WinGetPackage | Where-Object { $_.IsUpdateAvailable })
                    foreach ($pkg in $updatable) {
                        $op = Invoke-Op -Verb 'Update' -Id $pkg.Id -Source $pkg.Source
                        if ($op.Ok) { $result.Packages += @{ Id = $pkg.Id; Status = 'Updated'; Version = @($pkg.AvailableVersions)[0]; RebootRequired = $op.Reboot } }
                        else { $result.Packages += @{ Id = $pkg.Id; Status = 'Failed'; Error = $op.Status } }
                    }
                    $result.UpdatesAvailable = $updatable.Count
                }
                'Query' {
                    foreach ($id in $PackageIds) {
                        $found = Microsoft.WinGet.Client\Get-WinGetPackage -Id $id -MatchOption EqualsCaseInsensitive -ErrorAction SilentlyContinue | Select-Object -First 1
                        if ($found) {
                            $result.Packages += @{ Id = $id; Installed = $true; Version = $found.InstalledVersion; Name = $found.Name; UpdateAvailable = [bool]$found.IsUpdateAvailable }
                        } else {
                            $result.Packages += @{ Id = $id; Installed = $false }
                        }
                    }
                    $result.Status = 'Success'
                }
            }

            if ($ActionParam -in 'Install', 'Uninstall', 'Update', 'UpdateAll') {
                $failedCount = @($result.Packages | Where-Object { $_.Status -eq 'Failed' }).Count
                $result.Status = if ($failedCount -eq 0) { 'Success' } elseif ($failedCount -lt $result.Packages.Count) { 'PartialFailure' } else { 'Error' }
                if ($failedCount -gt 0) {
                    $result.Errors += @($result.Packages | Where-Object { $_.Status -eq 'Failed' } | ForEach-Object { "$($_.Id): $($_.Error)" })
                }
            }
        } catch {
            $result.Status = 'Error'
            $result.Errors += $_.Exception.Message
        }

        $sw.Stop()
        $result.DurationMs = $sw.ElapsedMilliseconds
        return ($result | ConvertTo-Json -Depth 5 -Compress)
    }

    # --- Execute across fleet ---
    $results = [System.Collections.Generic.List[hashtable]]::new()
    $sessionParams = @{}
    if ($Credential) { $sessionParams['Credential'] = $Credential }

    # One place that knows how to reach a machine (WinRM or SSH)
    $invokeRemote = {
        param([string]$Computer, [switch]$AsJob)
        $target = if ($UseSSH) { @{ HostName = $Computer } } else { @{ ComputerName = $Computer } }
        Invoke-Command @target @sessionParams -ScriptBlock $remoteScript -ArgumentList $Action, $PackageId, $TimeoutSeconds -AsJob:$AsJob -ErrorAction Stop
    }

    $total = $Computers.Count
    $completed = 0

    for ($batchStart = 0; $batchStart -lt $total; $batchStart += $ThrottleLimit) {
        $batch = $Computers[$batchStart..([Math]::Min($batchStart + $ThrottleLimit - 1, $total - 1))]
        $jobMap = @{}

        foreach ($computer in $batch) {
            $completed++
            Write-Progress -Activity "Fleet: $Action" -Status "[$completed/$total] $computer" -PercentComplete (($completed / $total) * 100)
            try {
                $job = & $invokeRemote -Computer $computer -AsJob
                $jobMap[$job.Id] = @{ Job = $job; Computer = $computer }
            }
            catch {
                $results.Add(@{ Computer = $computer; Status = 'Error'; Errors = @($_.Exception.Message) })
            }
        }

        # Wait for batch
        $batchJobs = @($jobMap.Values | ForEach-Object { $_.Job })
        if ($batchJobs.Count -gt 0) {
            $batchJobs | Wait-Job -Timeout $TimeoutSeconds | Out-Null
        }

        # Collect results
        foreach ($entry in $jobMap.Values) {
            $job = $entry.Job
            $computer = $entry.Computer
            try {
                if ($job.State -eq 'Running') {
                    Stop-Job $job -ErrorAction SilentlyContinue
                    $results.Add(@{ Computer = $computer; Status = 'Timeout'; Errors = @("No response within $TimeoutSeconds seconds") })
                    continue
                }
                $output = Receive-Job $job -ErrorAction Stop
                if ($output) {
                    $parsed = @($output)[-1] | ConvertFrom-Json -AsHashtable
                    # Report under the name we targeted (the remote COMPUTERNAME may differ, e.g. IPs)
                    $parsed['Computer'] = $computer
                    $results.Add($parsed)
                } else {
                    $results.Add(@{ Computer = $computer; Status = 'NoResponse'; Errors = @('Job returned no output') })
                }
            } catch {
                $results.Add(@{ Computer = $computer; Status = 'Error'; Errors = @($_.Exception.Message) })
            }
            finally {
                Remove-Job $job -Force -ErrorAction SilentlyContinue
            }
        }
    }

    Write-Progress -Activity "Fleet: $Action" -Completed

    # --- Retry failed ---
    if ($RetryFailed) {
        $failedMachines = @($results | Where-Object { $_.Status -in @('Error', 'NoResponse', 'Timeout') } | ForEach-Object { $_.Computer })
        if ($failedMachines.Count -gt 0) {
            Write-Host " Retrying $($failedMachines.Count) failed machines..." -ForegroundColor Yellow
            foreach ($computer in $failedMachines) {
                $idx = $results.FindIndex([Predicate[hashtable]] { param($r) $r.Computer -eq $computer })
                try {
                    $output = & $invokeRemote -Computer $computer
                    $parsed = @($output)[-1] | ConvertFrom-Json -AsHashtable
                    $parsed['Computer'] = $computer
                    if ($idx -ge 0) { $results[$idx] = $parsed }
                } catch {
                    if ($idx -ge 0) { $results[$idx]['Errors'] = @($results[$idx]['Errors']) + "Retry failed: $($_.Exception.Message)" }
                }
            }
        }
    }

    # --- Aggregate Report ---
    $successCount = @($results | Where-Object { $_.Status -eq 'Success' }).Count
    $partialCount = @($results | Where-Object { $_.Status -eq 'PartialFailure' }).Count
    $failedCount = @($results | Where-Object { $_.Status -in @('Error', 'NoResponse', 'Timeout') }).Count

    Write-Host ""
    Write-Host " ┌─────────────────────────────────────────────────────┐" -ForegroundColor White
    Write-Host " │ Fleet Operation Results │" -ForegroundColor White
    Write-Host " ├─────────────────────────────────────────────────────┤" -ForegroundColor White
    Write-Host " │ " -NoNewline -ForegroundColor White
    Write-Host "Success: $successCount" -NoNewline -ForegroundColor Green
    Write-Host " | " -NoNewline -ForegroundColor White
    Write-Host "Partial: $partialCount" -NoNewline -ForegroundColor Yellow
    Write-Host " | " -NoNewline -ForegroundColor White
    Write-Host "Failed: $failedCount" -NoNewline -ForegroundColor Red
    Write-Host " │" -ForegroundColor White
    Write-Host " └─────────────────────────────────────────────────────┘" -ForegroundColor White
    Write-Host ""

    # Per-machine summary
    foreach ($r in ($results | Sort-Object { $_.Computer })) {
        $icon = switch ($r.Status) {
            'Success' { '✓' }
            'PartialFailure' { '◐' }
            default { '✗' }
        }
        $color = switch ($r.Status) {
            'Success' { 'Green' }
            'PartialFailure' { 'Yellow' }
            default { 'Red' }
        }
        $duration = if ($r.DurationMs) { " ($([Math]::Round($r.DurationMs / 1000, 1))s)" } else { "" }
        Write-Host " $icon " -NoNewline -ForegroundColor $color
        Write-Host "$($r.Computer)" -NoNewline -ForegroundColor White
        Write-Host " — $($r.Status)$duration" -ForegroundColor $color

        if ($r.Errors -and $r.Errors.Count -gt 0) {
            foreach ($err in $r.Errors | Select-Object -First 2) {
                Write-Host " $err" -ForegroundColor DarkGray
            }
        }
    }
    Write-Host ""

    # --- Export ---
    if ($ExportReport) {
        $report = @{
            Timestamp = (Get-Date).ToString('o')
            Action = $Action
            PackageIds = $PackageId
            TotalMachines = $total
            Success = $successCount
            PartialFailure = $partialCount
            Failed = $failedCount
            Results = $results
        }
        $report | ConvertTo-Json -Depth 10 | Set-Content -Path $ExportReport -Encoding UTF8
        Write-Host " Report saved: $ExportReport" -ForegroundColor Green
        Write-Host ""
    }

    # Return structured results for pipeline
    $results | ForEach-Object { [PSCustomObject]$_ }
}