diff --git a/ModuleFast.psm1 b/ModuleFast.psm1 index 023608e..51baf96 100644 --- a/ModuleFast.psm1 +++ b/ModuleFast.psm1 @@ -250,7 +250,9 @@ function Install-ModuleFast { #Setting this to "CurrentUser" is the same as specifying the destination as 'Current'. This is a usability convenience. [InstallScope]$Scope, #The timeout for HTTP requests. This is set to 30 seconds by default. This is generally sufficient for most requests, but you may need to increase this if you are on a slow connection or are downloading large modules. - [int]$Timeout = 30 + [int]$Timeout = 30, + #ModuleFast performs some friendly operations that aren't strictly SemVer compliant. For example, if you ask for Module!<3.0.0, technically 3.0.0-alpha should be returned via the SemVer spec, but typically that's not what people actually want, they want what would effectively be Module!<2.999.999, so we exclude these prereleases for good UX. Specify this switch to enforce strict SemVer behavior and return the prerelease in this scenario. + [switch]$StrictSemVer ) begin { trap {$PSCmdlet.ThrowTerminatingError($PSItem)} @@ -395,7 +397,18 @@ function Install-ModuleFast { $ModulesToInstall.ToArray() } else { Write-Progress -Id 1 -Activity 'Install-ModuleFast' -Status 'Plan' -PercentComplete 1 - Get-ModuleFastPlan -Specification $ModulesToInstall -HttpClient $httpClient -Source $Source -Update:$Update -PreRelease:$Prerelease.IsPresent -DestinationOnly:$DestinationOnly -Destination $Destination -Timeout $Timeout + $getPlanParams = @{ + Specification = $ModulesToInstall + HttpClient = $httpClient + Source = $Source + Update = $Update + PreRelease = $Prerelease.IsPresent + DestinationOnly = $DestinationOnly + Destination = $Destination + Timeout = $Timeout + StrictSemVer = $StrictSemVer + } + Get-ModuleFastPlan @getPlanParams } } @@ -520,7 +533,8 @@ function Get-ModuleFastPlan { [int]$ParentProgress, [string]$Destination, [switch]$DestinationOnly, - [CancellationToken]$CancellationToken + [CancellationToken]$CancellationToken, + [switch]$StrictSemVer ) BEGIN { @@ -669,7 +683,7 @@ function Get-ModuleFastPlan { continue } - if ($currentModuleSpec.SatisfiedBy($candidate)) { + if ($currentModuleSpec.SatisfiedBy($candidate, $StrictSemVer)) { Write-Debug "${ModuleSpec}: Found satisfying version $candidate in the inlined index." $matchingEntry = $entries | Where-Object version -EQ $candidate if ($matchingEntry.count -gt 1) { throw 'Multiple matching Entries found for a specific version. This is a bug and should not happen' } @@ -729,7 +743,7 @@ function Get-ModuleFastPlan { continue } - if ($currentModuleSpec.SatisfiedBy($candidate)) { + if ($currentModuleSpec.SatisfiedBy($candidate, $StrictSemVer)) { Write-Debug "${currentModuleSpec}: Found satisfying version $candidate in the additional pages." $matchingEntry = $entries | Where-Object version -EQ $candidate if (-not $matchingEntry) { throw 'Multiple matching Entries found for a specific version. This is a bug and should not happen' } @@ -816,7 +830,7 @@ function Get-ModuleFastPlan { | Where-Object Name -EQ $dependency.Name | Sort-Object ModuleVersion -Descending | ForEach-Object { - if ($dependency.SatisfiedBy($PSItem.ModuleVersion)) { + if ($dependency.SatisfiedBy($PSItem.ModuleVersion, $StrictSemVer)) { Write-Debug "Dependency $dependency satisfied by existing planned install item $PSItem" return $false } @@ -1309,6 +1323,7 @@ function Add-Getters ([Parameter(Mandatory, ValueFromPipeline)][Type]$Type) { } #Information about a module, whether local or remote +[NoRunspaceAffinity()] class ModuleFastInfo: IComparable { [string]$Name #Sometimes the module version is not the same as the folder version, such as in the case of prerelease versions @@ -1387,6 +1402,8 @@ $ModuleFastInfoTypeData = @{ Update-TypeData -TypeName ModuleFastInfo @ModuleFastInfoTypeData -Force Update-TypeData -TypeName Nuget.Versioning.NugetVersion -SerializationMethod String -Force + +[NoRunspaceAffinity()] class ModuleFastSpec { #These properties are effectively read only thanks to some getter wizardy @@ -1551,15 +1568,62 @@ class ModuleFastSpec { } #region Methods + [bool] SatisfiedBy([version]$Version) { - return $this.SatisfiedBy([NuGetVersion]::new($Version)) + return $this.SatisfiedBy([NuGetVersion]::new($Version, $false)) + } + [bool] SatisfiedBy([version]$Version, [bool]$strictSemVer) { + return $this.SatisfiedBy([NuGetVersion]::new($Version, $strictSemVer)) } [bool] SatisfiedBy([NugetVersion]$Version) { - if ($this._VersionRange.IsFloating) { - return $this._VersionRange.Float.Satisfies($Version) + return $this.SatisfiedBy($Version, $false) + } + + #strictSemVer means [1.0.0,2.0.0) will match 2.0.0-alpha1. Most people don't want this. + [bool] SatisfiedBy([NugetVersion]$Version, [bool]$strictSemVer) { + $range = $this._VersionRange + $strictSatisfies = $range.IsFloating ? + $range.Float.Satisfies($Version) : + $range.Satisfies($Version) + + if ($strictSemVer) { + return $strictSatisfies + } + + if (-not $range.MaxVersion) {return $strictSatisfies} + $max = $range.MaxVersion + $min = $range.MinVersion + + if ( + #Example: Version is 2.0.0-alpha1 and the spec is module:[1.0.0,2.0.0) + $Version.IsPrerelease -and + -not $range.IsMaxInclusive -and + -not $max.IsPrerelease -and + ($max.Major -eq $Version.Major) -and + ($max.Minor -eq $Version.Minor) -and + ($max.Patch -eq $Version.Patch) + #If the minimum matches the maximum and has a prerelease, that means it's a range like (3.0.0-alpha,3.0.0-beta and we want strict matching) + ) + { + #In a special case like (3.0.0-alpha,3.0.0-beta) where the min and max are the same version, we want normal strict semver behavior + if ( + $min -and + $min.Major -eq $max.Major -and + $min.Minor -eq $max.Minor -and + $min.Patch -eq $max.Patch -and + $min.IsPrerelease + ) { + Write-Debug "ModuleFastSpec: $this is being compared to $Version. It was not excluded because the min matches the max and both are prereleases, so normal behavior occured." + return $strictSatisfies + } + + Write-Verbose "ModuleFastSpec: $this is typically satisfied by $Version, but this prerelease of the exclusive maximum version specification was ignored for ease of use. Specify -StrictSemVer to allow pre-releases of excluded versions." + return $false } - return $this._VersionRange.Satisfies($Version) + + #Last resort is to use strict matching + return $strictSatisfies } [bool] Overlap([ModuleFastSpec]$other) { @@ -1568,7 +1632,7 @@ class ModuleFastSpec { [bool] Overlap([VersionRange]$other) { [List[VersionRange]]$ranges = @($this._VersionRange, $other) - $subset = [versionrange]::CommonSubset($ranges) + $subset = [VersionRange]::CommonSubset($ranges) #If the subset has an explicit version of 0.0.0, this means there was no overlap. return '(0.0.0, 0.0.0)' -ne $subset } @@ -1932,7 +1996,7 @@ function Find-LocalModule { } $candidateVersion = $manifestCandidate.ModuleVersion - if ($ModuleSpec.SatisfiedBy($candidateVersion)) { + if ($ModuleSpec.SatisfiedBy($candidateVersion, $StrictSemVer)) { if ($Update -and ($ModuleSpec.Max -ne $candidateVersion)) { Write-Debug "${ModuleSpec}: Skipping $candidateVersion because -Update was specified and the version does not exactly meet the upper bound of the spec or no upper bound was specified at all, meaning there is a possible newer version remotely." #We can use this ref later to find out if our best remote version matches what is installed without having to read the manifest again diff --git a/ModuleFast.tests.ps1 b/ModuleFast.tests.ps1 index 3471b51..75e7ccc 100644 --- a/ModuleFast.tests.ps1 +++ b/ModuleFast.tests.ps1 @@ -159,6 +159,21 @@ Describe 'Get-ModuleFastPlan' -Tag 'E2E' { } -TestCases $moduleSpecTestCases } + Context 'StrictSemVer Parameter' { + It 'StrictSemVer matches prereleases with exclusive upper bound' { + $actual = Get-ModuleFastPlan 'PrereleaseTest!<0.0.2' -StrictSemVer + $actual | Should -HaveCount 1 + $actual.ModuleVersion.IsPrerelease | Should -Be $true + $actual.ModuleVersion.Patch | Should -Be 2 + } + It 'StrictSemVer not specified does not match prereleases with exclusive upper bound' { + $actual = Get-ModuleFastPlan 'PrereleaseTest!<0.0.2' + $actual | Should -HaveCount 1 + $actual.ModuleVersion.IsPrerelease | Should -Be $false + $actual.ModuleVersion.Patch | Should -Be 1 + } + } + Context 'ModuleFastSpec String' { $stringTestCases = ( @{ @@ -252,10 +267,10 @@ Describe 'Get-ModuleFastPlan' -Tag 'E2E' { ModuleName = 'PrereleaseTest' }, @{ - Spec = 'PrereleaseTest!<0.0.1' + Spec = 'PrereleaseTest!<=0.0.1' Check = { $actual.Name | Should -Be 'PrereleaseTest' - $actual.ModuleVersion | Should -Be '0.0.1-prerelease' + $actual.ModuleVersion | Should -Be '0.0.1' } ModuleName = 'PrereleaseTest' }, @@ -278,6 +293,51 @@ Describe 'Get-ModuleFastPlan' -Tag 'E2E' { $actual.ModuleVersion | Should -Be '1.99.0' #Nuget changes this to 1.99 } }, + # Special cases where the upper bound is specified as exclusive, ignore prereleases + @{ + Spec = 'PrereleaseTest!<0.0.2' + ModuleName = 'PrereleaseTest' + Check = { + $actual.ModuleVersion | Should -Be '0.0.1' + } + }, + @{ + Spec = 'PrereleaseTest!<=0.0.2' + ModuleName = 'PrereleaseTest' + Check = { + $actual.ModuleVersion | Should -Be '0.0.2-prerelease' + } + }, + @{ + # Test lexical matching. This should not match the 'prerelease' version because r is after p + Spec = 'PrereleaseTest!:(0.0.2-rIsAfterP,0.0.2)' + ModuleName = 'PrereleaseTest' + Check = { + [string]$actual | Should -Match 'a matching module was not found' + } + }, + @{ + Spec = 'PrereleaseTest!<0.0.2-prerelease' + ModuleName = 'PrereleaseTest' + Check = { + [string]$actual.ModuleVersion | Should -Be '0.0.2-newerversion' + } + }, + @{ + Spec = 'PrereleaseTest!:(0.0.2-alpha,0.0.2)' + ModuleName = 'PrereleaseTest' + Check = { + [string]$actual.ModuleVersion | Should -Be '0.0.2-prerelease' + } + }, + @{ + Spec = 'PrereleaseTest!:(0.0.2-alpha,0.0.2-prerelease)' + ModuleName = 'PrereleaseTest' + Check = { + [string]$actual.ModuleVersion | Should -Be '0.0.2-newerversion' + } + }, + #End special cases @{ Spec = 'PnP.PowerShell:2.2.*' ModuleName = 'PnP.PowerShell' @@ -300,18 +360,27 @@ Describe 'Get-ModuleFastPlan' -Tag 'E2E' { } It 'Gets Module with String Parameter: ' { - $actual = Get-ModuleFastPlan $Spec - $actual | Should -HaveCount 1 - $ModuleName | Should -Be $actual.Name - $actual.ModuleVersion | Should -Not -BeNullOrEmpty + try { + $actual = Get-ModuleFastPlan $Spec + $actual | Should -HaveCount 1 + $ModuleName | Should -Be $actual.Name + $actual.ModuleVersion | Should -Not -BeNullOrEmpty + } catch { + $actual = $PSItem + } if ($Check) { . $Check } + } -TestCases $stringTestCases It 'Gets Module with String Pipeline: ' { - $actual = $Spec | Get-ModuleFastPlan - $actual | Should -HaveCount 1 - $ModuleName | Should -Be $actual.Name - $actual.ModuleVersion | Should -Not -BeNullOrEmpty + try { + $actual = $Spec | Get-ModuleFastPlan + $actual | Should -HaveCount 1 + $ModuleName | Should -Be $actual.Name + $actual.ModuleVersion | Should -Not -BeNullOrEmpty + } catch { + $actual = $PSItem + } if ($Check) { . $Check } } -TestCases $stringTestCases } diff --git a/README.MD b/README.MD index 91e0d9f..9654a9d 100644 --- a/README.MD +++ b/README.MD @@ -89,6 +89,8 @@ For more information about NuGet version range syntax used with the ':' operator ModuleFast also fully supports the [ModuleSpecification object and hashtable-like string syntaxes](https://learn.microsoft.com/en-us/powershell/module/microsoft.powershell.core/about/about_requires?view=powershell-7.5#-modules-module-name--hashtable) that are used by Install-Module and Install-PSResource. More information on this format: https://learn.microsoft.com/en-us/dotnet/api/microsoft.powershell.commands.modulespecification?view=powershellsdk-7.4.0 +NOTE: ModuleFast does not strictly conform to SemVer without the `-StrictSemVer` parameter. For example, for ergonomics, we exclude 2.0 prereleases from `Module<2.0`, since most people who do this do not want 2.0 prereleases which might contain breaking changes, even though by semver definition, `Module 2.0-alpha1` is less than 2.0 + ## Logging ModuleFast has extensive Verbose and Debug information available if you specify the -Verbose and/or -Debug parameters. This can be useful for troubleshooting or understanding how ModuleFast is working. Verbose level provides a high level "what" view of the process of module selection, while Debug level provides a much more detailed "Why" information about the module selection and installation process that can be useful in troubleshooting issues.