Skip to content

Instantly share code, notes, and snippets.

@Coly010
Created April 14, 2023 11:47
Show Gist options
  • Select an option

  • Save Coly010/5477132c9477a2a23c23ac902819cce3 to your computer and use it in GitHub Desktop.

Select an option

Save Coly010/5477132c9477a2a23c23ac902819cce3 to your computer and use it in GitHub Desktop.
Run Interactive Commands Executor for Nx
import { exec } 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 '@nrwl/devkit';
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 RunInteractiveCommandsOptions 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;
__unparsed__: string[];
}
const propKeys = [
'command',
'commands',
'color',
'parallel',
'readyWhen',
'cwd',
'args',
'envFile',
'outputPath',
];
export interface NormalizedRunInteractiveCommandsOptions extends RunInteractiveCommandsOptions {
commands: {
command: string;
forwardAllArgs?: boolean;
}[];
parsedArgs: { [k: string]: any };
}
export default async function (
options: RunInteractiveCommandsOptions,
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-interactive-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: NormalizedRunInteractiveCommandsOptions,
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: RunInteractiveCommandsOptions
): NormalizedRunInteractiveCommandsOptions {
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 NormalizedRunInteractiveCommandsOptions).commands.forEach((c) => {
c.command = interpolateArgsIntoCommand(
c.command,
options as NormalizedRunInteractiveCommandsOptions,
c.forwardAllArgs ?? true
);
});
return options as any;
}
async function runSerially(
options: NormalizedRunInteractiveCommandsOptions,
context: ExecutorContext
) {
for (const c of options.commands) {
const success = await createProcess(
c,
undefined,
options.color,
calculateCwd(options.cwd, context)
);
if (!success) {
process.stderr.write(
`Warning: run-commands command "${c.command}" exited with non-zero status code`
);
return false;
}
}
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 = (signal?: number | NodeJS.Signals) => () =>
childProcess.kill(signal);
process.on('exit', processExitListener);
process.on('SIGTERM', processExitListener);
process.on('SIGINT', processExitListener);
process.on('SIGQUIT', 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 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;
}
export function interpolateArgsIntoCommand(
command: string,
opts: NormalizedRunInteractiveCommandsOptions,
forwardAllArgs: boolean
) {
if (command.indexOf('{args.') > -1) {
const regex = /{args\.([^}]+)}/g;
return command.replace(regex, (_, group: string) => opts.parsedArgs[group]);
} else if (forwardAllArgs) {
return `${command}${
opts.__unparsed__.length > 0 ? ' ' + opts.__unparsed__.join(' ') : ''
}`;
} else {
return command;
}
}
function parseArgs(options: RunInteractiveCommandsOptions) {
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': false },
});
}
{
"version": 2,
"title": "Run Interactive Commands",
"description": "Run interactive commands via Nx.",
"type": "object",
"cli": "nx",
"outputCapture": "direct-nodejs",
"presets": [
{
"name": "Arguments forwarding",
"keys": ["commands"]
},
{
"name": "Custom done conditions",
"keys": ["commands", "readyWhen"]
},
{
"name": "Setting the cwd",
"keys": ["commands", "cwd"]
}
],
"properties": {
"commands": {
"type": "array",
"description": "Commands to run in child process.",
"items": {
"oneOf": [
{
"type": "object",
"properties": {
"command": {
"type": "string",
"description": "Command to run in child process."
},
"forwardAllArgs": {
"type": "boolean",
"description": "Whether arguments should be forwarded when interpolation is not present."
},
"prefix": {
"type": "string",
"description": "Prefix in front of every line out of the output"
},
"color": {
"type": "string",
"description": "Color of the output",
"enum": [
"black",
"red",
"green",
"yellow",
"blue",
"magenta",
"cyan",
"white"
]
},
"bgColor": {
"type": "string",
"description": "Background color of the output",
"enum": [
"bgBlack",
"bgRed",
"bgGreen",
"bgYellow",
"bgBlue",
"bgMagenta",
"bgCyan",
"bgWhite"
]
},
"description": {
"type": "string",
"description": "An optional description useful for inline documentation purposes. It is not used as part of the execution of the command."
}
},
"additionalProperties": false,
"required": ["command"]
},
{
"type": "string"
}
]
},
"x-priority": "important"
},
"command": {
"type": "string",
"description": "Command to run in child process.",
"x-priority": "important"
},
"parallel": {
"type": "boolean",
"description": "Run commands in parallel.",
"default": true,
"x-priority": "important"
},
"readyWhen": {
"type": "string",
"description": "String to appear in `stdout` or `stderr` that indicates that the task is done. When running multiple commands, this option can only be used when `parallel` is set to `true`. If not specified, the task is done when all the child processes complete."
},
"args": {
"type": "string",
"description": "Extra arguments. You can pass them as follows: nx run project:target --args='--wait=100'. You can then use {args.wait} syntax to interpolate them in the workspace config file. See example [above](#chaining-commands-interpolating-args-and-setting-the-cwd)"
},
"envFile": {
"type": "string",
"description": "You may specify a custom .env file path."
},
"color": {
"type": "boolean",
"description": "Use colors when showing output of command.",
"default": false
},
"outputPath": {
"description": "Allows you to specify where the build artifacts are stored. This allows Nx Cloud to pick them up correctly, in the case that the build artifacts are placed somewhere other than the top level dist folder.",
"oneOf": [
{
"type": "string"
},
{
"type": "array",
"items": {
"type": "string"
}
}
]
},
"cwd": {
"type": "string",
"description": "Current working directory of the commands. If it's not specified the commands will run in the workspace root, if a relative path is specified the commands will run in that path relative to the workspace root and if it's an absolute path the commands will run in that path."
},
"__unparsed__": {
"hidden": true,
"type": "array",
"items": {
"type": "string"
},
"$default": {
"$source": "unparsed"
},
"x-priority": "internal"
}
},
"additionalProperties": true,
"oneOf": [
{
"required": ["commands"]
},
{
"required": ["command"]
}
]
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment