nx/packages/nx/src/executors/run-commands/run-commands.impl.ts
2022-05-03 14:35:18 -04:00

329 lines
7.8 KiB
TypeScript

import { exec, execSync } from 'child_process';
import * as path from 'path';
import * as yargsParser from 'yargs-parser';
import { env as appendLocalEnv } from 'npm-run-path';
import { ExecutorContext } from '../../config/misc-interfaces';
import * as chalk from 'chalk';
export const LARGE_BUFFER = 1024 * 1000000;
async function loadEnvVars(path?: string) {
if (path) {
const result = (await import('dotenv')).config({ path });
if (result.error) {
throw result.error;
}
} else {
try {
(await import('dotenv')).config();
} catch {}
}
}
export type Json = { [k: string]: any };
export interface RunCommandsOptions extends Json {
command?: string;
commands?: (
| {
command: string;
forwardAllArgs?: boolean;
/**
* description was added to allow users to document their commands inline,
* it is not intended to be used as part of the execution of the command.
*/
description?: string;
prefix?: string;
color?: string;
bgColor?: string;
}
| string
)[];
color?: boolean;
parallel?: boolean;
readyWhen?: string;
cwd?: string;
args?: string;
envFile?: string;
outputPath?: string;
}
const propKeys = [
'command',
'commands',
'color',
'parallel',
'readyWhen',
'cwd',
'args',
'envFile',
'outputPath',
];
export interface NormalizedRunCommandsOptions extends RunCommandsOptions {
commands: {
command: string;
forwardAllArgs?: boolean;
}[];
parsedArgs: { [k: string]: any };
}
export default async function (
options: RunCommandsOptions,
context: ExecutorContext
): Promise<{ success: boolean }> {
await loadEnvVars(options.envFile);
const normalized = normalizeOptions(options);
if (options.readyWhen && !options.parallel) {
throw new Error(
'ERROR: Bad executor config for run-commands - "readyWhen" can only be used when "parallel=true".'
);
}
if (
options.commands.find((c: any) => c.prefix || c.color || c.bgColor) &&
!options.parallel
) {
throw new Error(
'ERROR: Bad executor config for run-commands - "prefix", "color" and "bgColor" can only be set when "parallel=true".'
);
}
try {
const success = options.parallel
? await runInParallel(normalized, context)
: await runSerially(normalized, context);
return { success };
} catch (e) {
if (process.env.NX_VERBOSE_LOGGING === 'true') {
console.error(e);
}
throw new Error(
`ERROR: Something went wrong in run-commands - ${e.message}`
);
}
}
async function runInParallel(
options: NormalizedRunCommandsOptions,
context: ExecutorContext
) {
const procs = options.commands.map((c) =>
createProcess(
c,
options.readyWhen,
options.color,
calculateCwd(options.cwd, context)
).then((result) => ({
result,
command: c.command,
}))
);
if (options.readyWhen) {
const r = await Promise.race(procs);
if (!r.result) {
process.stderr.write(
`Warning: run-commands command "${r.command}" exited with non-zero status code`
);
return false;
} else {
return true;
}
} else {
const r = await Promise.all(procs);
const failed = r.filter((v) => !v.result);
if (failed.length > 0) {
failed.forEach((f) => {
process.stderr.write(
`Warning: run-commands command "${f.command}" exited with non-zero status code`
);
});
return false;
} else {
return true;
}
}
}
function normalizeOptions(
options: RunCommandsOptions
): NormalizedRunCommandsOptions {
options.parsedArgs = parseArgs(options);
if (options.command) {
options.commands = [{ command: options.command }];
options.parallel = !!options.readyWhen;
} else {
options.commands = options.commands.map((c) =>
typeof c === 'string' ? { command: c } : c
);
}
(options as NormalizedRunCommandsOptions).commands.forEach((c) => {
c.command = transformCommand(
c.command,
(options as NormalizedRunCommandsOptions).parsedArgs,
c.forwardAllArgs ?? true
);
});
return options as any;
}
async function runSerially(
options: NormalizedRunCommandsOptions,
context: ExecutorContext
) {
for (const c of options.commands) {
createSyncProcess(
c.command,
options.color,
calculateCwd(options.cwd, context)
);
}
return true;
}
function createProcess(
commandConfig: {
command: string;
color?: string;
bgColor?: string;
prefix?: string;
},
readyWhen: string,
color: boolean,
cwd: string
): Promise<boolean> {
return new Promise((res) => {
const childProcess = exec(commandConfig.command, {
maxBuffer: LARGE_BUFFER,
env: processEnv(color),
cwd,
});
/**
* Ensure the child process is killed when the parent exits
*/
const processExitListener = () => childProcess.kill();
process.on('exit', processExitListener);
process.on('SIGTERM', processExitListener);
childProcess.stdout.on('data', (data) => {
process.stdout.write(addColorAndPrefix(data, commandConfig));
if (readyWhen && data.toString().indexOf(readyWhen) > -1) {
res(true);
}
});
childProcess.stderr.on('data', (err) => {
process.stderr.write(addColorAndPrefix(err, commandConfig));
if (readyWhen && err.toString().indexOf(readyWhen) > -1) {
res(true);
}
});
childProcess.on('exit', (code) => {
if (!readyWhen) {
res(code === 0);
}
});
});
}
function addColorAndPrefix(
out: string,
config: {
prefix?: string;
color?: string;
bgColor?: string;
}
) {
if (config.prefix) {
out = out
.split('\n')
.map((l) =>
l.trim().length > 0 ? `${chalk.bold(config.prefix)} ${l}` : l
)
.join('\n');
}
if (config.color && chalk[config.color]) {
out = chalk[config.color](out);
}
if (config.bgColor && chalk[config.bgColor]) {
out = chalk[config.bgColor](out);
}
return out;
}
function createSyncProcess(command: string, color: boolean, cwd: string) {
execSync(command, {
env: processEnv(color),
stdio: ['inherit', 'inherit', 'inherit'],
maxBuffer: LARGE_BUFFER,
cwd,
});
}
function calculateCwd(
cwd: string | undefined,
context: ExecutorContext
): string {
if (!cwd) return context.root;
if (path.isAbsolute(cwd)) return cwd;
return path.join(context.root, cwd);
}
function processEnv(color: boolean) {
const env = {
...process.env,
...appendLocalEnv(),
};
if (color) {
env.FORCE_COLOR = `${color}`;
}
return env;
}
function transformCommand(
command: string,
args: { [key: string]: string },
forwardAllArgs: boolean
) {
if (command.indexOf('{args.') > -1) {
const regex = /{args\.([^}]+)}/g;
return command.replace(regex, (_, group: string) => args[camelCase(group)]);
} else if (Object.keys(args).length > 0 && forwardAllArgs) {
const stringifiedArgs = Object.keys(args)
.map((a) =>
typeof args[a] === 'string' && args[a].includes(' ')
? `--${a}="${args[a].replace(/"/g, '"')}"`
: `--${a}=${args[a]}`
)
.join(' ');
return `${command} ${stringifiedArgs}`;
} else {
return command;
}
}
function parseArgs(options: RunCommandsOptions) {
const args = options.args;
if (!args) {
const unknownOptionsTreatedAsArgs = Object.keys(options)
.filter((p) => propKeys.indexOf(p) === -1)
.reduce((m, c) => ((m[c] = options[c]), m), {});
return unknownOptionsTreatedAsArgs;
}
return yargsParser(args.replace(/(^"|"$)/g, ''), {
configuration: { 'camel-case-expansion': true },
});
}
function camelCase(input) {
if (input.indexOf('-') > 1) {
return input
.toLowerCase()
.replace(/-(.)/g, (match, group1) => group1.toUpperCase());
} else {
return input;
}
}