dsh-remote 0.8.72 → 0.8.73
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 -21
- package/README.en.md +389 -389
- package/README.md +462 -462
- package/cordis.patch.yml +9 -9
- package/lib/binding.js +172 -172
- package/lib/client.js +4022 -4022
- package/lib/credential.js +263 -263
- package/lib/errors.js +141 -141
- package/lib/exec-job.js +235 -235
- package/lib/file-reference.js +457 -457
- package/lib/forwards.js +258 -258
- package/lib/hostkey.js +108 -108
- package/lib/http-transport.js +196 -196
- package/lib/ignore.js +202 -202
- package/lib/index.js +4550 -4540
- package/lib/instructions-dynamic.js +415 -415
- package/lib/openssh.js +879 -879
- package/lib/paths.js +252 -252
- package/lib/pool.js +963 -893
- package/lib/registry.js +296 -293
- package/lib/remote-fs.js +173 -173
- package/lib/remote-sandbox.js +183 -183
- package/lib/remote-shell-child.cjs +344 -305
- package/lib/remote-shell.js +542 -518
- package/lib/routes-fs.js +245 -245
- package/lib/routes-term.js +169 -169
- package/lib/search.js +235 -235
- package/lib/sftp-surface.js +119 -119
- package/lib/sshconfig.js +779 -705
- package/lib/sync.js +275 -275
- package/lib/tasks.js +95 -95
- package/lib/telemetry.js +213 -213
- package/lib/terminal.js +205 -205
- package/lib/update.js +472 -472
- package/lib/web-attach.js +850 -850
- 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
|
+
}
|