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. Only packages that come from a WinGet source (winget, msstore) are exported and considered for removal. Programs WinGet merely sees in Add/Remove Programs (drivers, runtimes, OEM tools) cannot be reinstalled from a manifest and are never touched unless -IncludeUnmanaged is used for export. .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 WinGet-sourced packages not present in the manifest. .PARAMETER Force With -Reconcile, skip the confirmation prompt (for unattended use). .PARAMETER Format Output format for exported manifests: JSON (default) or YAML. .PARAMETER IncludeVersions Include specific version pins in the export (default: latest). .PARAMETER IncludeUnmanaged Also export packages that have no WinGet source (informational only; they cannot be reinstalled). .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 = 'Reconcile')] [switch]$Force, [Parameter(ParameterSetName = 'Export')] [ValidateSet('JSON', 'YAML')] [string]$Format = 'JSON', [Parameter(ParameterSetName = 'Export')] [switch]$IncludeVersions, [Parameter(ParameterSetName = 'Export')] [switch]$IncludeUnmanaged, [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 } } # --- Helpers (defined here so they exist before process{} calls them) --- function Read-StateManifest { param([string]$ManifestPath) if (-not (Test-Path $ManifestPath)) { throw "State manifest not found: $ManifestPath" } $content = Get-Content -Raw -Path $ManifestPath if ($ManifestPath -match '\.ya?ml$') { if (-not (Get-Module -ListAvailable -Name powershell-yaml)) { throw "powershell-yaml module required for YAML manifests (Install-Module powershell-yaml)." } Import-Module powershell-yaml -ErrorAction Stop $manifest = ConvertFrom-Yaml $content } else { $manifest = $content | ConvertFrom-Json } $targets = foreach ($pkg in @($manifest.packages)) { if (-not $pkg) { continue } $id = if ($pkg -is [string]) { $pkg } else { [string]$pkg.id } if (-not $id) { continue } $ver = if ($pkg -isnot [string] -and $pkg.version) { [string]$pkg.version } else { 'latest' } $src = if ($pkg -isnot [string] -and $pkg.source) { [string]$pkg.source } else { $null } [PSCustomObject]@{ Id = $id; Version = $ver; Source = $src } } if (-not $targets) { throw "No packages found in manifest: $ManifestPath" } return @($targets) } function Get-InstalledMap { $map = @{} foreach ($pkg in (Microsoft.WinGet.Client\Get-WinGetPackage -ErrorAction SilentlyContinue)) { if ($pkg.Id -and -not $map.ContainsKey($pkg.Id)) { $map[$pkg.Id] = $pkg } } return $map } # Returns the drift between a target list and the installed map function Get-StateDrift { param($Targets, [hashtable]$InstalledMap) $missing = [System.Collections.Generic.List[PSCustomObject]]::new() $outdated = [System.Collections.Generic.List[PSCustomObject]]::new() $compliant = [System.Collections.Generic.List[string]]::new() $unmanageable = [System.Collections.Generic.List[string]]::new() $targetIds = @{} foreach ($t in $Targets) { $targetIds[$t.Id] = $true if ($InstalledMap.ContainsKey($t.Id)) { $inst = $InstalledMap[$t.Id] if ($t.Version -ne 'latest') { # Pinned: compliant only when exactly that version is installed if ((Compare-WingetVersion -ReferenceVersion $inst.InstalledVersion -DifferenceVersion $t.Version) -eq 0) { $compliant.Add($t.Id) } else { $outdated.Add([PSCustomObject]@{ Id = $t.Id; Installed = $inst.InstalledVersion; Target = $t.Version; Pinned = $true; Source = $inst.Source }) } } elseif ($inst.IsUpdateAvailable) { $outdated.Add([PSCustomObject]@{ Id = $t.Id; Installed = $inst.InstalledVersion; Target = @($inst.AvailableVersions)[0]; Pinned = $false; Source = $inst.Source }) } else { $compliant.Add($t.Id) } } elseif ($t.Id -match '^(ARP|MSIX)\\') { # Add/Remove Programs entries from old exports cannot be installed by WinGet $unmanageable.Add($t.Id) } else { $missing.Add($t) } } # Extraneous = WinGet-sourced packages not in the manifest. Unsourced entries are ignored. $extraneous = [System.Collections.Generic.List[string]]::new() foreach ($id in $InstalledMap.Keys) { if (-not $targetIds.ContainsKey($id) -and $InstalledMap[$id].Source) { $extraneous.Add($id) } } [PSCustomObject]@{ Missing = $missing Outdated = $outdated Compliant = $compliant Extraneous = @($extraneous | Sort-Object) Unmanageable = $unmanageable } } function Invoke-StateExport { Write-Host "" Write-Host " Capturing machine package state..." -ForegroundColor Cyan $installed = @(Microsoft.WinGet.Client\Get-WinGetPackage -ErrorAction SilentlyContinue) if ($installed.Count -eq 0) { Write-Error "No installed packages found via COM API." return } $skipped = 0 if (-not $IncludeUnmanaged) { $managed = @($installed | Where-Object { $_.Source }) $skipped = $installed.Count - $managed.Count $installed = $managed } if ($Source) { $installed = @($installed | Where-Object { $_.Source -eq $Source }) } $packages = [System.Collections.Generic.List[object]]::new() foreach ($pkg in ($installed | Sort-Object Id)) { $entry = [ordered]@{ id = $pkg.Id } $entry['version'] = if ($IncludeVersions -and $pkg.InstalledVersion) { $pkg.InstalledVersion } else { '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 } $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' -and -not (Get-Module -ListAvailable -Name powershell-yaml)) { Write-Warning "powershell-yaml module not found. Falling back to JSON." $outPath = $outPath -replace '\.ya?ml$', '.json' $Format = 'JSON' } if ($Format -eq 'YAML') { Import-Module powershell-yaml -ErrorAction Stop $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 if ($skipped -gt 0) { Write-Host " Skipped $skipped programs with no WinGet source (use -IncludeUnmanaged to list them)." -ForegroundColor DarkGray } Write-Host "" } function Invoke-StateCompare { $manifestPath = $ExecutionContext.SessionState.Path.GetUnresolvedProviderPathFromPSPath($Path) try { $targets = Read-StateManifest -ManifestPath $manifestPath } catch { Write-Error $_.Exception.Message; return } Write-Host "" Write-Host " Comparing machine state against manifest..." -ForegroundColor Cyan Write-Host " Manifest: " -ForegroundColor Gray -NoNewline Write-Host $manifestPath -ForegroundColor White $drift = Get-StateDrift -Targets $targets -InstalledMap (Get-InstalledMap) Write-Host "" Write-Host " Drift Analysis Report" -ForegroundColor White Write-Host " $('=' * 50)" -ForegroundColor DarkGray if ($drift.Compliant.Count -gt 0) { Write-Host " [OK] Compliant: " -ForegroundColor Green -NoNewline Write-Host "$($drift.Compliant.Count) packages" -ForegroundColor White } if ($drift.Missing.Count -gt 0) { Write-Host " [!] Missing: " -ForegroundColor Red -NoNewline Write-Host "$($drift.Missing.Count) packages" -ForegroundColor White foreach ($m in $drift.Missing) { $v = if ($m.Version -ne 'latest') { " (v$($m.Version))" } else { '' } Write-Host " - $($m.Id)$v" -ForegroundColor Red } } if ($drift.Outdated.Count -gt 0) { Write-Host " [~] Outdated: " -ForegroundColor Yellow -NoNewline Write-Host "$($drift.Outdated.Count) packages" -ForegroundColor White foreach ($o in $drift.Outdated) { $pin = if ($o.Pinned) { ' (pinned)' } else { '' } Write-Host " - $($o.Id) ($($o.Installed) -> $($o.Target))$pin" -ForegroundColor Yellow } } if ($drift.Extraneous.Count -gt 0) { Write-Host " [+] Extraneous:" -ForegroundColor Magenta -NoNewline Write-Host " $($drift.Extraneous.Count) packages" -ForegroundColor White foreach ($e in $drift.Extraneous) { Write-Host " - $e" -ForegroundColor DarkGray } } if ($drift.Unmanageable.Count -gt 0) { Write-Host " [-] Skipped: $($drift.Unmanageable.Count) manifest entries have no WinGet source and cannot be installed" -ForegroundColor DarkGray } Write-Host "" $totalDrift = $drift.Missing.Count + $drift.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 = $drift.Compliant.Count Missing = @($drift.Missing | ForEach-Object { $_.Id }) Outdated = $drift.Outdated Extraneous = $drift.Extraneous IsCompliant = ($totalDrift -eq 0) } } function Invoke-StateReconcile { $manifestPath = $ExecutionContext.SessionState.Path.GetUnresolvedProviderPathFromPSPath($Path) try { $targets = Read-StateManifest -ManifestPath $manifestPath } catch { Write-Error $_.Exception.Message; return } Write-Host "" Write-Host " Reconciling machine state to target manifest..." -ForegroundColor Cyan $drift = Get-StateDrift -Targets $targets -InstalledMap (Get-InstalledMap) $toRemove = if ($RemoveExtraneous) { $drift.Extraneous } else { @() } $totalActions = $drift.Missing.Count + $drift.Outdated.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 foreach ($m in $drift.Missing) { Write-Host " + install $($m.Id)$(if ($m.Version -ne 'latest') { " v$($m.Version)" })" -ForegroundColor Green } foreach ($o in $drift.Outdated) { Write-Host " ~ $(if ($o.Pinned) { 'pin ' } else { 'update ' }) $($o.Id) $($o.Installed) -> $($o.Target)" -ForegroundColor Yellow } foreach ($r in $toRemove) { Write-Host " - remove $r" -ForegroundColor Red } Write-Host "" if (-not $Force) { $confirmMsg = "Proceed with reconciliation of $totalActions package(s)?" if (-not $PSCmdlet.ShouldContinue($confirmMsg, "WingetBatch State Reconciliation")) { Write-Host " Cancelled." -ForegroundColor Yellow return } } Invoke-WingetAutoSnapshot -Reason 'Get-WingetMachineState -Reconcile' $successCount = 0 $failCount = 0 $report = { param($result) if ($result.Succeeded) { Write-Host " [OK]" -ForegroundColor Green return $true } Write-Host " [FAIL] $($result.Message)" -ForegroundColor Red return $false } foreach ($pkg in $drift.Missing) { Write-Host " >>> Installing: " -ForegroundColor Green -NoNewline Write-Host $pkg.Id -ForegroundColor White $r = Invoke-WingetPackageAction -Action Install -Id $pkg.Id -Version $pkg.Version -Source $pkg.Source -Options @{ Mode = 'Silent' } if (& $report $r) { $successCount++ } else { $failCount++ } } foreach ($pkg in $drift.Outdated) { Write-Host " >>> $(if ($pkg.Pinned) { 'Pinning' } else { 'Updating' }): " -ForegroundColor Yellow -NoNewline Write-Host "$($pkg.Id) -> $($pkg.Target)" -ForegroundColor White if ($pkg.Pinned) { # Verified move to an exact (possibly older) version $r = Set-WingetPackageVersion -Id $pkg.Id -Version $pkg.Target -Source $pkg.Source } else { $r = Invoke-WingetPackageAction -Action Update -Id $pkg.Id -Source $pkg.Source -Options @{ Mode = 'Silent' } } if (& $report $r) { $successCount++ } else { $failCount++ } } foreach ($pkgId in $toRemove) { Write-Host " >>> Removing: " -ForegroundColor Red -NoNewline Write-Host $pkgId -ForegroundColor White $r = Invoke-WingetPackageAction -Action Uninstall -Id $pkgId -Options @{ Mode = 'Silent' } if (& $report $r) { $successCount++ } else { $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 "" } } process { switch ($PSCmdlet.ParameterSetName) { 'Export' { Invoke-StateExport } 'Compare' { Invoke-StateCompare } 'Reconcile' { Invoke-StateReconcile } } } } |