UNPKG

@ts-common/azure-js-dev-tools

Version:

Developer dependencies for TypeScript related projects

589 lines (532 loc) 19 kB
/** * Copyright (c) Microsoft Corporation. All rights reserved. * Licensed under the MIT License. See License.txt in the project root for * license information. */ import { ChildProcess, spawn, StdioOptions } from "child_process"; import * as os from "os"; import { any, last } from "./arrays"; import { replaceAll, StringMap } from "./common"; import { normalizePath } from "./path"; import { Readable } from "stream"; export interface Command { /** * The executable file/command to run. */ executable: string; /** * The arguments to pass to the executable. */ args?: string[]; /** * The options to use when running the command. */ options?: RunOptions; } /** * Get the string representation of the provided command. */ export function commandToString(command: Command): string { let result: string = quoteIfNeeded(command.executable); if (any(command.args)) { for (const arg of command.args) { if (arg) { result += ` ${quoteIfNeeded(arg)}`; } } } return result; } export interface CommandToken { text: string; startIndex: number; endIndex: number; isWhitespace?: boolean; } function parseCommandNonWhitespaceToken(commandString: string, startIndex: number): CommandToken { const commandStringLength: number = commandString.length; let endIndex: number = startIndex; let quote: string | undefined; let escaped = false; while (endIndex < commandStringLength) { const currentCharacter: string = commandString[endIndex]; ++endIndex; if (!quote) { if (currentCharacter === " ") { --endIndex; break; } else if (currentCharacter === `'` || currentCharacter === `"`) { quote = currentCharacter; } } else if (!escaped) { if (currentCharacter === "\\") { escaped = true; } else if (currentCharacter === quote) { quote = undefined; } } else { escaped = false; } } return { text: commandString.substring(startIndex, endIndex), startIndex, endIndex, }; } function parseCommandWhitespaceToken(commandString: string, startIndex: number): CommandToken { const commandStringLength: number = commandString.length; let endIndex: number = startIndex + 1; while (endIndex < commandStringLength && commandString[endIndex] === " ") { ++endIndex; } return { text: commandString.substring(startIndex, endIndex), startIndex, endIndex, isWhitespace: true, }; } export function parseCommandToken(commandString: string, startIndex: number): CommandToken | undefined { let result: CommandToken | undefined; const commandStringLength: number = commandString.length; if (0 <= startIndex && startIndex < commandStringLength) { const firstCharacter: string = commandString[startIndex]; if (firstCharacter === " ") { result = parseCommandWhitespaceToken(commandString, startIndex); } else { result = parseCommandNonWhitespaceToken(commandString, startIndex); } } return result; } export function parseCommandTokens(commandString: string, startIndex = 0): CommandToken[] { const result: CommandToken[] = []; let argToken: CommandToken | undefined = parseCommandToken(commandString, startIndex); while (argToken) { if (!argToken.isWhitespace) { result.push(argToken); } argToken = parseCommandToken(commandString, argToken.endIndex); } return result; } export function createCommand(commandTokens: CommandToken[] | undefined): Command | undefined { let result: Command | undefined; if (any(commandTokens)) { result = { executable: commandTokens.shift()!.text, args: commandTokens.map((commandToken: CommandToken) => commandToken.text), }; } return result; } /** * Parse a command object from the provided command string. */ export function parseCommand(commandString: string, startIndex = 0): Command | undefined { const commandTokens: CommandToken[] = parseCommandTokens(commandString, startIndex); return createCommand(commandTokens); } export function parseCommands(commandString: string, startIndex = 0): Command[] { const commandTokens: CommandToken[] = parseCommandTokens(commandString, startIndex); let commandStartIndex = 0; const result: Command[] = []; for (let commandIndex = 0; commandIndex < commandTokens.length; ++commandIndex) { const command: CommandToken = commandTokens[commandIndex]; if (command.text === "&" || command.text === "&&") { if (commandStartIndex === commandIndex) { ++commandStartIndex; } else { result.push(createCommand(commandTokens.slice(commandStartIndex, commandIndex))!); commandStartIndex = commandIndex + 1; } } } if (commandStartIndex < commandTokens.length) { result.push(createCommand(commandTokens.slice(commandStartIndex, commandTokens.length))!); } return result; } /** * Quote the provided value if it is needed. */ export function quoteIfNeeded(value: string, quote = `"`): string { return shouldQuote(value, quote) ? ensureQuoted(value, quote) : value; } /** * Determine whether or not the provided value should be quoted. */ export function shouldQuote(value: string, quote = `"`): boolean { return !!value && (value.includes(" ") || value.includes(quote)); } /** * Ensure that the provided value is surrounded by the provided quote string. */ export function ensureQuoted(value: string, quote = `"`): string { if (quote && value.length < quote.length * 2 || !value.startsWith(quote) || !value.endsWith(quote)) { if (quote === `"` || quote === `'`) { value = replaceAll(value, quote, `\\${quote}`)!; } value = `${quote}${value}${quote}`; } return value; } /** * An object that runs a provided command. */ export interface Runner { /** * Run the provided command asynchronously. * @param command The command to run. * @param options The options to use when running the command. */ run(command: Command, options: RunOptions | undefined): Promise<RunResult>; } export function chunkToString(chunk: any): string { if (Buffer.isBuffer(chunk)) { chunk = chunk.toString("utf8"); } return chunk; } const captureProcessOutput = async ( source: Readable | null, captureFn?: ((text: string) => void) | boolean, capturePrefix?: string ): Promise<string | undefined> => { let capturedOutput = ""; if (captureFn === false || source === null) { return undefined; } const captureOutputFn = (text: string) => { capturedOutput += text; if (typeof captureFn === "function") { if (capturePrefix) { text = `[${capturePrefix}] ${text}`; } captureFn(text); } }; return new Promise((resolve, reject) => { let currentOutputLine = ""; source.addListener("data", (chunk: any) => { currentOutputLine += chunkToString(chunk); while (true) { const newLineIndex: number = currentOutputLine.indexOf("\n"); if (newLineIndex === -1) { break; } const startOfNextLine: number = newLineIndex + 1; captureOutputFn(currentOutputLine.substring(0, startOfNextLine)); currentOutputLine = currentOutputLine.substring(startOfNextLine); } }); source.addListener("error", (error: Error) => { if (currentOutputLine) { captureOutputFn(currentOutputLine); currentOutputLine = ""; } reject(error); }); source.addListener("end", () => { if (currentOutputLine) { captureOutputFn(currentOutputLine); currentOutputLine = ""; } resolve(capturedOutput); }); }); }; /** * A command runner that runs commands using a spawned process. */ export class RealRunner implements Runner { public run(command: Command, options: RunOptions = {}): Promise<RunResult> { const executablePath: string = normalizePath(command.executable, os.platform()); const childProcess: ChildProcess = spawn(executablePath, command.args || [], { cwd: options.executionFolderPath, stdio: getChildProcessStdio(options), env: options.environmentVariables || process.env }); const stdoutCaptured = captureProcessOutput(childProcess.stdout, options.captureOutput, options.capturePrefix); const stderrCaptured = captureProcessOutput(childProcess.stderr, options.captureError, options.capturePrefix); let processExitCode: number | undefined; const processDone: Promise<RunResult> = new Promise((resolve, reject) => { childProcess.addListener("exit", (exitCode: number) => { processExitCode = exitCode; resolve(); }); childProcess.addListener("error", reject); }); return Promise.all([processDone, stdoutCaptured, stderrCaptured]) .then(([_, stdout, stderr]) => { return { exitCode: processExitCode, stdout, stderr, processId: childProcess.pid, }; }) .catch((error: Error) => { if (typeof options.captureError === "function") { options.captureError(error.toString()); } return { error }; }); } } function getExecutionFolderPath(options: RunOptions | undefined): string { return (options && options.executionFolderPath) || process.cwd(); } export interface FakeCommand { executable: string; args?: string[]; executionFolderPath?: string; result?: RunResult | Promise<RunResult> | (() => RunResult | Promise<RunResult>); } /** * A fake command runner. */ export class FakeRunner implements Runner { private readonly innerRunner: Runner; private readonly fakeCommands: FakeCommand[] = []; private unrecognizedCommand: (command: Command, options?: RunOptions) => (RunResult | Promise<RunResult>); constructor(innerRunner?: Runner) { this.innerRunner = innerRunner || new RealRunner(); this.unrecognizedCommand = (command: Command, options?: RunOptions) => Promise.reject(new Error(`No FakeRunner result has been registered for the command "${commandToString(command)}" at "${getExecutionFolderPath(options)}".`)); } /** * Set the function to invoke when an unrecognized command is run. * @param unrecognizedCommandHandler The function to call when an unrecognized command is run. */ public onUnrecognizedCommand(unrecognizedCommandHandler: ((command: Command, options?: RunOptions) => (RunResult | Promise<RunResult>))): void { this.unrecognizedCommand = unrecognizedCommandHandler; } /** * Configure this FakeRunner so that all unrecognized commands will be passed through to its * inner runner. */ public passthroughUnrecognized(): void { this.onUnrecognizedCommand((command: Command, options?: RunOptions) => this.innerRunner.run(command, options)); } /** * Set the fake result to return when the provided command is run. * @param command The command to fake. */ public set(command: FakeCommand): void { this.fakeCommands.push(command); } /** * Indicate that when this Runner attempts to run the provided commandString it should just pass * the commandString through to the inner runner. * @param commandString The commandString to pass through to the inner runner. */ public passthrough(command: Command, executionFolderPath?: string): void { this.set({ executable: command.executable, args: command.args, executionFolderPath, result: () => this.innerRunner.run(command, { executionFolderPath }) }); } public async run(command: Command, options?: RunOptions): Promise<RunResult> { const commandString: string = commandToString(command); const executionFolderPath: string = getExecutionFolderPath(options); const fakeCommand: FakeCommand | undefined = last(this.fakeCommands, (registeredFakeCommand: FakeCommand) => commandString === commandToString(registeredFakeCommand) && (!registeredFakeCommand.executionFolderPath || executionFolderPath === registeredFakeCommand.executionFolderPath)); let result: Promise<RunResult>; if (!fakeCommand) { result = Promise.resolve(this.unrecognizedCommand(command, options)); } else { let runResult: RunResult | Promise<RunResult> | (() => RunResult | Promise<RunResult>) | undefined = fakeCommand.result; if (!runResult) { runResult = { exitCode: 0 }; } else if (typeof runResult === "function") { runResult = runResult(); } result = Promise.resolve(runResult); } return result; } } /** * The result of running a command. */ export interface RunResult { /** * The code that the process exited with. */ exitCode?: number; /** * The text that the process wrote to its stdout stream. */ stdout?: string; /** * The text that the process wrote to its stderr stream. */ stderr?: string; /** * An error that occurred when trying to run the process. */ error?: Error; /** * The id of the process. */ processId?: number; } export interface RunOptions { /** * A function that will be used to write logs. */ log?: (text: string) => Promise<any> | any; /** * The folder/directory where the command will be executed from. */ executionFolderPath?: string; /** * Whether or not to write the command that will be executed to the console. Defaults to true. */ showCommand?: boolean; /** * Whether or not to show the environment variables that have been passed to this command. */ showEnvironmentVariables?: boolean; /** * Whether or not to write the command result to the console. Defaults to only show if the exit * code was non-zero. */ showResult?: boolean | ((result: RunResult) => boolean); /** * Prefix added to log */ capturePrefix?: string; /** * Whether or not to capture the command's output stream. If true or undefined, then the output * stream will be captured and returned in the RunResult's stdout property. If a function, then * the function will be called on each line of output text written by the process. If false, then * the command's output stream will be written to the parent process's output stream. */ captureOutput?: boolean | ((outputLine: string) => void); /** * Whether or not to capture the command's error stream. If true or undefined, then the error * stream will be captured and returned in the RunResult's stderr property. If a function, then * the function will be called on each line of error text written by the process. If false, then * the command's error stream will be written to the parent process's error stream. */ captureError?: boolean | ((errorLine: string) => void); /** * The runner that will be used to execute this command. */ runner?: Runner; /** * The environment variables that will be added when the command is run. */ environmentVariables?: StringMap<string>; /** * Throw on non-zero exit code */ throwOnError?: boolean; } /** * Get an array of string arguments from the provided args. * @param args The args string or array of strings. */ export function getArgsArray(args: undefined | string | string[]): string[] { const argsArray: string[] = []; if (typeof args === "string") { argsArray.push(...args.split(" ")); } else if (args) { argsArray.push(...args); } return argsArray; } /** * Get the showResult function that will determine whether the command's result will be logged. * @param showResult The showResult property from the command's RunOptions. */ export function getShowResultFunction(showResult: undefined | boolean | ((result: RunResult) => boolean)): (result: RunResult) => boolean { let result: (result: RunResult) => boolean; if (showResult === undefined) { result = (result: RunResult) => result.exitCode !== 0; } else if (typeof showResult === "boolean") { result = () => !!showResult; } else { result = showResult; } return result; } export async function logCommand(command: Command, options: RunOptions): Promise<void> { if (options && options.log && (options.showCommand == undefined || options.showCommand)) { let commandString: string = commandToString(command); if (options.executionFolderPath) { commandString = `${options.executionFolderPath}: ${commandString}`; } await Promise.resolve(options.log(commandString)); } } export async function logEnvironmentVariables(options: RunOptions): Promise<void> { if (options && options.log && options.environmentVariables && options.showEnvironmentVariables) { await options.log("Environment Variables:"); for (const [entryName, entryValue] of Object.entries(options.environmentVariables)) { await options.log(` "${entryName}": "${entryValue}"`); } } } export async function logResult(result: RunResult, options: RunOptions | undefined): Promise<void> { if (options && options.log) { const showResult: (result: RunResult) => boolean = getShowResultFunction(options.showResult); if (showResult(result)) { await Promise.resolve(options.log(`Exit Code: ${result.exitCode}`)); if (result.stdout && (options.captureOutput === true || options.captureOutput === undefined)) { await Promise.resolve(options.log(`Output:`)); await Promise.resolve(options.log(result.stdout)); } if (result.stderr && (options.captureError === true || options.captureError === undefined)) { await Promise.resolve(options.log(`Error:`)); await Promise.resolve(options.log(result.stderr)); } if (result.error && (options.captureError === true || options.captureError === undefined)) { await Promise.resolve(options.log(`Error:`)); await Promise.resolve(options.log(JSON.stringify(result.error, undefined, 2))); } } } } export function getChildProcessStdio(options: RunOptions): StdioOptions { return [ "inherit", options.captureOutput === false ? "ignore" : "pipe", options.captureError === false ? "ignore" : "pipe" ]; } /** * Run the provided command asynchronously. * @param command The command to run. * @param args The arguments to provide to the command. */ export async function run(command: string | Command, args?: string[], options: RunOptions = {}): Promise<RunResult> { if (typeof command === "string") { command = { executable: command, }; } if (!command.args) { command.args = []; } if (args) { command.args.push(...args); } const runner: Runner = options.runner || new RealRunner(); await logCommand(command, options); await logEnvironmentVariables(options); const result: RunResult = await runner.run(command, options); await logResult(result, options); if (options.throwOnError && result.exitCode) { throw new Error(`${command.executable} ${command.args.join(" ")} ${result.stderr || ""}`); } return result; }