mstro-app 0.1.47

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 (213) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +177 -0
  3. package/bin/commands/config.js +145 -0
  4. package/bin/commands/login.js +313 -0
  5. package/bin/commands/logout.js +75 -0
  6. package/bin/commands/status.js +197 -0
  7. package/bin/commands/whoami.js +161 -0
  8. package/bin/configure-claude.js +298 -0
  9. package/bin/mstro.js +581 -0
  10. package/bin/postinstall.js +45 -0
  11. package/bin/release.sh +110 -0
  12. package/dist/server/cli/headless/claude-invoker.d.ts +17 -0
  13. package/dist/server/cli/headless/claude-invoker.d.ts.map +1 -0
  14. package/dist/server/cli/headless/claude-invoker.js +311 -0
  15. package/dist/server/cli/headless/claude-invoker.js.map +1 -0
  16. package/dist/server/cli/headless/index.d.ts +13 -0
  17. package/dist/server/cli/headless/index.d.ts.map +1 -0
  18. package/dist/server/cli/headless/index.js +10 -0
  19. package/dist/server/cli/headless/index.js.map +1 -0
  20. package/dist/server/cli/headless/mcp-config.d.ts +11 -0
  21. package/dist/server/cli/headless/mcp-config.d.ts.map +1 -0
  22. package/dist/server/cli/headless/mcp-config.js +76 -0
  23. package/dist/server/cli/headless/mcp-config.js.map +1 -0
  24. package/dist/server/cli/headless/output-utils.d.ts +33 -0
  25. package/dist/server/cli/headless/output-utils.d.ts.map +1 -0
  26. package/dist/server/cli/headless/output-utils.js +101 -0
  27. package/dist/server/cli/headless/output-utils.js.map +1 -0
  28. package/dist/server/cli/headless/prompt-utils.d.ts +21 -0
  29. package/dist/server/cli/headless/prompt-utils.d.ts.map +1 -0
  30. package/dist/server/cli/headless/prompt-utils.js +84 -0
  31. package/dist/server/cli/headless/prompt-utils.js.map +1 -0
  32. package/dist/server/cli/headless/runner.d.ts +24 -0
  33. package/dist/server/cli/headless/runner.d.ts.map +1 -0
  34. package/dist/server/cli/headless/runner.js +99 -0
  35. package/dist/server/cli/headless/runner.js.map +1 -0
  36. package/dist/server/cli/headless/types.d.ts +106 -0
  37. package/dist/server/cli/headless/types.d.ts.map +1 -0
  38. package/dist/server/cli/headless/types.js +4 -0
  39. package/dist/server/cli/headless/types.js.map +1 -0
  40. package/dist/server/cli/improvisation-session-manager.d.ts +155 -0
  41. package/dist/server/cli/improvisation-session-manager.d.ts.map +1 -0
  42. package/dist/server/cli/improvisation-session-manager.js +415 -0
  43. package/dist/server/cli/improvisation-session-manager.js.map +1 -0
  44. package/dist/server/index.d.ts +2 -0
  45. package/dist/server/index.d.ts.map +1 -0
  46. package/dist/server/index.js +386 -0
  47. package/dist/server/index.js.map +1 -0
  48. package/dist/server/mcp/bouncer-cli.d.ts +3 -0
  49. package/dist/server/mcp/bouncer-cli.d.ts.map +1 -0
  50. package/dist/server/mcp/bouncer-cli.js +99 -0
  51. package/dist/server/mcp/bouncer-cli.js.map +1 -0
  52. package/dist/server/mcp/bouncer-integration.d.ts +36 -0
  53. package/dist/server/mcp/bouncer-integration.d.ts.map +1 -0
  54. package/dist/server/mcp/bouncer-integration.js +301 -0
  55. package/dist/server/mcp/bouncer-integration.js.map +1 -0
  56. package/dist/server/mcp/security-audit.d.ts +52 -0
  57. package/dist/server/mcp/security-audit.d.ts.map +1 -0
  58. package/dist/server/mcp/security-audit.js +118 -0
  59. package/dist/server/mcp/security-audit.js.map +1 -0
  60. package/dist/server/mcp/security-patterns.d.ts +73 -0
  61. package/dist/server/mcp/security-patterns.d.ts.map +1 -0
  62. package/dist/server/mcp/security-patterns.js +247 -0
  63. package/dist/server/mcp/security-patterns.js.map +1 -0
  64. package/dist/server/mcp/server.d.ts +3 -0
  65. package/dist/server/mcp/server.d.ts.map +1 -0
  66. package/dist/server/mcp/server.js +146 -0
  67. package/dist/server/mcp/server.js.map +1 -0
  68. package/dist/server/routes/files.d.ts +9 -0
  69. package/dist/server/routes/files.d.ts.map +1 -0
  70. package/dist/server/routes/files.js +24 -0
  71. package/dist/server/routes/files.js.map +1 -0
  72. package/dist/server/routes/improvise.d.ts +3 -0
  73. package/dist/server/routes/improvise.d.ts.map +1 -0
  74. package/dist/server/routes/improvise.js +72 -0
  75. package/dist/server/routes/improvise.js.map +1 -0
  76. package/dist/server/routes/index.d.ts +10 -0
  77. package/dist/server/routes/index.d.ts.map +1 -0
  78. package/dist/server/routes/index.js +12 -0
  79. package/dist/server/routes/index.js.map +1 -0
  80. package/dist/server/routes/instances.d.ts +10 -0
  81. package/dist/server/routes/instances.d.ts.map +1 -0
  82. package/dist/server/routes/instances.js +47 -0
  83. package/dist/server/routes/instances.js.map +1 -0
  84. package/dist/server/routes/notifications.d.ts +3 -0
  85. package/dist/server/routes/notifications.d.ts.map +1 -0
  86. package/dist/server/routes/notifications.js +136 -0
  87. package/dist/server/routes/notifications.js.map +1 -0
  88. package/dist/server/services/analytics.d.ts +56 -0
  89. package/dist/server/services/analytics.d.ts.map +1 -0
  90. package/dist/server/services/analytics.js +240 -0
  91. package/dist/server/services/analytics.js.map +1 -0
  92. package/dist/server/services/auth.d.ts +26 -0
  93. package/dist/server/services/auth.d.ts.map +1 -0
  94. package/dist/server/services/auth.js +71 -0
  95. package/dist/server/services/auth.js.map +1 -0
  96. package/dist/server/services/client-id.d.ts +10 -0
  97. package/dist/server/services/client-id.d.ts.map +1 -0
  98. package/dist/server/services/client-id.js +61 -0
  99. package/dist/server/services/client-id.js.map +1 -0
  100. package/dist/server/services/credentials.d.ts +39 -0
  101. package/dist/server/services/credentials.d.ts.map +1 -0
  102. package/dist/server/services/credentials.js +110 -0
  103. package/dist/server/services/credentials.js.map +1 -0
  104. package/dist/server/services/files.d.ts +119 -0
  105. package/dist/server/services/files.d.ts.map +1 -0
  106. package/dist/server/services/files.js +560 -0
  107. package/dist/server/services/files.js.map +1 -0
  108. package/dist/server/services/instances.d.ts +52 -0
  109. package/dist/server/services/instances.d.ts.map +1 -0
  110. package/dist/server/services/instances.js +241 -0
  111. package/dist/server/services/instances.js.map +1 -0
  112. package/dist/server/services/pathUtils.d.ts +47 -0
  113. package/dist/server/services/pathUtils.d.ts.map +1 -0
  114. package/dist/server/services/pathUtils.js +124 -0
  115. package/dist/server/services/pathUtils.js.map +1 -0
  116. package/dist/server/services/platform.d.ts +72 -0
  117. package/dist/server/services/platform.d.ts.map +1 -0
  118. package/dist/server/services/platform.js +368 -0
  119. package/dist/server/services/platform.js.map +1 -0
  120. package/dist/server/services/sentry.d.ts +5 -0
  121. package/dist/server/services/sentry.d.ts.map +1 -0
  122. package/dist/server/services/sentry.js +71 -0
  123. package/dist/server/services/sentry.js.map +1 -0
  124. package/dist/server/services/terminal/pty-manager.d.ts +149 -0
  125. package/dist/server/services/terminal/pty-manager.d.ts.map +1 -0
  126. package/dist/server/services/terminal/pty-manager.js +377 -0
  127. package/dist/server/services/terminal/pty-manager.js.map +1 -0
  128. package/dist/server/services/terminal/tmux-manager.d.ts +82 -0
  129. package/dist/server/services/terminal/tmux-manager.d.ts.map +1 -0
  130. package/dist/server/services/terminal/tmux-manager.js +352 -0
  131. package/dist/server/services/terminal/tmux-manager.js.map +1 -0
  132. package/dist/server/services/websocket/autocomplete.d.ts +50 -0
  133. package/dist/server/services/websocket/autocomplete.d.ts.map +1 -0
  134. package/dist/server/services/websocket/autocomplete.js +361 -0
  135. package/dist/server/services/websocket/autocomplete.js.map +1 -0
  136. package/dist/server/services/websocket/file-utils.d.ts +44 -0
  137. package/dist/server/services/websocket/file-utils.d.ts.map +1 -0
  138. package/dist/server/services/websocket/file-utils.js +272 -0
  139. package/dist/server/services/websocket/file-utils.js.map +1 -0
  140. package/dist/server/services/websocket/handler.d.ts +246 -0
  141. package/dist/server/services/websocket/handler.d.ts.map +1 -0
  142. package/dist/server/services/websocket/handler.js +1771 -0
  143. package/dist/server/services/websocket/handler.js.map +1 -0
  144. package/dist/server/services/websocket/index.d.ts +11 -0
  145. package/dist/server/services/websocket/index.d.ts.map +1 -0
  146. package/dist/server/services/websocket/index.js +14 -0
  147. package/dist/server/services/websocket/index.js.map +1 -0
  148. package/dist/server/services/websocket/types.d.ts +214 -0
  149. package/dist/server/services/websocket/types.d.ts.map +1 -0
  150. package/dist/server/services/websocket/types.js +4 -0
  151. package/dist/server/services/websocket/types.js.map +1 -0
  152. package/dist/server/utils/agent-manager.d.ts +69 -0
  153. package/dist/server/utils/agent-manager.d.ts.map +1 -0
  154. package/dist/server/utils/agent-manager.js +269 -0
  155. package/dist/server/utils/agent-manager.js.map +1 -0
  156. package/dist/server/utils/paths.d.ts +25 -0
  157. package/dist/server/utils/paths.d.ts.map +1 -0
  158. package/dist/server/utils/paths.js +38 -0
  159. package/dist/server/utils/paths.js.map +1 -0
  160. package/dist/server/utils/port-manager.d.ts +10 -0
  161. package/dist/server/utils/port-manager.d.ts.map +1 -0
  162. package/dist/server/utils/port-manager.js +60 -0
  163. package/dist/server/utils/port-manager.js.map +1 -0
  164. package/dist/server/utils/port.d.ts +26 -0
  165. package/dist/server/utils/port.d.ts.map +1 -0
  166. package/dist/server/utils/port.js +83 -0
  167. package/dist/server/utils/port.js.map +1 -0
  168. package/hooks/bouncer.sh +138 -0
  169. package/package.json +74 -0
  170. package/server/README.md +191 -0
  171. package/server/cli/headless/claude-invoker.ts +415 -0
  172. package/server/cli/headless/index.ts +39 -0
  173. package/server/cli/headless/mcp-config.ts +87 -0
  174. package/server/cli/headless/output-utils.ts +109 -0
  175. package/server/cli/headless/prompt-utils.ts +108 -0
  176. package/server/cli/headless/runner.ts +133 -0
  177. package/server/cli/headless/types.ts +118 -0
  178. package/server/cli/improvisation-session-manager.ts +531 -0
  179. package/server/index.ts +456 -0
  180. package/server/mcp/README.md +122 -0
  181. package/server/mcp/bouncer-cli.ts +127 -0
  182. package/server/mcp/bouncer-integration.ts +430 -0
  183. package/server/mcp/security-audit.ts +180 -0
  184. package/server/mcp/security-patterns.ts +290 -0
  185. package/server/mcp/server.ts +174 -0
  186. package/server/routes/files.ts +29 -0
  187. package/server/routes/improvise.ts +82 -0
  188. package/server/routes/index.ts +13 -0
  189. package/server/routes/instances.ts +54 -0
  190. package/server/routes/notifications.ts +158 -0
  191. package/server/services/analytics.ts +277 -0
  192. package/server/services/auth.ts +80 -0
  193. package/server/services/client-id.ts +68 -0
  194. package/server/services/credentials.ts +134 -0
  195. package/server/services/files.ts +710 -0
  196. package/server/services/instances.ts +275 -0
  197. package/server/services/pathUtils.ts +158 -0
  198. package/server/services/platform.test.ts +1314 -0
  199. package/server/services/platform.ts +435 -0
  200. package/server/services/sentry.ts +81 -0
  201. package/server/services/terminal/pty-manager.ts +464 -0
  202. package/server/services/terminal/tmux-manager.ts +426 -0
  203. package/server/services/websocket/autocomplete.ts +438 -0
  204. package/server/services/websocket/file-utils.ts +305 -0
  205. package/server/services/websocket/handler.test.ts +20 -0
  206. package/server/services/websocket/handler.ts +2047 -0
  207. package/server/services/websocket/index.ts +40 -0
  208. package/server/services/websocket/types.ts +339 -0
  209. package/server/tsconfig.json +19 -0
  210. package/server/utils/agent-manager.ts +323 -0
  211. package/server/utils/paths.ts +45 -0
  212. package/server/utils/port-manager.ts +70 -0
  213. package/server/utils/port.ts +102 -0
