RequiredModules.psm1

#Region '.\Classes\RequiredModule.ps1' 0
# A class for a structured version of a dependency
# Note that by default, we leave the repository empty
# - If you set the repository to "PSGallery" we will _only_ look there
# - If you leave it blank, we'll look in all registered repositories
class RequiredModule {
    [string]$Name
    [NuGet.Versioning.VersionRange]$Version
    [string]$Repository
    [PSCredential]$Credential

    # A simple dependency has just a name and a minimum version
    RequiredModule([string]$Name, [string]$Version) {
        $this.Name = $Name
        $this.Version = $Version
        # $this.Repository = "PSGallery"
    }

    # A more complicated dependency includes a specific repository URL
    RequiredModule([string]$Name, [NuGet.Versioning.VersionRange]$Version, [string]$Repository) {
        $this.Name = $Name
        $this.Version = $Version
        $this.Repository = $Repository
    }

    # The most complicated dependency includes credentials for that specific repository
    RequiredModule([string]$Name, [NuGet.Versioning.VersionRange]$Version, [string]$Repository, [PSCredential]$Credential) {
        $this.Name = $Name
        $this.Version = $Version
        $this.Repository = $Repository
        $this.Credential = $Credential
    }

    # This contains the logic for parsing a dependency entry: @{ module = "[1.2.3]" }
    # As well as extended logic for allowing a nested hashtable like:
    # @{
    # module = @{
    # version = "[1.2.3,2.0)"
    # repository = "url"
    # }
    # }
    hidden [void] Update([System.Collections.DictionaryEntry]$Data) {
        $this.Name = $Data.Key

        if ($Data.Value -as [NuGet.Versioning.VersionRange]) {
            $this.Version = [NuGet.Versioning.VersionRange]$Data.Value
            # This is extra: don't care about version, do care about repo ...
        } elseif ($Data.Value -is [string] -or $Data.Value -is [uri]) {
            $this.Repository = $Data.Value

        } elseif ($Data.Value -is [System.Collections.IDictionary]) {
            # this allows partial matching like the -Property of Select-Object:
            switch ($Data.Value.GetEnumerator()) {
                { "Version".StartsWith($_.Key, [StringComparison]::InvariantCultureIgnoreCase) } {
                    $this.Version = $_.Value
                }
                { "Repository".StartsWith($_.Key, [StringComparison]::InvariantCultureIgnoreCase) } {
                    $this.Repository = $_.Value
                }
                { "Credential".StartsWith($_.Key, [StringComparison]::InvariantCultureIgnoreCase) } {
                    $this.Credential = $_.Value
                }
                default {
                    throw [ArgumentException]::new($_.Key, "Unrecognized key '$($_.Key)' in module constraints for '$($_.Name)'")
                }
            }
        } else {
            throw [System.Management.Automation.ArgumentTransformationMetadataException]::new("Unsupported data type in module constraint for $($Data.Key) ($($Data.Key.PSTypeNames[0]))")
        }
    }

