diff --git a/ModuleFast.psd1 b/ModuleFast.psd1 index 564f918..054a53b 100644 --- a/ModuleFast.psd1 +++ b/ModuleFast.psd1 @@ -33,7 +33,7 @@ Description = 'Optimizes the PowerShell Module Installation Process to be as fast as possible and operate in CI/CD scenarios in a declarative manner' # Minimum version of the PowerShell engine required by this module - PowerShellVersion = '7.3' #Due to use of CLEAN block + PowerShellVersion = '7.2' #Due to use of CLEAN block # Name of the PowerShell host required by this module # PowerShellHostName = '' diff --git a/ModuleFast.psm1 b/ModuleFast.psm1 index e957397..b11369a 100644 --- a/ModuleFast.psm1 +++ b/ModuleFast.psm1 @@ -1,4 +1,4 @@ -#requires -version 7.3 +#requires -version 7.2 using namespace Microsoft.PowerShell.Commands using namespace NuGet.Versioning using namespace System.Collections @@ -319,94 +319,94 @@ function Install-ModuleFast { end { trap {$PSCmdlet.ThrowTerminatingError($PSItem)} - - if (-not $installPlan) { - if ($ModulesToInstall.Count -eq 0 -and $PSCmdlet.ParameterSetName -eq 'Specification') { - Write-Verbose '🔎 No modules specified to install. Beginning SpecFile detection...' - $modulesToInstall = if ($CI -and (Test-Path $CILockFilePath)) { - Write-Debug "Found lockfile at $CILockFilePath. Using for specification evaluation and ignoring all others." - ConvertFrom-RequiredSpec -RequiredSpecPath $CILockFilePath - } else { - $Destination = $PWD - $specFiles = Find-RequiredSpecFile $Destination -CILockFileHint $CILockFilePath - if (-not $specFiles) { - Write-Warning "No specfiles found in $Destination. Please ensure you have a .requires.json or .requires.psd1 file in the current directory, specify a path with -Path, or define specifications with the -Specification parameter to skip this search." - } - foreach ($specfile in $specFiles) { - Write-Verbose "Found Specfile $specFile. Evaluating..." - ConvertFrom-RequiredSpec -RequiredSpecPath $specFile + try { + if (-not $installPlan) { + if ($ModulesToInstall.Count -eq 0 -and $PSCmdlet.ParameterSetName -eq 'Specification') { + Write-Verbose '🔎 No modules specified to install. Beginning SpecFile detection...' + $modulesToInstall = if ($CI -and (Test-Path $CILockFilePath)) { + Write-Debug "Found lockfile at $CILockFilePath. Using for specification evaluation and ignoring all others." + ConvertFrom-RequiredSpec -RequiredSpecPath $CILockFilePath + } else { + $Destination = $PWD + $specFiles = Find-RequiredSpecFile $Destination -CILockFileHint $CILockFilePath + if (-not $specFiles) { + Write-Warning "No specfiles found in $Destination. Please ensure you have a .requires.json or .requires.psd1 file in the current directory, specify a path with -Path, or define specifications with the -Specification parameter to skip this search." + } + foreach ($specfile in $specFiles) { + Write-Verbose "Found Specfile $specFile. Evaluating..." + ConvertFrom-RequiredSpec -RequiredSpecPath $specFile + } } } - } - if (-not $ModulesToInstall) { - throw [InvalidDataException]'No modules specifications found to evaluate.' + if (-not $ModulesToInstall) { + throw [InvalidDataException]'No modules specifications found to evaluate.' + } + + #If we do not have an explicit implementation plan, fetch it + #This is done so that Get-ModuleFastPlan | Install-ModuleFastPlan and Install-ModuleFastPlan have the same flow. + [ModuleFastInfo[]]$installPlan = if ($PSCmdlet.ParameterSetName -eq 'ModuleFastInfo') { + $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 + } } - #If we do not have an explicit implementation plan, fetch it - #This is done so that Get-ModuleFastPlan | Install-ModuleFastPlan and Install-ModuleFastPlan have the same flow. - [ModuleFastInfo[]]$installPlan = if ($PSCmdlet.ParameterSetName -eq 'ModuleFastInfo') { - $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 + if ($installPlan.Count -eq 0) { + $planAlreadySatisfiedMessage = "`u{2705} $($ModulesToInstall.count) Module Specifications have all been satisfied by installed modules. If you would like to check for newer versions remotely, specify -Update" + if ($WhatIfPreference) { + Write-Host -fore DarkGreen $planAlreadySatisfiedMessage + } else { + Write-Verbose $planAlreadySatisfiedMessage + } + return } - } - if ($installPlan.Count -eq 0) { - $planAlreadySatisfiedMessage = "`u{2705} $($ModulesToInstall.count) Module Specifications have all been satisfied by installed modules. If you would like to check for newer versions remotely, specify -Update" - if ($WhatIfPreference) { - Write-Host -fore DarkGreen $planAlreadySatisfiedMessage + #Unless Plan was specified, run the process (WhatIf will also short circuit). + #Plan is specified first so that WhatIf message will only show if Plan is not specified due to -or short circuit logic. + if ($Plan -or -not $PSCmdlet.ShouldProcess($Destination, "Install $($installPlan.Count) Modules")) { + if ($Plan) { + Write-Verbose "📑 -Plan was specified. Returning a plan including $($installPlan.Count) Module Specifications" + } + #TODO: Separate planned installs and dependencies. Can probably do this with a dependency flag on the ModuleInfo item and some custom formatting. + Write-Output $installPlan } else { - Write-Verbose $planAlreadySatisfiedMessage + Write-Progress -Id 1 -Activity 'Install-ModuleFast' -Status "Installing: $($installPlan.count) Modules" -PercentComplete 50 + + $installHelperParams = @{ + ModuleToInstall = $installPlan + Destination = $Destination + CancellationToken = $cancelSource.Token + HttpClient = $httpClient + Update = $Update -or $PSCmdlet.ParameterSetName -eq 'ModuleFastInfo' + ThrottleLimit = $ThrottleLimit + } + $installedModules = Install-ModuleFastHelper @installHelperParams + Write-Progress -Id 1 -Activity 'Install-ModuleFast' -Completed + Write-Verbose "`u{2705} All required modules installed! Exiting." + if ($PassThru) { + Write-Output $installedModules + } } - return - } - #Unless Plan was specified, run the process (WhatIf will also short circuit). - #Plan is specified first so that WhatIf message will only show if Plan is not specified due to -or short circuit logic. - if ($Plan -or -not $PSCmdlet.ShouldProcess($Destination, "Install $($installPlan.Count) Modules")) { - if ($Plan) { - Write-Verbose "📑 -Plan was specified. Returning a plan including $($installPlan.Count) Module Specifications" - } - #TODO: Separate planned installs and dependencies. Can probably do this with a dependency flag on the ModuleInfo item and some custom formatting. - Write-Output $installPlan - } else { - Write-Progress -Id 1 -Activity 'Install-ModuleFast' -Status "Installing: $($installPlan.count) Modules" -PercentComplete 50 - - $installHelperParams = @{ - ModuleToInstall = $installPlan - Destination = $Destination - CancellationToken = $cancelSource.Token - HttpClient = $httpClient - Update = $Update -or $PSCmdlet.ParameterSetName -eq 'ModuleFastInfo' - ThrottleLimit = $ThrottleLimit - } - $installedModules = Install-ModuleFastHelper @installHelperParams - Write-Progress -Id 1 -Activity 'Install-ModuleFast' -Completed - Write-Verbose "`u{2705} All required modules installed! Exiting." - if ($PassThru) { - Write-Output $installedModules - } - } + if ($CI) { + #FIXME: If a package was already installed, it doesn't show up in this lockfile. + Write-Verbose "Writing lockfile to $CILockFilePath" + [Dictionary[string, string]]$lockFile = @{} + $installPlan + | ForEach-Object { + $lockFile.Add($PSItem.Name, $PSItem.ModuleVersion) + } - if ($CI) { - #FIXME: If a package was already installed, it doesn't show up in this lockfile. - Write-Verbose "Writing lockfile to $CILockFilePath" - [Dictionary[string, string]]$lockFile = @{} - $installPlan - | ForEach-Object { - $lockFile.Add($PSItem.Name, $PSItem.ModuleVersion) + $lockFile + | ConvertTo-Json -Depth 2 + | Out-File -FilePath $CILockFilePath -Encoding UTF8 } - - $lockFile - | ConvertTo-Json -Depth 2 - | Out-File -FilePath $CILockFilePath -Encoding UTF8 + } finally { + $cancelSource.Dispose() } } - CLEAN { - $cancelSource.Dispose() - } } function New-ModuleFastClient { @@ -504,330 +504,331 @@ function Get-ModuleFastPlan { END { trap {$PSCmdlet.ThrowTerminatingError($PSItem)} - # A deduplicated list of modules to install - [HashSet[ModuleFastInfo]]$modulesToInstall = @{} + try { + # A deduplicated list of modules to install + [HashSet[ModuleFastInfo]]$modulesToInstall = @{} - # We use this as a fast lookup table for the context of the request - [Dictionary[Task, ModuleFastSpec]]$taskSpecMap = @{} + # We use this as a fast lookup table for the context of the request + [Dictionary[Task, ModuleFastSpec]]$taskSpecMap = @{} - #We use this to track the tasks that are currently running - #We dont need this to be ConcurrentList because we only manipulate it in the "main" runspace. - [List[Task]]$currentTasks = @() + #We use this to track the tasks that are currently running + #We dont need this to be ConcurrentList because we only manipulate it in the "main" runspace. + [List[Task]]$currentTasks = @() - #This is used to track the highest candidate if -Update was specified to force a remote lookup. If the candidate is still the most valid after remote lookup we can skip it without hitting disk to read the manifest again. - [Dictionary[ModuleFastSpec, ModuleFastInfo]]$bestLocalCandidate = @{} + #This is used to track the highest candidate if -Update was specified to force a remote lookup. If the candidate is still the most valid after remote lookup we can skip it without hitting disk to read the manifest again. + [Dictionary[ModuleFastSpec, ModuleFastInfo]]$bestLocalCandidate = @{} - foreach ($moduleSpec in $ModulesToResolve) { - Write-Verbose "${moduleSpec}: Evaluating Module Specification" - $findLocalParams = @{ - Update = $Update - BestCandidate = ([ref]$bestLocalCandidate) - } - if ($DestinationOnly) { $findLocalParams.ModulePaths = $Destination } - - [ModuleFastInfo]$localMatch = Find-LocalModule @findLocalParams $moduleSpec - if ($localMatch) { - Write-Debug "${localMatch}: 🎯 FOUND satisfying version $($localMatch.ModuleVersion) at $($localMatch.Location). Skipping remote search." - #TODO: Capture this somewhere that we can use it to report in the deploy plan - continue - } - - #If we get this far, we didn't find a manifest in this module path - Write-Debug "${moduleSpec}: 🔍 No installed versions matched the spec. Will check remotely." - - $task = Get-ModuleInfoAsync @httpContext -Endpoint $Source -Name $moduleSpec.Name - $taskSpecMap[$task] = $moduleSpec - $currentTasks.Add($task) - } - - [int]$tasksCompleteCount = 1 - [int]$resolveTaskCount = $currentTasks.Count -as [Int] - do { - #The timeout here allow ctrl-C to continue working in PowerShell - #-1 is returned by WaitAny if we hit the timeout before any tasks completed - $noTasksYetCompleted = -1 - [int]$thisTaskIndex = [Task]::WaitAny($currentTasks, 500) - if ($thisTaskIndex -eq $noTasksYetCompleted) { continue } - - #The Plan whitespace is intentional so that it lines up with install progress using the compact format - Write-Progress -Id 1 -Activity 'Install-ModuleFast' -Status "Plan: Resolving $tasksCompleteCount/$resolveTaskCount Module Dependencies" -PercentComplete ((($tasksCompleteCount / $resolveTaskCount) * 50) + 1) - - #TODO: This only indicates headers were received, content may still be downloading and we dont want to block on that. - #For now the content is small but this could be faster if we have another inner loop that WaitAny's on content - #TODO: Perform a HEAD query to see if something has changed - - $completedTask = $currentTasks[$thisTaskIndex] - [ModuleFastSpec]$currentModuleSpec = $taskSpecMap[$completedTask] - if (-not $currentModuleSpec) { - throw 'Failed to find Module Specification for completed task. This is a bug.' - } - - if ($currentModuleSpec.Guid -ne [Guid]::Empty) { - Write-Warning "${currentModuleSpec}: A GUID constraint was found in the module spec. ModuleSpec will currently only verify GUIDs after the module has been installed, so a plan may not be accurate. It is not recommended to match modules by GUID in ModuleFast, but instead verify package signatures for full package authenticity." - } + foreach ($moduleSpec in $ModulesToResolve) { + Write-Verbose "${moduleSpec}: Evaluating Module Specification" + $findLocalParams = @{ + Update = $Update + BestCandidate = ([ref]$bestLocalCandidate) + } + if ($DestinationOnly) { $findLocalParams.ModulePaths = $Destination } - Write-Debug "${currentModuleSpec}: Processing Response" - # We use GetAwaiter so we get proper error messages back, as things such as network errors might occur here. - try { - $response = $completedTask.GetAwaiter().GetResult() - | ConvertFrom-Json - Write-Debug "${currentModuleSpec}: Received Response with $($response.Count) pages" - } catch { - $taskException = $PSItem.Exception.InnerException - #TODO: Rewrite this as a handle filter - if ($taskException -isnot [HttpRequestException]) { throw } - [HttpRequestException]$err = $taskException - if ($err.StatusCode -eq [HttpStatusCode]::NotFound) { - throw [InvalidOperationException]"${currentModuleSpec}: module was not found in the $Source repository. Check the spelling and try again." + [ModuleFastInfo]$localMatch = Find-LocalModule @findLocalParams $moduleSpec + if ($localMatch) { + Write-Debug "${localMatch}: 🎯 FOUND satisfying version $($localMatch.ModuleVersion) at $($localMatch.Location). Skipping remote search." + #TODO: Capture this somewhere that we can use it to report in the deploy plan + continue } - #All other cases - $PSItem.ErrorDetails = "${currentModuleSpec}: Failed to fetch module $currentModuleSpec from $Source. Error: $PSItem" - throw $PSItem - } + #If we get this far, we didn't find a manifest in this module path + Write-Debug "${moduleSpec}: 🔍 No installed versions matched the spec. Will check remotely." - if (-not $response.count) { - throw [InvalidDataException]"${currentModuleSpec}: invalid result received from $Source. This is probably a bug. Content: $response" + $task = Get-ModuleInfoAsync @httpContext -Endpoint $Source -Name $moduleSpec.Name + $taskSpecMap[$task] = $moduleSpec + $currentTasks.Add($task) } - #If what we are looking for exists in the response, we can stop looking - #TODO: Type the responses and check on the type, not the existence of a property. + [int]$tasksCompleteCount = 1 + [int]$resolveTaskCount = $currentTasks.Count -as [Int] + do { + #The timeout here allow ctrl-C to continue working in PowerShell + #-1 is returned by WaitAny if we hit the timeout before any tasks completed + $noTasksYetCompleted = -1 + [int]$thisTaskIndex = [Task]::WaitAny($currentTasks, 500) + if ($thisTaskIndex -eq $noTasksYetCompleted) { continue } + + #The Plan whitespace is intentional so that it lines up with install progress using the compact format + Write-Progress -Id 1 -Activity 'Install-ModuleFast' -Status "Plan: Resolving $tasksCompleteCount/$resolveTaskCount Module Dependencies" -PercentComplete ((($tasksCompleteCount / $resolveTaskCount) * 50) + 1) + + #TODO: This only indicates headers were received, content may still be downloading and we dont want to block on that. + #For now the content is small but this could be faster if we have another inner loop that WaitAny's on content + #TODO: Perform a HEAD query to see if something has changed + + $completedTask = $currentTasks[$thisTaskIndex] + [ModuleFastSpec]$currentModuleSpec = $taskSpecMap[$completedTask] + if (-not $currentModuleSpec) { + throw 'Failed to find Module Specification for completed task. This is a bug.' + } - #TODO: This needs to be moved to a function so it isn't duplicated down in the "else" section below - $pageLeaves = $response.items.items - $pageLeaves | ForEach-Object { - if ($PSItem.packageContent -and -not $PSItem.catalogEntry.packagecontent) { - $PSItem.catalogEntry - | Add-Member -NotePropertyName 'PackageContent' -NotePropertyValue $PSItem.packageContent + if ($currentModuleSpec.Guid -ne [Guid]::Empty) { + Write-Warning "${currentModuleSpec}: A GUID constraint was found in the module spec. ModuleSpec will currently only verify GUIDs after the module has been installed, so a plan may not be accurate. It is not recommended to match modules by GUID in ModuleFast, but instead verify package signatures for full package authenticity." } - } - $entries = $pageLeaves.catalogEntry + Write-Debug "${currentModuleSpec}: Processing Response" + # We use GetAwaiter so we get proper error messages back, as things such as network errors might occur here. + try { + $response = $completedTask.GetAwaiter().GetResult() + | ConvertFrom-Json + Write-Debug "${currentModuleSpec}: Received Response with $($response.Count) pages" + } catch { + $taskException = $PSItem.Exception.InnerException + #TODO: Rewrite this as a handle filter + if ($taskException -isnot [HttpRequestException]) { throw } + [HttpRequestException]$err = $taskException + if ($err.StatusCode -eq [HttpStatusCode]::NotFound) { + throw [InvalidOperationException]"${currentModuleSpec}: module was not found in the $Source repository. Check the spelling and try again." + } - #Get the highest version that satisfies the requirement in the inlined index, if possible - $selectedEntry = if ($entries) { - #Sanity Check for Modules - if ('ItemType:Script' -in $entries[0].tags) { - throw [NotImplementedException]"${currentModuleSpec}: Script installations are currently not supported." + #All other cases + $PSItem.ErrorDetails = "${currentModuleSpec}: Failed to fetch module $currentModuleSpec from $Source. Error: $PSItem" + throw $PSItem } - [SortedSet[NuGetVersion]]$inlinedVersions = $entries.version + if (-not $response.count) { + throw [InvalidDataException]"${currentModuleSpec}: invalid result received from $Source. This is probably a bug. Content: $response" + } - foreach ($candidate in $inlinedVersions.Reverse()) { - #Skip Prereleases unless explicitly requested - if (($candidate.IsPrerelease -or $candidate.HasMetadata) -and -not ($currentModuleSpec.PreRelease -or $Prerelease)) { - Write-Debug "${moduleSpec}: skipping candidate $candidate because it is a prerelease and prerelease was not specified either with the -Prerelease parameter, by specifying a prerelease version in the spec, or adding a ! on the module name spec to indicate prerelease is acceptable." - continue - } + #If what we are looking for exists in the response, we can stop looking + #TODO: Type the responses and check on the type, not the existence of a property. - if ($currentModuleSpec.SatisfiedBy($candidate)) { - 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' } - $matchingEntry - break + #TODO: This needs to be moved to a function so it isn't duplicated down in the "else" section below + $pageLeaves = $response.items.items + $pageLeaves | ForEach-Object { + if ($PSItem.packageContent -and -not $PSItem.catalogEntry.packagecontent) { + $PSItem.catalogEntry + | Add-Member -NotePropertyName 'PackageContent' -NotePropertyValue $PSItem.packageContent } } - } - if ($selectedEntry.count -gt 1) { throw 'Multiple Entries Selected. This is a bug.' } - #Search additional pages if we didn't find it in the inlined ones - $selectedEntry ??= $( - Write-Debug "${currentModuleSpec}: not found in inlined index. Determining appropriate page(s) to query" + $entries = $pageLeaves.catalogEntry + + #Get the highest version that satisfies the requirement in the inlined index, if possible + $selectedEntry = if ($entries) { + #Sanity Check for Modules + if ('ItemType:Script' -in $entries[0].tags) { + throw [NotImplementedException]"${currentModuleSpec}: Script installations are currently not supported." + } - #If not inlined, we need to find what page(s) might have the candidate info we are looking for, starting with the highest numbered page first + [SortedSet[NuGetVersion]]$inlinedVersions = $entries.version - $pages = $response.items - | Where-Object { -not $PSItem.items } #Get non-inlined pages - | Where-Object { - [VersionRange]$pageRange = [VersionRange]::new($PSItem.Lower, $true, $PSItem.Upper, $true, $null, $null) - return $currentModuleSpec.Overlap($pageRange) - } - | Sort-Object -Descending { [NuGetVersion]$PSItem.Upper } + foreach ($candidate in $inlinedVersions.Reverse()) { + #Skip Prereleases unless explicitly requested + if (($candidate.IsPrerelease -or $candidate.HasMetadata) -and -not ($currentModuleSpec.PreRelease -or $Prerelease)) { + Write-Debug "${moduleSpec}: skipping candidate $candidate because it is a prerelease and prerelease was not specified either with the -Prerelease parameter, by specifying a prerelease version in the spec, or adding a ! on the module name spec to indicate prerelease is acceptable." + continue + } - if (-not $pages) { - throw [InvalidOperationException]"${currentModuleSpec}: a matching module was not found in the $Source repository that satisfies the requested version constraints. You may need to specify -PreRelease or adjust your version constraints." + if ($currentModuleSpec.SatisfiedBy($candidate)) { + 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' } + $matchingEntry + break + } + } } - Write-Debug "${currentModuleSpec}: Found $(@($pages).Count) additional pages that might match the query: $($pages.'@id' -join ',')" + if ($selectedEntry.count -gt 1) { throw 'Multiple Entries Selected. This is a bug.' } + #Search additional pages if we didn't find it in the inlined ones + $selectedEntry ??= $( + Write-Debug "${currentModuleSpec}: not found in inlined index. Determining appropriate page(s) to query" - #TODO: This is relatively slow and blocking, but we would need complicated logic to process it in the main task handler loop. - #I really should make a pipeline that breaks off tasks based on the type of the response. - #This should be a relatively rare query that only happens when the latest package isn't being resolved. + #If not inlined, we need to find what page(s) might have the candidate info we are looking for, starting with the highest numbered page first - #Start with the highest potentially matching page and work our way down until we find a match. - foreach ($page in $pages) { - $response = (Get-ModuleInfoAsync @httpContext -Uri $page.'@id').GetAwaiter().GetResult() | ConvertFrom-Json + $pages = $response.items + | Where-Object { -not $PSItem.items } #Get non-inlined pages + | Where-Object { + [VersionRange]$pageRange = [VersionRange]::new($PSItem.Lower, $true, $PSItem.Upper, $true, $null, $null) + return $currentModuleSpec.Overlap($pageRange) + } + | Sort-Object -Descending { [NuGetVersion]$PSItem.Upper } - $pageLeaves = $response.items | ForEach-Object { - if ($PSItem.packageContent -and -not $PSItem.catalogEntry.packagecontent) { - $PSItem.catalogEntry - | Add-Member -NotePropertyName 'PackageContent' -NotePropertyValue $PSItem.packageContent - } - $PSItem + if (-not $pages) { + throw [InvalidOperationException]"${currentModuleSpec}: a matching module was not found in the $Source repository that satisfies the requested version constraints. You may need to specify -PreRelease or adjust your version constraints." } - $entries = $pageLeaves.catalogEntry + Write-Debug "${currentModuleSpec}: Found $(@($pages).Count) additional pages that might match the query: $($pages.'@id' -join ',')" + + #TODO: This is relatively slow and blocking, but we would need complicated logic to process it in the main task handler loop. + #I really should make a pipeline that breaks off tasks based on the type of the response. + #This should be a relatively rare query that only happens when the latest package isn't being resolved. - #TODO: Dedupe as a function with above - if ($entries) { - [SortedSet[NuGetVersion]]$pageVersions = $entries.version + #Start with the highest potentially matching page and work our way down until we find a match. + foreach ($page in $pages) { + $response = (Get-ModuleInfoAsync @httpContext -Uri $page.'@id').GetAwaiter().GetResult() | ConvertFrom-Json - foreach ($candidate in $pageVersions.Reverse()) { - #Skip Prereleases unless explicitly requested - if (($candidate.IsPrerelease -or $candidate.HasMetadata) -and -not ($currentModuleSpec.PreRelease -or $Prerelease)) { - Write-Debug "Skipping candidate $candidate because it is a prerelease and prerelease was not specified either with the -Prerelease parameter or with a ! on the module name." - continue + $pageLeaves = $response.items | ForEach-Object { + if ($PSItem.packageContent -and -not $PSItem.catalogEntry.packagecontent) { + $PSItem.catalogEntry + | Add-Member -NotePropertyName 'PackageContent' -NotePropertyValue $PSItem.packageContent } + $PSItem + } - if ($currentModuleSpec.SatisfiedBy($candidate)) { - 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' } - $matchingEntry - break + $entries = $pageLeaves.catalogEntry + + #TODO: Dedupe as a function with above + if ($entries) { + [SortedSet[NuGetVersion]]$pageVersions = $entries.version + + foreach ($candidate in $pageVersions.Reverse()) { + #Skip Prereleases unless explicitly requested + if (($candidate.IsPrerelease -or $candidate.HasMetadata) -and -not ($currentModuleSpec.PreRelease -or $Prerelease)) { + Write-Debug "Skipping candidate $candidate because it is a prerelease and prerelease was not specified either with the -Prerelease parameter or with a ! on the module name." + continue + } + + if ($currentModuleSpec.SatisfiedBy($candidate)) { + 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' } + $matchingEntry + break + } } } + + #Candidate found, no need to process additional pages + if ($matchingEntry) { break } } + ) - #Candidate found, no need to process additional pages - if ($matchingEntry) { break } + if (-not $selectedEntry) { + throw [InvalidOperationException]"${currentModuleSpec}: a matching module was not found in the $Source repository that satisfies the version constraints. You may need to specify -PreRelease or adjust your version constraints." } - ) + if (-not $selectedEntry.PackageContent) { throw "No package location found for $($selectedEntry.PackageContent). This should never happen and is a bug" } - if (-not $selectedEntry) { - throw [InvalidOperationException]"${currentModuleSpec}: a matching module was not found in the $Source repository that satisfies the version constraints. You may need to specify -PreRelease or adjust your version constraints." - } - if (-not $selectedEntry.PackageContent) { throw "No package location found for $($selectedEntry.PackageContent). This should never happen and is a bug" } - - [ModuleFastInfo]$selectedModule = [ModuleFastInfo]::new( - $selectedEntry.id, - $selectedEntry.version, - $selectedEntry.PackageContent - ) - if ($moduleSpec.Guid -and $moduleSpec.Guid -ne [Guid]::Empty) { - $selectedModule.Guid = $moduleSpec.Guid - } + [ModuleFastInfo]$selectedModule = [ModuleFastInfo]::new( + $selectedEntry.id, + $selectedEntry.version, + $selectedEntry.PackageContent + ) + if ($moduleSpec.Guid -and $moduleSpec.Guid -ne [Guid]::Empty) { + $selectedModule.Guid = $moduleSpec.Guid + } - #If -Update was specified, we need to re-check that none of the selected modules are already installed. - #TODO: Persist state of the local modules found to this point so we don't have to recheck. - if ($Update -and $bestLocalCandidate[$currentModuleSpec].ModuleVersion -eq $selectedModule.ModuleVersion) { - Write-Debug "${selectedModule}: ✅ -Update was specified and the best remote candidate matches what is locally installed, so we can skip this module." - #TODO: Fix the flow so this isn't stated twice - [void]$taskSpecMap.Remove($completedTask) - [void]$currentTasks.Remove($completedTask) - $tasksCompleteCount++ - continue - } + #If -Update was specified, we need to re-check that none of the selected modules are already installed. + #TODO: Persist state of the local modules found to this point so we don't have to recheck. + if ($Update -and $bestLocalCandidate[$currentModuleSpec].ModuleVersion -eq $selectedModule.ModuleVersion) { + Write-Debug "${selectedModule}: ✅ -Update was specified and the best remote candidate matches what is locally installed, so we can skip this module." + #TODO: Fix the flow so this isn't stated twice + [void]$taskSpecMap.Remove($completedTask) + [void]$currentTasks.Remove($completedTask) + $tasksCompleteCount++ + continue + } - #Check if we have already processed this item and move on if we have - if (-not $modulesToInstall.Add($selectedModule)) { - Write-Debug "$selectedModule already exists in the install plan. Skipping..." - #TODO: Fix the flow so this isn't stated twice - [void]$taskSpecMap.Remove($completedTask) - [void]$currentTasks.Remove($completedTask) - $tasksCompleteCount++ - continue - } + #Check if we have already processed this item and move on if we have + if (-not $modulesToInstall.Add($selectedModule)) { + Write-Debug "$selectedModule already exists in the install plan. Skipping..." + #TODO: Fix the flow so this isn't stated twice + [void]$taskSpecMap.Remove($completedTask) + [void]$currentTasks.Remove($completedTask) + $tasksCompleteCount++ + continue + } - Write-Verbose "${selectedModule}: Added to install plan" + Write-Verbose "${selectedModule}: Added to install plan" - # HACK: Pwsh doesn't care about target framework as of today so we can skip that evaluation - # TODO: Should it? Should we check for the target framework and only install if it matches? - $dependencyInfo = $selectedEntry.dependencyGroups.dependencies + # HACK: Pwsh doesn't care about target framework as of today so we can skip that evaluation + # TODO: Should it? Should we check for the target framework and only install if it matches? + $dependencyInfo = $selectedEntry.dependencyGroups.dependencies - #Determine dependencies and add them to the pending tasks - if ($dependencyInfo) { - # HACK: I should be using the Id provided by the server, for now I'm just guessing because - # I need to add it to the ComparableModuleSpec class - [List[ModuleFastSpec]]$dependencies = $dependencyInfo | ForEach-Object { - # Handle rare cases where range is not specified in the dependency - [VersionRange]$range = [string]::IsNullOrWhiteSpace($PSItem.range) ? - [VersionRange]::new() : - [VersionRange]::Parse($PSItem.range) + #Determine dependencies and add them to the pending tasks + if ($dependencyInfo) { + # HACK: I should be using the Id provided by the server, for now I'm just guessing because + # I need to add it to the ComparableModuleSpec class + [List[ModuleFastSpec]]$dependencies = $dependencyInfo | ForEach-Object { + # Handle rare cases where range is not specified in the dependency + [VersionRange]$range = [string]::IsNullOrWhiteSpace($PSItem.range) ? + [VersionRange]::new() : + [VersionRange]::Parse($PSItem.range) - [ModuleFastSpec]::new($PSItem.id, $range) - } - Write-Debug "${currentModuleSpec}: has $($dependencies.count) additional dependencies: $($dependencies -join ', ')" - - # TODO: Where loop filter maybe - [ModuleFastSpec[]]$dependenciesToResolve = $dependencies | Where-Object { - $dependency = $PSItem - # TODO: This dependency resolution logic should be a separate function - # Maybe ModulesToInstall should be nested/grouped by Module Name then version to speed this up, as it currently - # enumerates every time which shouldn't be a big deal for small dependency trees but might be a - # meaninful performance difference on a whole-system upgrade. - [HashSet[string]]$moduleNames = $modulesToInstall.Name - if ($dependency.Name -notin $ModuleNames) { - Write-Debug "$($dependency.Name): No modules with this name currently exist in the install plan. Resolving dependency..." - return $true + [ModuleFastSpec]::new($PSItem.id, $range) } - - $modulesToInstall - | Where-Object Name -EQ $dependency.Name - | Sort-Object ModuleVersion -Descending - | ForEach-Object { - if ($dependency.SatisfiedBy($PSItem.ModuleVersion)) { - Write-Debug "Dependency $dependency satisfied by existing planned install item $PSItem" - return $false + Write-Debug "${currentModuleSpec}: has $($dependencies.count) additional dependencies: $($dependencies -join ', ')" + + # TODO: Where loop filter maybe + [ModuleFastSpec[]]$dependenciesToResolve = $dependencies | Where-Object { + $dependency = $PSItem + # TODO: This dependency resolution logic should be a separate function + # Maybe ModulesToInstall should be nested/grouped by Module Name then version to speed this up, as it currently + # enumerates every time which shouldn't be a big deal for small dependency trees but might be a + # meaninful performance difference on a whole-system upgrade. + [HashSet[string]]$moduleNames = $modulesToInstall.Name + if ($dependency.Name -notin $ModuleNames) { + Write-Debug "$($dependency.Name): No modules with this name currently exist in the install plan. Resolving dependency..." + return $true } - } - - Write-Debug "Dependency $($dependency.Name) is not satisfied by any existing planned install items. Resolving dependency..." - return $true - } - - if (-not $dependenciesToResolve) { - Write-Debug "$moduleSpec has no remaining dependencies that need resolving" - continue - } - Write-Debug "Fetching info on remaining $($dependenciesToResolve.count) dependencies" + $modulesToInstall + | Where-Object Name -EQ $dependency.Name + | Sort-Object ModuleVersion -Descending + | ForEach-Object { + if ($dependency.SatisfiedBy($PSItem.ModuleVersion)) { + Write-Debug "Dependency $dependency satisfied by existing planned install item $PSItem" + return $false + } + } - # We do this here rather than populate modulesToResolve because the tasks wont start until all the existing tasks complete - # TODO: Figure out a way to dedupe this logic maybe recursively but I guess a function would be fine too - foreach ($dependencySpec in $dependenciesToResolve) { - $findLocalParams = @{ - Update = $Update - BestCandidate = ([ref]$bestLocalCandidate) + Write-Debug "Dependency $($dependency.Name) is not satisfied by any existing planned install items. Resolving dependency..." + return $true } - if ($DestinationOnly) { $findLocalParams.ModulePaths = $Destination } - [ModuleFastInfo]$localMatch = Find-LocalModule @findLocalParams $dependencySpec - if ($localMatch) { - Write-Debug "FOUND local module $($localMatch.Name) $($localMatch.ModuleVersion) at $($localMatch.Location.AbsolutePath) that satisfies $moduleSpec. Skipping..." - #TODO: Capture this somewhere that we can use it to report in the deploy plan + if (-not $dependenciesToResolve) { + Write-Debug "$moduleSpec has no remaining dependencies that need resolving" continue - } else { - Write-Debug "No local modules that satisfies dependency $dependencySpec. Checking Remote..." } - Write-Debug "${currentModuleSpec}: Fetching dependency $dependencySpec" - #TODO: Do a direct version lookup if the dependency is a required version - $task = Get-ModuleInfoAsync @httpContext -Endpoint $Source -Name $dependencySpec.Name - $taskSpecMap[$task] = $dependencySpec - #Used to track progress as tasks can get removed - $resolveTaskCount++ + Write-Debug "Fetching info on remaining $($dependenciesToResolve.count) dependencies" + + # We do this here rather than populate modulesToResolve because the tasks wont start until all the existing tasks complete + # TODO: Figure out a way to dedupe this logic maybe recursively but I guess a function would be fine too + foreach ($dependencySpec in $dependenciesToResolve) { + $findLocalParams = @{ + Update = $Update + BestCandidate = ([ref]$bestLocalCandidate) + } + if ($DestinationOnly) { $findLocalParams.ModulePaths = $Destination } + + [ModuleFastInfo]$localMatch = Find-LocalModule @findLocalParams $dependencySpec + if ($localMatch) { + Write-Debug "FOUND local module $($localMatch.Name) $($localMatch.ModuleVersion) at $($localMatch.Location.AbsolutePath) that satisfies $moduleSpec. Skipping..." + #TODO: Capture this somewhere that we can use it to report in the deploy plan + continue + } else { + Write-Debug "No local modules that satisfies dependency $dependencySpec. Checking Remote..." + } + + Write-Debug "${currentModuleSpec}: Fetching dependency $dependencySpec" + #TODO: Do a direct version lookup if the dependency is a required version + $task = Get-ModuleInfoAsync @httpContext -Endpoint $Source -Name $dependencySpec.Name + $taskSpecMap[$task] = $dependencySpec + #Used to track progress as tasks can get removed + $resolveTaskCount++ - $currentTasks.Add($task) + $currentTasks.Add($task) + } } - } - #Putting .NET methods in a try/catch makes errors in them terminating - try { - [void]$taskSpecMap.Remove($completedTask) - [void]$currentTasks.Remove($completedTask) - $tasksCompleteCount++ - } catch { - throw - } - } while ($currentTasks.count -gt 0) + #Putting .NET methods in a try/catch makes errors in them terminating + try { + [void]$taskSpecMap.Remove($completedTask) + [void]$currentTasks.Remove($completedTask) + $tasksCompleteCount++ + } catch { + throw + } + } while ($currentTasks.count -gt 0) - if ($modulesToInstall) { return $modulesToInstall } - } - CLEAN { - #Cancel any outstanding tasks if unexpected error occurs - $cancelTokenSource.Dispose() + if ($modulesToInstall) { return $modulesToInstall } + } finally { + #Cancel any outstanding tasks if unexpected error occurs + $cancelTokenSource.Dispose() + } } } @@ -864,158 +865,160 @@ function Install-ModuleFastHelper { END { $ErrorActionPreference = 'Stop' - #Used to keep track of context with Tasks, because we dont have "await" style syntax like C# - [Dictionary[Task, hashtable]]$taskMap = @{} - [List[Task[Stream]]]$streamTasks = foreach ($module in $ModuleToInstall) { - $installPath = Join-Path $Destination $module.Name (Resolve-FolderVersion $module.ModuleVersion) - #TODO: Do a get-localmodule check here - $installIndicatorPath = Join-Path $installPath '.incomplete' - if (Test-Path $installIndicatorPath) { - Write-Warning "${module}: Incomplete installation found at $installPath. Will delete and retry." - Remove-Item $installPath -Recurse -Force - } - - if (Test-Path $installPath) { - $existingManifestPath = try { - Resolve-Path (Join-Path $installPath "$($module.Name).psd1") -ErrorAction Stop - } catch [ActionPreferenceStopException] { - throw "${module}: Existing module folder found at $installPath but the manifest could not be found. This is likely a corrupted or missing module and should be fixed manually." + try { + #Used to keep track of context with Tasks, because we dont have "await" style syntax like C# + [Dictionary[Task, hashtable]]$taskMap = @{} + [List[Task[Stream]]]$streamTasks = foreach ($module in $ModuleToInstall) { + $installPath = Join-Path $Destination $module.Name (Resolve-FolderVersion $module.ModuleVersion) + #TODO: Do a get-localmodule check here + $installIndicatorPath = Join-Path $installPath '.incomplete' + if (Test-Path $installIndicatorPath) { + Write-Warning "${module}: Incomplete installation found at $installPath. Will delete and retry." + Remove-Item $installPath -Recurse -Force } - #TODO: Dedupe all import-powershelldatafile operations to a function ideally - $existingModuleMetadata = Import-ModuleManifest $existingManifestPath - $existingVersion = [NugetVersion]::new( - $existingModuleMetadata.ModuleVersion, - $existingModuleMetadata.privatedata.psdata.prerelease - ) + if (Test-Path $installPath) { + $existingManifestPath = try { + Resolve-Path (Join-Path $installPath "$($module.Name).psd1") -ErrorAction Stop + } catch [ActionPreferenceStopException] { + throw "${module}: Existing module folder found at $installPath but the manifest could not be found. This is likely a corrupted or missing module and should be fixed manually." + } - #Do a prerelease evaluation - if ($module.ModuleVersion -eq $existingVersion) { - if ($Update) { - Write-Debug "${module}: Existing module found at $installPath and its version $existingVersion is the same as the requested version. -Update was specified so we are assuming that the discovered online version is the same as the local version and skipping this module installation." - continue + #TODO: Dedupe all import-powershelldatafile operations to a function ideally + $existingModuleMetadata = Import-ModuleManifest $existingManifestPath + $existingVersion = [NugetVersion]::new( + $existingModuleMetadata.ModuleVersion, + $existingModuleMetadata.privatedata.psdata.prerelease + ) + + #Do a prerelease evaluation + if ($module.ModuleVersion -eq $existingVersion) { + if ($Update) { + Write-Debug "${module}: Existing module found at $installPath and its version $existingVersion is the same as the requested version. -Update was specified so we are assuming that the discovered online version is the same as the local version and skipping this module installation." + continue + } else { + throw [NotImplementedException]"${module}: Existing module found at $installPath and its version $existingVersion is the same as the requested version. This is probably a bug because it should have been detected by localmodule detection. Use -Update to override..." + } + } + if ($module.ModuleVersion -lt $existingVersion) { + #TODO: Add force to override + throw [NotSupportedException]"${module}: Existing module found at $installPath and its version $existingVersion is newer than the requested prerelease version $($module.ModuleVersion). If you wish to continue, please remove the existing module folder or modify your specification and try again." } else { - throw [NotImplementedException]"${module}: Existing module found at $installPath and its version $existingVersion is the same as the requested version. This is probably a bug because it should have been detected by localmodule detection. Use -Update to override..." + Write-Warning "${module}: Planned version $($module.ModuleVersion) is newer than existing prerelease version $existingVersion so we will overwrite." + Remove-Item $installPath -Force -Recurse } } - if ($module.ModuleVersion -lt $existingVersion) { - #TODO: Add force to override - throw [NotSupportedException]"${module}: Existing module found at $installPath and its version $existingVersion is newer than the requested prerelease version $($module.ModuleVersion). If you wish to continue, please remove the existing module folder or modify your specification and try again." - } else { - Write-Warning "${module}: Planned version $($module.ModuleVersion) is newer than existing prerelease version $existingVersion so we will overwrite." - Remove-Item $installPath -Force -Recurse - } - } - Write-Verbose "${module}: Downloading from $($module.Location)" - if (-not $module.Location) { - throw "${module}: No Download Link found. This is a bug" - } + Write-Verbose "${module}: Downloading from $($module.Location)" + if (-not $module.Location) { + throw "${module}: No Download Link found. This is a bug" + } - $streamTask = $httpClient.GetStreamAsync($module.Location, $CancellationToken) - $context = @{ - Module = $module - InstallPath = $installPath + $streamTask = $httpClient.GetStreamAsync($module.Location, $CancellationToken) + $context = @{ + Module = $module + InstallPath = $installPath + } + $taskMap.Add($streamTask, $context) + $streamTask } - $taskMap.Add($streamTask, $context) - $streamTask - } - [List[Job2]]$installJobs = while ($streamTasks.count -gt 0) { - $noTasksYetCompleted = -1 - [int]$thisTaskIndex = [Task]::WaitAny($streamTasks, 500) - if ($thisTaskIndex -eq $noTasksYetCompleted) { continue } - $thisTask = $streamTasks[$thisTaskIndex] - $stream = $thisTask.GetAwaiter().GetResult() - $context = $taskMap[$thisTask] - $context.fetchStream = $stream - $streamTasks.RemoveAt($thisTaskIndex) - - # This is a sync process and we want to do it in parallel, hence the threadjob - Write-Verbose "$($context.Module): Extracting to $($context.installPath)" - $installJob = Start-ThreadJob -ThrottleLimit $ThrottleLimit { - param( - [ValidateNotNullOrEmpty()]$stream = $USING:stream, - [ValidateNotNullOrEmpty()]$context = $USING:context - ) - process { - $installPath = $context.InstallPath - $installIndicatorPath = Join-Path $installPath '.incomplete' - - if (Test-Path $installIndicatorPath) { - #FIXME: Output inside a threadjob is not surfaced to the user. - Write-Warning "$($context.Module): Incomplete installation found at $installPath. Will delete and retry." - Remove-Item $installPath -Recurse -Force - } - - if (-not (Test-Path $context.InstallPath)) { - New-Item -Path $context.InstallPath -ItemType Directory -Force | Out-Null - } + [List[Job2]]$installJobs = while ($streamTasks.count -gt 0) { + $noTasksYetCompleted = -1 + [int]$thisTaskIndex = [Task]::WaitAny($streamTasks, 500) + if ($thisTaskIndex -eq $noTasksYetCompleted) { continue } + $thisTask = $streamTasks[$thisTaskIndex] + $stream = $thisTask.GetAwaiter().GetResult() + $context = $taskMap[$thisTask] + $context.fetchStream = $stream + $streamTasks.RemoveAt($thisTaskIndex) + + # This is a sync process and we want to do it in parallel, hence the threadjob + Write-Verbose "$($context.Module): Extracting to $($context.installPath)" + $installJob = Start-ThreadJob -ThrottleLimit $ThrottleLimit { + param( + [ValidateNotNullOrEmpty()]$stream = $USING:stream, + [ValidateNotNullOrEmpty()]$context = $USING:context + ) + process { + try { + $installPath = $context.InstallPath + $installIndicatorPath = Join-Path $installPath '.incomplete' + + if (Test-Path $installIndicatorPath) { + #FIXME: Output inside a threadjob is not surfaced to the user. + Write-Warning "$($context.Module): Incomplete installation found at $installPath. Will delete and retry." + Remove-Item $installPath -Recurse -Force + } - New-Item -ItemType File -Path $installIndicatorPath -Force | Out-Null + if (-not (Test-Path $context.InstallPath)) { + New-Item -Path $context.InstallPath -ItemType Directory -Force | Out-Null + } - #We are going to extract these straight out of memory, so we don't need to write the nupkg to disk - $zip = [IO.Compression.ZipArchive]::new($stream, 'Read') - [IO.Compression.ZipFileExtensions]::ExtractToDirectory($zip, $installPath) + New-Item -ItemType File -Path $installIndicatorPath -Force | Out-Null + + #We are going to extract these straight out of memory, so we don't need to write the nupkg to disk + $zip = [IO.Compression.ZipArchive]::new($stream, 'Read') + [IO.Compression.ZipFileExtensions]::ExtractToDirectory($zip, $installPath) + + if ($context.Module.Guid -and $context.Module.Guid -ne [Guid]::Empty) { + Write-Debug "$($context.Module): GUID was specified in Module. Verifying manifest" + $manifestPath = Join-Path $installPath "$($context.Module.Name).psd1" + #FIXME: This should be using Import-ModuleManifest but it needs to be brought in via the ThreadJob context. This will fail if the module has a dynamic manifest. + $manifest = Import-PowerShellDataFile $manifestPath + if ($manifest.Guid -ne $context.Module.Guid) { + Remove-Item $installPath -Force -Recurse + throw [InvalidOperationException]"$($context.Module): The installed package GUID does not match what was in the Module Spec. Expected $($context.Module.Guid) but found $($manifest.Guid) in $($manifestPath). Deleting this module, please check that your GUID specification is correct, or otherwise investigate why the GUID is different." + } + } - if ($context.Module.Guid -and $context.Module.Guid -ne [Guid]::Empty) { - Write-Debug "$($context.Module): GUID was specified in Module. Verifying manifest" - $manifestPath = Join-Path $installPath "$($context.Module.Name).psd1" - #FIXME: This should be using Import-ModuleManifest but it needs to be brought in via the ThreadJob context. This will fail if the module has a dynamic manifest. - $manifest = Import-PowerShellDataFile $manifestPath - if ($manifest.Guid -ne $context.Module.Guid) { - Remove-Item $installPath -Force -Recurse - throw [InvalidOperationException]"$($context.Module): The installed package GUID does not match what was in the Module Spec. Expected $($context.Module.Guid) but found $($manifest.Guid) in $($manifestPath). Deleting this module, please check that your GUID specification is correct, or otherwise investigate why the GUID is different." + #FIXME: Output inside a threadjob is not surfaced to the user. + Write-Debug "Cleanup Nuget Files in $installPath" + if (-not $installPath) { throw 'ModuleDestination was not set. This is a bug, report it' } + Get-ChildItem -Path $installPath | Where-Object { + $_.Name -in '_rels', 'package', '[Content_Types].xml' -or + $_.Name.EndsWith('.nuspec') + } | Remove-Item -Force -Recurse + + Remove-Item $installIndicatorPath -Force + return $context + } finally { + if ($zip) {$zip.Dispose()} + if ($stream) {$stream.Dispose()} } } - - #FIXME: Output inside a threadjob is not surfaced to the user. - Write-Debug "Cleanup Nuget Files in $installPath" - if (-not $installPath) { throw 'ModuleDestination was not set. This is a bug, report it' } - Get-ChildItem -Path $installPath | Where-Object { - $_.Name -in '_rels', 'package', '[Content_Types].xml' -or - $_.Name.EndsWith('.nuspec') - } | Remove-Item -Force -Recurse - - Remove-Item $installIndicatorPath -Force - return $context } + $installJob + } - CLEAN { - if ($zip) {$zip.Dispose()} - if ($stream) {$stream.Dispose()} - } + $installed = 0 + $installedModules = while ($installJobs.count -gt 0) { + $ErrorActionPreference = 'Stop' + $completedJob = $installJobs | Wait-Job -Any + $completedJobContext = $completedJob | Receive-Job -Wait -AutoRemoveJob + if (-not $installJobs.Remove($completedJob)) { throw 'Could not remove completed job from list. This is a bug, report it' } + $installed++ + Write-Verbose "$($completedJobContext.Module): Installed to $($completedJobContext.InstallPath)" + Write-Progress -Id 1 -Activity 'Install-ModuleFast' -Status "Install: $installed/$($ModuleToInstall.count) Modules" -PercentComplete ((($installed / $ModuleToInstall.count) * 50) + 50) + $context.Module.Location = $completedJobContext.InstallPath + #Output the module for potential future passthru + $context.Module } - $installJob - } - $installed = 0 - $installedModules = while ($installJobs.count -gt 0) { - $ErrorActionPreference = 'Stop' - $completedJob = $installJobs | Wait-Job -Any - $completedJobContext = $completedJob | Receive-Job -Wait -AutoRemoveJob - if (-not $installJobs.Remove($completedJob)) { throw 'Could not remove completed job from list. This is a bug, report it' } - $installed++ - Write-Verbose "$($completedJobContext.Module): Installed to $($completedJobContext.InstallPath)" - Write-Progress -Id 1 -Activity 'Install-ModuleFast' -Status "Install: $installed/$($ModuleToInstall.count) Modules" -PercentComplete ((($installed / $ModuleToInstall.count) * 50) + 50) - $context.Module.Location = $completedJobContext.InstallPath - #Output the module for potential future passthru - $context.Module - } + if ($PassThru) { + return $installedModules + } - if ($PassThru) { - return $installedModules - } - } - CLEAN { - $cancelTokenSource.Dispose() - if ($installJobs) { - try { - $installJobs | Remove-Job -Force -ErrorAction SilentlyContinue - } catch { - #Suppress this error because it is likely that the job was already removed - if ($PSItem -notlike '*because it is a child job*') {throw} + } finally { + $cancelTokenSource.Dispose() + if ($installJobs) { + try { + $installJobs | Remove-Job -Force -ErrorAction SilentlyContinue + } catch { + #Suppress this error because it is likely that the job was already removed + if ($PSItem -notlike '*because it is a child job*') {throw} + } } } }