spck 0.3.1

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 (155) hide show
  1. package/.oxlintrc.json +49 -0
  2. package/LICENSE +21 -0
  3. package/README.md +631 -0
  4. package/bin/cli.js +20 -0
  5. package/bin/validate-cwd.js +41 -0
  6. package/dist/config/__tests__/config.test.d.ts +2 -0
  7. package/dist/config/__tests__/config.test.js +262 -0
  8. package/dist/config/__tests__/credentials.test.d.ts +2 -0
  9. package/dist/config/__tests__/credentials.test.js +360 -0
  10. package/dist/config/config.d.ts +33 -0
  11. package/dist/config/config.js +185 -0
  12. package/dist/config/credentials.d.ts +75 -0
  13. package/dist/config/credentials.js +259 -0
  14. package/dist/config/server-selection.d.ts +40 -0
  15. package/dist/config/server-selection.js +130 -0
  16. package/dist/connection/__tests__/firebase-auth.test.d.ts +2 -0
  17. package/dist/connection/__tests__/firebase-auth.test.js +96 -0
  18. package/dist/connection/__tests__/hmac.test.d.ts +2 -0
  19. package/dist/connection/__tests__/hmac.test.js +372 -0
  20. package/dist/connection/auth.d.ts +13 -0
  21. package/dist/connection/auth.js +91 -0
  22. package/dist/connection/firebase-auth.d.ts +40 -0
  23. package/dist/connection/firebase-auth.js +429 -0
  24. package/dist/connection/hmac.d.ts +24 -0
  25. package/dist/connection/hmac.js +109 -0
  26. package/dist/i18n/index.d.ts +25 -0
  27. package/dist/i18n/index.js +101 -0
  28. package/dist/i18n/locales/en.json +313 -0
  29. package/dist/i18n/locales/es.json +302 -0
  30. package/dist/i18n/locales/fr.json +302 -0
  31. package/dist/i18n/locales/id.json +302 -0
  32. package/dist/i18n/locales/ja.json +302 -0
  33. package/dist/i18n/locales/ko.json +302 -0
  34. package/dist/i18n/locales/locales/en.json +309 -0
  35. package/dist/i18n/locales/locales/es.json +302 -0
  36. package/dist/i18n/locales/locales/fr.json +302 -0
  37. package/dist/i18n/locales/locales/id.json +302 -0
  38. package/dist/i18n/locales/locales/ja.json +302 -0
  39. package/dist/i18n/locales/locales/ko.json +302 -0
  40. package/dist/i18n/locales/locales/pt.json +302 -0
  41. package/dist/i18n/locales/locales/zh-Hans.json +302 -0
  42. package/dist/i18n/locales/pt.json +302 -0
  43. package/dist/i18n/locales/zh-Hans.json +302 -0
  44. package/dist/index.d.ts +25 -0
  45. package/dist/index.js +493 -0
  46. package/dist/proxy/ProxyClient.d.ts +125 -0
  47. package/dist/proxy/ProxyClient.js +781 -0
  48. package/dist/proxy/ProxySocketWrapper.d.ts +43 -0
  49. package/dist/proxy/ProxySocketWrapper.js +98 -0
  50. package/dist/proxy/__tests__/ProxyClient.test.d.ts +2 -0
  51. package/dist/proxy/__tests__/ProxyClient.test.js +445 -0
  52. package/dist/proxy/__tests__/ProxySocketWrapper.test.d.ts +2 -0
  53. package/dist/proxy/__tests__/ProxySocketWrapper.test.js +190 -0
  54. package/dist/proxy/__tests__/handshake-validation.test.d.ts +2 -0
  55. package/dist/proxy/__tests__/handshake-validation.test.js +282 -0
  56. package/dist/proxy/__tests__/token-refresh-race.test.d.ts +14 -0
  57. package/dist/proxy/__tests__/token-refresh-race.test.js +173 -0
  58. package/dist/proxy/chunking.d.ts +53 -0
  59. package/dist/proxy/chunking.js +127 -0
  60. package/dist/proxy/handshake-validation.d.ts +21 -0
  61. package/dist/proxy/handshake-validation.js +49 -0
  62. package/dist/rpc/__tests__/router.test.d.ts +2 -0
  63. package/dist/rpc/__tests__/router.test.js +262 -0
  64. package/dist/rpc/router.d.ts +37 -0
  65. package/dist/rpc/router.js +132 -0
  66. package/dist/services/BrowserProxyService.d.ts +13 -0
  67. package/dist/services/BrowserProxyService.js +139 -0
  68. package/dist/services/FilesystemService.d.ts +99 -0
  69. package/dist/services/FilesystemService.js +742 -0
  70. package/dist/services/GitService.d.ts +243 -0
  71. package/dist/services/GitService.js +1439 -0
  72. package/dist/services/SearchService.d.ts +93 -0
  73. package/dist/services/SearchService.js +670 -0
  74. package/dist/services/TerminalService.d.ts +62 -0
  75. package/dist/services/TerminalService.js +337 -0
  76. package/dist/services/__tests__/BrowserProxyService.test.d.ts +2 -0
  77. package/dist/services/__tests__/BrowserProxyService.test.js +145 -0
  78. package/dist/services/__tests__/FilesystemService.test.d.ts +2 -0
  79. package/dist/services/__tests__/FilesystemService.test.js +609 -0
  80. package/dist/services/__tests__/GitService.test.d.ts +2 -0
  81. package/dist/services/__tests__/GitService.test.js +953 -0
  82. package/dist/services/__tests__/SearchService.test.d.ts +2 -0
  83. package/dist/services/__tests__/SearchService.test.js +384 -0
  84. package/dist/services/__tests__/TerminalService.test.d.ts +2 -0
  85. package/dist/services/__tests__/TerminalService.test.js +513 -0
  86. package/dist/setup/wizard.d.ts +10 -0
  87. package/dist/setup/wizard.js +172 -0
  88. package/dist/types.d.ts +196 -0
  89. package/dist/types.js +44 -0
  90. package/dist/utils/__tests__/gitignore.test.d.ts +2 -0
  91. package/dist/utils/__tests__/gitignore.test.js +127 -0
  92. package/dist/utils/gitignore.d.ts +24 -0
  93. package/dist/utils/gitignore.js +77 -0
  94. package/dist/utils/logger.d.ts +96 -0
  95. package/dist/utils/logger.js +456 -0
  96. package/dist/utils/project-dir.d.ts +51 -0
  97. package/dist/utils/project-dir.js +191 -0
  98. package/dist/utils/ripgrep.d.ts +34 -0
  99. package/dist/utils/ripgrep.js +148 -0
  100. package/dist/utils/tool-detection.d.ts +17 -0
  101. package/dist/utils/tool-detection.js +126 -0
  102. package/dist/watcher/FileWatcher.d.ts +10 -0
  103. package/dist/watcher/FileWatcher.js +42 -0
  104. package/package.json +70 -0
  105. package/src/config/__tests__/config.test.ts +318 -0
  106. package/src/config/__tests__/credentials.test.ts +494 -0
  107. package/src/config/config.ts +206 -0
  108. package/src/config/credentials.ts +302 -0
  109. package/src/config/server-selection.ts +150 -0
  110. package/src/connection/__tests__/firebase-auth.test.ts +121 -0
  111. package/src/connection/__tests__/hmac.test.ts +509 -0
  112. package/src/connection/auth.ts +140 -0
  113. package/src/connection/firebase-auth.ts +504 -0
  114. package/src/connection/hmac.ts +139 -0
  115. package/src/i18n/index.ts +119 -0
  116. package/src/i18n/locales/en.json +313 -0
  117. package/src/i18n/locales/es.json +302 -0
  118. package/src/i18n/locales/fr.json +302 -0
  119. package/src/i18n/locales/id.json +302 -0
  120. package/src/i18n/locales/ja.json +302 -0
  121. package/src/i18n/locales/ko.json +302 -0
  122. package/src/i18n/locales/pt.json +302 -0
  123. package/src/i18n/locales/zh-Hans.json +302 -0
  124. package/src/index.ts +542 -0
  125. package/src/proxy/ProxyClient.ts +968 -0
  126. package/src/proxy/ProxySocketWrapper.ts +113 -0
  127. package/src/proxy/__tests__/ProxyClient.test.ts +575 -0
  128. package/src/proxy/__tests__/ProxySocketWrapper.test.ts +251 -0
  129. package/src/proxy/__tests__/handshake-validation.test.ts +367 -0
  130. package/src/proxy/chunking.ts +162 -0
  131. package/src/proxy/handshake-validation.ts +64 -0
  132. package/src/rpc/__tests__/router.test.ts +400 -0
  133. package/src/rpc/router.ts +183 -0
  134. package/src/services/BrowserProxyService.ts +179 -0
  135. package/src/services/FilesystemService.ts +841 -0
  136. package/src/services/GitService.ts +1639 -0
  137. package/src/services/SearchService.ts +809 -0
  138. package/src/services/TerminalService.ts +413 -0
  139. package/src/services/__tests__/BrowserProxyService.test.ts +155 -0
  140. package/src/services/__tests__/FilesystemService.test.ts +1002 -0
  141. package/src/services/__tests__/GitService.test.ts +1552 -0
  142. package/src/services/__tests__/SearchService.test.ts +484 -0
  143. package/src/services/__tests__/TerminalService.test.ts +702 -0
  144. package/src/setup/wizard.ts +242 -0
  145. package/src/types/fossil-delta.d.ts +4 -0
  146. package/src/types.ts +287 -0
  147. package/src/utils/__tests__/gitignore.test.ts +174 -0
  148. package/src/utils/gitignore.ts +91 -0
  149. package/src/utils/logger.ts +578 -0
  150. package/src/utils/project-dir.ts +218 -0
  151. package/src/utils/ripgrep.ts +180 -0
  152. package/src/utils/tool-detection.ts +141 -0
  153. package/src/watcher/FileWatcher.ts +53 -0
  154. package/tsconfig.json +24 -0
  155. package/vitest.config.ts +19 -0
