mirror of https://github.com/actions/toolkit
668 lines
19 KiB
TypeScript
668 lines
19 KiB
TypeScript
import * as os from 'os'
|
|
import * as events from 'events'
|
|
import * as child from 'child_process'
|
|
import * as stream from 'stream'
|
|
import * as im from './interfaces'
|
|
|
|
/* eslint-disable @typescript-eslint/unbound-method */
|
|
|
|
const IS_WINDOWS = process.platform === 'win32'
|
|
|
|
/*
|
|
* Class for running command line tools. Handles quoting and arg parsing in a platform agnostic way.
|
|
*/
|
|
export class ToolRunner extends events.EventEmitter {
|
|
constructor(toolPath: string, args?: string[], options?: im.ExecOptions) {
|
|
super()
|
|
|
|
if (!toolPath) {
|
|
throw new Error("Parameter 'toolPath' cannot be null or empty.")
|
|
}
|
|
|
|
this.toolPath = toolPath
|
|
this.args = args || []
|
|
this.options = options || {}
|
|
}
|
|
|
|
private toolPath: string
|
|
private args: string[]
|
|
private options: im.ExecOptions
|
|
|
|
private _debug(message: string): void {
|
|
if (this.options.listeners && this.options.listeners.debug) {
|
|
this.options.listeners.debug(message)
|
|
}
|
|
}
|
|
|
|
private _getCommandString(
|
|
options: im.ExecOptions,
|
|
noPrefix?: boolean
|
|
): string {
|
|
const toolPath = this._getSpawnFileName()
|
|
const args = this._getSpawnArgs(options)
|
|
let cmd = noPrefix ? '' : '[command]' // omit prefix when piped to a second tool
|
|
if (IS_WINDOWS) {
|
|
// Windows + cmd file
|
|
if (this._isCmdFile()) {
|
|
cmd += toolPath
|
|
for (const a of args) {
|
|
cmd += ` ${a}`
|
|
}
|
|
}
|
|
// Windows + verbatim
|
|
else if (options.windowsVerbatimArguments) {
|
|
cmd += `"${toolPath}"`
|
|
for (const a of args) {
|
|
cmd += ` ${a}`
|
|
}
|
|
}
|
|
// Windows (regular)
|
|
else {
|
|
cmd += this._windowsQuoteCmdArg(toolPath)
|
|
for (const a of args) {
|
|
cmd += ` ${this._windowsQuoteCmdArg(a)}`
|
|
}
|
|
}
|
|
} else {
|
|
// OSX/Linux - this can likely be improved with some form of quoting.
|
|
// creating processes on Unix is fundamentally different than Windows.
|
|
// on Unix, execvp() takes an arg array.
|
|
cmd += toolPath
|
|
for (const a of args) {
|
|
cmd += ` ${a}`
|
|
}
|
|
}
|
|
|
|
return cmd
|
|
}
|
|
|
|
private _processLineBuffer(
|
|
data: Buffer,
|
|
strBuffer: string,
|
|
onLine: (line: string) => void
|
|
): void {
|
|
try {
|
|
let s = strBuffer + data.toString()
|
|
let n = s.indexOf(os.EOL)
|
|
|
|
while (n > -1) {
|
|
const line = s.substring(0, n)
|
|
onLine(line)
|
|
|
|
// the rest of the string ...
|
|
s = s.substring(n + os.EOL.length)
|
|
n = s.indexOf(os.EOL)
|
|
}
|
|
|
|
strBuffer = s
|
|
} catch (err) {
|
|
// streaming lines to console is best effort. Don't fail a build.
|
|
this._debug(`error processing line. Failed with error ${err}`)
|
|
}
|
|
}
|
|
|
|
private _getSpawnFileName(): string {
|
|
if (IS_WINDOWS) {
|
|
if (this._isCmdFile()) {
|
|
return process.env['COMSPEC'] || 'cmd.exe'
|
|
}
|
|
}
|
|
|
|
return this.toolPath
|
|
}
|
|
|
|
private _getSpawnArgs(options: im.ExecOptions): string[] {
|
|
if (IS_WINDOWS) {
|
|
if (this._isCmdFile()) {
|
|
let argline = `/D /S /C "${this._windowsQuoteCmdArg(this.toolPath)}`
|
|
for (const a of this.args) {
|
|
argline += ' '
|
|
argline += options.windowsVerbatimArguments
|
|
? a
|
|
: this._windowsQuoteCmdArg(a)
|
|
}
|
|
|
|
argline += '"'
|
|
return [argline]
|
|
}
|
|
}
|
|
|
|
return this.args
|
|
}
|
|
|
|
private _endsWith(str: string, end: string): boolean {
|
|
return str.endsWith(end)
|
|
}
|
|
|
|
private _isCmdFile(): boolean {
|
|
const upperToolPath: string = this.toolPath.toUpperCase()
|
|
return (
|
|
this._endsWith(upperToolPath, '.CMD') ||
|
|
this._endsWith(upperToolPath, '.BAT')
|
|
)
|
|
}
|
|
|
|
private _windowsQuoteCmdArg(arg: string): string {
|
|
// for .exe, apply the normal quoting rules that libuv applies
|
|
if (!this._isCmdFile()) {
|
|
return this._uvQuoteCmdArg(arg)
|
|
}
|
|
|
|
// otherwise apply quoting rules specific to the cmd.exe command line parser.
|
|
// the libuv rules are generic and are not designed specifically for cmd.exe
|
|
// command line parser.
|
|
//
|
|
// for a detailed description of the cmd.exe command line parser, refer to
|
|
// http://stackoverflow.com/questions/4094699/how-does-the-windows-command-interpreter-cmd-exe-parse-scripts/7970912#7970912
|
|
|
|
// need quotes for empty arg
|
|
if (!arg) {
|
|
return '""'
|
|
}
|
|
|
|
// determine whether the arg needs to be quoted
|
|
const cmdSpecialChars = [
|
|
' ',
|
|
'\t',
|
|
'&',
|
|
'(',
|
|
')',
|
|
'[',
|
|
']',
|
|
'{',
|
|
'}',
|
|
'^',
|
|
'=',
|
|
';',
|
|
'!',
|
|
"'",
|
|
'+',
|
|
',',
|
|
'`',
|
|
'~',
|
|
'|',
|
|
'<',
|
|
'>',
|
|
'"'
|
|
]
|
|
let needsQuotes = false
|
|
for (const char of arg) {
|
|
if (cmdSpecialChars.some(x => x === char)) {
|
|
needsQuotes = true
|
|
break
|
|
}
|
|
}
|
|
|
|
// short-circuit if quotes not needed
|
|
if (!needsQuotes) {
|
|
return arg
|
|
}
|
|
|
|
// the following quoting rules are very similar to the rules that by libuv applies.
|
|
//
|
|
// 1) wrap the string in quotes
|
|
//
|
|
// 2) double-up quotes - i.e. " => ""
|
|
//
|
|
// this is different from the libuv quoting rules. libuv replaces " with \", which unfortunately
|
|
// doesn't work well with a cmd.exe command line.
|
|
//
|
|
// note, replacing " with "" also works well if the arg is passed to a downstream .NET console app.
|
|
// for example, the command line:
|
|
// foo.exe "myarg:""my val"""
|
|
// is parsed by a .NET console app into an arg array:
|
|
// [ "myarg:\"my val\"" ]
|
|
// which is the same end result when applying libuv quoting rules. although the actual
|
|
// command line from libuv quoting rules would look like:
|
|
// foo.exe "myarg:\"my val\""
|
|
//
|
|
// 3) double-up slashes that precede a quote,
|
|
// e.g. hello \world => "hello \world"
|
|
// hello\"world => "hello\\""world"
|
|
// hello\\"world => "hello\\\\""world"
|
|
// hello world\ => "hello world\\"
|
|
//
|
|
// technically this is not required for a cmd.exe command line, or the batch argument parser.
|
|
// the reasons for including this as a .cmd quoting rule are:
|
|
//
|
|
// a) this is optimized for the scenario where the argument is passed from the .cmd file to an
|
|
// external program. many programs (e.g. .NET console apps) rely on the slash-doubling rule.
|
|
//
|
|
// b) it's what we've been doing previously (by deferring to node default behavior) and we
|
|
// haven't heard any complaints about that aspect.
|
|
//
|
|
// note, a weakness of the quoting rules chosen here, is that % is not escaped. in fact, % cannot be
|
|
// escaped when used on the command line directly - even though within a .cmd file % can be escaped
|
|
// by using %%.
|
|
//
|
|
// the saving grace is, on the command line, %var% is left as-is if var is not defined. this contrasts
|
|
// the line parsing rules within a .cmd file, where if var is not defined it is replaced with nothing.
|
|
//
|
|
// one option that was explored was replacing % with ^% - i.e. %var% => ^%var^%. this hack would
|
|
// often work, since it is unlikely that var^ would exist, and the ^ character is removed when the
|
|
// variable is used. the problem, however, is that ^ is not removed when %* is used to pass the args
|
|
// to an external program.
|
|
//
|
|
// an unexplored potential solution for the % escaping problem, is to create a wrapper .cmd file.
|
|
// % can be escaped within a .cmd file.
|
|
let reverse = '"'
|
|
let quoteHit = true
|
|
for (let i = arg.length; i > 0; i--) {
|
|
// walk the string in reverse
|
|
reverse += arg[i - 1]
|
|
if (quoteHit && arg[i - 1] === '\\') {
|
|
reverse += '\\' // double the slash
|
|
} else if (arg[i - 1] === '"') {
|
|
quoteHit = true
|
|
reverse += '"' // double the quote
|
|
} else {
|
|
quoteHit = false
|
|
}
|
|
}
|
|
|
|
reverse += '"'
|
|
return reverse
|
|
.split('')
|
|
.reverse()
|
|
.join('')
|
|
}
|
|
|
|
private _uvQuoteCmdArg(arg: string): string {
|
|
// Tool runner wraps child_process.spawn() and needs to apply the same quoting as
|
|
// Node in certain cases where the undocumented spawn option windowsVerbatimArguments
|
|
// is used.
|
|
//
|
|
// Since this function is a port of quote_cmd_arg from Node 4.x (technically, lib UV,
|
|
// see https://github.com/nodejs/node/blob/v4.x/deps/uv/src/win/process.c for details),
|
|
// pasting copyright notice from Node within this function:
|
|
//
|
|
// Copyright Joyent, Inc. and other Node contributors. All rights reserved.
|
|
//
|
|
// Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
// of this software and associated documentation files (the "Software"), to
|
|
// deal in the Software without restriction, including without limitation the
|
|
// rights to use, copy, modify, merge, publish, distribute, sublicense, and/or
|
|
// sell copies of the Software, and to permit persons to whom the Software is
|
|
// furnished to do so, subject to the following conditions:
|
|
//
|
|
// The above copyright notice and this permission notice shall be included in
|
|
// all copies or substantial portions of the Software.
|
|
//
|
|
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
|
// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS
|
|
// IN THE SOFTWARE.
|
|
|
|
if (!arg) {
|
|
// Need double quotation for empty argument
|
|
return '""'
|
|
}
|
|
|
|
if (!arg.includes(' ') && !arg.includes('\t') && !arg.includes('"')) {
|
|
// No quotation needed
|
|
return arg
|
|
}
|
|
|
|
if (!arg.includes('"') && !arg.includes('\\')) {
|
|
// No embedded double quotes or backslashes, so I can just wrap
|
|
// quote marks around the whole thing.
|
|
return `"${arg}"`
|
|
}
|
|
|
|
// Expected input/output:
|
|
// input : hello"world
|
|
// output: "hello\"world"
|
|
// input : hello""world
|
|
// output: "hello\"\"world"
|
|
// input : hello\world
|
|
// output: hello\world
|
|
// input : hello\\world
|
|
// output: hello\\world
|
|
// input : hello\"world
|
|
// output: "hello\\\"world"
|
|
// input : hello\\"world
|
|
// output: "hello\\\\\"world"
|
|
// input : hello world\
|
|
// output: "hello world\\" - note the comment in libuv actually reads "hello world\"
|
|
// but it appears the comment is wrong, it should be "hello world\\"
|
|
let reverse = '"'
|
|
let quoteHit = true
|
|
for (let i = arg.length; i > 0; i--) {
|
|
// walk the string in reverse
|
|
reverse += arg[i - 1]
|
|
if (quoteHit && arg[i - 1] === '\\') {
|
|
reverse += '\\'
|
|
} else if (arg[i - 1] === '"') {
|
|
quoteHit = true
|
|
reverse += '\\'
|
|
} else {
|
|
quoteHit = false
|
|
}
|
|
}
|
|
|
|
reverse += '"'
|
|
return reverse
|
|
.split('')
|
|
.reverse()
|
|
.join('')
|
|
}
|
|
|
|
private _cloneExecOptions(options?: im.ExecOptions): im.ExecOptions {
|
|
options = options || <im.ExecOptions>{}
|
|
const result: im.ExecOptions = <im.ExecOptions>{
|
|
cwd: options.cwd || process.cwd(),
|
|
env: options.env || process.env,
|
|
silent: options.silent || false,
|
|
windowsVerbatimArguments: options.windowsVerbatimArguments || false,
|
|
failOnStdErr: options.failOnStdErr || false,
|
|
ignoreReturnCode: options.ignoreReturnCode || false,
|
|
delay: options.delay || 10000
|
|
}
|
|
result.outStream = options.outStream || <stream.Writable>process.stdout
|
|
result.errStream = options.errStream || <stream.Writable>process.stderr
|
|
return result
|
|
}
|
|
|
|
private _getSpawnOptions(
|
|
options: im.ExecOptions,
|
|
toolPath: string
|
|
): child.SpawnOptions {
|
|
options = options || <im.ExecOptions>{}
|
|
const result = <child.SpawnOptions>{}
|
|
result.cwd = options.cwd
|
|
result.env = options.env
|
|
result['windowsVerbatimArguments'] =
|
|
options.windowsVerbatimArguments || this._isCmdFile()
|
|
if (options.windowsVerbatimArguments) {
|
|
result.argv0 = `"${toolPath}"`
|
|
}
|
|
return result
|
|
}
|
|
|
|
/**
|
|
* Exec a tool.
|
|
* Output will be streamed to the live console.
|
|
* Returns promise with return code
|
|
*
|
|
* @param tool path to tool to exec
|
|
* @param options optional exec options. See ExecOptions
|
|
* @returns number
|
|
*/
|
|
async exec(): Promise<number> {
|
|
return new Promise<number>((resolve, reject) => {
|
|
this._debug(`exec tool: ${this.toolPath}`)
|
|
this._debug('arguments:')
|
|
for (const arg of this.args) {
|
|
this._debug(` ${arg}`)
|
|
}
|
|
|
|
const optionsNonNull = this._cloneExecOptions(this.options)
|
|
if (!optionsNonNull.silent && optionsNonNull.outStream) {
|
|
optionsNonNull.outStream.write(
|
|
this._getCommandString(optionsNonNull) + os.EOL
|
|
)
|
|
}
|
|
|
|
const state = new ExecState(optionsNonNull, this.toolPath)
|
|
state.on('debug', (message: string) => {
|
|
this._debug(message)
|
|
})
|
|
|
|
const fileName = this._getSpawnFileName()
|
|
const cp = child.spawn(
|
|
fileName,
|
|
this._getSpawnArgs(optionsNonNull),
|
|
this._getSpawnOptions(this.options, fileName)
|
|
)
|
|
|
|
const stdbuffer = ''
|
|
if (cp.stdout) {
|
|
cp.stdout.on('data', (data: Buffer) => {
|
|
if (this.options.listeners && this.options.listeners.stdout) {
|
|
this.options.listeners.stdout(data)
|
|
}
|
|
|
|
if (!optionsNonNull.silent && optionsNonNull.outStream) {
|
|
optionsNonNull.outStream.write(data)
|
|
}
|
|
|
|
this._processLineBuffer(data, stdbuffer, (line: string) => {
|
|
if (this.options.listeners && this.options.listeners.stdline) {
|
|
this.options.listeners.stdline(line)
|
|
}
|
|
})
|
|
})
|
|
}
|
|
|
|
const errbuffer = ''
|
|
if (cp.stderr) {
|
|
cp.stderr.on('data', (data: Buffer) => {
|
|
state.processStderr = true
|
|
if (this.options.listeners && this.options.listeners.stderr) {
|
|
this.options.listeners.stderr(data)
|
|
}
|
|
|
|
if (
|
|
!optionsNonNull.silent &&
|
|
optionsNonNull.errStream &&
|
|
optionsNonNull.outStream
|
|
) {
|
|
const s = optionsNonNull.failOnStdErr
|
|
? optionsNonNull.errStream
|
|
: optionsNonNull.outStream
|
|
s.write(data)
|
|
}
|
|
|
|
this._processLineBuffer(data, errbuffer, (line: string) => {
|
|
if (this.options.listeners && this.options.listeners.errline) {
|
|
this.options.listeners.errline(line)
|
|
}
|
|
})
|
|
})
|
|
}
|
|
|
|
cp.on('error', (err: Error) => {
|
|
state.processError = err.message
|
|
state.processExited = true
|
|
state.processClosed = true
|
|
state.CheckComplete()
|
|
})
|
|
|
|
cp.on('exit', (code: number) => {
|
|
state.processExitCode = code
|
|
state.processExited = true
|
|
this._debug(`Exit code ${code} received from tool '${this.toolPath}'`)
|
|
state.CheckComplete()
|
|
})
|
|
|
|
cp.on('close', (code: number) => {
|
|
state.processExitCode = code
|
|
state.processExited = true
|
|
state.processClosed = true
|
|
this._debug(`STDIO streams have closed for tool '${this.toolPath}'`)
|
|
state.CheckComplete()
|
|
})
|
|
|
|
state.on('done', (error: Error, exitCode: number) => {
|
|
if (stdbuffer.length > 0) {
|
|
this.emit('stdline', stdbuffer)
|
|
}
|
|
|
|
if (errbuffer.length > 0) {
|
|
this.emit('errline', errbuffer)
|
|
}
|
|
|
|
cp.removeAllListeners()
|
|
|
|
if (error) {
|
|
reject(error)
|
|
} else {
|
|
resolve(exitCode)
|
|
}
|
|
})
|
|
})
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Convert an arg string to an array of args. Handles escaping
|
|
*
|
|
* @param argString string of arguments
|
|
* @returns string[] array of arguments
|
|
*/
|
|
export function argStringToArray(argString: string): string[] {
|
|
const args: string[] = []
|
|
|
|
let inQuotes = false
|
|
let escaped = false
|
|
let arg = ''
|
|
|
|
function append(c: string): void {
|
|
// we only escape double quotes.
|
|
if (escaped && c !== '"') {
|
|
arg += '\\'
|
|
}
|
|
|
|
arg += c
|
|
escaped = false
|
|
}
|
|
|
|
for (let i = 0; i < argString.length; i++) {
|
|
const c = argString.charAt(i)
|
|
|
|
if (c === '"') {
|
|
if (!escaped) {
|
|
inQuotes = !inQuotes
|
|
} else {
|
|
append(c)
|
|
}
|
|
continue
|
|
}
|
|
|
|
if (c === '\\' && escaped) {
|
|
append(c)
|
|
continue
|
|
}
|
|
|
|
if (c === '\\' && inQuotes) {
|
|
escaped = true
|
|
continue
|
|
}
|
|
|
|
if (c === ' ' && !inQuotes) {
|
|
if (arg.length > 0) {
|
|
args.push(arg)
|
|
arg = ''
|
|
}
|
|
continue
|
|
}
|
|
|
|
append(c)
|
|
}
|
|
|
|
if (arg.length > 0) {
|
|
args.push(arg.trim())
|
|
}
|
|
|
|
return args
|
|
}
|
|
|
|
class ExecState extends events.EventEmitter {
|
|
constructor(options: im.ExecOptions, toolPath: string) {
|
|
super()
|
|
|
|
if (!toolPath) {
|
|
throw new Error('toolPath must not be empty')
|
|
}
|
|
|
|
this.options = options
|
|
this.toolPath = toolPath
|
|
if (options.delay) {
|
|
this.delay = options.delay
|
|
}
|
|
}
|
|
|
|
processClosed: boolean = false // tracks whether the process has exited and stdio is closed
|
|
processError: string = ''
|
|
processExitCode: number = 0
|
|
processExited: boolean = false // tracks whether the process has exited
|
|
processStderr: boolean = false // tracks whether stderr was written to
|
|
private delay = 10000 // 10 seconds
|
|
private done: boolean = false
|
|
private options: im.ExecOptions
|
|
private timeout: NodeJS.Timer | null = null
|
|
private toolPath: string
|
|
|
|
CheckComplete(): void {
|
|
if (this.done) {
|
|
return
|
|
}
|
|
|
|
if (this.processClosed) {
|
|
this._setResult()
|
|
} else if (this.processExited) {
|
|
this.timeout = setTimeout(ExecState.HandleTimeout, this.delay, this)
|
|
}
|
|
}
|
|
|
|
private _debug(message: string): void {
|
|
this.emit('debug', message)
|
|
}
|
|
|
|
private _setResult(): void {
|
|
// determine whether there is an error
|
|
let error: Error | undefined
|
|
if (this.processExited) {
|
|
if (this.processError) {
|
|
error = new Error(
|
|
`There was an error when attempting to execute the process '${
|
|
this.toolPath
|
|
}'. This may indicate the process failed to start. Error: ${
|
|
this.processError
|
|
}`
|
|
)
|
|
} else if (this.processExitCode !== 0 && !this.options.ignoreReturnCode) {
|
|
error = new Error(
|
|
`The process '${this.toolPath}' failed with exit code ${
|
|
this.processExitCode
|
|
}`
|
|
)
|
|
} else if (this.processStderr && this.options.failOnStdErr) {
|
|
error = new Error(
|
|
`The process '${
|
|
this.toolPath
|
|
}' failed because one or more lines were written to the STDERR stream`
|
|
)
|
|
}
|
|
}
|
|
|
|
// clear the timeout
|
|
if (this.timeout) {
|
|
clearTimeout(this.timeout)
|
|
this.timeout = null
|
|
}
|
|
|
|
this.done = true
|
|
this.emit('done', error, this.processExitCode)
|
|
}
|
|
|
|
private static HandleTimeout(state: ExecState): void {
|
|
if (state.done) {
|
|
return
|
|
}
|
|
|
|
if (!state.processClosed && state.processExited) {
|
|
const message = `The STDIO streams did not close within ${state.delay /
|
|
1000} seconds of the exit event from process '${
|
|
state.toolPath
|
|
}'. This may indicate a child process inherited the STDIO streams and has not yet exited.`
|
|
state._debug(message)
|
|
}
|
|
|
|
state._setResult()
|
|
}
|
|
}
|