dsh-mesh-node 0.0.0-stage → 0.0.2

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 (55) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +8 -2
  3. package/package.json +43 -4
  4. package/src/cli.js +1246 -0
  5. package/src/core/acl.js +81 -0
  6. package/src/core/audit.js +75 -0
  7. package/src/core/autostart.js +119 -0
  8. package/src/core/cluster.js +341 -0
  9. package/src/core/config.js +622 -0
  10. package/src/core/desktop-proxy.js +85 -0
  11. package/src/core/ds-web.js +394 -0
  12. package/src/core/host-agent.js +263 -0
  13. package/src/core/jobs.js +259 -0
  14. package/src/core/pairing.js +141 -0
  15. package/src/core/peer-client.js +273 -0
  16. package/src/core/peer-doctor.js +294 -0
  17. package/src/core/peer-http.js +61 -0
  18. package/src/core/repair.js +93 -0
  19. package/src/core/restart-policy.js +21 -0
  20. package/src/core/result.js +29 -0
  21. package/src/core/sakura-api.js +103 -0
  22. package/src/core/session-profile.js +146 -0
  23. package/src/core/tls.js +160 -0
  24. package/src/core/tunnels.js +212 -0
  25. package/src/core/util.js +68 -0
  26. package/src/e2e-autostart.js +58 -0
  27. package/src/e2e-channel.js +384 -0
  28. package/src/e2e-cluster.js +417 -0
  29. package/src/e2e-host.js +411 -0
  30. package/src/e2e-onboard.js +232 -0
  31. package/src/e2e-pair.js +317 -0
  32. package/src/e2e-plugin.js +395 -0
  33. package/src/e2e-repair.js +297 -0
  34. package/src/index.js +20 -0
  35. package/src/peer-bridge.js +124 -0
  36. package/src/selftest.js +872 -0
  37. package/src/server.js +317 -0
  38. package/src/tools/alerts.js +80 -0
  39. package/src/tools/channel.js +253 -0
  40. package/src/tools/cluster.js +235 -0
  41. package/src/tools/config-patch.js +182 -0
  42. package/src/tools/control-apps.js +469 -0
  43. package/src/tools/control-path.js +160 -0
  44. package/src/tools/control.js +232 -0
  45. package/src/tools/core.js +79 -0
  46. package/src/tools/delegate.js +653 -0
  47. package/src/tools/fs.js +436 -0
  48. package/src/tools/git.js +266 -0
  49. package/src/tools/host.js +305 -0
  50. package/src/tools/index.js +122 -0
  51. package/src/tools/repair-fs.js +130 -0
  52. package/src/tools/repair.js +317 -0
  53. package/src/tools/session-history.js +60 -0
  54. package/src/tools/shell.js +66 -0
  55. package/src/tools/tasks.js +117 -0
@@ -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
+ }