Automating PC Provisioning With winget + PowerShell — Making the Runbook Executable

· Updated: · · winget, PowerShell, Windows, PC Provisioning, Information Systems, Automation, Operational Improvement, Business Efficiency

Revision history (first version, published Jul 25, 2026)
First published
Cite this article(DOI (registered archive): 10.5281/zenodo.22170779)

The DOIs below refer to previously archived versions and may not match the current text. Use this page’s URL to reference the current text.

Go Komura (2026). Automating PC Provisioning With winget + PowerShell — Making the Runbook Executable. KomuraSoft LLC. https://comcomponent.com/en/blog/winget-powershell-pc-kitting/

DOI (registered archive)
10.5281/zenodo.22170779
DOI (last registered version)
10.5281/zenodo.22170780

Every time a new employee joins, or a PC is replaced, someone works through the runbook and configures machines one at a time. In the IT department of a small or medium-sized company, what causes trouble is not only the hours that takes. Manual work produces variation in the settings, and between stale runbooks and staff turnover you are left with problems of the form “only this machine is configured differently.”

To reduce that, replace the runbook with configuration files and scripts you can re-run. Leave application installation to Windows’ package manager winget, use WinGet Configuration for the parts you want to manage declaratively, and fill in with PowerShell the settings that neither of those covers.

The first thing to separate in the design is “what to install” and “under whose privileges to configure it.” Even when the applications are in place, provisioning is not finished if the drives and printers are not visible to the user. This article works through the basics of application installation, then an implementation split into an administrator phase and a user phase, and finally the checks for unattended execution.

Start From the Problem You Have

What you want to check What to grasp first Where to read
The install stalls waiting for an agreement Separate the target ID, unattended execution, and agreement to the terms of use Chapter 2: Unattended installation
You want to install for all users Whether an installer that supports the machine scope exists Chapter 2: Checking the scope
You want to move a standard PC’s configuration to a new PC Reproducing the package list and migrating settings are separate things Chapter 3: export / import
You are unsure whether to use YAML or your own script What each one covers, and avoiding a duplicated application list Chapter 4: Configuration, Section 4.2: Division of labor
You want to know which part of the long sample to change The configuration JSON, and two scripts that run at different privilege levels Chapter 5: The overall picture, Configuration keys
The drives and printers you mapped are not visible Configure them unelevated, at the user’s logon The user phase, How to launch it
You want to plan for re-running after a failure partway through The current state, the post-install check, logs, exit codes The administrator phase, Exit codes, Design checks
You want to run it from SYSTEM or a distribution tool Verification with the actual execution account Chapter 6: The PowerShell module, Chapter 7: Unattended execution
You want to check the files to distribute and the final decisions The conditions the sample applies under, and the values for your own organization Chapter 8: Decision table, Sample code

To read straight through, start at Chapter 1. To go straight to the implementation, start at the three-part structure in Chapter 5.

Before you run the samples: Paths, server names, and the like are examples. Do not run them as-is in production. Adapt them to your own environment and confirm the behavior on a test machine. The scope of verification for the distributed samples is described in the download section.

This article reorganizes material first published in July 2026 and updated on August 2. Check the tool requirements and the constraints in the system context against the version you deploy and the account you run it under. The date the article was updated and verification on your target environment are two different things.

1. The Bottom Line First

Separate Application Installation From Reproducing Settings

  • Leave application installation to winget. winget install supports silent installation as standard.1
  • You can extract an existing PC’s configuration with winget export. But it is limited to packages under winget’s management, and settings are not included.2

    Choose How to Manage the Configuration

  • If you want to do it declaratively, use WinGet Configuration (winget configure). It is based on PowerShell DSC and lets you write applications and settings into a single YAML file. The requirements are Windows 10 1809 or later + winget 1.6 or later.3
  • Fill in what winget does not cover with PowerShell. Printers, shared drives, the registry, Windows features, local accounts, and so on.
  • To drive it from PowerShell there is the Microsoft.WinGet.Client module. It offers cmdlets such as Install-WinGetPackage.4

    Design the Execution Account and Re-runs

  • Execution in the system context (running under an account other than the logged-on user, such as the SYSTEM account) needs care. Microsoft still lists it as a future development item, so verifying with the actual execution account is essential.3
  • Always ensure idempotency (getting the same result no matter how many times it runs). Provisioning fails partway through, and it must be possible to re-run it any number of times.
  • Do not force in-house applications onto winget; running a silent installer from PowerShell is the realistic option.

In the diagram a solid line marks a relation that always holds and a dashed line marks a conditional one (the conditions are given per relation on the detail page). The full list of relations (20 in total, with evidence and certainty) and the definitions of the main concepts are collected on the knowledge map detail page (in Japanese). Data: JSON-LD / Turtle

2. winget Basics — How to Write an Unattended Install

Specify the Target ID, Unattended Execution, and the Agreements

Start with the boilerplate for installing reliably without any interaction.1

