Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 26 additions & 5 deletions src/monorepo/operations/fs/CreateFileOperation.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,22 @@
import { CommandExecError } from '@';
import { execa } from 'execa';
import { open, statfs, utimes, writeFile } from 'node:fs/promises';
import { Writable } from 'node:stream';
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'),
Expand Down Expand Up @@ -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();
Expand Down
57 changes: 57 additions & 0 deletions tests/unit/monorepo/operations/fs/CreateFileOperation.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down Expand Up @@ -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');
}
});
});
});