import kill from 'tree-kill' ; import stripAnsi from 'strip-ansi' ; import { EventEmitter } from 'events' ; import * as Path from 'path' ; import * as Fs from 'fs-extra' ; import { compact , flattenDeep , get } from 'lodash' ; import { spawn , ChildProcess } from 'child_process' ; import { SSHOptions , SSH } from '@lib/process/ssh' ; import { BufferedSearch } from '@lib/process/search' ; import { ErrorWithCode , ProcessError } from '@lib/process/errors' ; export type ProcessId = string | number ; export type ProcessOptions = { command : string ; args : Array < string > ; path : string ; forceRunner ? : string | null ; ssh ? : boolean ; sshOptions ? : SSHOptions ; platform ? : NodeJS . Platform ; } ; export interface IProcessEnvironment { [ key ] : any ; } export interface IProcess extends EventEmitter { getId ( ) : ProcessId ; stop ( ) : void ; owns ( command ) : boolean ; } export class DefaultProcess extends EventEmitter implements IProcess { static readonly type : string = 'default' ; protected readonly startDelimiter : string = '<<<REPORT{' ; protected readonly endDelimiter : string = '}REPORT>>>' ; protected search : BufferedSearch ; protected command ! : string ; protected binary = '' ; protected args : Array < string > = [ ] ; protected path ? : string ; protected chunks : Array < string > = [ ] ; protected rawChunks : Array < string > = [ ] ; protected error = '' ; protected killed = false ; protected closed = false ; protected exitCode ? : number | null ; protected exitSignal : string | null = null ; protected reports = false ; protected reportBuffer = '' ; protected reportClosed = false ; protected writeToFile = false ; protected process ? : ChildProcess ; protected platform ? : NodeJS . Platform ; constructor ( options ) { super ( ) ; this . search = new BufferedSearch ( ) ; if ( __DEV__ && process . env . FROM_FILE ) { process . nextTick ( ( ) => { const file = Path . isAbsolute ( process . env . FROM_FILE ! ) ? process . env . FROM_FILE ! : Path . join ( __dirname , `./debug/${ process . env . FROM_FILE }.json` ) ; log . debug ( `Re-running process from file ${ file }.` ) ; if ( ! Fs . existsSync ( file ) ) { log . debug ( `File ${ file } does not exist.` ) ; } const stored = Fs . readJsonSync ( file , { throws : false } ) || [ ] ; get ( stored , 'process.rawChunks' , [ ] ) . forEach ( ( chunk ) => { this . onData ( chunk ) ; } ) ; this . onClose ( get ( stored , 'process.exitCode' , 0 ) , null ) ; } , 0 ) ; return ; } this . platform = options . platform || process . platform ; this . command = options . command ; this . path = options . path ; this . args = this . spawnArguments ( compact ( flattenDeep ( ( ( this . command as any ) . split ( ' ' ) ) . concat ( options . args ! ) ) ) || [ ] ) ; if ( ! this . args . length ) { log . debug ( [ 'Failed to determine process to run' , JSON . stringify ( options ) ] . join ( ' ' ) ) ; throw new Error ( 'Failed to determine process to run' ) ; } if ( options . ssh ) { this . binary = 'ssh' ; this . args = ( new SSH ( options . sshOptions ) ) . commandArgs ( this . args ) ; } else { this . binary = this . args . shift ( ) ! ; } log . debug ( [ 'Spawning child process' , JSON . stringify ( { spawn : this . binary , args : this . args , path : this . path } ) ] . join ( ' ' ) ) ; log . info ( `Executing command: ${ this . binary } ${ this . args . join ( ' ' ) }` ) ; const spawnedProcess = spawn ( this . binary , this . args , { cwd : this . path , detached : false , shell : options . ssh , windowsHide : true , env : this . spawnEnv ( { ... process . env , ... { FORCE_COLOR : 3 } } ) } ) ; spawnedProcess . stdout . setEncoding ( 'utf8' ) ; spawnedProcess . stderr . setEncoding ( 'utf8' ) ; this . addListeners ( spawnedProcess ) ; this . process = spawnedProcess ; } /**
     * Return the array of arguments with which to spawn the child process.
     * This is a chance for runners to influence how arguments are passed.
     */ protected spawnArguments ( args ) { return args ; } /**
     * Return the env object with which to spawn the child process.
     * This is a chance for runners to influence the process environment.
     */ protected spawnEnv ( env ) { return env ; } param process protected addListeners ( process ) { process . on ( 'close' , ( ... args ) => this . onClose ( ... args ) ) ; process . on ( 'error' , ( err ) => this . onError ( err as ErrorWithCode ) ) ; process . stdout ! . on ( 'data' , ( ... args ) => this . onData ( ... args ) ) ; process . stderr ! . on ( 'data' , ( ... args ) => this . onData ( ... args ) ) ; } /**
     * Get this process's unique id.
     */ public getId ( ) { if ( __DEV__ && process . env . FROM_FILE ) { return Array . from ( process . env . FROM_FILE ) . reduce ( ( s , c ) => Math . imul ( 31 , s ) + c . charCodeAt ( 0 ) | 0 , 0 ) ; } return this . process ! . pid ; } param err protected onError ( err ) { log . debug ( 'Process error' , err ) ; if ( typeof err . code === 'string' ) { if ( err . code === 'ENOENT' ) { this . error = `${ this . command }: command not found` ; } return ; } this . error = err . message ; } param code param signal protected onClose ( code , signal ) { log . debug ( [ 'Process closing.' , JSON . stringify ( { code , signal } ) ] . join ( ' ' ) ) ; if ( this . process && this . process . killed || this . killed ) { log . debug ( 'Process killed.' ) ; this . emit ( 'killed' , { process : this } ) ; } else if ( code === 0 || ( this . reports && this . reportClosed ) ) { log . debug ( 'Process successfully exited.' ) ; this . emit ( 'success' , { process : this , lines : this . getLines ( ) , rawLines : this . getRawLines ( ) } ) ; } else { if ( ! this . error ) { if ( this . reportBuffer ) { this . chunks . push ( this . reportBuffer ) ; } this . error = this . chunks . join ( '' ) ; } const error = ( new ProcessError ( this . error ) ) . setProcess ( this ) . setCode ( code ) ; log . error ( 'Process errored without throwing.' , error ) ; this . emit ( 'error' , error ) ; } if ( this . writeToFile ) { Fs . writeFileSync ( Path . join ( Path . join ( __dirname , 'debug' ) , 'log-' + Math . floor ( new Date ( ) . getTime ( ) / 1000 ) + '.json' ) , JSON . stringify ( { error : this . error . toString ( ) , process : this . toString ( ) , env : Object . keys ( process . env ) } , null , 4 ) ) ; } this . closed = true ; this . exitCode = code ; this . exitSignal = signal ; this . emit ( 'close' , { process : this } ) ; } param rawChunk protected onData ( rawChunk ) { const chunk = rawChunk ; if ( this . writeToFile ) { this . rawChunks . push ( rawChunk ) ; } let storeChunk = true ; if ( ! this . reports && ! this . reportClosed ) { this . reports = this . search . term ( this . startDelimiter , chunk ) ; } if ( this . reports && ! this . reportClosed ) { if ( ( /^[A-Za-z0-9\/\+=\(\)]+$/im ) . test ( chunk ) ) { this . reportBuffer += chunk ; if ( ( /\((?:(?!\)).)+\)/ ) . test ( this . reportBuffer ) ) { this . reportBuffer = this . reportBuffer . replace ( /\s*({\s*)?\((?:(?!\)).)+\)\s*}?/g , ( match , offset , string ) => { let report ; try { report = JSON . parse ( Buffer . from ( match . match ( /\((.+)\)/ ) ! [ 1 ] , 'base64' ) . toString ( 'utf8' ) ) ; } catch ( SyntaxError ) { log . warn ( `Error parsing report match: ${ match }.` ) ; this . chunks . push ( chunk ) ; return '' ; } this . emit ( 'report' , { report } ) ; if ( __DEV__ ) { log . debug ( report ) ; } return '' ; } ) ; } storeChunk = false ; } this . reportClosed = this . search . term ( this . endDelimiter , chunk ) ; } if ( storeChunk && chunk ) { this . chunks . push ( chunk ) ; log . debug ( chunk ) ; } } /**
     * Get all lines received from the output stream, clear of Ansi escape codes.
     */ protected getLines ( ) { return this . chunks . join ( '\n' ) . split ( '\n' ) . map ( chunk => stripAnsi ( chunk ) ) ; } /**
     * Get all lines received from the output stream, unprocessed
     */ protected getRawLines ( ) { return this . chunks . join ( '\n' ) . split ( '\n' ) ; } /**
     * Kill this process.
     */ public stop ( ) { this . killed = true ; kill ( this . process ! . pid ) ; } param code param signal public close ( code , signal ) { this . onClose ( code , signal ) ; } param command public owns ( command ) { return true ; } /**
     * The string representation of this process.
     */ public toString ( ) { return JSON . stringify ( { id : this . getId ( ) , command : this . command , binary : this . binary , args : this . args , chunks : this . chunks , rawChunks : this . rawChunks , closed : this . closed , killed : this . killed , reports : this . reports , reportClosed : this . reportClosed , exitCode : this . exitCode , exitSignal : this . exitSignal } ) ; } }