# Specify the ID exactly and install (-e is an exact match, --id specifies the ID)
#   --silent                     : do not show the UI
#   --accept-package-agreements  : agree to the package's terms of use
#   --accept-source-agreements   : agree to the source's terms of use
#   --scope machine              : install for all users (supported packages only)
# Put the line-continuation backtick at the end of the line. A comment after it breaks the continuation
winget install --id Google.Chrome -e --silent `
    --accept-package-agreements --accept-source-agreements --scope machine

# Look up the ID
winget search "Visual Studio Code"
winget show --id Microsoft.VisualStudioCode

Forget the --accept-* options and the unattended run stalls waiting for an agreement. This is the first thing people trip over when automating provisioning.

Check First Whether Installing for All Users Is Supported

--scope machine cannot be used with every package; some applications can only be installed per user. In that case, arrange for them to run in the user context at first logon.

You can check support before installing. winget show accepts --scope, so you can find out in advance whether a machine-scope installer is available.5

# Check whether a machine-scope installer exists
winget show --id Google.Chrome -e --scope machine

# Look at the user scope too, for comparison
winget show --id Google.Chrome -e --scope user

If no installer matches the scope you specified, you get a message saying so. Run this over your target packages before writing "scope": "machine" into the provisioning configuration file, and you avoid hitting the failure for the first time in the middle of an unattended run.

3. Extracting the Current PC’s Configuration — export / import

Start by Exporting the Package List

If you already have a “standard PC” that has been set up, you can turn its configuration into a file.2

# Export the list of installed packages from the standard PC to JSON
winget export --output D:\kitting\apps.json --include-versions

# Restore it on the new PC
winget import --import-file D:\kitting\apps.json `
    --accept-package-agreements --accept-source-agreements --ignore-unavailable

The JSON that comes out has this structure (the values are examples).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"
}

What Goes Into the JSON, and What You Configure Separately

As you can see, the contents are a list of package identifiers and versions (without --include-versions there is no Version, and import installs the latest version).2 In other words, all winget import does is “install the package with this identifier” — not one character of application settings, nor of applications installed by any means other than winget, is in here. Open the JSON for yourself and the limitation becomes concrete.

Use the following table to separate what reproduces the package list from the settings you have to supply separately.

What it can do What it cannot do
Reproduce the list of packages under winget’s management Migrate in-app settings
Pin versions (--include-versions) Applications installed by means other than winget, and in-house applications
Skip packages that do not exist (--ignore-unavailable) License activation and sign-in state

So winget import is a starting point, not the whole of provisioning. How to fill in the rest is the real subject.

4. Writing It Declaratively — WinGet Configuration

Write the Desired State, Not the Procedure

winget configure is a mechanism that declares in YAML the state you want to end up in and applies it through PowerShell DSC.3 It has three advantages over a procedural script.

  • It does nothing when the desired state is already in place (idempotent)
  • Application installation and Windows and application settings can be expressed in one file
  • If it fails partway through, you just re-run the same file

The following example shows the structure. Read Section 4.1 for what allowPrerelease means and for the caution about keeping the setting consistent within a resource type before you write a file for your own organization.

# kitting.winget — declare the desired state of a standard machine
properties:
  configurationVersion: 0.2.0
  resources:
    - resource: Microsoft.WinGet.DSC/WinGetPackage
      id: chrome
      directives:
        description: Install Google Chrome
        allowPrerelease: true
      settings:
        id: Google.Chrome
        source: winget

    - resource: Microsoft.WinGet.DSC/WinGetPackage
      id: vscode
      directives:
        description: Install Visual Studio Code
      settings:
        id: Microsoft.VisualStudioCode
        source: winget

    - resource: Microsoft.Windows.Developer/DeveloperMode
      id: devmode
      directives:
        description: Enable Developer Mode (development machines only)
        allowPrerelease: true
      settings:
        Ensure: Present
# Review the contents before applying (shows what will be run)
winget configure show --file D:\kitting\kitting.winget

# Apply it
winget configure --file D:\kitting\kitting.winget --accept-configuration-agreements

Check the Requirements of the Target

The requirements are Windows 10 version 1809 (build 17763) or later, or Windows 11, and winget 1.6.2631 or later.3 In environments where older machines remain, the PowerShell-based configuration in the next chapter is the surer option.

4.1. How to Read directivesallowPrerelease Is Not About the Application

The Application Goes in settings, Instructions for the Resource Go in directives

directives is what invariably causes confusion when you stitch YAML together from other samples. What goes here is instructions about how the resource is handled, not the specification of the application to install (that belongs on the settings side).

  • description — the descriptive text shown at run time. It makes the output of winget configure show readable as it stands, so always write one
  • allowPrerelease — an instruction that permits the use of a prerelease version of the PowerShell module that provides the DSC resource. It does not mean “install a prerelease (beta) version of the application.”

Keep the Setting Consistent Within a Resource Type

