diff --git a/src/monorepo/operations/fs/CreateFileOperation.ts b/src/monorepo/operations/fs/CreateFileOperation.ts index 44a1bc4..217d032 100644 --- a/src/monorepo/operations/fs/CreateFileOperation.ts +++ b/src/monorepo/operations/fs/CreateFileOperation.ts @@ -1,3 +1,4 @@ +import { CommandExecError } from '@'; import { execa } from 'execa'; import { open, statfs, utimes, writeFile } from 'node:fs/promises'; import { Writable } from 'node:stream'; @@ -5,6 +6,17 @@ import * as z from 'zod'; import { AbstractOperation } from '@/operations'; +// Type guard for execa error objects +function isExecaError(error: unknown): error is { + stderr?: string; + shortMessage?: string; + message: string; + exitCode?: number; + signal?: NodeJS.Signals; +} { + return error instanceof Error && 'exitCode' in error; +} + const schema = z.object({ // path: z.string().describe('Path to the file to create'), @@ -45,11 +57,20 @@ export class CreateFileOperation extends AbstractOperation< if (input.content !== undefined) { await writeFile(input.path, input.content); } else if (input.script) { - await execa(input.script, { - all: true, - cwd: input.cwd, - shell: true, - }); + try { + await execa(input.script, { + all: true, + cwd: input.cwd, + shell: true, + }); + } catch (error) { + if (isExecaError(error)) { + const stderr = error.stderr?.trim(); + const message = stderr || error.shortMessage || error.message; + throw new CommandExecError(message, error.exitCode ?? 1, error.signal); + } + throw error; + } } else { const fn = await open(input.path, 'a'); fn.close(); diff --git a/tests/unit/monorepo/operations/fs/CreateFileOperation.spec.ts b/tests/unit/monorepo/operations/fs/CreateFileOperation.spec.ts index 47ed8a6..3f19fda 100644 --- a/tests/unit/monorepo/operations/fs/CreateFileOperation.spec.ts +++ b/tests/unit/monorepo/operations/fs/CreateFileOperation.spec.ts @@ -10,6 +10,7 @@ import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { afterEach, beforeEach, describe, expect, test } from 'vitest'; +import { CommandExecError } from '../../../../../src/errors.js'; import { CreateFileOperation } from '../../../../../src/monorepo/operations/fs/CreateFileOperation.js'; describe('Monorepo / Operations / FS / CreateFileOperation', () => { @@ -138,5 +139,61 @@ describe('Monorepo / Operations / FS / CreateFileOperation', () => { const fileContent = await readFile(filePath, 'utf8'); expect(fileContent).toBe(content); }); + + test('it throws CommandExecError when script exits with non-zero code', async () => { + const filePath = join(tempDir, 'failedscript.txt'); + + await expect( + operation.run({ + path: filePath, + script: 'exit 1', + cwd: tempDir, + }), + ).rejects.toThrow(CommandExecError); + }); + + test('it throws CommandExecError when script command does not exist', async () => { + const filePath = join(tempDir, 'nonexistent.txt'); + + await expect( + operation.run({ + path: filePath, + script: 'nonexistent_command_that_does_not_exist', + cwd: tempDir, + }), + ).rejects.toThrow(CommandExecError); + }); + + test('it includes exit code in CommandExecError when script fails', async () => { + const filePath = join(tempDir, 'exitcode.txt'); + + try { + await operation.run({ + path: filePath, + script: 'exit 42', + cwd: tempDir, + }); + expect.fail('Expected operation to throw'); + } catch (error) { + expect(error).toBeInstanceOf(CommandExecError); + expect((error as CommandExecError).exitCode).toBe(42); + } + }); + + test('it includes stderr message in CommandExecError when script fails', async () => { + const filePath = join(tempDir, 'stderr.txt'); + + try { + await operation.run({ + path: filePath, + script: 'echo "error message" >&2 && exit 1', + cwd: tempDir, + }); + expect.fail('Expected operation to throw'); + } catch (error) { + expect(error).toBeInstanceOf(CommandExecError); + expect((error as CommandExecError).message).toContain('error message'); + } + }); }); });