Public/Invoke-WinGetBatch.ps1
|
function Invoke-WinGetBatch { <# .SYNOPSIS Invoke Next-Generation idempotent package deployments using COM APIs and parallel downloading. .DESCRIPTION Reads package target states from a pipeline or manifest file (JSON/YAML), verifies local state idempotency using the native Microsoft.WinGet.Client COM APIs, parallelizes download operations, and serializes silent installation execution while trapping and mapping system exit codes. .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 Maximum number of concurrent downloads. Default is 4. .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()] [switch]$WhatIf ) begin { # Prepend WindowsApps folder to ensure winget and COM APIs resolve correctly $env:PATH = "C:\Users\user\AppData\Local\Microsoft\WindowsApps;" + $env:PATH # 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" } }) } } } 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" } }) } } } } } 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 = 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($target) } 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($target) } } } else { Write-Host " [Missing]" -ForegroundColor Red $executionQueue.Add($target) } } 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 execute split-phase deployment for:" -ForegroundColor Yellow foreach ($item in $executionQueue) { Write-Host " -> $($item.Id) ($($item.Version))" -ForegroundColor Gray } return } # Phase 1: Parallel Downloads using ForEach-Object -Parallel Write-Host "`n[PHASE 2] Parallel Download Operations Launching..." -ForegroundColor Cyan $cacheDir = "C:\temp\winget_cache" if (-not (Test-Path $cacheDir)) { New-Item -ItemType Directory -Path $cacheDir -Force | Out-Null } $downloads = $executionQueue | ForEach-Object -Parallel { $env:PATH = "C:\Users\user\AppData\Local\Microsoft\WindowsApps;" + $env:PATH $pkgId = $_.Id $versionStr = if ($_.Version -ne "latest") { "--version $($_.Version)" } else { "" } Write-Host " >>> Downloading installer for $pkgId ..." -ForegroundColor DarkGray # Executing winget download $dlPath = "C:\temp\winget_cache\$pkgId" $cmd = "winget download --id $pkgId --exact --accept-package-agreements --accept-source-agreements --disable-interactivity --download-directory $dlPath $versionStr" Invoke-Expression $cmd | Out-Null if ($LASTEXITCODE -eq 0) { Write-Host " ✓ Cached installer: $pkgId" -ForegroundColor Green return [PSCustomObject]@{ Id = $pkgId; Downloaded = $true; Path = $dlPath } } else { Write-Host " ✗ Failed download cache: $pkgId (Exit Code: $LASTEXITCODE)" -ForegroundColor Red return [PSCustomObject]@{ Id = $pkgId; Downloaded = $false; Path = $null } } } -ThrottleLimit $ThrottleLimit $downloadResults = @{} foreach ($res in $downloads) { $downloadResults[$res.Id] = $res } # Phase 2: Serialized Sequential Installations Write-Host "`n[PHASE 3] Serialized Installation Queue Executing..." -ForegroundColor Cyan $successCount = 0 $failCount = 0 $rebootPending = $false $reportData = [System.Collections.Generic.List[PSCustomObject]]::new() foreach ($pkg in $executionQueue) { $pkgId = $pkg.Id $targetVer = $pkg.Version $dlResult = $downloadResults[$pkgId] Write-Host "`n>>> Deploying: " -NoNewline -ForegroundColor Magenta Write-Host $pkgId -ForegroundColor White if ($dlResult -and $dlResult.Downloaded) { Write-Host "Using pre-cached local installer." -ForegroundColor DarkGray } else { Write-Warning "Local cache missing. Falling back to dynamic installer fetch." } # Run installation $installMode = if ($Silent) { "--silent" } else { "" } $versionArg = if ($targetVer -ne "latest") { "--version $targetVer" } else { "" } # Execute serialized install $cmd = "winget install --id $pkgId --exact --accept-package-agreements --accept-source-agreements --disable-interactivity $installMode $versionArg" $output = Invoke-Expression $cmd 2>&1 | Out-String $exitCode = $LASTEXITCODE # Exit Code Trapping & Telemetry Mapping $status = "Failed" $message = "Unknown installation error." switch ($exitCode) { 0 { $status = "Success" $message = "Successfully installed package." $successCount++ Write-Host "✓ Successfully deployed " -NoNewline -ForegroundColor Green Write-Host $pkgId -ForegroundColor White } 3010 { $status = "Success (Reboot Required)" $message = "Installation successful, but system reboot is required." $successCount++ $rebootPending = $true Write-Host "✓ Deployed (Reboot Required): " -NoNewline -ForegroundColor Yellow Write-Host $pkgId -ForegroundColor White } 1641 { $status = "Success (Reboot Initiated)" $message = "Installation successful, reboot has been initiated." $successCount++ $rebootPending = $true Write-Host "✓ Deployed (Reboot Initiated): " -NoNewline -ForegroundColor Yellow Write-Host $pkgId -ForegroundColor White } default { $status = "Failed" $message = "Installer returned non-zero code: $exitCode." $failCount++ Write-Host "✗ Installation failed for " -NoNewline -ForegroundColor Red Write-Host $pkgId -NoNewline -ForegroundColor White Write-Host " (Exit Code: $exitCode)" -ForegroundColor Red Write-Host $output -ForegroundColor DarkGray } } $reportData.Add([PSCustomObject]@{ PackageId = $pkgId Version = $targetVer Status = $status ExitCode = $exitCode Message = $message Timestamp = (Get-Date).ToString("o") }) } # Compile structured JSON report $reportDir = "C:\temp\winget_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 = @{ TotalInstalled = $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 Operations Concluded" -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 system reboot is pending to complete installation changes." -ForegroundColor Yellow } Write-Host "`nStructured JSON deployment audit report saved to:" -ForegroundColor Gray Write-Host " $reportPath" -ForegroundColor Cyan } } |