Whiskey.psm1


$startedAt = Get-Date
function Write-Timing
{
    param(
        [Parameter(Position=0)]
        $Message
    )

    $now = Get-Date
    Write-Debug -Message ('[{0:hh":"mm":"ss"."ff}] {1}' -f ($now - $startedAt),$Message)
}

$powerShellModulesDirectoryName = 'PSModules'

$whiskeyScriptRoot = $PSScriptRoot
$whiskeyBinPath = Join-Path -Path $whiskeyScriptRoot -ChildPath 'bin' -Resolve
$whiskeyNuGetExePath = Join-Path -Path $whiskeyBinPath -ChildPath 'NuGet.exe' -Resolve

$buildStartedAt = [DateTime]::MinValue

$PSModuleAutoLoadingPreference = 'None'

Write-Timing 'Updating serialiazation depths on Whiskey objects.'
# Make sure our custom objects get serialized/deserialized correctly, otherwise they don't get passed to PowerShell tasks correctly.
Update-TypeData -TypeName 'Whiskey.BuildContext' -SerializationDepth 50 -ErrorAction Ignore
Update-TypeData -TypeName 'Whiskey.BuildInfo' -SerializationDepth 50 -ErrorAction Ignore
Update-TypeData -TypeName 'Whiskey.BuildVersion' -SerializationDepth 50 -ErrorAction Ignore

Write-Timing 'Testing that correct Whiskey assembly is loaded.'
$oldVersionLoadedMsg = 'You''ve got an old version of Whiskey loaded. Please open a new PowerShell session.'

function New-WhiskeyObject
{
    param(
        [Parameter(Mandatory)]
        [String]$TypeName,

        [Object[]]$ArgumentList
    )

    try
    {
        return (New-Object -TypeName $TypeName -ArgumentList $ArgumentList -ErrorAction Ignore)
    }
    catch
    {
        Write-Error -Message ('Unable to find type "{0}". {1}' -f $TypeName,$oldVersionLoadedMsg) -ErrorAction Stop
    }
}

function Assert-Member
{
    [CmdletBinding()]
    param(
        [Parameter(Mandatory)]
        [AllowNull()]
        [Object]$Object,

        [String[]]$Property = @()
    )

    $oldVersionLoadedMsg = 'You''ve got an old version of Whiskey loaded. Please open a new PowerShell session.'

    if( -not $Object )
    {
        Write-Error -Message $oldVersionLoadedMsg -ErrorAction Stop
    }

    foreach( $propertyToCheck in $Property )
    {
        if( -not ($Object | Get-Member $propertyToCheck) )
        {
            $msg = 'Object "{0}" is missing member "{1}".' -f $Object.GetType().FullName,$propertyToCheck
            Write-Error -Message ('{0} {1}' -f $msg,$oldVersionLoadedMsg) -ErrorAction Stop
        }
    }
}

Write-Timing 'Checking Whiskey.Context class.'
$context = New-WhiskeyObject -TypeName 'Whiskey.Context'
Assert-Member -Object $context -Property @( 'TaskPaths', 'MSBuildConfiguration', 'ApiKeys' )

Write-Timing 'Checking Whiskey.TaskAttribute class.'
$attr = New-WhiskeyObject -TypeName 'Whiskey.TaskAttribute' -ArgumentList 'Whiskey' 
Assert-Member -Object $attr -Property @( 'Aliases', 'WarnWhenUsingAlias', 'Obsolete', 'ObsoleteMessage', 'Platform' )

Write-Timing 'Checking for Whiskey.RequiresPowerShellModuleAttribute class.'
New-WhiskeyObject -TypeName 'Whiskey.RequiresPowerShellModuleAttribute' -ArgumentList ('Whiskey') | Out-Null

$attr = New-WhiskeyObject -TypeName 'Whiskey.Tasks.ValidatePathAttribute'
Assert-Member -Object $attr -Property @( 'Create' )

[Type]$apiKeysType = $context.ApiKeys.GetType()
$apiKeysDictGenericTypes = $apiKeysType.GenericTypeArguments
if( -not $apiKeysDictGenericTypes -or $apiKeysDictGenericTypes.Count -ne 2 -or $apiKeysDictGenericTypes[1].FullName -ne [securestring].FullName )
{
    Write-Error -Message  $oldVersionLoadedMsg -ErrorAction Stop 
}

Write-Timing 'Updating formats.'
$prependFormats = @(
                        (Join-Path -Path $PSScriptRoot -ChildPath 'Formats\System.Management.Automation.ErrorRecord.format.ps1xml'),
                        (Join-Path -Path $PSScriptRoot -ChildPath 'Formats\System.Exception.format.ps1xml')
                    )
Update-FormatData -PrependPath $prependFormats

Write-Timing ('Creating internal module variables.')

# PowerShell 5.1 doesn't have these variables so create them if they don't exist.
if( -not (Get-Variable -Name 'IsLinux' -ErrorAction Ignore) )
{
    $IsLinux = $false
    $IsMacOS = $false
    $IsWindows = $true
}

$dotNetExeName = 'dotnet'
$nodeExeName = 'node'
$nodeDirName = 'bin'
if( $IsWindows )
{
    $dotNetExeName = '{0}.exe' -f $dotNetExeName
    $nodeExeName = '{0}.exe' -f $nodeExeName
    $nodeDirName = ''
}

$CurrentPlatform = [Whiskey.Platform]::Unknown
if( $IsLinux )
{
    $CurrentPlatform = [Whiskey.Platform]::Linux
}
elseif( $IsMacOS )
{
    $CurrentPlatform = [Whiskey.Platform]::MacOS
}
elseif( $IsWindows )
{
    $CurrentPlatform = [Whiskey.Platform]::Windows
}

Write-Timing -Message ('Dot-sourcing files.')
$count = 0
& {
        Join-Path -Path $PSScriptRoot -ChildPath 'Functions'
        Join-Path -Path $PSScriptRoot -ChildPath 'Tasks'
    } |
    Where-Object { Test-Path -Path $_ } |
    Get-ChildItem -Filter '*.ps1' |
    ForEach-Object { 
        $count += 1
        . $_.FullName 
    }
Write-Timing -Message ('Finished dot-sourcing {0} files.' -f $count)



function Add-WhiskeyApiKey
{
    <#
    .SYNOPSIS
    Adds an API key to Whiskey's API key store.
 
    .DESCRIPTION
    The `Add-WhiskeyApiKey` function adds an API key to Whiskey's API key store. Tasks that need API keys usually have a property where you provide the ID of the API key to use. You provide Whiskey the value of the API Key with this function.
 
    For example, if you are publishing a PowerShell module, your `whiskey.yml` file will look something like this:
 
        Publish:
        - PublishPowerShellModule:
            RepositoryName: PSGallery
            Path: Whiskey
            ApiKeyID: PSGalleryApiKey
 
    After you create your build's context with `New-WhiskeyContext`, you would then call `Add-WhiskeyApiKey` to add the actual API key:
 
        $context = New-WhiskeyContext
        Add-WhiskeyApiKey -Context $context -ID 'PSGalleryApiKey' -Value '901a072f-fe5e-44ec-8546-029ffbec0687'
    #>

    [CmdletBinding()]
    param(
        [Parameter(Mandatory)]
        # The context of the build that needs the API key.
        [Whiskey.Context]$Context,

        [Parameter(Mandatory)]
        # The ID of the API key. This should match the ID given in your `whiskey.yml` for the API key ID property of the task that needs it.
        [String]$ID,

        [Parameter(Mandatory)]
        # The value of the API key. Can be a string or a SecureString.
        $Value
    )

    Set-StrictMode -Version 'Latest'

    if( $Value -isnot [securestring] )
    {
        $Value = ConvertTo-SecureString -String $Value -AsPlainText -Force
    }

    $Context.ApiKeys[$ID] = $Value
}



function Add-WhiskeyCredential
{
    <#
    .SYNOPSIS
    Adds credential to Whiskey's credential store.
 
    .DESCRIPTION
    The `Add-WhiskeyCredential` function adds a credential to Whiskey's credential store. Tasks that need credentials usually have a property where you provide the ID of the credential. You provide Whiskey the value of that credential with this function.
 
    For example, if you are publishing a ProGet universal pakcage, your `whiskey.yml` file will look something like this:
 
        Publish:
        - PublishProGetUniversalPackage:
            Uri: https://proget.example.com
            FeedName: 'upack'
            CredentialID: ProgetExampleCom
 
    After you create your build's context with `New-WhiskeyContext`, you would then call `Add-WhiskeyCredential` to add the actual credential:
 
        $context = New-WhiskeyContext
        Add-WhiskeyCredential -Context $context -ID 'ProgetExampleCom' -Credential $credential
    #>

    [CmdletBinding()]
    param(
        [Parameter(Mandatory)]
        # The context of the build that needs the API key.
        [Whiskey.Context]$Context,

        [Parameter(Mandatory)]
        # The ID of the credential. This should match the ID given in your `whiskey.yml` of credential ID property of the task that needs it.
        [String]$ID,

        [Parameter(Mandatory)]
        # The value of the credential.
        [pscredential]$Credential
    )

    Set-StrictMode -Version 'Latest'

    $Context.Credentials[$ID] = $Credential
}



function Add-WhiskeyTaskDefault
{
    <#
    .SYNOPSIS
    Sets default values for task parameters.
 
    .DESCRIPTION
    The `Add-WhiskeyTaskDefault` functions sets default properties for tasks. These defaults are only used if the property is missing from the task in your `whiskey.yml` file, i.e. properties defined in your whiskey.yml file take precedence over task defaults.
 
    `TaskName` must be the name of an existing task. Otherwise, `Add-WhiskeyTaskDefault` will throw an terminating error.
 
    By default, existing defaults are left in place. To override any existing defaults, use the `-Force`... switch.
 
    .EXAMPLE
    Add-WhiskeyTaskDefault -Context $context -TaskName 'MSBuild' -PropertyName 'Version' -Value 12.0
 
    Demonstrates setting the default value of the `MSBuild` task's `Version` property to `12.0`.
 
    .EXAMPLE
    Add-WhiskeyTaskDefault -Context $context -TaskName 'MSBuild' -PropertyName 'Version' -Value 15.0 -Force
 
    Demonstrates overwriting the current default value for `MSBuild` task's `Version` property to `15.0`.
    #>

    [CmdletBinding()]
    param(
        [Parameter(Mandatory,ValueFromPipeline)]
        # The current build context. Use `New-WhiskeyContext` to create context objects.
        [Whiskey.Context]$Context,

        [Parameter(Mandatory)]
        # The name of the task that a default parameter value will be set.
        [String]$TaskName,

        [Parameter(Mandatory)]
        # The name of the task parameter to set a default value for.
        [String]$PropertyName,

        [Parameter(Mandatory)]
        # The default value for the task parameter.
        $Value,

        # Overwrite an existing task default with a new value.
        [switch]$Force
    )

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

    if (-not ($Context | Get-Member -Name 'TaskDefaults'))
    {
        throw 'The given ''Context'' object does not contain a ''TaskDefaults'' property. Create a proper Whiskey context object using the ''New-WhiskeyContext'' function.'
    }

    if ($TaskName -notin (Get-WhiskeyTask | Select-Object -ExpandProperty 'Name'))
    {
        throw 'Task ''{0}'' does not exist.' -f $TaskName
    }

    if ($context.TaskDefaults.ContainsKey($TaskName))
    {
        if ($context.TaskDefaults[$TaskName].ContainsKey($PropertyName) -and -not $Force)
        {
            throw 'The ''{0}'' task already contains a default value for the property ''{1}''. Use the ''Force'' parameter to overwrite the current value.' -f $TaskName,$PropertyName
        }
        else
        {
            $context.TaskDefaults[$TaskName][$PropertyName] = $Value
        }
    }
    else
    {
        $context.TaskDefaults[$TaskName] = @{ $PropertyName = $Value }
    }
}



function Add-WhiskeyVariable
{
    <#
    .SYNOPSIS
    Adds a variable to the current build.
 
    .DESCRIPTION
    The `Add-WhiskeyVariable` adds a variable to the current build. Variables can be used in task properties and at runtime are replaced with their values. Variables syntax is `$(VARIABLE_NAME)`. Variable names are case-insensitive.
 
    .EXAMPLE
    Add-WhiskeyVariable -Context $context -Name 'Timestamp' -Value (Get-Date).ToString('o')
 
    Demonstrates how to add a variable. In this example, Whiskey will replace any `$(Timestamp)` variables it finds in any task properties with the value returned by `(Get-Date).ToString('o')`.
    #>

    [CmdletBinding()]
    param(
        [Parameter(Mandatory)]
        # The context of the build here you want to add a variable.
        [Whiskey.Context]$Context,

        [Parameter(Mandatory)]
        # The name of the variable.
        [String]$Name,

        [Parameter(Mandatory)]
        [AllowEmptyString()]
        [Object]$Value
    )

    Set-StrictMode -Version 'Latest'

    $Context.Variables[$Name] = $Value

}



function Assert-WhiskeyNodeModulePath
{
    <#
    .SYNOPSIS
    Asserts that the path to a Node module directory exists.
 
    .DESCRIPTION
    The `Assert-WhiskeyNodeModulePath` function asserts that a Node module directory exists. If the directory doesn't exist, the function writes an error with details on how to solve the problem. It returns the path if it exists. Otherwise, it returns nothing.
 
    This won't fail a build. To fail a build if the path doesn't exist, pass `-ErrorAction Stop`.
 
    .EXAMPLE
    Assert-WhiskeyNodeModulePath -Path $TaskParameter['NspPath']
 
    Demonstrates how to check that a Node module directory exists.
 
    .EXAMPLE
    Assert-WhiskeyNodeModulePath -Path $TaskParameter['NspPath'] -ErrorAction Stop
 
    Demonstrates how to fail a build if a Node module directory doesn't exist.
    #>

    [CmdletBinding()]
    param(
        [Parameter(Mandatory)]
        # The path to check.
        [String]$Path,

        # The path to a command inside the module path.
        [String]$CommandPath
    )

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

    $moduleName = $Path | Split-Path
    
    if( -not (Test-Path -Path $Path -PathType Container) )
    {
        Write-WhiskeyError -Message ('Node module ''{0}'' does not exist at ''{1}''. Whiskey or NPM maybe failed to install this module correctly. Clean your build then re-run your build normally. If the problem persists, it might be a task authoring error. Please see the `about_Whiskey_Writing_Tasks` help topic for more information.' -f $moduleName,$Path)
        return
    }

    if( -not $CommandPath )
    {
        return $Path
    }

    $commandName = $CommandPath | Split-Path -Leaf

    $fullCommandPath = Join-Path -Path $Path -ChildPath $CommandPath
    if( -not (Test-Path -Path $fullCommandPath -PathType Leaf) )
    {
        Write-WhiskeyError -Message ('Node module ''{0}'' does not contain command ''{1}'' at ''{2}''. Whiskey or NPM maybe failed to install this module correctly or that command doesn''t exist in this version of the module. Clean your build then re-run your build normally. If the problem persists, it might be a task authoring error. Please see the `about_Whiskey_Writing_Tasks` help topic for more information.' -f $moduleName,$commandName,$fullCommandPath)
        return
    }

    return $fullCommandPath
}



function Assert-WhiskeyNodePath
{
    <#
    .SYNOPSIS
    Asserts that the path to a node executable exists.
 
    .DESCRIPTION
    The `Assert-WhiskeyNodePath` function asserts that a path to a Node executable exists. If it doesn't, it writes an error with details on how to solve the problem. It returns the path if it exists. Otherwise, it returns nothing.
 
    This won't fail a build. To fail a build if the path doesn't exist, pass `-ErrorAction Stop`.
 
    .EXAMPLE
    Assert-WhiskeyNodePath -Path $TaskParameter['NodePath']
 
    Demonstrates how to check that Node exists.
 
    .EXAMPLE
    Assert-WhiskeyNodePath -Path $TaskParameter['NodePath'] -ErrorAction Stop
 
    Demonstrates how to fail a build if the path to Node doesn't exist.
    #>

    [CmdletBinding()]
    param(
        [Parameter(Mandatory)]
        # The path to check.
        [String]$Path
    )

    Set-StrictMode -Version 'Latest'
    Use-CallerPreference -Cmdlet $PSCmdlet -SessionState $ExecutionContext.SessionState
    
    if( -not (Test-Path -Path $Path -PathType Leaf) )
    {
        Write-WhiskeyError -Message ('Node executable ''{0}'' does not exist. Whiskey maybe failed to install Node correctly. Clean your build then re-run your build normally. If the problem persists, it might be a task authoring error. Please see the `about_Whiskey_Writing_Tasks` help topic for more information.' -f $Path)
        return
    }

    return $Path
}



function ConvertFrom-WhiskeyContext
{
    <#
    .SYNOPSIS
    Converts a `Whiskey.Context` into a generic object that can be serialized across platforms.
     
    .DESCRIPTION
    Some tasks need to run in background jobs and need access to Whiskey's context. This function converts a `Whiskey.Context` object into an object that can be serialized by PowerShell across platforms. The object returned by this function can be passed to a `Start-Job` script block. Inside that script block you should import Whiskey and pass the serialized context to `ConvertTo-WhiskeyContext`.
 
        $serializableContext = $TaskContext | ConvertFrom-WhiskeyContext
        $job = Start-Job {
                    Invoke-Command -ScriptBlock {
                                            $VerbosePreference = 'SilentlyContinue';
                                            # Or wherever your project keeps Whiskey relative to your task definition.
                                            Import-Module -Name (Join-Path -Path $using:PSScriptRoot -ChildPath '..\Whiskey' -Resolve -ErrorAction Stop)
                                        }
                    $context = $using:serializableContext | ConvertTo-WhiskeyContext
                    # Run your task
              }
 
    You should create a new serializable context for each job you are running. Whiskey generates a temporary encryption key so it can encrypt/decrypt credentials. Once it decrypts the credentials, it deletes the key from memory. If you use the same context object between jobs, one job will clear the key and other jobs will fail because the key will be gone.
 
    .EXAMPLE
    $TaskContext | ConvertFrom-WhiskeyContext
 
    Demonstrates how to call `ConvertFrom-WhiskeyContext`. See the description for a full example.
    #>

    [CmdletBinding()]
    param(
        [Parameter(Mandatory,ValueFromPipeline)]
        # The context to convert. You can pass an existing context via the pipeline.
        [Whiskey.Context]$Context
    )

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

        $key = New-Object 'byte[]' (256/8)
        $rng = New-Object 'Security.Cryptography.RNGCryptoServiceProvider'
        $rng.GetBytes($key)
    }

    process 
    {
        # PowerShell on Linux/MacOS can't serialize SecureStrings. So, we have to encrypt and serialize them.
        $serializableCredentials = @{ }
        foreach( $credentialID in $Context.Credentials.Keys )
        {
            [pscredential]$credential = $Context.Credentials[$credentialID]
            $serializableCredential = [pscustomobject]@{ 
                                                            UserName = $credential.UserName;
                                                            Password = ConvertFrom-SecureString -SecureString $credential.Password -Key $key
                                                        }
            $serializableCredentials[$credentialID] = $serializableCredential
        }

        $serializableApiKeys = @{ }
        foreach( $apiKeyID in $Context.ApiKeys.Keys )
        {
            [securestring]$apiKey = $Context.ApiKeys[$apiKeyID]
            $serializableApiKey = ConvertFrom-SecureString -SecureString $apiKey -Key $key
            $serializableApiKeys[$apiKeyID] = $serializableApiKey
        }

        $Context | 
            Select-Object -Property '*' -ExcludeProperty 'Credentials','ApiKeys' | 
            Add-Member -MemberType NoteProperty -Name 'Credentials' -Value $serializableCredentials -PassThru |
            Add-Member -MemberType NoteProperty -Name 'ApiKeys' -Value $serializableApiKeys -PassThru |
            Add-Member -MemberType NoteProperty -Name 'CredentialKey' -Value $key.Clone() -PassThru
    }

    end
    {
        [Array]::Clear($key,0,$key.Length)
    }
}


function ConvertFrom-WhiskeyYamlScalar
{
    <#
    .SYNOPSIS
    Converts a string that came from a YAML configuation file into a strongly-typed object.
 
    .DESCRIPTION
    The `ConvertFrom-WhiskeyYamlScalar` function converts a string that came from a YAML configuration file into a strongly-typed object according to the parsing rules in the YAML specification. It converts strings into booleans, integers, floating-point numbers, and timestamps. See the YAML specification for examples on how to represent these values in your YAML file.
     
    It will convert:
 
    * `y`, `yes`, `true`, and `on` to `$true`
    * `n`, `no`, `false`, and `off` to `$false`
    * Numbers to `int32` or `int64` types. Numbers can be prefixed with `0x` (for hex), `0b` (for bits), or `0` for octal.
    * Floating point numbers to `double`, or `single` types. Floating point numbers can be expressed as decimals (`1.5`), or with scientific notation (`6.8523015e+5`).
    * `~`, `null`, and `` to `$null`
    * timestamps (e.g. `2001-12-14t21:59:43.10-05:00`) to date
 
    If it can't convert a string into a known type, `ConvertFrom-WhiskeyYamlScalar` writes an error.
 
    .EXAMPLE
    $value | ConvertFrom-WhiskeyYamlScalar
 
    Demonstrates how to pipe values to `ConvertFrom-WhiskeyYamlScalar`.
    #>

    [CmdletBinding()]
    param(
        [Parameter(Mandatory,ValueFromPipeline)]
        [AllowEmptyString()]
        [AllowNull()]
        # The object to convert.
        [String]$InputObject
    )

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

        if( [String]::IsNullOrEmpty($InputObject)  -or $InputObject -match '^(~|null)' )
        {
            return $null
        }

        if( $InputObject -match '^(y|yes|n|no|true|false|on|off)$' )
        {
            return $InputObject -match '^(y|yes|true|on)$'
        }

        # Integer
        $regex = @'
^(
 [-+]?0b[0-1_]+ # (base 2)
|[-+]?0[0-7_]+ # (base 8)
|[-+]?(0|[1-9][0-9_]*) # (base 10)
|[-+]?0x[0-9a-fA-F_]+ # (base 16)
|[-+]?[1-9][0-9_]*(:[0-5]?[0-9])+ # (base 60)
)$
'@

        if( [Text.RegularExpressions.Regex]::IsMatch($InputObject, $regex, [Text.RegularExpressions.RegexOptions]::IgnorePatternWhitespace) ) 
        {
            [int64]$int64 = 0

            $value = $InputObject -replace '_',''

            if( $value -match '^0x' -and [int64]::TryParse(($value -replace '0x',''), [Globalization.NumberStyles]::HexNumber, $null,[ref]$int64))
            {
            }
            elseif( $value -match '^0b' )
            {
                $int64 = [Convert]::ToInt64(($value -replace ('^0b','')),2)
            }
            elseif( $value -match '^0' )
            {
                $int64 = [Convert]::ToInt64($value,8)
            }
            elseif( [int64]::TryParse($value,[ref]$int64) )
            {
            }
            
            if( $int64 -gt [Int32]::MaxValue )
            {
                return $int64
            }

            return [int32]$int64
        }    

        $regex = @'
^(
 [-+]?([0-9][0-9_]*)?\.[0-9_]*([eE][-+][0-9]+)? # (base 10)
|[-+]?[0-9][0-9_]*(:[0-5]?[0-9])+\.[0-9_]* # (base 60)
|[-+]?\.(inf|Inf|INF) # (infinity)
|\.(nan|NaN|NAN) # (not a number)
)$
'@

        if( [Text.RegularExpressions.Regex]::IsMatch($InputObject, $regex, [Text.RegularExpressions.RegexOptions]::IgnorePatternWhitespace) ) 
        {
            $value = $InputObject -replace '_',''
            [Double]$double = 0.0
            if( $value -eq '.NaN' )
            {
                return [Double]::NaN
            }

            if( $value -match '-\.inf' )
            {
                return [Double]::NegativeInfinity
            }

            if( $value -match '\+?.inf' )
            {
                return [Double]::PositiveInfinity
            }

            if( [Double]::TryParse($value,[ref]$double) )
            {
                return $double
            }
        }

        $regex = '^\d\d\d\d-\d\d?-\d\d?(([Tt]|[ \t]+)\d\d?\:\d\d\:\d\d(\.\d+)?(Z|\ *[-+]\d\d?(:\d\d)?)?)?$'
        if( [Text.RegularExpressions.Regex]::IsMatch($InputObject, $regex, [Text.RegularExpressions.RegexOptions]::IgnorePatternWhitespace) ) 
        {
            [DateTime]$datetime = [DateTime]::MinValue
            if( ([DateTime]::TryParse(($InputObject -replace 'T',' '),[ref]$datetime)) ) 
            {
                return $datetime
            }
        }

        Write-WhiskeyError -Message ('Unable to convert scalar value ''{0}''. See http://yaml.org/type/ for documentation on YAML''s scalars.' -f $InputObject)
    }

}



function ConvertTo-WhiskeyContext
{
    <#
    .SYNOPSIS
    Converts an `Whiskey.Context` returned by `ConvertFrom-WhiskeyContext` back into a `Whiskey.Context` object.
     
    .DESCRIPTION
    Some tasks need to run in background jobs and need access to Whiskey's context. This function converts an object returned by `ConvertFrom-WhiskeyContext` back into a `Whiskey.Context` object.
 
        $serializableContext = $TaskContext | ConvertFrom-WhiskeyContext
        $job = Start-Job {
                    Invoke-Command -ScriptBlock {
                                            $VerbosePreference = 'SilentlyContinue';
                                            # Or wherever your project keeps Whiskey relative to your task definition.
                                            Import-Module -Name (Join-Path -Path $using:PSScriptRoot -ChildPath '..\Whiskey' -Resolve -ErrorAction Stop)
                                        }
                    [Whiskey.Context]$context = $using:serializableContext | ConvertTo-WhiskeyContext
                    # Run your task
              }
 
    .EXAMPLE
    $serializedContext | ConvertTo-WhiskeyContext
 
    Demonstrates how to call `ConvertTo-WhiskeyContext`. See the description for a full example.
    #>

    [CmdletBinding()]
    param(
        [Parameter(Mandatory,ValueFromPipeline)]
        # The context to convert. You can pass an existing context via the pipeline.
        [Object]$InputObject
    )

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

        try
        {
            function Sync-ObjectProperty
            {
                param(
                    [Parameter(Mandatory)]
                    [Object]$Source,

                    [Parameter(Mandatory)]
                    [Object]$Destination,

                    [String[]]$ExcludeProperty
                )

                $destinationType = $Destination.GetType()
                $destinationType.DeclaredProperties |
                    Where-Object { $ExcludeProperty -notcontains $_.Name } |
                    Where-Object { $_.GetSetMethod($false) } |
                    Select-Object -ExpandProperty 'Name' |
                    ForEach-Object { 
                        Write-WhiskeyDebug ('{0} {1} -> {2}' -f $_,$Destination.$_,$Source.$_)
                                
                        $propertyType = $destinationType.GetProperty($_).PropertyType
                        if( $propertyType.IsSubclassOf([IO.FileSystemInfo]) )
                        {
                            if( $Source.$_ )
                            {
                                Write-WhiskeyDebug -Message ('{0} {1} = {2}' -f $propertyType.FullName,$_,$Source.$_)
                                $Destination.$_ = New-Object $propertyType.FullName -ArgumentList $Source.$_.FullName
                            }
                        }
                        else
                        {
                            $Destination.$_ = $Source.$_ 
                        }
                    }

                Write-WhiskeyDebug ('Source -eq $null ? {0}' -f ($Source -eq $null))
                if( $Source -ne $null )
                {
                    Write-WhiskeyDebug -Message 'Source'
                    Get-Member -InputObject $Source | Out-String | Write-WhiskeyDebug
                }

                Write-WhiskeyDebug ('Destination -eq $null ? {0}' -f ($Destination -eq $null))
                if( $Destination -ne $null )
                {
                    Write-WhiskeyDebug -Message 'Destination'
                    Get-Member -InputObject $Destination | Out-String | Write-WhiskeyDebug
                }

                Get-Member -InputObject $Destination -MemberType Property |
                    Where-Object { $ExcludeProperty -notcontains $_.Name } |
                    Where-Object {
                        $name = $_.Name
                        if( -not $name )
                        {
                            return
                        }

                        $value = $Destination.$name
                        if( $value -eq $null )
                        {
                            return
                        }

                        Write-WhiskeyDebug ('Destination.{0,-20} -eq $null ? {1}' -f $name,($value -eq $null))
                        Write-WhiskeyDebug (' .{0,-20} is {1}' -f $name,$value.GetType())
                        return (Get-Member -InputObject $value -Name 'Keys') -or ($value -is [Collections.IList])
                    } |
                    ForEach-Object {
                        $propertyName = $_.Name
                        Write-WhiskeyDebug -Message ('{0}.{1} -> {2}.{1}' -f $Source.GetType(),$propertyName,$Destination.GetType())
                        $destinationObject = $Destination.$propertyName
                        $sourceObject = $source.$propertyName
                        if( (Get-Member -InputObject $destinationObject -Name 'Keys') )
                        {
                            $keys = $sourceObject.Keys
                            foreach( $key in $keys )
                            {
                                $value = $sourceObject[$key]
                                Write-WhiskeyDebug (' [{0,-20}] -> {1}' -f $key,$value)
                                $destinationObject[$key] = $sourceObject[$key]
                            }
                        }
                        elseif( $destinationObject -is [Collections.IList] )
                        {
                            $idx = 0
                            foreach( $item in $sourceObject )
                            {
                                Write-WhiskeyDebug(' [{0}] {1}' -f $idx++,$item)
                                $destinationObject.Add($item)
                            }
                        }
                    }
            }

            $buildInfo = New-WhiskeyBuildMetadataObject
            Sync-ObjectProperty -Source $InputObject.BuildMetadata -Destination $buildInfo -Exclude @( 'BuildServer' )
            if( $InputObject.BuildMetadata.BuildServer )
            {
                $buildInfo.BuildServer = $InputObject.BuildMetadata.BuildServer
            }

            $buildVersion = New-WhiskeyVersionObject
            Sync-ObjectProperty -Source $InputObject.Version -Destination $buildVersion -ExcludeProperty @( 'SemVer1', 'SemVer2', 'SemVer2NoBuildMetadata' )
            if( $InputObject.Version )
            {
                if( $InputObject.Version.SemVer1 )
                {
                    $buildVersion.SemVer1 = $InputObject.Version.SemVer1.ToString()
                }

                if( $InputObject.Version.SemVer2 )
                {
                    $buildVersion.SemVer2 = $InputObject.Version.SemVer2.ToString()
                }

                if( $InputObject.Version.SemVer2NoBuildMetadata )
                {
                    $buildVersion.SemVer2NoBuildMetadata = $InputObject.Version.SemVer2NoBuildMetadata.ToString()
                }
            }

            [Whiskey.Context]$context = New-WhiskeyContextObject
            Sync-ObjectProperty -Source $InputObject -Destination $context -ExcludeProperty @( 'BuildMetadata', 'Configuration', 'Version', 'Credentials', 'TaskPaths', 'ApiKeys' )
            if( $context.ConfigurationPath )
            {
                $context.Configuration = Import-WhiskeyYaml -Path $context.ConfigurationPath
            }

            $context.BuildMetadata = $buildInfo
            $context.Version = $buildVersion

            foreach( $credentialID in $InputObject.Credentials.Keys )
            {
                $serializedCredential = $InputObject.Credentials[$credentialID]
                $username = $serializedCredential.UserName
                $password = ConvertTo-SecureString -String $serializedCredential.Password -Key $InputObject.CredentialKey
                [pscredential]$credential = New-Object 'pscredential' $username,$password
                Add-WhiskeyCredential -Context $context -ID $credentialID -Credential $credential
            }

            foreach( $apiKeyID in $InputObject.ApiKeys.Keys )
            {
                $serializedApiKey = $InputObject.ApiKeys[$apiKeyID]
                $apiKey = ConvertTo-SecureString -String $serializedApiKey -Key $InputObject.CredentialKey
                Add-WhiskeyApiKey -Context $context -ID $apiKeyID -Value $apiKey
            }

            foreach( $path in $InputObject.TaskPaths )
            {
                $context.TaskPaths.Add((New-Object -TypeName 'IO.FileInfo' -ArgumentList $path))
            }

            Write-WhiskeyDebug 'Variables'
            $context.Variables | ConvertTo-Json -Depth 50 | Write-WhiskeyDebug
            Write-WhiskeyDebug 'ApiKeys'
            $context.ApiKeys | ConvertTo-Json -Depth 50 | Write-WhiskeyDebug
            Write-WhiskeyDebug 'Credentials'
            $context.Credentials | ConvertTo-Json -Depth 50 | Write-WhiskeyDebug
            Write-WhiskeyDebug 'TaskDefaults'
            $context.TaskDefaults | ConvertTo-Json -Depth 50 | Write-WhiskeyDebug
            Write-WhiskeyDebug 'TaskPaths'
            $context.TaskPaths | ConvertTo-Json | Write-WhiskeyDebug

            return $context
        }
        finally
        {
            # Don't leave the decryption key lying around.
            [Array]::Clear($InputObject.CredentialKey,0,$InputObject.CredentialKey.Length)
        }
    }
}



function ConvertTo-WhiskeySemanticVersion
{
    <#
    .SYNOPSIS
    Converts an object to a semantic version.
 
    .DESCRIPTION
    The `ConvertTo-WhiskeySemanticVersion` function converts strings, numbers, and date/time objects to semantic versions. If the conversion fails, it writes an error and you get nothing back.
 
    .EXAMPLE
    '1.2.3' | ConvertTo-WhiskeySemanticVersion
 
    Demonstrates how to convert an object into a semantic version.
    #>

    [CmdletBinding()]
    param(
        [Parameter(ValueFromPipeline)]
        # The object to convert to a semantic version. Can be a version string, number, or date/time.
        [Object]$InputObject
    )

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

        [Version]$asVersion = $null
        if( $InputObject -is [String] )
        {
            [int]$asInt = 0
            [Double]$asDouble = 0.0
            [SemVersion.SemanticVersion]$semVersion = $null
            if( [SemVersion.SemanticVersion]::TryParse($InputObject,[ref]$semVersion) )
            {
                return $semVersion
            }

            if( [Version]::TryParse($InputObject,[ref]$asVersion) )
            {
                $InputObject = $asVersion
            }
            elseif( [int]::TryParse($InputObject,[ref]$asInt) )
            {
                $InputObject = $asInt
            }
        }
        
        if( $InputObject -is [SemVersion.SemanticVersion] )
        {
            return $InputObject
        }
        elseif( $InputObject -is [DateTime] )
        {
            $InputObject = '{0}.{1}.{2}' -f $InputObject.Month,$InputObject.Day,$InputObject.Year
        }
        elseif( $InputObject -is [Double] )
        {
            $major,$minor = $InputObject.ToString('g') -split '\.'
            if( -not $minor )
            {
                $minor = '0'
            }
            $InputObject = '{0}.{1}.0' -f $major,$minor
        }
        elseif( $InputObject -is [int] )
        {
            $InputObject = '{0}.0.0' -f $InputObject
        }
        elseif( $InputObject -is [Version] )
        {
            if( $InputObject.Build -le -1 )
            {
                $InputObject = '{0}.0' -f $InputObject
            }
            else
            {
                $InputObject = $InputObject.ToString()
            }
        }
        else
        {
            Write-WhiskeyError -Message ('Unable to convert ''{0}'' to a semantic version. We tried parsing it as a version, date, double, and integer. Sorry. But we''re giving up.' -f $PSBoundParameters['InputObject'])
            return
        }

        $semVersion = $null
        if( ([SemVersion.SemanticVersion]::TryParse($InputObject,[ref]$semVersion)) )
        {
            return $semVersion
        }

        Write-WhiskeyError -Message ('Unable to convert ''{0}'' of type ''{1}'' to a semantic version.' -f $PSBoundParameters['InputObject'],$PSBoundParameters['InputObject'].GetType().FullName)
    }
}




function ConvertTo-WhiskeyTask
{
    <#
    .SYNOPSIS
    Converts an object parsed from a whiskey.yml file into a task name and task parameters.
 
    .DESCRIPTION
    The `ConvertTo-WhiskeyTask` function takes an object parsed from a whiskey.yml file and converts it to a task name and hashtable of parameters and returns both in that order.
 
    .EXAMPLE
    $name,$parameter = ConvertTo-WhiskeyTask -InputObject $parsedTask
    #>

    [CmdletBinding()]
    param(
        [Parameter(Mandatory)]
        [AllowNull()]
        [Object]$InputObject
    )

    Set-StrictMode -Version 'Latest'
    Use-CallerPreference -Cmdlet $PSCmdlet -SessionState $ExecutionContext.SessionState
    
    if( $InputObject -is [String] )
    {
        $InputObject
        @{ }
        return
    }
    elseif( $InputObject -and ($InputObject | Get-Member -Name 'Keys') )
    {
        $taskName = $InputObject.Keys | Select-Object -First 1
        $parameter = $InputObject[$taskName]
        if( -not $parameter )
        {
            $parameter = @{ }
        }
        elseif( -not ($parameter | Get-Member -Name 'Keys') )
        {
            $parameter = @{ '' = $parameter }
        }
        $taskName
        $parameter
        return
    }

    # Convert back to YAML to display its invalidness to the user.
    $builder = New-Object 'YamlDotNet.Serialization.SerializerBuilder'
    $yamlWriter = New-Object "System.IO.StringWriter"
    $serializer = $builder.Build()
    $serializer.Serialize($yamlWriter, $InputObject)
    $yaml = $yamlWriter.ToString()
    $yaml = $yaml -split [regex]::Escape([Environment]::NewLine) |
                Where-Object { @( '...', '---' ) -notcontains $_ } |
                ForEach-Object { ' {0}' -f $_ }
    Write-WhiskeyError -Message ('Invalid task YAML:{0} {0}{1}{0}A task must have a name followed by optional parameters, e.g.
  
    Build:
    - Task1
    - Task2:
        Parameter1: Value1
        Parameter2: Value2
  
    '
 -f [Environment]::NewLine,($yaml -join [Environment]::NewLine))
}



function Convert-WhiskeyPathDirectorySeparator
{
    <#
    .SYNOPSIS
    Converts the directory separators in a path to the preferred separator for the current platform.
 
    .DESCRIPTION
    The `Convert-WhiskeyPathDirectorySeparator` function uses PowerShell's `Join-Path` cmdlet to convert the directory separator characters in a path to the ones for the current platform. The path does not have to exist. It joins the path with the `.` character, then trims all periods and
 
    .EXAMPLE
    $Path | Convert-WhiskeyPathDirectorySeparator
 
    Demonstrates how to use this function by piping paths to it.
    #>

    [CmdletBinding()]
    param(
        [Parameter(Mandatory,ValueFromPipeline)]
        [String]$Path
    )

    process
    {
        $Path = Join-Path -Path $Path -ChildPath '.'
        # Take off the '.' period we added.
        $Path = $Path.TrimEnd('.')
        # Now remove the extra separator we added.
        return $Path.TrimEnd([IO.Path]::DirectorySeparatorChar, [IO.Path]::AltDirectorySeparatorChar);
    }
}


function Get-MSBuild
{
    [CmdletBinding()]
    param(
    )

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

    function Resolve-MSBuildToolsPath
    {
        param(
            [Microsoft.Win32.RegistryKey]$Key
        )

        $toolsPath = Get-ItemProperty -Path $Key.PSPath -Name 'MSBuildToolsPath' -ErrorAction Ignore | Select-Object -ExpandProperty 'MSBuildToolsPath' -ErrorAction Ignore
        if( -not $toolsPath )
        {
            return ''
        }

        $path = Join-Path -Path $toolsPath -ChildPath 'MSBuild.exe'
        if( (Test-Path -Path $path -PathType Leaf) )
        {
            return $path
        }

        return ''
    }

    filter Test-Version
    {
        param(
            [Parameter(Mandatory,ValueFromPipeline)]
            $InputObject
        )

        [Version]$version = $null
        [Version]::TryParse($InputObject,[ref]$version)

    }

    $toolsVersionRegPath = 'HKLM:\SOFTWARE\Microsoft\MSBuild\ToolsVersions'
    $toolsVersionRegPath32 = 'HKLM:\SOFTWARE\Wow6432Node\Microsoft\MSBuild\ToolsVersions'
    $tools32Exists = Test-Path -Path $toolsVersionRegPath32 -PathType Container

    foreach( $key in (Get-ChildItem -Path $toolsVersionRegPath) )
    {
        $name = $key.Name | Split-Path -Leaf
        if( -not ($name | Test-Version) )
        {
            continue
        }

        $msbuildPath = Resolve-MSBuildToolsPath -Key $key
        if( -not $msbuildPath )
        {
            continue
        }

        $msbuildPath32 = $msbuildPath
        if( $tools32Exists )
        {
            $key32 = Get-ChildItem -Path $toolsVersionRegPath32 | Where-Object { ($_.Name | Split-Path -Leaf) -eq $name }
            if( $key32 )
            {
                $msbuildPath32 = Resolve-MSBuildToolsPath -Key $key32
            }
            else
            {
                $msbuildPath32 = ''
            }
        }

        [pscustomobject]@{
            Name = $name;
            Version = [Version]$name;
            Path = $msbuildPath;
            Path32 = $msbuildPath32;
        }
    }

    foreach( $instance in (Get-VSSetupInstance) )
    {
        $msbuildRoot = Join-Path -Path $instance.InstallationPath -ChildPath 'MSBuild'
        if( -not (Test-Path -Path $msbuildRoot -PathType Container) )
        {
            Write-WhiskeyVerbose -Message ('Skipping {0} {1}: its MSBuild directory ''{2}'' doesn''t exist.' -f $instance.DisplayName,$instance.InstallationVersion,$msbuildRoot)
            continue
        }

        $versionRoots =
            Get-ChildItem -Path $msbuildRoot -Directory |
            Where-Object { Get-ChildItem -Path $_.FullName -Filter 'MSBuild.exe' -Recurse }

        foreach( $versionRoot in $versionRoots )
        {
            $paths = Get-ChildItem -Path $versionRoot.FullName -Filter 'MSBuild.exe' -Recurse

            $path =
                $paths |
                Where-Object { $_.Directory.Name -eq 'amd64' } |
                Select-Object -ExpandProperty 'FullName'

            $path32 =
                $paths |
                Where-Object { $_.Directory.Name -ne 'amd64' } |
                Select-Object -ExpandProperty 'FullName'

            if( -not $path )
            {
                $path = $path32
            }

            if( -not $path )
            {
                continue
            }

            if( -not $path32 )
            {
                $path32 = ''
            }

            $majorVersion =
                Get-Item -Path $path |
                Select-Object -ExpandProperty 'VersionInfo' |
                Select-Object -ExpandProperty 'ProductMajorPart'

            $majorMinor = '{0}.0' -f $majorVersion

            [pscustomobject]@{
                                Name =  $majorMinor;
                                Version = [Version]$majorMinor;
                                Path = $path;
                                Path32 = $path32;
                            }
        }
    }
}



function Get-TaskParameter
{
    [CmdletBinding()]
    param(
        # The name of the command.
        [Parameter(Mandatory)]
        [String]$Name,

        # The properties from the tasks's YAML.
        [Parameter(Mandatory)]
        [hashtable]$TaskProperty,

        # The current context.
        [Parameter(Mandatory)]
        [Whiskey.Context]$Context
    )

    Set-StrictMode -Version 'Latest'
    Use-CallerPreference -Cmdlet $PSCmdlet -SessionState $ExecutionContext.SessionState
    
    # Parameters of the actual command.
    $cmdParameters =
        Get-Command -Name $task.CommandName |
        Select-Object -ExpandProperty 'Parameters'

    # Parameters to pass to the command.
    $taskParameters = @{ }

    [Management.Automation.ParameterMetadata]$cmdParameter = $null

    foreach( $cmdParameter in $cmdParameters.Values )
    {
        $propertyName = $cmdParameter.Name

        $value = $TaskProperty[$propertyName]

        if( -not $value )
        {
            foreach( $aliasName in $cmdParameter.Aliases )
            {
                $value = $TaskProperty[$aliasName]
                if( $value )
                {
                    Write-WhiskeyWarning -Context $Context -Message ('Property "{0}" is deprecated. Rename to "{1}" instead.' -f $aliasName,$propertyName)
                    break
                }
            }
        }

        # PowerShell can't implicitly convert strings to bool/switch values so we have to do it.
        if( $cmdParameter.ParameterType -eq [switch] -or $cmdParameter.ParameterType -eq [bool] )
        {
            $value = $value | ConvertFrom-WhiskeyYamlScalar
        }

        [Whiskey.Tasks.ParameterValueFromVariableAttribute]$valueFromVariableAttr =
            $cmdParameter.Attributes |
            Where-Object { $_ -is [Whiskey.Tasks.ParameterValueFromVariableAttribute] }

        if( $valueFromVariableAttr )
        {
            $value = Resolve-WhiskeyVariable -InputObject ('$({0})' -f $valueFromVariableAttr.VariableName) `
                                             -Context $Context
        }

        [Whiskey.Tasks.ValidatePathAttribute]$validatePathAttribute =
            $cmdParameter.Attributes |
            Where-Object { $_ -is [Whiskey.Tasks.ValidatePathAttribute] }

        if( $validatePathAttribute )
        {
            $params = @{ }

            $params['CmdParameter'] = $cmdParameter
            $params['ValidatePathAttribute'] = $validatePathAttribute
            $value = $value | Resolve-WhiskeyTaskPath -TaskContext $Context -TaskParameter $TaskProperty @params
        }

        # If the user didn't provide a value and we couldn't find one, don't pass anything.
        if( -not $TaskProperty.ContainsKey($propertyName) -and -not $value )
        {
            continue
        }

        $taskParameters[$propertyName] = $value
        $TaskProperty.Remove($propertyName)
    }

    foreach( $name in @( 'TaskContext', 'Context' ) )
    {
        if( $cmdParameters.ContainsKey($name) )
        {
            $taskParameters[$name] = $Context
        }
    }

    foreach( $name in @( 'TaskParameter', 'Parameter' ) )
    {
        if( $cmdParameters.ContainsKey($name) )
        {
            $taskParameters[$name] = $TaskProperty
        }
    }

    return $taskParameters
}



function Get-WhiskeyApiKey
{
    <#
    .SYNOPSIS
    Gets an API key from the Whiskey API key store.
 
    .DESCRIPTION
    The `Get-WhiskeyApiKey` function returns an API key from Whiskey's API key store. If the API key doesn't exist, the current build stops (i.e. a terminating exception is thrown).
 
    Credentials are identified by an ID that you create. Credentials are added using `Add-WhiskeyCredential`. Credentials are used by tasks. You specify the credential's ID in tasks section of the `whiskey.yml` file. See the documentation for each task for more details.
    API keys are identified by an ID that you create. API keys are added using `Add-WhiskeyApiKey`. API keys are used by tasks. You specify the API keys' ID in the task's section of the `whiskey.yml` file. See the documentation for each task for more details.
 
    .EXAMPLE
    Get-WhiskeyApiKey -Context $context -ID 'nuget.org' -PropertyName 'ApiKeyID'
 
    Demonstrates how to get an API key. IN this case, retrieves the API key that was added with the ID `nuget.org`.
    #>

    [CmdletBinding()]
    param(
        [Parameter(Mandatory)]
        # The current build context. Use `New-WhiskeyContext` to create context objects.
        [Whiskey.Context]$Context,

        [Parameter(Mandatory)]
        # The ID of the API key. You make this up.
        [String]$ID,

        [Parameter(Mandatory)]
        # The property name in the task that needs this API key. Used in error messages to help users pinpoint what task and property might be misconfigured.
        [String]$PropertyName
    )

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

    if( -not $Context.ApiKeys.ContainsKey($ID) )
    {
        Stop-WhiskeyTask -TaskContext $Context `
                         -Message ('API key ''{0}'' does not exist in Whiskey''s API key store. Use the `Add-WhiskeyApiKey` function to add this API key, e.g. `Add-WhiskeyApiKey -Context $context -ID ''{0}'' -Value $apikey`.' -f $ID) `
                         -PropertyName $PropertyName
        return
    }

    $secureString = $Context.ApiKeys[$ID]

    $convertFromSecureString = Get-Command -Name 'ConvertFrom-SecureString'
    if( $convertFromSecureString.Parameters.ContainsKey('AsPlainText') )
    {
        ConvertFrom-SecureString -SecureString $secureString -AsPlainText
    }
    else
    {
        $stringPtr = [Runtime.InteropServices.Marshal]::SecureStringToBSTR($SecureString)
        return [Runtime.InteropServices.Marshal]::PtrToStringBSTR($stringPtr)
    }
}



function Get-WhiskeyBuildMetadata
{
    <#
    SYNOPSIS
    Gets metadata about the current build.
 
    .DESCRIPTION
    The `Get-WhiskeyBuildMetadata` function gets information about the current build. It is exists to hide what CI server the current build is running under. It returns an object with the following properties:
 
    * `ScmUri`: the URI to the source control repository used in this build.
    * `BuildNumber`: the build number of the current build. This is the incrementing number most CI servers used to identify a build of a specific job.
    * `BuildID`: this unique identifier for this build. Usually, this is used by CI servers to distinguish this build from builds across all jobs.
    * `ScmCommitID`: the full ID of the commit that is being built.
    * `ScmBranch`: the branch name of the commit that is being built.
    * `JobName`: the name of the job that is running the build.
    * `BuildUri`: the URI to this build's results.
 
    #>

    [CmdletBinding()]
    param(
    )

    Set-StrictMode -Version 'Latest'

    function Get-EnvironmentVariable
    {
        param(
            $Name
        )

        Get-Item -Path ('env:{0}' -f $Name) | Select-Object -ExpandProperty 'Value'
    }

    $buildInfo = New-WhiskeyBuildMetadataObject

    if( (Test-Path -Path 'env:JENKINS_URL') )
    {
        $buildInfo.BuildNumber = Get-EnvironmentVariable 'BUILD_NUMBER'
        $buildInfo.BuildID = Get-EnvironmentVariable 'BUILD_TAG'
        $buildInfo.BuildUri = Get-EnvironmentVariable 'BUILD_URL'
        $buildInfo.JobName = Get-EnvironmentVariable 'JOB_NAME'
        $buildInfo.JobUri = Get-EnvironmentVariable 'JOB_URL'
        $buildInfo.ScmUri = Get-EnvironmentVariable 'GIT_URL'
        $buildInfo.ScmCommitID = Get-EnvironmentVariable 'GIT_COMMIT'
        $buildInfo.ScmBranch = Get-EnvironmentVariable 'GIT_BRANCH'
        $buildInfo.ScmBranch = $buildInfo.ScmBranch -replace '^origin/',''
        $buildInfo.BuildServer = [Whiskey.BuildServer]::Jenkins
    }
    elseif( (Test-Path -Path 'env:APPVEYOR') )
    {
        $buildInfo.BuildNumber = Get-EnvironmentVariable 'APPVEYOR_BUILD_NUMBER'
        $buildInfo.BuildID = Get-EnvironmentVariable 'APPVEYOR_BUILD_ID'
        $accountName = Get-EnvironmentVariable 'APPVEYOR_ACCOUNT_NAME'
        $projectSlug = Get-EnvironmentVariable 'APPVEYOR_PROJECT_SLUG'
        $projectUri = 'https://ci.appveyor.com/project/{0}/{1}' -f $accountName,$projectSlug
        $buildVersion = Get-EnvironmentVariable 'APPVEYOR_BUILD_VERSION'
        $buildUri = '{0}/build/{1}' -f $projectUri,$buildVersion
        $buildInfo.BuildUri = $buildUri
        $buildInfo.JobName = Get-EnvironmentVariable 'APPVEYOR_PROJECT_NAME'
        $buildInfo.JobUri = $projectUri
        $baseUri = ''
        switch( (Get-EnvironmentVariable 'APPVEYOR_REPO_PROVIDER') )
        {
            'gitHub'
            {
                $baseUri = 'https://github.com'
            }
            default
            {
                Write-WhiskeyError -Message ('Unsupported AppVeyor source control provider ''{0}''. If you''d like us to add support for this provider, please submit a new issue at https://github.com/webmd-health-services/Whiskey/issues. Copy/paste your environment variables from this build''s output into your issue.' -f $_)
            }
        }
        $repoName = Get-EnvironmentVariable 'APPVEYOR_REPO_NAME'
        $buildInfo.ScmUri = '{0}/{1}.git' -f $baseUri,$repoName
        $buildInfo.ScmCommitID = Get-EnvironmentVariable 'APPVEYOR_REPO_COMMIT'
        $buildInfo.ScmBranch = Get-EnvironmentVariable 'APPVEYOR_REPO_BRANCH'
        $buildInfo.BuildServer = [Whiskey.BuildServer]::AppVeyor
    }
    elseif( (Test-Path -Path 'env:TEAMCITY_BUILD_PROPERTIES_FILE') )
    {
        function Import-TeamCityProperty
        {
            [OutputType([hashtable])]
            param(
                $Path
            )

            $properties = @{ }
            Get-Content -Path $Path |
                Where-Object { $_ -match '^([^=]+)=(.*)$' } |
                ForEach-Object { $properties[$Matches[1]] = $Matches[2] -replace '\\(.)','$1' }
            $properties
        }

        $buildInfo.BuildNumber = Get-EnvironmentVariable 'BUILD_NUMBER'
        $buildInfo.ScmCommitID = Get-EnvironmentVariable 'BUILD_VCS_NUMBER'
        $buildPropertiesPath = Get-EnvironmentVariable 'TEAMCITY_BUILD_PROPERTIES_FILE'

        $buildProperties = Import-TeamCityProperty -Path $buildPropertiesPath
        $buildInfo.BuildID = $buildProperties['teamcity.build.id']
        $buildInfo.JobName = $buildProperties['teamcity.buildType.id']
        
        $configProperties = Import-TeamCityProperty -Path $buildProperties['teamcity.configuration.properties.file']
        $buildInfo.ScmBranch = $configProperties['teamcity.build.branch'] -replace '^refs/heads/',''
        $buildInfo.ScmUri = $configProperties['vcsroot.url']
        $buildInfo.BuildServer = [Whiskey.BuildServer]::TeamCity

        $serverUri = $configProperties['teamcity.serverUrl']
        $buildInfo.JobUri = '{0}/viewType.html?buildTypeId={1}' -f $serverUri,$buildInfo.JobName
        $buildInfo.BuildUri = '{0}/viewLog.html?buildId={1}&buildTypeId={2}' -f $serverUri,$buildInfo.BuildID,$buildInfo.JobName
    }

    return $buildInfo
}




function Get-WhiskeyContext
{
    [CmdletBinding()]
    [OutputType([Whiskey.Context])]
    param(
    )

    Set-StrictMode -Version 'Latest'
    Use-CallerPreference -Cmdlet $PSCmdlet -SessionState $ExecutionContext.SessionState
    
    [Management.Automation.CallStackFrame[]]$callstack = Get-PSCallStack
    # Skip myself.
    for( $idx = 1; $idx -lt $callstack.Length; ++$idx )
    {
        $frame = $callstack[$idx]
        $invokeInfo = $frame.InvocationInfo

        if( -not $invokeInfo.MyCommand -or $invokeInfo.MyCommand.ModuleName -ne 'Whiskey' )
        {
            # Nice try!
            continue
        }

        $frameParams = $invokeInfo.BoundParameters
        foreach( $parameterName in $frameParams.Keys )
        {
            $value = $frameParams[$parameterName]
            if( $null -ne $value -and $value -is [Whiskey.Context] )
            {
                return $value
            }
        }
    }
}


function Get-WhiskeyCredential
{
    <#
    .SYNOPSIS
    Gets a credential from the Whiskey credential store.
 
    .DESCRIPTION
    The `Get-WhiskeyCredential` function returns a credential from Whiskey's credential store. If the credential doesn't exist, the current build stops (i.e. a terminating exception is thrown).
 
    Credentials are identified by an ID that you create. Credentials are added using `Add-WhiskeyCredential`. Credentials are used by tasks. You specify the credential's ID in the task's section of the `whiskey.yml` file. See the documentation for each task for more details.
 
    .EXAMPLE
    Get-WhiskeyCredential -Context $context -ID 'bitbucketserver.example.com' -PropertyName 'CredentialID'
 
    Demonstrates how to get a credential. IN this case, retrieves the credential that was added with the ID `bitbucketserver.example.com`. #>

    [CmdletBinding()]
    param(
        [Parameter(Mandatory)]
        # The current build context. Use `New-WhiskeyContext` to create context objects.
        [Whiskey.Context]$Context,

        [Parameter(Mandatory)]
        # The ID of the credential. You make this up.
        [String]$ID,

        [Parameter(Mandatory)]
        # The property name in the task that needs this credential. Used in error messages to help users pinpoint what task and property might be misconfigured.
        [String]$PropertyName,

        # INTERNAL. DO NOT USE.
        [String]$PropertyDescription
    )

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

    if( -not $Context.Credentials.ContainsKey($ID) )
    {
        $propertyDescriptionParam = @{ }
        if( $PropertyDescription )
        {
            $propertyDescriptionParam['PropertyDescription'] = $PropertyDescription
        }
        Stop-WhiskeyTask -TaskContext $Context `
                         -Message ('Credential "{0}" does not exist in Whiskey''s credential store. Use the `Add-WhiskeyCredential` function to add this credential, e.g. `Add-WhiskeyCredential -Context $context -ID ''{0}'' -Credential $credential`.' -f $ID) `
                         @propertyDescriptionParam
        return
    }

    return $Context.Credentials[$ID]
}



function Get-WhiskeyMSBuildConfiguration
{
    <#
    .SYNOPSIS
    Gets the configuration to use when running any MSBuild-based task/tool.
 
    .DESCRIPTION
    The `Get-WhiskeyMSBuildConfiguration` function gets the configuration to use when running any MSBuild-based task/tool (e.g. the `MSBuild`, `DotNetBuild`, `DotNetPublish`, etc.). By default, the value is `Debug` when the build is being run by a developer and `Release` when run by a build server.
 
    Use `Set-WhiskeyMSBuildConfiguration` to change the current configuration.
 
    .EXAMPLE
    Get-WhiskeyMSBuildConfiguration -Context $Context
 
    Gets the configuration to use when runinng an MSBuild-based task/tool
    #>

    [CmdletBinding()]
    param(
        [Parameter(Mandatory)]
        # The context of the current build.
        [Whiskey.Context]$Context
    )

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

    if( -not $Context.MSBuildConfiguration )
    {
        $configuration = 'Debug'
        if( $Context.ByBuildServer )
        {
            $configuration = 'Release'
        }
        Set-WhiskeyMSBuildConfiguration -Context $Context -Value $configuration
    }
    return $Context.MSBuildConfiguration
}


function Get-WhiskeyTask
{
    <#
    .SYNOPSIS
    Returns a list of available Whiskey tasks.
 
    .DESCRIPTION
    The `Get-WhiskeyTask` function returns a list of all available Whiskey tasks. Obsolete tasks are not returned. If you also want obsolete tasks returned, use the `-Force` switch.
 
    .EXAMPLE
    Get-WhiskeyTask
 
    Demonstrates how to get a list of all non-obsolete Whiskey tasks.
 
    .EXAMPLE
    Get-WhiskeyTask -Force
 
    Demonstrates how to get a list of all Whiskey tasks, including those that are obsolete.
    #>

    [CmdLetBinding()]
    [OutputType([Whiskey.TaskAttribute])]
    param(
        # Return tasks that are obsolete. Otherwise, no obsolete tasks are returned.
        [switch]$Force
    )

    Set-StrictMode -Version 'Latest'
    Use-CallerPreference -Cmdlet $PSCmdlet -SessionState $ExecutionContext.SessionState
    
    [Management.Automation.FunctionInfo]$functionInfo = $null;
    
    foreach( $functionInfo in (Get-Command -CommandType Function) )
    {
        $functionInfo.ScriptBlock.Attributes | 
            Where-Object { $_ -is [Whiskey.TaskAttribute] } |
            ForEach-Object {
                $_.CommandName = $functionInfo.Name
                $_
            } |
            Where-Object {
                if( $Force )
                {
                    $true
                }
                return -not $_.Obsolete
            }
    }
}


function Import-WhiskeyPowerShellModule
{
    <#
    .SYNOPSIS
    Imports a PowerShell module.
 
    .DESCRIPTION
    The `Import-WhiskeyPowerShellModule` function imports a PowerShell module that is needed/used by a Whiskey task. Since Whiskey tasks all run in the module's scope, the imported modules are imported into the global scope. If a module with the same name is currently loaded, it is removed and re-imported.
 
    The module must be installed in Whiskey's PowerShell modules directory. Use the `RequiresTool` attribute on a task to have Whiskey install a module in this directory or the `GetPowerShellModule` task to install a module in the appropriate place.
 
    Pass the name of the modules to the `Name` parameter.
 
    .EXAMPLE
    Import-WhiskeyPowerShellModule -Name 'BuildMasterAutomtion'
 
    Demonstrates how to use this method to import a single module.
 
    .EXAMPLE
    Import-WhiskeyPowerShellModule -Name 'BuildMasterAutomtion','ProGetAutomation'
 
    Demonstrates how to use this method to import multiple modules.
    #>

    [CmdletBinding()]
    param(
        [Parameter(Mandatory)]
        # The module names to import.
        [String[]]$Name,

        [Parameter(Mandatory)]
        # The path to the build root, where the PSModules directory can be found.
        [String]$PSModulesRoot
    )

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

    & {
        $VerbosePreference = 'SilentlyContinue'
        Get-Module -Name $Name | Remove-Module -Force -WhatIf:$false
    }

    $relativePSModulesRoot = Resolve-Path -Path $PSModulesRoot -Relative -ErrorAction Ignore

    foreach( $moduleName in $Name )
    {
        $moduleDir = Join-Path -Path $PSModulesRoot -ChildPath $moduleName
        if( (Test-Path -Path $moduleDir -PathType Container) )
        {
            Write-WhiskeyDebug -Message ('PSModuleAutoLoadingPreference = "{0}"' -f $PSModuleAutoLoadingPreference)
            Write-WhiskeyVerbose -Message ('Importing PowerShell module "{0}" from "{1}".' -f $moduleName,$relativePSModulesRoot)
            $errorsBefore = $Global:Error.Clone()
            $Global:Error.Clear()
            try
            {
                & {
                    $VerbosePreference = 'SilentlyContinue'
                    Import-Module -Name $moduleDir -Global -Force -ErrorAction Stop -Verbose:$false
                } 4> $null
            }
            finally
            {
                # Some modules (...cough...PowerShellGet...cough...) write silent errors during import. This causes our
                # tests to fail. I know this is a little extreme.
                $Global:Error.Clear()
                $Global:Error.AddRange($errorsBefore)
            }
            break
        }

        if( -not (Get-Module -Name $moduleName) )
        {
            Write-WhiskeyError -Message ('Module "{0}" does not exist. Make sure your task uses the "RequiresTool" attribute so that the module gets installed automatically.' -f $moduleName) -ErrorAction Stop
        }
    }
}



function Import-WhiskeyYaml
{
    [CmdletBinding()]
    param(
        [Parameter(Mandatory,ParameterSetName='FromFile')]
        [String]$Path,

        [Parameter(Mandatory,ParameterSetName='FromString')]
        [String]$Yaml
    )

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

    if( $PSCmdlet.ParameterSetName -eq 'FromFile' )
    {
        $Yaml = Get-Content -Path $Path -Raw 
    }

    $builder = New-Object 'YamlDotNet.Serialization.DeserializerBuilder'
    $deserializer = $builder.Build()

    $reader = New-Object 'IO.StringReader' $Yaml
    $config = @{}
    try
    {
        $config = $deserializer.Deserialize( $reader )
    }
    finally
    {
        $reader.Close()
    }
    
    if( -not $config )
    {
        $config = @{} 
    }

    if( $config -is [String] )
    {
        $config = @{ $config = '' }
    }

    return $config
}



function Install-WhiskeyDotNetSdk
{
    <#
    .SYNOPSIS
    Installs the .NET Core SDK tooling.
 
    .DESCRIPTION
    The `Install-WhiskeyDotNetSdk` function will install the .NET Core SDK tools and return the path to the installed `dotnet.exe` command. If you specify the `Global` switch then the function will first look for any globally installed .NET Core SDK's with the desired version already installed. If one is found, then install is skipped and the path to the global install is returned. The function uses the `dotnet-install.ps1` script from the [dotnet-cli](https://github.com/dotnet/cli) GitHub repository to download and install the SDK.
 
    .EXAMPLE
    Install-WhiskeyDotNetSdk -InstallRoot 'C:\Build\.dotnet' -Version '2.1.4'
 
    Demonstrates installing .NET Core SDK version 2.1.4 to the 'C:\Build\.dotnet' directory. After install the function will return the path 'C:\Build\.dotnet\dotnet.exe'.
 
    .EXAMPLE
    Install-WhiskeyDotNetSdk -InstallRoot 'C:\Build\.dotnet' -Version '2.1.4' -Global
 
    Demonstrates searching for an existing global install of the .NET Core SDK version '2.1.4'. If not found globally, the SDK will be installed to 'C:\Build\.dotnet'.
    #>

    [CmdletBinding()]
    param(
        [Parameter(Mandatory)]
        # Directory where the .NET Core SDK will be installed.
        [String]$InstallRoot,

        [Parameter(Mandatory)]
        # Version of the .NET Core SDK to install.
        [String]$Version,

        # Search for the desired version from existing global installs of the .NET Core SDK. If found, the install is skipped and the path to the global install is returned.
        [switch]$Global
    )

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

    if ($Global)
    {
        $dotnetGlobalInstalls = Get-Command -Name 'dotnet' -All -ErrorAction Ignore | Select-Object -ExpandProperty 'Path'
        if ($dotnetGlobalInstalls)
        {
            Write-WhiskeyVerbose -Message ('[{0}] Found global installs of .NET Core SDK: "{1}"' -f $MyInvocation.MyCommand,($dotnetGlobalInstalls -join '","'))

            Write-WhiskeyVerbose -Message ('[{0}] Checking global installs for SDK version "{1}"' -f $MyInvocation.MyCommand,$Version)
            foreach ($dotnetPath in $dotnetGlobalInstalls)
            {
                $sdkPath = Join-Path -Path ($dotnetPath | Split-Path -Parent) -ChildPath ('sdk\{0}' -f $Version)

                if (Test-Path -Path $sdkPath -PathType Container)
                {
                    Write-WhiskeyVerbose ('[{0}] Found SDK version "{1}" at "{2}"' -f $MyInvocation.MyCommand,$Version,$sdkPath)
                    return $dotnetPath
                }
            }
        }

        Write-WhiskeyVerbose -Message ('[{0}] .NET Core SDK version "{1}" not found globally' -f $MyInvocation.MyCommand,$Version)
    }

    $verboseParam = @{}
    if ($VerbosePreference -eq 'Continue')
    {
        $verboseParam['Verbose'] = $true
    }

    Write-WhiskeyVerbose -Message ('[{0}] Installing .NET Core SDK version "{1}" to "{2}"' -f $MyInvocation.MyCommand,$Version,$InstallRoot)

    $dotnetInstallScript = Join-Path -Path $whiskeyBinPath -ChildPath 'dotnet-install.ps1' -Resolve
    $errorActionParam = @{ ErrorAction = 'Stop' }
    $installingWithShell = $false
    $executableName = 'dotnet.exe'
    if( $IsLinux -or $IsMacOS )
    {
        $dotnetInstallScript = Join-Path -Path $whiskeyBinPath -ChildPath 'dotnet-install.sh' -Resolve
        $errorActionParam = @{ }
        $installingWithShell = $true
        $executableName = 'dotnet'  
    }
    Invoke-Command -NoNewScope -ArgumentList $dotnetInstallScript,$InstallRoot,$Version,$verboseParam -ScriptBlock {
        param(
            $dotnetInstall,
            $InstallDir,
            $VersionNumber,
            $Verbose
        )

        $errCount = $Global:Error.Count
        & {
            if( $installingWithShell )
            {
                Write-WhiskeyVerbose ('bash {0} -InstallDir "{1}" -Version "{2}" -NoPath' -f $dotnetInstall,$InstallDir,$VersionNumber)
                bash $dotnetInstall -InstallDir $InstallDir -Version $VersionNumber -NoPath
            }
            else 
            {
                Write-WhiskeyVerbose ('{0} -InstallDir "{1}" -Version "{2}" -NoPath' -f $dotnetInstall,$InstallDir,$VersionNumber)
                & $dotnetInstall -InstallDir $InstallDir -Version $VersionNumber -NoPath @Verbose @errorActionParam
            }
        } | Write-WhiskeyVerbose
        if( $installingWithShell -and $LASTEXITCODE )
        {
            Write-WhiskeyError -Message ('{0} exited with code "{1}". Failed to install .NET Core.' -f $dotnetInstall,$LASTEXITCODE) -ErrorAction Stop
            return
        }
        $newErrCount = $Global:Error.Count
        for( $count = 0; $count -lt $newErrCount; ++$count )
        {
            $Global:Error.RemoveAt(0)
        }
    }

    $dotnetPath = Join-Path -Path $InstallRoot -ChildPath $executableName -Resolve -ErrorAction Ignore
    if (-not $dotnetPath)
    {
        Write-WhiskeyError -Message ('After attempting to install .NET Core SDK version "{0}", the "{1}" executable was not found in "{2}"' -f $Version,$executableName,$InstallRoot)
        return
    }

    $sdkPath = Join-Path -Path $InstallRoot -ChildPath ('sdk\{0}' -f $Version)
    if (-not (Test-Path -Path $sdkPath -PathType Container))
    {
        Write-WhiskeyError -Message ('The "{0}" command was installed but version "{1}" of the SDK was not found at "{2}"' -f $executableName,$Version,$sdkPath)
        return
    }

    return $dotnetPath
}



function Install-WhiskeyDotNetTool
{
    <#
    .SYNOPSIS
    Installs the .NET Core SDK tooling for a Whiskey task.
 
    .DESCRIPTION
    The `Install-WhiskeyDotNetTool` function installs the desired version of the .NET Core SDK for a Whiskey task. When given a `Version` the function will attempt to resolve that version to a valid released version of the SDK. If `Version` is null the function will search for a `global.json` file, first in the `WorkingDirectory` and then the `InstallRoot`, and if found it will look for the desired SDK verson in the `sdk.version` property of that file. After installing the SDK the function will update the `global.json`, creating it in the `InstallRoot` if it doesn't exist, `sdk.version` property with the installed version of the SDK. The function returns the path to the installed `dotnet.exe` command.
 
    .EXAMPLE
    Install-WhiskeyDotNetTool -InstallRoot 'C:\Build\Project' -WorkingDirectory 'C:\Build\Project\src' -Version '2.1.4'
     
    Demonstrates installing version '2.1.4' of the .NET Core SDK to a '.dotnet' directory in the 'C:\Build\Project' directory.
 
    .EXAMPLE
    Install-WhiskeyDotNetTool -InstallRoot 'C:\Build\Project' -WorkingDirectory 'C:\Build\Project\src' -Version '2.*'
     
    Demonstrates installing the latest '2.*' version of the .NET Core SDK to a '.dotnet' directory in the 'C:\Build\Project' directory.
 
    .EXAMPLE
    Install-WhiskeyDotNetTool -InstallRoot 'C:\Build\Project' -WorkingDirectory 'C:\Build\Project\src'
     
    Demonstrates installing the version of the .NET Core SDK specified in the `sdk.version` property of the `global.json` file found in either the `WorkingDirectory` or the `InstallRoot` paths.
    #>

    [CmdletBinding()]
    param(
        [Parameter(Mandatory)]
        # Path where the `.dotnet` directory will be installed containing the .NET Core SDK.
        [String]$InstallRoot,

        [Parameter(Mandatory)]
        # The working directory of the task requiring the .NET Core SDK tool. This path is used for searching for an existing `global.json` file containing an SDK version value.
        [String]$WorkingDirectory,

        [AllowEmptyString()]
        [AllowNull()]
        # The version of the .NET Core SDK to install. Accepts wildcards.
        [String]$Version
    )

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

    $globalJsonPath = Join-Path -Path $WorkingDirectory -ChildPath 'global.json'
    if (-not (Test-Path -Path $globalJsonPath -PathType Leaf))
    {
        $globalJsonPath = Join-Path -Path $InstallRoot -ChildPath 'global.json'
    }
    
    $sdkVersion = $null
    if ($Version)
    {
        Write-WhiskeyVerbose -Message ('[{0}] .NET Core SDK version ''{1}'' found in whiskey.yml' -f $MyInvocation.MyCommand,$Version)
        $sdkVersion = Resolve-WhiskeyDotNetSdkVersion -Version $Version
    }
    elseif (Test-Path -Path $globalJsonPath -PathType Leaf)
    {
        try
        {
            $globalJson = Get-Content -Path $globalJsonPath -Raw | ConvertFrom-Json
        }
        catch
        {
            Write-WhiskeyError -Message ('global.json file ''{0}'' contains invalid JSON.' -f $globalJsonPath)
            return
        }

        $globalJsonVersion = $globalJson |
                                 Select-Object -ExpandProperty 'sdk' -ErrorAction Ignore |
                                 Select-Object -ExpandProperty 'version' -ErrorAction Ignore
        
        if ($globalJsonVersion)
        {
            Write-WhiskeyVerbose -Message ('[{0}] .NET Core SDK version ''{1}'' found in ''{2}''' -f $MyInvocation.MyCommand,$globalJsonVersion,$globalJsonPath)
            $sdkVersion = Resolve-WhiskeyDotNetSdkVersion -Version $globalJsonVersion
        }
    }

    if (-not $sdkVersion)
    {
        Write-WhiskeyVerbose -Message ('[{0}] No specific .NET Core SDK version found in whiskey.yml or global.json. Using latest LTS version.' -f $MyInvocation.MyCommand)
        $sdkVersion = Resolve-WhiskeyDotNetSdkVersion -LatestLTS
    }

    $dotnetPath = Install-WhiskeyDotNetSdk -InstallRoot (Join-Path -Path $InstallRoot -ChildPath '.dotnet') -Version $sdkVersion -Global

    Set-WhiskeyDotNetGlobalJson -Directory ($globalJsonPath | Split-Path -Parent) -SdkVersion $sdkVersion

    return $dotnetPath
}



function Install-WhiskeyNode
{
    [CmdletBinding()]
    param(
        [Parameter(Mandatory)]
        # The directory where Node should be installed. Will actually be installed into `Join-Path -Path $InstallRoot -ChildPath '.node'`.
        [String]$InstallRoot,

        # Are we running in clean mode? If so, don't re-install the tool.
        [switch]$InCleanMode,

        # The version of Node to install. If not provided, will use the version defined in the package.json file. If that isn't supplied, will install the latest LTS version.
        [String]$Version
    )

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

    $nodePath = Resolve-WhiskeyNodePath -BuildRootPath $InstallRoot -ErrorAction Ignore

    if( $InCleanMode )
    {
        if( $nodePath )
        {
            return $nodePath
        }
        return
    }

    $npmVersionToInstall = $null
    $nodeVersionToInstall = $null
    $nodeVersions = Invoke-RestMethod -Uri 'https://nodejs.org/dist/index.json' | ForEach-Object { $_ }
    if( $Version )
    {
        $nodeVersionToInstall = $nodeVersions | Where-Object { $_.version -like 'v{0}' -f $Version } | Select-Object -First 1
        if( -not $nodeVersionToInstall )
        {
            throw ('Node v{0} does not exist.' -f $Version)
        }
    }
    else
    {
        $packageJsonPath = Join-Path -Path (Get-Location).ProviderPath -ChildPath 'package.json'
        if( -not (Test-Path -Path $packageJsonPath -PathType Leaf) )
        {
            $packageJsonPath = Join-Path -Path $InstallRoot -ChildPath 'package.json'
        }

        if( (Test-Path -Path $packageJsonPath -PathType Leaf) )
        {
            Write-WhiskeyVerbose -Message ('Reading ''{0}'' to determine Node and NPM versions to use.' -f $packageJsonPath)
            $packageJson = Get-Content -Raw -Path $packageJsonPath | ConvertFrom-Json
            if( $packageJson -and ($packageJson | Get-Member 'engines') )
            {
                if( ($packageJson.engines | Get-Member 'node') -and $packageJson.engines.node -match '(\d+\.\d+\.\d+)' )
                {
                    $nodeVersionToInstall = 'v{0}' -f $Matches[1]
                    $nodeVersionToInstall =  $nodeVersions |
                                                Where-Object { $_.version -eq $nodeVersionToInstall } |
                                                Select-Object -First 1
                }

                if( ($packageJson.engines | Get-Member 'npm') -and $packageJson.engines.npm -match '(\d+\.\d+\.\d+)' )
                {
                    $npmVersionToInstall = $Matches[1]
                }
            }
        }
    }

    if( -not $nodeVersionToInstall )
    {
        $nodeVersionToInstall = $nodeVersions |
                                    Where-Object { ($_ | Get-Member 'lts') -and $_.lts } |
                                    Select-Object -First 1
    }

    if( -not $npmVersionToInstall )
    {
        $npmVersionToInstall = $nodeVersionToInstall.npm
    }

    $installNode = $false
    if( $nodePath )
    {
        $currentNodeVersion = & $nodePath '--version'
        if( $currentNodeVersion -ne $nodeVersionToInstall.version )
        {
            Uninstall-WhiskeyNode -InstallRoot $InstallRoot
            $installNode = $true
        }
    }
    else
    {
        $installNode = $true
    }

    $nodeRoot = Join-Path -Path $InstallRoot -ChildPath '.node'
    if( -not (Test-Path -Path $nodeRoot -PathType Container) )
    {
        New-Item -Path $nodeRoot -ItemType 'Directory' -Force | Out-Null
    }

    $platform = 'win'
    $packageExtension = 'zip'
    if( $IsLinux )
    {
        $platform = 'linux'
        $packageExtension = 'tar.xz'
    }
    elseif( $IsMacOS )
    {
        $platform = 'darwin'
        $packageExtension = 'tar.gz'
    }

    $extractedDirName = 'node-{0}-{1}-x64' -f $nodeVersionToInstall.version,$platform
    $filename = '{0}.{1}' -f $extractedDirName,$packageExtension
    $nodeZipFile = Join-Path -Path $nodeRoot -ChildPath $filename
    if( -not (Test-Path -Path $nodeZipFile -PathType Leaf) )
    {
        $uri = 'https://nodejs.org/dist/{0}/{1}' -f $nodeVersionToInstall.version,$filename
        try
        {
            Invoke-WebRequest -Uri $uri -OutFile $nodeZipFile
        }
        catch
        {
            $responseInfo = ''
            $notFound = $false
            if( $_.Exception | Get-Member -Name 'Response' )
            {
                $responseStatus = $_.Exception.Response.StatusCode
                $responseInfo = 'Received a {0} ({1}) response.' -f $responseStatus,[int]$responseStatus
                if( $responseStatus -eq [Net.HttpStatusCode]::NotFound )
                {
                    $notFound = $true
                }
            }
            else
            {
                Write-WhiskeyError -ErrorRecord $_
                $responseInfo = 'Please see previous error for more information.'
            }

            $errorMsg = 'Failed to download Node {0} from {1}.{2}' -f $nodeVersionToInstall.version,$uri,$responseInfo
            if( $notFound )
            {
                $errorMsg = '{0} It looks like this version of Node wasn''t packaged as a ZIP file. Please use Node v4.5.0 or newer.' -f $errorMsg
            }
            Write-WhiskeyError -Message $errorMsg -ErrorAction Stop
            return
        }
    }

    if( $installNode )
    {
        if( $IsWindows )
        {
            # Windows/.NET can't handle the long paths in the Node package, so on that platform, we need to download 7-zip. It can handle paths that long.
            $7zipPackageRoot = Install-WhiskeyTool -NuGetPackageName '7-Zip.CommandLine' -DownloadRoot $InstallRoot
            $7z = Join-Path -Path $7zipPackageRoot -ChildPath 'tools\x64\7za.exe' -Resolve -ErrorAction Stop
            Write-WhiskeyVerbose -Message ('{0} x {1} -o{2} -y' -f $7z,$nodeZipFile,$nodeRoot)
            & $7z 'x' $nodeZipFile ('-o{0}' -f $nodeRoot) '-y' | Write-WhiskeyVerbose

            Get-ChildItem -Path $nodeRoot -Filter 'node-*' -Directory |
                Get-ChildItem |
                Move-Item -Destination $nodeRoot
        }
        else
        {
            Write-WhiskeyVerbose -Message ('tar -xJf "{0}" -C "{1}" --strip-components=1' -f $nodeZipFile,$nodeRoot)
            tar -xJf $nodeZipFile -C $nodeRoot '--strip-components=1' | Write-WhiskeyVerbose
            if( $LASTEXITCODE )
            {
                Write-WhiskeyError -Message ('Failed to extract Node.js {0} package "{1}" to "{2}".' -f $nodeVersionToInstall.version,$nodeZipFile,$nodeRoot)
                return
            }
        }

        $nodePath = Resolve-WhiskeyNodePath -BuildRootPath $InstallRoot -ErrorAction Stop
    }

    $npmPath = Resolve-WhiskeyNodeModulePath -Name 'npm' -NodeRootPath $nodeRoot -ErrorAction Stop
    $npmPath = Join-Path -Path $npmPath -ChildPath 'bin\npm-cli.js'
    $npmVersion = & $nodePath $npmPath '--version'
    if( $npmVersion -ne $npmVersionToInstall )
    {
        Write-WhiskeyVerbose ('Installing npm@{0}.' -f $npmVersionToInstall)
        # Bug in NPM 5 that won't delete these files in the node home directory.
        Get-ChildItem -Path (Join-Path -Path $nodeRoot -ChildPath '*') -Include 'npm.cmd','npm','npx.cmd','npx' | Remove-Item
        & $nodePath $npmPath 'install' ('npm@{0}' -f $npmVersionToInstall) '-g' | Write-WhiskeyVerbose
        if( $LASTEXITCODE )
        {
            throw ('Failed to update to NPM {0}. Please see previous output for details.' -f $npmVersionToInstall)
        }
    }

    return $nodePath
}



function Install-WhiskeyNodeModule
{
    <#
    .SYNOPSIS
    Installs Node.js modules
     
    .DESCRIPTION
    The `Install-WhiskeyNodeModule` function installs Node.js modules to the `node_modules` directory located in the current working directory. The path to the module's directory is returned.
 
    Failing to install a module does not cause a bulid to fail. If you want a build to fail if the module fails to install, you must pass `-ErrorAction Stop`.
     
    .EXAMPLE
    Install-WhiskeyNodeModule -Name 'rimraf' -Version '^2.0.0' -NodePath $TaskParameter['NodePath']
 
    This example will install the Node module `rimraf` at the latest `2.x.x` version in the `node_modules` directory located in the current directory.
     
    .EXAMPLE
    Install-WhiskeyNodeModule -Name 'rimraf' -Version '^2.0.0' -NodePath $TaskParameter['NodePath -ErrorAction Stop
 
    Demonstrates how to fail a build if installing the module fails by setting the `ErrorAction` parameter to `Stop`.
    #>

    
    [CmdletBinding()]
    param(
        [Parameter(Mandatory)]
        # The name of the module to install.
        [String]$Name,

        # The version of the module to install.
        [String]$Version,

        # Node modules are being installed on a developer computer.
        [switch]$ForDeveloper,

        [Parameter(Mandatory)]
        # The path to the build root.
        [String]$BuildRootPath,

        # Whether or not to install the module globally.
        [switch]$Global,

        # Are we running in clean mode?
        [switch]$InCleanMode
    )

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

    $npmArgument = & {
                        if( $Version )
                        {
                            ('{0}@{1}' -f $Name,$Version)
                        }
                        else
                        {
                            $Name
                        }
                        if( $Global )
                        {
                            '-g'
                        }
                    }

    $modulePath = Resolve-WhiskeyNodeModulePath -Name $Name -BuildRootPath $BuildRootPath -Global:$Global -ErrorAction Ignore
    if( $modulePath )
    {
        return $modulePath
    }
    elseif( $InCleanMode )
    {
        return
    }

    Invoke-WhiskeyNpmCommand -Name 'install' -ArgumentList $npmArgument -BuildRootPath $BuildRootPath -ForDeveloper:$ForDeveloper | 
        Write-WhiskeyVerbose
    if( $LASTEXITCODE )
    {
        return
    }

    $modulePath = Resolve-WhiskeyNodeModulePath -Name $Name -BuildRootPath $BuildRootPath -Global:$Global -ErrorAction Ignore
    if( -not $modulePath )
    {
        Write-WhiskeyError -Message ('NPM executed successfully when attempting to install "{0}" but the module was not found anywhere in the build root "{1}"' -f ($npmArgument -join ' '),$BuildRootPath)
        return
    }

    return $modulePath
}



function Install-WhiskeyNuGet
{
    <#
    .SYNOPSIS
    Installs NuGet from its NuGet package.
 
    .DESCRIPTION
    The `Install-WhiskeyNuGet` function installs NuGet from its NuGet package. It returns the path to the `NuGet.exe` from the package.
    #>

    [CmdletBinding()]
    param(
        [Parameter(Mandatory)]
        # Where to install NuGet.
        [String]$DownloadRoot,

        # The version to download.
        [String]$Version
    )

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

    $versionParam = @{ }
    if( $Version )
    {
        $versionParam['Version'] = $Version
    }

    $nuGetPath = Install-WhiskeyTool -NuGetPackageName 'NuGet.CommandLine' -DownloadRoot $DownloadRoot @versionParam
    return Join-Path -Path $nuGetPath -ChildPath 'tools\NuGet.exe' -Resolve
}



function Install-WhiskeyPowerShellModule
{
    <#
    .SYNOPSIS
    Installs a PowerShell module.
 
    .DESCRIPTION
    The `Install-WhiskeyPowerShellModule` function installs a PowerShell module into a "PSModules" directory in the current working directory. It returns the path to the module.
 
    .EXAMPLE
    Install-WhiskeyPowerShellModule -Name 'Pester' -Version '4.3.0'
 
    This example will install the PowerShell module `Pester` at version `4.3.0` version in the `PSModules` directory.
 
    .EXAMPLE
    Install-WhiskeyPowerShellModule -Name 'Pester' -Version '4.*'
 
    Demonstrates that you can use wildcards to choose the latest minor version of a module.
 
    .EXAMPLE
    Install-WhiskeyPowerShellModule -Name 'Pester' -Version '4.3.0' -ErrorAction Stop
 
    Demonstrates how to fail a build if installing the module fails by setting the `ErrorAction` parameter to `Stop`.
    #>


    [CmdletBinding()]
    param(
        [Parameter(Mandatory)]
        # The name of the module to install.
        [String]$Name,

        # The version of the module to install.
        [String]$Version,

        [Parameter(Mandatory)]
        # Modules are saved into a PSModules directory. This is the directory where PSModules directory should created, *not* the path to the PSModules directory itself, i.e. this is the path to the "PSModules" directory's parent directory.
        [String]$BuildRoot,

        # The path to a custom directory where you want the module installed. The default is `PSModules` in the build root.
        [String]$Path,

        # Don't import the module.
        [switch]$SkipImport,

        # Allow prerelease versions.
        [switch]$AllowPrerelease
    )

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

    function Get-ModuleVersionDirectoryName
    {
        param(
            [Parameter(Mandatory)]
            [String]$Version
        )

        if( [Version]::TryParse($Version,[ref]$null) )
        {
            return $Version
        }

        if( $Version -match ('^(\d+\.\d+\.\d+)') )
        {
            return $Matches[1]
        }

        return $Version
    }

    $resolveParameters = @{
        'Name' = $Name;
        'BuildRoot' = $BuildRoot;
        'AllowPrerelease' = $AllowPrerelease;
    }
    $module = $null
    if( -not $Version )
    {
        $module = Resolve-WhiskeyPowerShellModule @resolveParameters
        if( -not $module )
        {
            return
        }
        $Version = $module.Version
    }

    $moduleVersionDirName = Get-ModuleVersionDirectoryName -Version $Version

    $modulesRoot = Join-Path -Path $BuildRoot -ChildPath $powerShellModulesDirectoryName
    if( $Path )
    {
        $modulesRoot = $Path
    }
    $moduleRoot = Join-Path -Path $modulesRoot -ChildPath $Name
    $moduleManifestPath = Join-Path -Path $moduleRoot -ChildPath ('{0}\{1}.psd1' -f $moduleVersionDirName,$Name)

    $manifest = $null
    $manifestOk = $false
    try
    {
        $manifest =
            Get-Item -Path $moduleManifestPath -ErrorAction Ignore |
            Test-ModuleManifest -ErrorAction Ignore |
            Sort-Object -Property 'Version' -Descending |
            Select-Object -First 1
        $manifestOk = $true
    }
    catch
    {
        $Global:Error.RemoveAt(0)
    }

    if( $manifestOk -and $manifest )
    {
        $manifest
    }
    else
    {
        $module = $null
        if( -not $manifest )
        {
            $module = Resolve-WhiskeyPowerShellModule @resolveParameters -Version $Version 
            if( -not $module )
            {
                return
            }
        }

        Write-WhiskeyVerbose -Message ('Saving PowerShell module {0} {1} to "{2}" from repository {3}.' -f $Name,$module.Version,$modulesRoot,$module.Repository)
        Save-Module -Name $Name `
                    -RequiredVersion $module.Version `
                    -Repository $module.Repository `
                    -Path $modulesRoot `
                    -AllowPrerelease:$AllowPrerelease

        $moduleVersionDirName = Get-ModuleVersionDirectoryName -Version $module.Version
        $moduleManifestPath = Join-Path -Path $moduleRoot -ChildPath ('{0}\{1}.psd1' -f $moduleVersionDirName,$Name)
        $manifest = Test-ModuleManifest -Path $moduleManifestPath -ErrorAction Ignore -WarningAction Ignore
        if( -not $manifest )
        {
            Write-WhiskeyError -Message ('Failed to download {0} {1} from {2} ({3}). Either the {0} module does not exist, or it does but version {1} does not exist.' -f $Name,$Version,$module.Repository,$module.RepositorySourceLocation)
            return
        }
        $manifest
    }

    if( -not $SkipImport )
    {
        Import-WhiskeyPowerShellModule -Name $Name -PSModulesRoot $modulesRoot
    }
}



function Install-WhiskeyTool
{
    <#
    .SYNOPSIS
    Downloads and installs tools needed by the Whiskey module.
 
    .DESCRIPTION
    The `Install-WhiskeyTool` function downloads and installs PowerShell modules or NuGet Packages needed by functions in the Whiskey module. PowerShell modules are installed to a `Modules` directory in your build root. A `DirectoryInfo` object for the downloaded tool's directory is returned.
 
    `Install-WhiskeyTool` also installs tools that are needed by tasks. Tasks define the tools they need with a [Whiskey.RequiresTool()] attribute in the tasks function. Supported tools are 'Node', 'NodeModule', and 'DotNet'.
 
    Users of the `Whiskey` API typcially won't need to use this function. It is called by other `Whiskey` function so they ahve the tools they need.
 
    .EXAMPLE
    Install-WhiskeyTool -NugetPackageName 'NUnit.Runners' -version '2.6.4'
 
    Demonstrates how to install a specific version of a NuGet Package. In this case, NUnit Runners version 2.6.4 would be installed.
    #>

    [CmdletBinding()]
    param(
        [Parameter(Mandatory,ParameterSetName='Tool')]
        # The attribute that defines what tool is necessary.
        [Whiskey.RequiresToolAttribute]$ToolInfo,

        [Parameter(Mandatory,ParameterSetName='Tool')]
        # The directory where you want the tools installed.
        [String]$InstallRoot,

        [Parameter(Mandatory,ParameterSetName='Tool')]
        # The task parameters for the currently running task.
        [hashtable]$TaskParameter,

        [Parameter(ParameterSetName='Tool')]
        # Running in clean mode, so don't install the tool if it isn't installed.
        [switch]$InCleanMode,

        [Parameter(Mandatory,ParameterSetName='NuGet')]
        # The name of the NuGet package to download.
        [String]$NuGetPackageName,

        [Parameter(ParameterSetName='NuGet')]
        # The version of the package to download. Must be a three part number, i.e. it must have a MAJOR, MINOR, and BUILD number.
        [String]$Version,

        [Parameter(Mandatory,ParameterSetName='NuGet')]
        # The root directory where the tools should be downloaded. The default is your build root.
        #
        # PowerShell modules are saved to `$DownloadRoot\Modules`.
        #
        # NuGet packages are saved to `$DownloadRoot\packages`.
        [String]$DownloadRoot
    )

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

    $mutexName = $InstallRoot
    if( $DownloadRoot )
    {
        $mutexName = $DownloadRoot
    }
    # Back slashes in mutex names are reserved.
    $mutexName = $mutexName -replace '\\','/'
    $mutexName = $mutexName -replace '/','-'
    $startedWaitingAt = Get-Date
    $startedUsingAt = Get-Date
    Write-WhiskeyDebug -Message ('Creating mutex "{0}".' -f $mutexName)
    $installLock = New-Object 'Threading.Mutex' $false,$mutexName
    #$DebugPreference = 'Continue'
    Write-WhiskeyDebug -Message ('[{0:yyyy-MM-dd HH:mm:ss}] Process "{1}" is waiting for mutex "{2}".' -f (Get-Date),$PID,$mutexName)

    try
    {
        try
        {
            [Void]$installLock.WaitOne()
        }
        catch [Threading.AbandonedMutexException]
        {
            Write-WhiskeyDebug -Message ('[{0:yyyy-MM-dd HH:mm:ss}] Process "{1}" caught "{2}" exception waiting to acquire mutex "{3}": {4}.' -f (Get-Date),$PID,$_.Exception.GetType().FullName,$mutexName,$_)
            $Global:Error.RemoveAt(0)
        }

        $waitedFor = (Get-Date) - $startedWaitingAt
        Write-WhiskeyDebug -Message ('[{0:yyyy-MM-dd HH:mm:ss}] Process "{1}" obtained mutex "{2}" in {3}.' -f (Get-Date),$PID,$mutexName,$waitedFor)
        #$DebugPreference = 'SilentlyContinue'
        $startedUsingAt = Get-Date

        if( $PSCmdlet.ParameterSetName -eq 'NuGet' )
        {
            if( -not $IsWindows )
            {
                Write-WhiskeyError -Message ('Unable to install NuGet-based package {0} {1}: NuGet.exe is only supported on Windows.' -f $NuGetPackageName,$Version) -ErrorAction Stop
                return
            }
            $packagesRoot = Join-Path -Path $DownloadRoot -ChildPath 'packages'
            $version = Resolve-WhiskeyNuGetPackageVersion -NuGetPackageName $NuGetPackageName -Version $Version -NugetPath $whiskeyNuGetExePath
            if( -not $Version )
            {
                return
            }

            $nuGetRootName = '{0}.{1}' -f $NuGetPackageName,$Version
            $nuGetRoot = Join-Path -Path $packagesRoot -ChildPath $nuGetRootName
            Set-Item -Path 'env:EnableNuGetPackageRestore' -Value 'true'
            if( -not (Test-Path -Path $nuGetRoot -PathType Container) )
            {
               & $whiskeyNuGetExePath install $NuGetPackageName -version $Version -outputdirectory $packagesRoot | Write-CommandOutput -Description ('nuget.exe install')
            }
            return $nuGetRoot
        }
        elseif( $PSCmdlet.ParameterSetName -eq 'Tool' )
        {
            $provider,$name = $ToolInfo.Name -split '::'
            if( -not $name )
            {
                $name = $provider
                $provider = ''
            }

            $version = $TaskParameter[$ToolInfo.VersionParameterName]
            if( -not $version )
            {
                $version = $ToolInfo.Version
            }

            if( $ToolInfo -is [Whiskey.RequiresPowerShellModuleAttribute] )
            {
                $module = Install-WhiskeyPowerShellModule -Name $name `
                                                          -Version $version `
                                                          -BuildRoot $InstallRoot `
                                                          -SkipImport:$ToolInfo.SkipImport `
                                                          -ErrorAction Stop
                if( $ToolInfo.ModuleInfoParameterName )
                {
                    $TaskParameter[$ToolInfo.ModuleInfoParameterName] = $module
                }
                return
            }

            $toolPath = $null
            switch( $provider )
            {
                'NodeModule'
                {
                    $nodePath = Resolve-WhiskeyNodePath -BuildRootPath $InstallRoot
                    if( -not $nodePath )
                    {
                        Write-WhiskeyError -Message ('It looks like Node isn''t installed in your repository. Whiskey usually installs Node for you into a .node directory. If this directory doesn''t exist, this is most likely a task authoring error and the author of your task needs to add a `WhiskeyTool` attribute declaring it has a dependency on Node. If the .node directory exists, the Node package is most likely corrupt. Please delete it and re-run your build.') -ErrorAction stop
                        return
                    }
                    $toolPath = Install-WhiskeyNodeModule -Name $name `
                                                          -BuildRootPath $InstallRoot `
                                                          -Version $version `
                                                          -Global `
                                                          -InCleanMode:$InCleanMode `
                                                          -ErrorAction Stop
                }
                default
                {
                    switch( $name )
                    {
                        'Node'
                        {
                            $toolPath = Install-WhiskeyNode -InstallRoot $InstallRoot -Version $version -InCleanMode:$InCleanMode
                        }
                        'DotNet'
                        {
                            $toolPath = Install-WhiskeyDotNetTool -InstallRoot $InstallRoot -WorkingDirectory (Get-Location).ProviderPath -Version $version -ErrorAction Stop
                        }
                        default
                        {
                            throw ('Unknown tool "{0}". The only supported tools are "Node" and "DotNet".' -f $name)
                        }
                    }
                }
            }

            if( $ToolInfo.PathParameterName )
            {
                $TaskParameter[$ToolInfo.PathParameterName] = $toolPath
            }
        }
    }
    finally
    {
        #$DebugPreference = 'Continue'
        $usedFor = (Get-Date) - $startedUsingAt
        Write-WhiskeyDebug -Message ('[{0:yyyy-MM-dd HH:mm:ss}] Process "{1}" releasing mutex "{2}" after using it for {3}.' -f (Get-Date),$PID,$mutexName,$usedFor)
        $startedReleasingAt = Get-Date
        $installLock.ReleaseMutex();
        $installLock.Dispose()
        $installLock.Close()
        $installLock = $null
        $releasedDuration = (Get-Date) - $startedReleasingAt
        Write-WhiskeyDebug -Message ('[{0:yyyy-MM-dd HH:mm:ss}] Process "{1}" released mutex "{2}" in {3}.' -f (Get-Date),$PID,$mutexName,$releasedDuration)
        #$DebugPreference = 'SilentlyContinue'
    }
}



function Invoke-WhiskeyBuild
{
    <#
    .SYNOPSIS
    Runs a build.
 
    .DESCRIPTION
    The `Invoke-WhiskeyBuild` function runs a build as defined by your `whiskey.yml` file. Use the `New-WhiskeyContext` function to create a context object, then pass that context object to `Invoke-WhiskeyBuild`. `New-WhiskeyContext` takes the path to the `whiskey.yml` file you want to run:
 
        $context = New-WhiskeyContext -Environment 'Developer' -ConfigurationPath 'whiskey.yml'
        Invoke-WhiskeyBuild -Context $context
 
    Builds can run in three modes: `Build`, `Clean`, and `Initialize`. The default behavior is `Build` mode.
 
    In `Build` mode, each task in the `Build` pipeline is run. If you're on a publishing branch, and being run on a build server, each task in the `Publish` pipeline is also run.
 
    In `Clean` mode, each task that supports clean mode is run. In this mode, tasks clean up any build artifacts they create. Tasks opt-in to this mode. If a task isn't cleaning up, it should be updated to support clean mode.
 
    In `Initialize` mode, each task that suppors initialize mode is run. In this mode, tasks download, install, and configure any tools or other dependencies needed. This mode is intended to be used by developers so they can get any tools needed to start developing without having to run an entire build, which may take a long time. Tasks opt-in to this mode. If a task uses an external tool or dependences, and they don't exist after running in `Initialize` mode, it should be updated to support `Initialize` mode.
 
    (Task authors should see the `about_Whiskey_Writing_Tasks` for information about how to opt-in to `Clean` and `Initialize` mode.)
 
    Your `whiskey.yml` file can contain multiple pipelines (see `about_Whiskey.yml` for information about `whiskey.yml syntax). Usually, there is a pipeline for each application you want to build. To build specific pipelines, pass the pipeline names to the `PipelineName` parameter. Just those pipeline will be run. The `Publish` pipeline will *not* run unless it is one of the names you pass to the `PipelineName` parameter.
 
    .LINK
    about_Whiskey.yml
 
    .LINK
    New-WhiskeyContext
 
    .LINK
    about_Whiskey_Writing_Tasks
 
    .EXAMPLE
    Invoke-WhiskeyBuild -Context $context
 
    Demonstrates how to run a complete build. In this example, the `Build` pipeline is run, and, if running on a build server and on a publishing branch, the `Publish` pipeline is run.
 
    .EXAMPLE
    Invoke-WhiskeyBuild -Context $context -Clean
 
    Demonstrates how to run a build in `Clean` mode. In this example, each task in the `Build` and `Publish` pipelines that support `Clean` mode is run so they can delete any build output, downloaded depedencies, etc.
 
    .EXAMPLE
    Invoke-WhiskeyBuild -Context $context -Initialize
 
    Demonstrates how to run a build in `Initialize` mode. In this example, each task in the `Build` and `Publish` pipelines that supports `Initialize` mode is run so they can download/install/configure any tools or dependencies.
 
    .EXAMPLE
    Invoke-WhiskeyBuild -Context $context -PipelineName 'App1','App2'
 
    Demonstrates how to run specific pipelines. In this example, all the tasks in the `App1` and `App2` pipelines are run. See `about_Whiskey.yml` for information about how to define pipelines.
    #>

    [CmdletBinding(DefaultParameterSetName='Build')]
    param(
        [Parameter(Mandatory)]
        # The context for the build. Use `New-WhiskeyContext` to create context objects.
        [Whiskey.Context]$Context,

        # The name(s) of any pipelines to run. Default behavior is to run the `Build` pipeline and, if on a publishing branch, the `Publish` pipeline.
        #
        # If you pass a value to this parameter, the `Publish` pipeline is *not* run implicitly. You must pass its name to run it.
        [String[]]$PipelineName,

        [Parameter(Mandatory,ParameterSetName='Clean')]
        # Runs the build in clean mode. In clean mode, tasks delete any artifacts they create, including downloaded tools and dependencies. This is opt-in, so if a task is not deleting its artifacts, it needs to be updated to support clean mode.
        [switch]$Clean,

        [Parameter(Mandatory,ParameterSetName='Initialize')]
        # Runs the build in initialize mode. In initialize mode, tasks download/install/configure any tools/dependencies they use/need during the build. Initialize mode is intended to be used by developers so that any tools/dependencies they need can be downloaded/installe/configured without needing to run an entire build, which can sometimes take a long time.
        [switch]$Initialize
    )

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

    if( -not $PSBoundParameters.ContainsKey('InformationAction') )
    {
        # Whiskey logs to the information stream so make sure it is enabled. Unless the user wants it off.
        $InformationPreference = 'Continue'
    }

    $Context.StartedAt = $script:buildStartedAt = Get-Date

    # If there are older versions of the PackageManagement and/or PowerShellGet
    # modules available on this system, the modules that ship with Whiskey will use
    # those global versions instead of the versions we load from inside Whiskey. So,
    # we have to put the ones that ship with Whiskey first. See
    # https://github.com/PowerShell/PowerShellGet/issues/446 .
    $originalPSModulesPath = $env:PSModulePath
    $env:PSModulePath = '{0};{1}' -f (Join-Path -Path $Context.BuildRoot -ChildPath $powerShellModulesDirectoryName),$env:PSModulePath

    Set-WhiskeyBuildStatus -Context $Context -Status Started

    $succeeded = $false
    Push-Location -Path $Context.BuildRoot
    try
    {
        $Context.RunMode = $PSCmdlet.ParameterSetName

        if( $PipelineName )
        {
            foreach( $name in $PipelineName )
            {
                Invoke-WhiskeyPipeline -Context $Context -Name $name
            }
        }
        else
        {
            $config = $Context.Configuration

            $buildPipelineName = 'Build'
            if( $config.ContainsKey('BuildTasks') )
            {
                $buildPipelineName = 'BuildTasks'
            }

            Invoke-WhiskeyPipeline -Context $Context -Name $buildPipelineName

            $publishPipelineName = 'Publish'
            if( $config.ContainsKey('PublishTasks') )
            {
                $publishPipelineName = 'PublishTasks'
            }

            Write-WhiskeyVerbose -Context $Context -Message ('Publish? {0}' -f $Context.Publish)
            Write-WhiskeyVerbose -Context $Context -Message ('Publish Pipeline? {0}' -f $config.ContainsKey($publishPipelineName))
            if( $Context.Publish -and $config.ContainsKey($publishPipelineName) )
            {
                Invoke-WhiskeyPipeline -Context $Context -Name $publishPipelineName
            }
        }

        $succeeded = $true
    }
    finally
    {
        if( $Clean )
        {
            Remove-Item -path $Context.OutputDirectory -Recurse -Force | Out-String | Write-WhiskeyVerbose -Context $Context
        }
        Pop-Location

        $status = 'Failed'
        if( $succeeded )
        {
            $status = 'Completed'
        }
        Set-WhiskeyBuildStatus -Context $Context -Status $status

        $env:PSModulePath = $originalPSModulesPath
    }

    # There are some errors (strict mode validation failures, command not found errors, etc.) that stop a build, but
    # even though ErrorActionPreference is Stop, it doesn't stop the current process, which is what causes a build to
    # fail the build. If we get here, and the build didn't succeed, we've encountered one of those errors. Throw a
    # guaranteed terminating error.
    if( -not $succeeded )
    {
        Write-Error -Message ('Build failed. See previous error output for more information.') -ErrorAction Stop
    }
}



function Invoke-WhiskeyDotNetCommand
{
    <#
    .SYNOPSIS
    Runs `dotnet.exe` with a given SDK command and arguments.
 
    .DESCRIPTION
    The `Invoke-WhiskeyDotNetCommand` function runs the `dotnet.exe` executable with a given SDK command and any optional arguments. Pass the path to the `dotnet.exe` to the `DotNetPath` parameter. Pass the name of the SDK command to the `Name` parameter.
 
    You may pass a list of arguments to the `dotnet.exe` command with the `ArgumentList` parameter. By default, the `dotnet.exe` command runs with any solution or .csproj files found in the current directory. To run the `dotnet.exe` command with a specific solution or .csproj file pass the path to that file to the `ProjectPath` parameter.
 
    .EXAMPLE
    Invoke-WhiskeyDotNetCommand -TaskContext $TaskContext -DotNetPath 'C:\Program Files\dotnet\dotnet.exe' -Name 'build' -ArgumentList '--verbosity minimal','--no-incremental' -ProjectPath 'C:\Build\DotNetCore.csproj'
 
    Demonstrates running the following command `C:\> & "C:\Program Files\dotnet\dotnet.exe" build --verbosity minimal --no-incremental C:\Build\DotNetCore.csproj`
    #>

    [CmdletBinding()]
    param(
        [Parameter(Mandatory)]
        # The `Whiskey.Context` object for the task running the command.
        [Whiskey.Context]$TaskContext,

        [Parameter(Mandatory)]
        # The path to the `dotnet` executable to run the SDK command with.
        [String]$DotNetPath,

        [Parameter(Mandatory)]
        # The name of the .NET Core SDK command to run.
        [String]$Name,

        # A list of arguments to pass to the .NET Core SDK command.
        [String[]]$ArgumentList,

        # The path to a .NET Core solution or project file to pass to the .NET Core SDK command.
        [String]$ProjectPath
    )

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

    $dotNetExe = $DotNetPath | Resolve-Path -ErrorAction 'Ignore'
    if (-not $dotNetExe)
    {
        Write-WhiskeyError -Context $TaskContext -Message ('"{0}" does not exist.' -f $DotNetPath)
        return
    }

    $loggerArgs = & {
        '/filelogger9'
        $logFilePath = ('dotnet.{0}.log' -f $Name.ToLower())
        if( $ProjectPath )
        {
            $logFilePath = 'dotnet.{0}.{1}.log' -f $Name.ToLower(),($ProjectPath | Split-Path -Leaf)
        }
        $relativeOutDirectory = $TaskContext.OutputDirectory | Resolve-Path -Relative
        $logFilePath = Join-Path -Path $relativeOutDirectory -ChildPath $logFilePath
        ('/flp9:LogFile={0};Verbosity=d' -f $logFilePath)
    }

    $commandInfoArgList = & {
        $Name
        $ArgumentList
        $loggerArgs
        $ProjectPath
    }

    Write-WhiskeyCommand -Context $TaskContext -Path $dotNetExe -ArgumentList $commandInfoArgList

    Invoke-Command -ScriptBlock {
        param(
            $DotNetExe,
            $Command,
            $DotNetArgs,
            $LoggerArgs,
            $Project
        )

        & $DotNetExe $Command $DotNetArgs $LoggerArgs $Project

    } -ArgumentList $dotNetExe,$Name,$ArgumentList,$loggerArgs,$ProjectPath

    if ($LASTEXITCODE -ne 0)
    {
        Stop-WhiskeyTask -TaskContext $TaskContext -Message ('"{0}" failed with exit code {1}' -f $DotNetExe,$LASTEXITCODE)
        return
    }
}



function Invoke-WhiskeyNpmCommand
{
    <#
    .SYNOPSIS
    Runs `npm` with given command and argument.
     
    .DESCRIPTION
    The `Invoke-WhiskeyNpmCommand` function runs `npm` commands in the current workding directory. Pass the path to the build root to the `BuildRootPath` parameter. The function will use the copy of Node and NPM installed in the `.node` directory in the build root.
 
    Pass the name of the NPM command to run with the `Name` parameter. Pass any arguments to pass to the command with the `ArgumentList`.
 
    Task authors should add the `RequiresTool` attribute to their task functions to ensure that Whiskey installs Node and NPM, e.g.
 
        function MyTask
        {
            [Whiskey.Task('MyTask')]
            [Whiskey.RequiresTool('Node', PathParameterName='NodePath')]
            param(
            )
        }
 
    .EXAMPLE
    Invoke-WhiskeyNpmCommand -Name 'install' -BuildRootPath $TaskParameter.BuildRoot -ForDeveloper:$Context.ByDeveloper
 
    Demonstrates how to run the `npm install` command from a task.
    #>

    [CmdletBinding()]
    param(
        [Parameter(Mandatory)]
        # The NPM command to execute, e.g. `install`, `prune`, `run-script`, etc.
        [String]$Name,
        
        # An array of arguments to be given to the NPM command being executed.
        [String[]]$ArgumentList,

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

        # NPM commands are being run on a developer computer.
        [switch]$ForDeveloper
    )

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

    $nodePath = Resolve-WhiskeyNodePath -BuildRootPath $BuildRootPath -ErrorAction Stop
    if( -not $nodePath )
    {
        return
    }

    $npmPath = Resolve-WhiskeyNodeModulePath -Name 'npm' -BuildRootPath $BuildRootPath -Global -ErrorAction Stop
    $npmPath = Join-Path -Path $npmPath -ChildPath 'bin\npm-cli.js'

    if( -not $npmPath -or -not (Test-Path -Path $npmPath -PathType Leaf) )
    {
        Write-WhiskeyError -Message ('Whiskey failed to install NPM. Something pretty serious has gone wrong.')
        return
    }

    # Assign to new variables otherwise Invoke-Command can't find them.
    $commandName = $Name
    $commandArgs = & {
                        $ArgumentList
                        '--scripts-prepend-node-path=auto'
                        if( -not $ForDeveloper )
                        {
                            '--no-color'
                        }
                    }

    $npmCommandString = ('npm {0} {1}' -f $commandName,($commandArgs -join ' '))

    $originalPath = $env:PATH
    Set-Item -Path 'env:PATH' -Value ('{0}{1}{2}' -f (Split-Path -Path $nodePath -Parent),[IO.Path]::PathSeparator,$env:PATH)
    try
    {
        Write-Progress -Activity $npmCommandString
        Invoke-Command -ScriptBlock {
            # The ISE bails if processes write anything to STDERR. Node writes notices and warnings to
            # STDERR. We only want to stop a build if the command actually fails.
            $originalEap = $ErrorActionPreference
            if( $ErrorActionPreference -ne 'SilentlyContinue' -and $ErrorActionPreference -ne 'Ignore' )
            {
                $ErrorActionPreference = 'Continue'
            }
            try
            {
                Write-WhiskeyVerbose ('{0} {1} {2} {3}' -f $nodePath,$npmPath,$commandName,($commandArgs -join ' '))
                & $nodePath $npmPath $commandName $commandArgs
            }
            finally
            {
                Write-WhiskeyVerbose -Message ($LASTEXITCODE)
                $ErrorActionPreference = $originalEap
            }
        }
        if( $LASTEXITCODE -ne 0 )
        {
            Write-WhiskeyError -Message ('NPM command "{0}" failed with exit code {1}. Please see previous output for more details.' -f $npmCommandString,$LASTEXITCODE)
        }
    }
    finally
    {
        Set-Item -Path 'env:PATH' -Value $originalPath
        Write-Progress -Activity $npmCommandString -Completed -PercentComplete 100
    }
}



function Invoke-WhiskeyNuGetPush
{
    [CmdletBinding()]
    param(
        [Parameter(Mandatory)]
        [String]$Path,

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

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

        [Parameter(Mandatory)]
        [String]$NuGetPath
    )

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

    & $NuGetPath push $Path -Source $Uri -ApiKey $ApiKey

}



function Invoke-WhiskeyPipeline
{
    <#
    .SYNOPSIS
    Invokes Whiskey pipelines.
 
    .DESCRIPTION
    The `Invoke-WhiskeyPipeline` function runs the tasks in a pipeline. Pipelines are properties in a `whiskey.yml` under which one or more tasks are defined. For example, this `whiskey.yml` file:
 
        Build:
        - TaskOne
        - TaskTwo
        Publish:
        - TaskOne
        - Task
 
    Defines two pipelines: `Build` and `Publish`.
 
    .EXAMPLE
    Invoke-WhiskeyPipeline -Context $context -Name 'Build'
 
    Demonstrates how to run the tasks in a `Build` pipeline. The `$context` object is created by calling `New-WhiskeyContext`.
    #>

    [CmdletBinding()]
    param(
        [Parameter(Mandatory)]
        # The current build context. Use the `New-WhiskeyContext` function to create a context object.
        [Whiskey.Context]$Context,

        [Parameter(Mandatory)]
        # The name of pipeline to run, e.g. `Build` would run all the tasks under a property named `Build`. Pipelines are properties in your `whiskey.yml` file that are lists of Whiskey tasks to run.
        [String]$Name
    )

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

    $config = $Context.Configuration
    $Context.PipelineName = $Name

    if( -not $config.ContainsKey($Name) )
    {
        Stop-Whiskey -Context $Context -Message ('Pipeline "{0}" does not exist. Create a pipeline by defining a "{0}" property:
         
    {0}:
    - TASK_ONE
    - TASK_TWO
     
'
 -f $Name)
        return
    }

    $taskIdx = -1
    if( -not $config[$Name] )
    {
        Write-WhiskeyWarning -Context $Context -Message ('It looks like pipeline "{0}" doesn''t have any tasks.' -f $Name)
        $config[$Name] = @()
    }

    foreach( $taskItem in $config[$Name] )
    {
        $taskIdx++

        $taskName,$taskParameter = ConvertTo-WhiskeyTask -InputObject $taskItem -ErrorAction Stop
        if( -not $taskName )
        {
            continue
        }

        $Context.TaskIndex = $taskIdx

        Invoke-WhiskeyTask -TaskContext $Context -Name $taskName -Parameter $taskParameter
    }
}



function Invoke-WhiskeyRobocopy
{
    [CmdletBinding()]
    param(
        [Parameter(Mandatory)]
        [String]$Source,

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

        [String[]]$WhiteList,

        [String[]]$Exclude,

        [String]$LogPath
    )

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

    $numRobocopyThreads = Get-CimInstance -ClassName 'Win32_Processor' | Select-Object -ExpandProperty 'NumberOfLogicalProcessors' | Measure-Object -Sum | Select-Object -ExpandProperty 'Sum'
    $numRobocopyThreads *= 2

    $logParam = ''
    if ($LogPath)
    {
        $logParam = '/LOG:{0}' -f $LogPath
    }

    $excludeParam = $Exclude | ForEach-Object { '/XF' ; $_ ; '/XD' ; $_ }
    Write-WhiskeyDebug ('robocopy BEGIN {0} -> {1}' -f $Source,$Destination)
    robocopy $Source $Destination '/PURGE' '/S' '/NP' '/R:0' '/NDL' '/NFL' '/NS' ('/MT:{0}' -f $numRobocopyThreads) $WhiteList $excludeParam $logParam
    Write-WhiskeyDebug ('robocopy END')
}



function Invoke-WhiskeyTask
{
    <#
    .SYNOPSIS
    Runs a Whiskey task.
 
    .DESCRIPTION
    The `Invoke-WhiskeyTask` function runs a Whiskey task.
    #>

    [CmdletBinding()]
    param(
        [Parameter(Mandatory)]
        # The context this task is operating in. Use `New-WhiskeyContext` to create context objects.
        [Whiskey.Context]$TaskContext,

        [Parameter(Mandatory)]
        # The name of the task.
        [String]$Name,

        [Parameter(Mandatory)]
        # The parameters/configuration to use to run the task.
        [hashtable]$Parameter
    )

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

    function Invoke-Event
    {
        param(
            $EventName,
            $Property
        )

        $events = $TaskContext.Events

        if( -not $events.ContainsKey($EventName) )
        {
            return
        }

        foreach( $commandName in $events[$EventName] )
        {
            Write-WhiskeyVerbose -Context $TaskContext -Message ''
            Write-WhiskeyVerbose -Context $TaskContext -Message ('[On{0}] {1}' -f $EventName,$commandName)
            $startedAt = Get-Date
            $result = 'FAILED'
            try
            {
                $TaskContext.Temp = Join-Path -Path $TaskContext.OutputDirectory -ChildPath ('Temp.{0}.On{1}.{2}' -f $Name,$EventName,[IO.Path]::GetRandomFileName())
                if( -not (Test-Path -Path $TaskContext.Temp -PathType Container) )
                {
                    New-Item -Path $TaskContext.Temp -ItemType 'Directory' -Force | Out-Null
                }
                & $commandName -TaskContext $TaskContext -TaskName $Name -TaskParameter $Property
                $result = 'COMPLETED'
            }
            finally
            {
                Remove-WhiskeyFileSystemItem -Path $TaskContext.Temp
                $endedAt = Get-Date
                $duration = $endedAt - $startedAt
                Write-WhiskeyVerbose -Context $TaskContext ('{0} {1} in {2}' -f (' ' * ($EventName.Length + 4)),$result,$duration)
                Write-WhiskeyVerbose -Context $TaskContext -Message ''
            }
        }
    }

    function Merge-Parameter
    {
        param(
            [hashtable]$SourceParameter,

            [hashtable]$TargetParameter
        )

        foreach( $key in $SourceParameter.Keys )
        {
            $sourceValue = $SourceParameter[$key]
            if( $TargetParameter.ContainsKey($key) )
            {
                $targetValue = $TargetParameter[$key]
                if( ($targetValue | Get-Member -Name 'Keys') -and ($sourceValue | Get-Member -Name 'Keys') )
                {
                    Merge-Parameter -SourceParameter $sourceValue -TargetParameter $targetValue
                }
                continue
            }

            $TargetParameter[$key] = $sourceValue
        }
    }

    function Get-RequiredTool
    {
        param(
            $CommandName
        )

        $cmd = Get-Command -Name $CommandName -ErrorAction Ignore
        if( -not $cmd -or -not (Get-Member -InputObject $cmd -Name 'ScriptBlock') )
        {
            return
        }

        $cmd.ScriptBlock.Attributes |
            Where-Object { $_ -is [Whiskey.RequiresToolAttribute] }
    }

    $knownTasks = Get-WhiskeyTask -Force

    $task = $knownTasks | Where-Object { $_.Name -eq $Name }

    if( -not $task )
    {
        $task = $knownTasks | Where-Object { $_.Aliases -contains $Name }
        $taskCount = ($task | Measure-Object).Count
        if( $taskCount -gt 1 )
        {
            Stop-WhiskeyTask -TaskContext $TaskContext -Message ('Found {0} tasks with alias "{1}". Please update to use one of these task names: {2}.' -f $taskCount,$Name,(($task | Select-Object -ExpandProperty 'Name') -join ', '))
            return
        }
        if( $task -and $task.WarnWhenUsingAlias )
        {
            Write-WhiskeyWarning -Context $TaskContext -Message ('Task "{0}" is an alias to task "{1}". Please update "{2}" to use the task''s actual name, "{1}", instead of the alias.' -f $Name,$task.Name,$TaskContext.ConfigurationPath)
        }
    }

    if( -not $task )
    {
        $knownTaskNames = $knownTasks | Select-Object -ExpandProperty 'Name' | Sort-Object
        throw ('{0}: {1}[{2}]: ''{3}'' task does not exist. Supported tasks are:{4} * {5}' -f $TaskContext.ConfigurationPath,$Name,$TaskContext.TaskIndex,$Name,[Environment]::NewLine,($knownTaskNames -join ('{0} * ' -f [Environment]::NewLine)))
    }

    $taskCount = ($task | Measure-Object).Count
    if( $taskCount -gt 1 )
    {
        Stop-WhiskeyTask -TaskContext $TaskContext -Message ('Found {0} tasks named "{1}". We don''t know which one to use. Please make sure task names are unique.' -f $taskCount,$Name)
        return
    }

    $TaskContext.TaskName = $Name

    if( $task.Obsolete )
    {
        $message = 'The "{0}" task is obsolete and shouldn''t be used.' -f $task.Name
        if( $task.ObsoleteMessage )
        {
            $message = $task.ObsoleteMessage
        }
        Write-WhiskeyWarning -Context $TaskContext -Message $message
    }

    if( -not $task.Platform.HasFlag($CurrentPlatform) )
    {
        $msg = 'Unable to run task "{0}": it is only supported on the {1} platform(s) and we''re currently running on {2}.' -f `
                    $task.Name,$task.Platform,$CurrentPlatform
        Write-WhiskeyError -Message $msg -ErrorAction Stop
        return
    }

    if( $TaskContext.TaskDefaults.ContainsKey( $Name ) )
    {
        Merge-Parameter -SourceParameter $TaskContext.TaskDefaults[$Name] -TargetParameter $Parameter
    }

    Resolve-WhiskeyVariable -Context $TaskContext -InputObject $Parameter | Out-Null

    [hashtable]$taskProperties = $Parameter.Clone()
    $commonProperties = @{}
    foreach( $commonPropertyName in @(
        'OnlyBy',           'ExceptBy',
        'OnlyOnBranch',     'ExceptOnBranch',
        'OnlyDuring',       'ExceptDuring',
        'OnlyOnPlatform',   'ExceptOnPlatform',
        'IfExists',         'UnlessExists',
        'WorkingDirectory', 'OutVariable' ) )
    {
        if ($taskProperties.ContainsKey($commonPropertyName))
        {
            $commonProperties[$commonPropertyName] = $taskProperties[$commonPropertyName]
            $taskProperties.Remove($commonPropertyName)
        }
    }

    # Start every task in the BuildRoot.
    Push-Location $TaskContext.BuildRoot
    $originalDirectory = [IO.Directory]::GetCurrentDirectory()
    [IO.Directory]::SetCurrentDirectory($TaskContext.BuildRoot)
    try
    {
        $workingDirectory = $TaskContext.BuildRoot
        if( $Parameter['WorkingDirectory'] )
        {
            # We need a full path because we pass it to `IO.Path.SetCurrentDirectory`.
            $workingDirectory =
                $Parameter['WorkingDirectory'] |
                Resolve-WhiskeyTaskPath -TaskContext $TaskContext -PropertyName 'WorkingDirectory' -Mandatory -OnlySinglePath -PathType 'Directory' |
                Resolve-Path |
                Select-Object -ExpandProperty 'ProviderPath'
        }

        $taskTempDirectory = ''
        $requiredTools = Get-RequiredTool -CommandName $task.CommandName
        $startedAt = Get-Date
        $result = 'FAILED'
        Set-Location -Path $workingDirectory
        [IO.Directory]::SetCurrentDirectory($workingDirectory)
        $originalDebugPreference = $DebugPreference
        try
        {
            if( Test-WhiskeyTaskSkip -Context $TaskContext -Properties $commonProperties)
            {
                $result = 'SKIPPED'
                return
            }

            $inCleanMode = $TaskContext.ShouldClean
            if( $inCleanMode )
            {
                if( -not $task.SupportsClean )
                {
                    Write-WhiskeyVerbose -Context $TaskContext -Message ('SupportsClean.{0} -ne Build.ShouldClean.{1}' -f $task.SupportsClean,$TaskContext.ShouldClean)
                    $result = 'SKIPPED'
                    return
                }
            }

            foreach( $requiredTool in $requiredTools )
            {
                Install-WhiskeyTool -ToolInfo $requiredTool `
                                    -InstallRoot $TaskContext.BuildRoot `
                                    -TaskParameter $taskProperties `
                                    -InCleanMode:$inCleanMode `
                                    -ErrorAction Stop
            }

            if( $TaskContext.ShouldInitialize -and -not $task.SupportsInitialize )
            {
                Write-WhiskeyVerbose -Context $TaskContext -Message ('SupportsInitialize.{0} -ne Build.ShouldInitialize.{1}' -f $task.SupportsInitialize,$TaskContext.ShouldInitialize)
                $result = 'SKIPPED'
                return
            }

            Invoke-Event -EventName 'BeforeTask' -Property $taskProperties
            Invoke-Event -EventName ('Before{0}Task' -f $Name) -Property $taskProperties

            Write-WhiskeyVerbose -Context $TaskContext -Message ''
            $startedAt = Get-Date
            $taskTempDirectory = Join-Path -Path $TaskContext.OutputDirectory -ChildPath ('Temp.{0}.{1}' -f $Name,[IO.Path]::GetRandomFileName())
            $TaskContext.Temp = $taskTempDirectory
            if( -not (Test-Path -Path $TaskContext.Temp -PathType Container) )
            {
                New-Item -Path $TaskContext.Temp -ItemType 'Directory' -Force | Out-Null
            }

            $parameter = Get-TaskParameter -Name $task.CommandName -TaskProperty $taskProperties -Context $TaskContext

            # PowerShell's default DebugPreference when someone uses the -Debug switch is `Inquire`. That would cause a build
            # to hang, so let's set it to Continue so users can see debug output.
            if( $parameter['Debug'] )
            {
                $DebugPreference = 'Continue'
                $parameter.Remove('Debug')
            }

            $outVariable = $commonProperties['OutVariable']

            if ($outVariable)
            {
                $taskOutput = & $task.CommandName @parameter

                if (-not $taskOutput)
                {
                    $taskOutput = ''
                }

                Add-WhiskeyVariable -Context $TaskContext -Name $outVariable -Value $taskOutput
            }
            else
            {
                & $task.CommandName @parameter
            }

            $result = 'COMPLETED'
        }
        finally
        {
            $DebugPreference = $originalDebugPreference

            # Clean required tools *after* running the task since the task might need a required tool in order to do the cleaning (e.g. using Node to clean up installed modules)
            if( $TaskContext.ShouldClean )
            {
                foreach( $requiredTool in $requiredTools )
                {
                    Uninstall-WhiskeyTool -BuildRoot $TaskContext.BuildRoot -ToolInfo $requiredTool
                }
            }

            if( $taskTempDirectory -and (Test-Path -Path $taskTempDirectory -PathType Container) )
            {
                Remove-Item -Path $taskTempDirectory -Recurse -Force -ErrorAction Ignore
            }
            $endedAt = Get-Date
            $duration = $endedAt - $startedAt
            Write-WhiskeyVerbose -Context $TaskContext -Message ('{0} in {1}' -f $result,$duration)
            Write-WhiskeyVerbose -Context $TaskContext -Message ''
        }

        Invoke-Event -EventName 'AfterTask' -Property $taskProperties
        Invoke-Event -EventName ('After{0}Task' -f $Name) -Property $taskProperties
    }
    finally
    {
        [IO.Directory]::SetCurrentDirectory($originalDirectory)
        Pop-Location
    }
}



function New-WhiskeyBuildMetadataObject
{
    [CmdletBinding()]
    [OutputType([Whiskey.BuildInfo])]
    param(
    )

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

    return New-Object -TypeName 'Whiskey.BuildInfo'
}



function New-WhiskeyContext
{
    <#
    .SYNOPSIS
    Creates a context object to use when running builds.
 
    .DESCRIPTION
    The `New-WhiskeyContext` function creates a `Whiskey.Context` object used when running builds. It:
 
    * Reads in the whiskey.yml file containing the build you want to run.
    * Creates a ".output" directory in the same directory as your whiskey.yml file for storing build output, logs, results, temp files, etc.
    * Reads build metadata created by the current build server (if being run by a build server).
    * Sets the version number to "0.0.0".
 
    ## Whiskey.Context
 
    The `Whiskey.Context` object has the following properties. ***Do not use any property not defined below.*** Also, these properties are ***read-only***. If you write to them, Bad Things (tm) could happen.
 
    * `BuildMetadata`: a `Whiskey.BuildInfo` object representing build metadata provided by the build server.
    * `BuildRoot`: a `System.IO.DirectoryInfo` object representing the directory the YAML configuration file is in.
    * `ByBuildServer`: a flag indicating if the build is being run by a build server.
    * `ByDeveloper`: a flag indicating if the build is being run by a developer.
    * `Environment`: the environment the build is running in.
    * `OutputDirectory`: a `System.IO.DirectoryInfo` object representing the path to a directory where build output, reports, etc. should be saved. This directory is created for you.
    * `ShouldClean`: a flag indicating if the current build is running in clean mode.
    * `ShouldInitialize`: a flag indicating if the current build is running in initialize mode.
    * `Temp`: the temporary work directory for the current task.
    * `Version`: a `Whiskey.BuildVersion` object representing version being built (see below).
 
    Any other property is considered private and may be removed, renamed, and/or reimplemented at our discretion without notice.
 
    ## Whiskey.BuildInfo
 
    The `Whiskey.BuildInfo` object has the following properties. ***Do not use any property not defined below.*** Also, these properties are ***read-only***. If you write to them, Bad Things (tm) could happen.
 
    * `BuildNumber`: the current build number. This comes from the build server. (If the build is being run by a developer, this is always "0".) It increments with every new build (or should). This number is unique only to the current build job.
    * `ScmBranch`: the branch name from which the current build is running.
    * `ScmCommitID`: the unique commit ID from which the current build is running. The commit ID distinguishes the current commit from all others in the source repository and is the same across copies of a repository.
 
    ## Whiskey.BuildVersion
 
    The `Whiskey.BuildVersion` object has the following properties. ***Do not use any property not defined below.*** Also, these properties are ***read-only***. If you write to them, Bad Things (tm) could happen.
 
    * `SemVer2`: the version currently being built.
    * `Version`: a `System.Version` object for the current build. Only major, minor, and patch/build numbers will be filled in.
    * `SemVer1`: a semver version 1 compatible version of the current build.
    * `SemVer2NoBuildMetadata`: the current version without any build metadata.
 
    .EXAMPLE
    New-WhiskeyContext -Path '.\whiskey.yml'
 
    Demonstrates how to create a context for a developer build.
    #>

    [CmdletBinding()]
    [OutputType([Whiskey.Context])]
    param(
        [Parameter(Mandatory)]
        # The environment you're building in.
        [String]$Environment,

        [Parameter(Mandatory)]
        # The path to the `whiskey.yml` file that defines build settings and tasks.
        [String]$ConfigurationPath,

        # The place where downloaded tools should be cached. The default is the build root.
        [String]$DownloadRoot
    )

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

    $ConfigurationPath = Resolve-Path -LiteralPath $ConfigurationPath -ErrorAction Ignore
    if( -not $ConfigurationPath )
    {
        throw ('Configuration file path ''{0}'' does not exist.' -f $PSBoundParameters['ConfigurationPath'])
    }

    $config = Import-WhiskeyYaml -Path $ConfigurationPath

    if( $config.ContainsKey('Build') -and $config.ContainsKey('BuildTasks') )
    {
        throw ('{0}: The configuration file contains both "Build" and the deprecated "BuildTasks" pipelines. Move all your build tasks under "Build" and remove the "BuildTasks" pipeline.' -f $ConfigurationPath)
    }

    if( $config.ContainsKey('BuildTasks') )
    {
        Write-WhiskeyWarning ('{0}: The default "BuildTasks" pipeline has been renamed to "Build". Backwards compatibility with "BuildTasks" will be removed in the next major version of Whiskey. Rename your "BuildTasks" pipeline to "Build".' -f $ConfigurationPath)
    }

    if( $config.ContainsKey('Publish') -and $config.ContainsKey('PublishTasks') )
    {
        throw ('{0}: The configuration file contains both "Publish" and the deprecated "PublishTasks" pipelines. Move all your publish tasks under "Publish" and remove the "PublishTasks" pipeline.' -f $ConfigurationPath)
    }

    if( $config.ContainsKey('PublishTasks') )
    {
        Write-WhiskeyWarning ('{0}: The default "PublishTasks" pipeline has been renamed to "Publish". Backwards compatibility with "PublishTasks" will be removed in the next major version of Whiskey. Rename your "PublishTasks" pipeline to "Publish".' -f $ConfigurationPath)
    }

    $buildRoot = $ConfigurationPath | Split-Path
    if( -not $DownloadRoot )
    {
        $DownloadRoot = $buildRoot
    }

    [Whiskey.BuildInfo]$buildMetadata = Get-WhiskeyBuildMetadata
    $publish = $false
    $byBuildServer = $buildMetadata.IsBuildServer
    if( $byBuildServer )
    {
        $branch = $buildMetadata.ScmBranch

        if( $config.ContainsKey( 'PublishOn' ) )
        {
            Write-WhiskeyVerbose -Message ('PublishOn')
            foreach( $publishWildcard in $config['PublishOn'] )
            {
                $publish = $branch -like $publishWildcard
                if( $publish )
                {
                    Write-WhiskeyVerbose -Message (' {0} -like {1}' -f $branch,$publishWildcard)
                    break
                }
                else
                {
                    Write-WhiskeyVerbose -Message (' {0} -notlike {1}' -f $branch,$publishWildcard)
                }
            }
        }
    }

    $outputDirectory = Join-Path -Path $buildRoot -ChildPath '.output'
    if( -not (Test-Path -Path $outputDirectory -PathType Container) )
    {
        New-Item -Path $outputDirectory -ItemType 'Directory' -Force | Out-Null
    }

    $context = New-WhiskeyContextObject
    $context.BuildRoot = $buildRoot
    $runBy = [Whiskey.RunBy]::Developer
    if( $byBuildServer )
    {
        $runBy = [Whiskey.RunBy]::BuildServer
    }
    $context.RunBy = $runBy
    $context.BuildMetadata = $buildMetadata
    $context.Configuration = $config
    $context.ConfigurationPath = $ConfigurationPath
    $context.DownloadRoot = $DownloadRoot
    $context.Environment = $Environment
    $context.OutputDirectory = $outputDirectory
    $context.Publish = $publish
    $context.RunMode = [Whiskey.RunMode]::Build

    if( $config['Variable'] )
    {
        Write-WhiskeyError -Message ('{0}: The "Variable" property is no longer supported. Use the `SetVariable` task instead. Move your `Variable` property (and values) into your `Build` pipeline as the first task. Rename `Variable` to `SetVariable`.' -f $ConfigurationPath) -ErrorAction Stop
    }

    $context.Version = New-WhiskeyVersionObject -SemVer '0.0.0'

    return $context
}




function New-WhiskeyContextObject
{
    [CmdletBinding()]
    [OutputType([Whiskey.Context])]
    param(
    )

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

    return New-Object -TypeName 'Whiskey.Context'
}


function New-WhiskeyVersionObject
{
    [CmdletBinding()]
    [OutputType([Whiskey.BuildVersion])]
    param(
        [SemVersion.SemanticVersion]$SemVer
    )

    $whiskeyVersion = New-Object -TypeName 'Whiskey.BuildVersion'
    
    if( $SemVer )
    {
        $major = $SemVer.Major
        $minor = $SemVer.Minor
        $patch = $SemVer.Patch
        $prerelease = $SemVer.Prerelease
        $build = $SemVer.Build

        $version = New-Object -TypeName 'Version' -ArgumentList $major,$minor,$patch
        $semVersionNoBuild = New-Object -TypeName 'SemVersion.SemanticVersion' -ArgumentList $major,$minor,$patch
        $semVersionV1 = New-Object -TypeName 'SemVersion.SemanticVersion' -ArgumentList $major,$minor,$patch
        if( $prerelease )
        {
            $semVersionNoBuild = New-Object -TypeName 'SemVersion.SemanticVersion' -ArgumentList $major,$minor,$patch,$prerelease
            $semVersionV1Prerelease = $prerelease -replace '[^A-Za-z0-90]',''
            $semVersionV1 = New-Object -TypeName 'SemVersion.SemanticVersion' -ArgumentList $major,$minor,$patch,$semVersionV1Prerelease
        }

        $whiskeyVersion.Version = $version
        $whiskeyVersion.SemVer2 = $SemVer
        $whiskeyVersion.SemVer2NoBuildMetadata = $semVersionNoBuild
        $whiskeyVersion.SemVer1 = $semVersionV1
    }

    return $whiskeyVersion
}



function Publish-WhiskeyPesterTestResult
{
    [CmdletBinding()]
    param(
        [Parameter(Mandatory)]
        # The path to the Pester test resut.
        [String]$Path
    )

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

    if( -not (Test-Path -Path 'env:APPVEYOR_JOB_ID') )
    {
        return
    }

    $webClient = New-Object 'Net.WebClient'
    $uploadUri = 'https://ci.appveyor.com/api/testresults/nunit/{0}' -f $env:APPVEYOR_JOB_ID
    Resolve-Path -Path $Path -ErrorAction Stop |
        Select-Object -ExpandProperty 'ProviderPath' |
        ForEach-Object { 
            $resultPath = $_
            Write-WhiskeyVerbose -Message ('Uploading Pester test result file ''{0}'' to AppVeyor at ''{1}''.' -f $resultPath,$uploadUri)
            $webClient.UploadFile($uploadUri, $resultPath)
        }
}



function Register-WhiskeyEvent
{
    <#
    .SYNOPSIS
    Registers a command to call when specific events happen during a build.
 
    .DESCRIPTION
    The `Register-WhiskeyEvent` function registers a command to run when a specific event happens during a build. Supported events are:
     
    * `BeforeTask` which runs before each task
    * `AfterTask`, which runs after each task
 
    `BeforeTask` and `AfterTask` event handlers must have the following parameters:
 
        function Invoke-WhiskeyTaskEvent
        {
            param(
                [Parameter(Mandatory)]
                [Whiskey.Context]$TaskContext,
 
                [Parameter(Mandatory)]
                [String]$TaskName,
 
                [Parameter(Mandatory)]
                [hashtable]$TaskParameter
            )
        }
 
    To stop a build while handling an event, call the `Stop-WhiskeyTask` function.
    #>

    [CmdletBinding()]
    param(
        [Parameter(Mandatory)]
        # The context where the event should fire.
        [Whiskey.Context]$Context,
        [Parameter(Mandatory)]
        # The name of the command to run during the event.
        [String]$CommandName,

        [Parameter(Mandatory)]
        [ValidateSet('BeforeTask','AfterTask')]
        # When the command should be run; what events does it respond to?
        [String]$Event,

        # Only fire the event for a specific task.
        [String]$TaskName
    )

    Set-StrictMode -Version 'Latest'

    $eventName = $Event
    if( $TaskName )
    {
        $eventType = $Event -replace 'Task$',''
        $eventName = '{0}{1}Task' -f $eventType,$TaskName
    }

    $events = $Context.Events

    if( -not $events[$eventName] )
    {
        $events[$eventName] = New-Object -TypeName 'Collections.Generic.List[String]'
    }

    $events[$eventName].Add( $CommandName )
}



function Remove-WhiskeyFileSystemItem
{
    <#
    .SYNOPSIS
    Deletes a file or directory.
 
    .DESCRIPTION
    The `Remove-WhiskeyFileSystemItem` deletes files and directories. Directories are deleted recursively. On Windows, this function uses robocopy to delete directories, since it can handle files/directories whose paths are longer than the maximum 260 characters.
 
    If the file or directory doesn't exist, nothing happens.
 
    The path to delete should be absolute or relative to the current working directory.
 
    This function won't fail a build. If you want it to fail a build, pass the `-ErrorAction Stop` parameter.
 
    .EXAMPLE
    Remove-WhiskeyFileSystemItem -Path 'C:\some\file'
 
    Demonstrates how to delete a file.
 
    .EXAMPLE
    Remove-WhiskeyFilesystemItem -Path 'C:\project\node_modules'
 
    Demonstrates how to delete a directory.
 
    .EXAMPLE
    Remove-WhiskeyFileSystemItem -Path 'C:\project\node_modules' -ErrorAction Stop
 
    Demonstrates how to fail a build if the delete fails.
    #>

    [CmdletBinding()]
    param(
        [Parameter(Mandatory)]
        [String]$Path
    )

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

    Write-WhiskeyDebug -Message ('Remove-WhiskeyFileSystemItem BEGIN {0}' -f $Path)
    if( (Test-Path -Path $Path -PathType Leaf) )
    {
        Remove-Item -Path $Path -Force
    }
    elseif( (Test-Path -Path $Path -PathType Container) )
    {
        if( $IsWindows )
        {
            $logPath = Join-Path -Path ([IO.Path]::GetTempPath()) -ChildPath ('whiskey.robocopy.{0}.log' -f ([IO.Path]::GetRandomFileName()))
            $emptyDir = Join-Path -Path ([IO.Path]::GetTempPath()) -ChildPath ([IO.Path]::GetRandomFileName())
            $deleteLog = $true
            New-Item -Path $emptyDir -ItemType 'Directory' | Out-Null
            try
            {
                Invoke-WhiskeyRobocopy -Source $emptyDir -Destination $Path -LogPath $logPath | Out-Null
                if( $LASTEXITCODE -ge 8 )
                {
                    $deleteLog = $false
                    Write-WhiskeyError -Message ('Failed to remove directory "{0}". See "{1}" for more information.' -f $Path,$logPath)
                    return
                }
                Remove-Item -Path $Path -Recurse -Force
            }
            finally
            {
                if( $deleteLog )
                {
                    Remove-Item -Path $logPath -ErrorAction Ignore -Force
                }
                Remove-Item -Path $emptyDir -Recurse -Force
            }
        }
        else
        {
            Remove-Item -Path $Path -Recurse -Force
        }
    }
    Write-WhiskeyDebug -Message ('Remove-WhiskeyFileSystemItem END')
}



function Resolve-WhiskeyDotNetSdkVersion
{
    <#
    .SYNOPSIS
    Searches for a version of the .NET Core SDK to ensure it exists and returns the resolved version.
 
    .DESCRIPTION
    The `Resolve-WhiskeyDotNetSdkVersion` function ensures a given version is a valid released version of the .NET Core SDK. By default, the function will return the latest LTS version of the SDK. If a `Version` number is given then that version is compared against the list of released SDK versions to ensure the given version is valid. If no valid version is found matching `Version`, then an error is written and nothing is returned.
 
    .EXAMPLE
    Resolve-WhiskeyDotNetSdkVersion -LatestLTS
 
    Demonstrates returning the latest LTS version of the .NET Core SDK.
 
    .EXAMPLE
    Resolve-WhiskeyDotNetSdkVersion -Version '2.1.2'
 
    Demonstrates ensuring that version '2.1.2' is a valid released version of the .NET Core SDK.
 
    .EXAMPLE
    Resolve-WhiskeyDotNetSdkVersion -Version '2.*'
 
    Demonstrates resolving the latest '2.x.x' version of the .NET Core SDK.
    #>

    [CmdletBinding(DefaultParameterSetName='LatestLTS')]
    param(
        [Parameter(ParameterSetName='LatestLTS')]
        # Returns the latest LTS version of the .NET Core SDK.
        [switch]$LatestLTS,

        [Parameter(Mandatory, ParameterSetName='Version')]
        # Version of the .NET Core SDK to search for and resolve. Accepts wildcards.
        [String]$Version
    )

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

    if ($Version)
    {
        $urisToTry = @(
            'https://dotnetcli.blob.core.windows.net/dotnet/release-metadata/releases-index.json',
            'https://raw.githubusercontent.com/dotnet/core/master/release-notes/releases-index.json'
        )
        $releasesIndex = $null
        foreach( $uri in $urisToTry )
        {
            $releasesIndex = 
                Invoke-RestMethod -Uri $uri -ErrorAction Ignore |
                Select-Object -ExpandProperty 'releases-index' -ErrorAction Ignore

            if( $releasesIndex )
            {
                $releasesIndexUri = $uri
            }
        }

        if( -not $releasesIndex )
        {
            Write-WhiskeyError -Message ('Unable to find the .NET Core releases index. We tried each of these URIs:{0} {0}* {1}{0} ' -f [Environment]::NewLine,($urisToTry -join ('{0}* ' -f [Environment]::NewLine)))
            return
        }

        $releasesIndex =
            $releasesIndex |
            Where-Object { [Version]::TryParse($_.'channel-version', [ref]$null) } |
            ForEach-Object {
                $_.'channel-version' = [Version]$_.'channel-version'
                $_
            } |
            Sort-Object -Property 'channel-version' -Descending

        $Version -match '^\d+\.(?:\d+|\*)|^\*' | Out-Null
        $matcher = $Matches[0]

        $release = $releasesIndex | Where-Object { $_.'channel-version' -like $matcher } | Select-Object -First 1
        if (-not $release)
        {
            Write-WhiskeyError -Message ('.NET Core release matching "{0}" could not be found in "{1}"' -f $matcher, $releasesIndexUri)
            return
        }

        $releasesJsonUri = $release | Select-Object -ExpandProperty 'releases.json'
        Write-WhiskeyVerbose -Message ('[{0}] Resolving .NET Core SDK version "{1}" against known released versions at: "{2}"' -f $MyInvocation.MyCommand,$Version,$releasesJsonUri)

        $releasesJson = Invoke-RestMethod -Uri $releasesJsonUri -ErrorAction Stop

        $sdkVersions = & {
            $releasesJson.releases |
                Where-Object { $_ | Get-Member -Name 'sdk' } |
                Select-Object -ExpandProperty 'sdk' |
                Select-Object -ExpandProperty 'version'

            $releasesJson.releases |
                Where-Object { $_ | Get-Member -Name 'sdks' } |
                Select-Object -ExpandProperty 'sdks' |
                Select-Object -ExpandProperty 'version'
        }

        $resolvedVersion =
            $sdkVersions |
            ForEach-Object { $_ -as [Version] } |
            Where-Object { $_ -like $Version } |
            Sort-Object -Descending |
            Select-Object -First 1

        if (-not $resolvedVersion)
        {
            Write-WhiskeyError -Message ('A released version of the .NET Core SDK matching "{0}" could not be found in "{1}"' -f $Version, $releasesJsonUri)
            return
        }

        Write-WhiskeyVerbose -Message ('[{0}] SDK version "{1}" resolved to "{2}"' -f $MyInvocation.MyCommand,$Version,$resolvedVersion)
    }
    else
    {
        $latestLTSVersionUri = 'https://dotnetcli.blob.core.windows.net/dotnet/Sdk/LTS/latest.version'

        Write-WhiskeyVerbose -Message ('[{0}] Resolving latest LTS version of .NET Core SDK from: "{1}"' -f $MyInvocation.MyCommand,$latestLTSVersionUri)
        $latestLTSVersion = Invoke-RestMethod -Uri $latestLTSVersionUri -ErrorAction Stop

        if ($latestLTSVersion -match '(\d+\.\d+\.\d+)')
        {
            $resolvedVersion = $Matches[1]
        }
        else
        {
            Write-WhiskeyError -Message ('Could not retrieve the latest LTS version of the .NET Core SDK. "{0}" returned:{1}{2}' -f $latestLTSVersionUri,[Environment]::NewLine,$latestLTSVersion)
            return
        }

        Write-WhiskeyVerbose -Message ('[{0}] Latest LTS version resolved as: "{1}"' -f $MyInvocation.MyCommand,$resolvedVersion)
    }

    return $resolvedVersion
}



function Resolve-WhiskeyNodeModulePath
{
    <#
    .SYNOPSIS
    Gets the path to Node module's directory.
 
    .DESCRIPTION
    The `Resolve-WhiskeyNodeModulePath` resolves the path to a Node modules's directory. Pass the name of the module to the `Name` parameter. Pass the path to the build root to the `BuildRootPath` (this is usually where the package.json file is). The function will return the path to the Node module's directory in the local "node_modules" directory. Whiskey installs a private copy of Node for you into a ".node" directory in the build root. If you want to get the path to a global module from this private location, use the `-Global` switch.
     
    To get the Node module's directory from an arbitrary directory where Node is installed, pass the install directory to the `NodeRootPath` directory. This function handles the different locations of the "node_modules" directory across/between operating systems.
 
    If the Node module isn't installed, you'll get an error and nothing will be returned.
 
    .EXAMPLE
    Resolve-WhiskeyNodeModulePath -Name 'npm' -NodeRootPath $pathToNodeInstallRoot
 
    Demonstrates how to get the path to the `npm' module's directory from the "node_modules" directory from a directory where Node is installed, given by the `$pathToInstallRoot` variable.
 
    .EXAMPLE
    Resolve-WhiskeyNodeModulePath -Name 'npm' -BuildRootPath $TaskContext.BuildRoot
 
    Demonstrates how to get the path to a Node module's directory where Node installs a local copy. In this case, `Join-Path -Path $TaskContext.BuildRoot -ChildPath 'node_modules\npm'` would be returned (if it exists).
 
    .EXAMPLE
    Resolve-WhiskeyNodeModulePath -Name 'npm' -BuildRootPath $TaskContext.BuildRoot -Global
 
    Demonstrates how to get the path to a globally installed Node module's directory. Whiskey installs a private copy of Node into a ".node" directory in the build root, so this example would return a path to the module in that directory (if it exists). That path can be different between operating systems.
    #>

    [CmdletBinding()]
    param(
        [Parameter(Mandatory)]
        # The name of the Node module whose path to get.
        [String]$Name,

        [Parameter(Mandatory,ParameterSetName='FromBuildRoot')]
        # The path to the build root. This will return the path to Node modules's directory from the "node_modules" directory in the build root. If you want the path to a global node module, installed in the local Node directory Whiskey installs in the repository, use the `-Global` switch.
        [String]$BuildRootPath,

        [Parameter(ParameterSetName='FromBuildRoot')]
        # Get the path to a Node module in the global "node_modules" directory. The default is to get the path to the copy in the local node_modules directory.
        [switch]$Global,

        [Parameter(Mandatory,ParameterSetName='FromNodeRoot')]
        # The path to the root of a Node package, as downloaded and expanded from the Node.js project.
        [String]$NodeRootPath
    )

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

    if( $PSCmdlet.ParameterSetName -eq 'FromBuildRoot' )
    {
        if( $Global )
        {
            return (Resolve-WhiskeyNodeModulePath -NodeRootPath (Join-Path -Path $BuildRootPath -ChildPath '.node') -Name $Name)
        }

        return (Resolve-WhiskeyNodeModulePath -NodeRootPath $BuildRootPath -Name $Name)
    }

    $nodeModulePath = & {
                            Join-Path -Path $NodeRootPath -ChildPath 'lib/node_modules'
                            Join-Path -Path $NodeRootPath -ChildPath 'node_modules'
                        } |
                        ForEach-Object { Join-Path -Path $_ -ChildPath $Name } |
                        Where-Object { Test-Path -Path $_ -PathType Container } |
                        Select-Object -First 1 |
                        Resolve-Path |
                        Select-Object -ExpandProperty 'ProviderPath'

    if( -not $nodeModulePath )
    {
        Write-WhiskeyError -Message ('Node module "{0}" directory doesn''t exist in "{1}".' -f $Name,$NodeRootPath) -ErrorAction $ErrorActionPreference
        return
    }

    return $nodeModulePath
}



function Resolve-WhiskeyNodePath
{
    <#
    .SYNOPSIS
    Gets the path to the Node executable.
 
    .DESCRIPTION
    The `Resolve-WhiskeyNodePath` resolves the path to the Node executable in a cross-platform manner. The path/name of the Node executable is different on different operating systems. Pass the path to the root directory where Node is installed to the `NodeRootPath` parameter.
 
    If you want the path to the local version of Node that Whiskey installs for tasks that need it, pass the build root path to the `BuildRootPath` parameter.
 
    Returns the full path to the Node executable. If one isn't found, writes an error and returns nothing.
 
    .EXAMPLE
    Resolve-WhiskeyNodePath -NodeRootPath $pathToNodeInstallRoot
 
    Demonstrates how to get the path to the Node executable when the path to the root Node directory is in the `$pathToInstallRoot` variable.
 
    .EXAMPLE
    Resolve-WhiskeyNodePath -BuildRootPath $TaskContext.BuildRoot
 
    Demonstrates how to get the path to the Node executable in the directory where Whiskey installs it.
    #>

    [CmdletBinding()]
    param(
        [Parameter(Mandatory,ParameterSetName='FromBuildRoot')]
        # The path to the build root. This will return the path to Node where Whiskey installs a local copy.
        [String]$BuildRootPath,

        [Parameter(Mandatory,ParameterSetName='FromNodeRoot')]
        # The path to the root of an Node package, as downloaded and expanded from the Node.js download page.
        [String]$NodeRootPath
    )

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

    if( $PSCmdlet.ParameterSetName -eq 'FromBuildRoot' )
    {
        return (Resolve-WhiskeyNodePath -NodeRootPath (Join-Path -Path $BuildRootPath -ChildPath '.node'))
    }

    $nodePath = & {
                        Join-Path -Path $NodeRootPath -ChildPath 'bin/node'
                        Join-Path -Path $NodeRootPath -ChildPath 'node.exe'
                } |
                ForEach-Object {
                    Write-WhiskeyDebug -Message ('Looking for Node executable at "{0}".' -f $_)
                    $_
                } |
                Where-Object { Test-Path -Path $_ -PathType Leaf } |
                Select-Object -First 1 |
                Resolve-Path |
                Select-Object -ExpandProperty 'ProviderPath'

    if( -not $nodePath )
    {
        Write-WhiskeyError -Message ('Node executable doesn''t exist in "{0}".' -f $NodeRootPath) -ErrorAction $ErrorActionPreference
        return
    }

    Write-WhiskeyDebug -Message ('Found Node executable at "{0}".' -f $nodePath)
    return $nodePath
}


function Resolve-WhiskeyNuGetPackageVersion
{
    [CmdletBinding()]
    param(

        [Parameter(Mandatory)]
        # The name of the NuGet package to download.
        [String]$NuGetPackageName,

        # The version of the package to download. Must be a three part number, i.e. it must have a MAJOR, MINOR, and BUILD number.
        [String]$Version,

        [String]$NugetPath = ($whiskeyNuGetExePath)
    )

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

    if( -not $Version )
    {
        Set-Item -Path 'env:EnableNuGetPackageRestore' -Value 'true'
        $NuGetPackage = Invoke-Command -NoNewScope -ScriptBlock {
            & $NugetPath list ('packageid:{0}' -f $NuGetPackageName)
        }
        $Version = $NuGetPackage |
            Where-Object { $_ -match $NuGetPackageName } |
            Where-Object { $_ -match ' (\d+\.\d+\.\d+.*)' } |
            ForEach-Object { $Matches[1] } |
            Select-Object -First 1

        if( -not $Version )
        {
            Write-WhiskeyError -Message ("Unable to find latest version of package '{0}'." -f $NuGetPackageName)
            return
        }
    }
    elseif( [Management.Automation.WildcardPattern]::ContainsWildcardCharacters($version) )
    {
        Write-WhiskeyError -Message "Wildcards are not allowed for NuGet packages yet because of a bug in the nuget.org search API (https://github.com/NuGet/NuGetGallery/issues/3274)."
        return
    }
    return $Version
}


function Resolve-WhiskeyPowerShellModule
{
    <#
    .SYNOPSIS
    Searches for a PowerShell module using PowerShellGet to ensure it exists and returns the resulting object from PowerShellGet.
 
    .DESCRIPTION
    The `Resolve-WhiskeyPowerShellModule` function takes a `Name` of a PowerShell module and uses PowerShellGet's `Find-Module` cmdlet to search for the module. If the module is found, the object from `Find-Module` describing the module is returned. If no module is found, an error is written and nothing is returned. If the module is found in multiple PowerShellGet repositories, only the first one from `Find-Module` is returned.
 
    If a `Version` is specified then this function will search for that version of the module from all versions returned from `Find-Module`. If the version cannot be found, an error is written and nothing is returned.
 
    `Version` supports wildcard patterns.
 
    .EXAMPLE
    Resolve-WhiskeyPowerShellModule -Name 'Pester'
 
    Demonstrates getting the module info on the latest version of the Pester module.
 
    .EXAMPLE
    Resolve-WhiskeyPowerShellModule -Name 'Pester' -Version '4.*'
 
    Demonstrates getting the module info on the latest '4.X' version of the Pester module.
    #>

    [CmdletBinding()]
    param(
        [Parameter(Mandatory)]
        # The name of the PowerShell module.
        [String]$Name,

        # The version of the PowerShell module to search for. Must be a three part number, i.e. it must have a MAJOR, MINOR, and BUILD number.
        [String]$Version,

        [Parameter(Mandatory)]
        # The path to the directory where the PSModules directory should be created.
        [String]$BuildRoot,

        # Allow prerelease versions.
        [switch]$AllowPrerelease
    )

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

    $modulesRoot = Join-Path -Path $BuildRoot -ChildPath $powerShellModulesDirectoryName

    if( -not (Test-Path -Path $modulesRoot -PathType Container) )
    {
        New-Item -Path $modulesRoot -ItemType 'Directory' -ErrorAction Stop | Out-Null
    }

    # If you want to upgrade the PackageManagement and PowerShellGet versions, you must also update:
    # * Test\Resolve-WhiskeyPowerShellModule.Tests.ps1
    # * Whiskey\Tasks\PublishPowerShellModule.ps1
    # * whiskey.yml
    $packageManagementPackages = @{
        'PackageManagement' = '1.4.5';
        'PowerShellGet' = '2.2.1'
    }
    $modulesToInstall = New-Object 'Collections.ArrayList' 
    foreach( $packageName in $packageManagementPackages.Keys )
    {
        $packageVersion = $packageManagementPackages[$packageName]
        $moduleManifestPath = Join-Path -Path $modulesRoot -ChildPath ('{0}\{1}\{0}.psd1' -f $packageName,$packageVersion)
        $manifestOk = $false
        $manifest = $null
        try
        {
            $manifest = Test-ModuleManifest -Path $moduleManifestPath -ErrorAction Ignore
            $manifestOk = $true
        }
        catch
        {
            $Global:Error.RemoveAt(0)
        }

        if( -not $manifestOk -or -not $manifest )
        {
            Write-WhiskeyDebug -Message ('Module "{0}" version {1} does not exist at {2}.' -f $packageName,$packageVersion,($moduleManifestPath | Split-Path))
            $module = [pscustomobject]@{ 'Name' = $packageName ; 'Version' = $packageVersion }
            [Void]$modulesToInstall.Add($module)
        }
    }

    if( $modulesToInstall.Count )
    {
        Write-WhiskeyDebug -Message ('Installing package management modules to {0}. BEGIN' -f $modulesRoot)
        # Install Package Management modules in the background so we can load the new versions. These modules use
        # assemblies so once you load an old version, you have to re-launch your process to load a newer version.
        Start-Job -ScriptBlock {
            $ErrorActionPreference = $using:ErrorActionPreference
            $VerbosePreference = $using:VerbosePreference
            $InformationPreference = $using:InformationPreference
            $DebugPreference = $using:DebugPreference
            $ProgressPreference = $using:ProgressPreference

            $modulesToInstall = $using:modulesToInstall
            $modulesRoot = $using:modulesRoot

            Get-PackageProvider -Name 'NuGet' -ForceBootstrap | Out-Null
            foreach( $moduleInfo in $modulesToInstall )
            {
                $module = 
                    Find-Module -Name $moduleInfo.Name -RequiredVersion $moduleInfo.Version |
                    Select-Object -First 1
                if( -not $module )
                {
                    continue
                }

                Write-Verbose -Message ('Saving PowerShell module {0} {1} to "{2}" from repository {3}.' -f $module.Name,$module.Version,$modulesRoot,$module.Repository)
                Save-Module -Name $module.Name -RequiredVersion $module.Version -Repository $module.Repository -Path $modulesRoot
            }
        } | Receive-Job -Wait -AutoRemoveJob -InformationAction SilentlyContinue | Out-Null
        Write-WhiskeyDebug -Message (' END')
    }

    Import-WhiskeyPowerShellModule -Name 'PackageManagement','PowerShellGet' -PSModulesRoot $modulesRoot

    Write-WhiskeyDebug -Message ('{0} {1} ->' -f $Name,$Version)
    if( $Version )
    {
        $atVersionString = ' at version {0}' -f $Version

        if( -not [Management.Automation.WildcardPattern]::ContainsWildcardCharacters($version) -and [Version]::TryParse($Version,[ref]$null) )
        {
            $tempVersion = [Version]$Version
            if( $TempVersion -and ($TempVersion.Build -lt 0) )
            {
                $Version = [Version]('{0}.{1}.0' -f $TempVersion.Major, $TempVersion.Minor)
            }
        }

        $module = 
            Find-Module -Name $Name -AllVersions -AllowPrerelease:$AllowPrerelease |
            Where-Object { $_.Version.ToString() -like $Version } |
            Sort-Object -Property 'Version' -Descending
    }
    else
    {
        $atVersionString = ''
        $module = Find-Module -Name $Name -AllowPrerelease:$AllowPrerelease -ErrorAction Ignore
    }

    if( -not $module )
    {
        $registeredRepositories = Get-PSRepository | ForEach-Object { ('{0} ({1})' -f $_.Name,$_.SourceLocation) }
        $registeredRepositories = $registeredRepositories -join ('{0} * ' -f [Environment]::NewLine)
        Write-WhiskeyError -Message ('Failed to find PowerShell module {0}{1} in any of the registered PowerShell repositories:{2} {2} * {3} {2}' -f $Name, $atVersionString, [Environment]::NewLine, $registeredRepositories)
        return
    }

    $module = $module | Select-Object -First 1
    Write-WhiskeyDebug -Message ('{0} {1} {2}' -f (' ' * $Name.Length),(' ' * $Version.Length),$module.Version)
    return $module
}



function Resolve-WhiskeyTaskPath
{
    <#
    .SYNOPSIS
    Resolves paths provided by users to actual paths tasks can use.
 
    .DESCRIPTION
    The `Resolve-WhiskeyTaskPath` function validates and resolves paths provided by users to actual paths. It:
 
    * ensures the paths exist (use the `AllowNonexistent` switch to allow paths that don't exist).
    * ensures the paths exist under the build root (use the `AllowOutsideBuildRoot` switch to allow paths to escape the build root).
    * can ensure the user provides at least one value (use the `Mandatory` switch).
    * can ensure the user only provides one path or one path that resolves to a single path (use the `OnlySinglePath` switch).
    * can ensure that the user provides a path to a file or directory (pass the type you want to the `PathType` parameter).
    * can create the paths the user passed in (use the `Create` switch, the `AllowNonexistent` switch, and the `PathType` parameters).
 
    Wildcards are accepted for all paths and are resolved to actual paths.
 
    Paths are resolved relative to the current working directory, which for a Whiskey task is the build root.
 
    You must pass the name of the property whose path you're resolving to the `ProperytName` parameter. This is so Whiskey can write friendly error messages to the user.
 
    The resolved, relative paths are returned.
 
    If paths don't exist, Whiskey will stop and fail the current build. To allow paths to not exist, use the `AllowNonexistent` switch.
 
    You can use glob patterns (e.g. `**`) to find files. Pass your patterns to the `Path` parameter and use the `UseGlob` switch. The function installs and uses the [Glob](https://www.powershellgallery.com/packages/Glob) PowerShell module to resolve the patterns to files.
 
    .LINK
    https://www.powershellgallery.com/packages/Glob
 
    .EXAMPLE
    $paths | Resolve-WhiskeyTaskPath -TaskContext $context -PropertyName 'Path'
 
    Demonstrates the simplest way to use `Resolve-WhiskeyTaskPath`.
 
    .EXAMPLE
    $paths | Resolve-WhiskeyTaskPath -TaskContext $context -PropertyName 'Path' -Mandatory
 
    Demonstrates how to ensure that the user provides at least one path value to resolve.
 
    .EXAMPLE
    $path | Resolve-WhiskeyTaskPath -TaskContext $context -PropertyName 'Path' -OnlySinglePath
 
    Demonstrates how to ensure that the path(s) the user provides only resolves to one item.
 
    .EXAMPLE
    $path | Resolve-WhiskeyTaskPath -TaskContext $context -PropertyName 'Path' -PathType 'File'
 
    Demonstrates how to ensure that the user has passed paths to only files.
 
    .EXAMPLE
    $path | Resolve-WhiskeyTaskPath -TaskContext $context -PropertyName 'Path' -PathType 'Directory'
 
    Demonstrates how to ensure that the user has passed paths to only directories.
     
    .EXAMPLE
    $path | Resolve-WhiskeyTaskPath -TaskContext $context -PropertyName 'Path' -AllowNonexistent
 
    Demonstrates how to let the user pass paths to items that may or may not exist.
 
    .EXAMPLE
    $path | Resolve-WhiskeyTaskPath -TaskContext $context -PropertyName 'Path' -Create -AllowNonexistent -PathType File
 
    Demonstrates how to get Whiskey to create any non-existent items whose path the user passes. In this example, Whiskey will create files. To create directories, pass `Directory` to the PathType parameter. You *must* use `Create`, `AllowNonexistent`, and `PathType` parameters together.
 
    .EXAMPLE
    $path | Resolve-WhiskeyTaskPath -TaskContext $context -PropertyName 'Path' -AllowOutsideBuildRoot
 
    Demonstrates how to allow the user to pass paths to items that are outside the build root. Be very careful using this switch as it could allow attackers to use your task to do nefarious things to servers.
    #>

    [CmdletBinding(DefaultParameterSetName='FromParameters')]
    param(
        [Parameter(Mandatory)]
        # An object that holds context about the current build and executing task.
        [Whiskey.Context]$TaskContext,

        [Parameter(ValueFromPipeline)]
        [String]$Path,

        [Parameter(Mandatory,ParameterSetName='FromAttribute')]
        # INTERNAL. DO NOT USE.
        [Management.Automation.ParameterMetadata]$CmdParameter,

        [Parameter(Mandatory,ParameterSetName='FromAttribute')]
        # INTERNAL. DO NOT USE.
        [Whiskey.Tasks.ValidatePathAttribute]$ValidatePathAttribute,

        [Parameter(Mandatory,ParameterSetName='FromAttribute')]
        # INTERNAL. DO NOT USE.
        [hashtable]$TaskParameter,

        [Parameter(Mandatory,ParameterSetName='FromParameters')]
        [Parameter(Mandatory,ParameterSetName='FromParametersUsingGlob')]
        # The name of the property from the user's whiskey.yml file being parsed. Used to output helpful error messages.
        [String]$PropertyName,

        [Parameter(ParameterSetName='FromParameters')]
        # Fail if the path does not resolve to a single path.
        [switch]$OnlySinglePath,

        [Parameter(ParameterSetName='FromParameters')]
        [Parameter(ParameterSetName='FromParametersUsingGlob')]
        # The `Path` parameter must have at least one value.
        [switch]$Mandatory,

        [Parameter(ParameterSetName='FromParameters')]
        [ValidateSet('File','Directory')]
        # The type of item the path should be.
        [String]$PathType,

        [Parameter(ParameterSetName='FromParameters')]
        # Allow the paths to not exist.
        [switch]$AllowNonexistent,

        [Parameter(ParameterSetName='FromParameters')]
        # Allow the path to point to something outside the build root.
        [switch]$AllowOutsideBuildRoot,

        [Parameter(ParameterSetName='FromParameters')]
        # Create the path if it doesn't exist. Requires the `PathType` parameter.
        [switch]$Create,

        [Parameter(Mandatory,ParameterSetName='FromParametersUsingGlob')]
        # Whether or not to use glob syntax to find files. Install and uses the [Glob](https://www.powershellgallery.com/packages/Glob) PowerShell module to perform the search.
        [switch]$UseGlob,

        [Parameter(ParameterSetName='FromParametersUsingGlob')]
        # Files to exclude from being returned.
        [String[]]$Exclude = @()
    )

    begin
    {
        Set-StrictMode -Version 'Latest'

        $pathIdx = -1

        if( $PSCmdlet.ParameterSetName -eq 'FromAttribute' )
        {
            $Mandatory = $ValidatePathAttribute.Mandatory
            if( $ValidatePathAttribute.PathType )
            {
                $PathType = $ValidatePathAttribute.PathType
            }
            $AllowNonexistent = $ValidatePathAttribute.AllowNonexistent
            $AllowOutsideBuildRoot = $ValidatePathAttribute.AllowOutsideBuildRoot
            $Create = $ValidatePathAttribute.Create
            $UseGlob = $ValidatePathAttribute.UseGlob
            if( $ValidatePathAttribute.GlobExcludeParameter )
            {
                $Exclude = $TaskParameter[$ValidatePathAttribute.GlobExcludeParameter]
            }
            $PropertyName = $CmdParameter.Name
            $OnlySinglePath = $CmdParameter.ParameterType -ne [String[]]
            if( $UseGlob )
            {
                if( $OnlySinglePath )
                {
                    Stop-WhiskeyTask -TaskContext $Context -Message ('The "{0}" property is configured to use glob syntax to find matching paths, but the parameter''s type is not [String[]]. This is a task authoring error. If you are the task''s author, please change the "{0}" parameter''s type to be [String[]]. If you are not the task''s author, please contact them to request this change.' -f $PropertyName)
                    return
                }
            }
        }

        $useGetRelativePath = [IO.Path] | Get-Member -Static -Name 'GetRelativePath'

        $currentDirRelative = Join-Path -Path '..' -ChildPath (Get-Location | Split-Path -Leaf)
        $currentDir = (Get-Location).Path

        $globPaths = [Collections.ArrayList]::new()

        if( $UseGlob )
        {
            Install-WhiskeyPowerShellModule -Name 'Glob' -Version '0.1.*' -BuildRoot $TaskContext.BuildRoot | Out-Null
        }

        $insideCurrentDirPrefix = '.{0}' -f [IO.Path]::DirectorySeparatorChar
        $outsideCurrentDirPrefix = '..{0}' -f [IO.Path]::DirectorySeparatorChar

        # Carbon has a Resolve-RelativePath alias which is why we add a `W` prefix.
        function Resolve-WRelativePath
        {
            param(
                [Parameter(Mandatory)]
                [String[]]$Path,

                [String]$DebugPrefix
            )

            # Now, convert the paths to relative paths.
            foreach( $resolvedPath in $Path )
            {
                if( $useGetRelativePath )
                {
                    $relativePath = [IO.Path]::GetRelativePath($currentDir,$resolvedPath)
                    if( $relativePath -eq '.' )
                    {
                        $relativePath = '{0}{1}' -f $relativePath,[IO.Path]::DirectorySeparatorChar
                    }
                }
                else
                {
                    if( (Test-Path -Path $resolvedPath) )
                    {
                        $relativePath = Resolve-Path -Path $resolvedPath -Relative
                        # Resolve-Path likes to resolve the current directory's relative path as ..\DIR_NAME instead of .
                        if( $relativePath -eq $currentDirRelative )
                        {
                            $relativePath = '.{0}' -f [IO.Path]::DirectorySeparatorChar
                        }
                    }
                    else
                    {
                        # .NET Framework doesn't have a method to convert a non-existent path to a relative path, so we use
                        # P/Invoke to call into Windows shlwapi.
                        $relativePathBuilder = New-Object System.Text.StringBuilder 260
                        $converted = [Whiskey.Path]::PathRelativePathTo( $relativePathBuilder, $currentDir, [IO.FileAttributes]::Directory, $resolvedPath, [IO.FileAttributes]::Normal )
                        if( $converted ) 
                        { 
                            $relativePath = $relativePathBuilder.ToString() 
                        } 
                        else
                        {
                            $relativePath = $resolvedPath
                        }
                    }
                }
                
                # Files/directories that begin with a period don't get the .\ or ./ prefix put on them.
                if( -not $relativePath.StartsWith($insideCurrentDirPrefix) -and -not $relativePath.StartsWith($outsideCurrentDirPrefix) )
                {
                    $relativePath = Join-Path -Path '.' -ChildPath $relativePath
                }

                if( $DebugPrefix )
                {
                    Write-WhiskeyDebug -Context $TaskContext -Message ('{0} -> {1}' -f $DebugPrefix,$relativePath)
                }
                Write-Output $relativePath
            }
        }
    }

    process
    {
        Set-StrictMode -Version 'Latest'

        $pathIdx++

        if( -not $Path )
        {
            if( $Mandatory )
            {
                Stop-WhiskeyTask -TaskContext $Context `
                                 -PropertyName $PropertyName `
                                 -Message ('{0} is mandatory.' -f $PropertyName)
                return
            }
            return     
        }

        $result = $Path
        $resolvedPaths = $null
        
        # Normalize the directory separators, otherwise, if a path begins with '\', on Linux (and probably macOS),
        # `IsPathRooted` doesn't think the path is rooted.
        $normalizedPath = $result | Convert-WhiskeyPathDirectorySeparator

        if( $UseGlob )
        {
            if( [IO.Path]::IsPathRooted($normalizedPath) )
            {
                $normalizedPath = Resolve-WRelativePath -Path $normalizedPath
                if( $normalizedPath.StartsWith($insideCurrentDirPrefix) -and -not $normalizedPath.StartsWith($outsideCurrentDirPrefix) )
                {
                    $normalizedPath = $normalizedPath.Substring(2)
                }
            }
            [void]$globPaths.Add($normalizedPath)
        }
        else
        {
            $message = 'Resolve {0} ->' -f $Path
            $prefix = ' ' * ($message.Length - 3)
            Write-WhiskeyDebug -Context $TaskContext -Message $message

            if( -not [IO.Path]::IsPathRooted($normalizedPath) )
            {
                # Get the full path to the item
                $normalizedPath = Join-Path -Path $currentDir -ChildPath $result
            }

            # Remove all the '..' and '.' path parts from the path.
            if( -not [wildcardpattern]::ContainsWildcardCharacters($normalizedPath) )
            {
                $normalizedPath = [IO.Path]::GetFullPath($normalizedPath)
            }

            if( (Test-Path -Path $normalizedPath) )
            {
                $resolvedPaths = Get-Item -Path $normalizedPath -Force | Select-Object -ExpandProperty 'FullName'
            }

            if( -not $resolvedPaths ) 
            {
                if( -not $AllowNonexistent )
                {
                    Stop-WhiskeyTask -TaskContext $TaskContext `
                                     -Message ('{0}[{1}] "{2}" does not exist.' -f $PropertyName,$pathIdx,$Path)
                    return
                }

                $resolvedPaths = $normalizedPath

                # If it contains a wildcard, it didn't resolve to anything, so don't return it.
                if( [wildcardpattern]::ContainsWildcardCharacters($resolvedPaths) )
                {
                    return
                }
            }
            
            if( -not $AllowOutsideBuildRoot )
            {
                $fsCaseSensitive = -not (Test-Path -Path ($TaskContext.BuildRoot.FullName.ToUpperInvariant()))
                $comparer = [System.StringComparison]::OrdinalIgnoreCase
                if( $fsCaseSensitive )
                {
                    $comparer = [System.StringComparison]::Ordinal
                }
                
                $normalizedBuildRoot = $TaskContext.BuildRoot.FullName.TrimEnd([IO.Path]::DirectorySeparatorChar, [IO.Path]::AltDirectorySeparatorChar)
                $normalizedBuildRoot = '{0}{1}' -f $normalizedBuildRoot,[IO.Path]::DirectorySeparatorChar

                $invalidPaths =
                    $resolvedPaths |
                    Where-Object { -not ( $_.StartsWith($normalizedBuildRoot, $comparer) ) } |
                    # What if the user supplies '.' for the current directory?
                    Where-Object { ('{0}{1}' -f $_,[IO.Path]::DirectorySeparatorChar) -ne $normalizedBuildRoot }

                if( $invalidPaths )
                {
                    Stop-WhiskeyTask -TaskContext $TaskContext `
                                    -Message ('{0}[{1}] "{2}" is outside the build root "{3}".' -f $PropertyName,$pathIdx,$Path,$TaskContext.BuildRoot)
                    return
                }
            }

            $expectedPathType = $PathType  
            if( $expectedPathType -and -not $AllowNonexistent )
            {
                $itemType = 'Leaf'
                if( $expectedPathType -eq 'Directory' )
                {
                    $itemType = 'Container'
                }
                $invalidPaths = $resolvedPaths | Where-Object { -not (Test-Path -Path $_ -PathType $itemType) }
                if( $invalidPaths )
                {
                    Stop-WhiskeyTask -TaskContext $TaskContext -PropertyName $PropertyName -Message (@'
Found {0} paths that should resolve to a {1}, but don''t:
 
* {2}
 
'@
 -f ($invalidPaths | Measure-Object).Count,$expectedPathType.ToLowerInvariant(),($invalidPaths -join ('{0}* ' -f [Environment]::NewLine)))
                    return
                }
            }

            $pathCount = $resolvedPaths | Measure-Object | Select-Object -ExpandProperty 'Count'
            if( $OnlySinglePath -and $pathCount -gt 1 )
            {
                Stop-WhiskeyTask -TaskContext $TaskContext -PropertyName $CmdParameter.Name -Message (@'
    The value "{1}" resolved to {2} paths [1] but this task requires a single path. Please change "{1}" to a value that resolves to a single item.
 
    If you are this task''s author, and you want this property to accept multiple paths, please update the "{3}" command''s "{0}" property so it''s type is "[String[]]".
 
    [1] The {1} path resolved to:
 
    * {4}
 
'@
 -f $CmdParameter.Name,$Path,$pathCount,$TaskContext.TaskName,($resolvedPaths -join ('{0}* ' -f [Environment]::NewLine)))
            }

            if( $Create )
            {
                if( -not $PathType )
                {
                    Write-WhiskeyError -Message ('The ValidatePath attribute on the "{0}" task''s "{1}" property has Create set to true but the attribute doesn''t specify a value for the PathType property. This is a task authoring error. The task''s author must update this ValidatePath attribute to either remove its Create property (so Whiskey doesn''t try to create non-existent items) or add a PathType property and set its value to either "File" or "Directory" (so Whiskey knows what kind of item to create).' -f $TaskContext.TaskName,$CmdParameter.Name) -ErrorAction Stop
                    return
                }
                
                foreach( $item in $resolvedPaths )
                {
                    if( (Test-Path -Path $item) )
                    {
                        continue
                    }

                    New-Item -Path $item -ItemType $PathType -Force | Out-Null
                }
            }
        }

        if( $resolvedPaths )
        {
            Resolve-WRelativePath -Path $resolvedPaths -DebugPrefix $prefix
        }
    }

    end
    {
        if( -not $UseGlob )
        {
            return
        }

        $globPathsStats = $globPaths | Measure-Object -Maximum -Property 'Length'
        $longestPathLength = $globPathsStats.Maximum
        $messageFormat = 'Resolve {{0,-{0}}} ->' -f $longestPathLength
        $message = $messageFormat -f ($globPaths | Select-Object -First 1)
        $prefix = ' ' * ($message.Length - 3)
        Write-WhiskeyDebug -Context $TaskContext -Message $message

        $messageFormat = $messageFormat -replace '^Resolve',' '
        foreach( $globPath in ($globPaths | Select-Object -Skip 1) )
        {
            Write-WhiskeyDebug -Context $TaskContext -Message ($messageFormat -f $globPath)
        } 

        # Detect the case-sensitivity of the current directory so we can do a case-sensitive search if current directory
        # is on a case-sensitive file system.
        $parentPath = ''
        # Split-Path throws an exception if passed / in PowerShell Core.
        if( $currentDir -ne [IO.Path]::DirectorySeparatorChar -and $currentDir -ne [IO.Path]::AltDirectorySeparatorChar )
        {
            $parentPath = Split-Path -Path $currentDir -ErrorAction Ignore
        }
        $childName = Split-Path -Leaf -Path $currentDir
        # If we're in the root of the file system.
        if( -not $parentPath -or -not $childName )
        {
            $childPath = Get-ChildItem -Path $currentDir | Select-Object -First 1 | Select-Object -ExpandProperty 'FullName'
            $parentPath = Split-Path -Path $childPath
            if( -not $parentPath )
            {
                $parentPath = [IO.Path]::DirectorySeparatorChar
            }
            $childName = Split-Path -Leaf  -Path $childPath
        }
        $caseSensitivePath = Join-Path -Path $parentPath -ChildPath $childName.ToUpperInvariant()
        $caseSensitive = -not (Test-Path -Path $caseSensitivePath)
        # We only want to hit the file system once, since globs are pretty greedy.
        $resolvedPaths = 
            Find-GlobFile -Path $currentDir -Include $globPaths -Exclude $Exclude -Force -CaseSensitive:$caseSensitive |
            Select-Object -ExpandProperty 'FullName'

        if( -not $resolvedPaths )
        {
            if( -not $AllowNonexistent )
            {
                $pluralSuffix = ''
                if( $globPathsStats.Count -gt 1 )
                {
                    $pluralSuffix = '(s)'
                }
                $exclusionFilters = ''
                if( $Exclude )
                {
                    $exclusionFilters = ' (and excluding "{0}")' -f ($Exclude -join ', ')
                }

                Stop-WhiskeyTask -TaskContext $TaskContext `
                                    -Message ('{0}: glob pattern{1} "{2}"{3} did not match any files.' -f $PropertyName,$pluralSuffix,($globPaths -join ', '),$exclusionFilters)
            }
            return
        }
        
        Resolve-WRelativePath -Path $resolvedPaths -DebugPrefix $prefix
    }
}



function Resolve-WhiskeyTaskPathInternal
{
    [CmdletBinding()]
    param(
        [Parameter(Mandatory)]
        # An object that holds context about the current build and executing task.
        [Whiskey.Context]$TaskContext,

        [Parameter(Mandatory,ValueFromPipeline)]
        [String]$Path,

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

        # The root directory to use when resolving paths. The default is to use the `$TaskContext.BuildRoot` directory. Each path must be relative to this path.
        [String]$ParentPath,

        # Create the path if it doesn't exist. By default, the path will be created as a directory. To create the path as a file, pass `File` to the `PathType` parameter.
        [switch]$Force,

        [ValidateSet('Directory','File')]
        # The type of item to create when using the `Force` parameter to create paths that don't exist. The default is to create the path as a directory. Pass `File` to create the path as a file.
        [String]$PathType = 'Directory'
    )

    begin
    {
        Set-StrictMode -Version 'Latest'

        $pathIdx = -1
    }

    process
    {
        Set-StrictMode -Version 'Latest'

        $pathIdx++

        $originalPath = $Path
        if( [IO.Path]::IsPathRooted($Path) )
        {
            Stop-WhiskeyTask -TaskContext $TaskContext -Message ('{0}[{1}] ''{2}'' is absolute but must be relative to the ''{3}'' file.' -f $PropertyName,$pathIdx,$Path,$TaskContext.ConfigurationPath)
            return
        }

        if( -not $ParentPath )
        {
            $ParentPath = $TaskContext.BuildRoot
        }

        $Path = Join-Path -Path $ParentPath -ChildPath $Path
        if( -not (Test-Path -Path $Path) )
        {
            if( $Force )
            {
                New-Item -Path $Path -ItemType $PathType -Force | Out-String | Write-WhiskeyDebug -Context $TaskContext
            }
            else
            {
                if( $ErrorActionPreference -ne [Management.Automation.ActionPreference]::Ignore )
                {
                    Stop-WhiskeyTask -TaskContext $TaskContext -Message ('{0}[{1}] "{2}" does not exist.' -f $PropertyName,$pathIdx,$Path)
                }
                return
            }
        }

        $message = 'Resolve {0} ->' -f $originalPath
        $prefix = ' ' * ($message.Length - 3)
        Write-WhiskeyDebug -Context $TaskContext -Message $message
        Resolve-Path -Path $Path | 
            Select-Object -ExpandProperty 'ProviderPath' |
            ForEach-Object { 
                Write-WhiskeyDebug -Context $TaskContext -Message ('{0} -> {1}' -f $prefix,$_)
                $_
            }
    }

    end
    {
    }
}





function Resolve-WhiskeyVariable
{
    <#
    .SYNOPSIS
    Replaces any variables in a string to their values.
 
    .DESCRIPTION
    The `Resolve-WhiskeyVariable` function replaces any variables in strings, arrays, or hashtables with their values. Variables have the format `$(VARIABLE_NAME)`. Variables are expanded in each item of an array. Variables are expanded in each value of a hashtable. If an array or hashtable contains an array or hashtable, variables are expanded in those objects as well, i.e. `Resolve-WhiskeyVariable` recursivelye expands variables in all arrays and hashtables.
     
    You can add variables to replace via the `Add-WhiskeyVariable` function. If a variable doesn't exist, environment variables are used. If a variable has the same name as an environment variable, the variable value is used instead of the environment variable's value. If no variable or environment variable is found, `Resolve-WhiskeyVariable` will write an error and return the origin string.
 
    See the [Variables](https://github.com/webmd-health-services/Whiskey/wiki/Variables) page on the [Whiskey wiki](https://github.com/webmd-health-services/Whiskey/wiki) for a list of variables.
 
    .EXAMPLE
    '$(COMPUTERNAME)' | Resolve-WhiskeyVariable
 
    Demonstrates that you can use environment variable as variables. In this case, `Resolve-WhiskeyVariable` would return the name of the current computer.
 
    .EXAMPLE
    @( '$(VARIABLE)', 4, @{ 'Key' = '$(VARIABLE') } ) | Resolve-WhiskeyVariable
 
    Demonstrates how to replace all the variables in an array. Any value of the array that isn't a string is ignored. Any hashtable in the array will have any variables in its values replaced. In this example, if the value of `VARIABLE` is 'Whiskey`, `Resolve-WhiskeyVariable` would return:
 
        @(
            'Whiskey',
            4,
            @{
                Key = 'Whiskey'
            }
        )
 
    .EXAMPLE
    @{ 'Key' = '$(Variable)'; 'Array' = @( '$(VARIABLE)', 4 ) 'Integer' = 4; } | Resolve-WhiskeyVariable
 
    Demonstrates that `Resolve-WhiskeyVariable` searches hashtable values and replaces any variables in any strings it finds. If the value of `VARIABLE` is set to `Whiskey`, then the code in this example would return:
 
        @{
            'Key' = 'Whiskey';
            'Array' = @(
                            'Whiskey',
                            4
                      );
            'Integer' = 4;
        }
    #>

    [CmdletBinding(DefaultParameterSetName='ByPipeline')]
    param(
        [Parameter(Mandatory,ValueFromPipeline,ParameterSetName='ByPipeline')]
        [AllowNull()]
        # The object on which to perform variable replacement/substitution. If the value is a string, all variables in the string are replaced with their values.
        #
        # If the value is an array, variable expansion is done on each item in the array.
        #
        # If the value is a hashtable, variable replcement is done on each value of the hashtable.
        #
        # Variable expansion is performed on any arrays and hashtables found in other arrays and hashtables, i.e. arrays and hashtables are searched recursively.
        [Object]$InputObject,

        [Parameter(ParameterSetName='ByName')]
        # The name of a single Whiskey variable to resolve.
        [String]$Name,

        [Parameter(Mandatory)]
        # The context of the current build. Necessary to lookup any variables.
        [Whiskey.Context]$Context
    )

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

        if( $Name )
        {
            $InputObject = '$({0})' -f $Name
        }

        $version = $Context.Version
        $prereleaseID = ''
        if( $version.SemVer2.Prerelease -match '^(.*)\..*$' )
        {
            $prereleaseID = $Matches[1]
        }
        $buildInfo = $Context.BuildMetadata

        $sem1Version = ''
        if( $version.SemVer1 )
        {
            $sem1Version = '{0}.{1}.{2}' -f $version.SemVer1.Major,$version.SemVer1.Minor,$version.SemVer1.Patch
        }

        $sem2Version = ''
        if( $version.SemVer2 )
        {
            $sem2Version = '{0}.{1}.{2}' -f $version.SemVer2.Major,$version.SemVer2.Minor,$version.SemVer2.Patch
        }

        $wellKnownVariables = @{
            'WHISKEY_BUILD_ID' = $buildInfo.BuildID;
            'WHISKEY_BUILD_NUMBER' = $buildInfo.BuildNumber;
            'WHISKEY_BUILD_ROOT' = $Context.BuildRoot;
            'WHISKEY_BUILD_SERVER_NAME' = $buildInfo.BuildServer;
            'WHISKEY_BUILD_STARTED_AT' = $Context.StartedAt;
            'WHISKEY_BUILD_URI' = $buildInfo.BuildUri;
            'WHISKEY_ENVIRONMENT' = $Context.Environment;
            'WHISKEY_JOB_URI' = $buildInfo.JobUri;
            'WHISKEY_MSBUILD_CONFIGURATION' = (Get-WhiskeyMSBuildConfiguration -Context $Context);
            'WHISKEY_OUTPUT_DIRECTORY' = $Context.OutputDirectory;
            'WHISKEY_PIPELINE_NAME' = $Context.PipelineName;
            'WHISKEY_SCM_BRANCH' = $buildInfo.ScmBranch;
            'WHISKEY_SCM_COMMIT_ID' = $buildInfo.ScmCommitID;
            'WHISKEY_SCM_URI' = $buildInfo.ScmUri;
            'WHISKEY_SEMVER1' = $version.SemVer1;
            'WHISKEY_SEMVER1_VERSION' = $sem1Version;
            'WHISKEY_SEMVER2' = $version.SemVer2;
            'WHISKEY_SEMVER2_NO_BUILD_METADATA' = $version.SemVer2NoBuildMetadata;
            'WHISKEY_SEMVER2_PRERELEASE_ID' = $prereleaseID
            'WHISKEY_SEMVER2_VERSION' = $sem2Version;
            'WHISKEY_TASK_NAME' = $Context.TaskName;
            'WHISKEY_TEMP_DIRECTORY' = (Get-Item -Path ([IO.Path]::GetTempPath()));
            'WHISKEY_TASK_TEMP_DIRECTORY' = $Context.Temp;
            'WHISKEY_VERSION' = $version.Version;
        }
    }

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

        if( $InputObject -eq $null )
        {
            return $InputObject
        }

        if( (Get-Member -Name 'Keys' -InputObject $InputObject) )
        {
            $newValues = @{ }
            $toRemove = New-Object 'Collections.Generic.List[String]'
            # Can't modify a collection while enumerating it.
            foreach( $key in $InputObject.Keys )
            {
                $newKey = $key | Resolve-WhiskeyVariable -Context $Context  
                if( $newKey -ne $key )
                {
                    $toRemove.Add($key)
                }
                $newValues[$newKey] = Resolve-WhiskeyVariable -Context $Context -InputObject $InputObject[$key]
            }
            foreach( $key in $newValues.Keys )
            {
                $InputObject[$key] = $newValues[$key]
            }
            $toRemove | ForEach-Object { $InputObject.Remove($_) } | Out-Null
            return $InputObject
        }

        if( (Get-Member -Name 'Count' -InputObject $InputObject) )
        {
            for( $idx = 0; $idx -lt $InputObject.Count; ++$idx )
            {
                $InputObject[$idx] = Resolve-WhiskeyVariable -Context $Context -InputObject $InputObject[$idx]
            }
            return ,$InputObject
        }

        $startAt = 0
        $haystack = $InputObject.ToString()
        do
        {
            # Parse the variable expression, everything between $( and )
            $needleStart = $haystack.IndexOf('$(',$startAt)
            if( $needleStart -lt 0 )
            {
                break
            }
            elseif( $needleStart -gt 0 )
            {
                if( $haystack[$needleStart - 1] -eq '$' )
                {
                    $haystack = $haystack.Remove($needleStart - 1, 1)
                    $startAt = $needleStart
                    continue
                }
            }

            # Variable expressions can contain method calls, which begin and end with parenthesis, so
            # make sure you don't treat the close parenthesis of a method call as the close parenthesis
            # to the current variable expression.
            $needleEnd = $needleStart + 2
            $depth = 0
            while( $needleEnd -lt $haystack.Length )
            {
                $currentChar = $haystack[$needleEnd]
                if( $currentChar -eq ')' )
                {
                    if( $depth -eq 0 )
                    {
                        break
                    }

                    $depth--
                }
                elseif( $currentChar -eq '(' )
                {
                    $depth++
                }
                ++$needleEnd
            }
            
            $variableName = $haystack.Substring($needleStart + 2, $needleEnd - $needleStart - 2)
            $memberName = $null
            $arguments = $null

            # Does the variable expression contain a method call?
            if( $variableName -match '([^.]+)\.([^.(]+)(\(([^)]+)\))?' )
            {
                $variableName = $Matches[1]
                $memberName = $Matches[2]
                $arguments = $Matches[4]
                $arguments = & {
                                    if( -not $arguments )
                                    {
                                        return
                                    }

                                    $currentArg = New-Object 'Text.StringBuilder'
                                    $currentChar = $null
                                    $inString = $false
                                    # Parse each of the arguments in the method call. Each argument is
                                    # seperated by a comma. Ignore whitespace. Commas and whitespace that
                                    # are part of an argument must be double or single quoted. To include
                                    # a double quote inside a double-quoted string, double it. To include
                                    # a single quote inside a single-quoted string, double it.
                                    for( $idx = 0; $idx -lt $arguments.Length; ++$idx )
                                    {
                                        $nextChar = ''
                                        if( ($idx + 1) -lt $arguments.Length )
                                        {
                                            $nextChar = $arguments[$idx + 1]
                                        }

                                        $currentChar = $arguments[$idx]
                                        if( $currentChar -eq '"' -or $currentChar -eq "'" )
                                        {
                                            if( $inString )
                                            {
                                                if( $nextChar -eq $currentChar )
                                                {
                                                    [Void]$currentArg.Append($currentChar)
                                                    $idx++
                                                    continue
                                                }
                                            }
                                            
                                            $inString = -not $inString
                                            continue
                                        }

                                        if( $currentChar -eq ',' -and -not $inString )
                                        {
                                            $currentArg.ToString()
                                            [Void]$currentArg.Clear()
                                            continue
                                        }

                                        if( $inString -or -not [String]::IsNullOrWhiteSpace($currentChar) )
                                        {
                                            [Void]$currentArg.Append($currentChar)
                                        }
                                    }
                                    if( $currentArg.Length )
                                    {
                                        $currentArg.ToString()
                                    }
                               }

            }

            $envVarPath = 'env:{0}' -f $variableName
            if( $Context.Variables.ContainsKey($variableName) )
            {
                $value = $Context.Variables[$variableName]
            }
            elseif( $wellKnownVariables.ContainsKey($variableName) )
            {
                $value = $wellKnownVariables[$variableName]
            }
            elseif( [Environment] | Get-Member -Static -Name $variableName )
            {
                $value = [Environment]::$variableName
            }
            elseif( (Test-Path -Path $envVarPath) )
            {
                $value = (Get-Item -Path $envVarPath).Value
            }
            else
            {
                Write-WhiskeyError -Context $Context -Message ('Variable ''{0}'' does not exist. We were trying to replace it in the string ''{1}''. You can:
                 
* Use the `Add-WhiskeyVariable` function to add a variable named ''{0}'', e.g. Add-WhiskeyVariable -Context $context -Name ''{0}'' -Value VALUE.
* Create an environment variable named ''{0}''.
* Prevent variable expansion by escaping the variable with a backtick or backslash, e.g. `$({0}) or \$({0}).
* Remove the variable from the string.
  '
 -f $variableName,$InputObject) -ErrorAction $ErrorActionPreference
                return $InputObject
            }

            if( $value -eq $null )
            {
                $value = ''
            }

            if( $value -ne $null -and $memberName )
            {
                if( -not (Get-Member -Name $memberName -InputObject $value ) )
                {
                    Write-WhiskeyError -Context $Context -Message ('Variable ''{0}'' does not have a ''{1}'' member. Here are the available members:{2} {2}{3}{2} ' -f $variableName,$memberName,[Environment]::NewLine,($value | Get-Member | Out-String))
                    return $InputObject
                }

                if( $arguments )
                {
                    try
                    {
                        $value = $value.$memberName.Invoke($arguments)
                    }
                    catch
                    {
                        Write-WhiskeyError -Context $Context -Message ('Failed to call ([{0}]{1}).{2}(''{3}''): {4}.' -f $value.GetType().FullName,$value,$memberName,($arguments -join ''','''),$_)
                        return $InputObject
                    }
                }
                else
                {
                    $value = $value.$memberName
                }
            }

            $variableNumChars = $needleEnd - $needleStart + 1
            if( $needleStart + $variableNumChars -gt $haystack.Length )
            {
                Write-WhiskeyError -Context $Context -Message ('Unclosed variable expression ''{0}'' in value ''{1}''. Add a '')'' to the end of this value or escape the variable expression with a double dollar sign, e.g. ''${1}''.' -f $haystack.Substring($needleStart),$haystack)
                return $InputObject
            }

            $haystack = $haystack.Remove($needleStart,$variableNumChars)
            $haystack = $haystack.Insert($needleStart,$value)
            # No need to keep searching where we've already looked.
            $startAt = $needleStart
        }
        while( $true )

        return $haystack
    }
}



function Set-WhiskeyBuildStatus
{
    [CmdletBinding()]
    param(
        [Parameter(Mandatory)]
        [Whiskey.Context]$Context,

        [Parameter(Mandatory)]
        [ValidateSet('Started','Completed','Failed')]
        # The build status. Should be one of `Started`, `Completed`, or `Failed`.
        [String]$Status
    )

    Set-StrictMode -Version 'Latest'

    if( $Context.ByDeveloper )
    {
        return
    }

    $reportingTo = $Context.Configuration['PublishBuildStatusTo']

    if( -not $reportingTo )
    {
        return
    }

    $reporterIdx = -1
    foreach( $reporter in $reportingTo )
    {
        $reporterIdx++
        $reporterName = $reporter.Keys | Select-Object -First 1
        $propertyDescription = 'PublishBuildStatusTo[{0}]: {1}' -f $reporterIdx,$reporterName
        $reporterConfig = $reporter[$reporterName]
        switch( $reporterName )
        {
            'BitbucketServer'
            {
                Install-WhiskeyPowerShellModule -Name 'BitbucketServerAutomation' -Version '0.9.*' -BuildRoot $Context.BuildRoot

                $uri = $reporterConfig['Uri']
                if( -not $uri )
                {
                    Stop-WhiskeyTask -TaskContext $Context -PropertyDescription $propertyDescription -Message (@'
Property 'Uri' does not exist or does not have a value. Set this property to the Bitbucket Server URI where you want build statuses reported to, e.g.,
  
    PublishBuildStatusTo:
    - BitbucketServer:
        Uri: BITBUCKET_SERVER_URI
        CredentialID: CREDENTIAL_ID
         
'@
 -f $uri)
                    return
                }
                $credID = $reporterConfig['CredentialID']
                if( -not $credID )
                {
                    Stop-WhiskeyTask -TaskContext $Context -PropertyDescription $propertyDescription -Message (@'
Property 'CredentialID' does not exist or does not have a value. Set this property to the ID of the credential to use when connecting to the Bitbucket Server at '{0}', e.g.,
  
    PublishBuildStatusTo:
    - BitbucketServer:
        Uri: {0}
        CredentialID: CREDENTIAL_ID
  
Use the `Add-WhiskeyCredential` function to add the credential to the build.`
'@
 -f $uri)
                    return
                }
                $credential = Get-WhiskeyCredential -Context $Context -ID $credID -PropertyName 'CredentialID' -PropertyDescription $propertyDescription
                $conn = New-BBServerConnection -Credential $credential -Uri $uri
                $statusMap = @{
                                    'Started' = 'INPROGRESS';
                                    'Completed' = 'Successful';
                                    'Failed' = 'Failed'
                              }

                $buildInfo = $Context.BuildMetadata
                Set-BBServerCommitBuildStatus -Connection $conn -Status $statusMap[$Status] -CommitID $buildInfo.ScmCommitID -Key $buildInfo.JobUri -BuildUri $buildInfo.BuildUri -Name $buildInfo.JobName
            }

            default
            {
                Stop-WhiskeyTask -TaskContext $Context -PropertyDescription $propertyDescription -Message ('Unknown build status reporter ''{0}''. Supported reporters are ''BitbucketServer''.' -f $reporterName)
                return
            }
        }
    }
}




function Set-WhiskeyDotNetGlobalJson
{
    <#
    .SYNOPSIS
    Sets values within a .NET Core global.json file.
 
    .DESCRIPTION
    The `Set-WhiskeyDotNetGlobalJson` function sets values within a .NET Core `global.json` file. If the `global.json` file does not exist in the given `Directory` then it will be created. If the `global.json` file already exists, then the function will only update the desired values and leave the rest of the content as-is.
 
    .EXAMPLE
    Set-WhiskeyDotNetGlobalJson -Directory 'C:\Build\app' -SdkVersion '2.1.4'
 
    Demonstrates setting the `sdk.version` property in global.json to '2.1.4'.
    #>

    [CmdletBinding()]
    param(
        [Parameter(Mandatory)]
        # The directory where the `global.json` will be created/modified.
        [String]$Directory,

        [Parameter(Mandatory)]
        # The version of the SDK to set within the `global.json` file.
        [String]$SdkVersion
    )

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

    Write-WhiskeyVerbose -Message ('[{0}] Setting global.json properties: ''sdk.version'' => ''{1}''' -f $MyInvocation.MyCommand,$SdkVersion)

    if (-not (Test-Path -Path $Directory -PathType Container))
    {
        Write-WhiskeyError -Message ('The directory ''{0}'' does not exist.' -f $Directory)
        return
    }

    $globalJsonPath = Join-Path -Path $Directory -ChildPath 'global.json'
    Write-WhiskeyVerbose -Message ('[{0}] Looking for global.json at ''{1}''' -f $MyInvocation.MyCommand,$globalJsonPath)

    if (Test-Path -Path $globalJsonPath -PathType Leaf)
    {
        Write-WhiskeyVerbose -Message ('[{0}] Found existing global.json' -f $MyInvocation.MyCommand)
        Write-WhiskeyVerbose -Message ('[{0}] Updating ''{1}''' -f $MyInvocation.MyCommand,$globalJsonPath)
        try
        {
            $globalJson = Get-Content -Path $globalJsonPath -Raw | ConvertFrom-Json
        }
        catch
        {
            Write-WhiskeyError -Message ('global.json file ''{0}'' contains invalid JSON.' -f $globalJsonPath)
            return
        }

        if (-not ($globalJson | Get-Member -Name 'sdk'))
        {
            $globalJson | Add-Member -MemberType NoteProperty -Name 'sdk' -Value ([pscustomobject]@{ })
        }

        if (-not ($globalJson.sdk | Get-Member -Name 'version'))
        {
            $globalJson.sdk | Add-Member -MemberType NoteProperty -Name 'version' -Value ([pscustomobject]@{ })
        }

        $globalJson.sdk.version = $SdkVersion
    }
    else
    {
        Write-WhiskeyVerbose -Message ('[{0}] global.json does not exist at ''{1}''' -f $MyInvocation.MyCommand,$globalJsonPath)
        Write-WhiskeyVerbose -Message ('[{0}] Creating ''{1}''' -f $MyInvocation.MyCommand,$globalJsonPath)
        $globalJson = @{
                            'sdk' = @{
                                'version' = $SdkVersion
                            }
                       }
    }

    $globalJson | ConvertTo-Json -Depth 100 | Set-Content -Path $globalJsonPath -Force
    Write-WhiskeyVerbose -Message ('[{0}] global.json update finished.' -f $MyInvocation.MyCommand)
}



function Set-WhiskeyMSBuildConfiguration
{
    <#
    .SYNOPSIS
    Changes the configuration to use when running any MSBuild-based task/tool.
 
    .DESCRIPTION
    The `Set-WhiskeyMSBuildConfiguration` function sets the configuration to use when running any MSBuild-based task/tool (e.g. the `MSBuild`, `DotNetBuild`, `DotNetPublish`, etc.). Usually, the value should be set to either `Debug` or `Release`.
 
    Use `Get-WhiskeyMSBuildConfiguration` to get the current configuration.
 
    .EXAMPLE
    Set-WhiskeyMSBuildConfiguration -Context $Context -Value 'Release'
 
    Demonstrates how to set the configuration to use when running MSBuild tasks/tools.
    #>

    [CmdletBinding()]
    param(
        [Parameter(Mandatory)]
        # The context of the build whose MSBuild configuration you want to set. Use `New-WhiskeyContext` to create a context.
        [Whiskey.Context]$Context,

        [Parameter(Mandatory)]
        # The configuration to use.
        [String]$Value
    )

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

    $Context.MSBuildConfiguration = $Value
}



function Stop-Whiskey
{
    [CmdletBinding()]
    param(
        [Parameter(Mandatory)]
        # An object
        [Whiskey.Context]$Context,
              
        [Parameter(Mandatory)]
        [String]$Message
    )
              
    Set-StrictMode -Version 'Latest'
    Use-CallerPreference -Cmdlet $PSCmdlet -SessionState $ExecutionContext.SessionState
              
    throw '{0}: {1}' -f $Context.ConfigurationPath,$Message
}



function Stop-WhiskeyTask
{
    <#
    .SYNOPSIS
    Fails a Whiskey build by writing a terminating exception.
 
    .DESCRIPTION
    The `Stop-WhiskeyTask` function fails the current task and build by writing a terminating exception. Pass the current task's context to the `TaskContext` parameter. Pass a failure message to the `Message` property. Whiskey will fail the build with an error message that explans what task in what whiskey.yml file failed.
 
    If your build is failing because a task property is invalid, pass the name of the property to the `PropertyName` parameter. The property's name will be inserted into the error message.
 
    If you want to customize the task description in the error message, pass that description to the `PropertyDescription` parameter. Instead of using 'Task "TASK_NAME"' in the error message, Whiskey will use the value of the PropertyDescription parameter.
 
    .EXAMPLE
    Stop-WhiskeyTask -TaskContext $TaskContext -Message 'Something bad happened!'
 
    Demonstrates how to fail and stop the current build with the message "Something bad happened!".
 
    .EXAMPLE
    Stop-WhiskeyTask -TaskContext $TaskContext -Message 'Must be a number.' -PropertyName 'Count'
 
    Demonstrates how to add the name of an invalid property to the error message. The result of this example will be to have an error message like 'whiskey.yml: Task "MyTask": Property "Count": Must be a number.'
 
    .EXAMPLE
    Stop-WhiskeyTask -TaskContext $TaskContext -Message 'Bad things!' -PropertyDescription '"Fubar" task's "Snafu" property'
 
    Demonstrates how to customize the task name portion of the error message. In this case, Whiskey will write an error message like 'whiskey.yml: "Fubar" task's "Snafu" property: Bad things!'.
    #>

    [CmdletBinding()]
    param(
        [Parameter(Mandatory)]
        [Whiskey.Context]$TaskContext,

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

        [String]$PropertyName,

        [String]$PropertyDescription
    )

    Set-StrictMode -Version 'Latest'
    Use-CallerPreference -Cmdlet $PSCmdlet -SessionState $ExecutionContext.SessionState
    
    if( -not ($PropertyDescription) )
    {
        $PropertyDescription = 'Task "{0}"' -f $TaskContext.TaskName
    }

    if( $PropertyName )
    {
        $PropertyName = ': Property "{0}"' -f $PropertyName
    }

    if( $ErrorActionPreference -ne 'Ignore' )
    {
        $message = '{0}: {1}{2}: {3}' -f $TaskContext.ConfigurationPath,$PropertyDescription,$PropertyName,$Message
        Write-WhiskeyError -Context $TaskContext -Message $message -ErrorAction Stop
    }
}



function Test-WhiskeyTaskSkip
{
    <#
    .SYNOPSIS
    Determines if the current Whiskey task should be skipped.
 
    .DESCRIPTION
    The `Test-WhiskeyTaskSkip` function returns `$true` or `$false` indicating whether the current Whiskey task should be skipped. It determines if the task should be skipped by comparing values in the Whiskey context and common task properties.
    #>

    [CmdletBinding()]
    param(
        [Parameter(Mandatory)]
        # The context for the build.
        [Whiskey.Context]$Context,

        [Parameter(Mandatory)]
        # The common task properties defined for the current task.
        [hashtable]$Properties
    )

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

    if( $Properties['OnlyBy'] -and $Properties['ExceptBy'] )
    {
        Stop-WhiskeyTask -TaskContext $Context -Message ('This task defines both "OnlyBy" and "ExceptBy" properties. Only one of these can be used. Please remove one or both of these properties and re-run your build.')
        return
    }
    elseif( $Properties['OnlyBy'] )
    {
        [Whiskey.RunBy]$onlyBy = [Whiskey.RunBy]::Developer
        if( -not ([Enum]::TryParse($Properties['OnlyBy'], [ref]$onlyBy)) )
        {
            Stop-WhiskeyTask -TaskContext $Context -PropertyName 'OnlyBy' -Message ('invalid value: ''{0}''. Valid values are ''{1}''.' -f $Properties['OnlyBy'],([Enum]::GetValues([Whiskey.RunBy]) -join ''', '''))
            return
        }

        if( $onlyBy -ne $Context.RunBy )
        {
            Write-WhiskeyVerbose -Context $Context -Message ('OnlyBy.{0} -ne Build.RunBy.{1}' -f $onlyBy,$Context.RunBy)
            return $true
        }
    }
    elseif( $Properties['ExceptBy'] )
    {
        [Whiskey.RunBy]$exceptBy = [Whiskey.RunBy]::Developer
        if( -not ([Enum]::TryParse($Properties['ExceptBy'], [ref]$exceptBy)) )
        {
            Stop-WhiskeyTask -TaskContext $Context -PropertyName 'ExceptBy' -Message ('invalid value: ''{0}''. Valid values are ''{1}''.' -f $Properties['ExceptBy'],([Enum]::GetValues([Whiskey.RunBy]) -join ''', '''))
            return
        }

        if( $exceptBy -eq $Context.RunBy )
        {
            Write-WhiskeyVerbose -Context $Context -Message ('ExceptBy.{0} -eq Build.RunBy.{1}' -f $exceptBy,$Context.RunBy)
            return $true
        }
    }

    $branch = $Context.BuildMetadata.ScmBranch

    if( $Properties['OnlyOnBranch'] -and $Properties['ExceptOnBranch'] )
    {
        Stop-WhiskeyTask -TaskContext $Context -Message ('This task defines both OnlyOnBranch and ExceptOnBranch properties. Only one of these can be used. Please remove one or both of these properties and re-run your build.')
        return
    }

    if( $Properties['OnlyOnBranch'] )
    {
        $runTask = $false
        Write-WhiskeyVerbose -Context $Context -Message ('OnlyOnBranch')
        foreach( $wildcard in $Properties['OnlyOnBranch'] )
        {
            if( $branch -like $wildcard )
            {
                $runTask = $true
                Write-WhiskeyVerbose -Context $Context -Message (' {0} -like {1}' -f $branch, $wildcard)
                break
            }

            Write-WhiskeyVerbose -Context $Context -Message     (' {0} -notlike {1}' -f $branch, $wildcard)
        }
        if( -not $runTask )
        {
            return $true
        }
    }

    if( $Properties['ExceptOnBranch'] )
    {
        $runTask = $true
        Write-WhiskeyVerbose -Context $Context -Message ('ExceptOnBranch')
        foreach( $wildcard in $Properties['ExceptOnBranch'] )
        {
            if( $branch -like $wildcard )
            {
                $runTask = $false
                Write-WhiskeyVerbose -Context $Context -Message (' {0} -like {1}' -f $branch, $wildcard)
                break
            }

            Write-WhiskeyVerbose -Context $Context -Message     (' {0} -notlike {1}' -f $branch, $wildcard)
        }
        if( -not $runTask )
        {
            return $true
        }
    }

    $modes = @( 'Clean', 'Initialize', 'Build' )
    $onlyDuring = $Properties['OnlyDuring']
    $exceptDuring = $Properties['ExceptDuring']

    if ($onlyDuring -and $exceptDuring)
    {
        Stop-WhiskeyTask -TaskContext $Context -Message 'Both ''OnlyDuring'' and ''ExceptDuring'' properties are used. These properties are mutually exclusive, i.e. you may only specify one or the other.'
        return
    }
    elseif ($onlyDuring -and ($onlyDuring -notin $modes))
    {
        Stop-WhiskeyTask -TaskContext $Context -Message ('Property ''OnlyDuring'' has an invalid value: ''{0}''. Valid values are: ''{1}''.' -f $onlyDuring,($modes -join "', '"))
        return
    }
    elseif ($exceptDuring -and ($exceptDuring -notin $modes))
    {
        Stop-WhiskeyTask -TaskContext $Context -Message ('Property ''ExceptDuring'' has an invalid value: ''{0}''. Valid values are: ''{1}''.' -f $exceptDuring,($modes -join "', '"))
        return
    }

    if ($onlyDuring -and ($Context.RunMode -ne $onlyDuring))
    {
        Write-WhiskeyVerbose -Context $Context -Message ('OnlyDuring.{0} -ne Build.RunMode.{1}' -f $onlyDuring,$Context.RunMode)
        return $true
    }
    elseif ($exceptDuring -and ($Context.RunMode -eq $exceptDuring))
    {
        Write-WhiskeyVerbose -Context $Context -Message ('ExceptDuring.{0} -ne Build.RunMode.{1}' -f $exceptDuring,$Context.RunMode)
        return $true
    }

    if( $Properties['IfExists'] )
    {
        $exists = Test-Path -Path $Properties['IfExists']
        if( -not $exists )
        {
            Write-WhiskeyVerbose -Context $Context -Message ('IfExists {0} not exists' -f $Properties['IfExists'])
            return $true
        }
        Write-WhiskeyVerbose -Context $Context -Message     ('IfExists {0} exists' -f $Properties['IfExists'])
    }

    if( $Properties['UnlessExists'] )
    {
        $exists = Test-Path -Path $Properties['UnlessExists']
        if( $exists )
        {
            Write-WhiskeyVerbose -Context $Context -Message ('UnlessExists {0} exists' -f $Properties['UnlessExists'])
            return $true
        }
        Write-WhiskeyVerbose -Context $Context -Message     ('UnlessExists {0} not exists' -f $Properties['UnlessExists'])
    }

    if( $Properties['OnlyIfBuild'] )
    {
        [Whiskey.BuildStatus]$buildStatus = [Whiskey.BuildStatus]::Succeeded
        if( -not ([Enum]::TryParse($Properties['OnlyIfBuild'], [ref]$buildStatus)) )
        {
            Stop-WhiskeyTask -TaskContext $Context -PropertyName 'OnlyIfBuild' -Message ('invalid value: ''{0}''. Valid values are ''{1}''.' -f $Properties['OnlyIfBuild'],([Enum]::GetValues([Whiskey.BuildStatus]) -join ''', '''))
            return
        }

        if( $buildStatus -ne $Context.BuildStatus )
        {
            Write-WhiskeyVerbose -Context $Context -Message ('OnlyIfBuild.{0} -ne Build.BuildStatus.{1}' -f $buildStatus,$Context.BuildStatus)
            return $true
        }
    }

    if( $Properties['OnlyOnPlatform'] )
    {
        $shouldSkip = $true
        [Whiskey.Platform]$platform = [Whiskey.Platform]::Unknown
        foreach( $item in $Properties['OnlyOnPlatform'] )
        {
            if( -not [Enum]::TryParse($item,[ref]$platform) )
            {
                $validValues = [Enum]::GetValues([Whiskey.Platform]) | Where-Object { $_ -notin @( 'Unknown', 'All' ) }
                Stop-WhiskeyTask -TaskContext $Context -PropertyName 'OnlyOnPlatform' -Message ('Invalid platform "{0}". Valid values are "{1}".' -f $item,($validValues -join '", "'))
                return
            }
            $platform = [Whiskey.Platform]$item
            if( $CurrentPlatform.HasFlag($platform) )
            {
                Write-WhiskeyVerbose -Context $Context -Message ('OnlyOnPlatform {0} -eq {1}' -f $platform,$CurrentPlatform)
                $shouldSkip = $false
                break
            }
            else
            {
                Write-WhiskeyVerbose -Context $Context -Message ('OnlyOnPlatform ! {0} -ne {1}' -f $platform,$CurrentPlatform)
            }
        }
        return $shouldSkip
    }


    if( $Properties['ExceptOnPlatform'] )
    {
        $shouldSkip = $false
        [Whiskey.Platform]$platform = [Whiskey.Platform]::Unknown
        foreach( $item in $Properties['ExceptOnPlatform'] )
        {
            if( -not [Enum]::TryParse($item,[ref]$platform) )
            {
                $validValues = [Enum]::GetValues([Whiskey.Platform]) | Where-Object { $_ -notin @( 'Unknown', 'All' ) }
                Stop-WhiskeyTask -TaskContext $Context -PropertyName 'ExceptOnPlatform' -Message ('Invalid platform "{0}". Valid values are "{1}".' -f $item,($validValues -join '", "'))
                return
            }
            $platform = [Whiskey.Platform]$item
            if( $CurrentPlatform.HasFlag($platform) )
            {
                Write-WhiskeyVerbose -Context $Context -Message ('ExceptOnPlatform ! {0} -eq {1}' -f $platform,$CurrentPlatform)
                $shouldSkip = $true
                break
            }
            else
            {
                Write-WhiskeyVerbose -Context $Context -Message ('ExceptOnPlatform {0} -ne {1}' -f $platform,$CurrentPlatform)
            }
        }
        return $shouldSkip
    }

    return $false
}



function Uninstall-WhiskeyNode
{
    [CmdletBinding()]
    param(
        [Parameter(Mandatory)]
        # The directory where node is installed and from which it should be removed.
        [String]$InstallRoot
    )

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

    $dirToRemove = Join-Path -Path $InstallRoot -ChildPath '.node'
    Remove-WhiskeyFileSystemItem -Path $dirToRemove
}



function Uninstall-WhiskeyNodeModule
{
    <#
    .SYNOPSIS
    Uninstalls Node.js modules.
     
    .DESCRIPTION
    The `Uninstall-WhiskeyNodeModule` function will uninstall Node.js modules from the `node_modules` directory in the current working directory. It uses the `npm uninstall` command to remove the module.
     
    If the `npm uninstall` command fails to uninstall the module and the `Force` parameter was not used, then the function will write an error and return. If the `Force` parameter is used then the function will attempt to manually remove the module if `npm uninstall` fails.
     
    .EXAMPLE
    Uninstall-WhiskeyNodeModule -Name 'rimraf' -NodePath $TaskParameter['NodePath']
     
    Removes the node module 'rimraf' from the `node_modules` directory in the current directory.
 
    .EXAMPLE
    Uninstall-WhiskeyNodeModule -Name 'rimraf' -NodePath $TaskParameter['NodePath'] -Force
     
    Removes the node module 'rimraf' from `node_modules` directory in the current directory. Because the `Force` switch is used, if `npm uninstall` fails, will attemp to use PowerShell to remove the module.
    #>

    
    [CmdletBinding()]
    param(
        [Parameter(Mandatory)]
        # The name of the module to uninstall.
        [String]$Name,

        [Parameter(Mandatory)]
        # The path to the build root directory.
        [String]$BuildRootPath,

        # Node modules are being uninstalled on a developer computer.
        [switch]$ForDeveloper,

        # Remove the module manually if NPM fails to uninstall it
        [switch]$Force,

        # Uninstall the module from the global cache.
        [switch]$Global
    )

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

    $argumentList = & {
                        $Name
                        if( $Global )
                        {
                            '-g'
                        }
                    }

    Invoke-WhiskeyNpmCommand -Name 'uninstall' `
                             -BuildRootPath $BuildRootPath `
                             -ArgumentList $argumentList `
                             -ForDeveloper:$ForDeveloper
    
    $modulePath = Resolve-WhiskeyNodeModulePath -Name $Name -BuildRootPath $BuildRootPath -Global:$Global -ErrorAction Ignore

    if( $modulePath )
    {
        if( $Force )
        {
            Remove-WhiskeyFileSystemItem -Path $modulePath
        }
        else
        {
            Write-WhiskeyError -Message ('Failed to remove Node module "{0}" from "{1}". See previous errors for more details.' -f $Name,$modulePath)
            return
        }
    }

    if( $modulePath -and (Test-Path -Path $modulePath -PathType Container) )
    {
        Write-WhiskeyError -Message ('Failed to remove Node module "{0}" from "{1}" using both "npm prune" and manual removal. See previous errors for more details.' -f $Name,$modulePath)
        return
    }
}



function Uninstall-WhiskeyPowerShellModule
{
    <#
    .SYNOPSIS
    Removes downloaded PowerShell modules.
 
    .DESCRIPTION
    The `Uninstall-WhiskeyPowerShellModule` function deletes downloaded PowerShell modules from Whiskey's local "PSModules" directory.
 
    .EXAMPLE
    Uninstall-WhiskeyPowerShellModule -Name 'Pester'
 
    This example will uninstall the PowerShell module `Pester` from Whiskey's local `PSModules` directory.
 
    .EXAMPLE
    Uninstall-WhiskeyPowerShellModule -Name 'Pester' -ErrorAction Stop
 
    Demonstrates how to fail a build if uninstalling the module fails by setting the `ErrorAction` parameter to `Stop`.
    #>

    [CmdletBinding()]
    param(
        [Parameter(Mandatory)]
        # The name of the module to uninstall.
        [String]$Name,

        [String]$Version = '*.*.*',

        [Parameter(Mandatory)]
        # Modules are saved into a PSModules directory. This is the path where the PSModules directory was created and should be the same path passed to `Install-WhiskeyPowerShellModule`.
        [String]$BuildRoot,

        # The directory where the module is installed. If this parameter is provided, the BuildRoot parameter is ignored.
        [String]$Path
    )

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

    Get-Module -Name $Name | Remove-Module -Force

    $modulesRoot = Join-Path -Path $BuildRoot -ChildPath $powerShellModulesDirectoryName
    if( $Path )
    {
        $modulesRoot = $Path
    }

    # Remove modules saved by either PowerShell4 or PowerShell5
    $moduleRoots = @( ('{0}\{1}' -f $Name, $Version) )
    foreach ($item in $moduleRoots)
    {
        $removeModule = (Join-Path -Path $modulesRoot -ChildPath $item )
        if( Test-Path -Path $removeModule -PathType Container )
        {
            Remove-Item -Path $removeModule -Recurse -Force
            break
        }
    }

    if( (Test-Path -Path $modulesRoot -PathType Container) )
    {
        $psmodulesDirEmpty = $null -eq (Get-ChildItem -Path $modulesRoot -File -Recurse)
        if( $psmodulesDirEmpty )
        {
            Remove-Item -Path $modulesRoot -Recurse -Force
        }
    }
}


function Uninstall-WhiskeyTool
{
    <#
    .SYNOPSIS
    Removes a tool installed with `Install-WhiskeyTool`.
 
    .DESCRIPTION
    The `Uninstall-WhiskeyTool` function removes tools that were installed with `Install-WhiskeyTool`. It removes PowerShell modules, NuGet packages, Node, Node modules, and .NET Core SDKs that Whiskey installs into your build root. PowerShell modules are removed from the `Modules` direcory. NuGet packages are removed from the `packages` directory. Node and node modules are removed from the `.node` directory. The .NET Core SDK is removed from the `.dotnet` directory.
 
    When uninstalling a Node module, its name should be prefixed with `NodeModule::`, e.g. `NodeModule::rimraf`.
     
    Users of the `Whiskey` API typcially won't need to use this function. It is called by other `Whiskey` function so they have the tools they need.
 
    .EXAMPLE
    Uninstall-WhiskeyTool -ModuleName 'Pester'
 
    Demonstrates how to remove the `Pester` module from the default location.
         
    .EXAMPLE
    Uninstall-WhiskeyTool -NugetPackageName 'NUnit.Runners' -Version '2.6.4'
 
    Demonstrates how to uninstall a specific NuGet Package. In this case, NUnit Runners version 2.6.4 would be removed from the default location.
 
    .EXAMPLE
    Uninstall-WhiskeyTool -ModuleName 'Pester' -Path $forPath
 
    Demonstrates how to remove a Pester module from a specified path location other than the default location. In this case, Pester would be removed from the directory pointed to by the $forPath variable.
     
    .EXAMPLE
    Uninstall-WhiskeyTool -ModuleName 'Pester' -DownloadRoot $Root
 
    Demonstrates how to remove a Pester module from a DownloadRoot. In this case, Pester would be removed from `$Root\Modules`.
 
    .EXAMPLE
    Uninstall-WhiskeyTool -Name 'Node' -BuildRoot $TaskContext.BuildRoot
 
    Demonstrates how to uninstall Node from the `.node` directory in your build root.
 
    .EXAMPLE
    Uninstall-WhiskeyTool -Name 'NodeModule::rimraf' -BuildRoot $TaskContext.BuildRoot
 
    Demonstrates how to uninstall the `rimraf` Node module from the `node_modules` directory in the Node directory in your build root.
 
    .EXAMPLE
    Uninstall-WhiskeyTool -Name 'DotNet' -BuildRoot $TaskContext.BuildRoot
 
    Demonstrates how to uninstall the .NET Core SDK from the `.dotnet` directory in your build root.
    #>

    [CmdletBinding()]
    param(
        [Parameter(Mandatory,ParameterSetName='Tool')]
        # The tool attribute that defines what tool to uninstall.
        [Whiskey.RequiresToolAttribute]$ToolInfo,

        [Parameter(Mandatory,ParameterSetName='NuGet')]
        # The name of the NuGet package to uninstall.
        [String]$NuGetPackageName,

        # The version of the package to uninstall. Must be a three part number, i.e. it must have a MAJOR, MINOR, and BUILD number.
        [String]$Version,

        # The build root where the build is currently running. Tools are installed here.
        [String]$BuildRoot
    )

    Set-StrictMode -Version 'Latest'
    Use-CallerPreference -Cmdlet $PSCmdlet -SessionState $ExecutionContext.SessionState
    
    if( $PSCmdlet.ParameterSetName -eq 'NuGet' )
    {
        $Version = Resolve-WhiskeyNuGetPackageVersion -NuGetPackageName $NuGetPackageName -Version $Version -NugetPath $whiskeyNuGetExePath
        if( -not $Version )
        {
            return
        }
        $packagesRoot = Join-Path -Path $BuildRoot -ChildPath 'packages'
        $nuGetRootName = '{0}.{1}' -f $NuGetPackageName,$Version
        $nuGetRoot = Join-Path -Path $packagesRoot -ChildPath $nuGetRootName
        
        if( (Test-Path -Path $nuGetRoot -PathType Container) )
        {
            Remove-Item -Path $nuGetRoot -Recurse -Force
        }
    }
    elseif( $PSCmdlet.ParameterSetName -eq 'Tool' )
    {
        $provider,$name = $ToolInfo.Name -split '::'
        if( -not $name )
        {
            $name = $provider
            $provider = ''
        }

        if( $ToolInfo -is [Whiskey.RequiresPowerShellModuleAttribute] )
        {
            $provider = 'PowerShellModule'
        }

        switch( $provider )
        {
            'NodeModule'
            {
                # Don't do anything. All node modules require the Node tool to also be defined so they'll get deleted by the Node deletion.
            }
            'PowerShellModule'
            {
                Uninstall-WhiskeyPowerShellModule -Name $name -BuildRoot $BuildRoot
            }
            default
            {
                switch( $name )
                {
                    'Node'
                    {
                        Uninstall-WhiskeyNode -InstallRoot $BuildRoot
                    }
                    'DotNet'
                    {
                        $dotnetToolRoot = Join-Path -Path $BuildRoot -ChildPath '.dotnet'
                        Remove-WhiskeyFileSystemItem -Path $dotnetToolRoot
                    }
                    default
                    {
                        throw ('Unknown tool "{0}". The only supported tools are "Node" and "DotNet".' -f $name)
                    }
                }
            }
        }
    }
}



function Unregister-WhiskeyEvent
{
    <#
    .SYNOPSIS
    Unregisters a command to call when specific events happen during a build.
 
    .DESCRIPTION
    The `Unregister-WhiskeyEvent` function unregisters a command to run when a specific event happens during a build. This function is paired with `Register-WhiskeyEvent'.
    #>

    [CmdletBinding()]
    param(
        [Parameter(Mandatory)]
        # The context where the event should fire.
        [Whiskey.Context]$Context,
    
        [Parameter(Mandatory)]
        # The name of the command to run during the event.
        [String]$CommandName,

        [Parameter(Mandatory)]
        [ValidateSet('BeforeTask','AfterTask')]
        # When the command should be run; what events does it respond to?
        [String]$Event,

        # The specific task whose events to unregister.
        [String]$TaskName
    )

    Set-StrictMode -Version 'Latest'

    $eventName = $Event
    if( $TaskName )
    {
        $eventType = $Event -replace 'Task$',''
        $eventName = '{0}{1}Task' -f $eventType,$TaskName
    }

    $events = $Context.Events

    if( -not $events[$eventName] )
    {
        return
    }

    if( -not $Events[$eventName].Contains( $CommandName ) )
    {
        return
    }

    $events[$eventName].Remove( $CommandName )
}


# Copyright 2012 Aaron Jensen
#
# 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.

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 function/cmdlet call in your function. 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)
        }
    }

}

function Write-CommandOutput
{
    param(
        [Parameter(ValueFromPipeline)]
        [String]$InputObject,

        [Parameter(Mandatory)]
        [String]$Description
    )

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

        if( $InputObject -match '^WARNING\b' )
        {
            $InputObject | Write-WhiskeyWarning 
        }
        elseif( $InputObject -match '^ERROR\b' )
        {
            $InputObject | Write-WhiskeyError 
        }
        else
        {
            $InputObject | 
                ForEach-Object { '[{0}] {1}' -f $Description,$_ } | 
                Write-WhiskeyVerbose 
        }
    }
}



function Write-WhiskeyCommand
{
    [CmdletBinding()]
    param(
        [Parameter(Mandatory)]
        [Whiskey.Context]$Context,

        [String]$Path,

        [String[]]$ArgumentList
    )

    Set-StrictMode -Version 'Latest'
    Use-CallerPreference -Cmdlet $PSCmdlet -SessionState $ExecutionContext.SessionState
    
    $logArgumentList = Invoke-Command {
                                          if( $Path -match '\ ' )
                                          {
                                              '&'
                                          }
                                          $Path
                                          $ArgumentList
                                      } |
                                      ForEach-Object { 
                                          if( $_ -match '\ ' )
                                          {
                                              '"{0}"' -f $_.Trim('"',"'")
                                          }
                                          else
                                          {
                                              $_
                                          }
                                      }

    Write-WhiskeyInfo -Context $TaskContext -Message ($logArgumentList -join ' ')
    Write-WhiskeyVerbose -Context $TaskContext -Message $path
    $argumentPrefix = ' ' * ($path.Length + 2)
    foreach( $argument in $ArgumentList )
    {
        Write-WhiskeyVerbose -Context $TaskContext -Message ('{0}{1}' -f $argumentPrefix,$argument)
    }
}



function Write-WhiskeyDebug
{
    <#
    .SYNOPSIS
    Logs debug messages.
 
    .DESCRIPTION
    The `Write-WhiskeyDebug` function writes debug messages using PowerShell's `Write-Debug` cmdlet. Pass the context of the current build to the `Context` parameter and the message to write to the `Message` parameter. Messages are prefixed with the duration of the current build and the curren task name (if any). If the duration can't be determined, the current time is used.
 
    If `$DebugPreference` is set to `SilentlyContinue` or `Ignore`, Whiskey doesn't write anything at all. Message are silently dropped. Whiskey does its best to do as little work as possible.
 
    If you pipe multiple messages to `Write-WhiskeyDebug`, they are grouped together. The duration and task name are written before and after the group. Each message of the group is written unchanged, indented slightly, e.g.
 
        [00:00:16.39] [Log]
            My first debug message.
            My second debug message.
        [00:00:16.58] [Log]
 
    To view debug messages in your build output, you'll need to set the global `DebugPreference` variable to `Continue`.
 
    You can also log error, warning, info, and verbose messages with Whiskey's `Write-WhiskeyError`, `Write-WhiskeyWarning`, `Write-WhiskeyInfo`, and `Write-WhiskeyVerbose` functions.
 
    .EXAMPLE
    Write-WhiskeyDebug -Context $context -Message 'My debug message'
 
    Demonstrates how to write a debug message. In this case, something like this would be written:
 
        [00:00:20:93] [Log] My debug message
 
    .EXAMPLE
    $messages | Write-WhiskeyDebug -Context $context
 
    Demonstrates how to pipe messages to `Write-WhiskeyDebug`. If multiple messages are piped, the are grouped together like this:
 
        [00:00:16.39] [Log]
            My first debug message.
            My second debug message.
        [00:00:16.58] [Log]
    #>

    [CmdletBinding()]
    param(
        # The context of the current build.
        [Whiskey.Context]$Context,

        [Parameter(Mandatory,ValueFromPipeline,Position=0)]
        [AllowEmptyString()]
        [AllowNull()]
        # The message to write. Before being written, the message will be prefixed with the duration of the current build and the current task name (if any). If the current duration can't be determined, then the current time is used.
        #
        # If you pipe multiple messages, they are grouped together.
        [String]$Message
    )

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

        $write = $DebugPreference -notin @( [Management.Automation.ActionPreference]::Ignore, [Management.Automation.ActionPreference]::SilentlyContinue )

        if( -not $write )
        {
            return
        }
        
        $messages = $null
        if( $PSCmdlet.MyInvocation.ExpectingInput )
        {
            $messages = [Collections.ArrayList]::new()
        }
    }

    process
    {
        if( -not $write )
        {
            return
        }
        
        if( $PSCmdlet.MyInvocation.ExpectingInput )
        {
            [Void]$messages.Add($Message)
            return
        }
       
        Write-WhiskeyInfo -Context $Context -Message $Message -Level 'Debug'
    }

    end
    {
        if( -not $write )
        {
            return
        }
        
        if( $messages )
        {
            Write-WhiskeyInfo -Context $Context -Level 'Debug' -Message $messages
        }
    }
}



function Write-WhiskeyError
{
    <#
    .SYNOPSIS
    Logs error messages.
 
    .DESCRIPTION
    The `Write-WhiskeyError` function writes error messages using `Write-Error`. Pass the context of the current build to the `Context` parameter and the message you want to write to the `Message` parameter. Error messages are prefixed with the duration of the current build and the current task name (if any). If the duration of the current build can't be determined, the current time is written instead.
     
    By default, error messages do not stop a build. If you want to log an error *and* fail/stop a build, use `Stop-WhiskeyTask`.
 
    If `$ErrorActionPreference` is `Ignore`, Whiskey drops all messages and tries to do as little as possible so logging has minimal impact. For all other error action preferences, messages are still processed and written.
 
    Whiskey ships with its own error output formatter that will show the entire script stack trace of an error. You'll get this view even if you don't use `Write-WhiskeyError`.
 
    You can also log warning, info, verbose, and debug messages with Whiskey's `Write-WhiskeyWarning`, `Write-WhiskeyInfo`, `Write-WhiskeyVerbose`, and `Write-WhiskeyDebug` functions.
 
    .EXAMPLE
    Write-WhiskeyError -Context $context -Message 'Something bad happened!'
 
    Demonstrates how to write an error.
 
    .EXAMPLE
    $errors | Write-WhiskeyError -Context $context
 
    Demonstrates that you can pipe messages to `Write-WhiskeyError`.
    #>

    [CmdletBinding()]
    param(
        # The context for the current build. If not provided, Whiskey will search up the call stack looking for it.
        [Whiskey.Context]$Context,

        [Parameter(Mandatory,ValueFromPipeline,Position=0)]
        [AllowNull()]
        [AllowEmptyString()]
        # The message to write. Each message is written to the user with `Write-Error`.
        [String]$Message
    )

    begin
    {
        Set-StrictMode -Version 'Latest'
        Use-CallerPreference -Cmdlet $PSCmdlet -SessionState $ExecutionContext.SessionState
        
        $write = $ErrorActionPreference -ne [Management.Automation.ActionPreference]::Ignore

        if( -not $write )
        {
            return
        }

        [Collections.ArrayList]$messages = $null
        if( $PSCmdlet.MyInvocation.ExpectingInput )
        {
            $messages = [Collections.ArrayList]::new()
        }
    }

    process
    {
        if( -not $write )
        {
            return
        }

        if( $PSCmdlet.MyInvocation.ExpectingInput )
        {
            [Void]$messages.Add($Message)
            return
        }

        Write-WhiskeyInfo -Context $Context -Level Error -Message $Message
    }

    end
    {
        if( -not $write )
        {
            return
        }

        if( $messages )
        {
            Write-WhiskeyInfo -Context $Context -Level Error -Message $messages
        }
    }
}




function Write-WhiskeyInfo
{
    <#
    .SYNOPSIS
    Logs informational messages.
 
    .DESCRIPTION
    The `Write-WhiskeyInfo` function writes informational messages during a build using PowerShell's `Write-Information` cmdlet. Pass the current build's context object to the `Context` parameter and the message to write to the `Message` parameter. By default, Whiskey sets the `InformationPreference` to `Continue` for all builds so all information messages will typically be visible. To hide information messages, you must call `Invoke-WhiskeyBuild` with `-InformationAction` set to `Ignore`.
 
    Messages are prefixed with the duration of the current build and the current task's name (if any) e.g.
 
         [00:00:10.45] [Task] My message!
 
    If the duration of the current build can't be determined, the current time is written instead.
 
    If you pass in multiple messages to the `Message` parameter or pipe multiple messages in, the timestamp and task name are written, then each messages (indented four spaces), followed by another line with the duration and task name.
 
         [00:00:10.45] [Task]
             My first message.
             My second message.
         [00:00:10.45] [Task]
 
    If `$InformationPrefernce` is `Ignore`, Whiskey drops all messages as quickly as possible. It tries to do as little work so that logging has minimal affect. For all other information preference values, each message is processed and written.
 
    You can also log error, warning, verbose, and debug messages with Whiskey's `Write-WhiskeyError`, `Write-WhiskeyWarning`, `Write-WhiskeyVerbose`, and `Write-WhiskeyDebug` functions.
 
    You can use Whiskey's `Log` task to log messages at different levels.
 
    .EXAMPLE
    Write-WhiskeyInfo -Context $context -Message 'An info message'
 
    Demonstrates how write an `Info` message. In this case, something like this would be written:
 
        [00:00:20:93] [Log] An info message
 
    .EXAMPLE
    $output | Write-WhiskeyInfo -Context $context
 
    Demonstrates that you can pipe messages to `Write-WhiskeyInfo`. If multiple messages are piped, the are grouped together like this:
 
        [00:00:16.39] [Log]
            My first info message.
            My second info message.
        [00:00:16.58] [Log]
    #>

    [CmdletBinding()]
    param(
        # The context for the current build. If not provided, Whiskey will search up the call stack looking for it.
        [Whiskey.Context]$Context,

        [ValidateSet('Error','Warning','Info','Verbose','Debug')]
        # INTERNAL. DO NOT USE. To log at different levels, use `Write-WhiskeyError`, `Write-WhiskeyWarning`, `Write-WhiskeyVerbose`, or `Write-WhiskeyDebug`
        [String]$Level = 'Info',

        [Parameter(Mandatory,ValueFromPipeline,Position=0)]
        [AllowNull()]
        [AllowEmptyString()]
        # The message to write. Before being written, the message will be prefixed with the duration of the current build and the current task name (if any). If the current duration can't be determined, then the current time is used.
        #
        # If you pipe multiple messages, they are grouped together.
        [String[]]$Message
    )

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

        # Only write if absolutely necessary as it can be expensive.
        # Since silent errors, warnings, and info can be captured, we still have to output when their prefs are silent.
        # Silent verbose and debug messages are currently capturable, so don't even bother to write them.
        $write = ($Level -eq 'Error' -and $ErrorActionPreference -ne [Management.Automation.ActionPreference]::Ignore) -or 
                 ($Level -eq 'Warning' -and $WarningPreference -ne [Management.Automation.ActionPreference]::Ignore) -or
                 ($Level -eq 'Info' -and $InformationPreference -ne [Management.Automation.ActionPreference]::Ignore) -or
                 ($Level -eq 'Verbose' -and $VerbosePreference -notin @([Management.Automation.ActionPreference]::Ignore,[Management.Automation.ActionPreference]::SilentlyContinue)) -or
                 ($Level -eq 'Debug' -and $DebugPreference -notin @([Management.Automation.ActionPreference]::Ignore,[Management.Automation.ActionPreference]::SilentlyContinue))

        if( -not $write )
        {
            return
        }

        if( -not $context )
        {
            $context = Get-WhiskeyContext
        }

        $messages = New-Object 'Collections.ArrayList'

        $task = ''
        if( -not $context )
        {
            $duration = (Get-Date).ToString('HH:mm:ss.ff')
        }
        else
        {
            $duration = ((Get-Date) - $context.StartedAt).ToString('hh":"mm":"ss"."ff')
            if( $context.TaskName )
            {
                $task = ' [{0}]' -f $context.TaskName
            }
        }
    }

    process
    {
        if( -not $write )
        {
            return
        }

        if( $Message.Length -eq 0 )
        {
            return
        }

        foreach( $item in $Message )
        {
            [Void]$messages.Add($item)
        }
    }

    end
    {
        if( -not $write -or $messages.Count -lt 1 )
        {
            return
        }

        if( $Level -eq 'Error' )
        {
            $errorMsg = $messages -join [Environment]::NewLine
            Write-Error -Message $errorMsg
            return
        }

        $messagesToWrite = New-Object 'Collections.ArrayList' ($messages.Count + 2)
        $prefix = '[{0}]{1}' -f $duration,$task
        if( $messages.Count -gt 1 )
        {
            [Void]$messagesToWrite.Add($prefix)
            $indent = ' '
            foreach( $item in $Messages )
            {
                [Void]$messagesToWrite.Add(('{0}{1}' -f $indent,$item))
            }
            [Void]$messagesToWrite.Add($prefix)
        }
        else
        {
            [Void]$messagesToWrite.Add(('{0} {1}' -f $prefix,$messages[0]))
        }

        $writeCmd = 'Write-{0}' -f $Level
        if( $Level -eq 'Info' )
        {
            $writeCmd = 'Write-Information'
        }

        foreach( $messageToWrite in $messagesToWrite )
        {
            & $writeCmd $messageToWrite
        }
    }
}



function Write-WhiskeyObject
{
    <#
    .SYNOPSIS
    Writes objects as recognizable strings.
 
    .DESCRIPTION
    The `Write-WhiskeyObject` function writes objects as recognizable strings. Use the `Level` parameter to control what write function to use (see the help for `Write-WhiskeyInfo` for more information). It supports hashtables and dictionaries. It writes the keys and values in separate columns. If a value contains multiple values, each value is aligned with previous values. For example:
 
        VERBOSE: [13:34:39.15]
        VERBOSE: OutputFile .output\pester.xml
        VERBOSE: OutputFormat JUnitXml
        VERBOSE: PassThru True
        VERBOSE: Script .\PassingTests.ps1
        VERBOSE: .\OtherPassingTests.ps1
        VERBOSE: Show None
        VERBOSE: TestName PassingTests
        VERBOSE: [13:34:39.15]
 
    .EXAMPLE
    $hashtable | Write-WhiskeyObject -Context $Context -Level Verbose
 
    Demonstrates how to print the value of a hashtable in a recognizable format.
 
    .EXAMPLE
    $hashtable | Write-WhiskeyObject -Level Verbose
 
    Demonstrates that the `Context` parameter is optional. Whiskey searches up the call stack to find one if you don't pass it.
    #>

    [CmdletBinding()]
    param(
        # The context for the current build. If not provided, Whiskey will search up the call stack looking for it.
        [Whiskey.Context]$Context,

        [ValidateSet('Error','Warning','Info','Verbose','Debug')]
        # The level at which to write the object. The default is `Info`.
        [String]$Level = 'Info',

        [Parameter(Mandatory,ValueFromPipeline,Position=0)]
        [AllowNull()]
        [AllowEmptyString()]
        # The message/object to write. Before being written, the message will be prefixed with the duration of the current build and the current task name (if any). If the current duration can't be determined, then the current time is used.
        #
        # If you pipe multiple messages, they are grouped together.
        [Object]$InputObject
    )

    begin
    {
        Set-StrictMode -Version 'Latest'
        Use-CallerPreference -Cmdlet $PSCmdlet -SessionState $ExecutionContext.SessionState
        
        $objects = [Collections.ArrayList]::new()

        function ConvertTo-String
        {
            param(
                [Parameter(Mandatory,ValueFromPipeline)]
                [AllowNull()]
                [AllowEmptyString()]
                [AllowEmptyCollection()]
                [Object]$InputObject
            )
 
            process
            {
                if( $null -eq $InputObject )
                {
                    return '$null'
                }

                if( ($InputObject | Measure-Object).Count -gt 1 )
                {
                    $output = New-Object 'Text.StringBuilder' '@( '
                    $values = $InputObject | ConvertTo-String
                    $values = $values -join ', '
                    [void]$output.Append($values)
                    [void]$output.Append(' )')
                    return $output.ToString()
                }

                if( $InputObject | Get-Member 'Keys' )
                {
                    $output = New-Object 'Text.StringBuilder' '@{ '
                    $values = & {
                        foreach( $key in $InputObject.Keys )
                        {
                            $value = ConvertTo-String -InputObject $InputObject[$key]
                            $key = $key | ConvertTo-String
                            Write-Output ('{0} = {1}' -f $key,$value)
                        }
                    }
                    [void]$output.Append(($values -join '; '))
                    [void]$output.Append(' }')
                    return $output.ToString()
                }

                if( $InputObject -is [String] -and $InputObject -ne '$null' )
                {
                    return ("'{0}'" -f ($InputObject -replace "'","''"))
                }

                return $InputObject.ToString()
            }
        }
    }

    process
    {
        [void]$objects.Add($InputObject)
    }

    end
    {
        & {
            foreach( $object in $objects )
            {
                if( $object | Get-Member 'Keys' )
                {
                    $maxKeyLength = $object.Keys | ForEach-Object { $_.ToString().Length } | Sort-Object -Descending | Select-Object -First 1
                    $formatString = '{{0,-{0}}} {{1}}' -f $maxKeyLength
                    foreach( $key in ($object.Keys | Sort-Object) )
                    {
                        $value = $object[$key]
                        $firstValue = ConvertTo-String ($value | Select-Object -First 1)
                        Write-Output ($formatString -f $key,$firstValue)
                        $value | 
                            Select-Object -Skip 1 | 
                            ForEach-Object { $formatString -f ' ',(ConvertTo-String $_) }
                    }
                }
                else 
                {
                    $object | Out-String
                }
            }
        } | Write-WhiskeyInfo -Context $Context -Level $Level
    }
}



function Write-WhiskeyVerbose
{
    <#
    .SYNOPSIS
    Logs verbose messages.
 
    .DESCRIPTION
    The `Write-WhiskeyVerbose` function writes verbose messages with PowerShell's `Write-Verbose` cmdlet. Pass the context of the current build to the `Context` parameter and the message to log to the `Message` parameter. Each message is prefixed with the duration of the current build and the current task name (if any). If the duration can't be determined, the current time is used.
 
    If `$VerbosePreference` is set to `SilentlyContinue` or `Ignore`, Whiskey doesn't write anything at all. Message are silently dropped. Whiskey does its best to do as little work as possible.
 
    If you pipe multiple messages to `Write-WhiskeyVerbose`, they are grouped together. The duration and task name are written before and after the group. Each message in the group is then written unchanged, indented slightly, e.g.
 
        [00:00:08.42] [Log]
            My first verbose message.
            My second verbose message.
        [00:00:08.52] [Log]
 
    To see verbose messages in your build output, use the `-Verbose` switch when running your build.
 
    You can also log error, warning, info, and debug messages with Whiskey's `Write-WhiskeyError`, `Write-WhiskeyWarning`, `Write-WhiskeyInfo`, and `Write-WhiskeyDebug` functions.
 
    .EXAMPLE
    Write-WhiskeyVerbose -Context $context -Message 'My verbose message'
 
    Demonstrates how write a verbose message. In this case, something like this would be written:
 
        [00:00:20:93] [Log] My verbose message.
 
    .EXAMPLE
    $messages | Write-WhiskeyVerbose -Context $context
 
    Demonstrates that you can pipe messages to `Write-WhiskeyVerbose`. If multiple messages are piped, the are grouped together like this:
 
        [00:00:16.39] [Log]
            My first verbose message.
            My second verbose message.
        [00:00:16.58] [Log]
    #>

    [CmdletBinding()]
    param(
        # The current context.
        [Whiskey.Context]$Context,

        [Parameter(Mandatory,ValueFromPipeline,Position=0)]
        [AllowEmptyString()]
        [AllowNull()]
        # The message to write. Before being written, the message will be prefixed with the duration of the current build and the current task name (if any). If the current duration can't be determined, then the current time is used.
        #
        # If you pipe multiple messages, they are grouped together.
        [String]$Message
    )

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

        $write = $VerbosePreference -notin @( [Management.Automation.ActionPreference]::Ignore, [Management.Automation.ActionPreference]::SilentlyContinue )
        
        if( -not $write )
        {
            return
        }
        
        $messages = $null
        if( $PSCmdlet.MyInvocation.ExpectingInput )
        {
            $messages = [Collections.ArrayList]::new()
        }
    }

    process
    {
        if( -not $write )
        {
            return
        }

        if( $PSCmdlet.MyInvocation.ExpectingInput )
        {
            [Void]$messages.Add($Message)
            return
        }

        Write-WhiskeyInfo -Context $Context -Message $Message -Level 'Verbose'
    }

    end
    {
        if( -not $write )
        {
            return
        }

        if( $messages )
        {
            Write-WhiskeyInfo -Context $Context -Level 'Verbose' -Message $messages
        }
    }
}



function Write-WhiskeyWarning
{
    <#
    .SYNOPSIS
    Logs warning messages.
 
    .DESCRIPTION
    The `Write-WhiskeyWarning` function writes warning messages during a build using PowerShell's `Write-Warning` cmdlet. Pass the context of the current build to the `Context` parameter and the message to write to the `Message` parameter. Messages are prefixed with the duration of the current build and the current task (if any). If the duration can't be determined, the current time is written.
 
    If `$WarningPreference` is `Ignore`, Whiskey drops all messages and tries to do as little as possible so logging has minimal impact. For all other warning preferences, messages are still processed and written.
 
    If multiple messages are piped to `Write-WhiskeyWarning`, the are grouped together. The duration and task name are written before and after the group, and each message is the group is written unchanged, indented slightly, e.g.
 
        [00.00.03.34] [Log]
            My first warning message.
            My second warning message.
        [00.00.03.46] [Log]
 
    You can also log error, info, verbose, and debug messages with Whiskey's `Write-WhiskeyError`, `Write-WhiskeyInfo`, `Write-WhiskeyVerbose`, and `Write-WhiskeyDebug` functions.
 
    .EXAMPLE
    Write-WhiskeyWarning -Context $context -Message 'My warning!'
 
    Demonstrates how write a `Warning` message. In this case, something like this would be written:
 
        [00:00:20:93] [Log] My warning!
 
    .EXAMPLE
    $messages | Write-WhiskeyWarning -Context $context
 
    Demonstrates that you can pipe messages to `Write-WhiskeyWarning`. If multiple messages are piped, the are grouped together like this:
 
        [00:00:16.39] [Log]
            My first warning message.
            My second warning message.
        [00:00:16.58] [Log]
    #>

    [CmdletBinding()]
    param(
        # The current context.
        [Whiskey.Context]$Context,

        [Parameter(Mandatory,ValueFromPipeline,Position=0)]
        # The message to write. Before being written, the message will be prefixed with the duration of the current build and the current task name (if any). If the current duration can't be determined, then the current time is used.
        #
        # If you pipe multiple messages, they are grouped together.
        [String]$Message
    )

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

        $write = $WarningPreference -ne [Management.Automation.ActionPreference]::Ignore 

        if( -not $write )
        {
            return
        }

        $messages = $null
        if( $PSCmdlet.MyInvocation.ExpectingInput )
        {
            $messages = [Collections.ArrayList]::new()
        }
    }

    process 
    {
        if( -not $write )
        {
            return
        }

        if( $PSCmdlet.MyInvocation.ExpectingInput )
        {
            [Void]$messages.Add($Message)
            return
        }

        Write-WhiskeyInfo -Context $Context -Level 'Warning' -Message $Message
    }

    end
    {
        if( -not $write )
        {
            return 
        }

        if( $messages )
        {
            Write-WhiskeyInfo -Context $Context -Level 'Warning' -Message $messages
        }
    }

}



function Wait-WhiskeyAppVeyorBuildJob
{
    [CmdletBinding()]
    [Whiskey.Task('AppVeyorWaitForBuildJobs')]
    param(
        [Parameter(Mandatory)]
        [Whiskey.Context]$TaskContext,

        [String]$ApiKeyID,

        [TimeSpan]$CheckInterval = '00:00:10',

        [TimeSpan]$ReportInterval = '00:01:00',

        [String[]]$InProgressStatus = @('running','queued'),

        [String[]]$SuccessStatus = @('success')
    )

    Set-StrictMode -Version 'Latest'
    Use-CallerPreference -Cmdlet $PSCmdlet -SessionState $ExecutionContext.SessionState
    
    if( -not (Test-Path -Path 'env:APPVEYOR') )
    {
        Stop-WhiskeyTask -TaskContext $TaskContext -Message ('Not running under AppVeyor.')
        return
    }

    $bearerToken = Get-WhiskeyApiKey -Context $TaskContext -ID $ApiKeyID -PropertyName 'ApiKeyID'

    $headers = @{
        'Authorization' = ('Bearer {0}' -f $bearerToken);
        'Content-Type' = 'application/json';
    }

    $accountName = (Get-Item -Path 'env:APPVEYOR_ACCOUNT_NAME').Value
    $slug = (Get-Item -Path 'env:APPVEYOR_PROJECT_SLUG').Value
    $myBuildId = (Get-Item -Path 'env:APPVEYOR_BUILD_ID').Value
    $buildUri = 'https://ci.appveyor.com/api/projects/{0}/{1}/builds/{2}' -f $accountName,$slug,$myBuildId

    $myJobId =  (Get-Item -Path 'env:APPVEYOR_JOB_ID').Value

    $nextOutput = [Diagnostics.StopWatch]::new()
    # Eventually AppVeyor will time us out.
    while( $true )
    {
        $result = Invoke-RestMethod -Uri $buildUri -Method Get -Headers $headers -Verbose:$false
        $result | ConvertTo-Json -Depth 100 | Write-Debug

        if( -not $result -or -not $result.build )
        {
            Stop-WhiskeyTask -TaskContext $TaskContext -Message ('Failed to retrieve current build status from {0}: the request returned no build information:{1} {1}{2}.' -f $buildUri,[Environment]::NewLine,($result | ConvertTo-Json -Depth 100))
            return
        }

        $build = $result.build

        # Skip this job.
        $jobsToCheck = 
            $build.jobs | 
            Where-Object { $_ } |
            Where-Object { $_.jobId -ne $myJobId }

        $unfinishedJobs = 
            $jobsToCheck |
            # Jobs currently running don't have a 'finished' member. Just in case that changes in the future, also check status.
            Where-Object { -not ($_ | Get-Member 'finished') -or $_.status -in $InProgressStatus } |
            ForEach-Object {
                if( -not $nextOutput.IsRunning -or $ReportInterval -lt $nextOutput.Elapsed )
                {
                    Write-WhiskeyInfo -Context $TaskContext -Message ('"{0}" job is {1}.' -f $_.name,$_.status)
                    $nextOutput.Restart()
                }
                $_
            }
        
        if( $unfinishedJobs )
        {
            Start-Sleep -Milliseconds $CheckInterval.TotalMilliseconds
            continue
        }

        $failedJobs = $jobsToCheck | Where-Object { $_.status -notin $SuccessStatus }

        if( $failedJobs )
        {
            $suffix = ''
            if( ($failedJobs | Measure-Object).Count -gt 1 )
            {
                $suffix = 's'
            }
            $jobDescriptions = $failedJobs | ForEach-Object { '{0} (status: {1})' -f $_.name,$_.status}
            $jobDescriptions = $jobDescriptions -join ('{0} * ' -f [Environment]::NewLine)
            $errorMsg = 'This build''s other job{0} did not succeed.{1} {1} * {2} {1} ' -f $suffix, [Environment]::NewLine, $jobDescriptions
            Stop-WhiskeyTask -TaskContext $TaskContext -Message $errorMsg
            return
        }

        break
    }
}



function Copy-WhiskeyFile
{
    [Whiskey.Task('CopyFile')]
    [CmdletBinding()]
    param(
        [Parameter(Mandatory)]
        [Whiskey.Context]$TaskContext,

        [Parameter(Mandatory)]
        [hashtable]$TaskParameter,

        [Whiskey.Tasks.ValidatePath(Mandatory,PathType='File')]
        [String[]]$Path,

        [Whiskey.Tasks.ValidatePath(Mandatory,PathType='Directory',AllowNonexistent,Create)]
        [String[]]$DestinationDirectory
    )

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


    if( -not $DestinationDirectory )
    {
        Stop-WhiskeyTask -TaskContext $TaskContext -Message 'Property "DestinationDirectory" didn''t resolve to any existing paths.'
        return
    }

    foreach( $destDir in $DestinationDirectory )
    {
        foreach($sourceFile in $Path)
        {
            Write-WhiskeyInfo -Context $TaskContext -Message ('{0} -> {1}' -f $sourceFile,$destDir)
            Copy-Item -Path $sourceFile -Destination $destDir
        }
    }
}



function Remove-WhiskeyItem
{
    [Whiskey.TaskAttribute('Delete',SupportsClean)]
    [CmdletBinding()]
    param(
        [Parameter(Mandatory)]
        [Whiskey.Context]$TaskContext,

        [Whiskey.Tasks.ValidatePath(Mandatory,AllowNonexistent)]
        [String[]]$Path
    )

    Set-StrictMode -Version 'Latest'
    Use-CallerPreference -Cmdlet $PSCmdlet -SessionState $ExecutionContext.SessionState
    
    foreach( $pathItem in $Path )
    {
        if( -not (Test-Path -Path $pathItem) )
        {
            continue
        }

        Remove-WhiskeyFileSystemItem -Path $pathitem -ErrorAction Stop
    }
}


function Invoke-WhiskeyDotNet
{
    [CmdletBinding()]
    [Whiskey.Task('DotNet')]
    [Whiskey.RequiresTool('DotNet',PathParameterName='DotNetPath',VersionParameterName='SdkVersion')]
    param(
        [Parameter(Mandatory)]
        [Whiskey.Context]$TaskContext,

        [Parameter(Mandatory)]
        [hashtable]$TaskParameter
    )

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

    $command = $TaskParameter['Command']
    if( -not $command )
    {
        Stop-WhiskeyTask -TaskContext $TaskContext -Message ('Property "Command" is required. It should be the name of the dotnet.exe command to run, e.g. "build", "test", etc.')
        return
    }

    $dotnetExe = $TaskParameter['DotNetPath']

    $invokeParameters = @{
        TaskContext = $TaskContext
        DotNetPath = $dotnetExe
        Name = $command
        ArgumentList = $TaskParameter['Argument']
    }

    Write-WhiskeyVerbose -Context $TaskContext -Message ('.NET Core SDK {0}' -f (& $dotnetExe --version))

    if( $TaskParameter.ContainsKey('Path') )
    {
        $projectPaths = 
            $TaskParameter['Path'] | 
            Resolve-WhiskeyTaskPath -TaskContext $TaskContext -PropertyName 'Path' -PathType 'File' -AllowNonexistent
        if( -not $projectPaths -and (Get-Location).Path -ne $TaskContext.BuildRoot )
        {
            Push-Location $TaskContext.BuildRoot
            try
            {
                $projectPaths =
                    $TaskParameter['Path'] |
                    Resolve-WhiskeyTaskPath -TaskContext $TaskContext -PropertyName 'Path' -PathType 'File' |
                    Resolve-Path |
                    Select-Object -ExpandProperty 'ProviderPath'
            }
            finally
            {
                Pop-Location
            }

            if( $projectPaths )
            {
                Write-WhiskeyWarning -Context $TaskContext -Message ('Property Path: Paths are now resolved relative to a task''s working directory, not the build root. Please update the paths in your whiskey.yml file so they are relative to the DotNet task''s working directory.')
                $projectPaths = $projectPaths | Resolve-Path -Relative
            }
        }

        foreach( $projectPath in $projectPaths )
        {
            Invoke-WhiskeyDotNetCommand @invokeParameters -ProjectPath $projectPath
        }
    }
    else
    {
        Invoke-WhiskeyDotNetCommand @invokeParameters
    }
}



function Invoke-WhiskeyDotNetBuild
{
    [CmdletBinding()]
    [Whiskey.Task('DotNetBuild',Obsolete,ObsoleteMessage='The "DotNetTest" task is obsolete and will be removed in a future version of Whiskey. Please use the "DotNet" task instead.')]
    [Whiskey.RequiresTool('DotNet',PathParameterName='DotNetPath',VersionParameterName='SdkVersion')]
    param(
        [Parameter(Mandatory)]
        [Whiskey.Context]$TaskContext,

        [Parameter(Mandatory)]
        [hashtable]$TaskParameter
    )

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

    $dotnetExe = $TaskParameter['DotNetPath']

    $projectPaths = ''
    if ($TaskParameter['Path'])
    {
        $projectPaths = $TaskParameter['Path'] | Resolve-WhiskeyTaskPathInternal -TaskContext $TaskContext -PropertyName 'Path'
    }

    $verbosity = $TaskParameter['Verbosity']
    if( -not $verbosity )
    {
        $verbosity = 'minimal'
    }

    $dotnetArgs = & {
        '--configuration={0}' -f (Get-WhiskeyMSBuildConfiguration -Context $TaskContext)
        '-p:Version={0}'      -f $TaskContext.Version.SemVer1.ToString()

        if ($verbosity)
        {
            '--verbosity={0}' -f $verbosity
        }

        if ($TaskParameter['OutputDirectory'])
        {
            '--output={0}' -f $TaskParameter['OutputDirectory']
        }

        if ($TaskParameter['Argument'])
        {
            $TaskParameter['Argument']
        }
    }

    Write-WhiskeyVerbose -Context $TaskContext -Message ('.NET Core SDK {0}' -f (& $dotnetExe --version))

    foreach($project in $projectPaths)
    {
        Invoke-WhiskeyDotNetCommand -TaskContext $TaskContext -DotNetPath $dotnetExe -Name 'build' -ArgumentList $dotnetArgs -ProjectPath $project
    }
}



function Invoke-WhiskeyDotNetPack
{
    [CmdletBinding()]
    [Whiskey.Task('DotNetPack',Obsolete,ObsoleteMessage='The "DotNetTest" task is obsolete and will be removed in a future version of Whiskey. Please use the "DotNet" task instead.')]
    [Whiskey.RequiresTool('DotNet',PathParameterName='DotNetPath',VersionParameterName='SdkVersion')]
    param(
        [Parameter(Mandatory)]
        [Whiskey.Context]$TaskContext,

        [Parameter(Mandatory)]
        [hashtable]$TaskParameter
    )

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

    $dotnetExe = $TaskParameter['DotNetPath']

    $projectPaths = ''
    if ($TaskParameter['Path'])
    {
        $projectPaths = $TaskParameter['Path'] | Resolve-WhiskeyTaskPathInternal -TaskContext $TaskContext -PropertyName 'Path'
    }

    $symbols = $TaskParameter['Symbols'] | ConvertFrom-WhiskeyYamlScalar

    $verbosity = $TaskParameter['Verbosity']
    if (-not $verbosity)
    {
        $verbosity = 'minimal'
    }

    $dotnetArgs = & {
        '-p:PackageVersion={0}' -f $TaskContext.Version.SemVer1.ToString()
        '--configuration={0}' -f (Get-WhiskeyMSBuildConfiguration -Context $TaskContext)
        '--output={0}' -f $TaskContext.OutputDirectory
        '--no-build'
        '--no-dependencies'
        '--no-restore'

        if ($symbols)
        {
            '--include-symbols'
        }

        if ($verbosity)
        {
            '--verbosity={0}' -f $verbosity
        }

        if ($TaskParameter['Argument'])
        {
            $TaskParameter['Argument']
        }
    }

    Write-WhiskeyVerbose -Context $TaskContext -Message ('.NET Core SDK {0}' -f (& $dotnetExe --version))

    foreach($project in $projectPaths)
    {
        Invoke-WhiskeyDotNetCommand -TaskContext $TaskContext -DotNetPath $dotnetExe -Name 'pack' -ArgumentList $dotnetArgs -ProjectPath $project
    }
}



function Invoke-WhiskeyDotNetPublish
{
    [CmdletBinding()]
    [Whiskey.Task('DotNetPublish',Obsolete,ObsoleteMessage='The "DotNetPublish" task is obsolete and will be removed in a future version of Whiskey. Please use the "DotNet" task instead.')]
    [Whiskey.RequiresTool('DotNet',PathParameterName='DotNetPath',VersionParameterName='SdkVersion')]
    param(
        [Parameter(Mandatory)]
        [Whiskey.Context]$TaskContext,

        [Parameter(Mandatory)]
        [hashtable]$TaskParameter
    )

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

    $dotnetExe = $TaskParameter['DotNetPath']

    $projectPaths = ''
    if ($TaskParameter['Path'])
    {
        $projectPaths = $TaskParameter['Path'] | Resolve-WhiskeyTaskPathInternalInternal -TaskContext $TaskContext -PropertyName 'Path'
    }

    $verbosity = $TaskParameter['Verbosity']
    if (-not $verbosity)
    {
        $verbosity = 'minimal'
    }

    $dotnetArgs = & {
        '--configuration={0}' -f (Get-WhiskeyMSBuildConfiguration -Context $TaskContext)
        '-p:Version={0}'      -f $TaskContext.Version.SemVer1.ToString()

        if ($verbosity)
        {
            '--verbosity={0}' -f $verbosity
        }

        if ($TaskParameter['OutputDirectory'])
        {
            '--output={0}' -f $TaskParameter['OutputDirectory']
        }

        if ($TaskParameter['Argument'])
        {
            $TaskParameter['Argument']
        }
    }

    Write-WhiskeyVerbose -Context $TaskContext -Message ('.NET Core SDK {0}' -f (& $dotnetExe --version))

    foreach($project in $projectPaths)
    {
        Invoke-WhiskeyDotNetCommand -TaskContext $TaskContext -DotNetPath $dotnetExe -Name 'publish' -ArgumentList $dotnetArgs -ProjectPath $project
    }
}



function Invoke-WhiskeyDotNetTest
{
    [CmdletBinding()]
    [Whiskey.Task('DotNetTest',Obsolete,ObsoleteMessage='The "DotNetTest" task is obsolete and will be removed in a future version of Whiskey. Please use the "DotNet" task instead.')]
    [Whiskey.RequiresTool('DotNet',PathParameterName='DotNetPath',VersionParameterName='SdkVersion')]
    param(
        [Parameter(Mandatory)]
        [Whiskey.Context]$TaskContext,

        [Parameter(Mandatory)]
        [hashtable]$TaskParameter
    )

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

    $dotnetExe = $TaskParameter['DotNetPath']

    $projectPaths = ''
    if ($TaskParameter['Path'])
    {
        $projectPaths = $TaskParameter['Path'] | Resolve-WhiskeyTaskPathInternal -TaskContext $TaskContext -PropertyName 'Path'
    }

    $verbosity = $TaskParameter['Verbosity']
    if (-not $verbosity)
    {
        $verbosity = 'minimal'
    }

    $dotnetArgs = & {
        '--configuration={0}' -f (Get-WhiskeyMSBuildConfiguration -Context $TaskContext)
        '--no-build'
        '--results-directory={0}' -f ($TaskContext.OutputDirectory.FullName)

        if ($Taskparameter['Filter'])
        {
            '--filter={0}' -f $TaskParameter['Filter']
        }

        if ($TaskParameter['Logger'])
        {
            '--logger={0}' -f $TaskParameter['Logger']
        }

        if ($verbosity)
        {
            '--verbosity={0}' -f $verbosity
        }

        if ($TaskParameter['Argument'])
        {
            $TaskParameter['Argument']
        }
    }

    Write-WhiskeyVerbose -Context $TaskContext -Message ('.NET Core SDK {0}' -f (& $dotnetExe --version))

    foreach($project in $projectPaths)
    {
        Invoke-WhiskeyDotNetCommand -TaskContext $TaskContext -DotNetPath $dotnetExe -Name 'test' -ArgumentList $dotnetArgs -ProjectPath $project
    }
}


function Invoke-WhiskeyExec
{
    [CmdletBinding()]
    [Whiskey.Task('Exec',SupportsClean,SupportsInitialize)]
    param(
        [Parameter(Mandatory)]
        [Whiskey.Context]$TaskContext,

        [Parameter(Mandatory)]
        [hashtable]$TaskParameter
    )

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

    if( $TaskParameter.ContainsKey('') )
    {
        $regExMatches = Select-String -InputObject $TaskParameter[''] -Pattern '([^\s"'']+)|("[^"]*")|(''[^'']*'')' -AllMatches
        $defaultProperty = @($regExMatches.Matches.Groups | Where-Object { $_.Name -ne '0' -and $_.Success -eq $true } | Select-Object -ExpandProperty 'Value')

        $TaskParameter['Path'] = $defaultProperty[0]
        if( $defaultProperty.Count -gt 1 )
        {
            $TaskParameter['Argument'] = $defaultProperty[1..($defaultProperty.Count - 1)] | ForEach-Object { $_.Trim("'",'"') }
        }
    }

    $path = $TaskParameter['Path']
    if ( -not $path )
    {
        Stop-WhiskeyTask -TaskContext $TaskContext -Message ('Property "Path" is mandatory. It should be the Path to the executable you want the Exec task to run, e.g.
 
            Build:
            - Exec:
                Path: cmd.exe
 
        '
)
        return
    }

    $path = & {
                    if( [IO.Path]::IsPathRooted($path) )
                    {
                        $path
                    }
                    else 
                    {
                        Join-Path -Path (Get-Location).Path -ChildPath $path
                        Join-Path -Path $TaskContext.BuildRoot -ChildPath $path
                    }
            } |
            Where-Object { Test-Path -path $_ -PathType Leaf } |
            Select-Object -First 1 |
            Resolve-Path |
            Select-Object -ExpandProperty 'ProviderPath'

    if( -not $path )
    {
        $path = $TaskParameter['Path']
        if( -not (Get-Command -Name $path -CommandType Application -ErrorAction Ignore) )
        {
            Stop-WhiskeyTask -TaskContext $TaskContext -Message ('Executable "{0}" does not exist. We checked if the executable is at that path on the file system and if it is in your PATH environment variable.' -f $path)
            return
        }
    }

    if( ($path | Measure-Object).Count -gt 1 )
    {
        Stop-WhiskeyTask -TaskContext $TaskContext -Message ('Unable to run executable "{0}": it contains wildcards and resolves to the following files: "{1}".' -f $TaskParameter['Path'],($path -join '","'))
        return
    }

    Write-WhiskeyCommand -Context $TaskContext -Path $path -ArgumentList $TaskParameter['Argument']

    # Don't use Start-Process. If/when a build runs in a background job, when Start-Process finishes, it immediately terminates the build. Full stop.
    & $path $TaskParameter['Argument']
    $exitCode = $LASTEXITCODE

    $successExitCodes = $TaskParameter['SuccessExitCode']
    if( -not $successExitCodes )
    {
        $successExitCodes = '0'
    }

    foreach( $successExitCode in $successExitCodes )
    {
        if( $successExitCode -match '^(\d+)$' )
        {
            if( $exitCode -eq [int]$Matches[0] )
            {
                Write-WhiskeyVerbose -Context $TaskContext -Message ('Exit Code {0} = {1}' -f $exitCode,$Matches[0])
                return
            }
        }

        if( $successExitCode -match '^(<|<=|>=|>)\s*(\d+)$' )
        {
            $operator = $Matches[1]
            $successExitCode = [int]$Matches[2]
            switch( $operator )
            {
                '<'
                {
                    if( $exitCode -lt $successExitCode )
                    {
                        Write-WhiskeyVerbose -Context $TaskContext -Message ('Exit Code {0} < {1}' -f $exitCode,$successExitCode)
                        return
                    }
                }
                '<='
                {
                    if( $exitCode -le $successExitCode )
                    {
                        Write-WhiskeyVerbose -Context $TaskContext -Message ('Exit Code {0} <= {1}' -f $exitCode,$successExitCode)
                        return
                    }
                }
                '>'
                {
                    if( $exitCode -gt $successExitCode )
                    {
                        Write-WhiskeyVerbose -Context $TaskContext -Message ('Exit Code {0} > {1}' -f $exitCode,$successExitCode)
                        return
                    }
                }
                '>='
                {
                    if( $exitCode -ge $successExitCode )
                    {
                        Write-WhiskeyVerbose -Context $TaskContext -Message ('Exit Code {0} >= {1}' -f $exitCode,$successExitCode)
                        return
                    }
                }
            }
        }

        if( $successExitCode -match '^(\d+)\.\.(\d+)$' )
        {
            if( $exitCode -ge [int]$Matches[1] -and $exitCode -le [int]$Matches[2] )
            {
                Write-WhiskeyVerbose -Context $TaskContext -Message ('Exit Code {0} <= {1} <= {2}' -f $Matches[1],$exitCode,$Matches[2])
                return
            }
        }
    }

    Stop-WhiskeyTask -TaskContext $TaskContext -Message ('"{0}" returned with an exit code of "{1}". View the build output to see why the executable''s process failed.' -F $TaskParameter['Path'],$exitCode)
}


function New-WhiskeyFile
{
    [Whiskey.Task('File')]
    [CmdletBinding()]
    param(
        [Parameter(Mandatory)]
        [Whiskey.Context]$TaskContext,

        [Whiskey.Tasks.ValidatePath(Mandatory,PathType='File',AllowNonexistent)]
        [String[]]$Path,

        [String]$Content,

        [switch]$Touch
    )

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

    foreach( $item in $Path )
    {
        if( Test-Path -Path $item -PathType Container )
        {
            Stop-WhiskeyTask -TaskContext $TaskContext `
                             -Message ('Path "{0}" is a directory but must be a file.' -f $item)
            return
        }

        if( -not (Test-Path -Path $item) )
        {
            New-Item -Path $item -Force -ErrorAction Stop
        }

        if( $Touch )
        {
            (Get-Item $item).LastWriteTime = Get-Date
        }

        if( $Content ) 
        {
            Set-Content -Path $item -Value $Content
        }

    }
}


function Get-WhiskeyPowerShellModule
{
    [CmdletBinding()]
    [Whiskey.Task('GetPowerShellModule',SupportsClean,SupportsInitialize)]
    param(
        [Parameter(Mandatory)]
        [Whiskey.Context]$TaskContext,

        [String]$Name,

        [String]$Version,

        [switch]$AllowPrerelease,

        [Whiskey.Tasks.ValidatePath(AllowNonexistent,Create,PathType='Directory')]
        [String]$Path,

        [switch]$Import
    )

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

    if( -not $Name )
    {
        Stop-WhiskeyTask -TaskContext $TaskContext -Message 'Property "Name" is mandatory. It should be set to the name of the PowerShell module you want installed.'
        return
    }

    if( $TaskContext.ShouldClean )
    {
        Uninstall-WhiskeyPowerShellModule -Name $Name -BuildRoot $TaskContext.BuildRoot -Path $Path
        return
    }

    if( -not $Path )
    {
        $Path = Join-Path -Path $TaskContext.BuildRoot -ChildPath $powershellModulesDirectoryName
        if( -not (Test-Path -Path $Path -PathType Container) )
        {
            New-Item -Path $Path -ItemType 'Directory' | Out-Null
        }
        $Path = $Path | Resolve-Path -Relative
    }

    # PackageManagement/PowerShellGet functions don't like relative paths.
    $fullPath = $Path | Resolve-Path | Select-Object -ExpandProperty 'ProviderPath'

    $module = Resolve-WhiskeyPowerShellModule -Name $Name `
                                              -Version $Version `
                                              -BuildRoot $TaskContext.BuildRoot `
                                              -AllowPrerelease:$AllowPrerelease `
                                              -ErrorAction Stop
    if( -not $module )
    {
        return
    }

    Write-WhiskeyInfo -Context $TaskContext -Message ('Installing PowerShell module {0} {1} to {2}.' -f $Name,$module.Version,$Path)
    $moduleRoot = Install-WhiskeyPowerShellModule -Name $Name `
                                                  -Version $module.Version `
                                                  -BuildRoot $TaskContext.BuildRoot `
                                                  -SkipImport:(-not $Import) `
                                                  -AllowPrerelease:$AllowPrerelease `
                                                  -Path $fullPath `
                                                  -ErrorAction Stop
    Write-WhiskeyVerbose -Context $TaskContext -Message (' {0}' -f $moduleRoot)
}


function New-WhiskeyGitHubRelease
{
    [CmdletBinding()]
    [Whiskey.Task('GitHubRelease')]
    param(
        [Parameter(Mandatory)]
        [Whiskey.Context]$TaskContext,

        [Parameter(Mandatory)]
        [hashtable]$TaskParameter
    )

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

    $apiKeyID = $TaskParameter['ApiKeyID']
    if( -not $apiKeyID )
    {
        Stop-WhiskeyTask -TaskContext $TaskContext -Message ('Property "ApiKeyID" is mandatory. It should be set to the ID of the API key to use when talking to the GitHub API. API keys are added to your build with the "Add-WhiskeyApiKey" function.')
        return
    }

    $apiKey = Get-WhiskeyApiKey -Context $TaskContext -ID $apiKeyID -PropertyName 'ApiKeyID'
    $headers = @{
                    Authorization = 'Basic ' + [Convert]::ToBase64String([Text.Encoding]::UTF8.GetBytes($apiKey + ":x-oauth-basic"))
                }
    $repositoryName = $TaskParameter['RepositoryName']
    if( -not $repositoryName )
    {
        Stop-WhiskeyTask -TaskContext $TaskContext -Message ('Property "RepositoryName" is mandatory. It should be the owner and repository name of the repository you want to access as a URI path, e.g. OWNER/REPO.')
        return
    }

    if( $repositoryName -notmatch '^[^/]+/[^/]+$' )
    {
        Stop-WhiskeyTask -TaskContext $TaskContext -Message ('Property "RepositoryName" is invalid. It should be the owner and repository name of the repository you want to access as a URI path, e.g. OWNER/REPO.')
        return
    }

    $baseUri = [Uri]'https://api.github.com/repos/{0}' -f $repositoryName

    [System.Net.ServicePointManager]::SecurityProtocol = [System.Net.ServicePointManager]::SecurityProtocol -bor [System.Net.SecurityProtocolType]::Tls12

    function Invoke-GitHubApi
    {
        [CmdletBinding(DefaultParameterSetName='NoBody')]
        param(
            [Parameter(Mandatory)]
            [Uri]$Uri,

            [Parameter(Mandatory,ParameterSetName='FileUpload')]
            [String]$ContentType,

            [Parameter(Mandatory,ParameterSetName='FileUpload')]
            [String]$InFile,

            [Parameter(Mandatory,ParameterSetName='JsonRequest')]
            $Parameter,

            [Microsoft.PowerShell.Commands.WebRequestMethod]$Method = 'Post'
        )

        $optionalParams = @{ }
        if( $PSCmdlet.ParameterSetName -eq 'JsonRequest' )
        {
            if( $Parameter )
            {
                $optionalParams['Body'] = $Parameter | ConvertTo-Json
                Write-WhiskeyVerbose -Context $TaskContext -Message $optionalParams['Body']
            }
            $ContentType = 'application/json'
        }
        elseif( $PSCmdlet.ParameterSetName -eq 'FileUpload' )
        {
            $optionalParams['InFile'] = $InFile
        }

        try
        {
            Invoke-RestMethod -Uri $Uri -Method $Method -Headers $headers -ContentType $ContentType @optionalParams
        }
        catch
        {
            if( $ErrorActionPreference -eq 'Ignore' )
            {
                $Global:Error.RemoveAt(0)
            }
            Stop-WhiskeyTask -TaskContext $TaskContext -Message ('GitHub API call to "{0}" failed: {1}' -f $uri,$_)
            return
        }
    }

    $tag = $TaskParameter['Tag']
    if( -not $tag )
    {
        Stop-WhiskeyTask -TaskContext $TaskContext -Message ('Property "Tag" is mandatory. It should be the tag to create in your repository for this release. This is usually a version number. We recommend using the `$(WHISKEY_SEMVER2_NO_BUILD_METADATA)` variable to use the version number of the current build.')
        return
    }
    $release = Invoke-GitHubApi -Uri ('{0}/releases/tags/{1}' -f $baseUri,[Uri]::EscapeUriString($tag)) -Method Get -ErrorAction Ignore

    $createOrEditMethod = [Microsoft.PowerShell.Commands.WebRequestMethod]::Post
    $actionDescription = 'Creating'
    $createOrEditUri = '{0}/releases' -f $baseUri
    if( $release )
    {
        $createOrEditMethod = [Microsoft.PowerShell.Commands.WebRequestMethod]::Patch
        $actionDescription = 'Updating'
        $createOrEditUri = $release.url
    }

    $releaseName = $TaskParameter['Name']
    $releaseNameDesc = ''

    $releaseData = @{
                        tag_name = $tag
                    }

    if( $TaskParameter['Commitish'] )
    {
        $releaseData['target_commitish'] = $TaskParameter['Commitish']
    }

    if( $releaseName )
    {
        $releaseData['name'] = $releaseName
        $releaseNameDesc = '"{0}" ' -f $releaseName
    }

    if( $TaskParameter['Description'] )
    {
        $releaseData['body'] = $TaskParameter['Description']
    }

    Write-WhiskeyInfo -Context $TaskContext -Message ('{0} release {1}with tag "{2}" at commit "{3}".' -f $actionDescription,$releaseNameDesc,$tag,$TaskContext.BuildMetadata.ScmCommitID)
    $release = Invoke-GitHubApi -Uri $createOrEditUri -Parameter $releaseData -Method $createOrEditMethod
    $release

    if( $TaskParameter['Assets'] )
    {
        $existingAssets = Invoke-GitHubApi -Uri $release.assets_url -Method Get

        $assetIdx = 0
        foreach( $asset in $TaskParameter['Assets'] )
        {
            $basePropertyName = 'Assets[{0}]' -f $assetIdx++
            $assetPath = 
                $asset['Path'] | 
                Resolve-WhiskeyTaskPath -TaskContext $TaskContext `
                                        -PropertyName ('{0}.Path:' -f $basePropertyName) `
                                        -PathType File `
                                        -Mandatory `
                                        -OnlySinglePath
            if( -not $assetPath )
            {
                continue
            }

            $assetName = $asset['Name']
            if( -not $assetName ) 
            {
                $assetName = $assetPath | Split-Path -Leaf
            }

            $existingAsset = $existingAssets | Where-Object { $_ -and $_.name -eq $assetName }
            if( $existingAsset )
            {
                Write-WhiskeyInfo -Context $TaskContext -Message ('Updating asset "{0}" from file "{1}.' -f $assetName,$assetPath)
                Invoke-GitHubApi -Method Patch -Uri $existingAsset.url -Parameter @{ name = $assetName; label = $assetName }
            }
            else
            {
                $uri = $release.upload_url -replace '{[^}]+}$'
                $uri = '{0}?name={1}&label={1}' -f $uri,[Uri]::EscapeDataString($assetName)
                $contentType = $asset['ContentType']
                if( -not $contentType )
                {
                    Stop-WhiskeyTask -TaskContext $TaskContext -PropertyName $basePropertyName -Message ('Property "ContentType" is mandatory. It must be the "{0}" file''s media type. For a list of acceptable types, see https://www.iana.org/assignments/media-types/media-types.xhtml.' -f $assetPath)
                    continue
                }
                Write-WhiskeyInfo -Context $TaskContext -Message ('Uploading asset "{0}" from file "{1}".' -f $assetName,$assetPath)
                Invoke-GitHubApi -Method Post -Uri $uri -ContentType $asset['ContentType'] -InFile $assetPath
            }
        }
    }
}


function Install-Node
{
    [Whiskey.Task('InstallNode')]
    [Whiskey.RequiresTool('Node', PathParameterName='NodePath')]
    [CmdletBinding()]
    param(
    )
}


function Import-WhiskeyTask
{
    [Whiskey.Task('LoadTask')]
    [CmdletBinding()]
    param(
        [Parameter(Mandatory)]
        [Whiskey.Context]$TaskContext,

        [Parameter(Mandatory)]
        [hashtable]$TaskParameter,

        [Whiskey.Tasks.ValidatePath(Mandatory,PathType='File')]
        [String[]]$Path
    )

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

    $module = Get-Module -Name 'Whiskey'
    foreach( $pathItem in $Path )
    {
        $fullPathItem = Resolve-Path -Path $pathItem | Select-Object -ExpandProperty 'ProviderPath'
        if( $TaskContext.TaskPaths | Where-Object { $_.FullName -eq $fullPathItem } )
        {
            Write-WhiskeyVerbose -Context $TaskContext -Message ('Already loaded tasks from file "{0}".' -f $pathItem)
            continue
        }

        $knownTasks = @{}
        Get-WhiskeyTask | ForEach-Object { $knownTasks[$_.Name] = $_ }
        # We do this in a background script block to ensure the function is scoped correctly. If it isn't, it
        # won't be available outside the script block. If it is, it will be visible after the script block completes.
        & {
            . $pathItem
        }
        $newTasks = Get-WhiskeyTask | Where-Object { -not $knownTasks.ContainsKey($_.Name) }
        if( -not $newTasks )
        {
            Stop-WhiskeyTask -TaskContext $TaskContext -Message ('File "{0}" contains no Whiskey tasks. Make sure:
 
* the file contains a function
* the function is scoped correctly (e.g. `function script:MyTask`)
* the function has a `[Whiskey.Task(''MyTask'')]` attribute that declares the task''s name
* a task with the same name hasn''t already been loaded
 
See about_Whiskey_Writing_Tasks for more information.'
 -f $pathItem)
            return
        }

        Write-WhiskeyInfo -Context $TaskContext -Message ($pathItem)
        foreach( $task in $newTasks )
        {
            Write-WhiskeyInfo -Context $TaskContext -Message (' {0}' -f $task.Name)
        }
        $TaskContext.TaskPaths.Add((Get-Item -Path $pathItem))
    }
}



function Write-WhiskeyLog
{
    [CmdletBinding()]
    [Whiskey.Task('Log',SupportsClean,SupportsInitialize)]
    param(
        [Parameter(Mandatory)]
        [Whiskey.Context]$Context,

        [String[]]$Message,

        [String]$Level = 'Info'
    )

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

    $infoCmd = Get-Command -Name 'Write-WhiskeyInfo' -ModuleName 'Whiskey'
    if( -not $infoCmd )
    {
        Stop-WhiskeyTask -Context $Context -Message ('Umm, we can''t seem to find Whiskey''s Write-WhiskeyInfo function. Something pretty bad has gone wrong.')
        return
    }

    $levels = 
        $infoCmd.Parameters.GetEnumerator() | 
        Where-Object { $_.Key -eq 'Level' } |
        Select-Object -ExpandProperty 'Value' |
        Select-Object -ExpandProperty 'Attributes' |
        Where-Object { $_ -is [Management.Automation.ValidateSetAttribute] } |
        Select-Object -ExpandProperty 'ValidValues'
    
    if( -not $levels )
    {
        Stop-WhiskeyTask -Context $Context -Message ('We can''t seem to find the ValidateSet attribute on the Write-WhiskeyInfo function''s Level parameter. Somethign pretty bad has gone wrong.')
        return
    }
        
    if( $Level -notin $levels )
    {
        Stop-WhiskeyTask -TaskContext $Context -Message ('Property "Level" has an invalid value, "{0}". Valid values are {1}.' -f $Level,($levels -join ", "))
        return
    }

    Write-WhiskeyInfo -Context $Context -Message $Message -Level $Level
}



function Merge-WhiskeyFile
{
    [CmdletBinding()]
    [Whiskey.Task('MergeFile')]
    param(
        [Parameter(Mandatory)]
        [Whiskey.Context]$TaskContext,

        [Whiskey.Tasks.ValidatePath(Mandatory,PathType='File')]
        [String[]]$Path,

        [Whiskey.Tasks.ValidatePath(Mandatory,PathType='File',AllowNonexistent,Create)]
        [String]$DestinationPath,

        [switch]$DeleteSourceFiles,

        [String]$TextSeparator,

        [Byte[]]$BinarySeparator,

        [switch]$Clear,

        [String[]]$Exclude
    )

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

    if( $Clear )
    {
        Clear-Content -Path $DestinationPath
    }

    if( $TextSeparator -and $BinarySeparator )
    {
        Stop-WhiskeyTask -TaskContext $TaskContext `
                         -Message ('You can''t use both a text separator and binary separator when merging files. Please use only the TextSeparator or BinarySeparator property, not both.')
        return
    }

    [Byte[]]$separatorBytes = $BinarySeparator
    if( $TextSeparator )
    {
        $separatorBytes = [Text.Encoding]::UTF8.GetBytes($TextSeparator)
    }

    $relativePath = Resolve-Path -Path $DestinationPath -Relative
    $writer = [IO.File]::OpenWrite($relativePath)
    try 
    {
        Write-WhiskeyInfo -Context $TaskContext -Message $relativePath 

        # Move to the end of the file.
        $writer.Position = $writer.Length

        # Only add the separator first if we didn't clear the file's original contents.
        $addSeparator = (-not $Clear) -and ($writer.Length -gt 0)

        # Normalize the exclusion pattern so it works across platforms.
        $Exclude = 
            $Exclude | 
            ForEach-Object { $_ -replace '\\|/',[IO.Path]::DirectorySeparatorChar }
        foreach( $filePath in $Path )
        {
            $excluded = $false
            foreach( $pattern in $Exclude )
            {
                if( $filePath -like $pattern )
                {
                    Write-WhiskeyVerbose -Context $TaskContext -Message ('Skipping file "{0}": it matches exclusion pattern "{1}".' -f $filePath,$pattern)
                    $excluded = $true
                    break
                }
                else 
                {
                    Write-WhiskeyDebug -Context $TaskContext -Message ('"{0}" -notlike "{1}"' -f $filePath,$pattern)
                }
            }

            if( $excluded )
            {
                continue
            }

            $relativePath = Resolve-Path -Path $filePath -Relative
            Write-WhiskeyInfo -Context $TaskContext -Message (' + {0}' -f $relativePath)

            if( $addSeparator -and $separatorBytes )
            {
                $writer.Write($separatorBytes,0,$separatorBytes.Length)
            }
            $addSeparator = $true

            $reader = [IO.File]::OpenRead($filePath)
            try
            {
                $bufferSize = 4kb
                [Byte[]]$buffer = New-Object 'byte[]' ($bufferSize)
                while( $bytesRead = $reader.Read($buffer,0,$bufferSize) )
                {
                    $writer.Write($buffer,0,$bytesRead)
                }
            }
            finally
            {
                $reader.Close()
            }

            if( $DeleteSourceFiles )
            {
                Remove-Item -Path $filePath -Force
            }
        }
    }
    finally
    {
        $writer.Close()
    }
}



function Invoke-WhiskeyMSBuild
{
    [Whiskey.Task('MSBuild',SupportsClean,Platform='Windows')]
    [Whiskey.RequiresPowerShellModule('VSSetup',Version='2.*',VersionParameterName='VSSetupVersion')]
    [CmdletBinding()]
    param(
        [Whiskey.Context]$TaskContext,

        [hashtable]$TaskParameter,

        [Whiskey.Tasks.ValidatePath(Mandatory,PathType='File')]
        [String[]]$Path,

        [Whiskey.Tasks.ValidatePath(AllowNonexistent,PathType='Directory',Create)]
        [String]$OutputDirectory
    )

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

    #setup
    $nuGetPath = Install-WhiskeyNuGet -DownloadRoot $TaskContext.BuildRoot -Version $TaskParameter['NuGetVersion']

    $msbuildInfos = Get-MSBuild | Sort-Object -Descending 'Version'
    $version = $TaskParameter['Version']
    if( $version )
    {
        $msbuildInfo = $msbuildInfos | Where-Object { $_.Name -eq $version } | Select-Object -First 1
    }
    else
    {
        $msbuildInfo = $msbuildInfos | Select-Object -First 1
    }

    if( -not $msbuildInfo )
    {
        $msbuildVersionNumbers = $msbuildInfos | Select-Object -ExpandProperty 'Name'
        Stop-WhiskeyTask -TaskContext $TaskContext -Message ('MSBuild {0} is not installed. Installed versions are: {1}' -f $version,($msbuildVersionNumbers -join ', '))
        return
    }

    $msbuildExePath = $msbuildInfo.Path
    if( $TaskParameter.ContainsKey('Use32Bit') -and ($TaskParameter['Use32Bit'] | ConvertFrom-WhiskeyYamlScalar) )
    {
        $msbuildExePath = $msbuildInfo.Path32
        if( -not $msbuildExePath )
        {
            Stop-WhiskeyTask -TaskContext $TaskContext -Message ('A 32-bit version of MSBuild {0} does not exist.' -f $version)
            return
        }
    }
    Write-WhiskeyVerbose -Context $TaskContext -Message ('{0}' -f $msbuildExePath)

    $target = @( 'build' )
    if( $TaskContext.ShouldClean )
    {
        $target = 'clean'
    }
    else
    {
        if( $TaskParameter.ContainsKey('Target') )
        {
            $target = $TaskParameter['Target']
        }
    }

    foreach( $projectPath in $Path )
    {
        Write-WhiskeyVerbose -Context $TaskContext -Message (' {0}' -f $projectPath)
        $errors = $null
        if( $projectPath -like '*.sln' )
        {
            if( $TaskContext.ShouldClean )
            {
                $packageDirectoryPath = Join-Path -Path ( Split-Path -Path $projectPath -Parent ) -ChildPath 'packages'
                if( Test-Path -Path $packageDirectoryPath -PathType Container )
                {
                    Write-WhiskeyVerbose -Context $TaskContext -Message (' Removing NuGet packages at {0}.' -f $packageDirectoryPath)
                    Remove-Item $packageDirectoryPath -Recurse -Force
                }
            }
            else
            {
                Write-WhiskeyVerbose -Context $TaskContext -Message (' Restoring NuGet packages.')
                & $nugetPath restore $projectPath
            }
        }

        if( $TaskContext.ByBuildServer )
        {
            $projectPath |
                Split-Path |
                Get-ChildItem -Filter 'AssemblyInfo.cs' -Recurse |
                ForEach-Object {
                    $assemblyInfo = $_
                    $assemblyInfoPath = $assemblyInfo.FullName
                    $newContent = Get-Content -Path $assemblyInfoPath | Where-Object { $_ -notmatch '\bAssembly(File|Informational)?Version\b' }
                    $newContent | Set-Content -Path $assemblyInfoPath
                    Write-WhiskeyVerbose -Context $TaskContext -Message (' Updating version in {0}.' -f $assemblyInfoPath)
    @"
[assembly: System.Reflection.AssemblyVersion("{0}")]
[assembly: System.Reflection.AssemblyFileVersion("{0}")]
[assembly: System.Reflection.AssemblyInformationalVersion("{1}")]
"@
 -f $TaskContext.Version.Version,$TaskContext.Version.SemVer2 | Add-Content -Path $assemblyInfoPath
                }
        }

        $verbosity = 'm'
        if( $TaskParameter['Verbosity'] )
        {
            $verbosity = $TaskParameter['Verbosity']
        }

        $configuration = Get-WhiskeyMSBuildConfiguration -Context $TaskContext

        $property = Invoke-Command {
            Write-Output ('Configuration={0}' -f $configuration)

            if( $TaskParameter.ContainsKey('Property') )
            {
                Write-Output ($TaskParameter['Property'])
            }

            if( $OutputDirectory )
            {
                # Get an absolute path. MSBuild interprets relative paths as being relative to .csproj being compiled.
                $OutputDirectory = Resolve-Path -Path $OutputDirectory | Select-Object -ExpandProperty 'ProviderPath'
                Write-Output ('OutDir={0}' -f $OutputDirectory)
            }
        }

        $cpuArg = '/maxcpucount'
        $cpuCount = $TaskParameter['CpuCount'] | ConvertFrom-WhiskeyYamlScalar
        if( $cpuCount )
        {
            $cpuArg = '/maxcpucount:{0}' -f $TaskParameter['CpuCount']
        }

        if( ($TaskParameter['NoMaxCpuCountArgument'] | ConvertFrom-WhiskeyYamlScalar) )
        {
            $cpuArg = ''
        }

        $noFileLogger = $TaskParameter['NoFileLogger'] | ConvertFrom-WhiskeyYamlScalar

        $projectFileName = $projectPath | Split-Path -Leaf
        $logFilePath = Join-Path -Path $TaskContext.OutputDirectory -ChildPath ('msbuild.{0}.log' -f $projectFileName)
        $msbuildArgs = Invoke-Command {
                                            ('/verbosity:{0}' -f $verbosity)
                                            $cpuArg
                                            $TaskParameter['Argument']
                                            if( -not $noFileLogger )
                                            {
                                                '/filelogger9'
                                                ('/flp9:LogFile={0};Verbosity=d' -f $logFilePath)
                                            }
                                      } | Where-Object { $_ }
        $separator = '{0}VERBOSE: ' -f [Environment]::NewLine
        Write-WhiskeyVerbose -Context $TaskContext -Message (' Target {0}' -f ($target -join $separator))
        Write-WhiskeyVerbose -Context $TaskContext -Message (' Property {0}' -f ($property -join $separator))
        Write-WhiskeyVerbose -Context $TaskContext -Message (' Argument {0}' -f ($msbuildArgs -join $separator))

        $propertyArgs = $property | ForEach-Object {
            $item = $_
            $name,$value = $item -split '=',2
            $value = $value.Trim('"')
            $value = $value.Trim("'")
            if( $value.EndsWith( '\' ) )
            {
                $value = '{0}\' -f $value
            }
            '/p:{0}="{1}"' -f $name,($value -replace ' ','%20')
        }

        $targetArg = '/t:{0}' -f ($target -join ';')

        & $msbuildExePath $projectPath $targetArg $propertyArgs $msbuildArgs /nologo
        if( $LASTEXITCODE -ne 0 )
        {
            Stop-WhiskeyTask -TaskContext $TaskContext -Message ('MSBuild exited with code {0}.' -f $LASTEXITCODE)
            return
        }
    }
}



function Invoke-WhiskeyNodeTask
{
    [Whiskey.Task('Node',SupportsClean,SupportsInitialize,Obsolete,ObsoleteMessage='The "Node" task is obsolete and will be removed in a future version of Whiskey. It''s functionality has been broken up into the "Npm" and "NodeLicenseChecker" tasks.')]
    [Whiskey.RequiresTool('Node',PathParameterName='NodePath')]
    [Whiskey.RequiresTool('NodeModule::license-checker',PathParameterName='LicenseCheckerPath',VersionParameterName='LicenseCheckerVersion')]
    [Whiskey.RequiresTool('NodeModule::nsp',PathParameterName='NspPath',VersionParameterName='PINNED_TO_NSP_2_7_0',Version='2.7.0')]
    [CmdletBinding()]
    param(
        [Parameter(Mandatory)]
        # The context the task is running under.
        [Whiskey.Context]$TaskContext,

        [Parameter(Mandatory)]
        # The task parameters, which are:
        #
        # * `NpmScript`: a list of one or more NPM scripts to run, e.g. `npm run $SCRIPT_NAME`. Each script is run indepently.
        # * `WorkingDirectory`: the directory where all the build commands should be run. Defaults to the directory where the build's `whiskey.yml` file was found. Must be relative to the `whiskey.yml` file.
        [hashtable]$TaskParameter
    )

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

    if( $TaskContext.ShouldClean )
    {
        Write-WhiskeyDebug -Context $TaskContext -Message 'Cleaning'
        $nodeModulesPath = Join-Path -Path $TaskContext.BuildRoot -ChildPath 'node_modules'
        Remove-WhiskeyFileSystemItem -Path $nodeModulesPath
        Write-WhiskeyDebug -Context $TaskContext -Message 'COMPLETE'
        return
    }

    $npmScripts = $TaskParameter['NpmScript']
    $npmScriptCount = $npmScripts | Measure-Object | Select-Object -ExpandProperty 'Count'
    $numSteps = 4 + $npmScriptCount
    $stepNum = 0

    $activity = 'Running Node Task'

    function Update-Progress
    {
        param(
            [Parameter(Mandatory)]
            [String]$Status,

            [int]$Step
        )

        Write-Progress -Activity $activity -Status $Status.TrimEnd('.') -PercentComplete ($Step/$numSteps*100)
    }

    $workingDirectory = (Get-Location).ProviderPath
    $originalPath = $env:PATH

    try
    {
    $nodePath = Resolve-WhiskeyNodePath -BuildRoot $TaskContext.BuildRoot
    
        Set-Item -Path 'env:PATH' -Value ('{0}{1}{2}' -f ($nodePath | Split-Path),[IO.Path]::PathSeparator,$env:PATH)

        Update-Progress -Status ('Installing NPM packages') -Step ($stepNum++)
        Write-WhiskeyDebug -Context $TaskContext -Message ('npm install')
        Invoke-WhiskeyNpmCommand -Name 'install' -ArgumentList '--production=false' -BuildRootPath $TaskContext.BuildRoot -ForDeveloper:$TaskContext.ByDeveloper -ErrorAction Stop
        Write-WhiskeyDebug -Context $TaskContext -Message ('COMPLETE')

        if( $TaskContext.ShouldInitialize )
        {
            Write-WhiskeyDebug -Context $TaskContext -Message 'Initialization complete.'
            return
        }

        if( -not $npmScripts )
        {
            Write-WhiskeyWarning -Context $TaskContext -Message (@'
Property 'NpmScript' is missing or empty. Your build isn''t *doing* anything. The 'NpmScript' property should be a list of one or more npm scripts to run during your build, e.g.
 
Build:
- Node:
  NpmScript:
  - build
  - test
'@
)
        }

        foreach( $script in $npmScripts )
        {
            Update-Progress -Status ('npm run {0}' -f $script) -Step ($stepNum++)
            Write-WhiskeyDebug -Context $TaskContext -Message ('Running script ''{0}''.' -f $script)
            Invoke-WhiskeyNpmCommand -Name 'run-script' -ArgumentList $script -BuildRootPath $TaskContext.BuildRoot -ForDeveloper:$TaskContext.ByDeveloper -ErrorAction Stop
            Write-WhiskeyDebug -Context $TaskContext -Message ('COMPLETE')
        }

        $nodePath = Resolve-WhiskeyNodePath -BuildRootPath $TaskContext.BuildRoot

        Update-Progress -Status ('nsp check') -Step ($stepNum++)
        Write-WhiskeyDebug -Context $TaskContext -Message ('Running NSP security check.')
        $nspPath = Assert-WhiskeyNodeModulePath -Path $TaskParameter['NspPath'] -CommandPath 'bin\nsp' -ErrorAction Stop
        $output = & $nodePath $nspPath 'check' '--output' 'json' 2>&1 |
                        ForEach-Object { if( $_ -is [Management.Automation.ErrorRecord]) { $_.Exception.Message } else { $_ } }
        Write-WhiskeyDebug -Context $TaskContext -Message ('COMPLETE')
        $results = ($output -join [Environment]::NewLine) | ConvertFrom-Json
        if( $LASTEXITCODE )
        {
            $summary = $results | Format-List | Out-String
            Stop-WhiskeyTask -TaskContext $TaskContext -Message ('NSP, the Node Security Platform, found the following security vulnerabilities in your dependencies (exit code: {0}):{1}{2}' -f $LASTEXITCODE,[Environment]::NewLine,$summary)
            return
        }

        Update-Progress -Status ('license-checker') -Step ($stepNum++)
        Write-WhiskeyDebug -Context $TaskContext -Message ('Generating license report.')

        $licenseCheckerPath = Assert-WhiskeyNodeModulePath -Path $TaskParameter['LicenseCheckerPath'] -CommandPath 'bin\license-checker' -ErrorAction Stop

        $reportJson = & $nodePath $licenseCheckerPath '--json'
        Write-WhiskeyDebug -Context $TaskContext -Message ('COMPLETE')
        $report = ($reportJson -join [Environment]::NewLine) | ConvertFrom-Json
        if( -not $report )
        {
            Stop-WhiskeyTask -TaskContext $TaskContext -Message ('License Checker failed to output a valid JSON report.')
            return
        }

        Write-WhiskeyDebug -Context $TaskContext -Message ('Converting license report.')
        # The default license checker report has a crazy format. It is an object with properties for each module.
        # Let's transform it to a more sane format: an array of objects.
        [Object[]]$newReport = 
            $report |
            Get-Member -MemberType NoteProperty |
            Select-Object -ExpandProperty 'Name' |
            ForEach-Object { $report.$_ | Add-Member -MemberType NoteProperty -Name 'name' -Value $_ -PassThru }

        # show the report
        $newReport | Sort-Object -Property 'licenses','name' | Format-Table -Property 'licenses','name' -AutoSize | Out-String | Write-WhiskeyVerbose -Context $TaskContext

        $licensePath = 'node-license-checker-report.json'
        $licensePath = Join-Path -Path $TaskContext.OutputDirectory -ChildPath $licensePath
        ConvertTo-Json -InputObject $newReport -Depth 100 | Set-Content -Path $licensePath
        Write-WhiskeyDebug -Context $TaskContext -Message ('COMPLETE')
    }
    finally
    {
        Set-Item -Path 'env:PATH' -Value $originalPath
        Write-Progress -Activity $activity -Completed -PercentComplete 100
    }
}



function Invoke-WhiskeyNodeLicenseChecker
{
    [CmdletBinding()]
    [Whiskey.Task('NodeLicenseChecker')]
    [Whiskey.RequiresTool('Node',PathParameterName='NodePath',VersionParameterName='NodeVersion')]
    [Whiskey.RequiresTool('NodeModule::license-checker',PathParameterName='LicenseCheckerPath',VersionParameterName='Version')]
    param(
        [Parameter(Mandatory)]
        [Whiskey.Context]$TaskContext,

        [Parameter(Mandatory)]
        [hashtable]$TaskParameter
    )

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

    $licenseCheckerPath = Assert-WhiskeyNodeModulePath -Path $TaskParameter['LicenseCheckerPath'] -CommandPath 'bin\license-checker' -ErrorAction Stop

    $nodePath = Assert-WhiskeyNodePath -Path $TaskParameter['NodePath'] -ErrorAction Stop

    Write-WhiskeyDebug -Context $TaskContext -Message ('Generating license report')
    $reportJson = Invoke-Command -NoNewScope -ScriptBlock {
        & $nodePath $licenseCheckerPath '--json'
    }
    Write-WhiskeyDebug -Context $TaskContext -Message ('COMPLETE')

    $report = Invoke-Command -NoNewScope -ScriptBlock {
        ($reportJson -join [Environment]::NewLine) | ConvertFrom-Json
    }
    if (-not $report)
    {
        Stop-WhiskeyTask -TaskContext $TaskContext -Message 'License Checker failed to output a valid JSON report.'
        return
    }

    Write-WhiskeyDebug -Context $TaskContext -Message 'Converting license report.'
    # The default license checker report has a crazy format. It is an object with properties for each module.
    # Let's transform it to a more sane format: an array of objects.
    [Object[]]$newReport = 
        $report |
        Get-Member -MemberType NoteProperty |
        Select-Object -ExpandProperty 'Name' |
        ForEach-Object { $report.$_ | Add-Member -MemberType NoteProperty -Name 'name' -Value $_ -PassThru }

    # show the report
    $newReport | Sort-Object -Property 'licenses','name' | Format-Table -Property 'licenses','name' -AutoSize | Out-String | Write-WhiskeyVerbose -Context $TaskContext

    $licensePath = 'node-license-checker-report.json'
    $licensePath = Join-Path -Path $TaskContext.OutputDirectory -ChildPath $licensePath
    ConvertTo-Json -InputObject $newReport -Depth 100 | Set-Content -Path $licensePath
    Write-WhiskeyDebug -Context $TaskContext -Message ('COMPLETE')
}



function Invoke-WhiskeyNodeNspCheck
{
    [Whiskey.Task('NodeNspCheck',Obsolete,ObsoleteMessage='The "NodeNspCheck" task is obsolete and will be removed in a future version of Whiskey. Please use the "Npm" task instead. The NSP project shut down in September 2018 and was replaced with the `npm audit` command.')]
    [Whiskey.RequiresTool('Node',PathParameterName='NodePath',VersionParameterName='NodeVersion')]
    [Whiskey.RequiresTool('NodeModule::nsp',PathParameterName='NspPath',VersionParameterName='Version')]
    [CmdletBinding()]
    param(
        [Parameter(Mandatory)]
        [Whiskey.Context]$TaskContext,

        [Parameter(Mandatory)]
        [hashtable]$TaskParameter
    )

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

    $nspPath = Assert-WhiskeyNodeModulePath -Path $TaskParameter['NspPath'] -CommandPath 'bin\nsp' -ErrorAction Stop

    $nodePath = Assert-WhiskeyNodePath -Path $TaskParameter['NodePath'] -ErrorAction Stop

    $formattingArg = '--reporter'
    $isPreNsp3 = $TaskParameter.ContainsKey('Version') -and $TaskParameter['Version'] -match '^(0|1|2)\.'
    if( $isPreNsp3 )
    {
        $formattingArg = '--output'
    }

    Write-WhiskeyDebug -Context $TaskContext -Message 'Running NSP security check'
    $output = Invoke-Command -NoNewScope -ScriptBlock {
        param(
            $JsonOutputFormat
        )

        & $nodePath $nspPath 'check' $JsonOutputFormat 'json' 2>&1 |
            ForEach-Object { if( $_ -is [Management.Automation.ErrorRecord]) { $_.Exception.Message } else { $_ } }
    } -ArgumentList $formattingArg

    Write-WhiskeyDebug -Context $TaskContext -Message 'COMPLETE'

    try
    {
        $results = ($output -join [Environment]::NewLine) | ConvertFrom-Json
    }
    catch
    {
        Stop-WhiskeyTask -TaskContext $TaskContext -Message ('NSP, the Node Security Platform, did not run successfully as it did not return valid JSON (exit code: {0}):{1}{2}' -f $LASTEXITCODE,[Environment]::NewLine,$output)
        return
    }

    if ($Global:LASTEXITCODE -ne 0)
    {
        $summary = $results | Format-List | Out-String
        Stop-WhiskeyTask -TaskContext $TaskContext -Message ('NSP, the Node Security Platform, found the following security vulnerabilities in your dependencies (exit code: {0}):{1}{2}' -f $LASTEXITCODE,[Environment]::NewLine,$summary)
        return
    }
}



function Invoke-WhiskeyNpm
{
    [Whiskey.Task('Npm')]
    [Whiskey.RequiresTool('Node',PathParameterName='NodePath',VersionParameterName='NodeVersion')]
    [Whiskey.RequiresTool('NodeModule::npm',PathParameterName='NpmPath',VersionParameterName='NpmVersion')]
    [CmdletBinding()]
    param(
        [Parameter(Mandatory)]
        [Whiskey.Context]$TaskContext,

        [Parameter(Mandatory)]
        [hashtable]$TaskParameter
    )

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

    $commandName = $TaskParameter['Command']
    if( -not $commandName )
    {
        Stop-WhiskeyTask -TaskContext $TaskContext -Message ('Property "Command" is required. It should be the name of the NPM command to run. See https://docs.npmjs.com/cli#cli for a list.')
        return
    }

    Invoke-WhiskeyNpmCommand -Name $commandName -BuildRootPath $TaskContext.BuildRoot -ArgumentList $TaskParameter['Argument'] -ErrorAction Stop

}



function Invoke-WhiskeyNpmConfig
{
    [Whiskey.Task('NpmConfig',Obsolete,ObsoleteMessage='The "NpmConfig" task is obsolete. It will be removed in a future version of Whiskey. Please use the "Npm" task instead.')]
    [Whiskey.RequiresTool('Node',PathParameterName='NodePath',VersionParameterName='NodeVersion')]
    param(
        [Parameter(Mandatory)]
        [Whiskey.Context]$TaskContext,

        [Parameter(Mandatory)]
        [hashtable]$TaskParameter
    )

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

    $configuration = $TaskParameter['Configuration']
    if( -not $configuration )
    {
        Write-WhiskeyWarning -Context $TaskContext -Message ('Your NpmConfig task isn''t doing anything. Its Configuration property is missing. Please update the NpmConfig task in your whiskey.yml file so that it is actually setting configuration, e.g.
 
    Build:
    - NpmConfig:
        Configuration:
            key1: value1
            key2: value2
            '
)
        return
    }

    if( -not ($configuration | Get-Member -Name 'Keys') )
    {
        Stop-WhiskeyTask -TaskContext $TaskContext -Message ('Configuration property is invalid. It must have only key/value pairs, e.g.
 
    Build:
    - NpmConfig:
        Configuration:
            key1: value1
            key2: value2
     '
)
        return
    }

    $scope = $TaskParameter['Scope']
    if( $scope )
    {
        if( @('Project', 'User', 'Global') -notcontains $scope )
        {
            Stop-WhiskeyTask -TaskContext $TaskContext -Message ('Scope property ''{0}'' is invalid. Allowed values are `Project`, `User`, `Global` to set configuration at the project, user, or global level. You may also remove the `Scope` property to set configuration at the project level (i.e. in the current directory).' -f $scope)
            return
        }
    }

    foreach( $key in $TaskParameter['Configuration'].Keys )
    {
        $argumentList = & {
                                'set'
                                $key
                                $configuration[$key]
                                if( $scope -eq 'User' )
                                {
                                }
                                elseif( $scope -eq 'Global' )
                                {
                                    '-g'
                                }
                                else
                                {
                                    '-userconfig'
                                    '.npmrc'
                                }
                        }

        Invoke-WhiskeyNpmCommand -Name 'config' -ArgumentList $argumentList -BuildRootPath $TaskContext.BuildRoot -ForDeveloper:$TaskContext.ByDeveloper
    }

}


function Invoke-WhiskeyNpmInstall
{
    [Whiskey.Task('NpmInstall',SupportsClean,Obsolete,ObsoleteMessage='The "NpmInstall" task is obsolete. It will be removed in a future version of Whiskey. Please use the "Npm" task instead.')]
    [Whiskey.RequiresTool('Node',PathParameterName='NodePath',VersionParameterName='NodeVersion')]
    [CmdletBinding()]
    param(
        [Parameter(Mandatory)]
        [Whiskey.Context]$TaskContext,

        [Parameter(Mandatory)]
        [hashtable]$TaskParameter
    )

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

    $workingDirectory = (Get-Location).ProviderPath

    if( -not $TaskParameter['Package'] )
    {
        if( $TaskContext.ShouldClean )
        {
            Write-WhiskeyDebug -Context $TaskContext -Message 'Removing project node_modules'
            Remove-WhiskeyFileSystemItem -Path 'node_modules' -ErrorAction Stop
        }
        else
        {
            Write-WhiskeyDebug -Context $TaskContext -Message 'Installing Node modules'
            Invoke-WhiskeyNpmCommand -Name 'install' -ArgumentList '--production=false' -BuildRootPath $TaskContext.BuildRoot -ForDeveloper:$TaskContext.ByDeveloper -ErrorAction Stop
        }
        Write-WhiskeyDebug -Context $TaskContext -Message 'COMPLETE'
    }
    else
    {
        $installGlobally = $false
        if( $TaskParameter.ContainsKey('Global') )
        {
            $installGlobally = $TaskParameter['Global'] | ConvertFrom-WhiskeyYamlScalar
        }

        foreach( $package in $TaskParameter['Package'] )
        {
            $packageVersion = ''
            if ($package | Get-Member -Name 'Keys')
            {
                $packageName = $package.Keys | Select-Object -First 1
                $packageVersion = $package[$packageName]
            }
            else
            {
                $packageName = $package
            }

            if( $TaskContext.ShouldClean )
            {
                if( $TaskParameter.ContainsKey('NodePath') -and (Test-Path -Path $TaskParameter['NodePath'] -PathType Leaf) )
                {
                    Write-WhiskeyDebug -Context $TaskContext -Message ('Uninstalling {0}' -f $packageName)
                    Uninstall-WhiskeyNodeModule -BuildRootPath $TaskContext.BuildRoot `
                                                -Name $packageName `
                                                -ForDeveloper:$TaskContext.ByDeveloper `
                                                -Global:$installGlobally `
                                                -ErrorAction Stop
                }
            }
            else
            {
                Write-WhiskeyDebug -Context $TaskContext -Message ('Installing {0}' -f $packageName)
                Install-WhiskeyNodeModule -BuildRootPath $TaskContext.BuildRoot `
                                          -Name $packageName `
                                          -Version $packageVersion `
                                          -ForDeveloper:$TaskContext.ByDeveloper `
                                          -Global:$installGlobally `
                                          -ErrorAction Stop
            }
            Write-WhiskeyDebug -Context $TaskContext -Message 'COMPLETE'
        }
    }
}



function Invoke-WhiskeyNpmPrune
{
    [Whiskey.Task('NpmPrune',Obsolete,ObsoleteMessage='The "NpmPrune" task is obsolete. It will be removed in a future version of Whiskey. Please use the "Npm" task instead.')]
    [Whiskey.RequiresTool('Node',PathParameterName='NodePath',VersionParameterName='NodeVersion')]
    [CmdletBinding()]
    param(
        [Parameter(Mandatory)]
        # The context the task is running under.
        [Whiskey.Context]$TaskContext,

        [Parameter(Mandatory)]
        # The parameters/configuration to use to run the task.
        [hashtable]$TaskParameter
    )

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

    Invoke-WhiskeyNpmCommand -Name 'prune' -ArgumentList '--production' -BuildRootPath $TaskContext.BuildRoot -ForDeveloper:$TaskContext.ByDeveloper -ErrorAction Stop
}



function Invoke-WhiskeyNpmRunScript
{
    [Whiskey.Task('NpmRunScript',Obsolete,ObsoleteMessage='The "NpmRunScriptTask" is obsolete. It will be removed in a future version of Whiskey. Please use the "Npm" task instead.')]
    [Whiskey.RequiresTool('Node',PathParameterName='NodePath',VersionParameterName='NodeVersion')]
    [CmdletBinding()]
    param(
        [Parameter(Mandatory)]
        [Whiskey.Context]$TaskContext,

        [Parameter(Mandatory)]
        [hashtable]$TaskParameter
    )

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

    $npmScripts = $TaskParameter['Script']
    if (-not $npmScripts)
    {
        Stop-WhiskeyTask -TaskContext $TaskContext -Message 'Property ''Script'' is mandatory. It should be a list of one or more npm scripts to run during your build, e.g.,
 
        Build:
        - NpmRunScript:
            Script:
            - build
            - test
 
        '

        return
    }

    foreach ($script in $npmScripts)
    {
        Write-WhiskeyDebug -Context $TaskContext -Message ('Running script ''{0}''.' -f $script)
        Invoke-WhiskeyNpmCommand -Name 'run-script' -ArgumentList $script -BuildRootPath $TaskContext.BuildRoot -ForDeveloper:$TaskContext.ByDeveloper -ErrorAction Stop
        Write-WhiskeyDebug -Context $TaskContext -Message ('COMPLETE')
    }
}



function New-WhiskeyNuGetPackage
{
    [Whiskey.Task('NuGetPack',Platform='Windows')]
    [CmdletBinding()]
    param(
        [Parameter(Mandatory)]
        [Whiskey.Context]$TaskContext,

        [Parameter(Mandatory)]
        [hashtable]$TaskParameter,

        [Whiskey.Tasks.ValidatePath(Mandatory,PathType='File')]
        [String[]]$Path
    )

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

    $symbols = $TaskParameter['Symbols'] | ConvertFrom-WhiskeyYamlScalar
    $symbolsArg = ''
    $symbolsFileNameSuffix = ''
    if( $symbols )
    {
        $symbolsArg = '-Symbols'
        $symbolsFileNameSuffix = '.symbols'
    }

    $nuGetPath = Install-WhiskeyNuGet -DownloadRoot $TaskContext.BuildRoot -Version $TaskParameter['Version']
    if( -not $nugetPath )
    {
        return
    }

    $properties = $TaskParameter['Properties']
    $propertiesArgs = @()
    if( $properties )
    {
        if( -not (Get-Member -InputObject $properties -Name 'Keys') )
        {
            Stop-WhiskeyTask -TaskContext $TaskContext -PropertyName 'Properties' -Message ('Property is invalid. This property must be a name/value mapping of properties to pass to nuget.exe pack command''s "-Properties" parameter.')
            return
        }

        $propertiesArgs = $properties.Keys |
                                ForEach-Object {
                                    '-Properties'
                                    '{0}={1}' -f $_,$properties[$_]
                                }
    }

    foreach( $pathItem in $Path )
    {
        $projectName = $TaskParameter['PackageID']
        if( -not $projectName )
        {
            $projectName = [IO.Path]::GetFileNameWithoutExtension(($pathItem | Split-Path -Leaf))
        }
        $packageVersion = $TaskParameter['PackageVersion']
        if( -not $packageVersion )
        {
            $packageVersion = $TaskContext.Version.SemVer1
        }

        # Create NuGet package
        $configuration = Get-WhiskeyMSBuildConfiguration -Context $TaskContext

        & $nugetPath pack -Version $packageVersion -OutputDirectory $TaskContext.OutputDirectory $symbolsArg -Properties ('Configuration={0}' -f $configuration) $propertiesArgs $pathItem

        # Make sure package was created.
        $filename = '{0}.{1}{2}.nupkg' -f $projectName,$packageVersion,$symbolsFileNameSuffix

        $packagePath = Join-Path -Path $TaskContext.OutputDirectory -childPath $filename
        if( -not (Test-Path -Path $packagePath -PathType Leaf) )
        {
            Stop-WhiskeyTask -TaskContext $TaskContext -Message ('We ran nuget pack against "{0}" but the expected NuGet package "{1}" does not exist.' -f $pathItem,$packagePath)
            return
        }
    }
}



function Publish-WhiskeyNuGetPackage
{
    [Whiskey.Task('NuGetPush',Platform='Windows',Aliases=('PublishNuGetLibrary','PublishNuGetPackage'),WarnWhenUsingAlias=$true)]
    [CmdletBinding()]
    param(
        [Parameter(Mandatory)]
        [Whiskey.Context]$TaskContext,

        [Parameter(Mandatory)]
        [hashtable]$TaskParameter,

        [Whiskey.Tasks.ValidatePath(PathType='File')]
        [String[]]$Path
    )

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

    if( -not $Path )
    {
        $Path = 
            Join-Path -Path $TaskContext.OutputDirectory.FullName -ChildPath '*.nupkg' |
            Resolve-WhiskeyTaskPath -TaskContext $TaskContext -PathType 'File' -PropertyName 'Path'
    }

    $publishSymbols = $TaskParameter['Symbols'] | ConvertFrom-WhiskeyYamlScalar

    $paths = $Path |
                Where-Object {
                    $wildcard = '*.symbols.nupkg'
                    if( $publishSymbols )
                    {
                        $_ -like $wildcard
                    }
                    else
                    {
                        $_ -notlike $wildcard
                    }
                }

    $source = $TaskParameter['Uri']
    if( -not $source )
    {
        Stop-WhiskeyTask -TaskContext $TaskContext -Message ('Property ''Uri'' is mandatory. It should be the URI where NuGet packages should be published, e.g.
 
    Build:
    - PublishNuGetPackage:
        Uri: https://nuget.org
    '
)
        return
    }

    $apiKeyID = $TaskParameter['ApiKeyID']
    if( -not $apiKeyID )
    {
        Stop-WhiskeyTask -TaskContext $TaskContext -Message ('Property ''ApiKeyID'' is mandatory. It should be the ID/name of the API key to use when publishing NuGet packages to {0}, e.g.:
 
    Build:
    - PublishNuGetPackage:
        Uri: {0}
        ApiKeyID: API_KEY_ID
 
Use the `Add-WhiskeyApiKey` function to add the API key to the build.
 
            '
 -f $source)
        return
    }
    $apiKey = Get-WhiskeyApiKey -Context $TaskContext -ID $apiKeyID -PropertyName 'ApiKeyID'

    $nuGetPath = Install-WhiskeyNuGet -DownloadRoot $TaskContext.BuildRoot -Version $TaskParameter['Version']
    if( -not $nugetPath )
    {
        return
    }

    foreach ($packagePath in $paths)
    {
        $packageFilename = [IO.Path]::GetFileNameWithoutExtension(($packagePath | Split-Path -Leaf))
        $packageName = $packageFilename -replace '\.\d+\.\d+\.\d+(-.*)?(\.symbols)?',''

        $packageFilename -match '(\d+\.\d+\.\d+(?:-[0-9a-z]+)?)'
        $packageVersion = $Matches[1]

        $packageUri = '{0}/package/{1}/{2}' -f $source,$packageName,$packageVersion

        # Make sure this version doesn't exist.
        $packageExists = $false
        $numErrorsAtStart = $Global:Error.Count
        try
        {
            Invoke-WebRequest -Uri $packageUri -UseBasicParsing | Out-Null
            $packageExists = $true
        }
        catch
        {
            # Invoke-WebRequest throws differnt types of errors in Windows PowerShell and PowerShell Core. Handle the case where a non-HTTP exception occurs.
            if( -not ($_.Exception | Get-Member 'Response') )
            {
                Write-Error -ErrorRecord $_
                Stop-WhiskeyTask -TaskContext $TaskContext -Message ('Unknown failure checking if {0} {1} package already exists at {2}. {3}' -f  $packageName,$packageVersion,$packageUri,$_)
                return
            }

            $response = $_.Exception.Response
            if( $response.StatusCode -ne [Net.HttpStatusCode]::NotFound )
            {
                $error = ''
                if( $response | Get-Member 'GetResponseStream' )
                {
                    $content = $response.GetResponseStream()
                    $content.Position = 0
                    $reader = New-Object 'IO.StreamReader' $content
                    $error = $reader.ReadToEnd() -replace '<[^>]+?>',''
                    $reader.Close()
                    $response.Close()
                }
                Stop-WhiskeyTask -TaskContext $TaskContext -Message ('Failure checking if {0} {1} package already exists at {2}. The web request returned a {3} ({4}) status code:{5} {5}{6}' -f $packageName,$packageVersion,$packageUri,$response.StatusCode,[int]$response.StatusCode,[Environment]::NewLine,$error)
                return
            }

            for( $idx = 0; $idx -lt ($Global:Error.Count - $numErrorsAtStart); ++$idx )
            {
                $Global:Error.RemoveAt(0)
            }
        }

        if( $packageExists )
        {
            Stop-WhiskeyTask -TaskContext $TaskContext -Message ('{0} {1} already exists. Please increment your library''s version number in ''{2}''.' -f $packageName,$packageVersion,$TaskContext.ConfigurationPath)
            return
        }

        # Publish package and symbols to NuGet
        Invoke-WhiskeyNuGetPush -Path $packagePath -Uri $source -ApiKey $apiKey -NuGetPath $nuGetPath

        if( -not ($TaskParameter['SkipUploadedCheck'] | ConvertFrom-WhiskeyYamlScalar) )
        {
            try
            {
                Invoke-WebRequest -Uri $packageUri -UseBasicParsing | Out-Null
            }
            catch
            {
                # Invoke-WebRequest throws differnt types of errors in Windows PowerShell and PowerShell Core. Handle the case where a non-HTTP exception occurs.
                if( -not ($_.Exception | Get-Member 'Response') )
                {
                    Write-Error -ErrorRecord $_
                    Stop-WhiskeyTask -TaskContext $TaskContext -Message ('Unknown failure checking if {0} {1} package was published to {2}. {3}' -f  $packageName,$packageVersion,$packageUri,$_)
                    return
                }

                Stop-WhiskeyTask -TaskContext $TaskContext -Message ('Failed to publish NuGet package {0} {1} to {2}. When we checked if that package existed, we got a {3} HTTP status code. Please see build output for more information.' -f $packageName,$packageVersion,$packageUri,$_.Exception.Response.StatusCode)
                return
            }
        }
    }
}



function Restore-WhiskeyNuGetPackage
{
    [CmdletBinding()]
    [Whiskey.TaskAttribute("NuGetRestore",Platform='Windows')]
    param(
        [Parameter(Mandatory)]
        [Whiskey.Tasks.ValidatePath(Mandatory)]
        [String[]]$Path,

        [String[]]$Argument,

        [String]$Version,

        [Whiskey.Tasks.ParameterValueFromVariable('WHISKEY_BUILD_ROOT')]
        [IO.DirectoryInfo]$BuildRoot
    )

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

    $nuGetPath = Install-WhiskeyNuGet -DownloadRoot $BuildRoot -Version $Version

    foreach( $item in $Path )
    {
        & $nuGetPath 'restore' $item $Argument
    }
}


function Invoke-WhiskeyNUnit2Task
{
    [Whiskey.Task('NUnit2',SupportsClean,SupportsInitialize,Platform='Windows')]
    [CmdletBinding()]
    param(
        [Parameter(Mandatory)]
        [Whiskey.Context]$TaskContext,

        [Parameter(Mandatory)]
        [hashtable]$TaskParameter,

        # TODO: Once this task uses NuGet tool provider, make this Mandatory and remove the test that Path has a value.
        [Whiskey.Tasks.ValidatePath(AllowNonexistent,PathType='File')]
        [String[]]$Path
    )

    Set-StrictMode -version 'latest'
    Use-CallerPreference -Cmdlet $PSCmdlet -SessionState $ExecutionContext.SessionState

    $package = 'NUnit.Runners'
    $version = '2.6.4'
    if( $TaskParameter['Version'] )
    {
        $version = $TaskParameter['Version']
        if( $version -notlike '2.*' )
        {
            Stop-WhiskeyTask -TaskContext $TaskContext -PropertyName 'Version' -Message ('The version ''{0}'' isn''t a valid 2.x version of NUnit.' -f $TaskParameter['Version'])
            return
        }
    }

    $openCoverVersionArg  = @{}
    $reportGeneratorVersionArg = @{}
    if( $TaskParameter['OpenCoverVersion'] )
    {
        $openCoverVersionArg['Version'] = $TaskParameter['OpenCoverVersion']
    }
    if( $TaskParameter['ReportGeneratorVersion'] )
    {
        $reportGeneratorVersionArg['Version'] = $TaskParameter['ReportGeneratorVersion']
    }

    $openCoverArgs = @()
    if( $TaskParameter['OpenCoverArgument'] )
    {
        $openCoverArgs += $TaskParameter['OpenCoverArgument']
    }

    $reportGeneratorArgs = @()
    if( $TaskParameter['ReportGeneratorArgument'] )
    {
        $reportGeneratorArgs += $TaskParameter['ReportGeneratorArgument']
    }

    if( $TaskContext.ShouldClean )
    {
        Write-WhiskeyDebug -Message ('Uninstalling ReportGenerator.')
        Uninstall-WhiskeyTool -NuGetPackageName 'ReportGenerator' -BuildRoot $TaskContext.BuildRoot @reportGeneratorVersionArg
        Write-WhiskeyDebug -Message ('COMPLETE')
        Write-WhiskeyDebug -Message ('Uninstalling OpenCover.')
        Uninstall-WhiskeyTool -NuGetPackageName 'OpenCover' -BuildRoot $TaskContext.BuildRoot @openCoverVersionArg
        Write-WhiskeyDebug -Message ('COMPLETE')
        Write-WhiskeyDebug -Message ('Uninstalling NUnit.')
        Uninstall-WhiskeyTool -NuGetPackageName $package -BuildRoot $TaskContext.BuildRoot -Version $version
        Write-WhiskeyDebug -Message ('COMPLETE')
        return
    }

    $includeParam = $null
    if( $TaskParameter.ContainsKey('Include') )
    {
        $includeParam = '/include={0}' -f $TaskParameter['Include']
    }

    $excludeParam = $null
    if( $TaskParameter.ContainsKey('Exclude') )
    {
        $excludeParam = '/exclude={0}' -f $TaskParameter['Exclude']
    }

    $frameworkParam = '4.0'
    if( $TaskParameter.ContainsKey('Framework') )
    {
        $frameworkParam = $TaskParameter['Framework']
    }
    $frameworkParam = '/framework={0}' -f $frameworkParam

    Write-WhiskeyDebug -Message ('Installing NUnit.')
    $nunitRoot = Install-WhiskeyTool -NuGetPackageName $package -Version $version -DownloadRoot $TaskContext.BuildRoot
    Write-WhiskeyDebug -Message ('COMPLETE')
    if( -not (Test-Path -Path $nunitRoot -PathType Container) )
    {
        Stop-WhiskeyTask -TaskContext $TaskContext -Message ('Package {0} {1} failed to install!' -f $package,$version)
        return
    }
    $nunitRoot = Get-Item -Path $nunitRoot | Select-Object -First 1
    $nunitRoot = Join-Path -Path $nunitRoot -ChildPath 'tools'
    $nunitConsolePath = Join-Path -Path $nunitRoot -ChildPath 'nunit-console.exe' -Resolve
    if( -not ($nunitConsolePath))
    {
        Stop-WhiskeyTask -TaskContext $TaskContext -Message ('{0} {1} was installed, but couldn''t find nunit-console.exe at ''{2}''.' -f $package,$version,$nunitConsolePath)
        return
    }

    Write-WhiskeyDebug -Message ('Installing OpenCover.')
    $openCoverRoot = Install-WhiskeyTool -NuGetPackageName 'OpenCover' -DownloadRoot $TaskContext.BuildRoot @openCoverVersionArg
    Write-WhiskeyDebug -Message ('COMPLETE')
    if( -not (Test-Path -Path $openCoverRoot -PathType Container))
    {
        Stop-WhiskeyTask -TaskContext $TaskContext -Message ('Failed to install NuGet package OpenCover {0}.' -f $version)
        return
    }
    $openCoverPath = Get-ChildItem -Path $openCoverRoot -Filter 'OpenCover.Console.exe' -Recurse |
                        Select-Object -First 1 |
                        Select-Object -ExpandProperty 'FullName'
    if( -not (Test-Path -Path $openCoverPath -PathType Leaf) )
    {
        Stop-WhiskeyTask -TaskContext $TaskContext -Message ('Unable to find OpenCover.Console.exe in OpenCover NuGet package at ''{0}''.' -f $openCoverRoot)
        return
    }

    Write-WhiskeyDebug -Message ('Installing ReportGenerator.')
    $reportGeneratorRoot = Install-WhiskeyTool -NuGetPackageName 'ReportGenerator' -DownloadRoot $TaskContext.BuildRoot @reportGeneratorVersionArg
    Write-WhiskeyDebug -Message ('COMPLETE')
    if( -not (Test-Path -Path $reportGeneratorRoot -PathType Container))
    {
        Stop-WhiskeyTask -TaskContext $TaskContext -Message ('Failed to install NuGet package ReportGenerator.' -f $version)
        return
    }
    $reportGeneratorPath = Get-ChildItem -Path $reportGeneratorRoot -Filter 'ReportGenerator.exe' -Recurse |
                                Select-Object -First 1 |
                                Select-Object -ExpandProperty 'FullName'
    if( -not (Test-Path -Path $reportGeneratorPath -PathType Leaf) )
    {
        Stop-WhiskeyTask -TaskContext $TaskContext -Message ('Unable to find ReportGenerator.exe in ReportGenerator NuGet package at ''{0}''.' -f $reportGeneratorRoot)
        return
    }

    if( $TaskContext.ShouldInitialize )
    {
        return
    }

    if( -not $Path )
    {
        Stop-WhiskeyTask -TaskContext $TaskContext -Message ('Property "Path" is mandatory. It should be one or more paths, which should be a list of assemblies whose tests to run, e.g.
 
        Build:
        - NUnit2:
            Path:
            - Assembly.dll
            - OtherAssembly.dll'
)
        return
    }

    $missingPaths = $Path | Where-Object { -not (Test-Path -Path $_ -PathType Leaf) }
    if( $missingPaths )
    {
        $missingPaths = $missingPaths -join ('{0}*' -f [Environment]::NewLine)
        Stop-WhiskeyTask -TaskContext $TaskContext -Message ('The following paths do not exist.{0} {0}*{1}{0} ' -f [Environment]::NewLine,$missingPaths)
        return
    }

    $reportPath = Join-Path -Path $TaskContext.OutputDirectory -ChildPath ('nunit2+{0}.xml' -f [IO.Path]::GetRandomFileName())

    $coverageReportDir = Join-Path -Path $TaskContext.outputDirectory -ChildPath "opencover"
    New-Item -Path $coverageReportDir -ItemType 'Directory' -Force | Out-Null
    $openCoverReport = Join-Path -Path $coverageReportDir -ChildPath 'openCover.xml'

    $extraArgs = $TaskParameter['Argument'] | Where-Object { $_ }
    
    Write-WhiskeyVerbose -Context $TaskContext -Message (' Path {0}' -f ($Path | Select-Object -First 1))
    $Path | Select-Object -Skip 1 | ForEach-Object { Write-WhiskeyVerbose -Context $TaskContext -Message (' {0}' -f $_) }
    Write-WhiskeyVerbose -Context $TaskContext -Message (' Framework {0}' -f $frameworkParam)
    Write-WhiskeyVerbose -Context $TaskContext -Message (' Include {0}' -f $includeParam)
    Write-WhiskeyVerbose -Context $TaskContext -Message (' Exclude {0}' -f $excludeParam)
    Write-WhiskeyVerbose -Context $TaskContext -Message (' Argument /xml={0}' -f $reportPath)
    $extraArgs | ForEach-Object { Write-WhiskeyVerbose -Context $TaskContext -Message (' {0}' -f $_) }
    Write-WhiskeyVerbose -Context $TaskContext -Message (' CoverageFilter {0}' -f ($TaskParameter['CoverageFilter'] | Select-Object -First 1))
    $TaskParameter['CoverageFilter'] | Select-Object -Skip 1 | ForEach-Object { Write-WhiskeyVerbose -Context $TaskContext -Message (' {0}' -f $_) }
    Write-WhiskeyVerbose -Context $TaskContext -Message (' Output {0}' -f $openCoverReport)
    $disableCodeCoverage = $TaskParameter['DisableCodeCoverage'] | ConvertFrom-WhiskeyYamlScalar
    Write-WhiskeyVerbose -Context $TaskContext -Message (' DisableCodeCoverage {0}' -f $disableCodeCoverage)
    Write-WhiskeyVerbose -Context $TaskContext -Message (' OpenCoverArgs {0}' -f ($openCoverArgs | Select-Object -First 1))
    $openCoverArgs | Select-Object -Skip 1 | ForEach-Object { Write-WhiskeyVerbose -Context $TaskContext -Message (' {0}' -f $_) }
    Write-WhiskeyVerbose -Context $TaskContext -Message (' ReportGeneratorArgs {0}' -f ($reportGeneratorArgs | Select-Object -First 1))
    $reportGeneratorArgs | Select-Object -Skip 1 | ForEach-Object { Write-WhiskeyVerbose -Context $TaskContext -Message (' {0}' -f $_) }

    if( -not $disableCodeCoverage )
    {
        $coverageFilterString = ($TaskParameter['CoverageFilter'] -join " ")
        $extraArgString = ($extraArgs -join " ")
        $pathsArg = ($Path -join '" "')
        $nunitArgs = '"{0}" /noshadow {1} /xml="{2}" {3} {4} {5}' -f $pathsArg,$frameworkParam,$reportPath,$includeParam,$excludeParam,$extraArgString
        $nunitArgs = $nunitArgs -replace '"', '\"'
        Write-WhiskeyDebug -Message ('Running OpenCover')
        & $openCoverPath "-target:${nunitConsolePath}" "-targetargs:${nunitArgs}" "-filter:${coverageFilterString}" '-register:user' "-output:${openCoverReport}" '-returntargetcode' $openCoverArgs
        Write-WhiskeyDebug -Message ('COMPLETE')
        $testsFailed = $LastExitCode;
        Write-WhiskeyDebug -Message ('Running ReportGenerator')
        & $reportGeneratorPath "-reports:${openCoverReport}" "-targetdir:$coverageReportDir" $reportGeneratorArgs
        Write-WhiskeyDebug -Message ('COMPLETE')
        if( $LastExitCode -or $testsFailed )
        {
            Stop-WhiskeyTask -TaskContext $TaskContext -Message ('NUnit2 tests failed. {0} returned exit code {1}.' -f $openCoverPath,$LastExitCode)
            return
        }
    }
    else
    {
        Write-WhiskeyDebug -Message ('Running NUnit')
        & $nunitConsolePath $Path $frameworkParam $includeParam $excludeParam $extraArgs ('/xml={0}' -f $reportPath)
        Write-WhiskeyDebug -Message ('COMPLETE')
        if( $LastExitCode )
        {
            Stop-WhiskeyTask -TaskContext $TaskContext -Message ('NUnit2 tests failed. {0} returned exit code {1}.' -f $nunitConsolePath,$LastExitCode)
            return
        }
    }
}



function Invoke-WhiskeyNUnit3Task
{
    [CmdletBinding()]
    [Whiskey.Task('NUnit3',SupportsClean,SupportsInitialize,Platform='Windows')]
    param(
        [Parameter(Mandatory)]
        [Whiskey.Context]$TaskContext,

        [Parameter(Mandatory)]
        [hashtable]$TaskParameter,

        # TODO: Once this task uses NuGet tool provider, make this Mandatory and remove the test that Path has a value.
        [Whiskey.Tasks.ValidatePath(AllowNonexistent,PathType='File')]
        [String[]]$Path
    )

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

    # NUnit.Console pulls in ConsoleRunner (of the same version) as a dependency and several NUnit2 compatibility/extension packages.
    # The ConsoleRunner packages is installed explicitly to resolve the tool/bin path from installed package location.
    $nunitSupportPackage = 'NUnit.Console'
    $nunitPackage = 'NUnit.ConsoleRunner'

    # Due to a bug in NuGet we can't search for and install packages with wildcards (e.g. 3.*), so we're hardcoding a version for now. See Resolve-WhiskeyNuGetPackageVersion for more details.
    # (This is the vesrion of NUnit.Console/NUnit.ConsoleRunner which may differ from the core NUnit library version.)
    $nunitVersion = '3.10.0'
    if( $TaskParameter['Version'] )
    {
        $nunitVersion = $TaskParameter['Version']
        if( $nunitVersion -notlike '3.*' )
        {
            Stop-WhiskeyTask -TaskContext $TaskContext -PropertyName 'Version' -Message ('The version ''{0}'' isn''t a valid 3.x version of NUnit.' -f $TaskParameter['Version'])
            return
        }
    }

    $reportFormat = 'nunit3';
    if ($TaskParameter['ResultFormat'])
    {
        $reportFormat = $TaskParameter['ResultFormat']
    }

    # NUnit3 currently allows 'nunit2' and 'nunit3' which aligns with output filename usage
    $nunitReport = Join-Path -Path $TaskContext.OutputDirectory -ChildPath ('{0}+{1}.xml' -f  $reportFormat, [IO.Path]::GetRandomFileName())
    $nunitReportParam = '--result={0};format={1}' -f $nunitReport, $reportFormat

    $openCoverVersionParam = @{}
    if ($TaskParameter['OpenCoverVersion'])
    {
        $openCoverVersionParam['Version'] = $TaskParameter['OpenCoverVersion']
    }

    $reportGeneratorVersionParam = @{}
    if ($TaskParameter['ReportGeneratorVersion'])
    {
        $reportGeneratorVersionParam['Version'] = $TaskParameter['ReportGeneratorVersion']
    }

    if( $TaskContext.ShouldClean )
    {
        Uninstall-WhiskeyTool -NuGetPackageName $nunitSupportPackage -BuildRoot $TaskContext.BuildRoot -Version $nunitVersion
        Uninstall-WhiskeyTool -NuGetPackageName $nunitPackage -BuildRoot $TaskContext.BuildRoot -Version $nunitVersion
        Uninstall-WhiskeyTool -NuGetPackageName 'OpenCover' -BuildRoot $TaskContext.BuildRoot @openCoverVersionParam
        Uninstall-WhiskeyTool -NuGetPackageName 'ReportGenerator' -BuildRoot $TaskContext.BuildRoot @reportGeneratorVersionParam
        return
    }

    $nunitSupportPath = Install-WhiskeyTool -NuGetPackageName $nunitSupportPackage -Version $nunitVersion -DownloadRoot $TaskContext.BuildRoot
    if (-not $nunitSupportPath)
    {
        Stop-WhiskeyTask -TaskContext $TaskContext -Message ('Package "{0}" failed to install.' -f $nunitSupportPackage)
        return
    }

    $nunitPath = Install-WhiskeyTool -NuGetPackageName $nunitPackage -Version $nunitVersion -DownloadRoot $TaskContext.BuildRoot
    if (-not $nunitPath)
    {
        Stop-WhiskeyTask -TaskContext $TaskContext -Message ('Package "{0}" failed to install.' -f $nunitPackage)
        return
    }

    $openCoverPath = Install-WhiskeyTool -NuGetPackageName 'OpenCover' -DownloadRoot $TaskContext.BuildRoot @openCoverVersionParam
    if (-not $openCoverPath)
    {
        Stop-WhiskeyTask -TaskContext $TaskContext -Message 'Package "OpenCover" failed to install.'
        return
    }

    $reportGeneratorPath = Install-WhiskeyTool -NuGetPackageName 'ReportGenerator' -DownloadRoot $TaskContext.BuildRoot @reportGeneratorVersionParam
    if (-not $reportGeneratorPath)
    {
        Stop-WhiskeyTask -TaskContext $TaskContext -Message 'Package "ReportGenerator" failed to install.'
        return
    }

    if( $TaskContext.ShouldInitialize )
    {
        return
    }

    $openCoverArgument = @()
    if ($TaskParameter['OpenCoverArgument'])
    {
        $openCoverArgument = $TaskParameter['OpenCoverArgument']
    }

    $reportGeneratorArgument = @()
    if ($TaskParameter['ReportGeneratorArgument'])
    {
        $reportGeneratorArgument = $TaskParameter['ReportGeneratorArgument']
    }

    $framework = '4.0'
    if ($TaskParameter['Framework'])
    {
        $framework = $TaskParameter['Framework']
    }
    $frameworkParam = '--framework={0}' -f $framework

    $testFilter = ''
    $testFilterParam = ''
    if ($TaskParameter['TestFilter'])
    {
        $testFilter = $TaskParameter['TestFilter'] | ForEach-Object { '({0})' -f $_ }
        $testFilter = $testFilter -join ' or '
        $testFilterParam = '--where={0}' -f $testFilter
    }

    $nunitExtraArgument = ''
    if ($TaskParameter['Argument'])
    {
        $nunitExtraArgument = $TaskParameter['Argument']
    }

    $disableCodeCoverage = $TaskParameter['DisableCodeCoverage'] | ConvertFrom-WhiskeyYamlScalar

    $coverageFilter = ''
    if ($TaskParameter['CoverageFilter'])
    {
        $coverageFilter = $TaskParameter['CoverageFilter'] -join ' '
    }

    $nunitConsolePath = Get-ChildItem -Path $nunitPath -Filter 'nunit3-console.exe' -Recurse |
                            Select-Object -First 1 |
                            Select-Object -ExpandProperty 'FullName'

    if( -not $nunitConsolePath )
    {
        Stop-WhiskeyTask -TaskContext $TaskContext -Message ('Unable to find "nunit3-console.exe" in NUnit3 NuGet package at "{0}".' -f $nunitPath)
        return
    }


    $openCoverConsolePath = Get-ChildItem -Path $openCoverPath -Filter 'OpenCover.Console.exe' -Recurse |
                                Select-Object -First 1 |
                                Select-Object -ExpandProperty 'FullName'

    if( -not $openCoverConsolePath )
    {
        Stop-WhiskeyTask -TaskContext $TaskContext -Message ('Unable to find "OpenCover.Console.exe" in OpenCover NuGet package at "{0}".' -f $openCoverPath)
        return
    }


    $reportGeneratorConsolePath = Get-ChildItem -Path $reportGeneratorPath -Filter 'ReportGenerator.exe' -Recurse |
                                      Select-Object -First 1 |
                                      Select-Object -ExpandProperty 'FullName'

    if( -not $reportGeneratorConsolePath )
    {
        Stop-WhiskeyTask -TaskContext $TaskContext -Message ('Unable to find "ReportGenerator.exe" in ReportGenerator NuGet package at "{0}".' -f $reportGeneratorPath)
        return
    }

    if( -not $Path )
    {
        Stop-WhiskeyTask -TaskContext $TaskContext -Message ('Property "Path" is mandatory. It should be one or more paths to the assemblies whose tests should be run, e.g.
 
            Build:
            - NUnit3:
                Path:
                - Assembly.dll
                - OtherAssembly.dll
 
        '
)
        return
    }

    foreach( $pathItem in $Path )
    {
        if (-not (Test-Path -Path $pathItem -PathType Leaf))
        {
            Stop-WhiskeyTask -TaskContext $TaskContext -Message ('"Path" item "{0}" does not exist.' -f $pathItem)
            return
        }
    }


    $coverageReportDir = Join-Path -Path $TaskContext.outputDirectory -ChildPath "opencover"
    New-Item -Path $coverageReportDir -ItemType 'Directory' -Force | Out-Null
    $openCoverReport = Join-Path -Path $coverageReportDir -ChildPath 'openCover.xml'

    $separator = '{0}VERBOSE: ' -f [Environment]::NewLine
    Write-WhiskeyVerbose -Context $TaskContext -Message (' Path {0}' -f ($Path -join $separator))
    Write-WhiskeyVerbose -Context $TaskContext -Message (' Framework {0}' -f $framework)
    Write-WhiskeyVerbose -Context $TaskContext -Message (' TestFilter {0}' -f $testFilter)
    Write-WhiskeyVerbose -Context $TaskContext -Message (' Argument {0}' -f ($nunitExtraArgument -join $separator))
    Write-WhiskeyVerbose -Context $TaskContext -Message (' NUnit Report {0}' -f $nunitReport)
    Write-WhiskeyVerbose -Context $TaskContext -Message (' CoverageFilter {0}' -f $coverageFilter)
    Write-WhiskeyVerbose -Context $TaskContext -Message (' OpenCover Report {0}' -f $openCoverReport)
    Write-WhiskeyVerbose -Context $TaskContext -Message (' DisableCodeCoverage {0}' -f $disableCodeCoverage)
    Write-WhiskeyVerbose -Context $TaskContext -Message (' OpenCoverArgs {0}' -f ($openCoverArgument -join ' '))
    Write-WhiskeyVerbose -Context $TaskContext -Message (' ReportGeneratorArgs {0}' -f ($reportGeneratorArgument -join ' '))

    $nunitExitCode = 0
    $reportGeneratorExitCode = 0
    $openCoverExitCode = 0
    $openCoverExitCodeOffset = 1000

    if (-not $disableCodeCoverage)
    {
        $pathArg = $Path | ForEach-Object { '\"{0}\"' -f $_ }
        $pathArg = $pathArg -join ' '

        $nunitReportParam = '\"{0}\"' -f $nunitReportParam

        if ($frameworkParam)
        {
            $frameworkParam = '\"{0}\"' -f $frameworkParam
        }

        if ($testFilterParam)
        {
            $testFilterParam = '\"{0}\"' -f $testFilterParam
        }

        if ($nunitExtraArgument)
        {
            $nunitExtraArgument = $nunitExtraArgument | ForEach-Object { '\"{0}\"' -f $_ }
            $nunitExtraArgument = $nunitExtraArgument -join ' '
        }

        $openCoverNunitArguments = '{0} {1} {2} {3} {4}' -f $pathArg,$frameworkParam,$testFilterParam,$nunitReportParam,$nunitExtraArgument
        & $openCoverConsolePath "-target:$nunitConsolePath" "-targetargs:$openCoverNunitArguments" "-filter:$coverageFilter" "-output:$openCoverReport" -register:user -returntargetcode:$openCoverExitCodeOffset $openCoverArgument

        if ($LASTEXITCODE -ge 745)
        {
            $openCoverExitCode = $LASTEXITCODE - $openCoverExitCodeOffset
        }
        else
        {
            $nunitExitCode = $LASTEXITCODE
        }

        & $reportGeneratorConsolePath "-reports:$openCoverReport" "-targetdir:$coverageReportDir" $reportGeneratorArgument
        $reportGeneratorExitCode = $LASTEXITCODE
    }
    else
    {
        & $nunitConsolePath $Path $frameworkParam $testFilterParam $nunitReportParam $nunitExtraArgument
        $nunitExitCode = $LASTEXITCODE

    }

    if ($reportGeneratorExitCode -ne 0)
    {
        Stop-WhiskeyTask -TaskContext $TaskContext -Message ('ReportGenerator didn''t run successfully. ''{0}'' returned exit code ''{1}''.' -f $reportGeneratorConsolePath,$reportGeneratorExitCode)
        return
    }
    elseif ($openCoverExitCode -ne 0)
    {
        Stop-WhiskeyTask -TaskContext $TaskContext -Message ('OpenCover didn''t run successfully. ''{0}'' returned exit code ''{1}''.' -f $openCoverConsolePath, $openCoverExitCode)
        return
    }
    elseif ($nunitExitCode -ne 0)
    {
        if (-not (Test-Path -Path $nunitReport -PathType Leaf))
        {
            Stop-WhiskeyTask -TaskContext $TaskContext -Message ('NUnit3 didn''t run successfully. ''{0}'' returned exit code ''{1}''.' -f $nunitConsolePath,$nunitExitCode)
            return
        }
        else
        {
            Stop-WhiskeyTask -TaskContext $TaskContext -Message ('NUnit3 tests failed. ''{0}'' returned exit code ''{1}''.' -f $nunitConsolePath,$nunitExitCode)
            return
        }
    }
}



function Invoke-WhiskeyParallelTask
{
    [CmdletBinding()]
    [Whiskey.Task('Parallel')]
    param(
        [Parameter(Mandatory)]
        [Whiskey.Context]$TaskContext,

        [Parameter(Mandatory)]
        [hashtable]$TaskParameter
    )

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

    $queues = $TaskParameter['Queues']
    if( -not $queues )
    {
        Stop-WhiskeyTask -TaskContext $TaskContext -Message 'Property "Queues" is mandatory. It should be an array of queues to run. Each queue should contain a "Tasks" property that is an array of task to run, e.g.
 
    Build:
    - Parallel:
        Queues:
        - Tasks:
            - TaskOne
            - TaskTwo
        - Tasks:
            - TaskOne
 
'

        return
    }

    try
    {

        $jobs = New-Object 'Collections.ArrayList'
        $queueIdx = -1

        foreach( $queue in $queues )
        {
            $queueIdx++
            $whiskeyModulePath = Join-Path -Path $whiskeyScriptRoot -ChildPath 'Whiskey.psd1' -Resolve

            if( -not $queue.ContainsKey('Tasks') )
            {
                Stop-WhiskeyTask -TaskContext $TaskContext -Message ('Queue[{0}]: Property "Tasks" is mandatory. Each queue should have a "Tasks" property that is an array of Whiskey task to run, e.g.
 
    Build:
    - Parallel:
        Queues:
        - Tasks:
            - TaskOne
            - TaskTwo
        - Tasks:
            - TaskOne
 
    '
 -f $queueIdx);
                return
            }

            Write-WhiskeyVerbose -Context $TaskContext -Message ('[{0}] Starting background queue.' -f $queueIdx)

            $serializableContext = $TaskContext | ConvertFrom-WhiskeyContext

            $tasks = 
                $queue['Tasks'] |
                ForEach-Object { 
                    $taskName,$taskParameter = ConvertTo-WhiskeyTask -InputObject $_ -ErrorAction Stop
                    [pscustomobject]@{
                        Name = $taskName;
                        Parameter = $taskParameter
                    }
                }
            $job = Start-Job -Name $queueIdx -ScriptBlock {

                    Set-StrictMode -Version 'Latest'

                    $VerbosePreference = $using:VerbosePreference
                    $DebugPreference = $using:DebugPreference
                    $ProgressPreference = $using:ProgressPreference
                    $WarningPreference = $using:WarningPreference
                    $ErrorActionPreference = $using:ErrorActionPreference

                    $whiskeyModulePath = $using:whiskeyModulePath
                    $serializedContext = $using:serializableContext

                    & {
                        Import-Module -Name $whiskeyModulePath
                    } 4> $null

                    [Whiskey.Context]$context = $serializedContext | ConvertTo-WhiskeyContext

                    # Load third-party tasks.
                    foreach( $info in $context.TaskPaths )
                    {
                        Write-WhiskeyVerbose -Context $context -Message ('Loading tasks from "{0}".' -f $info.FullName)
                        . $info.FullName
                    }

                    foreach( $task in $using:tasks )
                    {
                        Write-WhiskeyDebug -Context $context -Message ($task.Name)
                        $task.Parameter | ConvertTo-Json -Depth 50 | Write-WhiskeyDebug -Context $context
                        Invoke-WhiskeyTask -TaskContext $context -Name $task.Name -Parameter $task.Parameter
                    }
                }

                $job |
                    Add-Member -MemberType NoteProperty -Name 'QueueIndex' -Value $queueIdx -PassThru |
                    Add-Member -MemberType NoteProperty -Name 'Completed' -Value $false
                [Void]$jobs.Add($job)
        }

        $lastNotice = (Get-Date).AddSeconds(-61)
        while( $jobs | Where-Object { -not $_.Completed } )
        {
            foreach( $job in $jobs )
            {
                if( $job.Completed )
                {
                    continue
                }

                if( $lastNotice -lt (Get-Date).AddSeconds(-60) )
                {
                    Write-WhiskeyVerbose -Context $TaskContext -Message ('[{0}][{1}] Waiting for queue.' -f $job.QueueIndex,$job.Name)
                    $notified = $true
                }

                $completedJob = $job | Wait-Job -Timeout 1
                if( $completedJob )
                {
                    $job.Completed = $true
                    # There's a bug where Write-Host output gets duplicated by Receive-Job if $InformationPreference is set to "Continue".
                    # Since some things use Write-Host, this is a workaround to avoid seeing duplicate host output.
                    $completedJob | Receive-Job -InformationAction SilentlyContinue
                    $duration = $job.PSEndTime - $job.PSBeginTime
                    Write-WhiskeyVerbose -Context $TaskContext -Message ('[{0}][{1}] {2} in {3}' -f $job.QueueIndex,$job.Name,$job.State.ToString().ToUpperInvariant(),$duration)
                    if( $job.JobStateInfo.State -eq [Management.Automation.JobState]::Failed )
                    {
                        Stop-WhiskeyTask -TaskContext $TaskContext -Message ('Queue[{0}] failed. See previous output for error information.' -f $job.Name)
                        return
                    }
                }
            }

            if( $notified )
            {
                $notified = $false
                $lastNotice = Get-Date
            }
        }
    }
    finally
    {
        $jobs | Stop-Job
        $jobs | Remove-Job
    }
}



function Invoke-WhiskeyPester3Task
{
    [Whiskey.Task('Pester3',Platform='Windows')]
    [Whiskey.RequiresPowerShellModule('Pester',ModuleInfoParameterName='PesterModuleInfo',Version='3.*',VersionParameterName='Version',SkipImport)]
    [CmdletBinding()]
    param(
        [Parameter(Mandatory)]
        [Whiskey.Context]$TaskContext,

        [Parameter(Mandatory)]
        [hashtable]$TaskParameter,

        [Whiskey.Tasks.ValidatePath(Mandatory)]
        [String[]]$Path
    )

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

    $outputFile = Join-Path -Path $TaskContext.OutputDirectory -ChildPath ('pester+{0}.xml' -f [IO.Path]::GetRandomFileName())
    $outputFile = [IO.Path]::GetFullPath($outputFile)

    $moduleInfo = $TaskParameter['PesterModuleInfo']
    $pesterManifestPath = $moduleInfo.Path

    $workingDirectory = (Get-Location).Path

    # We do this in the background so we can test this with Pester.
    $job = Start-Job -ScriptBlock {
        $VerbosePreference = $using:VerbosePreference
        $DebugPreference = $using:DebugPreference
        $ProgressPreference = $using:ProgressPreference
        $WarningPreference = $using:WarningPreference
        $ErrorActionPreference = $using:ErrorActionPreference

        Set-Location -Path $using:workingDirectory

        $script = $using:Path
        $pesterManifestPath = $using:pesterManifestPath
        $outputFile = $using:outputFile

        Invoke-Command -ScriptBlock {
                                        $VerbosePreference = 'SilentlyContinue'
                                        Import-Module -Name $pesterManifestPath
                                    }

        Invoke-Pester -Script $script -OutputFile $outputFile -OutputFormat NUnitXml -PassThru
    }


    # There's a bug where Write-Host output gets duplicated by Receive-Job if $InformationPreference is set to "Continue".
    # Since Pester uses Write-Host, this is a workaround to avoid seeing duplicate Pester output.
    do
    {
        $job | Receive-Job -InformationAction SilentlyContinue
    }
    while( -not ($job | Wait-Job -Timeout 1) )

    $job | Receive-Job -InformationAction SilentlyContinue

    Publish-WhiskeyPesterTestResult -Path $outputFile

    $result = [xml](Get-Content -Path $outputFile -Raw)

    if( -not $result )
    {
        throw ('Unable to parse Pester output XML report ''{0}''.' -f $outputFile)
    }

    if( $result.'test-results'.errors -ne '0' -or $result.'test-results'.failures -ne '0' )
    {
        throw ('Pester tests failed.')
    }
}




function Invoke-WhiskeyPester4Task
{
    [Whiskey.Task('Pester4')]
    [Whiskey.RequiresPowerShellModule('Pester',ModuleInfoParameterName='PesterModuleInfo',Version='4.*',VersionParameterName='Version',SkipImport)]
    [CmdletBinding()]
    param(
        [Parameter(Mandatory)]
        [Whiskey.Context]$TaskContext,

        [Alias('Path')]
        [object]$Script,

        [String[]]$Exclude,

        [int]$DescribeDurationReportCount = 0,

        [int]$ItDurationReportCount = 0,

        [Management.Automation.PSModuleInfo]$PesterModuleInfo,

        [Object]$Argument = @{},

        [switch]$NoJob
    )

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

    if( $Exclude )
    {
        $Exclude = $Exclude | Convert-WhiskeyPathDirectorySeparator 
    }

    if( -not $Script )
    {
        Stop-WhiskeyTask -TaskContext $TaskContext -PropertyName 'Script' -Message ('Script is mandatory.')
        return
    }

    $Script = & {
        foreach( $scriptItem in $Script )
        {
            $path = $null

            if( $scriptItem -is [String] )
            {
                $path = $scriptItem
            }
            elseif( $scriptItem | Get-Member -Name 'Keys' )
            {
                $path = $scriptItem['Path']
                $numPaths = ($path | Measure-Object).Count
                if( $numPaths -gt 1 )
                {
                    Stop-WhiskeyTask -TaskContext $TaskContext -PropertyName 'Script' -Message ('when passing a hashtable to Pester''s "Script" parameter, the "Path" value must be a single string. We got {0} strings: {1}' -f $numPaths,($path -join ', '))
                    continue
                }
            }

            $path = Resolve-WhiskeyTaskPath -TaskContext $TaskContext -PropertyName 'Script' -Path $path -Mandatory

            foreach( $pathItem in $path )
            {
                if( $Exclude )
                {
                    $excluded = $false
                    foreach( $exclusion in $Exclude )
                    {
                        if( $pathItem -like $exclusion )
                        {
                            Write-WhiskeyVerbose -Context $TaskContext -Message ('EXCLUDE {0} -like {1}' -f $pathItem,$exclusion)
                            $excluded = $true
                        }
                        else
                        {
                            Write-WhiskeyVerbose -Context $TaskContext -Message (' {0} -notlike {1}' -f $pathItem,$exclusion)
                        }
                    }

                    if( $excluded )
                    {
                        continue
                    }
                }

                if( $scriptItem -is [String] )
                {
                    Write-Output $pathItem
                    continue
                }

                if( $scriptItem | Get-Member -Name 'Keys' )
                {
                    $newScriptItem = $scriptItem.Clone()
                    $newScriptItem['Path'] = $pathItem
                    Write-Output $newScriptItem
                }
            }
        }
    }

    if( -not $Script )
    {
        Stop-WhiskeyTask -TaskContext $TaskContext -Message ('Found no tests to run.')
        return
    }

    $pesterManifestPath = $PesterModuleInfo.Path

    $Argument['Script'] = $Script
    $Argument['PassThru'] = $true

    if( $Argument.ContainsKey('OutputFile') )
    {
        $outputFile = $Argument['OutputFile']
    }
    else
    {
        $outputFileRoot = Resolve-Path -Path $TaskContext.OutputDirectory -Relative
        $outputFile = Join-Path -Path $outputFileRoot -ChildPath ('pester+{0}.xml' -f [IO.Path]::GetRandomFileName())
        $Argument['OutputFile'] = $outputFile
    }

    if( -not $Argument.ContainsKey('OutputFormat') )
    {
        $Argument['OutputFormat'] = 'NUnitXml'
    }

    $Argument | Write-WhiskeyObject -Context $context -Level Verbose

    $args = @(
        (Get-Location).Path,
        $pesterManifestPath,
        $Argument,
        @{
            'VerbosePreference' = $VerbosePreference;
            'DebugPreference' = $DebugPreference;
            'ProgressPreference' = $ProgressPreference;
            'WarningPreference' = $WarningPreference;
            'ErrorActionPreference' = $ErrorActionPreference;
        }
    )

    $cmdName = 'Start-Job'
    if( $NoJob )
    {
        $cmdName = 'Invoke-Command'
    }

    $result = & $cmdName -ArgumentList $args -ScriptBlock {
        param(
            [String]$WorkingDirectory,
            [String]$PesterManifestPath,
            [hashtable]$Parameter,
            [hashtable]$Preference
        )

        Set-Location -Path $WorkingDirectory

        $VerbosePreference = 'SilentlyContinue'
        Import-Module -Name $PesterManifestPath -Verbose:$false -WarningAction Ignore

        $VerbosePreference = $Preference['VerbosePreference']
        $DebugPreference = $Preference['DebugPreference']
        $ProgressPreference = $Preference['ProgressPreference']
        $WarningPreference = $Preference['WarningPreference']
        $ErrorActionPreference = $Preference['ErrorActionPreference']

        Invoke-Pester @Parameter
    }
    
    if( -not $NoJob )
    {
        $result = $result | Receive-Job -Wait -AutoRemoveJob -InformationAction Ignore
    }

    $result.TestResult |
        Group-Object 'Describe' |
        ForEach-Object {
            $totalTime = [TimeSpan]::Zero
            $_.Group | ForEach-Object { $totalTime += $_.Time }
            [pscustomobject]@{
                                Describe = $_.Name;
                                Duration = $totalTime
                            }
        } | Sort-Object -Property 'Duration' -Descending |
        Select-Object -First $DescribeDurationReportCount |
        Format-Table -AutoSize

    $result.TestResult |
        Sort-Object -Property 'Time' -Descending |
        Select-Object -First $ItDurationReportCount |
        Format-Table -AutoSize -Property 'Describe','Name','Time'

    Publish-WhiskeyPesterTestResult -Path $outputFile

    $outputFileContent = Get-Content -Path $outputFile -Raw
    $outputFileContent | Write-WhiskeyDebug
    $result = [xml]$outputFileContent

    if( -not $result )
    {
        Stop-WhiskeyTask -TaskContext $TaskContext -Message ('Unable to parse Pester output XML report "{0}".' -f $outputFile)
        return
    }

    if( $result.DocumentElement.errors -ne '0' -or $result.DocumentElement.failures -ne '0' )
    {
        Stop-WhiskeyTask -TaskContext $TaskContext -Message ('Pester tests failed.')
        return
    }
}



 function Invoke-WhiskeyPipelineTask
{
    [CmdletBinding()]
    [Whiskey.Task('Pipeline',SupportsClean,SupportsInitialize)]
    param(
        [Parameter(Mandatory)]
        [Whiskey.Context]$TaskContext,

        [Parameter(Mandatory)]
        [hashtable]$TaskParameter
    )

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

    if( -not $TaskParameter['Name'] )
    {
        Stop-WhiskeyTask -TaskContext $TaskContext -Message ('Name is a mandatory property, but is missing or doesn''t have a value. It should be set to a list of pipeline names you want to run as part of another pipeline, e.g.
 
    Build:
    - Pipeline:
        Name:
        - One
        - Two
 
    One:
    - TASK
 
    Two:
    - TASK
 
'
)
        return
    }

    $currentPipeline = $TaskContext.PipelineName
    try
    {
        foreach( $name in $TaskParameter['Name'] )
        {
            Invoke-WhiskeyPipeline -Context $TaskContext -Name $name
        }
    }
    finally
    {
        $TaskContext.PipelineName = $currentPipeline
    }
}


function Invoke-WhiskeyPowerShell
{
    [Whiskey.Task('PowerShell',SupportsClean,SupportsInitialize)]
    [CmdletBinding()]
    param(
        [Parameter(Mandatory)]
        [Whiskey.Context]$TaskContext,

        [Whiskey.Tasks.ValidatePath(PathType='File')]
        [String[]]$Path,

        [String]$ScriptBlock,

        [Object]$Argument = @()
    )

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

    $scriptBlockGiven = $PSCmdlet.MyInvocation.BoundParameters.ContainsKey('ScriptBlock')

    if( -not $Path -and -not $scriptBlockGiven )
    {
        Stop-WhiskeyTask -TaskContext $TaskContext -Message 'Missing required property. Task must use one of "Path" or "ScriptBlock".'
        return
    }
    elseif( $Path -and $scriptBlockGiven )
    {
        Stop-WhiskeyTask -TaskContext $TaskContext -Message 'Task uses both "Path" and "ScriptBlocK" properties. Only one of these properties is allowed.'
        return
    }

    if( $scriptBlockGiven )
    {
        $Path = Join-Path -Path $TaskContext.Temp.FullName -ChildPath 'scriptblock.ps1'
        Set-Content -Path $Path -Value $ScriptBlock -Force
    }

    $workingDirectory = (Get-Location).ProviderPath

    foreach( $scriptPath in $Path )
    {
        $mediumAndPath = "script `"$($scriptPath)`""
        if( $scriptBlockGiven )
        {
            $mediumAndPath = 'script block'
        }

        $scriptCommand = Get-Command -Name $scriptPath -ErrorAction Ignore
        if( -not $scriptCommand )
        {
            Stop-WhiskeyTask -TaskContext $TaskContext -Message "Can't run PowerShell $($mediumAndPath): it has a syntax error."
            continue
        }

        $passTaskContext = $scriptCommand.Parameters.ContainsKey('TaskContext')

        if( (Get-Member -InputObject $argument -Name 'Keys') )
        {
            $scriptCommand.Parameters.Values |
                Where-Object { $_.ParameterType -eq [switch] } |
                Where-Object { $argument.ContainsKey($_.Name) } |
                ForEach-Object { $argument[$_.Name] = $argument[$_.Name] | ConvertFrom-WhiskeyYamlScalar }
        }

        $resultPath = Join-Path -Path $TaskContext.OutputDirectory -ChildPath ('PowerShell-{0}-RunResult-{1}' -f ($scriptPath | Split-Path -Leaf),([IO.Path]::GetRandomFileName()))
        $serializableContext = $TaskContext | ConvertFrom-WhiskeyContext
        $job = Start-Job -ScriptBlock {

            Set-StrictMode -Version 'Latest'

            $VerbosePreference = $using:VerbosePreference
            $DebugPreference = $using:DebugPreference
            $ProgressPreference = $using:ProgressPreference
            $WarningPreference = $using:WarningPreference
            $ErrorActionPreference = $using:ErrorActionPreference
            $InformationPreference = $using:InformationPreference

            $workingDirectory = $using:WorkingDirectory
            $scriptPath = $using:ScriptPath
            $argument = $using:argument
            $serializedContext = $using:serializableContext
            $whiskeyScriptRoot = $using:whiskeyScriptRoot
            $resultPath = $using:resultPath
            $passTaskContext = $using:passTaskContext

            Invoke-Command -ScriptBlock {
                                            $VerbosePreference = 'SilentlyContinue';
                                            & (Join-Path -Path $whiskeyScriptRoot -ChildPath 'Import-Whiskey.ps1' -Resolve -ErrorAction Stop)
                                        }
            [Whiskey.Context]$context = $serializedContext | ConvertTo-WhiskeyContext

            Set-Location $workingDirectory

            $message = Resolve-Path -Path $scriptPath -Relative
            if( $message.Contains(' ') )
            {
                $message = '& "{0}"' -f $message
            }

            $contextArgument = @{ }
            if( $passTaskContext )
            {
                $contextArgument['TaskContext'] = $context
                $message = '{0} -TaskContext $context' -f $message
            }

            if( $argument )
            {
                $argumentDesc =
                    & {
                        if( ($argument | Get-Member -Name 'Keys') )
                        {
                            foreach( $parameterName in $argument.Keys )
                            {
                                Write-Output ('-{0}' -f $parameterName)
                                Write-Output $argument[$parameterName]
                            }
                        }
                        else
                        {
                            Write-Output $argument
                        }
                    } |
                    ForEach-Object {
                        if( $_.ToString().Contains(' ') )
                        {
                            Write-Output ("{0}" -f $_)
                            return
                        }
                        Write-Output $_
                    }
                $message = '{0} {1}' -f $message,($argumentDesc -join ' ')
            }

            Write-WhiskeyInfo -Context $context -Message $message

            $Global:LASTEXITCODE = 0

            $result = [pscustomobject]@{
                'ExitCode'   = $Global:LASTEXITCODE
                'Successful' = $false
            }
            $result | ConvertTo-Json | Set-Content -Path $resultPath

            try
            {
                Set-StrictMode -Off
                & $scriptPath @contextArgument @argument
                $result.ExitCode = $Global:LASTEXITCODE
                $result.Successful = $?
            }
            catch
            {
                $_ | Out-String | Write-WhiskeyError
            }

            Set-StrictMode -Version 'Latest'

            Write-WhiskeyVerbose -Context $context -Message ('Exit Code {0}' -f $result.ExitCode)
            Write-WhiskeyVerbose -Context $context -Message ('$? {0}' -f $result.Successful)
            $result | ConvertTo-Json | Set-Content -Path $resultPath
        }

        do
        {
            # There's a bug where Write-Host output gets duplicated by Receive-Job if $InformationPreference is set to "Continue".
            # Since some things use Write-Host, this is a workaround to avoid seeing duplicate host output.
            $job | Receive-Job -InformationAction SilentlyContinue
        }
        while( -not ($job | Wait-Job -Timeout 1) )

        $job | Receive-Job -InformationAction SilentlyContinue

        if( (Test-Path -Path $resultPath -PathType Leaf) )
        {
            $runResult = Get-Content -Path $resultPath -Raw | ConvertFrom-Json
        }
        else
        {
            Stop-WhiskeyTask -TaskContext $TaskContext -Message "PowerShell $($mediumAndPath) threw a terminating exception."
            return
        }

        if( $runResult.ExitCode -ne 0 )
        {
            Stop-WhiskeyTask -TaskContext $TaskContext -Message "PowerShell $($mediumAndPath) failed, exited with code $($runResult.ExitCode)."
            return
        }
        elseif( -not $runResult.Successful )
        {
            Stop-WhiskeyTask -TaskContext $TaskContext -Message "PowerShell $($mediumAndPath) threw a terminating exception."
            return
        }
    }
}



function New-WhiskeyProGetUniversalPackage
{
    [CmdletBinding()]
    [Whiskey.Task('ProGetUniversalPackage')]
    [Whiskey.RequiresPowerShellModule('ProGetAutomation',Version='0.9.*',VersionParameterName='ProGetAutomationVersion')]
    param(
        [Parameter(Mandatory)]
        [Whiskey.Context]$TaskContext,

        [Parameter(Mandatory)]
        [hashtable]$TaskParameter,

        [Whiskey.Tasks.ValidatePath(PathType='Directory')]
        [String]$SourceRoot
    )

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

    $manifestProperties = @{}
    if( $TaskParameter.ContainsKey('ManifestProperties') )
    {
        $manifestProperties = $TaskParameter['ManifestProperties']
        foreach( $taskProperty in @( 'Name', 'Description', 'Version' ))
        {
            if( $manifestProperties.Keys -contains $taskProperty )
            {
                Stop-WhiskeyTask -TaskContext $TaskContext -Message ('"ManifestProperties" contains key "{0}". This property cannot be manually defined in "ManifestProperties" as it is set automatically from the corresponding task property "{0}".' -f $taskProperty)
                return
            }
        }
    }

    foreach( $mandatoryProperty in @( 'Name', 'Description' ) )
    {
        if( -not $TaskParameter.ContainsKey($mandatoryProperty) )
        {
            Stop-WhiskeyTask -TaskContext $TaskContext -Message ('Property "{0}" is mandatory.' -f $mandatoryProperty)
            return
        }
    }

    $name = $TaskParameter['Name']
    $validNameRegex = '^[0-9A-z\-\._]{1,50}$'
    if ($name -notmatch $validNameRegex)
    {
        Stop-WhiskeyTask -TaskContext $TaskContext -Message '"Name" property is invalid. It should be a string of one to fifty characters: numbers (0-9), upper and lower-case letters (A-z), dashes (-), periods (.), and underscores (_).'
        return
    }

    $version = $TaskParameter['Version']

    # ProGet uses build metadata to distinguish different versions, so we can't use a full semantic version.
    if( $version )
    {
        if( ($version -notmatch '^\d+\.\d+\.\d+$') )
        {
            Stop-WhiskeyTask -TaskContext $TaskContext -Message ('Property "Version" is invalid. It must be a three part version number, i.e. MAJOR.MINOR.PATCH.')
            return
        }
        [SemVersion.SemanticVersion]$semVer = $null
        if( -not ([SemVersion.SemanticVersion]::TryParse($version, [ref]$semVer)) )
        {
            Stop-WhiskeyTask -TaskContext $TaskContext -Message ('Property "Version" is not a valid semantic version.')
            return
        }
        $semVer = New-Object 'SemVersion.SemanticVersion' $semVer.Major,$semVer.Minor,$semVer.Patch,$TaskContext.Version.SemVer2.Prerelease,$TaskContext.Version.SemVer2.Build
        $version = New-WhiskeyVersionObject -SemVer $semVer
    }
    else
    {
        $version = $TaskContext.Version
    }

    $compressionLevel = [IO.Compression.CompressionLevel]::Optimal
    if( $TaskParameter['CompressionLevel'] )
    {
        $expectedValues = [Enum]::GetValues([IO.Compression.CompressionLevel])
        $compressionLevel = $TaskParameter['CompressionLevel']
        if( $compressionLevel -notin $expectedValues )
        {
            [int]$intCompressionLevel = 0
            if( -not [int]::TryParse($TaskParameter['CompressionLevel'],[ref]$intCompressionLevel) )
            {
                Stop-WhiskeyTask -TaskContext $TaskContext -Message ('Property "CompressionLevel": "{0}" is not a valid compression level. It must be one of: {1}' -f $TaskParameter['CompressionLevel'],($expectedValues -join ', '));
                return
            }
            $compressionLevel = $intCompressionLevel
            if( $compressionLevel -ge 5 )
            {
                $compressionLevel = [IO.Compression.CompressionLevel]::Optimal
            }
            else
            {
                $compressionLevel = [IO.Compression.CompressionLevel]::Fastest
            }
            Write-WhiskeyWarning -Context $TaskContext -Message ('The ProGetUniversalPackage task no longer supports integer-style compression levels. Please update your task in your whiskey.yml file to use one of the new values: {0}. We''re converting the number you provided, "{1}", to "{2}".' -f ($expectedValues -join ', '),$TaskParameter['CompressionLevel'],$compressionLevel)
        }
    }

    function Copy-ToPackage
    {
        param(
            [Parameter(Mandatory)]
            [Object[]]$Path,

            [switch]$AsThirdPartyItem
        )

        foreach( $item in $Path )
        {
            $override = $False
            if( (Get-Member -InputObject $item -Name 'Keys') )
            {
                $sourcePath = $null
                $override = $true
                foreach( $key in $item.Keys )
                {
                    $destinationItemName = $item[$key]
                    $sourcePath = $key
                }
            }
            else
            {
                $sourcePath = $item
            }

            $pathparam = 'Path'
            if( $AsThirdPartyItem )
            {
                $pathparam = 'ThirdPartyPath'
            }

            $sourcePaths = 
                $sourcePath | 
                Resolve-WhiskeyTaskPath -TaskContext $TaskContext -PropertyName $pathparam 
            if( -not $sourcePaths )
            {
                return
            }

            $basePath = (Get-Location).Path
            foreach( $sourcePath in $sourcePaths )
            {
                $addParams = @{ BasePath = $basePath }
                $overrideInfo = ''
                if( $override )
                {
                    $addParams = @{ PackageItemName = $destinationItemName }
                    $overrideInfo = ' -> {0}' -f $destinationItemName
                }
                $addParams['CompressionLevel'] = $compressionLevel

                if( $AsThirdPartyItem )
                {
                    Write-WhiskeyInfo -Context $TaskContext -Message (' packaging unfiltered item {0}{1}' -f $sourcePath,$overrideInfo)
                    Get-Item -Path $sourcePath |
                        Add-ProGetUniversalPackageFile -PackagePath $outFile @addParams -ErrorAction Stop
                    continue
                }

                if( (Test-Path -Path $sourcePath -PathType Leaf) )
                {
                    Write-WhiskeyInfo -Context $TaskContext -Message (' packaging file {0}{1}' -f $sourcePath,$overrideInfo)
                    Add-ProGetUniversalPackageFile -PackagePath $outFile -InputObject $sourcePath @addParams -ErrorAction Stop
                    continue
                }

                if( -not $TaskParameter['Include'] )
                {
                    Stop-WhiskeyTask -TaskContext $TaskContext -Message ('Property "Include" is mandatory because "{0}" is in your "Path" property and it is a directory. The "Include" property is a whitelist of files (wildcards supported) to include in your package. Only files in directories that match an item in the "Include" list will be added to your package.' -f $sourcePath)
                    return
                }

                function Find-Item
                {
                    param(
                        [Parameter(Mandatory)]
                        $Path
                    )

                    if( (Test-Path -Path $Path -PathType Leaf) )
                    {
                        return Get-Item -Path $Path
                    }

                    $Path = Join-Path -Path $Path -ChildPath '*'
                    & {
                            Get-ChildItem -Path $Path -Include $TaskParameter['Include'] -Exclude $TaskParameter['Exclude'] -File
                            Get-Item -Path $Path -Exclude $TaskParameter['Exclude'] |
                                Where-Object { $_.PSIsContainer }
                        }  |
                        ForEach-Object {
                            if( $_.PSIsContainer )
                            {
                                Find-Item -Path $_.FullName
                            }
                            else
                            {
                                $_
                            }
                        }
                }

                if( $override )
                {
                    $overrideBasePath = 
                        Resolve-Path -Path $sourcePath | 
                        Select-Object -ExpandProperty 'ProviderPath'

                    if( (Test-Path -Path $overrideBasePath -PathType Leaf) )
                    {
                        $overrideBasePath = Split-Path -Parent -Path $overrideBasePath
                    }
                    $addParams['BasePath'] = $overrideBasePath 
                    $addParams.Remove('PackageItemName')
                    $overrideInfo = ' -> {0}' -f $destinationItemName

                    if ($destinationItemName -ne '.')
                    {
                        $addParams['PackageParentPath'] = $destinationItemName
                    }
                }

                Write-WhiskeyInfo -Context $TaskContext -Message (' packaging filtered directory {0}{1}' -f $sourcePath,$overrideInfo)
                Find-Item -Path $sourcePath |
                    Add-ProGetUniversalPackageFile -PackagePath $outFile @addParams -ErrorAction Stop
            }
        }
    }

    $tempRoot = Join-Path -Path $TaskContext.Temp -ChildPath 'upack'
    New-Item -Path $tempRoot -ItemType 'Directory' | Out-Null

    $tempPackageRoot = Join-Path -Path $tempRoot -ChildPath 'package'
    New-Item -Path $tempPackageRoot -ItemType 'Directory' | Out-Null

    $upackJsonPath = Join-Path -Path $tempRoot -ChildPath 'upack.json'
    $manifestProperties | ConvertTo-Json | Set-Content -Path $upackJsonPath

    # Add the version.json file
    $versionJsonPath = Join-Path -Path $tempPackageRoot -ChildPath 'version.json'
    @{
        Version = $version.Version.ToString();
        SemVer2 = $version.SemVer2.ToString();
        SemVer2NoBuildMetadata = $version.SemVer2NoBuildMetadata.ToString();
        PrereleaseMetadata = $version.SemVer2.Prerelease;
        BuildMetadata = $version.SemVer2.Build;
        SemVer1 = $version.SemVer1.ToString();
    } | ConvertTo-Json -Depth 1 | Set-Content -Path $versionJsonPath

    $badChars = [IO.Path]::GetInvalidFileNameChars() | ForEach-Object { [regex]::Escape($_) }
    $fixRegex = '[{0}]' -f ($badChars -join '')
    $fileName = '{0}.{1}.upack' -f $name,($version.SemVer2NoBuildMetadata -replace $fixRegex,'-')

    $outFile = Join-Path -Path $TaskContext.OutputDirectory -ChildPath $fileName

    if( (Test-Path -Path $outFile -PathType Leaf) )
    {
        Remove-Item -Path $outFile -Force
    }

    if( -not $manifestProperties.ContainsKey('title') )
    {
        $manifestProperties['title'] = $TaskParameter['Name']
    }

    $outFileDisplay = $outFile -replace ('^{0}' -f [regex]::Escape($TaskContext.BuildRoot)),''
    $outFileDisplay = $outFileDisplay.Trim([IO.Path]::DirectorySeparatorChar)
    Write-WhiskeyInfo -Context $TaskContext -Message ('Creating universal package "{0}".' -f $outFileDisplay)
    New-ProGetUniversalPackage -OutFile $outFile `
                               -Version $version.SemVer2NoBuildMetadata.ToString() `
                               -Name $TaskParameter['Name'] `
                               -Description $TaskParameter['Description'] `
                               -AdditionalMetadata $manifestProperties

    Add-ProGetUniversalPackageFile -PackagePath $outFile -InputObject $versionJsonPath -ErrorAction Stop

    if( $SourceRoot )
    {
        Write-WhiskeyWarning -Context $TaskContext -Message ('The "SourceRoot" property is obsolete. Please use the "WorkingDirectory" property instead.')
        Push-Location -Path $SourceRoot
    }

    try
    {
        if( $TaskParameter['Path'] )
        {
            Copy-ToPackage -Path $TaskParameter['Path']
        }

        if( $TaskParameter.ContainsKey('ThirdPartyPath') -and $TaskParameter['ThirdPartyPath'] )
        {
            Copy-ToPackage -Path $TaskParameter['ThirdPartyPath'] -AsThirdPartyItem
        }
    }
    finally
    {
        if( $SourceRoot )
        {
            Pop-Location
        }
    }
}


function Publish-WhiskeyBBServerTag
{
    [CmdletBinding()]
    [Whiskey.Task('PublishBitbucketServerTag')]
    [Whiskey.RequiresPowerShellModule('BitbucketServerAutomation',Version='0.9.*',VersionParameterName='BitbucketServerAutomationVersion')]
    param(
        [Parameter(Mandatory)]
        [Whiskey.Context]$TaskContext,

        [Parameter(Mandatory)]
        [hashtable]$TaskParameter
    )

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

    $exampleTask = 'Publish:
        - PublishBitbucketServerTag:
            CredentialID: BitbucketServerCredential
            Uri: https://bitbucketserver.example.com'


    if( -not $TaskParameter['CredentialID'] )
    {
        Stop-WhiskeyTask -TaskContext $TaskContext -Message "Property 'CredentialID' is mandatory. It should be the ID of the credential to use when connecting to Bitbucket Server:
 
        $exampleTask
 
        Use the `Add-WhiskeyCredential` function to add credentials to the build.
        "

        return
    }

    if( -not $TaskParameter['Uri'] )
    {
        Stop-WhiskeyTask -TaskContext $TaskContext -Message "Property 'Uri' is mandatory. It should be the URL to the instance of Bitbucket Server where the tag should be created:
 
        $exampleTask
        "

        return
    }

    $commitHash = $TaskContext.BuildMetadata.ScmCommitID
    if( -not $commitHash )
    {
        Stop-WhiskeyTask -TaskContext $TaskContext -PropertyDescription '' -Message ('Unable to identify a valid commit to tag. Are you sure you''re running under a build server?')
        return
    }

    if( $TaskParameter['ProjectKey'] -and $TaskParameter['RepositoryKey'] )
    {
        $projectKey = $TaskParameter['ProjectKey']
        $repoKey = $TaskParameter['RepositoryKey']
    }
    elseif( $TaskContext.BuildMetadata.ScmUri -and $TaskContext.BuildMetadata.ScmUri.Segments )
    {
        $uri = [Uri]$TaskContext.BuildMetadata.ScmUri
        $projectKey = $uri.Segments[-2].Trim('/')
        $repoKey = $uri.Segments[-1] -replace '\.git$',''
    }
    else
    {
        Stop-WhiskeyTask -TaskContext $TaskContext -PropertyDescription '' -Message ("Unable to determine the repository where we should create the tag. Either create a `GIT_URL` environment variable that is the URI used to clone your repository, or add your repository''s project and repository keys as `ProjectKey` and `RepositoryKey` properties, respectively, on this task:
 
        Publish:
        - PublishBitbucketServerTag:
            CredentialID: $($TaskParameter['CredentialID'])
            Uri: $($TaskParameter['Uri'])
            ProjectKey: PROJECT_KEY
            RepositoryKey: REPOSITORY_KEY
       "
)
        return
    }

    $credentialID = $TaskParameter['CredentialID']
    $credential = Get-WhiskeyCredential -Context $TaskContext -ID $credentialID -PropertyName 'CredentialID'
    $connection = New-BBServerConnection -Credential $credential -Uri $TaskParameter['Uri']
    $tag = $TaskContext.Version.SemVer2NoBuildMetadata
    Write-WhiskeyVerbose -Context $TaskContext -Message ('[{0}] [{1}] [{2}] {3} -> {4}' -f $TaskParameter['Uri'],$projectKey,$repoKey,$commitHash,$tag)
    New-BBServerTag -Connection $connection -ProjectKey $projectKey -force -RepositoryKey $repoKey -Name $tag -CommitID $commitHash -ErrorAction Stop
}



function Publish-WhiskeyBuildMasterPackage
{
    [CmdletBinding()]
    [Whiskey.Task('PublishBuildMasterPackage')]
    [Whiskey.RequiresPowerShellModule('BuildMasterAutomation',Version='0.6.*',VersionParameterName='BuildMasterAutomationVersion')]
    param(
        [Parameter(Mandatory)]
        [Whiskey.Context]$TaskContext,

        [Parameter(Mandatory)]
        [hashtable]$TaskParameter
    )

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

    $applicationName = $TaskParameter['ApplicationName']
    if( -not $applicationName )
    {
        Stop-WhiskeyTask -TaskContext $TaskContext -Message ('Property ''ApplicationName'' is mandatory. It must be set to the name of the application in BuildMaster where the package should be published.')
        return
    }

    $releaseName = $TaskParameter['ReleaseName']
    if( -not $releaseName )
    {
        Stop-WhiskeyTask -TaskContext $TaskContext -Message ('Property ''ReleaseName'' is mandatory. It must be set to the release name in the BuildMaster application where the package should be published.')
        return
    }

    $buildmasterUri = $TaskParameter['Uri']
    if( -not $buildmasterUri )
    {
        Stop-WhiskeyTask -TaskContext $TaskContext -Message ('Property ''Uri'' is mandatory. It must be set to the BuildMaster URI where the package should be published.')
        return
    }

    $apiKeyID = $TaskParameter['ApiKeyID']
    if( -not $apiKeyID )
    {
        Stop-WhiskeyTask -TaskContext $TaskContext -Message ('Property ''ApiKeyID'' is mandatory. It should be the ID of the API key to use when publishing the package to BuildMaster. Use the `Add-WhiskeyApiKey` to add your API key.')
        return
    }

    $apiKey = Get-WhiskeyApiKey -Context $TaskContext -ID $TaskParameter['ApiKeyID'] -PropertyName 'ApiKeyID'
    $buildMasterSession = New-BMSession -Uri $TaskParameter['Uri'] -ApiKey $apiKey

    $version = $TaskContext.Version.SemVer2

    $variables = $TaskParameter['PackageVariable']

    $release = Get-BMRelease -Session $buildMasterSession -Application $applicationName -Name $releaseName -ErrorAction Stop
    if( -not $release )
    {
        Stop-WhiskeyTask -TaskContext $TaskContext -Message ('Unable to create and deploy a release package in BuildMaster. Either the ''{0}'' application doesn''t exist or it doesn''t have a ''{1}'' release.' -f $applicationName,$releaseName)
        return
    }

    $release | Format-List | Out-String | Write-WhiskeyVerbose -Context $TaskContext

    if( $TaskParameter['PackageName'] )
    {
        $packageName = $TaskParameter['PackageName']
    }
    else
    {
        $packageName = '{0}.{1}.{2}' -f $version.Major,$version.Minor,$version.Patch
    }

    $package = New-BMPackage -Session $buildMasterSession -Release $release -PackageNumber $packageName -Variable $variables -ErrorAction Stop
    $package | Format-List | Out-String | Write-WhiskeyVerbose -Context $TaskContext

    if( ConvertFrom-WhiskeyYamlScalar -InputObject $TaskParameter['SkipDeploy'] )
    {
        Write-WhiskeyVerbose -Context $TaskContext -Message ('Skipping deploy. SkipDeploy property is true')
    }
    else
    {
        $optionalParams = @{ 'Stage' = $TaskParameter['StartAtStage'] }

        $deployment = Publish-BMReleasePackage -Session $buildMasterSession -Package $package @optionalParams -ErrorAction Stop
        $deployment | Format-List | Out-String | Write-WhiskeyVerbose -Context $TaskContext
    }
}



function Publish-WhiskeyNodeModule
{
    [Whiskey.Task('PublishNodeModule')]
    [Whiskey.RequiresTool('Node',PathParameterName='NodePath',VersionParameterName='NodeVersion')]
    [CmdletBinding()]
    param(
        [Parameter(Mandatory)]
        [Whiskey.Context]$TaskContext,

        [String]$CredentialID,

        [String]$EmailAddress,

        [Uri]$NpmRegistryUri,

        [String]$Tag
    )

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

    if (-not $NpmRegistryUri) 
    {
        Stop-WhiskeyTask -TaskContext $TaskContext -Message 'Property ''NpmRegistryUri'' is mandatory and must be a URI. It should be the URI to the registry where the module should be published. E.g.,
         
    Build:
    - PublishNodeModule:
        NpmRegistryUri: https://registry.npmjs.org/
    '

        return
    }

    if( -not $CredentialID )
    {
        Stop-WhiskeyTask -TaskContext $TaskContext -Message ('Property ''CredentialID'' is mandatory. It should be the ID of the credential to use when publishing to ''{0}'', e.g.
     
    Build:
    - PublishNodeModule:
        NpmRegistryUri: {0}
        CredentialID: NpmCredential
     
    Use the `Add-WhiskeyCredential` function to add the credential to the build.
    '
 -f $NpmRegistryUri)
        return
    }

    $credential = Get-WhiskeyCredential -Context $TaskContext -ID $CredentialID -PropertyName 'CredentialID'
    $npmUserName = $credential.UserName
    if( -not $EmailAddress )
    {
        Stop-WhiskeyTask -TaskContext $TaskContext -Message ('Property ''EmailAddress'' is mandatory. It should be the e-mail address of the user publishing the module, e.g.
     
    Build:
    - PublishNodeModule:
        NpmRegistryUri: {0}
        CredentialID: {1}
        EmailAddress: somebody@example.com
    '
 -f $NpmRegistryUri,$CredentialID)
        return
    }

    $npmConfigPrefix = '//{0}{1}:' -f $NpmRegistryUri.Authority,$NpmRegistryUri.LocalPath
    $npmCredPassword = $credential.GetNetworkCredential().Password
    $npmBytesPassword  = [System.Text.Encoding]::UTF8.GetBytes($npmCredPassword)
    $npmPassword = [System.Convert]::ToBase64String($npmBytesPassword)

    $originalPackageJsonPath = Resolve-Path -Path 'package.json' | Select-Object -ExpandProperty 'ProviderPath'
    $backupPackageJsonPath = Join-Path -Path $TaskContext.Temp -ChildPath 'package.json'

    try
    {
        $packageNpmrc = New-Item -Path '.npmrc' -ItemType File -Force
        Add-Content -Path $packageNpmrc -Value ('{0}_password="{1}"' -f $npmConfigPrefix, $npmPassword)
        Add-Content -Path $packageNpmrc -Value ('{0}username={1}' -f $npmConfigPrefix, $npmUserName)
        Add-Content -Path $packageNpmrc -Value ('{0}email={1}' -f $npmConfigPrefix, $EmailAddress)
        Add-Content -Path $packageNpmrc -Value ('registry={0}' -f $NpmRegistryUri)
        Write-WhiskeyVerbose -Context $TaskContext -Message ('Creating .npmrc at {0}.' -f $packageNpmrc)
        Get-Content -Path $packageNpmrc |
            ForEach-Object {
                if( $_ -match '_password' )
                {
                    return $_ -replace '=(.*)$','=********'
                }
                return $_
            } |
            Write-WhiskeyVerbose -Context $TaskContext


        Copy-Item -Path $originalPackageJsonPath -Destination $backupPackageJsonPath
        Invoke-WhiskeyNpmCommand -Name 'version' `
                                -ArgumentList $TaskContext.Version.SemVer2NoBuildMetadata, '--no-git-tag-version', '--allow-same-version' `
                                -BuildRootPath $TaskContext.BuildRoot `
                                -ErrorAction Stop

        Invoke-WhiskeyNpmCommand -Name 'prune' -ArgumentList '--production' -BuildRootPath $TaskContext.BuildRoot -ErrorAction Stop

        $publishArgumentList = @(
            if( $Tag )
            {
                '--tag'
                $Tag
            }
            elseif( $TaskContext.Version.SemVer2.Prerelease )
            {
                '--tag'
                Resolve-WhiskeyVariable -Context $TaskContext -Name 'WHISKEY_SEMVER2_PRERELEASE_ID'
            }
        )

        Invoke-WhiskeyNpmCommand -Name 'publish' -ArgumentList $publishArgumentList -BuildRootPath $TaskContext.BuildRoot -ErrorAction Stop
    }
    finally
    {
        if (Test-Path -Path $packageNpmrc -PathType Leaf)
        {
            Write-WhiskeyVerbose -Context $TaskContext -Message ('Removing .npmrc at {0}.' -f $packageNpmrc)
            Remove-Item -Path $packageNpmrc
        }

        if (Test-Path -Path $backupPackageJsonPath -PathType Leaf)
        {
            Copy-Item -Path $backupPackageJsonPath -Destination $originalPackageJsonPath -Force
        }
    }
}



function Publish-WhiskeyPowerShellModule
{
    [Whiskey.Task('PublishPowerShellModule')]
    # If you want to upgrade the PackageManagement and PowerShellGet versions, you must also update:
    # * Test\Resolve-WhiskeyPowerShellModule.Tests.ps1
    # * Whiskey\Functions\Resolve-WhiskeyPowerShellModule.ps1
    # * whiskey.yml
    [Whiskey.RequiresPowerShellModule('PackageManagement',Version='1.4.5',VersionParameterName='PackageManagementVersion')]
    [Whiskey.RequiresPowerShellModule('PowerShellGet',Version='2.2.1',VersionParameterName='PowerShellGetVersion')]
    [CmdletBinding()]
    param(
        [Parameter(Mandatory)]
        [Whiskey.Context]$TaskContext,

        [Parameter(Mandatory)]
        [hashtable]$TaskParameter,

        [Whiskey.Tasks.ValidatePath(Mandatory,PathType='Directory')]
        [String]$Path
    )

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

    if( -not $TaskParameter.ContainsKey('RepositoryName') )
    {
        Stop-WhiskeyTask -TaskContext $TaskContext `
                         -Message ('Property "RepositoryName" is mandatory. It should be the name of the PowerShell repository you want to publish to, e.g.
 
        Build:
        - PublishPowerShellModule:
            Path: mymodule
            RepositoryName: PSGallery
        '
)
        return
    }
    $repositoryName = $TaskParameter['RepositoryName']

    $apiKeyID = $TaskParameter['ApiKeyID']
    if( -not $apiKeyID )
    {
        Stop-WhiskeyTask -TaskContext $TaskContext `
                         -Message ('Property "ApiKeyID" is mandatory. It must be the ID of the API key to use when publishing to the "{0}" repository. Use the `Add-WhiskeyApiKey` function to add API keys to the build.' -f $repositoryName)
        return
    }

    $apiKey = Get-WhiskeyApiKey -Context $TaskContext -ID $apiKeyID -PropertyName 'ApiKeyID'

    $manifestPath = '{0}\{1}.psd1' -f $Path,($Path | Split-Path -Leaf)
    if( $TaskParameter.ContainsKey('ModuleManifestPath') )
    {
        $manifestPath = $TaskParameter['ModuleManifestPath']
    }
    if( -not (Test-Path -Path $manifestPath -PathType Leaf) )
    {
        Stop-WhiskeyTask -TaskContext $TaskContext `
                         -Message ('Module manifest path "{0}" either does not exist or is a directory.' -f $manifestPath)
        return
    }

    $manifest = Test-ModuleManifest -Path $manifestPath -ErrorAction Ignore
    if( $TaskContext.Version.SemVer2.Prerelease -and `
        (-not ($manifest.PrivateData) -or `
        -not ($manifest.PrivateData | Get-Member 'Keys') -or `
        -not $manifest.PrivateData.ContainsKey('PSData') -or `
        -not ($manifest.PrivateData['PSData'] | Get-Member 'Keys') -or `
        -not $manifest.PrivateData['PSData'].ContainsKey('Prerelease')) )
    {
        Stop-WhiskeyTask -TaskContext $Context -Message ("Module manifest ""$($manifestPath)"" is missing a ""Prerelease"" property. Please make sure the manifest's PrivateData hashtable contains a PSData key with a Prerelease property, e.g.
         
    @{
        PrivateData = @{
            PSData = @{
                Prerelease = '';
            }
        }
    }
        "
)
        return
    }

    $manifest = Get-Content $manifestPath
    $versionString = 'ModuleVersion = ''{0}.{1}.{2}''' -f ( $TaskContext.Version.SemVer2.Major, $TaskContext.Version.SemVer2.Minor, $TaskContext.Version.SemVer2.Patch )
    $manifest = $manifest -replace 'ModuleVersion\s*=\s*(''|")[^''"]*(''|")', $versionString
    $prereleaseString = 'Prerelease = ''{0}''' -f $TaskContext.Version.SemVer2.Prerelease  
    $manifest = $manifest -replace 'Prerelease\s*=\s*(''|")[^''"]*(''|")', $prereleaseString
    $manifest | Set-Content $manifestPath

    $commonParams = @{}
    if( $VerbosePreference -in @('Continue','Inquire') )
    {
        $commonParams['Verbose'] = $true
    }
    if( $DebugPreference -in @('Continue','Inquire') )
    {
        $commonParams['Debug'] = $true
    }
    if( (Test-Path -Path 'variable:InformationPreference') )
    {
        $commonParams['InformationAction'] = $InformationPreference
    }

    Get-PackageProvider -Name 'NuGet' -ForceBootstrap @commonParams | Out-Null
    $registeredRepositories = Get-PSRepository -ErrorAction Ignore @commonParams

    if( $repositoryName -notin $registeredRepositories.Name )
    {
        $publishLocation = $TaskParameter['RepositoryUri']
        if( -not $publishLocation )
        {
            Stop-WhiskeyTask -TaskContext $TaskContext `
                             -Message ('Property "RepositoryUri" is mandatory since there is no registered repository named "{0}". The "RepositoryUri" must be the URI to the PowerShall repository to publish to. The repository will be registered for you.' -f $repositoryName)
            return
        }

        $credentialParam = @{ }
        if( $TaskParameter.ContainsKey('CredentialID') )
        {
            $credentialParam['Credential'] = 
                Get-WhiskeyCredential -Context $TaskContext `
                                      -ID $TaskParameter['CredentialID'] `
                                      -PropertyName 'CredentialID'
        }

        $exists = $registeredRepositories | Where-Object { $_.SourceLocation -eq $publishLocation }
        if( $exists )
        {
            $repositoryName = $exists.Name 
            Write-WhiskeyWarning -Context $TaskContext `
                                 -Message ('The URI "{0}" is registered as the {1} repository. Please update your whiskey.yml file to use this repository name.' -f $publishLocation, $repositoryName)
        }
        else
        {
            Register-PSRepository -Name $repositoryName `
                                  -SourceLocation $publishLocation `
                                  -PublishLocation $publishLocation `
                                  -InstallationPolicy Trusted `
                                  -PackageManagementProvider NuGet @credentialParam `
                                  -ErrorAction Stop @commonParams
        }
    }

    # Use the Force switch to allow publishing versions that come *before* the latest version.
    Publish-Module -Path $Path `
                   -Repository $repositoryName `
                   -NuGetApiKey $apiKey `
                   -Force `
                   -ErrorAction Stop `
                   @commonParams
}




function Publish-WhiskeyProGetAsset
{
    [Whiskey.Task('PublishProGetAsset')]
    [Whiskey.RequiresPowerShellModule('ProGetAutomation',Version='0.9.*',VersionParameterName='ProGetAutomationVersion')]
    [CmdletBinding()]
    param(
        # The context this task is operating in. Use `New-WhiskeyContext` to create context objects.
        [Whiskey.Context]$TaskContext,

        # The parameters/configuration to use to run the task.
        [hashtable]$TaskParameter
    )


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

    $message = "
    Build:
    - PublishProGetAsset:
        CredentialID: ProGetCredential
        Path:
        - ""path/to/file.txt""
        - ""path/to/anotherfile.txt""
        Uri: http://proget.dev.webmd.com/
        AssetPath:
        - ""path/to/exampleAsset""
        - ""path/toanother/file.txt""
        AssetDirectory: 'versions'
        "

    if( -not $TaskParameter['Path'] )
    {
        Stop-WhiskeyTask -TaskContext $TaskContext -Message ("Please add a valid Path Parameter to your whiskey.yml file:" + $message)
        return
    }

    if( -not $TaskParameter['AssetDirectory'] )
    {
        Stop-WhiskeyTask -TaskContext $TaskContext -Message ("Please add a valid Directory Parameter to your whiskey.yml file:" + $message)
        return
    }

    if( -Not $TaskParameter['CredentialID'])
    {
        Stop-WhiskeyTask -TaskContext $TaskContext -Message ("CredentialID is a mandatory property. It should be the ID of the credential to use when connecting to ProGet. Add the credential with the `Add-WhiskeyCredential` function:" + $message)
        return
    }

    $credential = Get-WhiskeyCredential -Context $TaskContext -ID $TaskParameter['CredentialID'] -PropertyName 'CredentialID'

    $session = New-ProGetSession -Uri $TaskParameter['Uri'] -Credential $credential

    foreach($path in $TaskParameter['Path']){
        if( $TaskParameter['AssetPath'] -and @($TaskParameter['AssetPath']).count -eq @($TaskParameter['Path']).count){
            $name = @($TaskParameter['AssetPath'])[$TaskParameter['Path'].indexOf($path)]
        }
        else
        {
            Stop-WhiskeyTask -TaskContext $TaskContext -Message ("There must be the same number of `Path` items as `AssetPath` Items. Each asset must have both a `Path` and an `AssetPath` in the whiskey.yml file." + $message)
            return
        }
        Set-ProGetAsset -Session $session -DirectoryName $TaskParameter['AssetDirectory'] -Path $name -FilePath $path
    }
}


function Publish-WhiskeyProGetUniversalPackage
{
    [CmdletBinding()]
    [Whiskey.Task('PublishProGetUniversalPackage')]
    [Whiskey.RequiresPowerShellModule('ProGetAutomation',Version='0.9.*',VersionParameterName='ProGetAutomationVersion')]
    param(
        [Parameter(Mandatory)]
        [Whiskey.Context]$TaskContext,

        [Parameter(Mandatory)]
        [hashtable]$TaskParameter,

        [Whiskey.Tasks.ValidatePath(AllowNonexistent,PathType='File')]
        [String[]]$Path
    )

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

    $exampleTask = 'Publish:
        - PublishProGetUniversalPackage:
            CredentialID: ProGetCredential
            Uri: https://proget.example.com
            FeedName: UniversalPackages'



    if( -not $TaskParameter['CredentialID'] )
    {
        Stop-WhiskeyTask -TaskContext $TaskContext -Message "CredentialID is a mandatory property. It should be the ID of the credential to use when connecting to ProGet:
 
        $exampleTask
 
        Use the `Add-WhiskeyCredential` function to add credentials to the build."

        return
    }

    if( -not $TaskParameter['Uri'] )
    {
        Stop-WhiskeyTask -TaskContext $TaskContext -Message "Uri is a mandatory property. It should be the URI to the ProGet instance where you want to publish your package:
 
        $exampleTask
        "

        return
    }

    if( -not $TaskParameter['FeedName'] )
    {
        Stop-WhiskeyTask -TaskContext $TaskContext -Message "FeedName is a mandatory property. It should be the name of the universal feed in ProGet where you want to publish your package:
 
        $exampleTask
        "

        return
    }

    $credential = Get-WhiskeyCredential -Context $TaskContext -ID $TaskParameter['CredentialID'] -PropertyName 'CredentialID'

    $session = New-ProGetSession -Uri $TaskParameter['Uri'] -Credential $credential

    if( -not $Path )
    {
        $Path = 
            Join-Path -Path $TaskContext.OutputDirectory -ChildPath '*.upack' |
            Resolve-WhiskeyTaskPath -TaskContext $TaskContext -AllowNonexistent -PropertyName 'Path' -PathType 'File'
    }

    $allowMissingPackages = $false
    if( $TaskParameter.ContainsKey('AllowMissingPackage') )
    {
        $allowMissingPackages = $TaskParameter['AllowMissingPackage'] | ConvertFrom-WhiskeyYamlScalar
    }

    $packages = 
        $Path |
        Where-Object {
            if( -not $TaskParameter.ContainsKey('Exclude') )
            {
                return $true
            }

            foreach( $exclusion in $TaskParameter['Exclude'] )
            {
                if( $_ -like $exclusion )
                {
                    return $false
                }
            }

            return $true
        }


    if( $allowMissingPackages -and -not $packages )
    {
        Write-WhiskeyVerbose -Context $TaskContext -Message ('There are no packages to publish.')
        return
    }

    if( -not $packages )
    {
        Stop-WhiskeyTask -TaskContext $TaskContext -PropertyDescription '' -Message ('Found no packages to publish. By default, the PublishProGetUniversalPackage task publishes all files with a .upack extension in the output directory. Check your whiskey.yml file to make sure you''re running the `ProGetUniversalPackage` task before this task (or some other task that creates universal ProGet packages). To publish other .upack files, set this task''s `Path` property to the path to those files. If you don''t want your build to fail when there are missing packages, then set this task''s `AllowMissingPackage` property to `true`.' -f $TaskContext.OutputDirectory)
        return
    }

    $feedName = $TaskParameter['FeedName']
    $taskPrefix = '[{0}] [{1}]' -f $session.Uri,$feedName

    $optionalParam = @{ }
    if( $TaskParameter['Timeout'] )
    {
        $optionalParam['Timeout'] = $TaskParameter['Timeout']
    }
    if( $TaskParameter['Overwrite'] )
    {
        $optionalParam['Force'] = $TaskParameter['Overwrite'] | ConvertFrom-WhiskeyYamlScalar
    }

    Write-WhiskeyVerbose -Context $TaskContext -Message ('{0}' -f $taskPrefix)
    foreach( $package in $packages )
    {
        Write-WhiskeyVerbose -Context $TaskContext -Message ('{0} {1}' -f (' ' * $taskPrefix.Length),$package)
        Publish-ProGetUniversalPackage -Session $session -FeedName $feedName -PackagePath $package @optionalParam -ErrorAction Stop
    }
}



function Set-WhiskeyVariable 
{
    [CmdletBinding()]
    [Whiskey.Task('SetVariable',SupportsClean,SupportsInitialize)]
    param(
        [Parameter(Mandatory)]
        [Whiskey.Context]$TaskContext,

        [Parameter(Mandatory)]
        [hashtable]$TaskParameter
    )

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

    foreach( $key in $TaskParameter.Keys )
    {
        if( $key -match '^WHISKEY_' )
        {
            Stop-WhiskeyTask -TaskContext $TaskContext -Message ('Variable ''{0}'' is a built-in Whiskey variable and can not be changed.' -f $key)
            continue
        }
        Add-WhiskeyVariable -Context $TaskContext -Name $key -Value $TaskParameter[$key]
    }
}



function Set-WhiskeyVariableFromPowerShellDataFile
{
    [CmdletBinding()]
    [Whiskey.Task('SetVariableFromPowerShellDataFile')]
    param(
        [Parameter(Mandatory)]
        [Whiskey.Context]$TaskContext,

        [Parameter(Mandatory)]
        [hashtable]$TaskParameter,

        [Whiskey.Tasks.ValidatePath(Mandatory,PathType='File')]
        [String]$Path
    )

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

    $data = Import-PowerShellDataFile -Path $Path
    if( -not $data )
    {
        Stop-WhiskeyTask -TaskContext $TaskContext -PropertyName 'Path' -Message ('Failed to parse PowerShell Data File "{0}". Make sure this is a properly formatted PowerShell data file. Use the `Import-PowerShellDataFile` cmdlet.' -f $Path)
        return
    }

    function Set-VariableFromData
    {
        param(
            [Object]$Variable,

            [hashtable]$Data,
            
            [String]$ParentPropertyName = ''
        )

        foreach( $propertyName in $Variable.Keys )
        {
            $variableName = $Variable[$propertyName]
            if( -not $Data.ContainsKey($propertyName) )
            {
                Stop-WhiskeyTask -TaskContext $TaskContext -PropertyName 'Variables' -Message ('PowerShell Data File "{0}" does not contain "{1}{2}" property.' -f $Path,$ParentPropertyName,$propertyName)
                continue
            }

            $variableValue = $Data[$propertyName]
            if( $variableName | Get-Member 'Keys' )
            {
                Set-VariableFromData -Variable $variableName -Data $variableValue -ParentPropertyName ('{0}{1}.' -f $ParentPropertyName,$propertyName)
                continue
            }

            Add-WhiskeyVariable -Context $TaskContext -Name $variableName -Value $variableValue
        }
    }

    Set-VariableFromData -Variable $TaskParameter['Variables'] -Data $data
}



function Set-WhiskeyVariableFromXml
{
    [Whiskey.Task('SetVariableFromXml')]
    [CmdletBinding()]
    param(
        [Parameter(Mandatory)]
        [Whiskey.Context]$TaskContext,

        [Parameter(Mandatory)]
        [hashtable]$TaskParameter,

        [Whiskey.Tasks.ValidatePath(Mandatory,PathType='File')]
        [String]$Path
    )

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

    Write-WhiskeyVerbose -Context $TaskContext -Message ($Path)
    [xml]$xml = $null
    try
    {
        $xml = Get-Content -Path $Path -Raw
    }
    catch
    {
        $Global:Error.RemoveAt(0)
        Stop-WhiskeyTask -TaskContext $TaskContext -Message ('Exception reading XML from file "{0}": {1}"' -f $Path,$_)
        return
    }

    $nsManager = New-Object -TypeName 'Xml.XmlNamespaceManager' -ArgumentList $xml.NameTable
    $prefixes = $TaskParameter['NamespacePrefixes']
    if( $prefixes -and ($prefixes | Get-Member 'Keys') )
    {
        foreach( $prefix in $prefixes.Keys )
        {
            $nsManager.AddNamespace($prefix, $prefixes[$prefix])
        }
    }

    $allowMissingNodes = $TaskParameter['AllowMissingNodes'] | ConvertFrom-WhiskeyYamlScalar

    $variables = $TaskParameter['Variables']
    if( $variables -and ($variables | Get-Member 'Keys') )
    {
        foreach( $variableName in $variables.Keys )
        {
            $xpath = $variables[$variableName]
            $value = $xml.SelectNodes($xpath, $nsManager) | ForEach-Object {
                if( $_ | Get-Member 'InnerText' )
                {
                    $_.InnerText
                }
                elseif( $_ | Get-Member '#text' )
                {
                    $_.'#text'
                }
            }
            $exists = ' '
            if( $value -eq $null )
            {
                if( -not $allowMissingNodes )
                {
                    Stop-WhiskeyTask -TaskContext $TaskContext -Message ('Variable {0}: XPath expression "{1}" matched no elements/attributes in XML file "{2}".' -f $variableName,$xpath,$Path)
                    return
                }
                $value = ''
                $exists = '!'
            }
            Write-WhiskeyVerbose -Context $TaskContext -Message (' {0} {1}' -f $exists,$xpath)
            Write-WhiskeyVerbose -Context $TaskContext -Message (' {0} = {1}' -f $variableName,($value | Select-Object -First 1))
            $value | Select-Object -Skip 1 | ForEach-Object {
                Write-WhiskeyVerbose -Context $TaskContext -Message (' {0} {1}' -f (' ' * $variableName.Length),$_)
            }
            Add-WhiskeyVariable -Context $TaskContext -Name $variableName -Value $value
        }
    }
}



function Set-WhiskeyTaskDefaults
{
    [CmdletBinding()]
    [Whiskey.Task('TaskDefaults',SupportsClean,SupportsInitialize)]
    param(
        [Parameter(Mandatory)]
        [Whiskey.Context]$TaskContext,

        [Parameter(Mandatory)]
        [hashtable]$TaskParameter
    )

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

    foreach ($taskName in $TaskParameter.Keys)
    {
        foreach ($propertyName in $TaskParameter[$taskName].Keys)
        {
            Add-WhiskeyTaskDefault -Context $TaskContext -TaskName $taskName -PropertyName $propertyname -Value $TaskParameter[$taskName][$propertyName] -Force
        }
    }
}



 function Set-WhiskeyVersion
{
    [CmdletBinding()]
    [Whiskey.Task('Version')]
    param(
        [Parameter(Mandatory)]
        [Whiskey.Context]$TaskContext,

        [Parameter(Mandatory)]
        [hashtable]$TaskParameter,

        [Whiskey.Tasks.ValidatePath(PathType='File')]
        [String]$Path
    )

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

    function ConvertTo-SemVer
    {
        param(
            [Parameter(Mandatory,ValueFromPipeline)]
            $InputObject,
            $PropertyName,
            $VersionSource
        )

        process
        {
            [SemVersion.SemanticVersion]$semver = $null
            if( -not [SemVersion.SemanticVersion]::TryParse($rawVersion,[ref]$semver) )
            {
                if( $VersionSource )
                {
                    $VersionSource = ' ({0})' -f $VersionSource
                }
                $optionalParam = @{ }
                if( $PropertyName )
                {
                    $optionalParam['PropertyName'] = $PropertyName
                }
                Stop-WhiskeyTask -TaskContext $TaskContext -Message ('''{0}''{1} is not a semantic version. See http://semver.org for documentation on semantic versions.' -f $rawVersion,$VersionSource) @optionalParam
            }
            return $semver
        }
    }

    [Whiskey.BuildVersion]$buildVersion = $TaskContext.Version
    [SemVersion.SemanticVersion]$semver = $buildVersion.SemVer2

    if( $TaskParameter[''] )
    {
        $rawVersion = $TaskParameter['']
        $semVer = $rawVersion | ConvertTo-SemVer -PropertyName 'Version'
    }
    elseif( $TaskParameter['Version'] )
    {
        $rawVersion = $TaskParameter['Version']
        $semVer = $rawVersion | ConvertTo-SemVer -PropertyName 'Version'
    }
    elseif( $Path )
    {
        $fileInfo = Get-Item -Path $Path
        if( $fileInfo.Extension -eq '.psd1' )
        {
            $rawVersion = Test-ModuleManifest -Path $Path -ErrorAction Ignore  | Select-Object -ExpandProperty 'Version'
            if( -not $rawVersion )
            {
                Stop-WhiskeyTask -TaskContext $TaskContext -Message ('Unable to read version from PowerShell module manifest ''{0}'': the manifest is invalid or doesn''t contain a ''ModuleVersion'' property.' -f $Path)
                return
            }
            Write-WhiskeyVerbose -Context $TaskContext -Message ('Read version ''{0}'' from PowerShell module manifest ''{1}''.' -f $rawVersion,$Path)
            $semver = $rawVersion | ConvertTo-SemVer -VersionSource ('from PowerShell module manifest ''{0}''' -f $Path)
        }
        elseif( $fileInfo.Name -eq 'package.json' )
        {
            try
            {
                $rawVersion = Get-Content -Path $Path -Raw | ConvertFrom-Json | Select-Object -ExpandProperty 'Version' -ErrorAction Ignore
            }
            catch
            {
                Stop-WhiskeyTask -TaskContext $TaskContext -Message ('Node package.json file ''{0}'' contains invalid JSON.' -f $Path)
                return
            }
            if( -not $rawVersion )
            {
                Stop-WhiskeyTask -TaskContext $TaskContext -Message ('Unable to read version from Node package.json ''{0}'': the ''Version'' property is missing.' -f $Path)
                return
            }
            Write-WhiskeyVerbose -Context $TaskContext -Message ('Read version ''{0}'' from Node package.json ''{1}''.' -f $rawVersion,$Path)
            $semVer = $rawVersion | ConvertTo-SemVer -VersionSource ('from Node package.json file ''{0}''' -f $Path)
        }
        elseif( $fileInfo.Extension -eq '.csproj' )
        {
            [xml]$csprojXml = $null
            try
            {
                $csprojxml = Get-Content -Path $Path -Raw
            }
            catch
            {
                Stop-WhiskeyTask -TaskContext $TaskContext -Message ('.NET .cspoj file ''{0}'' contains invalid XMl.' -f $Path)
                return
            }

            if( $csprojXml.DocumentElement.Attributes['xmlns'] )
            {
                Stop-WhiskeyTask -TaskContext $TaskContext -Message ('.NET .csproj file ''{0}'' has an "xmlns" attribute. .NET Core/Standard .csproj files should not have a default namespace anymore (see https://docs.microsoft.com/en-us/dotnet/core/migration/). Please remove the "xmlns" attribute from the root "Project" document element. If this is a .NET framework .csproj, it doesn''t support versioning. Use the Whiskey Version task''s Version property to version your assemblies.' -f $Path)
                return
            }
            $csprojVersionNode = $csprojXml.SelectSingleNode('/Project/PropertyGroup/Version')
            if( -not $csprojVersionNode )
            {
                Stop-WhiskeyTask -TaskContext $TaskContext -Message ('Element ''/Project/PropertyGroup/Version'' does not exist in .NET .csproj file ''{0}''. Please create this element and set it to the MAJOR.MINOR.PATCH version of the next version of your assembly.' -f $Path)
                return
            }
            $rawVersion = $csprojVersionNode.InnerText
            Write-WhiskeyVerbose -Context $TaskContext -Message ('Read version ''{0}'' from .NET Core .csproj ''{1}''.' -f $rawVersion,$Path)
            $semver = $rawVersion | ConvertTo-SemVer -VersionSource ('from .NET .csproj file ''{0}''' -f $Path)
        }
        elseif( $fileInfo.Name -eq 'metadata.rb' )
        {
            $metadataContent = Get-Content -Path $Path -Raw
            $metadataContent = $metadataContent.Split([Environment]::NewLine) | Where-Object { $_ -ne '' }

            $rawVersion = $null
            foreach( $line in $metadataContent )
            {
                if( $line -match '^\s*version\s+[''"](\d+\.\d+\.\d+)[''"]' )
                {
                    $rawVersion = $Matches[1]
                    break
                }
            }

            if( -not $rawVersion )
            {
                Stop-WhiskeyTask -TaskContext $TaskContext -Message ('Unable to locate property "version ''x.x.x''" in metadata.rb file "{0}"' -f $Path )
                return
            }

            Write-WhiskeyVerbose -Context $TaskContext -Message ('Read version "{0}" from metadata.rb file "{1}".' -f $rawVersion,$Path)
            $semver = $rawVersion | ConvertTo-SemVer -VersionSource ('from metadata.rb file "{0}"' -f $Path)
        }
    }

    $prerelease = $TaskParameter['Prerelease']
    if( $prerelease -isnot [String] )
    {
        $foundLabel = $false
        foreach( $object in $prerelease )
        {
            foreach( $map in $object )
            {
                if( -not ($map | Get-Member -Name 'Keys') )
                {
                    Stop-WhiskeyTask -TaskContext $TaskContext -PropertyName 'Prerelease' -Message ('Unable to find keys in ''[{1}]{0}''. It looks like you''re trying use the Prerelease property to map branches to prerelease versions. If you want a static prerelease version, the syntax should be:
 
    Build:
    - Version:
        Prerelease: {0}
 
If you want certain branches to always have certain prerelease versions, set Prerelease to a list of key/value pairs:
 
    Build:
    - Version:
        Prerelease:
        - feature/*: alpha.$(WHISKEY_BUILD_NUMBER)
        - develop: beta.$(WHISKEY_BUILD_NUMBER)
    '
 -f $map,$map.GetType().FullName)
                    return
                }
                foreach( $wildcardPattern in $map.Keys )
                {
                    if( $TaskContext.BuildMetadata.ScmBranch -like $wildcardPattern )
                    {
                        Write-WhiskeyVerbose -Context $TaskContext -Message ('{0} -like {1}' -f $TaskContext.BuildMetadata.ScmBranch,$wildcardPattern)
                        $foundLabel = $true
                        $prerelease = $map[$wildcardPattern]
                        break
                    }
                    else
                    {
                        Write-WhiskeyVerbose -Context $TaskContext -Message ('{0} -notlike {1}' -f $TaskContext.BuildMetadata.ScmBranch,$wildcardPattern)
                    }
                }

                if( $foundLabel )
                {
                    break
                }
            }

            if( $foundLabel )
            {
                break
            }
        }

        if( -not $foundLabel )
        {
            $prerelease = ''
        }
    }

    if( $prerelease )
    {
        $buildSuffix = ''
        if( $semver.Build )
        {
            $buildSuffix = '+{0}' -f $semver.Build
        }

        $rawVersion = '{0}.{1}.{2}-{3}{4}' -f $semver.Major,$semver.Minor,$semver.Patch,$prerelease,$buildSuffix
        if( -not [SemVersion.SemanticVersion]::TryParse($rawVersion,[ref]$semver) )
        {
            Stop-WhiskeyTask -TaskContext $TaskContext -PropertyName 'Prerelease' -Message ('''{0}'' is not a valid prerelease version. Only letters, numbers, hyphens, and periods are allowed. See http://semver.org for full documentation.' -f $prerelease)
            return
        }
    }

    $build = $TaskParameter['Build']
    if( $build )
    {
        $prereleaseSuffix = ''
        if( $semver.Prerelease )
        {
            $prereleaseSuffix = '-{0}' -f $semver.Prerelease
        }

        $build = $build -replace '[^A-Za-z0-9\.-]','-'
        $rawVersion = '{0}.{1}.{2}{3}+{4}' -f $semver.Major,$semver.Minor,$semver.Patch,$prereleaseSuffix,$build
        if( -not [SemVersion.SemanticVersion]::TryParse($rawVersion,[ref]$semver) )
        {
            Stop-WhiskeyTask -TaskContext $TaskContext -PropertyName 'Build' -Message ('''{0}'' is not valid build metadata. Only letters, numbers, hyphens, and periods are allowed. See http://semver.org for full documentation.' -f $build)
            return
        }
    }

    # Build metadata is only available when running under a build server.
    if( $TaskContext.ByDeveloper )
    {
        $semver = New-Object -TypeName 'SemVersion.SemanticVersion' $semver.Major,$semVer.Minor,$semVer.Patch,$semver.Prerelease
    }

    $buildVersion.SemVer2 = $semver
    Write-WhiskeyInfo -Context $TaskContext -Message ('Building version {0}' -f $semver)
    $buildVersion.Version = [Version]('{0}.{1}.{2}' -f $semver.Major,$semver.Minor,$semver.Patch)
    Write-WhiskeyVerbose -Context $TaskContext -Message ('Version {0}' -f $buildVersion.Version)
    $buildVersion.SemVer2NoBuildMetadata = New-Object 'SemVersion.SemanticVersion' ($semver.Major,$semver.Minor,$semver.Patch,$semver.Prerelease)
    Write-WhiskeyVerbose -Context $TaskContext -Message ('SemVer2NoBuildMetadata {0}' -f $buildVersion.SemVer2NoBuildMetadata)
    $semver1Prerelease = $semver.Prerelease
    if( $semver1Prerelease )
    {
        $semver1Prerelease = $semver1Prerelease -replace '[^A-Za-z0-9]',''
    }
    $buildVersion.SemVer1 = New-Object 'SemVersion.SemanticVersion' ($semver.Major,$semver.Minor,$semver.Patch,$semver1Prerelease)
    Write-WhiskeyVerbose -Context $TaskContext -Message ('SemVer1 {0}' -f $buildVersion.SemVer1)
}



function New-WhiskeyZipArchive
{
    [Whiskey.Task('Zip')]
    [Whiskey.RequiresPowerShellModule('Zip',Version='0.3.*',VersionParameterName='ZipVersion')]
    [CmdletBinding()]
    param(
        [Parameter(Mandatory)]
        [Whiskey.Context]$TaskContext,

        [Parameter(Mandatory)]
        [hashtable]$TaskParameter,

        [Whiskey.Tasks.ValidatePath()]
        [String]$SourceRoot,

        [Whiskey.Tasks.ValidatePath(Mandatory,AllowNonexistent)]
        [String]$ArchivePath
    )

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

    function Write-CompressionInfo
    {
        param(
            [Parameter(Mandatory)]
            [ValidateSet('file','directory','filtered directory')]
            [String]$What,
            [String]$Source,
            [String]$Destination
        )

        if( $Destination )
        {
            $Destination = ' -> {0}' -f ($Destination -replace '\\','/')
        }

        if( [IO.Path]::DirectorySeparatorChar -eq [IO.Path]::AltDirectorySeparatorChar )
        {
            $Source = $Source -replace '\\','/'
        }
        Write-WhiskeyInfo -Context $TaskContext -Message (' compressing {0,-18} {1}{2}' -f $What,$Source,$Destination)
    }

    $behaviorParams = @{ }
    if( $TaskParameter['CompressionLevel'] )
    {
        [IO.Compression.CompressionLevel]$compressionLevel = [IO.Compression.CompressionLevel]::NoCompression
        if( -not [Enum]::TryParse($TaskParameter['CompressionLevel'], [ref]$compressionLevel) )
        {
            Stop-WhiskeyTask -TaskContext $TaskContext -PropertyName 'CompressionLevel' -Message ('Value "{0}" is an invalid compression level. Must be one of: {1}.' -f $TaskParameter['CompressionLevel'],([Enum]::GetValues([IO.Compression.CompressionLevel]) -join ', '))
            return
        }
        $behaviorParams['CompressionLevel'] = $compressionLevel
    }

    if( $TaskParameter['EntryNameEncoding'] )
    {
        $entryNameEncoding = $TaskParameter['EntryNameEncoding']
        [int]$codePage = 0
        if( [int]::TryParse($entryNameEncoding,[ref]$codePage) )
        {
            try
            {
                $entryNameEncoding = [Text.Encoding]::GetEncoding($codePage)
            }
            catch
            {
                Write-Error -ErrorRecord $_
                Stop-WhiskeyTask -TaskContext $TaskContext -Message ('EntryNameEncoding: An encoding with code page "{0}" does not exist. To get a list of encodings, run `[Text.Encoding]::GetEncodings()` or see https://docs.microsoft.com/en-us/dotnet/api/system.text.encoding . Use the encoding''s `CodePage` or `WebName` property as the value of this property.' -f $entryNameEncoding)
                return
            }
        }
        else
        {
            try
            {
                $entryNameEncoding = [Text.Encoding]::GetEncoding($entryNameEncoding)
            }
            catch
            {
                Write-Error -ErrorRecord $_
                Stop-WhiskeyTask -TaskContext $TaskContext -Message ('EntryNameEncoding: An encoding named "{0}" does not exist. To get a list of encodings, run `[Text.Encoding]::GetEncodings()` or see https://docs.microsoft.com/en-us/dotnet/api/system.text.encoding . Use the encoding''s "CodePage" or "WebName" property as the value of this property.' -f $entryNameEncoding)
                return
            }
        }
        $behaviorParams['EntryNameEncoding'] = $entryNameEncoding
    }

    Write-WhiskeyInfo -Context $TaskContext -Message ('Creating ZIP archive "{0}".' -f $ArchivePath)
    $archiveDirectory = $ArchivePath | Split-Path -Parent
    if( $archiveDirectory -and -not (Test-Path -Path $archiveDirectory -PathType Container) )
    {
        New-Item -Path $archiveDirectory -ItemType 'Directory' -Force | Out-Null
    }

    if( -not $TaskParameter['Path'] )
    {
        Stop-WhiskeyTask -TaskContext $TaskContext -Message ('Property "Path" is required. It must be a list of paths, relative to your whiskey.yml file, of files or directories to include in the ZIP archive.')
        return
    }

    New-ZipArchive -Path $ArchivePath @behaviorParams -Force

    if( $SourceRoot )
    {
        Write-WhiskeyWarning -Context $TaskContext -Message ('The "SourceRoot" property is obsolete. Please use the "WorkingDirectory" property instead.')
        $ArchivePath = Resolve-Path -Path $ArchivePath | Select-Object -ExpandProperty 'ProviderPath'
        Push-Location -Path $SourceRoot
    }

    try
    {
        foreach( $item in $TaskParameter['Path'] )
        {
            $override = $False
            if( (Get-Member -InputObject $item -Name 'Keys') )
            {
                $sourcePath = $null
                $override = $True
                foreach( $key in $item.Keys )
                {
                    $destinationItemName = $item[$key]
                    $sourcePath = $key
                }
            }
            else
            {
                $sourcePath = $item
            }

            $sourcePaths = 
                $sourcePath | 
                Resolve-WhiskeyTaskPath -TaskContext $TaskContext -PropertyName 'Path'
            if( -not $sourcePaths )
            {
                return
            }

            $basePath = (Get-Location).Path
            foreach( $sourcePath in $sourcePaths )
            {
                $addParams = @{ BasePath = $basePath }
                $destinationParam = @{ }
                if( $override )
                {
                    $addParams = @{ EntryName = $destinationItemName }
                    $destinationParam['Destination'] = $destinationItemName
                }

                if( (Test-Path -Path $sourcePath -PathType Leaf) )
                {
                    Write-CompressionInfo -What 'file' -Source $sourcePath @destinationParam
                    Add-ZipArchiveEntry -ZipArchivePath $ArchivePath -InputObject $sourcePath @addParams @behaviorParams
                    continue
                }

                function Find-Item
                {
                    param(
                        [Parameter(Mandatory)]
                        $Path
                    )

                    if( (Test-Path -Path $Path -PathType Leaf) )
                    {
                        return Get-Item -Path $Path
                    }

                    $Path = Join-Path -Path $Path -ChildPath '*'
                    & {
                            Get-ChildItem -Path $Path -Include $TaskParameter['Include'] -Exclude $TaskParameter['Exclude'] -File
                            Get-Item -Path $Path -Exclude $TaskParameter['Exclude'] |
                                Where-Object { $_.PSIsContainer }
                        }  |
                        ForEach-Object {
                            if( $_.PSIsContainer )
                            {
                                Find-Item -Path $_.FullName
                            }
                            else
                            {
                                $_
                            }
                        }
                }

                if( $override )
                {
                    $overrideBasePath = 
                        Resolve-Path -Path $sourcePath | 
                        Select-Object -ExpandProperty 'ProviderPath'

                    if( (Test-Path -Path $overrideBasePath -PathType Leaf) )
                    {
                        $overrideBasePath = Split-Path -Parent -Path $overrideBasePath
                    }

                    $addParams['BasePath'] = $overrideBasePath
                    $addParams['EntryParentPath'] = $destinationItemName
                    $addParams.Remove('EntryName')
                    $destinationParam['Destination'] = $destinationItemName
                }

                $typeDesc = 'directory'
                if( $TaskParameter['Include'] -or $TaskParameter['Exclude'] )
                {
                    $typeDesc = 'filtered directory'
                }

                Write-CompressionInfo -What $typeDesc -Source $sourcePath @destinationParam
                Find-Item -Path $sourcePath |
                    Add-ZipArchiveEntry -ZipArchivePath $ArchivePath @addParams @behaviorParams
            }
        }
    }
    finally
    {
        if( $SourceRoot )
        {
            Pop-Location
        }
    }
}