diff --git a/packages/exec/README.md b/packages/exec/README.md index 53a6bf5243..426a4f4533 100644 --- a/packages/exec/README.md +++ b/packages/exec/README.md @@ -55,3 +55,57 @@ const exec = require('@actions/exec'); await exec.exec('"/path/to/my-tool"', ['arg1']); ``` + +#### CommandRunner + +CommandRunner is a more feature-rich alternative to `exec.getExecOutput`, it adds another level of abstraction to adjust behavior depending on execution results. + +Example with echo command on different platforms +```js +const commandRunner = exec.createCommandRunner() + +// Set command and arguments that are platform-specific +if (IS_WINDOWS) { + runner.setCommand(await io.which('cmd', true)) + runner.setArgs(['/c', 'echo']) +} else { + runner.setCommand(await io.which('echo', true)) +} + +// Set arguments that should be added regardless of platform +runner.setArgs((...args) => [...args, 'hello', 'world']) + +// Run just like exec.getExecOutput +const { stdout, stderr, exitCode } = await runner.run() +``` + +Handling outputs example: +```js +await exec.createCommandRunner('echo', ['hello', 'world']) + .onExecutionError('fail', 'optional fail message') // will fail action if command failed to execute + .onStdError('log') // will log automatically generated message if command output has produced stderr + .onExitCode('> 0', 'throw') // will throw error on non-zero exit code + .run() +``` + +Handling options: +- `onEmptyOutput('throw' | 'fail' | 'log' | handler, [, message])` - triggers on empty output +- `onExecutionError('throw' | 'fail' | 'log' | handler, [, message])` - triggers when failed to execute command itself +- `onStdError('throw' | 'fail' | 'log' | handler, [, message])` - triggers when command reports that it was executed with errors +- `onError('throw' | 'fail' | 'log' | handler, [,message])` - triggers either when failed to execute command or when command has been executed with stderr +- `onSuccess('throw' | 'fail' | 'log' | handler, [, message])` - triggers when there's no errors and exitCode equals to zero +- `onSpecificError(RegExp | string | matcherFn, 'throw' | 'fail' | 'log' | handler, [, message])` - matches specific error to handle +- `onOutput(RegExp | string | matcherFn, 'throw' | 'fail' | 'log' | handler, [, message])` - matches specific stdout to handle +- `onExitCode(string | number, 'throw' | 'fail' | 'log' | handler, [, message])` - matches specific exitCode to handle, when exitCode is passed is string it can be prefixed with an operator, i.e: `> 0`, `>= 1`, `= 31`, etc. +- ` +on(eventOrEventArray, + 'throw' | 'fail' | 'log' | handler + [, message] + ) +` - matches specific event in fasion similar to other handlers, but can be set to be triggered by different events passed as array (i.e. `['execerr', 'stderr'] - any error`) or by events not occuring using `!` prefix (i.e. `'!stdout' - empty output`), allowed events: + - 'execerr' - command failed to run + - 'stderr' - command had run but produced stderr + - 'stdout' - non-empty stdout + - 'exitcode' - non-zero exit code + - 'ok' - non-zero exit code + no stderr + no execerr + diff --git a/packages/exec/__tests__/command-runner.test.ts b/packages/exec/__tests__/command-runner.test.ts new file mode 100644 index 0000000000..1a30ab018e --- /dev/null +++ b/packages/exec/__tests__/command-runner.test.ts @@ -0,0 +1,507 @@ +import * as exec from '../src/exec' +import * as core from '@actions/core' +import * as io from '@actions/io' +import {CommandRunner, createCommandRunner} from '../src/command-runner' + +describe('command-runner', () => { + describe('createCommandRunner', () => { + it('creates a command object', async () => { + const command = createCommandRunner('echo') + expect(command).toBeDefined() + expect(command).toBeInstanceOf(CommandRunner) + }) + }) + + describe('CommandRunner', () => { + const execSpy = jest.spyOn(exec, 'exec') + const failSpy = jest.spyOn(core, 'setFailed') + + afterEach(() => { + jest.resetAllMocks() + }) + + it('runs basic commands', async () => { + execSpy.mockImplementation(async () => 0) + + const command = createCommandRunner('echo', ['hello', 'world'], { + silent: true + }) + command.run() + + expect(execSpy).toHaveBeenCalledTimes(1) + expect(execSpy).toHaveBeenCalledWith( + 'echo', + ['hello', 'world'], + expect.objectContaining({ + silent: true, + ignoreReturnCode: true + }) + ) + }) + + it('throws error if command is not specified', async () => { + const command = createCommandRunner() + await expect(command.run()).rejects.toThrow('Command was not specified') + }) + + it('will have exec error if it occured', async () => { + execSpy.mockImplementation(async () => { + throw new Error('test') + }) + + const command = createCommandRunner('echo', ['hello', 'world'], { + silent: true + }) + const context = await command.run() + + expect(context.execerr).toBeDefined() + expect(context.execerr?.message).toBe('test') + }) + + it('allows to set command, args and options', async () => { + execSpy.mockImplementation(async () => 0) + + createCommandRunner() + .setCommand('echo') + .setArgs(['hello', 'world']) + .setOptions({silent: true}) + .run() + + expect(execSpy).toHaveBeenCalledTimes(1) + expect(execSpy).toHaveBeenCalledWith( + 'echo', + ['hello', 'world'], + expect.objectContaining({ + silent: true, + ignoreReturnCode: true + }) + ) + }) + + it('allows to modify command, args and options', async () => { + execSpy.mockImplementation(async () => 0) + + createCommandRunner('echo', ['hello', 'world'], {silent: true}) + .setCommand(commandLine => `${commandLine} hello world`) + .setArgs(() => []) + .setOptions(options => ({...options, env: {test: 'test'}})) + .run() + + expect(execSpy).toHaveBeenCalledTimes(1) + expect(execSpy).toHaveBeenCalledWith( + 'echo hello world', + [], + expect.objectContaining({ + silent: true, + ignoreReturnCode: true, + env: {test: 'test'} + }) + ) + }) + + const createExecMock = (output: { + stdout: string + stderr: string + exitCode: number + }): typeof exec.exec => { + const stdoutBuffer = Buffer.from(output.stdout, 'utf8') + const stderrBuffer = Buffer.from(output.stderr, 'utf8') + + return async ( + commandLine?: string, + args?: string[], + options?: exec.ExecOptions + ) => { + options?.listeners?.stdout?.(stdoutBuffer) + options?.listeners?.stderr?.(stderrBuffer) + + await new Promise(resolve => setTimeout(resolve, 5)) + return output.exitCode + } + } + + it('allows to use middlewares', async () => { + execSpy.mockImplementation( + createExecMock({stdout: 'hello', stderr: '', exitCode: 0}) + ) + + const command = createCommandRunner('echo', ['hello', 'world'], { + silent: true + }) + + const middleware = jest.fn() + + await command.use(middleware).run() + + expect(middleware).toHaveBeenCalledTimes(1) + + expect(middleware).toHaveBeenCalledWith( + expect.objectContaining({ + args: ['hello', 'world'], + commandLine: 'echo', + execerr: null, + exitCode: 0, + options: {failOnStdErr: false, ignoreReturnCode: true, silent: true}, + stderr: '', + stdout: 'hello' + }), + expect.any(Function) + ) + }) + + describe('CommandRunner.prototype.on', () => { + it('passes control to next middleware if nothing has matched', async () => { + execSpy.mockImplementation( + createExecMock({ + stdout: 'hello', + stderr: '', + exitCode: 0 + }) + ) + + const willBeCalled = jest.fn() + const willNotBeCalled = jest.fn() + await createCommandRunner('echo', ['hello', 'world'], { + silent: true + }) + .on('!stdout', willNotBeCalled) + .use(willBeCalled) + .run() + + expect(willNotBeCalled).not.toHaveBeenCalled() + expect(willBeCalled).toHaveBeenCalledTimes(1) + }) + + it('runs a middleware if event matches', async () => { + execSpy.mockImplementation( + createExecMock({stdout: '', stderr: '', exitCode: 0}) + ) + + const middleware = jest.fn() + + await createCommandRunner('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( + createExecMock({stdout: '', stderr: '', exitCode: 1}) + ) + + const middleware = jest.fn() + await createCommandRunner('echo', ['hello', 'world'], { + silent: true + }) + .on('!stdout', middleware) + .run() + + expect(middleware).toHaveBeenCalledTimes(1) + }) + + it('fails if event matches', async () => { + execSpy.mockImplementation( + createExecMock({stdout: '', stderr: '', exitCode: 1}) + ) + + failSpy.mockImplementation(() => {}) + + await createCommandRunner('echo', ['hello', 'world'], { + silent: true + }) + .on('!stdout', 'fail') + .run() + + expect(failSpy).toHaveBeenCalledWith( + `The command "echo" finished with exit code 1 and produced an empty output.` + ) + }) + + it('throws error if event matches', async () => { + execSpy.mockImplementation( + createExecMock({stdout: '', stderr: '', exitCode: 1}) + ) + + const cmdPromise = createCommandRunner('echo', ['hello', 'world']) + .onExitCode('> 0', 'throw') + .run() + + await expect(cmdPromise).rejects.toThrow( + 'The command "echo" finished with exit code 1 and produced an empty output.' + ) + }) + + it('logs if event matches', async () => { + execSpy.mockImplementation( + createExecMock({stdout: '', stderr: 'test', exitCode: 1}) + ) + + const logSpy = jest.spyOn(core, 'error') + + await createCommandRunner('echo', ['hello', 'world'], { + silent: true + }) + .on('!ok', 'log') + .run() + + expect(logSpy).toHaveBeenCalledWith( + 'The command "echo" finished with exit code 1 and produced an error: test' + ) + }) + }) + + describe('default handlers', () => { + test('onEmptyOutput', async () => { + execSpy.mockImplementation( + createExecMock({stdout: '', stderr: '', exitCode: 0}) + ) + + const notCalledMiddleware = jest.fn() + const middleware = jest.fn() + + await createCommandRunner('echo', ['hello', 'world']) + .onError(notCalledMiddleware) + .onEmptyOutput(middleware) + .onError(notCalledMiddleware) + .run() + + expect(middleware).toHaveBeenCalledTimes(1) + expect(notCalledMiddleware).not.toHaveBeenCalled() + }) + + test('onExecutionError', async () => { + execSpy.mockImplementation(() => { + throw new Error('test') + }) + + const notCalledMiddleware = jest.fn() + const middleware = jest.fn() + + await createCommandRunner('echo', ['hello', 'world'], { + silent: true + }) + .onSuccess(notCalledMiddleware) + .onExecutionError(middleware) + .onSuccess(notCalledMiddleware) + .run() + + expect(middleware).toHaveBeenCalledTimes(1) + expect(notCalledMiddleware).not.toHaveBeenCalled() + }) + + test('onStdError', async () => { + execSpy.mockImplementation( + createExecMock({stdout: '', stderr: 'test', exitCode: 0}) + ) + + const notCalledMiddleware = jest.fn() + const middleware = jest.fn() + + await createCommandRunner('echo', ['hello', 'world'], { + silent: true + }) + .onSuccess(notCalledMiddleware) + .onStdError(middleware) + .onSuccess(notCalledMiddleware) + .run() + + expect(middleware).toHaveBeenCalledTimes(1) + expect(notCalledMiddleware).not.toHaveBeenCalled() + }) + + test('onError', async () => { + execSpy.mockImplementation( + createExecMock({stdout: '', stderr: 'test', exitCode: 0}) + ) + + const notCalledMiddleware = jest.fn() + const middleware = jest.fn() + + await createCommandRunner('echo', ['hello', 'world'], { + silent: true + }) + .onSuccess(notCalledMiddleware) + .onError(middleware) + .onSuccess(notCalledMiddleware) + .run() + + execSpy.mockImplementation(() => { + throw new Error('test') + }) + + await createCommandRunner('echo', ['hello', 'world'], { + silent: true + }) + .onSuccess(notCalledMiddleware) + .onError(middleware) + .onSuccess(notCalledMiddleware) + .run() + + expect(middleware).toHaveBeenCalledTimes(2) + expect(notCalledMiddleware).not.toHaveBeenCalled() + }) + + test('onSpecificError', async () => { + execSpy.mockImplementation( + createExecMock({stdout: '', stderr: 'test', exitCode: 0}) + ) + + const notCalledMiddleware = jest.fn() + const middleware = jest.fn() + + await createCommandRunner('echo', ['hello', 'world'], { + silent: true + }) + .onSuccess(notCalledMiddleware) + .onSpecificError(/test/, middleware) + .onSuccess(notCalledMiddleware) + .run() + + expect(middleware).toHaveBeenCalledTimes(1) + expect(notCalledMiddleware).not.toHaveBeenCalled() + }) + + test('onSuccess', async () => { + execSpy.mockImplementation( + createExecMock({stdout: '', stderr: '', exitCode: 0}) + ) + + const notCalledMiddleware = jest.fn() + const middleware = jest.fn() + + await createCommandRunner('echo', ['hello', 'world'], { + silent: true + }) + .onError(notCalledMiddleware) + .onSuccess(middleware) + .onError(notCalledMiddleware) + .run() + + expect(middleware).toHaveBeenCalledTimes(1) + expect(notCalledMiddleware).not.toHaveBeenCalled() + }) + + test('onExitcode', async () => { + execSpy.mockImplementation( + createExecMock({stdout: '', stderr: '', exitCode: 2}) + ) + + const notCalledMiddleware = jest.fn() + const middleware = jest.fn() + + await createCommandRunner('echo', ['hello', 'world'], { + silent: true + }) + .onSuccess(notCalledMiddleware) + .onExitCode('> 0', middleware) + .onSuccess(notCalledMiddleware) + .run() + + expect(middleware).toHaveBeenCalledTimes(1) + expect(notCalledMiddleware).not.toHaveBeenCalled() + }) + + test('onOutput', async () => { + execSpy.mockImplementation( + createExecMock({stdout: 'test', stderr: '', exitCode: 0}) + ) + + const notCalledMiddleware = jest.fn() + const middleware = jest.fn() + + await createCommandRunner('echo', ['hello', 'world'], { + silent: true + }) + .onError(notCalledMiddleware) + .onOutput(/test/, middleware) + .onError(notCalledMiddleware) + .run() + + expect(middleware).toHaveBeenCalledTimes(1) + expect(notCalledMiddleware).not.toHaveBeenCalled() + }) + }) + }) + + const IS_WINDOWS = process.platform === 'win32' + + describe('no-mock testing', () => { + beforeEach(() => { + jest.restoreAllMocks() + }) + + it('creates a command object', async () => { + let toolpath: string + let args: string[] + if (IS_WINDOWS) { + toolpath = await io.which('cmd', true) + args = ['/c', 'echo', 'hello'] + } else { + toolpath = await io.which('echo', true) + args = ['hello'] + } + const command = createCommandRunner(`"${toolpath}"`, args) + expect(command).toBeDefined() + expect(command).toBeInstanceOf(CommandRunner) + }) + + it('runs a command with non-zero exit code', async () => { + const runner = createCommandRunner() + + runner.setOptions({ + silent: true + }) + + if (IS_WINDOWS) { + runner.setCommand(await io.which('cmd', true)) + runner.setArgs(['/c', 'dir']) + } else { + runner.setCommand(await io.which('ls', true)) + runner.setArgs(['-l']) + } + + runner.setArgs((args: string[]) => [...args, 'non-existent-dir']) + + const cmdPromise = runner.onError('throw').run() + + await expect(cmdPromise).rejects.toThrow() + }) + + it('runs a command with zero exit code', async () => { + const runner = createCommandRunner() + + if (IS_WINDOWS) { + runner.setCommand(await io.which('cmd', true)) + runner.setArgs(['/c', 'echo']) + } else { + runner.setCommand(await io.which('echo', true)) + } + + runner.setArgs((args: string[]) => [...args, 'hello']) + + const result = await runner.run() + + expect(result.stdout).toContain('hello') + expect(result.exitCode).toEqual(0) + }) + + it('runs a command with empty output', async () => { + const runner = createCommandRunner() + + if (IS_WINDOWS) { + runner.setCommand(await io.which('cmd', true)) + runner.setArgs(['/c', 'echo.']) + } else { + runner.setCommand(await io.which('echo', true)) + } + + const cmdPromise = runner.onEmptyOutput('throw').run() + + await expect(cmdPromise).rejects.toThrow() + }) + }) +}) diff --git a/packages/exec/package-lock.json b/packages/exec/package-lock.json index 3832178444..c909c7dafb 100644 --- a/packages/exec/package-lock.json +++ b/packages/exec/package-lock.json @@ -9,20 +9,115 @@ "version": "1.1.1", "license": "MIT", "dependencies": { + "@actions/core": "^1.10.1", "@actions/io": "^1.0.1" } }, + "node_modules/@actions/core": { + "version": "1.10.1", + "resolved": "https://registry.npmjs.org/@actions/core/-/core-1.10.1.tgz", + "integrity": "sha512-3lBR9EDAY+iYIpTnTIXmWcNbX3T2kCkAEQGIQx4NVQ0575nk2k3GRZDTPQG+vVtS2izSLmINlxXf0uLtnrTP+g==", + "dependencies": { + "@actions/http-client": "^2.0.1", + "uuid": "^8.3.2" + } + }, + "node_modules/@actions/http-client": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@actions/http-client/-/http-client-2.2.0.tgz", + "integrity": "sha512-q+epW0trjVUUHboliPb4UF9g2msf+w61b32tAkFEwL/IwP0DQWgbCMM0Hbe3e3WXSKz5VcUXbzJQgy8Hkra/Lg==", + "dependencies": { + "tunnel": "^0.0.6", + "undici": "^5.25.4" + } + }, "node_modules/@actions/io": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/@actions/io/-/io-1.0.1.tgz", "integrity": "sha512-rhq+tfZukbtaus7xyUtwKfuiCRXd1hWSfmJNEpFgBQJ4woqPEpsBw04awicjwz9tyG2/MVhAEMfVn664Cri5zA==" + }, + "node_modules/@fastify/busboy": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@fastify/busboy/-/busboy-2.0.0.tgz", + "integrity": "sha512-JUFJad5lv7jxj926GPgymrWQxxjPYuJNiNjNMzqT+HiuP6Vl3dk5xzG+8sTX96np0ZAluvaMzPsjhHZ5rNuNQQ==", + "engines": { + "node": ">=14" + } + }, + "node_modules/tunnel": { + "version": "0.0.6", + "resolved": "https://registry.npmjs.org/tunnel/-/tunnel-0.0.6.tgz", + "integrity": "sha512-1h/Lnq9yajKY2PEbBadPXj3VxsDDu844OnaAo52UVmIzIvwwtBPIuNvkjuzBlTWpfJyUbG3ez0KSBibQkj4ojg==", + "engines": { + "node": ">=0.6.11 <=0.7.0 || >=0.7.3" + } + }, + "node_modules/undici": { + "version": "5.26.3", + "resolved": "https://registry.npmjs.org/undici/-/undici-5.26.3.tgz", + "integrity": "sha512-H7n2zmKEWgOllKkIUkLvFmsJQj062lSm3uA4EYApG8gLuiOM0/go9bIoC3HVaSnfg4xunowDE2i9p8drkXuvDw==", + "dependencies": { + "@fastify/busboy": "^2.0.0" + }, + "engines": { + "node": ">=14.0" + } + }, + "node_modules/uuid": { + "version": "8.3.2", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz", + "integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==", + "bin": { + "uuid": "dist/bin/uuid" + } } }, "dependencies": { + "@actions/core": { + "version": "1.10.1", + "resolved": "https://registry.npmjs.org/@actions/core/-/core-1.10.1.tgz", + "integrity": "sha512-3lBR9EDAY+iYIpTnTIXmWcNbX3T2kCkAEQGIQx4NVQ0575nk2k3GRZDTPQG+vVtS2izSLmINlxXf0uLtnrTP+g==", + "requires": { + "@actions/http-client": "^2.0.1", + "uuid": "^8.3.2" + } + }, + "@actions/http-client": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@actions/http-client/-/http-client-2.2.0.tgz", + "integrity": "sha512-q+epW0trjVUUHboliPb4UF9g2msf+w61b32tAkFEwL/IwP0DQWgbCMM0Hbe3e3WXSKz5VcUXbzJQgy8Hkra/Lg==", + "requires": { + "tunnel": "^0.0.6", + "undici": "^5.25.4" + } + }, "@actions/io": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/@actions/io/-/io-1.0.1.tgz", "integrity": "sha512-rhq+tfZukbtaus7xyUtwKfuiCRXd1hWSfmJNEpFgBQJ4woqPEpsBw04awicjwz9tyG2/MVhAEMfVn664Cri5zA==" + }, + "@fastify/busboy": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@fastify/busboy/-/busboy-2.0.0.tgz", + "integrity": "sha512-JUFJad5lv7jxj926GPgymrWQxxjPYuJNiNjNMzqT+HiuP6Vl3dk5xzG+8sTX96np0ZAluvaMzPsjhHZ5rNuNQQ==" + }, + "tunnel": { + "version": "0.0.6", + "resolved": "https://registry.npmjs.org/tunnel/-/tunnel-0.0.6.tgz", + "integrity": "sha512-1h/Lnq9yajKY2PEbBadPXj3VxsDDu844OnaAo52UVmIzIvwwtBPIuNvkjuzBlTWpfJyUbG3ez0KSBibQkj4ojg==" + }, + "undici": { + "version": "5.26.3", + "resolved": "https://registry.npmjs.org/undici/-/undici-5.26.3.tgz", + "integrity": "sha512-H7n2zmKEWgOllKkIUkLvFmsJQj062lSm3uA4EYApG8gLuiOM0/go9bIoC3HVaSnfg4xunowDE2i9p8drkXuvDw==", + "requires": { + "@fastify/busboy": "^2.0.0" + } + }, + "uuid": { + "version": "8.3.2", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz", + "integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==" } } } diff --git a/packages/exec/package.json b/packages/exec/package.json index bc4d77a23c..9fc136ca35 100644 --- a/packages/exec/package.json +++ b/packages/exec/package.json @@ -36,6 +36,7 @@ "url": "https://github.com/actions/toolkit/issues" }, "dependencies": { + "@actions/core": "^1.10.1", "@actions/io": "^1.0.1" } } diff --git a/packages/exec/src/command-runner/command-runner.ts b/packages/exec/src/command-runner/command-runner.ts new file mode 100644 index 0000000000..523da872fb --- /dev/null +++ b/packages/exec/src/command-runner/command-runner.ts @@ -0,0 +1,323 @@ +import * as exec from '../exec' +import {CommandRunnerBase} from './core' +import { + ErrorMatcher, + ExitCodeMatcher, + OutputMatcher, + failAction, + matchEvent, + matchExitCode, + matchOutput, + matchSpecificError, + produceLog, + throwError +} from './middlware' +import { + CommandRunnerActionType, + CommandRunnerEventTypeExtended, + CommandRunnerMiddleware, + CommandRunnerOptions +} from './types' + +const commandRunnerActions = { + throw: throwError, + fail: failAction, + log: produceLog +} 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, + message?: string + ): this { + const middleware = + typeof action === 'string' + ? [commandRunnerActions[action](message)] + : [action] + + this.use(matchEvent(event, middleware)) + 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 + ): this { + this.onOutput(stdout => stdout?.trim() === '', action, message) + 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 + ): this { + const middleware = + typeof action === 'string' + ? [commandRunnerActions[action](message)] + : [action] + + this.use(matchSpecificError(({type}) => type === 'execerr', middleware)) + + 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 + ): this { + const middleware = + typeof action === 'string' + ? [commandRunnerActions[action](message)] + : [action] + + this.use(matchSpecificError(({type}) => type === 'stderr', middleware)) + + 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 + ): this { + 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, + message?: string + ): this { + const middleware = + typeof action === 'string' + ? [commandRunnerActions[action](message)] + : [action] + + this.use(matchSpecificError(matcher, middleware)) + + 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 + ): this { + 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, + message?: string + ): this { + const middleware = + typeof action === 'string' + ? [commandRunnerActions[action](message)] + : [action] + + this.use(matchExitCode(matcher, middleware)) + + 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, + message?: string + ): this { + const middleware = + typeof action === 'string' + ? [commandRunnerActions[action](message)] + : [action] + + this.use(matchOutput(matcher, middleware)) + + return this + } +} + +/** + * 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[] = [], + options: CommandRunnerOptions = {} +): CommandRunner => new CommandRunner(commandLine, args, options, exec.exec) diff --git a/packages/exec/src/command-runner/core.ts b/packages/exec/src/command-runner/core.ts new file mode 100644 index 0000000000..795489abc6 --- /dev/null +++ b/packages/exec/src/command-runner/core.ts @@ -0,0 +1,211 @@ +import * as exec from '../exec' +import {StringDecoder} from 'string_decoder' +import { + CommandRunnerContext, + CommandRunnerMiddleware, + CommandRunnerOptions +} from './types' +import {PromisifiedFn, promisifyFn} from './utils' + +export class CommandRunnerBase { + private middleware: PromisifiedFn[] = [] + + constructor( + private commandLine = '', + private args: string[] = [], + private options: CommandRunnerOptions, + 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' + ? commandLine(this.commandLine) + : commandLine + + 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] + + 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 + | ((options: CommandRunnerOptions) => CommandRunnerOptions) + ): this { + this.options = + typeof options === 'function' ? options(this.options) : options + + 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, + + /* overrides args for this specific execution if not undefined */ + args?: string[], + + /* overrides options for this specific execution if not undefined */ + options?: CommandRunnerOptions + ): Promise { + const requiredOptions: exec.ExecOptions = { + ignoreReturnCode: true, + failOnStdErr: false + } + + const context: CommandRunnerContext = { + commandLine: commandLine ?? this.commandLine, + args: args ?? this.args, + options: {...(options ?? this.options), ...requiredOptions}, + stdout: null, + stderr: null, + execerr: null, + exitCode: null + } + + if (!context.commandLine) { + throw new Error('Command was not specified') + } + + try { + const stderrDecoder = new StringDecoder('utf8') + const stdErrListener = (data: Buffer): void => { + context.stderr = (context.stderr ?? '') + stderrDecoder.write(data) + options?.listeners?.stderr?.(data) + } + + const stdoutDecoder = new StringDecoder('utf8') + const stdOutListener = (data: Buffer): void => { + context.stdout = (context.stdout ?? '') + stdoutDecoder.write(data) + options?.listeners?.stdout?.(data) + } + + context.exitCode = await this.executor( + context.commandLine, + context.args, + { + ...context.options, + listeners: { + ...options?.listeners, + stdout: stdOutListener, + stderr: stdErrListener + } + } + ) + + context.stdout = (context.stdout ?? '') + stdoutDecoder.end() + context.stderr = (context.stderr ?? '') + stderrDecoder.end() + } catch (error) { + context.execerr = error as Error + } + + const next = async (): Promise => Promise.resolve() + await composeMiddleware(this.middleware)(context, next) + + return context + } +} + +/** + * 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 ( + context: CommandRunnerContext, + nextGlobal: () => Promise + ) => { + 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++] + if (middleware === undefined) { + return + } + + 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/index.ts b/packages/exec/src/command-runner/index.ts new file mode 100644 index 0000000000..83f9a612b5 --- /dev/null +++ b/packages/exec/src/command-runner/index.ts @@ -0,0 +1 @@ +export {createCommandRunner, CommandRunner} from './command-runner' 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 new file mode 100644 index 0000000000..5f2a9d03bf --- /dev/null +++ b/packages/exec/src/command-runner/types.ts @@ -0,0 +1,84 @@ +import * as exec from '../exec' +import {PromisifiedFn} from './utils' + +/** + * CommandRunner.prototype.run() outpout and context + * that is passed to each middleware + */ +export interface CommandRunnerContext { + /** 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 + + /** 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 +} + +/** + * Base middleware shape + */ +type _CommandRunnerMiddleware = ( + ctx: CommandRunnerContext, + next: () => Promise +) => void | Promise + +/** + * Normalized middleware shape that is always promisified + */ +export type CommandRunnerMiddleware = PromisifiedFn<_CommandRunnerMiddleware> + +/** + * Shape for the command runner default middleware creators + */ +export type CommandRunnerAction = ( + message?: + | string + | ((ctx: CommandRunnerContext, events: CommandRunnerEventType[]) => string) +) => PromisifiedFn + +/** + * Default middleware identifires that can be uset to set respective action + * in copmposing middleware + */ +export type CommandRunnerActionType = 'throw' | 'fail' | 'log' + +/** + * Command runner event types on which middleware can be set + */ +export type CommandRunnerEventType = + | 'execerr' + | 'stderr' + | 'stdout' + | 'exitcode' + | 'ok' + +/** + * 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' +> diff --git a/packages/exec/src/command-runner/utils.ts b/packages/exec/src/command-runner/utils.ts new file mode 100644 index 0000000000..6afe2c7d53 --- /dev/null +++ b/packages/exec/src/command-runner/utils.ts @@ -0,0 +1,34 @@ +/** + * Promisifies a a function type + */ +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export type PromisifiedFn any> = ( + ...args: Parameters +) => ReturnType extends Promise + ? ReturnType + : Promise> + +/** + * Promisifies a function + */ +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export const promisifyFn = any>( + fn: T +): PromisifiedFn => { + const result = async (...args: Parameters): Promise => { + return new Promise((resolve, reject) => { + try { + resolve(fn(...args)) + } catch (error) { + reject(error) + } + }) + } + + return result as PromisifiedFn +} + +/** + * Removes all whitespaces from a string + */ +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} /**