用 winget + PowerShell 自動化 PC 配置 ── 讓操作手冊變得可執行

· · winget, PowerShell, Windows, 配置, 資訊系統, 自動化, 維運改善, 業務效率化

每當有新進員工報到,或是更換 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.jsonwingetPackages 中寫入相同內容,修改其中一邊時,另一邊就會維持舊狀態。若要採用併用 Configuration 的架構,實務上的做法是把應用程式導入的處理從 PowerShell 這一側移除,JSON 只保留登錄值、共用印表機等Configuration 無法處理的項目。反過來說,想單靠 Configuration 涵蓋一切、四處尋找對應資源,往往是不划算的投入。

5. 以 PowerShell 補足 ── winget 不做的部分

實務配置作業中佔去大半功夫的,其實是應用程式導入以外的部分。這裡就是 PowerShell 上場的時候。重點是全部都要寫成「若已執行過就不做任何事」的形式

這一章篇幅較長,先展示整體結構。出場的只有三個檔案,是擁有一份決定「要安裝什麼」的 JSON,再由兩個腳本以不同權限讀取這樣的架構。

使用者階段 ── 未提升權限,每次使用者登入時管理員階段 ── 提升權限,每台端末一次重新讀取已分發的設定Invoke-KsUserKitting.ps1HKCU 登錄機碼 /網路磁碟機 /共用印表機Invoke-KsKitting.ps1資料夾 / HKLM 登錄機碼 /Windows 功能 / winget 套件 /公司內部應用程式-MSI、EXE把設定 JSON 分發到 ProgramData 底下kitting.config.json「這台端末應有的樣貌」定義

圖 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 安裝的套件 idscope 管理員(提升權限) scope 要事先用 winget show --scope 確認(第 2 章)
internalApps 共用資料夾中的公司內部應用程式(MSI/EXE) 管理員(提升權限) displayName 要與「程式和功能」中的顯示名稱完全一致
drives 網路磁碟機的配置 使用者(未提升權限) 以登入工作階段為單位。若在提升權限的一側建立,使用者將看不到
printers 連接共用印表機 使用者(未提升權限) 同上。會依使用者個別建立連線

「讀取階段」欄分成兩種,這本身就是這份設定檔的設計理念。新增項目時,請先判斷「這是機器的設定,還是使用者的設定」,再決定要放入哪一個鍵。

之所以把 registryuserRegistry 分開,是因為套用的對象不同。像檔案總管的「顯示副檔名」(HideFileExt)這類設定,看的不是 HKLM 的政策,而是各使用者的 HKCU。即使在管理員階段寫入 HKLM,顯示也不會改變。這類使用者單位的設定要放進 userRegistry,並在後述的未提升權限階段套用。

internalAppsdisplayName 請與「程式和功能」中顯示的名稱完全一致。若指定 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 有其限制」為前提來設計。

範例程式碼下載

本文所使用的程式碼,已整理成可直接執行的形式提供下載。內含管理員階段、使用者階段、設定檔的完整範例。

下載範例程式碼(zip)

由於本文的範例會受 Windows 及租用戶環境影響,尚未進行實際執行驗證。所有檔案都已完成語法解析與 PSScriptAnalyzer 靜態分析,但實際運作請務必在您自己的驗證機上確認。

# 語法解析 + 靜態分析(在 Windows 以外的環境也能執行)
./Invoke-SampleTests.ps1

設定值(路徑、伺服器名稱、租用戶 ID 等)僅為範例。請勿直接在正式環境中執行,請依貴公司的環境自行調整。

相關文章

相關諮詢領域

合同會社小村軟體提供 PC 配置與公司內部標準環境的自動化、把僅停留在操作手冊、因人而異的維運變成可執行化,以及分發腳本設計支援等服務。

參考連結

  1. 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

  2. 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

  3. 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

  4. 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

  5. Microsoft Learn,show 命令 (winget)。關於這是顯示指定應用程式詳細資訊(中繼資料與安裝程式資訊)的命令、可以用 –scope 選項選擇安裝範圍(user / machine)、顯示的安裝程式資訊是根據指定的引數與 WinGet 的判斷而定。 

  6. Microsoft Learn,Windows Installer 錯誤代碼。關於 ERROR_SUCCESS_REBOOT_REQUIRED(3010)為「需要重新啟動才能使變更生效。安裝本身已成功」,ERROR_SUCCESS_REBOOT_INITIATED(1641)為「安裝程式已開始重新啟動。表示成功的代碼」。  2

共用相同標籤的最新文章。能以相近的主題延伸理解。

與本文相近的主題頁面。以本文為起點,可進一步連到相關服務與其他文章。

本文連結到以下服務頁面,歡迎從最接近的入口查看。

常見問題

整理諮詢這個主題時常見的問題。

用 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 安裝,這樣的分工在小規模組織中最為實際。

作者檔案

本文作者的個人檔案頁面。

Go Komura

小村軟體有限公司 代表

以 Windows 軟體開發、技術諮詢與故障調查為中心,在難以重現的故障調查與既有資產仍在運作的專案上具有優勢。

回到部落格一覽