@hcu-lab.me/mcp-device 1.0.0

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.
Files changed (261) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +51 -0
  3. package/dist/bootstrap.d.ts +1 -0
  4. package/dist/bootstrap.js +22 -0
  5. package/dist/command-manager.d.ts +8 -0
  6. package/dist/command-manager.js +241 -0
  7. package/dist/config-field-definitions.d.ts +41 -0
  8. package/dist/config-field-definitions.js +37 -0
  9. package/dist/config-manager.d.ts +102 -0
  10. package/dist/config-manager.js +291 -0
  11. package/dist/config.d.ts +6 -0
  12. package/dist/config.js +10 -0
  13. package/dist/custom-stdio.d.ts +51 -0
  14. package/dist/custom-stdio.js +358 -0
  15. package/dist/data/official-app-ca.pem +12 -0
  16. package/dist/data/onboarding-prompts.json +70 -0
  17. package/dist/device/device-state.d.ts +13 -0
  18. package/dist/device/device-state.js +100 -0
  19. package/dist/device/device-status.d.ts +81 -0
  20. package/dist/device/device-status.js +156 -0
  21. package/dist/device/device.d.ts +16 -0
  22. package/dist/device/device.js +190 -0
  23. package/dist/device/execution-engine.d.ts +144 -0
  24. package/dist/device/execution-engine.js +195 -0
  25. package/dist/device/gateway-channel.d.ts +48 -0
  26. package/dist/device/gateway-channel.js +384 -0
  27. package/dist/device/gateway-config.d.ts +40 -0
  28. package/dist/device/gateway-config.js +207 -0
  29. package/dist/device/gateway-identity.d.ts +35 -0
  30. package/dist/device/gateway-identity.js +178 -0
  31. package/dist/device/gateway-pairing.d.ts +25 -0
  32. package/dist/device/gateway-pairing.js +105 -0
  33. package/dist/device/gateway-secure-transport.d.ts +25 -0
  34. package/dist/device/gateway-secure-transport.js +117 -0
  35. package/dist/device/gateway-tool-adapter.d.ts +17 -0
  36. package/dist/device/gateway-tool-adapter.js +284 -0
  37. package/dist/device/gateway-url-policy.d.ts +3 -0
  38. package/dist/device/gateway-url-policy.js +43 -0
  39. package/dist/device/linux-service.d.ts +70 -0
  40. package/dist/device/linux-service.js +268 -0
  41. package/dist/device/official-trust.d.ts +7 -0
  42. package/dist/device/official-trust.js +49 -0
  43. package/dist/device/project-inspection.d.ts +1 -0
  44. package/dist/device/project-inspection.js +180 -0
  45. package/dist/device/runtime-owner.d.ts +41 -0
  46. package/dist/device/runtime-owner.js +177 -0
  47. package/dist/device/windows-dpapi.d.ts +11 -0
  48. package/dist/device/windows-dpapi.js +67 -0
  49. package/dist/device/windows-service.d.ts +56 -0
  50. package/dist/device/windows-service.js +238 -0
  51. package/dist/error-handlers.d.ts +7 -0
  52. package/dist/error-handlers.js +15 -0
  53. package/dist/handlers/edit-search-handlers.d.ts +6 -0
  54. package/dist/handlers/edit-search-handlers.js +6 -0
  55. package/dist/handlers/filesystem-handlers.d.ts +38 -0
  56. package/dist/handlers/filesystem-handlers.js +440 -0
  57. package/dist/handlers/history-handlers.d.ts +12 -0
  58. package/dist/handlers/history-handlers.js +67 -0
  59. package/dist/handlers/index.d.ts +6 -0
  60. package/dist/handlers/index.js +7 -0
  61. package/dist/handlers/process-handlers.d.ts +9 -0
  62. package/dist/handlers/process-handlers.js +15 -0
  63. package/dist/handlers/search-handlers.d.ts +17 -0
  64. package/dist/handlers/search-handlers.js +224 -0
  65. package/dist/handlers/terminal-handlers.d.ts +21 -0
  66. package/dist/handlers/terminal-handlers.js +35 -0
  67. package/dist/index.d.ts +2 -0
  68. package/dist/index.js +138 -0
  69. package/dist/mcp-device.d.ts +2 -0
  70. package/dist/mcp-device.js +7 -0
  71. package/dist/npm-scripts/remote.d.ts +3 -0
  72. package/dist/npm-scripts/remote.js +314 -0
  73. package/dist/search-manager.d.ts +147 -0
  74. package/dist/search-manager.js +828 -0
  75. package/dist/server.d.ts +46 -0
  76. package/dist/server.js +1563 -0
  77. package/dist/terminal-manager.d.ts +104 -0
  78. package/dist/terminal-manager.js +635 -0
  79. package/dist/tools/config.d.ts +96 -0
  80. package/dist/tools/config.js +260 -0
  81. package/dist/tools/edit.d.ts +38 -0
  82. package/dist/tools/edit.js +435 -0
  83. package/dist/tools/feedback.d.ts +8 -0
  84. package/dist/tools/feedback.js +132 -0
  85. package/dist/tools/filesystem.d.ts +79 -0
  86. package/dist/tools/filesystem.js +947 -0
  87. package/dist/tools/fuzzySearch.d.ts +12 -0
  88. package/dist/tools/fuzzySearch.js +74 -0
  89. package/dist/tools/fuzzySearchCore.d.ts +52 -0
  90. package/dist/tools/fuzzySearchCore.js +125 -0
  91. package/dist/tools/improved-process-tools.d.ts +24 -0
  92. package/dist/tools/improved-process-tools.js +627 -0
  93. package/dist/tools/mime-types.d.ts +3 -0
  94. package/dist/tools/mime-types.js +28 -0
  95. package/dist/tools/pdf/extract-images.d.ts +34 -0
  96. package/dist/tools/pdf/extract-images.js +132 -0
  97. package/dist/tools/pdf/index.d.ts +6 -0
  98. package/dist/tools/pdf/index.js +3 -0
  99. package/dist/tools/pdf/lib/pdf2md.d.ts +36 -0
  100. package/dist/tools/pdf/lib/pdf2md.js +76 -0
  101. package/dist/tools/pdf/manipulations.d.ts +13 -0
  102. package/dist/tools/pdf/manipulations.js +99 -0
  103. package/dist/tools/pdf/markdown.d.ts +26 -0
  104. package/dist/tools/pdf/markdown.js +275 -0
  105. package/dist/tools/pdf/utils.d.ts +12 -0
  106. package/dist/tools/pdf/utils.js +34 -0
  107. package/dist/tools/process.d.ts +3 -0
  108. package/dist/tools/process.js +56 -0
  109. package/dist/tools/prompts.d.ts +28 -0
  110. package/dist/tools/prompts.js +259 -0
  111. package/dist/tools/schemas.d.ts +454 -0
  112. package/dist/tools/schemas.js +242 -0
  113. package/dist/tools/usage.d.ts +5 -0
  114. package/dist/tools/usage.js +24 -0
  115. package/dist/types.d.ts +83 -0
  116. package/dist/types.js +1 -0
  117. package/dist/ui/config-editor/config-editor-runtime.js +150 -0
  118. package/dist/ui/config-editor/index.html +13 -0
  119. package/dist/ui/config-editor/src/app.d.ts +43 -0
  120. package/dist/ui/config-editor/src/app.js +831 -0
  121. package/dist/ui/config-editor/src/array-modal.d.ts +19 -0
  122. package/dist/ui/config-editor/src/array-modal.js +185 -0
  123. package/dist/ui/config-editor/src/main.d.ts +1 -0
  124. package/dist/ui/config-editor/src/main.js +2 -0
  125. package/dist/ui/config-editor/styles.css +587 -0
  126. package/dist/ui/contracts.d.ts +14 -0
  127. package/dist/ui/contracts.js +21 -0
  128. package/dist/ui/file-preview/index.html +16 -0
  129. package/dist/ui/file-preview/preview-runtime.js +469 -0
  130. package/dist/ui/file-preview/shared/preview-file-types.d.ts +5 -0
  131. package/dist/ui/file-preview/shared/preview-file-types.js +59 -0
  132. package/dist/ui/file-preview/src/app.d.ts +4 -0
  133. package/dist/ui/file-preview/src/app.js +680 -0
  134. package/dist/ui/file-preview/src/components/code-viewer.d.ts +6 -0
  135. package/dist/ui/file-preview/src/components/code-viewer.js +73 -0
  136. package/dist/ui/file-preview/src/components/highlighting.d.ts +2 -0
  137. package/dist/ui/file-preview/src/components/highlighting.js +54 -0
  138. package/dist/ui/file-preview/src/components/html-renderer.d.ts +5 -0
  139. package/dist/ui/file-preview/src/components/html-renderer.js +47 -0
  140. package/dist/ui/file-preview/src/components/markdown-renderer.d.ts +1 -0
  141. package/dist/ui/file-preview/src/components/markdown-renderer.js +59 -0
  142. package/dist/ui/file-preview/src/directory-controller.d.ts +8 -0
  143. package/dist/ui/file-preview/src/directory-controller.js +240 -0
  144. package/dist/ui/file-preview/src/document-layout.d.ts +20 -0
  145. package/dist/ui/file-preview/src/document-layout.js +109 -0
  146. package/dist/ui/file-preview/src/document-outline.d.ts +17 -0
  147. package/dist/ui/file-preview/src/document-outline.js +97 -0
  148. package/dist/ui/file-preview/src/document-workspace.d.ts +19 -0
  149. package/dist/ui/file-preview/src/document-workspace.js +33 -0
  150. package/dist/ui/file-preview/src/file-type-handlers.d.ts +10 -0
  151. package/dist/ui/file-preview/src/file-type-handlers.js +109 -0
  152. package/dist/ui/file-preview/src/host/external-actions.d.ts +8 -0
  153. package/dist/ui/file-preview/src/host/external-actions.js +55 -0
  154. package/dist/ui/file-preview/src/host/selection-context.d.ts +9 -0
  155. package/dist/ui/file-preview/src/host/selection-context.js +106 -0
  156. package/dist/ui/file-preview/src/image-preview.d.ts +3 -0
  157. package/dist/ui/file-preview/src/image-preview.js +21 -0
  158. package/dist/ui/file-preview/src/main.d.ts +1 -0
  159. package/dist/ui/file-preview/src/main.js +5 -0
  160. package/dist/ui/file-preview/src/markdown/conflict-dialog.d.ts +40 -0
  161. package/dist/ui/file-preview/src/markdown/conflict-dialog.js +163 -0
  162. package/dist/ui/file-preview/src/markdown/controller.d.ts +44 -0
  163. package/dist/ui/file-preview/src/markdown/controller.js +1043 -0
  164. package/dist/ui/file-preview/src/markdown/editor.d.ts +131 -0
  165. package/dist/ui/file-preview/src/markdown/editor.js +1479 -0
  166. package/dist/ui/file-preview/src/markdown/linking.d.ts +16 -0
  167. package/dist/ui/file-preview/src/markdown/linking.js +228 -0
  168. package/dist/ui/file-preview/src/markdown/outline.d.ts +2 -0
  169. package/dist/ui/file-preview/src/markdown/outline.js +16 -0
  170. package/dist/ui/file-preview/src/markdown/parser.d.ts +30 -0
  171. package/dist/ui/file-preview/src/markdown/parser.js +38 -0
  172. package/dist/ui/file-preview/src/markdown/preview.d.ts +1 -0
  173. package/dist/ui/file-preview/src/markdown/preview.js +20 -0
  174. package/dist/ui/file-preview/src/markdown/slugify.d.ts +3 -0
  175. package/dist/ui/file-preview/src/markdown/slugify.js +31 -0
  176. package/dist/ui/file-preview/src/markdown/utils.d.ts +1 -0
  177. package/dist/ui/file-preview/src/markdown/utils.js +15 -0
  178. package/dist/ui/file-preview/src/model.d.ts +35 -0
  179. package/dist/ui/file-preview/src/model.js +1 -0
  180. package/dist/ui/file-preview/src/panel-actions.d.ts +17 -0
  181. package/dist/ui/file-preview/src/panel-actions.js +182 -0
  182. package/dist/ui/file-preview/src/path-utils.d.ts +6 -0
  183. package/dist/ui/file-preview/src/path-utils.js +64 -0
  184. package/dist/ui/file-preview/src/payload-utils.d.ts +11 -0
  185. package/dist/ui/file-preview/src/payload-utils.js +119 -0
  186. package/dist/ui/file-preview/src/types.d.ts +1 -0
  187. package/dist/ui/file-preview/src/types.js +1 -0
  188. package/dist/ui/file-preview/styles.css +1631 -0
  189. package/dist/ui/resources.d.ts +28 -0
  190. package/dist/ui/resources.js +86 -0
  191. package/dist/ui/shared/compact-row.d.ts +11 -0
  192. package/dist/ui/shared/compact-row.js +18 -0
  193. package/dist/ui/shared/escape-html.d.ts +4 -0
  194. package/dist/ui/shared/escape-html.js +11 -0
  195. package/dist/ui/shared/host-context.d.ts +15 -0
  196. package/dist/ui/shared/host-context.js +51 -0
  197. package/dist/ui/shared/tool-bridge.d.ts +30 -0
  198. package/dist/ui/shared/tool-bridge.js +137 -0
  199. package/dist/ui/shared/tool-shell.d.ts +25 -0
  200. package/dist/ui/shared/tool-shell.js +107 -0
  201. package/dist/ui/shared/ui-event-tracker.d.ts +9 -0
  202. package/dist/ui/shared/ui-event-tracker.js +27 -0
  203. package/dist/ui/shared/widget-state.d.ts +33 -0
  204. package/dist/ui/shared/widget-state.js +158 -0
  205. package/dist/utils/ab-test.d.ts +12 -0
  206. package/dist/utils/ab-test.js +104 -0
  207. package/dist/utils/capture.d.ts +35 -0
  208. package/dist/utils/capture.js +481 -0
  209. package/dist/utils/dockerPrompt.d.ts +17 -0
  210. package/dist/utils/dockerPrompt.js +53 -0
  211. package/dist/utils/feature-flags.d.ts +59 -0
  212. package/dist/utils/feature-flags.js +215 -0
  213. package/dist/utils/files/base.d.ts +173 -0
  214. package/dist/utils/files/base.js +5 -0
  215. package/dist/utils/files/binary.d.ts +21 -0
  216. package/dist/utils/files/binary.js +65 -0
  217. package/dist/utils/files/docx.d.ts +47 -0
  218. package/dist/utils/files/docx.js +679 -0
  219. package/dist/utils/files/excel.d.ts +24 -0
  220. package/dist/utils/files/excel.js +437 -0
  221. package/dist/utils/files/factory.d.ts +41 -0
  222. package/dist/utils/files/factory.js +113 -0
  223. package/dist/utils/files/image.d.ts +21 -0
  224. package/dist/utils/files/image.js +78 -0
  225. package/dist/utils/files/index.d.ts +10 -0
  226. package/dist/utils/files/index.js +13 -0
  227. package/dist/utils/files/pdf.d.ts +32 -0
  228. package/dist/utils/files/pdf.js +142 -0
  229. package/dist/utils/files/text.d.ts +63 -0
  230. package/dist/utils/files/text.js +370 -0
  231. package/dist/utils/fuzzySearchLogger.d.ts +30 -0
  232. package/dist/utils/fuzzySearchLogger.js +126 -0
  233. package/dist/utils/lineEndingHandler.d.ts +21 -0
  234. package/dist/utils/lineEndingHandler.js +77 -0
  235. package/dist/utils/logger.d.ts +32 -0
  236. package/dist/utils/logger.js +72 -0
  237. package/dist/utils/mcp-ui-ab-test.d.ts +13 -0
  238. package/dist/utils/mcp-ui-ab-test.js +62 -0
  239. package/dist/utils/open-browser.d.ts +9 -0
  240. package/dist/utils/open-browser.js +42 -0
  241. package/dist/utils/process-detection.d.ts +23 -0
  242. package/dist/utils/process-detection.js +150 -0
  243. package/dist/utils/ripgrep-resolver.d.ts +9 -0
  244. package/dist/utils/ripgrep-resolver.js +74 -0
  245. package/dist/utils/system-info.d.ts +71 -0
  246. package/dist/utils/system-info.js +712 -0
  247. package/dist/utils/toolHistory.d.ts +86 -0
  248. package/dist/utils/toolHistory.js +257 -0
  249. package/dist/utils/trackTools.d.ts +6 -0
  250. package/dist/utils/trackTools.js +54 -0
  251. package/dist/utils/unsupportedParams.d.ts +24 -0
  252. package/dist/utils/unsupportedParams.js +66 -0
  253. package/dist/utils/usageTracker.d.ts +133 -0
  254. package/dist/utils/usageTracker.js +477 -0
  255. package/dist/utils/welcome-onboarding.d.ts +11 -0
  256. package/dist/utils/welcome-onboarding.js +99 -0
  257. package/dist/utils/withTimeout.d.ts +28 -0
  258. package/dist/utils/withTimeout.js +85 -0
  259. package/dist/version.d.ts +1 -0
  260. package/dist/version.js +1 -0
  261. package/package.json +131 -0
