每當有新進員工報到,或是更換 PC 時,承辦人員就要一邊看操作手冊一邊逐台設定 ── 在中小企業的資訊系統部門,這至今仍是常見的景象。問題不只是時間。手動作業沒有可重現性,「只有這台端末的設定不一樣」這種問題會在之後才顯現出來。操作手冊在未更新的情況下逐漸過時,承辦人員一換,細節就會流失。
Windows 標準內建套件管理員 winget,應用程式的導入只要一行指令即可完成。此外,若使用 WinGet Configuration,還能以一份 YAML 檔案,用宣告式(不是條列步驟,而是寫出「最終希望達到的狀態」的方式)表達應用程式與設定。而 winget 未涵蓋的範圍(印表機、網路磁碟機、公司內部標準的登錄設定等),則可以用 PowerShell 補足。
本文將以實際可運用的細緻度,整理把配置操作手冊替換成「可執行檔案」的方法。
1. 先講結論
- 應用程式導入交給 winget 處理。
winget install標準支援靜默安裝。1 - 既有 PC 的組態可以用
winget export匯出。但僅限於 winget 管理下的套件,不包含設定。2 - 想以宣告式進行,就用 WinGet Configuration(
winget configure)。以 PowerShell DSC 為基礎,可以把應用程式與設定寫在同一份 YAML 中。要求 Windows 10 1809 以上 + winget 1.6 以上。3 - winget 不足的部分用 PowerShell 補齊。包括印表機、共用磁碟機、登錄、Windows 功能、本機帳戶等。
- 想從 PowerShell 操作,可以使用
Microsoft.WinGet.Client模組。可以使用Install-WinGetPackage等 Cmdlet。4 - 系統內容(System context,例如 SYSTEM 帳戶等,與登入中使用者不同帳戶的執行)需要特別注意。Microsoft 目前也僅將其列為未來的開發項目,務必以實際的執行帳戶進行驗證。3
- 務必確保冪等性(不論執行幾次都得到相同結果)。配置作業本來就會中途失敗,必須能夠反覆重新執行。
- 公司內部自製應用程式不必勉強放上 winget,用 PowerShell 靜默執行才是實際做法。
2. winget 的基本 ── 無人安裝的寫法
首先,先掌握不需互動、能確實安裝的固定寫法。1
# 準確指定 ID 來安裝(-e 為完全比對,--id 指定 ID)
# --silent : 不顯示 UI
# --accept-package-agreements : 同意套件的使用條款
# --accept-source-agreements : 同意來源的使用條款
# --scope machine : 為所有使用者安裝(僅限支援的套件)
# 換行用的反引號要放在行尾。若後面接註解就不算是換行
winget install --id Google.Chrome -e --silent `
--accept-package-agreements --accept-source-agreements --scope machine
# 查詢 ID
winget search "Visual Studio Code"
winget show --id Microsoft.VisualStudioCode
忘記加上 --accept-* 的話,無人執行會停在等待同意的畫面。這是配置自動化中最先卡關的地方。
--scope machine 並非適用於所有套件,有些應用程式只能以使用者為單位導入。這種情況下,要設計成在首次登入時以使用者內容執行。
是否支援,在安裝之前就能確認。winget show 接受 --scope 參數,因此可以事先查詢是否備有機器範圍(machine scope)的安裝程式。5
# 確認是否有機器範圍(machine scope)的安裝程式
winget show --id Google.Chrome -e --scope machine
# 為了比較,也看一下使用者範圍(user scope)
winget show --id Google.Chrome -e --scope user
若沒有對應到指定範圍的安裝程式,就會顯示相應的訊息。在配置設定檔中寫下 "scope": "machine" 之前,先用這個方式把要處理的套件全部確認一遍,就能避免無人執行到一半才第一次失敗。
3. 匯出現有 PC 的組態 ── export / import
若已經有整備完成的「標準 PC」,就可以把它的組態轉存成檔案。2
# 從標準 PC 把已導入套件的清單寫出為 JSON
winget export --output D:\kitting\apps.json --include-versions
# 在新的 PC 上還原
winget import --import-file D:\kitting\apps.json `
--accept-package-agreements --accept-source-agreements --ignore-unavailable
輸出的 JSON 結構如下(值為範例)。2
{
"CreationDate": "2026-07-25T10:40:00.000-00:00",
"Sources": [
{
"Packages": [
{ "PackageIdentifier": "Google.Chrome", "Version": "126.0.6478.127" },
{ "PackageIdentifier": "Microsoft.VisualStudioCode", "Version": "1.101.2" },
{ "PackageIdentifier": "7zip.7zip", "Version": "24.09" }
],
"SourceDetails": {
"Argument": "https://cdn.winget.microsoft.com/cache",
"Identifier": "Microsoft.Winget.Source_8wekyb3d8bbwe",
"Name": "winget",
"Type": "Microsoft.PreIndexed.Package"
}
}
],
"WinGetVersion": "1.9.25180"
}
如你所見,內容是套件識別碼與版本的清單(若不加 --include-versions,就不會有 Version,import 會安裝最新版本)。2 也就是說,winget import 所做的只是「安裝這個識別碼的套件」而已,應用程式的設定,以及非透過 winget 安裝的應用程式,這裡完全沒有記錄。實際打開 JSON 來看,就能具體理解這個限制。
理解這個限制非常重要。
| 能做到的事 | 做不到的事 |
|---|---|
| 重現 winget 管理下的套件清單 | 應用程式內部設定的移轉 |
固定版本(--include-versions) |
非透過 winget 安裝的應用程式・公司內部自製應用程式 |
跳過不存在的套件(--ignore-unavailable) |
授權驗證、登入狀態 |
也就是說,winget import 是出發點,而不是配置作業的全部。剩下的部分要如何補齊,才是本文的重點。
4. 以宣告式撰寫 ── WinGet Configuration
winget configure 是一套用 YAML 宣告「最終希望達到的狀態」,再透過 PowerShell DSC 套用的機制。3 與程序式腳本相比,有三項優點。
- 若已經是理想狀態,就不做任何事(冪等)
- 應用程式的導入,以及 Windows、應用程式的設定,都能用一份檔案表達
- 即使中途失敗,只要重新執行同一份檔案即可
# kitting.winget ── 宣告標準端末的理想狀態
properties:
configurationVersion: 0.2.0
resources:
- resource: Microsoft.WinGet.DSC/WinGetPackage
id: chrome
directives:
description: 導入 Google Chrome
allowPrerelease: true
settings:
id: Google.Chrome
source: winget
- resource: Microsoft.WinGet.DSC/WinGetPackage
id: vscode
directives:
description: 導入 Visual Studio Code
settings:
id: Microsoft.VisualStudioCode
source: winget
- resource: Microsoft.Windows.Developer/DeveloperMode
id: devmode
directives:
description: 啟用開發人員模式(僅限開發用端末)
allowPrerelease: true
settings:
Ensure: Present
# 套用前先確認內容(顯示將執行的操作)
winget configure show --file D:\kitting\kitting.winget
# 套用
winget configure --file D:\kitting\kitting.winget --accept-configuration-agreements
要求為Windows 10 版本 1809(組建 17763)以上或 Windows 11,以及 winget 1.6.2631 以上。3 若環境中仍有舊型端末,下一章以 PowerShell 進行的組態設定會更為確實。
4.1. directives 的解讀方式 ── allowPrerelease 不是應用程式的問題
若把 YAML 從其他範例東拼西湊而成,一定會搞混的就是 directives。這裡寫的是與資源處理方式相關的指示,而不是要安裝的應用程式的指定(那是 settings 那一側的事)。
description……執行時顯示的說明文字。winget configure show的輸出會直接呈現這段文字,因此務必填寫allowPrerelease……允許使用提供 DSC 資源的 PowerShell 模組的預發行版本的指示。並不代表「安裝應用程式的預發行版本(beta 版)」的意思。
這個區別很重要。因為 allowPrerelease 是以模組為單位的設定,在使用同一個 resource: 的項目之間,值卻不一致的情況,大多是拼接範例造成的不一致。上面的範例中,Microsoft.WinGet.DSC/WinGetPackage 的兩個項目裡只有 chrome 加上了這個屬性,但沒有理由依應用程式而異,若是同一種資源類型,請保持一致。判斷基準只有一個:「提供該資源的模組是否已發行穩定版」,若有穩定版就拿掉,只有在使用尚無穩定版、只有預發行版本的模組時才加上。
4.2. WinGet Configuration 與自製 PowerShell 的分工
「既然 WinGet Configuration 是冪等的,為什麼下一章又要自己寫冪等的程式碼?」這是理所當然的疑問。答案是兩者能涵蓋的範圍不同,並非互相替代的關係。以下依需求整理。
| 需求 | WinGet Configuration | 自製 PowerShell(第 5 章) | 實務上的選擇 |
|---|---|---|---|
| winget 公開應用程式的導入 | ◎ 標準的 WinGetPackage 資源 |
○ 可以寫,但要自己實作冪等 | Configuration |
| Windows 設定(開發人員模式等) | ○ 若有對應的 DSC 資源 | ○ | 有資源的話用 Configuration |
| 任意的登錄值(公司內部標準設定) | △ 視對應資源而定 | ◎ 什麼都能寫 | PowerShell |
| 共用資料夾中的公司內部應用程式(MSI/EXE) | △ 超出標準資源的範圍 | ◎ | PowerShell |
| 網路磁碟機・共用印表機 | × 使用者單位・登入時的處理 | ◎ | PowerShell(非提升權限階段) |
| 部門別・機型別的差異 | △ 分開 YAML | ◎ 只需替換設定 JSON | PowerShell |
| 目標作業系統較舊(低於 1809・winget 低於 1.6) | × 不符合要求 | ◎ | PowerShell |
| 用結束代碼向部署工具回報是否需要重新啟動 | △ 不易控制 | ◎ 可以自行決定 | PowerShell |
結論是「能放上 winget 這個舞台的就放到 Configuration,放不上的就放到 PowerShell」。不過,若兩者並用,應用程式清單只能放在其中一方。若在 Configuration 的 YAML 中寫了套件,又在第 5 章 kitting.config.json 的 wingetPackages 中寫入相同內容,修改其中一邊時,另一邊就會維持舊狀態。若要採用併用 Configuration 的架構,實務上的做法是把應用程式導入的處理從 PowerShell 這一側移除,JSON 只保留登錄值、共用印表機等Configuration 無法處理的項目。反過來說,想單靠 Configuration 涵蓋一切、四處尋找對應資源,往往是不划算的投入。
5. 以 PowerShell 補足 ── winget 不做的部分
實務配置作業中佔去大半功夫的,其實是應用程式導入以外的部分。這裡就是 PowerShell 上場的時候。重點是全部都要寫成「若已執行過就不做任何事」的形式。
這一章篇幅較長,先展示整體結構。出場的只有三個檔案,是擁有一份決定「要安裝什麼」的 JSON,再由兩個腳本以不同權限讀取這樣的架構。
flowchart TB
CFG["kitting.config.json<br/>「這台端末應有的樣貌」定義"]
subgraph ADM["管理員階段 ── 提升權限,每台端末一次"]
A1["Invoke-KsKitting.ps1"]
A2["資料夾 / HKLM 登錄機碼 /<br/>Windows 功能 / winget 套件 /<br/>公司內部應用程式-MSI、EXE"]
A3["把設定 JSON 分發到 ProgramData 底下"]
A1 --> A2
A1 --> A3
end
subgraph USR["使用者階段 ── 未提升權限,每次使用者登入時"]
U1["Invoke-KsUserKitting.ps1"]
U2["HKCU 登錄機碼 /<br/>網路磁碟機 /<br/>共用印表機"]
U1 --> U2
end
CFG --> A1
A3 -->|"重新讀取已分發的設定"| U1
圖 1:配置作業的三部分結構。相同的定義,由權限不同的兩個階段分別讀取
劃分方式的原則只有一個。對整台機器生效的設定放在管理員階段,依使用者建立的設定放在使用者階段。如果把這兩者混在一起,如後文所述,一定會發生「明明已配置的磁碟機卻看不到」這類事故。
接下來出現的程式碼很長,先說明哪裡寫了什麼。管理員腳本內部以 --- N. --- 的註解做區隔,請只閱讀需要的部分即可。
| 區段 | 內容 | 應該閱讀的對象 |
|---|---|---|
| 開頭(前置處理) | 讀取設定 JSON、分發到 C:\ProgramData、以 Start-Transcript 開始記錄日誌 |
所有人 |
--- 1. 標準資料夾 --- |
建立資料夾。冪等寫法中最簡單的範例 | 想了解冪等寫法的人 |
--- 2. 登錄設定 --- |
寫入 HKLM。重點是不只比較值,連類型也要比較 | 想分發公司內部標準設定的人 |
--- 3. Windows 功能 --- |
啟用功能,以及判斷是否需要重新啟動的方式 | 需要 .NET Framework 3.5 等功能的人 |
--- 4. winget 套件 --- |
判斷是否已導入,以及不信任結束代碼的寫法 | 只想看 winget 部分的人 |
--- 5. 公司內部應用程式 --- |
靜默執行共用資料夾中的 MSI/EXE。版本比較與結束代碼的處理 | 想分發公司內部應用程式的人 |
| 結尾(結束處理) | 3010 / 1641 / 1 / 0 的分別回傳 | 需要與部署工具(Intune 等)串接的人 |
這個腳本會讀取 kitting.config.json。完整的設定檔範例已隨本文的範例程式碼(文章末尾的 zip)一併附上 kitting.config.json,這裡先展示其結構。
{
"folders": [ "C:\\Work", "C:\\KsTools" ],
"registry": [
{ "key": "HKLM:\\SOFTWARE\\Policies\\Microsoft\\Edge",
"name": "HomepageLocation", "value": "https://intra.example.co.jp/", "type": "String" }
],
"userRegistry": [
{ "key": "HKCU:\\Software\\Microsoft\\Windows\\CurrentVersion\\Explorer\\Advanced",
"name": "HideFileExt", "value": 0, "type": "DWord" }
],
"windowsFeatures": [ "NetFx3" ],
"wingetPackages": [ { "id": "Google.Chrome", "scope": "machine" } ],
"internalApps": [
{ "displayName": "KsApp 業務系統 用戶端",
"installer": "\\\\fileserver\\deploy\\KsApp\\KsAppSetup.msi",
"arguments": "/quiet /norestart INSTALLDIR=\"C:\\Program Files\\KsApp\"",
"version": "3.2.0" }
],
"drives": [ { "letter": "S", "path": "\\\\fileserver\\share" } ],
"printers": [ { "name": "\\\\printserver\\MFP-1F", "connection": "\\\\printserver\\MFP-1F" } ]
}
各鍵的角色,以及由哪個階段讀取,整理如下表。設計理念全部反映在這張表中。
| 鍵 | 內容 | 讀取階段 | 補充說明 |
|---|---|---|---|
folders |
要建立的標準資料夾路徑 | 管理員(提升權限) | 若已存在則不做任何事 |
registry |
寫入 HKLM 的公司內部標準設定 | 管理員(提升權限) | 政策類(Edge 首頁等)。對所有使用者生效 |
userRegistry |
寫入 HKCU 的個別使用者設定 | 使用者(未提升權限) | 副檔名顯示等。寫在 HKLM 不會生效 |
windowsFeatures |
要啟用的 Windows 選用功能 | 管理員(提升權限) | 有時需要重新啟動 |
wingetPackages |
要以 winget 安裝的套件 id 與 scope |
管理員(提升權限) | scope 要事先用 winget show --scope 確認(第 2 章) |
internalApps |
共用資料夾中的公司內部應用程式(MSI/EXE) | 管理員(提升權限) | displayName 要與「程式和功能」中的顯示名稱完全一致 |
drives |
網路磁碟機的配置 | 使用者(未提升權限) | 以登入工作階段為單位。若在提升權限的一側建立,使用者將看不到 |
printers |
連接共用印表機 | 使用者(未提升權限) | 同上。會依使用者個別建立連線 |
「讀取階段」欄分成兩種,這本身就是這份設定檔的設計理念。新增項目時,請先判斷「這是機器的設定,還是使用者的設定」,再決定要放入哪一個鍵。
之所以把 registry 和 userRegistry 分開,是因為套用的對象不同。像檔案總管的「顯示副檔名」(HideFileExt)這類設定,看的不是 HKLM 的政策,而是各使用者的 HKCU。即使在管理員階段寫入 HKLM,顯示也不會改變。這類使用者單位的設定要放進 userRegistry,並在後述的未提升權限階段套用。
internalApps 的 displayName 請與「程式和功能」中顯示的名稱完全一致。若指定 version,還會確認是否已安裝該版本以上(省略時只以名稱是否一致來判斷)。
應用程式導入本身,用前一章的 winget import 或 WinGet Configuration 也能完成,但這個腳本也把 wingetPackages 的導入包含在內。把設定檔整合成一份,「這台端末要安裝什麼」的定義就能集中在一處,執行也只需一次。反過來說,若設定檔中寫了項目卻放著不讓腳本讀取,就會發生把根本沒安裝的端末記錄成「配置成功」的情況。
#Requires -RunAsAdministrator
[CmdletBinding()]
param(
[string] $ConfigPath = "$PSScriptRoot\kitting.config.json"
)
$ErrorActionPreference = 'Stop'
# 5.1 的 Get-Content 若沒有 BOM,會以 ANSI 字碼頁讀取。
# 在日文環境中讀取 UTF-8 的 JSON 會亂碼,因此明確指定編碼
$config = Get-Content $ConfigPath -Raw -Encoding utf8 | ConvertFrom-Json
$rebootRequired = $false
$rebootInitiated = $false
$log = "C:\ProgramData\KsKitting\kitting_$(Get-Date -f yyyyMMdd_HHmmss).log"
$null = New-Item -Path (Split-Path $log) -ItemType Directory -Force
# 後述的使用者單位階段是在另一個處理序中執行的,因此要把設定
# 分發到所有使用者都能讀取的位置。
# 若把發布物放在這個位置直接執行,複製來源與目的地會是同一個檔案。
# Copy-Item 把複製到自身視為錯誤,因此在 $ErrorActionPreference = 'Stop'
# 的設定下,腳本會在配置開始之前就整個停止
$sharedConfig = 'C:\ProgramData\KsKitting\kitting.config.json'
$sourceFull = (Resolve-Path $ConfigPath).ProviderPath
$sharedFull = if (Test-Path $sharedConfig) { (Resolve-Path $sharedConfig).ProviderPath } else { '' }
if (-not [string]::Equals($sourceFull, $sharedFull, 'OrdinalIgnoreCase')) {
Copy-Item $ConfigPath $sharedConfig -Force
}
Start-Transcript -Path $log -Append
try {
# --- 1. 標準資料夾 ------------------------------------------------
foreach ($dir in $config.folders) {
if (-not (Test-Path $dir)) {
$null = New-Item -Path $dir -ItemType Directory
Write-Verbose "建立: $dir"
}
}
# --- 2. 公司內部標準的登錄設定 --------------------------------------
foreach ($reg in $config.registry) {
if (-not (Test-Path $reg.key)) { $null = New-Item -Path $reg.key -Force }
$item = Get-Item -Path $reg.key
$exists = $item.GetValueNames() -contains $reg.name
# 不只比較「值」,也要比較「型別」。REG_SZ 的 "1" 和 DWORD 的 1
# 在 PowerShell 的比較中會被視為相等,導致把實際上並未生效的設定
# 誤判為「已套用」
$sameValue = $exists -and $item.GetValue($reg.name) -eq $reg.value
$sameKind = $exists -and $item.GetValueKind($reg.name).ToString() -eq $reg.type
if (-not ($sameValue -and $sameKind)) {
Set-ItemProperty -Path $reg.key -Name $reg.name -Value $reg.value -Type $reg.type
Write-Verbose "設定: $($reg.key)\$($reg.name) = $($reg.value) ($($reg.type))"
}
}
# --- 3. Windows 功能 --------------------------------------------------
foreach ($feature in $config.windowsFeatures) {
$state = Get-WindowsOptionalFeature -Online -FeatureName $feature
if ($state.State -ne 'Enabled') {
# 若以 -NoRestart 抑制重新啟動,是否需要重新啟動會出現在傳回值的 RestartNeeded 中。
# 若不記錄下來,就會發生「明明需要重新啟動卻以 0 結束」的情況
$result = Enable-WindowsOptionalFeature -Online -FeatureName $feature -NoRestart
if ($result.RestartNeeded) { $rebootRequired = $true }
}
}
# --- 4. winget 套件 ----------------------------------------------
# winget 的結束代碼,即使「已經導入」也可能不是 0;
# 反過來,即使是 0 實際上也可能沒有安裝。成敗要以查詢狀態來判斷。
# 若不加上 --scope,會抓到管理員自己以使用者單位導入的紀錄,
# 誤判為「已在 machine 範圍安裝」
function Test-KsWingetPackage {
param([string] $Id, [string] $Scope)
$arguments = @('list', '--id', $Id, '--exact', '--accept-source-agreements')
if ($Scope) { $arguments += @('--scope', $Scope) }
$null = winget @arguments 2>&1
return ($LASTEXITCODE -eq 0)
}
foreach ($package in $config.wingetPackages) {
if (Test-KsWingetPackage -Id $package.id -Scope $package.scope) { continue }
# 換行用的反引號要放在行尾。若後面接註解就不算是換行
winget install --id $package.id -e --silent `
--accept-package-agreements --accept-source-agreements `
--scope $package.scope
$wingetExit = $LASTEXITCODE
# 這裡如果只顯示警告就繼續往下走,部署工具會把
# 沒有安裝到必要應用程式的端末記錄成「配置成功」
if (-not (Test-KsWingetPackage -Id $package.id -Scope $package.scope)) {
throw "$($package.id) 的導入失敗 (winget ExitCode=$wingetExit)"
}
}
# --- 5. 公司內部應用程式(靜默執行共用資料夾中的安裝程式) ----
# 已安裝清單要明確開啟 32 位元 / 64 位元兩種檢視。
# 若 32 位元的 PowerShell 在 64 位元 Windows 上執行(依 Intune 的組態設定可能發生),
# WOW64 重新導向會使 HKLM:\SOFTWARE\... 指向 32 位元檢視,
# 把 64 位元的應用程式誤判為「未安裝」而每次都重新安裝一次
$installedApps = foreach ($view in 'Registry64', 'Registry32') {
$baseKey = [Microsoft.Win32.RegistryKey]::OpenBaseKey('LocalMachine', $view)
try {
$uninstall = $baseKey.OpenSubKey('SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall')
if (-not $uninstall) { continue }
try {
foreach ($name in $uninstall.GetSubKeyNames()) {
$appKey = $uninstall.OpenSubKey($name)
if (-not $appKey) { continue }
try {
$displayName = $appKey.GetValue('DisplayName')
if ($displayName) {
[pscustomobject]@{
DisplayName = [string] $displayName
DisplayVersion = [string] $appKey.GetValue('DisplayVersion')
}
}
}
finally { $appKey.Dispose() }
}
}
finally { $uninstall.Dispose() }
}
finally { $baseKey.Dispose() }
}
foreach ($app in $config.internalApps) {
$installed = $installedApps | Where-Object DisplayName -eq $app.displayName
# 不只以 DisplayName 一致就判斷為「導入完成」。否則舊版仍在的端末,
# 新版不會被安裝,即使重新執行也無法收斂到設定檔的狀態。
# 若設定中有 version,就連是否已安裝該版本以上都一併確認
$upToDate = if ($app.version) {
$wanted = [version] $app.version
[bool]($installed | Where-Object {
$cur = $_.DisplayVersion -as [version] # 非版本格式的表示法不列入對象
$cur -and $cur -ge $wanted
})
} else {
[bool] $installed
}
if ($upToDate) { continue }
# Start-Process 的 -ArgumentList 只是把陣列用空白連接成一行命令,
# 因此包含空白的值要在設定檔那一側先加上引號
# 例: '/quiet /norestart INSTALLDIR="C:\Program Files\KsApp"'
# .msi 不是可執行檔,若直接傳給 Start-Process,
# 會因「不是有效的應用程式」而失敗。改為透過 msiexec 啟動
if ([System.IO.Path]::GetExtension($app.installer) -eq '.msi') {
$msiArgs = @('/i', "`"$($app.installer)`"") + $app.arguments
$proc = Start-Process -FilePath 'msiexec.exe' -ArgumentList $msiArgs -Wait -PassThru -NoNewWindow
}
else {
$proc = Start-Process -FilePath $app.installer -ArgumentList $app.arguments -Wait -PassThru -NoNewWindow
}
# Windows Installer 的結束代碼並非「只有 0 才算成功」。
# 3010 和 1641 都算成功,差別只在重新啟動的處理方式
switch ($proc.ExitCode) {
0 { } # 成功
3010 { $rebootRequired = $true } # 成功。需要重新啟動(ERROR_SUCCESS_REBOOT_REQUIRED)
1641 { $rebootInitiated = $true } # 成功。安裝程式已開始重新啟動
default {
throw "$($app.displayName) 的安裝失敗 (ExitCode=$($proc.ExitCode))"
}
}
# 若重新啟動已經開始,後續的安裝即使執行也會被中斷
if ($rebootInitiated) { break }
}
if ($rebootInitiated) {
# 1641 是「成功。但已開始重新啟動」。若回傳 0,部署工具會
# 誤以為「明明完成了卻擅自重新啟動」,因此原樣傳遞
Write-Host '安裝程式已開始重新啟動。請於重新啟動後再次執行' -ForegroundColor Yellow
exit 1641
}
if ($rebootRequired) {
# 若原樣回傳 3010,Intune 或部署工具就能解讀為「成功。需要重新啟動」,
# 進而排程並回報重新啟動。若在此回傳 0,重新啟動就會被遺漏
Write-Host '配置完成(需要重新啟動)' -ForegroundColor Yellow
exit 3010
}
Write-Host '配置完成' -ForegroundColor Green
exit 0
}
catch {
Write-Warning "失敗: $($_.Exception.Message)"
Write-Warning $_.InvocationInfo.PositionMessage
exit 1
}
finally {
Stop-Transcript
}
請注意,這個管理員腳本刻意將網路磁碟機的配置排除在外。磁碟機代號的配置是以登入工作階段為單位的設定,即使在提升為管理員權限的工作階段中建立,在 UAC 之下,使用者一般的檔案總管也看不到。若透過 Intune 等以系統權限執行,原本就會配置到 SYSTEM 的工作階段,與使用者無關。正確做法是在該使用者登入時,以未提升權限的方式執行使用者專屬的設定。
# 使用者單位的設定(未提升權限,於使用者登入時執行。登錄方式後述)
# 因為是與管理員腳本不同的處理序,所以不會繼承 $config。
# 重新讀取管理員階段時已分發到 ProgramData 的設定
$configPath = 'C:\ProgramData\KsKitting\kitting.config.json'
if (-not (Test-Path $configPath)) {
Write-Warning "找不到設定檔: $configPath"
exit 1
}
$config = Get-Content $configPath -Raw -Encoding utf8 | ConvertFrom-Json
# 使用者單位的登錄設定(HideFileExt 等。寫在 HKLM 不會生效)
foreach ($reg in $config.userRegistry) {
if (-not (Test-Path $reg.key)) { $null = New-Item -Path $reg.key -Force }
$item = Get-Item -Path $reg.key
$exists = $item.GetValueNames() -contains $reg.name
$same = $exists -and
$item.GetValue($reg.name) -eq $reg.value -and
$item.GetValueKind($reg.name).ToString() -eq $reg.type
if (-not $same) {
Set-ItemProperty -Path $reg.key -Name $reg.name -Value $reg.value -Type $reg.type
}
}
# 網路磁碟機
foreach ($drive in $config.drives) {
$local = "$($drive.letter):"
$existing = Get-SmbMapping -LocalPath $local -ErrorAction SilentlyContinue
if ($existing) {
# 若只以「是否已配置」來判斷,即使因共用資料夾搬遷而變更了設定,
# 舊的配置仍會殘留卻被回報為「成功」。連連線目標都要比較
if ($existing.RemotePath.TrimEnd('\') -eq $drive.path.TrimEnd('\')) { continue }
Remove-SmbMapping -LocalPath $local -Force
}
New-SmbMapping -LocalPath $local -RemotePath $drive.path -Persistent $true
}
# 連接共用印表機同樣是以使用者為單位。若以管理員或 SYSTEM 執行,
# 只會替該帳戶建立連線,使用者看不到
foreach ($printer in $config.printers) {
if (-not (Get-Printer -Name $printer.name -ErrorAction SilentlyContinue)) {
Add-Printer -ConnectionName $printer.connection
}
}
連接共用印表機(Add-Printer -ConnectionName)也是同樣的處理方式。這是建立每個使用者個別連線的操作,即使在管理員腳本或 Intune 的 SYSTEM 執行中進行,之後登入的員工也看不到。若希望所有端末都通用,請使用以機器為單位部署印表機的機制(如列印伺服器的政策分發),或是在這個未提升權限的階段執行。
基於同樣的理由,使用者設定檔底下的檔案放置、寫入 HKCU、建立使用者專屬捷徑,也都整合在這個未提升權限的階段。「整台機器的設定由管理員執行一次,使用者專屬的設定則在每次登入時以未提升權限執行」這種雙層架構,是配置腳本的基本形式。關於 UNC 路徑與磁碟機配置的注意事項,也請參閱「網路磁碟機與 UNC 路徑的陷阱」。
如何啟動使用者階段?這個雙層架構的關鍵在於,要有一套機制,能在「使用者登入時,以該使用者自身的權限」執行這個未提升權限的腳本。若這一段沒有串接起來,就只會執行管理員階段,結果變成一台「磁碟機和印表機都沒有配置的端末」。方法有三種。
| 方法 | 執行時機 | 適合的環境 | 注意事項 |
|---|---|---|---|
| HKLM 的 Run 機碼 | 每次所有使用者登入時 | 未加入網域。想用 Intune、部署工具以單一腳本完成 | 每次都會執行,前提是要有冪等性。需要指定不顯示視窗 |
| 工作排程器(登入時觸發) | 每次所有使用者登入時 | 想保留執行結果的歷史紀錄。想延遲執行 | 「以最高權限執行」要關閉(開啟的話會提升權限而失去意義) |
| 登入指令碼(群組原則) | 每次所有使用者登入時 | 已加入網域 | 可以納入既有的 GPO 運用,但在單機環境下不易使用 |
方法 1:登錄到 HKLM 的 Run 機碼(在管理員階段的最後執行)
# 把使用者階段的腳本,放到所有使用者都能讀取的位置
$userScript = 'C:\ProgramData\KsKitting\Invoke-KsUserKitting.ps1'
Copy-Item "$PSScriptRoot\Invoke-KsUserKitting.ps1" $userScript -Force
# HKLM 的 Run 會以登入使用者的權限(未提升權限)執行。
# 即使想寫入 HKCU,既然是以管理員/SYSTEM 執行,寫入的
# 會是「管理員自己的 HKCU」,而不是接下來要使用的員工的 HKCU
$runKey = 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Run'
$command = 'powershell.exe -NoProfile -ExecutionPolicy Bypass ' +
"-WindowStyle Hidden -File `"$userScript`""
Set-ItemProperty -Path $runKey -Name 'KsUserKitting' -Value $command
方法 2:登錄到工作排程器(想保留歷史紀錄時)
為了讓跳過方法 1、直接從這裡開始閱讀的讀者也能理解,以下從腳本的放置開始重新說明一次。
# 放置到所有使用者都能讀取的位置(與方法 1 相同)
$userScript = 'C:\ProgramData\KsKitting\Invoke-KsUserKitting.ps1'
New-Item -ItemType Directory -Path (Split-Path $userScript) -Force | Out-Null
Copy-Item -Path '.\Invoke-KsUserKitting.ps1' -Destination $userScript -Force
$action = New-ScheduledTaskAction -Execute 'powershell.exe' `
-Argument "-NoProfile -ExecutionPolicy Bypass -WindowStyle Hidden -File `"$userScript`""
$trigger = New-ScheduledTaskTrigger -AtLogOn
# 指定 BUILTIN\Users(S-1-5-32-545)後,會以登入本人的權限執行。
# RunLevel Limited 就是「不提升權限」的指定。若在此改為 Highest,
# 就會在提升權限的工作階段中執行,導致磁碟機配置對使用者不可見
$principal = New-ScheduledTaskPrincipal -GroupId 'S-1-5-32-545' -RunLevel Limited
Register-ScheduledTask -TaskName 'KsUserKitting' `
-Action $action -Trigger $trigger -Principal $principal -Force
方法 3:群組原則的登入指令碼
設定位置在 使用者設定 > 原則 > Windows 設定 > 指令碼 (登入/登出) > 登入(在單機的 gpedit.msc 中沒有 原則 這一層,是 使用者設定 > Windows 設定 > 指令碼 (登入/登出))。請新增到對話方塊的「PowerShell 指令碼」索引標籤。若直接在「指令碼」索引標籤中寫入 .ps1,會被當作執行檔處理,無法得到預期的行為。若是本機原則,指令碼的實體會放在 %SystemRoot%\System32\GroupPolicy\User\Scripts\Logon。
不論哪一種方法,每次登入都會執行這一點是相同的。之所以把使用者階段的腳本寫成冪等(先確認目前狀態再變更),也是為了這個緣故。若想要「只執行一次」,請在 HKCU 底下寫入完成標記,並在腳本開頭進行確認。
以下列出設計上的要點。
- 把需要提升權限的設定,與使用者單位的設定分開。如前所述,混在一起會造成「明明已配置的磁碟機卻看不到」的事故
- 把設定外部化到 JSON。能在不修改腳本的情況下,表達部門別、機型別的差異
- 在每項處理之前先確認目前狀態。這樣不管執行幾次都能保持安全
- 用
Start-Transcript留下記錄。之後可以追溯「這台端末做過什麼」(「告別 Write-Host ── PowerShell 的輸出資料流與日誌設計」) - 回傳結束代碼。讓 Intune 或部署工具能判斷成敗。請注意,在 Windows Installer 中並非只有 0 才算成功。3010(ERROR_SUCCESS_REBOOT_REQUIRED,需要重新啟動)和 1641(ERROR_SUCCESS_REBOOT_INITIATED,已開始重新啟動)都算成功。6 若把這些歸入
default當作失敗處理,原本成功的安裝就會在部署工具上被標記為紅色。重點是把它們當作成功處理,最後把該代碼原樣回傳給呼叫端。若回傳 0,部署工具就無從得知需要重新啟動(「PowerShell 的錯誤處理與重新執行設計」) - 注意安裝程式引數的引號。
Start-Process -ArgumentList只是把陣列用空白連接,並不會保留引數的分隔。含有空白的路徑,請在設定檔那一側加上引號,或使用ProcessStartInfo.ArgumentList(PowerShell 7)(「從 PowerShell 正確呼叫外部 exe」)
6. 從 PowerShell 模組使用 winget
比起以字串解析命令列輸出,使用 PowerShell 模組更為穩健。Microsoft.WinGet.Client 提供了 Find-WinGetPackage / Install-WinGetPackage / Get-WinGetPackage / Update-WinGetPackage 等 Cmdlet。4
Install-Module -Name Microsoft.WinGet.Client -Scope AllUsers -Force
# 確認是否已導入後再安裝(冪等)
foreach ($id in 'Google.Chrome', 'Microsoft.VisualStudioCode', '7zip.7zip') {
if (Get-WinGetPackage -Id $id -ErrorAction SilentlyContinue) {
Write-Verbose "已導入: $id"
continue
}
Install-WinGetPackage -Id $id -Mode Silent -Scope System
}
結果會以物件形式傳回,因此可以直接撰寫成敗判斷與清單比對,是其優點。
7. 無人執行、系統內容的注意事項
若想把配置作業完全自動化,一定會遇到「以哪個帳戶執行」的問題。
- winget 有部分設計是以使用者內容執行為前提。系統內容下的執行,目前 Microsoft 僅將其列為未來的功能項目3
- 把需要提升權限的處理,與使用者專屬的處理分開。整台機器的設定用管理員權限,使用者設定檔底下的設定則在首次登入時執行,這樣的架構較容易處理
- 請務必以實際的執行帳戶進行驗證。「在手邊的管理員帳戶上能動,一部署出去就不動了」,是這個領域中最常見的失敗(「工作排程器的工作不執行」)
8. 實務定石(判斷表)
| 要做的事 | 手段 | 補充說明 |
|---|---|---|
| 市售・OSS 產品的導入 | winget install / configure | --silent --accept-* 為必要項目1 |
| 匯出既有標準機的組態 | winget export |
不包含設定。作為出發點使用2 |
| 以宣告式表達應用程式 + Windows 設定 | winget configure(YAML) |
Win10 1809 以上 + winget 1.6 以上3 |
| 登錄・Windows 功能・公司內部應用程式 | PowerShell(管理員) | 先確認狀態再變更(冪等) |
| 共用磁碟機・共用印表機・使用者專屬設定 | PowerShell(登入時・未提升權限) | 以提升權限或 SYSTEM 建立,使用者將看不到 |
| 公司內部自製應用程式 | PowerShell + 靜默安裝程式 | 建構專用儲存庫對小規模來說過度 |
| 想從 PowerShell 控制 | Microsoft.WinGet.Client |
不再需要解析輸出字串4 |
| 無人執行 | 以執行帳戶驗證 | 系統內容有其限制3 |
| 執行記錄 | Start-Transcript + 結束代碼 |
留下「這台端末做過什麼」的紀錄 |
| 需要重新啟動時 | 回傳結束代碼 3010 / 1641 | 兩者都算成功。若回傳 0,部署工具將無法辨識需要重新啟動6 |
9. 總結
- 配置操作手冊可以替換成可執行的檔案。應用程式導入交給 winget、其他設定交給 PowerShell,這樣的分工是實際可行的做法。
- 使用
winget install時,務必加上--silent以及--accept-package-agreements、--accept-source-agreements。忘記加上的話,無人執行會停住。 winget export可以從既有標準機匯出組態,但不包含設定,也不包含 winget 管理範圍外的應用程式。- 使用 WinGet Configuration(
winget configure),可以把應用程式與設定整合成一份宣告式檔案,形成能耐受重新執行的組態。 - PowerShell 這一側的處理,務必寫成「先確認目前狀態再變更」的形式,以確保冪等性。
- 請把執行階段分開:整台機器的設定用管理員權限,網路磁碟機、共用印表機等使用者專屬設定則在登入時以未提升權限執行。以提升權限的工作階段或 SYSTEM 建立的連線,使用者是看不到的。
- 在無人執行中,驗證執行帳戶是最重要的一環。請以「系統內容下執行 winget 有其限制」為前提來設計。
範例程式碼下載
本文所使用的程式碼,已整理成可直接執行的形式提供下載。內含管理員階段、使用者階段、設定檔的完整範例。
由於本文的範例會受 Windows 及租用戶環境影響,尚未進行實際執行驗證。所有檔案都已完成語法解析與 PSScriptAnalyzer 靜態分析,但實際運作請務必在您自己的驗證機上確認。
# 語法解析 + 靜態分析(在 Windows 以外的環境也能執行)
./Invoke-SampleTests.ps1
設定值(路徑、伺服器名稱、租用戶 ID 等)僅為範例。請勿直接在正式環境中執行,請依貴公司的環境自行調整。
相關文章
- 從 PowerShell 正確呼叫外部 exe ── 引數的引號、結束代碼、亂碼陷阱
- PowerShell 的錯誤處理與重新執行設計 ── 從 try/catch 失效的陷阱到 exit code、重試的實務定石
- 告別 Write-Host ── PowerShell 的輸出資料流與日誌設計
- 工作排程器的工作不執行、以 0x1 結束 ── 原因排查與安全的維運設計
- 用 Kiosk 模式固定業務終端 ── Assigned Access・Shell Launcher 的選型與運用設計
- Windows 10支援結束後的現實解方 ── ESU、LTSC、換機的判斷表
相關諮詢領域
合同會社小村軟體提供 PC 配置與公司內部標準環境的自動化、把僅停留在操作手冊、因人而異的維運變成可執行化,以及分發腳本設計支援等服務。
參考連結
-
Microsoft Learn,install 命令 (winget)。關於以 –id / -e 指定對象、以 –silent 進行無人安裝、以 –accept-package-agreements / –accept-source-agreements 同意使用條款、以 –scope 指定安裝範圍(user / machine)。以及Use WinGet to install and manage applications的命令清單。 ↩ ↩2 ↩3
-
Microsoft Learn,export 命令 (winget)。關於可以把已安裝套件的清單匯出為 JSON、以 –include-versions 記錄版本、以import 命令還原及 –ignore-unavailable 的行為、匯出的對象僅限於 winget 管理下的套件,以及輸出 JSON 的階層結構(Sources / Packages / PackageIdentifier / Version,Version 為選填)。JSON 的結構在 packages.schema.2.0.json 中也有定義,關於頂層為 WinGetVersion、CreationDate、Sources,Sources 的每個元素擁有 SourceDetails(Name / Identifier / Argument / Type)與 Packages,以及 Packages 的每個元素必須有 PackageIdentifier、Version 等為選填。 ↩ ↩2 ↩3 ↩4 ↩5
-
Microsoft Learn,WinGet Configuration。關於 WinGet Configuration 是以 YAML 宣告理想狀態、透過 PowerShell DSC 套用的機制、可用於無人安裝設定、需要 Windows 10 版本 1809(組建 17763)以上或 Windows 11 及 WinGet v1.6.2631 以上、從管理員 Shell 執行時 UAC 的處理方式,以及系統內容下的執行已被列為未來的開發項目。以及configure 命令的 show / –accept-configuration-agreements。 ↩ ↩2 ↩3 ↩4 ↩5 ↩6 ↩7
-
GitHub,microsoft/winget-cli ─ Microsoft.WinGet.Client PowerShell 模組。關於可以從 PowerShell Gallery 導入 Microsoft.WinGet.Client 模組、提供 Find-WinGetPackage / Get-WinGetPackage / Install-WinGetPackage / Update-WinGetPackage / Uninstall-WinGetPackage 等 Cmdlet,以及結果可以物件形式處理。 ↩ ↩2 ↩3
-
Microsoft Learn,show 命令 (winget)。關於這是顯示指定應用程式詳細資訊(中繼資料與安裝程式資訊)的命令、可以用 –scope 選項選擇安裝範圍(user / machine)、顯示的安裝程式資訊是根據指定的引數與 WinGet 的判斷而定。 ↩
-
Microsoft Learn,Windows Installer 錯誤代碼。關於 ERROR_SUCCESS_REBOOT_REQUIRED(3010)為「需要重新啟動才能使變更生效。安裝本身已成功」,ERROR_SUCCESS_REBOOT_INITIATED(1641)為「安裝程式已開始重新啟動。表示成功的代碼」。 ↩ ↩2
相關文章
共用相同標籤的最新文章。能以相近的主題延伸理解。
PowerShell 腳本的引數設計與模組化 ── 從「能動的腳本」到「能交給別人用的腳本」
本文整理將 PowerShell 腳本提升到可以交給別人使用之品質的步驟,說明 param 區塊與 [CmdletBinding()]、輸入驗證、管線輸入、-WhatIf 對應、.psm1 模組化,一直到公司內部共用與 Git 管理的要點。
PowerShell 的安全性強化 ── 日誌・AMSI・語言模式・JEA
本文整理在不禁止 PowerShell 的前提下安全使用它的實務做法。內容涵蓋啟用指令碼區塊記錄與逐字稿記錄、停用 AMSI 與舊版本、以語言模式進行限制,以及透過 JEA 委派權限。
使用 Get-WinEvent 有效率地調查事件記錄 ── 篩選的速度決定調查所需的時間
本文整理如何用 PowerShell 有效率地進行 Windows 事件記錄的調查。說明以 Where-Object 篩選為什麼慢、FilterHashtable 與 XPath 的使用區分、重新開機、登入、應用程式異常結束等調查手法,以及跨多台伺服器收集記錄的方法。
PowerShell 模組的公司內部分發與更新 ── PSResourceGet 與內部儲存庫
整理如何告別複製共用資料夾中的 ps1 檔案並重複使用的運作方式。內容涵蓋模組資訊清單(manifest)的撰寫方式、版本管理、用 PSResourceGet 建立內部儲存庫並進行分發與更新,以及與簽章搭配運用的做法。
PowerShell與REST API串接 ── Invoke-RestMethod的實務
本文整理從PowerShell呼叫公司內部API或SaaS REST API的實務作法。內容涵蓋認證標頭的傳遞方式、日文JSON亂碼的因應對策、4xx/5xx的錯誤處理、429的重試、分頁,以及Proxy與TLS的陷阱。
相關主題
與本文相近的主題頁面。以本文為起點,可進一步連到相關服務與其他文章。
Windows 技術主題
彙整 KomuraSoft LLC 關於 Windows 開發、故障調查與既有資產活用文章的主題中心。
常見問題
整理諮詢這個主題時常見的問題。
- 用 winget import,可以把配置作業全部自動化嗎?
- 應用程式的安裝可以自動化,但光靠這樣還不夠。winget import 重現的是套件清單,應用程式內部的設定、新增印表機、網路磁碟機的配置、電源設定、透過登錄設定的公司內部標準等都不在範圍內。此外,以 winget 以外方式安裝的應用程式,以及公司內部自製的業務應用程式,也不會包含在匯出範圍中。實務上,把應用程式導入交給 winget,其餘設定用 PowerShell 腳本補足的雙層架構才是實際可行的做法。
- winget 與 WinGet Configuration(winget configure)有什麼不同?
- winget 的 install/import 是「依這個順序安裝這些內容」的程序式指示,而 WinGet Configuration 則是把「最終希望達到這個狀態」的宣告寫進 YAML 檔案的方式。內部使用 PowerShell DSC,不只是應用程式的導入,連 Windows 的設定與應用程式的組態,都能用一份檔案表達。因為若已經是理想狀態就不會做任何事,即使中途失敗,只要重新執行同一份檔案即可,這種對重新配置有耐受力是它的優點。需要 Windows 10 1809 以上及 winget 1.6 以上。
- 從工作排程器或 Intune 以 SYSTEM 權限執行 winget 沒問題嗎?
- 需要特別注意。winget 有部分設計是以使用者內容執行為前提,系統內容下的執行,目前 Microsoft 僅將其列為未來的開發項目。實務上會採取這些因應方式:使用為所有使用者安裝的 --scope machine、透過 PowerShell 模組(Microsoft.WinGet.Client)執行,或是在首次登入時以使用者內容執行。不論採用哪一種,都務必以實際使用的執行帳戶進行驗證。
- 配置腳本應該不論執行幾次都是安全的嗎?
- 是的。請把冪等性(不論執行幾次都得到相同結果)視為必要條件。配置作業中途失敗是日常會發生的事,每次都必須能夠從頭重新開始。只要寫成:建立資料夾前先用 Test-Path 確認是否存在、登錄設定前先確認目前值、應用程式導入前先確認是否已安裝,就能從失敗的地方重新開始。WinGet Configuration 從一開始就內建了這種思維。
- 公司內部自製的業務應用程式可以用 winget 分發嗎?
- 若準備公司內部用的私有儲存庫(REST API 來源)是可行的,但建置與維護伺服器需要額外的工夫。若只是幾套應用程式,用 PowerShell 腳本靜默執行共用資料夾中的安裝程式會更簡單。市售、OSS 產品交給 winget,公司內部應用程式用 PowerShell 安裝,這樣的分工在小規模組織中最為實際。