用 winget + PowerShell 自动化 PC 装机 ── 让操作手册可执行

· · winget, PowerShell, Windows, 装机, 信息系统, 自动化, 运维改善, 业务效率化

每当有新员工入职,或是更换电脑时,负责人就一边看着操作手册一边逐台设置 ── 在中小企业的信息系统部门,这至今仍是标准场景。问题不只是耗时。由于手工作业没有可复现性,”只有这台设备设置不一样”这类麻烦事会在之后带来影响。操作手册在没有更新的情况下逐渐过时,负责人一换,细节就随之遗失。

Windows 标准内置了包管理器 winget,安装应用只需一行命令即可完成。此外,使用 WinGet Configuration,还能用一个 YAML 文件声明式(不是罗列步骤,而是写出「最终希望达到的状态」的方式)表达应用与设置。而 winget 覆盖不到的领域(打印机、网络驱动器、公司内部标准的注册表设置等),则可以用 PowerShell 来补足。

本文将以能够在实际运维中使用的颗粒度,整理把装机操作手册替换为「可执行文件」的方法。

1. 先说结论

  • 应用安装交给 winget。winget install 标准支持静默安装。1
  • 现有电脑的配置可以用 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 账户等,与正在登录的用户不同的账户下的执行)。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

# 确认是否存在机器范围的安装程序
winget show --id Google.Chrome -e --scope machine

# 为了比较,也顺便看一下用户范围一侧
winget show --id Google.Chrome -e --scope user

如果没有符合指定范围的安装程序,就会显示相应的提示信息。在装机配置文件里写下 "scope": "machine" 之前,先用这个方法把目标软件包逐一确认一遍,就能避免在无人执行途中才第一次发现失败。

3. 提取现有电脑的配置 ── export / import

如果已经有整备完毕的「标准电脑」,就可以把它的配置文件化。2

# 从标准电脑,把已安装软件包的列表写出到 JSON
winget export --output D:\kitting\apps.json --include-versions

# 在新电脑上还原
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,就不会包含 Versionimport 会安装最新版本)。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 模块的预发行版的指示。并不是「安装应用的预发行版(测试版)」的意思。

这个区别很关键。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
目标 OS 较旧(低于 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配置文件的完整示例,作为 kitting.config.json 收录在本文的示例代码(文末的 zip)中,这里先展示它的结构。

{
  "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. 内部应用(对共享文件夹中的安装程序执行静默安装) ----
    # 已安装列表要明确打开 32bit/64bit 两种视图。
    # 如果 32bit 的 PowerShell 在 64bit Windows 上运行(视 Intune 的配置而定,会发生这种情况),
    # WOW64 重定向会让 HKLM:\SOFTWARE\... 指向 32bit 视图,
    # 从而把 64bit 的应用误判为「未安装」,每次都重新安装一遍
    $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 安装程序错误代码。关于 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 软件开发、技术咨询与故障排查为中心,擅长难以复现的故障调查,以及既有资产仍在运行的项目。

返回博客列表