From 7a6dd108f330b4adc6ba59c0cd462179794c9c9c Mon Sep 17 00:00:00 2001 From: Stefan Siegl Date: Thu, 14 Aug 2025 20:06:26 +0200 Subject: [PATCH] feat: allow to capture command output to variable --- README.md | 2 +- .../use-cases/use-case-runner.test.ts | 38 +++++++++++++++ src/services/use-cases/use-case-runner.ts | 11 ++++- src/utils/processes.test.ts | 46 +++++++++++++++++++ src/utils/processes.ts | 9 +++- 5 files changed, 102 insertions(+), 4 deletions(-) create mode 100644 src/utils/processes.test.ts diff --git a/README.md b/README.md index 1b88352..9945e92 100644 --- a/README.md +++ b/README.md @@ -275,7 +275,7 @@ Each step consists of the following properties: - `type`: the type of the step; can be one of the following - `FORMULA`: a JavaScript formula that is executed in this step; executed via `eval` - - `COMMAND`: execute a command; the command is executed via NodeJS `child_process.execSync` + - `COMMAND`: execute a command; the command is executed via NodeJS `child_process.execSync`; if used in combination with `resultVariable` command output is captured - `EXECUTOR`: a special step that is connected to some build-in executor; see [Available executors](#available-executors) - `USE_CASE`: call another use case - `PROMPT`: trigger a prompt for user input diff --git a/src/services/use-cases/use-case-runner.test.ts b/src/services/use-cases/use-case-runner.test.ts index 884e603..d8e3aae 100644 --- a/src/services/use-cases/use-case-runner.test.ts +++ b/src/services/use-cases/use-case-runner.test.ts @@ -3,6 +3,7 @@ import { temporaryDirectory } from 'tempy'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { CONFIG, initConfig } from '../../config.js'; import { UseCase } from '../../types/use-case.js'; +import * as utils from '../../utils/index.js'; import { Logger, TestBed } from '../../utils/index.js'; import { TemplatesAccess } from '../access/templates-access.js'; import { RepositoriesRepository } from '../repositories.repository.js'; @@ -174,6 +175,43 @@ describe('UseCaseRunner', () => { expect(spy).not.toHaveBeenCalledWith('a === b', expect.any(Object)); }); + describe('type COMMAND', () => { + it('should not capture output unless resultVariable is set', async () => { + const spy = vi + .spyOn(utils, 'execCommand') + .mockReturnValue(Promise.resolve('')); + + useCase.steps = [ + { + type: 'COMMAND', + command: '`do stuff`', + }, + ]; + + await sut.run(useCase); + + expect(spy).toHaveBeenCalledWith('do stuff', expect.any(String), false); + }); + + it('should capture the output if resultVariable is set', async () => { + const spy = vi + .spyOn(utils, 'execCommand') + .mockReturnValue(Promise.resolve('hello world')); + useCase.steps = [ + { + type: 'COMMAND', + command: '`do stuff`', + resultVariable: 'myVar', + }, + ]; + + const context = await sut.run(useCase); + + expect(spy).toHaveBeenCalledWith('do stuff', expect.any(String), true); + expect(context.myVar).toBe('hello world'); + }); + }); + describe('type FORMULA', () => { it('should error; formula missing', async () => { useCase.steps = [ diff --git a/src/services/use-cases/use-case-runner.ts b/src/services/use-cases/use-case-runner.ts index 7260fb3..479a66e 100644 --- a/src/services/use-cases/use-case-runner.ts +++ b/src/services/use-cases/use-case-runner.ts @@ -173,7 +173,16 @@ export class UseCaseRunner { step.command!, context ); - await execCommand(command, this.templatesAccess.getWorkspacePath()); + const result = await execCommand( + command, + this.templatesAccess.getWorkspacePath(), + !!step.resultVariable + ); + + if (step.resultVariable) { + return { ...context, [step.resultVariable]: result }; + } + return context; }); } diff --git a/src/utils/processes.test.ts b/src/utils/processes.test.ts new file mode 100644 index 0000000..8d413ec --- /dev/null +++ b/src/utils/processes.test.ts @@ -0,0 +1,46 @@ +import { execSync } from 'node:child_process'; +import { afterEach, describe, expect, Mock, test, vi } from 'vitest'; + +import { execCommand } from './processes.js'; + +vi.mock('node:child_process', () => ({ + execSync: vi.fn(), +})); + +describe('processes', () => { + afterEach(() => { + vi.clearAllMocks(); + }); + + describe('execCommand', () => { + test('should return the output if captureStdout is true', async () => { + const command = 'ls -la'; + const cwd = '/'; + const expectedOutput = 'total 0'; + (execSync as Mock).mockReturnValue(expectedOutput); + + const output = await execCommand(command, cwd, true); + + expect(execSync).toHaveBeenCalledWith(command, { + cwd, + encoding: 'utf-8', + stdio: 'pipe', + }); + expect(output).toBe(expectedOutput); + }); + + test('should run with stdio=inherit if captureStdout is false', async () => { + const command = 'ls -la'; + const cwd = '/'; + (execSync as Mock).mockReturnValue(''); + + await execCommand(command, cwd, false); + + expect(execSync).toHaveBeenCalledWith(command, { + cwd, + encoding: 'utf-8', + stdio: 'inherit', + }); + }); + }); +}); diff --git a/src/utils/processes.ts b/src/utils/processes.ts index 132adbc..e7889a6 100644 --- a/src/utils/processes.ts +++ b/src/utils/processes.ts @@ -2,9 +2,14 @@ import { execSync } from 'node:child_process'; export const OS = process.platform; -export async function execCommand(command: string, cwd: string) { +export async function execCommand( + command: string, + cwd: string, + captureStdout = false +) { return execSync(command, { cwd, - stdio: 'inherit', + encoding: 'utf-8', + stdio: captureStdout ? 'pipe' : 'inherit', }); }