diff --git a/packages/exec/src/command-runner/command-runner.ts b/packages/exec/src/command-runner/command-runner.ts index f2f0967ccb..523da872fb 100644 --- a/packages/exec/src/command-runner/command-runner.ts +++ b/packages/exec/src/command-runner/command-runner.ts @@ -1,6 +1,9 @@ import * as exec from '../exec' import {CommandRunnerBase} from './core' import { + ErrorMatcher, + ExitCodeMatcher, + OutputMatcher, failAction, matchEvent, matchExitCode, @@ -8,15 +11,12 @@ import { matchSpecificError, produceLog, throwError -} from './middleware' +} from './middlware' import { CommandRunnerActionType, CommandRunnerEventTypeExtended, CommandRunnerMiddleware, - CommandRunnerOptions, - ErrorMatcher, - ExitCodeMatcher, - OutputMatcher + CommandRunnerOptions } from './types' const commandRunnerActions = { @@ -26,6 +26,30 @@ const commandRunnerActions = { } as const export class CommandRunner extends CommandRunnerBase { + /** + * Sets middleware (default or custom) to be executed on command runner run + * @param event allows to set middleware on certain event + * - `execerr` - when error happens during command execution + * - `stderr` - when stderr is not empty + * - `stdout` - when stdout is not empty + * - `exitcode` - when exit code is not 0 + * - `ok` - when exit code is 0 and stderr is empty + * Each event can also be negated by prepending `!` to it, e.g. `!ok` + * @param action allows to set action to be executed on event, it can be + * either default action (passed as string) or a custom middleware, default + * actions are: + * - `throw` - throws an error with message passed as second argument or a default one (inferred from event type) + * - `fail` - fails the command with message passed as second argument or a default one (inferred from event type) + * - `log` - logs the message passed as second argument or a default one (inferred from event type) + * @param message optional message to be passed to action, is not relevant when action is a custom middleware + * @example ```typescript + * const runner = createCommandRunner('echo', ['hello']) + * await runner + * .on('ok', 'log', 'Command executed successfully') + * .on('!ok', 'throw') + * .run() + * ``` + */ on( event: CommandRunnerEventTypeExtended | CommandRunnerEventTypeExtended[], action: CommandRunnerActionType | CommandRunnerMiddleware, @@ -40,6 +64,23 @@ export class CommandRunner extends CommandRunnerBase { return this } + /** + * Sets middleware (default or custom) to be executed when command executed + * with empty stdout. + * @param action allows to set action to be executed on event, it can be + * either default action (passed as string) or a custom middleware, default + * actions are: + * - `throw` - throws an error with message passed as second argument or a default one (inferred from event type) + * - `fail` - fails the command with message passed as second argument or a default one (inferred from event type) + * - `log` - logs the message passed as second argument or a default one (inferred from event type) + * @param message optional message to be passed to action, is not relevant when action is a custom middleware + * @example ```typescript + * const runner = createCommandRunner('echo', ['hello']) + * await runner + * .onEmptyOutput('throw', 'Command did not produce an output') + * .run() + * ``` + */ onEmptyOutput( action: CommandRunnerActionType | CommandRunnerMiddleware, message?: string @@ -48,6 +89,23 @@ export class CommandRunner extends CommandRunnerBase { return this } + /** + * Sets middleware (default or custom) to be executed when command failed + * to execute (either did not find such command or failed to spawn it). + * @param action allows to set action to be executed on event, it can be + * either default action (passed as string) or a custom middleware, default + * actions are: + * - `throw` - throws an error with message passed as second argument or a default one (inferred from event type) + * - `fail` - fails the command with message passed as second argument or a default one (inferred from event type) + * - `log` - logs the message passed as second argument or a default one (inferred from event type) + * @param message optional message to be passed to action, is not relevant when action is a custom middleware + * @example ```typescript + * const runner = createCommandRunner('echo', ['hello']) + * await runner + * .onExecutionError('throw', 'Command failed to execute') + * .run() + * ``` + */ onExecutionError( action: CommandRunnerActionType | CommandRunnerMiddleware, message?: string @@ -62,6 +120,23 @@ export class CommandRunner extends CommandRunnerBase { return this } + /** + * Sets middleware (default or custom) to be executed when command produced + * non-empty stderr. + * @param action allows to set action to be executed on event, it can be + * either default action (passed as string) or a custom middleware, default + * actions are: + * - `throw` - throws an error with message passed as second argument or a default one (inferred from event type) + * - `fail` - fails the command with message passed as second argument or a default one (inferred from event type) + * - `log` - logs the message passed as second argument or a default one (inferred from event type) + * @param message optional message to be passed to action, is not relevant when action is a custom middleware + * @example ```typescript + * const runner = createCommandRunner('echo', ['hello']) + * await runner + * .onStdError('throw', 'Command produced an error') + * .run() + * ``` + */ onStdError( action: CommandRunnerActionType | CommandRunnerMiddleware, message?: string @@ -76,6 +151,23 @@ export class CommandRunner extends CommandRunnerBase { return this } + /** + * Sets middleware (default or custom) to be executed when command produced + * non-empty stderr or failed to execute (either did not find such command or failed to spawn it). + * @param action allows to set action to be executed on event, it can be + * either default action (passed as string) or a custom middleware, default + * actions are: + * - `throw` - throws an error with message passed as second argument or a default one (inferred from event type) + * - `fail` - fails the command with message passed as second argument or a default one (inferred from event type) + * - `log` - logs the message passed as second argument or a default one (inferred from event type) + * @param message optional message to be passed to action, is not relevant when action is a custom middleware + * @example ```typescript + * const runner = createCommandRunner('echo', ['hello']) + * await runner + * .onError('throw', 'Command produced an error or failed to execute') + * .run() + * ``` + */ onError( action: CommandRunnerActionType | CommandRunnerMiddleware, message?: string @@ -83,6 +175,28 @@ export class CommandRunner extends CommandRunnerBase { return this.on(['execerr', 'stderr'], action, message) } + /** + * Sets middleware (default or custom) to be executed when command produced + * an error that matches provided matcher. + * @param matcher allows to match specific error, can be either a string (to match error message exactly), + * a regular expression (to match error message with it) or a function (to match error object with it) + * @param action allows to set action to be executed on event, it can be + * either default action (passed as string) or a custom middleware, default + * actions are: + * - `throw` - throws an error with message passed as second argument or a default one (inferred from event type) + * - `fail` - fails the command with message passed as second argument or a default one (inferred from event type) + * - `log` - logs the message passed as second argument or a default one (inferred from event type) + * @param message optional message to be passed to action, is not relevant when action is a custom middleware + * @example ```typescript + * await createCommandRunner() + * .setCommand('curl') + * .setArgs(['-f', 'http://example.com/']) + * .onSpecificError('Failed to connect to example.com port 80: Connection refused', 'throw', 'Failed to connect to example.com') + * .onSpecificError(/429/, log, 'Too many requests, retrying in 4 seconds') + * .onSpecificError(/429/, () => retryIn(4000)) + * .run() + * ``` + */ onSpecificError( matcher: ErrorMatcher, action: CommandRunnerActionType | CommandRunnerMiddleware, @@ -98,6 +212,23 @@ export class CommandRunner extends CommandRunnerBase { return this } + /** + * Sets middleware (default or custom) to be executed when command produced + * zero exit code and empty stderr. + * @param action allows to set action to be executed on event, it can be + * either default action (passed as string) or a custom middleware, default + * actions are: + * - `throw` - throws an error with message passed as second argument or a default one (inferred from event type) + * - `fail` - fails the command with message passed as second argument or a default one (inferred from event type) + * - `log` - logs the message passed as second argument or a default one (inferred from event type) + * @param message optional message to be passed to action, is not relevant when action is a custom middleware + * @example ```typescript + * const runner = createCommandRunner('echo', ['hello']) + * await runner + * .onSuccess('log', 'Command executed successfully') + * .run() + * ``` + */ onSuccess( action: CommandRunnerActionType | CommandRunnerMiddleware, message?: string @@ -105,6 +236,27 @@ export class CommandRunner extends CommandRunnerBase { return this.on('ok', action, message) } + /** + * Sets middleware (default or custom) to be executed when command produced an + * exit code that matches provided matcher. + * @param matcher allows to match specific exit code, can be either a number (to match exit code exactly) + * or a string to match exit code against operator and number, e.g. `'>= 0'` + * @param action allows to set action to be executed on event, it can be + * either default action (passed as string) or a custom middleware, default + * actions are: + * - `throw` - throws an error with message passed as second argument or a default one (inferred from event type) + * - `fail` - fails the command with message passed as second argument or a default one (inferred from event type) + * - `log` - logs the message passed as second argument or a default one (inferred from event type) + * @param message optional message to be passed to action, is not relevant when action is a custom middleware + * @example ```typescript + * await createCommandRunner() + * .setCommand('curl') + * .setArgs(['-f', 'http://example.com/']) + * .onExitCode(0, 'log', 'Command executed successfully') + * .onExitCode('>= 400', 'throw', 'Command failed to execute') + * .run() + * ``` + */ onExitCode( matcher: ExitCodeMatcher, action: CommandRunnerActionType | CommandRunnerMiddleware, @@ -120,6 +272,27 @@ export class CommandRunner extends CommandRunnerBase { return this } + /** + * Sets middleware (default or custom) to be executed when command produced + * the stdout that matches provided matcher. + * @param matcher allows to match specific stdout, can be either a string (to match stdout exactly), + * a regular expression (to match stdout with it) or a function (to match stdout with it) + * @param action allows to set action to be executed on event, it can be + * either default action (passed as string) or a custom middleware, default + * actions are: + * - `throw` - throws an error with message passed as second argument or a default one (inferred from matcher) + * - `fail` - fails the command with message passed as second argument or a default one (inferred from matcher) + * - `log` - logs the message passed as second argument or a default one (inferred from matcher) + * @param message optional message to be passed to action, is not relevant when action is a custom middleware + * @example ```typescript + * const runner = createCommandRunner('echo', ['hello']) + * await runner + * .onOutput('hello', 'log', 'Command executed successfully') + * .onOutput(/hello\S+/, 'log', 'What?') + * .onOutput(stdout => stdout.includes('world'), 'log', 'Huh') + * .run() + * ``` + */ onOutput( matcher: OutputMatcher, action: CommandRunnerActionType | CommandRunnerMiddleware, @@ -136,6 +309,13 @@ export class CommandRunner extends CommandRunnerBase { } } +/** + * Creates a command runner with provided command line, arguments and options + * @param commandLine command line to execute + * @param args arguments to pass to command + * @param options options to pass to command executor + * @returns command runner instance + */ export const createCommandRunner = ( commandLine = '', args: string[] = [], diff --git a/packages/exec/src/command-runner/core.ts b/packages/exec/src/command-runner/core.ts index e6917418da..795489abc6 100644 --- a/packages/exec/src/command-runner/core.ts +++ b/packages/exec/src/command-runner/core.ts @@ -17,6 +17,10 @@ export class CommandRunnerBase { private executor: typeof exec.exec = exec.exec ) {} + /** + * Sets command to be executed, passing a callback + * allows to modify command based on currently set command + */ setCommand(commandLine: string | ((commandLine: string) => string)): this { this.commandLine = typeof commandLine === 'function' @@ -26,6 +30,10 @@ export class CommandRunnerBase { return this } + /** + * Sets command arguments, passing a callback + * allows to modify arguments based on currently set arguments + */ setArgs(args: string[] | ((args: string[]) => string[])): this { this.args = typeof args === 'function' ? args(this.args) : [...this.args, ...args] @@ -33,6 +41,10 @@ export class CommandRunnerBase { return this } + /** + * Sets options for command executor (exec.exec by default), passing a callback + * allows to modify options based on currently set options + */ setOptions( options: | CommandRunnerOptions @@ -44,11 +56,37 @@ export class CommandRunnerBase { return this } + /** + * Sets arbitrary middleware to be executed on command runner run + * middleware is executed in the order it was added + * @param middleware middleware to be executed + * @example + * ```ts + * const runner = new CommandRunner() + * runner.use(async (ctx, next) => { + * console.log('before') + * const { + * exitCode // exit code of the command + * stdout // stdout of the command + * stderr // stderr of the command + * execerr // error thrown by the command executor + * commandLine // command line that was executed + * args // arguments that were passed to the command + * options // options that were passed to the command + * } = ctx + * await next() + * console.log('after') + * }) + * ``` + */ use(middleware: CommandRunnerMiddleware): this { this.middleware.push(promisifyFn(middleware)) return this } + /** + * Runs command with currently set options and arguments + */ async run( /* overrides command for this specific execution if not undefined */ commandLine?: string, @@ -117,9 +155,21 @@ export class CommandRunnerBase { } } +/** + * Composes multiple middleware into a single middleware + * implements a chain of responsibility pattern + * with next function passed to each middleware + * and each middleware being able to call next() to pass control to the next middleware + * or not call next() to stop the chain, + * it is also possible to run code after the next was called by using async/await + * for a cleanup or other purposes. + * This behavior is mostly implemented to be similar to express, koa or other middleware based frameworks + * in order to avoid confusion. Executing code after next() usually would not be needed. + */ export function composeMiddleware( middleware: CommandRunnerMiddleware[] ): PromisifiedFn { + // promisify all passed middleware middleware = middleware.map(mw => promisifyFn(mw)) return async ( @@ -128,6 +178,12 @@ export function composeMiddleware( ) => { let index = 0 + /** + * Picks the first not-yet-executed middleware from the list and + * runs it, passing itself as next function for + * that middleware to call, therefore would be called + * by each middleware in the chain + */ const nextLocal = async (): Promise => { if (index < middleware.length) { const currentMiddleware = middleware[index++] @@ -138,9 +194,18 @@ export function composeMiddleware( await currentMiddleware(context, nextLocal) } + /** + * If no middlware left to be executed + * will call the next funtion passed to the + * composed middleware + */ await nextGlobal() } + /** + * Starts the chain of middleware execution by + * calling nextLocal directly + */ await nextLocal() } } diff --git a/packages/exec/src/command-runner/get-events.ts b/packages/exec/src/command-runner/get-events.ts new file mode 100644 index 0000000000..5baac4ab59 --- /dev/null +++ b/packages/exec/src/command-runner/get-events.ts @@ -0,0 +1,55 @@ +import {CommandRunnerContext, CommandRunnerEventType} from './types' + +/** + * Keeps track of already computed events for context + * to avoid recomputing them + */ +let contextEvents: WeakMap< + CommandRunnerContext, + CommandRunnerEventType[] +> | null = null + +/** + * Returns event types that were triggered by the command execution + */ +export const getEvents = ( + ctx: CommandRunnerContext +): CommandRunnerEventType[] => { + const existingEvents = contextEvents?.get(ctx) + + if (existingEvents) { + return existingEvents + } + + const eventTypes = new Set() + + if (ctx.execerr) { + eventTypes.add('execerr') + } + + if (ctx.stderr) { + eventTypes.add('stderr') + } + + if (ctx.exitCode) { + eventTypes.add('exitcode') + } + + if (ctx.stdout) { + eventTypes.add('stdout') + } + + if (!ctx.stderr && !ctx.execerr && ctx.stdout !== null && !ctx.exitCode) { + eventTypes.add('ok') + } + + const result = [...eventTypes] + + if (!contextEvents) { + contextEvents = new WeakMap() + } + + contextEvents.set(ctx, result) + + return result +} diff --git a/packages/exec/src/command-runner/middleware.ts b/packages/exec/src/command-runner/middleware.ts deleted file mode 100644 index 3707e3e722..0000000000 --- a/packages/exec/src/command-runner/middleware.ts +++ /dev/null @@ -1,401 +0,0 @@ -import * as core from '@actions/core' -import { - CommandRunnerAction, - CommandRunnerContext, - CommandRunnerEventType, - CommandRunnerEventTypeExtended, - CommandRunnerMiddleware, - ErrorMatcher, - ExitCodeMatcher, - OutputMatcher -} from './types' -import {composeMiddleware} from './core' -import {gte, gt, lte, lt, eq, PromisifiedFn} from './utils' - -const getEventTypesFromContext = ( - ctx: CommandRunnerContext -): CommandRunnerEventType[] => { - const eventTypes = new Set() - - if (ctx.execerr) { - eventTypes.add('execerr') - } - - if (ctx.stderr) { - eventTypes.add('stderr') - } - - if (ctx.exitCode) { - eventTypes.add('exitcode') - } - - if (ctx.stdout) { - eventTypes.add('stdout') - } - - if (!ctx.stderr && !ctx.execerr && ctx.stdout !== null && !ctx.exitCode) { - eventTypes.add('ok') - } - - return [...eventTypes] -} - -/** - * Basic middleware - */ - -/** Calls next middleware */ -export const passThrough: () => PromisifiedFn = - () => async (_, next) => - next() - -/** - * Fails Github Action with the given message or with a default one depending on execution conditions. - */ -export const failAction: CommandRunnerAction = message => async ctx => { - const events = getEventTypesFromContext(ctx) - - if (message !== undefined) { - core.setFailed(typeof message === 'string' ? message : message(ctx, events)) - return - } - - if (events.includes('execerr')) { - core.setFailed( - `The command "${ctx.commandLine}" failed to run: ${ctx.execerr?.message}` - ) - - return - } - - if (events.includes('stderr')) { - core.setFailed( - `The command "${ctx.commandLine}" finished with exit code ${ctx.exitCode} and produced an error: ${ctx.stderr}` - ) - - return - } - - if (!events.includes('stdout')) { - core.setFailed( - `The command "${ctx.commandLine}" finished with exit code ${ctx.exitCode} and produced an empty output.` - ) - - return - } - - core.setFailed( - `The command "${ctx.commandLine}" finished with exit code ${ctx.exitCode} and produced the following output: ${ctx.stdout}` - ) -} - -/** - * Throws an error with the given message or with a default one depending on execution conditions. - */ -export const throwError: CommandRunnerAction = message => { - return async ctx => { - const events = getEventTypesFromContext(ctx) - - if (message !== undefined) { - throw new Error( - typeof message === 'string' ? message : message(ctx, events) - ) - } - - if (events.includes('execerr')) { - throw new Error( - `The command "${ctx.commandLine}" failed to run: ${ctx.execerr?.message}` - ) - } - - if (events.includes('stderr')) { - throw new Error( - `The command "${ctx.commandLine}" finished with exit code ${ctx.exitCode} and produced an error: ${ctx.stderr}` - ) - } - - if (!events.includes('stdout')) { - throw new Error( - `The command "${ctx.commandLine}" finished with exit code ${ctx.exitCode} and produced an empty output.` - ) - } - - throw new Error( - `The command "${ctx.commandLine}" finished with exit code ${ctx.exitCode} and produced the following output: ${ctx.stdout}` - ) - } -} - -/** - * Logs a message with the given message or with a default one depending on execution conditions. - */ -export const produceLog: CommandRunnerAction = message => async (ctx, next) => { - const events = getEventTypesFromContext(ctx) - - if (message !== undefined) { - // core.info(typeof message === 'string' ? message : message(ctx, [])) - const messageText = - typeof message === 'string' ? message : message(ctx, events) - - if (events.includes('execerr')) { - core.error(messageText) - next() - return - } - - if (events.includes('stderr')) { - core.error(messageText) - next() - return - } - - if (!events.includes('stdout')) { - core.warning(messageText) - next() - return - } - - if (events.includes('ok')) { - core.notice(messageText) - next() - return - } - - core.info(messageText) - next() - return - } - - if (events.includes('execerr')) { - core.error( - `The command "${ctx.commandLine}" failed to run: ${ctx.execerr?.message}` - ) - next() - return - } - - if (events.includes('stderr')) { - core.error( - `The command "${ctx.commandLine}" finished with exit code ${ctx.exitCode} and produced an error: ${ctx.stderr}` - ) - next() - return - } - - if (!events.includes('stdout')) { - core.warning( - `The command "${ctx.commandLine}" finished with exit code ${ctx.exitCode} and produced an empty output.` - ) - next() - return - } - - if (events.includes('ok')) { - core.notice( - `The command "${ctx.commandLine}" finished with exit code ${ctx.exitCode} and produced the following output: ${ctx.stdout}` - ) - next() - return - } - - core.info( - `The command "${ctx.commandLine}" finished with exit code ${ctx.exitCode} and produced the following output: ${ctx.stdout}` - ) - next() -} - -/** - * Will call passed middleware if matching event has occured. - * Will call the next middleware otherwise. - */ -export const matchEvent = ( - eventType: CommandRunnerEventTypeExtended | CommandRunnerEventTypeExtended[], - middleware?: CommandRunnerMiddleware[] -): PromisifiedFn => { - const composedMiddleware = composeMiddleware( - !middleware?.length ? [passThrough()] : middleware - ) - - const expectedEventsPositiveArray = ( - Array.isArray(eventType) ? eventType : [eventType] - ).filter(e => !e.startsWith('!')) as CommandRunnerEventType[] - - const expectedEventsNegativeArray = ( - Array.isArray(eventType) ? eventType : [eventType] - ) - .filter(e => e.startsWith('!')) - .map(e => e.slice(1)) as CommandRunnerEventType[] - - const expectedEventsPositive = new Set(expectedEventsPositiveArray) - const expectedEventsNegative = new Set(expectedEventsNegativeArray) - - return async (ctx, next) => { - const currentEvents = getEventTypesFromContext(ctx) - let shouldRun = false - - if ( - expectedEventsPositive.size && - currentEvents.some(e => expectedEventsPositive.has(e)) - ) { - shouldRun = true - } - - if ( - expectedEventsNegative.size && - currentEvents.every(e => !expectedEventsNegative.has(e)) - ) { - shouldRun = true - } - - if (shouldRun) { - await composedMiddleware(ctx, next) - return - } - - next() - } -} - -/** - * Will call passed middleware if matching event has occured. - * Will call the next middleware otherwise. - */ -export const matchOutput = ( - matcher: OutputMatcher, - middleware?: CommandRunnerMiddleware[] -): PromisifiedFn => { - const composedMiddleware = composeMiddleware( - !middleware?.length ? [passThrough()] : middleware - ) - - return async (ctx, next) => { - const output = ctx.stdout - - if (output === null) { - next() - return - } - - if (typeof matcher === 'function' && !matcher(output)) { - next() - return - } - - if (typeof matcher === 'string' && output !== matcher) { - next() - return - } - - if (matcher instanceof RegExp && !matcher.test(output)) { - next() - return - } - - await composedMiddleware(ctx, next) - } -} - -const removeWhitespaces = (str: string): string => str.replace(/\s/g, '') - -const MATCHERS = { - '>=': gte, - '>': gt, - '<=': lte, - '<': lt, - '=': eq -} as const - -const parseExitCodeMatcher = ( - code: ExitCodeMatcher -): [keyof typeof MATCHERS, number] => { - if (typeof code === 'number') { - return ['=', code] - } - - code = removeWhitespaces(code) - - // just shortcuts for the most common cases - if (code.startsWith('=')) return ['=', Number(code.slice(1))] - if (code === '>0') return ['>', 0] - if (code === '<1') return ['<', 1] - - const match = code.match(/^([><]=?)(\d+)$/) - - if (match === null) { - throw new Error(`Invalid exit code matcher: ${code}`) - } - - const [, operator, number] = match - return [operator as keyof typeof MATCHERS, parseInt(number)] -} - -/** - * Will call passed middleware if matching exit code was returned. - * Will call the next middleware otherwise. - */ -export const matchExitCode = ( - code: ExitCodeMatcher, - middleware?: CommandRunnerMiddleware[] -): PromisifiedFn => { - const [operator, number] = parseExitCodeMatcher(code) - const matcherFn = MATCHERS[operator](number) - - const composedMiddleware = composeMiddleware( - !middleware?.length ? [passThrough()] : middleware - ) - - return async (ctx, next) => { - // if exit code is undefined, NaN will not match anything - if (matcherFn(ctx.exitCode ?? NaN)) { - await composedMiddleware(ctx, next) - return - } - - next() - } -} - -export const matchSpecificError = ( - matcher: ErrorMatcher, - middleware?: - | CommandRunnerMiddleware[] - | PromisifiedFn[] -): PromisifiedFn => { - const composedMiddleware = composeMiddleware( - !middleware?.length ? [passThrough()] : middleware - ) - - return async (ctx, next) => { - if (ctx.execerr === null && ctx.stderr === null) { - next() - return - } - - const error: { - type: 'stderr' | 'execerr' - error: Error | null - message: string - } = { - type: ctx.execerr ? 'execerr' : 'stderr', - error: ctx.execerr ? ctx.execerr : null, - message: ctx.execerr ? ctx.execerr.message : ctx.stderr ?? '' - } - - if (typeof matcher === 'function' && !matcher(error)) { - next() - return - } - - if (typeof matcher === 'string' && error.message !== matcher) { - next() - return - } - - if (matcher instanceof RegExp && !matcher.test(error.message)) { - next() - return - } - - await composedMiddleware(ctx, next) - } -} diff --git a/packages/exec/src/command-runner/middlware/action-middleware.ts b/packages/exec/src/command-runner/middlware/action-middleware.ts new file mode 100644 index 0000000000..009bc7c7f0 --- /dev/null +++ b/packages/exec/src/command-runner/middlware/action-middleware.ts @@ -0,0 +1,158 @@ +import * as core from '@actions/core' +import {CommandRunnerAction} from '../types' +import {getEvents} from '../get-events' + +/** + * Fails Github Action with the given message or with a default one depending on execution conditions. + */ +export const failAction: CommandRunnerAction = message => async ctx => { + const events = getEvents(ctx) + + if (message !== undefined) { + core.setFailed(typeof message === 'string' ? message : message(ctx, events)) + return + } + + if (events.includes('execerr')) { + core.setFailed( + `The command "${ctx.commandLine}" failed to run: ${ctx.execerr?.message}` + ) + + return + } + + if (events.includes('stderr')) { + core.setFailed( + `The command "${ctx.commandLine}" finished with exit code ${ctx.exitCode} and produced an error: ${ctx.stderr}` + ) + + return + } + + if (!events.includes('stdout')) { + core.setFailed( + `The command "${ctx.commandLine}" finished with exit code ${ctx.exitCode} and produced an empty output.` + ) + + return + } + + core.setFailed( + `The command "${ctx.commandLine}" finished with exit code ${ctx.exitCode} and produced the following output: ${ctx.stdout}` + ) +} + +/** + * Throws an error with the given message or with a default one depending on execution conditions. + */ +export const throwError: CommandRunnerAction = message => { + return async ctx => { + const events = getEvents(ctx) + + if (message !== undefined) { + throw new Error( + typeof message === 'string' ? message : message(ctx, events) + ) + } + + if (events.includes('execerr')) { + throw new Error( + `The command "${ctx.commandLine}" failed to run: ${ctx.execerr?.message}` + ) + } + + if (events.includes('stderr')) { + throw new Error( + `The command "${ctx.commandLine}" finished with exit code ${ctx.exitCode} and produced an error: ${ctx.stderr}` + ) + } + + if (!events.includes('stdout')) { + throw new Error( + `The command "${ctx.commandLine}" finished with exit code ${ctx.exitCode} and produced an empty output.` + ) + } + + throw new Error( + `The command "${ctx.commandLine}" finished with exit code ${ctx.exitCode} and produced the following output: ${ctx.stdout}` + ) + } +} + +/** + * Logs a message with the given message or with a default one depending on execution conditions. + */ +export const produceLog: CommandRunnerAction = message => async (ctx, next) => { + const events = getEvents(ctx) + + if (message !== undefined) { + // core.info(typeof message === 'string' ? message : message(ctx, [])) + const messageText = + typeof message === 'string' ? message : message(ctx, events) + + if (events.includes('execerr')) { + core.error(messageText) + next() + return + } + + if (events.includes('stderr')) { + core.error(messageText) + next() + return + } + + if (!events.includes('stdout')) { + core.warning(messageText) + next() + return + } + + if (events.includes('ok')) { + core.notice(messageText) + next() + return + } + + core.info(messageText) + next() + return + } + + if (events.includes('execerr')) { + core.error( + `The command "${ctx.commandLine}" failed to run: ${ctx.execerr?.message}` + ) + next() + return + } + + if (events.includes('stderr')) { + core.error( + `The command "${ctx.commandLine}" finished with exit code ${ctx.exitCode} and produced an error: ${ctx.stderr}` + ) + next() + return + } + + if (!events.includes('stdout')) { + core.warning( + `The command "${ctx.commandLine}" finished with exit code ${ctx.exitCode} and produced an empty output.` + ) + next() + return + } + + if (events.includes('ok')) { + core.notice( + `The command "${ctx.commandLine}" finished with exit code ${ctx.exitCode} and produced the following output: ${ctx.stdout}` + ) + next() + return + } + + core.info( + `The command "${ctx.commandLine}" finished with exit code ${ctx.exitCode} and produced the following output: ${ctx.stdout}` + ) + next() +} diff --git a/packages/exec/src/command-runner/middlware/index.ts b/packages/exec/src/command-runner/middlware/index.ts new file mode 100644 index 0000000000..9097aaf5b9 --- /dev/null +++ b/packages/exec/src/command-runner/middlware/index.ts @@ -0,0 +1,6 @@ +export * from './action-middleware' +export * from './match-error' +export * from './match-event' +export * from './match-exitcode' +export * from './match-output' +export * from './pass-through' diff --git a/packages/exec/src/command-runner/middlware/match-error.ts b/packages/exec/src/command-runner/middlware/match-error.ts new file mode 100644 index 0000000000..acca69689c --- /dev/null +++ b/packages/exec/src/command-runner/middlware/match-error.ts @@ -0,0 +1,71 @@ +import {composeMiddleware} from '../core' +import {passThrough} from './pass-through' +import {CommandRunnerMiddleware} from '../types' +import {PromisifiedFn} from '../utils' + +/** + * Matcher types that are available to user to match error against + * and set middleware on + */ +export type ErrorMatcher = + | RegExp + | string + | ((error: { + type: 'stderr' | 'execerr' + error: Error | null + message: string + }) => boolean) + +/** + * Will call passed middleware if matching error has occured. + * If matching error has occured will call passed middleware. Will call the next middleware otherwise. + */ +export const matchSpecificError = ( + matcher: ErrorMatcher, + middleware?: + | CommandRunnerMiddleware[] + | PromisifiedFn[] +): PromisifiedFn => { + /** + * Composes passed middleware if any or replaces them + * with passThrough middleware if none were passed + * to avoid errors when calling composed middleware + */ + const composedMiddleware = composeMiddleware( + !middleware?.length ? [passThrough()] : middleware + ) + + return async (ctx, next) => { + if (ctx.execerr === null && ctx.stderr === null) { + next() + return + } + + const error: { + type: 'stderr' | 'execerr' + error: Error | null + message: string + } = { + type: ctx.execerr ? 'execerr' : 'stderr', + error: ctx.execerr ? ctx.execerr : null, + message: ctx.execerr ? ctx.execerr.message : ctx.stderr ?? '' + } + + if (typeof matcher === 'function' && !matcher(error)) { + next() + return + } + + if (typeof matcher === 'string' && error.message !== matcher) { + next() + return + } + + if (matcher instanceof RegExp && !matcher.test(error.message)) { + next() + return + } + + await composedMiddleware(ctx, next) + } +} diff --git a/packages/exec/src/command-runner/middlware/match-event.ts b/packages/exec/src/command-runner/middlware/match-event.ts new file mode 100644 index 0000000000..20b7de5a12 --- /dev/null +++ b/packages/exec/src/command-runner/middlware/match-event.ts @@ -0,0 +1,66 @@ +import {composeMiddleware} from '../core' +import {getEvents} from '../get-events' +import { + CommandRunnerEventTypeExtended, + CommandRunnerMiddleware, + CommandRunnerEventType +} from '../types' +import {PromisifiedFn} from '../utils' +import {passThrough} from './pass-through' + +/** + * Will call passed middleware if matching event has occured. + * Will call the next middleware otherwise. + */ +export const matchEvent = ( + eventType: CommandRunnerEventTypeExtended | CommandRunnerEventTypeExtended[], + middleware?: CommandRunnerMiddleware[] +): PromisifiedFn => { + /** + * Composes passed middleware if any or replaces them + * with passThrough middleware if none were passed + * to avoid errors when calling composed middleware + */ + const composedMiddleware = composeMiddleware( + !middleware?.length ? [passThrough()] : middleware + ) + + const expectedEventsPositiveArray = ( + Array.isArray(eventType) ? eventType : [eventType] + ).filter(e => !e.startsWith('!')) as CommandRunnerEventType[] + + const expectedEventsNegativeArray = ( + Array.isArray(eventType) ? eventType : [eventType] + ) + .filter(e => e.startsWith('!')) + .map(e => e.slice(1)) as CommandRunnerEventType[] + + const expectedEventsPositive = new Set(expectedEventsPositiveArray) + const expectedEventsNegative = new Set(expectedEventsNegativeArray) + + return async (ctx, next) => { + const currentEvents = getEvents(ctx) + let shouldRun = false + + if ( + expectedEventsPositive.size && + currentEvents.some(e => expectedEventsPositive.has(e)) + ) { + shouldRun = true + } + + if ( + expectedEventsNegative.size && + currentEvents.every(e => !expectedEventsNegative.has(e)) + ) { + shouldRun = true + } + + if (shouldRun) { + await composedMiddleware(ctx, next) + return + } + + next() + } +} diff --git a/packages/exec/src/command-runner/middlware/match-exitcode.ts b/packages/exec/src/command-runner/middlware/match-exitcode.ts new file mode 100644 index 0000000000..a789117c8f --- /dev/null +++ b/packages/exec/src/command-runner/middlware/match-exitcode.ts @@ -0,0 +1,100 @@ +import {composeMiddleware} from '../core' +import {passThrough} from './pass-through' +import {CommandRunnerMiddleware} from '../types' +import {PromisifiedFn, removeWhitespaces} from '../utils' + +/** + * Matcher types that are available to user to match exit code against + * and set middleware on + */ +export type ExitCodeMatcher = string | number + +/** + * Comparators + */ +export const lte = + (a: number) => + (b: number): boolean => + b <= a +export const gte = + (a: number) => + (b: number): boolean => + b >= a +export const lt = + (a: number) => + (b: number): boolean => + b < a +export const gt = + (a: number) => + (b: number): boolean => + b > a +export const eq = + (a: number) => + (b: number): boolean => + b === a + +const MATCHERS = { + '>=': gte, + '>': gt, + '<=': lte, + '<': lt, + '=': eq +} as const + +const parseExitCodeMatcher = ( + code: ExitCodeMatcher +): [keyof typeof MATCHERS, number] => { + if (typeof code === 'number') { + return ['=', code] + } + + code = removeWhitespaces(code) + + // just shortcuts for the most common cases + if (code.startsWith('=')) return ['=', Number(code.slice(1))] + if (code === '>0') return ['>', 0] + if (code === '<1') return ['<', 1] + + const match = code.match(/^([><]=?)(\d+)$/) + + if (match === null) { + throw new Error(`Invalid exit code matcher: ${code}`) + } + + const [, operator, number] = match + return [operator as keyof typeof MATCHERS, parseInt(number)] +} + +/** + * Will call passed middleware if matching exit code was returned. + * Will call the next middleware otherwise. Will also call next middleware + * if exit code is null (command did not run). + */ +export const matchExitCode = ( + code: ExitCodeMatcher, + middleware?: CommandRunnerMiddleware[] +): PromisifiedFn => { + const [operator, number] = parseExitCodeMatcher(code) + + // sets appropriate matching function + const matcherFn = MATCHERS[operator](number) + + /** + * Composes passed middleware if any or replaces them + * with passThrough middleware if none were passed + * to avoid errors when calling composed middleware + */ + const composedMiddleware = composeMiddleware( + !middleware?.length ? [passThrough()] : middleware + ) + + return async (ctx, next) => { + // if exit code is undefined, NaN will not match anything + if (matcherFn(ctx.exitCode ?? NaN)) { + await composedMiddleware(ctx, next) + return + } + + next() + } +} diff --git a/packages/exec/src/command-runner/middlware/match-output.ts b/packages/exec/src/command-runner/middlware/match-output.ts new file mode 100644 index 0000000000..3e121ee1d1 --- /dev/null +++ b/packages/exec/src/command-runner/middlware/match-output.ts @@ -0,0 +1,55 @@ +import {composeMiddleware} from '../core' +import {passThrough} from './pass-through' +import {CommandRunnerMiddleware} from '../types' +import {PromisifiedFn} from '../utils' + +/** + * Matcher types that are available to user to match output against + * and set middleware on + */ +export type OutputMatcher = RegExp | string | ((output: string) => boolean) + +/** + * Will call passed middleware if command produced a matching stdout. + * Will call the next middleware otherwise. Will also call the next middleware + * if stdout is null (command did not run). + */ +export const matchOutput = ( + matcher: OutputMatcher, + middleware?: CommandRunnerMiddleware[] +): PromisifiedFn => { + /** + * Composes passed middleware if any or replaces them + * with passThrough middleware if none were passed + * to avoid errors when calling composed middleware + */ + const composedMiddleware = composeMiddleware( + !middleware?.length ? [passThrough()] : middleware + ) + + return async (ctx, next) => { + const output = ctx.stdout + + if (output === null) { + next() + return + } + + if (typeof matcher === 'function' && !matcher(output)) { + next() + return + } + + if (typeof matcher === 'string' && output !== matcher) { + next() + return + } + + if (matcher instanceof RegExp && !matcher.test(output)) { + next() + return + } + + await composedMiddleware(ctx, next) + } +} diff --git a/packages/exec/src/command-runner/middlware/pass-through.ts b/packages/exec/src/command-runner/middlware/pass-through.ts new file mode 100644 index 0000000000..b48748412f --- /dev/null +++ b/packages/exec/src/command-runner/middlware/pass-through.ts @@ -0,0 +1,7 @@ +import {CommandRunnerMiddleware} from '../types' +import {PromisifiedFn} from '../utils' + +/** Calls next middleware */ +export const passThrough: () => PromisifiedFn = + () => async (_, next) => + next() diff --git a/packages/exec/src/command-runner/types.ts b/packages/exec/src/command-runner/types.ts index 7f4659aa87..5f2a9d03bf 100644 --- a/packages/exec/src/command-runner/types.ts +++ b/packages/exec/src/command-runner/types.ts @@ -1,41 +1,64 @@ import * as exec from '../exec' import {PromisifiedFn} from './utils' -/* CommandRunner core */ - +/** + * CommandRunner.prototype.run() outpout and context + * that is passed to each middleware + */ export interface CommandRunnerContext { - /* Inputs with which command was executed */ + /** Command that was executed */ commandLine: string + + /** Arguments with which command was executed */ args: string[] + + /** Command options with which command executor was ran */ options: exec.ExecOptions - /* Results of the execution */ + /** Error that was thrown when attempting to execute command */ execerr: Error | null + + /** Command's output that was passed to stderr if command did run, null otherwise */ stderr: string | null + + /** Command's output that was passed to stdout if command did run, null otherwise */ stdout: string | null + + /** Command's exit code if command did run, null otherwise */ exitCode: number | null } -/* Middlewares as used by the user */ +/** + * Base middleware shape + */ type _CommandRunnerMiddleware = ( ctx: CommandRunnerContext, next: () => Promise ) => void | Promise +/** + * Normalized middleware shape that is always promisified + */ export type CommandRunnerMiddleware = PromisifiedFn<_CommandRunnerMiddleware> -/* Command runner events handling and command runner actions */ - +/** + * Shape for the command runner default middleware creators + */ export type CommandRunnerAction = ( message?: | string | ((ctx: CommandRunnerContext, events: CommandRunnerEventType[]) => string) ) => PromisifiedFn -/* Command runner default actions types on which preset middleware exists */ +/** + * Default middleware identifires that can be uset to set respective action + * in copmposing middleware + */ export type CommandRunnerActionType = 'throw' | 'fail' | 'log' -/* Command runner event types as used internally passed to middleware for the user */ +/** + * Command runner event types on which middleware can be set + */ export type CommandRunnerEventType = | 'execerr' | 'stderr' @@ -43,27 +66,19 @@ export type CommandRunnerEventType = | 'exitcode' | 'ok' -/* Command runner event types as used by the user for filtering results */ +/** + * Extended event type that can be used to set middleware on event not happening + */ export type CommandRunnerEventTypeExtended = | CommandRunnerEventType | `!${CommandRunnerEventType}` +/** + * options that would be passed to the command executor (exec.exec by default) + * failOnStdErr and ignoreReturnCode are excluded as they are + * handled by the CommandRunner itself + */ export type CommandRunnerOptions = Omit< exec.ExecOptions, 'failOnStdErr' | 'ignoreReturnCode' > - -/* Matchers */ - -export type OutputMatcher = RegExp | string | ((output: string) => boolean) - -export type ExitCodeMatcher = string | number - -export type ErrorMatcher = - | RegExp - | string - | ((error: { - type: 'stderr' | 'execerr' - error: Error | null - message: string - }) => boolean) diff --git a/packages/exec/src/command-runner/utils.ts b/packages/exec/src/command-runner/utils.ts index 279f5c4955..6afe2c7d53 100644 --- a/packages/exec/src/command-runner/utils.ts +++ b/packages/exec/src/command-runner/utils.ts @@ -1,7 +1,6 @@ /** - * Promises + * Promisifies a a function type */ - // eslint-disable-next-line @typescript-eslint/no-explicit-any export type PromisifiedFn any> = ( ...args: Parameters @@ -9,6 +8,9 @@ export type PromisifiedFn any> = ( ? ReturnType : Promise> +/** + * Promisifies a function + */ // eslint-disable-next-line @typescript-eslint/no-explicit-any export const promisifyFn = any>( fn: T @@ -27,26 +29,6 @@ export const promisifyFn = any>( } /** - * Comparators + * Removes all whitespaces from a string */ - -export const lte = - (a: number) => - (b: number): boolean => - b <= a -export const gte = - (a: number) => - (b: number): boolean => - b >= a -export const lt = - (a: number) => - (b: number): boolean => - b < a -export const gt = - (a: number) => - (b: number): boolean => - b > a -export const eq = - (a: number) => - (b: number): boolean => - b === a +export const removeWhitespaces = (str: string): string => str.replace(/\s/g, '') diff --git a/packages/exec/src/exec.ts b/packages/exec/src/exec.ts index 2a67a912d5..0693d1c011 100644 --- a/packages/exec/src/exec.ts +++ b/packages/exec/src/exec.ts @@ -2,6 +2,8 @@ import {StringDecoder} from 'string_decoder' import {ExecOptions, ExecOutput, ExecListeners} from './interfaces' import * as tr from './toolrunner' +export {CommandRunner, createCommandRunner} from './command-runner' + export {ExecOptions, ExecOutput, ExecListeners} /**