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,809 @@
1
+ /**
2
+ * Search service - efficient server-side text search
3
+ *
4
+ * Design principles:
5
+ * - Use ripgrep when available for maximum performance
6
+ * - Stream-based processing to handle large files efficiently
7
+ * - Buffer-based searching for performance
8
+ * - Cross-platform compatible
9
+ * - Memory-efficient with configurable limits
10
+ */
11
+
12
+ import * as fs from 'fs';
13
+ import * as path from 'path';
14
+ import { ErrorCode, createRPCError, AuthenticatedSocket } from '../types.js';
15
+ import { logSearchRead } from '../utils/logger.js';
16
+ import { isRipgrepAvailable, executeRipgrepStream } from '../utils/ripgrep.js';
17
+
18
+ interface StreamSearchParams {
19
+ glob: string;
20
+ rootDir?: string;
21
+ maxResults: number;
22
+ maxLength: number;
23
+ searchTerm: string;
24
+ matchCase: boolean;
25
+ useRegEx: boolean;
26
+ onlyWholeWords: boolean;
27
+ }
28
+
29
+ interface SearchResult {
30
+ start: { row: number; column: number };
31
+ end: { row: number; column: number };
32
+ range: { start: number; end: number };
33
+ line: string;
34
+ value: string;
35
+ match: { start: number; end: number };
36
+ path: string;
37
+ }
38
+
39
+ export class SearchService {
40
+ private maxFileSize: number;
41
+ private chunkSize: number;
42
+ private ripgrepAvailable: boolean | null = null;
43
+ private ripgrepForceDisabled: boolean = false;
44
+
45
+ constructor(
46
+ private rootPath: string = process.cwd(),
47
+ maxFileSize: number = 10 * 1024 * 1024, // 10MB default
48
+ chunkSize: number = 64 * 1024, // 64KB chunks
49
+ ripgrepEnabled: boolean = true // Allow force disabling ripgrep
50
+ ) {
51
+ this.maxFileSize = maxFileSize;
52
+ this.chunkSize = chunkSize;
53
+ this.ripgrepForceDisabled = !ripgrepEnabled;
54
+
55
+ // Check ripgrep availability on initialization (unless force disabled)
56
+ if (ripgrepEnabled) {
57
+ this.checkRipgrepAvailability();
58
+ } else {
59
+ this.ripgrepAvailable = false;
60
+ }
61
+ }
62
+
63
+ /**
64
+ * Check if ripgrep is available (async, caches result)
65
+ */
66
+ private async checkRipgrepAvailability(): Promise<void> {
67
+ if (this.ripgrepAvailable === null) {
68
+ this.ripgrepAvailable = await isRipgrepAvailable();
69
+ }
70
+ }
71
+
72
+ /**
73
+ * Check if character is a word boundary (space or punctuation)
74
+ */
75
+ private isWordBoundary(char: string): boolean {
76
+ return /[\W]/.test(char);
77
+ }
78
+
79
+ /**
80
+ * Find word boundary going backwards from position
81
+ */
82
+ private findWordBoundaryBackward(text: string, pos: number): number {
83
+ if (pos <= 0) return 0;
84
+
85
+ // Move back to find a word boundary
86
+ for (let i = pos; i >= 0; i--) {
87
+ if (this.isWordBoundary(text[i])) {
88
+ // Return position after the boundary character
89
+ return i + 1;
90
+ }
91
+ }
92
+ return 0;
93
+ }
94
+
95
+ /**
96
+ * Find word boundary going forward from position
97
+ */
98
+ private findWordBoundaryForward(text: string, pos: number): number {
99
+ if (pos >= text.length) return text.length;
100
+
101
+ // Move forward to find a word boundary
102
+ for (let i = pos; i < text.length; i++) {
103
+ if (this.isWordBoundary(text[i])) {
104
+ // Return position of the boundary character
105
+ return i;
106
+ }
107
+ }
108
+ return text.length;
109
+ }
110
+
111
+ /**
112
+ * Trim line text to show context around match
113
+ * - Trims to word boundaries
114
+ * - Adds "…" prefix/suffix if trimmed
115
+ * - Returns trimmed line and offset
116
+ */
117
+ private trimLineToMatch(lineText: string, matchStart: number, matchEnd: number, maxLength: number): { line: string; offset: number } {
118
+ // Default maxLength if not provided or invalid
119
+ if (!maxLength || maxLength <= 0) {
120
+ maxLength = 500;
121
+ }
122
+
123
+ // Ensure valid match positions
124
+ if (matchStart < 0 || matchEnd > lineText.length || matchStart >= matchEnd) {
125
+ return { line: lineText, offset: 0 };
126
+ }
127
+
128
+ // If line is short enough, return as-is
129
+ if (lineText.length <= maxLength) {
130
+ return { line: lineText, offset: 0 };
131
+ }
132
+
133
+ const matchLength = matchEnd - matchStart;
134
+
135
+ // If match itself is longer than maxLength, just show the start of the match
136
+ if (matchLength >= maxLength) {
137
+ const trimmed = lineText.substring(matchStart, matchStart + maxLength);
138
+ return {
139
+ line: trimmed,
140
+ offset: matchStart
141
+ };
142
+ }
143
+
144
+ // Calculate initial context on each side
145
+ const contextPerSide = Math.floor((maxLength - matchLength) / 2);
146
+
147
+ // Initial positions
148
+ let start = Math.max(0, matchStart - contextPerSide);
149
+ let end = Math.min(lineText.length, matchEnd + contextPerSide);
150
+
151
+ // Adjust to word boundaries
152
+ if (start > 0) {
153
+ const wordStart = this.findWordBoundaryBackward(lineText, start);
154
+ // Only use word boundary if it doesn't shrink the context too much
155
+ if (matchStart - wordStart >= contextPerSide * 0.5) {
156
+ start = wordStart;
157
+ }
158
+ }
159
+
160
+ if (end < lineText.length) {
161
+ const wordEnd = this.findWordBoundaryForward(lineText, end);
162
+ // Only use word boundary if it doesn't shrink the context too much
163
+ if (wordEnd - matchEnd >= contextPerSide * 0.5) {
164
+ end = wordEnd;
165
+ }
166
+ }
167
+
168
+ // Try to extend to include more complete words if we have room
169
+ let currentLength = end - start;
170
+
171
+ // Extend backward
172
+ while (start > 0 && currentLength < maxLength) {
173
+ const prevBoundary = this.findWordBoundaryBackward(lineText, start - 1);
174
+
175
+ // Break if we didn't move (no progress)
176
+ if (prevBoundary >= start) break;
177
+
178
+ const newLength = end - prevBoundary;
179
+
180
+ // Don't extend if it would exceed maxLength
181
+ if (newLength > maxLength) break;
182
+
183
+ start = prevBoundary;
184
+ currentLength = newLength;
185
+
186
+ if (currentLength >= maxLength) break;
187
+ }
188
+
189
+ // Extend forward
190
+ while (end < lineText.length && currentLength < maxLength) {
191
+ const nextBoundary = this.findWordBoundaryForward(lineText, end + 1);
192
+
193
+ // Break if we didn't move (no progress)
194
+ if (nextBoundary <= end) break;
195
+
196
+ const newLength = nextBoundary - start;
197
+
198
+ // Don't extend if it would exceed maxLength
199
+ if (newLength > maxLength) break;
200
+
201
+ end = nextBoundary;
202
+ currentLength = newLength;
203
+
204
+ if (currentLength >= maxLength) break;
205
+ }
206
+
207
+ // Build result with ellipsis
208
+ let result = lineText.substring(start, end);
209
+ let adjustedOffset = start;
210
+
211
+ // Add leading ellipsis if not at start
212
+ if (start > 0) {
213
+ result = '…' + result;
214
+ // Adjust offset to account for the leading ellipsis
215
+ adjustedOffset = start - 1;
216
+ }
217
+
218
+ // Add trailing ellipsis if not at end
219
+ if (end < lineText.length) {
220
+ result = result + '…';
221
+ }
222
+
223
+ // Final check: ensure result doesn't exceed maxLength
224
+ // This can happen when ellipsis are added
225
+ if (result.length > maxLength) {
226
+ // Trim from the end, preserving the match
227
+ const hasTrailingEllipsis = end < lineText.length;
228
+
229
+ // Calculate how much to trim
230
+ const excess = result.length - maxLength;
231
+
232
+ // Remove trailing ellipsis temporarily if present
233
+ if (hasTrailingEllipsis) {
234
+ result = result.slice(0, -1);
235
+ }
236
+
237
+ // Trim the excess from the end
238
+ result = result.slice(0, result.length - excess);
239
+
240
+ // Re-add trailing ellipsis if it was there
241
+ if (hasTrailingEllipsis) {
242
+ result = result + '…';
243
+ }
244
+ }
245
+
246
+ return {
247
+ line: result,
248
+ offset: adjustedOffset
249
+ };
250
+ }
251
+
252
+ /**
253
+ * Stream search results using ripgrep with glob patterns
254
+ * Falls back to Node.js implementation if ripgrep is not available
255
+ * Sends results in batches of 50 via RPC notifications
256
+ */
257
+ private async findWithStream(params: StreamSearchParams, socket: AuthenticatedSocket): Promise<void> {
258
+ const { glob, maxLength, maxResults, searchTerm, matchCase, useRegEx, onlyWholeWords } = params;
259
+ const deviceId = socket.data.deviceId;
260
+
261
+ // Check ripgrep availability
262
+ await this.checkRipgrepAvailability();
263
+
264
+ if (!this.ripgrepAvailable) {
265
+ // Fall back to Node.js implementation
266
+ return await this.findWithStreamNode(params, socket);
267
+ }
268
+
269
+ // Build ripgrep arguments
270
+ const args: string[] = [
271
+ '--json', // Output as JSON
272
+ '--max-count', maxResults.toString(), // Max matches per file (high limit for streaming)
273
+ '--with-filename', // Include filename in output
274
+ '--line-number', // Include line numbers
275
+ ];
276
+
277
+ // Case sensitivity
278
+ if (matchCase) {
279
+ args.push('--case-sensitive');
280
+ } else {
281
+ args.push('--ignore-case');
282
+ }
283
+
284
+ // Regex mode
285
+ if (!useRegEx) {
286
+ args.push('--fixed-strings');
287
+ }
288
+
289
+ // Whole words
290
+ if (onlyWholeWords) {
291
+ args.push('--word-regexp');
292
+ }
293
+
294
+ // Add glob pattern
295
+ if (glob) {
296
+ args.push('--glob', glob);
297
+ }
298
+
299
+ // Add pattern and search directory
300
+ args.push('--', searchTerm, '.');
301
+
302
+ try {
303
+ let batch: SearchResult[] = [];
304
+ let totalResults = 0;
305
+ const searchRoot = this.rootPath;
306
+
307
+ // Execute ripgrep with streaming - process results as they arrive
308
+ await executeRipgrepStream(searchRoot, args, {
309
+ timeout: 300000, // 5 minute timeout for large searches
310
+ onLine: (line: string) => {
311
+ if (totalResults >= maxResults) return;
312
+
313
+ try {
314
+ const json = JSON.parse(line);
315
+
316
+ // Only process match messages
317
+ if (json.type === 'match') {
318
+ const data = json.data;
319
+ const lineText = data.lines?.text || '';
320
+ const submatches = data.submatches || [];
321
+
322
+ // Skip if no line text
323
+ if (!lineText) {
324
+ return;
325
+ }
326
+
327
+ // Process each submatch
328
+ for (const submatch of submatches) {
329
+ if (totalResults >= maxResults) break;
330
+
331
+ const matchStart = submatch.start;
332
+ const matchEnd = submatch.end;
333
+ const matchValue = lineText.substring(matchStart, matchEnd);
334
+
335
+ // Trim line to show context around match
336
+ const { line: trimmedLine, offset: trimOffset } = this.trimLineToMatch(lineText, matchStart, matchEnd, maxLength);
337
+
338
+ // Calculate match positions relative to trimmed line
339
+ const relativeMatchStart = matchStart - trimOffset;
340
+ const relativeMatchEnd = matchEnd - trimOffset;
341
+
342
+ // Get file path relative to root
343
+ const relativePath = path.relative(searchRoot, data.path.text);
344
+
345
+ batch.push({
346
+ start: {
347
+ row: data.line_number - 1,
348
+ column: matchStart
349
+ },
350
+ end: {
351
+ row: data.line_number - 1,
352
+ column: matchEnd
353
+ },
354
+ range: {
355
+ start: data.absolute_offset + matchStart,
356
+ end: data.absolute_offset + matchEnd
357
+ },
358
+ line: trimmedLine,
359
+ value: matchValue,
360
+ match: {
361
+ start: relativeMatchStart,
362
+ end: relativeMatchEnd
363
+ },
364
+ path: relativePath
365
+ });
366
+
367
+ totalResults++;
368
+
369
+ if (batch.length >= 25) {
370
+ socket.emit('rpc', {
371
+ jsonrpc: '2.0',
372
+ method: 'search.results',
373
+ params: {
374
+ results: batch,
375
+ done: false
376
+ }
377
+ });
378
+ batch = [];
379
+ }
380
+ }
381
+ }
382
+ } catch (error) {
383
+ // Skip malformed JSON lines
384
+ }
385
+ }
386
+ });
387
+
388
+ // Send remaining results if any
389
+ if (batch.length > 0) {
390
+ socket.emit('rpc', {
391
+ jsonrpc: '2.0',
392
+ method: 'search.results',
393
+ params: {
394
+ results: batch,
395
+ done: false
396
+ }
397
+ });
398
+ }
399
+
400
+ // Send completion notification
401
+ socket.emit('rpc', {
402
+ jsonrpc: '2.0',
403
+ method: 'search.results',
404
+ params: {
405
+ results: [],
406
+ done: true,
407
+ total: totalResults
408
+ }
409
+ });
410
+
411
+ logSearchRead('findWithStream', params, deviceId, true, undefined, {
412
+ matches: totalResults,
413
+ method: 'ripgrep-stream',
414
+ glob
415
+ });
416
+ } catch (error: any) {
417
+ logSearchRead('findWithStream', params, deviceId, false, error, {
418
+ method: 'ripgrep-stream',
419
+ glob
420
+ });
421
+
422
+ // Send error notification
423
+ socket.emit('rpc', {
424
+ jsonrpc: '2.0',
425
+ method: 'search.error',
426
+ params: {
427
+ error: error.message
428
+ }
429
+ });
430
+
431
+ throw error;
432
+ }
433
+ }
434
+
435
+ /**
436
+ * Stream search results using Node.js implementation
437
+ * Used as fallback when ripgrep is not available
438
+ */
439
+ private async findWithStreamNode(params: StreamSearchParams, socket: AuthenticatedSocket): Promise<void> {
440
+ const { glob, maxResults, searchTerm, matchCase, useRegEx, onlyWholeWords } = params;
441
+ const deviceId = socket.data.deviceId;
442
+
443
+ try {
444
+ // Get all files in the directory
445
+ const files = await this.getAllFiles(this.rootPath, glob);
446
+
447
+ let batch: SearchResult[] = [];
448
+ let totalResults = 0;
449
+
450
+ // Search through each file
451
+ for (const file of files) {
452
+ if (totalResults >= maxResults) break;
453
+
454
+ try {
455
+ const relativePath = path.relative(this.rootPath, file);
456
+
457
+ // Check file size
458
+ const stats = await fs.promises.stat(file);
459
+ if (stats.size === 0 || stats.size > this.maxFileSize || stats.isDirectory()) {
460
+ continue;
461
+ }
462
+
463
+ // Build regex pattern
464
+ const regex = this.buildRegExp(searchTerm, useRegEx, matchCase, onlyWholeWords);
465
+
466
+ // Search the file
467
+ let results: SearchResult[] | null = null;
468
+ if (stats.size < this.chunkSize * 2) {
469
+ results = await this.searchSmallFile(file, relativePath, regex, 100, 10000);
470
+ } else {
471
+ results = await this.searchLargeFile(file, relativePath, regex, 100, 10000);
472
+ }
473
+
474
+ if (results && results.length > 0) {
475
+ for (const result of results) {
476
+ if (totalResults >= maxResults) break;
477
+
478
+ batch.push(result);
479
+ totalResults++;
480
+
481
+ // Send batch when we reach 20 results
482
+ if (batch.length >= 20) {
483
+ socket.emit('rpc', {
484
+ jsonrpc: '2.0',
485
+ method: 'search.results',
486
+ params: { results: batch, done: false }
487
+ });
488
+ batch = [];
489
+ }
490
+ }
491
+ }
492
+ } catch (error) {
493
+ // Skip files that can't be read
494
+ continue;
495
+ }
496
+ }
497
+
498
+ // Send final batch if any
499
+ if (batch.length > 0) {
500
+ socket.emit('rpc', {
501
+ jsonrpc: '2.0',
502
+ method: 'search.results',
503
+ params: { results: batch, done: false }
504
+ });
505
+ }
506
+
507
+ // Send completion notification
508
+ socket.emit('rpc', {
509
+ jsonrpc: '2.0',
510
+ method: 'search.results',
511
+ params: { results: [], done: true, total: totalResults }
512
+ });
513
+
514
+ logSearchRead('findWithStream', params, deviceId, true, null, {
515
+ matches: totalResults,
516
+ method: 'node-stream',
517
+ glob
518
+ });
519
+ } catch (error: any) {
520
+ logSearchRead('findWithStream', params, deviceId, false, error, {
521
+ method: 'node-stream',
522
+ glob
523
+ });
524
+
525
+ socket.emit('rpc', {
526
+ jsonrpc: '2.0',
527
+ method: 'search.error',
528
+ params: {
529
+ error: error.message
530
+ }
531
+ });
532
+
533
+ throw error;
534
+ }
535
+ }
536
+
537
+ /**
538
+ * Get all files in a directory matching optional glob pattern
539
+ */
540
+ private async getAllFiles(dir: string, globPattern?: string): Promise<string[]> {
541
+ const files: string[] = [];
542
+ const ignoreSet = new Set(['.git', '.spck-editor', '.DS_Store', 'node_modules', 'dist', 'build']);
543
+
544
+ const walk = async (currentDir: string): Promise<void> => {
545
+ const entries = await fs.promises.readdir(currentDir, { withFileTypes: true });
546
+
547
+ for (const entry of entries) {
548
+ if (ignoreSet.has(entry.name)) continue;
549
+
550
+ const fullPath = path.join(currentDir, entry.name);
551
+
552
+ if (entry.isDirectory()) {
553
+ await walk(fullPath);
554
+ } else if (entry.isFile()) {
555
+ // Basic glob matching (simple pattern support)
556
+ if (!globPattern || this.matchGlob(fullPath, dir, globPattern)) {
557
+ files.push(fullPath);
558
+ }
559
+ }
560
+ }
561
+ };
562
+
563
+ await walk(dir);
564
+ return files;
565
+ }
566
+
567
+ /**
568
+ * Simple glob pattern matching
569
+ */
570
+ private matchGlob(filePath: string, baseDir: string, pattern: string): boolean {
571
+ const relativePath = path.relative(baseDir, filePath);
572
+
573
+ // Convert glob pattern to regex
574
+ let regexPattern = pattern
575
+ .replace(/\./g, '\\.')
576
+ .replace(/\*\*/g, '.*')
577
+ .replace(/\*/g, '[^/]*')
578
+ .replace(/\?/g, '.');
579
+
580
+ const regex = new RegExp(`^${regexPattern}$`);
581
+ return regex.test(relativePath);
582
+ }
583
+
584
+ /**
585
+ * Handle search RPC methods
586
+ */
587
+ async handle(method: string, params: any, socket: AuthenticatedSocket): Promise<any> {
588
+ switch (method) {
589
+ case 'findWithStream':
590
+ return await this.findWithStream(params, socket);
591
+ default:
592
+ throw createRPCError(ErrorCode.METHOD_NOT_FOUND, `Method not found: search.${method}`);
593
+ }
594
+ }
595
+
596
+ /**
597
+ * Build regular expression from search parameters
598
+ */
599
+ private buildRegExp(pattern: string, useRegEx: boolean, matchCase: boolean, onlyWholeWords: boolean): RegExp {
600
+ // Escape special regex characters if not using regex mode
601
+ if (!useRegEx) {
602
+ pattern = pattern.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
603
+ }
604
+
605
+ // Add word boundary markers if matching whole words
606
+ if (onlyWholeWords) {
607
+ pattern = '\\b' + pattern + '\\b';
608
+ }
609
+
610
+ const flags = matchCase ? 'gm' : 'igm';
611
+ return new RegExp(pattern, flags);
612
+ }
613
+
614
+ /**
615
+ * Search small files by reading entirely into memory
616
+ */
617
+ private async searchSmallFile(
618
+ absolutePath: string,
619
+ relativePath: string,
620
+ regex: RegExp,
621
+ maxMatchPerFile: number,
622
+ maxLength: number
623
+ ): Promise<SearchResult[] | null> {
624
+ let text: string;
625
+
626
+ try {
627
+ text = await fs.promises.readFile(absolutePath, 'utf8');
628
+ } catch (error: any) {
629
+ // If not valid UTF-8, skip this file
630
+ if (error.message?.includes('invalid') || error.message?.includes('encoding')) {
631
+ return null;
632
+ }
633
+ // Log full error server-side
634
+ console.error('Failed to read file for search:', {
635
+ path: absolutePath,
636
+ error: error.message,
637
+ });
638
+ // Send sanitized error to client
639
+ throw createRPCError(ErrorCode.INTERNAL_ERROR, 'Failed to read file');
640
+ }
641
+
642
+ const results: SearchResult[] = [];
643
+ let match: RegExpExecArray | null;
644
+
645
+ for (let i = 0; i < maxMatchPerFile; i++) {
646
+ match = regex.exec(text);
647
+ if (!match) {
648
+ break;
649
+ }
650
+
651
+ const range = this.getSurroundingLineRange(text, match.index, match[0].length, maxLength);
652
+ results.push({
653
+ start: this.indexToPosition(text, match.index),
654
+ end: this.indexToPosition(text, regex.lastIndex),
655
+ range,
656
+ line: text.slice(range.start, range.end),
657
+ value: match[0],
658
+ match: {
659
+ start: match.index - range.start,
660
+ end: regex.lastIndex - range.start,
661
+ },
662
+ path: relativePath,
663
+ });
664
+ }
665
+
666
+ return results.length > 0 ? results : null;
667
+ }
668
+
669
+ /**
670
+ * Search large files using streaming approach
671
+ * This handles files that might not fit in memory
672
+ */
673
+ private async searchLargeFile(
674
+ absolutePath: string,
675
+ relativePath: string,
676
+ regex: RegExp,
677
+ maxMatchPerFile: number,
678
+ maxLength: number
679
+ ): Promise<SearchResult[] | null> {
680
+ const stream = fs.createReadStream(absolutePath, {
681
+ encoding: 'utf8',
682
+ highWaterMark: this.chunkSize,
683
+ });
684
+
685
+ let buffer = '';
686
+ let offset = 0;
687
+ const results: SearchResult[] = [];
688
+ const overlapSize = Math.max(maxLength * 2, 1024); // Overlap to catch matches at chunk boundaries
689
+
690
+ try {
691
+ for await (const chunk of stream) {
692
+ buffer += chunk;
693
+
694
+ // Search in current buffer
695
+ regex.lastIndex = 0;
696
+ let match: RegExpExecArray | null;
697
+
698
+ while ((match = regex.exec(buffer)) !== null && results.length < maxMatchPerFile) {
699
+ const absoluteIndex = offset + match.index;
700
+
701
+ // Build result
702
+ const range = this.getSurroundingLineRange(buffer, match.index, match[0].length, maxLength);
703
+ const lineText = buffer.slice(range.start, range.end);
704
+
705
+ results.push({
706
+ start: this.indexToPosition(buffer.slice(0, offset + buffer.length), absoluteIndex),
707
+ end: this.indexToPosition(buffer.slice(0, offset + buffer.length), absoluteIndex + match[0].length),
708
+ range: {
709
+ start: offset + range.start,
710
+ end: offset + range.end,
711
+ },
712
+ line: lineText,
713
+ value: match[0],
714
+ match: {
715
+ start: match.index - range.start,
716
+ end: regex.lastIndex - range.start,
717
+ },
718
+ path: relativePath,
719
+ });
720
+
721
+ if (results.length >= maxMatchPerFile) {
722
+ stream.destroy();
723
+ break;
724
+ }
725
+ }
726
+
727
+ if (results.length >= maxMatchPerFile) {
728
+ break;
729
+ }
730
+
731
+ // Keep overlap for next chunk to catch matches at boundaries
732
+ if (buffer.length > overlapSize) {
733
+ offset += buffer.length - overlapSize;
734
+ buffer = buffer.slice(-overlapSize);
735
+ }
736
+ }
737
+ } catch (error: any) {
738
+ // Handle encoding errors gracefully
739
+ if (error.message?.includes('invalid') || error.message?.includes('encoding')) {
740
+ return null;
741
+ }
742
+ // Log full error server-side
743
+ console.error('Failed to search file:', {
744
+ path: absolutePath,
745
+ error: error.message,
746
+ });
747
+ // Send sanitized error to client
748
+ throw createRPCError(ErrorCode.INTERNAL_ERROR, 'Failed to search file');
749
+ }
750
+
751
+ return results.length > 0 ? results : null;
752
+ }
753
+
754
+ /**
755
+ * Get surrounding line range for context
756
+ */
757
+ private getSurroundingLineRange(
758
+ source: string,
759
+ index: number,
760
+ matchLength: number,
761
+ maxLength: number
762
+ ): { start: number; end: number } {
763
+ maxLength = maxLength || 10000;
764
+ maxLength = maxLength > matchLength ? maxLength - matchLength : 0;
765
+
766
+ const indexEnd = index + matchLength;
767
+ let before = source.slice(Math.max(index - maxLength, 0), index);
768
+ let after = source.slice(indexEnd, indexEnd + maxLength);
769
+
770
+ // Trim to line boundaries
771
+ const beforeLines = before.split(/[\n\r]/);
772
+ before = beforeLines[beforeLines.length - 1] || '';
773
+ before = before.replace(/^\s+/, '');
774
+
775
+ const afterLines = after.split(/[\n\r]/);
776
+ after = afterLines[0] || '';
777
+ after = after.replace(/\s+$/, '');
778
+
779
+ // Truncate if too long
780
+ if (before.length + after.length > maxLength) {
781
+ before = before.slice(Math.max(0, before.length - Math.floor(maxLength / 2)));
782
+ after = after.slice(0, maxLength - before.length);
783
+ }
784
+
785
+ return {
786
+ start: index - before.length,
787
+ end: indexEnd + after.length,
788
+ };
789
+ }
790
+
791
+ /**
792
+ * Convert string index to row/column position
793
+ */
794
+ private indexToPosition(str: string, index: number): { row: number; column: number } {
795
+ const beforeMatch = str.slice(0, index);
796
+ const lines = beforeMatch.split(/\r\n|\r|\n/g);
797
+ return {
798
+ row: lines.length - 1,
799
+ column: lines[lines.length - 1]?.length || 0,
800
+ };
801
+ }
802
+
803
+ /**
804
+ * Cleanup resources
805
+ */
806
+ cleanup(): void {
807
+ // No persistent resources to clean up
808
+ }
809
+ }