@@ -0,0 +1,710 @@
1
+ // Copyright (c) 2025-present Mstro, Inc. All rights reserved.
2
+ // Licensed under the MIT License. See LICENSE file for details.
3
+
4
+ /**
5
+ * File Service
6
+ *
7
+ * Handles file autocomplete, directory operations, and file explorer features.
8
+ */
9
+
10
+ import {
11
+ existsSync,
12
+ mkdirSync,
13
+ readdirSync,
14
+ renameSync,
15
+ rmdirSync,
16
+ statSync,
17
+ unlinkSync,
18
+ writeFileSync
19
+ } from 'node:fs'
20
+ import { dirname, join } from 'node:path'
21
+ import {
22
+ containsDangerousPatterns,
23
+ validateBothPathsWithinWorkingDir,
24
+ validatePathWithinWorkingDir
25
+ } from './pathUtils.js'
26
+
27
+ export interface FileItem {
28
+ name: string
29
+ path: string
30
+ isDirectory: boolean
31
+ score?: number
32
+ }
33
+
34
+ /**
35
+ * Internal type for scored file items (score is required)
36
+ */
37
+ interface ScoredFileItem {
38
+ name: string
39
+ path: string
40
+ isDirectory: boolean
41
+ score: number
42
+ }
43
+
44
+ /**
45
+ * Directory entry with metadata for file explorer
46
+ */
47
+ export interface DirectoryEntry {
48
+ name: string
49
+ path: string
50
+ type: 'file' | 'directory'
51
+ size?: number
52
+ modifiedAt?: string
53
+ }
54
+
55
+ /**
56
+ * Result type for file operations
57
+ */
58
+ export interface FileOperationResult {
59
+ success: boolean
60
+ path?: string
61
+ error?: string
62
+ }
63
+
64
+ /**
65
+ * Result type for list directory operation
66
+ */
67
+ export interface ListDirectoryResult {
68
+ success: boolean
69
+ entries?: DirectoryEntry[]
70
+ error?: string
71
+ }
72
+
73
+ export class FileService {
74
+ private workingDirectory: string
75
+ private skipDirs = ['node_modules', '.git', '.mstro', 'dist', 'build', '.next', 'coverage', '.vscode', '.idea']
76
+
77
+ constructor(workingDirectory: string) {
78
+ this.workingDirectory = workingDirectory
79
+ }
80
+
81
+ /**
82
+ * Get all files recursively with optional filtering
83
+ */
84
+ getAllFiles(
85
+ _baseDir: 'working' | 'scores' = 'working',
86
+ filter?: string,
87
+ limit: number = 10
88
+ ): FileItem[] {
89
+ // Note: 'scores' baseDir kept for API compatibility, but maps to working directory
90
+ const directory = this.workingDirectory
91
+
92
+ let files = this.scanDirectory(directory, directory)
93
+
94
+ // Apply filter if provided
95
+ if (filter) {
96
+ files = this.filterAndScore(files, filter)
97
+ }
98
+
99
+ // Sort by score if filtered, otherwise by type and name
100
+ if (filter) {
101
+ files.sort((a, b) => (b.score || 0) - (a.score || 0))
102
+ } else {
103
+ files.sort((a, b) => {
104
+ if (a.isDirectory && !b.isDirectory) return -1
105
+ if (!a.isDirectory && b.isDirectory) return 1
106
+ return a.name.localeCompare(b.name)
107
+ })
108
+ }
109
+
110
+ return files.slice(0, limit)
111
+ }
112
+
113
+ /**
114
+ * Recursively scan directory
115
+ */
116
+ private scanDirectory(
117
+ dir: string,
118
+ baseDir: string,
119
+ results: FileItem[] = []
120
+ ): FileItem[] {
121
+ try {
122
+ // Skip common ignore directories
123
+ const dirName = dir.split('/').pop() || ''
124
+ if (this.skipDirs.includes(dirName)) {
125
+ return results
126
+ }
127
+
128
+ const entries = readdirSync(dir, { withFileTypes: true })
129
+
130
+ for (const entry of entries) {
131
+ // Skip hidden files and ignore directories
132
+ if (entry.name.startsWith('.') || this.skipDirs.includes(entry.name)) {
133
+ continue
134
+ }
135
+
136
+ const fullPath = join(dir, entry.name)
137
+ const relativePath = fullPath.replace(`${baseDir}/`, '')
138
+
139
+ results.push({
140
+ name: entry.name,
141
+ path: relativePath,
142
+ isDirectory: entry.isDirectory()
143
+ })
144
+
145
+ // Recursively search directories (with depth limit)
146
+ if (entry.isDirectory() && results.length < 1000) {
147
+ this.scanDirectory(fullPath, baseDir, results)
148
+ }
149
+ }
150
+ } catch (_error) {
151
+ // Skip directories we can't read
152
+ }
153
+
154
+ return results
155
+ }
156
+
157
+ /**
158
+ * Filter files and assign relevance scores
159
+ */
160
+ private filterAndScore(files: FileItem[], filter: string): FileItem[] {
161
+ const filterLower = filter.toLowerCase()
162
+
163
+ const scoredFiles: ScoredFileItem[] = []
164
+
165
+ for (const file of files) {
166
+ const nameLower = file.name.toLowerCase()
167
+ const pathLower = file.path.toLowerCase()
168
+
169
+ let score = 0
170
+
171
+ // Highest priority: starts with filter
172
+ if (nameLower.startsWith(filterLower)) {
173
+ score = 1000
174
+ }
175
+ // High priority: word in path starts with filter
176
+ else if (pathLower.split('/').some(part => part.startsWith(filterLower))) {
177
+ score = 500
178
+ }
179
+ // Medium priority: contains filter
180
+ else if (nameLower.includes(filterLower)) {
181
+ score = 100
182
+ }
183
+ // Low priority: path contains filter
184
+ else if (pathLower.includes(filterLower)) {
185
+ score = 10
186
+ }
187
+ else {
188
+ continue // Doesn't match, skip
189
+ }
190
+
191
+ // Boost score for shorter paths (prefer files closer to root)
192
+ score += (50 - Math.min(50, file.path.split('/').length * 5))
193
+
194
+ scoredFiles.push({
195
+ name: file.name,
196
+ path: file.path,
197
+ isDirectory: file.isDirectory,
198
+ score
199
+ })
200
+ }
201
+
202
+ return scoredFiles
203
+ }
204
+
205
+ /**
206
+ * Get file statistics
207
+ */
208
+ getFileStats(path: string, _baseDir: 'working' | 'scores' = 'working') {
209
+ try {
210
+ // Note: 'scores' baseDir kept for API compatibility, but maps to working directory
211
+ const fullPath = join(this.workingDirectory, path)
212
+ const stats = statSync(fullPath)
213
+
214
+ return {
215
+ size: stats.size,
216
+ created: stats.birthtime,
217
+ modified: stats.mtime,
218
+ isDirectory: stats.isDirectory(),
219
+ isFile: stats.isFile()
220
+ }
221
+ } catch (_error) {
222
+ return null
223
+ }
224
+ }
225
+ }
226
+
227
+ // ============================================================================
228
+ // File Explorer Functions (standalone exports for WebSocket handler)
229
+ // ============================================================================
230
+
231
+ /**
232
+ * List immediate children of a directory.
233
+ * Returns entries sorted: directories first, then files, alphabetically.
234
+ *
235
+ * @param dirPath - Directory path (relative to workingDir or absolute)
236
+ * @param workingDir - Working directory boundary
237
+ * @param showHidden - Whether to show hidden files (starting with .)
238
+ * @returns ListDirectoryResult with entries array or error
239
+ */
240
+ export function listDirectory(
241
+ dirPath: string,
242
+ workingDir: string,
243
+ showHidden: boolean = false
244
+ ): ListDirectoryResult {
245
+ // Check for dangerous patterns first
246
+ if (containsDangerousPatterns(dirPath)) {
247
+ console.error(`[FileService] SECURITY: Dangerous pattern in path: "${dirPath}"`)
248
+ return {
249
+ success: false,
250
+ error: 'Invalid path: contains dangerous patterns'
251
+ }
252
+ }
253
+
254
+ // Validate path is within working directory
255
+ const validation = validatePathWithinWorkingDir(dirPath, workingDir)
256
+ if (!validation.valid) {
257
+ return {
258
+ success: false,
259
+ error: validation.error
260
+ }
261
+ }
262
+
263
+ const resolvedPath = validation.resolvedPath
264
+
265
+ try {
266
+ // Check if path exists and is a directory
267
+ if (!existsSync(resolvedPath)) {
268
+ return {
269
+ success: false,
270
+ error: 'Directory not found'
271
+ }
272
+ }
273
+
274
+ const stats = statSync(resolvedPath)
275
+ if (!stats.isDirectory()) {
276
+ return {
277
+ success: false,
278
+ error: 'Path is not a directory'
279
+ }
280
+ }
281
+
282
+ // Read directory entries
283
+ const entries = readdirSync(resolvedPath, { withFileTypes: true })
284
+
285
+ // Map to DirectoryEntry objects
286
+ const directoryEntries: DirectoryEntry[] = entries
287
+ .filter(entry => {
288
+ // Filter hidden files unless showHidden is true
289
+ if (!showHidden && entry.name.startsWith('.')) {
290
+ return false
291
+ }
292
+ return true
293
+ })
294
+ .map(entry => {
295
+ const entryPath = join(resolvedPath, entry.name)
296
+ const isDir = entry.isDirectory()
297
+
298
+ // Get stats for size and modification time
299
+ let size: number | undefined
300
+ let modifiedAt: string | undefined
301
+
302
+ try {
303
+ const entryStats = statSync(entryPath)
304
+ modifiedAt = entryStats.mtime.toISOString()
305
+ if (!isDir) {
306
+ size = entryStats.size
307
+ }
308
+ } catch {
309
+ // Skip stats if we can't read them
310
+ }
311
+
312
+ // Return path relative to workingDir for consistency
313
+ const relativePath = entryPath.replace(`${workingDir}/`, '')
314
+
315
+ return {
316
+ name: entry.name,
317
+ path: relativePath,
318
+ type: isDir ? 'directory' : 'file',
319
+ size,
320
+ modifiedAt
321
+ } as DirectoryEntry
322
+ })
323
+
324
+ // Sort: directories first, then files, alphabetically within each group
325
+ directoryEntries.sort((a, b) => {
326
+ if (a.type === 'directory' && b.type === 'file') return -1
327
+ if (a.type === 'file' && b.type === 'directory') return 1
328
+ return a.name.localeCompare(b.name)
329
+ })
330
+
331
+ return {
332
+ success: true,
333
+ entries: directoryEntries
334
+ }
335
+ } catch (error: any) {
336
+ // Handle permission errors gracefully
337
+ if (error.code === 'EACCES') {
338
+ return {
339
+ success: false,
340
+ error: 'Permission denied'
341
+ }
342
+ }
343
+
344
+ console.error('[FileService] Error listing directory:', error)
345
+ return {
346
+ success: false,
347
+ error: error.message || 'Failed to list directory'
348
+ }
349
+ }
350
+ }
351
+
352
+ /**
353
+ * Write content to a file.
354
+ * Creates parent directories if needed.
355
+ *
356
+ * @param filePath - File path (relative to workingDir or absolute)
357
+ * @param content - Content to write
358
+ * @param workingDir - Working directory boundary
359
+ * @returns FileOperationResult
360
+ */
361
+ export function writeFile(
362
+ filePath: string,
363
+ content: string,
364
+ workingDir: string
365
+ ): FileOperationResult {
366
+ // Check for dangerous patterns first
367
+ if (containsDangerousPatterns(filePath)) {
368
+ console.error(`[FileService] SECURITY: Dangerous pattern in path: "${filePath}"`)
369
+ return {
370
+ success: false,
371
+ error: 'Invalid path: contains dangerous patterns'
372
+ }
373
+ }
374
+
375
+ // Validate path is within working directory
376
+ const validation = validatePathWithinWorkingDir(filePath, workingDir)
377
+ if (!validation.valid) {
378
+ return {
379
+ success: false,
380
+ error: validation.error
381
+ }
382
+ }
383
+
384
+ const resolvedPath = validation.resolvedPath
385
+
386
+ try {
387
+ // Create parent directories if they don't exist
388
+ const parentDir = dirname(resolvedPath)
389
+ if (!existsSync(parentDir)) {
390
+ mkdirSync(parentDir, { recursive: true })
391
+ }
392
+
393
+ // Check if target is a directory (can't write to directory)
394
+ if (existsSync(resolvedPath)) {
395
+ const stats = statSync(resolvedPath)
396
+ if (stats.isDirectory()) {
397
+ return {
398
+ success: false,
399
+ error: 'Cannot write to a directory'
400
+ }
401
+ }
402
+ }
403
+
404
+ // Write the file
405
+ writeFileSync(resolvedPath, content, 'utf-8')
406
+
407
+ return {
408
+ success: true,
409
+ path: resolvedPath.replace(`${workingDir}/`, '')
410
+ }
411
+ } catch (error: any) {
412
+ console.error('[FileService] Error writing file:', error)
413
+ return {
414
+ success: false,
415
+ error: error.message || 'Failed to write file'
416
+ }
417
+ }
418
+ }
419
+
420
+ /**
421
+ * Create an empty file.
422
+ * Returns error if file already exists.
423
+ *
424
+ * @param filePath - File path (relative to workingDir or absolute)
425
+ * @param workingDir - Working directory boundary
426
+ * @returns FileOperationResult
427
+ */
428
+ export function createFile(
429
+ filePath: string,
430
+ workingDir: string
431
+ ): FileOperationResult {
432
+ // Check for dangerous patterns first
433
+ if (containsDangerousPatterns(filePath)) {
434
+ console.error(`[FileService] SECURITY: Dangerous pattern in path: "${filePath}"`)
435
+ return {
436
+ success: false,
437
+ error: 'Invalid path: contains dangerous patterns'
438
+ }
439
+ }
440
+
441
+ // Validate path is within working directory
442
+ const validation = validatePathWithinWorkingDir(filePath, workingDir)
443
+ if (!validation.valid) {
444
+ return {
445
+ success: false,
446
+ error: validation.error
447
+ }
448
+ }
449
+
450
+ const resolvedPath = validation.resolvedPath
451
+
452
+ try {
453
+ // Check if file already exists
454
+ if (existsSync(resolvedPath)) {
455
+ return {
456
+ success: false,
457
+ error: 'File already exists'
458
+ }
459
+ }
460
+
461
+ // Create parent directories if needed
462
+ const parentDir = dirname(resolvedPath)
463
+ if (!existsSync(parentDir)) {
464
+ mkdirSync(parentDir, { recursive: true })
465
+ }
466
+
467
+ // Create empty file
468
+ writeFileSync(resolvedPath, '', 'utf-8')
469
+
470
+ return {
471
+ success: true,
472
+ path: resolvedPath.replace(`${workingDir}/`, '')
473
+ }
474
+ } catch (error: any) {
475
+ console.error('[FileService] Error creating file:', error)
476
+ return {
477
+ success: false,
478
+ error: error.message || 'Failed to create file'
479
+ }
480
+ }
481
+ }
482
+
483
+ /**
484
+ * Create a directory (recursively if needed).
485
+ * Returns error if directory already exists.
486
+ *
487
+ * @param dirPath - Directory path (relative to workingDir or absolute)
488
+ * @param workingDir - Working directory boundary
489
+ * @returns FileOperationResult
490
+ */
491
+ export function createDirectory(
492
+ dirPath: string,
493
+ workingDir: string
494
+ ): FileOperationResult {
495
+ // Check for dangerous patterns first
496
+ if (containsDangerousPatterns(dirPath)) {
497
+ console.error(`[FileService] SECURITY: Dangerous pattern in path: "${dirPath}"`)
498
+ return {
499
+ success: false,
500
+ error: 'Invalid path: contains dangerous patterns'
501
+ }
502
+ }
503
+
504
+ // Validate path is within working directory
505
+ const validation = validatePathWithinWorkingDir(dirPath, workingDir)
506
+ if (!validation.valid) {
507
+ return {
508
+ success: false,
509
+ error: validation.error
510
+ }
511
+ }
512
+
513
+ const resolvedPath = validation.resolvedPath
514
+
515
+ try {
516
+ // Check if path already exists
517
+ if (existsSync(resolvedPath)) {
518
+ const stats = statSync(resolvedPath)
519
+ if (stats.isDirectory()) {
520
+ return {
521
+ success: false,
522
+ error: 'Directory already exists'
523
+ }
524
+ } else {
525
+ return {
526
+ success: false,
527
+ error: 'A file with that name already exists'
528
+ }
529
+ }
530
+ }
531
+
532
+ // Create directory recursively
533
+ mkdirSync(resolvedPath, { recursive: true })
534
+
535
+ return {
536
+ success: true,
537
+ path: resolvedPath.replace(`${workingDir}/`, '')
538
+ }
539
+ } catch (error: any) {
540
+ console.error('[FileService] Error creating directory:', error)
541
+ return {
542
+ success: false,
543
+ error: error.message || 'Failed to create directory'
544
+ }
545
+ }
546
+ }
547
+
548
+ /**
549
+ * Delete a file or EMPTY directory.
550
+ * For safety, only deletes empty directories.
551
+ *
552
+ * CRITICAL: Validates path is within workingDir to prevent path traversal.
553
+ *
554
+ * @param targetPath - Path to delete (relative to workingDir or absolute)
555
+ * @param workingDir - Working directory boundary
556
+ * @returns FileOperationResult
557
+ */
558
+ export function deleteFile(
559
+ targetPath: string,
560
+ workingDir: string
561
+ ): FileOperationResult {
562
+ // Check for dangerous patterns first
563
+ if (containsDangerousPatterns(targetPath)) {
564
+ console.error(`[FileService] SECURITY: Dangerous pattern in path: "${targetPath}"`)
565
+ return {
566
+ success: false,
567
+ error: 'Invalid path: contains dangerous patterns'
568
+ }
569
+ }
570
+
571
+ // Validate path is within working directory
572
+ const validation = validatePathWithinWorkingDir(targetPath, workingDir)
573
+ if (!validation.valid) {
574
+ return {
575
+ success: false,
576
+ error: validation.error
577
+ }
578
+ }
579
+
580
+ const resolvedPath = validation.resolvedPath
581
+
582
+ // Additional safety: prevent deleting the working directory itself
583
+ if (resolvedPath === workingDir || resolvedPath === `${workingDir}/`) {
584
+ console.error(`[FileService] SECURITY: Attempted to delete working directory: "${resolvedPath}"`)
585
+ return {
586
+ success: false,
587
+ error: 'Cannot delete the working directory'
588
+ }
589
+ }
590
+
591
+ try {
592
+ // Check if path exists
593
+ if (!existsSync(resolvedPath)) {
594
+ return {
595
+ success: false,
596
+ error: 'File or directory not found'
597
+ }
598
+ }
599
+
600
+ const stats = statSync(resolvedPath)
601
+
602
+ if (stats.isDirectory()) {
603
+ // Only delete empty directories for safety
604
+ const contents = readdirSync(resolvedPath)
605
+ if (contents.length > 0) {
606
+ return {
607
+ success: false,
608
+ error: 'Directory is not empty. Only empty directories can be deleted.'
609
+ }
610
+ }
611
+
612
+ rmdirSync(resolvedPath)
613
+ } else {
614
+ unlinkSync(resolvedPath)
615
+ }
616
+
617
+ return {
618
+ success: true,
619
+ path: resolvedPath.replace(`${workingDir}/`, '')
620
+ }
621
+ } catch (error: any) {
622
+ console.error('[FileService] Error deleting file:', error)
623
+ return {
624
+ success: false,
625
+ error: error.message || 'Failed to delete'
626
+ }
627
+ }
628
+ }
629
+
630
+ /**
631
+ * Rename or move a file/directory.
632
+ * Both source and destination must be within workingDir.
633
+ *
634
+ * @param oldPath - Current path (relative to workingDir or absolute)
635
+ * @param newPath - New path (relative to workingDir or absolute)
636
+ * @param workingDir - Working directory boundary
637
+ * @returns FileOperationResult
638
+ */
639
+ export function renameFile(
640
+ oldPath: string,
641
+ newPath: string,
642
+ workingDir: string
643
+ ): FileOperationResult {
644
+ // Check for dangerous patterns in both paths
645
+ if (containsDangerousPatterns(oldPath)) {
646
+ console.error(`[FileService] SECURITY: Dangerous pattern in source path: "${oldPath}"`)
647
+ return {
648
+ success: false,
649
+ error: 'Invalid source path: contains dangerous patterns'
650
+ }
651
+ }
652
+
653
+ if (containsDangerousPatterns(newPath)) {
654
+ console.error(`[FileService] SECURITY: Dangerous pattern in destination path: "${newPath}"`)
655
+ return {
656
+ success: false,
657
+ error: 'Invalid destination path: contains dangerous patterns'
658
+ }
659
+ }
660
+
661
+ // Validate both paths are within working directory
662
+ const validation = validateBothPathsWithinWorkingDir(oldPath, newPath, workingDir)
663
+ if (!validation.valid) {
664
+ return {
665
+ success: false,
666
+ error: validation.error
667
+ }
668
+ }
669
+
670
+ const resolvedOldPath = validation.resolvedSourcePath
671
+ const resolvedNewPath = validation.resolvedDestPath
672
+
673
+ try {
674
+ // Check if source exists
675
+ if (!existsSync(resolvedOldPath)) {
676
+ return {
677
+ success: false,
678
+ error: 'Source file or directory not found'
679
+ }
680
+ }
681
+
682
+ // Check if destination already exists
683
+ if (existsSync(resolvedNewPath)) {
684
+ return {
685
+ success: false,
686
+ error: 'Destination already exists'
687
+ }
688
+ }
689
+
690
+ // Create parent directories for destination if needed
691
+ const parentDir = dirname(resolvedNewPath)
692
+ if (!existsSync(parentDir)) {
693
+ mkdirSync(parentDir, { recursive: true })
694
+ }
695
+
696
+ // Perform the rename
697
+ renameSync(resolvedOldPath, resolvedNewPath)
698
+
699
+ return {
700
+ success: true,
701
+ path: resolvedNewPath.replace(`${workingDir}/`, '')
702
+ }
703
+ } catch (error: any) {
704
+ console.error('[FileService] Error renaming file:', error)
705
+ return {
706
+ success: false,
707
+ error: error.message || 'Failed to rename'
708
+ }
709
+ }
710
+ }