This distinction starts to matter here. allowPrerelease is a per-module matter, so when the value differs between entries that use the same resource:, it is usually an inconsistency introduced by stitching samples together. In the example above it is set only on chrome, one of the two Microsoft.WinGet.DSC/WinGetPackage entries, but there is no reason to differ per application, so keep it consistent within a resource type. The single criterion is whether a stable version of the module that provides the resource has been published: drop it if there is a stable version, and set it only when you are using a module that is still prerelease-only.

4.2. Dividing the Work Between WinGet Configuration and Your Own PowerShell

“If WinGet Configuration is idempotent, why write your own idempotent code in the next chapter?” is a fair question. The answer is that what each one covers is different; they do not replace one another. Here is the breakdown by requirement.

Requirement WinGet Configuration Your own PowerShell (Chapter 5) What to choose in practice
Installing applications published on winget ◎ The standard WinGetPackage resource ○ You can write it, but you make it idempotent yourself Configuration
Windows settings (Developer Mode and so on) ○ If a corresponding DSC resource exists Configuration if the resource exists
Arbitrary registry values (company-standard settings) △ Depends on the available resource ◎ Anything can be written PowerShell
In-house applications on a shared folder (MSI/EXE) △ Outside the scope of the standard resources PowerShell
Network drives and shared printers × Per-user work that happens at logon PowerShell (the unelevated phase)
Differences by department or machine model △ Split the YAML ◎ Swapping the configuration JSON is enough PowerShell
The target OS is old (earlier than 1809, winget earlier than 1.6) × Does not meet the requirements PowerShell
Returning whether a restart is needed to the distribution tool via an exit code △ Hard to control ◎ You decide it yourself PowerShell

Keep the Canonical Application List in One Place

The conclusion is: what fits on winget’s turf goes to Configuration, and what does not goes to PowerShell.

But if you use both, keep the application list in only one of them. Write the packages into the Configuration YAML and write the same ones into wingetPackages in kitting.config.json from Chapter 5 as well, and fixing one leaves the other stale.

If you go with a setup that uses Configuration alongside, the practical approach is to remove application installation from the PowerShell side and have the JSON hold only the items Configuration cannot handle, such as registry values and shared printers. Conversely, hunting around for available resources in an attempt to cover everything with Configuration alone is work that often does not pay for itself.

5. Filling In With PowerShell — The Parts winget Does Not Do

Most of the effort in real-world provisioning is actually everything other than installing applications. This is where PowerShell comes in. The key is to write everything so that it does nothing if it has already been done.

5.1. Two Phases at Different Privilege Levels Read the Configuration JSON

This chapter is long, so here is the overall picture first. Only three files are involved: one JSON that decides what to install, read by two scripts running at different privilege levels.

User phase (unelevated, at every user logon)Administrator phase (elevated, once per machine)re-read the distributed configurationInvoke-KsUserKitting.ps1HKCU registry /Network drives /Shared printersInvoke-KsKitting.ps1Folders / HKLM registry /Windows features / winget packages /In-house applications (MSI, EXE)Distribute the configuration JSON under ProgramDatakitting.config.jsonthe definition of how this machine should be

Figure 1: The three-part structure of provisioning. Two phases at different privilege levels read the same definition

There is only one principle behind the split. Settings that apply to the whole machine go in the administrator phase; settings that are created per user go in the user phase. Mix the two and, as described below, it will always break in the form of “the drive I was supposed to have mapped is not visible.”

Where to Look in the Administrator Script

The code that follows is long, so here is what is written where, first. The administrator script is divided by --- N. --- comments, so read only the parts you need.

Section What it does Who should read it
The opening (setup) Loading the configuration JSON, distributing it to C:\ProgramData, starting the log with Start-Transcript Everyone
--- 1. Standard folders --- Creating folders. The simplest example of writing idempotently Anyone who wants to know how to write idempotent code
--- 2. Registry settings --- Writing to HKLM. The key point is to compare the type as well as the value Anyone who wants to distribute company-standard settings
--- 3. Windows features --- Enabling features, and how to pick up whether a restart is needed Anyone who needs .NET Framework 3.5 or similar
--- 4. winget packages --- Deciding whether a package is already installed, and writing it without trusting the exit code Anyone who only wants to look at the winget part
--- 5. In-house applications --- Running an MSI/EXE from a shared folder silently. Comparing versions and handling exit codes Anyone who wants to distribute in-house applications
The ending (exit handling) Choosing between returning 3010 / 1641 / 1 / 0 Anyone wiring this up to a distribution tool such as Intune

5.2. The Configuration JSON Decides What Gets Configured

This script reads kitting.config.json. A complete example of the configuration file is bundled as kitting.config.json in this article’s sample code (the zip at the end of the article), but here is the structure up front.

{
  "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 Business System Client",
      "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" } ]
}

The table below gives each key’s role and which phase reads it. The whole intent of the design is in this table.

