Public/Invoke-WinGetBatch.ps1

function Invoke-WinGetBatch {
    <#
    .SYNOPSIS
        Idempotent, manifest-driven package deployments using the WinGet COM API.
 
    .DESCRIPTION
        Reads package target states from a pipeline or manifest file (JSON/YAML), verifies local state
        idempotency using the Microsoft.WinGet.Client COM API, then installs missing packages, updates
        outdated ones and applies version pins one at a time. Each result is checked against WinGet's
        reported status and written to a JSON report in ~/.wingetbatch/reports.
 
    .PARAMETER Path
        Path to a JSON or YAML state manifest file defining the target package configurations.
 
    .PARAMETER Packages
        Optional array of package objects passed directly or via pipeline. Each package should have an 'Id' property
        and an optional 'Version' property.
 
    .PARAMETER ThrottleLimit
        Deprecated and ignored (kept so existing scripts keep working). WinGet downloads each installer during its install.
 
    .PARAMETER Silent
        Runs installations completely silently without user interaction.
 
    .PARAMETER WhatIf
        Previews the deployment plan, performing idempotency checks without downloading or installing anything.
 
    .EXAMPLE
        Invoke-WinGetBatch -Path .\packages.yaml
 
    .EXAMPLE
        Get-Content .\packages.json | ConvertFrom-Json | Invoke-WinGetBatch
    #>


    [CmdletBinding(DefaultParameterSetName = 'Pipeline')]
    param(
        [Parameter(Mandatory = $true, ParameterSetName = 'Manifest', Position = 0)]
        [ValidateNotNullOrEmpty()]
        [string]$Path,

        [Parameter(Mandatory = $true, ParameterSetName = 'Pipeline', ValueFromPipeline = $true)]
        [PSCustomObject[]]$Packages,

        [Parameter()]
        [int]$ThrottleLimit = 4,

        [Parameter()]
        [switch]$Silent,

        [Parameter()]
        [ValidateSet("Default", "Silent", "Interactive")]
        [string]$Mode,

        [Parameter()]
        [ValidateSet("User", "Machine")]
        [string]$Scope,

        [Parameter()]
        [string]$Architecture,

        [Parameter()]
        [string]$Override,

        [Parameter()]
        [string]$Location,

        [Parameter()]
        [switch]$Force,

        [Parameter()]
        [switch]$SkipDependencies,

        [Parameter()]
        [switch]$AllowHashMismatch,

        [Parameter()]
        [switch]$WhatIf
    )

    begin {
        # Ensure Microsoft.WinGet.Client module is imported
        if (-not (Get-Module -Name Microsoft.WinGet.Client)) {
            try {
                Import-Module Microsoft.WinGet.Client -ErrorAction Stop
            }
            catch {
                Write-Error "Microsoft.WinGet.Client module is a required dependency. Please install it."
                return
            }
        }

        # Initialize collections
        $targetPackages = [System.Collections.Generic.List[PSCustomObject]]::new()
        $executionQueue = [System.Collections.Generic.List[PSCustomObject]]::new()
    }

    process {
        if ($PSCmdlet.ParameterSetName -eq 'Manifest') {
            # Resolve full manifest path
            $manifestPath = $ExecutionContext.SessionState.Path.GetUnresolvedProviderPathFromPSPath($Path)
            if (-not (Test-Path $manifestPath)) {
                Write-Error "Manifest file not found at: $manifestPath"
                return
            }

            Write-Host "[SYSTEM] Parsing state manifest: " -NoNewline -ForegroundColor Cyan
            Write-Host $manifestPath -ForegroundColor White

            $content = Get-Content -Raw -Path $manifestPath
            $parsed = $null

            if ($manifestPath.EndsWith(".yaml") -or $manifestPath.EndsWith(".yml")) {
                if (-not (Get-Module -ListAvailable -Name powershell-yaml)) {
                    Write-Error "powershell-yaml module is required to parse YAML manifests."
                    return
                }
                $parsed = ConvertFrom-Yaml $content
            }
            elseif ($manifestPath.EndsWith(".json")) {
                $parsed = ConvertFrom-Json $content
            }
            else {
                Write-Error "Unsupported manifest format. Use .json, .yaml, or .yml"
                return
            }

            if ($parsed -and $parsed.packages) {
                foreach ($pkg in $parsed.packages) {
                    $targetPackages.Add([PSCustomObject]@{
                        Id      = $pkg.id
                        Version = if ($pkg.version) { $pkg.version } else { "latest" }
                        Source  = $pkg.source
                    })
                }
            }
        }
        else {
            # Pipeline parameters input
            if ($null -ne $Packages) {
                foreach ($pkg in $Packages) {
                    if ($pkg.Id) {
                        $targetPackages.Add([PSCustomObject]@{
                            Id      = $pkg.Id
                            Version = if ($pkg.Version) { $pkg.Version } else { "latest" }
                            Source  = $pkg.Source
                        })
                    }
                }
            }
        }
    }

    end {
        if ($targetPackages.Count -eq 0) {
            Write-Host "[INFO] No packages resolved for deployment." -ForegroundColor Yellow
            return
        }

        Write-Host "`n[PHASE 1] Resolving and Checking Local State Idempotency..." -ForegroundColor Cyan

        # Query all installed packages once to optimize execution speed
        $installedList = Microsoft.WinGet.Client\Get-WinGetPackage -ErrorAction SilentlyContinue
        $installedMap = @{}
        foreach ($inst in $installedList) {
            if ($inst.Id -and -not $installedMap.ContainsKey($inst.Id)) {
                $installedMap[$inst.Id] = $inst
            }
        }

        # Validate local state idempotency against targets
        foreach ($target in $targetPackages) {
            $pkgId = $target.Id
            $targetVer = $target.Version

            Write-Host " Checking " -NoNewline -ForegroundColor Gray
            Write-Host $pkgId -NoNewline -ForegroundColor White

            if ($installedMap.ContainsKey($pkgId)) {
                $installedPkg = $installedMap[$pkgId]
                $installedVer = $installedPkg.InstalledVersion
                $updateAvailable = $installedPkg.IsUpdateAvailable

                if ($targetVer -eq 'latest') {
                    if ($updateAvailable) {
                        Write-Host " [Outdated] Installed: $installedVer (Update Available)" -ForegroundColor Yellow
                        $executionQueue.Add([PSCustomObject]@{ Id = $pkgId; Version = $targetVer; Operation = 'Update'; Source = $installedPkg.Source })
                    }
                    else {
                        Write-Host " [Idempotent] Installed: $installedVer (Up to date)" -ForegroundColor Green
                    }
                }
                else {
                    # Compare specific versions
                    if ($installedVer -eq $targetVer) {
                        Write-Host " [Idempotent] Installed version matches target: $targetVer" -ForegroundColor Green
                    }
                    else {
                        Write-Host " [Mismatch] Installed: $installedVer | Target: $targetVer" -ForegroundColor Yellow
                        $executionQueue.Add([PSCustomObject]@{ Id = $pkgId; Version = $targetVer; Operation = 'Install'; Source = $installedPkg.Source })
                    }
                }
            }
            else {
                Write-Host " [Missing]" -ForegroundColor Red
                $executionQueue.Add([PSCustomObject]@{ Id = $pkgId; Version = $targetVer; Operation = 'Install'; Source = $target.Source })
            }
        }

        if ($executionQueue.Count -eq 0) {
            Write-Host "`n[OK] System state is fully idempotent. No actions required." -ForegroundColor Green
            return
        }

        Write-Host "`nDeployment execution queue compiled: " -NoNewline -ForegroundColor Cyan
        Write-Host "$($executionQueue.Count) packages require changes." -ForegroundColor White

        if ($WhatIf) {
            Write-Host "`n[WhatIf] Would deploy:" -ForegroundColor Yellow
            foreach ($item in $executionQueue) {
                Write-Host " -> $($item.Operation) $($item.Id) ($($item.Version))" -ForegroundColor Gray
            }
            return
        }

        if ($PSBoundParameters.ContainsKey('ThrottleLimit')) {
            Write-Verbose "-ThrottleLimit is ignored: installers are fetched by WinGet during each install."
        }

        Write-Host "`n[PHASE 2] Installing (one package at a time)..." -ForegroundColor Cyan

        Invoke-WingetAutoSnapshot -Reason 'Invoke-WinGetBatch'

        $installOptions = @{
            Mode = $(if ($Silent) { 'Silent' } else { $Mode })
            Scope = $Scope; Architecture = $Architecture; Override = $Override; Location = $Location
            Force = [bool]$Force; SkipDependencies = [bool]$SkipDependencies; AllowHashMismatch = [bool]$AllowHashMismatch
        }

        $successCount = 0
        $failCount = 0
        $rebootPending = $false
        $reportData = [System.Collections.Generic.List[PSCustomObject]]::new()
        $i = 0

        foreach ($pkg in $executionQueue) {
            $i++
            Write-Host "`n>>> [$i/$($executionQueue.Count)] $($pkg.Operation): " -NoNewline -ForegroundColor Magenta
            Write-Host $pkg.Id -NoNewline -ForegroundColor White
            if ($pkg.Version -ne 'latest') { Write-Host " v$($pkg.Version)" -ForegroundColor Green } else { Write-Host "" }

            $result = Invoke-WingetPackageAction -Action $pkg.Operation -Id $pkg.Id -Version $pkg.Version -Source $pkg.Source -Options $installOptions

            if ($result.Succeeded) {
                $successCount++
                if ($result.RebootRequired) {
                    $rebootPending = $true
                    $status = "Success (Reboot Required)"
                    Write-Host "[OK] Deployed (restart required): " -NoNewline -ForegroundColor Yellow
                }
                else {
                    $status = "Success"
                    Write-Host "[OK] Deployed " -NoNewline -ForegroundColor Green
                }
                Write-Host $pkg.Id -ForegroundColor White
            }
            else {
                $failCount++
                $status = "Failed"
                Write-Host "[FAIL] " -NoNewline -ForegroundColor Red
                Write-Host $pkg.Id -NoNewline -ForegroundColor White
                Write-Host " ($($result.Message))" -ForegroundColor Red
            }

            $reportData.Add([PSCustomObject]@{
                PackageId      = $pkg.Id
                Operation      = $pkg.Operation
                Version        = $pkg.Version
                Status         = $status
                WinGetStatus   = $result.Status
                RebootRequired = $result.RebootRequired
                Message        = $(if ($result.Message) { $result.Message } else { "OK" })
                Timestamp      = (Get-Date).ToString("o")
            })
        }

        # Compile structured JSON report
        $reportDir = Join-Path (Get-WingetBatchConfigDir) "reports"
        if (-not (Test-Path $reportDir)) {
            New-Item -ItemType Directory -Path $reportDir -Force | Out-Null
        }

        $reportPath = Join-Path $reportDir "deployment_report_$((Get-Date).ToString('yyyyMMdd_HHmmss')).json"
        $reportObj = [ordered]@{
            Summary = [ordered]@{
                Total          = $executionQueue.Count
                Successful     = $successCount
                Failed         = $failCount
                RebootRequired = $rebootPending
            }
            Results = $reportData
        }

        $reportObj | ConvertTo-Json -Depth 5 | Out-File -FilePath $reportPath -Encoding utf8

        Write-Host ("`n" + ("=" * 60)) -ForegroundColor Green
        Write-Host "Deployment Complete" -ForegroundColor Green
        Write-Host ("=" * 60) -ForegroundColor Green
        Write-Host " Successful: " -NoNewline -ForegroundColor Green
        Write-Host $successCount -ForegroundColor White
        Write-Host " Failed: " -NoNewline -ForegroundColor Red
        Write-Host $failCount -ForegroundColor White

        if ($rebootPending) {
            Write-Host " A restart is required to finish at least one installation." -ForegroundColor Yellow
        }

        Write-Host "`nJSON deployment report saved to:" -ForegroundColor Gray
        Write-Host " $reportPath" -ForegroundColor Cyan
    }
}