1
0
Fork 0

Rearrange code and add comprehensive comments

pull/1562/head
Nikolai Laevskii 2023-10-16 11:31:09 +02:00
parent b7fcb99778
commit bfa86cc586
14 changed files with 816 additions and 455 deletions

View File

@ -1,6 +1,9 @@
import * as exec from '../exec' import * as exec from '../exec'
import {CommandRunnerBase} from './core' import {CommandRunnerBase} from './core'
import { import {
ErrorMatcher,
ExitCodeMatcher,
OutputMatcher,
failAction, failAction,
matchEvent, matchEvent,
matchExitCode, matchExitCode,
@ -8,15 +11,12 @@ import {
matchSpecificError, matchSpecificError,
produceLog, produceLog,
throwError throwError
} from './middleware' } from './middlware'
import { import {
CommandRunnerActionType, CommandRunnerActionType,
CommandRunnerEventTypeExtended, CommandRunnerEventTypeExtended,
CommandRunnerMiddleware, CommandRunnerMiddleware,
CommandRunnerOptions, CommandRunnerOptions
ErrorMatcher,
ExitCodeMatcher,
OutputMatcher
} from './types' } from './types'
const commandRunnerActions = { const commandRunnerActions = {
@ -26,6 +26,30 @@ const commandRunnerActions = {
} as const } as const
export class CommandRunner extends CommandRunnerBase { 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( on(
event: CommandRunnerEventTypeExtended | CommandRunnerEventTypeExtended[], event: CommandRunnerEventTypeExtended | CommandRunnerEventTypeExtended[],
action: CommandRunnerActionType | CommandRunnerMiddleware, action: CommandRunnerActionType | CommandRunnerMiddleware,
@ -40,6 +64,23 @@ export class CommandRunner extends CommandRunnerBase {
return this 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( onEmptyOutput(
action: CommandRunnerActionType | CommandRunnerMiddleware, action: CommandRunnerActionType | CommandRunnerMiddleware,
message?: string message?: string
@ -48,6 +89,23 @@ export class CommandRunner extends CommandRunnerBase {
return this 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( onExecutionError(
action: CommandRunnerActionType | CommandRunnerMiddleware, action: CommandRunnerActionType | CommandRunnerMiddleware,
message?: string message?: string
@ -62,6 +120,23 @@ export class CommandRunner extends CommandRunnerBase {
return this 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( onStdError(
action: CommandRunnerActionType | CommandRunnerMiddleware, action: CommandRunnerActionType | CommandRunnerMiddleware,
message?: string message?: string
@ -76,6 +151,23 @@ export class CommandRunner extends CommandRunnerBase {
return this 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( onError(
action: CommandRunnerActionType | CommandRunnerMiddleware, action: CommandRunnerActionType | CommandRunnerMiddleware,
message?: string message?: string
@ -83,6 +175,28 @@ export class CommandRunner extends CommandRunnerBase {
return this.on(['execerr', 'stderr'], action, message) 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( onSpecificError(
matcher: ErrorMatcher, matcher: ErrorMatcher,
action: CommandRunnerActionType | CommandRunnerMiddleware, action: CommandRunnerActionType | CommandRunnerMiddleware,
@ -98,6 +212,23 @@ export class CommandRunner extends CommandRunnerBase {
return this 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( onSuccess(
action: CommandRunnerActionType | CommandRunnerMiddleware, action: CommandRunnerActionType | CommandRunnerMiddleware,
message?: string message?: string
@ -105,6 +236,27 @@ export class CommandRunner extends CommandRunnerBase {
return this.on('ok', action, message) 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( onExitCode(
matcher: ExitCodeMatcher, matcher: ExitCodeMatcher,
action: CommandRunnerActionType | CommandRunnerMiddleware, action: CommandRunnerActionType | CommandRunnerMiddleware,
@ -120,6 +272,27 @@ export class CommandRunner extends CommandRunnerBase {
return this 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( onOutput(
matcher: OutputMatcher, matcher: OutputMatcher,
action: CommandRunnerActionType | CommandRunnerMiddleware, 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 = ( export const createCommandRunner = (
commandLine = '', commandLine = '',
args: string[] = [], args: string[] = [],

View File

@ -17,6 +17,10 @@ export class CommandRunnerBase {
private executor: typeof exec.exec = exec.exec 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 { setCommand(commandLine: string | ((commandLine: string) => string)): this {
this.commandLine = this.commandLine =
typeof commandLine === 'function' typeof commandLine === 'function'
@ -26,6 +30,10 @@ export class CommandRunnerBase {
return this return this
} }
/**
* Sets command arguments, passing a callback
* allows to modify arguments based on currently set arguments
*/
setArgs(args: string[] | ((args: string[]) => string[])): this { setArgs(args: string[] | ((args: string[]) => string[])): this {
this.args = this.args =
typeof args === 'function' ? args(this.args) : [...this.args, ...args] typeof args === 'function' ? args(this.args) : [...this.args, ...args]
@ -33,6 +41,10 @@ export class CommandRunnerBase {
return this return this
} }
/**
* Sets options for command executor (exec.exec by default), passing a callback
* allows to modify options based on currently set options
*/
setOptions( setOptions(
options: options:
| CommandRunnerOptions | CommandRunnerOptions
@ -44,11 +56,37 @@ export class CommandRunnerBase {
return this 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 { use(middleware: CommandRunnerMiddleware): this {
this.middleware.push(promisifyFn(middleware)) this.middleware.push(promisifyFn(middleware))
return this return this
} }
/**
* Runs command with currently set options and arguments
*/
async run( async run(
/* overrides command for this specific execution if not undefined */ /* overrides command for this specific execution if not undefined */
commandLine?: string, 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( export function composeMiddleware(
middleware: CommandRunnerMiddleware[] middleware: CommandRunnerMiddleware[]
): PromisifiedFn<CommandRunnerMiddleware> { ): PromisifiedFn<CommandRunnerMiddleware> {
// promisify all passed middleware
middleware = middleware.map(mw => promisifyFn(mw)) middleware = middleware.map(mw => promisifyFn(mw))
return async ( return async (
@ -128,6 +178,12 @@ export function composeMiddleware(
) => { ) => {
let index = 0 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<void> => { const nextLocal = async (): Promise<void> => {
if (index < middleware.length) { if (index < middleware.length) {
const currentMiddleware = middleware[index++] const currentMiddleware = middleware[index++]
@ -138,9 +194,18 @@ export function composeMiddleware(
await currentMiddleware(context, nextLocal) await currentMiddleware(context, nextLocal)
} }
/**
* If no middlware left to be executed
* will call the next funtion passed to the
* composed middleware
*/
await nextGlobal() await nextGlobal()
} }
/**
* Starts the chain of middleware execution by
* calling nextLocal directly
*/
await nextLocal() await nextLocal()
} }
} }

View File

@ -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<CommandRunnerEventType>()
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
}

View File

@ -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<CommandRunnerEventType>()
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<CommandRunnerMiddleware> =
() => 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<CommandRunnerMiddleware> => {
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<CommandRunnerMiddleware> => {
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<CommandRunnerMiddleware> => {
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<CommandRunnerMiddleware>[]
): PromisifiedFn<CommandRunnerMiddleware> => {
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)
}
}

View File

@ -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()
}

View File

@ -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'

View File

@ -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<CommandRunnerMiddleware>[]
): PromisifiedFn<CommandRunnerMiddleware> => {
/**
* 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)
}
}

View File

@ -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<CommandRunnerMiddleware> => {
/**
* 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()
}
}

View File

@ -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<CommandRunnerMiddleware> => {
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()
}
}

View File

@ -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<CommandRunnerMiddleware> => {
/**
* 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)
}
}

View File

@ -0,0 +1,7 @@
import {CommandRunnerMiddleware} from '../types'
import {PromisifiedFn} from '../utils'
/** Calls next middleware */
export const passThrough: () => PromisifiedFn<CommandRunnerMiddleware> =
() => async (_, next) =>
next()

View File

@ -1,41 +1,64 @@
import * as exec from '../exec' import * as exec from '../exec'
import {PromisifiedFn} from './utils' import {PromisifiedFn} from './utils'
/* CommandRunner core */ /**
* CommandRunner.prototype.run() outpout and context
* that is passed to each middleware
*/
export interface CommandRunnerContext { export interface CommandRunnerContext {
/* Inputs with which command was executed */ /** Command that was executed */
commandLine: string commandLine: string
/** Arguments with which command was executed */
args: string[] args: string[]
/** Command options with which command executor was ran */
options: exec.ExecOptions options: exec.ExecOptions
/* Results of the execution */ /** Error that was thrown when attempting to execute command */
execerr: Error | null execerr: Error | null
/** Command's output that was passed to stderr if command did run, null otherwise */
stderr: string | null stderr: string | null
/** Command's output that was passed to stdout if command did run, null otherwise */
stdout: string | null stdout: string | null
/** Command's exit code if command did run, null otherwise */
exitCode: number | null exitCode: number | null
} }
/* Middlewares as used by the user */ /**
* Base middleware shape
*/
type _CommandRunnerMiddleware = ( type _CommandRunnerMiddleware = (
ctx: CommandRunnerContext, ctx: CommandRunnerContext,
next: () => Promise<void> next: () => Promise<void>
) => void | Promise<void> ) => void | Promise<void>
/**
* Normalized middleware shape that is always promisified
*/
export type CommandRunnerMiddleware = PromisifiedFn<_CommandRunnerMiddleware> export type CommandRunnerMiddleware = PromisifiedFn<_CommandRunnerMiddleware>
/* Command runner events handling and command runner actions */ /**
* Shape for the command runner default middleware creators
*/
export type CommandRunnerAction = ( export type CommandRunnerAction = (
message?: message?:
| string | string
| ((ctx: CommandRunnerContext, events: CommandRunnerEventType[]) => string) | ((ctx: CommandRunnerContext, events: CommandRunnerEventType[]) => string)
) => PromisifiedFn<CommandRunnerMiddleware> ) => PromisifiedFn<CommandRunnerMiddleware>
/* 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' 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 = export type CommandRunnerEventType =
| 'execerr' | 'execerr'
| 'stderr' | 'stderr'
@ -43,27 +66,19 @@ export type CommandRunnerEventType =
| 'exitcode' | 'exitcode'
| 'ok' | '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 = export type CommandRunnerEventTypeExtended =
| CommandRunnerEventType | CommandRunnerEventType
| `!${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< export type CommandRunnerOptions = Omit<
exec.ExecOptions, exec.ExecOptions,
'failOnStdErr' | 'ignoreReturnCode' '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)

View File

@ -1,7 +1,6 @@
/** /**
* Promises * Promisifies a a function type
*/ */
// eslint-disable-next-line @typescript-eslint/no-explicit-any // eslint-disable-next-line @typescript-eslint/no-explicit-any
export type PromisifiedFn<T extends (...args: any[]) => any> = ( export type PromisifiedFn<T extends (...args: any[]) => any> = (
...args: Parameters<T> ...args: Parameters<T>
@ -9,6 +8,9 @@ export type PromisifiedFn<T extends (...args: any[]) => any> = (
? ReturnType<T> ? ReturnType<T>
: Promise<ReturnType<T>> : Promise<ReturnType<T>>
/**
* Promisifies a function
*/
// eslint-disable-next-line @typescript-eslint/no-explicit-any // eslint-disable-next-line @typescript-eslint/no-explicit-any
export const promisifyFn = <T extends (...args: any[]) => any>( export const promisifyFn = <T extends (...args: any[]) => any>(
fn: T fn: T
@ -27,26 +29,6 @@ export const promisifyFn = <T extends (...args: any[]) => any>(
} }
/** /**
* Comparators * Removes all whitespaces from a string
*/ */
export const removeWhitespaces = (str: string): string => str.replace(/\s/g, '')
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

View File

@ -2,6 +2,8 @@ import {StringDecoder} from 'string_decoder'
import {ExecOptions, ExecOutput, ExecListeners} from './interfaces' import {ExecOptions, ExecOutput, ExecListeners} from './interfaces'
import * as tr from './toolrunner' import * as tr from './toolrunner'
export {CommandRunner, createCommandRunner} from './command-runner'
export {ExecOptions, ExecOutput, ExecListeners} export {ExecOptions, ExecOutput, ExecListeners}
/** /**