RequiredModules.psm1
using namespace System.Management.Automation using namespace System.Management.Automation.Language #Region '.\Classes\PSEquality.ps1' -1 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' -1 # 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' -1 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" $null = 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 $null = 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' -1 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' -1 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 true 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' -AllowPrerelease:$AllowPrerelease $(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") { # The default value for this doesn't get recalculated for pipeline input if (!$PSBoundParameters.ContainsKey("AllowPrerelease")) { $AllowPrerelease = $($Version.MinVersion.IsPreRelease, $Version.MaxVersion.IsPreRelease -contains $True) if ($AllowPrerelease) { Write-Verbose "Allowing pre-release modules because of $Version" } } $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 -ErrorAction SilentlyContinue | 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' 131 #Region '.\Private\GetModuleVersion.ps1' -1 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)'" $PSCmdlet.WriteInformation("Found module '$($Name)' with version '$($Found.Version)'$(if($Destination){ " in $Destination" })", $script:InfoTags) $Found } } #EndRegion '.\Private\GetModuleVersion.ps1' 50 #Region '.\Private\ImportRequiredModulesFile.ps1' -1 # 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' -1 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)' to '$($Destination)' 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 } Write-Debug "Save-Module $($ModuleOptions.GetEnumerator() | ForEach-Object { "-$($_.Key) '$($_.Value)'" })" Save-Module @ModuleOptions } else { $ModuleOptions += @{ # PowerShellGet requires both -AllowClobber and -SkipPublisherCheck for example SkipPublisherCheck = $true AllowClobber = $true Scope = $Scope } Write-Debug "Install-Module $($ModuleOptions.GetEnumerator() | ForEach-Object { "-$($_.Key) '$($_.Value)'" })" 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' 100 #Region '.\Public\Install-RequiredModule.ps1' -1 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" $Destination = 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" Set-Variable -Scope Global -Name IRM_InstallErrors -Value $InstallErrors Set-Variable -Scope Global -Name IRM_RequiredModules -Value $Modules Set-Variable -Scope Global -Name IRM_InstalledModules -Value $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' -1 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 |