Public/New-ProvisioningPackage.ps1

<#
    ProvisioningTools — Automates creation of Windows provisioning packages.
    Copyright (C) 2022 David Haymond.

    This program is free software: you can redistribute it and/or modify
    it under the terms of the GNU General Public License as published by
    the Free Software Foundation, either version 3 of the License, or
    (at your option) any later version.

    This program is distributed in the hope that it will be useful,
    but WITHOUT ANY WARRANTY; without even the implied warranty of
    MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
    GNU General Public License for more details.

    You should have received a copy of the GNU General Public License
    along with this program. If not, see <https://www.gnu.org/licenses/>.
#>


function New-ProvisioningPackage {
    <#
    .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 -LocalAdminCredential Admin

        Creates two provisioning packages (PC01.ppkg, PC02.ppkg), one for each
        computer specified in the ComputerName parameter. The packages will
        create a local administrator account named "Admin" on each computer.

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


        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 = 'ContosoPrivate'; SecurityKey = 'CompanySecrets' }
            @{ Ssid = 'ContosoPublic' }
        )
        New-ProvisioningPackage -ComputerName Bob-Laptop `
        -LocalAdminCredential admin -DomainName CONTOSO `
        -DomainJoinCredential CONTOSO\Admin -Application $apps `
        -Wifi $wifiProfiles


        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 CONTOSO domain using the CONTOSO\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
        ContosoPrivate WPA2-Personal network with a security key of
        CompanySecrets, and the open ContosoPublic network.

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

        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
    #>


    [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
    }
}