Key Contents Phase that reads it Notes
folders Paths of the standard folders to create Administrator (elevated) Does nothing if they already exist
registry Company-standard settings written to HKLM Administrator (elevated) Policy-type settings such as the Edge homepage. They apply to all users
userRegistry Per-user settings written to HKCU User (unelevated) Things like showing file extensions. Writing to HKLM has no effect
windowsFeatures Windows optional features to enable Administrator (elevated) A restart may become necessary
wingetPackages The id and scope of the packages to install with winget Administrator (elevated) Check scope in advance with winget show --scope (Chapter 2)
internalApps In-house applications on a shared folder (MSI/EXE) Administrator (elevated) displayName must match the name shown in Programs and Features exactly
drives Network drive mappings User (unelevated) Per logon session. Created on the elevated side, they are invisible to the user
printers Connections to shared printers User (unelevated) Same as above. A per-user connection is created

The fact that the phase column has two values is the design of this configuration file. When you add a new item, first decide whether it is a machine setting or a user setting, and then choose which key it goes into.

Separate the HKLM and HKCU Settings

registry and userRegistry are separate because they apply in different places. A setting like File Explorer’s “show file extensions” (HideFileExt) reads the per-user HKCU, not an HKLM policy. Writing to HKLM in the administrator phase does not change the display. Put per-user settings like this in userRegistry and apply them in the unelevated phase described below.

For In-House Applications, Specify the Display Name and the Version Required

The displayName in internalApps must match exactly the name shown in Programs and Features. If you specify version, the script also checks whether that version or later is installed (when it is omitted, the decision rests on the name match alone).

This Sample Treats the JSON as the Canonical Source

Application installation itself can also be done with winget import or WinGet Configuration from the previous chapter, but this script includes installing wingetPackages as well. Keeping everything in one configuration file puts the definition of what goes on a machine in a single place, and one run covers it.

Conversely, if you leave an item written in the configuration file that the script never reads, you end up recording a machine that does not have it as “provisioning succeeded.”

5.3. Configure the Whole Machine in the Administrator Phase

What happens here is folders, HKLM, Windows features, application installation, and distributing the configuration JSON that the user phase reads. Shared drives and shared printers are split off into the next phase.

In the code, it checks the registry’s value and type, the winget package’s scope and post-install state, and the in-house application’s display name and version before acting. On exit it returns to the caller not only success or failure but also whether a restart is required or has already been started.

#Requires -RunAsAdministrator
[CmdletBinding()]
param(
    [string] $ConfigPath = "$PSScriptRoot\kitting.config.json"
)

$ErrorActionPreference = 'Stop'
# Get-Content in PowerShell 5.1 reads with the ANSI code page when there is no BOM.
# Reading UTF-8 JSON that way on a Japanese system produces mojibake, so state it explicitly
$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

