Whiskey.psm1


using module '.\Whiskey.Types.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)
}

[Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseDeclaredVarsMoreThanAssignments', '')]
$psModulesDirectoryName = 'PSModules'

$whiskeyScriptRoot = $PSScriptRoot
$whiskeyBinPath = Join-Path -Path $whiskeyScriptRoot -ChildPath 'bin' -Resolve

[Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseDeclaredVarsMoreThanAssignments', '')]
$buildStartedAt = [DateTime]::MinValue

$PSModuleAutoLoadingPreference = 'None'

# The indentation to use when writing task messages.
[Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseDeclaredVarsMoreThanAssignments', '')]
$taskWriteIndent = ' '

[Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseDeclaredVarsMoreThanAssignments', '')]
$indentLevel = 0

# PowerShell 5.1 doesn't have these variables so create them if they don't exist.
if( -not (Get-Variable -Name 'IsLinux' -ErrorAction Ignore) )
{
    # We only set these variables on platforms where they're not defined.
    [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSAvoidAssignmentToAutomaticVariable', '')]
    $IsLinux = $false

    [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSAvoidAssignmentToAutomaticVariable', '')]
    $IsMacOS = $false

    [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSAvoidAssignmentToAutomaticVariable', '')]
    $IsWindows = $true
}

if( -not (Test-Path -Path 'env:WHISKEY_DISABLE_ERROR_FORMAT') )
{
    Write-Timing 'Updating formats.'
    $prependFormats = @(
                            (Join-Path -Path $PSScriptRoot -ChildPath 'Formats\System.Management.Automation.ErrorRecord.ps1xml'),
                            (Join-Path -Path $PSScriptRoot -ChildPath 'Formats\System.Exception.ps1xml')
                        )
    Update-FormatData -PrependPath $prependFormats
}

Write-Timing 'Loading assemblies.'

# .NET Framework 4.6.2 won't load netstandard2.0 assemblies.
$frameworkMoniker = 'netstandard2.0'
$net4Key = Get-ItemProperty 'HKLM:\SOFTWARE\Microsoft\NET Framework Setup\NDP\v4\Full' -ErrorAction Ignore
$net462Release = 460798
if( $net4Key -and $net4Key.Release -le $net462Release )
{
    $frameworkMoniker = 'net452'
}

foreach( $assemblyBaseName in @('SemanticVersion', 'YamlDotNet', 'Whiskey') )
{
    $assemblyPath = Join-Path -Path $whiskeyBinPath -ChildPath $frameworkMoniker
    $assemblyPath = Join-Path -Path $assemblyPath -ChildPath "$($assemblyBaseName).dll"
    $addTypeErrors = @()
    Add-Type -Path $assemblyPath -ErrorVariable 'addTypeErrors' -ErrorAction Continue
    if( $addTypeErrors )
    {
        $ex = $addTypeErrors | Select-Object -First 1
        while( $ex.InnerException )
        {
            $ex = $ex.InnerException
        }

        if( $ex | Get-Member 'LoaderExceptions' )
        {
            $ex = $ex.LoaderExceptions | Select-Object -First 1
        }

        Write-Error -Message "Exception loading assembly ""$($assemblyPath)"": $($ex)" -ErrorAction Stop
    }
}
Add-Type -AssemblyName 'System.IO.Compression.FileSystem'

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 assembly loaded.'
$context = New-WhiskeyObject -TypeName 'Whiskey.Context'
Assert-Member -Object $context `
              -Property @( 'TaskPaths', 'MSBuildConfiguration', 'ApiKeys', 'BuildStopwatch', 'TaskStopwatch' )

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

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' )

$buildInfo = New-WhiskeyObject -TypeName 'Whiskey.BuildInfo'
Assert-Member -Object $buildInfo -Property @( 'ScmSourceBranch' )

New-WhiskeyObject -TypeName 'Whiskey.RequiresNuGetPackageAttribute' -ArgumentList 'NuGet.CommandLine' | Out-Null

[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 ('Creating internal module variables.')

$dotNetExeName = 'dotnet'
$nodeExeName = 'node'
[Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseDeclaredVarsMoreThanAssignments', '')]
$nodeDirName = 'bin'
if( $IsWindows )
{
    $dotNetExeName = '{0}.exe' -f $dotNetExeName
    $nodeExeName = '{0}.exe' -f $nodeExeName
    $nodeDirName = ''
}

[Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseDeclaredVarsMoreThanAssignments', '')]
$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 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 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

        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()
            }
        }

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

        if( [Version]::TryParse($InputObject, [ref]$asVersion) )
        {
            $major,$minor,$patch =
                @($asVersion.Major, $asVersion.Minor, $asVersion.Build) |
                ForEach-Object { if( $_ -eq -1 ) { return 0 } return $_ }
            return [SemVersion.SemanticVersion]::New($major, $minor, $patch)
        }

        [int] $asInt = 0
        if( [int]::TryParse($InputObject, [ref]$asInt) )
        {
            return [SemVersion.SemanticVersion]::New($asInt, 0, 0)
        }

        $original = $PSBoundParameters['InputObject']
        $msg = "Unable to convert ""[$($original.GetType().FullName)] $($original)"" to a semantic version."
        Write-WhiskeyError -Message $msg
    }
}




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)
    {
        # Build:
        # - TaskName
        # - command
        if( $InputObject -is [String] )
        {
            $InputObject
            @{ }
            return
        }
        elseif ($InputObject | Get-Member -Name 'Keys')
        {
            # Build:
            # - TaskName:
            # Property1: Value1
            # Property2: Value2
            if ($InputObject.Count -eq 1)
            {
                $taskName = $InputObject.Keys | Select-Object -First 1
                $parameter = $InputObject[$taskName]
                if( -not $parameter )
                {
                    $parameter = @{ }
                }
                elseif( -not ($parameter | Get-Member -Name 'Keys') )
                {
                    $parameter = @{ '' = $parameter }
                }

            }
            # Build:
            # - Exec: Command
            # Property1: Value1
            # Property2: Value2
            # - PowerShell: ScriptBlock
            # Property3: Value3
            else
            {
                $taskName = $InputObject.Keys | Select-Object -First 1
                $parameter = @{ '' = $InputObject[$taskName] }
                $InputObject.Keys | Select-Object -Skip 1 | ForEach-Object { $parameter[$_] = $InputObject[$_] }
            }

            $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 Find-WhiskeyPowerShellModule
{
    <#
    .SYNOPSIS
    Searches for a PowerShell module using PowerShellGet to ensure it exists and returns the resulting object from
    PowerShellGet.
 
    .DESCRIPTION
    The `Find-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
    Find-WhiskeyPowerShellModule -Name 'Pester'
 
    Demonstrates getting the module info on the latest version of the Pester module.
 
    .EXAMPLE
    Find-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

    Write-WhiskeyDebug '\Find-WhiskeyPowerShellModule\' -Indent

    function Import
    {
        [CmdletBinding()]
        param(
            [Parameter(Mandatory)]
            [String] $Name,

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

            [Parameter(Mandatory)]
            [Version] $MaximumVersion
        )

        Write-WhiskeyDebug "Importing module $($Name) for version $($MinimumVersion) - $($MaximumVersion)."
        $module = Get-Module -Name $Name
        if( $module )
        {
            if( $module.Version -ge $MinimumVersion -and $module.Version -le $MaximumVersion )
            {
                Write-WhiskeyDebug "Module $($Name) $($module.Version) already imported."
                return
            }

            Write-WhiskeyDebug "Removing module $($Name) $($module.Version)."
            $module | Remove-Module -Force
        }

        $importFailed = $true
        try
        {
            Import-Module -Name $Name `
                          -MinimumVersion $MinimumVersion `
                          -MaximumVersion $MaximumVersion `
                          -Global
            Write-WhiskeyDebug "Imported module $($Name) $((Get-Module -Name $Name).Version)."
            $importFailed = $false
        }
        finally
        {
            if( $importFailed )
            {
                Write-WhiskeyDebug "Exception importing $($Name) module: $($Global:Error[0])"

                Write-WhiskeyDebug '\PSModulePath\' -Indent
                $env:PSModulePath -split ([IO.Path]::PathSeparator) | Write-WhiskeyDebug
                Write-WhiskeyDebug '/PSModulePath/' -Outdent

                Write-WhiskeyDebug '\Get-Module\' -Indent
                Get-Module | Out-String | Write-WhiskeyDebug
                Write-WhiskeyDebug '/Get-Module/' -Outdent

                Write-WhiskeyDebug '\Get-Module -ListAvailable\' -Indent
                Get-Module -ListAvailable | Out-String | Write-WhiskeyDebug
                Write-WhiskeyDebug '/Get-Module -ListAvailable/' -Outdent
            }
        }
    }

    try
    {
        $pwshGetModule = Get-Module -Name 'PowerShellGet' -ErrorAction Ignore
        if( $pwshGetModule -and $pwshGetModule.Version -lt [Version]'2.0.10' )
        {
            $msg = "The currently installed version of PowerShellGet $($pwshGetModule.Version) is less than the " +
                   'minimum recommended version 2.0.10. If you run into issues with resolving PowerShell modules, ' +
                   'please update PowerShellGet to the latest version using the following command: ' +
                   '"Install-Module -Name ''PowerShellGet'' -Force -AllowClobber"'
            Write-WhiskeyWarning -Message $msg
        }
        Register-WhiskeyPSModulePath -PSModulesRoot $BuildRoot

        $allowPrereleaseArg = Get-AllowPrereleaseArg -CommandName 'Find-Module' -AllowPrerelease:$AllowPrerelease

        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)
                }
            }

            Write-WhiskeyDebug -Message "Searching for module $($Name) $($Version)."
            $module =
                Find-Module -Name $Name -AllVersions @allowPrereleaseArg |
                Where-Object { $_.Version.ToString() -like $Version } |
                Sort-Object -Property 'Version' -Descending
        }
        else
        {
            Write-WhiskeyDebug -Message "Searching for latest version of module $($Name)."
            $atVersionString = ''
            $module = Find-Module -Name $Name @allowPrereleaseArg -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 ($module | Format-List | Out-String)
        Write-WhiskeyDebug -Message ('{0} {1} {2}' -f (' ' * $Name.Length),(' ' * $Version.Length),$module.Version)
        return $module
    }
    finally
    {
        Write-WhiskeyDebug '/Find-WhiskeyPowerShellModule/' -Outdent
    }
}



function Format-Command
{
    [CmdletBinding()]
    param(
        [Parameter(Mandatory, ValueFromPipeline)]
        [AllowNull()]
        [AllowEmptyString()]
        [String[]] $ArgumentList
    )

    begin
    {
        Set-StrictMode -version 'latest'
        Use-CallerPreference -Cmdlet $PSCmdlet -SessionState $ExecutionContext.SessionState

        $cmdArgs = [Collections.Generic.List[String]]::New()
    }

    process
    {
        foreach( $cmdArg in $ArgumentList )
        {
            if( -not $cmdArg )
            {
                continue
            }

            if( $cmdArg.Contains(' ') -or $cmdArg.Contains(';') )
            {
                if( $cmdArg.Contains('"') )
                {
                    $cmdArg = $cmdArg.Replace('"', '""')
                }
    
                $cmdArg = """$($cmdArg)"""
            }

            $cmdArgs.Add($cmdArg)
        }
    }

    end
    {
        return $cmdArgs -join ' '
    }
}


function Format-Stopwatch
{
    [CmdletBinding()]
    param(
        [Parameter(Mandatory,ValueFromPipeline)]
        [Diagnostics.Stopwatch]$Stopwatch
    )

    process
    {
        $duration = $Stopwatch.Elapsed
        "{0,2}m{1:00}s" -f $duration.TotalMinutes.ToUInt32($null), $duration.Seconds
    }
}



function Get-AllowPrereleaseArg
{
    <#
    .SYNOPSIS
    Return a hashtable that can be splatted for a command that can or can't have an AllowPrerelease parameter.
 
    .DESCRIPTION
    Whiskey has to support older versions of PowerShellGet and PackageManagement. Some of these older versions don't
    have support for the `AllowPrerelease` switch and some of them have switches that function to allow prereleases, but
    the parameter name is different. This function determines if the function supports `AllowPrerelease` or not, and if
    it does *and* this function's `AllowPrerelease` switch is set, returns a hashtable with an `AllowPrerelease` key
    (or whatever the parameter name is for that function) whose value is set to `$true`. Otherwise, it returns an empty
    hashtable.
    #>

    [CmdletBinding()]
    param(
        [Parameter(Mandatory)]
        [String] $CommandName,

        [switch] $AllowPrerelease
    )

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

    if( -not $AllowPrerelease )
    {
        return @{}
    }

    $cmd = Get-Command -Name $CommandName -ParameterName 'AllowPrerelease*' -ErrorAction Ignore
    if( $cmd )
    {
        $allowPrereleaseArg = @{}
        $cmd.Parameters.Keys |
            Where-Object { $_ -like 'AllowPrerelease*' } |
            ForEach-Object { $allowPrereleaseArg[$cmd.Parameters[$_].Name] = $true }
        return $allowPrereleaseArg
    }

    return @{}
}

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 )
        {
            $msg = "$($indent)Skipping registry key ""$($Key | Convert-Path)"": key value ""MSBuildToolsPath"" " +
                   'doesn''t exist.'
            Write-WhiskeyVerbose -Message $msg
            return ''
        }

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

        $msg = "$($indent)Skipping registry key ""$($Key | Convert-Path)"": key value ""MSBuildToolsPath"" " +
                "is path ""$($path)"", which doesn't exist."
        Write-WhiskeyVerbose -Message $msg
        return ''
    }

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

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

    Write-WhiskeyVerbose '[Get-MSBuild]'
    $indent = ' '

    $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) )
        {
            $msg = "$($preindentfix)Skipping registry key ""$($key | Convert-Path)"": name isn't a version number."
            Write-WhiskeyVerbose -Message $msg
            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 = ''
            }
        }

        Write-WhiskeyVerbose ("$($indent)Found MSBuild $($name) at ""$($msbuildPath)"".")
        [pscustomobject]@{
            Name = $name;
            Version = [Version]$name;
            Path = $msbuildPath;
            Path32 = $msbuildPath32;
            PathArm64 = '';
        }
    }

    foreach( $instance in (Get-VSSetupInstance) )
    {
        $msbuildRoot = Join-Path -Path $instance.InstallationPath -ChildPath 'MSBuild'
        if( -not (Test-Path -Path $msbuildRoot -PathType Container) )
        {
            $msg = "$($indent)Skipping $($instance.DisplayName): its MSBuild directory ""$($msbuildRoot)"" doesn''t " +
                   'exist.'
            Write-WhiskeyVerbose -Message $msg
            continue
        }

        $path32 = Join-Path -Path $msbuildRoot -ChildPath '*\Bin\MSBuild.exe' -Resolve -ErrorAction Ignore
        if( -not $path32 )
        {
            $msg = "$($indent)Skipping $($instance.DisplayName): " +
                   """$(Join-Path -Path $msbuildRoot -ChildPath '*\Bin\MSBuild.exe')"" doesn't exist."
                   """$($msbuildRoot)"" doesn''t exist."
            Write-WhiskeyVerbose -Message $msg
            continue
        }

        $msbuildRoot = $path32 | Split-Path | Split-Path
        $path = Join-Path -Path $msbuildRoot -ChildPath 'Bin\amd64\MSBuild.exe' -Resolve -ErrorAction Ignore
        $pathArm64 =
            Join-Path -Path $msbuildRoot -ChildPath 'Bin\arm64\MSBuild.exe' -Resolve -ErrorAction Ignore

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

        if( -not $path )
        {
            $msg = "$($indent)Skipping $($instance.DisplayName) $($instance.InstallationVersion): " +
                   """$(Join-Path -Path $msbuildRoot -ChildPath 'Bin\amd64\MSBuild.exe')"" doesn't exist."
                   """$($msbuildRoot)"" doesn''t exist."
            Write-WhiskeyVerbose -Message $msg
            continue
        }

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

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

        Write-WhiskeyVerbose ("$($indent)Found MSBuild $($majorMinor) at ""$($path)"".")
        [pscustomobject]@{
            Name =  $majorMinor;
            Version = [Version]$majorMinor;
            Path = $path;
            Path32 = $path32;
            PathArm64 = $pathArm64;
        } | Write-Output
    }
    Write-WhiskeyVerbose '[Get-MSBuild]'
}


function Get-TaskArgument
{
    [CmdletBinding()]
    param(
        # The task who's arguments to get.
        [Parameter(Mandatory)]
        [Whiskey.TaskAttribute] $Task,

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

        # 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.
    $taskArgs = @{ }

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

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

        $value = $null

        if ($Property.ContainsKey($propertyName))
        {
            $value = $Property[$propertyName]
        }
        else
        {
            if ($propertyName -eq $Task.DefaultParameterName -and $Property.ContainsKey(''))
            {
                $value = $Property['']
            }
            else
            {
                foreach ($aliasName in $cmdParameter.Aliases)
                {
                    $value = $Property[$aliasName]
                    if ($Property.ContainsKey($aliasName))
                    {
                        $msg = "Property ""${aliasName}"" is deprecated. Rename to ""${propertyName}"" instead."
                        Write-WhiskeyWarning -Context $Context -Message $msg
                        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 $Property @params
        }

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

        $taskArgs[$propertyName] = $value
    }

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

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

    return $taskArgs
}


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'
    Use-CallerPreference -Cmdlet $PSCmdlet -SessionState $ExecutionContext.SessionState

    function Get-EnvironmentVariable
    {
        param(
            $Name
        )

        Get-Item -Path ('env:{0}' -f $Name) -ErrorAction Ignore | 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

        if( (Test-Path -Path 'env:CHANGE_BRANCH') )
        {
            $buildInfo.IsPullRequest = $true
            $buildInfo.ScmSourceBranch = Get-EnvironmentVariable 'CHANGE_BRANCH'
            $buildInfo.ScmSourceBranch = $buildInfo.ScmSourceBranch -replace '^origin/',''
            $buildInfo.ScmSourceCommitID = Get-EnvironmentVariable 'GIT_COMMIT'
        }
    }
    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
            {
                $msg = "Unsupported AppVeyor source control provider ""$($_)"". 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.'
                Write-WhiskeyError -Message $msg
            }
        }
        $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'
        if( (Test-Path -Path 'env:APPVEYOR_PULL_REQUEST_HEAD_REPO_BRANCH') )
        {
            $buildInfo.IsPullRequest = $true
            $buildInfo.ScmSourceBranch = Get-EnvironmentVariable 'APPVEYOR_PULL_REQUEST_HEAD_REPO_BRANCH'
            $buildInfo.ScmSourceCommitID = Get-EnvironmentVariable 'APPVEYOR_PULL_REQUEST_HEAD_COMMIT'
        }
        $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-WhiskeyPSModule
{
    <#
    .SYNOPSIS
    Get's module information, with priority given to modules saved in Whiskey's PSModules directory.
 
    .DESCRIPTION
    The `Get-WhiskeyPSModule` function return a PowerShell module information object for a module. It returns the same
    object returned by PowerShell's `Get-Module` cmdlet. Pass the name of the module to the `Name` parameter. Pass the
    path to the directory that contains Whiskey's "PSModules" directory (this is usually the build root). The function
    uses `Get-Module` to find the requested module and return its metadata information. The function validates the
    module's manifest to ensure the module could be imported. Modules that would fail to be imported are not returned.
 
    If multiple versions of a module exist, the latest version is returned. If you want a specific version, pass the
    version to the `Version` parameter. The `Get-WhiskeyPSModule` will return the latest version that matches
    the version. Wildcards are supported.
 
    If no modules exist, nothing is returned and no errors are written.
 
    This function adds the PSModules directory to the `PSModulePath` environment variable. If this path is in the build
    root, it will be removed when a build is done.
 
    This funcation adds a `ManifestPath` property to the return object that is the path to the module's .psd1 file.
 
    .LINK
    Find-WhiskeyPSModule
 
    .EXAMPLE
    Get-WhiskeyPSModule -Name Pester -PSModulesRoot $Context.BuildRoot
 
    Demonstrates how to call `Get-WhiskeyPSModule` to get module information. In this case, the function will return the
    latest version of the `Pester` module, and will include the PSModules path in the build root.
 
    .EXAMPLE
    Get-WhiskeyPSModule -Name Pester -PSModulesRoot $Context.BuildRoot -Version '4.*'
 
    Demonstrates how to call `Get-WhiskeyPSModule` to get module information for a specific version of a module. In this
    example, the function will return the latest 4.x version of the `Pester` module, and will include the PSModules path
    in the build root.
    #>

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

        [String]$Version,

        [Parameter(Mandatory)]
        $PSModulesRoot
    )

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

    Write-WhiskeyDebug '\Get-WhiskeyPSModule\' -Indent

    Register-WhiskeyPSModulePath -PSModulesRoot $PSModulesRoot

    try
    {
        Write-WhiskeyDebug '\env:PSModulePath\' -Indent
        $env:PSModulePath -split [IO.Path]::PathSeparator | Write-WhiskeyDebug
        Write-WhiskeyDebug '/env:PSModulePath/' -Outdent

        Write-WhiskeyDebug "\Get-Module -Name ''$($Name)'' -ListAvailable\" -Indent
        $modules = Get-Module -Name $Name -ListAvailable -ErrorAction Ignore
        $modules | Out-String | Write-WhiskeyDebug
        Write-WhiskeyDebug "/Get-Module -Name ''$($Name)'' -ListAvailable/" -Outdent


        $modules |
            Where-Object {
                if( -not $Version )
                {
                    return $true
                }

                $moduleInfo = $_

                $moduleVersion = $moduleInfo.Version
                $prerelease = ''
                if( ($moduleInfo | Get-Member 'PreRelease') )
                {
                    $prerelease = $moduleInfo.PreRelease
                }
                else
                {
                    $privateData = $moduleInfo.PrivateData
                    if( $privateData )
                    {
                        $psdata = $privateData['PSData']
                        if( $psdata )
                        {
                            $prerelease = $psdata['Prerelease']
                        }
                    }
                }

                if( $prerelease )
                {
                    $moduleVersion = "$($moduleVersion)-$($prerelease)"
                }

                $msg = "Checking if $($moduleInfo.Name) module's version $($moduleVersion) is like ""$($Version)""."
                Write-WhiskeyDebug -Message $msg
                return $moduleVersion -like $Version
            } |
            Add-Member -Name 'ManifestPath' `
                    -MemberType ScriptProperty `
                    -Value { return Join-Path -Path ($_.Path | Split-Path) -ChildPath "$($_.Name).psd1" } `
                    -Force `
                    -PassThru |
            Where-Object {
                $module = $_

                # Make sure there's a valid module there.
                $numErrorsBefore = $Global:Error.Count
                $manifest = $null
                $debugMsg = "Module $($module.Name) $($module.Version) ($($module.ManifestPath)) has "
                try
                {
                    $manifest = Test-ModuleManifest -Path $module.ManifestPath -ErrorAction Ignore -WarningAction Ignore
                    Write-WhiskeyDebug -Message ("$($debugMsg)a valid manifest.")
                }
                catch
                {
                    Write-WhiskeyDebug -Message ("$($debugMsg)an invalid manifest: $($_).")
                    $numErrorsToRemove = $Global:Error.Count - $numErrorsBefore
                    for( $idx = 0; $idx -lt $numErrorsToRemove; ++$idx )
                    {
                        $Global:Error.RemoveAt(0)
                    }
                }

                if( -not $manifest )
                {
                    return $false
                }

                return $true
            } |
            # Get the highest versioned module in the order in which they appear in the PSModulePath environment variable.
            Group-Object -Property 'Version' |
            Sort-Object -Property { [Version]$_.Name } -Descending |
            Select-Object -First 1 |
            Select-Object -ExpandProperty 'Group' |
            Select-Object -First 1
    }
    finally
    {
        Write-WhiskeyDebug '/Get-WhiskeyPSModule/' -Outdent
    }
}


function Get-WhiskeyPSModulePath
{
    [CmdletBinding()]
    param(
        [Parameter(Mandatory)]
        [String]$PSModulesRoot,

        [switch]$Create
    )

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

    Write-WhiskeyDebug '\Get-WhiskeyPSModulePath\' -Indent

    try
    {
        $path = Join-Path -Path $PSModulesRoot -ChildPath 'PSModules' | Write-Output

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

        return $path
    }
    finally
    {
        Write-WhiskeyDebug '/Get-WhiskeyPSModulePath/' -Outdent
    }
}


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-ChildItem -Path 'Function:\'))
    {
        $functionInfo.ScriptBlock.Attributes |
            Where-Object { $_ -is [Whiskey.TaskAttribute] } |
            ForEach-Object {
                $_.CommandName = $functionInfo.Name
                $_
            } |
            Where-Object {
                if( $Force )
                {
                    $true
                }
                return -not $_.Obsolete
            }
    }
}


function Get-WhiskeyTempPath
{
    [CmdletBinding()]
    param(
        [Object] $Context,

        [String] $Name
    )

    $tempPath = [IO.Path]::GetTempPath()
    if (-not $Context)
    {
        $Context = Get-WhiskeyContext
    }

    if ($Context)
    {
        $tempPath = $Context.Temp.FullName
    }

    if ($Name)
    {
        $tempPath = Join-Path -Path $tempPath -ChildPath $Name
    }

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

    return $tempPath
}



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 the
    module is currently loaded but is at a different version than requested, that module is removed first, and the
    correct version is imported.
 
    If the module isn't installed (or if the requested version of the module isn't installed), you'll get an error. Use
    the `Install-WhiskeyPowerShellModule` to install the module. If a task needs a module, use the
    `[Whiskey.RequiresPowerShellModule]` task attribute.
 
    .EXAMPLE
    Import-WhiskeyPowerShellModule -Name 'Zip' -PSModulesRoot $buildRoot
 
    Demonstrates how to use this method to import a module that is installed in a global module location or in the
    current build's PSModules directory (usually in the build root directory). The latest/newest installed version is
    imported.
 
    .EXAMPLE
    Import-WhiskeyPowerShellModule -Name 'Zip' -Version '0.2.0' -PSModulesRoot $buildRoot
 
    Demonstrates how to use this method to import a specific version of a module that is installed in a global module
    location or in the current build's PSModules directory (usually in the build root directory). The latest/newest
    installed version is imported.
    #>

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

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

        # The path to the build root, where the PSModules directory can be found. Must be included to import a locally installed module.
        [Parameter(Mandatory)]
        [String] $PSModulesRoot
    )

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

    $numErrorsBefore = $Global:Error.Count
    $foundModule = $false
    try
    {
        $foundModule = & {
            $VerbosePreference = 'SilentlyContinue'
            $module = Get-WhiskeyPSModule -Name $Name -Version $Version -PSModulesRoot $PSModulesRoot
            if( -not $module )
            {
                return $false
            }

            $loadedModules = Get-Module -Name $Name
            $loadedModules |
                Where-Object 'Version' -ne $module.Version |
                Remove-Module -Verbose:$false -WhatIf:$false -Force

            if( ($loadedModules | Where-Object 'Version' -eq $module.Version) )
            {
                Write-WhiskeyDebug -Message ("Module $($Name) $($module.Version) already loaded.")
                return $true
            }

            $module | Import-Module -Global -ErrorAction Stop -Verbose:$false -WarningAction 'Ignore'
            return $true
        } 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.
        $numToRemove = $Global:Error.Count - $numErrorsBefore
        for( $idx = 0; $idx -lt $numToRemove; $idx++ )
        {
            $Global:Error.RemoveAt(0)
        }
    }

    if( -not $foundModule )
    {
        $versionDesc = ''
        if( $Version )
        {
            $versionDesc = " $($Version)"
        }
        $msg = "Unable to import module ""$($Name)""$($versionDesc): that module isn't installed. To install a module, " +
               'use the "GetPowerShellModule" task.'
        Write-WhiskeyError -Message $msg
    }
}



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 )
    }
    catch
    {
        if( $PSCmdlet.ParameterSetName -eq 'FromFile' )
        {
            Write-WhiskeyError "Whiskey configuration file ""$($Path)"" cannot be parsed: $($_)." -ErrorAction Stop
        }
        else
        {
            Write-WhiskeyError "YAML cannot be parsed: $($_)$([Environment]::NewLine * 2)$($Yaml)" -ErrorAction Stop
        }
    }
    finally
    {
        $reader.Close()
    }
    if( -not $config )
    {
        $config = @{} 
    }

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

    return $config
}



function Install-WhiskeyDotNetSdk
{
    <#
    .SYNOPSIS
    Installs the .NET SDK.
 
    .DESCRIPTION
    The `Install-WhiskeyDotNetSdk` function installs the .NET SDK. It uses the `dotnet-install.ps1` and
    `dotnet-install.sh` scripts—provided and supported by Microsoft—on Windows and Linux/macOS, respectively. Any output
    from the install scripts is written instead to PowerShell's information stream. The function returns the path to the
    dotnet command.
 
    If a `dotnet` tool is already installed and availble, `Install-WhiskeyDotNetSdk` inspects the contents of its
    installation folder to determine if the version of the SDK is installed globally (it looks for a "sdk\$VERSION"
    directory where the dotnet command is. If the SDK is installed, the path to the global dotnet command is returned.
 
    .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'.
    #>

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

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

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

    $dotnetPaths = Get-Command -Name 'dotnet' -All -ErrorAction Ignore | Select-Object -ExpandProperty 'Source'
    if( $dotnetPaths )
    {
        $msg = "Checking for installed .NET SDK $($Version)."
        Write-WhiskeyVerbose -Message $msg
        foreach( $dotnetPath in $dotnetPaths )
        {
            $sdkPath = Join-Path -Path ($dotnetPath | Split-Path -Parent) -ChildPath ('sdk\{0}' -f $Version)

            if (Test-Path -Path $sdkPath -PathType Container)
            {
                $msg = "Found .NET SDK $($Version) at ""$($sdkPath)""."
                Write-WhiskeyVerbose -Message $msg
                return $dotnetPath
            }
        }
    }

    $InstallRoot = $InstallRoot | Resolve-WhiskeyRelativePath
    $msg = "Installing .NET SDK $($Version) to ""$($InstallRoot)""."
    Write-WhiskeyInfo -Message $msg

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

    $verboseParam = @{}
    [String[]] $displayArgs = & {
        if( -not $IsWindows )
        {
            ''
        }
        '-InstallDir'
        $InstallRoot
        '-Version'
        $Version
        if( $IsWindows )
        {
            '-NoPath'
            if( $VerbosePreference -eq 'Continue' )
            {
                '-Verbose'
                $verboseParam['Verbose'] = $true
            }
        }
    }

    # Both scripts handle if the .NET SDK is installed or not.
    if( $IsWindows )
    {
        $cmdName = 'dotnet.exe'
        $dotnetInstallPath =
            Join-Path -Path $whiskeyBinPath -ChildPath 'dotnet-install.ps1' | Resolve-WhiskeyRelativePath
        $ProgressPreference = [Management.Automation.ActionPreference]::SilentlyContinue
        Write-WhiskeyCommand -Path $dotnetInstallPath -ArgumentList $displayArgs
        & $dotnetInstallPath -InstallDir $InstallRoot -Version $Version -NoPath @verboseParam |
            ForEach-Object { Write-Information $_ }
    }
    else
    {
        $cmdName = 'dotnet'
        $dotnetInstallPath =
            Join-Path -Path $whiskeyBinPath -ChildPath 'dotnet-install.sh' | Resolve-WhiskeyRelativePath
        $displayArgs[0] = $dotnetInstallPath
        Write-WhiskeyCommand -Path 'bash' -ArgumentList $displayArgs
        bash $dotnetInstallPath -InstallDir $InstallRoot -Version $Version | ForEach-Object { Write-Information $_ }
        Write-WhiskeyDebug 'Install complete.'
    }
    
    $dotnetPath = Join-Path -Path $InstallRoot -ChildPath $cmdName -Resolve -ErrorAction Ignore
    if( -not $dotnetPath )
    {
        $msg = "After attempting to install .NET Core SDK version ""$($Version)"", the ""$($cmdName)"" command was " +
               "not found in ""$($InstallRoot)""."
        Write-WhiskeyError -Message $msg
        return
    }

    $sdkPath = Join-Path -Path $InstallRoot -ChildPath ('sdk\{0}' -f $Version) -Resolve -ErrorAction Ignore
    if( -not $sdkPath )
    {
        $msg = "The ""$($cmdName)"" command was installed but .NET SDK ""$($Version)"" doesn't exist in " +
               """$(Join-Path -Path $InstallRoot -ChildPath 'sdk')""."
        Write-WhiskeyError -Message $msg
        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

    $WorkingDirectory = Resolve-Path -Path $WorkingDirectory
    if (-not $WorkingDirectory)
    {
        return
    }

    Push-Location $WorkingDirectory
    try
    {
        if ($Version)
        {
            $sdkVersion = Resolve-WhiskeyDotNetSdkVersion -Version $Version
            $installRoot = Join-Path -Path $InstallRoot -ChildPath '.dotnet'
            return Install-WhiskeyDotNetSdk -InstallRoot $installRoot -Version $sdkVersion
        }

        if ((Get-Command -Name 'dotnet' -ErrorAction Ignore))
        {
            # This command prints out the version of the .NET SDK that the "dotnet" command would use after taking into
            # account the global.json file if it exists. Exit code 0 means a compatible SDK was found
            $possibleVersion = Invoke-Command -ScriptBlock {
                $ErrorActionPreference = 'Continue'
                dotnet --version 2>$null
            }
            if ( $LASTEXITCODE -eq 0 )
            {
                $msg = "[$($MyInvocation.MyCommand)] Using globally installed .NET SDK version $($possibleVersion)"
                Write-WhiskeyVerbose -Message $msg
                return 'dotnet'
            }
        }

        $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 ( Test-Path -Path $globalJsonPath -PathType Leaf )
        {
            try
            {
                $globalJson = Get-Content -Path $globalJsonPath -Raw | ConvertFrom-Json
            }
            catch
            {
                $msg = "Failed to install .NET because global.json file ""$($globalJsonPath)"" contains invalid JSON."
                Write-WhiskeyError -Message $msg
                return
            }

            $globalJsonSdkOptions =
                $globalJson |
                Select-Object -ExpandProperty 'sdk' -ErrorAction Ignore

            $globalJsonVersion =
                $globalJsonSdkOptions |
                Select-Object -ExpandProperty 'version' -ErrorAction Ignore

            $globalJsonRollForward =
                $globalJsonSdkOptions |
                Select-Object -ExpandProperty 'rollForward' -ErrorAction Ignore

            $rollForwardStrategy = [WhiskeyDotNetSdkRollForward]::Disable
            $validRollForwardValues = [WhiskeyDotNetSdkRollForward].GetEnumNames()

            if ($globalJsonRollForward)
            {
                if ($globalJsonRollForward -notin $validRollForwardValues)
                {
                    $msg = "Using default roll forward strategy ""$($rollForwardStrategy)"" because the " +
                           "sdk.rollForward value ""$($globalJsonRollForward)"" in ""$($globalJsonPath)"" is not " +
                           "recognized. Accepted values are: $($validRollForwardValues -join ', ')." +
                           [Environment]::NewLine + [Environment]::NewLine +
                           "If ""$($globalJsonRollForward)"" is a new roll forward strategy, " +
                           '[submit a Whiskey issue](https://github.com/webmd-health-services/Whiskey/issues) ' +
                           'requesting that we add it.'
                    Write-Warning -Message $msg
                }
                else
                {
                    $rollForwardStrategy = [WhiskeyDotNetSdkRollForward] $globalJsonRollForward
                }
            }

            if ( $globalJsonVersion )
            {
                $msg = "[$($MyInvocation.MyCommand)] .NET Core SDK version '$($globalJsonVersion)' with rollforward value '$($globalJsonRollForward)' found in '$($globalJsonPath)'"
                Write-WhiskeyVerbose -Message $msg
                $sdkVersion =
                    Resolve-WhiskeyDotNetSdkVersion -Version $globalJsonVersion -RollForward $rollForwardStrategy
            }

        }

        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
        }

        $installRoot = Join-Path -Path $InstallRoot -ChildPath '.dotnet'
        $dotnetPath = Install-WhiskeyDotNetSdk -InstallRoot $installRoot -Version $sdkVersion
        return $dotnetPath
    }
    finally
    {
        Pop-Location
    }
}



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

        # The directory where the Node.js package file should be downloaded.
        [Parameter(Mandatory)]
        [String] $OutFileRootPath,

        # 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 $InstallRootPath -ErrorAction Ignore

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

    $npmVersionToInstall = $null
    $nodeVersionToInstall = $null
    $ProgressPreference = [Management.Automation.ActionPreference]::SilentlyContinue
    $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 $InstallRootPath -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 $InstallRootPath
            $installNode = $true
        }
    }
    else
    {
        $installNode = $true
    }

    $nodeDirectoryName = '.node'
    $nodeRoot = Join-Path -Path $InstallRootPath -ChildPath $nodeDirectoryName

    $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

    if( $installNode )
    {
        $nodeZipFilePath = Join-Path -Path $OutFileRootPath -ChildPath $filename
        if( -not (Test-Path -Path $nodeZipFilePath -PathType Leaf) )
        {
            $uri = 'https://nodejs.org/dist/{0}/{1}' -f $nodeVersionToInstall.version,$filename

            if( -not (Test-Path -Path $OutFileRootPath) )
            {
                Write-WhiskeyDebug -Message "Creating output directory ""$($OutFileRootPath)""."
                New-Item -Path $OutFileRootPath -ItemType 'Directory' -Force | Out-Null
            }

            $preExistingPkgPath =
                Join-Path -Path $OutFileRootPath -ChildPath "node-*-*-x64.$($packageExtension)"
            if( (Test-Path -Path $preExistingPkgPath) )
            {
                Remove-Item -Path $preExistingPkgPath -Force -ErrorAction Ignore
            }

            try
            {
                $ProgressPreference = [Management.Automation.ActionPreference]::SilentlyContinue
                Invoke-WebRequest -Uri $uri -OutFile $nodeZipFilePath
            }
            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 -Message "Exception downloading ""$($uri)"": $($_)"
                    $responseInfo = ' Please see previous error for more information.'
                }

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

        if( $IsWindows )
        {
            # Windows/.NET can't handle the long paths in the Node package, so on that platform, we need to download
            # 7-zip because it can handle long paths.
            $7zipPackageRoot = Install-WhiskeyTool -Name '7-Zip.CommandLine' `
                                                   -ProviderName 'NuGet' `
                                                   -Version '18.*' `
                                                   -InstallRoot $InstallRootPath
            $7z = Join-Path -Path $7zipPackageRoot -ChildPath 'tools\x64\7za.exe' -Resolve -ErrorAction Stop

            $archive = [IO.Compression.ZipFile]::OpenRead($nodeZipFilePath)
            $outputDirectoryName = $archive.Entries[0].FullName
            $archive.Dispose()
            $outputDirectoryName =
                $outputDirectoryName.TrimEnd([IO.Path]::DirectorySeparatorChar, [IO.Path]::AltDirectorySeparatorChar)
            $outputRoot = Join-Path -Path $InstallRootPath -ChildPath $outputDirectoryName

            Write-WhiskeyVerbose -Message ('{0} x {1} -o{2} -y' -f $7z,$nodeZipFilePath,$outputRoot)
            & $7z -spe 'x' $nodeZipFilePath ('-o{0}' -f $outputRoot) '-y' | Write-WhiskeyVerbose

            # We use New-TimeSpan so we can mock it and wait for our simulated anti-virus process to lock a
            # file (i.e. so we can test that this wait logic works).
            $maxTime = New-TimeSpan -Seconds 10
            $timer = [Diagnostics.Stopwatch]::StartNew()
            $exists = $false
            $lastError = $null
            Write-WhiskeyDebug "Renaming ""$($outputRoot)"" -> ""$($nodeDirectoryName)""."
            do
            {
                Rename-Item -Path $outputRoot -NewName $nodeDirectoryName -ErrorAction SilentlyContinue
                $exists = Test-Path -Path $nodeRoot -PathType Container

                if( $exists )
                {
                    Write-WhiskeyDebug "Rename succeeded."
                    break
                }

                $lastError = $Global:Error | Select-Object -First 1
                Write-WhiskeyDebug -Message "Rename failed: $($lastError)"

                $Global:Error.RemoveAt(0)
                Start-Sleep -Seconds 1
            }
            while( $timer.Elapsed -lt $maxTime )

            if( -not $exists )
            {
                $msg = "Failed to install Node to ""$($nodeRoot)"" because renaming directory " +
                       """$($outputDirectoryName)"" to ""$($nodeDirectoryName)"" failed: $($lastError)"
                Write-WhiskeyError -Message $msg
            }

        }
        else
        {
            if( -not (Test-Path -Path $nodeRoot -PathType Container) )
            {
                New-Item -Path $nodeRoot -ItemType 'Directory' -Force | Out-Null
            }

            Write-WhiskeyVerbose -Message ('tar -xJf "{0}" -C "{1}" --strip-components=1' -f $nodeZipFilePath,$nodeRoot)
            tar -xJf $nodeZipFilePath -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,$nodeZipFilePath,$nodeRoot)
                return
            }
        }

        $nodePath = Resolve-WhiskeyNodePath -BuildRootPath $InstallRootPath -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-WhiskeyInfo ('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'
        if( $LASTEXITCODE )
        {
            "Failed to update to NPM $($npmVersionToInstall). See previous output for details." |
                Write-WhiskeyError
        }
    }

    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-WhiskeyNuGetPackage
{
    [CmdletBinding()]
    param(
        [Parameter(Mandatory)]
        [String] $Name,

        [String] $Version,

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

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

    # Sometimes finding packages can be flaky, so we try multiple times.
    $numErrors = $Global:Error.Count
    $numTries = 6
    $waitMilliseconds = 100
    $allPkgs = @()
    $pkgMgmtErrors = @{}
    for( $idx = 0; $idx -lt $numTries; ++$idx )
    {
        $allPkgs =
            Find-Package -Name $Name `
                         -ProviderName 'NuGet' `
                         -AllVersions `
                         -ErrorAction SilentlyContinue `
                         -ErrorVariable 'pkgMgmtErrors' |
            Where-Object { $_ -notmatch '-' }

        if( $allPkgs )
        {
            Write-WhiskeyVerbose "Found $(($allPkgs | Measure-Object).Count) $($Name) packages:"
            foreach ($pkg in $allPkgs)
            {
                Write-WhiskeyVerbose " $($pkg.Name) $($pkg.Version) from $($pkg.Source) [$($pkg.GetType().FullName)]"
            }
            break
        }

        Start-Sleep -Milliseconds $waitMilliseconds
        $waitMilliseconds = $waitMilliseconds + 2
    }

    if (-not $allPkgs)
    {
        $pkgMgmtErrors | Write-Error

        $msg = "NuGet package $($Name) $($Version) does not exist or search request failed."
        Write-WhiskeyError -Message $msg
        return
    }

    for ($idx = 0; $idx -lt $Global:Error.Count - $numErrors; ++$idx)
    {
        $Global:Error.RemoveAt(0)
    }

    $pkg = $allPkgs | Select-Object -First 1
    if ($Version)
    {
        $pkgVersions = $allPkgs | Where-Object 'Version' -Like $Version
        if (-not $pkgVersions)
        {
            # Some package versions have build metadata at the end.
            $pkgVersions = $allPkgs | Where-Object 'Version' -Like "$($Version)+*"
        }

        if (-not $pkgVersions)
        {
            "Failed to install NuGet package $($Name) $($Version) because that version does not exist. Available " +
                'versions are:' + [Environment]::NewLine +
                [Environment]::NewLine +
                "* $(($allPkgs | Select-Object -ExpandProperty 'Version') -join "$([Environment]::NewLine)* ")" |
                Write-WhiskeyError
            return
        }

        Write-WhiskeyVerbose "Found $(($pkgVersions | Measure-Object).Count) $($Name) $($Version) packages:"
        foreach ($pkg in $pkgVersions)
        {
            Write-WhiskeyVerbose " $($pkg.Name) $($pkg.Version) from $($pkg.Source) [$($pkg.GetType().FullName)]"
        }

        $pkg = $pkgVersions | Select-Object -First 1
    }

    "Found package $($pkg.Name) $($pkg.Version) from $($pkg.Source)." | Write-WhiskeyVerbose

    $pkgBaseName = "$($Name).$($pkg.Version -replace '\+.*$', '')"

    # Save-Module downloads dependencies, too. Save everything for a package into its own directory so we know which
    # packages to install as dependencies.
    $cachePath = Join-Path -Path $BuildRootPath -ChildPath ".output\nuget\$($pkgBaseName)"
    if( -not (Test-Path -Path $cachePath) )
    {
        New-Item -Path $cachePath -ItemType 'Directory' | Out-Null
    }

    $nupkgPath = Join-Path -Path $cachePath -ChildPath "$($pkgBaseName).nupkg"

    $packagesPath = Join-Path -Path $BuildRootPath -ChildPath 'packages'
    if (-not (Test-Path -Path $packagesPath))
    {
        New-Item -Path $packagesPath -ItemType 'Directory' | Out-Null
    }

    if( -not (Test-Path -Path $nupkgPath) )
    {
        $waitMilliseconds = 100
        $numErrors = $Global:Error.Count
        $pkgMgmtErrors = @()
        for( $idx = 0; $idx -lt $numTries; ++$idx )
        {
            $destinationPath = [IO.Path]::GetFileNameWithoutExtension($nupkgPath)
            $destinationPath = Join-Path -Path ($packagesPath | Resolve-Path -Relative) -ChildPath $destinationPath
            "Saving NuGet package $($pkg.Name) $($pkg.Version) to ""$($destinationPath)""." | Write-WhiskeyInfo
            $pkg |
                Save-Package -Path $cachePath -ErrorAction SilentlyContinue -ErrorVariable 'pkgMgmtErrors' -Force |
                Out-Null

            if( (Test-Path -Path $nupkgPath) )
            {
                break
            }

            Start-Sleep -Milliseconds $waitMilliseconds
            $waitMilliseconds = $waitMilliseconds * 2
        }

        if( -not (Test-Path -Path $nupkgPath) )
        {
            $pkgMgmtErrors | Write-Error
            $msg = "Failed to download NuGet package $($pkg.Name) $($pkg.Version)."
            Write-WhiskeyError -Message $msg
            return
        }

        for( $idx = 0; $idx -lt $Global:Error.Count - $numErrors; ++$idx )
        {
            $Global:Error.RemoveAt(0)
        }
    }


    # Install the package and all its dependencies into 'packages'.
    foreach( $pkgInfo in (Get-ChildItem -Path $cachePath -Filter '*.nupkg') )
    {
        $pkgPath = Join-Path -Path $packagesPath -ChildPath $pkgInfo.BaseName
        if( -not (Test-Path -Path $pkgPath) )
        {
            New-Item -Path $pkgPath -ItemType 'Directory' -Force | Out-Null
        }

        if( -not (Get-ChildItem -LiteralPath $pkgPath) )
        {
            Write-WhiskeyVerbose -Message "Extracting ""$($pkgInfo.Name)"" to ""$($pkgPath | Resolve-Path -Relative)""."
            Add-Type -AssemblyName 'System.IO.Compression.FileSystem'
            [IO.Compression.ZipFile]::ExtractToDirectory($pkgInfo.FullName, $pkgPath)
        }
    }

    return Join-Path -Path $packagesPath -ChildPath $pkgBaseName
}


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

    Write-WhiskeyDebug "\Install-WhiskeyPowerShellModule\" -Indent

    try
    {
        function Find-PSModule
        {
            $findParameters = @{
                'Name' = $Name;
                'BuildRoot' = $BuildRoot;
                'AllowPrerelease' = $AllowPrerelease;
                'Version' = $Version;
            }

            return Find-WhiskeyPowerShellModule @findParameters
        }

        Write-WhiskeyDebug ($PSBoundParameters | Format-Table | Out-String)
        if( $Path )
        {
            $Path = $Path.TrimEnd([IO.Path]::DirectorySeparatorChar, [IO.Path]::AltDirectorySeparatorChar)
            if( -not [IO.Path]::IsPathRooted($Path) )
            {
                $Path = Join-Path -Path $BuildRoot -ChildPath $Path
                $Path = [IO.Path]::GetFullPath($Path)
            }
            if( -not (Test-Path -Path $Path) )
            {
                New-Item -Path $Path -ItemType 'Directory' | Out-Null
            }

            # Whiskey's PowerShell functions assume all modules are installed in a path in PSModulePath environment variable
            # so make sure the user's path is in that path.
            Register-WhiskeyPSModulePath -Path $Path
            $installRoot = $Path
        }
        else
        {
            $installRoot = Get-WhiskeyPSModulePath -PSModulesRoot $BuildRoot -Create
        }
        Write-WhiskeyDebug "Module $($Name) $($Version) will be installed to ""$($installRoot)""."

        if( -not $Version )
        {
            Write-WhiskeyDebug "Searching for latest $($Name) module version."
            # We need to know the latest version of the module so we can see if it is already installed.
            $latestModule = Find-PSModule
            if( -not $latestModule )
            {
                Write-WhiskeyDebug "Module $($Name) not found in any repository."
                return
            }
            Write-WhiskeyDebug ($latestModule | Format-List | Out-String)
            $Version = $latestModule.Version
        }

        try
        {
            $installedModule = Get-WhiskeyPSModule -PSModulesRoot $BuildRoot -Name $Name -Version $Version

            if( $installedModule )
            {
                Write-WhiskeyDebug "Module $($Name) $($Version) found:$([Environment]::NewLine)$($installedModule | Format-List)"
                $installedInPSModulePath = -not $Path
                $installedInCustomPath = $Path -and `
                                        ($installedModule.Path | Split-Path | Split-Path | Split-Path) -eq $Path
                if( $installedInPSModulePath -or $installedInCustomPath )
                {
                    if( -not $SkipImport )
                    {
                        Import-WhiskeyPowerShellModule -Name $Name -Version $installedModule.Version -PSModulesRoot $BuildRoot
                    }

                    # Already installed or installed where the user wants it.
                    return $installedModule
                }
            }

            Write-WhiskeyDebug "Module $($Name) $($Version) not installed."
            # Find what module *should* be installed.
            $moduleToInstall = Find-PSModule
            if( -not $moduleToInstall )
            {
                Write-WhiskeyDebug "Module $($Name) $($Version) not found in any repository."
                return
            }

            Write-WhiskeyDebug "Module $($Name) $($Version) found: $($moduleToInstall | Format-List | Out-String)."
            # Now we know where the module is going to be saved, let's make sure the destination doesn't exist.
            $moduleRoot = Join-Path -Path $installRoot -ChildPath $moduleToInstall.Name
            $moduleRoot = Join-Path -Path $moduleRoot -ChildPath $moduleToInstall.Version
            if( (Test-Path -Path $moduleRoot) )
            {
                Write-WhiskeyDebug "Removing module $($Name) $($Version) destination directory ""$($moduleRoot)""."
                Remove-Item -Path $moduleRoot -Recurse -Force
                if( (Test-Path -Path $moduleRoot) )
                {
                    $msg = "Unable to install PowerShell module $($moduleToInstall.Name) $($moduleToInstall.Version) to " +
                        """$($installRoot | Resolve-Path -Relative)"": the destination path " +
                        """$($moduleRoot | Resolve-Path -Relative)"" exists and deleting it failed. Make sure files " +
                        'under the destination directory aren''t in use.'
                    Write-WhiskeyError -Message $msg
                    return
                }
            }

            $msg = "Saving PowerShell module ""$($moduleToInstall.Name)"" $($moduleToInstall.Version) from repository " +
                """$($moduleToInstall.Repository)"" to ""$($installRoot)""."
            Write-WhiskeyVerbose -Message $msg
            $globalProgressPref = $Global:ProgressPreference
            # Ignore doesn't work in Windows PowerShell.
            $Global:ProgressPreference = [Management.Automation.ActionPreference]::SilentlyContinue
            $allowPrereleaseArg = Get-AllowPrereleaseArg -CommandName 'Save-Module' -AllowPrerelease:$AllowPrerelease
            try
            {
                Save-Module -Name $moduleToInstall.Name `
                            -RequiredVersion $moduleToInstall.Version `
                            -Repository $moduleToInstall.Repository `
                            -Path $installRoot `
                            @allowPrereleaseArg
            }
            finally
            {
                $Global:ProgressPreference = $globalProgressPref
            }
            $installedModule = Get-WhiskeyPSModule -PSModulesRoot $BuildRoot `
                                                -Name $moduleToInstall.Name `
                                                -Version $moduleToInstall.Version

            if( -not $installedModule )
            {
                $msg = "Failed to download PowerShell module $($moduleToInstall.Name) $($moduleToInstall.Version) from " +
                    "repository $($moduleToInstall.Repository) to ""$($installRoot | Resolve-Path -Relative)"": the " +
                    'module doesn''t exist after running PowerShell''s "Save-Module" command.'
                Write-WhiskeyError -Message $msg
                return
            }

            $installedModule | Write-Output

            if( -not $SkipImport )
            {
                Import-WhiskeyPowerShellModule -Name $Name -Version $installedModule.Version -PSModulesRoot $BuildRoot
            }
        }
        finally
        {
            if( $Path )
            {
                # Remove the user's path from the PSModulePath environment variable.
                Unregister-WhiskeyPSModulePath -Path $Path
            }
        }
    }
    finally
    {
        Write-WhiskeyDebug "/Install-WhiskeyPowerShellModule/" -Outdent
    }
}



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. If PowerShell
    modules are already installed globally and are listed in the PSModulePath environment variable, they will not be
    re-installed. 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 have 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(
        # The attribute that defines what tool is necessary.
        [Parameter(Mandatory, ParameterSetName='FromAttribute')]
        [Whiskey.RequiresToolAttribute] $ToolInfo,

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

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

        # The path to a directory where downloaded package files should be saved prior to installation.
        [Parameter(Mandatory, ParameterSetName='FromAttribute')]
        [Parameter(ParameterSetName='AtRuntime')]
        [String] $OutFileRootPath,

        [Parameter(ParameterSetName='AtRuntime')]
        [AllowEmptyString()]
        [String] $ProviderName,

        [Parameter(Mandatory, ParameterSetName='AtRuntime')]
        [String] $Name,

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

        [Parameter(ParameterSetName='NuGet')]
        [Parameter(ParameterSetName='AtRuntime')]
        [String] $Version,

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

        # 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`.
        [Parameter(Mandatory, ParameterSetName='NuGet')]
        [String] $DownloadRoot
    )

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

    Write-WhiskeyDebug '\Install-WhiskeyTool\' -Indent

    try
    {
        $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' )
            {
                $msg = 'The "Install-WhiskeyTool" function''s "NuGetPackage" name parameter is obsolete. Use ' +
                    '[Whiskey.WhiskeyTool] attribute on your task instead.'
                Write-Warning -Message $msg
                    
                Install-WhiskeyNuGetPackage -Name $NuGetPackageName -Version $Version -BuildRootPath $DownloadRoot
                return
            }

            if( $PSCmdlet.ParameterSetName -eq 'FromAttribute' )
            {
                $ProviderName = $ToolInfo.ProviderName
                $Name = $ToolInfo.Name
                $Version = $TaskParameter[$ToolInfo.VersionParameterName]
                if( -not $Version )
                {
                    $Version = $ToolInfo.Version
                }
            }

            if( -not $OutFileRootPath )
            {
                $OutFileRootPath = Join-Path -Path $InstallRoot -ChildPath '.output'
            }

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

            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( $ProviderName )
            {
                'NuGet'
                {
                    $toolPath = Install-WhiskeyNuGetPackage -Name $Name -Version $Version -BuildRootPath $InstallRoot
                }
                'NodeModule'
                {
                    $nodePath = Resolve-WhiskeyNodePath -BuildRootPath $InstallRoot
                    if( -not $nodePath )
                    {
                        $msg = 'It looks like Node isn''t installed in your repository. Whiskey usually installs Node.js ' +
                            '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.js. If the .node directory exists, the Node ' +
                            'package is most likely corrupt. Please delete it and re-run your build.'
                        Write-WhiskeyError -Message $msg -ErrorAction Stop
                        return
                    }
                    $toolPath = Install-WhiskeyNodeModule -Name $Name `
                                                        -BuildRootPath $InstallRoot `
                                                        -Version $Version `
                                                        -Global `
                                                        -InCleanMode:$InCleanMode `
                                                        -ErrorAction Stop
                }
                default
                {
                    switch( $Name )
                    {
                        'Node'
                        {
                            $toolPath = Install-WhiskeyNode -InstallRootPath $InstallRoot `
                                                            -Version $Version `
                                                            -InCleanMode:$InCleanMode `
                                                            -OutFileRootPath $OutFileRootPath
                        }
                        'DotNet'
                        {
                            $toolPath = Install-WhiskeyDotNetTool -InstallRoot $InstallRoot `
                                                                -WorkingDirectory (Get-Location).ProviderPath `
                                                                -Version $Version `
                                                                -ErrorAction Stop
                        }
                        default
                        {
                            throw "Unknown tool ""$($Name)"". The only supported tools are ""Node"" and ""DotNet""."
                        }
                    }
                }
            }

            if( $PSCmdlet.ParameterSetName -eq 'FromAttribute' -and $ToolInfo.PathParameterName )
            {
                $TaskParameter[$ToolInfo.PathParameterName] = $toolPath
            }

            return $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'
        }
    }
    finally
    {
        Write-WhiskeyDebug '/Install-WhiskeyTool/' -Outdent
    }
}



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.StartBuild()

    Register-WhiskeyPSModulePath -PSModulesRoot $Context.BuildRoot

    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

        Unregister-WhiskeyPSModulePath -PSModulesRoot $Context.BuildRoot

        $context.StopBuild()

        $msg = "$($status) in $(($context.BuildStopwatch | Format-Stopwatch).Trim())"
        Write-WhiskeyInfo -Context $context -Message $msg -NoTiming -NoIndent
    }

    # 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,

        # 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,

        [switch] $NoLog
    )

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

    if ( -not $DotNetPath )
    {
        $DotNetPath = 'dotnet'
    }

    if ( -not (Get-Command -Name $DotNetPath -ErrorAction Ignore) )
    {
        Write-WhiskeyError -Context $TaskContext -Message ('"{0}" does not exist.' -f $DotNetPath)
        return
    }

    $loggerArgs = @()
    if( -not $NoLog )
    {
        $loggerArgs = & {
            '/filelogger9'
            $logFilePath = ('dotnet.{0}.log' -f $Name.ToLower())
            if( $ProjectPath )
            {
                $logFilePath = 'dotnet.{0}.{1}.log' -f $Name.ToLower(),($ProjectPath | Split-Path -Leaf)
            }
            $logFilePath = $logFilePath -replace '[^a-zA-Z0-9.]', '_'
            $relativeOutDirectory = $TaskContext.OutputDirectory | Resolve-Path -Relative
            $logFilePath = Join-Path -Path $relativeOutDirectory -ChildPath $logFilePath
            "/flp9:LogFile=$($logFilePath);Verbosity=d"
        }
    }

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

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

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

        & $DotNetExe $Command $DotNetArgs $LoggerArgs $Project

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

    if ($LASTEXITCODE -ne 0)
    {
        Stop-WhiskeyTask -TaskContext $TaskContext -Message ('"{0}" failed with exit code {1}' -f $DotNetPath,$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' )
            {
                $ErrorActionPreference = 'Continue'
            }
            try
            {
                Write-WhiskeyCommand -Path $nodePath -ArgumentList $npmPath,$commandName,$commandArgs
                & $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] $Url,

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

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

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

    Write-WhiskeyCommand -Path $NuGetPath -ArgumentList $Path, $Url, $ApiKey
    & $NuGetPath push $Path -Source $Url -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
    )

    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

    $logPathFileName = "robocopy-$([IO.Path]::GetRandomFileName() -replace '\.','').log"
    $logPath = Join-Path -Path (Get-WhiskeyTempPath) -ChildPath $logPathFileName

    $excludeParam = $Exclude | ForEach-Object { '/XF' ; $_ ; '/XD' ; $_ }
    robocopy $Source `
             $Destination `
             '/PURGE' `
             '/S' `
             '/R:0' `
             "/LOG:${logPath}" `
             "/MT:${numRobocopyThreads}" `
             $WhiteList `
             $excludeParam

    try
    {
        if ($LASTEXITCODE -ge 8)
        {
            Get-Content -Path $logPath
            $msg = "The command ""robocopy.exe '${Source}' '${Destination}'"" failed with exit code ${LASTEXITCODE}."
            Write-WhiskeyError $msg
            return
        }

        # Make sure one of Robocopy's success exit codes doesn't fail the build.
        $Global:LASTEXITCODE = $LASTEXITCODE = 0
    }
    finally
    {
        if (Test-Path -Path $logPath)
        {
            Remove-Item -Path $logPath -Force
        }
    }
}



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

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

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

        # The parameters/configuration to use to run the task.
        [Parameter(Mandatory)]
        [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)
        {
            $msg = "Found ${taskCount} tasks with alias ""{Name}"". Please update to use one of these task names: " +
                   "$(($task | Select-Object -ExpandProperty 'Name') -join ', ')"
            Stop-WhiskeyTask -TaskContext $TaskContext -Message $msg
            return
        }
        if( $task -and $task.WarnWhenUsingAlias )
        {
            $msg = "Task ""${Name}"" is an alias to task ""$($task.Name)"". Please update " +
                   """$($TaskContext.ConfigurationPath)"" to use the task''s actual name, ""$($task.Name)"", instead " +
                   'of the alias.'
            Write-WhiskeyWarning -Context $TaskContext -Message $msg
        }
    }

    if (-not $task -and $Parameter.ContainsKey(''))
    {
        # By default, assume task is an executable command.
        $task = $knownTasks | Where-Object 'Name' -eq 'Exec'
    }

    if( -not $task )
    {
        $knownTaskNames = $knownTasks | Select-Object -ExpandProperty 'Name' | Sort-Object
        $msg = "$($TaskContext.ConfigurationPath): ${Name}[$($TaskContext.TaskIndex)]: ""${Name}"" task does not " +
               "exist. Supported tasks are:$([Environment]::NewLine) " +
               "$($knownTaskNames -join "$([Environment]::NewLine) * ")"
        throw $msg
    }
    $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
    }

    if( $task.Obsolete )
    {
        $message = 'The "{0}" task is obsolete and shouldn''t be used.' -f $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 `
                    $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
    {
        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
            }
        }

        $requiredTools = Get-RequiredTool -CommandName $task.CommandName
        foreach( $requiredTool in $requiredTools )
        {
            Install-WhiskeyTool -ToolInfo $requiredTool `
                                -InstallRoot $TaskContext.BuildRoot.FullName `
                                -TaskParameter $taskProperties `
                                -OutFileRootPath $TaskContext.OutputDirectory.FullName `
                                -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
        }

        $taskTempDirectory = ''
        $result = 'FAILED'

        $originalDebugPreference = $DebugPreference
        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'
            }
            Set-Location -Path $workingDirectory
            [IO.Directory]::SetCurrentDirectory($workingDirectory)

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

            Write-WhiskeyVerbose -Context $TaskContext -Message ''
            $TaskContext.StartTask($Name)
            Write-WhiskeyInfo -Context $TaskContext -Message "$($Name)" -NoIndent
            $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
            }

            $taskArgs = Get-TaskArgument -Task $task -Property $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( $taskArgs['Debug'] )
            {
                $DebugPreference = 'Continue'
                $taskArgs.Remove('Debug')
            }

            $outVariable = $commonProperties['OutVariable']

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

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

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

            $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
            }
            $Context.StopTask()
            $duration = $Context.TaskStopwatch.Elapsed
            if( $result -eq 'FAILED' )
            {
                $msg = "!$($taskWriteIndent.Substring(1))FAILED"
                Write-WhiskeyInfo -Context $TaskContext -Message $msg -NoIndent
            }
            Write-WhiskeyInfo -Context $TaskContext -Message '' -NoIndent
        }

        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(
        # The environment you're building in.
        [Parameter(Mandatory)]
        [String] $Environment,

        # The path to the `whiskey.yml` file that defines build settings and tasks.
        [Parameter(Mandatory)]
        [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( -not $buildMetadata.IsPullRequest -and $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 Publish-WhiskeyPSObject
{
    [CmdletBinding()]
    param(
        [Parameter(Mandatory)]
        [Object] $Context,
        
        [Parameter(Mandatory, ParameterSetName='Module')]
        [Management.Automation.PSModuleInfo] $ModuleInfo,
        
        [Parameter(Mandatory, ParameterSetName='Script')]
        [PSCustomObject] $ScriptInfo,
        
        [String] $RepositoryName,
        
        [String] $RepositoryLocation,
        
        [String] $CredentialID,
        
        [String] $ApiKeyID
    )

    $commonParams = @{}
    if( $VerbosePreference -in @('Continue','Inquire') )
    {
        $commonParams['Verbose'] = $true
    }
    if( (Test-Path -Path 'variable:InformationPreference') )
    {
        $commonParams['InformationAction'] = $InformationPreference
    }

    Write-WhiskeyDebug -Context $TaskContext -Message 'Bootstrapping NuGet packageprovider.'
    Get-PackageProvider -Name 'NuGet' -ForceBootstrap @commonParams | Out-Null

    $createTempRepo = $false
    $infoMsg = ''
    if( -not $RepositoryLocation -and -not $RepositoryName )
    {
        $createTempRepo = $true
        $RepositoryLocation = $TaskContext.OutputDirectory.FullName
        $infoMsg = """$($RepositoryLocation | Resolve-WhiskeyRelativePath)"""
    }
    elseif( $RepositoryLocation )
    {
        $publishTo =
            Get-PSRepository -ErrorAction Ignore @commonParams | Where-Object 'PublishLocation' -eq $RepositoryLocation
        if( $publishTo )
        {
            $RepositoryName = $publishTo.Name
        }
        else
        {
            $createTempRepo = $true
        }
    }
    elseif( $RepositoryName )
    {
        $publishTo = Get-PSRepository -ErrorAction Ignore @commonParams | Where-Object 'Name' -eq $RepositoryName
        if( -not $publishTo )
        {
            Get-PSRepository | Format-Table -AutoSize
            if( $ScriptInfo )
            {
                $msg = "Unable to publish PowerShell script ""$($ScriptInfo.ScriptBase | Resolve-WhiskeyRelativePath)"" to " +
                   "repository ""$($RepositoryName)"": a repository with that name doesn't exist. Update your " +
                   'PublishPowerShellScript task with the name of one of the repository''s that ' +
                   'exists (see above), use the "RepositoryLocation" to specify the URI or path to a repository, ' +
                   'or leave "RepositoryName" blank to publish to the build output directory.'
            }
            else 
            {
                $msg = "Unable to publish PowerShell module ""$($ModuleInfo.ModuleBase | Resolve-WhiskeyRelativePath)"" to " +
                   "repository ""$($RepositoryName)"": a repository with that name doesn't exist. Update your " +
                   'PublishPowerShellModule task with the name of one of the repository''s that ' +
                   'exists (see above), use the "RepositoryLocation" to specify the URI or path to a repository, ' +
                   'or leave "RepositoryName" blank to publish to the build output directory.'
            }

            Stop-WhiskeyTask -TaskContext $TaskContext -Message $msg
            return
        }
        $RepositoryLocation = $publishTo.PublishLocation
    }

    try
    {
        if( $createTempRepo )
        {
            $credentialParam = @{ }
            if( $CredentialID )
            {
                $credentialParam['Credential'] =
                    Get-WhiskeyCredential -Context $TaskContext -ID $CredentialID -PropertyName 'CredentialID'
            }

            $tempNameSuffix = [IO.Path]::GetRandomFileName() -replace '\.', ''
            $RepositoryName = "Whiskey-$($TaskContext.BuildRoot.FullName)-$($tempNameSuffix)"

            $msg = "Registering PowerShell repository ""$($RepositoryName)"" at ""$($RepositoryLocation)""."
            Write-WhiskeyVerbose -Context $TaskContext -Message $msg
            # Do *not* ErrorAction Stop this. It causes a handled error deep in the bowels of PackageManagement to
            # cause Register-PSRepository to fail.
            Register-PSRepository -Name $RepositoryName `
                                  -SourceLocation $RepositoryLocation `
                                  -PublishLocation $RepositoryLocation `
                                  -InstallationPolicy Trusted `
                                  -PackageManagementProvider NuGet `
                                  -ErrorAction Continue `
                                  @credentialParam `
                                  @commonParams

            if( -not (Get-PSRepository -Name $RepositoryName) )
            {
                Get-PSRepository | Format-Table -Auto
                $msg = "Register-PSRepository didn't register ""$($RepositoryName)"" at location " +
                       """$($RepositoryLocation)""."
                Stop-WhiskeyTask -TaskContext $Context -Message $msg
                return
            }
        }

        $apiKeyParam = @{}
        if( $ApiKeyID )
        {
            $apiKey = Get-WhiskeyApiKey -Context $TaskContext -ID $ApiKeyID -PropertyName 'ApiKeyID'
            if( $apiKey )
            {
                $apiKeyParam['NuGetApiKey'] = $apiKey
            }
        }

        if( -not $infoMsg )
        {
            $infoMsg = "repository ""$($RepositoryName)"" at ""$($RepositoryLocation)"""
        }

        if( $ScriptInfo )
        {
            $msg = "Publishing PowerShell script ""$($Path | Resolve-WhiskeyRelativePath)"" to $($infoMsg)."
        }
        else 
        {
            $msg = "Publishing PowerShell module ""$($Path | Resolve-WhiskeyRelativePath)"" to $($infoMsg)."
        }
        Write-WhiskeyInfo -Context $TaskContext -Message $msg
        Get-Module | Format-Table -AutoSize | Out-String | Write-Debug
        # Use the Force switch to allow publishing versions that come *before* the latest version.
        if( $ScriptInfo )
        {
            Publish-Script -Path $Path -Repository $RepositoryName -Force @apiKeyParam @commonParams -ErrorAction Stop
        }
        else 
        {
            Publish-Module -Path $Path -Repository $RepositoryName -Force @apiKeyParam @commonParams -ErrorAction Stop
        }
    }
    finally
    {
        if( $createTempRepo )
        {
            $msg = "Unregistering temporary PowerShell repository ""$($RepositoryName)""."
            Write-WhiskeyVerbose -Context $TaskContext -Message $msg
            Unregister-PSRepository -Name $RepositoryName
        }
    }
}


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 Register-WhiskeyPSModulePath
{
    # 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/55 .
    [CmdletBinding()]
    param(
        [Parameter(Mandatory,ParameterSetName='FromUser')]
        [String]$Path,

        [Parameter(Mandatory,ParameterSetName='FromWhiskey')]
        [String]$PSModulesRoot
    )

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

    Write-WhiskeyDebug '\Register-WhiskeyPSModulePath\' -Indent
    
    try
    {
        if( $PSCmdlet.ParameterSetName -eq 'FromWhiskey' )
        {
            $Path = Get-WhiskeyPSModulePath -PSModulesRoot $PSModulesRoot
        }

        $pathBefore = $env:PSModulePath -split [IO.Path]::PathSeparator
        try
        {
            if( $pathBefore -contains $Path )
            {
                return
            }

            $env:PSModulePath = $Path,$env:PSModulePath -join [IO.Path]::PathSeparator
        }
        finally
        {
            Write-WhiskeyDebug "Changes to PSModulePath:"
            $pathNow = $env:PSModulePath -split [IO.Path]::PathSeparator
            $diff = Compare-Object -ReferenceObject $pathBefore -DifferenceObject $pathNow -IncludeEqual
            if( $diff )
            {
                $diff | Format-Table -AutoSize | Out-String | Write-WhiskeyDebug
            }
        }
    }
    finally
    {
        Write-WhiskeyDebug '/Register-WhiskeyPSModulePath/' -Outdent
    }
}


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 )
        {
            $emptyDir = Get-WhiskeyTempPath -Name 'Empty'
            try
            {
                Invoke-WhiskeyRobocopy -Source $emptyDir -Destination $Path
                Remove-Item -Path $Path -Recurse -Force
            }
            finally
            {
                if (Test-Path -Path $emptyDir)
                {
                    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.
    The logic for the provided RollForward value is as follows: For `Patch`, `Feature`, `Major`, and `Minor`, the most recent patch for the specified versions is returned. For `LatestPatch`, the latest patch for the specified major, minor, and feature versions is used. For `LatestFeature`, the latest patch and feature is used for the provided major and minor versions. For `LatestMinor`, the latest minor, feature, and patch are used
    for the specified major version. For `LatestMajor`, the most recently released version of the .NET Core SDK is used.
 
    .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.
 
    .EXAMPLE
    Resolve-WhiskeyDotNetSdkVersion -Version '2.1.2' -RollForward Patch
 
    Demonstrates finding the latest '2.1.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,

        # Roll forward preferences for the .NET Core SDK
        [Parameter(ParameterSetName='Version')]
        [WhiskeyDotNetSdkRollForward] $RollForward = [WhiskeyDotNetSdkRollForward]::Disable
    )
    Set-StrictMode -version 'Latest'
    Use-CallerPreference -Cmdlet $PSCmdlet -SessionState $ExecutionContext.SessionState
    $ProgressPreference = [Management.Automation.ActionPreference]::SilentlyContinue

    if ( $LatestLTS )
    {
        $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
    }

    $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
            break
        }
    }

    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

    if ($Version -match '^\*|^\d+\.\*')
    {
        $matcher = $Matches[0]
    }
    elseif ($Version -match '^(\d+)\.(\d+)')
    {
        switch ($RollForward)
        {
            LatestMajor
            {
                $matcher = '*'
            }
            LatestMinor
            {
                $matcher = "$($Matches[1]).*"
            }
            default
            {
                $matcher = "$($Matches[1]).$($Matches[2])"
            }
        }
    }

    $release = $releasesIndex |
        Sort-Object -Property 'channel-version' -Descending |
        Where-Object { $_.'channel-version' -like $matcher } |
        Select-Object -First 1
    if (-not $release -and $RollForward -eq [WhiskeyDotNetSdkRollForward]::Disable)
    {
        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'
    }

    $desiredVersion = $null
    $sortedSdkVersions = $null

    if ([WildcardPattern]::ContainsWildcardCharacters($Version))
    {
        $resolvedVersion =
            $sdkVersions |
            Where-Object { $_ -like $Version} |
            Sort-Object -Descending |
            Select-Object -First 1
        Write-WhiskeyVerbose -Message ('[{0}] SDK version "{1}" resolved to "{2}"' -f $MyInvocation.MyCommand, $Version, $resolvedVersion)
        return $resolvedVersion
    }

    if ( $Version -notmatch '^(\d+)\.(\d+)\.(\d{1})(\d+)' )
    {
        $msg = ".NET SDK version ""$($Version)"" is invalid. The SDK version must be in the form of a 3-part version " +
               "number. See https://learn.microsoft.com/en-us/dotnet/core/versions/ for more information."
        Write-WhiskeyError -Message $msg
        return
    }

    $desiredVersion = [Version] "$($Matches[1]).$($Matches[2]).$($Matches[3]).$($Matches[4])"
    $sortedSdkVersions =
        $sdkVersions |
        ForEach-Object {
            $_ -match '^(\d+)\.(\d+)\.(\d{1})(\d+)' | Out-Null
            [Version] "$($Matches[1]).$($Matches[2]).$($Matches[3]).$($Matches[4])"
        } |
        Sort-Object -Descending

    $resolvedVersion = $null
    switch ($RollForward)
    {
        Disable
        {
            $resolvedVersion =
                $sortedSdkVersions |
                Where-Object { $_ -eq $desiredVersion } |
                Select-Object -First 1
        }
        {
            $_ -eq [WhiskeyDotNetSdkRollForward]::Patch -or
            $_ -eq [WhiskeyDotNetSdkRollForward]::Feature -or
            $_ -eq [WhiskeyDotNetSdkRollForward]::Major -or
            $_ -eq [WhiskeyDotNetSdkRollForward]::Minor -or
            $_ -eq [WhiskeyDotNetSdkRollForward]::LatestPatch
        }
        {
            $resolvedVersion =
                $sortedSdkVersions |
                Where-Object {
                    $_.Major -eq $desiredVersion.Major -and
                    $_.Minor -eq $desiredVersion.Minor -and
                    $_.Build -eq $desiredVersion.Build
                } |
                Select-Object -First 1
        }
        LatestFeature
        {
            $resolvedVersion =
                $sortedSdkVersions |
                Where-Object {
                    $_.Major -eq $desiredVersion.Major -and
                    $_.Minor -eq $desiredVersion.Minor -and
                    $_.Build -ge $desiredVersion.Build
                } |
                Select-Object -First 1
        }
        {
            $_ -eq [WhiskeyDotNetSdkRollForward]::LatestMinor -or
            $_ -eq [WhiskeyDotNetSdkRollForward]::LatestMajor
        }
        {
            $resolvedVersion = $release | Select-Object -ExpandProperty 'latest-sdk'
        }
        Default
        {
            $validStrategies = [WhiskeyDotNetSdkRollForward].GetEnumNames()
            $msg = "Roll forward strategy $($RollForward) is not one of the valid .NET SDK roll forward strategies: " +
                   "$($validStrategies -join ', ')."
            Write-WhiskeyError -Message $msg
            return
        }
    }

    if ( -not $resolvedVersion)
    {
        Write-WhiskeyError -Message ('A released version of the .NET Core SDK matching "{0}" could not be found in "{1}" with rollForward value in global.json set to {2}' -f $Version, $releasesJsonUri, $RollForward)
        return
    }

    if ( $resolvedVersion -is [Version] )
    {
        $resolvedVersion = '{0}.{1}.{2}{3:00}' -f $resolvedVersion.Major, $resolvedVersion.Minor, $resolvedVersion.Build, $resolvedVersion.Revision
    }

    Write-WhiskeyVerbose -Message ('[{0}] SDK version "{1}" resolved to "{2}' -f $MyInvocation.MyCommand, $Version, $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-WhiskeyRelativePath
{
    [CmdletBinding()]
    param(
        [Parameter(Mandatory, ValueFromPipeline, ValueFromPipelineByPropertyName)]
        [String] $Path
    )

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

        $realPath = Resolve-Path -Path $Path -ErrorAction Ignore | Select-Object -ExpandProperty 'ProviderPath'
        if( $realPath )
        {
            $Path = $realPath
        }

        if( -not [IO.Path]::IsPathRooted($Path) )
        {
            $context = Get-WhiskeyContext
            $Path = Join-Path -Path $Context.BuildRoot.FullName -ChildPath $Path
        }

        $currentDir = (Get-Location).Path
        $currentDir = $currentDir.TrimEnd([IO.Path]::DirectorySeparatorChar, [IO.Path]::AltDirectorySeparatorChar)
        $currentDir = "$($currentDir)$([IO.Path]::DirectorySeparatorChar)"

        $ignoreCase = $IsWindows

        if( $Path.StartsWith($currentDir, $ignoreCase, [cultureinfo]::CurrentCulture) )
        {
            $Path = ".$($Path.Substring(($currentDir.Length - 1)))"
        }

        return $Path
    }
}



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 -ErrorAction Stop |
                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 = [Text.StringBuilder]::New((Join-Path -Path $parentPath -ChildPath $childName))
        for( $idx = $caseSensitivePath.Length - 1; $idx -ge 0; --$idx )
        {
            $char = $caseSensitivePath[$idx]
            $isUpper = [char]::IsUpper($char)
            $isLower = [char]::IsLower($char)
            if( -not ($isUpper -or $isLower) )
            {
                # Not a character so move on to the next.
                continue
            }

            if( $isUpper )
            {
                $caseSensitivePath[$idx] = [char]::ToLower($char)
                break
            }

            $caseSensitivePath[$idx] = [char]::ToUpper($char)
        }
        $caseSensitive = -not (Test-Path -Path $caseSensitivePath.ToString())
        # 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( $null -eq $InputObject -or $InputObject -is [scriptblock] )
        {
            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-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 $script:psModulesDirectoryName
    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(
        # The tool attribute that defines what tool to uninstall.
        [Parameter(Mandatory, ParameterSetName='Tool')]
        [Whiskey.RequiresToolAttribute] $ToolInfo,

        # The name of the NuGet package to uninstall.
        [Parameter(Mandatory, ParameterSetName='NuGet')]
        [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

    function Remove-NuGetPackage
    {
        $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
        }
    }
    
    if( $PSCmdlet.ParameterSetName -eq 'NuGet' )
    {
        Remove-NuGetPackage
        return
    }

    $provider = $ToolInfo.ProviderName
    $name = $ToolInfo.Name

    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.
        }
        'NuGet'
        {
            Remove-NuGetPackage
        }
        '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 )
}



function Unregister-WhiskeyPSModulePath
{
    [CmdletBinding()]
    param(
        [Parameter(Mandatory,ParameterSetName='FromUser')]
        [String]$Path,

        [Parameter(Mandatory,ParameterSetName='FromWhiskey')]
        [String]$PSModulesRoot
    )

    Set-StrictMode -Version 'Latest'
    Use-CallerPreference -Cmdlet $PSCmdlet -SessionState $ExecutionContext.SessionState
    
    if( $PSCmdlet.ParameterSetName -eq 'FromWhiskey' )
    {
        $Path = Get-WhiskeyPSModulePath -PSModulesRoot $PSModulesRoot
    }

    $pathBefore = $env:PSModulePath -split [IO.Path]::PathSeparator
    try
    {
        $modulePaths = $pathBefore | Where-Object { $_ -ne $Path }
        $env:PSModulePath = $modulePaths -join [IO.Path]::PathSeparator
    }
    finally
    {
        Write-WhiskeyDebug "[Unregister-WhiskeyPSModulePath] Changes to PSModulePath:"
        $pathNow = $env:PSModulePath -split [IO.Path]::PathSeparator
        $diff = Compare-Object -ReferenceObject $pathBefore -DifferenceObject $pathNow -IncludeEqual
        if( $diff )
        {
            $diff | Format-Table -AutoSize | Out-String | Write-WhiskeyDebug
        }
    }
}

# 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(
        [Whiskey.Context] $Context,

        [String] $Path,

        [Object[]] $ArgumentList
    )

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

    # Might have passed array of arrays.
    $ArgumentList = & {
        $ArgumentList | ForEach-Object { $_ | Write-Output }
    }

    $logArgumentList = & {
            if( $Path -match '\ ' )
            {
                '&'
            }
            $Path
            $ArgumentList
        } |
        Where-Object { $null -ne $_ } |
        ForEach-Object {
            if ($_ -match '\ |;' -or $_ -eq '')
            {
                '"{0}"' -f $_.Trim('"',"'")
            }
            else
            {
                $_
            }
        }

    Write-WhiskeyInfo -Context $Context -Message ($logArgumentList -join ' ')
    Write-WhiskeyVerbose -Context $Context -Message $Path
    $argumentPrefix = ' '
    foreach( $argument in $ArgumentList )
    {
        Write-WhiskeyVerbose -Context $Context -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 curren task. If the duration can't be determined, the current
    time is used.
 
    If `$DebugPreference` is set to `SilentlyContinue` or `Ignore`, `Write-WhiskeyDebug` immediately returns.
 
    You can pass messages to the `Message` parameter, or pipe messages to `Write-WhiskeyDebug`.
 
    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.
 
    .EXAMPLE
    $messages | Write-WhiskeyDebug -Context $context
 
    Demonstrates how to pipe messages to `Write-WhiskeyDebug`.
    #>

    [CmdletBinding(DefaultParameterSetName='NoIndent')]
    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,

        [Parameter(Mandatory, ParameterSetName='Indent')]
        [switch] $Indent,

        [Parameter(Mandatory, ParameterSetName='Outdent')]
        [switch] $Outdent
    )

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

        if( $Outdent )
        {
            $script:indentLevel -= 1
        }

        $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( $indentLevel -gt 0 )
        {
            $prefix = ' ' * ($indentLevel * 4)
            foreach( $line in ($Message -split '\n\r?') )
            {
                $newMsg = "$($prefix)$($line)"

                if( $PSCmdlet.MyInvocation.ExpectingInput )
                {
                    [void]$messages.Add($newMsg)
                    return
                }
            
                Write-WhiskeyInfo -Context $Context -Message $newMsg -Level 'Debug'
            }

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

    end
    {
        try
        {
            if( -not $write )
            {
                return
            }
            
            if( $messages )
            {
                Write-WhiskeyInfo -Context $Context -Level 'Debug' -Message $messages
            }
        }
        finally
        {
            if( $Indent )
            {
                $script:indentLevel += 1
            }
        }
    }
}



function Write-WhiskeyError
{
    <#
    .SYNOPSIS
    Logs error messages.
 
    .DESCRIPTION
    The `Write-WhiskeyError` function writes error messages using PowerShell's `Write-Error` cmdlet. 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 current task.
 
    You may pass multiple message to the `Message` parameter or pipe messages to `Write-WhiskeyError`. All messages are
    joined together with newlines before `Write-Error` is called.
     
    By default, error messages do *not* stop a build. If you want to log an error *and* fail/stop a build, use
    the `Stop-WhiskeyTask` function.
 
    If `$ErrorActionPreference` is `Ignore`, `Write-WhiskeyError` does no work and immediately returns
 
    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`. If you do, all the messages will be combined with
    a newline before calling `Write-Error`.
    #>

    [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. Messages are prefixed with the duration of the current build and current task.
 
    By default, Whiskey sets the `InformationPreference` to `Continue` for all builds so all information messages will
    be visible. To hide information messages, you must call `Invoke-WhiskeyBuild` with `-InformationAction` set to
    `Ignore`.
 
    You may pass multiple messages to the `Message` property, or pipe messages to `Write-WhiskeyInfo`.
 
    If `$InformationPreference` is `Ignore`, Whiskey does no work and immediately returns.
 
    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.
 
    .EXAMPLE
    $output | Write-WhiskeyInfo -Context $context
 
    Demonstrates that you can pipe messages to `Write-WhiskeyInfo`.
    #>

    [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,

        [switch] $NoIndent,

        [switch] $NoTiming
    )

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

        $isError = $Level -eq 'Error'
        $isInfo = $Level -eq 'Info'
        $isWarn = $Level -eq 'Warning'
        $isVerbose = $Level -eq 'Verbose'
        $isDebug = $Level -eq 'Debug'

        # 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 = ($isError -and $ErrorActionPreference -ne [Management.Automation.ActionPreference]::Ignore) -or
                 ($IsWarn -and $WarningPreference -ne [Management.Automation.ActionPreference]::Ignore) -or
                 ($isInfo -and $InformationPreference -ne [Management.Automation.ActionPreference]::Ignore) -or
                 ($isVerbose -and $VerbosePreference -notin @([Management.Automation.ActionPreference]::Ignore,[Management.Automation.ActionPreference]::SilentlyContinue)) -or
                 ($isDebug -and $DebugPreference -notin @([Management.Automation.ActionPreference]::Ignore,[Management.Automation.ActionPreference]::SilentlyContinue))

        if( -not $write )
        {
            return
        }

        $errorMsgs = [Collections.ArrayList]::New()

        if( $isError )
        {
            return
        }

        # Don't put timings in error or warning messages
        if ($IsError -or $isWarn)
        {
            $NoTiming = $true
        }

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

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

        $prefix = ''

        $indent = $taskWriteIndent
        if( $NoIndent -or -not $isInfo )
        {
            $indent = ''
        }
    }

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

        foreach( $msg in $Message )
        {
            if( $isError )
            {
                [void]$errorMsgs.Add($msg)
                continue
            }

            $prefix = ''

            if (-not $NoTiming)
            {
                if( $Context )
                {
                    $prefix =
                        "[$($Context.BuildStopwatch | Format-Stopwatch)] [$($Context.TaskStopwatch | Format-Stopwatch)]"
                }
                else
                {
                    $prefix = "[$((Get-Date).ToString('HH:mm:ss'))]"
                }
            }

            $separator = ' '
            $thisMsgIndent = $indent
            if (-not $msg -or $isWarn)
            {
                $separator = ''
                $thisMsgIndent = ''
            }

            if (-not $isInfo)
            {
                $thisMsgIndent = ''
            }

            & $writeCmd "$($prefix)$($separator)$($thisMsgIndent)$($msg)"
        }
    }

    end
    {
        if( $write -and $isError -and $errorMsgs.Count )
        {
            $errorMsg = $errorMsgs -join [Environment]::NewLine
            Write-Error -Message $errorMsg
        }
    }
}



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:
 
        OutputFile .output\pester.xml
        OutputFormat JUnitXml
        PassThru True
        Script .\PassingTests.ps1
                      .\OtherPassingTests.ps1
        Show None
        TestName PassingTests
 
    .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 current task. Multiple messages may be passed to the
    `Message` parameter or piped to `Write-WhiskeyVerbose`.
 
    If `$VerbosePreference` is set to `SilentlyContinue` or `Ignore`, `Write-WhiskeyVerbose` does no work and
    immediately returns
 
    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 to write a verbose message.
 
    .EXAMPLE
    $messages | Write-WhiskeyVerbose -Context $context
 
    Demonstrates that you can pipe messages to `Write-WhiskeyVerbose`.
    #>

    [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 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 task. Multiple messages may be passed to the
    `Message` parameter or piped to `Write-WhiskeyWarning`.
 
    If the `$WarningPreference` is `Ignore`, `Write-WhiskeyWarning` does no work and immediately returns.
 
    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.
 
    .EXAMPLE
    $messages | Write-WhiskeyWarning -Context $context
 
    Demonstrates that you can pipe messages to `Write-WhiskeyWarning`.
    #>

    [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 )
    {
        $ProgressPreference = [Management.Automation.ActionPreference]::SilentlyContinue
        $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,

        [switch] $NoLog
    )

    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;
        Name = $command;
        ArgumentList = $TaskParameter['Argument'];
        NoLog = $NoLog;
    }

    if ( $TaskParameter.ContainsKey('DotNetPath') )
    {
        $invokeParameters['DotNetPath'] = $TaskParameter['DotNetPath']
    }

    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, DefaultParameterName='Command')]
    param(
        [Parameter(Mandatory)]
        [Whiskey.Context]$TaskContext,

        [String] $Path,

        [String[]] $Argument,

        [String] $Command,

        [String[]] $SuccessExitCode
    )

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

    if ($Command)
    {
        $regExMatches = Select-String -InputObject $Command -Pattern '([^\s"'']+)|("[^"]*")|(''[^'']*'')' -AllMatches
        [String[]]$cmdTokens =
            $regExMatches.Matches.Groups |
            Where-Object 'Name' -NE '0' |
            Where-Object 'Success' -EQ $true |
            Select-Object -ExpandProperty 'Value'

        $Path = $cmdTokens | Select-Object -First 1
        if ($cmdTokens.Count -gt 1)
        {
            $Argument = $cmdTokens | Select-Object -Skip 1 | ForEach-Object { $_.Trim("'",'"') }
        }
    }

    if (-not $Path)
    {
        $msg = 'Property "Command" or "Path" is mandatory. Command should be the command to run, with arguments. ' +
               'Path should be the Path to an executable you want the Exec task to run along with arguments given ' +
               'with the Argument parameter, e.g.
 
    Build:
    - cmd.exe /c echo ''HELLO WORLD''
    - Exec: cmd.exe /c echo ''HELLO WORLD''
    - Exec:
        Command: cmd.exe /C echo ''HELLO WORLD''
    - Exec:
        Path: cmd.exe
        Argument: [ ''/c'', ''echo "HELLO WORLD"'' ]
 
    '

        Stop-WhiskeyTask -TaskContext $TaskContext -Message $msg
        return
    }

    $resolvedPath = $Path
    $cmd = Get-Command -Name $resolvedPath -CommandType Application -ErrorAction Ignore
    if (-not $cmd)
    {
        $resolvedPath = ''
    }

    if (-not $resolvedPath)
    {
        $resolvedPath =
            & {
                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 $resolvedPath)
    {
        $msg = "Executable ""${Path}"" 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.'
        Stop-WhiskeyTask -TaskContext $TaskContext -Message $msg
        return
    }

    if( ($resolvedPath | Measure-Object).Count -gt 1 )
    {
        $msg = "Unable to run executable ""${Path}"": it contains wildcards and resolves to the following files: " +
               """$($Path -join '","')""."
        Stop-WhiskeyTask -TaskContext $TaskContext -Message $msg
        return
    }

    Write-WhiskeyVerbose -Context $TaskContext -Message "${Path} -> ${resolvedPath}"

    Write-WhiskeyCommand -Context $TaskContext -Path $resolvedPath -ArgumentList $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.
    & $resolvedPath $Argument
    $exitCode = $LASTEXITCODE

    if (-not $SuccessExitCode)
    {
        $SuccessExitCode = '0'
    }

    foreach ($_successExitCode in $SuccessExitCode )
    {
        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 )
                    {
                        $msg = "Exit Code ${exitCode} < ${_successExitCode}"
                        Write-WhiskeyVerbose -Context $TaskContext -Message $msg
                        return
                    }
                }
                '<='
                {
                    if( $exitCode -le $_successExitCode )
                    {
                        $msg = "Exit Code ${exitCode} <= ${_successExitCode}"
                        Write-WhiskeyVerbose -Context $TaskContext -Message $msg
                        return
                    }
                }
                '>'
                {
                    if( $exitCode -gt $_successExitCode )
                    {
                        $msg = "Exit Code ${exitCode} > ${_successExitCode}"
                        Write-WhiskeyVerbose -Context $TaskContext -Message $msg
                        return
                    }
                }
                '>='
                {
                    if( $exitCode -ge $_successExitCode )
                    {
                        $msg = "Exit Code ${exitCode} >= ${_successExitCode}"
                        Write-WhiskeyVerbose -Context $TaskContext -Message $msg
                        return
                    }
                }
            }
        }

        if ($_successExitCode -match '^(\d+)\.\.(\d+)$')
        {
            if( $exitCode -ge [int]$Matches[1] -and $exitCode -le [int]$Matches[2] )
            {
                $msg = "Exit Code $($Matches[1]) <= ${exitCode} <= $($Matches[2])"
                Write-WhiskeyVerbose -Context $TaskContext -Message $msg
                return
            }
        }
    }

    $msg = """${resolvedPath}"" returned with an exit code of ""${exitCode}"". View the build output to see why the " +
           'executable''s process failed.'
    Stop-WhiskeyTask -TaskContext $TaskContext -Message $msg
}


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 = Get-WhiskeyPSModulePath -PSModulesRoot $TaskContext.BuildRoot -Create
        $Path = $Path | Resolve-Path -Relative
    }

    $module = Find-WhiskeyPowerShellModule -Name $Name `
                                           -Version $Version `
                                           -BuildRoot $TaskContext.BuildRoot `
                                           -AllowPrerelease:$AllowPrerelease `
                                           -ErrorAction Stop
    if( -not $module )
    {
        return
    }

    # PackageManagement/PowerShellGet functions don't like relative paths.
    $fullPath = $Path | Resolve-Path | Select-Object -ExpandProperty 'ProviderPath'

    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
        {
            $ProgressPreference = [Management.Automation.ActionPreference]::SilentlyContinue
            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')]
    param(
        [Parameter(Mandatory)]
        [Whiskey.Context]$TaskContext,

        [String]$Version,

        [switch]$Force
    )

    if( $Force -or $Version )
    {
        # Skips install if specified version is already installed
        Install-WhiskeyNode -InstallRootPath $TaskContext.BuildRoot `
                            -Version $Version `
                            -OutFileRootPath $TaskContext.OutputDirectory
    }
}


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')]
    [Whiskey.RequiresNuGetPackage('NuGet.CommandLine', Version='6.3.1', VersionParameterName='NuGetVersion',
        PathParameterName='NuGetPath')]
    [CmdletBinding()]
    param(
        [Whiskey.Context]$TaskContext,

        [hashtable]$TaskParameter,

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

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

        [String] $NuGetPath
    )

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

    #setup
    $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']
        }
    }

    $NuGetPath = Join-Path -Path $NuGetPath -ChildPath 'tools\NuGet.exe' -Resolve
    if( -not $NuGetPath )
    {
        Stop-WhiskeyTask -TaskContext $TaskContext -Message "NuGet.exe not found at ""$($nugetPath)""."
        return
    }

    foreach( $projectPath in $Path )
    {
        Write-WhiskeyVerbose -Context $TaskContext -Message (' {0}' -f $projectPath)
        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-WhiskeyCommand -Path $NuGetPath -ArgumentList 'restore', $projectPath
                & $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 = & {
            if ($property)
            {
                Write-WhiskeyVerbose "Escaping MSBuild property values."
            }

            foreach ($item in $property)
            {
                $name,$value = $item -split '=',2
                # Unescape first in case the there's already an escaped character in there.
                $value = [Uri]::UnescapeDataString($value)
                $value = [Uri]::EscapeDataString($value)
                Write-WhiskeyVerbose " ${item} -> ${name}=${value}"
                "/p:${name}=${value}"
            }
        }

        $targetArg = '/t:{0}' -f ($target -join ';')

        Write-WhiskeyCommand -Path $msbuildExepath `
                             -ArgumentList (& { $projectPath ; $targetArg ; $propertyArgs ; $msbuildArgs })
        & $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.RequiresNodeModule('license-checker', PathParameterName='LicenseCheckerPath',
        VersionParameterName='LicenseCheckerVersion')]
    [Whiskey.RequiresNodeModule('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.RequiresNodeModule('license-checker', PathParameterName='LicenseCheckerPath')]
    param(
        [Parameter(Mandatory)]
        [Whiskey.Context]$TaskContext,

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

        [String[]]$Arguments
    )

    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')
    Invoke-Command -NoNewScope -ScriptBlock {
        & $nodePath $licenseCheckerPath $Arguments 
    }
    if( $LASTEXITCODE -eq 1 )
    {
        Stop-WhiskeyTask -TaskContext $TaskContext -Message "license-checker returned a non-zero exit code. See above output for more details."
        return
    }
    
    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.RequiresNodeModule('nsp', PathParameterName='NspPath')]
    [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.RequiresNodeModule('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')]
    [Whiskey.RequiresNuGetPackage('NuGet.CommandLine', Version='6.10.*', PathParameterName='NuGetPath')]
    [CmdletBinding()]
    param(
        [Parameter(Mandatory)]
        [Whiskey.Context]$TaskContext,

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

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

        [String] $NuGetPath
    )

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

    $symbols = $TaskParameter['Symbols'] | ConvertFrom-WhiskeyYamlScalar
    $symbolsArg = $null
    $symbolsFileNameSuffix = ''
    if ($symbols)
    {
        $symbolsArg = '-Symbols'
        $symbolsFileNameSuffix = '.symbols'
    }

    $NuGetPath = Join-Path -Path $NuGetPath -ChildPath 'tools\NuGet.exe' -Resolve
    if( -not $NuGetPath )
    {
        Stop-WhiskeyTask -Context $TaskContext -Message "NuGet.exe not found at ""$($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

        $configPropertyArg = "Configuration=${configuration}"
        Write-WhiskeyCommand -Path $NuGetPath `
                             -ArgumentList @(
                                'pack',
                                '-Version',
                                $packageVersion,
                                '-OutputDirectory',
                                $TaskContext.OutputDirectory,
                                $symbolsArg,
                                $configPropertyArg,
                                $propertiesArgs,
                                $pathItem
                             )
        & $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)]
    [Whiskey.RequiresNuGetPackage('NuGet.CommandLine', Version='6.10.*', PathParameterName='NuGetPath')]
    [CmdletBinding()]
    param(
        [Parameter(Mandatory)]
        [Whiskey.Context]$TaskContext,

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

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

        [String] $NuGetPath
    )

    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 = Join-Path -Path $NuGetPath -ChildPath 'tools\NuGet.exe' -Resolve
    if( -not $NuGetPath )
    {
        Stop-WhiskeyTask -Context $TaskContext -Message "NuGet.exe not found at ""$($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
        {
            $ProgressPreference = [Management.Automation.ActionPreference]::SilentlyContinue
            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 $_
                $msg = "Unknown failure checking if $($packageName) $($packageVersion) package already exists at " +
                       "$($packageUri): $($_)"
                Stop-WhiskeyTask -TaskContext $TaskContext -Message $msg
                return
            }

            $response = $_.Exception.Response
            if( -not ($response | Get-Member 'StatusCode') )
            {
                Write-Error -ErrorRecord $_
                $msg = "Unable to determine HTTP status code from failed HTTP response to $($packageUri) checking if " +
                       "$($packageName) $($packageVersion) exists: $($_)"
                Stop-WhiskeyTask -TaskContext $TaskContext -Message $msg
                return
            }

            if( $response.StatusCode -ne [Net.HttpStatusCode]::NotFound )
            {
                $content = ''
                if( $response | Get-Member 'GetResponseStream' )
                {
                    $responseStream = $response.GetResponseStream()
                    $responseStream.Position = 0
                    $reader = New-Object 'IO.StreamReader' $responseStream
                    $content = $reader.ReadToEnd() -replace '<[^>]+?>',''
                    $reader.Close()
                    $response.Close()
                }
                $msg = "Failure checking if $($packageName) $($packageVersion) package already exists at " +
                       "$($packageUri). The web request returned status code $($response.StatusCode) " +
                       "($([int]$response.StatusCode)) status code: $($content)"
                Stop-WhiskeyTask -TaskContext $TaskContext -Message $msg
                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 -Url $source -ApiKey $apiKey -NuGetPath $NuGetPath

        if( -not ($TaskParameter['SkipUploadedCheck'] | ConvertFrom-WhiskeyYamlScalar) )
        {
            try
            {
                $ProgressPreference = [Management.Automation.ActionPreference]::SilentlyContinue
                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')]
    [Whiskey.RequiresNuGetPackage('NuGet.CommandLine', Version='6.10.*', PathParameterName='NuGetPath')]
    param(
        [Parameter(Mandatory)]
        [Whiskey.Tasks.ValidatePath(Mandatory)]
        [String[]] $Path,

        [String[]] $Argument,

        [String] $Version,

        [String] $NuGetPath
    )

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

    $NuGetPath = Join-Path -Path $NuGetPath -ChildPath 'tools\NuGet.exe' -Resolve
    if( -not $NuGetPath )
    {
        Stop-WhiskeyTask -Context $TaskContext -Message "NuGet.exe not found at ""$($NuGetPath)""."
        return
    }

    foreach( $item in $Path )
    {
        & $nuGetPath 'restore' $item $Argument
    }
}


function Invoke-WhiskeyNUnit2Task
{
    [Whiskey.Task('NUnit2', Platform='Windows')]
    [Whiskey.RequiresNuGetPackage('NUnit.Runners', Version='2.*', PathParameterName='NUnitPath')]
    [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,

        [String] $NUnitPath
    )

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

    $includeParam = $null
    if( $TaskParameter.ContainsKey('Include') )
    {
        $includeParam = '/include={0}' -f $TaskParameter['Include'].Trim('"')
    }

    $excludeParam = $null
    if( $TaskParameter.ContainsKey('Exclude') )
    {
        $excludeParam = '/exclude={0}' -f $TaskParameter['Exclude'].Trim('"')
    }

    $frameworkParam = '4.0'
    if( $TaskParameter.ContainsKey('Framework') )
    {
        $frameworkParam = $TaskParameter['Framework']
    }
    $frameworkParam = '/framework={0}' -f $frameworkParam

    $nunitToolsRoot = Join-Path -Path $NUnitPath -ChildPath 'tools'
    $nunitConsolePath = Join-Path -Path $nunitToolsRoot -ChildPath 'nunit-console.exe'
    if( -not (Test-Path -Path $nunitConsolePath) )
    {
        $msg = "NUnit doesn't exist at ""$($nunitConsolePath)""."
        Stop-WhiskeyTask -TaskContext $TaskContext -Message $msg
        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 | Resolve-Path -Relative) `
                            -ChildPath ('nunit2+{0}.xml' -f [IO.Path]::GetRandomFileName())

    $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-WhiskeyDebug -Context $TaskContext -Message ('Running NUnit')
    Write-WhiskeyCommand -Path $nunitConsolePath `
                         -ArgumentList $Path,$frameworkParam,$includeParam,$excludeParam,$extraArgs,"/xml=${reportPath}"
    & $nunitConsolePath $Path $frameworkParam $includeParam $excludeParam $extraArgs ('/xml={0}' -f $reportPath)
    Write-WhiskeyVerbose -Message "$($nunitConsolePath | Resolve-Path -Relative) exited with code $($LastExitCode)."
    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', Platform='Windows')]
    [Whiskey.RequiresNuGetPackage('NUnit.Console', Version='3.*')]
    [Whiskey.RequiresNuGetPackage('NUnit.ConsoleRunner', Version='3.*', PathParameterName='NUnitPath')]
    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,

        [String] $NUnitPath,

        [String] $OpenCoverPath,

        [String] $ReportGeneratorPath

    )

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

    $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 | Resolve-Path -Relative) `
                             -ChildPath ('{0}+{1}.xml' -f  $reportFormat, [IO.Path]::GetRandomFileName())
    $nunitReportParam = '--result={0};format={1}' -f $nunitReport, $reportFormat


    $framework = 'net-4.0'
    if ($TaskParameter['Framework'])
    {
        $framework = $TaskParameter['Framework']
    }
    $frameworkParam = '--framework={0}' -f $framework

    $testFilter = ''
    $testFilterParam = $null
    if ($TaskParameter['TestFilter'])
    {
        $testFilter = $TaskParameter['TestFilter'] | ForEach-Object { '({0})' -f $_ }
        $testFilter = $testFilter -join ' or '
        $testFilterParam = '--where={0}' -f $testFilter
    }

    $nunitExtraArgument = $null
    if ($TaskParameter['Argument'])
    {
        $nunitExtraArgument = $TaskParameter['Argument']
    }

    $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
    }

    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
        }
    }

    $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)

    $nunitExitCode = 0

    Write-WhiskeyCommand -Path $nunitConsolePath `
                         -ArgumentList $Path, $frameworkParam, $testFilterParam, $nunitReportParam, $nunitExtraArgument
    & $nunitConsolePath $Path $frameworkParam $testFilterParam $nunitReportParam $nunitExtraArgument
    $nunitExitCode = $LASTEXITCODE
    if( $nunitExitCode -ne 0 )
    {
        if (-not (Test-Path -Path $nunitReport -PathType Leaf))
        {
            $msg = "NUnit didn't run successfully: NUnit returned exit code ""$($nunitExitCode)""."
            Stop-WhiskeyTask -TaskContext $TaskContext -Message $msg
            return
        }
        else
        {
            $msg = "NUnit tests failed: NUnit returned exit code ""$($nunitExitCode)""."
            Stop-WhiskeyTask -TaskContext $TaskContext -Message $msg
            return
        }
    }
}


function Invoke-WhiskeyParallelTask
{
    [CmdletBinding()]
    [Whiskey.Task('Parallel')]
    param(
        [Parameter(Mandatory)]
        [Whiskey.Context] $TaskContext,

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

        [TimeSpan] $Timeout = (New-TimeSpan -Minutes 10)
    )

    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
        $numTimedOut = 0

        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

            $taskPathsTasks =
                $queue['Tasks'] |
                ForEach-Object {
                    $taskName,$taskParameter = ConvertTo-WhiskeyTask -InputObject $_ -ErrorAction Stop
                    [pscustomobject]@{
                        Name = $taskName;
                        Parameter = $taskParameter
                    }
                }

            $taskModulePaths =
                Get-WhiskeyTask |
                ForEach-Object { Get-Command -Name $_.CommandName } |
                Select-Object -ExpandProperty 'Module' |
                Select-Object -ExpandProperty 'Path' |
                Select-Object -Unique
            if( $taskModulePaths )
            {
                $msg = "Found $(($taskModulePaths | Measure-Object).Count) module(s) containing Whiskey tasks:"
                Write-WhiskeyDebug -Context $TaskContext -Message $msg
                $taskModulePaths | ForEach-Object { "* $($_)" } | Write-Debug
            }
            else
            {
                Write-WhiskeyDebug -Context $TaskContext -Message 'Found no loaded modules that contain Whiskey tasks.'
            }

            Write-WhiskeyInfo -Context $TaskContext -Message "Starting background job #$($queueIdx)."
            $job = Start-Job -ScriptBlock {

                    Set-StrictMode -Version 'Latest'

                    # Progress bars in background jobs seem to cause problems.
                    $Global:ProgressPreference = [Management.Automation.ActionPreference]::SilentlyContinue
                    $VerbosePreference = $using:VerbosePreference
                    $DebugPreference = $using:DebugPreference
                    $InformationPreference = $using:InformationPreference
                    $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-WhiskeyDebug -Context $context -Message ('Loading task from "{0}".' -f $info.FullName)
                        . $info.FullName
                    }

                    # Load modules containing third-party tasks.
                    foreach( $modulePath in $using:taskModulePaths )
                    {
                        Write-WhiskeyDebug -Context $context -Message "Loading task module ""$($modulePath)""."
                        Import-Module -Name $modulePath -Global
                    }

                    foreach( $task in $using:taskPathsTasks )
                    {
                        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
            [Void]$jobs.Add($job)
        }

        $taskDuration = [Diagnostics.Stopwatch]::StartNew()
        foreach( $job in $jobs )
        {
            $msg = "Watching background job #$($job.QueueIndex) $($job.Name)."
            Write-WhiskeyInfo -Context $TaskContext -Message $msg
            Write-WhiskeyDebug -Context $TaskContext -Message "Job #$($job.QueueIndex) $($job.Name)"
            do
            {
                Write-WhiskeyDebug -Context $TaskContext -Message " Waiting for 9 seconds."
                $completedJob = $job | Wait-Job -Timeout 9
                if( $job.HasMoreData )
                {
                    Write-WhiskeyDebug -Context $TaskContext -Message " Receiving output."
                    # 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
                }
                if( $completedJob )
                {
                    $duration = $job.PSEndTime - $job.PSBeginTime
                    $msg = "Background job #$($job.QueueIndex) $($job.Name) is ""$($job.State.ToString())"" in " +
                           "$([int]$duration.TotalMinutes)m$($duration.Seconds)s."
                    Write-WhiskeyInfo -Context $TaskContext -Message $msg
                    if( $job.JobStateInfo.State -ne [Management.Automation.JobState]::Completed )
                    {
                        $msg = "Background job #$($job.QueueIndex) $($job.Name) didn't finish successfully but ended " +
                               "in state ""$($job.State.ToString())""."
                        Stop-WhiskeyTask -TaskContext $TaskContext -Message $msg
                        return
                    }

                    break
                }

                if( $taskDuration.Elapsed -gt $Timeout )
                {
                    $duration = (Get-Date) - $job.PSBeginTime
                    $msg = "Background job #$($job.QueueIndex) $($job.Name) is still running after " +
                           "$([int]$duration.TotalMinutes)m$($duration.Seconds)s which is longer than the " +
                           "$([int]$Timeout.TotalMinutes)m$($Timeout.Seconds)s timeout. It's final state is " +
                           """$($job.State.ToString())""."
                    Write-WhiskeyError -Context $TaskContext -Message $msg
                    $numTimedOut += 1
                    break
                }
            }
            while( $true )
        }

        if( $numTimedOut )
        {
            $msg = "$($numTimedOut) background jobs timed out without completing in $([int]$Timeout.TotalMinutes)m" +
                   "$($Timeout.Seconds)s."
            Stop-WhiskeyTask -TaskContext $TaskContext -Message $msg
            return
        }
    }
    finally
    {
        $jobs | Stop-Job
        $jobs | Remove-Job
    }
}



function Invoke-WhiskeyPesterTask
{
    [Whiskey.Task('Pester')]
    [Whiskey.RequiresPowerShellModule('Pester', Version='5.*', ModuleInfoParameterName='PesterModuleInfo', SkipImport)]
    [CmdletBinding()]
    param(
        [Parameter(Mandatory)]
        [Whiskey.Context] $TaskContext,

        [Management.Automation.PSModuleInfo] $PesterModuleInfo,

        [hashtable] $Configuration,

        [hashtable] $Container
    )

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

    $pesterManifestPath = $PesterModuleInfo.Path

    $exitCodePath = Join-Path -Path $TaskContext.Temp -ChildPath 'exitcode'

    $cmdArgList = @{
        WorkingDirectory = (Get-Location).Path;
        PesterManifestPath = $pesterManifestPath;
        Configuration = $Configuration;
        Container = $Container;
        ExitCodePath = $exitCodePath;
        Preference = @{
            'VerbosePreference' = [String]$VerbosePreference;
            'DebugPreference' = [String]$DebugPreference;
            'ProgressPreference' = [String]$ProgressPreference;
            'WarningPreference' = [String]$WarningPreference;
            'ErrorActionPreference' = [String]$ErrorActionPreference;
            'InformationPreference' = [String]$InformationPreference;
        }
    }

    $cmdName = 'powershell'
    if ($PSVersionTable['PSEdition'] -eq 'Core')
    {
        $cmdName = 'pwsh'
    }
    $invokePesterPath = Join-Path -Path $script:whiskeyBinPath -ChildPath 'Invoke-Pester.ps1' -Resolve
    Write-WhiskeyDebug "Starting ${cmdName}"
    $parameterJson = $cmdArgList | ConvertTo-Json -Depth 100
    $parameterBytes = [Text.Encoding]::Unicode.GetBytes($parameterJson)
    $parameterBase64 = [Convert]::ToBase64String($parameterBytes)
    & $cmdName -NoProfile -NonInteractive -File $invokePesterPath -ParameterBase64 $parameterBase64
    Write-WhiskeyDebug "Done ${cmdName}"

    if( $Configuration.ContainsKey('TestResult') -and `
        $Configuration['TestResult'] -is [Collections.ICollection] -and `
        $Configuration['TestResult'].ContainsKey('OutputPath') )
    {
        Publish-WhiskeyPesterTestResult -Path $Configuration['TestResult']['OutputPath']
    }

    if (-not (Test-Path -Path $exitCodePath -PathType Leaf))
    {
        Stop-WhiskeyTask -TaskContext $TaskContext -Message "Pester task failed to run tests."
        return
    }

    [int] $exitCode = Get-Content -Path $exitCodePath -ReadCount 1
    if( $exitCode -ne 0 )
    {
        Stop-WhiskeyTask -TaskContext $TaskContext -Message "Tests failed with exit code $($exitCode)."
    }
}


function Invoke-WhiskeyPester3Task
{
    [Whiskey.Task('Pester3',Platform='Windows', Obsolete,
                  ObsoleteMessage='The "Pester3" task is obsolete and is no longer supported.')]
    [Whiskey.RequiresPowerShellModule('Pester', ModuleInfoParameterName='PesterModuleInfo', Version='3.*', 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 -WarningAction Ignore
                                    }

        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.*', 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, DefaultParameterName='ScriptBlock')]
    [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 = $PSBoundParameters.ContainsKey('ScriptBlock')

    if ((-not $Path -and -not $scriptBlockGiven) -or ($Path -and $scriptBlockGiven))
    {
        $msg = 'Property "Path" or "ScriptBlock" is mandatory, but not both. Path should be the path to a script to ' +
               'run, passing arguments to the "Argument" parameter. ScriptBlock should be a string of raw PowerShell ' +
               'to execute, e.g.,
 
    Build:
    - PowerShell: Write-Output ''HELLO WORLD''
    - PowerShell:
        ScriptBlock: Write-Output ''HELLO WORLD''
    - PowerShell:
        Path: hello-world.ps1
        Argument: [ ''arg1'', ''arg2'' ]
 
        '

        Stop-WhiskeyTask -TaskContext $TaskContext -Message $msg
        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
            $scriptBlockGiven = $using:scriptBlockGiven

            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

            $scriptPath = Resolve-Path -Path $scriptPath -Relative

            if( $scriptBlockGiven )
            {
                $message = ''
                $lines = Get-Content -Path $scriptPath
                if( ($lines | Measure-Object).Count -le 1 )
                {
                    Write-WhiskeyInfo -Context $context -Message ($lines | Select-Object -First 1)
                }
                else
                {
                    & {
                        '' | Write-Output
                        $lines | Write-Output
                        '' | Write-Output
                    } | Write-WhiskeyInfo -NoTiming
                }
            }
            else
            {
                $message = $scriptPath
                if( $message.Contains(' ') )
                {
                    $message = '& "{0}"' -f $message
                }
            }

            $contextArgument = @{ }
            if( $passTaskContext )
            {
                $contextArgument['TaskContext'] = $context
                if( $message )
                {
                    $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 $_
                    }
                if( $message )
                {
                    $message = '{0} {1}' -f $message,($argumentDesc -join ' ')
                }
            }

            if( $message )
            {
                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='3.*',
                                        VersionParameterName='ProGetAutomationVersion')]
    param(
        [Parameter(Mandatory)]
        [Whiskey.Context]$TaskContext,

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

        [Whiskey.Tasks.ValidatePath(PathType='Directory')]
        [String]$SourceRoot,

        [String[]] $Include,

        [String[]] $Exclude
    )

    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']

    if( $version )
    {
        [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
        }
        # If someone has provided their own version, use it.
        $version = New-WhiskeyVersionObject -SemVer $semVer
        $packageVersion = $semVer.ToString()
    }
    else
    {
        $version = $TaskContext.Version
        # ProGet uses build metadata to distinguish different versions (i.e. 2.0.1+build.1 is different than
        # 2.0.1+build.2), which means users could inadvertently release multiple versions of a package. Remove the
        # build metadata to prevent this. This should be what people expect most of the time.
        $packageVersion = $version.SemVer2NoBuildMetadata.ToString()
    }

    $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)
        }
    }

    $Include = $Include | Where-Object { $_ }
    if ($null -eq $Include)
    {
        $Include = @()
    }

    $Exclude = $Exclude | Where-Object { $_ }
    if ($null -eq $Exclude)
    {
        $Exclude = @()
    }

    function Write-PackagedItemInfo
    {
        [CmdletBinding(DefaultParameterSetName='Path')]
        param(
            [Parameter(Mandatory)]
            [String] $Path,

            [switch] $Unfiltered,

            [String] $DestinationPath,

            [Parameter(Mandatory, ParameterSetName='Included')]
            [switch] $Included,

            [Parameter(Mandatory, ParameterSetName='Excluded')]
            [switch] $Excluded
        )

        $flag = ''
        if ($Included -or $Excluded)
        {
            $flag = ' + '
            if ($Excluded)
            {
                $flag = ' - '
            }
        }

        if (Test-Path -Path $Path -PathType Container)
        {
            $childPath = '\'
            if ($Unfiltered)
            {
                $childPath = '\**'
            }
            $Path = Join-Path -Path $Path -ChildPath $childPath

            if ($Path -eq '.\')
            {
                $Path = '.'
            }
        }

        $destinationMsg = ''
        if ($DestinationPath)
        {
            $destinationMsg = " → ${DestinationPath}"
        }
        $msg = " ${flag}${Path}${destinationMsg}"
        Write-WhiskeyInfo -Context $TaskContext -Message $msg

    }

    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 }
                $destPathMsgArg = @{}
                if( $override )
                {
                    $addParams = @{ PackageItemName = $destinationItemName }
                    $destPathMsgArg['DestinationPath'] = $destinationItemName
                }
                $addParams['CompressionLevel'] = $compressionLevel

                if( $AsThirdPartyItem )
                {
                    Write-PackagedItemInfo -Path $sourcePath @destPathMsgArg -Unfiltered
                    Get-Item -Path $sourcePath |
                        Add-ProGetUniversalPackageFile -PackagePath $outFile @addParams -ErrorAction Stop
                    continue
                }

                if( (Test-Path -Path $sourcePath -PathType Leaf) )
                {
                    Write-PackagedItemInfo -Path $sourcePath @destPathMsgArg
                    Add-ProGetUniversalPackageFile -PackagePath $outFile -InputObject $sourcePath @addParams -ErrorAction Stop
                    continue
                }

                if (-not $Include)
                {
                    $msg = "Property ""Include"" is mandatory because ""${sourcePath}"" 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.'
                    Stop-WhiskeyTask -TaskContext $TaskContext -Message $msg
                    return
                }

                # include/exclude items that contain directory seperators should be matched against an item's path
                $nameIncPatterns = $Include | Where-Object { -not ($_ | Split-Path) }
                $pathIncPatterns =
                    $Include |
                    Where-Object { $_ | Split-Path } |
                    ForEach-Object { [wildcardpattern]::Unescape($_) } |
                    ForEach-Object { [wildcardpattern]::Escape($_) } |
                    Resolve-WhiskeyRelativePath |
                    ForEach-Object { [wildcardpattern]::Unescape($_) }
                $nameExcPatterns = $Exclude | Where-Object { -not ($_ | Split-Path) }
                $pathExcPatterns =
                    $Exclude |
                    Where-Object { $_ | Split-Path } |
                    ForEach-Object { [wildcardpattern]::Unescape($_) } |
                    ForEach-Object { [wildcardpattern]::Escape($_) } |
                    Resolve-WhiskeyRelativePath |
                    ForEach-Object { [wildcardpattern]::Unescape($_) }

                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 $nameIncPatterns -Exclude $nameExcPatterns -File
                            Get-Item -Path $Path -Exclude $nameExcPatterns | Where-Object { $_.PSIsContainer }
                        }  |
                        ForEach-Object {
                            if ($pathIncPatterns -or $pathExcPatterns)
                            {
                                # Resolve path-based include and exclude patterns using relative paths.
                                $_ | Add-Member -Name 'RelativePath' `
                                                -MemberType NoteProperty `
                                                -Value ($_ | Resolve-WhiskeyRelativePath)
                            }
                            return $_
                        } |
                        Where-Object {
                            if (-not $pathIncPatterns)
                            {
                                return $true
                            }

                            foreach ($pattern in $pathIncPatterns)
                            {
                                if ($_.RelativePath -like $pattern)
                                {
                                    return $true
                                }
                            }

                            Write-PackagedItemInfo -Path $_.RelativePath -Excluded
                            return $false
                        } |
                        Where-Object {
                            if (-not $pathExcPatterns)
                            {
                                return $true
                            }

                            foreach ($pattern in $pathExcPatterns)
                            {
                                if ($_.RelativePath -like $pattern)
                                {
                                    Write-PackagedItemInfo -Path $_.RelativePath -Excluded
                                    return $false
                                }
                            }

                            return $true
                        } |
                        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-PackagedItemInfo -Path $sourcePath @destPathMsgArg
                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 $packageVersion `
                               -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( $TaskContext.BuildMetadata.IsPullRequest )
    {
        'Skipping PublishBitbucketServerTag task: can''t tag a pull request commit because it doesn''t exist in the ' +
        'origin repostory, only on the build server.' | Write-WhiskeyVerbose
        return
    }

    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
    $msg = "Tagging commit ""$($commitHash)"" with ""$($tag)"" in Bitbucket Server ""$($projectKey)"" project's " +
           """$($repoKey)"" repository at $($TaskParameter['Uri'])."
    Write-WhiskeyInfo $msg
    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')]
    [CmdletBinding()]
    param(
        [Parameter(Mandatory)]
        [Whiskey.Context]$TaskContext,

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

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

        [String] $RepositoryName,

        [Alias('RepositoryUri')]
        [String] $RepositoryLocation,

        [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSAvoidUsingPlainTextForPassword', '')]
        [String] $CredentialID,

        [String] $ApiKeyID
    )

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

    $manifestPath = '{0}\{1}.psd1' -f $Path,($Path | Split-Path -Leaf)
    if( $ModuleManifestPath )
    {
        $manifestPath = $ModuleManifestPath
    }

    if( -not (Test-Path -Path $manifestPath -PathType Leaf) )
    {
        $msg = "Module manifest path ""$($manifestPath)"" either does not exist or is a directory."
        Stop-WhiskeyTask -TaskContext $TaskContext -Message $msg
        return
    }

    $manifest = Test-ModuleManifest -Path $manifestPath -ErrorAction Ignore -WarningAction 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')) )
    {
        $msg = "Module manifest ""$($manifest.Path)"" 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 = '';
            }
        }
    }
"

        Stop-WhiskeyTask -TaskContext $Context -Message $msg
        return
    }

    $manifestContent = Get-Content $manifest.Path
    $versionString = 'ModuleVersion = ''{0}.{1}.{2}''' -f ( $TaskContext.Version.SemVer2.Major, $TaskContext.Version.SemVer2.Minor, $TaskContext.Version.SemVer2.Patch )
    $manifestContent = $manifestContent -replace 'ModuleVersion\s*=\s*(''|")[^''"]*(''|")', $versionString
    $prereleaseString = 'Prerelease = ''{0}''' -f $TaskContext.Version.SemVer2.Prerelease
    $manifestContent = $manifestContent -replace 'Prerelease\s*=\s*(''|")[^''"]*(''|")', $prereleaseString
    $manifestContent | Set-Content $manifest.Path
    Publish-WhiskeyPSObject -Context $TaskContext -ModuleInfo $manifest -RepositoryName $RepositoryName `
        -RepositoryLocation $RepositoryLocation -CredentialID $CredentialID -ApiKeyId $ApiKeyID
}



function Publish-WhiskeyPowerShellScript
{
    [Whiskey.Task('PublishPowerShellScript')]
    [CmdletBinding()]
    param(
        [Parameter(Mandatory)]
        [Whiskey.Context] $TaskContext,

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

        [String] $RepositoryName,

        [Alias('RepositoryUri')]
        [String] $RepositoryLocation,

        [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSAvoidUsingPlainTextForPassword', '')]
        [String] $CredentialID,

        [String] $ApiKeyID
    )

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

    if( -not (Test-Path -Path $Path -PathType Leaf) )
    {
        $msg = "Script manifest path ""$($Path)"" either does not exist or is a directory."
        Stop-WhiskeyTask -TaskContext $TaskContext -Message $msg
        return
    }

    try
    {
        $scriptManifest = Test-ScriptFileInfo -Path $Path
    }
    catch
    {
        Stop-WhiskeyTask -TaskContext $TaskContext -Message $_
        return
    }
    $manifestContent = Get-Content $scriptManifest.Path
    $versionString = ".VERSION $($TaskContext.Version.SemVer2NoBuildMetadata)"
    $manifestContent = $manifestContent -replace '.VERSION\s[^''"]*', $versionString
    $manifestContent | Set-Content $scriptManifest.Path
    Publish-WhiskeyPSObject -Context $TaskContext -ScriptInfo $scriptManifest -RepositoryName $RepositoryName `
        -RepositoryLocation $RepositoryLocation -CredentialID $CredentialID -ApiKeyId $ApiKeyID
}


function Publish-WhiskeyProGetAsset
{
    [Whiskey.Task('PublishProGetAsset')]
    [Whiskey.RequiresPowerShellModule('ProGetAutomation',
                                        Version='3.*',
                                        VersionParameterName='ProGetAutomationVersion')]

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

        [String[]] $Path,

        [String[]] $AssetPath,

        [String] $AssetDirectory,

        [String] $CredentialID,

        [Alias('Uri')]
        [Uri] $Url,

        [String] $ContentType
    )

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

    $documentationMsg = "See the PublishProGetAsset task documentation for details: https://github.com/webmd-health-services/Whiskey/wiki/PublishProGetAsset-Task"

    if (-not $Path)
    {
        $msg = """Path"" is a mandatory property. It must be a list of relative paths to the files/directories to " +
               "upload to ProGet. Paths are relative to the whiskey.yml file. ${documentationMsg}"
        Stop-WhiskeyTask -TaskContext $TaskContext -Message $msg
        return
    }

    if (-not $AssetDirectory)
    {
        $msg = """AssetDirectory"" is a mandatory property. It must be the root asset directory in ProGet where the item " +
               "will be uploaded to. ${documentationMsg}"
        Stop-WhiskeyTask -TaskContext $TaskContext -Message $msg
        return
    }

    if (-not $CredentialID)
    {
        $msg = """CredentialID"" is a mandatory property. It should be the ID of the Whiskey credential to use when " +
               "connecting to ProGet. Add the credential to your build with the `Add-WhiskeyCredential` function. ${documentationMsg}"
        Stop-WhiskeyTask -TaskContext $TaskContext -Message $msg
        return
    }

    $credential = Get-WhiskeyCredential -Context $TaskContext -ID $CredentialID -PropertyName 'CredentialID'

    $session = New-ProGetSession -Uri $Url -Credential $credential -WarningAction Ignore

    $optionalArgs = @{}

    if ($ContentType)
    {
        $optionalArgs['ContentType'] = $ContentType
    }

    $assetDirName = $AssetDirectory
    Write-WhiskeyInfo $Url

    foreach($pathItem in $Path)
    {
        if ($AssetPath -and (($AssetPath | Measure-Object).Count -eq ($Path | Measure-Object).Count)) {
            $name = @($AssetPath)[$Path.indexOf($pathItem)]
        }
        else
        {
            $msg = "There must be the same number of ""Path"" items as ""AssetPath"" items. For each asset ""Path"" " +
                   "there must be a respective ""AssetPath"" item which will be the item's path within the ProGet " +
                   "asset directory. ${documentationMsg}"
            Stop-WhiskeyTask -TaskContext $TaskContext -Message $msg
            return
        }

        Write-WhiskeyInfo " $($pathItem | Resolve-WhiskeyRelativePath) -> ${assetDirName}/${name}"
        Set-ProGetAsset -Session $session -DirectoryName $assetDirName -Path $name -FilePath $pathItem @optionalArgs
    }
}



function Publish-WhiskeyProGetUniversalPackage
{
    [CmdletBinding()]
    [Whiskey.Task('PublishProGetUniversalPackage')]
    [Whiskey.RequiresPowerShellModule('ProGetAutomation',
                                      Version='3.*',
                                      VersionParameterName='ProGetAutomationVersion')]
    param(
        [Parameter(Mandatory)]
        [Whiskey.Context]$TaskContext,

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

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

        [Alias('Uri')]
        [Uri] $Url
    )

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

    $exampleTask = 'Publish:
        - PublishProGetUniversalPackage:
            CredentialID: ProGetCredential
            Url: 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 $Url)
    {
        $msg = 'Url is a mandatory property. It should be the URL to the ProGet instance where you want to publish ' +
               "your package:
 
    $exampleTask
               "

        Stop-WhiskeyTask -TaskContext $TaskContext -Message $msg
        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 $Url -Credential $credential -WarningAction Ignore

    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']

    $optionalParam = @{ }
    if( $TaskParameter['Timeout'] )
    {
        $optionalParam['Timeout'] = $TaskParameter['Timeout']
    }
    if( $TaskParameter['Overwrite'] )
    {
        $optionalParam['Force'] = $TaskParameter['Overwrite'] | ConvertFrom-WhiskeyYamlScalar
    }

    Write-WhiskeyInfo -Context $TaskContext -Message "${Url} ${feedName}"
    foreach( $package in $packages )
    {
        Write-WhiskeyInfo -Context $TaskContext -Message " $($package | Resolve-WhiskeyRelativePath)"
        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()]
    [Diagnostics.CodeAnalysis.SuppressMessage('PSAvoidUsingPlainTextForPassword', '')]
    [Whiskey.Task('Version', DefaultParameterName='Version')]
    [Whiskey.RequiresPowerShellModule('ProGetAutomation',
                                        Version='3.*',
                                        VersionParameterName='ProGetAutomationVersion',
                                        ModuleInfoParameterName='ProGetAutomationModuleInfo')]
    param(
        [Parameter(Mandatory)]
        [Whiskey.Context] $TaskContext,

        [String] $Version,

        [Object] $Prerelease,

        [String] $Build,

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

        [String] $NuGetPackageID,

        [Uri] $UPackFeedUrl,

        [Uri] $ProGetUrl,

        [String] $UPackFeedName,

        [String] $UPackFeedCredentialID,

        [String] $UPackFeedApiKeyID,

        [String] $UPackGroupName,

        [String] $UPackName,

        [switch] $IncrementPatchVersion,

        [switch] $IncrementPrereleaseVersion
    )

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

    function ConvertTo-SemVer
    {
        [CmdletBinding()]
        param(
            [Parameter(Mandatory,ValueFromPipeline)]
            $InputObject,

            $PropertyName,

            $VersionSource
        )

        process
        {
            [SemVersion.SemanticVersion]$semver = $null
            if( -not [SemVersion.SemanticVersion]::TryParse($InputObject, [ref]$semver) )
            {
                if( $VersionSource )
                {
                    $VersionSource = ' ({0})' -f $VersionSource
                }
                $optionalParam = @{ }
                if( $PropertyName )
                {
                    $optionalParam['PropertyName'] = $PropertyName
                }
                $msg = """$($InputObject)""$($VersionSource) is not a semantic version. See https://semver.org for " +
                       'documentation on semantic versions.'
                Stop-WhiskeyTask -TaskContext $TaskContext -Message $msg @optionalParam
                return
            }
            return $semver
        }
    }

    [int]$nextPrereleaseVersion = 1
    [Whiskey.BuildVersion]$buildVersion = $TaskContext.Version
    [SemVersion.SemanticVersion]$semver = $buildVersion.SemVer2
    [String[]] $versions = @()
    [bool] $skipPackageLookup = -not $IncrementPatchVersion -and -not $IncrementPrereleaseVersion

    if ($Version)
    {
        $rawVersion = $Version
        $semVer = $rawVersion | ConvertTo-SemVer -PropertyName 'Version'
    }
    else
    {
        if( $Path )
        {
            $fileInfo = Get-Item -Path $Path
            if( $fileInfo.Extension -eq '.psd1' )
            {
                $moduleManifest = Test-ModuleManifest -Path $Path -ErrorAction Ignore -WarningAction Ignore
                $rawVersion = $moduleManifest.Version
                if( -not $rawVersion )
                {
                    $msg = "Unable to read version from PowerShell module manifest ""$($Path)"": the manifest is invalid " +
                        'or doesn''t contain a "ModuleVersion" property.'
                    Stop-WhiskeyTask -TaskContext $TaskContext -Message $msg
                    return
                }

                $nextPrerelease = ''
                if( ($moduleManifest | Get-Member -Name 'Prerelease') )
                {
                    $nextPrerelease = $moduleManifest.Prerelease
                }
                elseif( $moduleManifest.PrivateData -and `
                        $moduleManifest.PrivateData.ContainsKey('PSData') -and `
                        $moduleManifest.PrivateData['PSData'].ContainsKey('Prerelease') )
                {
                    $nextPrerelease = $moduleManifest.PrivateData['PSData']['Prerelease']
                }

                if( $nextPrerelease )
                {
                    $rawVersion = "$($rawVersion)-$($nextPrerelease)"
                }

                $msg = "Read version ""$($rawVersion)"" from PowerShell module manifest ""$($Path)""."
                Write-WhiskeyVerbose -Context $TaskContext -Message $msg
                $semver = $rawVersion | ConvertTo-SemVer -VersionSource "from PowerShell module manifest ""$($Path)"""

                if( -not $skipPackageLookup )
                {
                    $msg = "Retrieving versions for PowerShell module $($moduleManifest.Name)."
                    Write-WhiskeyVerbose -Context $TaskContext -Message $msg
                    $allowPrereleaseArg = Get-AllowPrereleaseArg -CommandName 'Find-Module' -AllowPrerelease
                    $versions =
                        Find-Module -Name $moduleManifest.Name -AllVersions @allowPrereleaseArg -ErrorAction Ignore |
                        Select-Object -ExpandProperty 'Version'
                }
            }
            elseif( $fileInfo.Name -eq 'package.json' )
            {
                $npmPackage = [pscustomobject]::New()
                try
                {
                    $npmPackage = Get-Content -Path $Path -Raw | ConvertFrom-Json
                }
                catch
                {
                    $msg = "Node package.json file ""$($Path)"" contains invalid JSON."
                    Stop-WhiskeyTask -TaskContext $TaskContext -Message $msg
                    return
                }
                $rawVersion = $npmPackage | Select-Object -ExpandProperty 'Version' -ErrorAction Ignore
                if( -not $rawVersion )
                {
                    $msg = "Unable to read version from Node package.json ""$($Path)"": the ""Version"" property is " +
                        'missing.'
                    Stop-WhiskeyTask -TaskContext $TaskContext -Message $msg
                    return
                }
                $msg = "Read version ""$($rawVersion)"" from Node package.json ""$($Path)""."
                Write-WhiskeyVerbose -Context $TaskContext -Message $msg
                $semVer = $rawVersion | ConvertTo-SemVer -VersionSource "from Node package.json file ""$($Path)"""

                $pkgName = $npmPackage | Select-Object -ExpandProperty 'name' -ErrorAction Ignore
                if( $pkgName -and -not $skipPackageLookup )
                {
                    $msg = "Retrieving versions for NPM package $($pkgName)."
                    Write-WhiskeyVerbose -Context $TaskContext -Message $msg
                    Install-WhiskeyNode -InstallRootPath $TaskContext.BuildRoot `
                                        -OutFileRootPath $TaskContext.OutputDirectory
                    $packageVersions =
                        Invoke-WhiskeyNpmCommand -Name 'show' `
                                                 -ArgumentList @($pkgName, 'versions', '--json') `
                                                 -BuildRoot $TaskContext.BuildRoot `
                                                 -ForDeveloper:($TaskContext.ByDeveloper) `
                                                 -ErrorAction Ignore 2>$null |
                        ConvertFrom-Json

                    if ($packageVersions | Get-Member -Name 'error')
                    {
                        $errCode = $packageVersions.error | Select-Object -ExpandProperty 'code' -ErrorAction 'Ignore'
                        $errSummary = $packageVersions.error | Select-Object -ExpandProperty 'summary' -ErrorAction 'Ignore'

                        if ($errCode -eq 'E404')
                        {
                            $msg = "NPM package ""${pkgName}"" has never been published to the registry. No existing versions."
                        }
                        else
                        {
                            $msg = "Failed to retrieve versions for NPM package ""${pkgName}"": [${errCode}] ${errSummary}"
                        }

                        Write-WhiskeyVerbose -Context $TaskContext -Message $msg
                        $IncrementPrereleaseVersion = $false
                        $versions = $semver
                    }
                    else
                    {
                        $versions = $packageVersions
                    }
                }
            }
            elseif( $fileInfo.Extension -eq '.csproj' )
            {
                [xml]$csprojXml = $null
                try
                {
                    $csprojxml = Get-Content -Path $Path -Raw
                }
                catch
                {
                    $msg = ".NET .csproj file ""$($Path)"" contains invalid XMl."
                    Stop-WhiskeyTask -TaskContext $TaskContext -Message $msg
                    return
                }

                if( $csprojXml.DocumentElement.Attributes['xmlns'] )
                {
                    $msg = ".NET .csproj file ""$($Path)"" 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.'
                    Stop-WhiskeyTask -TaskContext $TaskContext -Message $msg
                    return
                }

                $csprojVersionNode = $csprojXml.SelectSingleNode('/Project/PropertyGroup/Version')
                if( -not $csprojVersionNode )
                {
                    $msg = "Element ""/Project/PropertyGroup/Version"" does not exist in .NET .csproj file ""$($Path)"". " +
                        'Please create this element and set it to the MAJOR.MINOR.PATCH version of the next version ' +
                        'of your assembly.'
                    Stop-WhiskeyTask -TaskContext $TaskContext -Message $msg
                    return
                }
                $rawVersion = $csprojVersionNode.InnerText
                $msg = "Read version ""$($rawVersion)"" from .csproj file ""$($Path)"".'"
                Write-WhiskeyVerbose -Context $TaskContext -Message $msg
                $semver = $rawVersion | ConvertTo-SemVer -VersionSource "from .csproj file ""$($Path)"""

                if( -not $skipPackageLookup )
                {
                    if( -not $NuGetPackageID )
                    {
                        $node = $csprojXml.SelectSingleNode('/Project/PropertyGroup/PackageId')
                        if( $node )
                        {
                            $NuGetPackageID = $node.InnerText
                        }
                    }

                    if( $NuGetPackageID )
                    {
                        $msg = "Retrieving versions for NuGet package ""$($NuGetPackageID)""."
                        Write-WhiskeyVerbose -Context $TaskContext -Message $msg
                        $allowPrereleaseArg = Get-AllowPrereleaseArg -CommandName 'Find-Package' -AllowPrerelease
                        $versions =
                            Find-Package -Name $NuGetPackageID -ProviderName 'NuGet' -AllVersions @allowPrereleaseArg |
                            Select-Object -ExpandProperty 'Version'
                    }
                }
            }
            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 )
                {
                    $msg = "Unable to locate property ""version 'x.x.x'"" in metadata.rb file ""$($Path)"""
                    Stop-WhiskeyTask -TaskContext $TaskContext -Message $msg
                    return
                }

                $msg = "Read version ""$($rawVersion)"" from metadata.rb file ""$($Path)""."
                Write-WhiskeyVerbose -Context $TaskContext -Message $msg
                $semver = $rawVersion | ConvertTo-SemVer -VersionSource "from metadata.rb file ""$($Path)"""
            }
        }
    }

    if( -not $skipPackageLookup )
    {
        if( $UPackName )
        {
            $credArgs = @{}
            if ($UPackFeedCredentialID)
            {
                $credArgs['Credential'] = Get-WhiskeyCredential -Context $TaskContext `
                                                                -ID $UPackFeedCredentialID `
                                                                -PropertyName 'UPackFeedCredentialID'
            }
            if ($UPackFeedApiKeyID)
            {
                $credArgs['ApiKey'] =
                    Get-WhiskeyApiKey -Context $TaskContext -ID $UPackFeedApiKeyID -PropertyName 'UPackFeedApiKeyID'
            }

            if ($UPackFeedUrl)
            {
                $msg = 'The "UPackFeedUrl" property is obsolete. Use the "ProGetUrl" and "UPackFeedName" properties ' +
                       'instead.'
                Write-WhiskeyWarning $msg

                $ProGetUrl = "$($UPackFeedUrl.Scheme)://$($UPackFeedUrl.Authority)"
                $UPackFeedName = $UPackFeedUrl.Segments[-1]
            }

            $pgSession = New-ProGetSession -Url $ProGetUrl @credArgs

            $groupArg = @{}
            if ($UPackGroupName)
            {
                $groupArg['GroupName'] = $UPackGroupName
            }

            $msg = "Retrieving versions for universal package $($UPackName)."
            Write-WhiskeyVerbose -Context $TaskContext -Message $msg
            $numErr = $Global:Error.Count
            try
            {
                $versions = Get-ProGetUniversalPackage -Session $pgSession `
                                                        -FeedName $UPackFeedName `
                                                        -Name $UPackName `
                                                        @groupArg |
                                Select-Object -ExpandProperty 'versions'
            }
            catch
            {
                $versions = @()
                for( $idx = $Global:Error.Count ; $idx -gt $numErr ; --$idx )
                {
                    $Global:Error.RemoveAt(0)
                }
            }
        }
        elseif( $NuGetPackageID )
        {
            $msg = "Retrieving versions for NuGet package ""$($NuGetPackageID)""."
            Write-WhiskeyVerbose -Context $TaskContext -Message $msg
            $allowPrereleaseArg = Get-AllowPrereleaseArg -CommandName 'Find-Package' -AllowPrerelease
            $versions =
                Find-Package -Name $NuGetPackageID -ProviderName 'NuGet' -AllVersions @allowPrereleaseArg |
                Select-Object -ExpandProperty 'Version'
        }
    }

    $nextPrerelease = $Prerelease
    if( $nextPrerelease -isnot [String] )
    {
        $foundLabel = $false
        foreach( $object in $nextPrerelease )
        {
            foreach( $map in $object )
            {
                if( -not ($map | Get-Member -Name 'Keys') )
                {
                    $msg = "Unable to find keys in ""[$($map.GetType().Name)]$($map)"". 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: $($map)
 
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_PRERELEASE_VERSION)
        - develop: beta.`$(WHISKEY_PRERELEASE_VERSION)
    "


                    Stop-WhiskeyTask -TaskContext $TaskContext -PropertyName 'Prerelease' -Message $msg
                    return
                }

                $buildInfo = $TaskContext.BuildMetadata
                $branch = $buildInfo.ScmBranch
                if( $buildInfo.IsPullRequest )
                {
                    $branch = $buildInfo.ScmSourceBranch
                }

                foreach( $wildcardPattern in $map.Keys )
                {
                    if( $branch -like $wildcardPattern )
                    {
                        Write-WhiskeyVerbose -Context $TaskContext -Message "$($branch) -like $($wildcardPattern)"
                        $foundLabel = $true
                        $nextPrerelease = $map[$wildcardPattern]
                        break
                    }
                    else
                    {
                        Write-WhiskeyVerbose -Context $TaskContext -Message "$($branch) -notlike $($wildcardPattern)"
                    }
                }

                if( $foundLabel )
                {
                    break
                }
            }

            if( $foundLabel )
            {
                break
            }
        }

        if( -not $foundLabel )
        {
            $nextPrerelease = ''
        }
    }

    if( $nextPrerelease )
    {
        $buildSuffix = ''
        if( $semver.Build )
        {
            $buildSuffix = '+{0}' -f $semver.Build
        }

        $rawVersion = '{0}.{1}.{2}-{3}{4}' -f $semver.Major,$semver.Minor,$semver.Patch,$nextPrerelease,$buildSuffix
        if( -not [SemVersion.SemanticVersion]::TryParse($rawVersion,[ref]$semver) )
        {
            $msg = """$($nextPrerelease)"" is not a valid prerelease version. Only letters, numbers, hyphens, and " +
                   'periods are allowed. See https://semver.org for full documentation.'
            Stop-WhiskeyTask -TaskContext $TaskContext -PropertyName 'Prerelease' -Message $msg
            return
        }
    }

    if( $semver.Prerelease -match '(\d+)' )
    {
        $nextPrereleaseVersion = $Matches[1]
    }
    else
    {
        $nextPrereleaseVersion = 1
    }

    if( $versions )
    {
        [SemVersion.SemanticVersion[]] $semVersions =
            $versions |
            ConvertTo-SemVer -ErrorAction Ignore |
            ForEach-Object {
                if ($_.Prerelease -notmatch '^([A-Za-z-]+)(\d+)$')
                {
                    return $_
                }

                $nextPrerelease = "$($Matches[1]).$($Matches[2])"
                return [SemVersion.SemanticVersion]::New($_.Major, $_.Minor, $_.Patch, $nextPrerelease, $_.Build)
            }
        $sortedSemVersions = [Collections.Generic.SortedSet[SemVersion.SemanticVersion]]::New($semversions)
        $semVersions = [SemVersion.SemanticVersion[]]::New($sortedSemVersions.Count)
        $sortedSemVersions.CopyTo($semVersions)
        [Array]::Reverse($semVersions)

        $semVersions | Write-WhiskeyDebug -Context $TaskContext

        if( $IncrementPatchVersion )
        {
            $patchVersion = 0
            $baseMajorMinorVersion = @($semver.Major,$semver.Minor) -join '.'
            $lastVersion =
                $semVersions |
                Where-Object { (@($_.Major,$_.Minor) -join '.') -eq $baseMajorMinorVersion } |
                Select-Object -First 1
            if( $lastVersion )
            {
                $patchVersion = $lastVersion.Patch + 1
            }

            $nextPrerelease = $semver.Prerelease -replace '\d+', 1
            $semver = [SemVersion.SemanticVersion]::New($semver.Major, $semver.Minor, $patchVersion, $nextPrerelease,
                                                        $semver.Build)
        }

        if ($IncrementPrereleaseVersion -and $semver.Prerelease)
        {
            $baseVersion = @($semver.Major, $semver.Minor, $semver.Patch) -join '.'
            $prereleaseIdentifier = $semver.Prerelease -replace '[^A-Za-z]', ''
            $lastVersion =
                $semVersions |
                Where-Object { (@($_.Major,$_.Minor,$_.Patch) -join '.') -eq $baseVersion } |
                Where-Object { ($_.Prerelease -replace '[^A-Za-z]', '') -eq $prereleaseIdentifier } |
                Select-Object -First 1

            $nextPrereleaseVersion = 1
            if ($lastVersion -and $lastVersion.Prerelease -match '(\d+)')
            {
                $nextPrereleaseVersion = [int]$Matches[1]
                $nextPrereleaseVersion += 1
            }

            $nextPrerelease = "$($semver.Prerelease).${nextPrereleaseVersion}"
            if ($semver.Prerelease -match '\d+')
            {
                $nextPrerelease = $semver.Prerelease -replace '\d+', $nextPrereleaseVersion
            }

            $semver = [SemVersion.SemanticVersion]::New($semver.Major, $semver.Minor, $semver.Patch, $nextPrerelease,
                                                        $semver.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) )
        {
            $msg = """$($Build)"" is not valid build metadata. Only letters, numbers, hyphens, and periods are " +
                   'allowed. See https://semver.org for full documentation.'
            Stop-WhiskeyTask -TaskContext $TaskContext -PropertyName 'Build' -Message $msg
            return
        }
    }

    # Build metadata is only available when running under a build server.
    if( $TaskContext.ByDeveloper )
    {
        $semver = New-Object -TypeName 'SemVersion.SemanticVersion' `
                             -ArgumentList $semver.Major,$semVer.Minor,$semVer.Patch,$semver.Prerelease
    }

    $buildVersion.SemVer2 = $semver
    Write-WhiskeyInfo -Context $TaskContext -Message "Building version $($semver)"
    $buildVersion.Version = [Version](@($semver.Major,$semver.Minor,$semver.Patch) -join '.')
    Write-WhiskeyVerbose -Context $TaskContext -Message "Version $($buildVersion.Version)"
    $buildVersion.SemVer2NoBuildMetadata =
        New-Object 'SemVersion.SemanticVersion' ($semver.Major,$semver.Minor,$semver.Patch,$semver.Prerelease)
    $msg = "SemVer2NoBuildMetadata $($buildVersion.SemVer2NoBuildMetadata)"
    Write-WhiskeyVerbose -Context $TaskContext -Message $msg
    $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 $($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 | Out-Null

    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
        }
    }
}