PxGet.psm1

# Copyright WebMD Health Services
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License

#Requires -Version 5.1
Set-StrictMode -Version 'Latest'

# Functions should use $moduleRoot as the relative root from which to find
# things. A published module has its function appended to this file, while a
# module in development has its functions in the Functions directory.
$moduleRoot = $PSScriptRoot

# Store each of your module's functions in its own file in the Functions
# directory. On the build server, your module's functions will be appended to
# this file, so only dot-source files that exist on the file system. This allows
# developers to work on a module without having to build it first. Grab all the
# functions that are in their own files.
$functionsPath = Join-Path -Path $moduleRoot -ChildPath 'Functions\*.ps1'
if( (Test-Path -Path $functionsPath) )
{
    foreach( $functionPath in (Get-Item $functionsPath) )
    {
        . $functionPath.FullName
    }
}


function Invoke-PxGet
{
    <#
    .SYNOPSIS
    Invokes PxGet.
 
    .DESCRIPTION
    A tool similar to nuget but for PowerShell modules. A config file in the root of a repository that specifies
    what modules should be installed into the PSModules directory of the repository. If a path is provided for the
    module it will be installed at the specified path instead of the PSModules directory.
 
    .EXAMPLE
    Invoke-PxGet 'install'
 
    Demonstrates how to call this function to install required PSModules.
    #>


    [CmdletBinding()]
    param(
        [Parameter(Mandatory)]
        [ValidateSet('install')]
        [string] $Command
    )

    Set-StrictMode -Version 'Latest'
    Use-CallerPreference -Cmdlet $PSCmdlet -SessionState $ExecutionContext.SessionState

    $origModulePath = $env:PSModulePath
    $privateModulesPath = Join-Path -Path $(Get-Location) -ChildPath 'PSModules'
    if( -not (Test-Path -Path $privateModulesPath) )
    {
        New-Item -Path $privateModulesPath -ItemType 'Directory' | Out-Null
    }

    $deepPrefs = @{}

    if( (Test-Path -Path 'env:PXGET_DISABLE_DEEP_DEBUG') )
    {
        $deepPrefs['Debug'] = $false
    }

    if( (Test-Path -Path 'env:PXGET_DISABLE_DEEP_VERBOSE') )
    {
        $deepPrefs['Verbose'] = $false
    }

    $activity = 'pxget install'
    try
    {
        # pxget should ship with its own private copies of PackageManagement and PowerShellGet. Setting PSModulePath
        # to pxget module's Modules directory ensures no other package modules get loaded.
        $pxGetModulesRoot = Join-Path -Path $moduleRoot -ChildPath 'Modules'
        $env:PSModulePath = $pxGetModulesRoot
        Write-Debug "PSModulePath $($env:PSModulePath)"
        Write-Debug "moduleRoot $($pxGetModulesRoot)"
        Get-Module -ListAvailable | Format-Table -AutoSize | Out-String | Write-Debug
        Import-Module -Name 'PackageManagement' @deepPrefs
        Import-Module -Name 'PowerShellGet' @deepPrefs
        Get-Module | Format-Table -AutoSize | Out-String | Write-Debug

        $env:PSModulePath = @($privateModulesPath, $pxGetModulesRoot) -join [IO.Path]::PathSeparator
        Write-Debug "PSModulePath $($env:PSModulePath)"
        Get-Module -ListAvailable | Format-Table -AutoSize | Out-String | Write-Debug

        $modulesNotFound = @()
        $pxgetJsonPath = Join-Path -Path (Get-Location) -ChildPath 'pxget.json'

        if( -not (Test-Path -Path $pxgetJsonPath) )
        {
            Write-Error 'There is no pxget.json file in the current directory.'
            return
        }

        $pxModules = Get-Content -Path $pxgetJsonPath | ConvertFrom-Json
        if( -not $pxModules )
        {
            Write-Warning 'The pxget.json file is empty!'
            return
        }

        $moduleNames = $pxModules.PSModules | Select-Object -ExpandProperty 'Name'
        if( -not $moduleNames )
        {
            Write-Warning "There are no modules listed in ""$($pxgetJsonPath | Resolve-Path -Relative)""."
            return
        }

        $numInstalls = $moduleNames | Measure-Object | Select-Object -ExpandProperty 'Count'
        $numInstalls = $numInstalls * 2 + 1
        Write-Debug " numSteps $($numInstalls)"
        $curStep = 0
        $status = 'Finding latest module versions.'
        $uniqueModuleNames = $moduleNames | Select-Object -Unique
        $op = "Find-Module -Name '$($uniqueModuleNames -join "', '")'"
        $percentComplete = ($curStep++/$numInstalls * 100)
        Write-Progress -Activity $activity -Status $status -CurrentOperation $op -PercentComplete $percentComplete

        $modules = Find-Module -Name $uniqueModuleNames -ErrorAction Ignore @deepPrefs
        if( -not $modules )
        {
            $msg = "$($pxgetJsonPath | Resolve-Path -Relative): Modules ""$($uniqueModuleNames -join '", "')"" not " +
                   'found.'
            Write-Error $msg
            return
        }

        # Find-Module is expensive. Limit calls as much as possible.
        $findModuleCache = @{}

        # We only care if the module is in PSModules right now. Later we'll allow dev dependencies, which can be
        # installed globally.
        $env:PSModulePath = $privateModulesPath
        foreach( $pxModule in $pxModules.PSModules )
        {
            $allowPrerelease = $pxModule.Version -match '-'

            $progressState = @{
                Activity = $activity;
                Status = "Saving $($pxModule.Name) $($pxModule.Version)";
            }

            Write-Debug " curStep $($curStep)"
            $percentComplete = ($curStep++/$numInstalls * 100)
            $moduleToInstall =
                $modules |
                Select-Module -Name $pxModule.Name -Version $pxModule.Version -AllowPrerelease:$allowPrerelease |
                Select-Object -First 1
            if( -not $moduleToInstall )
            {
                $allowPrereleaseOp = ''
                if( $allowPrerelease )
                {
                    $allowPrereleaseOp = ' -AllowPrerelease'
                }
                $op = "Find-Module -Name '$($pxModule.Name)' -AllVersions$($allowPrereleaseOp)"
                if( -not $findModuleCache.ContainsKey($op) )
                {
                    Write-Progress @progressState -CurrentOperation $op -PercentComplete $percentComplete
                    $findModuleCache[$op] = Find-Module -Name $pxModule.Name `
                                                        -AllVersions `
                                                        -AllowPrerelease:$allowPrerelease `
                                                        -ErrorAction Ignore `
                                                        @deepPrefs
                }
                $moduleToInstall =
                    $findModuleCache[$op] |
                    Select-Module -Name $pxModule.Name -Version $pxModule.Version -AllowPrerelease:$allowPrerelease |
                    Select-Object -First 1
            }

            if( -not $moduleToInstall )
            {
                Write-Debug " curStep $($curStep)"
                $curStep += 1
                $modulesNotFound += $pxModule.Name
                continue
            }

            $progressState['Status'] = "Saving $($moduleToInstall.Name) $($moduleToInstall.Version)"
            Write-Progress @progressState -PercentComplete $percentComplete
            Start-Sleep -Seconds 2

            $installedModule =
                Get-Module -Name $pxModule.Name -List |
                Where-Object 'Version' -eq $moduleToInstall.Version
            # The latest version that matches the version in the pxget.json file is already installed
            if( $installedModule )
            {
                Write-Debug " curStep $($curStep)"
                $curStep += 1

                $installedModule
                continue
            }

            $installPath = $privateModulesPath
            if( ($pxModule.PSObject.Properties.Name -Contains 'Path') -and (-not [string]::IsNullOrWhiteSpace($pxModule.Path)) )
            {
                $installPath = $pxModule.Path
            }

            if( -not (Test-Path -Path $installPath) )
            {
                New-Item -Path $installPath -ItemType 'Directory' | Out-Null
            }

            $op = "Save-Module -Name '$($moduleToInstall.Name)' -Version '$($moduleToInstall.Version)' -Path " +
                  "'$($installPath | Resolve-Path -Relative)' -Repository '$($moduleToInstall.Repository)'"
            Write-Debug " curStep $($curStep)"
            $percentComplete = ($curStep++/$numInstalls * 100)
            Write-Progress @progressState -CurrentOperation $op -PercentComplete $percentComplete
            $curProgressPref = $ProgressPreference
            $Global:ProgressPreference = [Management.Automation.ActionPreference]::SilentlyContinue
            try
            {
                # Not installed. Install it. We pipe it so the repository of the module is also used.
                $moduleToInstall | Save-Module -Path $installPath @deepPrefs
            }
            finally
            {
                $Global:ProgressPreference = $curProgressPref
            }

            $savedToPath = Join-Path -Path $installPath -ChildPath $moduleToInstall.Name
            $savedToPath = Join-Path -Path $savedToPath -ChildPath ($moduleToInstall.Version -replace '-.*$', '')
            Get-Module -Name $savedToPath -ListAvailable @deepPrefs
        }
        if( $modulesNotFound )
        {
            Write-Error "$($pxgetJsonPath | Resolve-Path -Relative): Module(s) ""$($modulesNotFound -join '", "')"" not found."
            return
        }
    }
    finally
    {
        $env:PSModulePath = $origModulePath
        Write-Progress -Activity $activity -Completed
    }
}