@@ -0,0 +1,947 @@
1
+ import fs from "fs/promises";
2
+ import path from "path";
3
+ import os from 'os';
4
+ import fetch from 'cross-fetch';
5
+ import { execFile } from 'child_process';
6
+ import { promisify } from 'util';
7
+ import { capture } from '../utils/capture.js';
8
+ import { withTimeout, runWithAbortableTimeout } from '../utils/withTimeout.js';
9
+ import { configManager } from '../config-manager.js';
10
+ import { getFileHandler, TextFileHandler } from '../utils/files/index.js';
11
+ import { isPdfFile } from "./mime-types.js";
12
+ import { parsePdfToMarkdown, editPdf, parseMarkdownToPdf } from './pdf/index.js';
13
+ // CONSTANTS SECTION - Consolidate all timeouts and thresholds
14
+ const FILE_OPERATION_TIMEOUTS = {
15
+ PATH_VALIDATION: 10000, // 10 seconds
16
+ URL_FETCH: 30000, // 30 seconds
17
+ FILE_READ: 30000, // 30 seconds
18
+ };
19
+ // Cap file read operations at 3 minutes. The MCP client's hard per-call limit
20
+ // is ~4 minutes; timing out at 3m lets us abort the underlying fs op and return
21
+ // a useful error (e.g. the cloud-storage guidance in buildPermissionError)
22
+ // BEFORE the client gives up with an opaque "No result received after 4
23
+ // minutes". Paired with runWithAbortableTimeout so the read is actually
24
+ // cancelled (fd/thread released), not just abandoned.
25
+ export const READ_OPERATION_TIMEOUT_MS = 3 * 60 * 1000; // 3 minutes
26
+ const FILE_SIZE_LIMITS = {
27
+ LINE_COUNT_LIMIT: 10 * 1024 * 1024, // 10MB for line counting
28
+ };
29
+ // UTILITY FUNCTIONS - Eliminate duplication
30
+ /**
31
+ * Get MIME type information for a file
32
+ * @param filePath Path to the file
33
+ * @returns Object with mimeType and isImage properties
34
+ */
35
+ async function getMimeTypeInfo(filePath) {
36
+ const { getMimeType, isImageFile, isPdfFile } = await import('./mime-types.js');
37
+ const mimeType = getMimeType(filePath);
38
+ const isImage = isImageFile(mimeType);
39
+ const isPdf = isPdfFile(mimeType);
40
+ return { mimeType, isImage, isPdf };
41
+ }
42
+ /**
43
+ * Get file extension for telemetry purposes
44
+ * @param filePath Path to the file
45
+ * @returns Lowercase file extension
46
+ */
47
+ function getFileExtension(filePath) {
48
+ return path.extname(filePath).toLowerCase();
49
+ }
50
+ /**
51
+ * Get default read length from configuration
52
+ * @returns Default number of lines to read
53
+ */
54
+ async function getDefaultReadLength() {
55
+ const config = await configManager.getConfig();
56
+ return config.fileReadLineLimit ?? 1000; // Default to 1000 lines if not set
57
+ }
58
+ /**
59
+ * Returns a helpful error message when a file operation fails with a permission
60
+ * or timeout error.
61
+ *
62
+ * Lists all common causes without path-based detection — the AI receiving this
63
+ * error should inspect the path and inform the user which cause is most likely
64
+ * (e.g. cloud storage folder, network drive, system file, locked file, etc.)
65
+ */
66
+ function buildPermissionError(filePath, errCode) {
67
+ const isMac = process.platform === 'darwin';
68
+ const isTimeout = errCode === 'ETIMEDOUT';
69
+ const lines = [
70
+ `Cannot read file — ${isTimeout ? 'operation timed out' : 'permission denied'} (${errCode}).`,
71
+ `Path: ${filePath}`,
72
+ ``,
73
+ `[AI: based on the path above, determine which cause is most likely and explain it to the user.]`,
74
+ ``,
75
+ `Possible causes and fixes:`,
76
+ ` 1. File is in cloud storage (Google Drive / iCloud / Dropbox / OneDrive) but not downloaded locally.`,
77
+ ` → Right-click the file and choose "Download Now", "Make Available Offline", or "Keep on This Device".`,
78
+ ` 2. Cloud storage app is not running or not signed in.`,
79
+ ` → Open your cloud storage app and make sure it is syncing.`,
80
+ ` 3. File is on a network drive or virtual filesystem that is currently unavailable.`,
81
+ ` → Check that the network share or drive is mounted and accessible.`,
82
+ ` 4. File has restricted permissions (e.g. system file, locked by another process, or chmod 000).`,
83
+ ` → Check file permissions or close any app that may have the file open.`,
84
+ ` 5. The app does not have permission to access this location (macOS Full Disk Access).`,
85
+ ];
86
+ if (isMac) {
87
+ lines.push(` → Go to System Settings → Privacy & Security → Full Disk Access and enable Claude.`);
88
+ lines.push(` → To open that pane directly, run in terminal:`);
89
+ lines.push(` open "x-apple.systempreferences:com.apple.preference.security?Privacy_AllFiles"`);
90
+ lines.push(` Then find "Claude" in the list and enable the toggle next to it.`);
91
+ }
92
+ else {
93
+ lines.push(` → Check that the app has permission to access this file location.`);
94
+ }
95
+ return new Error(lines.join('\n'));
96
+ }
97
+ // Initialize allowed directories from configuration
98
+ async function getAllowedDirs() {
99
+ try {
100
+ let allowedDirectories;
101
+ const config = await configManager.getConfig();
102
+ if (config.allowedDirectories && Array.isArray(config.allowedDirectories)) {
103
+ allowedDirectories = config.allowedDirectories;
104
+ }
105
+ else {
106
+ // Fall back to default directories if not configured
107
+ allowedDirectories = [
108
+ os.homedir() // User's home directory
109
+ ];
110
+ // Update config with default
111
+ await configManager.setValue('allowedDirectories', allowedDirectories);
112
+ }
113
+ return allowedDirectories;
114
+ }
115
+ catch (error) {
116
+ console.error('Failed to initialize allowed directories:', error);
117
+ // Keep the default permissive path
118
+ }
119
+ return [];
120
+ }
121
+ // Normalize all paths consistently
122
+ function normalizePath(p) {
123
+ return path.normalize(expandHome(p)).toLowerCase();
124
+ }
125
+ function expandHome(filepath) {
126
+ if (filepath.startsWith('~/') || filepath === '~') {
127
+ return path.join(os.homedir(), filepath.slice(1));
128
+ }
129
+ return filepath;
130
+ }
131
+ /**
132
+ * Recursively validates parent directories until it finds a valid one
133
+ * This function handles the case where we need to create nested directories
134
+ * and we need to check if any of the parent directories exist
135
+ *
136
+ * @param directoryPath The path to validate
137
+ * @returns Promise<boolean> True if a valid parent directory was found
138
+ */
139
+ async function validateParentDirectories(directoryPath) {
140
+ const parentDir = path.dirname(directoryPath);
141
+ // Base case: we've reached the root or the same directory (shouldn't happen normally)
142
+ if (parentDir === directoryPath || parentDir === path.dirname(parentDir)) {
143
+ return false;
144
+ }
145
+ try {
146
+ // Check if the parent directory exists
147
+ await fs.realpath(parentDir);
148
+ return true;
149
+ }
150
+ catch {
151
+ // Parent doesn't exist, recursively check its parent
152
+ return validateParentDirectories(parentDir);
153
+ }
154
+ }
155
+ /**
156
+ * Checks if a path is within any of the allowed directories
157
+ *
158
+ * @param pathToCheck Path to check
159
+ * @returns boolean True if path is allowed
160
+ */
161
+ async function isPathAllowed(pathToCheck) {
162
+ // If root directory is allowed, all paths are allowed
163
+ const allowedDirectories = await getAllowedDirs();
164
+ if (allowedDirectories.includes('/') || allowedDirectories.length === 0) {
165
+ return true;
166
+ }
167
+ let normalizedPathToCheck = normalizePath(pathToCheck);
168
+ if (normalizedPathToCheck.slice(-1) === path.sep) {
169
+ normalizedPathToCheck = normalizedPathToCheck.slice(0, -1);
170
+ }
171
+ // Check if the path is within any allowed directory
172
+ const isAllowed = allowedDirectories.some(allowedDir => {
173
+ let normalizedAllowedDir = normalizePath(allowedDir);
174
+ if (normalizedAllowedDir.slice(-1) === path.sep) {
175
+ normalizedAllowedDir = normalizedAllowedDir.slice(0, -1);
176
+ }
177
+ // Check if path is exactly the allowed directory
178
+ if (normalizedPathToCheck === normalizedAllowedDir) {
179
+ return true;
180
+ }
181
+ // Check if path is a subdirectory of the allowed directory
182
+ // Make sure to add a separator to prevent partial directory name matches
183
+ // e.g. /home/user vs /home/username
184
+ const subdirCheck = normalizedPathToCheck.startsWith(normalizedAllowedDir + path.sep);
185
+ if (subdirCheck) {
186
+ return true;
187
+ }
188
+ // If allowed directory is the root (C:\ on Windows), allow access to the entire drive
189
+ if (normalizedAllowedDir === 'c:' && process.platform === 'win32') {
190
+ return normalizedPathToCheck.startsWith('c:');
191
+ }
192
+ return false;
193
+ });
194
+ return isAllowed;
195
+ }
196
+ /**
197
+ * Validates a path to ensure it can be accessed or created.
198
+ * For existing paths, returns the real path (resolving symlinks).
199
+ * For non-existent paths, validates parent directories to ensure they exist.
200
+ *
201
+ * @param requestedPath The path to validate
202
+ * @returns Promise<string> The validated path
203
+ * @throws Error if the path or its parent directories don't exist or if the path is not allowed
204
+ */
205
+ export async function validatePath(requestedPath) {
206
+ const validationOperation = async () => {
207
+ // Expand home directory if present
208
+ const expandedPath = expandHome(requestedPath);
209
+ // Convert to absolute path
210
+ const absoluteOriginal = path.isAbsolute(expandedPath)
211
+ ? path.resolve(expandedPath)
212
+ : path.resolve(process.cwd(), expandedPath);
213
+ // Attempt to resolve symlinks to get the real path
214
+ // This will succeed if the path exists and all symlinks in the chain are valid
215
+ // It will fail with ENOENT if:
216
+ // - The path itself doesn't exist, OR
217
+ // - A symlink exists but points to a non-existent target (broken symlink)
218
+ let resolvedRealPath = null;
219
+ try {
220
+ resolvedRealPath = await fs.realpath(absoluteOriginal, { encoding: 'utf8' });
221
+ }
222
+ catch (error) {
223
+ const err = error;
224
+ // Only throw for non-ENOENT errors (e.g., permission denied, I/O errors)
225
+ if (!err.code || err.code !== 'ENOENT') {
226
+ capture('server_path_realpath_error', {
227
+ error: err.message,
228
+ path: absoluteOriginal
229
+ });
230
+ throw new Error(`Failed to resolve symlink for path: ${absoluteOriginal}. Error: ${err.message}`);
231
+ }
232
+ // SECURITY FIX: When the full path doesn't exist (e.g., writing a new file),
233
+ // resolve the parent directory to detect symlinks in the path chain.
234
+ // Without this, an attacker could create a symlink inside an allowed directory
235
+ // pointing to a restricted location, then write to a non-existent file through
236
+ // that symlink — bypassing the directory restriction check.
237
+ try {
238
+ const parentDir = path.dirname(absoluteOriginal);
239
+ const resolvedParent = await fs.realpath(parentDir, { encoding: 'utf8' });
240
+ const basename = path.basename(absoluteOriginal);
241
+ resolvedRealPath = path.join(resolvedParent, basename);
242
+ }
243
+ catch {
244
+ // Parent also doesn't exist — walk up the tree to find
245
+ // the deepest existing ancestor and resolve it
246
+ let current = absoluteOriginal;
247
+ let remaining = [];
248
+ while (true) {
249
+ const parent = path.dirname(current);
250
+ if (parent === current)
251
+ break; // reached filesystem root
252
+ remaining.unshift(path.basename(current));
253
+ current = parent;
254
+ try {
255
+ const resolvedAncestor = await fs.realpath(current, { encoding: 'utf8' });
256
+ resolvedRealPath = path.join(resolvedAncestor, ...remaining);
257
+ break;
258
+ }
259
+ catch {
260
+ // keep walking up
261
+ }
262
+ }
263
+ }
264
+ }
265
+ const pathForNextCheck = resolvedRealPath ?? absoluteOriginal;
266
+ // Direct Gateway device workers are already authenticated and policy-gated by
267
+ // the central gateway. In that dedicated remote context, do not inherit a
268
+ // workstation's historical Desktop Commander directory allowlist. An optional
269
+ // MCP_GATEWAY_ALLOWED_ROOTS restriction is enforced by GatewayToolAdapter before
270
+ // calls reach this filesystem layer.
271
+ const remoteGatewayContext = process.env.MCP_DEVICE_REMOTE === 'true';
272
+ if (!remoteGatewayContext && !(await isPathAllowed(pathForNextCheck))) {
273
+ capture('server_path_validation_error', {
274
+ error: 'Path not allowed',
275
+ allowedDirsCount: (await getAllowedDirs()).length
276
+ });
277
+ throw new Error(`Path not allowed: ${requestedPath}. Must be within one of these directories: ${(await getAllowedDirs()).join(', ')}`);
278
+ }
279
+ // SECURITY: Always return the resolved path (with symlinks resolved) so that
280
+ // all subsequent file operations (read, write, mkdir, etc.) operate on the
281
+ // canonical target, not on a symlink that could point outside allowed directories.
282
+ // pathForNextCheck already holds resolvedRealPath ?? absoluteOriginal from above.
283
+ // Check if path exists
284
+ try {
285
+ // fs.stat() will automatically follow symlinks, so we get existence info
286
+ await fs.stat(pathForNextCheck);
287
+ return pathForNextCheck;
288
+ }
289
+ catch (error) {
290
+ // Path doesn't exist - validate parent directories
291
+ if (await validateParentDirectories(pathForNextCheck)) {
292
+ // Return the resolved path if a valid parent exists
293
+ // This will be used for folder creation and many other file operations
294
+ return pathForNextCheck;
295
+ }
296
+ // If no valid parent found, return the resolved path anyway
297
+ return pathForNextCheck;
298
+ }
299
+ };
300
+ // Execute with timeout
301
+ const result = await withTimeout(validationOperation(), FILE_OPERATION_TIMEOUTS.PATH_VALIDATION, `Path validation operation`, // Generic name for telemetry
302
+ null);
303
+ if (result === null) {
304
+ // Keep original path in error for AI but a generic message for telemetry
305
+ capture('server_path_validation_timeout', {
306
+ timeoutMs: FILE_OPERATION_TIMEOUTS.PATH_VALIDATION
307
+ });
308
+ throw new Error(`Path validation failed for path: ${requestedPath}`);
309
+ }
310
+ return result;
311
+ }
312
+ /**
313
+ * Read file content from a URL
314
+ * @param url URL to fetch content from
315
+ * @returns File content or file result with metadata
316
+ */
317
+ export async function readFileFromUrl(url) {
318
+ // Import the MIME type utilities
319
+ const { isImageFile } = await import('./mime-types.js');
320
+ // Set up fetch with timeout
321
+ const controller = new AbortController();
322
+ const timeoutId = setTimeout(() => controller.abort(), FILE_OPERATION_TIMEOUTS.URL_FETCH);
323
+ try {
324
+ const response = await fetch(url, {
325
+ signal: controller.signal
326
+ });
327
+ // Clear the timeout since fetch completed
328
+ clearTimeout(timeoutId);
329
+ if (!response.ok) {
330
+ throw new Error(`HTTP error! Status: ${response.status}`);
331
+ }
332
+ // Get MIME type from Content-Type header or infer from URL
333
+ const contentType = response.headers.get('content-type') || 'text/plain';
334
+ const isImage = isImageFile(contentType);
335
+ const isPdf = isPdfFile(contentType) || url.toLowerCase().endsWith('.pdf');
336
+ // NEW: Add PDF handling before image check
337
+ if (isPdf) {
338
+ // Use URL directly - pdfreader handles URL downloads internally
339
+ const pdfResult = await parsePdfToMarkdown(url);
340
+ return {
341
+ content: "",
342
+ mimeType: 'text/plain',
343
+ metadata: {
344
+ isImage: false,
345
+ isPdf: true,
346
+ author: pdfResult.metadata.author,
347
+ title: pdfResult.metadata.title,
348
+ totalPages: pdfResult.metadata.totalPages,
349
+ pages: pdfResult.pages
350
+ }
351
+ };
352
+ }
353
+ else if (isImage) {
354
+ // For images, convert to base64
355
+ const buffer = await response.arrayBuffer();
356
+ const content = Buffer.from(buffer).toString('base64');
357
+ return { content, mimeType: contentType, metadata: { isImage } };
358
+ }
359
+ else {
360
+ // For text content
361
+ const content = await response.text();
362
+ return { content, mimeType: contentType, metadata: { isImage } };
363
+ }
364
+ }
365
+ catch (error) {
366
+ // Clear the timeout to prevent memory leaks
367
+ clearTimeout(timeoutId);
368
+ // Return error information instead of throwing
369
+ const errorMessage = error instanceof DOMException && error.name === 'AbortError'
370
+ ? `URL fetch timed out after ${FILE_OPERATION_TIMEOUTS.URL_FETCH}ms: ${url}`
371
+ : `Failed to fetch URL: ${error instanceof Error ? error.message : String(error)}`;
372
+ throw new Error(errorMessage);
373
+ }
374
+ }
375
+ /**
376
+ * Read file content from the local filesystem
377
+ * @param filePath Path to the file
378
+ * @param options Read options (offset, length, sheet, range)
379
+ * @returns File content or file result with metadata
380
+ */
381
+ export async function readFileFromDisk(filePath, options) {
382
+ const { offset = 0, sheet, range } = options ?? {};
383
+ let { length } = options ?? {};
384
+ // Add validation for required parameters
385
+ if (!filePath || typeof filePath !== 'string') {
386
+ throw new Error('Invalid file path provided');
387
+ }
388
+ // Get default length from config if not provided
389
+ if (length === undefined) {
390
+ length = await getDefaultReadLength();
391
+ }
392
+ const validPath = await validatePath(filePath);
393
+ // Get file extension for telemetry
394
+ const fileExtension = getFileExtension(validPath);
395
+ // Check if path is a directory — return listing instead of EISDIR error
396
+ try {
397
+ const stats = await fs.stat(validPath);
398
+ if (stats.isDirectory()) {
399
+ const dirListOp = async () => {
400
+ const entries = await listDirectory(validPath);
401
+ const listing = entries.join('\n');
402
+ return {
403
+ content: `This is a directory, not a file. Use the list_directory tool instead of read_file for directories.\n\n${listing}`,
404
+ mimeType: 'text/plain',
405
+ metadata: { isImage: false, isDirectory: true }
406
+ };
407
+ };
408
+ const dirResult = await withTimeout(dirListOp(), FILE_OPERATION_TIMEOUTS.FILE_READ, 'Directory listing fallback', null);
409
+ if (dirResult === null) {
410
+ throw new Error(`Directory listing timed out for: ${filePath}`);
411
+ }
412
+ return dirResult;
413
+ }
414
+ }
415
+ catch (error) {
416
+ // If stat itself failed, fall through to the read path which will produce a proper error.
417
+ // But if this was a directory-listing error, re-throw — don't let it fall into the file-read path.
418
+ const err = error;
419
+ if (err.message?.includes('Directory listing') || err.message?.includes('list_directory')) {
420
+ throw error;
421
+ }
422
+ // stat() failed (e.g. ENOENT) — fall through to the read path below
423
+ }
424
+ // Check file size before attempting to read
425
+ try {
426
+ const stats = await fs.stat(validPath);
427
+ // Capture file extension in telemetry without capturing the file path
428
+ capture('server_read_file', {
429
+ fileExtension: fileExtension,
430
+ offset: offset,
431
+ length: length,
432
+ fileSize: stats.size
433
+ });
434
+ }
435
+ catch (error) {
436
+ console.error('error catch ' + error);
437
+ const errorMessage = error instanceof Error ? error.message : String(error);
438
+ capture('server_read_file_error', { error: errorMessage, fileExtension: fileExtension });
439
+ // If we can't stat the file, continue anyway and let the read operation handle errors
440
+ }
441
+ // Read under an abortable timeout so a hung/stalled read is cancelled
442
+ // (fd/thread freed) rather than leaked until the OS call returns.
443
+ const readOperation = async (signal) => {
444
+ // Get appropriate handler for this file type (async - includes binary detection)
445
+ const handler = await getFileHandler(validPath);
446
+ // Use handler to read the file
447
+ const result = await handler.read(validPath, {
448
+ offset,
449
+ length,
450
+ sheet,
451
+ range,
452
+ includeStatusMessage: true,
453
+ signal
454
+ });
455
+ // Return with content as string
456
+ // For images: content is already base64-encoded string from handler
457
+ // For text: content may be string or Buffer, convert to UTF-8 string
458
+ let content;
459
+ if (typeof result.content === 'string') {
460
+ content = result.content;
461
+ }
462
+ else if (result.metadata?.isImage) {
463
+ // Image buffer should be base64 encoded, not UTF-8 converted
464
+ content = result.content.toString('base64');
465
+ }
466
+ else {
467
+ content = result.content.toString('utf8');
468
+ }
469
+ return {
470
+ content,
471
+ mimeType: result.mimeType,
472
+ metadata: result.metadata
473
+ };
474
+ };
475
+ // Execute with a 3-minute, cancellable timeout
476
+ let result;
477
+ try {
478
+ result = await runWithAbortableTimeout((signal) => readOperation(signal), READ_OPERATION_TIMEOUT_MS, `Read file operation for ${filePath}`);
479
+ }
480
+ catch (error) {
481
+ const err = error;
482
+ // runWithAbortableTimeout rejects with an Error whose .code is 'ETIMEDOUT'
483
+ // on timeout; fs rejects with EPERM/EACCES. Map all to the guidance error.
484
+ if (err.code === 'EPERM' || err.code === 'EACCES' || err.code === 'ETIMEDOUT') {
485
+ throw buildPermissionError(filePath, err.code);
486
+ }
487
+ throw error;
488
+ }
489
+ if (result == null) {
490
+ // Handles the impossible case where withTimeout resolves to null instead of throwing
491
+ throw new Error('Failed to read the file');
492
+ }
493
+ return result;
494
+ }
495
+ /**
496
+ * Read a file from either the local filesystem or a URL
497
+ * @param filePath Path to the file or URL
498
+ * @param options Read options (isUrl, offset, length, sheet, range)
499
+ * @returns File content or file result with metadata
500
+ */
501
+ export async function readFile(filePath, options) {
502
+ const { isUrl, offset, length, sheet, range } = options ?? {};
503
+ return isUrl
504
+ ? readFileFromUrl(filePath)
505
+ : readFileFromDisk(filePath, { offset, length, sheet, range });
506
+ }
507
+ /**
508
+ * Read file content without status messages for internal operations
509
+ * This function preserves exact file content including original line endings,
510
+ * which is essential for edit operations that need to maintain file formatting.
511
+ * @param filePath Path to the file
512
+ * @param offset Starting line number to read from (default: 0)
513
+ * @param length Maximum number of lines to read (default: from config or 1000)
514
+ * @returns File content without status headers, with preserved line endings
515
+ */
516
+ export async function readFileInternal(filePath, offset = 0, length) {
517
+ // Get default length from config if not provided
518
+ if (length === undefined) {
519
+ length = await getDefaultReadLength();
520
+ }
521
+ const validPath = await validatePath(filePath);
522
+ // Get file extension and MIME type
523
+ const fileExtension = getFileExtension(validPath);
524
+ const { mimeType, isImage } = await getMimeTypeInfo(validPath);
525
+ if (isImage) {
526
+ throw new Error('Cannot read image files as text for internal operations');
527
+ }
528
+ // IMPORTANT: For internal operations (especially edit operations), we must
529
+ // preserve exact file content including original line endings.
530
+ // We cannot use readline-based reading as it strips line endings.
531
+ // Read entire file content preserving line endings, under a 3-minute,
532
+ // cancellable timeout so an edit on a stalled/cloud path can't hang forever
533
+ // (previously this read had no timeout at all).
534
+ const content = await runWithAbortableTimeout((signal) => fs.readFile(validPath, { encoding: 'utf8', signal }), READ_OPERATION_TIMEOUT_MS, `Internal read for ${filePath}`);
535
+ // If we need to apply offset/length, do it while preserving line endings
536
+ if (offset === 0 && length >= Number.MAX_SAFE_INTEGER) {
537
+ // Most common case for edit operations: read entire file
538
+ return content;
539
+ }
540
+ // Handle offset/length by splitting on line boundaries while preserving line endings
541
+ const lines = TextFileHandler.splitLinesPreservingEndings(content);
542
+ // Apply offset and length
543
+ const selectedLines = lines.slice(offset, offset + length);
544
+ // Join back together (this preserves the original line endings)
545
+ return selectedLines.join('');
546
+ }
547
+ export async function writeFile(filePath, content, mode = 'rewrite') {
548
+ const validPath = await validatePath(filePath);
549
+ // Get file extension for telemetry
550
+ const fileExtension = getFileExtension(validPath);
551
+ // Calculate content metrics
552
+ const contentBytes = Buffer.from(content).length;
553
+ const lineCount = TextFileHandler.countLines(content);
554
+ // Capture file extension and operation details in telemetry without capturing the file path
555
+ capture('server_write_file', {
556
+ fileExtension: fileExtension,
557
+ mode: mode,
558
+ contentBytes: contentBytes,
559
+ lineCount: lineCount
560
+ });
561
+ // Get appropriate handler for this file type (async - includes binary detection)
562
+ const handler = await getFileHandler(validPath);
563
+ // Use handler to write the file
564
+ await handler.write(validPath, content, mode);
565
+ }
566
+ export async function readMultipleFiles(paths) {
567
+ return Promise.all(paths.map(async (filePath) => {
568
+ try {
569
+ const validPath = await validatePath(filePath);
570
+ const fileResult = await readFile(validPath);
571
+ // Handle content conversion properly for images vs text
572
+ let content;
573
+ if (typeof fileResult.content === 'string') {
574
+ content = fileResult.content;
575
+ }
576
+ else if (fileResult.metadata?.isImage) {
577
+ content = fileResult.content.toString('base64');
578
+ }
579
+ else {
580
+ content = fileResult.content.toString('utf8');
581
+ }
582
+ return {
583
+ path: filePath,
584
+ content,
585
+ mimeType: fileResult.mimeType,
586
+ isImage: fileResult.metadata?.isImage ?? false,
587
+ isPdf: fileResult.metadata?.isPdf ?? false,
588
+ payload: fileResult.metadata?.isPdf ? {
589
+ metadata: {
590
+ author: fileResult.metadata.author,
591
+ title: fileResult.metadata.title,
592
+ totalPages: fileResult.metadata.totalPages ?? 0
593
+ },
594
+ pages: fileResult.metadata.pages ?? []
595
+ } : undefined
596
+ };
597
+ }
598
+ catch (error) {
599
+ const errorMessage = error instanceof Error ? error.message : String(error);
600
+ return {
601
+ path: filePath,
602
+ error: errorMessage
603
+ };
604
+ }
605
+ }));
606
+ }
607
+ export async function createDirectory(dirPath) {
608
+ const validPath = await validatePath(dirPath);
609
+ await fs.mkdir(validPath, { recursive: true });
610
+ }
611
+ export async function listDirectory(dirPath, depth = 2) {
612
+ const validPath = await validatePath(dirPath);
613
+ const results = [];
614
+ const MAX_NESTED_ITEMS = 100; // Maximum items to show per nested directory
615
+ async function listRecursive(currentPath, currentDepth, relativePath = '', isTopLevel = true) {
616
+ if (currentDepth <= 0)
617
+ return;
618
+ let entries;
619
+ try {
620
+ entries = await fs.readdir(currentPath, { withFileTypes: true });
621
+ }
622
+ catch (error) {
623
+ const err = error;
624
+ const displayPath = relativePath || path.basename(currentPath);
625
+ // Distinguish "not found" from "permission denied" so AI and UI get accurate info.
626
+ if (err.code === 'ENOENT') {
627
+ results.push(`[NOT_FOUND] ${displayPath} — path does not exist`);
628
+ }
629
+ else if (err.code === 'EPERM' || err.code === 'EACCES' || err.code === 'ETIMEDOUT') {
630
+ results.push(`[DENIED] ${displayPath} — not accessible (permission denied, cloud-only file, or Full Disk Access not granted)`);
631
+ }
632
+ else {
633
+ results.push(`[DENIED] ${displayPath}`);
634
+ }
635
+ return;
636
+ }
637
+ // Apply filtering for nested directories (not top level)
638
+ const totalEntries = entries.length;
639
+ let entriesToShow = entries;
640
+ let filteredCount = 0;
641
+ if (!isTopLevel && totalEntries > MAX_NESTED_ITEMS) {
642
+ entriesToShow = entries.slice(0, MAX_NESTED_ITEMS);
643
+ filteredCount = totalEntries - MAX_NESTED_ITEMS;
644
+ }
645
+ for (const entry of entriesToShow) {
646
+ const fullPath = path.join(currentPath, entry.name);
647
+ const displayPath = relativePath ? path.join(relativePath, entry.name) : entry.name;
648
+ // Add this entry to results
649
+ results.push(`${entry.isDirectory() ? "[DIR]" : "[FILE]"} ${displayPath}`);
650
+ // If it's a directory and we have depth remaining, recurse
651
+ if (entry.isDirectory() && currentDepth > 1) {
652
+ try {
653
+ // Validate the path before recursing
654
+ await validatePath(fullPath);
655
+ await listRecursive(fullPath, currentDepth - 1, displayPath, false);
656
+ }
657
+ catch (error) {
658
+ // If validation fails or we can't access it, it will be marked as denied
659
+ // when we try to read it in the recursive call
660
+ continue;
661
+ }
662
+ }
663
+ }
664
+ // Add warning message if items were filtered
665
+ if (filteredCount > 0) {
666
+ const displayPath = relativePath || path.basename(currentPath);
667
+ results.push(`[WARNING] ${displayPath}: ${filteredCount} items hidden (showing first ${MAX_NESTED_ITEMS} of ${totalEntries} total)`);
668
+ }
669
+ }
670
+ await listRecursive(validPath, depth, '', true);
671
+ return results;
672
+ }
673
+ export async function moveFile(sourcePath, destinationPath) {
674
+ const validSourcePath = await validatePath(sourcePath);
675
+ const validDestPath = await validatePath(destinationPath);
676
+ await fs.rename(validSourcePath, validDestPath);
677
+ }
678
+ export async function searchFiles(rootPath, pattern) {
679
+ // Use the new search manager for better performance
680
+ // This provides a temporary compatibility layer until we fully migrate to search sessions
681
+ const { searchManager } = await import('../search-manager.js');
682
+ try {
683
+ const result = await searchManager.startSearch({
684
+ rootPath,
685
+ pattern,
686
+ searchType: 'files',
687
+ ignoreCase: true,
688
+ maxResults: 5000, // Higher limit for compatibility
689
+ earlyTermination: true, // Use early termination for better performance
690
+ });
691
+ const sessionId = result.sessionId;
692
+ // Poll for results until complete
693
+ let allResults = [];
694
+ let isComplete = result.isComplete;
695
+ let startTime = Date.now();
696
+ // Add initial results
697
+ for (const searchResult of result.results) {
698
+ if (searchResult.type === 'file') {
699
+ allResults.push(searchResult.file);
700
+ }
701
+ }
702
+ while (!isComplete) {
703
+ await new Promise(resolve => setTimeout(resolve, 100)); // Wait 100ms
704
+ const results = searchManager.readSearchResults(sessionId);
705
+ isComplete = results.isComplete;
706
+ // Add new file paths to results
707
+ for (const searchResult of results.results) {
708
+ if (searchResult.file !== '__LAST_READ_MARKER__' && searchResult.type === 'file') {
709
+ allResults.push(searchResult.file);
710
+ }
711
+ }
712
+ // Safety check to prevent infinite loops (30 second timeout)
713
+ if (Date.now() - startTime > 30000) {
714
+ searchManager.terminateSearch(sessionId);
715
+ break;
716
+ }
717
+ }
718
+ // Log only the count of found files, not their paths
719
+ capture('server_search_files_complete', {
720
+ resultsCount: allResults.length,
721
+ patternLength: pattern.length,
722
+ usedRipgrep: true
723
+ });
724
+ return allResults;
725
+ }
726
+ catch (error) {
727
+ // Fallback to original Node.js implementation if ripgrep fails
728
+ capture('server_search_files_ripgrep_fallback', {
729
+ error: error instanceof Error ? error.message : 'Unknown error'
730
+ });
731
+ return await searchFilesNodeJS(rootPath, pattern);
732
+ }
733
+ }
734
+ // Keep the original Node.js implementation as fallback
735
+ async function searchFilesNodeJS(rootPath, pattern) {
736
+ const results = [];
737
+ async function search(currentPath) {
738
+ let entries;
739
+ try {
740
+ entries = await fs.readdir(currentPath, { withFileTypes: true });
741
+ }
742
+ catch (error) {
743
+ return; // Skip this directory on error
744
+ }
745
+ for (const entry of entries) {
746
+ const fullPath = path.join(currentPath, entry.name);
747
+ try {
748
+ await validatePath(fullPath);
749
+ if (entry.name.toLowerCase().includes(pattern.toLowerCase())) {
750
+ results.push(fullPath);
751
+ }
752
+ if (entry.isDirectory()) {
753
+ await search(fullPath);
754
+ }
755
+ }
756
+ catch (error) {
757
+ continue;
758
+ }
759
+ }
760
+ }
761
+ try {
762
+ // Validate root path before starting search
763
+ const validPath = await validatePath(rootPath);
764
+ await search(validPath);
765
+ // Log only the count of found files, not their paths
766
+ capture('server_search_files_complete', {
767
+ resultsCount: results.length,
768
+ patternLength: pattern.length,
769
+ usedRipgrep: false
770
+ });
771
+ return results;
772
+ }
773
+ catch (error) {
774
+ // For telemetry only - sanitize error info
775
+ capture('server_search_files_error', {
776
+ errorType: error instanceof Error ? error.name : 'Unknown',
777
+ error: 'Error with root path',
778
+ isRootPathError: true
779
+ });
780
+ // Re-throw the original error for the caller
781
+ throw error;
782
+ }
783
+ }
784
+ export async function getFileInfo(filePath) {
785
+ const validPath = await validatePath(filePath);
786
+ // Get fs.stat as a fallback for any missing fields
787
+ const stats = await fs.stat(validPath);
788
+ const fallbackInfo = {
789
+ size: stats.size,
790
+ created: stats.birthtime,
791
+ modified: stats.mtime,
792
+ accessed: stats.atime,
793
+ isDirectory: stats.isDirectory(),
794
+ isFile: stats.isFile(),
795
+ permissions: stats.mode.toString(8).slice(-3),
796
+ fileType: 'text',
797
+ metadata: undefined,
798
+ };
799
+ // Get appropriate handler for this file type (async - includes binary detection)
800
+ const handler = await getFileHandler(validPath);
801
+ // Use handler to get file info, with fallback
802
+ let fileInfo;
803
+ try {
804
+ fileInfo = await handler.getInfo(validPath);
805
+ }
806
+ catch (error) {
807
+ // If handler fails, use fallback stats
808
+ fileInfo = fallbackInfo;
809
+ }
810
+ // Convert to legacy format (for backward compatibility)
811
+ // Use handler values with fallback to fs.stat values for any missing fields
812
+ const info = {
813
+ size: fileInfo.size ?? fallbackInfo.size,
814
+ created: fileInfo.created ?? fallbackInfo.created,
815
+ modified: fileInfo.modified ?? fallbackInfo.modified,
816
+ accessed: fileInfo.accessed ?? fallbackInfo.accessed,
817
+ isDirectory: fileInfo.isDirectory ?? fallbackInfo.isDirectory,
818
+ isFile: fileInfo.isFile ?? fallbackInfo.isFile,
819
+ permissions: fileInfo.permissions ?? fallbackInfo.permissions,
820
+ fileType: fileInfo.fileType ?? fallbackInfo.fileType,
821
+ };
822
+ // Add type-specific metadata from file handler
823
+ if (fileInfo.metadata) {
824
+ // For text files
825
+ if (fileInfo.metadata.lineCount !== undefined) {
826
+ info.lineCount = fileInfo.metadata.lineCount;
827
+ info.lastLine = fileInfo.metadata.lineCount - 1;
828
+ info.appendPosition = fileInfo.metadata.lineCount;
829
+ }
830
+ // For Excel files
831
+ if (fileInfo.metadata.sheets) {
832
+ info.sheets = fileInfo.metadata.sheets;
833
+ info.isExcelFile = true;
834
+ }
835
+ // For images
836
+ if (fileInfo.metadata.isImage) {
837
+ info.isImage = true;
838
+ }
839
+ // For PDF files
840
+ if (fileInfo.metadata.isPdf) {
841
+ info.isPdf = true;
842
+ info.totalPages = fileInfo.metadata.totalPages;
843
+ if (fileInfo.metadata.title)
844
+ info.title = fileInfo.metadata.title;
845
+ if (fileInfo.metadata.author)
846
+ info.author = fileInfo.metadata.author;
847
+ }
848
+ // For binary files
849
+ if (fileInfo.metadata.isBinary) {
850
+ info.isBinary = true;
851
+ }
852
+ }
853
+ return info;
854
+ }
855
+ /**
856
+ * Write content to a PDF file.
857
+ * Can create a new PDF from Markdown string, or modify an existing PDF using operations.
858
+ *
859
+ * @param filePath Path to the output PDF file
860
+ * @param content Markdown string (for creation) or array of operations (for modification)
861
+ * @param options Options for PDF generation or modification. For modification, can include `sourcePdf`.
862
+ */
863
+ export async function writePdf(filePath, content, outputPath, options = {}) {
864
+ const validPath = await validatePath(filePath);
865
+ const fileExtension = getFileExtension(validPath);
866
+ if (typeof content === 'string') {
867
+ // --- PDF CREATION MODE ---
868
+ capture('server_write_pdf', {
869
+ fileExtension: fileExtension,
870
+ contentLength: content.length,
871
+ mode: 'create'
872
+ });
873
+ const pdfBuffer = await parseMarkdownToPdf(content, options);
874
+ // Use outputPath if provided, otherwise overwrite input file
875
+ const targetPath = outputPath ? await validatePath(outputPath) : validPath;
876
+ await fs.writeFile(targetPath, pdfBuffer);
877
+ }
878
+ else if (Array.isArray(content)) {
879
+ // Use outputPath if provided, otherwise overwrite input file
880
+ const targetPath = outputPath ? await validatePath(outputPath) : validPath;
881
+ const operations = [];
882
+ // Validate paths in operations
883
+ for (const o of content) {
884
+ if (o.type === 'insert') {
885
+ if (o.sourcePdfPath) {
886
+ o.sourcePdfPath = await validatePath(o.sourcePdfPath);
887
+ }
888
+ }
889
+ operations.push(o);
890
+ }
891
+ capture('server_write_pdf', {
892
+ fileExtension: fileExtension,
893
+ operationCount: operations.length,
894
+ mode: 'modify',
895
+ deleteCount: operations.filter(op => op.type === 'delete').length,
896
+ insertCount: operations.filter(op => op.type === 'insert').length
897
+ });
898
+ // Perform the PDF editing
899
+ const modifiedPdfBuffer = await editPdf(validPath, operations);
900
+ // Write the modified PDF to the output path
901
+ await fs.writeFile(targetPath, modifiedPdfBuffer);
902
+ }
903
+ else {
904
+ throw new Error('Invalid content type for writePdf. Expected string (markdown) or array of operations.');
905
+ }
906
+ }
907
+ const execFileAsync = promisify(execFile);
908
+ const DEFAULT_EDITOR_NEGATIVE_CACHE_MS = 5 * 60 * 1000;
909
+ const defaultEditorCache = new Map();
910
+ function escapeAppleScriptString(value) {
911
+ return value.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
912
+ }
913
+ export async function getDefaultEditorMetadata(filePath) {
914
+ if (os.platform() !== 'darwin') {
915
+ return {};
916
+ }
917
+ let cacheKey = '';
918
+ try {
919
+ const extension = path.extname(filePath).toLowerCase();
920
+ cacheKey = extension || path.basename(filePath).toLowerCase();
921
+ const cached = defaultEditorCache.get(cacheKey);
922
+ if (cached) {
923
+ if (!cached.expiresAt || cached.expiresAt > Date.now()) {
924
+ return cached.metadata;
925
+ }
926
+ defaultEditorCache.delete(cacheKey);
927
+ }
928
+ 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`;
929
+ const { stdout } = await execFileAsync('osascript', ['-e', script], { timeout: 12000 });
930
+ const lines = stdout.split('\n').map((line) => line.trim()).filter(Boolean);
931
+ const defaultEditorName = lines[lines.length - 2]?.replace(/\.app$/i, '') ?? '';
932
+ const defaultEditorPath = lines[lines.length - 1] ?? '';
933
+ if (defaultEditorName && defaultEditorPath.startsWith('/')) {
934
+ const metadata = { defaultEditorName, defaultEditorPath };
935
+ defaultEditorCache.set(cacheKey, { metadata });
936
+ return metadata;
937
+ }
938
+ defaultEditorCache.set(cacheKey, { metadata: {}, expiresAt: Date.now() + DEFAULT_EDITOR_NEGATIVE_CACHE_MS });
939
+ }
940
+ catch {
941
+ if (cacheKey) {
942
+ defaultEditorCache.set(cacheKey, { metadata: {}, expiresAt: Date.now() + DEFAULT_EDITOR_NEGATIVE_CACHE_MS });
943
+ }
944
+ // Generic UI fallback is good enough if detection fails.
945
+ }
946
+ return {};
947
+ }