mirror of https://github.com/actions/toolkit
Add exec (#10)
* Add exec * Fix linux tests * unnecessary dependency * Dont prefix ExecOptions with I * Consistency nits * Respond to feedback * Add toolrunner explanatory quote * Formatpull/13/head
parent
8ebbf59cb3
commit
c5f27c3c1b
|
@ -0,0 +1,7 @@
|
||||||
|
# `@actions/exec`
|
||||||
|
|
||||||
|
> Functions necessary for running tools on the command line
|
||||||
|
|
||||||
|
## Usage
|
||||||
|
|
||||||
|
See [src/exec.ts](src/exec.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: <stream.Writable>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: <stream.Writable>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: <stream.Writable>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: <stream.Writable>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 "<COMMAND>").
|
||||||
|
// 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: <stream.Writable>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]: "<quote>my arg 1<quote>"\r\n' +
|
||||||
|
'args[1]: "<quote>my arg 2<quote>"'
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
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',
|
||||||
|
'hello<world',
|
||||||
|
'hello>world',
|
||||||
|
'hello:"world again"',
|
||||||
|
'hello world\\'
|
||||||
|
]
|
||||||
|
const outStream = new StringStream()
|
||||||
|
let output = ''
|
||||||
|
const options = {
|
||||||
|
outStream: <stream.Writable>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"` +
|
||||||
|
` "hello<world"` +
|
||||||
|
` "hello>world"` +
|
||||||
|
` "hello:""world again"""` +
|
||||||
|
` "hello world\\\\"` +
|
||||||
|
`"`
|
||||||
|
)
|
||||||
|
expect(output.trim()).toBe(
|
||||||
|
'args[0]: "helloworld"\r\n' +
|
||||||
|
'args[1]: "<quote>hello world<quote>"\r\n' +
|
||||||
|
'args[2]: "<quote>hello\tworld<quote>"\r\n' +
|
||||||
|
'args[3]: "<quote>hello&world<quote>"\r\n' +
|
||||||
|
'args[4]: "<quote>hello(world<quote>"\r\n' +
|
||||||
|
'args[5]: "<quote>hello)world<quote>"\r\n' +
|
||||||
|
'args[6]: "<quote>hello[world<quote>"\r\n' +
|
||||||
|
'args[7]: "<quote>hello]world<quote>"\r\n' +
|
||||||
|
'args[8]: "<quote>hello{world<quote>"\r\n' +
|
||||||
|
'args[9]: "<quote>hello}world<quote>"\r\n' +
|
||||||
|
'args[10]: "<quote>hello^world<quote>"\r\n' +
|
||||||
|
'args[11]: "<quote>hello=world<quote>"\r\n' +
|
||||||
|
'args[12]: "<quote>hello;world<quote>"\r\n' +
|
||||||
|
'args[13]: "<quote>hello!world<quote>"\r\n' +
|
||||||
|
'args[14]: "<quote>hello\'world<quote>"\r\n' +
|
||||||
|
'args[15]: "<quote>hello+world<quote>"\r\n' +
|
||||||
|
'args[16]: "<quote>hello,world<quote>"\r\n' +
|
||||||
|
'args[17]: "<quote>hello`world<quote>"\r\n' +
|
||||||
|
'args[18]: "<quote>hello~world<quote>"\r\n' +
|
||||||
|
'args[19]: "<quote>hello|world<quote>"\r\n' +
|
||||||
|
'args[20]: "<quote>hello<world<quote>"\r\n' +
|
||||||
|
'args[21]: "<quote>hello>world<quote>"\r\n' +
|
||||||
|
'args[22]: "<quote>hello:<quote><quote>world again<quote><quote><quote>"\r\n' +
|
||||||
|
'args[23]: "<quote>hello world\\\\<quote>"'
|
||||||
|
)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
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)
|
||||||
|
}
|
|
@ -0,0 +1,12 @@
|
||||||
|
@echo off
|
||||||
|
setlocal
|
||||||
|
set index=0
|
||||||
|
|
||||||
|
:check_arg
|
||||||
|
set arg=%1
|
||||||
|
if not defined arg goto :eof
|
||||||
|
set "arg=%arg:"=<quote>%"
|
||||||
|
echo args[%index%]: "%arg%"
|
||||||
|
set /a index=%index%+1
|
||||||
|
shift
|
||||||
|
goto check_arg
|
|
@ -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]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1 @@
|
||||||
|
process.stderr.write('this is output to stderr');
|
|
@ -0,0 +1 @@
|
||||||
|
process.stdout.write('this is output to stdout');
|
|
@ -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);
|
|
@ -0,0 +1,12 @@
|
||||||
|
{
|
||||||
|
"name": "@actions/exec",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"lockfileVersion": 1,
|
||||||
|
"requires": true,
|
||||||
|
"dependencies": {
|
||||||
|
"@actions/io": {
|
||||||
|
"version": "1.0.0",
|
||||||
|
"dev": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,37 @@
|
||||||
|
{
|
||||||
|
"name": "@actions/exec",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"description": "Actions exec lib",
|
||||||
|
"keywords": [
|
||||||
|
"exec",
|
||||||
|
"actions"
|
||||||
|
],
|
||||||
|
"author": "Bryan MacFarlane <bryanmac@microsoft.com>",
|
||||||
|
"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"
|
||||||
|
}
|
||||||
|
}
|
|
@ -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<number> exit code
|
||||||
|
*/
|
||||||
|
export async function exec(
|
||||||
|
commandLine: string,
|
||||||
|
args?: string[],
|
||||||
|
options?: im.ExecOptions
|
||||||
|
): Promise<number> {
|
||||||
|
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()
|
||||||
|
}
|
|
@ -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
|
||||||
|
}
|
||||||
|
}
|
|
@ -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 || <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()
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,11 @@
|
||||||
|
{
|
||||||
|
"extends": "../../tsconfig.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"baseUrl": "./",
|
||||||
|
"outDir": "./lib",
|
||||||
|
"rootDir": "./src"
|
||||||
|
},
|
||||||
|
"include": [
|
||||||
|
"./src"
|
||||||
|
]
|
||||||
|
}
|
Loading…
Reference in New Issue