RequiredModules.psm1

using namespace System.Management.Automation
using namespace System.Management.Automation.Language
#Region '.\Classes\PSEquality.ps1' 0
class PSEquality : System.Collections.Generic.EqualityComparer[PSObject] {
    <#
        A customizable equality comparer for PowerShell
        By default compares objects using PowerShell's default -eq
 
        Supports passing a custom `Properties` list (e.g. "Name" to only compare names, or "Name", "Version" to compare name and version but nothing else)
        The default is a comparison using all properties
 
        Supports passing a custom `Equals` scriptblock (e.g. { $args[0].Equals($args[1]) } to use .net .Equals)
        Note that passing Equals means Properties are ignored (unless you use them)
    #>

    # A simple list of properties to be used in the comparison
    [string[]]$Properties = "*"

    # A custom implementation of the Equals method
    # Must accept two arguments
    # Must return $true if they are equal, $false otherwise
    [scriptblock]$Equals = {
        $left, $right = $args | Select-Object $this.Properties
        $Left -eq $right
    }

    PSEquality() {}

    PSEquality([string[]]$Properties) {
        $this.Properties = $Properties
    }

    PSEquality([scriptblock]$Equals) {
        $this.Equals = $Equals
    }

    PSEquality([string[]]$Properties, [scriptblock]$Equals) {
        $this.Properties = $Properties
        $this.Equals = $Equals
    }

    [bool] Equals([PSObject]$first, [PSObject]$second) {
        return [bool](& $this.Equals $first $second)
    }

