PSDependScripts/GitHub.ps1
<# .SYNOPSIS Installs a module from a GitHub repository. .DESCRIPTION Installs a module from a GitHub repository. Relevant Dependency metadata: DependencyName (Key): The key for this dependency is used as Name, if none is specified Name: Used to specify the GitHub repository name to download Version: Used to identify existing installs meeting this criteria, and as RequiredVersion for installation. Defaults to 'latest' Target: The folder to download repo to. Created if it doesn't exist. "CurrentUser" resolves to "$ENV:USERPROFILE\Documents\WindowsPowerShell\Modules\" "AllUsers" resolves to "$ENV:PROGRAMFILES\WindowsPowerShell\Modules\" Defaults to: Non-admin session: "$ENV:USERPROFILE\Documents\WindowsPowerShell\Modules\" Admin session: "$ENV:PROGRAMFILES\WindowsPowerShell\Modules\" .NOTES A huge thanks to Doug Finke for the idea and some code and to Jonas Thelemann for a rewrite for tags! https://github.com/dfinke/InstallModuleFromGitHub https://github.com/dargmuesli .PARAMETER PSDependAction Test, Install, or Import the module. Defaults to Install Test: Return true or false on whether the dependency is in place Install: Install the dependency Import: Import the dependency .PARAMETER ExtractPath Extract only these specified file(s) or folder(s) to the target. .PARAMETER ExtractProject Parse the GitHub repository for a common PowerShell project hierarchy and extract only the project folder Example: ramblingcookiemonster/psslack looks like this: PSSlack/ Repo root PSSlack/ Module root PSSlack.psd1 Module manifest Tests/ In this case, we would extract PSSlack/PSSlack only Example: bundyfx/vamp looks like this: vamp/ Repo root (also, module root) vamp.psd1 Module manifest In this case, we would extract the whole root vamp folder .PARAMETER TargetType How we interpret your target: Standard: DEFAULT: Extract to target\name Exact: Extract target\ Parallel: Extract to target\name\version or target\name\branch\name depending on the version specified .PARAMETER Force If specified, delete target folder (as defined by TargetType) if it exists already Default: We copy to the target folder without removing .EXAMPLE Image a GitHub repository containing a PowerShell module with git tags named "1.0.0" and "0.1.0". @{ 'Dargmuesli/powershell-lib' = '1.0.0' } @{ 'Dargmuesli/powershell-lib' = 'latest' } @{ 'Dargmuesli/powershell-lib' = '' } These download version 1.0.0 to "powershell-lib\1.0.0" @{ 'Dargmuesli/powershell-lib' = '0.1.0' } This downloads version 0.1.0 to "powershell-lib\0.1.0" @{ 'Dargmuesli/powershell-lib' = 'master' } This downloads branch "master" (most recent commit version) to "powershell-lib" .EXAMPLE Image a GitHub repository containing a PowerShell module with no git tags. @{ 'Dargmuesli/powershell-lib' = 'latest' } @{ 'Dargmuesli/powershell-lib' = '' } @{ 'Dargmuesli/powershell-lib' = 'master' } These download branch "master" (most recent commit version) to "powershell-lib" @{ 'Dargmuesli/powershell-lib' = @{ Version = 'latest' Parameters @{ TargetType = 'Parallel' } } } @{ 'Dargmuesli/powershell-lib' = @{ Parameters @{ TargetType = 'Parallel' } } } @{ 'Dargmuesli/powershell-lib' = @{ Version = 'master' Parameters @{ TargetType = 'Parallel' } } } These download branch "master" (most recent commit version) to "powershell-lib\master\powershell-lib" @{ 'Dargmuesli/powershell-lib' = @{ Version = 'feature' Parameters @{ TargetType = 'Parallel' } } } This downloads branch "feature" (most recent commit version) to "powershell-lib\feature\powershell-lib" .EXAMPLE @{ 'powershell/demo_ci' = @{ Version = 'latest' DependencyType = 'GitHub' Target = 'C:\T' Parameters = @{ ExtractPath = 'Assets/DscPipelineTools', 'InfraDNS/Configs/DNSServer.ps1' TargetType = 'Exact' } } } # Download the latest version of demo_ci by powershell on GitHub # Extract repo-root/Assets/DscPipelineTools to the target # Extract repo-root/InfraDNS/Configs/DNSServer.ps1 to the target #> [cmdletbinding()] param( [PSTypeName('PSDepend.Dependency')] [psobject[]]$Dependency, [ValidateSet('Test', 'Install', 'Import')] [string[]]$PSDependAction = @('Install'), [string[]]$ExtractPath, [bool]$ExtractProject = $True, [ValidateSet('Parallel', 'Standard', 'Exact')] [string]$TargetType = 'Standard', [switch]$Force ) Write-Verbose -Message "Examining GitHub dependency [$($Dependency.DependencyName)]" # Extract data from dependency $DependencyName = $Dependency.DependencyName $Version = $Dependency.Version $Target = $Dependency.Target $NameParts = $DependencyName.Split("/") $Name = $NameParts[1] # Translate "" to "latest" if($Version -eq "") { $Version = "latest" } # Check if the version that should be used is a version number if($Version -match "^\d+(?:\.\d+)+$") { $Version = New-Object "System.Version" $Version } $CurrentUserPath = "$ENV:USERPROFILE\Documents\WindowsPowerShell\Modules\" $AllUsersPath = "$ENV:PROGRAMFILES\WindowsPowerShell\Modules\" # Set default target depending on admin permissions if(-not $Target) { if(([Security.Principal.WindowsPrincipal] [Security.Principal.WindowsIdentity]::GetCurrent()).IsInRole([Security.Principal.WindowsBuiltInRole] "Administrator")) { $Target = $AllUsersPath } else { $Target = $CurrentUserPath } } else { # Resolve scope keywords if($Target -Eq "CurrentUser") { $Target = $CurrentUserPath } elseif($Target -Eq "AllUsers") { $Target = $AllUsersPath } } # Search for an already existing version of the dependency $Module = Get-Module -ListAvailable -Name $Name -ErrorAction SilentlyContinue $ModuleExisting = $null $ModuleExistingMatches = $false $ExistingVersions = $null $ShouldInstall = $false $RemoteAvailable = $false $URL = $null if($Module) { $ModuleExisting = $true } else { $ModuleExisting = $false } if($ModuleExisting) { Write-Verbose "Found existing module [$Name]" $ExistingVersions = $Module | Select-Object -ExpandProperty "Version" # Check if the version that is should be used is a version number if($Version -match "^\d+(?:\.\d+)+$") { :versionslocal foreach($ExistingVersion in $ExistingVersions) { switch($ExistingVersion.CompareTo($Version)) { {@(-1, 1) -contains $_} { Write-Verbose "For [$Name], the version you specified [$Version] does not match the already existing version [$ExistingVersion]" $ShouldInstall = $true break } 0 { Write-Verbose "For [$Name], the version you specified [$Version] matches the already existing version [$ExistingVersion]" $ShouldInstall = $false $ModuleExistingMatches = $True break versionslocal } } } } else { # The version that is to be used is probably a GitHub branch name $ShouldInstall = $true } } else { Write-Verbose "Did not find existing module [$Name]" $ShouldInstall = $true } # Skip the case when the version that is to be used already exists if($ShouldInstall) { # API-fetch the tags on GitHub $GitHubVersion = $null $GitHubTag = $null $Page = 0 try { :nullcheck while($GitHubVersion -Eq $null) { $Page++ $GitHubTags = Invoke-RestMethod -Uri "https://api.github.com/repos/$DependencyName/tags?per_page=100&page=$Page" if($GitHubTags) { foreach($GitHubTag in $GitHubTags) { if($GitHubTag.name -match "^\d+(?:\.\d+)+$" -and ($Version -match "^\d+(?:\.\d+)+$" -or $Version -eq "latest")) { $GitHubVersion = New-Object "System.Version" $GitHubTag.name if($Version -Eq "latest") { $Version = $GitHubVersion } switch($Version.CompareTo($GitHubVersion)) { -1 { # Version is older compared to the GitHub version, continue searching break } 0 { Write-Verbose "For [$Name], a matching version [$Version] has been found in the GitHub tags" $RemoteAvailable = $true break nullcheck } 1 { # Version is newer compared to the GitHub version, which means we can stop searching (given version history is reasonable) break nullcheck } } } } } else { break nullcheck } } } catch { # Repository does not seem to exist or a branch is the target $ShouldInstall = $False Write-Warning "Could not find module on GitHub: $_" } if($RemoteAvailable) { # Use the tag's link $URL = $GitHubTag.zipball_url if($ExistingVersions) { :versionsremote foreach($ExistingVersion in $ExistingVersions) { # Because a remote and a local version exist # Prevent a module from getting installed twice switch($ExistingVersion.CompareTo($GitHubVersion)) { {@(-1, 1) -contains $_} { Write-Verbose "For [$Name], you have a different version [$ExistingVersion] compared to the version available on GitHub [$GitHubVersion]" break } 0 { Write-Verbose "For [$Name], you already have the version [$ExistingVersion]" $ModuleExistingMatches = $true $ShouldInstall = $false break versionsremote } } } } } else { Write-Verbose "[$DependencyName] has no tags on GitHub or [$Version] is a branchname" # Translate version "latest" to "master" if($Version -eq "latest") { $Version = "master" } # Link for a .zip archive of the repository's branch $URL = "https://api.github.com/repos/$DependencyName/zipball/$Version" $ShouldInstall = $True } } # Install action needs to be wanted and logical $ImportName = $Name if(($PSDependAction -contains 'Install') -and $ShouldInstall) { # Create a temporary directory and download the repository to it $OutPath = Join-Path ([System.IO.Path]::GetTempPath()) ([guid]::NewGuid().guid) New-Item -ItemType Directory -Path $OutPath -Force | Out-Null $OutFile = Join-Path $OutPath "$Version.zip" Invoke-RestMethod -Uri $URL -OutFile $OutFile if(-not (Test-Path $OutFile)) { Write-Error "Could not download [$URL] to [$OutFile]. See error details and verbose output for more information" return } # Extract the zip file $Zipfile = (New-Object -com shell.application).NameSpace($OutFile) $Destination = (New-Object -com shell.application).NameSpace($OutPath) $Destination.CopyHere($Zipfile.Items()) # Remove the zip file Remove-Item $OutFile -Force -Confirm:$False $OutPath = (Get-ChildItem -Path $OutPath)[0].FullName $OutPath = (Rename-Item -Path $OutPath -NewName $Name -PassThru).FullName if($ExtractPath) { # Filter only the contents wanted [string[]]$ToCopy = foreach($RelativePath in $ExtractPath) { $AbsolutePath = Join-Path $OutPath $RelativePath if(-not (Test-Path $AbsolutePath)) { Write-Warning "Expected ExtractPath [$RelativePath], did not find at [$AbsolutePath]" } else { $AbsolutePath } } } elseif($ExtractProject) { # Filter only the project contents $ProjectDetails = Get-ProjectDetail -Path $OutPath [string[]]$ToCopy = $ProjectDetails.Path } else { # Use the standard download path [string[]]$ToCopy = $OutPath } Write-Verbose "Contents that will be copied: $ToCopy" # Copy the contents to their target if(-not (Test-Path $Target)) { mkdir $Target -Force } $Destination = $null if ($TargetType -ne 'Exact') { $Target = Join-Path $Target $Name } if($TargetType -eq 'Exact') { $Destination = $Target } elseif($Version -match "^\d+(?:\.\d+)+$" -and $PSVersionTable.PSVersion -ge '5.0' ) { # For versioned GitHub tags $Destination = Join-Path $Target $Version } elseif(($Version -eq "latest") -and ($RemoteAvailable) -and $PSVersionTable.PSVersion -ge '5.0' ) { # For latest GitHub tags $Destination = Join-Path $Target $GitHubVersion } elseif($PSVersionTable.PSVersion -ge '5.0' -and $TargetType -eq 'Parallel') { # For GitHub branches $Destination = Join-Path $Target $Version $Destination = Join-Path $Destination $Name } else { $Destination = $Target } if($Force -and (Test-Path -Path $Destination)) { Remove-Item -Path $Destination -Force -Recurse } Write-Verbose "Copying [$($ToCopy.Count)] items to destination [$Destination] with`nTarget [$Target]`nName [$Name]`nVersion [$Version]`nGitHubVersion [$GitHubVersion]" foreach($Item in $ToCopy) { Copy-Item -Path $Item -Destination $Destination -Force -Recurse $ImportName = $Destination } # Delete the temporary folder Remove-Item (Get-Item $OutPath).parent.FullName -Force -Recurse $ModuleExisting = $true } # Conditional import if($ModuleExisting) { Import-PSDependModule -Name $ImportName -Action $PSDependAction } elseif($PSDependAction -contains 'Import') { Write-Warning "[$Name] at [$Destination] should be imported, but does not exist" } # Return true or false if Test action is wanted if($PSDependAction -contains 'Test') { return $ModuleExistingMatches } # Otherwise return null return $null |