Public/Get-WingetMachineState.ps1
|
function Get-WingetMachineState { <# .SYNOPSIS Snapshot, compare, and reconcile machine package state. .DESCRIPTION Captures a complete inventory of all winget-managed packages on the system, exports it as a portable state manifest, compares two states for drift detection, and can reconcile the current machine to match a target state (install missing, optionally remove extraneous packages). This enables "Machine-as-Code" workflows: snapshot a golden machine, then replicate its package set onto any other Windows machine. .PARAMETER Export Export the current machine state to a manifest file. .PARAMETER Path Path to the state manifest file (.json or .yaml). .PARAMETER Compare Compare the current machine state against a saved manifest and report drift. .PARAMETER Reconcile Install missing packages and update outdated ones to match the target manifest. .PARAMETER RemoveExtraneous When used with -Reconcile, also uninstall packages not present in the manifest. .PARAMETER Format Output format for exported manifests: JSON (default) or YAML. .PARAMETER IncludeVersions Include specific version pins in the export (default: latest). .PARAMETER Source Only include packages from a specific source (e.g., winget, msstore). .EXAMPLE Get-WingetMachineState -Export -Path ".\golden-machine.json" Snapshots all installed packages to a JSON manifest. .EXAMPLE Get-WingetMachineState -Export -Path ".\state.yaml" -Format YAML -IncludeVersions Exports with exact version pins in YAML format. .EXAMPLE Get-WingetMachineState -Compare -Path ".\golden-machine.json" Shows what's missing, extra, or outdated vs the golden state. .EXAMPLE Get-WingetMachineState -Reconcile -Path ".\golden-machine.json" Installs all missing packages and updates outdated ones. .EXAMPLE Get-WingetMachineState -Reconcile -Path ".\golden-machine.json" -RemoveExtraneous Full reconciliation: install missing, update outdated, remove extraneous. .LINK https://github.com/thebubbsy/WingetBatch #> [CmdletBinding(DefaultParameterSetName = 'Export')] param( [Parameter(Mandatory, ParameterSetName = 'Export')] [switch]$Export, [Parameter(Mandatory, ParameterSetName = 'Compare')] [switch]$Compare, [Parameter(Mandatory, ParameterSetName = 'Reconcile')] [switch]$Reconcile, [Parameter(Mandatory, ParameterSetName = 'Compare')] [Parameter(Mandatory, ParameterSetName = 'Reconcile')] [Parameter(Mandatory, ParameterSetName = 'Export')] [ValidateNotNullOrEmpty()] [string]$Path, [Parameter(ParameterSetName = 'Reconcile')] [switch]$RemoveExtraneous, [Parameter(ParameterSetName = 'Export')] [ValidateSet('JSON', 'YAML')] [string]$Format = 'JSON', [Parameter(ParameterSetName = 'Export')] [switch]$IncludeVersions, [Parameter(ParameterSetName = 'Export')] [string]$Source ) begin { # Ensure COM API module if (-not (Get-Module -Name Microsoft.WinGet.Client)) { try { Import-Module Microsoft.WinGet.Client -ErrorAction Stop } catch { Write-Error "Microsoft.WinGet.Client module is required. Install-Module Microsoft.WinGet.Client -Force" return } } # Ensure PwshSpectreConsole for rich output if (-not (Get-Module -Name PwshSpectreConsole)) { if (Get-Module -ListAvailable -Name PwshSpectreConsole) { Import-Module PwshSpectreConsole -ErrorAction SilentlyContinue } } } process { switch ($PSCmdlet.ParameterSetName) { 'Export' { Invoke-StateExport } 'Compare' { Invoke-StateCompare } 'Reconcile' { Invoke-StateReconcile } } } end { function Invoke-StateExport { Write-Host "" Write-Host " Capturing machine package state..." -ForegroundColor Cyan $installed = Get-WinGetPackage -ErrorAction SilentlyContinue if (-not $installed) { Write-Error "No installed packages found via COM API." return } # Filter by source if specified if ($Source) { $installed = $installed | Where-Object { $_.Source -eq $Source } } $packages = [System.Collections.Generic.List[object]]::new() foreach ($pkg in $installed) { $entry = [ordered]@{ id = $pkg.Id } if ($IncludeVersions -and $pkg.InstalledVersion) { $entry['version'] = $pkg.InstalledVersion } else { $entry['version'] = 'latest' } if ($pkg.Source) { $entry['source'] = $pkg.Source } $packages.Add([PSCustomObject]$entry) } $manifest = [ordered]@{ _metadata = [ordered]@{ generator = "WingetBatch v$((Get-Module WingetBatch).Version)" created = (Get-Date).ToString('o') hostname = $env:COMPUTERNAME username = $env:USERNAME os_version = [System.Environment]::OSVersion.VersionString package_count = $packages.Count } packages = $packages } # Resolve output path $outPath = $ExecutionContext.SessionState.Path.GetUnresolvedProviderPathFromPSPath($Path) $outDir = Split-Path $outPath -Parent if ($outDir -and -not (Test-Path $outDir)) { New-Item -ItemType Directory -Path $outDir -Force | Out-Null } if ($Format -eq 'YAML') { if (-not (Get-Module -ListAvailable -Name powershell-yaml)) { Write-Warning "powershell-yaml module not found. Falling back to JSON." $outPath = $outPath -replace '\.ya?ml$', '.json' $manifest | ConvertTo-Json -Depth 10 | Out-File -FilePath $outPath -Encoding utf8 } else { Import-Module powershell-yaml -ErrorAction SilentlyContinue $manifest | ConvertTo-Yaml | Out-File -FilePath $outPath -Encoding utf8 } } else { $manifest | ConvertTo-Json -Depth 10 | Out-File -FilePath $outPath -Encoding utf8 } Write-Host "" Write-Host " [OK] " -ForegroundColor Green -NoNewline Write-Host "$($packages.Count) packages" -ForegroundColor White -NoNewline Write-Host " exported to " -ForegroundColor Green -NoNewline Write-Host $outPath -ForegroundColor Cyan Write-Host "" } function Invoke-StateCompare { $manifestPath = $ExecutionContext.SessionState.Path.GetUnresolvedProviderPathFromPSPath($Path) if (-not (Test-Path $manifestPath)) { Write-Error "State manifest not found: $manifestPath" return } Write-Host "" Write-Host " Comparing machine state against manifest..." -ForegroundColor Cyan Write-Host " Manifest: " -ForegroundColor Gray -NoNewline Write-Host $manifestPath -ForegroundColor White # Parse manifest $content = Get-Content -Raw -Path $manifestPath if ($manifestPath -match '\.ya?ml$') { if (-not (Get-Module -ListAvailable -Name powershell-yaml)) { Write-Error "powershell-yaml module required for YAML manifests." return } Import-Module powershell-yaml $manifest = ConvertFrom-Yaml $content } else { $manifest = $content | ConvertFrom-Json } $targetPackages = $manifest.packages if (-not $targetPackages) { Write-Error "No packages found in manifest." return } # Get current state $installed = Get-WinGetPackage -ErrorAction SilentlyContinue $installedMap = @{} foreach ($pkg in $installed) { if ($pkg.Id) { $installedMap[$pkg.Id] = $pkg } } # Build target map $targetMap = @{} foreach ($pkg in $targetPackages) { $targetMap[$pkg.id] = $pkg } # Classify drift $missing = [System.Collections.Generic.List[string]]::new() $outdated = [System.Collections.Generic.List[PSCustomObject]]::new() $extraneous = [System.Collections.Generic.List[string]]::new() $compliant = [System.Collections.Generic.List[string]]::new() foreach ($target in $targetPackages) { $pkgId = $target.id if ($installedMap.ContainsKey($pkgId)) { $inst = $installedMap[$pkgId] if ($inst.IsUpdateAvailable) { $outdated.Add([PSCustomObject]@{ Id = $pkgId Installed = $inst.InstalledVersion Available = $inst.AvailableVersion }) } else { $compliant.Add($pkgId) } } else { $missing.Add($pkgId) } } foreach ($instId in $installedMap.Keys) { if (-not $targetMap.ContainsKey($instId)) { $extraneous.Add($instId) } } # Display results Write-Host "" Write-Host " Drift Analysis Report" -ForegroundColor White Write-Host " $('=' * 50)" -ForegroundColor DarkGray if ($compliant.Count -gt 0) { Write-Host " [OK] Compliant: " -ForegroundColor Green -NoNewline Write-Host "$($compliant.Count) packages" -ForegroundColor White } if ($missing.Count -gt 0) { Write-Host " [!] Missing: " -ForegroundColor Red -NoNewline Write-Host "$($missing.Count) packages" -ForegroundColor White foreach ($m in $missing) { Write-Host " - $m" -ForegroundColor Red } } if ($outdated.Count -gt 0) { Write-Host " [~] Outdated: " -ForegroundColor Yellow -NoNewline Write-Host "$($outdated.Count) packages" -ForegroundColor White foreach ($o in $outdated) { Write-Host " - $($o.Id) ($($o.Installed) -> $($o.Available))" -ForegroundColor Yellow } } if ($extraneous.Count -gt 0) { Write-Host " [+] Extraneous:" -ForegroundColor Magenta -NoNewline Write-Host " $($extraneous.Count) packages" -ForegroundColor White foreach ($e in $extraneous) { Write-Host " - $e" -ForegroundColor DarkGray } } Write-Host "" $totalDrift = $missing.Count + $outdated.Count if ($totalDrift -eq 0) { Write-Host " [OK] Machine is fully compliant with target state." -ForegroundColor Green } else { Write-Host " [!] Drift detected: $totalDrift package(s) need attention." -ForegroundColor Yellow Write-Host " Run: Get-WingetMachineState -Reconcile -Path '$Path'" -ForegroundColor DarkGray } Write-Host "" # Return structured object for pipeline use [PSCustomObject]@{ Compliant = $compliant.Count Missing = $missing Outdated = $outdated Extraneous = $extraneous IsCompliant = ($totalDrift -eq 0) } } function Invoke-StateReconcile { $manifestPath = $ExecutionContext.SessionState.Path.GetUnresolvedProviderPathFromPSPath($Path) if (-not (Test-Path $manifestPath)) { Write-Error "State manifest not found: $manifestPath" return } Write-Host "" Write-Host " Reconciling machine state to target manifest..." -ForegroundColor Cyan # Parse manifest $content = Get-Content -Raw -Path $manifestPath if ($manifestPath -match '\.ya?ml$') { if (-not (Get-Module -ListAvailable -Name powershell-yaml)) { Write-Error "powershell-yaml module required for YAML manifests." return } Import-Module powershell-yaml $manifest = ConvertFrom-Yaml $content } else { $manifest = $content | ConvertFrom-Json } $targetPackages = $manifest.packages if (-not $targetPackages) { Write-Error "No packages found in manifest." return } # Get current state $installed = Get-WinGetPackage -ErrorAction SilentlyContinue $installedMap = @{} foreach ($pkg in $installed) { if ($pkg.Id) { $installedMap[$pkg.Id] = $pkg } } # Determine actions needed $toInstall = [System.Collections.Generic.List[PSCustomObject]]::new() $toUpdate = [System.Collections.Generic.List[PSCustomObject]]::new() $toRemove = [System.Collections.Generic.List[string]]::new() $targetMap = @{} foreach ($target in $targetPackages) { $pkgId = $target.id $targetMap[$pkgId] = $target if ($installedMap.ContainsKey($pkgId)) { $inst = $installedMap[$pkgId] if ($inst.IsUpdateAvailable) { $toUpdate.Add([PSCustomObject]@{ Id = $pkgId; Version = $target.version }) } } else { $toInstall.Add([PSCustomObject]@{ Id = $pkgId; Version = $target.version }) } } if ($RemoveExtraneous) { foreach ($instId in $installedMap.Keys) { if (-not $targetMap.ContainsKey($instId)) { $toRemove.Add($instId) } } } $totalActions = $toInstall.Count + $toUpdate.Count + $toRemove.Count if ($totalActions -eq 0) { Write-Host " [OK] Machine is already compliant. No actions needed." -ForegroundColor Green return } Write-Host "" Write-Host " Reconciliation Plan:" -ForegroundColor White if ($toInstall.Count -gt 0) { Write-Host " Install: $($toInstall.Count) packages" -ForegroundColor Green } if ($toUpdate.Count -gt 0) { Write-Host " Update: $($toUpdate.Count) packages" -ForegroundColor Yellow } if ($toRemove.Count -gt 0) { Write-Host " Remove: $($toRemove.Count) packages" -ForegroundColor Red } Write-Host "" # Confirm $confirmMsg = "Proceed with reconciliation of $totalActions package(s)?" if (-not $PSCmdlet.ShouldContinue($confirmMsg, "WingetBatch State Reconciliation")) { Write-Host " Cancelled." -ForegroundColor Yellow return } $successCount = 0 $failCount = 0 # Install missing foreach ($pkg in $toInstall) { Write-Host " >>> Installing: " -ForegroundColor Green -NoNewline Write-Host $pkg.Id -ForegroundColor White try { $installArgs = @{ Id = $pkg.Id; ErrorAction = 'Stop' } if ($pkg.Version -and $pkg.Version -ne 'latest') { $installArgs['Version'] = $pkg.Version } Microsoft.WinGet.Client\Install-WinGetPackage @installArgs | Out-Null Write-Host " [OK]" -ForegroundColor Green $successCount++ } catch { Write-Host " [FAIL] $_" -ForegroundColor Red $failCount++ } } # Update outdated foreach ($pkg in $toUpdate) { Write-Host " >>> Updating: " -ForegroundColor Yellow -NoNewline Write-Host $pkg.Id -ForegroundColor White try { Microsoft.WinGet.Client\Update-WinGetPackage -Id $pkg.Id -ErrorAction Stop | Out-Null Write-Host " [OK]" -ForegroundColor Green $successCount++ } catch { Write-Host " [FAIL] $_" -ForegroundColor Red $failCount++ } } # Remove extraneous foreach ($pkgId in $toRemove) { Write-Host " >>> Removing: " -ForegroundColor Red -NoNewline Write-Host $pkgId -ForegroundColor White try { Microsoft.WinGet.Client\Uninstall-WinGetPackage -Id $pkgId -ErrorAction Stop | Out-Null Write-Host " [OK]" -ForegroundColor Green $successCount++ } catch { Write-Host " [FAIL] $_" -ForegroundColor Red $failCount++ } } Write-Host "" Write-Host " $('=' * 50)" -ForegroundColor DarkGray Write-Host " Reconciliation complete: " -ForegroundColor Cyan -NoNewline Write-Host "$successCount succeeded" -ForegroundColor Green -NoNewline Write-Host ", " -ForegroundColor Gray -NoNewline Write-Host "$failCount failed" -ForegroundColor $(if ($failCount -gt 0) { 'Red' } else { 'Green' }) Write-Host "" } } } |