dsh-remote 0.8.72 → 0.8.74

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 (44) hide show
  1. package/LICENSE +21 -21
  2. package/README.en.md +470 -389
  3. package/README.md +529 -462
  4. package/cordis.patch.yml +9 -9
  5. package/lib/binding.js +172 -172
  6. package/lib/client.js +4039 -4022
  7. package/lib/credential.js +263 -263
  8. package/lib/errors.js +141 -141
  9. package/lib/exec-job.js +235 -235
  10. package/lib/file-reference.js +457 -457
  11. package/lib/forwards.js +258 -258
  12. package/lib/host-api.js +808 -0
  13. package/lib/hostkey.js +108 -108
  14. package/lib/http-transport.js +196 -196
  15. package/lib/ignore.js +202 -202
  16. package/lib/index.js +4923 -4541
  17. package/lib/instructions-dynamic.js +415 -415
  18. package/lib/openssh.js +879 -879
  19. package/lib/paths.js +252 -252
  20. package/lib/pool.js +963 -893
  21. package/lib/registry.js +296 -293
  22. package/lib/remote-fs.js +173 -173
  23. package/lib/remote-sandbox.js +183 -183
  24. package/lib/remote-shell-child.cjs +344 -305
  25. package/lib/remote-shell.js +542 -518
  26. package/lib/routes-fs.js +245 -245
  27. package/lib/routes-term.js +169 -169
  28. package/lib/search.js +235 -235
  29. package/lib/sftp-surface.js +119 -119
  30. package/lib/sshconfig.js +779 -705
  31. package/lib/sync.js +275 -275
  32. package/lib/tasks.js +95 -95
  33. package/lib/telemetry.js +213 -213
  34. package/lib/terminal.js +205 -205
  35. package/lib/tui/flow.js +130 -0
  36. package/lib/tui/format.js +332 -0
  37. package/lib/tui/i18n.js +344 -0
  38. package/lib/tui/index.js +322 -0
  39. package/lib/tui/provider.js +327 -0
  40. package/lib/tui/scene.js +996 -0
  41. package/lib/update.js +472 -472
  42. package/lib/web-attach.js +850 -850
  43. package/lib/workspace-cleanup.js +337 -0
  44. package/package.json +100 -99
