Public/New-ProvisioningPackage.ps1

# SPDX-FileCopyrightText: © 2019 David Haymond <david@davidhaymond.dev>
#
# SPDX-License-Identifier: GPL-3.0-or-later

<#
.SYNOPSIS
    Creates a new Windows desktop provisioning package.

.DESCRIPTION
    The New-ProvisioningPackage command creates a new Windows provisioning
    package designed to automate setting up a new Windows 10 or Windows 11
    device using a small subset of common settings. Packages can be copied to a
    USB drive and inserted during the Windows Out of Box Experience (OOBE) that
    appears when the device is first powered on. At a minimum, packages set the
    computer name and local administrator credentials. Packages can also join
    the computer to a domain, install applications, run scripts, add Wi-Fi
    profiles, and enable multi-app kiosk mode.

    The -ComputerName parameter accepts multiple computer names, and one
    provisioning package will be created for each computer name. You can also
    pipe a list of computer names to New-ProvisioningPackage. Each package
    contains a unique computer name but the packages are otherwise identical.

.PARAMETER ComputerName
    Specifies one or more computer names. New-ProvisioningPackage will generate
    one package file for each computer name specified, using the computer name
    as the package file name with the .ppkg extension appended.

    For help generating a unique name, you can use %SERIAL%, which includes a
    hardware-specific serial number, or you can use %RAND:x%, which generates
    random characters of x length.

.PARAMETER LocalAdminCredential
    Specifies the credentials of a local administrator account to create.

.PARAMETER DomainName
    Specifies the name of a domain to join. If omitted, the provisioning
    package will set up the device as a workgroup computer.

.PARAMETER DomainJoinCredential
    Specifies the credentials of a domain account with permission to join
    computers to a domain.

.PARAMETER Application
    Specifies a list of applications or scripts to run during provisioning.
    This parameter accepts an array of values, each of which should be either a
    string or a hashtable.

    If a string is used, it should point to the path of the script or
    executable. Note that the file will be invoked without any command-line
    arguments.

    If a hashtable is used, it should contain one or more of the following
    keys:

        - Path (required): Specifies one or more scripts or executables. The
                           first file will be executed (unless you specify
                           otherwise using the Command key below). Any
                           additional files will be copied to the same folder
                           and can be referenced by the primary script or
                           executable.
        - Name: Specifies the name of the application. Defaults to
                           the first Path entry.
        - Command: Specifies the command executed during provisioning.
                           Defaults to `cmd /c "<setup.exe>"`, where
                           <setup.exe> is replaced with the name of the first
                           entry in the Path key. Include this key when you
                           need to pass command-line arguments to the
                           executable (e.g. to cause an installer to run
                           silently), or if your script isn't a batch file and
                           needs to be run in a shell other than cmd.exe (e.g.
                           powershell.exe).
        - ContinueInstall: Indicates whether subsequent installations should
                           continue if the current install fails. Defaults to
                           $true.
        - RestartRequired: Indicates whether or not to force a restart after
                           running this application (and before proceeding with
                           subsequent applications). Defaults to $false.
        - RestartExitCode: Specifies the exit code returned by the installer
                           that indicates a restart is needed to complete
                           installation. Defaults to 3010.
        - SuccessExitCode: Specifies the exit code returned by the installer
                           that indicates the installation was successful.
                           Defaults to 0.

    The application output is logged to a file on the target device located at
    %SystemDrive%\<app name>-install.log, where <app name> is the name of the
    app as specified in the Name key explained above.

.PARAMETER Wifi
    Specifies a list of Wi-Fi profiles to configure during provisioning. This
    parameter accepts an array of hashtables, each containing one or more of
    the following keys:

        - Ssid (required): Specifies the Wi-Fi network name or SSID.
        - SecurityKey: If present, specifies the network security key for a
                           WPA2-Personal Wi-Fi network. Omit this key if the
                           network is open (unsecured).
        - AutoConnect: Indicates whether the target device should
                           automatically connect to this network when in range.
                           Defaults to $true.

    Note that if the Wifi parameter is specified, the resulting provisioning
    package will fail to install if the target device does not have a wireless
    network interface controller (NIC).

