fileditor-mcp 1.0.2 → 1.0.4

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,716 +1,238 @@
1
- import fs from 'fs/promises';
2
- import { resolve, dirname, isAbsolute, join } from 'path';
3
- import { existsSync, lstatSync } from 'fs';
4
- import { cwd } from 'process';
5
- import { createRequire } from 'module';
6
- import { simpleGit } from 'simple-git';
7
-
8
- const require = createRequire(import.meta.url);
9
- const pathIsInside = require('path-is-inside');
10
-
11
- /**
12
- * General file operation utility class
13
- */
14
- export class FileUtils {
15
-
16
- // Current workspace root directory, initially null, must be set via set_workspace
17
- static WORKSPACE_ROOT = null; /**
18
- * Set workspace root directory
19
- * @param {string} workspaceRoot - New workspace root directory path
20
- * @throws {Error} If directory does not exist or is not accessible
21
- */
22
- static setWorkspaceRoot(workspaceRoot) {
23
- // Input validation
24
- if (!workspaceRoot || typeof workspaceRoot !== 'string') {
25
- throw new Error('Invalid workspace root: must be a non-empty string');
26
- }
27
-
28
- // Prevent null bytes and other dangerous characters
29
- if (workspaceRoot.includes('\0') || workspaceRoot.includes('\x00')) {
30
- throw new Error('Invalid workspace root: contains null bytes');
31
- }
32
-
33
- const resolvedPath = resolve(workspaceRoot);
34
-
35
- if (!existsSync(resolvedPath)) {
36
- throw new Error(`Workspace directory does not exist: ${workspaceRoot}`);
37
- } // Verify it is a directory, not a file
38
- try {
39
- const stats = lstatSync(resolvedPath);
40
- if (!stats.isDirectory()) {
41
- throw new Error(`Workspace path is not a directory: ${workspaceRoot}`);
42
- }
43
- } catch (error) {
44
- if (error.code !== 'ENOENT') {
45
- throw new Error(`Cannot access workspace directory: ${error.message}`);
46
- }
47
- }
48
-
49
- FileUtils.WORKSPACE_ROOT = resolvedPath;
50
- console.error(`Workspace root set to: ${FileUtils.WORKSPACE_ROOT}`);
51
- }
52
-
53
- /**
54
- * Get current workspace root directory
55
- * @returns {string} Current workspace root directory
56
- */
57
- static getWorkspaceRoot() {
58
- return FileUtils.WORKSPACE_ROOT;
59
- }
60
-
61
- /**
62
- * Securely resolve file path, ensuring it does not go outside the workspace
63
- * Uses a mature third-party library to prevent path traversal attacks
64
- * @param {string} filePath - Input file path
65
- * @returns {string} Secure absolute path
66
- * @throws {Error} If path tries to access files outside the workspace or workspace is not set
67
- */
68
- static getSecurePath(filePath) {
69
- // First check if workspace is set
70
- if (!FileUtils.WORKSPACE_ROOT) {
71
- throw new Error('Workspace not set. Please call set_workspace first to establish a secure workspace root directory.');
72
- }
73
-
74
- // Input validation
75
- if (!filePath || typeof filePath !== 'string') {
76
- throw new Error('Invalid file path: must be a non-empty string');
77
- }
78
-
79
- // Prevent null bytes and other dangerous characters
80
- if (filePath.includes('\0') || filePath.includes('\x00')) {
81
- throw new Error('Invalid file path: contains null bytes');
82
- }
83
-
84
- let targetPath;
85
-
86
- if (isAbsolute(filePath)) {
87
- // Absolute path: use directly
88
- targetPath = filePath;
89
- } else {
90
- // Relative path: join to workspace root
91
- targetPath = join(FileUtils.WORKSPACE_ROOT, filePath);
92
- }
93
-
94
- // Resolve to canonical absolute path, handle symlinks
95
- let resolvedPath;
96
- try {
97
- resolvedPath = resolve(targetPath);
98
- } catch (error) {
99
- throw new Error(`Failed to resolve path '${filePath}': ${error.message}`);
100
- }
101
-
102
- // Use mature third-party library to check if path is inside workspace
103
- if (!pathIsInside(resolvedPath, FileUtils.WORKSPACE_ROOT) && resolvedPath !== FileUtils.WORKSPACE_ROOT) {
104
- throw new Error(`Access denied: Path is outside the workspace boundary`);
105
- }
106
-
107
- return resolvedPath;
108
- }
109
-
110
- /**
111
- * Check if file exists
112
- * @param {string} filePath - File path
113
- * @returns {boolean} Whether file exists
114
- */
115
- static fileExists(filePath) {
116
- const securePath = FileUtils.getSecurePath(filePath);
117
- return existsSync(securePath);
118
- }
119
-
120
- /**
121
- * Read file content
122
- * @param {string} filePath - File path
123
- * @returns {Promise<string>} File content
124
- */
125
- static async readFile(filePath) {
126
- const securePath = FileUtils.getSecurePath(filePath);
127
- if (!existsSync(securePath)) {
128
- throw new Error(`File not found: ${securePath}`);
129
- }
130
- return await fs.readFile(securePath, 'utf8');
131
- }
132
-
133
- /**
134
- * Write file content
135
- * @param {string} filePath - File path
136
- * @param {string} content - File content
137
- */
138
- static async writeFile(filePath, content) {
139
- const securePath = FileUtils.getSecurePath(filePath);
140
- const dir = dirname(securePath);
141
-
142
- // Ensure directory exists
143
- await fs.mkdir(dir, { recursive: true });
144
- await fs.writeFile(securePath, content, 'utf8');
145
- }
146
-
147
- /**
148
- * Create MCP standard response format
149
- * @param {string} text - Response text
150
- * @returns {Object} MCP response format
151
- */
152
- static createResponse(text) {
153
- return {
154
- content: [
155
- {
156
- type: "text",
157
- text: text
158
- }
159
- ]
160
- };
161
- }
162
-
163
- /**
164
- * Create MCP error response format
165
- * @param {string} errorMessage - Error message
166
- * @returns {Object} MCP error response format
167
- */
168
- static createErrorResponse(errorMessage) {
169
- return {
170
- isError: true,
171
- content: [
172
- {
173
- type: "text",
174
- text: `Error: ${errorMessage}`
175
- }
176
- ]
177
- };
178
- }
179
-
180
- /**
181
- * Count number of lines in a string
182
- * @param {string} content - String content
183
- * @returns {number} Number of lines
184
- */
185
- static countLines(content) {
186
- return content.split('\n').length;
187
- }
188
-
189
- /**
190
- * Get specified line range from file
191
- * @param {string} content - File content
192
- * @param {string} lineRange - Line range, e.g. "1-10"
193
- * @returns {string} Content of specified range
194
- */ static getLineRange(content, lineRange) {
195
- // Validate line range format
196
- if (!lineRange || !lineRange.includes('-')) {
197
- throw new Error(`Invalid line range format: ${lineRange}. Expected format: 'start-end'`);
198
- }
199
-
200
- const [start, end] = lineRange.split('-').map(Number);
201
-
202
- // Validate line numbers are valid numbers
203
- if (isNaN(start) || isNaN(end)) {
204
- throw new Error(`Invalid line range: ${lineRange}. Start and end must be valid numbers`);
205
- }
206
-
207
- // Validate line numbers are positive
208
- if (start < 1 || end < 1) {
209
- throw new Error(`Invalid line range: ${lineRange}. Line numbers must be positive`);
210
- }
211
-
212
- // Validate start line cannot be greater than end line
213
- if (start > end) {
214
- throw new Error(`Invalid line range: ${lineRange}. Start line cannot be greater than end line`);
215
- }
216
-
217
- const lines = content.split('\n');
218
-
219
- // Validate line numbers do not exceed file range
220
- if (start > lines.length) {
221
- throw new Error(`Line range ${lineRange} exceeds file length (${lines.length} lines)`);
222
- }
223
-
224
- const selectedLines = lines.slice(start - 1, end);
225
- return selectedLines.join('\n');
226
- } /**
227
- * Format content with line numbers
228
- * @param {string} content - Original content
229
- * @param {number} startLine - Starting line number, default is 1
230
- * @returns {string} Formatted content with line numbers
231
- */
232
- static formatWithLineNumbers(content, startLine = 1) {
233
- const lines = content.split('\n');
234
- const formattedLines = lines.map((line, index) => {
235
- const lineNumber = startLine + index;
236
- return `${lineNumber} | ${line}`;
237
- });
238
- return formattedLines.join('\n');
239
- } /**
240
- * Normalize multi-file operation parameters
241
- * @param {Object} params - Parameters object containing path, content, line, etc.
242
- * @returns {Object} Normalized parameters with all values as arrays
243
- */
244
- static normalizeMultiFileArgs(params) {
245
- const { path, content, line, line_count } = params;
246
-
247
- // Determine the primary parameter that defines the operation count
248
- const paths = Array.isArray(path) ? path : [path];
249
- const fileCount = paths.length;
250
-
251
- // Normalize all parameters to arrays
252
- const normalizeParam = (param, paramName, defaultValue = null) => {
253
- if (Array.isArray(param)) {
254
- if (param.length !== fileCount) {
255
- // Generate specific error messages for backward compatibility
256
- if (paramName === 'content') {
257
- throw new Error(`Path count (${fileCount}) doesn't match content count (${param.length})`);
258
- } else if (paramName === 'line_count') {
259
- throw new Error(`Path count (${fileCount}) doesn't match line_count array length (${param.length})`);
260
- } else {
261
- throw new Error(`Parameter array length (${param.length}) must match file count (${fileCount})`);
262
- }
263
- }
264
- return param;
265
- } else {
266
- // Single value: apply to all files
267
- return new Array(fileCount).fill(param !== undefined ? param : defaultValue);
268
- }
269
- };
270
-
271
- return {
272
- paths,
273
- contents: content !== undefined ? normalizeParam(content, 'content') : null,
274
- lines: line !== undefined ? normalizeParam(line, 'line') : null,
275
- lineCounts: line_count !== undefined ? normalizeParam(line_count, 'line_count') : null,
276
- fileCount
277
- };
278
- }
279
-
280
- /**
281
- * Read directory contents with detailed type information
282
- * @param {string} dirPath - Directory path
283
- * @param {boolean} recursive - Whether to read recursively
284
- * @returns {Promise<Array>} Array of directory items
285
- */
286
- static async readDirectory(dirPath, recursive = false) {
287
- const securePath = FileUtils.getSecurePath(dirPath);
288
-
289
- if (!existsSync(securePath)) {
290
- throw new Error(`Directory not found: ${dirPath}`);
291
- }
292
-
293
- const stats = lstatSync(securePath);
294
- if (!stats.isDirectory()) {
295
- throw new Error(`Path is not a directory: ${dirPath}`);
296
- }
297
-
298
- const result = [];
299
-
300
- if (recursive) {
301
- await FileUtils._readDirectoryRecursive(securePath, result, '');
302
- } else {
303
- const items = await fs.readdir(securePath, { withFileTypes: true });
304
- for (const item of items) {
305
- const type = item.isDirectory() ? 'directory' : 'file';
306
- result.push(`${type}: ${item.name}`);
307
- }
308
- }
309
-
310
- return result;
311
- }
312
-
313
- /**
314
- * Internal recursive directory reading helper
315
- * @param {string} dirPath - Directory path
316
- * @param {Array} result - Result array to populate
317
- * @param {string} prefix - Path prefix for display
318
- */
319
- static async _readDirectoryRecursive(dirPath, result, prefix) {
320
- const items = await fs.readdir(dirPath, { withFileTypes: true });
321
-
322
- for (const item of items) {
323
- const itemPath = join(dirPath, item.name);
324
- const displayPath = prefix ? `${prefix}/${item.name}` : item.name;
325
-
326
- if (item.isDirectory()) {
327
- result.push(`directory: ${displayPath}`);
328
- await FileUtils._readDirectoryRecursive(itemPath, result, displayPath);
329
- } else {
330
- result.push(`file: ${displayPath}`);
331
- }
332
- }
333
- } /**
334
- * Check if a path is a directory
335
- * @param {string} dirPath - Directory path
336
- * @returns {boolean} Whether the path is a directory
337
- */
338
- static isDirectory(dirPath) {
339
- const securePath = FileUtils.getSecurePath(dirPath);
340
- try {
341
- return existsSync(securePath) && lstatSync(securePath).isDirectory();
342
- } catch (error) {
343
- return false;
344
- }
345
- }
346
-
347
- /**
348
- * Check if a file/directory is hidden (starts with '.')
349
- * @param {string} name - File or directory name
350
- * @returns {boolean} Whether the item is hidden
351
- */
352
- static isHidden(name) {
353
- return name.startsWith('.');
354
- }
355
-
356
- /**
357
- * Check if a directory is a git repository
358
- * @param {string} dirPath - Directory path
359
- * @returns {Promise<boolean>} Whether the directory is a git repository
360
- */
361
- static async isGitRepository(dirPath) {
362
- try {
363
- const git = simpleGit(dirPath);
364
- await git.checkIsRepo();
365
- return true;
366
- } catch (error) {
367
- return false;
368
- }
369
- }
370
-
371
- /**
372
- * Find the git repository root for a given path
373
- * @param {string} startPath - Starting path to search from
374
- * @returns {Promise<string|null>} Git repository root path or null if not found
375
- */
376
- static async findGitRoot(startPath) {
377
- try {
378
- const git = simpleGit(startPath);
379
- const isRepo = await git.checkIsRepo();
380
- if (isRepo) {
381
- const rootPath = await git.revparse(['--show-toplevel']);
382
- return rootPath.trim();
383
- }
384
- return null;
385
- } catch (error) {
386
- return null;
387
- }
388
- }
389
-
390
- /**
391
- * Get git tracked and untracked files in a directory
392
- * @param {string} gitRoot - Git repository root path
393
- * @param {string} targetPath - Target directory path
394
- * @returns {Promise<{tracked: Set<string>, untracked: Set<string>}>} Sets of tracked and untracked file paths
395
- */
396
- static async getGitFileStatus(gitRoot, targetPath) {
397
- try {
398
- const git = simpleGit(gitRoot);
399
-
400
- // Get all files in the git repository
401
- const allFiles = await git.raw(['ls-files']);
402
- const trackedFiles = new Set();
403
-
404
- if (allFiles.trim()) {
405
- const files = allFiles.trim().split('\n');
406
- for (const file of files) {
407
- const fullPath = join(gitRoot, file.trim());
408
- trackedFiles.add(fullPath);
409
- }
410
- }
411
-
412
- // Get untracked files
413
- const status = await git.status();
414
- const untrackedFiles = new Set();
415
-
416
- for (const file of status.not_added) {
417
- const fullPath = join(gitRoot, file);
418
- untrackedFiles.add(fullPath);
419
- }
420
-
421
- // Filter files within target path
422
- const filteredTracked = new Set();
423
- const filteredUntracked = new Set();
424
-
425
- // Filter tracked files
426
- for (const filePath of trackedFiles) {
427
- if (FileUtils.isPathInTarget(filePath, targetPath)) {
428
- filteredTracked.add(filePath);
429
- }
430
- }
431
-
432
- // Filter untracked files
433
- for (const filePath of untrackedFiles) {
434
- if (FileUtils.isPathInTarget(filePath, targetPath)) {
435
- filteredUntracked.add(filePath);
436
- }
437
- }
438
-
439
- return {
440
- tracked: filteredTracked,
441
- untracked: filteredUntracked
442
- };
443
- } catch (error) {
444
- // If git command fails, return empty sets
445
- return {
446
- tracked: new Set(),
447
- untracked: new Set()
448
- };
449
- }
450
- }
451
-
452
- /**
453
- * Check if a file path is within the target directory
454
- * @param {string} filePath - File path to check
455
- * @param {string} targetPath - Target directory path
456
- * @returns {boolean} Whether the file is within the target directory
457
- */
458
- static isPathInTarget(filePath, targetPath) {
459
- const normalizedFile = resolve(filePath);
460
- const normalizedTarget = resolve(targetPath);
461
-
462
- return normalizedFile === normalizedTarget ||
463
- pathIsInside(normalizedFile, normalizedTarget);
464
- }/**
465
- * Check if a file/directory is hidden (starts with '.')
466
- * @param {string} name - File or directory name
467
- * @returns {boolean} Whether the item is hidden
468
- */
469
- static isHidden(name) {
470
- return name.startsWith('.');
471
- }
472
-
473
- /**
474
- * Check if a directory is a git repository
475
- * @param {string} dirPath - Directory path
476
- * @returns {Promise<boolean>} Whether the directory is a git repository
477
- */
478
- static async isGitRepository(dirPath) {
479
- try {
480
- const gitPath = join(dirPath, '.git');
481
- return existsSync(gitPath);
482
- } catch (error) {
483
- return false;
484
- }
485
- }
486
-
487
- /**
488
- * Find the git repository root for a given path
489
- * @param {string} startPath - Starting path to search from
490
- * @returns {Promise<string|null>} Git repository root path or null if not found
491
- */
492
- static async findGitRoot(startPath) {
493
- let currentPath = resolve(startPath);
494
-
495
- while (currentPath !== dirname(currentPath)) {
496
- if (await FileUtils.isGitRepository(currentPath)) {
497
- return currentPath;
498
- }
499
- currentPath = dirname(currentPath);
500
- }
501
-
502
- return null;
503
- }
504
-
505
- /**
506
- * Get relative path between two absolute paths
507
- * @param {string} from - Base path
508
- * @param {string} to - Target path
509
- * @returns {string} Relative path
510
- */
511
- static getRelativePath(from, to) {
512
- const fromParts = resolve(from).split(/[/\\]/);
513
- const toParts = resolve(to).split(/[/\\]/);
514
-
515
- // Find common base
516
- let commonLength = 0;
517
- const minLength = Math.min(fromParts.length, toParts.length);
518
-
519
- for (let i = 0; i < minLength; i++) {
520
- if (fromParts[i].toLowerCase() === toParts[i].toLowerCase()) {
521
- commonLength = i + 1;
522
- } else {
523
- break;
524
- }
525
- }
526
-
527
- // Build relative path
528
- const upLevels = fromParts.length - commonLength;
529
- const downParts = toParts.slice(commonLength);
530
-
531
- const relativeParts = [];
532
- for (let i = 0; i < upLevels; i++) {
533
- relativeParts.push('..');
534
- }
535
- relativeParts.push(...downParts);
536
-
537
- return relativeParts.join('/');
538
- }
539
-
540
- /**
541
- * Read directory contents with advanced filtering options
542
- * @param {string} dirPath - Directory path
543
- * @param {Object} options - Filtering options
544
- * @returns {Promise<Array>} Array of directory items
545
- */
546
- static async readDirectoryAdvanced(dirPath, options = {}) {
547
- const {
548
- recursive = false,
549
- show_hidden = false,
550
- git_filter = 'all'
551
- } = options;
552
-
553
- const securePath = FileUtils.getSecurePath(dirPath);
554
-
555
- if (!existsSync(securePath)) {
556
- throw new Error(`Directory not found: ${dirPath}`);
557
- }
558
-
559
- const stats = lstatSync(securePath);
560
- if (!stats.isDirectory()) {
561
- throw new Error(`Path is not a directory: ${dirPath}`);
562
- } // Check for git repository if git filtering is needed
563
- let gitRoot = null;
564
- let trackedFiles = new Set();
565
- let untrackedFiles = new Set(); if (git_filter !== 'all') {
566
- gitRoot = await FileUtils.findGitRoot(securePath);
567
- if (gitRoot) {
568
- const gitStatus = await FileUtils.getGitFileStatus(gitRoot, securePath);
569
- trackedFiles = gitStatus.tracked;
570
- untrackedFiles = gitStatus.untracked;
571
- }
572
- }
573
-
574
- const result = []; if (recursive) {
575
- await FileUtils._readDirectoryRecursiveAdvanced(
576
- securePath,
577
- result,
578
- '',
579
- { show_hidden, git_filter, gitRoot, trackedFiles, untrackedFiles }
580
- );
581
- } else {
582
- const items = await fs.readdir(securePath, { withFileTypes: true });
583
- for (const item of items) {
584
- // Apply hidden file filter
585
- if (!show_hidden && FileUtils.isHidden(item.name)) {
586
- continue;
587
- }
588
-
589
- const fullItemPath = join(securePath, item.name); // Apply git filter
590
- if (git_filter !== 'all' && gitRoot) {
591
- const isTracked = trackedFiles.has(fullItemPath);
592
- const isUntracked = untrackedFiles.has(fullItemPath);
593
-
594
- if (git_filter === 'tracked' && !isTracked) {
595
- continue;
596
- }
597
- if (git_filter === 'untracked' && !isUntracked) {
598
- continue;
599
- }
600
- }
601
-
602
- const type = item.isDirectory() ? 'directory' : 'file';
603
- let displayName = item.name;
604
-
605
- // Add git status indicator
606
- if (git_filter === 'all' && gitRoot) {
607
- const isTracked = trackedFiles.has(fullItemPath);
608
- const isUntracked = untrackedFiles.has(fullItemPath);
609
-
610
- if (isTracked) {
611
- displayName += ' [tracked]';
612
- } else if (isUntracked) {
613
- displayName += ' [untracked]';
614
- } else {
615
- displayName += ' [ignored]';
616
- }
617
- }
618
-
619
- result.push(`${type}: ${displayName}`);
620
- }
621
- }
622
-
623
- return result;
624
- }
625
-
626
- /**
627
- * Internal recursive directory reading helper with advanced options
628
- * @param {string} dirPath - Directory path
629
- * @param {Array} result - Result array to populate
630
- * @param {string} prefix - Path prefix for display
631
- * @param {Object} options - Filtering options
632
- */ static async _readDirectoryRecursiveAdvanced(dirPath, result, prefix, options) {
633
- const { show_hidden, git_filter, gitRoot, trackedFiles, untrackedFiles } = options;
634
-
635
- const items = await fs.readdir(dirPath, { withFileTypes: true });
636
-
637
- for (const item of items) {
638
- // Apply hidden file filter
639
- if (!show_hidden && FileUtils.isHidden(item.name)) {
640
- continue;
641
- }
642
-
643
- const itemPath = join(dirPath, item.name);
644
- const displayPath = prefix ? `${prefix}/${item.name}` : item.name;
645
-
646
- // Apply git filter
647
- if (git_filter !== 'all' && gitRoot) {
648
- const isTracked = trackedFiles.has(itemPath);
649
- const isUntracked = untrackedFiles.has(itemPath);
650
-
651
- if (git_filter === 'tracked' && !isTracked) {
652
- continue;
653
- }
654
- if (git_filter === 'untracked' && !isUntracked) {
655
- continue;
656
- }
657
- }
658
-
659
- let displayName = displayPath;
660
-
661
- // Add git status indicator
662
- if (git_filter === 'all' && gitRoot) {
663
- const isTracked = trackedFiles.has(itemPath);
664
- const isUntracked = untrackedFiles.has(itemPath);
665
-
666
- if (isTracked) {
667
- displayName += ' [tracked]';
668
- } else if (isUntracked) {
669
- displayName += ' [untracked]';
670
- } else {
671
- displayName += ' [ignored]';
672
- }
673
- }
674
-
675
- if (item.isDirectory()) {
676
- result.push(`directory: ${displayName}`);
677
- await FileUtils._readDirectoryRecursiveAdvanced(
678
- itemPath,
679
- result,
680
- displayPath,
681
- options
682
- );
683
- } else {
684
- result.push(`file: ${displayName}`);
685
- }
686
- }
687
- }
688
-
689
- /**
690
- * Validate line range parameters
691
- * @param {Object} params - Parameters containing start_line, end_line, totalLines
692
- * @throws {Error} If validation fails
693
- */
694
- static validateLineRange(params) {
695
- const { start_line, end_line, totalLines } = params;
696
-
697
- if (start_line !== null && start_line !== undefined) {
698
- if (start_line < 1) {
699
- throw new Error(`Invalid start_line: ${start_line}. Line numbers start from 1.`);
700
- }
701
- if (totalLines && start_line > totalLines) {
702
- throw new Error(`start_line (${start_line}) exceeds file length (${totalLines} lines)`);
703
- }
704
- }
705
-
706
- if (end_line !== null && end_line !== undefined) {
707
- if (end_line < 1) {
708
- throw new Error(`Invalid end_line: ${end_line}. Line numbers start from 1.`);
709
- }
710
- }
711
-
712
- if (start_line !== null && end_line !== null && start_line > end_line) {
713
- throw new Error(`start_line (${start_line}) cannot be greater than end_line (${end_line})`);
714
- }
715
- }
716
- }
1
+ import fs from 'fs/promises';
2
+ import { resolve, dirname, isAbsolute, join } from 'path';
3
+ import { existsSync, lstatSync } from 'fs';
4
+ import { cwd } from 'process';
5
+ import pathIsInside from 'path-is-inside';
6
+
7
+ /**
8
+ * General file operation utility class
9
+ */
10
+ export class FileUtils {
11
+
12
+ // Current workspace root directory, initially null, must be set via set_workspace
13
+ static WORKSPACE_ROOT = null; /**
14
+ * Set workspace root directory
15
+ * @param {string} workspaceRoot - New workspace root directory path
16
+ * @throws {Error} If directory does not exist or is not accessible
17
+ */
18
+ static setWorkspaceRoot(workspaceRoot) {
19
+ // Input validation
20
+ if (!workspaceRoot || typeof workspaceRoot !== 'string') {
21
+ throw new Error('Invalid workspace root: must be a non-empty string');
22
+ }
23
+
24
+ // Prevent null bytes and other dangerous characters
25
+ if (workspaceRoot.includes('\0') || workspaceRoot.includes('\x00')) {
26
+ throw new Error('Invalid workspace root: contains null bytes');
27
+ }
28
+
29
+ const resolvedPath = resolve(workspaceRoot);
30
+
31
+ if (!existsSync(resolvedPath)) {
32
+ throw new Error(`Workspace directory does not exist: ${workspaceRoot}`);
33
+ } // Verify it is a directory, not a file
34
+ try {
35
+ const stats = lstatSync(resolvedPath);
36
+ if (!stats.isDirectory()) {
37
+ throw new Error(`Workspace path is not a directory: ${workspaceRoot}`);
38
+ }
39
+ } catch (error) {
40
+ if (error.code !== 'ENOENT') {
41
+ throw new Error(`Cannot access workspace directory: ${error.message}`);
42
+ }
43
+ }
44
+
45
+ FileUtils.WORKSPACE_ROOT = resolvedPath;
46
+ console.error(`Workspace root set to: ${FileUtils.WORKSPACE_ROOT}`);
47
+ }
48
+
49
+ /**
50
+ * Get current workspace root directory
51
+ * @returns {string} Current workspace root directory
52
+ */
53
+ static getWorkspaceRoot() {
54
+ return FileUtils.WORKSPACE_ROOT;
55
+ }
56
+
57
+ /**
58
+ * Securely resolve file path, ensuring it does not go outside the workspace
59
+ * Uses a mature third-party library to prevent path traversal attacks
60
+ * @param {string} filePath - Input file path
61
+ * @returns {string} Secure absolute path
62
+ * @throws {Error} If path tries to access files outside the workspace or workspace is not set
63
+ */
64
+ static getSecurePath(filePath) {
65
+ // First check if workspace is set
66
+ if (!FileUtils.WORKSPACE_ROOT) {
67
+ throw new Error('Workspace not set. Please call set_workspace first to establish a secure workspace root directory.');
68
+ }
69
+
70
+ // Input validation
71
+ if (!filePath || typeof filePath !== 'string') {
72
+ throw new Error('Invalid file path: must be a non-empty string');
73
+ }
74
+
75
+ // Prevent null bytes and other dangerous characters
76
+ if (filePath.includes('\0') || filePath.includes('\x00')) {
77
+ throw new Error('Invalid file path: contains null bytes');
78
+ }
79
+
80
+ let targetPath;
81
+
82
+ if (isAbsolute(filePath)) {
83
+ // Absolute path: use directly
84
+ targetPath = filePath;
85
+ } else {
86
+ // Relative path: join to workspace root
87
+ targetPath = join(FileUtils.WORKSPACE_ROOT, filePath);
88
+ }
89
+
90
+ // Resolve to canonical absolute path, handle symlinks
91
+ let resolvedPath;
92
+ try {
93
+ resolvedPath = resolve(targetPath);
94
+ } catch (error) {
95
+ throw new Error(`Failed to resolve path '${filePath}': ${error.message}`);
96
+ }
97
+
98
+ // Use mature third-party library to check if path is inside workspace
99
+ if (!pathIsInside(resolvedPath, FileUtils.WORKSPACE_ROOT) && resolvedPath !== FileUtils.WORKSPACE_ROOT) {
100
+ throw new Error(`Access denied: Path is outside the workspace boundary`);
101
+ }
102
+
103
+ return resolvedPath;
104
+ }
105
+
106
+ /**
107
+ * Check if file exists
108
+ * @param {string} filePath - File path
109
+ * @returns {boolean} Whether file exists
110
+ */
111
+ static fileExists(filePath) {
112
+ const securePath = FileUtils.getSecurePath(filePath);
113
+ return existsSync(securePath);
114
+ }
115
+
116
+ /**
117
+ * Read file content
118
+ * @param {string} filePath - File path
119
+ * @returns {Promise<string>} File content
120
+ */
121
+ static async readFile(filePath) {
122
+ const securePath = FileUtils.getSecurePath(filePath);
123
+ if (!existsSync(securePath)) {
124
+ throw new Error(`File not found: ${securePath}`);
125
+ }
126
+ return await fs.readFile(securePath, 'utf8');
127
+ }
128
+
129
+ /**
130
+ * Write file content
131
+ * @param {string} filePath - File path
132
+ * @param {string} content - File content
133
+ */
134
+ static async writeFile(filePath, content) {
135
+ const securePath = FileUtils.getSecurePath(filePath);
136
+ const dir = dirname(securePath);
137
+
138
+ // Ensure directory exists
139
+ await fs.mkdir(dir, { recursive: true });
140
+ await fs.writeFile(securePath, content, 'utf8');
141
+ }
142
+
143
+ /**
144
+ * Create MCP standard response format
145
+ * @param {string} text - Response text
146
+ * @returns {Object} MCP response format
147
+ */
148
+ static createResponse(text) {
149
+ return {
150
+ content: [
151
+ {
152
+ type: "text",
153
+ text: text
154
+ }
155
+ ]
156
+ };
157
+ }
158
+
159
+ /**
160
+ * Create MCP error response format
161
+ * @param {string} errorMessage - Error message
162
+ * @returns {Object} MCP error response format
163
+ */
164
+ static createErrorResponse(errorMessage) {
165
+ return {
166
+ isError: true,
167
+ content: [
168
+ {
169
+ type: "text",
170
+ text: `Error: ${errorMessage}`
171
+ }
172
+ ]
173
+ };
174
+ }
175
+
176
+ /**
177
+ * Count number of lines in a string
178
+ * @param {string} content - String content
179
+ * @returns {number} Number of lines
180
+ */
181
+ static countLines(content) {
182
+ return content.split('\n').length;
183
+ }
184
+
185
+ /**
186
+ * Get specified line range from file
187
+ * @param {string} content - File content
188
+ * @param {string} lineRange - Line range, e.g. "1-10"
189
+ * @returns {string} Content of specified range
190
+ */ static getLineRange(content, lineRange) {
191
+ // Validate line range format
192
+ if (!lineRange || !lineRange.includes('-')) {
193
+ throw new Error(`Invalid line range format: ${lineRange}. Expected format: 'start-end'`);
194
+ }
195
+
196
+ const [start, end] = lineRange.split('-').map(Number);
197
+
198
+ // Validate line numbers are valid numbers
199
+ if (isNaN(start) || isNaN(end)) {
200
+ throw new Error(`Invalid line range: ${lineRange}. Start and end must be valid numbers`);
201
+ }
202
+
203
+ // Validate line numbers are positive
204
+ if (start < 1 || end < 1) {
205
+ throw new Error(`Invalid line range: ${lineRange}. Line numbers must be positive`);
206
+ }
207
+
208
+ // Validate start line cannot be greater than end line
209
+ if (start > end) {
210
+ throw new Error(`Invalid line range: ${lineRange}. Start line cannot be greater than end line`);
211
+ }
212
+
213
+ const lines = content.split('\n');
214
+
215
+ // Validate line numbers do not exceed file range
216
+ if (start > lines.length) {
217
+ throw new Error(`Line range ${lineRange} exceeds file length (${lines.length} lines)`);
218
+ }
219
+
220
+ const selectedLines = lines.slice(start - 1, end);
221
+ return selectedLines.join('\n');
222
+ }
223
+
224
+ /**
225
+ * Format content with line numbers
226
+ * @param {string} content - Original content
227
+ * @param {number} startLine - Starting line number, default is 1
228
+ * @returns {string} Formatted content with line numbers
229
+ */
230
+ static formatWithLineNumbers(content, startLine = 1) {
231
+ const lines = content.split('\n');
232
+ const formattedLines = lines.map((line, index) => {
233
+ const lineNumber = startLine + index;
234
+ return `${lineNumber} | ${line}`;
235
+ });
236
+ return formattedLines.join('\n');
237
+ }
238
+ }