Set-Alias -Name 'pxget' -Value 'Invoke-PxGet'


function Select-Module
{
    [CmdletBinding()]
    param(
        [Parameter(Mandatory, ValueFromPipeline = $true)]
        [PSCustomObject] $Module,

        [Parameter(Mandatory)]
        [String] $Name,

        [Parameter(Mandatory)]
        [String] $Version,  

        [switch] $AllowPrerelease
    )

    process
    {
        if( $Module.Name -ne $Name -or $Module.Version -notlike $Version )
        {
            return
        }

        if( $AllowPrerelease )
        {
            return $Module
        }

        [Version]$moduleVersion = $null
        if( [Version]::TryParse($Module.Version, [ref]$moduleVersion) )
        {
            return $Module
        }
    }
}


function Use-CallerPreference
{
    <#
    .SYNOPSIS
    Sets the PowerShell preference variables in a module's function based on the callers preferences.
 
    .DESCRIPTION
    Script module functions do not automatically inherit their caller's variables, including preferences set by common
    parameters. This means if you call a script with switches like `-Verbose` or `-WhatIf`, those that parameter don't
    get passed into any function that belongs to a module.
 
    When used in a module function, `Use-CallerPreference` will grab the value of these common parameters used by the
    function's caller:
 
     * ErrorAction
     * Debug
     * Confirm
     * InformationAction
     * Verbose
     * WarningAction
     * WhatIf
     
    This function should be used in a module's function to grab the caller's preference variables so the caller doesn't
    have to explicitly pass common parameters to the module function.
 
    This function is adapted from the [`Get-CallerPreference` function written by David Wyatt](https://gallery.technet.microsoft.com/scriptcenter/Inherit-Preference-82343b9d).
 
    There is currently a [bug in PowerShell](https://connect.microsoft.com/PowerShell/Feedback/Details/763621) that
    causes an error when `ErrorAction` is implicitly set to `Ignore`. If you use this function, you'll need to add
    explicit `-ErrorAction $ErrorActionPreference` to every `Write-Error` call. Please vote up this issue so it can get
    fixed.
 
    .LINK
    about_Preference_Variables
 
    .LINK
    about_CommonParameters
 
    .LINK
    https://gallery.technet.microsoft.com/scriptcenter/Inherit-Preference-82343b9d
 
    .LINK
    http://powershell.org/wp/2014/01/13/getting-your-script-module-functions-to-inherit-preference-variables-from-the-caller/
 
    .EXAMPLE
    Use-CallerPreference -Cmdlet $PSCmdlet -SessionState $ExecutionContext.SessionState
 
    Demonstrates how to set the caller's common parameter preference variables in a module function.
    #>

    [CmdletBinding()]
    param (
        [Parameter(Mandatory)]
        #[Management.Automation.PSScriptCmdlet]
        # The module function's `$PSCmdlet` object. Requires the function be decorated with the `[CmdletBinding()]`
        # attribute.
        $Cmdlet,

        [Parameter(Mandatory)]
        # The module function's `$ExecutionContext.SessionState` object. Requires the function be decorated with the
        # `[CmdletBinding()]` attribute.
        #
        # Used to set variables in its callers' scope, even if that caller is in a different script module.
        [Management.Automation.SessionState]$SessionState
    )

    Set-StrictMode -Version 'Latest'

    # List of preference variables taken from the about_Preference_Variables and their common parameter name (taken
    # from about_CommonParameters).
    $commonPreferences = @{
                              'ErrorActionPreference' = 'ErrorAction';
                              'DebugPreference' = 'Debug';
                              'ConfirmPreference' = 'Confirm';
                              'InformationPreference' = 'InformationAction';
                              'VerbosePreference' = 'Verbose';
                              'WarningPreference' = 'WarningAction';
                              'WhatIfPreference' = 'WhatIf';
                          }

    foreach( $prefName in $commonPreferences.Keys )
    {
        $parameterName = $commonPreferences[$prefName]

        # Don't do anything if the parameter was passed in.
        if( $Cmdlet.MyInvocation.BoundParameters.ContainsKey($parameterName) )
        {
            continue
        }

        $variable = $Cmdlet.SessionState.PSVariable.Get($prefName)
        # Don't do anything if caller didn't use a common parameter.
        if( -not $variable )
        {
            continue
        }

        if( $SessionState -eq $ExecutionContext.SessionState )
        {
            Set-Variable -Scope 1 -Name $variable.Name -Value $variable.Value -Force -Confirm:$false -WhatIf:$false
        }
        else
        {
            $SessionState.PSVariable.Set($variable.Name, $variable.Value)
        }
    }
}