    # This is a cast constructor supporting casting a dictionary entry to RequiredModule
    # It's used that way in ConvertToRequiredModule and thus in ImportRequiredModulesFile
    RequiredModule([System.Collections.DictionaryEntry]$Data) {
        $this.Update($Data)
    }
}
#EndRegion '.\Classes\RequiredModule.ps1' 77
#Region '.\Private\AddPsModulePath.ps1' 0
filter AddPSModulePath {
    [OutputType([string])]
    [CmdletBinding()]
    param(
        [Alias("PSPath")]
        [Parameter(Mandatory, ValueFromPipeline)]
        [string]$Path,

        [switch]$Clean
    )
    Write-Verbose "Adding '$Path' to the PSModulePath"

    # First, guarantee it exists, as a folder
    if (-not (Test-Path $Path -PathType Container)) {
        # NOTE: If it's there as a file, then
        # New-Item will throw a System.IO.IOException "An item with the specified name ... already exists"
        New-Item $Path -ItemType Directory -ErrorAction Stop
        Write-Verbose "Created Destination directory: $(Convert-Path $Path)"
    } elseif (Get-ChildItem $Path) {
        # If it's there as a directory that's not empty, maybe they said we should clean it?
        if (!$Clean) {
            Write-Warning "The folder at '$Path' is not empty, and it's contents may be overwritten"
        } else {
            Write-Warning "The folder at '$Path' is not empty, removing all content from '$($Path)'"
            try {
                Remove-Item $Path -Recurse -ErrorAction Stop # No -Force -- if this fails, you should handle it yourself
                New-Item $Path -ItemType Directory
            } catch {
                $PSCmdlet.WriteError(
                    [System.Management.Automation.ErrorRecord]::new(
                        [Exception]::new("Failed to clean destination folder '$($Path)'"),
                        "Destination Cannot Be Emptied",
                        "ResourceUnavailable", $Path))
                return
            }
        }
    }

    # Make sure it's on the PSModulePath
    $RealPath = Convert-Path $Path
    if (-not (@($Env:PSModulePath.Split([IO.Path]::PathSeparator)) -contains $RealPath)) {
        $Env:PSModulePath = $RealPath + [IO.Path]::PathSeparator + $Env:PSModulePath
        Write-Verbose "Addded $($RealPath) to the PSModulePath"
    }
    $RealPath
}
#EndRegion '.\Private\AddPsModulePath.ps1' 47
#Region '.\Private\ConvertToRequiredModule.ps1' 0
filter ConvertToRequiredModule {
    <#
        .SYNOPSIS
            Allows converting a full hashtable of dependencies
    #>

    [OutputType('RequiredModule')]
    [CmdletBinding()]
    param(
        # A hashtable of RequiredModules
        [Parameter(Mandatory, ValueFromPipeline)]
        [System.Collections.IDictionary]$InputObject
    )
    $InputObject.GetEnumerator().ForEach([RequiredModule])
}
#EndRegion '.\Private\ConvertToRequiredModule.ps1' 15
#Region '.\Private\FindModuleVersion.ps1' 0
filter FindModuleVersion {
    <#
        .SYNOPSIS
            Find the first module in the feed(s) that matches the specified name and VersionRange
        .DESCRIPTION
            This function wraps Find-Module -AllVersions to filter according to the specified VersionRange
 
            Install-RequiredModule supports Nuget style VersionRange, where both minimum and maximum versions can be either inclusive or exclusive. Since Find-Module only supports Inclusive, we can't just use that.
        .EXAMPLE
            FindModuleVersion PowerShellGet "[2.0,5.0)"
 
            Returns the first version of PowerShellGet greater than 2.0 and less than 5.0 (up to 4.9*) that's available in the feeds (in the results of Find-Module -Allversions)
    #>

    [CmdletBinding(DefaultParameterSetName = "Unrestricted")]
    param(
        # The name of the module to find
        [Parameter(ValueFromPipelineByPropertyName, Mandatory)]
        [string]$Name,

        # The VersionRange for valid modules
        [Parameter(ValueFromPipelineByPropertyName, Mandatory)]
        [NuGet.Versioning.VersionRange]$Version,

        # Set to allow pre-release versions (defaults to tru if either the minimum or maximum are a pre-release, false otherwise)
        [switch]$AllowPrerelease = $($Version.MinVersion.IsPreRelease, $Version.MaxVersion.IsPreRelease -contains $True),

        # A specific repository to fetch this particular module from
        [AllowNull()]
        [Parameter(ValueFromPipelineByPropertyName, Mandatory, ParameterSetName="SpecificRepository")]
        [string[]]$Repository,

        # Optionally, credentials for the specified repository
        [AllowNull()]
        [Parameter(ValueFromPipelineByPropertyName, ParameterSetName="SpecificRepository")]
        [PSCredential]$Credential
    )
    begin {
        $Trusted = Get-PSRepository | Where-Object { $_.InstallationPolicy -eq "Trusted" }
    }
    process {
        Write-Progress "Searching PSRepository for '$Name' module with version '$Version'" -Id 1 -ParentId 0
        Write-Verbose  "Searching PSRepository for '$Name' module with version '$Version'"

        $ModuleParam = @{
            Name = $Name
            Verbose = $false
        }
        # AllowPrerelease requires modern PowerShellGet
        if ((Get-Module PowerShellGet).Version -ge "1.6.0") {
            $ModuleParam.AllowPrerelease = $AllowPrerelease
        } elseif($AllowPrerelease) {
            Write-Warning "Installing pre-release modules requires PowerShellGet 1.6.0 or later. Please add that at the top of your RequiredModules!"
        }
        if ($Repository) {
            $ModuleParam["Repository"] = $Repository
            if ($Credential) {
                $ModuleParam["Credential"] = $Credential
            }
        }

        # Find returns modules in Feed and then Version order
        # Before PowerShell 6, sorting didn't preserve order, so we avoid it
        $Found = Find-Module @ModuleParam -AllVersions | Where-Object {
                ($Version.Float -and $Version.Float.Satisfies($_.Version.ToString())) -or
                (!$Version.Float -and $Version.Satisfies($_.Version.ToString()))
            }

        # $Found | Format-Table Name, Version, Repository, RepositorySourceLocation | Out-String -Stream | Write-Debug

        if (-not $Found) {
            Write-Warning "Unable to resolve dependency '$Name' with version '$Version'"
        } else {
            # Because we can't trust sorting in PS 5, we need to try checking for
            if (!($Single = @($Found).Where({ $_.RepositorySourceLocation -in $Trusted.SourceLocation }, "First", 1))) {
                $Single = $Found[0]
                Write-Warning "Dependency '$Name' with version '$($Single.Version)' found in untrusted repository $($Single.Repository) ($($Single.RepositorySourceLocation))"
            } else {
                Write-Verbose "Found '$Name' available with version '$($Single.Version)' in trusted repository $($Single.Repository) ($($Single.RepositorySourceLocation))"
            }

            if($Credential) { # if we have credentials, we're going to need to pass them through ...
                $Single | Add-Member -NotePropertyName Credential -NotePropertyValue $Credential
            }
            $Single
        }
    }
}
#EndRegion '.\Private\FindModuleVersion.ps1' 88
#Region '.\Private\GetModuleVersion.ps1' 0
filter GetModuleVersion {
    <#
        .SYNOPSIS
            Find the first installed module that matches the specified name and VersionRange
        .DESCRIPTION
            This function wraps Get-Module -ListAvailable to filter according to the specified VersionRange and path.
 
            Install-RequiredModule supports Nuget style VersionRange, where both minimum and maximum versions can be either inclusive or exclusive. Since Get-Module only supports Inclusive, we can't just use that.
        .EXAMPLE
            GetModuleVersion ~\Documents\PowerShell\Modules PowerShellGet "[1.0,5.0)"
 
            Returns any version of PowerShellGet greater than 1.0 and less than 5.0 (up to 4.9*) that's installed in the current user's PowerShell Core module folder.
    #>

    [CmdletBinding(DefaultParameterSetName = "Unrestricted")]
    param(
        # A specific Module install folder to search
        [AllowNull()]
        [string]$Destination,

        # The name of the module to find
        [Parameter(ValueFromPipelineByPropertyName, Mandatory)]
        [string]$Name,

        # The VersionRange for valid modules
        [Parameter(ValueFromPipelineByPropertyName, Mandatory)]
        [NuGet.Versioning.VersionRange]$Version
    )
    Write-Progress "Searching PSModulePath for '$Name' module with version '$Version'" -Id 1 -ParentId 0
    Write-Verbose  "Searching PSModulePath for '$Name' module with version '$Version'"
    $Found = @(Get-Module $Name -ListAvailable -Verbose:$false).Where({
        $Valid = (!$Destination -or $_.ModuleBase.ToUpperInvariant().StartsWith($Destination.ToUpperInvariant())) -and
        (
            ($Version.Float -and $Version.Float.Satisfies($_.Version.ToString())) -or
            (!$Version.Float -and $Version.Satisfies($_.Version.ToString()))
        )
        Write-Debug "$($_.Name) $($_.Version) $(if ($Valid) {"Valid"} else {"Wrong"}) - $($_.ModuleBase)"
        $Valid
        # Get returns modules in PSModulePath and then Version order,
        # so you're not necessarily getting the highest valid version,
        # but rather the _first_ valid version (as usual)
    }, "First", 1)
    if (-not $Found) {
        Write-Warning "Unable to find module '$Name' installed with version '$Version'"
    } else {
        Write-Verbose "Found '$Name' installed with version '$($Found.Version)'"
        $Found
    }
}
#EndRegion '.\Private\GetModuleVersion.ps1' 49
#Region '.\Private\ImportRequiredModulesFile.ps1' 0
filter ImportRequiredModulesFile {
    <#
        .SYNOPSIS
            Load a file defining one or more RequiredModules
    #>

    [OutputType('RequiredModule')]
    [CmdletBinding()]
    param(
        [Parameter(Mandatory, ValueFromPipeline)]
        [Alias("Path", "PSPath")]
        [string]$RequiredModulesFile
    )

    $RequiredModulesFile = Convert-Path $RequiredModulesFile
    Write-Progress "Loading Required Module list from '$RequiredModulesFile'" -Id 1 -ParentId 0
    Write-Verbose "Loading Required Module list from '$RequiredModulesFile'"
    $LocalizedData = @{
        BaseDirectory = [IO.Path]::GetDirectoryName($RequiredModulesFile)
        FileName = [IO.Path]::GetFileName($RequiredModulesFile)
    }
    Import-LocalizedData @LocalizedData | ConvertToRequiredModule
}
#EndRegion '.\Private\ImportRequiredModulesFile.ps1' 23
#Region '.\Private\InstallModuleVersion.ps1' 0
filter InstallModuleVersion {
    <#
        .SYNOPSIS
            Installs (or saves) a specific module version (using PowerShellGet)
        .DESCRIPTION
            This function wraps Install-Module to support a -Destination and produce consistent simple errors
 
            Assumes that the specified module, version and destination all exist
        .EXAMPLE
            InstallModuleVersion -Destination ~\Documents\PowerShell\Modules -Name PowerShellGet -Version "2.1.4"
 
            Saves a copy of PowerShellGet version 2.1.4 to your Documents\PowerShell\Modules folder
    #>

    [CmdletBinding(DefaultParameterSetName = "Unrestricted")]
    param(
        # Where to install to
        [AllowNull()]
        [string]$Destination,

        # The name of the module to install
        [Parameter(ValueFromPipelineByPropertyName, Mandatory)]
        [string]$Name,

        # The version of the module to install
        [Parameter(ValueFromPipelineByPropertyName, Mandatory)]
        [string]$Version, # This has to stay [string]

        # The scope in which to install the modules (defaults to "CurrentUser")
        [ValidateSet("CurrentUser", "AllUsers")]
        $Scope = "CurrentUser",

        # A specific repository to fetch this particular module from
        [AllowNull()]
        [Parameter(ValueFromPipelineByPropertyName, Mandatory, ParameterSetName="SpecificRepository")]
        [Alias("RepositorySourceLocation")]
        [string[]]$Repository,

        # Optionally, credentials for the specified repository
        [AllowNull()]
        [Parameter(ValueFromPipelineByPropertyName, ParameterSetName="SpecificRepository")]
        [PSCredential]$Credential
    )
    Write-Progress "Installing module '$($Name)' with version '$($Version)'$(if($Repository){ " from $Repository" })"
    Write-Verbose "Installing module '$($Name)' with version '$($Version)'$(if($Repository){ " from $Repository" })"
    Write-Verbose "ConfirmPreference: $ConfirmPreference"
    $ModuleOptions = @{
        Name               = $Name
        RequiredVersion    = $Version
        Verbose            = $VerbosePreference -eq "Continue"
        Confirm            = $ConfirmPreference -eq "Low"
        ErrorAction        = "Stop"
    }

    # The Save-Module that's preinstalled on Windows doesn't support AllowPrerelease
    if ((Get-Command Save-Module).Parameters.ContainsKey("AllowPrerelease")) {
        # Allow pre-release because we're always specifying a REQUIRED version
        # If the required version is a pre-release, then we want to allow that
        $ModuleOptions["AllowPrerelease"] = $true
    }

    if ($Repository) {
        $ModuleOptions["Repository"] = $Repository
        if ($Credential) {
            $ModuleOptions["Credential"] = $Credential
        }
    }

    if ($Destination) {
        $ModuleOptions += @{
            Path = $Destination
        }
        Save-Module @ModuleOptions
    } else {
        $ModuleOptions += @{
            # PowerShellGet requires both -AllowClobber and -SkipPublisherCheck for example
            SkipPublisherCheck = $true
            AllowClobber       = $true
            Scope              = $Scope
        }
        Install-Module @ModuleOptions
    }

    # We've had weird problems with things failing to install properly, so we check afterward to be sure they're visible
    $null = $PSBoundParameters.Remove("Repository")
    $null = $PSBoundParameters.Remove("Credential")
    $null = $PSBoundParameters.Remove("Scope")
    if (GetModuleVersion @PSBoundParameters -WarningAction SilentlyContinue) {
        $PSCmdlet.WriteInformation("Installed module '$($Name)' with version '$($Version)'$(if($Repository){ " from $Repository" })", $script:InfoTags)
    } else {
        $PSCmdlet.WriteError(
            [System.Management.Automation.ErrorRecord]::new(
                [Exception]::new("Failed to install module '$($Name)' with version '$($Version)'$(if($Repository){ " from $Repository" })"),
                "InstallModuleDidnt",
                "NotInstalled", $module))
    }
}
#EndRegion '.\Private\InstallModuleVersion.ps1' 97
#Region '.\Public\Install-RequiredModule.ps1' 0
function Install-RequiredModule {
    <#
        .SYNOPSIS
            Installs (and imports) modules listed in RequiredModules.psd1
        .DESCRIPTION
            Parses a RequiredModules.psd1 listing modules and attempts to import those modules.
            If it can't find the module in the PSModulePath, attempts to install it from PowerShellGet.
 
            The RequiredModules list looks like this (uses nuget version range syntax, and now, has an optional syntax for specifying the repository to install from):
            @{
                "PowerShellGet" = "2.0.4"
                "Configuration" = "[1.3.1,2.0)"
                "Pester" = "[4.4.2,4.7.0]"
                "ModuleBuilder" = @{
                    Version = "2.*"
                    Repository = "https://www.powershellgallery.com/api/v2"
                }
            }
 
            https://docs.microsoft.com/en-us/nuget/reference/package-versioning#version-ranges-and-wildcards
 
        .EXAMPLE
            Install-RequiredModule
 
            The default parameter-less usage reads the default 'RequiredModules.psd1' from the current folder and installs everything to your user scope PSModulePath
        .EXAMPLE
            Install-RequiredModule @{
                "Configuration" = @{
                    Version = "[1.3.1,2.0)"
                    Repository = "https://www.powershellgallery.com/api/v2"
                }
                "ModuleBuilder" = @{
                    Version = "2.*"
                    Repository = "https://www.powershellgallery.com/api/v2"
                }
            }
 
            Uses Install-RequiredModule to ensure Configuration and ModuleBuilder modules are available, without using a RequiredModules metadata file.
        .EXAMPLE
            Save-Script Install-RequiredModule -Path ./RequiredModules
            ./RequiredModules/Install-RequiredModule.ps1 -Path ./RequiredModules.psd1 -Confirm:$false -Destination ./RequiredModules -TrustRegisteredRepositories
 
            This shows another way to use required modules in a build script
            without changing the machine as much (keeping all the files local to the build script)
            and supressing prompts, trusting repositories that are already registerered
        .EXAMPLE
            Install-RequiredModule @{ Configuration = "*" } -Destination ~/.powershell/modules
 
            Uses Install-RequiredModules to avoid putting modules in your Documents folder...
    #>

    [CmdletBinding(DefaultParameterSetName = "FromFile", SupportsShouldProcess = $true, ConfirmImpact = "High")]
    param(
        # The path to a metadata file listing required modules. Defaults to "RequiredModules.psd1" (in the current working directory).
        [Parameter(Position = 0, ParameterSetName = "FromFile")]
        [Parameter(Position = 0, ParameterSetName = "LocalToolsFromFile")]
        [Alias("Path")]
        [string]$RequiredModulesFile = "RequiredModules.psd1",

        [Parameter(Position = 0, ParameterSetName = "FromHash")]
        [Parameter(Position = 0, ParameterSetName = "LocalToolsFromHash")]
        [hashtable]$RequiredModules,

        # If set, the local tools Destination path will be cleared and recreated
        [Parameter(ParameterSetName = "LocalToolsFromFile")]
        [Parameter(ParameterSetName = "LocalToolsFromHash")]
        [Switch]$CleanDestination,

        # If set, saves the modules to a local path rather than installing them to the scope
        [Parameter(ParameterSetName = "LocalToolsFromFile", Position = 1, Mandatory)]
        [Parameter(ParameterSetName = "LocalToolsFromHash", Position = 1, Mandatory)]
        [string]$Destination,

        # The scope in which to install the modules (defaults to "CurrentUser")
        [Parameter(ParameterSetName = "FromHash")]
        [Parameter(ParameterSetName = "FromFile")]
        [ValidateSet("CurrentUser", "AllUsers")]
        $Scope = "CurrentUser",

        # Automatically trust all repositories registered in the environment.
        # This allows you to leave some repositories set as "Untrusted"
        # but trust them for the sake of installing the modules specified as required
        [switch]$TrustRegisteredRepositories,

        # Suppress normal host information output
        [Switch]$Quiet,

        # If set, the specififed modules are imported (after they are installed, if necessary)
        [Switch]$Import
    )

    [string[]]$script:InfoTags = @("Install")
    if (!$Quiet) {
        [string[]]$script:InfoTags += "PSHOST"
    }

    if ($PSCmdlet.ParameterSetName -like "*FromFile") {
        Write-Progress "Installing required modules from $RequiredModulesFile" -Id 0

        if (-Not (Test-Path $RequiredModulesFile -PathType Leaf)) {
            $PSCmdlet.WriteError(
                [System.Management.Automation.ErrorRecord]::new(
                    [Exception]::new("RequiredModules file '$($RequiredModulesFile)' not found."),
                    "RequiredModules.psd1 Not Found",
                    "ResourceUnavailable", $RequiredModulesFile))
            return
        }
    } else {
        Write-Progress "Installing required modules from hashtable list" -Id 0
    }

    if ($Destination) {
        Write-Debug "Using manually specified Destination directory rather than default Scope"
        AddPSModulePath $Destination -Clean:$CleanDestination
    }

    Write-Progress "Verifying PSRepository trust" -Id 1 -ParentId 0

    if ($TrustRegisteredRepositories) {
        # Force Policy to Trusted so we can install without prompts and without -Force which is bad
        $OriginalRepositories = @(Get-PSRepository)
        foreach ($repo in $OriginalRepositories.Where({ $_.InstallationPolicy -ne "Trusted" })) {
            Write-Verbose "Setting $($repo.Name) Trusted"
            Set-PSRepository $repo.Name -InstallationPolicy Trusted
        }
    }
    try {
        $(  # For all the modules they want to install
            switch -Wildcard ($PSCmdlet.ParameterSetName) {
                "*FromFile" {
                    Write-Debug "Installing from RequiredModulesFile $RequiredModulesFile"
                    ImportRequiredModulesFile $RequiredModulesFile -OV Modules
                }
                "*FromHash"  {
                    Write-Debug "Installing from in-line hashtable $($RequiredModules | Out-String)"
                    ConvertToRequiredModule $RequiredModules -OV Modules
                }
            }
        ) |
            # Which do not already have a valid version installed
            Where-Object { -not ($_ | GetModuleVersion -Destination:$Destination -WarningAction SilentlyContinue) } |
            # Find a version on the gallery
            FindModuleVersion |
            # And install it
            InstallModuleVersion -Destination:$Destination -Scope:$Scope -ErrorVariable InstallErrors
    } finally {
        if ($TrustRegisteredRepositories) {
            # Put Policy back so we don't needlessly change environments permanently
            foreach ($repo in $OriginalRepositories.Where({ $_.InstallationPolicy -ne "Trusted" })) {
                Write-Verbose "Setting $($repo.Name) back to $($repo.InstallationPolicy)"
                Set-PSRepository $repo.Name -InstallationPolicy $repo.InstallationPolicy
            }
        }
    }
    Write-Progress "Importing Modules" -Id 1 -ParentId 0
    Write-Verbose "Importing Modules"

    if ($Import) {
        Remove-Module $Modules.Name -Force -ErrorAction Ignore -Verbose:$false
        $Modules | GetModuleVersion -OV InstalledModules | Import-Module -Passthru:(!$Quiet) -Verbose:$false -Scope Global
    } elseif ($InstallErrors) {
        Write-Warning "Module import skipped because of errors. `nSee error details in `$IRM_InstallErrors`nSee required modules in `$IRM_RequiredModules`nSee installed modules in `$IRM_InstalledModules"
        $global:IRM_InstallErrors = $InstallErrors
        $global:IRM_RequiredModules = $Modules
        $global:IRM_InstalledModules = $InstalledModules
    } else {
        Write-Warning "Module import skipped"
    }

    Write-Progress "Done" -Id 0 -Completed
}
#EndRegion '.\Public\Install-RequiredModule.ps1' 171