packages/@aws-cdk/cdk-build-tools/lib/os.ts (113 lines of code) (raw):
import * as child_process from 'child_process';
import * as fs from 'fs';
import * as util from 'util';
import * as chalk from 'chalk';
import { Timers } from './timer';
interface ShellOptions {
timers?: Timers;
env?: child_process.SpawnOptions['env'];
}
/**
* A shell command that does what you want
*
* Is platform-aware, handles errors nicely.
*/
export async function shell(command: string[], options: ShellOptions = {}): Promise<string> {
const [cmd, ...args] = command;
const timer = (options.timers || new Timers()).start(cmd);
await makeShellScriptExecutable(cmd);
// yarn exec runs the provided command with the correct environment for the workspace.
const child = child_process.spawn(
cmd,
args,
{
// Need this for Windows where we want .cmd and .bat to be found as well.
shell: true,
stdio: ['ignore', 'pipe', 'pipe'],
env: {
...process.env,
...options.env,
},
});
const makeRed = process.stderr.isTTY ? chalk.red : (x: string) => x;
return new Promise<string>((resolve, reject) => {
const stdout = new Array<any>();
child.stdout!.on('data', chunk => {
process.stdout.write(chunk);
stdout.push(chunk);
});
child.stderr!.on('data', chunk => {
process.stderr.write(makeRed(chunk.toString()));
});
child.once('error', reject);
child.once('exit', code => {
timer.end();
if (code === 0) {
resolve(Buffer.concat(stdout).toString('utf-8'));
} else {
reject(new Error(`${renderCommandLine(command)} exited with error code ${code}`));
}
});
});
}
/**
* Escape a shell argument for the current shell
*/
export function escape(x: string) {
if (process.platform === 'win32') {
return windowsEscape(x);
}
return posixEscape(x);
}
/**
* 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 = ['"', '&', '^', '%'];
return x.split('').map(c => shellMeta.indexOf(x) !== -1 ? '^' + c : c).join('');
}
/**
* Make the script executable on the current platform
*
* On UNIX, we'll use chmod to directly execute the file.
*
* On Windows, we'll do nothing and expect our other tooling
* (npm/lerna) to generate appropriate .cmd files when linking.
*/
export async function makeExecutable(javascriptFile: string): Promise<void> {
if (process.platform !== 'win32') {
await util.promisify(fs.chmod)(javascriptFile, 0o755);
}
}
/**
* If the given file exists and looks like a shell script, make sure it's executable
*/
async function makeShellScriptExecutable(script: string) {
try {
if (await canExecute(script)) {
return;
}
if (!await isShellScript(script)) {
return;
}
await util.promisify(fs.chmod)(script, 0o755);
} catch (e: any) {
// If it happens that this file doesn't exist, that's fine. It's
// probably a file that can be found on the $PATH.
if (e.code === 'ENOENT') {
return;
}
throw e;
}
}
async function canExecute(fileName: string): Promise<boolean> {
try {
await util.promisify(fs.access)(fileName, fs.constants.X_OK);
return true;
} catch (e: any) {
if (e.code === 'EACCES') {
return false;
}
throw e;
}
}
async function isShellScript(script: string): Promise<boolean> {
const f = await util.promisify(fs.open)(script, 'r');
const buffer = Buffer.alloc(2);
await util.promisify(fs.read)(f, buffer, 0, 2, null);
return buffer.equals(Buffer.from('#!'));
}