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 |