src/Private.PSResourceGet.ps1
|
function Assert-RequirementsAreSupported { [CmdletBinding()] param( [Parameter(Mandatory)] [ValidateNotNull()] [hashtable] $Requirements, [Parameter(Mandatory)] [ValidateNotNullOrEmpty()] [string] $RequirementsPath ) foreach ($name in $Requirements.Keys) { $entry = $Requirements[$name] if ($entry -isnot [hashtable]) { throw "Requirement entry must be a hashtable for resource '$name': $RequirementsPath" } $repository = $entry['Repository'] if ($repository -is [string] -and (-not [string]::IsNullOrWhiteSpace($repository))) { if ($repository -ine 'PSGallery') { throw "Only PSGallery is supported for Repository. Invalid repository '$repository' for '$name' in: $RequirementsPath" } } } } function Invoke-SavePSResource { [CmdletBinding()] [OutputType([Microsoft.PowerShell.PSResourceGet.UtilClasses.PSResourceInfo])] param( [Parameter(Mandatory)] [ValidateNotNullOrEmpty()] [string] $Name, [Parameter()] [AllowNull()] [string] $Version, [Parameter()] [switch] $Prerelease, [Parameter()] [AllowNull()] [string] $Repository, [Parameter(Mandatory)] [ValidateNotNullOrEmpty()] [string] $Path ) if (-not (Test-Path -LiteralPath $Path)) { New-Item -ItemType Directory -Path $Path -Force | Out-Null } elseif (Test-Path -LiteralPath $Path -PathType Leaf) { throw "Save path must be a directory: $Path" } $params = @{ Name = $Name Path = $Path PassThru = $true } if (-not [string]::IsNullOrWhiteSpace($Version)) { $params['Version'] = $Version } if ($Prerelease) { $params['Prerelease'] = $true } if (-not [string]::IsNullOrWhiteSpace($Repository)) { $params['Repository'] = $Repository } Save-PSResource @params } function Resolve-RequirementsToLockData { [CmdletBinding()] [OutputType([hashtable])] param( [Parameter(Mandatory)] [ValidateNotNull()] [hashtable] $Requirements, [Parameter(Mandatory)] [ValidateNotNullOrEmpty()] [string] $RequirementsPath, [Parameter(Mandatory)] [ValidateNotNullOrEmpty()] [string] $StorePath ) Assert-RequirementsAreSupported -Requirements $Requirements -RequirementsPath $RequirementsPath $savedResources = [System.Collections.Generic.List[Microsoft.PowerShell.PSResourceGet.UtilClasses.PSResourceInfo]]::new() $directNames = [string[]]$Requirements.Keys foreach ($name in $directNames) { $entry = $Requirements[$name] if ($entry -isnot [hashtable]) { throw "Requirement entry must be a hashtable for resource '$name': $RequirementsPath" } $ver = $entry['Version'] $verString = if ($null -eq $ver) { $null } elseif ($ver -is [string]) { $ver.Trim() } else { $ver.ToString() } if ([string]::IsNullOrWhiteSpace($verString)) { $verString = $null } $prereleaseSwitch = $false if ($entry.ContainsKey('Prerelease')) { $prereleaseSwitch = [bool]$entry['Prerelease'] } $saved = Invoke-SavePSResource -Name $name -Version $verString -Prerelease:$prereleaseSwitch -Repository 'PSGallery' -Path $StorePath foreach ($s in @($saved)) { if ($null -ne $s) { $savedResources.Add($s) } } } $lockData = @{} foreach ($r in $savedResources) { if ($null -eq $r) { continue } $name = $r.Name if (-not ($name -is [string]) -or [string]::IsNullOrWhiteSpace($name)) { throw 'Save-PSResource returned an entry without a valid Name.' } $repository = $r.Repository if (-not ($repository -is [string]) -or [string]::IsNullOrWhiteSpace($repository)) { $repository = 'PSGallery' } if ($repository -ine 'PSGallery') { throw "Only PSGallery is supported for Repository. Invalid repository '$repository' returned for '$name'." } $normalizedVersion = ConvertTo-NormalizedVersionString -Version $r.Version -Prerelease $r.Prerelease $lockData[$name] = @{ Version = $normalizedVersion Repository = 'PSGallery' } } @{ DirectNames = $directNames LockData = $lockData } } function ConvertTo-PSLRMResourcesFromLockData { [CmdletBinding()] [OutputType([object])] param( [Parameter(Mandatory)] [ValidateNotNull()] [hashtable] $LockData, [Parameter(Mandatory)] [ValidateNotNull()] [string[]] $DirectNames, [Parameter(Mandatory)] [bool] $IncludeDependencies, [Parameter(Mandatory)] [ValidateNotNullOrEmpty()] [string] $ProjectRoot ) $names = [string[]]$LockData.Keys [System.Array]::Sort($names, [System.StringComparer]::Ordinal) $result = [System.Collections.Generic.List[object]]::new() foreach ($name in $names) { $isDirect = $DirectNames -contains $name if ($IncludeDependencies -or $isDirect) { $entry = $LockData[$name] $result.Add((New-Resource -Name $name -Version $entry['Version'] -Prerelease $null -Repository 'PSGallery' -IsDirect $isDirect -ProjectRoot $ProjectRoot)) } } $result.ToArray() } function Save-LockDataToStore { [CmdletBinding()] param( [Parameter(Mandatory)] [ValidateNotNull()] [hashtable] $LockData, [Parameter(Mandatory)] [ValidateNotNullOrEmpty()] [string] $StorePath ) $names = [string[]]$LockData.Keys [System.Array]::Sort($names, [System.StringComparer]::Ordinal) foreach ($name in $names) { $entry = $LockData[$name] if ($entry -isnot [hashtable]) { throw "Lockfile entry must be a hashtable for resource '$name'." } $version = $entry['Version'] $versionString = if ($null -eq $version) { $null } elseif ($version -is [string]) { $version.Trim() } else { $version.ToString() } if ([string]::IsNullOrWhiteSpace($versionString)) { throw "Lockfile entry must contain a non-empty Version for '$name'." } $repository = $entry['Repository'] if (-not ($repository -is [string]) -or [string]::IsNullOrWhiteSpace($repository)) { $repository = 'PSGallery' } if ($repository -ine 'PSGallery') { throw "Only PSGallery is supported for Repository. Invalid repository '$repository' for '$name'." } $prereleaseSwitch = $false if ($entry.ContainsKey('Prerelease')) { $prereleaseSwitch = [bool]$entry['Prerelease'] } elseif ($versionString -match '-') { $prereleaseSwitch = $true } Invoke-SavePSResource -Name $name -Version $versionString -Prerelease:$prereleaseSwitch -Repository 'PSGallery' -Path $StorePath | Out-Null } } function Test-VersionConstraintSatisfied { [CmdletBinding()] [OutputType([bool])] param( [Parameter()] [AllowNull()] [object] $VersionConstraint, [Parameter(Mandatory)] [ValidateNotNull()] [object] $ResolvedVersion ) $constraintString = ConvertTo-NormalizedVersionString -Version $VersionConstraint if ([string]::IsNullOrWhiteSpace($constraintString)) { return $true } $resolvedVersionString = ConvertTo-NormalizedVersionString -Version $ResolvedVersion if ([string]::IsNullOrWhiteSpace($resolvedVersionString)) { throw 'ResolvedVersion must be a non-empty version string.' } function ConvertTo-ComparableVersion { param( [Parameter(Mandatory)] [string] $VersionString, [Parameter(Mandatory)] [string] $Label ) if ($VersionString -notmatch '^(?<core>\d+(?:\.\d+){0,3})(?:-(?<prerelease>.+))?$') { throw "Unsupported version format for ${Label}: '$VersionString'" } $segments = [System.Collections.Generic.List[int]]::new() foreach ($segment in ($Matches['core'] -split '\.')) { $segments.Add([int]$segment) } $prerelease = $Matches['prerelease'] $prereleaseIdentifiers = if ([string]::IsNullOrWhiteSpace($prerelease)) { @() } else { @($prerelease.Split('.')) } [pscustomobject]@{ Core = $segments.ToArray() Prerelease = $prerelease PrereleaseIdentifiers = $prereleaseIdentifiers } } function Compare-ComparableVersion { param( [Parameter(Mandatory)] [psobject] $Left, [Parameter(Mandatory)] [psobject] $Right ) $leftCore = @($Left.Core) $rightCore = @($Right.Core) $leftPrereleaseIdentifiers = @($Left.PrereleaseIdentifiers) $rightPrereleaseIdentifiers = @($Right.PrereleaseIdentifiers) $maxCoreLength = [Math]::Max($leftCore.Count, $rightCore.Count) for ($index = 0; $index -lt $maxCoreLength; $index++) { $leftSegment = if ($index -lt $leftCore.Count) { $leftCore[$index] } else { 0 } $rightSegment = if ($index -lt $rightCore.Count) { $rightCore[$index] } else { 0 } if ($leftSegment -lt $rightSegment) { return -1 } if ($leftSegment -gt $rightSegment) { return 1 } } $leftHasPrerelease = -not [string]::IsNullOrWhiteSpace($Left.Prerelease) $rightHasPrerelease = -not [string]::IsNullOrWhiteSpace($Right.Prerelease) if ($leftHasPrerelease -and (-not $rightHasPrerelease)) { return -1 } if ((-not $leftHasPrerelease) -and $rightHasPrerelease) { return 1 } if ((-not $leftHasPrerelease) -and (-not $rightHasPrerelease)) { return 0 } $maxPrereleaseLength = [Math]::Max($leftPrereleaseIdentifiers.Count, $rightPrereleaseIdentifiers.Count) for ($index = 0; $index -lt $maxPrereleaseLength; $index++) { if ($index -ge $leftPrereleaseIdentifiers.Count) { return -1 } if ($index -ge $rightPrereleaseIdentifiers.Count) { return 1 } $leftIdentifier = [string]$leftPrereleaseIdentifiers[$index] $rightIdentifier = [string]$rightPrereleaseIdentifiers[$index] $leftIsNumeric = $leftIdentifier -match '^\d+$' $rightIsNumeric = $rightIdentifier -match '^\d+$' if ($leftIsNumeric -and $rightIsNumeric) { $leftNumber = [int64]$leftIdentifier $rightNumber = [int64]$rightIdentifier if ($leftNumber -lt $rightNumber) { return -1 } if ($leftNumber -gt $rightNumber) { return 1 } continue } if ($leftIsNumeric -and (-not $rightIsNumeric)) { return -1 } if ((-not $leftIsNumeric) -and $rightIsNumeric) { return 1 } $comparison = [System.StringComparer]::OrdinalIgnoreCase.Compare($leftIdentifier, $rightIdentifier) if ($comparison -lt 0) { return -1 } if ($comparison -gt 0) { return 1 } } return 0 } $resolvedComparable = ConvertTo-ComparableVersion -VersionString $resolvedVersionString -Label 'ResolvedVersion' if (($constraintString.StartsWith('[') -or $constraintString.StartsWith('(')) -and ($constraintString.EndsWith(']') -or $constraintString.EndsWith(')'))) { if ($constraintString -match '^\[(?<exact>[^,\]]+)\]$') { $exactComparable = ConvertTo-ComparableVersion -VersionString $Matches['exact'].Trim() -Label 'VersionConstraint' return ((Compare-ComparableVersion -Left $resolvedComparable -Right $exactComparable) -eq 0) } if ($constraintString -notmatch '^(?<lowerBracket>[\[\(])\s*(?<lower>[^,\)]*)\s*,\s*(?<upper>[^\]\)]*)\s*(?<upperBracket>[\]\)])$') { throw "Unsupported version constraint format: '$constraintString'" } if (-not [string]::IsNullOrWhiteSpace($Matches['lower'])) { $lowerComparable = ConvertTo-ComparableVersion -VersionString $Matches['lower'].Trim() -Label 'VersionConstraint lower bound' $lowerComparison = Compare-ComparableVersion -Left $resolvedComparable -Right $lowerComparable if (($Matches['lowerBracket'] -eq '[') -and ($lowerComparison -lt 0)) { return $false } if (($Matches['lowerBracket'] -eq '(') -and ($lowerComparison -le 0)) { return $false } } if (-not [string]::IsNullOrWhiteSpace($Matches['upper'])) { $upperComparable = ConvertTo-ComparableVersion -VersionString $Matches['upper'].Trim() -Label 'VersionConstraint upper bound' $upperComparison = Compare-ComparableVersion -Left $resolvedComparable -Right $upperComparable if (($Matches['upperBracket'] -eq ']') -and ($upperComparison -gt 0)) { return $false } if (($Matches['upperBracket'] -eq ')') -and ($upperComparison -ge 0)) { return $false } } return $true } $exactVersionComparable = ConvertTo-ComparableVersion -VersionString $constraintString -Label 'VersionConstraint' (Compare-ComparableVersion -Left $resolvedComparable -Right $exactVersionComparable) -eq 0 } function Test-LockfileDrift { [CmdletBinding()] [OutputType([object])] param( [Parameter(Mandatory)] [ValidateNotNull()] [hashtable] $Requirements, [Parameter(Mandatory)] [ValidateNotNull()] [hashtable] $LockData ) $reasons = [System.Collections.Generic.List[string]]::new() $missingDirectNames = [System.Collections.Generic.List[string]]::new() $unexpectedDirectNames = [System.Collections.Generic.List[string]]::new() $repositoryMismatches = [System.Collections.Generic.List[string]]::new() $prereleaseViolations = [System.Collections.Generic.List[string]]::new() $versionViolations = [System.Collections.Generic.List[string]]::new() $directNames = [string[]]$Requirements.Keys [System.Array]::Sort($directNames, [System.StringComparer]::Ordinal) foreach ($name in $directNames) { if (-not $LockData.ContainsKey($name)) { $missingDirectNames.Add($name) $reasons.Add("Missing direct dependency in lockfile: '$name'.") continue } $requirementEntry = $Requirements[$name] if ($requirementEntry -isnot [hashtable]) { throw "Requirement entry must be a hashtable for resource '$name'." } $lockEntry = $LockData[$name] if ($lockEntry -isnot [hashtable]) { throw "Lockfile entry must be a hashtable for resource '$name'." } $requirementRepository = $requirementEntry['Repository'] if (-not ($requirementRepository -is [string]) -or [string]::IsNullOrWhiteSpace($requirementRepository)) { $requirementRepository = 'PSGallery' } $lockRepository = $lockEntry['Repository'] if (-not ($lockRepository -is [string]) -or [string]::IsNullOrWhiteSpace($lockRepository)) { $lockRepository = 'PSGallery' } if ($requirementRepository -cne $lockRepository) { $repositoryMismatches.Add($name) $reasons.Add("Repository mismatch for '$name': requirements=$requirementRepository lockfile=$lockRepository") } $resolvedVersion = ConvertTo-NormalizedVersionString -Version $lockEntry['Version'] -Prerelease $null if ([string]::IsNullOrWhiteSpace($resolvedVersion)) { $versionViolations.Add($name) $reasons.Add("Lockfile entry must contain a non-empty Version for '$name'.") continue } $allowPrerelease = $false if ($requirementEntry.ContainsKey('Prerelease')) { $allowPrerelease = [bool]$requirementEntry['Prerelease'] } if ((-not $allowPrerelease) -and ($resolvedVersion -match '-')) { $prereleaseViolations.Add($name) $reasons.Add("Prerelease version is not allowed for '$name': $resolvedVersion") } $versionConstraint = ConvertTo-NormalizedVersionString -Version $requirementEntry['Version'] -Prerelease $null if ((-not [string]::IsNullOrWhiteSpace($versionConstraint)) -and (-not (Test-VersionConstraintSatisfied -VersionConstraint $versionConstraint -ResolvedVersion $resolvedVersion))) { $versionViolations.Add($name) $reasons.Add("Resolved version '$resolvedVersion' does not satisfy version constraint '$versionConstraint' for '$name'.") } } [pscustomobject]@{ IsDrifted = ($reasons.Count -gt 0) Reasons = $reasons.ToArray() MissingDirectNames = $missingDirectNames.ToArray() UnexpectedDirectNames = $unexpectedDirectNames.ToArray() RepositoryMismatches = $repositoryMismatches.ToArray() PrereleaseViolations = $prereleaseViolations.ToArray() VersionViolations = $versionViolations.ToArray() } } function Invoke-InstallOrUpdateCore { [CmdletBinding()] [OutputType([object])] param( [Parameter(Mandatory)] [ValidateNotNullOrEmpty()] [string] $ProjectRoot, [Parameter(Mandatory)] [ValidateSet('Install', 'Update')] [string] $Operation, [Parameter(Mandatory)] [bool] $IncludeDependencies ) $requirementsPath = Get-RequirementsPath -ProjectRoot $ProjectRoot $lockfilePath = Get-LockfilePath -ProjectRoot $ProjectRoot $storePath = Get-StorePath -ProjectRoot $ProjectRoot $requirements = Import-PowerShellDataFile -Path $requirementsPath if ($requirements -isnot [hashtable]) { throw "Requirements file must be a hashtable: $requirementsPath" } Assert-RequirementsAreSupported -Requirements $requirements -RequirementsPath $requirementsPath $directNames = [string[]]$requirements.Keys if (($Operation -eq 'Install') -and (Test-Path -LiteralPath $lockfilePath)) { $lockData = Read-Lockfile -Path $lockfilePath $driftResult = Test-LockfileDrift -Requirements $requirements -LockData $lockData if ($driftResult.IsDrifted) { $reasonText = $driftResult.Reasons -join ' ' throw "Requirements and lockfile are out of sync. Run Update-PSLResource to refresh the lockfile. $reasonText" } Save-LockDataToStore -LockData $lockData -StorePath $storePath return (ConvertTo-PSLRMResourcesFromLockData -LockData $lockData -DirectNames $directNames -IncludeDependencies $IncludeDependencies -ProjectRoot $ProjectRoot) } # Shared resolve-and-write path (currently Update always and Install when lockfile is missing). # Future module-scoped operations can filter requirements before resolution here. $resolved = Resolve-RequirementsToLockData -Requirements $requirements -RequirementsPath $requirementsPath -StorePath $storePath $directNames = [string[]]$resolved['DirectNames'] $lockData = [hashtable]$resolved['LockData'] Write-Lockfile -Path $lockfilePath -Data $lockData ConvertTo-PSLRMResourcesFromLockData -LockData $lockData -DirectNames $directNames -IncludeDependencies $IncludeDependencies -ProjectRoot $ProjectRoot } |