package/lib/search.js CHANGED
@@ -1,235 +1,235 @@
1
- // dsh-remote — portable recursive search over SFTP.
2
- // Replaces `find … -exec grep` (which needs a POSIX shell and dies on
3
- // Windows remotes). Walks the remote tree over SFTP readdir/stat/readFile with
4
- // the same bounded-parallelism + ignore rules as the mirror sync.
5
- import { joinRemotePath, relPathUnder, shq } from './paths.js'
6
-
7
- function mapLimit(items, limit, fn) {
8
- const limitN = Math.max(1, Math.min(Math.floor(limit) || 1, items.length))
9
- let next = 0
10
- const worker = async () => {
11
- while (next < items.length) {
12
- const i = next++
13
- try { await fn(items[i], i) } catch { /* per-item errors are the fn's concern */ }
14
- }
15
- }
16
- const workers = []
17
- for (let w = 0; w < limitN; w++) workers.push(worker())
18
- return Promise.all(workers)
19
- }
20
-
21
- /** fnmatch-lite for the `glob` filter (matches the entry BASENAME). */
22
- export function matchGlob(name, glob) {
23
- let re = ''
24
- let i = 0
25
- const g = String(glob || '')
26
- while (i < g.length) {
27
- const c = g[i]
28
- if (c === '*') {
29
- if (g[i + 1] === '*') { re += '.*'; i += 2; continue }
30
- re += '[^/]*'; i++; continue
31
- }
32
- if (c === '?') { re += '[^/]'; i++; continue }
33
- if ('\\^$+{}[]()|.'.includes(c)) re += '\\' + c
34
- else re += c
35
- i++
36
- }
37
- return new RegExp('^' + re + '$').test(String(name))
38
- }
39
-
40
- const BINARY_SNIFF = 8192
41
- function looksBinary(buf) {
42
- const n = Math.min(buf.length, BINARY_SNIFF)
43
- for (let i = 0; i < n; i++) if (buf[i] === 0) return true
44
- return false
45
- }
46
-
47
- /**
48
- * Recursively search a remote directory.
49
- *
50
- * Cancellation (issue #44): the walk takes an AbortSignal and checks it at every
51
- * directory and every entry. Before this it was completely uninterruptible —
52
- * no signal was accepted and no flag was consulted — so a large-tree search
53
- * could never be stopped: DSH tool cancellation is *cooperative*
54
- * (@deepseek-ai/dsh-tool-call-timeout-policy can only ask a tool to stop), which
55
- * meant `session/cancel`, steering, and the tool timeout policy all had no
56
- * effect and the turn stayed `running` forever. On abort we return the partial
57
- * result instead of throwing, so the model still gets whatever was found.
58
- *
59
- * @param {object} sftp - pooled SFTP client
60
- * @param {string} rootDir
61
- * @param {object} opts - `signal` (AbortSignal), `maxDurationMs`, plus the caps below
62
- * @returns {Promise<{matches: Array<{path, line, text}>, scanned: number, truncated: boolean, cancelled?: boolean}>}
63
- */
64
- export async function searchTree(sftp, rootDir, opts = {}) {
65
- const {
66
- regex, // RegExp (already compiled with ignoreCase where asked)
67
- glob, // optional basename glob filter
68
- contextLines = 0,
69
- maxMatches = 500,
70
- maxDepth = 12,
71
- maxFiles = 20000,
72
- maxScanBytes = 1024 * 1024,
73
- maxDurationMs = 0, // 0 = no wall-clock budget
74
- isIgnored = () => false,
75
- signal,
76
- } = opts
77
-
78
- const matches = []
79
- let scanned = 0
80
- let truncated = false
81
- let cancelled = false
82
- const startedAt = Date.now()
83
-
84
- /** True when the caller asked us to stop (abort, or the time budget is spent). */
85
- const shouldStop = () => {
86
- if (cancelled || truncated) return true
87
- if (signal && signal.aborted) { cancelled = true; return true }
88
- if (maxDurationMs > 0 && Date.now() - startedAt >= maxDurationMs) { truncated = true; return true }
89
- return false
90
- }
91
-
92
- const walk = async (dir, depth) => {
93
- if (shouldStop() || scanned >= maxFiles) return
94
- let entries = []
95
- try { entries = (await sftp.readdir(dir)) || [] } catch { return }
96
- const dirs = []
97
- for (const e of entries) {
98
- const name = String(e.filename)
99
- if (name === '.' || name === '..') continue
100
- const isDir = !!(e.attrs && e.attrs.isDirectory && e.attrs.isDirectory())
101
- if (isDir) dirs.push(name)
102
- }
103
- // Depth-first, dirs first, but stop as soon as we hit caps.
104
- for (const name of dirs) {
105
- if (shouldStop() || scanned >= maxFiles) return
106
- if (depth <= 0) continue
107
- const sub = joinRemotePath(dir, name)
108
- // isIgnored receives the path RELATIVE to the search root (the same
109
- // contract sync has), so root-anchored ignore rules — `/proc/` when the
110
- // workspace is the filesystem root (issue #60), `Library/Caches/` in the
111
- // default search set — actually match here; a bare basename would never
112
- // satisfy an anchored pattern and those rules were silently dead.
113
- if (isIgnored(relPathUnder(rootDir, sub) || name, true)) continue
114
- await walk(sub, depth - 1)
115
- }
116
- if (shouldStop() || scanned >= maxFiles) return
117
-
118
- const fileNames = entries
119
- .filter((e) => !(e.attrs && e.attrs.isDirectory && e.attrs.isDirectory()))
120
- .map((e) => String(e.filename))
121
- .filter((n) => n !== '.' && n !== '..')
122
- await mapLimit(fileNames, 4, async (name) => {
123
- // Checked per entry: this is the loop that used to run for hours.
124
- if (shouldStop() || scanned >= maxFiles || matches.length >= maxMatches) return
125
- if (glob && !matchGlob(name, glob)) return
126
- const fp = joinRemotePath(dir, name)
127
- if (isIgnored(relPathUnder(rootDir, fp) || name, false)) return
128
- scanned++
129
- let st
130
- try { st = await sftp.stat(fp) } catch { return }
131
- if (shouldStop()) return
132
- if (st.size > maxScanBytes) return
133
- let buf
134
- try { buf = await sftp.readFile(fp) } catch { return }
135
- if (shouldStop()) return
136
- if (looksBinary(buf)) return
137
- const content = buf.toString('utf8').replace(/\r\n/g, '\n')
138
- const lines = content.split('\n')
139
- const hitLines = new Set()
140
- for (let i = 0; i < lines.length; i++) {
141
- if (regex.test(lines[i])) {
142
- regex.lastIndex = 0
143
- for (let c = Math.max(0, i - contextLines); c <= Math.min(lines.length - 1, i + contextLines); c++) {
144
- hitLines.add(c)
145
- }
146
- if (matches.length >= maxMatches) { truncated = true; return }
147
- }
148
- }
149
- if (hitLines.size) {
150
- for (const ln of [...hitLines].sort((a, b) => a - b)) {
151
- if (matches.length >= maxMatches) { truncated = true; return }
152
- matches.push({ path: fp, line: ln + 1, text: lines[ln] })
153
- }
154
- }
155
- })
156
- }
157
-
158
- await walk(rootDir, maxDepth)
159
- if (scanned >= maxFiles) truncated = true
160
- return { matches, scanned, truncated, cancelled }
161
- }
162
-
163
- function parseGrepLines(stdout, maxMatches) {
164
- const matches = []
165
- let truncated = false
166
- for (const line of String(stdout || '').split('\n')) {
167
- if (!line) continue
168
- const m = line.match(/^(.*?):(\d+):(.*)$/)
169
- if (!m) continue
170
- if (matches.length >= maxMatches) { truncated = true; break }
171
- matches.push({ path: m[1], line: Number(m[2]), text: m[3] })
172
- }
173
- return { matches, truncated }
174
- }
175
-
176
- /**
177
- * POSIX fast path: `rg` then `grep -R`. Returns null when the remote has
178
- * neither, so the caller can fall back to an SFTP walk (Windows / no grep).
179
- */
180
- export async function searchViaShell(pool, rootDir, opts = {}) {
181
- const {
182
- pattern,
183
- ignoreCase = true,
184
- glob,
185
- contextLines = 0,
186
- maxMatches = 500,
187
- signal,
188
- } = opts
189
- // Honour an already-aborted signal before spawning a remote grep on a huge tree.
190
- if (signal && signal.aborted) {
191
- return { matches: [], scanned: 0, truncated: false, cancelled: true, via: 'none' }
192
- }
193
- const ic = ignoreCase ? ' -i' : ''
194
- const globFlag = glob ? ` --glob ${shq(glob)}` : ''
195
- const ctx = contextLines > 0 ? ` -C ${Number(contextLines)}` : ''
196
- const rg = `rg -n --no-heading -I${ic}${globFlag}${ctx} --max-count ${maxMatches} -- ${shq(pattern)} ${shq(rootDir)}`
197
- const grepGlob = glob ? ` --include=${shq(glob)}` : ''
198
- const grep = `grep -R -n -I -E${ic}${grepGlob}${ctx} -- ${shq(pattern)} ${shq(rootDir)}`
199
- const script = `if command -v rg >/dev/null 2>&1; then ${rg}; elif command -v grep >/dev/null 2>&1; then ${grep}; else exit 127; fi`
200
- const res = await pool.exec(script, { timeoutMs: opts.timeoutMs, signal })
201
- // A cancelled call must return as cancelled (with whatever partial output the
202
- // remote produced), NOT throw: throwing would make searchRemote fall through
203
- // to the slower SFTP walk, i.e. keep running after the caller said stop.
204
- if (res.signal === 'ABORTED') {
205
- const partial = parseGrepLines(res.stdout, maxMatches)
206
- return { matches: partial.matches, scanned: partial.matches.length, truncated: false, cancelled: true, via: 'none' }
207
- }
208
- // 0 = hits, 1 = no hits; other codes mean the tools are missing / failed.
209
- if (res.signal === 'TIMEOUT') throw new Error('search timed out')
210
- if (res.code !== 0 && res.code !== 1) throw new Error(res.stderr || `search exit ${res.code}`)
211
- const parsed = parseGrepLines(res.stdout, maxMatches)
212
- return {
213
- matches: parsed.matches,
214
- scanned: parsed.matches.length,
215
- truncated: parsed.truncated || /truncated|max-count/i.test(String(res.stderr || '')),
216
- via: /rg/.test(String(res.stderr || '')) ? 'rg' : 'grep',
217
- }
218
- }
219
-
220
- /** Prefer a POSIX grep/rg, fall back to the portable SFTP walk. */
221
- export async function searchRemote(pool, rootDir, opts = {}) {
222
- if (pool && pool.platform !== 'windows') {
223
- try {
224
- const fast = await searchViaShell(pool, rootDir, opts)
225
- if (fast && Array.isArray(fast.matches)) return fast
226
- } catch (e) {
227
- // An abort is terminal, not a "try the slower fallback" signal.
228
- if (opts.signal && opts.signal.aborted) {
229
- return { matches: [], scanned: 0, truncated: false, cancelled: true, via: 'none' }
230
- }
231
- }
232
- }
233
- const sftp = await pool.sftp()
234
- return searchTree(sftp, rootDir, opts)
235
- }
1
+ // dsh-remote — portable recursive search over SFTP.
2
+ // Replaces `find … -exec grep` (which needs a POSIX shell and dies on
3
+ // Windows remotes). Walks the remote tree over SFTP readdir/stat/readFile with
4
+ // the same bounded-parallelism + ignore rules as the mirror sync.
5
+ import { joinRemotePath, relPathUnder, shq } from './paths.js'
6
+
7
+ function mapLimit(items, limit, fn) {
8
+ const limitN = Math.max(1, Math.min(Math.floor(limit) || 1, items.length))
9
+ let next = 0
10
+ const worker = async () => {
11
+ while (next < items.length) {
12
+ const i = next++
13
+ try { await fn(items[i], i) } catch { /* per-item errors are the fn's concern */ }
14
+ }
15
+ }
16
+ const workers = []
17
+ for (let w = 0; w < limitN; w++) workers.push(worker())
18
+ return Promise.all(workers)
19
+ }
20
+
21
+ /** fnmatch-lite for the `glob` filter (matches the entry BASENAME). */
22
+ export function matchGlob(name, glob) {
23
+ let re = ''
24
+ let i = 0
25
+ const g = String(glob || '')
26
+ while (i < g.length) {
27
+ const c = g[i]
28
+ if (c === '*') {
29
+ if (g[i + 1] === '*') { re += '.*'; i += 2; continue }
30
+ re += '[^/]*'; i++; continue
31
+ }
32
+ if (c === '?') { re += '[^/]'; i++; continue }
33
+ if ('\\^$+{}[]()|.'.includes(c)) re += '\\' + c
34
+ else re += c
35
+ i++
36
+ }
37
+ return new RegExp('^' + re + '$').test(String(name))
38
+ }
39
+
40
+ const BINARY_SNIFF = 8192
41
+ function looksBinary(buf) {
42
+ const n = Math.min(buf.length, BINARY_SNIFF)
43
+ for (let i = 0; i < n; i++) if (buf[i] === 0) return true
44
+ return false
45
+ }
46
+
47
+ /**
48
+ * Recursively search a remote directory.
49
+ *
50
+ * Cancellation (issue #44): the walk takes an AbortSignal and checks it at every
51
+ * directory and every entry. Before this it was completely uninterruptible —
52
+ * no signal was accepted and no flag was consulted — so a large-tree search
53
+ * could never be stopped: DSH tool cancellation is *cooperative*
54
+ * (@deepseek-ai/dsh-tool-call-timeout-policy can only ask a tool to stop), which
55
+ * meant `session/cancel`, steering, and the tool timeout policy all had no
56
+ * effect and the turn stayed `running` forever. On abort we return the partial
57
+ * result instead of throwing, so the model still gets whatever was found.
58
+ *
59
+ * @param {object} sftp - pooled SFTP client
60
+ * @param {string} rootDir
61
+ * @param {object} opts - `signal` (AbortSignal), `maxDurationMs`, plus the caps below
62
+ * @returns {Promise<{matches: Array<{path, line, text}>, scanned: number, truncated: boolean, cancelled?: boolean}>}
63
+ */
64
+ export async function searchTree(sftp, rootDir, opts = {}) {
65
+ const {
66
+ regex, // RegExp (already compiled with ignoreCase where asked)
67
+ glob, // optional basename glob filter
68
+ contextLines = 0,
69
+ maxMatches = 500,
70
+ maxDepth = 12,
71
+ maxFiles = 20000,
72
+ maxScanBytes = 1024 * 1024,
73
+ maxDurationMs = 0, // 0 = no wall-clock budget
74
+ isIgnored = () => false,
75
+ signal,
76
+ } = opts
77
+
78
+ const matches = []
79
+ let scanned = 0
80
+ let truncated = false
81
+ let cancelled = false
82
+ const startedAt = Date.now()
83
+
84
+ /** True when the caller asked us to stop (abort, or the time budget is spent). */
85
+ const shouldStop = () => {
86
+ if (cancelled || truncated) return true
87
+ if (signal && signal.aborted) { cancelled = true; return true }
88
+ if (maxDurationMs > 0 && Date.now() - startedAt >= maxDurationMs) { truncated = true; return true }
89
+ return false
90
+ }
91
+
92
+ const walk = async (dir, depth) => {
93
+ if (shouldStop() || scanned >= maxFiles) return
94
+ let entries = []
95
+ try { entries = (await sftp.readdir(dir)) || [] } catch { return }
96
+ const dirs = []
97
+ for (const e of entries) {
98
+ const name = String(e.filename)
99
+ if (name === '.' || name === '..') continue
100
+ const isDir = !!(e.attrs && e.attrs.isDirectory && e.attrs.isDirectory())
101
+ if (isDir) dirs.push(name)
102
+ }
103
+ // Depth-first, dirs first, but stop as soon as we hit caps.
104
+ for (const name of dirs) {
105
+ if (shouldStop() || scanned >= maxFiles) return
106
+ if (depth <= 0) continue
107
+ const sub = joinRemotePath(dir, name)
108
+ // isIgnored receives the path RELATIVE to the search root (the same
109
+ // contract sync has), so root-anchored ignore rules — `/proc/` when the
110
+ // workspace is the filesystem root (issue #60), `Library/Caches/` in the
111
+ // default search set — actually match here; a bare basename would never
112
+ // satisfy an anchored pattern and those rules were silently dead.
113
+ if (isIgnored(relPathUnder(rootDir, sub) || name, true)) continue
114
+ await walk(sub, depth - 1)
115
+ }
116
+ if (shouldStop() || scanned >= maxFiles) return
117
+
118
+ const fileNames = entries
119
+ .filter((e) => !(e.attrs && e.attrs.isDirectory && e.attrs.isDirectory()))
120
+ .map((e) => String(e.filename))
121
+ .filter((n) => n !== '.' && n !== '..')
122
+ await mapLimit(fileNames, 4, async (name) => {
123
+ // Checked per entry: this is the loop that used to run for hours.
124
+ if (shouldStop() || scanned >= maxFiles || matches.length >= maxMatches) return
125
+ if (glob && !matchGlob(name, glob)) return
126
+ const fp = joinRemotePath(dir, name)
127
+ if (isIgnored(relPathUnder(rootDir, fp) || name, false)) return
128
+ scanned++
129
+ let st
130
+ try { st = await sftp.stat(fp) } catch { return }
131
+ if (shouldStop()) return
132
+ if (st.size > maxScanBytes) return
133
+ let buf
134
+ try { buf = await sftp.readFile(fp) } catch { return }
135
+ if (shouldStop()) return
136
+ if (looksBinary(buf)) return
137
+ const content = buf.toString('utf8').replace(/\r\n/g, '\n')
138
+ const lines = content.split('\n')
139
+ const hitLines = new Set()
140
+ for (let i = 0; i < lines.length; i++) {
141
+ if (regex.test(lines[i])) {
142
+ regex.lastIndex = 0
143
+ for (let c = Math.max(0, i - contextLines); c <= Math.min(lines.length - 1, i + contextLines); c++) {
144
+ hitLines.add(c)
145
+ }
146
+ if (matches.length >= maxMatches) { truncated = true; return }
147
+ }
148
+ }
149
+ if (hitLines.size) {
150
+ for (const ln of [...hitLines].sort((a, b) => a - b)) {
151
+ if (matches.length >= maxMatches) { truncated = true; return }
152
+ matches.push({ path: fp, line: ln + 1, text: lines[ln] })
153
+ }
154
+ }
155
+ })
156
+ }
157
+
158
+ await walk(rootDir, maxDepth)
159
+ if (scanned >= maxFiles) truncated = true
160
+ return { matches, scanned, truncated, cancelled }
161
+ }
162
+
163
+ function parseGrepLines(stdout, maxMatches) {
164
+ const matches = []
165
+ let truncated = false
166
+ for (const line of String(stdout || '').split('\n')) {
167
+ if (!line) continue
168
+ const m = line.match(/^(.*?):(\d+):(.*)$/)
169
+ if (!m) continue
170
+ if (matches.length >= maxMatches) { truncated = true; break }
171
+ matches.push({ path: m[1], line: Number(m[2]), text: m[3] })
172
+ }
173
+ return { matches, truncated }
174
+ }
175
+
176
+ /**
177
+ * POSIX fast path: `rg` then `grep -R`. Returns null when the remote has
178
+ * neither, so the caller can fall back to an SFTP walk (Windows / no grep).
179
+ */
180
+ export async function searchViaShell(pool, rootDir, opts = {}) {
181
+ const {
182
+ pattern,
183
+ ignoreCase = true,
184
+ glob,
185
+ contextLines = 0,
186
+ maxMatches = 500,
187
+ signal,
188
+ } = opts
189
+ // Honour an already-aborted signal before spawning a remote grep on a huge tree.
190
+ if (signal && signal.aborted) {
191
+ return { matches: [], scanned: 0, truncated: false, cancelled: true, via: 'none' }
192
+ }
193
+ const ic = ignoreCase ? ' -i' : ''
194
+ const globFlag = glob ? ` --glob ${shq(glob)}` : ''
195
+ const ctx = contextLines > 0 ? ` -C ${Number(contextLines)}` : ''
196
+ const rg = `rg -n --no-heading -I${ic}${globFlag}${ctx} --max-count ${maxMatches} -- ${shq(pattern)} ${shq(rootDir)}`
197
+ const grepGlob = glob ? ` --include=${shq(glob)}` : ''
198
+ const grep = `grep -R -n -I -E${ic}${grepGlob}${ctx} -- ${shq(pattern)} ${shq(rootDir)}`
199
+ const script = `if command -v rg >/dev/null 2>&1; then ${rg}; elif command -v grep >/dev/null 2>&1; then ${grep}; else exit 127; fi`
200
+ const res = await pool.exec(script, { timeoutMs: opts.timeoutMs, signal })
201
+ // A cancelled call must return as cancelled (with whatever partial output the
202
+ // remote produced), NOT throw: throwing would make searchRemote fall through
203
+ // to the slower SFTP walk, i.e. keep running after the caller said stop.
204
+ if (res.signal === 'ABORTED') {
205
+ const partial = parseGrepLines(res.stdout, maxMatches)
206
+ return { matches: partial.matches, scanned: partial.matches.length, truncated: false, cancelled: true, via: 'none' }
207
+ }
208
+ // 0 = hits, 1 = no hits; other codes mean the tools are missing / failed.
209
+ if (res.signal === 'TIMEOUT') throw new Error('search timed out')
210
+ if (res.code !== 0 && res.code !== 1) throw new Error(res.stderr || `search exit ${res.code}`)
211
+ const parsed = parseGrepLines(res.stdout, maxMatches)
212
+ return {
213
+ matches: parsed.matches,
214
+ scanned: parsed.matches.length,
215
+ truncated: parsed.truncated || /truncated|max-count/i.test(String(res.stderr || '')),
216
+ via: /rg/.test(String(res.stderr || '')) ? 'rg' : 'grep',
217
+ }
218
+ }
219
+
220
+ /** Prefer a POSIX grep/rg, fall back to the portable SFTP walk. */
221
+ export async function searchRemote(pool, rootDir, opts = {}) {
222
+ if (pool && pool.platform !== 'windows') {
223
+ try {
224
+ const fast = await searchViaShell(pool, rootDir, opts)
225
+ if (fast && Array.isArray(fast.matches)) return fast
226
+ } catch (e) {
227
+ // An abort is terminal, not a "try the slower fallback" signal.
228
+ if (opts.signal && opts.signal.aborted) {
229
+ return { matches: [], scanned: 0, truncated: false, cancelled: true, via: 'none' }
230
+ }
231
+ }
232
+ }
233
+ const sftp = await pool.sftp()
234
+ return searchTree(sftp, rootDir, opts)
235
+ }