Tasks/Invoke-WhiskeyNodeTask.ps1


function Invoke-WhiskeyNodeTask
{
    <#
    .SYNOPSIS
    Runs a Node build.
     
    .DESCRIPTION
    The `Invoke-WhiskeyNodeTask` function runs Node builds. It uses NPM's `run` command to run a list of NPM scripts. These scripts are defined in your package.json file's `Scripts` property. If any script fails, the build will fail. This function checks if a script fails by looking at the exit code to `npm`. Any non-zero exit code is treated as a failure.
 
    You are required to specify what version of Node.js you want in the engines field of your package.json file. (See https://docs.npmjs.com/files/package.json#engines for more information.) The version of Node is installed for you using NVM.
 
    You may additionally specify a version of NPM to use in the engines field of your package.json file. NPM will be downloaded into your package's 'node_modules' directory at the specified version. This local version of NPM will be used to execute the list of `NpmScript` and then will be removed from 'node_modules' at the end of the build.
 
    This task accepts these parameters:
 
    * `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.
    * `NpmRegistryUri` the uri to set a custom npm registry
     
    Here's a sample `whiskey.yml` using the Node task:
 
        BuildTasks:
        - Node:
          NpmScript:
          - build
          - test
 
    This task also does the following as part of each Node.js build:
 
    * Runs `npm install` to install your dependencies.
    * Runs NSP, the Node Security Platform, to check for any vulnerabilities in your depedencies.
    * Saves a report on each dependency's license.
 
    .EXAMPLE
    Invoke-WhiskeyNodeTask -TaskContext $context -TaskParameter @{ NpmScript = 'build','test', NpmRegistryUri = 'http://registry.npmjs.org/' }
 
    Demonstrates how to run the `build` and `test` NPM targets in the directory specified by the `$context.BuildRoot` property. The function would run `npm run build test`.
    #>

    [Whiskey.Task("Node",SupportsClean=$true, SupportsInitialize=$true)]
    [CmdletBinding()]
    param(
        [Parameter(Mandatory=$true)]
        [object]
        # The context the task is running under.
        $TaskContext,

        [Parameter(Mandatory=$true)]
        [hashtable]
        # 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.
        # * `NpmRegistryUri` the uri to set a custom npm registry
        $TaskParameter
    )

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

    $startedAt = Get-Date
    function Write-Timing
    {
        param(
            $Message
        )

        $now = Get-Date
        Write-Debug -Message ('[{0}] [{1}] {2}' -f $now,($now - $startedAt),$Message)
    }

    if( $TaskContext.ShouldClean() )
    {
        Write-Timing -Message 'Cleaning'
        $nodeModulesPath = (Join-Path -path $TaskContext.BuildRoot -ChildPath 'node_modules')
        if( Test-Path $nodeModulesPath -PathType Container )
        {
            $outputDirectory = Join-Path -path $TaskContext.BuildRoot -ChildPath '.output' 
            $emptyDir = New-Item -Name 'TempEmptyDir' -Path $outputDirectory -ItemType 'Directory'
            Write-Timing -Message ('Emptying {0}' -f $nodeModulesPath)
            Invoke-WhiskeyRobocopy -Source $emptyDir -Destination $nodeModulesPath | Write-Debug
            Write-Timing -Message ('COMPLETE')
            Remove-Item -Path $emptyDir
            Remove-Item -Path $nodeModulesPath
        }
        return
    }

    $npmRegistryUri = $TaskParameter['NpmRegistryUri']
    if (-not $npmRegistryUri) 
    {
        Stop-WhiskeyTask -TaskContext $TaskContext -Message 'Property ''NpmRegistryUri'' is mandatory. It should be the URI to the registry from which Node.js packages should be downloaded. E.g.,
         
        BuildTasks:
        - Node:
            NpmRegistryUri: https://registry.npmjs.org/
        '

    }

    $npmScripts = $TaskParameter['NpmScript']
    $npmScriptCount = $npmScripts | Measure-Object | Select-Object -ExpandProperty 'Count'
    $numSteps = 6 + $npmScriptCount
    $stepNum = 0

    $originalPath = $env:PATH
    $activity = 'Running Node Task'

    function Update-Progress
    {
        param(
            [Parameter(Mandatory=$true)]
            [string]
            $Status,

            [int]
            $Step
        )

        Write-Progress -Activity $activity -Status $Status.TrimEnd('.') -PercentComplete ($Step/$numSteps*100)
    }

    $workingDir = $TaskContext.BuildRoot
    if( $TaskParameter.ContainsKey('WorkingDirectory') )
    {
        $workingDir = $TaskParameter['WorkingDirectory'] | Resolve-WhiskeyTaskPath -TaskContext $TaskContext -PropertyName 'WorkingDirectory'
    }

    Push-Location -Path $workingDir
    try
    {
        Update-Progress -Status 'Validating package.json and starting installation of Node.js version required for this package (if required)' -Step ($stepNum++)
        Write-Timing -Message 'Installing Node.js'
        $nodePath = Install-WhiskeyNodeJs -RegistryUri $npmRegistryUri -ApplicationRoot $workingDir -ForDeveloper:$TaskContext.ByDeveloper
        Write-Timing -Message ('COMPLETE')
        if( -not $nodePath )
        {
            Stop-WhiskeyTask -TaskContext $TaskContext -Message ('Node version required for this package failed to install. Please see previous errors for details.')
        }
        Update-Progress -Status ('Node.js version required for this package is installed') -Step ($stepNum++)

        $nodeRoot = $nodePath | Split-Path
        $npmGlobalPath = Join-Path -Path $nodeRoot -ChildPath 'node_modules\npm\bin\npm-cli.js' -Resolve
        if( -not $npmGlobalPath )
        {
            Stop-WhiskeyTask -TaskContext $TaskContext -Message ('NPM didn''t get installed by NVM when installing Node. Please use NVM to uninstall this version of Node.')
        }

        Update-Progress -Status ('Getting path to the version of NPM required for this package') -Step ($stepNum++)
        Write-Timing -Message 'Resolving path to NPM.'
        $npmPath = Get-WhiskeyNPMPath -NodePath $nodePath -ApplicationRoot $workingDir
        Write-Timing -Message ('COMPLETE')
        if( -not $npmPath )
        {
            Stop-WhiskeyTask -TaskContext $TaskContext -Message ('Could not locate version of NPM that is required for this package. Please see previous errors for details.')
        }

        Set-Item -Path 'env:PATH' -Value ('{0};{1}' -f $nodeRoot,$env:Path)

        $noColorArg = @()
        if( ($TaskContext.ByBuildServer) -or $Host.Name -ne 'ConsoleHost' )
        {
            $noColorArg = '--no-color'
        }

        Update-Progress -Status ('npm install') -Step ($stepNum++)
        Write-Timing -Message 'Installing Node.js modules.'
        & $nodePath $npmPath 'install' '--production=false' $noColorArg
        if( $LASTEXITCODE )
        {
            Stop-WhiskeyTask -TaskContext $TaskContext -Message ('NPM command `npm install` failed with exit code {0}.' -f $LASTEXITCODE)
        }

        if( $TaskContext.ShouldInitialize() )
        {
            Write-Timing -Message 'Initialization complete.'
            return
        }

        if( -not $npmScripts )
        {
            Write-WhiskeyWarning -TaskContext $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.
 
BuildTasks:
- Node:
  NpmScript:
  - build
  - test
'@
)
        }

        # local version of npm gets removed by 'npm install', so call Get-WhiskeyNPMPath to download it again if necessary
        $npmPath = Get-WhiskeyNPMPath -NodePath $nodePath -ApplicationRoot $workingDir
        foreach( $script in $npmScripts )
        {
            Update-Progress -Status ('npm run {0}' -f $script) -Step ($stepNum++)
            Write-Timing -Message ('Running script ''{0}''.' -f $script)
            & $nodePath $npmPath 'run' $script '--scripts-prepend-node-path=auto' $noColorArg 
            Write-Timing -Message ('COMPLETE')
            if( $LASTEXITCODE )
            {
                Stop-WhiskeyTask -TaskContext $TaskContext -Message ('NPM command `npm run {0}` failed with exit code {1}.' -f $script,$LASTEXITCODE)
            }
        }

        Update-Progress -Status ('nsp check') -Step ($stepNum++)
        $nodeModulesRoot = Join-Path -Path $nodeRoot -ChildPath 'node_modules'
        $nspPath = Join-Path -Path $nodeModulesRoot -ChildPath 'nsp\bin\nsp'
        $npmCmd = 'install'
        if( (Test-Path -Path $nspPath -PathType Leaf) )
        {
            $npmCmd = 'update'
        }

        Write-Timing -Message ('Installing NSP.')
        & $nodePath $npmPath $npmCmd 'nsp@latest' '-g'
        Write-Timing -Message ('COMPLETE')
        if( -not (Test-Path -Path $nspPath -PathType Leaf) )
        {
            Stop-WhiskeyTask -TaskContext $TaskContext -Message ('NSP module failed to install to ''{0}''.' -f $nodeModulesRoot)
        }

        Write-Timing -Message ('Running NSP security check.')
        $output = & $nodePath $nspPath 'check' '--output' 'json' 2>&1 |
                        ForEach-Object { if( $_ -is [Management.Automation.ErrorRecord]) { $_.Exception.Message } else { $_ } } 
        Write-Timing -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)
        }

        Update-Progress -Status ('license-checker') -Step ($stepNum++)
        $licenseCheckerPath = Join-Path -Path $nodeModulesRoot -ChildPath 'license-checker\bin\license-checker'
        $npmCmd = 'install'
        if( (Test-Path -Path $licenseCheckerPath -PathType Leaf) )
        {
            $npmCmd = 'update'
        }
        Write-Timing -Message ('Installing license checker.')
        & $nodePath $npmPath $npmCmd 'license-checker@latest' '-g'
        Write-Timing -Message ('COMPLETE')
        if( -not (Test-Path -Path $licenseCheckerPath -PathType Leaf) )
        {
            Stop-WhiskeyTask -TaskContext $TaskContext -Message ('License Checker module failed to install to ''{0}''.' -f $nodeModulesRoot)
        }

        Write-Timing -Message ('Generating license report.')
        $reportJson = & $nodePath $licenseCheckerPath '--json'
        Write-Timing -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.')
        }

        Write-Timing -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-Verbose

        $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-Timing -Message ('COMPLETE')
    }
    finally
    {
        Set-Item -Path 'env:PATH' -Value $originalPath

        Pop-Location

        Write-Progress -Activity $activity -Completed -PercentComplete 100
    }
}