@wonderwhy-er/desktop-commander
Version:
MCP server for terminal operations and file editing
1,049 lines (1,048 loc) • 47.1 kB
JavaScript
import fs from "fs/promises";
import path from "path";
import os from 'os';
import fetch from 'cross-fetch';
import { execFile } from 'child_process';
import { promisify } from 'util';
import { addToolCallPaths, capture } from '../utils/capture.js';
import { withTimeout, runWithAbortableTimeout } from '../utils/withTimeout.js';
import { configManager } from '../config-manager.js';
import { getFileHandler, TextFileHandler, isImageAnswer } from '../utils/files/index.js';
import { isPdfFile } from "./mime-types.js";
import { parsePdfToMarkdown, editPdf, insertRenderOptions, parseMarkdownToPdf, resolveRender } from './pdf/index.js';
import { movePath } from '../utils/rename.js';
// CONSTANTS SECTION - Consolidate all timeouts and thresholds
const FILE_OPERATION_TIMEOUTS = {
PATH_VALIDATION: 10000, // 10 seconds
URL_FETCH: 30000, // 30 seconds
FILE_READ: 30000, // 30 seconds
};
// Cap file read operations at 3 minutes. The MCP client's hard per-call limit
// is ~4 minutes; timing out at 3m lets us abort the underlying fs op and return
// a useful error (e.g. the cloud-storage guidance in buildPermissionError)
// BEFORE the client gives up with an opaque "No result received after 4
// minutes". Paired with runWithAbortableTimeout so the read is actually
// cancelled (fd/thread released), not just abandoned.
export const READ_OPERATION_TIMEOUT_MS = 3 * 60 * 1000; // 3 minutes
const FILE_SIZE_LIMITS = {
LINE_COUNT_LIMIT: 10 * 1024 * 1024, // 10MB for line counting
};
// UTILITY FUNCTIONS - Eliminate duplication
/**
* Get MIME type information for a file
* @param filePath Path to the file
* @returns Object with mimeType and isImage properties
*/
async function getMimeTypeInfo(filePath) {
const { getMimeType, isImageFile, isPdfFile } = await import('./mime-types.js');
const mimeType = getMimeType(filePath);
const isImage = isImageFile(mimeType);
const isPdf = isPdfFile(mimeType);
return { mimeType, isImage, isPdf };
}
/**
* Get file extension for telemetry purposes
* @param filePath Path to the file
* @returns Lowercase file extension
*/
function getFileExtension(filePath) {
return path.extname(filePath).toLowerCase();
}
/**
* Get default read length from configuration
* @returns Default number of lines to read
*/
async function getDefaultReadLength() {
const config = await configManager.getConfig();
return config.fileReadLineLimit ?? 1000; // Default to 1000 lines if not set
}
/**
* Returns a helpful error message when a file operation fails with a permission
* or timeout error.
*
* Lists all common causes without path-based detection — the AI receiving this
* error should inspect the path and inform the user which cause is most likely
* (e.g. cloud storage folder, network drive, system file, locked file, etc.)
*/
function buildPermissionError(filePath, errCode) {
const isMac = process.platform === 'darwin';
const isTimeout = errCode === 'ETIMEDOUT';
const lines = [
`Cannot read file — ${isTimeout ? 'operation timed out' : 'permission denied'} (${errCode}).`,
`Path: ${filePath}`,
``,
`[AI: based on the path above, determine which cause is most likely and explain it to the user.]`,
``,
`Possible causes and fixes:`,
` 1. File is in cloud storage (Google Drive / iCloud / Dropbox / OneDrive) but not downloaded locally.`,
` → Right-click the file and choose "Download Now", "Make Available Offline", or "Keep on This Device".`,
` 2. Cloud storage app is not running or not signed in.`,
` → Open your cloud storage app and make sure it is syncing.`,
` 3. File is on a network drive or virtual filesystem that is currently unavailable.`,
` → Check that the network share or drive is mounted and accessible.`,
` 4. File has restricted permissions (e.g. system file, locked by another process, or chmod 000).`,
` → Check file permissions or close any app that may have the file open.`,
` 5. The app does not have permission to access this location (macOS Full Disk Access).`,
];
if (isMac) {
lines.push(` → Go to System Settings → Privacy & Security → Full Disk Access and enable Claude.`);
lines.push(` → To open that pane directly, run in terminal:`);
lines.push(` open "x-apple.systempreferences:com.apple.preference.security?Privacy_AllFiles"`);
lines.push(` Then find "Claude" in the list and enable the toggle next to it.`);
}
else {
lines.push(` → Check that the app has permission to access this file location.`);
}
return new Error(lines.join('\n'));
}
// Initialize allowed directories from configuration
async function getAllowedDirs() {
try {
let allowedDirectories;
const config = await configManager.getConfig();
if (config.allowedDirectories && Array.isArray(config.allowedDirectories)) {
allowedDirectories = config.allowedDirectories;
}
else {
// Fall back to default directories if not configured
allowedDirectories = [
os.homedir() // User's home directory
];
// Update config with default
await configManager.setValue('allowedDirectories', allowedDirectories);
}
return allowedDirectories;
}
catch (error) {
console.error('Failed to initialize allowed directories:', error);
// Keep the default permissive path
}
return [];
}
// Normalize all paths consistently
function normalizePath(p) {
return path.normalize(expandHome(p)).toLowerCase();
}
function expandHome(filepath) {
if (filepath.startsWith('~/') || filepath === '~') {
return path.join(os.homedir(), filepath.slice(1));
}
return filepath;
}
/**
* Recursively validates parent directories until it finds a valid one
* This function handles the case where we need to create nested directories
* and we need to check if any of the parent directories exist
*
* @param directoryPath The path to validate
* @returns Promise<boolean> True if a valid parent directory was found
*/
async function validateParentDirectories(directoryPath) {
const parentDir = path.dirname(directoryPath);
// Base case: we've reached the root or the same directory (shouldn't happen normally)
if (parentDir === directoryPath || parentDir === path.dirname(parentDir)) {
return false;
}
try {
// Check if the parent directory exists
await fs.realpath(parentDir);
return true;
}
catch {
// Parent doesn't exist, recursively check its parent
return validateParentDirectories(parentDir);
}
}
// Most links followed while resolving one path before it counts as a loop (Linux's limit)
const MAX_LINKS_FOLLOWED = 40;
/**
* Resolves a path to where file operations on it land, like fs.realpath, but
* also for paths that don't exist yet (e.g. a file about to be written).
*
* Missing names are kept under their resolved parent. A dangling link (a
* symlink or Windows junction - Node reports both as symlinks - whose target
* doesn't exist) is followed to its target, because writing through it creates
* the target. Relative targets resolve against the link's real directory,
* chains are followed link by link, and loops fail with ELOOP.
*
* @param absolutePath Absolute path to resolve
* @returns Promise<string> The real path, or where the path would be created
* @throws Filesystem errors other than ENOENT, or ELOOP for a link loop
*/
async function resolveRealPath(absolutePath) {
let linksFollowed = 0;
const resolveFrom = async (currentPath) => {
try {
return await fs.realpath(currentPath, { encoding: 'utf8' });
}
catch (error) {
if (error.code !== 'ENOENT')
throw error;
}
const parent = path.dirname(currentPath);
if (parent === currentPath) {
return currentPath; // a root that doesn't exist, e.g. an unmapped drive
}
// Resolve the parent first so links along the way are followed, and a
// relative link target is read against the link's real directory
const resolvedParent = await resolveFrom(parent);
const candidate = path.join(resolvedParent, path.basename(currentPath));
let isLink;
try {
isLink = (await fs.lstat(candidate)).isSymbolicLink();
}
catch (error) {
if (error.code !== 'ENOENT')
throw error;
return candidate; // doesn't exist yet: this is where it would be created
}
if (!isLink) {
return candidate;
}
// Dangling link: a write through it lands on its target, so resolve that instead
if (++linksFollowed > MAX_LINKS_FOLLOWED) {
throw Object.assign(new Error(`ELOOP: too many symbolic links encountered, resolving '${absolutePath}'`), { code: 'ELOOP' });
}
const target = await fs.readlink(candidate, { encoding: 'utf8' });
return resolveFrom(path.resolve(resolvedParent, target));
};
return resolveFrom(absolutePath);
}
/**
* Resolves the folder an entry is in, like resolveRealPath, and keeps the
* entry's own name as written: the entry itself (a link, not what it points
* to), in the letter case given.
*/
async function resolveEntryPath(absolutePath) {
const parent = path.dirname(absolutePath);
if (parent === absolutePath) {
return resolveRealPath(absolutePath); // a root has no folder above it
}
return path.join(await resolveRealPath(parent), path.basename(absolutePath));
}
/**
* An allowed directory's real path, or null when it can't be read. validatePath
* checks real paths, so an allowed directory reached through a symlink or
* junction (macOS: /var -> /private/var) must match by its real path too.
* When the real path can't be read, only the written form matches, which can
* only deny, never widen, access.
*/
async function getAllowedDirRealPath(allowedDir) {
try {
return await fs.realpath(expandHome(allowedDir));
}
catch {
return null;
}
}
/**
* Whether a normalized path is an allowed directory or inside it
*/
function isWithinAllowedDir(normalizedPathToCheck, allowedDir) {
let normalizedAllowedDir = normalizePath(allowedDir);
if (normalizedAllowedDir.slice(-1) === path.sep) {
normalizedAllowedDir = normalizedAllowedDir.slice(0, -1);
}
// Check if path is exactly the allowed directory
if (normalizedPathToCheck === normalizedAllowedDir) {
return true;
}
// Check if path is a subdirectory of the allowed directory
// Make sure to add a separator to prevent partial directory name matches
// e.g. /home/user vs /home/username
// An allowed drive root (D:\, normalized to d:) allows its whole drive this
// way, whatever the letter: the paths checked here are always absolute (d:\...)
return normalizedPathToCheck.startsWith(normalizedAllowedDir + path.sep);
}
/**
* Checks if a path is within any of the allowed directories
*
* @param pathToCheck Path to check
* @returns boolean True if path is allowed
*/
async function isPathAllowed(pathToCheck) {
// If root directory is allowed, all paths are allowed
const allowedDirectories = await getAllowedDirs();
if (allowedDirectories.includes('/') || allowedDirectories.length === 0) {
return true;
}
let normalizedPathToCheck = normalizePath(pathToCheck);
if (normalizedPathToCheck.slice(-1) === path.sep) {
normalizedPathToCheck = normalizedPathToCheck.slice(0, -1);
}
// As written first: no filesystem call, so an allowed directory on an
// unresponsive mount can't hold up a path inside another one
if (allowedDirectories.some((allowedDir) => isWithinAllowedDir(normalizedPathToCheck, allowedDir))) {
return true;
}
// Then by real path: the first match answers, so a real path that never
// resolves only holds up a path that no other allowed directory contains
return new Promise((resolve) => {
let pending = allowedDirectories.length;
for (const allowedDir of allowedDirectories) {
void getAllowedDirRealPath(allowedDir).then((realPath) => {
if (realPath !== null && isWithinAllowedDir(normalizedPathToCheck, realPath)) {
resolve(true);
}
if (--pending === 0) {
resolve(false);
}
});
}
});
}
/**
* Validates a path to ensure it can be accessed or created.
* For existing paths, returns the real path (resolving symlinks).
* For non-existent paths, returns where they would be created (resolving the
* existing ancestors and any dangling link), and validates parent directories.
*
* With `entry`, the path names an entry to rename (move_file): links are
* followed up to its folder, and its own name is kept as written, so a link
* is renamed as a link and a change of letter case is kept. A rename only
* ever touches that entry, never what a link at that name points to.
*
* @param requestedPath The path to validate
* @returns Promise<string> The validated path
* @throws Error if the path or its parent directories don't exist or if the path is not allowed
*/
export async function validatePath(requestedPath, { entry = false } = {}) {
const validationOperation = async () => {
// Expand home directory if present
const expandedPath = expandHome(requestedPath);
// Convert to absolute path
const absoluteOriginal = path.isAbsolute(expandedPath)
? path.resolve(expandedPath)
: path.resolve(process.cwd(), expandedPath);
// Every form of this path an error text may name, so telemetry replaces it whole
addToolCallPaths(requestedPath, expandedPath, absoluteOriginal);
// SECURITY: Resolve symlinks (and Windows junctions) to where file operations
// on this path actually land, and check that location. This covers paths that
// don't exist yet (a new file under a linked directory) and dangling links:
// otherwise a link inside an allowed directory pointing to a missing file
// elsewhere would let a write create that file outside the allowed directories.
let pathForNextCheck;
try {
pathForNextCheck = entry
? await resolveEntryPath(absoluteOriginal)
: await resolveRealPath(absoluteOriginal);
}
catch (error) {
// Permission denied, I/O errors, link loops, ...
const err = error;
capture('server_path_realpath_error', {
error: err.message,
path: absoluteOriginal
});
throw new Error(`Failed to resolve symlink for path: ${absoluteOriginal}. Error: ${err.message}`);
}
addToolCallPaths(pathForNextCheck);
// Check if path is allowed
if (!(await isPathAllowed(pathForNextCheck))) {
capture('server_path_validation_error', {
error: 'Path not allowed',
allowedDirsCount: (await getAllowedDirs()).length
});
throw new Error(`Path not allowed: ${requestedPath}. Must be within one of these directories: ${(await getAllowedDirs()).join(', ')}`);
}
// SECURITY: Always return the resolved path (with symlinks resolved) so that
// all subsequent file operations (read, write, mkdir, etc.) operate on the
// canonical target, not on a symlink that could point outside allowed directories.
// pathForNextCheck already holds that resolved path from above.
// Check if path exists
try {
// fs.stat() will automatically follow symlinks, so we get existence info
await fs.stat(pathForNextCheck);
return pathForNextCheck;
}
catch (error) {
// Path doesn't exist - validate parent directories
if (await validateParentDirectories(pathForNextCheck)) {
// Return the resolved path if a valid parent exists
// This will be used for folder creation and many other file operations
return pathForNextCheck;
}
// If no valid parent found, return the resolved path anyway
return pathForNextCheck;
}
};
// Execute with timeout
const result = await withTimeout(validationOperation(), FILE_OPERATION_TIMEOUTS.PATH_VALIDATION, `Path validation operation`, // Generic name for telemetry
null);
if (result === null) {
// Keep original path in error for AI but a generic message for telemetry
capture('server_path_validation_timeout', {
timeoutMs: FILE_OPERATION_TIMEOUTS.PATH_VALIDATION
});
throw new Error(`Path validation failed for path: ${requestedPath}`);
}
return result;
}
/**
* Read file content from a URL
* @param url URL to fetch content from
* @param svgAsImage An SVG is answered as an image, for the file preview widget; otherwise as text
* @returns File content or file result with metadata
*/
export async function readFileFromUrl(url, svgAsImage = false) {
// Set up fetch with timeout
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), FILE_OPERATION_TIMEOUTS.URL_FETCH);
try {
const response = await fetch(url, {
signal: controller.signal
});
// Clear the timeout since fetch completed
clearTimeout(timeoutId);
if (!response.ok) {
throw new Error(`HTTP error! Status: ${response.status}`);
}
// Get MIME type from Content-Type header or infer from URL
const contentType = response.headers.get('content-type') || 'text/plain';
const isImage = isImageAnswer(contentType, svgAsImage);
const isPdf = isPdfFile(contentType) || url.toLowerCase().endsWith('.pdf');
// NEW: Add PDF handling before image check
if (isPdf) {
// Use URL directly - pdfreader handles URL downloads internally
const pdfResult = await parsePdfToMarkdown(url);
return {
content: "",
mimeType: 'text/plain',
metadata: {
isImage: false,
isPdf: true,
author: pdfResult.metadata.author,
title: pdfResult.metadata.title,
totalPages: pdfResult.metadata.totalPages,
pages: pdfResult.pages
}
};
}
else if (isImage) {
// For images, convert to base64
const buffer = await response.arrayBuffer();
const content = Buffer.from(buffer).toString('base64');
return { content, mimeType: contentType, metadata: { isImage } };
}
else {
// For text content
const content = await response.text();
return { content, mimeType: contentType, metadata: { isImage } };
}
}
catch (error) {
// Clear the timeout to prevent memory leaks
clearTimeout(timeoutId);
// Return error information instead of throwing
const errorMessage = error instanceof DOMException && error.name === 'AbortError'
? `URL fetch timed out after ${FILE_OPERATION_TIMEOUTS.URL_FETCH}ms: ${url}`
: `Failed to fetch URL: ${error instanceof Error ? error.message : String(error)}`;
throw new Error(errorMessage);
}
}
/**
* Read file content from the local filesystem
* @param filePath Path to the file
* @param options Read options (offset, length, sheet, range)
* @returns File content or file result with metadata
*/
export async function readFileFromDisk(filePath, options) {
const { offset = 0, sheet, range, svgAsImage } = options ?? {};
let { length } = options ?? {};
// Add validation for required parameters
if (!filePath || typeof filePath !== 'string') {
throw new Error('Invalid file path provided');
}
// Get default length from config if not provided
if (length === undefined) {
length = await getDefaultReadLength();
}
const validPath = await validatePath(filePath);
// Get file extension for telemetry
const fileExtension = getFileExtension(validPath);
// Check if path is a directory — return listing instead of EISDIR error
try {
const stats = await fs.stat(validPath);
if (stats.isDirectory()) {
const dirListOp = async () => {
const entries = await listDirectory(validPath);
const listing = entries.join('\n');
return {
content: `This is a directory, not a file. Use the list_directory tool instead of read_file for directories.\n\n${listing}`,
mimeType: 'text/plain',
metadata: { isImage: false, isDirectory: true }
};
};
const dirResult = await withTimeout(dirListOp(), FILE_OPERATION_TIMEOUTS.FILE_READ, 'Directory listing fallback', null);
if (dirResult === null) {
throw new Error(`Directory listing timed out for: ${filePath}`);
}
return dirResult;
}
}
catch (error) {
// If stat itself failed, fall through to the read path which will produce a proper error.
// But if this was a directory-listing error, re-throw — don't let it fall into the file-read path.
const err = error;
if (err.message?.includes('Directory listing') || err.message?.includes('list_directory')) {
throw error;
}
// stat() failed (e.g. ENOENT) — fall through to the read path below
}
// Check file size before attempting to read
try {
const stats = await fs.stat(validPath);
// Capture file extension in telemetry without capturing the file path
capture('server_read_file', {
fileExtension: fileExtension,
offset: offset,
length: length,
fileSize: stats.size
});
}
catch (error) {
console.error('error catch ' + error);
const errorMessage = error instanceof Error ? error.message : String(error);
capture('server_read_file_error', { error: errorMessage, fileExtension: fileExtension });
// If we can't stat the file, continue anyway and let the read operation handle errors
}
// Read under an abortable timeout so a hung/stalled read is cancelled
// (fd/thread freed) rather than leaked until the OS call returns.
const readOperation = async (signal) => {
// Get appropriate handler for this file type (async - includes binary detection)
const handler = await getFileHandler(validPath, { svgAsImage });
// Use handler to read the file
const result = await handler.read(validPath, {
offset,
length,
sheet,
range,
includeStatusMessage: true,
signal
});
// Return with content as string
// For images: content is already base64-encoded string from handler
// For text: content may be string or Buffer, convert to UTF-8 string
let content;
if (typeof result.content === 'string') {
content = result.content;
}
else if (result.metadata?.isImage) {
// Image buffer should be base64 encoded, not UTF-8 converted
content = result.content.toString('base64');
}
else {
content = result.content.toString('utf8');
}
return {
content,
mimeType: result.mimeType,
metadata: result.metadata
};
};
// Execute with a 3-minute, cancellable timeout
let result;
try {
result = await runWithAbortableTimeout((signal) => readOperation(signal), READ_OPERATION_TIMEOUT_MS, `Read file operation for ${filePath}`);
}
catch (error) {
const err = error;
// runWithAbortableTimeout rejects with an Error whose .code is 'ETIMEDOUT'
// on timeout; fs rejects with EPERM/EACCES. Map all to the guidance error.
if (err.code === 'EPERM' || err.code === 'EACCES' || err.code === 'ETIMEDOUT') {
throw buildPermissionError(filePath, err.code);
}
throw error;
}
if (result == null) {
// Handles the impossible case where withTimeout resolves to null instead of throwing
throw new Error('Failed to read the file');
}
return result;
}
/**
* Read a file from either the local filesystem or a URL
* @param filePath Path to the file or URL
* @param options Read options (isUrl, offset, length, sheet, range)
* @returns File content or file result with metadata
*/
export async function readFile(filePath, options) {
const { isUrl, offset, length, sheet, range, svgAsImage } = options ?? {};
return isUrl
? readFileFromUrl(filePath, svgAsImage)
: readFileFromDisk(filePath, { offset, length, sheet, range, svgAsImage });
}
/**
* Read file content without status messages for internal operations
* This function preserves exact file content including original line endings,
* which is essential for edit operations that need to maintain file formatting.
* @param filePath Path to the file
* @param offset Starting line number to read from (default: 0)
* @param length Maximum number of lines to read (default: from config or 1000)
* @returns File content without status headers, with preserved line endings
*/
export async function readFileInternal(filePath, offset = 0, length) {
// Get default length from config if not provided
if (length === undefined) {
length = await getDefaultReadLength();
}
const validPath = await validatePath(filePath);
// Get file extension and MIME type
const fileExtension = getFileExtension(validPath);
const { mimeType, isImage } = await getMimeTypeInfo(validPath);
if (isImage) {
throw new Error('Cannot read image files as text for internal operations');
}
// IMPORTANT: For internal operations (especially edit operations), we must
// preserve exact file content including original line endings.
// We cannot use readline-based reading as it strips line endings.
// Read entire file content preserving line endings, under a 3-minute,
// cancellable timeout so an edit on a stalled/cloud path can't hang forever
// (previously this read had no timeout at all).
const content = await runWithAbortableTimeout((signal) => fs.readFile(validPath, { encoding: 'utf8', signal }), READ_OPERATION_TIMEOUT_MS, `Internal read for ${filePath}`);
// If we need to apply offset/length, do it while preserving line endings
if (offset === 0 && length >= Number.MAX_SAFE_INTEGER) {
// Most common case for edit operations: read entire file
return content;
}
// Handle offset/length by splitting on line boundaries while preserving line endings
const lines = TextFileHandler.splitLinesPreservingEndings(content);
// Apply offset and length
const selectedLines = lines.slice(offset, offset + length);
// Join back together (this preserves the original line endings)
return selectedLines.join('');
}
export async function writeFile(filePath, content, mode = 'rewrite') {
const validPath = await validatePath(filePath);
// Get file extension for telemetry
const fileExtension = getFileExtension(validPath);
// Calculate content metrics
const contentBytes = Buffer.from(content).length;
const lineCount = TextFileHandler.countLines(content);
// Capture file extension and operation details in telemetry without capturing the file path
capture('server_write_file', {
fileExtension: fileExtension,
mode: mode,
contentBytes: contentBytes,
lineCount: lineCount
});
// Get appropriate handler for this file type (async - includes binary detection)
const handler = await getFileHandler(validPath);
// Use handler to write the file
await handler.write(validPath, content, mode);
}
/**
* Reads each file; a failure is that file's `error`. `stillLoadingError` (the
* server's) gives the error for a file whose package is still loading, which
* is then not read (utils/files/factory.ts).
*/
export async function readMultipleFiles(paths, stillLoadingError) {
return Promise.all(paths.map(async (filePath) => {
const stillLoading = stillLoadingError?.(filePath);
if (stillLoading) {
return { path: filePath, error: stillLoading };
}
try {
const validPath = await validatePath(filePath);
const fileResult = await readFile(validPath);
// Handle content conversion properly for images vs text
let content;
if (typeof fileResult.content === 'string') {
content = fileResult.content;
}
else if (fileResult.metadata?.isImage) {
content = fileResult.content.toString('base64');
}
else {
content = fileResult.content.toString('utf8');
}
return {
path: filePath,
content,
mimeType: fileResult.mimeType,
isImage: fileResult.metadata?.isImage ?? false,
isPdf: fileResult.metadata?.isPdf ?? false,
payload: fileResult.metadata?.isPdf ? {
metadata: {
author: fileResult.metadata.author,
title: fileResult.metadata.title,
totalPages: fileResult.metadata.totalPages ?? 0
},
pages: fileResult.metadata.pages ?? []
} : undefined
};
}
catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
return {
path: filePath,
error: errorMessage
};
}
}));
}
export async function createDirectory(dirPath) {
const validPath = await validatePath(dirPath);
await fs.mkdir(validPath, { recursive: true });
}
export async function listDirectory(dirPath, depth = 2) {
const validPath = await validatePath(dirPath);
const results = [];
const MAX_NESTED_ITEMS = 100; // Maximum items to show per nested directory
async function listRecursive(currentPath, currentDepth, relativePath = '', isTopLevel = true) {
if (currentDepth <= 0)
return;
let entries;
try {
entries = await fs.readdir(currentPath, { withFileTypes: true });
}
catch (error) {
const err = error;
const displayPath = relativePath || path.basename(currentPath);
// Distinguish "not found" from "permission denied" so AI and UI get accurate info.
if (err.code === 'ENOENT') {
results.push(`[NOT_FOUND] ${displayPath} — path does not exist`);
}
else if (err.code === 'EPERM' || err.code === 'EACCES' || err.code === 'ETIMEDOUT') {
results.push(`[DENIED] ${displayPath} — not accessible (permission denied, cloud-only file, or Full Disk Access not granted)`);
}
else {
results.push(`[DENIED] ${displayPath}`);
}
return;
}
// Apply filtering for nested directories (not top level)
const totalEntries = entries.length;
let entriesToShow = entries;
let filteredCount = 0;
if (!isTopLevel && totalEntries > MAX_NESTED_ITEMS) {
entriesToShow = entries.slice(0, MAX_NESTED_ITEMS);
filteredCount = totalEntries - MAX_NESTED_ITEMS;
}
for (const entry of entriesToShow) {
const fullPath = path.join(currentPath, entry.name);
const displayPath = relativePath ? path.join(relativePath, entry.name) : entry.name;
// A link (a junction on Windows) to a folder is a folder. A link whose
// target can't be read stays a [FILE], as every link was before.
const isDirectory = entry.isDirectory()
|| (entry.isSymbolicLink() && await fs.stat(fullPath).then((stats) => stats.isDirectory(), () => false));
// Add this entry to results
results.push(`${isDirectory ? "[DIR]" : "[FILE]"} ${displayPath}`);
// If it's a directory and we have depth remaining, recurse
if (isDirectory && currentDepth > 1) {
try {
// Validate the path before recursing, and list the folder that was
// checked: a link can be retargeted between the check and the read
const validatedPath = await validatePath(fullPath);
await listRecursive(validatedPath, currentDepth - 1, displayPath, false);
}
catch (error) {
// If validation fails or we can't access it, it will be marked as denied
// when we try to read it in the recursive call
continue;
}
}
}
// Add warning message if items were filtered
if (filteredCount > 0) {
const displayPath = relativePath || path.basename(currentPath);
results.push(`[WARNING] ${displayPath}: ${filteredCount} items hidden (showing first ${MAX_NESTED_ITEMS} of ${totalEntries} total)`);
}
}
// A path that is a file lists as that file. Any other path goes on to the
// listing, which reports why it can't be read ([NOT_FOUND], [DENIED]).
const stats = await fs.stat(validPath).catch(() => undefined);
if (stats?.isFile()) {
return [`[FILE] ${path.basename(validPath)}`];
}
await listRecursive(validPath, depth, '', true);
return results;
}
export async function moveFile(sourcePath, destinationPath) {
// The entries themselves: a link moves as a link, and a rename that only
// changes letter case keeps the new case
const validSourcePath = await validatePath(sourcePath, { entry: true });
const validDestPath = await validatePath(destinationPath, { entry: true });
// Across volumes, a copy and then removing the source
await movePath(validSourcePath, validDestPath);
}
export async function searchFiles(rootPath, pattern) {
// Use the new search manager for better performance
// This provides a temporary compatibility layer until we fully migrate to search sessions
const { searchManager } = await import('../search-manager.js');
try {
const { sessionId } = await searchManager.startSearch({
rootPath,
pattern,
searchType: 'files',
ignoreCase: true,
maxResults: 5000, // Higher limit for compatibility
earlyTermination: true, // Use early termination for better performance
});
// Every result of the session once it is complete - each exactly once.
// Stop a search still running after 30 seconds; it then completes with what it found
const stopTimer = setTimeout(() => searchManager.terminateSearch(sessionId), 30000);
const results = await searchManager.waitForCompletion(sessionId)
.finally(() => clearTimeout(stopTimer));
const allResults = results
.filter(searchResult => searchResult.type === 'file')
.map(searchResult => searchResult.file);
// Log only the count of found files, not their paths
capture('server_search_files_complete', {
resultsCount: allResults.length,
patternLength: pattern.length,
usedRipgrep: true
});
return allResults;
}
catch (error) {
// Fallback to original Node.js implementation if ripgrep fails
capture('server_search_files_ripgrep_fallback', {
error: error instanceof Error ? error.message : 'Unknown error'
});
return await searchFilesNodeJS(rootPath, pattern);
}
}
// Keep the original Node.js implementation as fallback
async function searchFilesNodeJS(rootPath, pattern) {
const results = [];
async function search(currentPath) {
let entries;
try {
entries = await fs.readdir(currentPath, { withFileTypes: true });
}
catch (error) {
return; // Skip this directory on error
}
for (const entry of entries) {
const fullPath = path.join(currentPath, entry.name);
try {
await validatePath(fullPath);
if (entry.name.toLowerCase().includes(pattern.toLowerCase())) {
results.push(fullPath);
}
if (entry.isDirectory()) {
await search(fullPath);
}
}
catch (error) {
continue;
}
}
}
try {
// Validate root path before starting search
const validPath = await validatePath(rootPath);
await search(validPath);
// Log only the count of found files, not their paths
capture('server_search_files_complete', {
resultsCount: results.length,
patternLength: pattern.length,
usedRipgrep: false
});
return results;
}
catch (error) {
// For telemetry only - sanitize error info
capture('server_search_files_error', {
errorType: error instanceof Error ? error.name : 'Unknown',
error: 'Error with root path',
isRootPathError: true
});
// Re-throw the original error for the caller
throw error;
}
}
export async function getFileInfo(filePath) {
const validPath = await validatePath(filePath);
// Get fs.stat as a fallback for any missing fields
const stats = await fs.stat(validPath);
const fallbackInfo = {
size: stats.size,
created: stats.birthtime,
modified: stats.mtime,
accessed: stats.atime,
isDirectory: stats.isDirectory(),
isFile: stats.isFile(),
permissions: stats.mode.toString(8).slice(-3),
fileType: (stats.isDirectory() ? 'directory' : 'text'),
metadata: undefined,
};
// Get appropriate handler for this file type (async - includes binary detection).
// A folder has none: one chosen by its name or content would call it text, or an image.
const handler = stats.isDirectory() ? null : await getFileHandler(validPath);
// Use handler to get file info, with fallback
let fileInfo;
try {
fileInfo = handler ? await handler.getInfo(validPath) : fallbackInfo;
}
catch (error) {
// If handler fails, use fallback stats
fileInfo = fallbackInfo;
}
// Convert to legacy format (for backward compatibility)
// Use handler values with fallback to fs.stat values for any missing fields
const info = {
size: fileInfo.size ?? fallbackInfo.size,
created: fileInfo.created ?? fallbackInfo.created,
modified: fileInfo.modified ?? fallbackInfo.modified,
accessed: fileInfo.accessed ?? fallbackInfo.accessed,
isDirectory: fileInfo.isDirectory ?? fallbackInfo.isDirectory,
isFile: fileInfo.isFile ?? fallbackInfo.isFile,
permissions: fileInfo.permissions ?? fallbackInfo.permissions,
fileType: fileInfo.fileType ?? fallbackInfo.fileType,
};
// Add type-specific metadata from file handler
if (fileInfo.metadata) {
// For text files
if (fileInfo.metadata.lineCount !== undefined) {
info.lineCount = fileInfo.metadata.lineCount;
info.lastLine = fileInfo.metadata.lineCount - 1;
info.appendPosition = fileInfo.metadata.lineCount;
}
// For Excel files
if (fileInfo.metadata.sheets) {
info.sheets = fileInfo.metadata.sheets;
info.isExcelFile = true;
}
// For images
if (fileInfo.metadata.isImage) {
info.isImage = true;
}
// For PDF files
if (fileInfo.metadata.isPdf) {
info.isPdf = true;
info.totalPages = fileInfo.metadata.totalPages;
if (fileInfo.metadata.title)
info.title = fileInfo.metadata.title;
if (fileInfo.metadata.author)
info.author = fileInfo.metadata.author;
}
// For binary files
if (fileInfo.metadata.isBinary) {
info.isBinary = true;
}
// A file its handler couldn't read (e.g. a workbook Excel can't open): say why
if (fileInfo.metadata.error && fileInfo.metadata.errorMessage) {
info.errorMessage = fileInfo.metadata.errorMessage;
}
}
return info;
}
/**
* Validate the paths a PDF's page operations use besides the PDF itself: the
* output path and each inserted PDF. write_pdf and edit_block both modify PDFs,
* so both check here. An inserted PDF's path is replaced by its validated path.
*
* @param validPath The PDF the operations apply to, already validated
* @returns Where to write the result: outputPath if provided, otherwise the PDF itself
*/
export async function validatePdfOperationPaths(validPath, operations, outputPath) {
// Use outputPath if provided, otherwise overwrite input file
const targetPath = outputPath ? await validatePath(outputPath) : validPath;
// Validate paths in operations
for (const o of operations) {
if (o.type === 'insert') {
if (o.sourcePdfPath) {
o.sourcePdfPath = await validatePath(o.sourcePdfPath);
}
}
}
return targetPath;
}
/**
* Write content to a PDF file.
* Can create a new PDF from Markdown string, or modify an existing PDF using operations.
*
* @param filePath Path to the output PDF file
* @param content Markdown string (for creation) or array of operations (for modification)
* @param options Options for PDF generation or modification. For modification, can include `sourcePdf`.
* @returns The render options Desktop Commander ignored (see resolveRender), so the caller can report them.
*/
export async function writePdf(filePath, content, outputPath, options = {}) {
const validPath = await validatePath(filePath);
const fileExtension = getFileExtension(validPath);
if (typeof content === 'string') {
// --- PDF CREATION MODE ---
capture('server_write_pdf', {
fileExtension: fileExtension,
contentLength: content.length,
mode: 'create'
});
const pdfBuffer = await parseMarkdownToPdf(content, options);
// Use outputPath if provided, otherwise overwrite input file
const targetPath = outputPath ? await validatePath(outputPath) : validPath;
await fs.writeFile(targetPath, pdfBuffer);
// The render succeeded; report which of the caller's/front matter's options were ignored
return resolveRender(content, options).ignoredOptions;
}
else if (Array.isArray(content)) {
const targetPath = await validatePdfOperationPaths(validPath, content, outputPath);
const operations = [...content];
capture('server_write_pdf', {
fileExtension: fileExtension,
operationCount: operations.length,
mode: 'modify',
deleteCount: operations.filter(op => op.type === 'delete').length,
insertCount: operations.filter(op => op.type === 'insert').length
});
// Perform the PDF editing (options render the inserted markdown pages)
const modifiedPdfBuffer = await editPdf(validPath, operations, options);
// Write the modified PDF to the output path
await fs.writeFile(targetPath, modifiedPdfBuffer);
// Report the options ignored in any inserted page's render options or front matter (once per option)
const ignored = new Map();
for (const op of operations) {
if (op.type === 'insert' && op.markdown !== undefined) {
for (const ignoredOption of resolveRender(op.markdown, insertRenderOptions(op, options)).ignoredOptions) {
ignored.set(ignoredOption.option, ignoredOption);
}
}
}
return [...ignored.values()];
}
else {
throw new Error('Invalid content type for writePdf. Expected string (markdown) or array of operations.');
}
}
const execFileAsync = promisify(execFile);
const DEFAULT_EDITOR_NEGATIVE_CACHE_MS = 5 * 60 * 1000;
const defaultEditorCache = new Map();
function escapeAppleScriptString(value) {
return value.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
}
export async function getDefaultEditorMetadata(filePath) {
if (os.platform() !== 'darwin') {
return {};
}
let cacheKey = '';
try {
const extension = path.extname(filePath).toLowerCase();
cacheKey = extension || path.basename(filePath).toLowerCase();
const cached = defaultEditorCache.get(cacheKey);
if (cached) {
if (!cached.expiresAt || cached.expiresAt > Date.now()) {
return cached.metadata;
}
defaultEditorCache.delete(cacheKey);
}
const script = `set appAlias to default application of (info for POSIX file "${escapeAppleScriptString(filePath)}")\nreturn (name of (info for appAlias)) & linefeed & POSIX path of appAlias`;
const { stdout } = await execFileAsync('osascript', ['-e', script], { timeout: 12000 });
const lines = stdout.split('\n').map((line) => line.trim()).filter(Boolean);
const defaultEditorName = lines[lines.length - 2]?.replace(/\.app$/i, '') ?? '';
const defaultEditorPath = lines[lines.length - 1] ?? '';
if (defaultEditorName && defaultEditorPath.startsWith('/')) {
const metadata = { defaultEditorName, defaultEditorPath };
defaultEditorCache.set(cacheKey, { metadata });
return metadata;
}
defaultEditorCache.set(cacheKey, { metadata: {}, expiresAt: Date.now() + DEFAULT_EDITOR_NEGATIVE_CACHE_MS });
}
catch {
if (cacheKey) {
defaultEditorCache.set(cacheKey, { metadata: {}, expiresAt: Date.now() + DEFAULT_EDITOR_NEGATIVE_CACHE_MS });
}
// Generic UI fallback is good enough if detection fails.
}
return {};
}