每当有新员工入职,或是更换电脑时,负责人就一边看着操作手册一边逐台设置 ── 在中小企业的信息系统部门,这至今仍是标准场景。问题不只是耗时。由于手工作业没有可复现性,”只有这台设备设置不一样”这类麻烦事会在之后带来影响。操作手册在没有更新的情况下逐渐过时,负责人一换,细节就随之遗失。
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,就不会包含 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 模块的预发行版的指示。并不是「安装应用的预发行版(测试版)」的意思。
这个区别很关键。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.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。配置文件的完整示例,作为 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 安装的软件包的 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. 内部应用(对共享文件夹中的安装程序执行静默安装) ----
# 已安装列表要明确打开 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 存在限制」为前提进行设计。
示例代码下载
本文涉及的代码,已整理成可以直接运行的形式对外分发。其中包含管理员阶段、用户阶段、配置文件的完整示例。
本文的示例由于依赖 Windows 与租户环境,未进行实际运行验证。所有文件都已完成语法解析和基于 PSScriptAnalyzer 的静态分析,但运行结果请务必在您自己的验证环境中确认。
# 语法解析 + 静态分析(在非 Windows 环境下也能执行)
./Invoke-SampleTests.ps1
配置值(路径、服务器名、租户 ID 等)均为示例。请勿直接在生产环境中运行,务必按照贵公司的环境进行相应替换。
相关文章
- 从 PowerShell 正确调用外部 exe ── 参数引用、退出代码与乱码的陷阱
- PowerShell 的错误处理与重试设计 ── 从 try/catch 失效的陷阱到 exit code、重试的实务定式
- 不再使用 Write-Host ── PowerShell 的输出流与日志设计
- 任务计划程序的任务不执行、以 0x1 结束 ── 原因排查与安全的运维设计
- 用信息亭模式固化业务终端 ── 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 安装程序错误代码。关于 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 反复使用的运维方式。内容涵盖模块清单的编写方法、版本管理、用 PSResourceGet 构建内部仓库并进行分发与更新,直至与签名的配合使用。
用 PowerShell 与 REST API 集成 ── Invoke-RestMethod 的实务
本文整理从 PowerShell 调用内部系统 API 与 SaaS REST API 的实务做法,涵盖认证请求头的传递方式、日语 JSON 乱码的应对、4xx/5xx 错误处理、429 重试、分页,直至代理与 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 安装,这种分工在小规模组织中最为现实。