适用于 PowerShell 7.0 及以上版本,Windows 10/11 内置 WinGet
从安装工具到企业级管理平台
随着 Windows Package Manager(WinGet)的持续迭代,它已经从一个简单的命令行安装工具成长为 Windows 平台软件生命周期管理的核心组件。微软在 2025 年为 WinGet 引入了 winget configure 和 winget export 等新能力,使其在配置即代码(Configuration as Code)方向上迈出了关键一步。对于运维团队而言,WinGet 不再只是”装软件的工具”,而是可以纳入 CI/CD 流水线的基础设施自动化组件。
在企业环境中,软件版本管理、环境一致性和更新回滚是最让人头疼的三件事。开发团队抱怨”我本地能跑但你那里不行”,安全团队要求所有软件必须及时打补丁,运维团队则担心某个更新导致大面积故障。这些需求都可以通过 PowerShell 与 WinGet 的深度集成来解决。
本文将从三个高级场景出发:软件版本审计与对比、YAML 配置驱动的声明式部署、更新自动化与安全回滚,展示如何用 PowerShell 构建企业级的 WinGet 包管理方案。
软件清单与版本审计
在多机环境中,了解每台机器安装了哪些软件、版本是否一致,是运维工作的起点。下面的脚本会扫描本机所有 WinGet 管理的软件,并与基准版本清单进行对比,快速识别版本偏差。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85
| function Compare-WinGetBaseline {
[CmdletBinding()] param( [Parameter(Mandatory)] [string]$BaselinePath, [switch]$ExportCurrent )
$rawOutput = winget list --accept-source-agreements 2>&1 $installed = $rawOutput | Where-Object { $_ -match '^\S' -and $_ -notmatch '^Name\s+Id' -and $_ -notmatch '^\d+ packages' } | ForEach-Object { $parts = $_ -split '\s{2,}' if ($parts.Count -ge 3) { [PSCustomObject]@{ Name = $parts[0].Trim() Id = $parts[1].Trim() Version = if ($parts.Count -ge 4) { $parts[2].Trim() } else { '未知' } Available = if ($parts.Count -ge 4) { $parts[3].Trim() } else { if ($parts.Count -eq 3) { $parts[2].Trim() } else { '' } } } } }
if ($ExportCurrent) { $baseline = $installed | ForEach-Object { [ordered]@{ Id = $_.Id; Version = $_.Version } } $baseline | ConvertTo-Json -Depth 3 | Set-Content $BaselinePath -Encoding UTF8 Write-Host "基准已导出至 $BaselinePath ($($baseline.Count) 个软件包)" ` -ForegroundColor Green return }
$baselineRaw = Get-Content $BaselinePath -Raw | ConvertFrom-Json $baselineMap = @{} foreach ($item in $baselineRaw) { $baselineMap[$item.Id] = $item.Version }
$results = foreach ($pkg in $installed) { if ($baselineMap.ContainsKey($pkg.Id)) { $expected = $baselineMap[$pkg.Id] $status = if ($pkg.Version -eq $expected) { '匹配' } elseif ([version]::TryParse($pkg.Version, [ref]$null) -and [version]::TryParse($expected, [ref]$null)) { if ([version]$pkg.Version -gt [version]$expected) { '更新' } else { '过旧' } } else { '不同' } [PSCustomObject]@{ Name = $pkg.Name Id = $pkg.Id Current = $pkg.Version Expected = $expected Status = $status } } }
$results | Sort-Object Status | Format-Table -AutoSize $summary = $results | Group-Object Status | ForEach-Object { "$($_.Name): $($_.Count)" } Write-Host "版本偏差汇总: $($summary -join ', ')" -ForegroundColor Cyan }
|
上面的脚本核心逻辑是:先用 winget list 采集当前已安装软件,再将结果与基准 JSON 文件逐项对比。首次使用时通过 -ExportCurrent 参数将当前状态保存为基准文件,后续巡检时直接运行对比即可。状态分为”匹配”、”更新”、”过旧”和”不同”四种,其中”更新”表示本机版本比基准还新(可能是自行升级的未记录变更),”过旧”则是需要关注的版本偏差。
1 2 3 4 5 6 7 8 9 10
| Name Id Current Expected Status ---- -- ------- -------- ------ 7-Zip 7zip.7zip 24.09 24.09 匹配 Visual Studio Code Microsoft.VisualStudioCode 1.98.2 1.97.0 更新 Git Git.Git 2.47.1 2.48.0 过旧 PowerShell Microsoft.PowerShell 7.5.0 7.5.0 匹配 Node.js LTS OpenJS.NodeJS.LTS 22.14.0 22.12.0 更新 Docker Desktop Docker.DockerDesktop 4.34.0 4.35.0 过旧
版本偏差汇总: 匹配: 2, 更新: 2, 过旧: 2
|
YAML 配置驱动的声明式部署
在企业环境中,软件部署配置通常需要纳入版本控制,并与团队共享。YAML 格式比 JSON 更易读、支持注释,非常适合作为声明式部署配置的载体。下面的脚本读取 YAML 配置文件,确保目标机器的软件状态与声明的期望状态一致。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137
| function Invoke-WinGetDesiredState {
[CmdletBinding()] param( [Parameter(Mandatory)] [string]$ConfigPath, [switch]$ReportOnly )
$configContent = Get-Content $ConfigPath -Raw
$lines = $configContent -split "`n" $desiredPackages = @{} $currentSection = ''
foreach ($line in $lines) { if ($line -match '^\s*#') { continue } if ($line -match '^packages:') { $currentSection = 'packages' continue } if ($currentSection -eq 'packages' -and $line -match '^\s{2}(\S+):\s*$') { $pkgId = $Matches[1] $desiredPackages[$pkgId] = @{ State = 'present'; Version = '' } $currentSection = "pkg_$pkgId" } if ($line -match '^\s{4}state:\s*(\S+)') { $desiredPackages[$pkgId]['State'] = $Matches[1] } if ($line -match '^\s{4}version:\s*(\S+)') { $desiredPackages[$pkgId]['Version'] = $Matches[1] } if ($line -match '^[^\s]') { $currentSection = '' } }
$rawOutput = winget list --accept-source-agreements 2>&1 $installedIds = @() $installedMap = @{}
$rawOutput | Where-Object { $_ -match '^\S' -and $_ -notmatch '^Name\s+Id' -and $_ -notmatch '^\d+ packages' } | ForEach-Object { $parts = $_ -split '\s{2,}' if ($parts.Count -ge 2) { $id = $parts[1].Trim() $installedIds += $id $installedMap[$id] = if ($parts.Count -ge 4) { $parts[2].Trim() } else { '未知' } } }
$actions = @()
foreach ($id in $desiredPackages.Keys) { $desired = $desiredPackages[$id] if ($desired.State -eq 'present') { if ($id -notin $installedIds) { $actions += [PSCustomObject]@{ Action = '安装' Id = $id Target = $desired.Version } } elseif ($desired.Version -and $installedMap[$id] -ne $desired.Version) { $actions += [PSCustomObject]@{ Action = '版本调整' Id = $id Target = "$($installedMap[$id]) -> $($desired.Version)" } } } elseif ($desired.State -eq 'absent' -and $id -in $installedIds) { $actions += [PSCustomObject]@{ Action = '卸载' Id = $id Target = $installedMap[$id] } } }
Write-Host "配置偏差分析完成,共 $($actions.Count) 项操作:" ` -ForegroundColor Cyan $actions | Format-Table -AutoSize
if ($ReportOnly) { Write-Host "[报告模式] 不执行实际操作" -ForegroundColor DarkYellow return }
foreach ($act in $actions) { switch ($act.Action) { '安装' { $args = @('install', '--id', $act.Id, '--accept-package-agreements', '--accept-source-agreements') if ($desiredPackages[$act.Id].Version) { $args += @('--version', $desiredPackages[$act.Id].Version) } Write-Host "安装 $($act.Id)..." -ForegroundColor Yellow -NoNewline winget @args 2>&1 | Out-Null } '卸载' { Write-Host "卸载 $($act.Id)..." -ForegroundColor Yellow -NoNewline winget uninstall --id $act.Id 2>&1 | Out-Null } '版本调整' { Write-Host "调整 $($act.Id) 版本..." -ForegroundColor Yellow -NoNewline winget install --id $act.Id --version ` $desiredPackages[$act.Id].Version ` --force --accept-package-agreements ` --accept-source-agreements 2>&1 | Out-Null } } if ($LASTEXITCODE -eq 0) { Write-Host " 完成" -ForegroundColor Green } else { Write-Host " 失败 (退出码: $LASTEXITCODE)" -ForegroundColor Red } } Write-Host "`n状态收敛完成" -ForegroundColor Green }
|
这段脚本实现了一个声明式配置引擎:读取 YAML 配置文件中声明的期望状态(present 或 absent),与本机实际状态对比,计算出需要执行的操作列表(安装、卸载、版本调整),然后逐一收敛。-ReportOnly 模式下仅展示偏差报告,不执行实际操作,适合在 CI 流水线中作为审计步骤。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15
| 配置偏差分析完成,共 4 项操作:
Action Id Target ------ -- ------ 安装 Microsoft.VisualStudioCode 1.98.2 安装 Docker.DockerDesktop 4.35.0 卸载 SomeApp.Legacy 2.1.0 版本调整 Git.Git 2.47.1 -> 2.48.0
安装 Microsoft.VisualStudioCode... 完成 安装 Docker.DockerDesktop... 完成 卸载 SomeApp.Legacy... 完成 调整 Git.Git 版本... 完成
状态收敛完成
|
更新自动化与安全回滚
自动更新是双刃剑:及时打补丁能堵住安全漏洞,但一次不兼容的更新可能让整个团队停工。下面的脚本在执行自动更新前会先创建软件快照,如果更新后出现问题,可以从快照恢复到更新前的版本。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162
| function Invoke-WinGetSafeUpdate {
[CmdletBinding()] param( [string]$SnapshotDir = (Join-Path $env:TEMP 'WinGetSnapshots'), [int]$MaxRetries = 2, [switch]$RollbackOnFailure )
if (-not (Test-Path $SnapshotDir)) { New-Item -ItemType Directory -Path $SnapshotDir | Out-Null }
$timestamp = Get-Date -Format 'yyyyMMdd_HHmmss' $snapshotPath = Join-Path $SnapshotDir "snapshot_$timestamp.json"
Write-Host "[1/4] 正在创建软件版本快照..." -ForegroundColor Cyan $rawOutput = winget list --accept-source-agreements 2>&1 $snapshot = $rawOutput | Where-Object { $_ -match '^\S' -and $_ -notmatch '^Name\s+Id' -and $_ -notmatch '^\d+ packages' } | ForEach-Object { $parts = $_ -split '\s{2,}' if ($parts.Count -ge 3) { [ordered]@{ Name = $parts[0].Trim() Id = $parts[1].Trim() Version = if ($parts.Count -ge 4) { $parts[2].Trim() } else { '未知' } } } }
$snapshot | ConvertTo-Json -Depth 3 | Set-Content $snapshotPath -Encoding UTF8 Write-Host "快照已保存: $snapshotPath ($($snapshot.Count) 个软件包)" ` -ForegroundColor Green
Write-Host "`n[2/4] 正在扫描可更新的软件..." -ForegroundColor Cyan $upgradeOutput = winget upgrade 2>&1 $upgradable = $upgradeOutput | Where-Object { $_ -match '^\S' -and $_ -notmatch '^Name\s+Id' -and $_ -notmatch '^\d+ upgrades' } | ForEach-Object { $parts = $_ -split '\s{2,}' if ($parts.Count -ge 4) { [PSCustomObject]@{ Name = $parts[0].Trim() Id = $parts[1].Trim() OldVer = $parts[2].Trim() NewVer = $parts[3].Trim() } } }
if (-not $upgradable) { Write-Host "所有软件已是最新版本,无需更新" -ForegroundColor Green return }
Write-Host "发现 $($upgradable.Count) 个可更新软件包:" -ForegroundColor Yellow $upgradable | Format-Table Name, Id, OldVer, NewVer -AutoSize
Write-Host "`n[3/4] 开始安全更新..." -ForegroundColor Cyan $results = @() $failed = @()
foreach ($pkg in $upgradable) { $success = $false $attempt = 0
while (-not $success -and $attempt -lt $MaxRetries) { $attempt++ Write-Host " 更新 $($pkg.Id) ($($pkg.OldVer) -> $($pkg.NewVer))" ` -ForegroundColor Yellow -NoNewline if ($attempt -gt 1) { Write-Host " [重试 $attempt]" -ForegroundColor DarkYellow -NoNewline }
winget upgrade --id $pkg.Id --accept-package-agreements ` --accept-source-agreements 2>&1 | Out-Null
if ($LASTEXITCODE -eq 0) { Write-Host " 完成" -ForegroundColor Green $success = $true $results += [PSCustomObject]@{ Id = $pkg.Id OldVer = $pkg.OldVer NewVer = $pkg.NewVer Status = '成功' Attempts = $attempt } } else { Write-Host " 失败" -ForegroundColor Red } }
if (-not $success) { $failed += $pkg $results += [PSCustomObject]@{ Id = $pkg.Id OldVer = $pkg.OldVer NewVer = $pkg.NewVer Status = '失败' Attempts = $MaxRetries } } }
Write-Host "`n[4/4] 更新结果汇总:" -ForegroundColor Cyan $results | Format-Table Id, OldVer, NewVer, Status, Attempts -AutoSize
if ($failed -and $RollbackOnFailure) { Write-Host "`n检测到更新失败,正在回滚到快照版本..." -ForegroundColor Red $snapshotData = Get-Content $snapshotPath -Raw | ConvertFrom-Json $updatedIds = $results | Where-Object { $_.Status -eq '成功' } | Select-Object -ExpandProperty Id
foreach ($id in $updatedIds) { $entry = $snapshotData | Where-Object { $_.Id -eq $id } if ($entry -and $entry.Version -ne '未知') { Write-Host " 回滚 $($entry.Id) 到 $($entry.Version)..." ` -ForegroundColor Yellow -NoNewline winget install --id $entry.Id --version $entry.Version ` --force --accept-package-agreements ` --accept-source-agreements 2>&1 | Out-Null if ($LASTEXITCODE -eq 0) { Write-Host " 完成" -ForegroundColor Green } else { Write-Host " 回滚失败" -ForegroundColor Red } } } Write-Host "回滚流程结束" -ForegroundColor Cyan } elseif ($failed) { Write-Host "`n提示: 更新失败的软件可使用以下命令手动回滚:" -ForegroundColor Yellow Write-Host " 回滚快照文件: $snapshotPath" -ForegroundColor DarkYellow foreach ($f in $failed) { Write-Host " winget install --id $($f.Id) " + ` "--version $($f.OldVer) --force" -ForegroundColor DarkYellow } }
Write-Host "`n快照文件: $snapshotPath" -ForegroundColor DarkGray Write-Host "可使用以下命令查看历史快照: Get-ChildItem $SnapshotDir" ` -ForegroundColor DarkGray }
|
这个脚本的四步流程设计体现了”先快照、再更新、后回滚”的安全策略。第一步将当前所有软件版本写入 JSON 快照文件;第二步扫描可更新软件列表;第三步逐个执行更新,每个软件包失败后自动重试最多 MaxRetries 次;第四步如果开启了 -RollbackOnFailure,则将所有已成功更新的软件回滚到快照版本。快照文件以时间戳命名,保存在临时目录下,可用于后续手动恢复。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39
| [1/4] 正在创建软件版本快照... 快照已保存: /tmp/WinGetSnapshots/snapshot_20260409_080000.json (48 个软件包)
[2/4] 正在扫描可更新的软件... 发现 5 个可更新软件包:
Name Id OldVer NewVer ---- -- ------ ------ 7-Zip 7zip.7zip 24.09 24.10 Visual Studio Code Microsoft.VisualStudioCode 1.98.2 1.99.0 Git Git.Git 2.48.0 2.49.0 PowerShell Microsoft.PowerShell 7.5.0 7.5.1 Docker Desktop Docker.DockerDesktop 4.35.0 4.36.0
[3/4] 开始安全更新... 更新 7zip.7zip (24.09 -> 24.10) 完成 更新 Microsoft.VisualStudioCode (1.98.2 -> 1.99.0) 完成 更新 Git.Git (2.48.0 -> 2.49.0) 完成 更新 Microsoft.PowerShell (7.5.0 -> 7.5.1) 完成 更新 Docker.DockerDesktop (4.35.0 -> 4.36.0) 失败 更新 Docker.DockerDesktop (4.35.0 -> 4.36.0) [重试 1] 失败 更新 Docker.DockerDesktop (4.35.0 -> 4.36.0) [重试 2] 失败
[4/4] 更新结果汇总:
Id OldVer NewVer Status Attempts -- ------ ------ ------ -------- 7zip.7zip 24.09 24.10 成功 1 Microsoft.VisualStudioCode 1.98.2 1.99.0 成功 1 Git.Git 2.48.0 2.49.0 成功 1 Microsoft.PowerShell 7.5.0 7.5.1 成功 1 Docker.DockerDesktop 4.35.0 4.36.0 失败 2
提示: 更新失败的软件可使用以下命令手动回滚: 回滚快照文件: /tmp/WinGetSnapshots/snapshot_20260409_080000.json winget install --id Docker.DockerDesktop --version 4.35.0 --force
快照文件: /tmp/WinGetSnapshots/snapshot_20260409_080000.json 可使用以下命令查看历史快照: Get-ChildItem /tmp/WinGetSnapshots
|
注意事项
- 版本号解析:部分软件使用非标准版本号格式(如
1.0.0-beta.3),直接用 [version] 类型转换会失败,对比时需要做容错处理。示例代码中对解析失败的版本使用了字符串直接比较
- YAML 解析依赖:完整的生产级 YAML 解析建议安装
powershell-yaml 模块(Install-Module powershell-yaml),示例中使用了简易的行解析逻辑以避免外部依赖,但不支持复杂的嵌套结构
- 快照存储位置:示例中快照保存在
$env:TEMP 目录,系统清理时可能被删除。生产环境建议将快照目录设置为持久化路径,如 $HOME\WinGetSnapshots,或推送到远程存储(如 S3、Azure Blob)
- 回滚局限性:
winget install --version 回滚依赖软件源保留旧版本安装包,部分软件(如 Chrome、Firefox)的旧版本可能已从源中移除,导致回滚失败。关键软件建议在快照时同时下载离线安装包
- 并发安全:多台机器同时运行大规模更新可能给内部软件源带来压力,建议在脚本中加入随机延迟(
Start-Sleep -Seconds (Get-Random -Max 300))进行错峰
- 日志与审计:建议将每次更新操作的结果追加写入日志文件(
Export-Csv 或 Add-Content),定期归档,方便事后审计和问题追溯