UNPKG

baldrick-broth

Version:
401 lines (363 loc) 9.3 kB
import { execaCommand } from 'execa'; import YAML from 'yaml'; import CSV from 'papaparse'; import { type AnyDataValue, type AnyCommand, type Ctx, type onCommandSuccess, type onCommandFailure, } from './build-model.js'; import { type Result, succeed, willFail } from './railway.js'; import { getSupportedProperty } from './data-value-utils.js'; import { basicCommandExecution } from './basic-execution.js'; import { getSingleCommandLine, mergeTemplateContext } from './templating.js'; import { currentTaskLogger } from './logging.js'; import { coloration } from './coloration.js'; import { appendFile, writeFile } from 'fs/promises'; type ExecuteCommandLineFailedCategory = | 'failed' | 'canceled' | 'timeout' | 'killed' | 'parse-json-failed' | 'parse-yaml-failed' | 'parse-csv-failed'; export type CommandLineInput = { memoryId: string; line: string; name: string; opts: AnyCommand; extra: Record<string, any>; }; type ExecuteCommandLineFailure = { category: ExecuteCommandLineFailedCategory; line: string; stdout: string; stderr: string; exitCode: number; message: string; onFailure: onCommandFailure[]; }; type ExecuteCommandLineSuccess = | { format: 'string'; line: string; name: string; data: string; onSuccess: onCommandSuccess[]; } | { format: 'json'; line: string; name: string; data: AnyDataValue; onSuccess: onCommandSuccess[]; } | { format: 'csv'; line: string; name: string; data: AnyDataValue; onSuccess: onCommandSuccess[]; }; type ExecuteCommandLineResult = Result< ExecuteCommandLineSuccess, ExecuteCommandLineFailure >; const toStatus = (params: { exitCode: number; failed: boolean; isCanceled: boolean; timedOut: boolean; killed: boolean; }): ExecuteCommandLineFailedCategory | 'success' => { const { exitCode, failed, isCanceled, timedOut, killed } = params; if (failed) { return 'failed'; } if (isCanceled) { return 'canceled'; } if (timedOut) { return 'timeout'; } if (killed) { return 'killed'; } if (exitCode > 0) { return 'failed'; } return 'success'; }; type JsonParsingResult = Result<AnyDataValue, { message: string }>; function getErrorMessage(error: unknown): string { if (error instanceof Error) return error.message; return String(error); } const parseJson = (content: string): JsonParsingResult => { try { const parsed: AnyDataValue = JSON.parse(content); return succeed(parsed); } catch (error) { return willFail({ message: getErrorMessage(error) }); } }; const parseYaml = (content: string): JsonParsingResult => { try { const parsed: AnyDataValue = YAML.parse(content); return succeed(parsed); } catch (error) { return willFail({ message: getErrorMessage(error) }); } }; type CsvParsingResult = Result< Array<Record<string, string>>, { message: string } >; const prepareCsv = (content: string): string => content .split('\n') .filter((line) => line.length > 2) .join('\n'); const parseCsv = (content: string): CsvParsingResult => { try { const parsed = CSV.parse<Record<string, string>>(prepareCsv(content), { header: true, }); const { data, errors } = parsed; if (errors.length > 0) { return willFail({ message: errors .map((err) => `Row ${err.row}: ${err.message}`) .join('\n'), }); } if (data.length === 0) { return willFail({ message: 'Length of csv data should be more than zero', }); } return succeed(data); } catch (error) { return willFail({ message: getErrorMessage(error) }); } }; const forceString = (value: unknown): string => typeof value === 'string' ? value : JSON.stringify(value, null, 2); const executeShellCommandLine = async ( ctx: Ctx, params: CommandLineInput & { opts: { a: 'shell' } } ): Promise<ExecuteCommandLineResult> => { const { line, name, opts, memoryId, extra } = params; const templateCtx = mergeTemplateContext({ memoryId, ctx, command: opts, extra: { ...ctx.data, ...extra }, }); const runnableLine = opts.multiline ? line : getSingleCommandLine(line, templateCtx); currentTaskLogger.info(`> ${coloration.running(runnableLine)}`); const { onSuccess, onFailure, stdin } = opts; let maybeStdin; if (stdin !== undefined) { const stdinPropValue = getSupportedProperty(memoryId, ctx, stdin); if (stdinPropValue === undefined) { return willFail({ category: 'failed', line: runnableLine, stdout: '', stderr: '', exitCode: 1, onFailure, message: `Could not get property for stdin ${stdin}`, }); } maybeStdin = { input: forceString(stdinPropValue) }; } else { maybeStdin = {}; } const { stdout, stderr, all, exitCode, failed, isCanceled, timedOut, killed, } = await execaCommand(runnableLine, { reject: false, all: true, env: { FORCE_COLOR: 'true' }, ...maybeStdin, }); const status = toStatus({ exitCode, failed, isCanceled, timedOut, killed }); if (status === 'success') { if (onSuccess.includes('json')) { const parsed = parseJson(stdout); return parsed.status === 'failure' ? willFail({ category: 'parse-json-failed', line, stdout, stderr, exitCode, onFailure, message: parsed.error.message, }) : succeed({ format: 'json', name, line, data: parsed.value, onSuccess, }); } if (onSuccess.includes('yaml')) { const parsed = parseYaml(stdout); return parsed.status === 'failure' ? willFail({ category: 'parse-yaml-failed', line, stdout, stderr, exitCode, onFailure, message: parsed.error.message, }) : succeed({ format: 'json', name, line, data: parsed.value, onSuccess, }); } if (onSuccess.includes('csv')) { const parsed = parseCsv(stdout); return parsed.status === 'failure' ? willFail({ category: 'parse-csv-failed', line, stdout, stderr, exitCode, onFailure, message: parsed.error.message, }) : succeed({ format: 'csv', name, line, data: parsed.value, onSuccess, }); } const data = onSuccess.includes('trim') ? (all || stdout).trim() : all || stdout; return succeed({ format: 'string', name, line, data, onSuccess }); } return willFail({ category: 'failed', line, stdout, stderr, exitCode, onFailure, message: `Failed with exit code ${exitCode}`, }); }; const appendVarToFile = async ( memoryId: string, ctx: Ctx, anyCommand: AnyCommand & { a: 'append-to-file' } ): Promise<ExecuteCommandLineResult> => { const objectValue = getSupportedProperty(memoryId, ctx, anyCommand.value) || {}; const content = forceString(objectValue); try { await appendFile(anyCommand.filename, content, { encoding: 'utf8' }); return succeed({ format: 'json', name: anyCommand.name, line: '', data: {}, onSuccess: [], }); } catch (e) { return willFail({ category: 'failed', line: '', stdout: '', stderr: '', exitCode: 1, onFailure: [], message: `Could not append to file ${anyCommand.filename}: ${e}`, }); } }; const writeVarToFile = async ( memoryId: string, ctx: Ctx, anyCommand: AnyCommand & { a: 'write-to-file' } ): Promise<ExecuteCommandLineResult> => { const objectValue = getSupportedProperty(memoryId, ctx, anyCommand.value) || {}; const content = forceString(objectValue); try { await writeFile(anyCommand.filename, content, { encoding: 'utf8' }); return succeed({ format: 'json', name: anyCommand.name, line: '', data: {}, onSuccess: [], }); } catch (e) { return willFail({ category: 'failed', line: '', stdout: '', stderr: '', exitCode: 1, onFailure: [], message: `Could not write to file ${anyCommand.filename}: ${e}`, }); } }; /** * Executes a command after template expansion */ export const executeCommandLine = async ( ctx: Ctx, params: CommandLineInput ): Promise<ExecuteCommandLineResult> => { const { line, name, opts, memoryId, extra } = params; if (opts.a === 'shell') { return executeShellCommandLine(ctx, { line, name, opts, memoryId, extra, }); } if (opts.a === 'append-to-file') { return await appendVarToFile(memoryId, ctx, opts); } if (opts.a === 'write-to-file') { return await writeVarToFile(memoryId, ctx, opts); } basicCommandExecution(memoryId, ctx, opts, extra); return succeed({ format: 'json', name, line, data: {}, onSuccess: [], }); };