.PARAMETER KioskXml
    Specifies the path to an XML file that contains the multi-app kiosk mode
    configuration settings.

    To learn how to create this file, see
    https://docs.microsoft.com/en-us/windows/configuration/lock-down-windows-10-to-specific-apps.

.PARAMETER Path
    Specifies the output directory to save the provisioning packages to.

.PARAMETER Force
    Overwrite existing files and surpress confirmation prompts.

.EXAMPLE
    New-ProvisioningPackage -ComputerName PC01, PC02

    Creates two provisioning packages (PC01.ppkg, PC02.ppkg), one for each
    computer specified in the ComputerName parameter. The user will be prompted
    for the local administrator account username and password.

.EXAMPLE
    $params = @{
        LocalAdminCredential = 'User'
        Application = '.\Office\setup.exe'
        Wifi = @{ Ssid = 'Internal'; SecurityKey = 'HouseSpeakerB#' }
    }
    Get-Content computer-names.txt | New-ProvisioningPackage @params

    Gets a list of computer names from computer-names.txt and pipes them to
    New-ProvisioningPackage, which generates a new package for each computer
    name. Each package will provision its respective device with a local
    administrator account named "User" and a Wi-Fi profile that connects to the
    Internal network with a security key of "HouseSpeakerB#". The package will
    also execute the ".\Office\setup.exe" installer during the provisioning
    process.