    [int] GetHashCode([PSObject]$PSObject) {
        return $PSObject.GetHashCode()
    }
}
#EndRegion '.\Classes\PSEquality.ps1' 46
#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
 
            RequiredModules supports Nuget style VersionRange, where both minimum and maximum versions can be _either_ inclusive or exclusive
            Since Find-Module only supports Inclusive, and only returns a single version if we use the Min/Max parameters, we have to use -AllVersions
        .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)]
        [string[]]$Repository,

        # Optionally, credentials for the specified repository
        # These are ignored unless the Repository is also specified
        [AllowNull()]
        [Parameter(ValueFromPipelineByPropertyName)]
        [PSCredential]$Credential,

        # Optionally, find dependencies (causes this to return more than one result)
        [switch]$Recurse,

        # Optionally, write a warning if there's a newer version available
        [switch]$WarnIfNewer
    )
    begin {
        $Trusted = Get-PSRepository -OutVariable Repositories | 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'$(if($Repository) { " in $Repository" })$(if($Credential) { " with credentials for " + $Credential.UserName })"
        $ModuleParam = @{
            Name = $Name
            Verbose = $false
            IncludeDependencies = [bool]$Recurse
        }
        # 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) {
            if (($MatchedRepository = $Repositories.Where{ $_.Name -in $Repository -or $_.SourceLocation -in $Repository }.Name)) {
                $ModuleParam["Repository"] = $MatchedRepository
            } else {
                # This would have to be a URL (or the path to a fileshare?)
                Write-Warning "Searching for '$Name' in unknown repository '$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 -OutVariable All | Where-Object {
                $_.Name -eq $Name -and
                ($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).Count) {
            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' with version '$($Single.Version)' in trusted repository '$($Single.Repository)' ($($Single.RepositorySourceLocation))"
            }

            if ($Recurse) {
                $Count = [Array]::IndexOf($All, $Single) + 1
                if ($All.Count -gt $Count) {
                    if (($Remaining = @($All | Select-Object -Skip $Count).Where({ $_.Name -eq $Name }, "Until"))) {
                        [Array]::Reverse($Remaining)
                        if ($Credential) {
                            $Remaining | Add-Member -NotePropertyName Credential -NotePropertyValue $Credential
                        }
                        $Remaining
                    }
                }
            }

            if ($Credential) {
                # if we have credentials, we're going to need to pass them through ...
                $Single | Add-Member -NotePropertyName Credential -NotePropertyValue $Credential
            }
            $Single
        }

        if ($WarnIfNewer) {
            # If they want to be warned, check if there's a newer version available
            if ($All[0] -and -not $Single -or $All[0].Version -gt $Single.Version) {
                Write-Warning "Newer version of '$Name' available: $($All[0].Version) -- Selected $($Single.Version) per constraint '$Version'"
            }
        }
    }
}
#EndRegion '.\Private\FindModuleVersion.ps1' 124
#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
#using namespace System.Management.Automation
#using namespace System.Management.Automation.Language

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'"

    # I really need the RequiredModules files to preserve order, so we're parsing by hand...
    $ErrorActionPreference = "Stop"
    $Tokens = $Null; $ParseErrors = $Null

    # ParseFile on PS5 (and older) doesn't handle utf8 properly (treats it as ASCII if there's no BOM)
    # Sometimes, that causes an avoidable error. So I'm avoiding it, if I can:
    $Path = Convert-Path $RequiredModulesFile
    $Content = (Get-Content -Path $RequiredModulesFile -Encoding UTF8)

    # Remove SIGnature blocks, PowerShell doesn't parse them in .psd1 and chokes on them here.
    $Content = $Content -join "`n" -replace "# SIG # Begin signature block(?s:.*)"

    try {
        # On current PowerShell, this will work
        $AST = [Parser]::ParseInput($Content, $Path, [ref]$Tokens, [ref]$ParseErrors)
        # Older versions throw a MethodException because the overload is missing
    } catch [MethodException] {
        $AST = [Parser]::ParseFile($Path, [ref]$Tokens, [ref]$ParseErrors)

        # If we got parse errors on older versions of PowerShell, test to see if the error is just encoding
        if ($null -ne $ParseErrors -and $ParseErrors.Count -gt 0) {
            $StillErrors = $null
            $AST = [Parser]::ParseInput($Content, [ref]$Tokens, [ref]$StillErrors)
            # If we didn't get errors the 2nd time, ignore the errors (it's the encoding bug)
            # Otherwise, use the original errors that they have the path in them
            if ($null -eq $StillErrors -or $StillErrors.Count -eq 0) {
                $ParseErrors = $StillErrors
            }
        }
    }
    if ($null -ne $ParseErrors -and $ParseErrors.Count -gt 0) {
        $PSCmdlet.ThrowTerminatingError([ErrorRecord]::new(([ParseException]::new([ParseError[]]$ParseErrors)), "RequiredModules Error", "ParserError", $RequiredModulesFile))
    }

    # Get the variables or subexpressions from strings which have them ("StringExpandable" vs "String") ...
    $Tokens += $Tokens | Where-Object { "StringExpandable" -eq $_.Kind } | Select-Object -ExpandProperty NestedTokens

    $Script = $AST.GetScriptBlock()
    try {
        $Script.CheckRestrictedLanguage( [string[]]@(), [string[]]@(), $false )
    } catch {
        $PSCmdlet.ThrowTerminatingError([ErrorRecord]::new($_.Exception.InnerException, "RequiredModules Error", "InvalidData", $Script))
    }

    # Make all the hashtables ordered, so that the output objects make more sense to humans...
    if ($Tokens | Where-Object { "AtCurly" -eq $_.Kind }) {
        $ScriptContent = $AST.ToString()
        $Hashtables = $AST.FindAll( { $args[0] -is [HashtableAst] -and ("ordered" -ne $args[0].Parent.Type.TypeName) }, $Recurse)
        $Hashtables = $Hashtables | ForEach-Object {
            [PSCustomObject]@{Type = "([ordered]"; Position = $_.Extent.StartOffset }
            [PSCustomObject]@{Type = ")"; Position = $_.Extent.EndOffset }
        } | Sort-Object Position -Descending
        foreach ($point in $Hashtables) {
            $ScriptContent = $ScriptContent.Insert($point.Position, $point.Type)
        }

        $AST = [Parser]::ParseInput($ScriptContent, [ref]$Tokens, [ref]$ParseErrors)
        $Script = $AST.GetScriptBlock()
    }

    $Mode, $ExecutionContext.SessionState.LanguageMode = $ExecutionContext.SessionState.LanguageMode, "RestrictedLanguage"

    try {
        $Script.InvokeReturnAsIs(@()) | ConvertToRequiredModule
    } finally {
        $ExecutionContext.SessionState.LanguageMode = $Mode
    }
}
#EndRegion '.\Private\ImportRequiredModulesFile.ps1' 89
#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' 98
#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 -Destination .\Modules -Upgrade
 
            Reads the default 'RequiredModules.psd1' from the current folder and installs everything to the specified "Modules" folder, upgrading any modules where there are newer (valid) versions than what's already installed.
        .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,

        # By default, Install-RequiredModule does not even check onlin if there's a suitable module available locally
        # If Upgrade is set, it always checks for newer versions of the modules and will install the newest version that's valid
        [Switch]$Upgrade
    )

    [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 (or that we're upgrading)
            Where-Object { $Upgrade -or -not ($_ | GetModuleVersion -Destination:$Destination -WarningAction SilentlyContinue) } |
            # Find a version on the gallery (if we're upgrading, warn if there are versions that are excluded)
            FindModuleVersion -Recurse -WarnIfNewer:$Upgrade | Optimize-Dependency |
            # And if we're not upgrading (or THIS version is not already installed)
            Where-Object {
                if (!$Upgrade) {
                    $true
                } else {
                    $Installed = GetModuleVersion -Destination:$Destination -Name:$_.Name -Version:"[$($_.Version)]"
                    if ($Installed) {
                        Write-Verbose "$($_.Name) version $($_.Version) is already installed."
                    } else {
                        $true
                    }
                }

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

    if ($Import) {
        Write-Verbose "Importing Modules"
        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
    } elseif(!$Quiet) {
        Write-Warning "Module import skipped"
    }

    Write-Progress "Done" -Id 0 -Completed
}
#EndRegion '.\Public\Install-RequiredModule.ps1' 193
#Region '.\Public\Optimize-Dependency.ps1' 0
function Optimize-Dependency {
    <#
        .SYNOPSIS
            Optimize a set of objects by their dependencies
        .EXAMPLE
            Find-Module TerminalBlocks, PowerLine | Optimize-Dependency
    #>

    [Alias("Sort-Dependency")]
    [OutputType([Array])]
    [CmdletBinding(DefaultParameterSetName = "ByPropertyFromInputObject")]
    param(
        # The path to a RequiredModules file
        [Parameter(Mandatory, ValueFromPipelineByPropertyName, ParameterSetName = "CustomEqualityFromPath")]
        [Parameter(Mandatory, ValueFromPipelineByPropertyName, ParameterSetName = "ByPropertyFromPath")]
        [string]$Path,

        # The objects you want to sort
        [Parameter(Mandatory, ValueFromPipeline, ParameterSetName = "CustomEqualityFromInputObject")]
        [Parameter(Mandatory, ValueFromPipeline, ParameterSetName = "ByPropertyFromInputObject")]
        [PSObject[]]$InputObject,

        # A list of properties used with Compare-Object in the default equality comparer
        # Since this is in RequiredModules, it defaults to "Name", "Version"
        [Parameter(ValueFromPipelineByPropertyName, ParameterSetName = "ByPropertyFromInputObject")]
        [Parameter(ValueFromPipelineByPropertyName, ParameterSetName = "ByPropertyFromPath")]
        [string[]]$Properties = @("Name", "Version"),

        # A custom implementation of the equality comparer for the InputObjects
        # Must accept two arguments, and return $true if they are equal, $false otherwise
        # InputObjects will only be added to the output if this returns $false
        # The default EqualityFilter compares based on the $Properties
        [Parameter(Mandatory, ValueFromPipelineByPropertyName, ParameterSetName = "CustomEqualityFromInputObject")]
        [Parameter(Mandatory, ValueFromPipelineByPropertyName, ParameterSetName = "CustomEqualityFromPath")]
        [scriptblock]$EqualityFilter = { !($args[0] | Compare-Object $args[1] -Property $Properties) },

        # A ScriptBlock to calculate the dependencies of the InputObjects
        # Defaults to a scriptblock that works for Find-Module and Get-Module
        [Parameter(ValueFromPipelineByPropertyName)]
        [ScriptBlock]$Dependency = {
            if ($_.PSTypeNames -eq "System.Management.Automation.PSModuleInfo") {
                $_.RequiredModules.Name.ForEach{ Get-Module $_ }
            } else {
                $_.Dependencies.Name.ForEach{ Find-Module $_ }
            }
        },

        # Do not pass this parameter. It's only for use in recursive calls
        [Parameter(DontShow)]
        [System.Collections.Generic.HashSet[PSObject]]${ Recursion Ancestors } = @()
    )
    begin {
        if ($null -eq $Optimize_Dependency_Results) {
            $Optimize_Dependency_Results = [System.Collections.Generic.HashSet[PSObject]]::new([PSEquality]::new($Properties, $EqualityFilter))
        }
        if ($Path) {
            $null = $PSBoundParameters.Remove("Path")
            ImportRequiredModulesFile $Path | FindModuleVersion -Recurse | Optimize-Dependency @PSBoundParameters
            return
        }
    }
    process {
        $null = $PSBoundParameters.Remove("InputObject")

        if ($VerbosePreference) {
            $Pad = " " * ${ Recursion Ancestors }.Count
            Write-Verbose  "${Pad}Optimizing Dependencies of $(@($InputObject | Select-Object $Properties | ForEach-Object { $_.PsObject.Properties.Value -join ','}) -join '; ')"
        }

        foreach ($IO in $InputObject) {
            # Optional reference to Ancestors, a hidden variable that acts like a parameter for recursion
            $Optimize_Dependency_Parents = [System.Collections.Generic.HashSet[PSObject]]::new([PSObject[]]@(${ Recursion Ancestors }), [PSEquality]::new($Properties, $EqualityFilter))
            # Technically, if we've seen this object before *at all*, we don't need to recurse it again, but I'm not optimizing that for now
            # However, if we see the same object twice in a single chain, that's a dependency loop, so we're broken
            if (!$Optimize_Dependency_Parents.Add($IO)) {
                Write-Warning "May contain a dependency loop: $(@(@($Optimize_Dependency_Parents) + @($IO) | Select-Object $Properties | ForEach-Object { $_.PsObject.Properties.Value -join ','}) -join ' --> ')"
                return
            }
            if ($DebugPreference) {
                Write-Debug "${Pad}TRACE: Optimize-Dependency chain: $(@($Optimize_Dependency_Parents | Select-Object $Properties | ForEach-Object { $_.PsObject.Properties.Value -join ','}) -join ' --> ')"
            }

            $PSBoundParameters[" Recursion Ancestors "] = $Optimize_Dependency_Parents
            ForEach-Object -In $IO -Process $Dependency | Optimize-Dependency @PSBoundParameters
        }
        foreach ($module in $InputObject){
            if ($Optimize_Dependency_Results.Add($module)) {
                Write-Verbose " + Include $(@($module | Select-Object $Properties | ForEach-Object { $_.PsObject.Properties.Value}) -join ', ')"
                $module
            }
        }
        if ($DebugPreference) {
            Write-Debug "${Pad}EXIT: Optimize-Dependency: $(@($InputObject | Select-Object $Properties | ForEach-Object { $_.PsObject.Properties.Value -join ','}) -join '; ')"
        }
    }
}
#EndRegion '.\Public\Optimize-Dependency.ps1' 96