# The per-user phase described later runs in a separate process, so distribute
# the configuration to a location every user can read.
# If you place the distributed files here and run them from here, the copy source and destination
# are the same file. Copy-Item treats a copy onto itself as an error, so under
# $ErrorActionPreference = 'Stop' the whole script stops before provisioning even starts
$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. Standard folders ----------------------------------------------
    foreach ($dir in $config.folders) {
        if (-not (Test-Path $dir)) {
            $null = New-Item -Path $dir -ItemType Directory
            Write-Verbose "Created: $dir"
        }
    }

    # --- 2. Company-standard registry settings ----------------------------
    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

        # Compare the "type" as well as the value. REG_SZ "1" and DWORD 1 come out
        # equal in a PowerShell comparison, which misjudges a setting that is not
        # actually in effect as "already applied"
        $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 "Set: $($reg.key)\$($reg.name) = $($reg.value) ($($reg.type))"
        }
    }

    # --- 3. Windows features -----------------------------------------------
    foreach ($feature in $config.windowsFeatures) {
        $state = Get-WindowsOptionalFeature -Online -FeatureName $feature
        if ($state.State -ne 'Enabled') {
            # When -NoRestart suppresses the restart, whether one is needed shows up in
            # RestartNeeded on the return value. Without picking it up, the script exits 0
            # even though a restart is required
            $result = Enable-WindowsOptionalFeature -Online -FeatureName $feature -NoRestart
            if ($result.RestartNeeded) { $rebootRequired = $true }
        }
    }

    # --- 4. winget packages ------------------------------------------------
    # winget's exit code can be nonzero even when the package is already installed, and
    # conversely 0 when it is not actually installed. Judge success by querying the state.
    # Without --scope, it picks up the administrator's own per-user installation and
    # misjudges it as "installed at machine scope"
    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 }

        # Put the line-continuation backtick at the end of the line. A comment after it breaks the continuation
        winget install --id $package.id -e --silent `
            --accept-package-agreements --accept-source-agreements `
            --scope $package.scope
        $wingetExit = $LASTEXITCODE

        # Emitting only a warning here and moving on makes the distribution tool record
        # a machine without a required application as "provisioning succeeded"
        if (-not (Test-KsWingetPackage -Id $package.id -Scope $package.scope)) {
            throw "Failed to install $($package.id) (winget ExitCode=$wingetExit)"
        }
    }

    # --- 5. In-house applications (run the shared folder's installer silently) ----
    # Open the installed-application list with both the 32-bit and 64-bit views stated explicitly.
    # When 32-bit PowerShell runs on 64-bit Windows (which happens with some Intune configurations),
    # WOW64 redirection makes HKLM:\SOFTWARE\... point at the 32-bit view, so a 64-bit
    # application is misjudged as "not installed" and reinstalled every time
    $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

        # Do not decide "already installed" on a DisplayName match alone. A machine left with an
        # old version never gets the new one, and re-running never converges on the state in the
        # configuration file. If the configuration has a version, check whether that version or later is installed
        $upToDate = if ($app.version) {
            $wanted = [version] $app.version
            [bool]($installed | Where-Object {
                $cur = $_.DisplayVersion -as [version]   # notations that are not version format are excluded
                $cur -and $cur -ge $wanted
            })
        } else {
            [bool] $installed
        }
        if ($upToDate) { continue }

        # Start-Process's -ArgumentList joins the array with spaces into a single command line, so
        # quote any value that contains a space on the configuration-file side
        # Example: '/quiet /norestart INSTALLDIR="C:\Program Files\KsApp"'
        # An .msi is not an executable, so passing it straight to Start-Process fails with
        # "is not a valid application." Launch it through 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
        }
        # With Windows Installer exit codes, 0 is not the only success.
        # 3010 and 1641 are both successes; only the handling of the restart differs
        switch ($proc.ExitCode) {
            0    { }                                   # success
            3010 { $rebootRequired = $true }           # success. Restart required (ERROR_SUCCESS_REBOOT_REQUIRED)
            1641 { $rebootInitiated = $true }          # success. The installer started a restart
            default {
                throw "Failed to install $($app.displayName) (ExitCode=$($proc.ExitCode))"
            }
        }
        # If a restart has begun, any subsequent installs would be interrupted anyway
        if ($rebootInitiated) { break }
    }

    if ($rebootInitiated) {
        # 1641 means "success, but a restart has already been started." Returning 0 makes the
        # distribution tool treat it as "it completed and then restarted on its own," so pass the code through
        Write-Host 'The installer started a restart. Re-run this after the restart' -ForegroundColor Yellow
        exit 1641
    }

    if ($rebootRequired) {
        # Returning 3010 as it is lets Intune or the distribution tool read it as "success, restart
        # required" and schedule and report the restart. Returning 0 here means the restart is forgotten
        Write-Host 'Provisioning complete (restart required)' -ForegroundColor Yellow
        exit 3010
    }

    Write-Host 'Provisioning complete' -ForegroundColor Green
    exit 0
}
catch {
    Write-Warning "Failed: $($_.Exception.Message)"
    Write-Warning $_.InvocationInfo.PositionMessage
    exit 1
}
finally {
    Stop-Transcript
}

Reading the Administrator Script’s Exit Codes

Code returned What it means in this script What to do next
0 It completed and is not requesting a restart Record it as complete
3010 Success, but a restart is needed for the changes to take effect Tell the distribution tool that a restart is needed
1641 Success. The installer started a restart Do not continue; re-run after the restart
1 The script caught an error Check the log for where it failed

Windows Installer’s 3010 and 1641 are not failures. Conversely, returning only 0 to the caller leaves no way to communicate that a restart is needed or has already started.6

5.4. Run the User Phase Unelevated, at the User’s Logon

Note that network drive mapping is deliberately left out of this administrator script. A drive letter mapping is a per-logon-session setting, so one created in a session elevated to administrator is not visible from the user’s ordinary File Explorer under UAC.

Run it with system privileges from something like Intune and the mapping goes to SYSTEM’s session in the first place, which is of no use to the user. The right answer is to apply user-specific settings unelevated, at that user’s logon.

# Per-user settings (run unelevated, at the user's logon. How to register it is described below)

# This is a separate process from the administrator script, so $config is not inherited.
# Re-read the configuration that the administrator phase distributed to ProgramData
$configPath = 'C:\ProgramData\KsKitting\kitting.config.json'
if (-not (Test-Path $configPath)) {
    Write-Warning "Configuration file not found: $configPath"
    exit 1
}
$config = Get-Content $configPath -Raw -Encoding utf8 | ConvertFrom-Json

# Per-user registry settings (HideFileExt and the like. Writing to HKLM has no effect)
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
    }
}

