Last active
June 13, 2019 22:32
-
-
Save jborean93/e0cb0e3aabeaa1701e41f2304b023366 to your computer and use it in GitHub Desktop.
Installs a PowerShell module from a Nupkg URI on systems that don't have PowerShellGet installed
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
# Copyright: (c) 2019, Jordan Borean (@jborean93) <jborean93@gmail.com> | |
# MIT License (see LICENSE or https://opensource.org/licenses/MIT) | |
<# | |
The cmdlets in this script can be used to install a PowerShell module from a nupkg as well as some logic to get the | |
nupkg URI from either the PowerShell Gallery or a GitHub release asset. The PowerShell Gallery is the most reliable | |
function to use as a nupkg is guaranteed to be there and a GitHub release must have explicitly added the nupkg itself. | |
You can run this by doing: | |
# Load the cmdlets | |
./Install-ModuleNupkg.ps1 | |
$pester_uri Get-PSGalleryNupkgUri -Name Pester | |
Install-PowerShellNupkg -Uri $pester_uri | |
There are many more options that can be set to change the behaviour of the install process but the default is to | |
install to the CurrentUsers PSModulePath directory. | |
Unfortunately this script does not automatically get dependencies. They will have to be manually specified and | |
installed separately. | |
#> | |
# Enable TLS1.1/TLS1.2 if they're available but disabled (eg. .NET 4.5) | |
$security_protocols = [Net.ServicePointManager]::SecurityProtocol -bor [Net.SecurityProtocolType]::SystemDefault | |
if ([Net.SecurityProtocolType].GetMember("Tls11").Count -gt 0) { | |
$security_protocols = $security_protocols -bor [Net.SecurityProtocolType]::Tls11 | |
} | |
if ([Net.SecurityProtocolType].GetMember("Tls12").Count -gt 0) { | |
$security_protocols = $security_protocols -bor [Net.SecurityProtocolType]::Tls12 | |
} | |
[Net.ServicePointManager]::SecurityProtocol = $security_protocols | |
Function Get-GitHubNupkgUri { | |
<# | |
.SYNOPSIS | |
Get the URI of the nupkg in a GitHub release. | |
.DESCRIPTION | |
Get the URI of the nupkg that is stored in a GitHub release. This URI can then be downloaded separately and | |
installed on the server using 'Install-PowerShellNupkg'. | |
.PARAMETER Account | |
The GitHub account the repo sits under. | |
.PARAMETER Name | |
The GitHub repo name. | |
.PARAMETER Tag | |
Optional tag to get the release artifact for. If omitted then the latest version will be retrieved. The value | |
should match the actual GitHub tag of the release. | |
.EXAMPLE Get the latest nupkg URI | |
Get-GitHubNupkgUri -Account jborean93 -Name PSPrivilege | |
.EXAMPLE Get the nupkg URI for a particular release | |
Get-GitHubNupkgUri -Account jborean93 -Name PSPrivilege -Tag v0.1.0 | |
.NOTES | |
This cmdlet will fail if no .nupkg has been stored as an asset for the GitHub release requested. The file name is | |
not important, only the extension must be '.nupkg'. | |
#> | |
[OutputType([System.String])] | |
[CmdletBinding()] | |
Param ( | |
[Parameter(Mandatory=$true)] | |
[System.String] | |
$Account, | |
[Parameter(Mandatory=$true)] | |
[System.String] | |
$Name, | |
[System.String] | |
$Tag = 'latest' | |
) | |
if ($Tag -eq 'latest') { | |
$release_uri = "https://api.github.com/repos/$Account/$Name/releases/latest" | |
} else { | |
$release_uri = "https://api.github.com/repos/$Account/$Name/releases/tags/$Tag" | |
} | |
try { | |
Write-Verbose -Message "Getting GitHub release asserts from '$release_uri'" | |
$release = Invoke-RestMethod -Uri $release_uri -ErrorAction Stop | |
} catch { | |
$msg = "Failed to find GitHub release for '$Account/$Name' release '$Tag': $($_.Exception.Message)" | |
Write-Error -Message $msg | |
return | |
} | |
$nupkg_asset = $release.assets | Where-Object { $_.name.EndsWith('.nupkg') } | |
if ($null -eq $nupkg_asset) { | |
Write-Error -Message "Failed to find .nupkg release asset for tagged release: '$($release.tag_name)'" | |
return | |
} | |
Write-Verbose -Message "Found nupkg download URI '$($nupkg_asset.browser_download_url)'" | |
return $nupkg_asset.browser_download_url | |
} | |
Function Get-PSGalleryNupkgUri { | |
<# | |
.SYNOPSIS | |
Get the download URI for a PowerShell Gallery package. | |
.DESCRIPTION | |
Get the download URI for a PowerShell Gallery package which can then be downloaded and installed onto the local | |
host. | |
.PARAMETER Name | |
The name of the package to search for. | |
.PARAMETER Version | |
The version of the package to search for, defaults to the latest version. | |
.EXAMPLE Get the nupkg download URI for the latest PSPrivileges package | |
Get-PSGalleryNupkgUri -Name PSPrivilege | |
.EXAMPLE Get the nupkg download URI for the PSPrivileges package at 0.1.4 | |
Get-PSGalleryNupkgUri -Name PSPrivilege -Version 0.1.4 | |
#> | |
[OutputType([System.String])] | |
[CmdletBinding()] | |
Param ( | |
[System.String] | |
$Name, | |
[System.String] | |
$Version = 'latest' | |
) | |
if ($Version -eq 'latest') { | |
$version_filter = 'IsLatestVersion' | |
} else { | |
$version_filter = "Version eq '$Version'" | |
} | |
$search_uri = "https://www.powershellgallery.com/api/v2/Packages?`$filter=Id eq '$Name' and $version_filter" | |
try { | |
$gallery_meta = Invoke-RestMethod -Uri $search_uri -ErrorAction Stop | |
} catch { | |
$msg = "Failed to find PowerShell Gallery release for '$Name' at version '$Version': $($_.Exception.Message)" | |
Write-Error -Message $msg | |
return | |
} | |
if ($null -eq $gallery_meta) { | |
Write-Error -Message "Failed to find PSGallery package info for $Name with the version $Version" | |
return | |
} | |
return $gallery_meta.Content.src | |
} | |
Function Install-PowerShellNupkg { | |
<# | |
.SYNOPSIS | |
Installs a PowerShell module as a nupkg. | |
.DESCRIPTION | |
Installs the PowerShell module nupkg specified by the URI on the local system. This module can be set up in the | |
path specified by using -Path, or in the PSModule path controlled by -Scope. The default is to install the module | |
in the CurrentUser scope so it is picked up automatically by PowerShell. | |
.PARAMETER Uri | |
The URI of the nupkg file to download and install. You can use Get-GitHubNupkgUri to get the URI to the latest | |
nupkg stored on a GitHub release. Otherwise this can be the URI to PowerShell Gallery nupkg for the version | |
specified. | |
.PARAMETER Scope | |
Used instead of -Path, determines the PSModulePath that module is installed to. Can be set to 'CurrentUser' or | |
'AllUsers' and defaults to 'CurrentUser'. Admin privileges are typically required when installing a module to the | |
'AllUsers' location. | |
.PARAMETER Path | |
Instead of installing it into the PSModulePath, the -Path parameter can be used to install the module in the | |
specified path. This should be a directory where module directory is installed to. | |
.PARAMETER Force | |
Overwrite an existing module if it already exists. If not set and an existing module is there, the cmdlet will | |
fail. | |
.PARAMETER PassThru | |
Output the module details back to the caller. You can use the 'ModuleBase' property to determine where the module | |
was installed to and remove it manually if need be. There are other properties you can also use. | |
.EXAMPLE Install a module into the CurrentUser PSModulePath | |
$nupkg_uri = 'https://psg-prod-eastus.azureedge.net/packages/psprivilege.0.1.0.nupkg' | |
Install-PowerShellNupkg -Uri $nupkg_uri | |
.EXAMPLE Install a module into the AllUsers PSModulePath | |
$nupkg_uri = 'https://psg-prod-eastus.azureedge.net/packages/psprivilege.0.1.0.nupkg' | |
Install-PowerShellNupkg -Uri $nupkg_uri -Scope Allusers | |
.EXAMPLE Install a module to C:\temp\PSPrivilege | |
$nupkg_uri = Get-GitHubNupkgUri -Account jborean93 -Name PSPrivilege | |
Install-PowerShellNupkg -Uri $nupkg_uri -Path C:\temp | |
.NOTES | |
For ease of use, this module will unblock the files so they are not seem by Windows as downloaded from the internet | |
and can be loaded easily. | |
#> | |
[OutputType([System.Management.AUtomation.PSModuleInfo])] | |
[CmdletBinding(DefaultParameterSetName='Install')] | |
Param ( | |
[Parameter(Mandatory=$true)] | |
[System.Uri] | |
$Uri, | |
[Parameter(ParameterSetName='Install')] | |
[ValidateSet('AllUsers', 'CurrentUser')] | |
[System.String] | |
$Scope = 'CurrentUser', | |
[Parameter(ParameterSetName='Path')] | |
[System.String] | |
$Path, | |
[Switch] | |
$Force, | |
[Switch] | |
$PassThru | |
) | |
if ($PSCmdlet.ParameterSetName -eq 'Path') { | |
# Resolve the Path so it become the absolute path based on the current PS Path. | |
$Path = $ExecutionContext.SessionState.Path.GetUnresolvedProviderPathFromPSPath($Path) | |
Write-Verbose -Message "Setting the resolved PSModule path to install to as '$Path'" | |
} else { | |
# Logic for these paths have been derived from PowerShellGet. | |
if ((-not (Get-Variable -Name IsWindows -ErrorAction Ignore)) -or $IsWindows) { | |
try { | |
$documents_path = [System.Environment]::GetFolderPath('MyDocuments') | |
} catch { | |
$documents_path = Join-Path -Path $env:USERPROFILE -ChildPath 'Documents' | |
} | |
if ($PSVersionTable.ContainsKey('PSEdition') -and $PSVersionTable.PSEdition -eq 'Core') { | |
Write-Verbose -Message "Determining PSModule path options for Windows on PowerShell Core" | |
$module_folder = 'PowerShell' | |
} else { | |
Write-Verbose -Message "Determining PSModule path options for Windows on PowerShell Desktop" | |
$module_folder = 'WindowsPowerShell' | |
} | |
$all_users_pspath = Join-Path -Path $env:ProgramFiles -ChildPath $module_folder | |
$current_users_pspath = Join-Path -Path $documents_path -ChildPath $module_folder | |
} else { | |
Write-Verbose -Message "Determining PSModule path options for non-Windows" | |
$all_users_pspath = Split-Path -Path ([System.Management.Automation.Platform]::SelectProductNameForDirectory('SHARED_MODULES')) -Parent | |
$current_users_pspath = Split-Path -Path ([System.Management.Automation.Platform]::SelectProductNameForDirectory('USER_MODULES')) -Parent | |
} | |
if ($Scope -eq 'AllUsers') { | |
$Path = Join-Path -Path $all_users_pspath -ChildPath 'Modules' | |
} else { | |
$Path = Join-Path -Path $current_users_pspath -ChildPath 'Modules' | |
} | |
Write-Verbose -Message "Setting PSModule path to install to as '$Path'" | |
} | |
$temp_folder = Join-Path -Path ([System.IO.Path]::GetTempPath()) -ChildPath ([System.IO.Path]::GetRandomFileName()) | |
New-Item -Path $temp_folder -ItemType Directory > $null | |
try { | |
# Use a .zip extension so Shell.Application can extract the file. | |
$temp_file = Join-Path -Path $temp_folder -ChildPath 'temp_nupkg.zip' | |
Write-Verbose -Message "Downloading '$($Uri)' to '$temp_file'" | |
$web_client = New-Object -TypeName System.Net.WebClient | |
$web_client.DownloadFile($Uri, $temp_file) | |
$use_legacy = $false | |
try { | |
# Requires .NET 4.5+, really old Windows versions may not have this. Remove the legacy work when Server | |
# 2008 goes EOL. | |
Add-Type -AssemblyName System.IO.Compression.FileSystem -ErrorAction Stop > $null | |
} catch { | |
Write-Verbose -Message "System.IO.Compression is not available, using the Windows Shell COM API to extract the nupkg" | |
$use_legacy = $true | |
} | |
Write-Verbose -Message "Extracting temp nupkg at '$temp_file' to '$temp_folder'" | |
if ($use_legacy) { | |
$shell = New-Object -ComObject Shell.Application | |
$zip = $shell.NameSpace($temp_file) | |
$zip_dest = $shell.NameSpace($temp_folder) | |
# https://msdn.microsoft.com/en-us/library/windows/desktop/bb787866.aspx | |
# From Folder.CopyHere documentation, 1044 means: | |
# - 1024: do not display a user interface if an error occurs | |
# - 16: respond with "yes to all" for any dialog box that is displayed | |
# - 4: do not display a progress dialog box | |
$zip_dest.CopyHere($zip.Items(), 1044) | |
} else { | |
[System.IO.Compression.ZipFile]::ExtractToDirectory($temp_file, $temp_folder) | |
} | |
Remove-Item -LiteralPath $temp_file -Force | |
$nuspec_file = Get-Item -Path (Join-Path -Path $temp_folder -ChildPath '*.nuspec') | |
if ($null -eq $nuspec_file) { | |
Write-Error -Message 'Failed to find .nuspec file inside .nupkg, cannot continue' -Category ObjectNotFound | |
return | |
} | |
$package_nuspec = [System.Xml.XmlDocument](Get-Content -LiteralPath $nuspec_file.FullName -Raw -ErrorAction Stop) | |
$name = $package_nuspec.package.metadata.id | |
$version = $package_nuspec.package.metadata.version | |
Write-Verbose -Message "Parsed package nuspec and gathered: Name=$name, Version=$version" | |
$module_path = Join-Path -Path $Path -ChildPath $name | |
# PowerShell 5.1+ supports modules being stored for a particular version. If running on 5.1+ we should place | |
# the files inside a folder of that particular version. | |
if ($PSVersionTable.PSVersion -ge [Version]'5.1') { | |
Write-Verbose -Message "PowerShell version supports version specific module path, appending module version $version" | |
$module_path = Join-Path -Path $module_path -ChildPath $version | |
} | |
if (-not (Test-Path -LiteralPath $module_path)) { | |
Write-Verbose -Message "Creating module path '$module_path'" | |
New-Item -Path $module_path -ItemType Directory > $null | |
} elseif ($Force) { | |
Write-Verbose -Message "Module at path '$module_path' already exists and -Force was used, cleaning directory" | |
Remove-Item -LiteralPath $module_path -Force -Recurse | |
New-Item -Path $module_path -ItemType Directory > $null | |
} else { | |
Write-Error -Message "Module at path '$module_path' already exists, use -Force to continue" | |
return | |
} | |
# Ignore these files in the .nupkg, we don't care or need them. | |
$ignored_files = @("$name.nuspec", '[Content_Types].xml', '_rels', 'package') | |
# Copy all the files and Unblock-File them | |
Function Copy-FolderContents { | |
Param ($Path, $DestinationPath, $IgnoredFiles = @()) | |
# -Filter won't work for all paths ([Content_Types].xml), so we do a post processing filter | |
$path_items = Get-ChildItem -LiteralPath $Path | |
foreach ($path_item in $path_items) { | |
if ($path_item.Name -in $IgnoredFiles) { | |
continue | |
} | |
$dest_path = Join-Path -Path $DestinationPath -ChildPath $path_item.Name | |
if ($path_item.PSIsContainer) { | |
Write-Verbose -Message "Creating dest directory at '$dest_path'" | |
New-Item -Path $dest_path -ItemType Directory -Force > $null | |
Copy-FolderContents -Path $path_item.FullName -DestinationPath $dest_path | |
} else { | |
Write-Verbose -Message "Copying module file '$($path_item.Name)' to $dest_path" | |
Copy-Item -LiteralPath $path_item.FullName -Destination $dest_path -Force | |
if (Get-Command -Name Unblock-File -ErrorAction Ignore) { | |
Write-Verbose -Message "Unblocking file at $dest_path" | |
Unblock-File -LiteralPath $dest_path | |
} | |
} | |
} | |
} | |
Copy-FolderContents -Path $temp_folder -DestinationPath $module_path -IgnoredFiles $ignored_files | |
} finally { | |
Write-Verbose -Message "Removing temporary directory at '$temp_folder'" | |
Remove-Item -LiteralPath $temp_folder -Force -Recurse | |
} | |
if ($PassThru) { | |
return Get-Module -Name $module_path -ListAvailable | |
} | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment