@wonderwhy-er/desktop-commander 0.1.30 → 0.1.32

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,6 +1,8 @@
1
1
  import fs from "fs/promises";
2
2
  import path from "path";
3
3
  import os from 'os';
4
+ import fetch from 'cross-fetch';
5
+ import { withTimeout } from '../utils.js';
4
6
  // Store allowed directories - temporarily allowing all paths
5
7
  // TODO: Make this configurable through a configuration file
6
8
  const allowedDirectories = [
@@ -23,70 +25,219 @@ function expandHome(filepath) {
23
25
  }
24
26
  return filepath;
25
27
  }
26
- // Security utilities
27
- export async function validatePath(requestedPath) {
28
- // Temporarily allow all paths by just returning the resolved path
29
- // TODO: Implement configurable path validation
30
- // Expand home directory if present
31
- const expandedPath = expandHome(requestedPath);
32
- // Convert to absolute path
33
- const absolute = path.isAbsolute(expandedPath)
34
- ? path.resolve(expandedPath)
35
- : path.resolve(process.cwd(), expandedPath);
36
- // Check if path exists
28
+ /**
29
+ * Recursively validates parent directories until it finds a valid one
30
+ * This function handles the case where we need to create nested directories
31
+ * and we need to check if any of the parent directories exist
32
+ *
33
+ * @param directoryPath The path to validate
34
+ * @returns Promise<boolean> True if a valid parent directory was found
35
+ */
36
+ async function validateParentDirectories(directoryPath) {
37
+ const parentDir = path.dirname(directoryPath);
38
+ // Base case: we've reached the root or the same directory (shouldn't happen normally)
39
+ if (parentDir === directoryPath || parentDir === path.dirname(parentDir)) {
40
+ return false;
41
+ }
37
42
  try {
38
- const stats = await fs.stat(absolute);
39
- // If path exists, resolve any symlinks
40
- return await fs.realpath(absolute);
43
+ // Check if the parent directory exists
44
+ await fs.realpath(parentDir);
45
+ return true;
41
46
  }
42
- catch (error) {
43
- // Path doesn't exist, throw an error
44
- throw new Error(`Path does not exist: ${absolute}`);
47
+ catch {
48
+ // Parent doesn't exist, recursively check its parent
49
+ return validateParentDirectories(parentDir);
45
50
  }
46
- /* Original implementation commented out for future reference
47
- const expandedPath = expandHome(requestedPath);
48
- const absolute = path.isAbsolute(expandedPath)
49
- ? path.resolve(expandedPath)
50
- : path.resolve(process.cwd(), expandedPath);
51
-
52
- const normalizedRequested = normalizePath(absolute);
53
-
54
- // Check if path is within allowed directories
55
- const isAllowed = allowedDirectories.some(dir => normalizedRequested.startsWith(normalizePath(dir)));
56
- if (!isAllowed) {
57
- throw new Error(`Access denied - path outside allowed directories: ${absolute}`);
51
+ }
52
+ /**
53
+ * Validates a path to ensure it can be accessed or created.
54
+ * For existing paths, returns the real path (resolving symlinks).
55
+ * For non-existent paths, validates parent directories to ensure they exist.
56
+ *
57
+ * @param requestedPath The path to validate
58
+ * @returns Promise<string> The validated path
59
+ * @throws Error if the path or its parent directories don't exist
60
+ */
61
+ export async function validatePath(requestedPath) {
62
+ const PATH_VALIDATION_TIMEOUT = 10000; // 10 seconds timeout
63
+ const validationOperation = async () => {
64
+ // Expand home directory if present
65
+ const expandedPath = expandHome(requestedPath);
66
+ // Convert to absolute path
67
+ const absolute = path.isAbsolute(expandedPath)
68
+ ? path.resolve(expandedPath)
69
+ : path.resolve(process.cwd(), expandedPath);
70
+ // Check if path exists
71
+ try {
72
+ const stats = await fs.stat(absolute);
73
+ // If path exists, resolve any symlinks
74
+ return await fs.realpath(absolute);
75
+ }
76
+ catch (error) {
77
+ // Path doesn't exist - validate parent directories
78
+ if (await validateParentDirectories(absolute)) {
79
+ // Return the path if a valid parent exists
80
+ // This will be used for folder creation and many other file operations
81
+ return absolute;
82
+ }
83
+ // If no valid parent found, return the absolute path anyway
84
+ return absolute;
85
+ }
86
+ };
87
+ // Execute with timeout
88
+ const result = await withTimeout(validationOperation(), PATH_VALIDATION_TIMEOUT, `Path validation for ${requestedPath}`, null);
89
+ if (result === null) {
90
+ throw new Error(`Path validation timed out after ${PATH_VALIDATION_TIMEOUT / 1000} seconds for: ${requestedPath}`);
58
91
  }
59
-
60
- // Handle symlinks by checking their real path
92
+ return result;
93
+ }
94
+ /**
95
+ * Read file content from a URL
96
+ * @param url URL to fetch content from
97
+ * @param returnMetadata Whether to return metadata with the content
98
+ * @returns File content or file result with metadata
99
+ */
100
+ export async function readFileFromUrl(url, returnMetadata) {
101
+ // Import the MIME type utilities
102
+ const { isImageFile } = await import('./mime-types.js');
103
+ // Set up fetch with timeout
104
+ const FETCH_TIMEOUT_MS = 30000;
105
+ const controller = new AbortController();
106
+ const timeoutId = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);
61
107
  try {
62
- const realPath = await fs.realpath(absolute);
63
- const normalizedReal = normalizePath(realPath);
64
- const isRealPathAllowed = allowedDirectories.some(dir => normalizedReal.startsWith(normalizePath(dir)));
65
- if (!isRealPathAllowed) {
66
- throw new Error("Access denied - symlink target outside allowed directories");
108
+ const response = await fetch(url, {
109
+ signal: controller.signal
110
+ });
111
+ // Clear the timeout since fetch completed
112
+ clearTimeout(timeoutId);
113
+ if (!response.ok) {
114
+ throw new Error(`HTTP error! Status: ${response.status}`);
67
115
  }
68
- return realPath;
69
- } catch (error) {
70
- // For new files that don't exist yet, verify parent directory
71
- const parentDir = path.dirname(absolute);
72
- try {
73
- const realParentPath = await fs.realpath(parentDir);
74
- const normalizedParent = normalizePath(realParentPath);
75
- const isParentAllowed = allowedDirectories.some(dir => normalizedParent.startsWith(normalizePath(dir)));
76
- if (!isParentAllowed) {
77
- throw new Error("Access denied - parent directory outside allowed directories");
116
+ // Get MIME type from Content-Type header
117
+ const contentType = response.headers.get('content-type') || 'text/plain';
118
+ const isImage = isImageFile(contentType);
119
+ if (isImage) {
120
+ // For images, convert to base64
121
+ const buffer = await response.arrayBuffer();
122
+ const content = Buffer.from(buffer).toString('base64');
123
+ if (returnMetadata === true) {
124
+ return { content, mimeType: contentType, isImage };
78
125
  }
79
- return absolute;
80
- } catch {
81
- throw new Error(`Parent directory does not exist: ${parentDir}`);
126
+ else {
127
+ return content;
128
+ }
129
+ }
130
+ else {
131
+ // For text content
132
+ const content = await response.text();
133
+ if (returnMetadata === true) {
134
+ return { content, mimeType: contentType, isImage };
135
+ }
136
+ else {
137
+ return content;
138
+ }
139
+ }
140
+ }
141
+ catch (error) {
142
+ // Clear the timeout to prevent memory leaks
143
+ clearTimeout(timeoutId);
144
+ // Improve error message for timeout/abort cases
145
+ if (error instanceof DOMException && error.name === 'AbortError') {
146
+ throw new Error(`URL fetch timed out after ${FETCH_TIMEOUT_MS}ms: ${url}`);
82
147
  }
148
+ throw new Error(`Failed to fetch URL: ${error instanceof Error ? error.message : String(error)}`);
83
149
  }
84
- */
85
150
  }
86
- // File operation tools
87
- export async function readFile(filePath) {
151
+ /**
152
+ * Read file content from the local filesystem
153
+ * @param filePath Path to the file
154
+ * @param returnMetadata Whether to return metadata with the content
155
+ * @returns File content or file result with metadata
156
+ */
157
+ export async function readFileFromDisk(filePath, returnMetadata) {
158
+ // Import the MIME type utilities
159
+ const { getMimeType, isImageFile } = await import('./mime-types.js');
88
160
  const validPath = await validatePath(filePath);
89
- return fs.readFile(validPath, "utf-8");
161
+ // Check file size before attempting to read
162
+ try {
163
+ const stats = await fs.stat(validPath);
164
+ const MAX_SIZE = 100 * 1024; // 100KB limit
165
+ if (stats.size > MAX_SIZE) {
166
+ const message = `File too large (${(stats.size / 1024).toFixed(2)}KB > ${MAX_SIZE / 1024}KB limit)`;
167
+ if (returnMetadata) {
168
+ return {
169
+ content: message,
170
+ mimeType: 'text/plain',
171
+ isImage: false
172
+ };
173
+ }
174
+ else {
175
+ return message;
176
+ }
177
+ }
178
+ }
179
+ catch (error) {
180
+ // If we can't stat the file, continue anyway and let the read operation handle errors
181
+ console.error(`Failed to stat file ${validPath}:`, error);
182
+ }
183
+ // Detect the MIME type based on file extension
184
+ const mimeType = getMimeType(validPath);
185
+ const isImage = isImageFile(mimeType);
186
+ const FILE_READ_TIMEOUT = 30000; // 30 seconds timeout for file operations
187
+ // Use withTimeout to handle potential hangs
188
+ const readOperation = async () => {
189
+ if (isImage) {
190
+ // For image files, read as Buffer and convert to base64
191
+ const buffer = await fs.readFile(validPath);
192
+ const content = buffer.toString('base64');
193
+ if (returnMetadata === true) {
194
+ return { content, mimeType, isImage };
195
+ }
196
+ else {
197
+ return content;
198
+ }
199
+ }
200
+ else {
201
+ // For all other files, try to read as UTF-8 text
202
+ try {
203
+ const content = await fs.readFile(validPath, "utf-8");
204
+ if (returnMetadata === true) {
205
+ return { content, mimeType, isImage };
206
+ }
207
+ else {
208
+ return content;
209
+ }
210
+ }
211
+ catch (error) {
212
+ // If UTF-8 reading fails, treat as binary and return base64 but still as text
213
+ const buffer = await fs.readFile(validPath);
214
+ const content = `Binary file content (base64 encoded):\n${buffer.toString('base64')}`;
215
+ if (returnMetadata === true) {
216
+ return { content, mimeType: 'text/plain', isImage: false };
217
+ }
218
+ else {
219
+ return content;
220
+ }
221
+ }
222
+ }
223
+ };
224
+ // Execute with timeout
225
+ const result = await withTimeout(readOperation(), FILE_READ_TIMEOUT, `Read file operation for ${filePath}`, returnMetadata ?
226
+ { content: `Operation timed out after ${FILE_READ_TIMEOUT / 1000} seconds`, mimeType: 'text/plain', isImage: false } :
227
+ `Operation timed out after ${FILE_READ_TIMEOUT / 1000} seconds`);
228
+ return result;
229
+ }
230
+ /**
231
+ * Read a file from either the local filesystem or a URL
232
+ * @param filePath Path to the file or URL
233
+ * @param returnMetadata Whether to return metadata with the content
234
+ * @param isUrl Whether the path is a URL
235
+ * @returns File content or file result with metadata
236
+ */
237
+ export async function readFile(filePath, returnMetadata, isUrl) {
238
+ return isUrl
239
+ ? readFileFromUrl(filePath, returnMetadata)
240
+ : readFileFromDisk(filePath, returnMetadata);
90
241
  }
91
242
  export async function writeFile(filePath, content) {
92
243
  const validPath = await validatePath(filePath);
@@ -96,12 +247,20 @@ export async function readMultipleFiles(paths) {
96
247
  return Promise.all(paths.map(async (filePath) => {
97
248
  try {
98
249
  const validPath = await validatePath(filePath);
99
- const content = await fs.readFile(validPath, "utf-8");
100
- return `${filePath}:\n${content}\n`;
250
+ const fileResult = await readFile(validPath, true);
251
+ return {
252
+ path: filePath,
253
+ content: typeof fileResult === 'string' ? fileResult : fileResult.content,
254
+ mimeType: typeof fileResult === 'string' ? "text/plain" : fileResult.mimeType,
255
+ isImage: typeof fileResult === 'string' ? false : fileResult.isImage
256
+ };
101
257
  }
102
258
  catch (error) {
103
259
  const errorMessage = error instanceof Error ? error.message : String(error);
104
- return `${filePath}: Error - ${errorMessage}`;
260
+ return {
261
+ path: filePath,
262
+ error: errorMessage
263
+ };
105
264
  }
106
265
  }));
107
266
  }
@@ -1,20 +1,14 @@
1
1
  // Simple MIME type detection based on file extension
2
2
  export function getMimeType(filePath) {
3
3
  const extension = filePath.toLowerCase().split('.').pop() || '';
4
- // Image types
4
+ // Image types - only the formats we can display
5
5
  const imageTypes = {
6
6
  'png': 'image/png',
7
7
  'jpg': 'image/jpeg',
8
8
  'jpeg': 'image/jpeg',
9
9
  'gif': 'image/gif',
10
- 'bmp': 'image/bmp',
11
- 'svg': 'image/svg+xml',
12
- 'webp': 'image/webp',
13
- 'ico': 'image/x-icon',
14
- 'tif': 'image/tiff',
15
- 'tiff': 'image/tiff',
10
+ 'webp': 'image/webp'
16
11
  };
17
- // Text types - consider everything else as text for simplicity
18
12
  // Check if the file is an image
19
13
  if (extension in imageTypes) {
20
14
  return imageTypes[extension];
@@ -47,10 +47,13 @@ export declare const UnblockCommandArgsSchema: z.ZodObject<{
47
47
  }>;
48
48
  export declare const ReadFileArgsSchema: z.ZodObject<{
49
49
  path: z.ZodString;
50
+ isUrl: z.ZodDefault<z.ZodOptional<z.ZodBoolean>>;
50
51
  }, "strip", z.ZodTypeAny, {
51
52
  path: string;
53
+ isUrl: boolean;
52
54
  }, {
53
55
  path: string;
56
+ isUrl?: boolean | undefined;
54
57
  }>;
55
58
  export declare const ReadMultipleFilesArgsSchema: z.ZodObject<{
56
59
  paths: z.ZodArray<z.ZodString, "many">;
@@ -96,12 +99,15 @@ export declare const MoveFileArgsSchema: z.ZodObject<{
96
99
  export declare const SearchFilesArgsSchema: z.ZodObject<{
97
100
  path: z.ZodString;
98
101
  pattern: z.ZodString;
102
+ timeoutMs: z.ZodOptional<z.ZodNumber>;
99
103
  }, "strip", z.ZodTypeAny, {
100
104
  path: string;
101
105
  pattern: string;
106
+ timeoutMs?: number | undefined;
102
107
  }, {
103
108
  path: string;
104
109
  pattern: string;
110
+ timeoutMs?: number | undefined;
105
111
  }>;
106
112
  export declare const GetFileInfoArgsSchema: z.ZodObject<{
107
113
  path: z.ZodString;
@@ -118,9 +124,11 @@ export declare const SearchCodeArgsSchema: z.ZodObject<{
118
124
  maxResults: z.ZodOptional<z.ZodNumber>;
119
125
  includeHidden: z.ZodOptional<z.ZodBoolean>;
120
126
  contextLines: z.ZodOptional<z.ZodNumber>;
127
+ timeoutMs: z.ZodOptional<z.ZodNumber>;
121
128
  }, "strip", z.ZodTypeAny, {
122
129
  path: string;
123
130
  pattern: string;
131
+ timeoutMs?: number | undefined;
124
132
  filePattern?: string | undefined;
125
133
  ignoreCase?: boolean | undefined;
126
134
  maxResults?: number | undefined;
@@ -129,6 +137,7 @@ export declare const SearchCodeArgsSchema: z.ZodObject<{
129
137
  }, {
130
138
  path: string;
131
139
  pattern: string;
140
+ timeoutMs?: number | undefined;
132
141
  filePattern?: string | undefined;
133
142
  ignoreCase?: boolean | undefined;
134
143
  maxResults?: number | undefined;
@@ -23,6 +23,7 @@ export const UnblockCommandArgsSchema = z.object({
23
23
  // Filesystem tools schemas
24
24
  export const ReadFileArgsSchema = z.object({
25
25
  path: z.string(),
26
+ isUrl: z.boolean().optional().default(false),
26
27
  });
27
28
  export const ReadMultipleFilesArgsSchema = z.object({
28
29
  paths: z.array(z.string()),
@@ -44,6 +45,7 @@ export const MoveFileArgsSchema = z.object({
44
45
  export const SearchFilesArgsSchema = z.object({
45
46
  path: z.string(),
46
47
  pattern: z.string(),
48
+ timeoutMs: z.number().optional(),
47
49
  });
48
50
  export const GetFileInfoArgsSchema = z.object({
49
51
  path: z.string(),
@@ -57,6 +59,7 @@ export const SearchCodeArgsSchema = z.object({
57
59
  maxResults: z.number().optional(),
58
60
  includeHidden: z.boolean().optional(),
59
61
  contextLines: z.number().optional(),
62
+ timeoutMs: z.number().optional(),
60
63
  });
61
64
  // Edit tools schemas
62
65
  export const EditBlockArgsSchema = z.object({
@@ -35,6 +35,11 @@ export async function searchCode(options) {
35
35
  const results = [];
36
36
  const rg = spawn(rgPath, args);
37
37
  let stdoutBuffer = '';
38
+ // Store a reference to the child process for potential termination
39
+ const childProcess = rg;
40
+ // Store in a process list - this could be expanded to a global registry
41
+ // of running search processes if needed for management
42
+ globalThis.currentSearchProcess = childProcess;
38
43
  rg.stdout.on('data', (data) => {
39
44
  stdoutBuffer += data.toString();
40
45
  });
@@ -42,6 +47,10 @@ export async function searchCode(options) {
42
47
  console.error(`ripgrep error: ${data}`);
43
48
  });
44
49
  rg.on('close', (code) => {
50
+ // Clean up the global reference
51
+ if (globalThis.currentSearchProcess === childProcess) {
52
+ delete globalThis.currentSearchProcess;
53
+ }
45
54
  if (code === 0 || code === 1) {
46
55
  // Process the buffered output
47
56
  const lines = stdoutBuffer.trim().split('\n');
package/dist/utils.d.ts CHANGED
@@ -1 +1,12 @@
1
1
  export declare const capture: (event: string, properties?: any) => void;
2
+ /**
3
+ * Executes a promise with a timeout. If the promise doesn't resolve or reject within
4
+ * the specified timeout, returns the provided default value.
5
+ *
6
+ * @param operation The promise to execute
7
+ * @param timeoutMs Timeout in milliseconds
8
+ * @param operationName Name of the operation (for logs)
9
+ * @param defaultValue Value to return if the operation times out
10
+ * @returns Promise that resolves with the operation result or the default value on timeout
11
+ */
12
+ export declare function withTimeout<T>(operation: Promise<T>, timeoutMs: number, operationName: string, defaultValue: T): Promise<T>;
package/dist/utils.js CHANGED
@@ -19,7 +19,7 @@ try {
19
19
  // Access the default export from the module
20
20
  uniqueUserId = machineIdModule.default.machineIdSync();
21
21
  if (isTrackingEnabled) {
22
- posthog = new PostHog('phc_TFQqTkCwtFGxlwkXDY3gSs7uvJJcJu8GurfXd6mV063', {
22
+ posthog = new PostHog('phc_BW8KJ0cajzj2v8qfMhvDQ4dtFdgHPzeYcMRvRFGvQdH', {
23
23
  host: 'https://eu.i.posthog.com',
24
24
  flushAt: 3, // send all every time
25
25
  flushInterval: 5 // send always
@@ -54,3 +54,41 @@ export const capture = (event, properties) => {
54
54
  // Silently fail - we don't want analytics issues to break functionality
55
55
  }
56
56
  };
57
+ /**
58
+ * Executes a promise with a timeout. If the promise doesn't resolve or reject within
59
+ * the specified timeout, returns the provided default value.
60
+ *
61
+ * @param operation The promise to execute
62
+ * @param timeoutMs Timeout in milliseconds
63
+ * @param operationName Name of the operation (for logs)
64
+ * @param defaultValue Value to return if the operation times out
65
+ * @returns Promise that resolves with the operation result or the default value on timeout
66
+ */
67
+ export function withTimeout(operation, timeoutMs, operationName, defaultValue) {
68
+ return new Promise((resolve) => {
69
+ let isCompleted = false;
70
+ // Set up timeout
71
+ const timeoutId = setTimeout(() => {
72
+ if (!isCompleted) {
73
+ isCompleted = true;
74
+ resolve(defaultValue);
75
+ }
76
+ }, timeoutMs);
77
+ // Execute the operation
78
+ operation
79
+ .then(result => {
80
+ if (!isCompleted) {
81
+ isCompleted = true;
82
+ clearTimeout(timeoutId);
83
+ resolve(result);
84
+ }
85
+ })
86
+ .catch(error => {
87
+ if (!isCompleted) {
88
+ isCompleted = true;
89
+ clearTimeout(timeoutId);
90
+ resolve(defaultValue);
91
+ }
92
+ });
93
+ });
94
+ }
package/dist/version.d.ts CHANGED
@@ -1 +1 @@
1
- export declare const VERSION = "0.1.30";
1
+ export declare const VERSION = "0.1.32";
package/dist/version.js CHANGED
@@ -1 +1 @@
1
- export const VERSION = '0.1.30';
1
+ export const VERSION = '0.1.32';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wonderwhy-er/desktop-commander",
3
- "version": "0.1.30",
3
+ "version": "0.1.32",
4
4
  "description": "MCP server for terminal operations and file editing",
5
5
  "license": "MIT",
6
6
  "author": "Eduards Ruzga",
@@ -31,8 +31,7 @@
31
31
  "setup": "npm install && npm run build && node setup-claude-server.js",
32
32
  "setup:debug": "npm install && npm run build && node setup-claude-server.js --debug",
33
33
  "prepare": "npm run build",
34
- "test": "node test/test.js",
35
- "test:watch": "nodemon test/test.js",
34
+ "test": "node test/run-all-tests.js",
36
35
  "link:local": "npm run build && npm link",
37
36
  "unlink:local": "npm unlink",
38
37
  "inspector": "npx @modelcontextprotocol/inspector dist/index.js"
@@ -62,6 +61,7 @@
62
61
  "dependencies": {
63
62
  "@modelcontextprotocol/sdk": "^1.8.0",
64
63
  "@vscode/ripgrep": "^1.15.9",
64
+ "cross-fetch": "^4.1.0",
65
65
  "glob": "^10.3.10",
66
66
  "node-machine-id": "^1.1.12",
67
67
  "posthog-node": "^4.11.1",
@@ -70,6 +70,7 @@
70
70
  },
71
71
  "devDependencies": {
72
72
  "@types/node": "^20.17.24",
73
+ "nexe": "^5.0.0-beta.4",
73
74
  "nodemon": "^3.0.2",
74
75
  "shx": "^0.3.4",
75
76
  "typescript": "^5.3.3"