@@ -0,0 +1,742 @@
1
+ /**
2
+ * Filesystem service - handles file operations with fossil-delta compression
3
+ */
4
+ import * as fs from 'fs/promises';
5
+ import * as fsSync from 'fs';
6
+ import * as path from 'path';
7
+ import * as crypto from 'crypto';
8
+ import fossilDelta from 'fossil-delta';
9
+ import writeFileAtomic from 'write-file-atomic';
10
+ import { ErrorCode, createRPCError } from '../types.js';
11
+ import { parseFileSize } from '../config/config.js';
12
+ import { logFsRead, logFsWrite } from '../utils/logger.js';
13
+ export class FilesystemService {
14
+ constructor(rootPath, config) {
15
+ this.rootPath = rootPath;
16
+ this.config = config;
17
+ this.realRootPath = null;
18
+ // Resolve rootPath symlinks once during construction
19
+ // This ensures consistent path comparisons in validatePath
20
+ this.resolvedRootPath = path.resolve(rootPath);
21
+ }
22
+ /**
23
+ * Get the real root path (following symlinks), cached after first call
24
+ */
25
+ async getRealRootPath() {
26
+ if (!this.realRootPath) {
27
+ this.realRootPath = await fs.realpath(this.rootPath);
28
+ }
29
+ return this.realRootPath;
30
+ }
31
+ /**
32
+ * Handle filesystem RPC methods
33
+ */
34
+ async handle(method, params, socket) {
35
+ const deviceId = socket.data.deviceId;
36
+ let result;
37
+ let error;
38
+ try {
39
+ // Validate and sandbox path
40
+ const safePath = params.path ? await this.validatePath(params.path) : undefined;
41
+ switch (method) {
42
+ case 'exists':
43
+ result = await this.exists(safePath);
44
+ logFsRead(method, params, deviceId, true);
45
+ return result;
46
+ case 'readFile':
47
+ result = await this.readFile(safePath, params);
48
+ logFsRead(method, params, deviceId, true, undefined, { size: result.size, encoding: result.encoding });
49
+ return result;
50
+ case 'write':
51
+ result = await this.write(safePath, params);
52
+ logFsWrite(method, params, deviceId, true, undefined, { size: result.size });
53
+ return result;
54
+ case 'patchFile':
55
+ result = await this.patchFile(safePath, params);
56
+ logFsWrite(method, params, deviceId, true, undefined, { size: result.size });
57
+ return result;
58
+ case 'getFileHash':
59
+ result = await this.getFileHash(safePath);
60
+ logFsRead(method, params, deviceId, true, undefined, { hash: result.hash });
61
+ return result;
62
+ case 'remove':
63
+ result = await this.remove(safePath);
64
+ logFsWrite(method, params, deviceId, true);
65
+ return result;
66
+ case 'mkdir':
67
+ result = await this.mkdir(safePath, false);
68
+ logFsWrite(method, params, deviceId, true);
69
+ return result;
70
+ case 'mkdirp':
71
+ result = await this.mkdir(safePath, true);
72
+ logFsWrite(method, params, deviceId, true);
73
+ return result;
74
+ case 'readdir':
75
+ result = await this.readdir(safePath, params);
76
+ logFsRead(method, params, deviceId, true, undefined, { count: result.entries.length });
77
+ return result;
78
+ case 'readdirDeep':
79
+ result = await this.readdirDeep(safePath, params);
80
+ logFsRead(method, params, deviceId, true, undefined, { count: result.length });
81
+ return result;
82
+ case 'bulkExists':
83
+ result = await this.bulkExists(safePath, params);
84
+ logFsRead(method, params, deviceId, true, undefined, { count: result.length });
85
+ return result;
86
+ case 'lstat':
87
+ result = await this.lstat(safePath);
88
+ logFsRead(method, params, deviceId, true, undefined, { isFile: result.isFile, isDirectory: result.isDirectory });
89
+ return result;
90
+ case 'mv':
91
+ result = await this.mv(await this.validatePath(params.src), await this.validatePath(params.target), params.opts);
92
+ logFsWrite(method, params, deviceId, true, undefined, { type: result });
93
+ return result;
94
+ case 'copy':
95
+ result = await this.copy(await this.validatePath(params.oldpath), safePath, params.opts);
96
+ logFsWrite(method, params, deviceId, true, undefined, { type: result });
97
+ return result;
98
+ case 'rmdir':
99
+ result = await this.rmdir(safePath);
100
+ logFsWrite(method, params, deviceId, true);
101
+ return result;
102
+ default:
103
+ throw createRPCError(ErrorCode.METHOD_NOT_FOUND, `Method not found: fs.${method}`);
104
+ }
105
+ }
106
+ catch (err) {
107
+ error = err;
108
+ // Determine if this was a read or write operation for logging
109
+ const readOps = ['exists', 'readFile', 'getFileHash', 'readdir', 'readdirDeep', 'bulkExists', 'lstat'];
110
+ if (readOps.includes(method)) {
111
+ logFsRead(method, params, deviceId, false, error);
112
+ }
113
+ else {
114
+ logFsWrite(method, params, deviceId, false, error);
115
+ }
116
+ throw error;
117
+ }
118
+ }
119
+ /**
120
+ * Validate and sandbox path with symlink resolution
121
+ * Prevents directory traversal and symlink escape attacks
122
+ */
123
+ async validatePath(userPath) {
124
+ // Normalize path
125
+ const normalized = path.normalize(userPath);
126
+ // Prevent directory traversal
127
+ if (normalized.includes('..')) {
128
+ throw createRPCError(ErrorCode.INVALID_PATH, 'Invalid path: directory traversal not allowed');
129
+ }
130
+ // Resolve to absolute path
131
+ const absolute = path.resolve(this.rootPath, normalized.startsWith('/') ? normalized.slice(1) : normalized);
132
+ // Check if within root (before symlink resolution)
133
+ if (!absolute.startsWith(this.resolvedRootPath)) {
134
+ throw createRPCError(ErrorCode.INVALID_PATH, 'Access denied: path outside root directory');
135
+ }
136
+ // Resolve symlinks and verify they stay within root
137
+ try {
138
+ // Try to resolve the path (follows all symlinks including in rootPath)
139
+ const realPath = await fs.realpath(absolute);
140
+ // Get real root path (cached, in case rootPath itself contains symlinks like /tmp -> /private/tmp)
141
+ const realRoot = await this.getRealRootPath();
142
+ // Check resolved path is still within resolved root
143
+ if (!realPath.startsWith(realRoot)) {
144
+ throw createRPCError(ErrorCode.INVALID_PATH, 'Access denied: symlink points outside root directory');
145
+ }
146
+ return realPath;
147
+ }
148
+ catch (error) {
149
+ // Path doesn't exist - validate parent directory instead
150
+ if (error.code === 'ENOENT') {
151
+ const parentDir = path.dirname(absolute);
152
+ try {
153
+ const parentReal = await fs.realpath(parentDir);
154
+ const realRoot = await this.getRealRootPath();
155
+ // Check parent directory is within root
156
+ if (!parentReal.startsWith(realRoot)) {
157
+ throw createRPCError(ErrorCode.INVALID_PATH, 'Access denied: parent directory symlink points outside root');
158
+ }
159
+ // Return original absolute path (not parent)
160
+ return absolute;
161
+ }
162
+ catch (parentError) {
163
+ // Parent doesn't exist either - allow for mkdirp operations
164
+ // Just validate the normalized path structure
165
+ return absolute;
166
+ }
167
+ }
168
+ // Re-throw validation errors
169
+ if (error.code === ErrorCode.INVALID_PATH) {
170
+ throw error;
171
+ }
172
+ // For other errors, continue with original path
173
+ return absolute;
174
+ }
175
+ }
176
+ /**
177
+ * Check if file/directory exists
178
+ */
179
+ async exists(safePath) {
180
+ try {
181
+ await fs.access(safePath);
182
+ return { exists: true };
183
+ }
184
+ catch {
185
+ return { exists: false };
186
+ }
187
+ }
188
+ /**
189
+ * Check existence of multiple paths in parallel
190
+ */
191
+ async bulkExists(basePath, params) {
192
+ const { paths } = params;
193
+ if (!Array.isArray(paths)) {
194
+ throw createRPCError(ErrorCode.INVALID_PARAMS, 'paths must be an array');
195
+ }
196
+ // basePath is already validated/resolved by validatePath in handle()
197
+ const realRoot = await this.getRealRootPath();
198
+ // Validate and check all paths in parallel
199
+ // Return 1/0 instead of true/false for bandwidth efficiency
200
+ const results = await Promise.all(paths.map(async (relativePath) => {
201
+ try {
202
+ // Normalize relative path and prevent traversal
203
+ const normalized = path.normalize(relativePath);
204
+ if (normalized.includes('..'))
205
+ return 0;
206
+ // Resolve against validated base path
207
+ const absolute = path.resolve(basePath, normalized);
208
+ // Ensure still within root
209
+ if (!absolute.startsWith(realRoot))
210
+ return 0;
211
+ await fs.access(absolute);
212
+ return 1;
213
+ }
214
+ catch {
215
+ return 0;
216
+ }
217
+ }));
218
+ return results;
219
+ }
220
+ /**
221
+ * Read file contents
222
+ */
223
+ async readFile(safePath, params) {
224
+ try {
225
+ const stats = await fs.stat(safePath);
226
+ // Check file size limit
227
+ const maxSize = parseFileSize(this.config.maxFileSize);
228
+ if (stats.size > maxSize) {
229
+ throw createRPCError(ErrorCode.FILE_TOO_LARGE, `File too large: ${stats.size} bytes (max: ${this.config.maxFileSize})`, { size: stats.size, maxSize });
230
+ }
231
+ const encoding = params.encoding || 'utf8';
232
+ if (encoding === 'binary') {
233
+ // Binary file - return buffer directly in response
234
+ const buffer = await fs.readFile(safePath);
235
+ const sha256 = crypto.createHash('sha256').update(buffer).digest('hex');
236
+ return {
237
+ buffer,
238
+ size: stats.size,
239
+ mtime: stats.mtimeMs,
240
+ encoding: 'binary',
241
+ sha256,
242
+ };
243
+ }
244
+ else {
245
+ // Text file
246
+ const contents = await fs.readFile(safePath, encoding);
247
+ const contentsStr = typeof contents === 'string' ? contents : contents.toString('utf8');
248
+ const sha256 = crypto.createHash('sha256').update(contentsStr, 'utf8').digest('hex');
249
+ return {
250
+ contents,
251
+ size: stats.size,
252
+ mtime: stats.mtimeMs,
253
+ encoding,
254
+ sha256,
255
+ };
256
+ }
257
+ }
258
+ catch (error) {
259
+ if (error.code === 'ENOENT') {
260
+ throw createRPCError(ErrorCode.FILE_NOT_FOUND, `File not found: ${safePath}`);
261
+ }
262
+ throw error;
263
+ }
264
+ }
265
+ /**
266
+ * Write file contents
267
+ */
268
+ async write(safePath, params) {
269
+ // Check if expectedHash provided (conflict detection)
270
+ if (params.expectedHash) {
271
+ const currentHash = await this.getFileHashValue(safePath);
272
+ if (currentHash && currentHash !== params.expectedHash) {
273
+ throw createRPCError(ErrorCode.WRITE_CONFLICT, 'File was modified on server since last read', {
274
+ expectedHash: params.expectedHash,
275
+ currentHash,
276
+ });
277
+ }
278
+ }
279
+ const encoding = params.encoding || 'utf8';
280
+ const atomic = params.atomic || false;
281
+ // Write file (atomic or regular)
282
+ if (atomic) {
283
+ // Use write-file-atomic for atomic writes
284
+ if (encoding === 'binary') {
285
+ const buffer = typeof params.contents === 'string'
286
+ ? Buffer.from(params.contents, 'base64')
287
+ : Buffer.from(params.contents || Buffer.alloc(0));
288
+ await writeFileAtomic(safePath, buffer);
289
+ }
290
+ else {
291
+ await writeFileAtomic(safePath, params.contents, { encoding });
292
+ }
293
+ }
294
+ else {
295
+ // Regular write
296
+ if (encoding === 'binary') {
297
+ const buffer = typeof params.contents === 'string'
298
+ ? Buffer.from(params.contents, 'base64')
299
+ : Buffer.from(params.contents || Buffer.alloc(0));
300
+ await fs.writeFile(safePath, buffer);
301
+ }
302
+ else {
303
+ await fs.writeFile(safePath, params.contents, encoding);
304
+ }
305
+ }
306
+ // Set executable if requested
307
+ if (params.executable) {
308
+ await fs.chmod(safePath, 0o755);
309
+ }
310
+ // Return metadata
311
+ const stats = await fs.stat(safePath);
312
+ // Check total file size after write to prevent bypass via multiple writes
313
+ const maxSize = parseFileSize(this.config.maxFileSize);
314
+ if (stats.size > maxSize) {
315
+ // Rollback: delete the oversized file
316
+ try {
317
+ await fs.unlink(safePath);
318
+ }
319
+ catch (unlinkError) {
320
+ // Ignore unlink errors (file might be locked)
321
+ }
322
+ throw createRPCError(ErrorCode.FILE_TOO_LARGE, `File exceeds maximum size after write: ${stats.size} bytes (max: ${this.config.maxFileSize})`, { size: stats.size, maxSize });
323
+ }
324
+ const contents = await fs.readFile(safePath);
325
+ const sha256 = crypto.createHash('sha256').update(contents).digest('hex');
326
+ return {
327
+ success: true,
328
+ mtime: stats.mtimeMs,
329
+ size: stats.size,
330
+ sha256,
331
+ };
332
+ }
333
+ /**
334
+ * Apply fossil-delta patch to file
335
+ */
336
+ async patchFile(safePath, params) {
337
+ try {
338
+ // Read current file
339
+ const currentContents = await fs.readFile(safePath);
340
+ // Verify base hash
341
+ const currentHash = crypto.createHash('sha256').update(currentContents).digest('hex');
342
+ if (currentHash !== params.baseHash) {
343
+ throw createRPCError(ErrorCode.WRITE_CONFLICT, 'Base hash mismatch - file was modified', {
344
+ expectedHash: params.baseHash,
345
+ currentHash,
346
+ });
347
+ }
348
+ // Apply fossil-delta patch
349
+ const deltaBuffer = Buffer.from(params.delta);
350
+ const patchedResult = fossilDelta.apply(currentContents, deltaBuffer);
351
+ // Convert to Buffer if it's an array
352
+ const patchedContents = Buffer.isBuffer(patchedResult)
353
+ ? patchedResult
354
+ : Buffer.from(patchedResult);
355
+ // Verify final hash
356
+ const finalHash = crypto.createHash('sha256').update(patchedContents).digest('hex');
357
+ // Check if delta resulted in expected content (80% efficiency check happens client-side)
358
+ if (params.newHash && finalHash !== params.newHash) {
359
+ throw createRPCError(ErrorCode.DELTA_PATCH_FAILED, 'Final hash mismatch - patch resulted in unexpected content', {
360
+ expectedHash: params.newHash,
361
+ actualHash: finalHash,
362
+ });
363
+ }
364
+ // Write file (atomic or regular)
365
+ const atomic = params.atomic || false;
366
+ const encoding = params.encoding || 'utf8';
367
+ if (atomic) {
368
+ await writeFileAtomic(safePath, patchedContents, { encoding });
369
+ }
370
+ else {
371
+ await fs.writeFile(safePath, patchedContents, encoding);
372
+ }
373
+ // Return metadata
374
+ const stats = await fs.stat(safePath);
375
+ return {
376
+ success: true,
377
+ finalHash,
378
+ size: stats.size,
379
+ mtime: stats.mtimeMs,
380
+ };
381
+ }
382
+ catch (error) {
383
+ if (error.code && error.message) {
384
+ throw error; // Re-throw RPC errors
385
+ }
386
+ throw createRPCError(ErrorCode.DELTA_PATCH_FAILED, `Failed to apply delta patch: ${error.message}`, { reason: error.toString() });
387
+ }
388
+ }
389
+ /**
390
+ * Get file hash
391
+ */
392
+ async getFileHash(safePath) {
393
+ const hash = await this.getFileHashValue(safePath);
394
+ const stats = await fs.stat(safePath);
395
+ return {
396
+ hash,
397
+ size: stats.size,
398
+ mtime: stats.mtimeMs,
399
+ };
400
+ }
401
+ /**
402
+ * Get file hash value (internal)
403
+ */
404
+ async getFileHashValue(safePath) {
405
+ try {
406
+ const contents = await fs.readFile(safePath);
407
+ return crypto.createHash('sha256').update(contents).digest('hex');
408
+ }
409
+ catch (error) {
410
+ if (error.code === 'ENOENT') {
411
+ return null;
412
+ }
413
+ throw error;
414
+ }
415
+ }
416
+ /**
417
+ * Remove file or directory
418
+ * Returns success even if file doesn't exist (idempotent operation)
419
+ */
420
+ async remove(safePath) {
421
+ try {
422
+ const stats = await fs.stat(safePath);
423
+ if (stats.isDirectory()) {
424
+ await fs.rm(safePath, { recursive: true, force: true });
425
+ }
426
+ else {
427
+ await fs.unlink(safePath);
428
+ }
429
+ return { success: true };
430
+ }
431
+ catch (error) {
432
+ if (error.code === 'ENOENT') {
433
+ // File doesn't exist - treat as already removed (idempotent)
434
+ return { success: true };
435
+ }
436
+ throw error;
437
+ }
438
+ }
439
+ /**
440
+ * Create directory
441
+ */
442
+ async mkdir(safePath, recursive) {
443
+ await fs.mkdir(safePath, { recursive });
444
+ return { success: true };
445
+ }
446
+ /**
447
+ * Read directory contents
448
+ */
449
+ async readdir(safePath, params) {
450
+ try {
451
+ const entries = await fs.readdir(safePath);
452
+ const result = [];
453
+ const ignoreSet = new Set(['.git', '.spck-editor', '.DS_Store']);
454
+ for (const name of entries) {
455
+ if (ignoreSet.has(name))
456
+ continue;
457
+ const entryPath = path.join(safePath, name);
458
+ const stats = await fs.stat(entryPath);
459
+ // Skip based on filters
460
+ if (params.skipFiles && stats.isFile())
461
+ continue;
462
+ else if (params.skipFolders && stats.isDirectory())
463
+ continue;
464
+ // Return just the name string, not the object
465
+ result.push(name);
466
+ }
467
+ return { entries: result };
468
+ }
469
+ catch (error) {
470
+ if (error.code === 'ENOENT') {
471
+ throw createRPCError(ErrorCode.FILE_NOT_FOUND, `Directory not found: ${safePath}`);
472
+ }
473
+ throw error;
474
+ }
475
+ }
476
+ /**
477
+ * Read directory recursively using breadth-first (level-order) traversal
478
+ * Performance optimizations:
479
+ * - matchPattern: Regex to filter paths (applied to full relative path)
480
+ * - limit: Maximum number of results (files + folders combined)
481
+ *
482
+ * Breadth-first ensures top-level items are returned first, which is important
483
+ * when using the limit parameter to get a representative sample of the directory.
484
+ */
485
+ async readdirDeep(safePath, params) {
486
+ try {
487
+ const includeFiles = params.files !== false; // Default true
488
+ const includeFolders = params.folders !== false; // Default true
489
+ const limit = params.limit !== undefined && params.limit !== null
490
+ ? parseInt(params.limit, 10)
491
+ : null;
492
+ // Compile regex pattern if provided (case-insensitive)
493
+ let matchRegex = null;
494
+ if (params.matchPattern) {
495
+ try {
496
+ matchRegex = new RegExp(params.matchPattern, 'i');
497
+ }
498
+ catch (error) {
499
+ throw createRPCError(ErrorCode.INVALID_PARAMS, `Invalid matchPattern regex: ${error.message}`);
500
+ }
501
+ }
502
+ // Parse ignoreName into a Set
503
+ const ignoreSet = new Set(['.git', '.spck-editor']);
504
+ if (params.ignoreName && typeof params.ignoreName === 'string') {
505
+ params.ignoreName.split(':').forEach((name) => {
506
+ if (name.trim()) {
507
+ ignoreSet.add(name.trim());
508
+ }
509
+ });
510
+ }
511
+ const results = [];
512
+ // Early exit if limit is 0
513
+ if (limit === 0) {
514
+ return [];
515
+ }
516
+ // Verify initial directory exists before starting traversal
517
+ try {
518
+ const stats = await fs.stat(safePath);
519
+ if (!stats.isDirectory()) {
520
+ throw createRPCError(ErrorCode.FILE_NOT_FOUND, `Not a directory: ${safePath}`);
521
+ }
522
+ }
523
+ catch (error) {
524
+ if (error.code === 'ENOENT') {
525
+ throw createRPCError(ErrorCode.FILE_NOT_FOUND, `Directory not found: ${safePath}`);
526
+ }
527
+ throw error;
528
+ }
529
+ // Get real root path for relative path calculations
530
+ // (safePath might be resolved real path if it was a symlink)
531
+ const realRoot = await this.getRealRootPath();
532
+ // Breadth-first traversal using a queue
533
+ let queue = [safePath];
534
+ while (queue.length > 0 && (limit === null || results.length < limit)) {
535
+ // Process current level
536
+ const currentLevel = queue;
537
+ queue = []; // New queue for next level
538
+ for (const currentPath of currentLevel) {
539
+ // Early exit if limit reached
540
+ if (limit !== null && results.length >= limit) {
541
+ break;
542
+ }
543
+ let entries;
544
+ try {
545
+ entries = await fs.readdir(currentPath);
546
+ }
547
+ catch (error) {
548
+ // Skip subdirectories we can't read (but initial dir should have been validated)
549
+ continue;
550
+ }
551
+ for (const name of entries) {
552
+ // Early exit if limit reached
553
+ if (limit !== null && results.length >= limit) {
554
+ break;
555
+ }
556
+ // Skip ignored names
557
+ if (ignoreSet.has(name)) {
558
+ continue;
559
+ }
560
+ const entryPath = path.join(currentPath, name);
561
+ let stats;
562
+ try {
563
+ stats = await fs.stat(entryPath);
564
+ }
565
+ catch (error) {
566
+ // Skip entries we can't stat
567
+ continue;
568
+ }
569
+ if (stats.isDirectory()) {
570
+ // Add to next level queue
571
+ queue.push(entryPath);
572
+ // Convert to relative path using real root
573
+ const outputPath = path.relative(realRoot, entryPath);
574
+ // Apply filter and check limit for folders
575
+ if (includeFolders) {
576
+ const matches = !matchRegex || matchRegex.test(outputPath);
577
+ if (matches) {
578
+ results.push(outputPath);
579
+ }
580
+ }
581
+ }
582
+ else if (stats.isFile() && includeFiles) {
583
+ // Convert to relative path using real root
584
+ const outputPath = path.relative(realRoot, entryPath);
585
+ // Apply filter and check limit for files
586
+ const matches = !matchRegex || matchRegex.test(outputPath);
587
+ if (matches) {
588
+ results.push(outputPath);
589
+ }
590
+ }
591
+ }
592
+ }
593
+ }
594
+ return results;
595
+ }
596
+ catch (error) {
597
+ if (error.code === 'ENOENT') {
598
+ throw createRPCError(ErrorCode.FILE_NOT_FOUND, `Directory not found: ${safePath}`);
599
+ }
600
+ throw error;
601
+ }
602
+ }
603
+ /**
604
+ * Get file metadata
605
+ */
606
+ async lstat(safePath) {
607
+ try {
608
+ const stats = await fs.lstat(safePath);
609
+ return {
610
+ mode: stats.mode,
611
+ size: stats.size,
612
+ mtimeMs: stats.mtimeMs,
613
+ ctimeMs: stats.ctimeMs,
614
+ atimeMs: stats.atimeMs,
615
+ isFile: stats.isFile(),
616
+ isDirectory: stats.isDirectory(),
617
+ isSymbolicLink: stats.isSymbolicLink(),
618
+ };
619
+ }
620
+ catch (error) {
621
+ if (error.code === 'ENOENT') {
622
+ throw createRPCError(ErrorCode.FILE_NOT_FOUND, `File not found: ${safePath}`);
623
+ }
624
+ throw error;
625
+ }
626
+ }
627
+ /**
628
+ * Move/rename file or directory
629
+ * Returns the type of the moved item ('file' or 'folder')
630
+ */
631
+ async mv(srcPath, targetPath, opts = {}) {
632
+ try {
633
+ // Check source type before moving
634
+ const srcStats = await fs.stat(srcPath);
635
+ const type = srcStats.isDirectory() ? 'folder' : 'file';
636
+ // Ensure target directory exists
637
+ const targetDir = path.dirname(targetPath);
638
+ try {
639
+ await fs.access(targetDir);
640
+ }
641
+ catch (error) {
642
+ if (error.code === 'ENOENT') {
643
+ // Create target directory if it doesn't exist
644
+ await fs.mkdir(targetDir, { recursive: true });
645
+ }
646
+ }
647
+ // Check if target exists
648
+ if (!opts.overwrite) {
649
+ try {
650
+ await fs.access(targetPath);
651
+ throw createRPCError(ErrorCode.INVALID_PATH, 'Target already exists and overwrite is false');
652
+ }
653
+ catch (error) {
654
+ if (error.code !== 'ENOENT')
655
+ throw error;
656
+ }
657
+ }
658
+ await fs.rename(srcPath, targetPath);
659
+ return type;
660
+ }
661
+ catch (error) {
662
+ if (error.code === 'ENOENT') {
663
+ // If this is a deletion (move to trash) and source doesn't exist, treat as already deleted
664
+ if (opts.deletion) {
665
+ return 'file'; // Return default type for deleted files
666
+ }
667
+ throw createRPCError(ErrorCode.FILE_NOT_FOUND, `Source file not found: ${srcPath} (attempting to move to ${targetPath})`);
668
+ }
669
+ throw error;
670
+ }
671
+ }
672
+ /**
673
+ * Copy file or directory
674
+ * Returns the type of the copied item ('file' or 'folder')
675
+ */
676
+ async copy(oldPath, newPath, opts = {}) {
677
+ try {
678
+ // Check source type before copying
679
+ const srcStats = await fs.stat(oldPath);
680
+ const type = srcStats.isDirectory() ? 'folder' : 'file';
681
+ if (srcStats.isDirectory()) {
682
+ // Copy directory recursively
683
+ await this.copyDirectory(oldPath, newPath, opts);
684
+ }
685
+ else {
686
+ // Copy file
687
+ const flags = opts.overwrite ? 0 : fsSync.constants.COPYFILE_EXCL;
688
+ await fs.copyFile(oldPath, newPath, flags);
689
+ }
690
+ return type;
691
+ }
692
+ catch (error) {
693
+ if (error.code === 'ENOENT') {
694
+ throw createRPCError(ErrorCode.FILE_NOT_FOUND, `Source file not found: ${oldPath}`);
695
+ }
696
+ if (error.code === 'EEXIST') {
697
+ throw createRPCError(ErrorCode.INVALID_PATH, 'Target already exists and overwrite is false');
698
+ }
699
+ throw error;
700
+ }
701
+ }
702
+ /**
703
+ * Copy directory recursively (helper method)
704
+ */
705
+ async copyDirectory(srcDir, destDir, opts = {}) {
706
+ // Create destination directory
707
+ await fs.mkdir(destDir, { recursive: true });
708
+ // Read source directory
709
+ const entries = await fs.readdir(srcDir, { withFileTypes: true });
710
+ for (const entry of entries) {
711
+ const srcPath = path.join(srcDir, entry.name);
712
+ const destPath = path.join(destDir, entry.name);
713
+ if (entry.isDirectory()) {
714
+ // Recursively copy subdirectory
715
+ await this.copyDirectory(srcPath, destPath, opts);
716
+ }
717
+ else {
718
+ // Copy file
719
+ const flags = opts.overwrite ? 0 : fsSync.constants.COPYFILE_EXCL;
720
+ await fs.copyFile(srcPath, destPath, flags);
721
+ }
722
+ }
723
+ }
724
+ /**
725
+ * Remove directory
726
+ * Returns success even if directory doesn't exist (idempotent operation)
727
+ */
728
+ async rmdir(safePath) {
729
+ try {
730
+ await fs.rmdir(safePath);
731
+ return { success: true };
732
+ }
733
+ catch (error) {
734
+ if (error.code === 'ENOENT') {
735
+ // Directory doesn't exist - treat as already removed (idempotent)
736
+ return { success: true };
737
+ }
738
+ throw error;
739
+ }
740
+ }
741
+ }
742
+ //# sourceMappingURL=FilesystemService.js.map