diff --git a/APIs/NuGet/.universal/endpoints.ps1 b/APIs/NuGet/.universal/endpoints.ps1 new file mode 100644 index 0000000..99bf955 --- /dev/null +++ b/APIs/NuGet/.universal/endpoints.ps1 @@ -0,0 +1,170 @@ +$NuGetModulePath = Join-Path $PSScriptRoot '..\PowerShellUniversal.NuGet.psd1' +if (Test-Path $NuGetModulePath) { + Import-Module $NuGetModulePath -Force +} +else { + Import-Module PowerShellUniversal.NuGet -Force +} + +New-PSUEndpoint -Url '/nuget/v3/index.json' -Description 'Returns the NuGet V3 service index.' -Method @('GET') -Endpoint { + $serviceIndex = Get-PSUNuGetServiceIndex -BaseUrl (Get-PSUNuGetRequestBaseUrl -Headers $Headers) + New-PSUApiResponse -StatusCode 200 -ContentType 'application/json' -Body ($serviceIndex | ConvertTo-Json -Depth 50) +} + +New-PSUEndpoint -Url '/nuget/query' -Description 'Searches NuGet packages.' -Method @('GET') -Endpoint { + param( + $q, + [int]$skip = 0, + [int]$take = 20, + [bool]$prerelease = $false, + $packageType + ) + + $searchResult = Search-PSUNuGetPackage -Query $q -Skip $skip -Take $take -Prerelease $prerelease -PackageType $packageType -BaseUrl (Get-PSUNuGetRequestBaseUrl -Headers $Headers) + New-PSUApiResponse -StatusCode 200 -ContentType 'application/json' -Body ($searchResult | ConvertTo-Json -Depth 50) +} + +New-PSUEndpoint -Url '/nuget/autocomplete' -Description 'Returns NuGet package ID and version autocomplete results.' -Method @('GET') -Endpoint { + param( + $q, + $id, + [int]$skip = 0, + [int]$take = 20, + [bool]$prerelease = $false + ) + + $autocompleteResult = Get-PSUNuGetAutocomplete -Query $q -Id $id -Skip $skip -Take $take -Prerelease $prerelease + New-PSUApiResponse -StatusCode 200 -ContentType 'application/json' -Body ($autocompleteResult | ConvertTo-Json -Depth 50) +} + +New-PSUEndpoint -Url '/nuget/v3-flatcontainer/:id/index.json' -Description 'Returns the NuGet flat container version index.' -Method @('GET') -Endpoint { + param($id) + + if (-not (Test-PSUNuGetSafeSegment -Value $id)) { + return New-PSUApiResponse -StatusCode 400 -Body 'The package ID is invalid.' + } + + $indexPath = Join-Path (Join-Path (Join-Path (Get-PSUNuGetFeedPath) 'v3-flatcontainer') $id.ToLowerInvariant()) 'index.json' + New-PSUNuGetJsonFileResponse -Path $indexPath +} + +New-PSUEndpoint -Url '/nuget/v3-flatcontainer/:id/:fileName' -Description 'Returns a NuGet flat container metadata file.' -Method @('GET') -Endpoint { + param($id, $fileName) + + if (-not (Test-PSUNuGetSafeSegment -Value $id) -or -not [string]::Equals($fileName, 'index.json', [StringComparison]::OrdinalIgnoreCase)) { + return New-PSUApiResponse -StatusCode 400 -Body 'The flat container path is invalid.' + } + + $indexPath = Join-Path (Join-Path (Join-Path (Get-PSUNuGetFeedPath) 'v3-flatcontainer') $id.ToLowerInvariant()) 'index.json' + New-PSUNuGetJsonFileResponse -Path $indexPath +} + +New-PSUEndpoint -Url '/nuget/v3-flatcontainer/:id/:version/:fileName' -Description 'Returns NuGet package content or manifest data from the flat container.' -Method @('GET') -Endpoint { + param($id, $version, $fileName) + + if (-not (Test-PSUNuGetSafeSegment -Value $id) -or -not (Test-PSUNuGetSafeSegment -Value $version) -or -not (Test-PSUNuGetSafeSegment -Value $fileName)) { + return New-PSUApiResponse -StatusCode 400 -Body 'The package path is invalid.' + } + + try { + $package = Get-PSUNuGetPackage -Id $id -Version $version -IncludeUnlisted | Select-Object -First 1 + } + catch [System.ArgumentException] { + return New-PSUApiResponse -StatusCode 400 -Body $_.Exception.Message + } + + if (-not $package) { + return New-PSUApiResponse -StatusCode 404 -Body "Package '$id' version '$version' was not found." + } + + $expectedPackageFileName = "$($package.LowerId).$($package.NormalizedVersion).nupkg" + $expectedNuspecFileName = "$($package.LowerId).nuspec" + if (-not [string]::Equals($fileName, $expectedPackageFileName, [StringComparison]::OrdinalIgnoreCase) -and + -not [string]::Equals($fileName, $expectedNuspecFileName, [StringComparison]::OrdinalIgnoreCase)) { + return New-PSUApiResponse -StatusCode 404 -Body 'The requested package file was not found.' + } + + $resolvedFileName = if ([string]::Equals($fileName, $expectedNuspecFileName, [StringComparison]::OrdinalIgnoreCase)) { $expectedNuspecFileName } else { $expectedPackageFileName } + $packagePath = Join-Path (Join-Path (Join-Path (Join-Path (Get-PSUNuGetFeedPath) 'v3-flatcontainer') $package.LowerId) $package.NormalizedVersion) $resolvedFileName + if (-not (Test-Path $packagePath)) { + return New-PSUApiResponse -StatusCode 404 -Body 'The requested package file was not found.' + } + + if ([string]::Equals($resolvedFileName, $expectedNuspecFileName, [StringComparison]::OrdinalIgnoreCase)) { + return New-PSUApiResponse -StatusCode 200 -ContentType 'application/xml' -Body (Get-Content -Path $packagePath -Raw) + } + + New-PSUApiResponse -StatusCode 200 -ContentType 'application/octet-stream' -Data ([IO.File]::ReadAllBytes($packagePath)) +} + +New-PSUEndpoint -Url '/nuget/v3/registration/:id/index.json' -Description 'Returns the NuGet registration index.' -Method @('GET') -Endpoint { + param($id) + + if (-not (Test-PSUNuGetSafeSegment -Value $id)) { + return New-PSUApiResponse -StatusCode 400 -Body 'The package ID is invalid.' + } + + $indexPath = Join-Path (Join-Path (Join-Path (Join-Path (Get-PSUNuGetFeedPath) 'v3') 'registration') $id.ToLowerInvariant()) 'index.json' + New-PSUNuGetJsonFileResponse -Path $indexPath +} + +New-PSUEndpoint -Url '/nuget/v3/registration/:id/:leaf' -Description 'Returns a NuGet registration leaf.' -Method @('GET') -Endpoint { + param($id, $leaf) + + if (-not (Test-PSUNuGetSafeSegment -Value $id) -or -not (Test-PSUNuGetSafeSegment -Value $leaf) -or -not $leaf.EndsWith('.json', [StringComparison]::OrdinalIgnoreCase)) { + return New-PSUApiResponse -StatusCode 400 -Body 'The registration path is invalid.' + } + + $leafPath = Join-Path (Join-Path (Join-Path (Join-Path (Get-PSUNuGetFeedPath) 'v3') 'registration') $id.ToLowerInvariant()) $leaf.ToLowerInvariant() + New-PSUNuGetJsonFileResponse -Path $leafPath +} + +New-PSUEndpoint -Url '/nuget/api/v2/package' -Description 'Publishes a NuGet package from raw .nupkg request bytes.' -Method @('PUT', 'POST') -Endpoint { + try { + $package = Publish-PSUNuGetPackage -Data $Data -BaseUrl (Get-PSUNuGetRequestBaseUrl -Headers $Headers) + New-PSUApiResponse -StatusCode 201 -ContentType 'application/json' -Body ($package | ConvertTo-Json -Depth 50) + } + catch [System.InvalidOperationException] { + New-PSUApiResponse -StatusCode 409 -Body $_.Exception.Message + } + catch [System.ArgumentException] { + New-PSUApiResponse -StatusCode 400 -Body $_.Exception.Message + } +} -Authentication -Role @('Administrator', 'NuGet Publisher') + +New-PSUEndpoint -Url '/nuget/api/v2/package/:id/:version' -Description 'Unlists or relists a NuGet package.' -Method @('DELETE', 'POST') -Endpoint { + param($id, $version) + + try { + if ($Method -eq 'DELETE') { + Set-PSUNuGetPackageListed -Id $id -Version $version -Listed $false -BaseUrl (Get-PSUNuGetRequestBaseUrl -Headers $Headers) | Out-Null + New-PSUApiResponse -StatusCode 204 + } + else { + $package = Set-PSUNuGetPackageListed -Id $id -Version $version -Listed $true -BaseUrl (Get-PSUNuGetRequestBaseUrl -Headers $Headers) + New-PSUApiResponse -StatusCode 200 -ContentType 'application/json' -Body ($package | ConvertTo-Json -Depth 50) + } + } + catch [System.IO.FileNotFoundException] { + New-PSUApiResponse -StatusCode 404 -Body $_.Exception.Message + } +} -Authentication -Role @('Administrator', 'NuGet Publisher') + +New-PSUEndpoint -Url '/nuget/package' -Description 'Lists packages in the NuGet feed catalog.' -Method @('GET') -Endpoint { + param($id, $version, [bool]$includeUnlisted = $false) + + $packages = Get-PSUNuGetPackage -Id $id -Version $version -IncludeUnlisted:$includeUnlisted + New-PSUApiResponse -StatusCode 200 -ContentType 'application/json' -Body ($packages | ConvertTo-Json -Depth 50) +} -Authentication -Role @('Administrator', 'NuGet Publisher') + +New-PSUEndpoint -Url '/nuget/package/:id/:version' -Description 'Deletes a package from the NuGet feed catalog and static package store.' -Method @('DELETE') -Endpoint { + param($id, $version) + + try { + Remove-PSUNuGetPackage -Id $id -Version $version -BaseUrl (Get-PSUNuGetRequestBaseUrl -Headers $Headers) | Out-Null + New-PSUApiResponse -StatusCode 204 + } + catch [System.IO.FileNotFoundException] { + New-PSUApiResponse -StatusCode 404 -Body $_.Exception.Message + } +} -Authentication -Role @('Administrator', 'NuGet Publisher') \ No newline at end of file diff --git a/APIs/NuGet/.universal/publishedFolders.ps1 b/APIs/NuGet/.universal/publishedFolders.ps1 new file mode 100644 index 0000000..84cd190 --- /dev/null +++ b/APIs/NuGet/.universal/publishedFolders.ps1 @@ -0,0 +1,18 @@ +$NuGetFeedPath = if ($env:PSU_NUGET_FEED_PATH) { + $env:PSU_NUGET_FEED_PATH +} +elseif ($env:Data__RepositoryPath) { + Join-Path $env:Data__RepositoryPath '.nuget' +} +else { + Join-Path (Join-Path ([Environment]::GetFolderPath('CommonApplicationData')) 'PowerShellUniversal') 'NuGet' +} + +$FlatContainerPath = Join-Path $NuGetFeedPath 'v3-flatcontainer' +$RegistrationPath = Join-Path (Join-Path $NuGetFeedPath 'v3') 'registration' + +New-Item -ItemType Directory -Path $FlatContainerPath -Force | Out-Null +New-Item -ItemType Directory -Path $RegistrationPath -Force | Out-Null + +New-PSUPublishedFolder -RequestPath '/nuget/static/v3-flatcontainer' -Path $FlatContainerPath -Name 'NuGet Flat Container' +New-PSUPublishedFolder -RequestPath '/nuget/static/v3/registration' -Path $RegistrationPath -Name 'NuGet Registration Metadata' \ No newline at end of file diff --git a/APIs/NuGet/PowerShellUniversal.NuGet.psd1 b/APIs/NuGet/PowerShellUniversal.NuGet.psd1 new file mode 100644 index 0000000..08a4c26 --- /dev/null +++ b/APIs/NuGet/PowerShellUniversal.NuGet.psd1 @@ -0,0 +1,36 @@ +@{ + RootModule = 'PowerShellUniversal.NuGet.psm1' + ModuleVersion = '1.0.0' + GUID = '8c096e3b-8c04-4a8f-9bab-3261b32d69f3' + Author = 'Devolutions, Inc.' + CompanyName = 'Devolutions, Inc.' + Copyright = '(c) Devolutions, Inc. All rights reserved.' + Description = 'A NuGet V3 package feed for PowerShell Universal.' + PowerShellVersion = '7.0' + FunctionsToExport = @( + 'Get-PSUNuGetFeedPath', + 'Get-PSUNuGetRequestBaseUrl', + 'Test-PSUNuGetSafeSegment', + 'New-PSUNuGetJsonFileResponse', + 'Initialize-PSUNuGetRepository', + 'Publish-PSUNuGetPackage', + 'Get-PSUNuGetPackage', + 'Set-PSUNuGetPackageListed', + 'Remove-PSUNuGetPackage', + 'Update-PSUNuGetStaticMetadata', + 'Get-PSUNuGetServiceIndex', + 'Search-PSUNuGetPackage', + 'Get-PSUNuGetAutocomplete' + ) + CmdletsToExport = @() + VariablesToExport = @() + AliasesToExport = @() + PrivateData = @{ + PSData = @{ + Tags = @('PowerShell', 'NuGet', 'PowerShellUniversal') + LicenseUri = 'https://github.com/devolutions/powershell-universal-gallery/blob/main/LICENSE' + ProjectUri = 'https://github.com/devolutions/powershell-universal-gallery/tree/main/APIs/NuGet' + IconUri = 'https://raw.githubusercontent.com/devolutions/powershell-universal-gallery/main/images/script.png' + } + } +} \ No newline at end of file diff --git a/APIs/NuGet/PowerShellUniversal.NuGet.psm1 b/APIs/NuGet/PowerShellUniversal.NuGet.psm1 new file mode 100644 index 0000000..7c6259e --- /dev/null +++ b/APIs/NuGet/PowerShellUniversal.NuGet.psm1 @@ -0,0 +1,893 @@ +Set-StrictMode -Version Latest + +function Get-PSUNuGetFeedPath { + [CmdletBinding()] + param() + + if ($env:PSU_NUGET_FEED_PATH) { + return $env:PSU_NUGET_FEED_PATH + } + + if ($env:Data__RepositoryPath) { + return (Join-Path $env:Data__RepositoryPath '.nuget') + } + + Join-Path (Join-Path ([Environment]::GetFolderPath('CommonApplicationData')) 'PowerShellUniversal') 'NuGet' +} + +function Get-PSUNuGetRequestBaseUrl { + [CmdletBinding()] + param( + [hashtable]$Headers + ) + + if ($env:PSU_NUGET_BASE_URL) { + return $env:PSU_NUGET_BASE_URL.TrimEnd('/') + } + + $scheme = 'http' + $hostName = $null + + if ($Headers) { + if ($Headers.ContainsKey('X-Forwarded-Proto')) { + $scheme = ($Headers['X-Forwarded-Proto'] -split ',')[0].Trim() + } + elseif ($Headers.ContainsKey('X-Forwarded-Scheme')) { + $scheme = ($Headers['X-Forwarded-Scheme'] -split ',')[0].Trim() + } + + if ($Headers.ContainsKey('X-Forwarded-Host')) { + $hostName = ($Headers['X-Forwarded-Host'] -split ',')[0].Trim() + } + elseif ($Headers.ContainsKey('Host')) { + $hostName = ($Headers['Host'] -split ',')[0].Trim() + } + } + + if (-not $hostName) { + $hostName = 'localhost:5000' + } + + $schemeSeparator = ([char]58).ToString() + '//' + '{0}{1}{2}/nuget' -f $scheme, $schemeSeparator, $hostName +} + +function Join-PSUNuGetUrl { + [CmdletBinding()] + param( + [Parameter(Mandatory)] + [string]$BaseUrl, + + [Parameter(Mandatory)] + [string[]]$Segment + ) + + $base = $BaseUrl.TrimEnd('/') + $path = ($Segment | ForEach-Object { $_.Trim('/') }) -join '/' + if ([string]::IsNullOrWhiteSpace($path)) { + return $base + } + + "$base/$path" +} + +function Test-PSUNuGetSafeSegment { + [CmdletBinding()] + param([string]$Value) + + -not [string]::IsNullOrWhiteSpace($Value) -and + $Value -match '^[A-Za-z0-9_.-]+$' -and + -not $Value.Contains('..') +} + +function New-PSUNuGetJsonFileResponse { + [CmdletBinding()] + param([string]$Path) + + if (-not (Test-Path $Path)) { + return New-PSUApiResponse -StatusCode 404 -Body 'The requested NuGet metadata file was not found.' + } + + New-PSUApiResponse -StatusCode 200 -ContentType 'application/json' -Body (Get-Content -Path $Path -Raw) +} + +function Initialize-PSUNuGetRepository { + [CmdletBinding()] + param( + [string]$Path = (Get-PSUNuGetFeedPath) + ) + + $directories = @( + $Path, + (Join-Path $Path '_catalog'), + (Join-Path $Path 'v3-flatcontainer'), + (Join-Path (Join-Path $Path 'v3') 'registration') + ) + + foreach ($directory in $directories) { + if (-not (Test-Path $directory)) { + New-Item -ItemType Directory -Path $directory -Force | Out-Null + } + } + + $catalogFile = Join-Path (Join-Path $Path '_catalog') 'packages.json' + if (-not (Test-Path $catalogFile)) { + '[]' | Set-Content -Path $catalogFile -Encoding utf8 + } + + Get-Item $Path +} + +function Get-PSUNuGetCatalogFile { + [CmdletBinding()] + param( + [string]$Path = (Get-PSUNuGetFeedPath) + ) + + Initialize-PSUNuGetRepository -Path $Path | Out-Null + Join-Path (Join-Path $Path '_catalog') 'packages.json' +} + +function Read-PSUNuGetCatalog { + [CmdletBinding()] + param( + [string]$Path = (Get-PSUNuGetFeedPath) + ) + + $catalogFile = Get-PSUNuGetCatalogFile -Path $Path + $content = Get-Content -Path $catalogFile -Raw + if ([string]::IsNullOrWhiteSpace($content)) { + return @() + } + + @($content | ConvertFrom-Json -Depth 50) +} + +function Write-PSUNuGetCatalog { + [CmdletBinding()] + param( + [Parameter(Mandatory)] + [AllowEmptyCollection()] + [object[]]$Package, + + [string]$Path = (Get-PSUNuGetFeedPath) + ) + + $catalogFile = Get-PSUNuGetCatalogFile -Path $Path + $sortedPackages = @($Package | Sort-Object LowerId, @{ Expression = { ConvertTo-PSUNuGetVersionSortKey -Version $_.NormalizedVersion } }) + ConvertTo-Json -InputObject $sortedPackages -Depth 50 | Set-Content -Path $catalogFile -Encoding utf8 +} + +function Get-PSUNuGetNuspecValue { + param( + [Parameter(Mandatory)] + [System.Xml.XmlElement]$Metadata, + + [Parameter(Mandatory)] + [string]$Name + ) + + $node = $Metadata.ChildNodes | Where-Object { $_.LocalName -eq $Name } | Select-Object -First 1 + if ($node) { + return $node.InnerText.Trim() + } + + $null +} + +function ConvertTo-PSUNuGetBoolean { + param([string]$Value) + + if ([string]::IsNullOrWhiteSpace($Value)) { + return $false + } + + [string]::Equals($Value, 'true', [StringComparison]::OrdinalIgnoreCase) +} + +function ConvertTo-PSUNuGetStringArray { + param([string]$Value) + + if ([string]::IsNullOrWhiteSpace($Value)) { + return @() + } + + @($Value -split '[,\s]+' | Where-Object { -not [string]::IsNullOrWhiteSpace($_) }) +} + +function ConvertTo-PSUNuGetNormalizedVersion { + [CmdletBinding()] + param( + [Parameter(Mandatory)] + [string]$Version + ) + + $withoutMetadata = ($Version -split '\+', 2)[0] + $versionParts = $withoutMetadata -split '-', 2 + $mainVersion = $versionParts[0] + $prerelease = if ($versionParts.Count -gt 1) { $versionParts[1].ToLowerInvariant() } else { $null } + + $numbers = @($mainVersion.Split('.') | ForEach-Object { + if ($_ -match '^\d+$') { + [int]$_ + } + else { + throw "Version '$Version' is not a valid NuGet version." + } + }) + + while ($numbers.Count -lt 3) { + $numbers += 0 + } + + while ($numbers.Count -gt 3 -and $numbers[-1] -eq 0) { + $numbers = @($numbers[0..($numbers.Count - 2)]) + } + + $normalized = ($numbers | ForEach-Object { $_.ToString().ToLowerInvariant() }) -join '.' + if ($prerelease) { + return "$normalized-$prerelease" + } + + $normalized +} + +function ConvertTo-PSUNuGetVersionSortKey { + [CmdletBinding()] + param( + [Parameter(Mandatory)] + [string]$Version + ) + + $normalized = ConvertTo-PSUNuGetNormalizedVersion -Version $Version + $parts = $normalized -split '-', 2 + $numbers = @($parts[0].Split('.') | ForEach-Object { '{0:D8}' -f [int]$_ }) + + while ($numbers.Count -lt 4) { + $numbers += '00000000' + } + + $releaseKey = if ($parts.Count -gt 1) { "0-$($parts[1])" } else { '1' } + "$(($numbers -join '.'))-$releaseKey" +} + +function Test-PSUNuGetPrereleaseVersion { + [CmdletBinding()] + param( + [Parameter(Mandatory)] + [string]$Version + ) + + $Version -match '-' +} + +function Get-PSUNuGetDependencyGroups { + param( + [Parameter(Mandatory)] + [System.Xml.XmlElement]$Metadata, + + [Parameter(Mandatory)] + [string]$BaseUrl + ) + + $dependenciesNode = $Metadata.ChildNodes | Where-Object { $_.LocalName -eq 'dependencies' } | Select-Object -First 1 + if (-not $dependenciesNode) { + return @() + } + + $groups = @() + $groupNodes = @($dependenciesNode.ChildNodes | Where-Object { $_.LocalName -eq 'group' }) + + if ($groupNodes.Count -eq 0) { + $dependencyItems = @($dependenciesNode.ChildNodes | Where-Object { $_.LocalName -eq 'dependency' }) + if ($dependencyItems.Count -gt 0) { + $groups += [ordered]@{ + dependencies = @($dependencyItems | ForEach-Object { + $dependencyId = $_.GetAttribute('id') + [ordered]@{ + id = $dependencyId + range = $_.GetAttribute('version') + registration = (Join-PSUNuGetUrl -BaseUrl $BaseUrl -Segment @('v3/registration', $dependencyId.ToLowerInvariant(), 'index.json')) + } + }) + } + } + + return $groups + } + + foreach ($groupNode in $groupNodes) { + $dependencies = @($groupNode.ChildNodes | Where-Object { $_.LocalName -eq 'dependency' } | ForEach-Object { + $dependencyId = $_.GetAttribute('id') + [ordered]@{ + id = $dependencyId + range = $_.GetAttribute('version') + registration = (Join-PSUNuGetUrl -BaseUrl $BaseUrl -Segment @('v3/registration', $dependencyId.ToLowerInvariant(), 'index.json')) + } + }) + + $group = [ordered]@{ + dependencies = $dependencies + } + + $targetFramework = $groupNode.GetAttribute('targetFramework') + if (-not [string]::IsNullOrWhiteSpace($targetFramework)) { + $group.targetFramework = $targetFramework + } + + $groups += $group + } + + $groups +} + +function Get-PSUNuGetPackageTypes { + param( + [Parameter(Mandatory)] + [System.Xml.XmlElement]$Metadata + ) + + $packageTypesNode = $Metadata.ChildNodes | Where-Object { $_.LocalName -eq 'packageTypes' } | Select-Object -First 1 + if (-not $packageTypesNode) { + return @(@{ name = 'Dependency' }) + } + + $packageTypes = @($packageTypesNode.ChildNodes | Where-Object { $_.LocalName -eq 'packageType' } | ForEach-Object { + [ordered]@{ + name = $_.GetAttribute('name') + } + }) + + if ($packageTypes.Count -eq 0) { + return @(@{ name = 'Dependency' }) + } + + $packageTypes +} + +function Get-PSUNuGetPackageMetadata { + [CmdletBinding()] + param( + [Parameter(Mandatory)] + [string]$PackagePath, + + [Parameter(Mandatory)] + [string]$BaseUrl + ) + + Add-Type -AssemblyName System.IO.Compression.FileSystem + + $archive = [System.IO.Compression.ZipFile]::OpenRead($PackagePath) + try { + $nuspecEntry = $archive.Entries | Where-Object { $_.FullName -like '*.nuspec' } | Select-Object -First 1 + if (-not $nuspecEntry) { + throw [ArgumentException]::new('The package does not contain a .nuspec file.') + } + + $reader = [System.IO.StreamReader]::new($nuspecEntry.Open()) + try { + $nuspecContent = $reader.ReadToEnd() + } + finally { + $reader.Dispose() + } + + [xml]$nuspec = $nuspecContent + $metadata = $nuspec.DocumentElement.ChildNodes | Where-Object { $_.LocalName -eq 'metadata' } | Select-Object -First 1 + if (-not $metadata) { + throw [ArgumentException]::new('The package .nuspec file does not contain metadata.') + } + + $id = Get-PSUNuGetNuspecValue -Metadata $metadata -Name 'id' + $version = Get-PSUNuGetNuspecValue -Metadata $metadata -Name 'version' + + if ([string]::IsNullOrWhiteSpace($id) -or [string]::IsNullOrWhiteSpace($version)) { + throw [ArgumentException]::new('The package .nuspec file must contain id and version metadata.') + } + + [ordered]@{ + Id = $id + Version = $version + LowerId = $id.ToLowerInvariant() + NormalizedVersion = ConvertTo-PSUNuGetNormalizedVersion -Version $version + Authors = ConvertTo-PSUNuGetStringArray -Value (Get-PSUNuGetNuspecValue -Metadata $metadata -Name 'authors') + Owners = ConvertTo-PSUNuGetStringArray -Value (Get-PSUNuGetNuspecValue -Metadata $metadata -Name 'owners') + Description = Get-PSUNuGetNuspecValue -Metadata $metadata -Name 'description' + Summary = Get-PSUNuGetNuspecValue -Metadata $metadata -Name 'summary' + Tags = ConvertTo-PSUNuGetStringArray -Value (Get-PSUNuGetNuspecValue -Metadata $metadata -Name 'tags') + Title = Get-PSUNuGetNuspecValue -Metadata $metadata -Name 'title' + ProjectUrl = Get-PSUNuGetNuspecValue -Metadata $metadata -Name 'projectUrl' + IconUrl = Get-PSUNuGetNuspecValue -Metadata $metadata -Name 'iconUrl' + LicenseUrl = Get-PSUNuGetNuspecValue -Metadata $metadata -Name 'licenseUrl' + LicenseExpression = Get-PSUNuGetNuspecValue -Metadata $metadata -Name 'license' + Language = Get-PSUNuGetNuspecValue -Metadata $metadata -Name 'language' + MinClientVersion = $metadata.GetAttribute('minClientVersion') + RequireLicenseAcceptance = ConvertTo-PSUNuGetBoolean -Value (Get-PSUNuGetNuspecValue -Metadata $metadata -Name 'requireLicenseAcceptance') + DependencyGroups = Get-PSUNuGetDependencyGroups -Metadata $metadata -BaseUrl $BaseUrl + PackageTypes = Get-PSUNuGetPackageTypes -Metadata $metadata + NuspecContent = $nuspecContent + } + } + finally { + $archive.Dispose() + } +} + +function New-PSUNuGetCatalogEntry { + [CmdletBinding()] + param( + [Parameter(Mandatory)] + [object]$Package, + + [Parameter(Mandatory)] + [string]$BaseUrl + ) + + $flatContainerUrl = Join-PSUNuGetUrl -BaseUrl $BaseUrl -Segment @('v3-flatcontainer', $Package.LowerId, $Package.NormalizedVersion, "$($Package.LowerId).$($Package.NormalizedVersion).nupkg") + $catalogEntry = [ordered]@{ + '@id' = (Join-PSUNuGetUrl -BaseUrl $BaseUrl -Segment @('v3/registration', $Package.LowerId, "$($Package.NormalizedVersion).json")) + '@type' = 'PackageDetails' + authors = @($Package.Authors) + dependencyGroups = @($Package.DependencyGroups) + description = $Package.Description + iconUrl = $Package.IconUrl + id = $Package.Id + language = $Package.Language + licenseUrl = $Package.LicenseUrl + listed = [bool]$Package.Listed + minClientVersion = $Package.MinClientVersion + packageContent = $flatContainerUrl + packageTypes = @($Package.PackageTypes) + projectUrl = $Package.ProjectUrl + published = if ($Package.Listed) { $Package.Published } else { '1900-01-01T00:00:00Z' } + requireLicenseAcceptance = [bool]$Package.RequireLicenseAcceptance + summary = $Package.Summary + tags = @($Package.Tags) + title = $Package.Title + version = $Package.Version + } + + if ($Package.LicenseExpression) { + $catalogEntry.licenseExpression = $Package.LicenseExpression + } + + $catalogEntry +} + +function New-PSUNuGetRegistrationLeaf { + [CmdletBinding()] + param( + [Parameter(Mandatory)] + [object]$Package, + + [Parameter(Mandatory)] + [string]$BaseUrl + ) + + $leafUrl = Join-PSUNuGetUrl -BaseUrl $BaseUrl -Segment @('v3/registration', $Package.LowerId, "$($Package.NormalizedVersion).json") + $packageContentUrl = Join-PSUNuGetUrl -BaseUrl $BaseUrl -Segment @('v3-flatcontainer', $Package.LowerId, $Package.NormalizedVersion, "$($Package.LowerId).$($Package.NormalizedVersion).nupkg") + [ordered]@{ + '@id' = $leafUrl + '@type' = 'Package' + catalogEntry = (New-PSUNuGetCatalogEntry -Package $Package -BaseUrl $BaseUrl) + listed = [bool]$Package.Listed + packageContent = $packageContentUrl + published = if ($Package.Listed) { $Package.Published } else { '1900-01-01T00:00:00Z' } + registration = (Join-PSUNuGetUrl -BaseUrl $BaseUrl -Segment @('v3/registration', $Package.LowerId, 'index.json')) + } +} + +function Update-PSUNuGetStaticMetadata { + [CmdletBinding()] + param( + [string]$Path = (Get-PSUNuGetFeedPath), + + [string]$BaseUrl = $(if ($env:PSU_NUGET_BASE_URL) { $env:PSU_NUGET_BASE_URL } else { 'http://localhost:5000/nuget' }) + ) + + Initialize-PSUNuGetRepository -Path $Path | Out-Null + $packages = @(Read-PSUNuGetCatalog -Path $Path) + $registrationRoot = Join-Path (Join-Path $Path 'v3') 'registration' + + if (Test-Path $registrationRoot) { + Remove-Item -Path $registrationRoot -Recurse -Force + } + New-Item -ItemType Directory -Path $registrationRoot -Force | Out-Null + + foreach ($packageGroup in ($packages | Group-Object LowerId)) { + $lowerId = $packageGroup.Name + $sortedPackages = @($packageGroup.Group | Sort-Object @{ Expression = { ConvertTo-PSUNuGetVersionSortKey -Version $_.NormalizedVersion } }) + + $flatPackagePath = Join-Path (Join-Path $Path 'v3-flatcontainer') $lowerId + if (-not (Test-Path $flatPackagePath)) { + New-Item -ItemType Directory -Path $flatPackagePath -Force | Out-Null + } + + [ordered]@{ + versions = @($sortedPackages | ForEach-Object { $_.NormalizedVersion }) + } | ConvertTo-Json -Depth 10 | Set-Content -Path (Join-Path $flatPackagePath 'index.json') -Encoding utf8 + + $registrationPackagePath = Join-Path $registrationRoot $lowerId + New-Item -ItemType Directory -Path $registrationPackagePath -Force | Out-Null + + $leaves = @($sortedPackages | ForEach-Object { + $leaf = New-PSUNuGetRegistrationLeaf -Package $_ -BaseUrl $BaseUrl + $leaf | ConvertTo-Json -Depth 50 | Set-Content -Path (Join-Path $registrationPackagePath "$($_.NormalizedVersion).json") -Encoding utf8 + $leaf + }) + + if ($leaves.Count -gt 0) { + $lowerVersion = $sortedPackages[0].NormalizedVersion + $upperVersion = $sortedPackages[-1].NormalizedVersion + [ordered]@{ + count = 1 + items = @( + [ordered]@{ + '@id' = "$(Join-PSUNuGetUrl -BaseUrl $BaseUrl -Segment @('v3/registration', $lowerId, 'index.json'))#page/$lowerVersion/$upperVersion" + count = $leaves.Count + items = $leaves + lower = $lowerVersion + upper = $upperVersion + } + ) + } | ConvertTo-Json -Depth 50 | Set-Content -Path (Join-Path $registrationPackagePath 'index.json') -Encoding utf8 + } + } +} + +function Publish-PSUNuGetPackage { + [CmdletBinding()] + param( + [byte[]]$Data, + + [string]$PackagePath, + + [string]$Path = (Get-PSUNuGetFeedPath), + + [string]$BaseUrl = $(if ($env:PSU_NUGET_BASE_URL) { $env:PSU_NUGET_BASE_URL } else { 'http://localhost:5000/nuget' }), + + [switch]$Force + ) + + Initialize-PSUNuGetRepository -Path $Path | Out-Null + + $temporaryPackage = $null + if (-not $PackagePath) { + if (-not $Data -or $Data.Length -eq 0) { + throw [ArgumentException]::new('The request did not include package bytes. Send the .nupkg as the raw request body.') + } + + $temporaryPackage = Join-Path ([IO.Path]::GetTempPath()) "$([Guid]::NewGuid()).nupkg" + [IO.File]::WriteAllBytes($temporaryPackage, $Data) + $PackagePath = $temporaryPackage + } + + try { + $metadata = Get-PSUNuGetPackageMetadata -PackagePath $PackagePath -BaseUrl $BaseUrl + $catalog = @(Read-PSUNuGetCatalog -Path $Path) + $existing = $catalog | Where-Object { $_.LowerId -eq $metadata.LowerId -and $_.NormalizedVersion -eq $metadata.NormalizedVersion } | Select-Object -First 1 + if ($existing -and -not $Force) { + throw [InvalidOperationException]::new("Package '$($metadata.Id)' version '$($metadata.Version)' already exists.") + } + + $packageDirectory = Join-Path (Join-Path (Join-Path $Path 'v3-flatcontainer') $metadata.LowerId) $metadata.NormalizedVersion + if (-not (Test-Path $packageDirectory)) { + New-Item -ItemType Directory -Path $packageDirectory -Force | Out-Null + } + + $nupkgPath = Join-Path $packageDirectory "$($metadata.LowerId).$($metadata.NormalizedVersion).nupkg" + $nuspecPath = Join-Path $packageDirectory "$($metadata.LowerId).nuspec" + Copy-Item -Path $PackagePath -Destination $nupkgPath -Force + $metadata.NuspecContent | Set-Content -Path $nuspecPath -Encoding utf8 + + $packageRecord = [ordered]@{ + Id = $metadata.Id + Version = $metadata.Version + LowerId = $metadata.LowerId + NormalizedVersion = $metadata.NormalizedVersion + Listed = $true + Published = [DateTime]::UtcNow.ToString('o') + Authors = @($metadata.Authors) + Owners = @($metadata.Owners) + Description = $metadata.Description + Summary = $metadata.Summary + Tags = @($metadata.Tags) + Title = $metadata.Title + ProjectUrl = $metadata.ProjectUrl + IconUrl = $metadata.IconUrl + LicenseUrl = $metadata.LicenseUrl + LicenseExpression = $metadata.LicenseExpression + Language = $metadata.Language + MinClientVersion = $metadata.MinClientVersion + RequireLicenseAcceptance = [bool]$metadata.RequireLicenseAcceptance + DependencyGroups = @($metadata.DependencyGroups) + PackageTypes = @($metadata.PackageTypes) + } + + $catalog = @($catalog | Where-Object { -not ($_.LowerId -eq $metadata.LowerId -and $_.NormalizedVersion -eq $metadata.NormalizedVersion) }) + $catalog += [PSCustomObject]$packageRecord + Write-PSUNuGetCatalog -Package $catalog -Path $Path + Update-PSUNuGetStaticMetadata -Path $Path -BaseUrl $BaseUrl + + [PSCustomObject]$packageRecord + } + finally { + if ($temporaryPackage -and (Test-Path $temporaryPackage)) { + Remove-Item $temporaryPackage -Force + } + } +} + +function Get-PSUNuGetPackage { + [CmdletBinding()] + param( + [string]$Id, + + [string]$Version, + + [switch]$IncludeUnlisted, + + [string]$Path = (Get-PSUNuGetFeedPath) + ) + + $packages = @(Read-PSUNuGetCatalog -Path $Path) + if ($Id) { + $lowerId = $Id.ToLowerInvariant() + $packages = @($packages | Where-Object { $_.LowerId -eq $lowerId }) + } + + if ($Version) { + $normalizedVersion = ConvertTo-PSUNuGetNormalizedVersion -Version $Version + $packages = @($packages | Where-Object { $_.NormalizedVersion -eq $normalizedVersion }) + } + + if (-not $IncludeUnlisted) { + $packages = @($packages | Where-Object { $_.Listed }) + } + + $packages | Sort-Object LowerId, @{ Expression = { ConvertTo-PSUNuGetVersionSortKey -Version $_.NormalizedVersion } } +} + +function Set-PSUNuGetPackageListed { + [CmdletBinding()] + param( + [Parameter(Mandatory)] + [string]$Id, + + [Parameter(Mandatory)] + [string]$Version, + + [Parameter(Mandatory)] + [bool]$Listed, + + [string]$Path = (Get-PSUNuGetFeedPath), + + [string]$BaseUrl = $(if ($env:PSU_NUGET_BASE_URL) { $env:PSU_NUGET_BASE_URL } else { 'http://localhost:5000/nuget' }) + ) + + $lowerId = $Id.ToLowerInvariant() + $normalizedVersion = ConvertTo-PSUNuGetNormalizedVersion -Version $Version + $catalog = @(Read-PSUNuGetCatalog -Path $Path) + $package = $catalog | Where-Object { $_.LowerId -eq $lowerId -and $_.NormalizedVersion -eq $normalizedVersion } | Select-Object -First 1 + if (-not $package) { + throw [System.IO.FileNotFoundException]::new("Package '$Id' version '$Version' was not found.") + } + + $package.Listed = $Listed + Write-PSUNuGetCatalog -Package $catalog -Path $Path + Update-PSUNuGetStaticMetadata -Path $Path -BaseUrl $BaseUrl + $package +} + +function Remove-PSUNuGetPackage { + [CmdletBinding()] + param( + [Parameter(Mandatory)] + [string]$Id, + + [Parameter(Mandatory)] + [string]$Version, + + [string]$Path = (Get-PSUNuGetFeedPath), + + [string]$BaseUrl = $(if ($env:PSU_NUGET_BASE_URL) { $env:PSU_NUGET_BASE_URL } else { 'http://localhost:5000/nuget' }) + ) + + $lowerId = $Id.ToLowerInvariant() + $normalizedVersion = ConvertTo-PSUNuGetNormalizedVersion -Version $Version + $catalog = @(Read-PSUNuGetCatalog -Path $Path) + $package = $catalog | Where-Object { $_.LowerId -eq $lowerId -and $_.NormalizedVersion -eq $normalizedVersion } | Select-Object -First 1 + if (-not $package) { + throw [System.IO.FileNotFoundException]::new("Package '$Id' version '$Version' was not found.") + } + + $catalog = @($catalog | Where-Object { -not ($_.LowerId -eq $lowerId -and $_.NormalizedVersion -eq $normalizedVersion) }) + $flatIdDirectory = Join-Path (Join-Path $Path 'v3-flatcontainer') $lowerId + $packageDirectory = Join-Path $flatIdDirectory $normalizedVersion + if (Test-Path $packageDirectory) { + Remove-Item -Path $packageDirectory -Recurse -Force + } + + if (-not ($catalog | Where-Object { $_.LowerId -eq $lowerId }) -and (Test-Path $flatIdDirectory)) { + Remove-Item -Path $flatIdDirectory -Recurse -Force + } + + Write-PSUNuGetCatalog -Package $catalog -Path $Path + Update-PSUNuGetStaticMetadata -Path $Path -BaseUrl $BaseUrl + $package +} + +function Get-PSUNuGetServiceIndex { + [CmdletBinding()] + param( + [string]$BaseUrl = $(if ($env:PSU_NUGET_BASE_URL) { $env:PSU_NUGET_BASE_URL } else { 'http://localhost:5000/nuget' }) + ) + + [ordered]@{ + version = '3.0.0' + resources = @( + [ordered]@{ + '@id' = (Join-PSUNuGetUrl -BaseUrl $BaseUrl -Segment @('v3-flatcontainer')) + '/' + '@type' = 'PackageBaseAddress/3.0.0' + }, + [ordered]@{ + '@id' = (Join-PSUNuGetUrl -BaseUrl $BaseUrl -Segment @('api/v2/package')) + '@type' = 'PackagePublish/2.0.0' + }, + [ordered]@{ + '@id' = (Join-PSUNuGetUrl -BaseUrl $BaseUrl -Segment @('v3/registration')) + '/' + '@type' = 'RegistrationsBaseUrl/3.6.0' + }, + [ordered]@{ + '@id' = (Join-PSUNuGetUrl -BaseUrl $BaseUrl -Segment @('query')) + '@type' = 'SearchQueryService/3.5.0' + }, + [ordered]@{ + '@id' = (Join-PSUNuGetUrl -BaseUrl $BaseUrl -Segment @('autocomplete')) + '@type' = 'SearchAutocompleteService/3.5.0' + } + ) + } +} + +function Search-PSUNuGetPackage { + [CmdletBinding()] + param( + [Alias('q')] + [string]$Query, + + [int]$Skip = 0, + + [int]$Take = 20, + + [bool]$Prerelease = $false, + + [string]$PackageType, + + [string]$Path = (Get-PSUNuGetFeedPath), + + [string]$BaseUrl = $(if ($env:PSU_NUGET_BASE_URL) { $env:PSU_NUGET_BASE_URL } else { 'http://localhost:5000/nuget' }) + ) + + if ($Take -lt 1) { $Take = 20 } + if ($Take -gt 1000) { $Take = 1000 } + if ($Skip -lt 0) { $Skip = 0 } + + $packages = @(Get-PSUNuGetPackage -IncludeUnlisted:$false -Path $Path) + if (-not $Prerelease) { + $packages = @($packages | Where-Object { -not (Test-PSUNuGetPrereleaseVersion -Version $_.NormalizedVersion) }) + } + + if ($PackageType) { + $packages = @($packages | Where-Object { @($_.PackageTypes).Name -contains $PackageType }) + } + + if ($Query) { + $packages = @($packages | Where-Object { + $_.Id -like "*$Query*" -or + $_.Title -like "*$Query*" -or + $_.Description -like "*$Query*" -or + (@($_.Tags) -join ' ') -like "*$Query*" + }) + } + + $results = @($packages | Group-Object LowerId | ForEach-Object { + $packageVersions = @($_.Group | Sort-Object @{ Expression = { ConvertTo-PSUNuGetVersionSortKey -Version $_.NormalizedVersion } }) + $latestPackage = $packageVersions[-1] + [ordered]@{ + '@id' = (Join-PSUNuGetUrl -BaseUrl $BaseUrl -Segment @('v3/registration', $latestPackage.LowerId, 'index.json')) + '@type' = 'Package' + registration = (Join-PSUNuGetUrl -BaseUrl $BaseUrl -Segment @('v3/registration', $latestPackage.LowerId, 'index.json')) + id = $latestPackage.Id + version = $latestPackage.Version + description = $latestPackage.Description + summary = $latestPackage.Summary + title = $latestPackage.Title + iconUrl = $latestPackage.IconUrl + licenseUrl = $latestPackage.LicenseUrl + projectUrl = $latestPackage.ProjectUrl + tags = @($latestPackage.Tags) + authors = @($latestPackage.Authors) + owners = @($latestPackage.Owners) + totalDownloads = 0 + verified = $false + packageTypes = @($latestPackage.PackageTypes) + versions = @($packageVersions | ForEach-Object { + [ordered]@{ + '@id' = (Join-PSUNuGetUrl -BaseUrl $BaseUrl -Segment @('v3/registration', $_.LowerId, "$($_.NormalizedVersion).json")) + version = $_.Version + downloads = 0 + } + }) + } + }) + + [ordered]@{ + totalHits = $results.Count + data = @($results | Select-Object -Skip $Skip -First $Take) + } +} + +function Get-PSUNuGetAutocomplete { + [CmdletBinding()] + param( + [Alias('q')] + [string]$Query, + + [string]$Id, + + [int]$Skip = 0, + + [int]$Take = 20, + + [bool]$Prerelease = $false, + + [string]$Path = (Get-PSUNuGetFeedPath) + ) + + if ($Take -lt 1) { $Take = 20 } + if ($Take -gt 1000) { $Take = 1000 } + if ($Skip -lt 0) { $Skip = 0 } + + $packages = @(Get-PSUNuGetPackage -IncludeUnlisted:$false -Path $Path) + if (-not $Prerelease) { + $packages = @($packages | Where-Object { -not (Test-PSUNuGetPrereleaseVersion -Version $_.NormalizedVersion) }) + } + + if ($Id) { + $lowerId = $Id.ToLowerInvariant() + return [ordered]@{ + data = @($packages | + Where-Object { $_.LowerId -eq $lowerId } | + Sort-Object @{ Expression = { ConvertTo-PSUNuGetVersionSortKey -Version $_.NormalizedVersion } } | + ForEach-Object { $_.Version }) + } + } + + $packageIds = @($packages | + Group-Object LowerId | + ForEach-Object { $_.Group[0].Id } | + Where-Object { -not $Query -or $_ -like "*$Query*" } | + Sort-Object) + + [ordered]@{ + totalHits = $packageIds.Count + data = @($packageIds | Select-Object -Skip $Skip -First $Take) + } +} + +Export-ModuleMember -Function @( + 'Get-PSUNuGetFeedPath', + 'Get-PSUNuGetRequestBaseUrl', + 'Test-PSUNuGetSafeSegment', + 'New-PSUNuGetJsonFileResponse', + 'Initialize-PSUNuGetRepository', + 'Publish-PSUNuGetPackage', + 'Get-PSUNuGetPackage', + 'Set-PSUNuGetPackageListed', + 'Remove-PSUNuGetPackage', + 'Update-PSUNuGetStaticMetadata', + 'Get-PSUNuGetServiceIndex', + 'Search-PSUNuGetPackage', + 'Get-PSUNuGetAutocomplete' +) diff --git a/APIs/NuGet/README.md b/APIs/NuGet/README.md new file mode 100644 index 0000000..1e61536 --- /dev/null +++ b/APIs/NuGet/README.md @@ -0,0 +1,63 @@ +# PowerShell Universal NuGet API + +This module provides a small NuGet V3 package feed for PowerShell Universal. It stores packages on disk, generates flat-container and registration metadata, and exposes NuGet-compatible discovery, search, autocomplete, package retrieval, unlist, relist, and management endpoints. + +## Endpoints + +- `GET /nuget/v3/index.json` - NuGet V3 service index. +- `GET /nuget/v3-flatcontainer/{id-lower}/index.json` - Package version list. +- `GET /nuget/v3-flatcontainer/{id-lower}/{version-lower}/{id-lower}.{version-lower}.nupkg` - Package content. +- `GET /nuget/v3-flatcontainer/{id-lower}/{version-lower}/{id-lower}.nuspec` - Package manifest. +- `GET /nuget/v3/registration/{id-lower}/index.json` - Registration metadata. +- `GET /nuget/v3/registration/{id-lower}/{version-lower}.json` - Registration leaf metadata. +- `GET /nuget/static/v3-flatcontainer/...` and `GET /nuget/static/v3/registration/...` - Published-folder mounts for generated package and metadata files. +- `GET /nuget/query` - NuGet V3 search. +- `GET /nuget/autocomplete` - NuGet V3 package ID and version autocomplete. +- `PUT|POST /nuget/api/v2/package` - Publish raw `.nupkg` bytes. Requires PSU authentication and the `Administrator` or `NuGet Publisher` role. +- `DELETE /nuget/api/v2/package/{id}/{version}` - Unlist a package. Requires PSU authentication and the `Administrator` or `NuGet Publisher` role. +- `POST /nuget/api/v2/package/{id}/{version}` - Relist a package. Requires PSU authentication and the `Administrator` or `NuGet Publisher` role. +- `GET /nuget/package` - List catalog packages. Requires PSU authentication and the `Administrator` or `NuGet Publisher` role. +- `DELETE /nuget/package/{id}/{version}` - Hard-delete package content and catalog metadata. Requires PSU authentication and the `Administrator` or `NuGet Publisher` role. + +## Configuration + +Set these environment variables before starting PowerShell Universal when you need to override defaults. + +- `PSU_NUGET_FEED_PATH` - Directory used to store packages, catalog data, and generated metadata. Defaults to `.nuget` under the PSU repository path when available, otherwise `%ProgramData%\PowerShellUniversal\NuGet`. +- `PSU_NUGET_BASE_URL` - Public feed root URL, such as `https://psu.contoso.com/nuget`. The service index can infer this from request headers, but static registration metadata is generated with this value during package publish. + +## Publishing + +PowerShell Universal endpoints do not support `multipart/form-data` file uploads. Because standard `nuget.exe push` sends packages as multipart form data, publish with raw package bytes instead. + +```powershell +$token = '' +$publish = 'https://psu.contoso.com/nuget/api/v2/package' + +Invoke-WebRequest -Uri $publish -Method Put -InFile .\MyPackage.1.0.0.nupkg -ContentType 'application/octet-stream' -Headers @{ + Authorization = "Bearer $token" +} +``` + +After publishing, clients can restore or install from the V3 source. + +```powershell +dotnet nuget add source https://psu.contoso.com/nuget/v3/index.json -n PSU +nuget.exe install MyPackage -Source https://psu.contoso.com/nuget/v3/index.json +``` + +```powershell +Register-PSResourceRepository -Name PSU -Uri https://psu.contoso.com/nuget/v3/index.json -Trusted +Install-PSResource -Name MyPackage -Repository PSU +``` + +## Maintenance + +You can use the module directly to initialize or repair metadata. + +```powershell +Import-Module PowerShellUniversal.NuGet +Initialize-PSUNuGetRepository +Update-PSUNuGetStaticMetadata -BaseUrl 'https://psu.contoso.com/nuget' +Get-PSUNuGetPackage -IncludeUnlisted +``` \ No newline at end of file