diff --git a/packages/exec/README.md b/packages/exec/README.md new file mode 100644 index 00000000..354acdcb --- /dev/null +++ b/packages/exec/README.md @@ -0,0 +1,7 @@ +# `@actions/exec` + +> Functions necessary for running tools on the command line + +## Usage + +See [src/exec.ts](src/exec.ts). \ No newline at end of file diff --git a/packages/exec/__tests__/exec.test.ts b/packages/exec/__tests__/exec.test.ts new file mode 100644 index 00000000..82459185 --- /dev/null +++ b/packages/exec/__tests__/exec.test.ts @@ -0,0 +1,739 @@ +import * as exec from '../src/exec' +import * as im from '../src/interfaces' + +import * as childProcess from 'child_process' +import * as fs from 'fs' +import * as os from 'os' +import * as path from 'path' +import * as stream from 'stream' +import * as io from '@actions/io' + +/* eslint-disable @typescript-eslint/unbound-method */ + +const IS_WINDOWS = process.platform === 'win32' + +describe('@actions/exec', () => { + beforeAll(() => { + io.mkdirP(getTestTemp()) + }) + + beforeEach(() => { + process.stdout.write = jest.fn() + process.stderr.write = jest.fn() + }) + + it('Runs exec successfully with arguments split out', async () => { + const _testExecOptions = getExecOptions() + + let exitCode = 1 + let toolpath = '' + if (IS_WINDOWS) { + toolpath = await io.which('cmd', true) + exitCode = await exec.exec( + `"${toolpath}"`, + ['/c', 'echo', 'hello'], + _testExecOptions + ) + } else { + toolpath = await io.which('ls', true) + exitCode = await exec.exec( + `"${toolpath}"`, + ['-l', '-a'], + _testExecOptions + ) + } + + expect(exitCode).toBe(0) + if (IS_WINDOWS) { + expect(process.stdout.write).toBeCalledWith( + `[command]${toolpath} /c echo hello${os.EOL}` + ) + expect(process.stdout.write).toBeCalledWith(new Buffer(`hello${os.EOL}`)) + } else { + expect(process.stdout.write).toBeCalledWith( + `[command]${toolpath} -l -a${os.EOL}` + ) + } + }) + + it('Runs exec successfully with arguments partially split out', async () => { + const _testExecOptions = getExecOptions() + + let exitCode = 1 + let toolpath = '' + if (IS_WINDOWS) { + toolpath = await io.which('cmd', true) + exitCode = await exec.exec( + `"${toolpath}" /c`, + ['echo', 'hello'], + _testExecOptions + ) + } else { + toolpath = await io.which('ls', true) + exitCode = await exec.exec(`"${toolpath}" -l`, ['-a'], _testExecOptions) + } + + expect(exitCode).toBe(0) + if (IS_WINDOWS) { + expect(process.stdout.write).toBeCalledWith( + `[command]${toolpath} /c echo hello${os.EOL}` + ) + expect(process.stdout.write).toBeCalledWith(new Buffer(`hello${os.EOL}`)) + } else { + expect(process.stdout.write).toBeCalledWith( + `[command]${toolpath} -l -a${os.EOL}` + ) + } + }) + + it('Runs exec successfully with arguments as part of command line', async () => { + const _testExecOptions = getExecOptions() + + let exitCode = 1 + let toolpath = '' + if (IS_WINDOWS) { + toolpath = await io.which('cmd', true) + exitCode = await exec.exec( + `"${toolpath}" /c echo hello`, + [], + _testExecOptions + ) + } else { + toolpath = await io.which('ls', true) + exitCode = await exec.exec(`"${toolpath}" -l -a`, [], _testExecOptions) + } + + expect(exitCode).toBe(0) + if (IS_WINDOWS) { + expect(process.stdout.write).toBeCalledWith( + `[command]${toolpath} /c echo hello${os.EOL}` + ) + expect(process.stdout.write).toBeCalledWith(new Buffer(`hello${os.EOL}`)) + } else { + expect(process.stdout.write).toBeCalledWith( + `[command]${toolpath} -l -a${os.EOL}` + ) + } + }) + + it('Exec fails with error on bad call', async () => { + const _testExecOptions = getExecOptions() + + let toolpath = '' + let args: string[] = [] + if (IS_WINDOWS) { + toolpath = await io.which('cmd', true) + args = ['/c', 'non-existent'] + } else { + toolpath = await io.which('ls', true) + args = ['-l', 'non-existent'] + } + + let failed = false + + await exec.exec(`"${toolpath}"`, args, _testExecOptions).catch(err => { + failed = true + expect(err.message).toContain( + `The process '${toolpath}' failed with exit code ` + ) + }) + + expect(failed).toBe(true) + if (IS_WINDOWS) { + expect(process.stdout.write).toBeCalledWith( + `[command]${toolpath} /c non-existent${os.EOL}` + ) + } else { + expect(process.stdout.write).toBeCalledWith( + `[command]${toolpath} -l non-existent${os.EOL}` + ) + } + }) + + it('Succeeds on stderr by default', async () => { + const scriptPath: string = path.join( + __dirname, + 'scripts', + 'stderroutput.js' + ) + const nodePath: string = await io.which('node', true) + + const _testExecOptions = getExecOptions() + + const exitCode = await exec.exec( + `"${nodePath}"`, + [scriptPath], + _testExecOptions + ) + + expect(exitCode).toBe(0) + expect(process.stdout.write).toBeCalledWith( + new Buffer('this is output to stderr') + ) + }) + + it('Fails on stderr if specified', async () => { + const scriptPath: string = path.join( + __dirname, + 'scripts', + 'stderroutput.js' + ) + const nodePath: string = await io.which('node', true) + + const _testExecOptions = getExecOptions() + _testExecOptions.failOnStdErr = true + + let failed = false + await exec + .exec(`"${nodePath}"`, [scriptPath], _testExecOptions) + .catch(() => { + failed = true + }) + + expect(failed).toBe(true) + expect(process.stderr.write).toBeCalledWith( + new Buffer('this is output to stderr') + ) + }) + + it('Fails when process fails to launch', async () => { + const nodePath: string = await io.which('node', true) + const _testExecOptions = getExecOptions() + _testExecOptions.cwd = path.join(__dirname, 'nosuchdir') + + let failed = false + await exec.exec(`"${nodePath}"`, [], _testExecOptions).catch(() => { + failed = true + }) + + expect(failed).toBe(true) + }) + + it('Handles output callbacks', async () => { + const stdErrPath: string = path.join( + __dirname, + 'scripts', + 'stderroutput.js' + ) + const stdOutPath: string = path.join( + __dirname, + 'scripts', + 'stdoutoutput.js' + ) + const nodePath: string = await io.which('node', true) + let stdoutCalled = false + let stderrCalled = false + + const _testExecOptions = getExecOptions() + _testExecOptions.listeners = { + stdout: (data: Buffer) => { + expect(data).toEqual(new Buffer('this is output to stdout')) + stdoutCalled = true + }, + stderr: (data: Buffer) => { + expect(data).toEqual(new Buffer('this is output to stderr')) + stderrCalled = true + } + } + + let exitCode = await exec.exec( + `"${nodePath}"`, + [stdOutPath], + _testExecOptions + ) + expect(exitCode).toBe(0) + exitCode = await exec.exec(`"${nodePath}"`, [stdErrPath], _testExecOptions) + expect(exitCode).toBe(0) + + expect(stdoutCalled).toBeTruthy() + expect(stderrCalled).toBeTruthy() + }) + + it('Handles child process holding streams open', async function() { + const semaphorePath = path.join( + getTestTemp(), + 'child-process-semaphore.txt' + ) + fs.writeFileSync(semaphorePath, '') + + const nodePath = await io.which('node', true) + const scriptPath = path.join(__dirname, 'scripts', 'wait-for-file.js') + const debugList: string[] = [] + const _testExecOptions = getExecOptions() + _testExecOptions.delay = 500 + _testExecOptions.windowsVerbatimArguments = true + _testExecOptions.listeners = { + debug: (data: string) => { + debugList.push(data) + } + } + + let exitCode: number + if (IS_WINDOWS) { + const toolName: string = await io.which('cmd.exe', true) + const args = [ + '/D', // Disable execution of AutoRun commands from registry. + '/E:ON', // Enable command extensions. Note, command extensions are enabled by default, unless disabled via registry. + '/V:OFF', // Disable delayed environment expansion. Note, delayed environment expansion is disabled by default, unless enabled via registry. + '/S', // Will cause first and last quote after /C to be stripped. + '/C', + `"start "" /B "${nodePath}" "${scriptPath}" "file=${semaphorePath}""` + ] + exitCode = await exec.exec(`"${toolName}"`, args, _testExecOptions) + } else { + const toolName: string = await io.which('bash', true) + const args = ['-c', `node '${scriptPath}' 'file=${semaphorePath}' &`] + + exitCode = await exec.exec(`"${toolName}"`, args, _testExecOptions) + } + + expect(exitCode).toBe(0) + expect( + debugList.filter(x => x.includes('STDIO streams did not close')).length + ).toBe(1) + + fs.unlinkSync(semaphorePath) + }) + + it('Handles child process holding streams open and non-zero exit code', async function() { + const semaphorePath = path.join( + getTestTemp(), + 'child-process-semaphore.txt' + ) + fs.writeFileSync(semaphorePath, '') + + const nodePath = await io.which('node', true) + const scriptPath = path.join(__dirname, 'scripts', 'wait-for-file.js') + const debugList: string[] = [] + const _testExecOptions = getExecOptions() + _testExecOptions.delay = 500 + _testExecOptions.windowsVerbatimArguments = true + _testExecOptions.listeners = { + debug: (data: string) => { + debugList.push(data) + } + } + + let toolName: string + let args: string[] + if (IS_WINDOWS) { + toolName = await io.which('cmd.exe', true) + args = [ + '/D', // Disable execution of AutoRun commands from registry. + '/E:ON', // Enable command extensions. Note, command extensions are enabled by default, unless disabled via registry. + '/V:OFF', // Disable delayed environment expansion. Note, delayed environment expansion is disabled by default, unless enabled via registry. + '/S', // Will cause first and last quote after /C to be stripped. + '/C', + `"start "" /B "${nodePath}" "${scriptPath}" "file=${semaphorePath}"" & exit /b 123` + ] + } else { + toolName = await io.which('bash', true) + args = ['-c', `node '${scriptPath}' 'file=${semaphorePath}' & exit 123`] + } + + await exec + .exec(`"${toolName}"`, args, _testExecOptions) + .then(() => { + throw new Error('Should not have succeeded') + }) + .catch(err => { + expect( + err.message.indexOf('failed with exit code 123') + ).toBeGreaterThanOrEqual(0) + }) + + expect( + debugList.filter(x => x.includes('STDIO streams did not close')).length + ).toBe(1) + + fs.unlinkSync(semaphorePath) + }) + + it('Handles child process holding streams open and stderr', async function() { + const semaphorePath = path.join( + getTestTemp(), + 'child-process-semaphore.txt' + ) + fs.writeFileSync(semaphorePath, '') + + const nodePath = await io.which('node', true) + const scriptPath = path.join(__dirname, 'scripts', 'wait-for-file.js') + const debugList: string[] = [] + const _testExecOptions = getExecOptions() + _testExecOptions.delay = 500 + _testExecOptions.failOnStdErr = true + _testExecOptions.windowsVerbatimArguments = true + _testExecOptions.listeners = { + debug: (data: string) => { + debugList.push(data) + } + } + + let toolName: string + let args: string[] + if (IS_WINDOWS) { + toolName = await io.which('cmd.exe', true) + args = [ + '/D', // Disable execution of AutoRun commands from registry. + '/E:ON', // Enable command extensions. Note, command extensions are enabled by default, unless disabled via registry. + '/V:OFF', // Disable delayed environment expansion. Note, delayed environment expansion is disabled by default, unless enabled via registry. + '/S', // Will cause first and last quote after /C to be stripped. + '/C', + `"start "" /B "${nodePath}" "${scriptPath}" "file=${semaphorePath}"" & echo hi 1>&2` + ] + } else { + toolName = await io.which('bash', true) + args = [ + '-c', + `node '${scriptPath}' 'file=${semaphorePath}' & echo hi 1>&2` + ] + } + + await exec + .exec(`"${toolName}"`, args, _testExecOptions) + .then(() => { + throw new Error('Should not have succeeded') + }) + .catch(err => { + expect( + err.message.indexOf( + 'failed because one or more lines were written to the STDERR stream' + ) + ).toBeGreaterThanOrEqual(0) + }) + + expect( + debugList.filter(x => x.includes('STDIO streams did not close')).length + ).toBe(1) + + fs.unlinkSync(semaphorePath) + }) + + if (IS_WINDOWS) { + // Win specific quoting tests + it('execs .exe with verbatim args (Windows)', async () => { + const exePath = process.env.ComSpec + const args: string[] = ['/c', 'echo', 'helloworld', 'hello:"world again"'] + const outStream = new StringStream() + let output = '' + const options = { + outStream: outStream, + windowsVerbatimArguments: true, + listeners: { + stdout: (data: Buffer) => { + output += data.toString() + } + } + } + + const exitCode = await exec.exec(`"${exePath}"`, args, options) + expect(exitCode).toBe(0) + expect(outStream.getContents().split(os.EOL)[0]).toBe( + `[command]"${exePath}" /c echo helloworld hello:"world again"` + ) + expect(output.trim()).toBe('helloworld hello:"world again"') + }) + + it('execs .exe with arg quoting (Windows)', async () => { + const exePath = process.env.ComSpec + const args: string[] = [ + '/c', + 'echo', + 'helloworld', + 'hello world', + 'hello:"world again"', + 'hello,world' + ] + const outStream = new StringStream() + let output = '' + const options = { + outStream: outStream, + listeners: { + stdout: (data: Buffer) => { + output += data.toString() + } + } + } + + const exitCode = await exec.exec(`"${exePath}"`, args, options) + expect(exitCode).toBe(0) + expect(outStream.getContents().split(os.EOL)[0]).toBe( + `[command]${exePath} /c echo` + + ` helloworld` + + ` "hello world"` + + ` "hello:\\"world again\\""` + + ` hello,world` + ) + expect(output.trim()).toBe( + 'helloworld' + + ' "hello world"' + + ' "hello:\\"world again\\""' + + ' hello,world' + ) + }) + + it('execs .exe with a space and with verbatim args (Windows)', async () => { + // this test validates the quoting that tool runner adds around the tool path + // when using the windowsVerbatimArguments option. otherwise the target process + // interprets the args as starting after the first space in the tool path. + const exePath = compileArgsExe('print args exe with spaces.exe') + const args: string[] = ['myarg1 myarg2'] + const outStream = new StringStream() + let output = '' + const options = { + outStream: outStream, + windowsVerbatimArguments: true, + listeners: { + stdout: (data: Buffer) => { + output += data.toString() + } + } + } + + const exitCode = await exec.exec(`"${exePath}"`, args, options) + expect(exitCode).toBe(0) + expect(outStream.getContents().split(os.EOL)[0]).toBe( + `[command]"${exePath}" myarg1 myarg2` + ) + expect(output.trim()).toBe("args[0]: 'myarg1'\r\nargs[1]: 'myarg2'") + }) + + it('execs .cmd with a space and with verbatim args (Windows)', async () => { + // this test validates the quoting that tool runner adds around the script path. + // otherwise cmd.exe will not be able to resolve the path to the script. + const cmdPath = path.join( + __dirname, + 'scripts', + 'print args cmd with spaces.cmd' + ) + const args: string[] = ['arg1 arg2', 'arg3'] + const outStream = new StringStream() + let output = '' + const options = { + outStream: outStream, + windowsVerbatimArguments: true, + listeners: { + stdout: (data: Buffer) => { + output += data.toString() + } + } + } + + const exitCode = await exec.exec(`"${cmdPath}"`, args, options) + expect(exitCode).toBe(0) + expect(outStream.getContents().split(os.EOL)[0]).toBe( + `[command]${process.env.ComSpec} /D /S /C ""${cmdPath}" arg1 arg2 arg3"` + ) + expect(output.trim()).toBe( + 'args[0]: "arg1"\r\nargs[1]: "arg2"\r\nargs[2]: "arg3"' + ) + }) + + it('execs .cmd with a space and with arg with space (Windows)', async () => { + // this test validates the command is wrapped in quotes (i.e. cmd.exe /S /C ""). + // otherwise the leading quote (around the script with space path) would be stripped + // and cmd.exe would not be able to resolve the script path. + const cmdPath = path.join( + __dirname, + 'scripts', + 'print args cmd with spaces.cmd' + ) + const args: string[] = ['my arg 1', 'my arg 2'] + const outStream = new StringStream() + let output = '' + const options = { + outStream: outStream, + listeners: { + stdout: (data: Buffer) => { + output += data.toString() + } + } + } + + const exitCode = await exec.exec(`"${cmdPath}"`, args, options) + expect(exitCode).toBe(0) + expect(outStream.getContents().split(os.EOL)[0]).toBe( + `[command]${ + process.env.ComSpec + } /D /S /C ""${cmdPath}" "my arg 1" "my arg 2""` + ) + expect(output.trim()).toBe( + 'args[0]: "my arg 1"\r\n' + + 'args[1]: "my arg 2"' + ) + }) + + it('execs .cmd with arg quoting (Windows)', async () => { + // this test validates .cmd quoting rules are applied, not the default libuv rules + const cmdPath = path.join( + __dirname, + 'scripts', + 'print args cmd with spaces.cmd' + ) + const args: string[] = [ + 'helloworld', + 'hello world', + 'hello\tworld', + 'hello&world', + 'hello(world', + 'hello)world', + 'hello[world', + 'hello]world', + 'hello{world', + 'hello}world', + 'hello^world', + 'hello=world', + 'hello;world', + 'hello!world', + "hello'world", + 'hello+world', + 'hello,world', + 'hello`world', + 'hello~world', + 'hello|world', + 'helloworld', + 'hello:"world again"', + 'hello world\\' + ] + const outStream = new StringStream() + let output = '' + const options = { + outStream: outStream, + listeners: { + stdout: (data: Buffer) => { + output += data.toString() + } + } + } + + const exitCode = await exec.exec(`"${cmdPath}"`, args, options) + expect(exitCode).toBe(0) + expect(outStream.getContents().split(os.EOL)[0]).toBe( + `[command]${process.env.ComSpec} /D /S /C ""${cmdPath}"` + + ` helloworld` + + ` "hello world"` + + ` "hello\tworld"` + + ` "hello&world"` + + ` "hello(world"` + + ` "hello)world"` + + ` "hello[world"` + + ` "hello]world"` + + ` "hello{world"` + + ` "hello}world"` + + ` "hello^world"` + + ` "hello=world"` + + ` "hello;world"` + + ` "hello!world"` + + ` "hello'world"` + + ` "hello+world"` + + ` "hello,world"` + + ` "hello\`world"` + + ` "hello~world"` + + ` "hello|world"` + + ` "helloworld"` + + ` "hello:""world again"""` + + ` "hello world\\\\"` + + `"` + ) + expect(output.trim()).toBe( + 'args[0]: "helloworld"\r\n' + + 'args[1]: "hello world"\r\n' + + 'args[2]: "hello\tworld"\r\n' + + 'args[3]: "hello&world"\r\n' + + 'args[4]: "hello(world"\r\n' + + 'args[5]: "hello)world"\r\n' + + 'args[6]: "hello[world"\r\n' + + 'args[7]: "hello]world"\r\n' + + 'args[8]: "hello{world"\r\n' + + 'args[9]: "hello}world"\r\n' + + 'args[10]: "hello^world"\r\n' + + 'args[11]: "hello=world"\r\n' + + 'args[12]: "hello;world"\r\n' + + 'args[13]: "hello!world"\r\n' + + 'args[14]: "hello\'world"\r\n' + + 'args[15]: "hello+world"\r\n' + + 'args[16]: "hello,world"\r\n' + + 'args[17]: "hello`world"\r\n' + + 'args[18]: "hello~world"\r\n' + + 'args[19]: "hello|world"\r\n' + + 'args[20]: "hello"\r\n' + + 'args[21]: "hello>world"\r\n' + + 'args[22]: "hello:world again"\r\n' + + 'args[23]: "hello world\\\\"' + ) + }) + } +}) + +function getTestTemp(): string { + return path.join(__dirname, '_temp') +} + +function getExecOptions(): im.ExecOptions { + return { + cwd: __dirname, + env: {}, + silent: false, + failOnStdErr: false, + ignoreReturnCode: false + } +} + +export class StringStream extends stream.Writable { + constructor() { + super() + stream.Writable.call(this) + } + + private contents = '' + + _write( + data: string | Buffer | Uint8Array, + encoding: string, + next: Function + ): void { + this.contents += data + next() + } + + getContents(): string { + return this.contents + } +} + +// function to compile a .NET program on Windows. +const compileExe = (sourceFileName: string, targetFileName: string): string => { + const directory = path.join(getTestTemp(), sourceFileName) + io.mkdirP(directory) + const exePath = path.join(directory, targetFileName) + // short-circuit if already compiled + try { + fs.statSync(exePath) + return exePath + } catch (err) { + if (err.code !== 'ENOENT') { + throw err + } + } + + const sourceFile = path.join(__dirname, 'scripts', sourceFileName) + const cscPath = 'C:\\Windows\\Microsoft.NET\\Framework64\\v4.0.30319\\csc.exe' + fs.statSync(cscPath) + childProcess.execFileSync(cscPath, [ + '/target:exe', + `/out:${exePath}`, + sourceFile + ]) + + return exePath +} + +// function to compile a .NET program that prints the command line args. + +// the helper program is used to validate that command line args are passed correctly. + +const compileArgsExe = (targetFileName: string): string => { + return compileExe('print-args-exe.cs', targetFileName) +} diff --git a/packages/exec/__tests__/scripts/print args cmd with spaces.cmd b/packages/exec/__tests__/scripts/print args cmd with spaces.cmd new file mode 100644 index 00000000..7f3e4e66 --- /dev/null +++ b/packages/exec/__tests__/scripts/print args cmd with spaces.cmd @@ -0,0 +1,12 @@ +@echo off +setlocal +set index=0 + +:check_arg +set arg=%1 +if not defined arg goto :eof +set "arg=%arg:"=%" +echo args[%index%]: "%arg%" +set /a index=%index%+1 +shift +goto check_arg \ No newline at end of file diff --git a/packages/exec/__tests__/scripts/print-args-exe.cs b/packages/exec/__tests__/scripts/print-args-exe.cs new file mode 100644 index 00000000..e4934c1d --- /dev/null +++ b/packages/exec/__tests__/scripts/print-args-exe.cs @@ -0,0 +1,14 @@ +using System; +namespace PrintArgs +{ + public static class Program + { + public static void Main(string[] args) + { + for (int i = 0 ; i < args.Length ; i++) + { + Console.WriteLine("args[{0}]: '{1}'", i, args[i]); + } + } + } +} diff --git a/packages/exec/__tests__/scripts/stderroutput.js b/packages/exec/__tests__/scripts/stderroutput.js new file mode 100644 index 00000000..3ff0ac21 --- /dev/null +++ b/packages/exec/__tests__/scripts/stderroutput.js @@ -0,0 +1 @@ +process.stderr.write('this is output to stderr'); \ No newline at end of file diff --git a/packages/exec/__tests__/scripts/stdoutoutput.js b/packages/exec/__tests__/scripts/stdoutoutput.js new file mode 100644 index 00000000..d44a6a3d --- /dev/null +++ b/packages/exec/__tests__/scripts/stdoutoutput.js @@ -0,0 +1 @@ +process.stdout.write('this is output to stdout'); \ No newline at end of file diff --git a/packages/exec/__tests__/scripts/wait-for-file.js b/packages/exec/__tests__/scripts/wait-for-file.js new file mode 100644 index 00000000..867d8e05 --- /dev/null +++ b/packages/exec/__tests__/scripts/wait-for-file.js @@ -0,0 +1,35 @@ +var fs = require('fs'); + +// get the command line args that use the format someArg=someValue +var args = {}; +process.argv.forEach(function (arg) { + var match = arg.match(/^(.+)=(.*)$/); + if (match) { + args[match[1]] = match[2]; + } +}); + +var state = { + file: args.file +}; + +if (!state.file) { + throw new Error('file is not specified'); +} + +state.checkFile = function (s) { + try { + fs.statSync(s.file); + } + catch (err) { + if (err.code == 'ENOENT') { + return; + } + + throw err; + } + + setTimeout(s.checkFile, 100, s); +}; + +state.checkFile(state); \ No newline at end of file diff --git a/packages/exec/package-lock.json b/packages/exec/package-lock.json new file mode 100644 index 00000000..ae5a0ad5 --- /dev/null +++ b/packages/exec/package-lock.json @@ -0,0 +1,12 @@ +{ + "name": "@actions/exec", + "version": "1.0.0", + "lockfileVersion": 1, + "requires": true, + "dependencies": { + "@actions/io": { + "version": "1.0.0", + "dev": true + } + } +} diff --git a/packages/exec/package.json b/packages/exec/package.json new file mode 100644 index 00000000..afa85210 --- /dev/null +++ b/packages/exec/package.json @@ -0,0 +1,37 @@ +{ + "name": "@actions/exec", + "version": "1.0.0", + "description": "Actions exec lib", + "keywords": [ + "exec", + "actions" + ], + "author": "Bryan MacFarlane ", + "homepage": "https://github.com/actions/toolkit/tree/master/packages/exec", + "license": "MIT", + "main": "lib/exec.js", + "directories": { + "lib": "lib", + "test": "__tests__" + }, + "files": [ + "lib" + ], + "publishConfig": { + "access": "public" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/actions/toolkit.git" + }, + "scripts": { + "test": "echo \"Error: run tests from root\" && exit 1", + "tsc": "tsc" + }, + "bugs": { + "url": "https://github.com/actions/toolkit/issues" + }, + "devDependencies": { + "@actions/io": "^1.0.0" + } +} diff --git a/packages/exec/src/exec.ts b/packages/exec/src/exec.ts new file mode 100644 index 00000000..fe88986b --- /dev/null +++ b/packages/exec/src/exec.ts @@ -0,0 +1,28 @@ +import * as im from './interfaces' +import * as tr from './toolrunner' + +/** + * Exec a command. + * Output will be streamed to the live console. + * Returns promise with return code + * + * @param commandLine command to execute (can include additional args). Must be correctly escaped. + * @param args optional arguments for tool. Escaping is handled by the lib. + * @param options optional exec options. See ExecOptions + * @returns Promise exit code + */ +export async function exec( + commandLine: string, + args?: string[], + options?: im.ExecOptions +): Promise { + const commandArgs = tr.argStringToArray(commandLine) + if (commandArgs.length === 0) { + throw new Error(`Parameter 'commandLine' cannot be null or empty.`) + } + // Path to tool to execute should be first arg + const toolPath = commandArgs[0] + args = commandArgs.slice(1).concat(args || []) + const runner: tr.ToolRunner = new tr.ToolRunner(toolPath, args, options) + return runner.exec() +} diff --git a/packages/exec/src/interfaces.ts b/packages/exec/src/interfaces.ts new file mode 100644 index 00000000..e9219b0b --- /dev/null +++ b/packages/exec/src/interfaces.ts @@ -0,0 +1,45 @@ +import * as stream from 'stream' +/** + * Interface for exec options + */ +export interface ExecOptions { + /** optional working directory. defaults to current */ + cwd?: string + + /** optional envvar dictionary. defaults to current process's env */ + env?: {[key: string]: string} + + /** optional. defaults to false */ + silent?: boolean + + /** optional out stream to use. Defaults to process.stdout */ + outStream?: stream.Writable + + /** optional err stream to use. Defaults to process.stderr */ + errStream?: stream.Writable + + /** optional. whether to skip quoting/escaping arguments if needed. defaults to false. */ + windowsVerbatimArguments?: boolean + + /** optional. whether to fail if output to stderr. defaults to false */ + failOnStdErr?: boolean + + /** optional. defaults to failing on non zero. ignore will not fail leaving it up to the caller */ + ignoreReturnCode?: boolean + + /** optional. How long in ms to wait for STDIO streams to close after the exit event of the process before terminating. defaults to 10000 */ + delay?: number + + /** optional. Listeners for output. Callback functions that will be called on these events */ + listeners?: { + stdout?: (data: Buffer) => void + + stderr?: (data: Buffer) => void + + stdline?: (data: string) => void + + errline?: (data: string) => void + + debug?: (data: string) => void + } +} diff --git a/packages/exec/src/toolrunner.ts b/packages/exec/src/toolrunner.ts new file mode 100644 index 00000000..206015b3 --- /dev/null +++ b/packages/exec/src/toolrunner.ts @@ -0,0 +1,667 @@ +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 preceed 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 || {} + const result: 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 || process.stdout + result.errStream = options.errStream || process.stderr + return result + } + + private _getSpawnOptions( + options: im.ExecOptions, + toolPath: string + ): child.SpawnOptions { + options = options || {} + const result = {} + 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 { + return new Promise((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() + } +} diff --git a/packages/exec/tsconfig.json b/packages/exec/tsconfig.json new file mode 100644 index 00000000..a8b812a6 --- /dev/null +++ b/packages/exec/tsconfig.json @@ -0,0 +1,11 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "baseUrl": "./", + "outDir": "./lib", + "rootDir": "./src" + }, + "include": [ + "./src" + ] +} \ No newline at end of file