From c00940a8206418c1e188e223c490c649aa6ea932 Mon Sep 17 00:00:00 2001 From: Nikolai Laevskii Date: Fri, 29 Sep 2023 16:17:46 +0200 Subject: [PATCH] Add middleware for command-runner --- .../helpers/__tests__/command-runner.test.ts | 202 ++++++++ ....test.ts => exec-command-wrapper.test1.ts} | 0 .../src/command-runner/command-runner.ts | 157 ++++++ packages/helpers/src/command-runner/core.ts | 125 +++++ packages/helpers/src/command-runner/index.ts | 1 + .../helpers/src/command-runner/middleware.ts | 474 ++++++++++++++++++ packages/helpers/src/command-runner/test.ts | 18 + packages/helpers/src/command-runner/types.ts | 44 ++ packages/helpers/src/helpers.ts | 2 +- 9 files changed, 1022 insertions(+), 1 deletion(-) create mode 100644 packages/helpers/__tests__/command-runner.test.ts rename packages/helpers/__tests__/{exec-command-wrapper.test.ts => exec-command-wrapper.test1.ts} (100%) create mode 100644 packages/helpers/src/command-runner/command-runner.ts create mode 100644 packages/helpers/src/command-runner/core.ts create mode 100644 packages/helpers/src/command-runner/index.ts create mode 100644 packages/helpers/src/command-runner/middleware.ts create mode 100644 packages/helpers/src/command-runner/test.ts create mode 100644 packages/helpers/src/command-runner/types.ts diff --git a/packages/helpers/__tests__/command-runner.test.ts b/packages/helpers/__tests__/command-runner.test.ts new file mode 100644 index 00000000..7dd28732 --- /dev/null +++ b/packages/helpers/__tests__/command-runner.test.ts @@ -0,0 +1,202 @@ +import * as exec from '@actions/exec' +import {CommandRunner, commandPipeline} from '../src/helpers' + +describe('command-runner', () => { + describe('commandPipeline', () => { + it('creates a command object', async () => { + const command = commandPipeline('echo') + expect(command).toBeDefined() + expect(command).toBeInstanceOf(CommandRunner) + }) + }) + + describe('CommandRunner', () => { + const execSpy = jest.spyOn(exec, 'getExecOutput') + + afterEach(() => { + jest.resetAllMocks() + }) + + it('runs basic commands', async () => { + execSpy.mockImplementation(async () => + Promise.resolve({ + stdout: 'hello', + stderr: '', + exitCode: 0 + }) + ) + + const command = commandPipeline('echo', ['hello', 'world'], { + silent: true + }) + command.run() + + expect(execSpy).toHaveBeenCalledTimes(1) + expect(execSpy).toHaveBeenCalledWith('echo', ['hello', 'world'], { + silent: true + }) + }) + + it('overrides args with addArgs and withArgs', async () => { + execSpy.mockImplementation(async () => + Promise.resolve({ + stdout: 'hello', + stderr: '', + exitCode: 0 + }) + ) + + const command = commandPipeline('echo', ['hello', 'world'], { + silent: true + }) + + await command.withArgs('bye').run() + + expect(execSpy).toHaveBeenCalledWith('echo', ['bye'], { + silent: true + }) + + execSpy.mockClear() + + await command.addArgs('and stuff').run() + + expect(execSpy).toHaveBeenCalledWith( + 'echo', + ['hello', 'world', 'and stuff'], + { + silent: true + } + ) + }) + + it('allows to use middlewares', async () => { + execSpy.mockImplementation(async () => { + return { + stdout: 'hello', + stderr: '', + exitCode: 0 + } + }) + + const command = commandPipeline('echo', ['hello', 'world'], { + silent: true + }) + + const middleware = jest.fn() + + await command.use(middleware).run() + + expect(middleware).toHaveBeenCalledTimes(1) + + expect(middleware).toHaveBeenCalledWith( + expect.objectContaining({ + commandLine: 'echo', + args: ['hello', 'world'], + options: { + silent: true + }, + stdout: 'hello', + stderr: '', + exitCode: 0, + execerr: null, + state: null + }), + expect.any(Function) + ) + }) + + describe('CommandRunner.prototype.on', () => { + it('passes control to next middleware if nothing has matched', async () => { + execSpy.mockImplementation(async () => { + return { + stdout: 'hello', + stderr: '', + exitCode: 0 + } + }) + + const willBeCalled = jest.fn() + const willNotBeCalled = jest.fn() + await commandPipeline('echo', ['hello', 'world'], { + silent: true + }) + .on('no-stdout', willNotBeCalled) + .use(willBeCalled) + .run() + + expect(willNotBeCalled).not.toHaveBeenCalled() + expect(willBeCalled).toHaveBeenCalledTimes(1) + }) + + it('runs a middleware if event matches', async () => { + execSpy.mockImplementation(async () => { + return { + stdout: 'hello', + stderr: '', + exitCode: 0 + } + }) + + const middleware = jest.fn() + + await commandPipeline('echo', ['hello', 'world'], { + silent: true + }) + .on('ok', middleware) + .run() + + expect(middleware).toHaveBeenCalledTimes(1) + }) + + it('runs a middleware if event matches with negation', async () => { + execSpy.mockImplementation(async () => { + return { + stdout: 'hello', + stderr: '', + exitCode: 0 + } + }) + + const middleware = jest.fn() + await commandPipeline('echo', ['hello', 'world'], { + silent: true + }) + .on('!no-stdout', middleware) + .run() + + expect(middleware).toHaveBeenCalledTimes(1) + }) + + it('runs a middleware on multiple events', async () => { + execSpy.mockImplementation(async () => { + return { + stdout: 'hello', + stderr: '', + exitCode: 0 + } + }) + + const middleware = jest.fn() + const command = commandPipeline('echo', ['hello', 'world'], { + silent: true + }).on(['!no-stdout', 'ok'], middleware) + + await command.run() + + expect(middleware).toHaveBeenCalledTimes(1) + + execSpy.mockImplementation(async () => { + return { + stdout: '', + stderr: '', + exitCode: 1 + } + }) + + await command.run() + + expect(middleware).toHaveBeenCalledTimes(1) + }) + }) + }) +}) diff --git a/packages/helpers/__tests__/exec-command-wrapper.test.ts b/packages/helpers/__tests__/exec-command-wrapper.test1.ts similarity index 100% rename from packages/helpers/__tests__/exec-command-wrapper.test.ts rename to packages/helpers/__tests__/exec-command-wrapper.test1.ts diff --git a/packages/helpers/src/command-runner/command-runner.ts b/packages/helpers/src/command-runner/command-runner.ts new file mode 100644 index 00000000..fd7cbbe2 --- /dev/null +++ b/packages/helpers/src/command-runner/command-runner.ts @@ -0,0 +1,157 @@ +import * as exec from '@actions/exec' +import {CommandRunnerBase} from './core' +import { + ErrorMatcher, + ExitCodeMatcher, + OutputMatcher, + failAction, + matchEvent, + matchExitCode, + matchOutput, + matchSpecificError, + produceLog, + throwError +} from './middleware' +import { + CommandRunnerActionType, + CommandRunnerEventTypeExtended, + CommandRunnerMiddleware +} from './types' + +const commandRunnerActions = { + throw: throwError, + fail: failAction, + log: produceLog +} as const + +export class CommandRunner extends CommandRunnerBase { + on( + event: CommandRunnerEventTypeExtended | CommandRunnerEventTypeExtended[], + action: CommandRunnerActionType | CommandRunnerMiddleware, + message?: string + ): this { + const middleware = + typeof action === 'string' + ? [commandRunnerActions[action](message)] + : [action] + + this.use(matchEvent(event, middleware as CommandRunnerMiddleware[])) + + return this + } + + onEmptyOutput( + action: CommandRunnerActionType | CommandRunnerMiddleware, + message?: string + ): this { + this.on('no-stdout', action, message) + + return this + } + + onExecutionError( + action: CommandRunnerActionType | CommandRunnerMiddleware, + message?: string + ): this { + const middleware = + typeof action === 'string' + ? [commandRunnerActions[action](message)] + : [action] + + this.use( + matchSpecificError( + ({type}) => type === 'execerr', + middleware as CommandRunnerMiddleware[] + ) + ) + + return this + } + + onStdError( + action: CommandRunnerActionType | CommandRunnerMiddleware, + message?: string + ): this { + const middleware = + typeof action === 'string' + ? [commandRunnerActions[action](message)] + : [action] + + this.use( + matchSpecificError( + ({type}) => type === 'stderr', + middleware as CommandRunnerMiddleware[] + ) + ) + + return this + } + + onError( + action: CommandRunnerActionType | CommandRunnerMiddleware, + message?: string + ): this { + return this.on(['execerr', 'stderr'], action, message) + } + + onSpecificError( + matcher: ErrorMatcher, + action: CommandRunnerActionType | CommandRunnerMiddleware, + message?: string + ): this { + const middleware = + typeof action === 'string' + ? [commandRunnerActions[action](message)] + : [action] + + this.use( + matchSpecificError(matcher, middleware as CommandRunnerMiddleware[]) + ) + + return this + } + + onSuccess( + action: CommandRunnerActionType | CommandRunnerMiddleware, + message?: string + ): this { + return this.on('ok', action, message) + } + + onExitCode( + matcher: ExitCodeMatcher, + action: CommandRunnerActionType | CommandRunnerMiddleware, + message?: string + ): this { + const middleware = + typeof action === 'string' + ? [commandRunnerActions[action](message)] + : [action] + + this.use(matchExitCode(matcher, middleware as CommandRunnerMiddleware[])) + + return this + } + + onOutput( + matcher: OutputMatcher, + action: CommandRunnerActionType | CommandRunnerMiddleware, + message?: string + ): this { + const middleware = + typeof action === 'string' + ? [commandRunnerActions[action](message)] + : [action] + + this.use(matchOutput(matcher, middleware as CommandRunnerMiddleware[])) + + return this + } +} + +export const commandPipeline = ( + commandLine: string, + args: string[] = [], + options: Record = {} +): CommandRunner => + new CommandRunner(commandLine, args, options, exec.getExecOutput) diff --git a/packages/helpers/src/command-runner/core.ts b/packages/helpers/src/command-runner/core.ts new file mode 100644 index 00000000..5efa7e7c --- /dev/null +++ b/packages/helpers/src/command-runner/core.ts @@ -0,0 +1,125 @@ +import * as exec from '@actions/exec' +import { + CommandRunnerContext, + CommandRunnerMiddleware, + CommandRunnerMiddlewarePromisified +} from './types' + +export const promisifyCommandRunnerMiddleware = + ( + middleware: CommandRunnerMiddleware + ): CommandRunnerMiddlewarePromisified => + async (ctx, next) => { + return Promise.resolve(middleware(ctx, next)) + } + +export const composeCommandRunnerMiddleware = + (middleware: CommandRunnerMiddlewarePromisified[]) => + async (context: CommandRunnerContext, nextGlobal: () => Promise) => { + let index = 0 + + const nextLocal = async (): Promise => { + if (index < middleware.length) { + const currentMiddleware = middleware[index++] + if (middleware === undefined) { + return + } + + await currentMiddleware(context, nextLocal) + } + + await nextGlobal() + } + + await nextLocal() + } + +export class CommandRunnerBase { + private middleware: CommandRunnerMiddlewarePromisified[] = [] + private tmpArgs: string[] = [] + + constructor( + private commandLine: string, + private args: string[] = [], + private options: exec.ExecOptions = {}, + private executor: typeof exec.getExecOutput = exec.getExecOutput + ) {} + + /** + * Adds additional arguments to the command + * for the one time execution. + */ + addArgs(...args: string[]): this { + this.tmpArgs = [...this.args, ...args] + return this + } + + /** Overrides command arguments for one time execution */ + withArgs(...args: string[]): this { + this.tmpArgs = args + return this + } + + /** Retrieves args for one-time execution and clears them afterwards */ + private getTmpArgs(): string[] | null { + if (this.tmpArgs.length === 0) return null + + const args = this.tmpArgs + + this.tmpArgs = [] + + return args + } + + use(middleware: CommandRunnerMiddleware): this { + this.middleware.push( + promisifyCommandRunnerMiddleware( + middleware as CommandRunnerMiddleware + ) + ) + return this + } + + async run( + /* overrides command for this specific execution if not undefined */ + commandLine?: string, + + /* overrides args for this specific execution if not undefined */ + args?: string[], + + /* overrides options for this specific execution if not undefined */ + options?: exec.ExecOptions + ): Promise> { + const tmpArgs = this.getTmpArgs() + + const context: CommandRunnerContext = { + commandLine: commandLine ?? this.commandLine, + args: args ?? tmpArgs ?? this.args, + options: options ?? this.options, + stdout: null, + stderr: null, + execerr: null, + exitCode: null, + state: null + } + + try { + const {stdout, stderr, exitCode} = await this.executor( + context.commandLine, + context.args, + context.options + ) + + context.stdout = stdout + context.stderr = stderr + context.exitCode = exitCode + } catch (error) { + context.execerr = error as Error + } + + const next = async (): Promise => Promise.resolve() + await composeCommandRunnerMiddleware(this.middleware)(context, next) + + return context + } +} diff --git a/packages/helpers/src/command-runner/index.ts b/packages/helpers/src/command-runner/index.ts new file mode 100644 index 00000000..24e10dc2 --- /dev/null +++ b/packages/helpers/src/command-runner/index.ts @@ -0,0 +1 @@ +export {commandPipeline, CommandRunner} from './command-runner' diff --git a/packages/helpers/src/command-runner/middleware.ts b/packages/helpers/src/command-runner/middleware.ts new file mode 100644 index 00000000..2ba84496 --- /dev/null +++ b/packages/helpers/src/command-runner/middleware.ts @@ -0,0 +1,474 @@ +import * as core from '@actions/core' +import { + CommandRunnerContext, + CommandRunnerEventType, + CommandRunnerEventTypeExtended, + CommandRunnerMiddleware, + CommandRunnerMiddlewarePromisified +} from './types' +import { + composeCommandRunnerMiddleware, + promisifyCommandRunnerMiddleware +} from './core' + +const getEventTypesFromContext = ( + ctx: CommandRunnerContext +): CommandRunnerEventType[] => { + const eventTypes = new Set() + + if (ctx.execerr) { + eventTypes.add('execerr') + } + + if (ctx.stderr || ctx.exitCode !== 0) { + eventTypes.add('stderr') + } + + if (ctx.stdout !== null && !ctx.stdout.trim()) { + eventTypes.add('no-stdout') + } + + if (!ctx.stderr && !ctx.execerr && ctx.stdout !== null && !ctx.exitCode) { + eventTypes.add('ok') + } + + return [...eventTypes] +} + +type CommandRunnerAction = ( + message?: + | string + | ((ctx: CommandRunnerContext, events: CommandRunnerEventType[]) => string) +) => CommandRunnerMiddlewarePromisified + +/** + * Basic middleware + */ + +/** + * 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('no-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 => 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('no-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('no-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('no-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() +} + +/** + * Filtering middleware + */ + +/** Calls next middleware */ +export const passThrough: () => CommandRunnerMiddlewarePromisified = + () => async (_, next) => + next() + +/** + * Either calls next middleware or not depending on the result of the given condition. + */ +export const filter: ( + shouldPass: + | boolean + | ((ctx: CommandRunnerContext) => boolean | Promise) +) => CommandRunnerMiddlewarePromisified = shouldPass => async (ctx, next) => { + if (typeof shouldPass === 'function') { + if (await shouldPass(ctx)) { + next() + return + } + } +} + +/** + * Will call passed middleware if matching event has occured. + * Will call the next middleware otherwise. + */ +export const matchEvent = ( + eventType: CommandRunnerEventTypeExtended | CommandRunnerEventTypeExtended[], + middleware?: CommandRunnerMiddleware[] +): CommandRunnerMiddlewarePromisified => { + if (!middleware?.length) { + middleware = [passThrough()] + } + + const composedMiddleware = composeCommandRunnerMiddleware( + middleware.map(mw => promisifyCommandRunnerMiddleware(mw)) + ) + + 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) { + composedMiddleware(ctx, next) + return + } + + next() + } +} + +export type OutputMatcher = RegExp | string | ((output: string) => boolean) + +/** + * Will call passed middleware if matching event has occured. + * Will call the next middleware otherwise. + */ +export const matchOutput = ( + matcher: OutputMatcher, + middleware?: CommandRunnerMiddleware[] +): CommandRunnerMiddlewarePromisified => { + if (!middleware?.length) { + middleware = [passThrough()] + } + + const composedMiddleware = composeCommandRunnerMiddleware( + middleware.map(mw => promisifyCommandRunnerMiddleware(mw)) + ) + + 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 + } + + composedMiddleware(ctx, next) + } +} + +export type ExitCodeMatcher = string | number + +const lte = + (a: number) => + (b: number): boolean => + b <= a +const gte = + (a: number) => + (b: number): boolean => + b >= a +const lt = + (a: number) => + (b: number): boolean => + b < a +const gt = + (a: number) => + (b: number): boolean => + b > a +const eq = + (a: number) => + (b: number): boolean => + b === a + +const matchers = { + '>=': gte, + '>': gt, + '<=': lte, + '<': lt, + '=': eq +} as const + +const removeWhitespaces = (str: string): string => str.replace(/\s/g, '') + +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)] +} + +const matcherToMatcherFn = ( + matcher: ExitCodeMatcher +): ((exitCode: number) => boolean) => { + const [operator, number] = parseExitCodeMatcher(matcher) + return matchers[operator](number) +} + +/** + * Will call passed middleware if matching exit code was returned. + * Will call the next middleware otherwise. + */ +export const matchExitCode = ( + code: ExitCodeMatcher, + middleware?: CommandRunnerMiddleware[] +): CommandRunnerMiddlewarePromisified => { + const matcher = matcherToMatcherFn(code) + + if (!middleware?.length) { + middleware = [passThrough()] + } + + const composedMiddleware = composeCommandRunnerMiddleware( + middleware.map(mw => promisifyCommandRunnerMiddleware(mw)) + ) + + return async (ctx, next) => { + // if exit code is undefined, NaN will not match anything + if (matcher(ctx.exitCode ?? NaN)) { + composedMiddleware(ctx, next) + return + } + + next() + } +} + +export type ErrorMatcher = + | RegExp + | string + | ((error: { + type: 'stderr' | 'execerr' + error: Error | null + message: string + }) => boolean) + +export const matchSpecificError = ( + matcher: ErrorMatcher, + middleware?: CommandRunnerMiddleware[] +): CommandRunnerMiddlewarePromisified => { + if (!middleware?.length) { + middleware = [passThrough()] + } + + const composedMiddleware = composeCommandRunnerMiddleware( + middleware.map(mw => promisifyCommandRunnerMiddleware(mw)) + ) + + 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 + } + + composedMiddleware(ctx, next) + } +} diff --git a/packages/helpers/src/command-runner/test.ts b/packages/helpers/src/command-runner/test.ts new file mode 100644 index 00000000..49a7c028 --- /dev/null +++ b/packages/helpers/src/command-runner/test.ts @@ -0,0 +1,18 @@ +import {CommandRunner} from './command-runner' +import * as io from '@actions/io' + +;(async () => { + const toolpath = await io.which('cmd', true) + const args = ['/c', 'echo'] + + const echo = new CommandRunner('echo') + + echo + .on('exec-error', 'log') + .use(async (ctx, next) => { + console.log('success') + next() + }) + .addArgs('hello') + .run() +})() diff --git a/packages/helpers/src/command-runner/types.ts b/packages/helpers/src/command-runner/types.ts new file mode 100644 index 00000000..478d6951 --- /dev/null +++ b/packages/helpers/src/command-runner/types.ts @@ -0,0 +1,44 @@ +import * as exec from '@actions/exec' + +/* CommandRunner core */ + +export interface CommandRunnerContext { + /* Inputs with which command was executed */ + commandLine: string + args: string[] + options: exec.ExecOptions + + /* Results of the execution */ + execerr: Error | null + stderr: string | null + stdout: string | null + exitCode: number | null + + /* Arbitrary state that can be change during middleware execution if needed */ + state: S | null +} + +/* Middlewares as used internally in CommandRunner */ +export type CommandRunnerMiddlewarePromisified = ( + ctx: CommandRunnerContext, + next: () => Promise +) => Promise + +/* Middlewares as used by the user */ +export type CommandRunnerMiddleware = ( + ctx: CommandRunnerContext, + next: () => Promise +) => void | Promise + +/* Command runner events handling and command runner actions */ + +/* Command runner default actions types on which preset middleware exists */ +export type CommandRunnerActionType = 'throw' | 'fail' | 'log' + +/* Command runner event types as used internally passed to middleware for the user */ +export type CommandRunnerEventType = 'execerr' | 'stderr' | 'no-stdout' | 'ok' + +/* Command runner event types as used by the user for filtering results */ +export type CommandRunnerEventTypeExtended = + | CommandRunnerEventType + | `!${CommandRunnerEventType}` diff --git a/packages/helpers/src/helpers.ts b/packages/helpers/src/helpers.ts index 8b137891..981c07b5 100644 --- a/packages/helpers/src/helpers.ts +++ b/packages/helpers/src/helpers.ts @@ -1 +1 @@ - +export * from './command-runner'