dsh-mesh-node 0.0.0-stage → 0.0.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.
- package/LICENSE +21 -0
- package/README.md +8 -2
- package/package.json +43 -4
- package/src/cli.js +1246 -0
- package/src/core/acl.js +81 -0
- package/src/core/audit.js +75 -0
- package/src/core/autostart.js +119 -0
- package/src/core/cluster.js +340 -0
- package/src/core/config.js +622 -0
- package/src/core/desktop-proxy.js +85 -0
- package/src/core/ds-web.js +394 -0
- package/src/core/host-agent.js +263 -0
- package/src/core/jobs.js +259 -0
- package/src/core/pairing.js +141 -0
- package/src/core/peer-client.js +273 -0
- package/src/core/peer-doctor.js +294 -0
- package/src/core/peer-http.js +61 -0
- package/src/core/repair.js +93 -0
- package/src/core/restart-policy.js +21 -0
- package/src/core/result.js +29 -0
- package/src/core/session-profile.js +146 -0
- package/src/core/tls.js +160 -0
- package/src/core/util.js +68 -0
- package/src/e2e-autostart.js +58 -0
- package/src/e2e-channel.js +384 -0
- package/src/e2e-cluster.js +417 -0
- package/src/e2e-host.js +411 -0
- package/src/e2e-onboard.js +232 -0
- package/src/e2e-pair.js +317 -0
- package/src/e2e-plugin.js +395 -0
- package/src/e2e-repair.js +297 -0
- package/src/index.js +20 -0
- package/src/peer-bridge.js +124 -0
- package/src/selftest.js +872 -0
- package/src/server.js +313 -0
- package/src/tools/alerts.js +80 -0
- package/src/tools/channel.js +253 -0
- package/src/tools/cluster.js +235 -0
- package/src/tools/config-patch.js +182 -0
- package/src/tools/control-apps.js +469 -0
- package/src/tools/control-path.js +160 -0
- package/src/tools/control.js +232 -0
- package/src/tools/core.js +79 -0
- package/src/tools/delegate.js +653 -0
- package/src/tools/fs.js +436 -0
- package/src/tools/git.js +266 -0
- package/src/tools/host.js +305 -0
- package/src/tools/index.js +122 -0
- package/src/tools/repair-fs.js +130 -0
- package/src/tools/repair.js +317 -0
- package/src/tools/session-history.js +60 -0
- package/src/tools/shell.js +66 -0
- package/src/tools/tasks.js +117 -0
package/src/tools/fs.js
ADDED
|
@@ -0,0 +1,436 @@
|
|
|
1
|
+
import {
|
|
2
|
+
existsSync,
|
|
3
|
+
lstatSync,
|
|
4
|
+
readdirSync,
|
|
5
|
+
readFileSync,
|
|
6
|
+
realpathSync,
|
|
7
|
+
statSync
|
|
8
|
+
} from 'node:fs'
|
|
9
|
+
import { basename, dirname, join, relative, resolve, sep } from 'node:path'
|
|
10
|
+
import { z } from 'zod'
|
|
11
|
+
import { isInsideRoots } from '../core/config.js'
|
|
12
|
+
import { clip, sha256 } from '../core/util.js'
|
|
13
|
+
import { denied, invalidArg, internal, notFound, ok } from '../core/result.js'
|
|
14
|
+
|
|
15
|
+
const SKIP_DIRS = new Set([
|
|
16
|
+
'.git',
|
|
17
|
+
'node_modules',
|
|
18
|
+
'.venv',
|
|
19
|
+
'venv',
|
|
20
|
+
'dist',
|
|
21
|
+
'build',
|
|
22
|
+
'out',
|
|
23
|
+
'.next',
|
|
24
|
+
'.turbo',
|
|
25
|
+
'.cache',
|
|
26
|
+
'__pycache__',
|
|
27
|
+
'target',
|
|
28
|
+
'.idea',
|
|
29
|
+
'.vscode'
|
|
30
|
+
])
|
|
31
|
+
|
|
32
|
+
function resolveGuarded(rawPath, config) {
|
|
33
|
+
const abs = resolve(rawPath)
|
|
34
|
+
const allowed = config.acl?.fsRoots || []
|
|
35
|
+
if (!isInsideRoots(abs, allowed)) {
|
|
36
|
+
return { error: denied(`path is outside the allowed fsRoots: ${abs}`, { fsRoots: allowed }) }
|
|
37
|
+
}
|
|
38
|
+
if (!existsSync(abs)) return { error: notFound(`path not found: ${abs}`) }
|
|
39
|
+
return { abs }
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function statEntry(abs, name) {
|
|
43
|
+
const st = lstatSync(abs)
|
|
44
|
+
return {
|
|
45
|
+
name,
|
|
46
|
+
type: st.isDirectory() ? 'dir' : st.isSymbolicLink() ? 'symlink' : 'file',
|
|
47
|
+
sizeBytes: st.isDirectory() ? null : st.size,
|
|
48
|
+
mtimeMs: Math.round(st.mtimeMs)
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function walkFiles(root, { maxFiles = 400, maxDepth = 8 } = {}) {
|
|
53
|
+
const out = []
|
|
54
|
+
const stack = [{ dir: root, depth: 0 }]
|
|
55
|
+
while (stack.length > 0 && out.length < maxFiles) {
|
|
56
|
+
const { dir, depth } = stack.pop()
|
|
57
|
+
let entries
|
|
58
|
+
try {
|
|
59
|
+
entries = readdirSync(dir, { withFileTypes: true })
|
|
60
|
+
} catch {
|
|
61
|
+
continue
|
|
62
|
+
}
|
|
63
|
+
for (const entry of entries) {
|
|
64
|
+
if (out.length >= maxFiles) break
|
|
65
|
+
const full = join(dir, entry.name)
|
|
66
|
+
if (entry.isDirectory()) {
|
|
67
|
+
if (SKIP_DIRS.has(entry.name)) continue
|
|
68
|
+
if (depth + 1 > maxDepth) continue
|
|
69
|
+
stack.push({ dir: full, depth: depth + 1 })
|
|
70
|
+
} else if (entry.isFile()) {
|
|
71
|
+
out.push(full)
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
return out
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function extKey(name) {
|
|
79
|
+
const dot = name.lastIndexOf('.')
|
|
80
|
+
return dot > 0 ? name.slice(dot).toLowerCase() : '(none)'
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Recursive aggregation over a tree. Returns only numbers, so a 40k-file tree
|
|
85
|
+
* costs a few KB on the wire instead of a few hundred thousand tokens.
|
|
86
|
+
* Never follows symlinks/junctions: a link is counted as one entry.
|
|
87
|
+
*/
|
|
88
|
+
function collectStats(root, { maxFiles, maxDepth, topDirs, topFiles, topExts, skipVendor }) {
|
|
89
|
+
const started = Date.now()
|
|
90
|
+
const dirBytes = new Map()
|
|
91
|
+
const dirFileCount = new Map()
|
|
92
|
+
const allDirs = new Set()
|
|
93
|
+
const rootFiles = []
|
|
94
|
+
const ext = new Map()
|
|
95
|
+
const largest = []
|
|
96
|
+
let files = 0
|
|
97
|
+
let dirs = 0
|
|
98
|
+
let symlinks = 0
|
|
99
|
+
let bytes = 0
|
|
100
|
+
let symlinkBytes = 0
|
|
101
|
+
let truncated = false
|
|
102
|
+
|
|
103
|
+
const pushLargest = (item, limit) => {
|
|
104
|
+
if (limit <= 0) return
|
|
105
|
+
if (largest.length >= limit && item.sizeBytes <= largest[largest.length - 1].sizeBytes) return
|
|
106
|
+
if (largest.length < limit) largest.push(item)
|
|
107
|
+
else largest[largest.length - 1] = item
|
|
108
|
+
largest.sort((a, b) => b.sizeBytes - a.sizeBytes)
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
const addToAncestors = (rel, size) => {
|
|
112
|
+
let cut = rel.lastIndexOf('/')
|
|
113
|
+
while (cut > 0) {
|
|
114
|
+
const dir = rel.slice(0, cut)
|
|
115
|
+
dirBytes.set(dir, (dirBytes.get(dir) || 0) + size)
|
|
116
|
+
dirFileCount.set(dir, (dirFileCount.get(dir) || 0) + 1)
|
|
117
|
+
cut = dir.lastIndexOf('/')
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
const stack = [{ abs: root, rel: '', depth: 0 }]
|
|
122
|
+
while (stack.length > 0) {
|
|
123
|
+
const { abs, rel, depth } = stack.pop()
|
|
124
|
+
let entries
|
|
125
|
+
try {
|
|
126
|
+
entries = readdirSync(abs, { withFileTypes: true })
|
|
127
|
+
} catch {
|
|
128
|
+
continue
|
|
129
|
+
}
|
|
130
|
+
for (const entry of entries) {
|
|
131
|
+
const childRel = rel ? `${rel}/${entry.name}` : entry.name
|
|
132
|
+
const full = join(abs, entry.name)
|
|
133
|
+
if (entry.isDirectory()) {
|
|
134
|
+
if (skipVendor && SKIP_DIRS.has(entry.name)) continue
|
|
135
|
+
dirs += 1
|
|
136
|
+
allDirs.add(childRel)
|
|
137
|
+
if (depth + 1 <= maxDepth) stack.push({ abs: full, rel: childRel, depth: depth + 1 })
|
|
138
|
+
continue
|
|
139
|
+
}
|
|
140
|
+
if (entry.isSymbolicLink()) {
|
|
141
|
+
symlinks += 1
|
|
142
|
+
try {
|
|
143
|
+
symlinkBytes += lstatSync(full).size
|
|
144
|
+
} catch {
|
|
145
|
+
/* broken link */
|
|
146
|
+
}
|
|
147
|
+
continue
|
|
148
|
+
}
|
|
149
|
+
if (!entry.isFile()) continue
|
|
150
|
+
if (files >= maxFiles) {
|
|
151
|
+
truncated = true
|
|
152
|
+
break
|
|
153
|
+
}
|
|
154
|
+
let st
|
|
155
|
+
try {
|
|
156
|
+
st = lstatSync(full)
|
|
157
|
+
} catch {
|
|
158
|
+
continue
|
|
159
|
+
}
|
|
160
|
+
files += 1
|
|
161
|
+
bytes += st.size
|
|
162
|
+
addToAncestors(childRel, st.size)
|
|
163
|
+
const key = extKey(entry.name)
|
|
164
|
+
const bucket = ext.get(key) || { ext: key, files: 0, bytes: 0 }
|
|
165
|
+
bucket.files += 1
|
|
166
|
+
bucket.bytes += st.size
|
|
167
|
+
ext.set(key, bucket)
|
|
168
|
+
if (!childRel.includes('/')) rootFiles.push({ name: childRel, sizeBytes: st.size })
|
|
169
|
+
pushLargest({ path: childRel, sizeBytes: st.size, mtimeMs: Math.round(st.mtimeMs) }, topFiles)
|
|
170
|
+
}
|
|
171
|
+
if (truncated) break
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
const emptyDirs = [...allDirs].filter((rel) => !dirFileCount.has(rel))
|
|
175
|
+
const topLevel = [
|
|
176
|
+
...[...allDirs]
|
|
177
|
+
.filter((rel) => !rel.includes('/'))
|
|
178
|
+
.map((rel) => ({ name: rel, type: 'dir', sizeBytes: dirBytes.get(rel) || 0, fileCount: dirFileCount.get(rel) || 0 })),
|
|
179
|
+
...rootFiles.map((f) => ({ name: f.name, type: 'file', sizeBytes: f.sizeBytes, fileCount: 1 }))
|
|
180
|
+
].sort((a, b) => (a.type === b.type ? b.sizeBytes - a.sizeBytes : a.type === 'dir' ? -1 : 1))
|
|
181
|
+
|
|
182
|
+
return {
|
|
183
|
+
path: root,
|
|
184
|
+
totals: {
|
|
185
|
+
files,
|
|
186
|
+
dirs,
|
|
187
|
+
symlinks,
|
|
188
|
+
bytes,
|
|
189
|
+
symlinkBytes,
|
|
190
|
+
emptyDirs: emptyDirs.length,
|
|
191
|
+
maxFiles,
|
|
192
|
+
maxDepth,
|
|
193
|
+
truncated
|
|
194
|
+
},
|
|
195
|
+
topLevel,
|
|
196
|
+
topDirs: [...allDirs]
|
|
197
|
+
.map((rel) => ({ path: rel, sizeBytes: dirBytes.get(rel) || 0, fileCount: dirFileCount.get(rel) || 0 }))
|
|
198
|
+
.sort((a, b) => b.sizeBytes - a.sizeBytes)
|
|
199
|
+
.slice(0, topDirs),
|
|
200
|
+
topFiles: largest.slice(0, topFiles),
|
|
201
|
+
extStats: {
|
|
202
|
+
distinct: ext.size,
|
|
203
|
+
top: [...ext.values()].sort((a, b) => b.bytes - a.bytes).slice(0, topExts)
|
|
204
|
+
},
|
|
205
|
+
emptyDirs: { count: emptyDirs.length, sample: emptyDirs.slice(0, 20) },
|
|
206
|
+
skippedVendor: skipVendor,
|
|
207
|
+
elapsedMs: Date.now() - started
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
function looksBinary(buf) {
|
|
212
|
+
const n = Math.min(buf.length, 8000)
|
|
213
|
+
for (let i = 0; i < n; i += 1) if (buf[i] === 0) return true
|
|
214
|
+
return false
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/** Read-only filesystem tools: fs_read / fs_list / fs_stat / fs_grep. */
|
|
218
|
+
export function buildFs(ctx) {
|
|
219
|
+
return [
|
|
220
|
+
{
|
|
221
|
+
name: 'fs_read',
|
|
222
|
+
title: 'Read a text file on the peer machine',
|
|
223
|
+
description:
|
|
224
|
+
'Read a UTF-8 text file on the peer machine. Only paths under the configured fsRoots are readable. Returns absolute path, size, sha256, total line count and content (optionally a 1-based line range). Use this instead of guessing file contents.',
|
|
225
|
+
inputSchema: z.object({
|
|
226
|
+
path: z.string().describe('Absolute path on the peer machine.'),
|
|
227
|
+
startLine: z.number().int().min(1).optional().describe('1-based first line to return (inclusive).'),
|
|
228
|
+
endLine: z.number().int().min(1).optional().describe('1-based last line to return (inclusive).'),
|
|
229
|
+
maxBytes: z.number().int().min(1).optional().describe('Byte budget for the returned content.')
|
|
230
|
+
}),
|
|
231
|
+
handler: async ({ path, startLine, endLine, maxBytes }, { signal } = {}) => {
|
|
232
|
+
const config = ctx.getConfig()
|
|
233
|
+
const g = resolveGuarded(path, config)
|
|
234
|
+
if (g.error) return g.error
|
|
235
|
+
const st = statSync(g.abs)
|
|
236
|
+
if (!st.isFile()) return invalidArg(`not a regular file: ${g.abs}`)
|
|
237
|
+
const budget = maxBytes ?? config.limits?.maxReadBytes ?? 262144
|
|
238
|
+
const text = readFileSync(g.abs, 'utf8')
|
|
239
|
+
const allLines = text.split(/\r?\n/)
|
|
240
|
+
let selected = text
|
|
241
|
+
let range = null
|
|
242
|
+
if (startLine || endLine) {
|
|
243
|
+
const from = Math.max(1, startLine ?? 1)
|
|
244
|
+
const to = Math.max(from, Math.min(endLine ?? allLines.length, from + (config.limits?.maxLines ?? 4000)))
|
|
245
|
+
selected = allLines.slice(from - 1, to).join('\n')
|
|
246
|
+
range = { startLine: from, endLine: to }
|
|
247
|
+
}
|
|
248
|
+
const capped = clip(selected, budget)
|
|
249
|
+
if (signal?.aborted) return denied('request cancelled')
|
|
250
|
+
return ok({
|
|
251
|
+
path: g.abs,
|
|
252
|
+
sizeBytes: st.size,
|
|
253
|
+
sha256: sha256(text),
|
|
254
|
+
totalLines: allLines.length,
|
|
255
|
+
range,
|
|
256
|
+
returnedBytes: capped.bytes,
|
|
257
|
+
truncated: capped.truncated,
|
|
258
|
+
content: capped.text
|
|
259
|
+
})
|
|
260
|
+
}
|
|
261
|
+
},
|
|
262
|
+
{
|
|
263
|
+
name: 'fs_list',
|
|
264
|
+
title: 'List a directory on the peer machine',
|
|
265
|
+
description:
|
|
266
|
+
'List one directory on the peer machine (non-recursive by default). Returns entries with type/size/mtime, sorted directories first. Paths outside fsRoots are refused.',
|
|
267
|
+
inputSchema: z.object({
|
|
268
|
+
path: z.string().describe('Directory path on the peer machine.'),
|
|
269
|
+
maxEntries: z.number().int().min(1).optional().describe('Cap on returned entries (default 500).'),
|
|
270
|
+
includeHidden: z.boolean().optional().describe('Include dotfiles (default false).'),
|
|
271
|
+
recursive: z.boolean().optional().describe('Walk subdirectories (bounded, skips node_modules/.git).')
|
|
272
|
+
}),
|
|
273
|
+
handler: async ({ path, maxEntries, includeHidden, recursive }) => {
|
|
274
|
+
const config = ctx.getConfig()
|
|
275
|
+
const g = resolveGuarded(path, config)
|
|
276
|
+
if (g.error) return g.error
|
|
277
|
+
const st = statSync(g.abs)
|
|
278
|
+
if (!st.isDirectory()) return invalidArg(`not a directory: ${g.abs}`)
|
|
279
|
+
const cap = Math.min(maxEntries ?? config.limits?.maxListEntries ?? 500, 5000)
|
|
280
|
+
let entries = []
|
|
281
|
+
if (recursive) {
|
|
282
|
+
const files = walkFiles(g.abs, { maxFiles: cap })
|
|
283
|
+
entries = files.map((f) => {
|
|
284
|
+
const s = lstatSync(f)
|
|
285
|
+
return {
|
|
286
|
+
name: relative(g.abs, f).split(sep).join('/'),
|
|
287
|
+
type: 'file',
|
|
288
|
+
sizeBytes: s.size,
|
|
289
|
+
mtimeMs: Math.round(s.mtimeMs)
|
|
290
|
+
}
|
|
291
|
+
})
|
|
292
|
+
entries.sort((a, b) => a.name.localeCompare(b.name))
|
|
293
|
+
} else {
|
|
294
|
+
entries = readdirSync(g.abs, { withFileTypes: true })
|
|
295
|
+
.filter((e) => includeHidden || !e.name.startsWith('.'))
|
|
296
|
+
.map((e) => statEntry(join(g.abs, e.name), e.name))
|
|
297
|
+
.sort((a, b) => (a.type === b.type ? a.name.localeCompare(b.name) : a.type === 'dir' ? -1 : 1))
|
|
298
|
+
}
|
|
299
|
+
return ok({
|
|
300
|
+
path: g.abs,
|
|
301
|
+
count: entries.length,
|
|
302
|
+
truncated: entries.length >= cap,
|
|
303
|
+
entries: entries.slice(0, cap)
|
|
304
|
+
})
|
|
305
|
+
}
|
|
306
|
+
},
|
|
307
|
+
{
|
|
308
|
+
name: 'fs_stat',
|
|
309
|
+
title: 'Stat a path on the peer machine',
|
|
310
|
+
description:
|
|
311
|
+
'Return metadata for a single path on the peer machine: type, size, timestamps, realpath and sha256 for small regular files.',
|
|
312
|
+
inputSchema: z.object({ path: z.string().describe('Path on the peer machine.') }),
|
|
313
|
+
handler: async ({ path }) => {
|
|
314
|
+
const config = ctx.getConfig()
|
|
315
|
+
const abs = resolve(path)
|
|
316
|
+
const g = resolveGuarded(abs, config)
|
|
317
|
+
if (g.error) return g.error
|
|
318
|
+
const st = lstatSync(g.abs)
|
|
319
|
+
let real = g.abs
|
|
320
|
+
try {
|
|
321
|
+
real = realpathSync(g.abs)
|
|
322
|
+
} catch {
|
|
323
|
+
/* keep abs */
|
|
324
|
+
}
|
|
325
|
+
let digest = null
|
|
326
|
+
if (st.isFile() && st.size <= (config.limits?.maxReadBytes ?? 262144)) {
|
|
327
|
+
digest = sha256(readFileSync(g.abs, 'utf8'))
|
|
328
|
+
}
|
|
329
|
+
return ok({
|
|
330
|
+
path: g.abs,
|
|
331
|
+
realpath: real,
|
|
332
|
+
type: st.isDirectory() ? 'dir' : st.isSymbolicLink() ? 'symlink' : 'file',
|
|
333
|
+
sizeBytes: st.size,
|
|
334
|
+
mtimeMs: Math.round(st.mtimeMs),
|
|
335
|
+
ctimeMs: Math.round(st.ctimeMs),
|
|
336
|
+
isSymlink: st.isSymbolicLink(),
|
|
337
|
+
basename: basename(g.abs),
|
|
338
|
+
parent: dirname(g.abs),
|
|
339
|
+
sha256: digest
|
|
340
|
+
})
|
|
341
|
+
}
|
|
342
|
+
},
|
|
343
|
+
{
|
|
344
|
+
name: 'fs_stats',
|
|
345
|
+
title: 'Aggregate directory sizes on the peer machine',
|
|
346
|
+
description:
|
|
347
|
+
'Walk a directory tree on the peer machine and return aggregated numbers only: totals, immediate children with recursive sizes, largest subdirectories, largest files and an extension histogram. Read-only and bounded. Use this for "how big is X / what is taking space" instead of recursive fs_list, which streams every file entry.',
|
|
348
|
+
inputSchema: z.object({
|
|
349
|
+
path: z.string().describe('Directory path on the peer machine (must be under fsRoots).'),
|
|
350
|
+
maxFiles: z.number().int().min(1).optional().describe('Stop after this many files (default 200000, hard cap 1000000).'),
|
|
351
|
+
maxDepth: z.number().int().min(1).max(64).optional().describe('Maximum directory depth to descend (default 24).'),
|
|
352
|
+
topDirs: z.number().int().min(0).max(200).optional().describe('How many subdirectories by recursive size to return (default 15).'),
|
|
353
|
+
topFiles: z.number().int().min(0).max(200).optional().describe('How many largest files to return (default 15).'),
|
|
354
|
+
topExts: z.number().int().min(0).max(200).optional().describe('How many file extensions to return (default 15).'),
|
|
355
|
+
skipVendor: z.boolean().optional().describe('Skip .git/node_modules/dist/build-style directories (default false).')
|
|
356
|
+
}),
|
|
357
|
+
handler: async ({ path, maxFiles, maxDepth, topDirs, topFiles, topExts, skipVendor }) => {
|
|
358
|
+
const config = ctx.getConfig()
|
|
359
|
+
const g = resolveGuarded(path, config)
|
|
360
|
+
if (g.error) return g.error
|
|
361
|
+
const st = lstatSync(g.abs)
|
|
362
|
+
if (!st.isDirectory()) return invalidArg(`not a directory: ${g.abs}`)
|
|
363
|
+
return ok(
|
|
364
|
+
collectStats(g.abs, {
|
|
365
|
+
maxFiles: Math.min(maxFiles ?? 200000, 1000000),
|
|
366
|
+
maxDepth: maxDepth ?? 24,
|
|
367
|
+
topDirs: Math.min(topDirs ?? 15, 200),
|
|
368
|
+
topFiles: Math.min(topFiles ?? 15, 200),
|
|
369
|
+
topExts: Math.min(topExts ?? 15, 200),
|
|
370
|
+
skipVendor: Boolean(skipVendor)
|
|
371
|
+
})
|
|
372
|
+
)
|
|
373
|
+
}
|
|
374
|
+
},
|
|
375
|
+
{
|
|
376
|
+
name: 'fs_grep',
|
|
377
|
+
title: 'Search file contents on the peer machine',
|
|
378
|
+
description:
|
|
379
|
+
'Search text files under a directory on the peer machine for a regular expression. Skips binary files and node_modules/.git/dist/build by default. Returns matching path, 1-based line number and the line text.',
|
|
380
|
+
inputSchema: z.object({
|
|
381
|
+
root: z.string().describe('Directory to search (must be under fsRoots).'),
|
|
382
|
+
pattern: z.string().describe('JavaScript regular expression source, e.g. "TODO|FIXME".'),
|
|
383
|
+
ignoreCase: z.boolean().optional(),
|
|
384
|
+
include: z
|
|
385
|
+
.string()
|
|
386
|
+
.optional()
|
|
387
|
+
.describe('Only search files whose path contains this substring, e.g. ".ts" or "docs/".'),
|
|
388
|
+
maxFiles: z.number().int().min(1).optional().describe('Cap on scanned files (default 400).'),
|
|
389
|
+
maxResults: z.number().int().min(1).optional().describe('Cap on returned matches (default 100).')
|
|
390
|
+
}),
|
|
391
|
+
handler: async ({ root, pattern, ignoreCase, include, maxFiles, maxResults }) => {
|
|
392
|
+
const config = ctx.getConfig()
|
|
393
|
+
const g = resolveGuarded(root, config)
|
|
394
|
+
if (g.error) return g.error
|
|
395
|
+
let re
|
|
396
|
+
try {
|
|
397
|
+
re = new RegExp(pattern, ignoreCase ? 'i' : '')
|
|
398
|
+
} catch (error) {
|
|
399
|
+
return invalidArg(`invalid regular expression: ${error.message}`)
|
|
400
|
+
}
|
|
401
|
+
const fileCap = Math.min(maxFiles ?? config.limits?.maxGrepFiles ?? 400, 5000)
|
|
402
|
+
const resultCap = Math.min(maxResults ?? 100, 1000)
|
|
403
|
+
const files = walkFiles(g.abs, { maxFiles: fileCap })
|
|
404
|
+
const matches = []
|
|
405
|
+
let scanned = 0
|
|
406
|
+
for (const file of files) {
|
|
407
|
+
if (matches.length >= resultCap) break
|
|
408
|
+
if (include && !file.includes(include)) continue
|
|
409
|
+
let buf
|
|
410
|
+
try {
|
|
411
|
+
buf = readFileSync(file)
|
|
412
|
+
} catch {
|
|
413
|
+
continue
|
|
414
|
+
}
|
|
415
|
+
scanned += 1
|
|
416
|
+
if (looksBinary(buf)) continue
|
|
417
|
+
const lines = buf.toString('utf8').split(/\r?\n/)
|
|
418
|
+
for (let i = 0; i < lines.length && matches.length < resultCap; i += 1) {
|
|
419
|
+
if (re.test(lines[i])) {
|
|
420
|
+
matches.push({ path: file, line: i + 1, text: lines[i].slice(0, 500) })
|
|
421
|
+
}
|
|
422
|
+
}
|
|
423
|
+
}
|
|
424
|
+
return ok({
|
|
425
|
+
root: g.abs,
|
|
426
|
+
pattern,
|
|
427
|
+
scannedFiles: scanned,
|
|
428
|
+
scannedLimit: fileCap,
|
|
429
|
+
matches: matches.length,
|
|
430
|
+
truncated: matches.length >= resultCap,
|
|
431
|
+
results: matches
|
|
432
|
+
})
|
|
433
|
+
}
|
|
434
|
+
}
|
|
435
|
+
]
|
|
436
|
+
}
|