.EXAMPLE
    $apps = @(
        'setup.exe',
        @{
            Path = 'C:\install.exe'
            Command = 'cmd /c "install.exe" /quiet'
            RestartRequired = $true
        }
    )
    $wifiProfiles = @(
        @{ Ssid = 'AcmePrivate'; SecurityKey = 'CompanySecrets' }
        @{ Ssid = 'AcmePublic' }
    )
    $params = @{
        ComputerName = 'Bob-Laptop'
        LocalAdminCredential = 'admin'
        DomainName = 'ACME'
        DomainJoinCredential = 'ACME\Admin'
        Application = $apps
        Wifi = $wifiProfiles
    New-ProvisioningPackage @params

    Creates a provisioning package for a device to be named "Bob-Laptop". In
    addition to naming the computer, the provisioning package will join the
    device to the ACME domain using the ACME\Admin account and create a local
    admininistrator account named "admin". Two applications are specified for
    installation: setup.exe from the current directory, and C:\install.exe. The
    latter application will be run with the /quiet argument, and the device
    will restart after installation. The package will also configure two Wi-Fi
    profiles on the target device: the AcmePrivate WPA2-Personal network with a
    security key of CompanySecrets, and the open AcmePublic network.

.EXAMPLE
    $params = @{
        ComputerName = ('KIOSK1', 'KIOSK2')
        KioskXml = 'kiosk-settings.xml'
        Application = @{
            Path = ('script.ps1', 'data.json')
            Command = 'powershell.exe -NoProfile -File script.ps1'
        }
    }
    New-ProvisioningPackage @params

    Creates provisioning packages for two kiosk computers using the settings in
    kiosk-settings.xml. Also executes a PowerShell script, which has access to
    a supporting datafile (data.json).

.NOTES
    For more information about provisioning packages, visit
    https://docs.microsoft.com/en-us/windows/configuration/provisioning-packages/provisioning-packages

    For information about multi-app kiosk mode, see
    https://docs.microsoft.com/en-us/windows/configuration/lock-down-windows-10-to-specific-apps
#>


function New-ProvisioningPackage {

    [CmdletBinding(
        DefaultParameterSetName = 'Workgroup',
        SupportsShouldProcess = $true,
        ConfirmImpact = 'Low'
    )]
    param (
        [Parameter(
            Mandatory = $true,
            Position = 0,
            ValueFromPipeline = $true
        )]
        [ValidatePattern('^[A-Za-z0-9\-:%]{1,63}$',
            ErrorMessage = 'The computer name "{0}" is invalid. ' +
            'Supply a name composed of letters, numbers, and hyphens that is between 1 and 63 characters long. ' +
            'Using %SERIAL% or %RAND:x% is also allowed to generate names (see Windows Configuration Designer documentation).'
        )]
        [string[]]
        $ComputerName,

        [Parameter(
            Mandatory = $true,
            Position = 1
        )]
        [pscredential]
        $LocalAdminCredential,

        [Parameter(
            ParameterSetName = 'Domain',
            Mandatory = $true
        )]
        [string]
        $DomainName,

        [Parameter(
            ParameterSetName = 'Domain',
            Mandatory = $true
        )]
        [pscredential]
        $DomainJoinCredential,

        [object[]]
        $Application,

        [hashtable[]]
        $Wifi,

        [string]
        $KioskXml,

        [string]
        [PSDefaultValue(Help = 'Current directory')]
        $Path = (Get-Location).Path,

        [switch]
        $Force
    )

    begin {
        if ($Force -and -not $Confirm) {
            $ConfirmPreference = 'None'
        }

        $script:TEMPDIR = New-TemporaryDirectory

        $icdRoot = Get-IcdRoot
        if (-not $icdRoot) {
            if (Install-Icd -Force:$Force) {
                $icdRoot = Get-IcdRoot
            }
            else {
                throw "Missing dependency: Windows Imaging and Configuration Designer"
            }
        }
        $icdExec = Join-Path -Path $icdRoot -ChildPath 'ICD.exe'
        $icdLogPath = Join-Path -Path $env:TEMP -ChildPath 'ProvisioningTools-ICD.log'
    }

    process {
        $ComputerName | ForEach-Object -Process {
            $currentComputerName = $_
            $params = @{
                ComputerName         = $currentComputerName
                LocalAdminCredential = $LocalAdminCredential
                DomainName           = $DomainName
                DomainJoinCredential = $DomainJoinCredential
                Application          = $Application
                Wifi                 = $Wifi
                KioskXml             = $KioskXml
            }
            $customizationsArgs = Get-CustomizationsArg @params
            $doc = New-CustomizationsXmlDocument @customizationsArgs
            $ppkgPath = Confirm-PackagePath -ComputerName $currentComputerName -Path $Path -Force:$Force

            if ($ppkgPath -and $PSCmdlet.ShouldProcess("Paths: $ppkgPath", "Build Provisioning Package")) {

                $xmlName = [System.IO.Path]::GetRandomFileName()
                $xmlPath = Join-Path -Path $script:TEMPDIR.FullName -ChildPath "$xmlName.xml"
                Set-XmlContent -XmlDocument $doc -Path $xmlPath -Confirm:$false

                try {
                    # Run ICD.exe to generate the provisioning package
                    $icdArgs = Get-IcdArg -IcdRoot $icdRoot -XmlPath $xmlPath -PackagePath $ppkgPath -Overwrite $Force
                    $startProcessArgs = @{
                        FilePath              = $icdExec
                        ArgumentList          = $icdArgs
                        WindowStyle           = 'Hidden'
                        Wait                  = $true
                        Confirm               = $false
                        RedirectStandardError = $icdLogPath
                    }
                    Start-Process @startProcessArgs
                }
                finally {
                    # ICD.exe also generates a .cat file in addition to the .ppkg file.
                    # We don't need it, so delete it.
                    $catPath = Join-Path -Path $Path -ChildPath "$($currentComputerName).cat"
                    Remove-Item -Path $catPath -ErrorAction SilentlyContinue -Confirm:$false -Force
                }

                if (-not (Test-Path $ppkgPath)) {
                    Write-Error "Couldn't find the output package. Please see $icdLogPath for details."
                }
            }
        }
    }
    end {
        $script:TEMPDIR | Remove-Item -Recurse -Force
    }
}