# Network drives
foreach ($drive in $config.drives) {
    $local    = "$($drive.letter):"
    $existing = Get-SmbMapping -LocalPath $local -ErrorAction SilentlyContinue
    if ($existing) {
        # Deciding on "is there a mapping?" alone means that when the share moves and you change
        # the setting, the old mapping stays and it is still reported as "success." Compare the target too
        if ($existing.RemotePath.TrimEnd('\') -eq $drive.path.TrimEnd('\')) { continue }
        Remove-SmbMapping -LocalPath $local -Force
    }
    New-SmbMapping -LocalPath $local -RemotePath $drive.path -Persistent $true
}

# Connections to shared printers are per user as well. Run as an administrator or as SYSTEM and
# the connection is created only for that account, invisible to the user
foreach ($printer in $config.printers) {
    if (-not (Get-Printer -Name $printer.name -ErrorAction SilentlyContinue)) {
        Add-Printer -ConnectionName $printer.connection
    }
}

Printers, HKCU, and the Profile Also Belong on the User Side

Connecting to a shared printer (Add-Printer -ConnectionName) is treated the same way. It is an operation that creates a per-user connection, so doing it in the administrator script or in Intune’s SYSTEM execution leaves it invisible to the employee who logs on later. If you want it present on every machine, either use a mechanism that deploys printers per machine, such as policy distribution from a print server, or run it in this unelevated phase.

For the same reason, placing files under the user profile, writing to HKCU, and creating shortcuts for the user all belong in this unelevated phase. The two-stage form, machine-wide settings once as an administrator and user-specific settings unelevated at every logon, is the basic shape of a provisioning script. For the caveats around UNC paths and drive mappings, see also “Pitfalls of Network Drives and UNC Paths.”

5.5. Pick One Way to Launch It at Logon

The crux of the two-stage design is the mechanism that runs this unelevated script at the user’s logon, under that user’s own privileges. If that is not wired up, only the administrator phase runs and you end up with a machine where neither the drives nor the printers are configured. There are three ways to do it.

Method When it runs Environments it suits Caveats
The HKLM Run key At every logon of every user Not domain-joined. You want one script from Intune or a distribution tool to cover everything It runs every time, so idempotency is a prerequisite. You need an option that keeps a window from appearing
Task Scheduler (logon trigger) At every logon of every user You want a history of run results. You want a delayed start Turn off “Run with highest privileges” (turning it on elevates the task and defeats the purpose)
A logon script (Group Policy) At every logon of every user Domain-joined It fits into existing GPO operations, but it is awkward for a standalone machine

Method 1: Register It in the HKLM Run Key

Register it with administrator privileges. If you fold it into the administrator script, put it before the exit that returns the exit code. The configuration JSON the user phase reads is distributed by the administrator phase in Section 5.3.

# Place the user-phase script somewhere every user can read
$userScript = 'C:\ProgramData\KsKitting\Invoke-KsUserKitting.ps1'
Copy-Item "$PSScriptRoot\Invoke-KsUserKitting.ps1" $userScript -Force

# The HKLM Run key runs under the privileges of the user who logged on (unelevated).
# Trying to write to HKCU instead would, since this runs as an administrator or as SYSTEM,
# write to "the administrator's own HKCU," not the HKCU of the employee about to use the machine
$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

Method 2: Register a Scheduled Task (When You Want a History)

In case you skipped Method 1 and start reading here, this repeats the script placement from the beginning.

# Place it somewhere every user can read (same as Method 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

# Specifying BUILTIN\Users (S-1-5-32-545) makes it run under the privileges of whoever logged on.
# RunLevel Limited is the "do not elevate" setting. Set it to Highest and the task runs in an
# elevated session, and the drive mappings stop being visible to the user
$principal = New-ScheduledTaskPrincipal -GroupId 'S-1-5-32-545' -RunLevel Limited

Register-ScheduledTask -TaskName 'KsUserKitting' `
    -Action $action -Trigger $trigger -Principal $principal -Force

Method 3: A Group Policy Logon Script

The setting lives at User Configuration > Policies > Windows Settings > Scripts (Logon/Logoff) > Logon (on a standalone machine’s gpedit.msc there is no Policies level, so it is User Configuration > Windows Settings > Scripts (Logon/Logoff)). Add it on the dialog’s PowerShell Scripts tab. Write a .ps1 directly on the Scripts tab and it is treated as an executable, which does not behave the way you intend. With local policy, the script itself is placed in %SystemRoot%\System32\GroupPolicy\User\Scripts\Logon.

Because It Runs Every Time, Check the Current State

With any of the methods, the fact that it runs at every logon is the same. That is part of why the user-phase script is written idempotently, checking the current state before making a change. If you want it to run only once, write a completion marker under HKCU and check it at the top of the script.

5.6. Review Re-runs, the Audit Trail, and Integration With the Distribution Tool

Review the design so far before putting it into operation.

Line Up Privileges, Settings, and the Current State

  • Separate settings that need elevation from per-user settings. As above, mixing them breaks in the form of “the drive I was supposed to have mapped is not visible”
  • Externalize the settings into JSON. Differences by department and by machine model can then be expressed without changing the script
  • Check the current state before each operation. That is what makes it safe to run any number of times

Keep a Log of What Was Done

Keep an audit trail with Start-Transcript. You can trace afterwards what was done on a given machine (“PowerShell Output Streams and Log Design”)

Communicate the Need for a Restart Through the Exit Code Too

Return an exit code. Intune or a distribution tool can then decide success or failure. Note that with Windows Installer 0 is not the only success. Both 3010 (ERROR_SUCCESS_REBOOT_REQUIRED, restart required) and 1641 (ERROR_SUCCESS_REBOOT_INITIATED, restart already started) are successes.6 Let them fall into default and be treated as failures, and a successful install is recorded in red in the distribution tool. Treat them as successes, and the key is to return that same code to the caller at the end. Return 0 and the distribution tool loses any way of knowing that a restart is needed (“PowerShell Error Handling and Retry Design”)

For Arguments Containing Spaces, Check the Quoting

Watch the quoting of installer arguments. Start-Process -ArgumentList merely joins the array with spaces, so the argument boundaries are not preserved. Either quote paths containing spaces on the configuration-file side, or use ProcessStartInfo.ArgumentList (PowerShell 7) (“Calling External EXEs Correctly from PowerShell”)

6. Using winget From the PowerShell Module

Work With Objects, Not Strings

Using the PowerShell module is more robust than parsing command-line output as strings. Microsoft.WinGet.Client offers cmdlets such as Find-WinGetPackage / Install-WinGetPackage / Get-WinGetPackage / Update-WinGetPackage.4

Install-Module -Name Microsoft.WinGet.Client -Scope AllUsers -Force

# Check whether it is installed before installing (idempotent)
foreach ($id in 'Google.Chrome', 'Microsoft.VisualStudioCode', '7zip.7zip') {
    if (Get-WinGetPackage -Id $id -ErrorAction SilentlyContinue) {
        Write-Verbose "Already installed: $id"
        continue
    }
    Install-WinGetPackage -Id $id -Mode Silent -Scope System
}

Because the results come back as objects, success checks and reconciling lists can be written directly, which is the advantage.

7. Caveats for Unattended Execution and the System Context

Any attempt to fully automate provisioning runs into the question of which account it runs under. Even after the scope checks in Chapter 2 and the phase split in Chapter 5, the last thing you need is to verify with the same execution account used at distribution time.

Do Not Assume It Works Under SYSTEM

  • Parts of winget assume execution in a user context. Execution in the system context is still at the stage where Microsoft lists it as a future feature3

    Do Not Confuse What Worked as Administrator With the User’s Environment

  • Separate work that needs elevation from user-specific work. A setup that applies machine-wide settings with administrator privileges and settings under the user profile at first logon is easier to work with

    Confirm Under the Same Conditions as Distribution

  • Always verify with the actual execution account. “It worked under my own administrator account but not once it was distributed” is the most common failure in this area (“When Task Scheduler Tasks Don’t Run”)

8. Practical Rules of Thumb (Decision Table)

Finally, map each item in the runbook onto this table. Decide not only the means of execution but also the privileges, the re-runs, the record, and the restart, and provisioning becomes something you can build as an operational process.

What to do Means Notes
Installing commercial and open-source products winget install / configure --silent --accept-* are mandatory1
Extracting the configuration of an existing standard machine winget export Settings are not included. Use it as a starting point2
Applications + Windows settings, declaratively winget configure (YAML) Windows 10 1809 or later + winget 1.6 or later3
Registry, Windows features, in-house applications PowerShell (administrator) Check the state before changing it (idempotent)
Shared drives, shared printers, user-specific settings PowerShell (at logon, unelevated) Created elevated or as SYSTEM, they are invisible to the user
In-house applications PowerShell + a silent installer Building a dedicated repository is overkill at a small scale
Driving it from PowerShell Microsoft.WinGet.Client Removes the need to parse output as strings4
Unattended execution Verify with the execution account The system context has constraints3
Recording what was done Start-Transcript + an exit code Keeps a record of what was done to each machine
When a restart is needed Return exit code 3010 / 1641 Both are successes. Return 0 and the distribution tool cannot recognize the restart6

9. Summary

  • A provisioning runbook can be replaced with executable files. Leaving application installation to winget and everything else to PowerShell is the realistic division.
  • With winget install, always add --silent and --accept-package-agreements --accept-source-agreements. Forget them and the unattended run stops.
  • winget export can extract the configuration from an existing standard machine, but settings and applications outside winget’s management are not included.
  • Use WinGet Configuration (winget configure) and you can gather applications and settings into a single declarative file, giving a setup that stands up to being re-run.
  • Always write the PowerShell side so that it checks the current state before making a change, to ensure idempotency.
  • Split the execution phases: machine-wide settings with administrator privileges, and user-specific settings such as network drives and shared printers unelevated at logon. Connections created in an elevated session or as SYSTEM are not visible to the user.
  • For unattended execution, verifying the execution account matters most. Design on the assumption that running winget in the system context has constraints.

Downloading the Sample Code

The code covered in this article is distributed packaged in a form you can run as it stands. It contains complete examples of the administrator phase, the user phase, and the configuration file.

Download the sample code (zip)

Because the samples in this article depend on Windows and on a tenant, they have not been verified by running them. Syntax parsing and static analysis with PSScriptAnalyzer have been applied to every file, but always confirm the behavior on your own test machine.

# Syntax parsing + static analysis (runs on non-Windows too)
./Invoke-SampleTests.ps1

The configured values (paths, server names, tenant IDs, and so on) are examples. Do not run them as-is in production; adapt them to your own environment.

KomuraSoft LLC handles PC provisioning, automation of company-standard environments, making operations that are locked up in a runbook and in one person’s head executable, and design support for distribution scripts.

References

  1. Microsoft Learn, install command (winget). On specifying the target with –id / -e, unattended installation with –silent, agreeing to the terms of use with –accept-package-agreements / –accept-source-agreements, and specifying the installation scope (user / machine) with –scope. See also the command list in Use WinGet to install and manage applications 2 3

  2. Microsoft Learn, export command (winget). On being able to write the list of installed packages out to JSON, recording versions with –include-versions, restoring with the import command and the behavior of –ignore-unavailable, the export being limited to packages under winget’s management, and the hierarchy of the output JSON (Sources / Packages / PackageIdentifier / Version, with Version optional). The JSON structure is also defined in packages.schema.2.0.json: the top level holds WinGetVersion, CreationDate and Sources, each element of Sources holds SourceDetails (Name / Identifier / Argument / Type) and Packages, and each element of Packages requires PackageIdentifier and optionally holds Version and others.  2 3 4 5

  3. Microsoft Learn, WinGet Configuration. On WinGet Configuration being a mechanism that declares the desired state in YAML and applies it using PowerShell DSC, on its being usable for unattended setup, on it requiring Windows 10 version 1809 (build 17763) or later or Windows 11 and WinGet v1.6.2631 or later, on how UAC is handled when it is run from an administrator shell, and on execution in the system context being listed as a future development item. See also show and –accept-configuration-agreements in the configure command 2 3 4 5 6 7

  4. GitHub, microsoft/winget-cli — Microsoft.WinGet.Client PowerShell module. On being able to install the Microsoft.WinGet.Client module from the PowerShell Gallery, and on the cmdlets it provides, such as Find-WinGetPackage / Get-WinGetPackage / Install-WinGetPackage / Update-WinGetPackage / Uninstall-WinGetPackage, letting you work with the results as objects.  2 3

  5. Microsoft Learn, show command (winget). On this being the command that displays the details of a specified application (metadata and installer information), on the –scope option letting you choose the installation scope (user / machine), and on the installer information displayed being based on the arguments you specify and on WinGet’s own determination. 

  6. Microsoft Learn, Windows Installer Error Codes. On ERROR_SUCCESS_REBOOT_REQUIRED (3010) meaning “a restart is required for the changes to take effect; the installation itself succeeded,” and on ERROR_SUCCESS_REBOOT_INITIATED (1641) meaning “the installer has started a restart,” a code that indicates success.  2 3

Recent articles sharing the same tags. Deepen your understanding with closely related topics.

These topic pages place the article in a broader service and decision context.

This article connects naturally to the following service pages.

Frequently Asked Questions

Common questions about the topic of this article.

Can winget import automate the whole of PC provisioning?
It can automate the application installs, but that alone is not enough. What winget import reproduces is a list of packages; in-app settings, adding printers, mapping network drives, power settings, company-standard settings applied through the registry, and so on are all out of scope. Applications installed by means other than winget, and in-house line-of-business applications, are also not included in the export. In practice, the realistic approach is a two-stage setup: leave application installation to winget and fill in the remaining settings with a PowerShell script.
What is the difference between winget and WinGet Configuration (winget configure)?
winget's install/import are procedural instructions of the form "install these, in this order," whereas WinGet Configuration is a way of writing a declaration in a YAML file of "this is the state I want to end up in." Internally it uses PowerShell DSC, so a single file can express not only application installation but also Windows settings and application configuration. Because it does nothing when the desired state is already in place, you can simply re-run the same file if something fails partway through, which makes it robust when provisioning has to be redone. It requires Windows 10 1809 or later and winget 1.6 or later.
Is it safe to run winget with SYSTEM privileges from Task Scheduler or Intune?
Care is needed. Parts of winget assume execution in a user context, and execution in the system context is still something Microsoft lists as a future development item. In practice you work around it by using --scope machine to install for all users, by going through the PowerShell module (Microsoft.WinGet.Client), or by running it in the user context at first logon. Either way, always verify with the actual account the script will run under.
Should a provisioning script be safe to run any number of times?
Yes. Treat idempotency (getting the same result no matter how many times it runs) as mandatory. Provisioning routinely fails partway through, and each time you need to be able to start over from the beginning. If you write it so that folder creation checks for existence with Test-Path first, registry settings check the current value first, and application installs check whether the application is already installed first, you can resume from the point of failure. WinGet Configuration has this way of thinking built in from the start.
Can we distribute in-house line-of-business applications through winget?
It is possible if you set up an internal private repository (a REST API source), but that means building and maintaining a server for it. For just a handful of applications, silently running an installer from a shared folder with a PowerShell script is simpler. For small organizations, the most realistic split is to leave commercial and open-source products to winget and install in-house applications with PowerShell.

Author Profile

Profile page for the article author.

Go Komura

Representative of KomuraSoft LLC

Focused on Windows software development, technical consulting, and investigations into failures that are difficult to reproduce.

Back to the Blog