import { dirname, extname, join } from 'path'; import { mkdir as fsMkdir, readdir as fsReaddir, rename as fsRename, rmdir as fsRmdir, stat as fsStat, unlink as fsUnlink, rm as fsRm } from 'node:fs/promises'; import { debug } from '$shared/utils/logger'; import { validateFileSize } from './file-size-limit'; // Cross-platform readdir using fs.promises async function readdir(path: string): Promise { const entries = await fsReaddir(path, { withFileTypes: true }); return entries.map(e => e.name); } async function mkdir(path: string, options?: { recursive?: boolean }) { try { await fsMkdir(path, { recursive: options?.recursive ?? false }); } catch (error) { throw new Error(`Failed to create directory ${path}: ${error}`); } } async function copyFile(src: string, dest: string) { const srcFile = Bun.file(src); const content = await srcFile.arrayBuffer(); await Bun.write(dest, content); } async function copyDirectory(src: string, dest: string) { // Create destination directory await mkdir(dest, { recursive: true }); // List items in source const items = await readdir(src); for (const item of items) { const srcPath = join(src, item); const destPath = join(dest, item); const file = Bun.file(srcPath); try { const stats = await file.stat(); if (stats.isDirectory()) { await copyDirectory(srcPath, destPath); } else { await copyFile(srcPath, destPath); } } catch { // Skip items that can't be read continue; } } } async function rename(oldPath: string, newPath: string) { await fsRename(oldPath, newPath); } async function unlink(path: string) { await fsUnlink(path); } async function rm(path: string, options?: { recursive?: boolean }) { await fsRm(path, { recursive: options?.recursive ?? false, force: true }); } async function rmdir(path: string) { await fsRmdir(path); } export async function writeFileOperation(filePath: string, content: string, baseModified?: string) { if (!filePath) { throw new Error('File path is required'); } if (typeof content !== 'string') { throw new Error('Content must be a string'); } // Validate content size before writing const contentSize = Buffer.byteLength(content, 'utf8'); validateFileSize(contentSize); // Optimistic concurrency: if the caller told us which on-disk version their // content is based on, refuse to overwrite when the file has since changed. // Skipped when the file no longer exists (treated as a fresh create) or when // no base token is supplied (explicit force-write). if (baseModified) { try { const currentStats = await fsStat(filePath); if (currentStats.mtime.toISOString() !== baseModified) { throw new Error( 'FILE_CONFLICT: file changed on disk since it was last loaded' ); } } catch (err) { if (err instanceof Error && err.message.startsWith('FILE_CONFLICT')) { throw err; } // stat failed because the file is gone — fall through and create it. } } try { debug.log('file', 'Writing file:', { filePath, contentLength: content.length }); await Bun.write(filePath, content); const file = Bun.file(filePath); const stats = await file.stat(); debug.log('file', 'File written successfully:', { size: stats.size }); return { message: 'File saved successfully', size: stats.size, modified: stats.mtime.toISOString() }; } catch (error) { debug.error('file', 'Write file error:', error); throw new Error(`Failed to write file: ${error}`); } } export async function createFileOperation(filePath: string, content: string = '') { if (!filePath) { throw new Error('File path is required'); } validateFileSize(Buffer.byteLength(content, 'utf8')); try { // Normalize path for Windows only const normalizedFilePath = process.platform === 'win32' ? filePath.replace(/\//g, '\\') : filePath; // Create parent directory if it doesn't exist const parentDir = dirname(normalizedFilePath); const parentFile = Bun.file(parentDir); if (!(await parentFile.exists())) { await mkdir(parentDir, { recursive: true }); } // Create empty file if it doesn't exist const file = Bun.file(normalizedFilePath); if (!(await file.exists())) { await Bun.write(normalizedFilePath, content); const stats = await file.stat(); return { message: 'File created successfully', path: normalizedFilePath, size: stats.size, modified: stats.mtime.toISOString() }; } else { throw new Error('File already exists'); } } catch (error) { debug.error('file', 'Create file error:', error); if (error instanceof Error) { if (error.message === 'File already exists') { throw error; } if (error.message.includes('EPERM')) { throw new Error('Permission denied - you may not have sufficient permissions to create files in this location'); } else if (error.message.includes('ENOENT')) { throw new Error('Invalid path or parent directory does not exist'); } throw error; } throw new Error('Failed to create file'); } } export async function createDirectoryOperation(dirPath: string) { if (!dirPath) { throw new Error('Directory path is required'); } try { // Normalize path for Windows only const normalizedDirPath = process.platform === 'win32' ? dirPath.replace(/\//g, '\\') : dirPath; const dirFile = Bun.file(normalizedDirPath); if (!(await dirFile.exists())) { await mkdir(normalizedDirPath, { recursive: true }); const stats = await dirFile.stat(); return { message: 'Directory created successfully', path: normalizedDirPath, modified: stats.mtime.toISOString() }; } else { throw new Error('Directory already exists'); } } catch (error) { debug.error('file', 'Create directory error:', error); if (error instanceof Error) { if (error.message === 'Directory already exists') { throw error; } if (error.message.includes('EPERM')) { throw new Error('Permission denied - you may not have sufficient permissions to create directories in this location'); } else if (error.message.includes('ENOENT')) { throw new Error('Invalid path or parent directory does not exist'); } throw error; } throw new Error('Failed to create directory'); } } export async function renameOperation(oldPath: string, newPath: string) { if (!oldPath || !newPath) { throw new Error('Both old and new paths are required'); } try { // Normalize paths for Windows only const normalizedOldPath = process.platform === 'win32' ? oldPath.replace(/\//g, '\\') : oldPath; const normalizedNewPath = process.platform === 'win32' ? newPath.replace(/\//g, '\\') : newPath; // Use node:fs stat for existence checks (Bun.file().exists() returns // false for directories, so it can't be used to rename folders). try { await fsStat(normalizedOldPath); } catch { throw new Error('Source path does not exist'); } let destinationExists = false; try { await fsStat(normalizedNewPath); destinationExists = true; } catch { // Destination does not exist — good. } if (destinationExists) { throw new Error('Destination path already exists'); } // On Windows, add a small delay to handle file locks if (process.platform === 'win32') { await new Promise(resolve => setTimeout(resolve, 100)); } await rename(normalizedOldPath, normalizedNewPath); const stats = await fsStat(normalizedNewPath); return { message: 'File/directory renamed successfully', oldPath: normalizedOldPath, newPath: normalizedNewPath, modified: stats.mtime.toISOString() }; } catch (error) { debug.error('file', 'Rename error:', error); if (error instanceof Error) { if (error.message === 'Source path does not exist' || error.message === 'Destination path already exists') { throw error; } if (error.message.includes('EPERM')) { throw new Error('Permission denied - file may be in use or you may not have sufficient permissions'); } else if (error.message.includes('ENOENT')) { throw new Error('File or directory not found'); } else if (error.message.includes('EEXIST')) { throw new Error('Destination already exists'); } throw error; } throw new Error('Failed to rename'); } } export async function duplicateOperation(sourcePath: string, targetPath: string) { if (!sourcePath || !targetPath) { throw new Error('Both source and target paths are required'); } try { // Normalize paths for Windows only const normalizedSourcePath = process.platform === 'win32' ? sourcePath.replace(/\//g, '\\') : sourcePath; const normalizedTargetPath = process.platform === 'win32' ? targetPath.replace(/\//g, '\\') : targetPath; // Use node:fs stat to check existence (works for both files AND directories) let sourceStats; try { sourceStats = await fsStat(normalizedSourcePath); } catch { throw new Error('Source file does not exist'); } let targetExists = false; try { await fsStat(normalizedTargetPath); targetExists = true; } catch { // Target doesn't exist - good } if (targetExists) { throw new Error('Target file already exists'); } // On Windows, add a small delay to handle file locks if (process.platform === 'win32') { await new Promise(resolve => setTimeout(resolve, 100)); } if (sourceStats.isFile()) { await copyFile(normalizedSourcePath, normalizedTargetPath); } else if (sourceStats.isDirectory()) { await copyDirectory(normalizedSourcePath, normalizedTargetPath); } else { throw new Error('Unsupported file type'); } const targetStats = await fsStat(normalizedTargetPath); return { message: sourceStats.isDirectory() ? 'Folder duplicated successfully' : 'File duplicated successfully', sourcePath: normalizedSourcePath, targetPath: normalizedTargetPath, size: sourceStats.isDirectory() ? 0 : targetStats.size, modified: targetStats.mtime.toISOString() }; } catch (error) { debug.error('file', 'Duplicate error:', error); if (error instanceof Error) { if (error.message === 'Source file does not exist' || error.message === 'Target file already exists') { throw error; } if (error.message.includes('EPERM')) { throw new Error('Permission denied - file may be in use or you may not have sufficient permissions'); } else if (error.message.includes('ENOENT')) { throw new Error('File or directory not found'); } else if (error.message.includes('EEXIST')) { throw new Error('Target file already exists'); } throw error; } throw new Error('Failed to duplicate file'); } } // Upload is served by the HTTP route (backend/http/files-upload.ts). The // earlier WS-based chunked uploader was removed because the Vite dev proxy // dropped sustained WS binary transfers with `write EPIPE`, regardless of // chunk size. export async function deleteOperation(filePath: string, force: boolean = false) { if (!filePath) { throw new Error('File path is required'); } const file = Bun.file(filePath); let stats; try { stats = await file.stat(); } catch { throw new Error('File or directory does not exist'); } try { if (stats.isFile()) { await unlink(filePath); return { message: 'File deleted successfully', path: filePath }; } else if (stats.isDirectory()) { // Check if directory is empty const items = await readdir(filePath); if (items.length > 0 && !force) { throw new Error('Directory is not empty. Empty the directory first or use force delete.'); } if (force && items.length > 0) { // Use rm with recursive option for force delete // This is safer than manual recursion and handles edge cases better await rm(filePath, { recursive: true }); } else { // For empty directories, use rmdir await rmdir(filePath); } return { message: 'Directory deleted successfully', path: filePath }; } else { throw new Error('Path is neither a file nor directory'); } } catch (error) { debug.error('file', 'Delete error:', error); if (error instanceof Error) { if (error.message === 'Directory is not empty. Empty the directory first or use force delete.' || error.message === 'Path is neither a file nor directory') { throw error; } if (error.message.includes('EPERM')) { throw new Error('Permission denied - the folder may be in use or protected'); } else if (error.message.includes('ENOTEMPTY')) { throw new Error('Directory is not empty'); } else if (error.message.includes('EBUSY')) { throw new Error('Resource is busy or locked'); } throw error; } throw new Error('Failed to delete'); } }