用指令碼無人值守裝軟體實際踩到的問題。msi 一定要走 msiexec、winget 認不出手動裝的軟體、3010 不是失敗、安裝程式會卡死。
發布於 2026-09-05・約 5 分鐘
新電腦交機要裝的東西每次都一樣,所以我把它寫成指令碼。看起來是最單純的那種自動化,實際上四個地方都出過事。
.msi
原本這樣寫:
Start-Process -FilePath "C:\Installers\7z2408-x64.msi" -ArgumentList "/qn /norestart" -Wait
跑起來會跳出安裝精靈,等人按下一步。/qn(完全靜默)像不存在一樣。
/qn
原因是 Start-Process 對一個 .msi 走的是 ShellExecute,也就是「照這個副檔名的預設動詞開啟它」,等同於在檔案總管上雙擊。預設動詞是 Install,它自己會決定要帶什麼參數,你的 ArgumentList 沒有地方可去,就被丟掉了。
Start-Process
Install
ArgumentList
沒有錯誤訊息。指令碼會回報成功,因為 msiexec 確實被啟動了。
msiexec
正確做法是自己叫 msiexec:
Start-Process -FilePath "msiexec.exe" ` -ArgumentList "/i `"C:\Installers\7z2408-x64.msi`" /qn /norestart" ` -Wait
我後來把這件事包進一個統一的入口,按副檔名決定實際要跑什麼:
msiexec.exe /i "<檔案>" <參數>
/qn /norestart
.msu
wusa.exe "<檔案>" <參數>
/quiet /norestart
.exe
/S
/silent
/quiet
.ps1
powershell -NoProfile -ExecutionPolicy Bypass -File "<檔案>"
.bat
.cmd
cmd.exe /c "<檔案> <參數>"
設定介面裡使用者只要填 /qn /norestart,/i "檔案" 由程式補上去。少了這個轉換,每個人都會照直覺寫成前面那個壞掉的版本。
/i "檔案"
winget list
判斷「要不要裝」的直覺寫法:
winget list --id Google.Chrome --exact if ($LASTEXITCODE -ne 0) { winget install --id Google.Chrome }
實測 Chrome 好端端地裝在機器上,winget list --id Google.Chrome --exact 仍然回報找不到。結果是這支指令碼每跑一次就重裝一次 Chrome。
winget list --id Google.Chrome --exact
原因是 winget 只能可靠地認出「它自己裝的」套件。對於手動安裝或原廠預裝的,它得靠名稱與發行者去猜這個套件對應到「新增或移除程式」裡的哪一筆,猜不到就當作沒裝。
補救方式是再開一路,直接讀解除安裝登錄機碼比對顯示名稱:
$roots = @( "HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall\*", "HKLM:\SOFTWARE\WOW6432Node\Microsoft\Windows\CurrentVersion\Uninstall\*", "HKCU:\SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall\*" ) foreach ($r in $roots) { foreach ($it in (Get-ItemProperty $r -ErrorAction SilentlyContinue)) { if ($it.DisplayName -like "Google Chrome*") { return $true } } }
三個位置都要看:64 位元程式在第一個、32 位元的在 WOW6432Node、只裝給目前使用者的在 HKCU。少看一個就會漏判。
WOW6432Node
HKCU
兩路任一個認得就跳過不裝。這個「比對名稱」的樣式我做成可填的參數,因為不同版本的軟體顯示名稱會變,寫死在程式裡遲早失準。
if ($exitCode -ne 0) { throw } 這種寫法會把兩個成功狀態誤判成失敗:
if ($exitCode -ne 0) { throw }
其他幾個常踩到的,順手翻成人看得懂的話寫進記錄檔:
winget 自己那套是有號 32 位元負數,跟 msiexec 完全不同系統。-1978335189(0x8A15002B)代表「已是最新版本」,這個要當成功處理,不然重跑指令碼會整批報錯。
-1978335189
防毒的原廠移除工具是最典型的:它們幾乎都會跳互動視窗。交機當下沒人守在電腦前,整個流程就停在那裡,隔天早上回來看還是同一個畫面。
$proc = Start-Process @sp -PassThru if (-not $proc.WaitForExit($TimeoutMinutes * 60 * 1000)) { try { $proc.Kill() } catch { } throw "安裝逾時($TimeoutMinutes 分鐘),已強制結束。" } $proc.WaitForExit() $code = $proc.ExitCode
WaitForExit(毫秒) 這個多載回傳布林值,逾時回 false;後面那個沒帶參數的 WaitForExit() 不是多餘的,它會等背景執行緒把輸出流收乾淨,少了它 ExitCode 偶爾會讀到空的。
WaitForExit(毫秒)
false
WaitForExit()
ExitCode
逾時長度要看項目分開設。一般軟體 30 分鐘夠了,Office 要下載三到四 GB,我給 90 分鐘。
還有一個相關的:移除舊 Office 那一步如果逾時,一定要 Kill()。不砍掉它會在背景繼續動,接著跑的「安裝 Office」會跟它撞在一起,兩邊都失敗,而錯誤訊息不會告訴你是這個原因。
Kill()
這四件事的共通點都是「失敗不會叫」。參數被忽略、重複安裝、成功被當成失敗、流程卡住不動,沒有一個會拋例外。自動化指令碼真正的成本不在寫,在於你得知道它什麼時候在騙你。
所以我在流程最後加了一段回頭讀系統實際狀態的驗證,不信任任何一支安裝程式的結束碼。
標籤:Windows、PowerShell、winget、msiexec、自動化