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