@sprucelabs/spruce-cli
Version:
Command line interface for building Spruce skills.
242 lines (208 loc) • 7.48 kB
text/typescript
import { spawn, SpawnOptions, ChildProcess } from 'child_process'
import { Writable } from 'stream'
import AbstractSpruceError from '@sprucelabs/error'
import { ERROR_DIVIDER } from '@sprucelabs/spruce-skill-utils'
import escapeRegExp from 'lodash/escapeRegExp'
import stringArgv from 'string-argv'
import treeKill from 'tree-kill'
import SpruceError from '../errors/SpruceError'
process.setMaxListeners(100)
export default class CommandServiceImpl implements CommandService {
private cwd: string
private activeChildProcess: ChildProcess | undefined
private ignoreCloseErrors = false
private static fakeResponses: {
command: string | RegExp
response: FakedCommandResponse
}[] = []
private static commandsRunCapturedByMockResponses: string[] = []
public constructor(cwd: string) {
this.cwd = cwd
}
public getCwd() {
return this.cwd
}
public setCwd(cwd: string) {
this.cwd = cwd
}
public async execute(
cmd: string,
options?: ExecuteCommandOptions
): Promise<{
stdout: string
}> {
const cwd = this.cwd
const args = options?.args || stringArgv(cmd)
const executable = options?.args ? cmd : args.shift()
const boundKill = this.kill.bind(this)
if (!executable) {
throw new Error('Bad params sent to command service')
}
const { mockResponse, mockKey } = this.getMockResponse(executable, args)
if (mockResponse) {
CommandServiceImpl.commandsRunCapturedByMockResponses.push(mockKey)
mockResponse.callback?.(executable, args)
if (mockResponse.code !== 0) {
throw new SpruceError({
code: 'EXECUTING_COMMAND_FAILED',
cmd: `${executable} ${args.join(' ')}`,
cwd,
stdout: mockResponse.stdout,
stderr: mockResponse.stderr,
})
}
return { stdout: mockResponse.stdout ?? '' }
}
process.on('exit', boundKill)
return new Promise((resolve, reject) => {
let stdout = ''
let stderr = ''
const spawnOptions: SpawnOptions = options?.shouldStream
? {
stdio: 'inherit',
cwd,
env: {
PATH: process.env.PATH,
IS_CLI: 'true',
FORCE_COLOR: options?.forceColor ? '1' : '0',
...options?.env,
},
}
: {
cwd,
env: {
PATH: process.env.PATH,
IS_CLI: 'true',
FORCE_COLOR: options?.forceColor ? '1' : '0',
...options?.env,
},
shell: true,
...options?.spawnOptions,
}
let child: ChildProcess
if (spawnOptions.shell) {
const commandStr = options?.args
? [executable, ...args].join(' ')
: cmd
child = spawn(commandStr, spawnOptions)
} else {
child = spawn(executable, args, spawnOptions)
}
this.activeChildProcess = child
if (options?.outStream) {
child.stdout?.pipe(options.outStream)
}
child.stdout?.addListener('data', (data) => {
options?.onData?.(data.toString())
stdout += data
})
child.stderr?.addListener('data', (data) => {
options?.onError?.(data.toString())
stderr += data
})
const closeHandler = (code: number) => {
process.off('exit', boundKill)
if (!this.activeChildProcess) {
return
}
this.activeChildProcess = undefined
setTimeout(() => {
child.stdout?.removeAllListeners()
child.stderr?.removeAllListeners()
child.removeAllListeners()
if (
code === 0 ||
this.ignoreCloseErrors ||
options?.ignoreErrors
) {
resolve({ stdout })
this.ignoreCloseErrors = false
} else {
if (stderr.search(escapeRegExp(ERROR_DIVIDER)) > -1) {
const stderrParts = stderr.split(ERROR_DIVIDER)
const err = AbstractSpruceError.parse(
stderrParts[1],
SpruceError
)
reject(err)
return
}
reject(
new SpruceError({
code: 'EXECUTING_COMMAND_FAILED',
cmd: `${executable} ${args.join(' ')}`,
cwd,
stdout,
stderr,
})
)
}
}, 0)
}
child.addListener('close', closeHandler)
child.addListener('exit', closeHandler)
})
}
public kill = () => {
if (this.activeChildProcess?.pid) {
this.ignoreCloseErrors = true
treeKill(this.activeChildProcess.pid, 'SIGTERM')
}
}
public pid = () => {
return this.activeChildProcess?.pid
}
private getMockResponse(executable: string, args: string[]) {
const mockKey = `${executable} ${args.join(' ')}`.trim()
const commands = CommandServiceImpl.fakeResponses
const match = commands.find((r) =>
r.command instanceof RegExp
? mockKey.search(r.command) > -1
: r.command.replace(/ +/gis, '') ===
mockKey.replace(/ +/gis, '')
)
return { mockResponse: match?.response, mockKey }
}
public static fakeCommand(
command: string | RegExp,
response: FakedCommandResponse
) {
this.fakeResponses.unshift({
command,
response,
})
}
public static clearFakedResponses() {
this.fakeResponses = []
}
}
export type FakedCommandCallback = (executable: string, args: any[]) => void
interface FakedCommandResponse {
code: number
stdout?: string
stderr?: string
callback?: FakedCommandCallback
}
export interface CommandService {
execute(
cmd: string,
options?: ExecuteCommandOptions
): Promise<{
stdout: string
}>
getCwd(): string
setCwd(cwd: string): void
kill(): void
pid(): number | undefined
}
export interface ExecuteCommandOptions {
ignoreErrors?: boolean
args?: string[]
shouldStream?: boolean
outStream?: Writable
onError?: (error: string) => void
onData?: (data: string) => void
spawnOptions?: SpawnOptions
forceColor?: boolean
env?: Record<string, any>
}