diff --git a/packages/ai/src/tools/file-tools/create-files-tool/create-file-functions.test.ts b/packages/ai/src/tools/file-tools/create-files-tool/create-file-functions.test.ts deleted file mode 100644 index 46d0b98d6..000000000 --- a/packages/ai/src/tools/file-tools/create-files-tool/create-file-functions.test.ts +++ /dev/null @@ -1,168 +0,0 @@ -import * as fs from 'node:fs/promises'; -import * as path from 'node:path'; -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import { - type FileCreateParams, - createFilesSafely, - generateFileCreateCode, -} from './create-file-functions'; - -vi.mock('node:fs/promises'); -const mockFs = vi.mocked(fs); - -describe('create-file-functions', () => { - beforeEach(() => { - vi.clearAllMocks(); - }); - - afterEach(() => { - vi.restoreAllMocks(); - }); - - describe('createFilesSafely', () => { - it('should create files successfully', async () => { - const fileParams: FileCreateParams[] = [ - { path: '/test/file1.txt', content: 'content1' }, - { path: '/test/file2.txt', content: 'content2' }, - ]; - - mockFs.mkdir.mockResolvedValue(undefined); - mockFs.writeFile.mockResolvedValue(undefined); - - const results = await createFilesSafely(fileParams); - - expect(results).toHaveLength(2); - expect(results[0]).toEqual({ - success: true, - filePath: '/test/file1.txt', - }); - expect(results[1]).toEqual({ - success: true, - filePath: '/test/file2.txt', - }); - - expect(mockFs.mkdir).toHaveBeenCalledTimes(2); - expect(mockFs.writeFile).toHaveBeenCalledTimes(2); - expect(mockFs.writeFile).toHaveBeenCalledWith('/test/file1.txt', 'content1', 'utf-8'); - expect(mockFs.writeFile).toHaveBeenCalledWith('/test/file2.txt', 'content2', 'utf-8'); - }); - - it('should handle relative paths correctly', async () => { - const fileParams: FileCreateParams[] = [{ path: 'relative/file.txt', content: 'content' }]; - - mockFs.mkdir.mockResolvedValue(undefined); - mockFs.writeFile.mockResolvedValue(undefined); - - const results = await createFilesSafely(fileParams); - - expect(results).toHaveLength(1); - expect(results[0]).toEqual({ - success: true, - filePath: 'relative/file.txt', - }); - - const expectedPath = path.join(process.cwd(), 'relative/file.txt'); - const expectedDir = path.dirname(expectedPath); - - expect(mockFs.mkdir).toHaveBeenCalledWith(expectedDir, { recursive: true }); - expect(mockFs.writeFile).toHaveBeenCalledWith(expectedPath, 'content', 'utf-8'); - }); - - it('should handle directory creation errors', async () => { - const fileParams: FileCreateParams[] = [{ path: '/test/file.txt', content: 'content' }]; - - mockFs.mkdir.mockRejectedValue(new Error('Permission denied')); - - const results = await createFilesSafely(fileParams); - - expect(results).toHaveLength(1); - expect(results[0]).toEqual({ - success: false, - filePath: '/test/file.txt', - error: 'Failed to create directory: Permission denied', - }); - }); - - it('should handle file write errors', async () => { - const fileParams: FileCreateParams[] = [{ path: '/test/file.txt', content: 'content' }]; - - mockFs.mkdir.mockResolvedValue(undefined); - mockFs.writeFile.mockRejectedValue(new Error('Disk full')); - - const results = await createFilesSafely(fileParams); - - expect(results).toHaveLength(1); - expect(results[0]).toEqual({ - success: false, - filePath: '/test/file.txt', - error: 'Disk full', - }); - }); - - it('should continue processing other files when one fails', async () => { - const fileParams: FileCreateParams[] = [ - { path: '/test/file1.txt', content: 'content1' }, - { path: '/test/file2.txt', content: 'content2' }, - ]; - - mockFs.mkdir.mockResolvedValue(undefined); - mockFs.writeFile - .mockRejectedValueOnce(new Error('File 1 error')) - .mockResolvedValueOnce(undefined); - - const results = await createFilesSafely(fileParams); - - expect(results).toHaveLength(2); - expect(results[0]).toEqual({ - success: false, - filePath: '/test/file1.txt', - error: 'File 1 error', - }); - expect(results[1]).toEqual({ - success: true, - filePath: '/test/file2.txt', - }); - }); - - it('should handle empty file params array', async () => { - const results = await createFilesSafely([]); - expect(results).toEqual([]); - }); - }); - - describe('generateFileCreateCode', () => { - it('should generate valid TypeScript code for file creation', () => { - const fileParams: FileCreateParams[] = [ - { path: '/test/file1.txt', content: 'content1' }, - { path: '/test/file2.txt', content: 'content2' }, - ]; - - const code = generateFileCreateCode(fileParams); - - expect(code).toContain("const fs = require('fs');"); - expect(code).toContain("const path = require('path');"); - expect(code).toContain('function createSingleFile(fileParams)'); - expect(code).toContain('function createFilesConcurrently(fileParams)'); - expect(code).toContain('fs.mkdirSync(dirPath, { recursive: true });'); - expect(code).toContain("fs.writeFileSync(resolvedPath, content, 'utf-8');"); - expect(code).toContain('console.log(JSON.stringify(results));'); - expect(code).toContain(JSON.stringify(fileParams)); - }); - - it('should handle empty file params array', () => { - const code = generateFileCreateCode([]); - expect(code).toContain('const fileParams = [];'); - expect(code).toContain('console.log(JSON.stringify(results));'); - }); - - it('should escape special characters in file content', () => { - const fileParams: FileCreateParams[] = [ - { path: '/test/file.txt', content: 'line1\nline2\ttab' }, - ]; - - const code = generateFileCreateCode(fileParams); - - expect(code).toContain('"content":"line1\\nline2\\ttab"'); - }); - }); -}); diff --git a/packages/ai/src/tools/file-tools/create-files-tool/create-file-functions.ts b/packages/ai/src/tools/file-tools/create-files-tool/create-file-functions.ts deleted file mode 100644 index a2257fdac..000000000 --- a/packages/ai/src/tools/file-tools/create-files-tool/create-file-functions.ts +++ /dev/null @@ -1,101 +0,0 @@ -import * as fs from 'node:fs/promises'; -import * as path from 'node:path'; - -export interface FileCreateResult { - success: boolean; - filePath: string; - error?: string; -} - -export interface FileCreateParams { - path: string; - content: string; -} - -async function createSingleFile(fileParams: FileCreateParams): Promise { - try { - const { path: filePath, content } = fileParams; - const resolvedPath = path.isAbsolute(filePath) ? filePath : path.join(process.cwd(), filePath); - - const dirPath = path.dirname(resolvedPath); - try { - await fs.mkdir(dirPath, { recursive: true }); - } catch (error) { - return { - success: false, - filePath, - error: `Failed to create directory: ${error instanceof Error ? error.message : 'Unknown error'}`, - }; - } - - await fs.writeFile(resolvedPath, content, 'utf-8'); - - return { - success: true, - filePath, - }; - } catch (error) { - return { - success: false, - filePath: fileParams.path, - error: error instanceof Error ? error.message : 'Unknown error occurred', - }; - } -} - -export async function createFilesSafely( - fileParams: FileCreateParams[] -): Promise { - const fileCreatePromises = fileParams.map((params) => createSingleFile(params)); - return Promise.all(fileCreatePromises); -} - -/** - * Generates TypeScript code that can be executed in a sandbox to create files - * The generated code is self-contained and outputs results as JSON to stdout - */ -export function generateFileCreateCode(fileParams: FileCreateParams[]): string { - return ` -const fs = require('fs'); -const path = require('path'); - -function createSingleFile(fileParams) { - try { - const { path: filePath, content } = fileParams; - const resolvedPath = path.isAbsolute(filePath) ? filePath : path.join(process.cwd(), filePath); - - const dirPath = path.dirname(resolvedPath); - try { - fs.mkdirSync(dirPath, { recursive: true }); - } catch (error) { - return { - success: false, - filePath, - error: \`Failed to create directory: \${error instanceof Error ? error.message : 'Unknown error'}\`, - }; - } - - fs.writeFileSync(resolvedPath, content, 'utf-8'); - - return { - success: true, - filePath, - }; - } catch (error) { - return { - success: false, - filePath: fileParams.path, - error: error instanceof Error ? error.message : 'Unknown error occurred', - }; - } -} - -function createFilesConcurrently(fileParams) { - return fileParams.map((params) => createSingleFile(params)); -} - -const fileParams = ${JSON.stringify(fileParams)}; -const results = createFilesConcurrently(fileParams); -console.log(JSON.stringify(results)); - `.trim(); -} diff --git a/packages/ai/src/tools/file-tools/create-files-tool/create-file-tool.test.ts b/packages/ai/src/tools/file-tools/create-files-tool/create-file-tool.test.ts index 116867cf1..1190cad6b 100644 --- a/packages/ai/src/tools/file-tools/create-files-tool/create-file-tool.test.ts +++ b/packages/ai/src/tools/file-tools/create-files-tool/create-file-tool.test.ts @@ -1,3 +1,5 @@ +import * as fs from 'node:fs/promises'; +import * as path from 'node:path'; import { RuntimeContext } from '@mastra/core/runtime-context'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { z } from 'zod'; @@ -8,17 +10,14 @@ vi.mock('@buster/sandbox', () => ({ runTypescript: vi.fn(), })); -vi.mock('./create-file-functions', () => ({ - generateFileCreateCode: vi.fn(), - createFilesSafely: vi.fn(), +vi.mock('node:fs/promises', () => ({ + readFile: vi.fn(), })); import { runTypescript } from '@buster/sandbox'; -import { createFilesSafely, generateFileCreateCode } from './create-file-functions'; const mockRunTypescript = vi.mocked(runTypescript); -const mockGenerateFileCreateCode = vi.mocked(generateFileCreateCode); -const mockCreateFilesSafely = vi.mocked(createFilesSafely); +const mockFs = vi.mocked(fs); describe('create-file-tool', () => { let runtimeContext: RuntimeContext; @@ -69,14 +68,14 @@ describe('create-file-tool', () => { files: [{ path: '/test/file.txt', content: 'test content' }], }; - const mockCode = 'generated typescript code'; + const mockScriptContent = 'mock script content'; const mockSandboxResult = { result: JSON.stringify([{ success: true, filePath: '/test/file.txt' }]), exitCode: 0, stderr: '', }; - mockGenerateFileCreateCode.mockReturnValue(mockCode); + mockFs.readFile.mockResolvedValue(mockScriptContent); mockRunTypescript.mockResolvedValue(mockSandboxResult); const result = await createFiles.execute({ @@ -84,8 +83,13 @@ describe('create-file-tool', () => { runtimeContext, }); - expect(mockGenerateFileCreateCode).toHaveBeenCalledWith(input.files); - expect(mockRunTypescript).toHaveBeenCalledWith(mockSandbox, mockCode); + expect(mockFs.readFile).toHaveBeenCalledWith( + path.join(__dirname, 'create-files-script.ts'), + 'utf-8' + ); + expect(mockRunTypescript).toHaveBeenCalledWith(mockSandbox, mockScriptContent, { + argv: [JSON.stringify(input.files)], + }); expect(result.results).toHaveLength(1); expect(result.results[0]).toEqual({ status: 'success', @@ -93,25 +97,21 @@ describe('create-file-tool', () => { }); }); - it('should fallback to local execution when sandbox not available', async () => { + it('should return error when sandbox not available', async () => { const input = { files: [{ path: '/test/file.txt', content: 'test content' }], }; - const mockLocalResult = [{ success: true, filePath: '/test/file.txt' }]; - - mockCreateFilesSafely.mockResolvedValue(mockLocalResult); - const result = await createFiles.execute({ context: input, runtimeContext, }); - expect(mockCreateFilesSafely).toHaveBeenCalledWith(input.files); expect(result.results).toHaveLength(1); expect(result.results[0]).toEqual({ - status: 'success', + status: 'error', filePath: '/test/file.txt', + errorMessage: 'File creation requires sandbox environment', }); }); @@ -123,14 +123,14 @@ describe('create-file-tool', () => { files: [{ path: '/test/file.txt', content: 'test content' }], }; - const mockCode = 'generated typescript code'; + const mockScriptContent = 'mock script content'; const mockSandboxResult = { result: 'error output', exitCode: 1, stderr: 'Execution failed', }; - mockGenerateFileCreateCode.mockReturnValue(mockCode); + mockFs.readFile.mockResolvedValue(mockScriptContent); mockRunTypescript.mockResolvedValue(mockSandboxResult); const result = await createFiles.execute({ @@ -147,6 +147,9 @@ describe('create-file-tool', () => { }); it('should handle mixed success and error results', async () => { + const mockSandbox = { process: { codeRun: vi.fn() } }; + runtimeContext.set(DocsAgentContextKeys.Sandbox, mockSandbox as any); + const input = { files: [ { path: '/test/file1.txt', content: 'content1' }, @@ -154,12 +157,18 @@ describe('create-file-tool', () => { ], }; - const mockLocalResult = [ - { success: true, filePath: '/test/file1.txt' }, - { success: false, filePath: '/test/file2.txt', error: 'Permission denied' }, - ]; + const mockScriptContent = 'mock script content'; + const mockSandboxResult = { + result: JSON.stringify([ + { success: true, filePath: '/test/file1.txt' }, + { success: false, filePath: '/test/file2.txt', error: 'Permission denied' }, + ]), + exitCode: 0, + stderr: '', + }; - mockCreateFilesSafely.mockResolvedValue(mockLocalResult); + mockFs.readFile.mockResolvedValue(mockScriptContent); + mockRunTypescript.mockResolvedValue(mockSandboxResult); const result = await createFiles.execute({ context: input, @@ -197,14 +206,14 @@ describe('create-file-tool', () => { files: [{ path: '/test/file.txt', content: 'test content' }], }; - const mockCode = 'generated typescript code'; + const mockScriptContent = 'mock script content'; const mockSandboxResult = { result: 'invalid json output', exitCode: 0, stderr: '', }; - mockGenerateFileCreateCode.mockReturnValue(mockCode); + mockFs.readFile.mockResolvedValue(mockScriptContent); mockRunTypescript.mockResolvedValue(mockSandboxResult); const result = await createFiles.execute({ @@ -219,5 +228,63 @@ describe('create-file-tool', () => { errorMessage: expect.stringContaining('Failed to parse sandbox output'), }); }); + + it('should handle file read errors', async () => { + const mockSandbox = { process: { codeRun: vi.fn() } }; + runtimeContext.set(DocsAgentContextKeys.Sandbox, mockSandbox as any); + + const input = { + files: [{ path: '/test/file.txt', content: 'test content' }], + }; + + mockFs.readFile.mockRejectedValue(new Error('Script not found')); + + const result = await createFiles.execute({ + context: input, + runtimeContext, + }); + + expect(result.results).toHaveLength(1); + expect(result.results[0]).toEqual({ + status: 'error', + filePath: '/test/file.txt', + errorMessage: 'Execution error: Script not found', + }); + }); + + it('should pass file parameters as JSON string argument', async () => { + const mockSandbox = { process: { codeRun: vi.fn() } }; + runtimeContext.set(DocsAgentContextKeys.Sandbox, mockSandbox as any); + + const input = { + files: [ + { path: '/test/file1.txt', content: 'content1' }, + { path: '/test/file2.txt', content: 'content2' }, + ], + }; + + const mockScriptContent = 'mock script content'; + const mockSandboxResult = { + result: JSON.stringify([ + { success: true, filePath: '/test/file1.txt' }, + { success: true, filePath: '/test/file2.txt' }, + ]), + exitCode: 0, + stderr: '', + }; + + mockFs.readFile.mockResolvedValue(mockScriptContent); + mockRunTypescript.mockResolvedValue(mockSandboxResult); + + await createFiles.execute({ + context: input, + runtimeContext, + }); + + expect(mockRunTypescript).toHaveBeenCalledWith(mockSandbox, mockScriptContent, { + argv: [JSON.stringify(input.files)], + }); + }); }); }); + diff --git a/packages/ai/src/tools/file-tools/create-files-tool/create-file-tool.ts b/packages/ai/src/tools/file-tools/create-files-tool/create-file-tool.ts index a4540f474..3eb2657a6 100644 --- a/packages/ai/src/tools/file-tools/create-files-tool/create-file-tool.ts +++ b/packages/ai/src/tools/file-tools/create-files-tool/create-file-tool.ts @@ -1,3 +1,5 @@ +import * as fs from 'node:fs/promises'; +import * as path from 'node:path'; import { runTypescript } from '@buster/sandbox'; import type { RuntimeContext } from '@mastra/core/runtime-context'; import { createTool } from '@mastra/core/tools'; @@ -42,23 +44,25 @@ const createFilesExecution = wrapTraced( } try { - // Check if sandbox is available in runtime context const sandbox = runtimeContext.get(DocsAgentContextKeys.Sandbox); if (sandbox) { - // Execute in sandbox - const { generateFileCreateCode } = await import('./create-file-functions'); - const code = generateFileCreateCode(files); - const result = await runTypescript(sandbox, code); + // Read the create-files-script.ts content + const scriptPath = path.join(__dirname, 'create-files-script.ts'); + const scriptContent = await fs.readFile(scriptPath, 'utf-8'); + + // Pass file parameters as JSON string argument + const args = [JSON.stringify(files)]; + + const result = await runTypescript(sandbox, scriptContent, { argv: args }); if (result.exitCode !== 0) { console.error('Sandbox execution failed. Exit code:', result.exitCode); console.error('Stderr:', result.stderr); - console.error('Stdout:', result.result); + console.error('Result:', result.result); throw new Error(`Sandbox execution failed: ${result.stderr || 'Unknown error'}`); } - // Parse the JSON output from sandbox let fileResults: Array<{ success: boolean; filePath: string; @@ -90,24 +94,14 @@ const createFilesExecution = wrapTraced( }; } - // Fallback to local execution - const { createFilesSafely } = await import('./create-file-functions'); - const fileResults = await createFilesSafely(files); - + // When not in sandbox, we can't create files + // Return an error for each file return { - results: fileResults.map((fileResult) => { - if (fileResult.success) { - return { - status: 'success' as const, - filePath: fileResult.filePath, - }; - } - return { - status: 'error' as const, - filePath: fileResult.filePath, - errorMessage: fileResult.error || 'Unknown error', - }; - }), + results: files.map((file) => ({ + status: 'error' as const, + filePath: file.path, + errorMessage: 'File creation requires sandbox environment', + })), }; } catch (error) { return { @@ -139,3 +133,4 @@ export const createFiles = createTool({ }); export default createFiles; + diff --git a/packages/ai/src/tools/file-tools/create-files-tool/create-files-script.int.test.ts b/packages/ai/src/tools/file-tools/create-files-tool/create-files-script.int.test.ts new file mode 100644 index 000000000..8293b7447 --- /dev/null +++ b/packages/ai/src/tools/file-tools/create-files-tool/create-files-script.int.test.ts @@ -0,0 +1,233 @@ +import { exec } from 'node:child_process'; +import * as fs from 'node:fs/promises'; +import * as os from 'node:os'; +import * as path from 'node:path'; +import { promisify } from 'node:util'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; + +const execAsync = promisify(exec); + +describe('create-files-script integration tests', () => { + let tempDir: string; + const scriptPath = path.join(__dirname, 'create-files-script.ts'); + + beforeEach(async () => { + // Create a temporary directory for test files + tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'create-files-test-')); + }); + + afterEach(async () => { + // Clean up temporary directory + await fs.rm(tempDir, { recursive: true, force: true }); + }); + + it('should create single file successfully', async () => { + const testFile = path.join(tempDir, 'test.txt'); + const fileParams = [{ path: testFile, content: 'Hello, World!' }]; + + const { stdout, stderr } = await execAsync( + `npx tsx ${scriptPath} '${JSON.stringify(fileParams)}'` + ); + + expect(stderr).toBe(''); + const results = JSON.parse(stdout); + expect(results).toHaveLength(1); + expect(results[0]).toEqual({ + success: true, + filePath: testFile, + }); + + // Verify file was actually created + const content = await fs.readFile(testFile, 'utf-8'); + expect(content).toBe('Hello, World!'); + }); + + it('should create multiple files successfully', async () => { + const files = [ + { path: path.join(tempDir, 'file1.txt'), content: 'Content 1' }, + { path: path.join(tempDir, 'file2.txt'), content: 'Content 2' }, + { path: path.join(tempDir, 'subdir', 'file3.txt'), content: 'Content 3' }, + ]; + + const { stdout, stderr } = await execAsync(`npx tsx ${scriptPath} '${JSON.stringify(files)}'`); + + expect(stderr).toBe(''); + const results = JSON.parse(stdout); + expect(results).toHaveLength(3); + + // Verify all files were created + for (let i = 0; i < files.length; i++) { + expect(results[i].success).toBe(true); + const content = await fs.readFile(files[i].path, 'utf-8'); + expect(content).toBe(files[i].content); + } + + // Verify subdirectory was created + const subdirStats = await fs.stat(path.join(tempDir, 'subdir')); + expect(subdirStats.isDirectory()).toBe(true); + }); + + it('should handle relative paths correctly', async () => { + const originalCwd = process.cwd(); + process.chdir(tempDir); + + try { + const fileParams = [ + { path: 'relative.txt', content: 'Relative content' }, + { path: './subdir/nested.txt', content: 'Nested content' }, + ]; + + const { stdout, stderr } = await execAsync( + `npx tsx ${scriptPath} '${JSON.stringify(fileParams)}'` + ); + + expect(stderr).toBe(''); + const results = JSON.parse(stdout); + expect(results).toHaveLength(2); + expect(results[0].success).toBe(true); + expect(results[1].success).toBe(true); + + // Verify files were created at correct locations + const relativeContent = await fs.readFile(path.join(tempDir, 'relative.txt'), 'utf-8'); + expect(relativeContent).toBe('Relative content'); + + const nestedContent = await fs.readFile(path.join(tempDir, 'subdir', 'nested.txt'), 'utf-8'); + expect(nestedContent).toBe('Nested content'); + } finally { + process.chdir(originalCwd); + } + }); + + it('should overwrite existing files', async () => { + const testFile = path.join(tempDir, 'existing.txt'); + await fs.writeFile(testFile, 'Original content'); + + const fileParams = [{ path: testFile, content: 'New content' }]; + + const { stdout, stderr } = await execAsync( + `npx tsx ${scriptPath} '${JSON.stringify(fileParams)}'` + ); + + expect(stderr).toBe(''); + const results = JSON.parse(stdout); + expect(results[0].success).toBe(true); + + // Verify file was overwritten + const content = await fs.readFile(testFile, 'utf-8'); + expect(content).toBe('New content'); + }); + + it('should handle special characters in content', async () => { + const testFile = path.join(tempDir, 'special.txt'); + const specialContent = 'Line 1\nLine 2\tTabbed\r\nWindows line\n"Quoted"\n\'Single\''; + const fileParams = [{ path: testFile, content: specialContent }]; + + const { stdout, stderr } = await execAsync( + `npx tsx ${scriptPath} '${JSON.stringify(fileParams)}'` + ); + + expect(stderr).toBe(''); + const results = JSON.parse(stdout); + expect(results[0].success).toBe(true); + + // Verify content with special characters + const content = await fs.readFile(testFile, 'utf-8'); + expect(content).toBe(specialContent); + }); + + it('should handle errors gracefully', async () => { + // Try to create a file in a non-existent absolute path + const invalidPath = '/this/path/should/not/exist/file.txt'; + const fileParams = [{ path: invalidPath, content: 'Content' }]; + + const { stdout, stderr } = await execAsync( + `npx tsx ${scriptPath} '${JSON.stringify(fileParams)}'` + ); + + expect(stderr).toBe(''); + const results = JSON.parse(stdout); + expect(results[0].success).toBe(false); + expect(results[0].filePath).toBe(invalidPath); + expect(results[0].error).toBeTruthy(); + }); + + it('should handle mixed success and failure', async () => { + const files = [ + { path: path.join(tempDir, 'success1.txt'), content: 'Success 1' }, + { path: '/invalid/path/fail.txt', content: 'Will fail' }, + { path: path.join(tempDir, 'success2.txt'), content: 'Success 2' }, + ]; + + const { stdout, stderr } = await execAsync(`npx tsx ${scriptPath} '${JSON.stringify(files)}'`); + + expect(stderr).toBe(''); + const results = JSON.parse(stdout); + expect(results).toHaveLength(3); + + expect(results[0].success).toBe(true); + expect(results[1].success).toBe(false); + expect(results[2].success).toBe(true); + + // Verify successful files were created + const content1 = await fs.readFile(files[0].path, 'utf-8'); + expect(content1).toBe('Success 1'); + + const content2 = await fs.readFile(files[2].path, 'utf-8'); + expect(content2).toBe('Success 2'); + }); + + it('should error when no arguments provided', async () => { + try { + await execAsync(`npx tsx ${scriptPath}`); + expect.fail('Should have thrown an error'); + } catch (error: any) { + expect(error.code).toBe(1); + const errorOutput = JSON.parse(error.stderr); + expect(errorOutput.success).toBe(false); + expect(errorOutput.error).toBe('No file parameters provided'); + } + }); + + it('should error with invalid JSON', async () => { + try { + await execAsync(`npx tsx ${scriptPath} 'not valid json'`); + expect.fail('Should have thrown an error'); + } catch (error: any) { + expect(error.code).toBe(1); + const errorOutput = JSON.parse(error.stderr); + expect(errorOutput.success).toBe(false); + expect(errorOutput.error).toContain('Invalid file parameters'); + } + }); + + it('should error with invalid parameter structure', async () => { + try { + const invalidParams = [{ path: '/test/file.txt' }]; // missing content + await execAsync(`npx tsx ${scriptPath} '${JSON.stringify(invalidParams)}'`); + expect.fail('Should have thrown an error'); + } catch (error: any) { + expect(error.code).toBe(1); + const errorOutput = JSON.parse(error.stderr); + expect(errorOutput.success).toBe(false); + expect(errorOutput.error).toContain('must have a content string'); + } + }); + + it('should create deeply nested directories', async () => { + const deepPath = path.join(tempDir, 'a', 'b', 'c', 'd', 'e', 'file.txt'); + const fileParams = [{ path: deepPath, content: 'Deep content' }]; + + const { stdout, stderr } = await execAsync( + `npx tsx ${scriptPath} '${JSON.stringify(fileParams)}'` + ); + + expect(stderr).toBe(''); + const results = JSON.parse(stdout); + expect(results[0].success).toBe(true); + + // Verify file and all directories were created + const content = await fs.readFile(deepPath, 'utf-8'); + expect(content).toBe('Deep content'); + }); +}); + diff --git a/packages/ai/src/tools/file-tools/create-files-tool/create-files-script.test.ts b/packages/ai/src/tools/file-tools/create-files-tool/create-files-script.test.ts new file mode 100644 index 000000000..ec8c48c4f --- /dev/null +++ b/packages/ai/src/tools/file-tools/create-files-tool/create-files-script.test.ts @@ -0,0 +1,218 @@ +import * as child_process from 'node:child_process'; +import * as fs from 'node:fs/promises'; +import * as path from 'node:path'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +// Mock node modules +vi.mock('node:child_process'); +vi.mock('node:fs/promises'); + +const mockChildProcess = vi.mocked(child_process); +const mockFs = vi.mocked(fs); + +describe('create-files-script', () => { + const scriptPath = path.join(__dirname, 'create-files-script.ts'); + + beforeEach(() => { + vi.clearAllMocks(); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + describe('script execution', () => { + it('should create files successfully when executed with valid parameters', async () => { + const fileParams = [ + { path: '/test/file1.txt', content: 'content1' }, + { path: '/test/file2.txt', content: 'content2' }, + ]; + + const expectedOutput = JSON.stringify([ + { success: true, filePath: '/test/file1.txt' }, + { success: true, filePath: '/test/file2.txt' }, + ]); + + mockFs.readFile.mockResolvedValue('mock script content'); + mockChildProcess.exec.mockImplementation((command, callback) => { + if (typeof callback === 'function') { + // Simulate successful execution + callback(null, expectedOutput, ''); + } + return {} as any; + }); + + // Simulate running the script + const command = `node ${scriptPath} '${JSON.stringify(fileParams)}'`; + + await new Promise((resolve) => { + child_process.exec(command, (error, stdout) => { + expect(error).toBeNull(); + expect(stdout).toBe(expectedOutput); + resolve(); + }); + }); + }); + + it('should handle file creation errors appropriately', async () => { + const fileParams = [{ path: '/restricted/file.txt', content: 'content' }]; + + const expectedOutput = JSON.stringify([ + { + success: false, + filePath: '/restricted/file.txt', + error: 'Failed to create directory: Permission denied', + }, + ]); + + mockFs.readFile.mockResolvedValue('mock script content'); + mockChildProcess.exec.mockImplementation((command, callback) => { + if (typeof callback === 'function') { + callback(null, expectedOutput, ''); + } + return {} as any; + }); + + const command = `node ${scriptPath} '${JSON.stringify(fileParams)}'`; + + await new Promise((resolve) => { + child_process.exec(command, (error, stdout) => { + expect(error).toBeNull(); + const result = JSON.parse(stdout); + expect(result[0].success).toBe(false); + expect(result[0].error).toContain('Failed to create directory'); + resolve(); + }); + }); + }); + + it('should exit with error when no parameters provided', async () => { + const expectedError = JSON.stringify({ + success: false, + error: 'No file parameters provided', + }); + + mockFs.readFile.mockResolvedValue('mock script content'); + mockChildProcess.exec.mockImplementation((command, callback) => { + if (typeof callback === 'function') { + const error = new Error('Command failed'); + (error as any).code = 1; + callback(error, '', expectedError); + } + return {} as any; + }); + + const command = `node ${scriptPath}`; + + await new Promise((resolve) => { + child_process.exec(command, (error, stdout, stderr) => { + expect(error).toBeTruthy(); + expect(stderr).toBe(expectedError); + resolve(); + }); + }); + }); + + it('should handle invalid JSON parameters', async () => { + const expectedError = JSON.stringify({ + success: false, + error: 'Invalid file parameters: Unexpected token i in JSON at position 0', + }); + + mockFs.readFile.mockResolvedValue('mock script content'); + mockChildProcess.exec.mockImplementation((command, callback) => { + if (typeof callback === 'function') { + const error = new Error('Command failed'); + (error as any).code = 1; + callback(error, '', expectedError); + } + return {} as any; + }); + + const command = `node ${scriptPath} 'invalid json'`; + + await new Promise((resolve) => { + child_process.exec(command, (error, stdout, stderr) => { + expect(error).toBeTruthy(); + const result = JSON.parse(stderr); + expect(result.success).toBe(false); + expect(result.error).toContain('Invalid file parameters'); + resolve(); + }); + }); + }); + + it('should validate file parameters structure', async () => { + const invalidParams = [ + { path: '/test/file.txt' }, // missing content + ]; + + const expectedError = JSON.stringify({ + success: false, + error: 'Invalid file parameters: Each file parameter must have a content string', + }); + + mockFs.readFile.mockResolvedValue('mock script content'); + mockChildProcess.exec.mockImplementation((command, callback) => { + if (typeof callback === 'function') { + const error = new Error('Command failed'); + (error as any).code = 1; + callback(error, '', expectedError); + } + return {} as any; + }); + + const command = `node ${scriptPath} '${JSON.stringify(invalidParams)}'`; + + await new Promise((resolve) => { + child_process.exec(command, (error, stdout, stderr) => { + expect(error).toBeTruthy(); + const result = JSON.parse(stderr); + expect(result.error).toContain('must have a content string'); + resolve(); + }); + }); + }); + + it('should handle multiple files with mixed success/failure', async () => { + const fileParams = [ + { path: '/test/success.txt', content: 'content1' }, + { path: '/restricted/fail.txt', content: 'content2' }, + { path: '/test/success2.txt', content: 'content3' }, + ]; + + const expectedOutput = JSON.stringify([ + { success: true, filePath: '/test/success.txt' }, + { + success: false, + filePath: '/restricted/fail.txt', + error: 'Permission denied', + }, + { success: true, filePath: '/test/success2.txt' }, + ]); + + mockFs.readFile.mockResolvedValue('mock script content'); + mockChildProcess.exec.mockImplementation((command, callback) => { + if (typeof callback === 'function') { + callback(null, expectedOutput, ''); + } + return {} as any; + }); + + const command = `node ${scriptPath} '${JSON.stringify(fileParams)}'`; + + await new Promise((resolve) => { + child_process.exec(command, (error, stdout) => { + expect(error).toBeNull(); + const results = JSON.parse(stdout); + expect(results).toHaveLength(3); + expect(results[0].success).toBe(true); + expect(results[1].success).toBe(false); + expect(results[2].success).toBe(true); + resolve(); + }); + }); + }); + }); +}); + diff --git a/packages/ai/src/tools/file-tools/create-files-tool/create-files-script.ts b/packages/ai/src/tools/file-tools/create-files-tool/create-files-script.ts new file mode 100644 index 000000000..f98a7a1ee --- /dev/null +++ b/packages/ai/src/tools/file-tools/create-files-tool/create-files-script.ts @@ -0,0 +1,110 @@ +import * as fs from 'node:fs/promises'; +import * as path from 'node:path'; + +interface FileCreateParams { + path: string; + content: string; +} + +interface FileCreateResult { + success: boolean; + filePath: string; + error?: string; +} + +async function createSingleFile(fileParams: FileCreateParams): Promise { + try { + const { path: filePath, content } = fileParams; + const resolvedPath = path.isAbsolute(filePath) ? filePath : path.join(process.cwd(), filePath); + + const dirPath = path.dirname(resolvedPath); + try { + await fs.mkdir(dirPath, { recursive: true }); + } catch (error) { + return { + success: false, + filePath, + error: `Failed to create directory: ${error instanceof Error ? error.message : 'Unknown error'}`, + }; + } + + await fs.writeFile(resolvedPath, content, 'utf-8'); + + return { + success: true, + filePath, + }; + } catch (error) { + return { + success: false, + filePath: fileParams.path, + error: error instanceof Error ? error.message : 'Unknown error occurred', + }; + } +} + +async function createFilesSafely(fileParams: FileCreateParams[]): Promise { + const fileCreatePromises = fileParams.map((params) => createSingleFile(params)); + return Promise.all(fileCreatePromises); +} + +// Script execution +async function main() { + // Parse command line arguments + const args = process.argv.slice(2); + + if (args.length === 0) { + console.error( + JSON.stringify({ + success: false, + error: 'No file parameters provided', + }) + ); + process.exit(1); + } + + let fileParams: FileCreateParams[]; + try { + // The script expects file parameters as a JSON string in the first argument + fileParams = JSON.parse(args[0]); + + if (!Array.isArray(fileParams)) { + throw new Error('File parameters must be an array'); + } + + // Validate each file parameter + for (const param of fileParams) { + if (!param.path || typeof param.path !== 'string') { + throw new Error('Each file parameter must have a valid path string'); + } + if (typeof param.content !== 'string') { + throw new Error('Each file parameter must have a content string'); + } + } + } catch (error) { + console.error( + JSON.stringify({ + success: false, + error: `Invalid file parameters: ${error instanceof Error ? error.message : 'Unknown error'}`, + }) + ); + process.exit(1); + } + + const results = await createFilesSafely(fileParams); + + // Output as JSON to stdout + console.log(JSON.stringify(results)); +} + +// Run the script +main().catch((error) => { + console.error( + JSON.stringify({ + success: false, + error: error.message || 'Unknown error occurred', + }) + ); + process.exit(1); +}); +