UNPKG

@wonderwhy-er/desktop-commander

Version:

MCP server for terminal operations and file editing

75 lines (74 loc) • 3.1 kB
import fs from 'fs/promises'; import os from 'os'; import path from 'path'; import { logger } from './logger.js'; /** * Single place for renaming files. Use this instead of fs.rename. * * Windows refuses to rename a file, or replace a destination, while another * process has it open (readers, antivirus and search indexers hold such * handles briefly) and reports EPERM, EACCES or EBUSY. Those errors are * retried with a short backoff. Any other error, one onto an existing * directory or a read-only file (Windows answers those with EPERM too, and * they never succeed), and every error on macOS/Linux, is thrown immediately. */ const WINDOWS_TRANSIENT_RENAME_ERRORS = new Set(['EPERM', 'EACCES', 'EBUSY']); const RENAME_RETRY_BUDGET_MS = 5000; export async function renameWithRetry(from, to) { const deadline = Date.now() + RENAME_RETRY_BUDGET_MS; for (let attempt = 1;; attempt++) { try { await fs.rename(from, to); return; } catch (error) { const transient = os.platform() === 'win32' && WINDOWS_TRANSIENT_RENAME_ERRORS.has(error?.code) && !(await cannotBeReplaced(to)); if (!transient || Date.now() >= deadline) throw error; await new Promise((resolve) => setTimeout(resolve, Math.min(10 * attempt, 100))); } } } /** * Moves `from` to `to`, a file or a folder with everything in it, as mv does. * A rename can't reach another volume (EXDEV): the entry is then copied, next * to `to` under a temporary name, renamed onto `to` once the whole copy is * there, and only then is the source removed. A copy that fails removes what * it wrote, leaves the source as it was and throws its error. */ export async function movePath(from, to) { try { await renameWithRetry(from, to); return; } catch (error) { if (error?.code !== 'EXDEV') throw error; } const copy = path.join(path.dirname(to), `.${path.basename(to)}.${process.pid}-${Date.now()}.moving`); try { // A link is copied as a link, as a rename moves it await fs.cp(from, copy, { recursive: true, errorOnExist: true, force: false, preserveTimestamps: true, verbatimSymlinks: true }); await renameWithRetry(copy, to); } catch (error) { await fs.rm(copy, { recursive: true, force: true }).catch((cleanupError) => { // The copy's error says why the move failed; this one is only logged logger.error(`Could not remove the partial copy ${copy}: ${cleanupError}`); }); throw error; } await fs.rm(from, { recursive: true }); } /** Whether `filePath` is what a rename never replaces on Windows: a directory, or a read-only file */ async function cannotBeReplaced(filePath) { try { const stats = await fs.lstat(filePath); return stats.isDirectory() || (stats.mode & 0o200) === 0; } catch { // Missing or unreadable: nothing says the rename can't succeed, so keep treating it as a held file return false; } }