packages/cdk-assets/lib/private/shell.ts (117 lines of code) (raw):
import * as child_process from 'child_process';
import type { SubprocessOutputDestination } from './asset-handler';
export type ShellEventType = 'open' | 'data_stdout' | 'data_stderr' | 'close';
export type ShellEventPublisher = (event: ShellEventType, message: string) => void;
export interface ShellOptions extends child_process.SpawnOptions {
readonly shellEventPublisher: ShellEventPublisher;
readonly input?: string;
readonly subprocessOutputDestination?: SubprocessOutputDestination;
}
/**
* OS helpers
*
* Shell function which both prints to stdout and collects the output into a
* string.
*/
export async function shell(command: string[], options: ShellOptions): Promise<string> {
handleShellOutput(renderCommandLine(command), options, 'open');
const child = child_process.spawn(command[0], command.slice(1), {
...options,
stdio: [options.input ? 'pipe' : 'ignore', 'pipe', 'pipe'],
});
return new Promise<string>((resolve, reject) => {
if (options.input) {
child.stdin!.write(options.input);
child.stdin!.end();
}
const stdout = new Array<any>();
const stderr = new Array<any>();
// Both emit event and collect output
child.stdout!.on('data', (chunk) => {
handleShellOutput(chunk, options, 'data_stdout');
stdout.push(chunk);
});
child.stderr!.on('data', (chunk) => {
handleShellOutput(chunk, options, 'data_stderr');
stderr.push(chunk);
});
child.once('error', reject);
child.once('close', (code, signal) => {
handleShellOutput(renderCommandLine(command), options, 'close');
if (code === 0) {
resolve(Buffer.concat(stdout).toString('utf-8'));
} else {
const out = Buffer.concat(stderr).toString('utf-8').trim();
reject(
new ProcessFailed(
code,
signal,
`${renderCommandLine(command)} exited with ${code != null ? 'error code' : 'signal'} ${code ?? signal}: ${out}`,
),
);
}
});
});
}
function handleShellOutput(
chunk: Buffer | string,
options: ShellOptions,
shellEventType: ShellEventType,
): void {
switch (options.subprocessOutputDestination) {
case 'ignore':
return;
case 'publish':
options.shellEventPublisher(shellEventType, chunk.toString('utf-8'));
break;
case 'stdio':
default:
switch (shellEventType) {
case 'data_stdout':
process.stdout.write(chunk);
break;
case 'data_stderr':
process.stderr.write(chunk);
break;
case 'open':
options.shellEventPublisher(shellEventType, chunk.toString('utf-8'));
break;
}
break;
}
}
export type ProcessFailedError = ProcessFailed;
class ProcessFailed extends Error {
public readonly code = 'PROCESS_FAILED';
constructor(
public readonly exitCode: number | null,
public readonly signal: NodeJS.Signals | null,
message: string,
) {
super(message);
}
}
/**
* Render the given command line as a string
*
* Probably missing some cases but giving it a good effort.
*/
function renderCommandLine(cmd: string[]) {
if (process.platform !== 'win32') {
return doRender(cmd, hasAnyChars(' ', '\\', '!', '"', "'", '&', '$'), posixEscape);
} else {
return doRender(cmd, hasAnyChars(' ', '"', '&', '^', '%'), windowsEscape);
}
}
/**
* Render a UNIX command line
*/
function doRender(
cmd: string[],
needsEscaping: (x: string) => boolean,
doEscape: (x: string) => string,
): string {
return cmd.map((x) => (needsEscaping(x) ? doEscape(x) : x)).join(' ');
}
/**
* Return a predicate that checks if a string has any of the indicated chars in it
*/
function hasAnyChars(...chars: string[]): (x: string) => boolean {
return (str: string) => {
return chars.some((c) => str.indexOf(c) !== -1);
};
}
/**
* Escape a shell argument for POSIX shells
*
* Wrapping in single quotes and escaping single quotes inside will do it for us.
*/
function posixEscape(x: string) {
// Turn ' -> '"'"'
x = x.replace(/'/g, "'\"'\"'");
return `'${x}'`;
}
/**
* Escape a shell argument for cmd.exe
*
* This is how to do it right, but I'm not following everything:
*
* https://blogs.msdn.microsoft.com/twistylittlepassagesallalike/2011/04/23/everyone-quotes-command-line-arguments-the-wrong-way/
*/
function windowsEscape(x: string): string {
// First surround by double quotes, ignore the part about backslashes
x = `"${x}"`;
// Now escape all special characters
const shellMeta = new Set<string>(['"', '&', '^', '%']);
return x
.split('')
.map((c) => (shellMeta.has(x) ? '^' + c : c))
.join('');
}