dsh-remote 0.8.13 → 0.8.15

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/lib/search.js CHANGED
@@ -1,127 +1,127 @@
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 } 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
- * @returns {Promise<{matches: Array<{path, line, text}>, scanned: number, truncated: boolean}>}
50
- */
51
- export async function searchTree(sftp, rootDir, opts = {}) {
52
- const {
53
- regex, // RegExp (already compiled with ignoreCase where asked)
54
- glob, // optional basename glob filter
55
- contextLines = 0,
56
- maxMatches = 500,
57
- maxDepth = 12,
58
- maxFiles = 20000,
59
- maxScanBytes = 1024 * 1024,
60
- isIgnored = () => false,
61
- } = opts
62
-
63
- const matches = []
64
- let scanned = 0
65
- let truncated = false
66
-
67
- const walk = async (dir, depth) => {
68
- if (truncated || scanned >= maxFiles) return
69
- let entries = []
70
- try { entries = (await sftp.readdir(dir)) || [] } catch { return }
71
- const dirs = []
72
- for (const e of entries) {
73
- const name = String(e.filename)
74
- if (name === '.' || name === '..') continue
75
- const isDir = !!(e.attrs && e.attrs.isDirectory && e.attrs.isDirectory())
76
- if (isDir) dirs.push(name)
77
- }
78
- // Depth-first, dirs first, but stop as soon as we hit caps.
79
- for (const name of dirs) {
80
- if (truncated || scanned >= maxFiles) return
81
- if (depth <= 0) continue
82
- const sub = joinRemotePath(dir, name)
83
- if (isIgnored(name, true)) continue
84
- await walk(sub, depth - 1)
85
- }
86
- if (truncated || scanned >= maxFiles) return
87
-
88
- const fileNames = entries
89
- .filter((e) => !(e.attrs && e.attrs.isDirectory && e.attrs.isDirectory()))
90
- .map((e) => String(e.filename))
91
- .filter((n) => n !== '.' && n !== '..')
92
- await mapLimit(fileNames, 4, async (name) => {
93
- if (truncated || scanned >= maxFiles || matches.length >= maxMatches) return
94
- if (glob && !matchGlob(name, glob)) return
95
- if (isIgnored(name, false)) return
96
- const fp = joinRemotePath(dir, name)
97
- scanned++
98
- let st
99
- try { st = await sftp.stat(fp) } catch { return }
100
- if (st.size > maxScanBytes) return
101
- let buf
102
- try { buf = await sftp.readFile(fp) } catch { return }
103
- if (looksBinary(buf)) return
104
- const content = buf.toString('utf8').replace(/\r\n/g, '\n')
105
- const lines = content.split('\n')
106
- const hitLines = new Set()
107
- for (let i = 0; i < lines.length; i++) {
108
- if (regex.test(lines[i])) {
109
- regex.lastIndex = 0
110
- for (let c = Math.max(0, i - contextLines); c <= Math.min(lines.length - 1, i + contextLines); c++) {
111
- hitLines.add(c)
112
- }
113
- if (matches.length >= maxMatches) { truncated = true; return }
114
- }
115
- }
116
- if (hitLines.size) {
117
- for (const ln of [...hitLines].sort((a, b) => a - b)) {
118
- if (matches.length >= maxMatches) { truncated = true; return }
119
- matches.push({ path: fp, line: ln + 1, text: lines[ln] })
120
- }
121
- }
122
- })
123
- }
124
-
125
- await walk(rootDir, maxDepth)
126
- return { matches, scanned, truncated }
127
- }
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 } 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
+ * @returns {Promise<{matches: Array<{path, line, text}>, scanned: number, truncated: boolean}>}
50
+ */
51
+ export async function searchTree(sftp, rootDir, opts = {}) {
52
+ const {
53
+ regex, // RegExp (already compiled with ignoreCase where asked)
54
+ glob, // optional basename glob filter
55
+ contextLines = 0,
56
+ maxMatches = 500,
57
+ maxDepth = 12,
58
+ maxFiles = 20000,
59
+ maxScanBytes = 1024 * 1024,
60
+ isIgnored = () => false,
61
+ } = opts
62
+
63
+ const matches = []
64
+ let scanned = 0
65
+ let truncated = false
66
+
67
+ const walk = async (dir, depth) => {
68
+ if (truncated || scanned >= maxFiles) return
69
+ let entries = []
70
+ try { entries = (await sftp.readdir(dir)) || [] } catch { return }
71
+ const dirs = []
72
+ for (const e of entries) {
73
+ const name = String(e.filename)
74
+ if (name === '.' || name === '..') continue
75
+ const isDir = !!(e.attrs && e.attrs.isDirectory && e.attrs.isDirectory())
76
+ if (isDir) dirs.push(name)
77
+ }
78
+ // Depth-first, dirs first, but stop as soon as we hit caps.
79
+ for (const name of dirs) {
80
+ if (truncated || scanned >= maxFiles) return
81
+ if (depth <= 0) continue
82
+ const sub = joinRemotePath(dir, name)
83
+ if (isIgnored(name, true)) continue
84
+ await walk(sub, depth - 1)
85
+ }
86
+ if (truncated || scanned >= maxFiles) return
87
+
88
+ const fileNames = entries
89
+ .filter((e) => !(e.attrs && e.attrs.isDirectory && e.attrs.isDirectory()))
90
+ .map((e) => String(e.filename))
91
+ .filter((n) => n !== '.' && n !== '..')
92
+ await mapLimit(fileNames, 4, async (name) => {
93
+ if (truncated || scanned >= maxFiles || matches.length >= maxMatches) return
94
+ if (glob && !matchGlob(name, glob)) return
95
+ if (isIgnored(name, false)) return
96
+ const fp = joinRemotePath(dir, name)
97
+ scanned++
98
+ let st
99
+ try { st = await sftp.stat(fp) } catch { return }
100
+ if (st.size > maxScanBytes) return
101
+ let buf
102
+ try { buf = await sftp.readFile(fp) } catch { return }
103
+ if (looksBinary(buf)) return
104
+ const content = buf.toString('utf8').replace(/\r\n/g, '\n')
105
+ const lines = content.split('\n')
106
+ const hitLines = new Set()
107
+ for (let i = 0; i < lines.length; i++) {
108
+ if (regex.test(lines[i])) {
109
+ regex.lastIndex = 0
110
+ for (let c = Math.max(0, i - contextLines); c <= Math.min(lines.length - 1, i + contextLines); c++) {
111
+ hitLines.add(c)
112
+ }
113
+ if (matches.length >= maxMatches) { truncated = true; return }
114
+ }
115
+ }
116
+ if (hitLines.size) {
117
+ for (const ln of [...hitLines].sort((a, b) => a - b)) {
118
+ if (matches.length >= maxMatches) { truncated = true; return }
119
+ matches.push({ path: fp, line: ln + 1, text: lines[ln] })
120
+ }
121
+ }
122
+ })
123
+ }
124
+
125
+ await walk(rootDir, maxDepth)
126
+ return { matches, scanned, truncated }
127
+ }
package/lib/sshconfig.js CHANGED
@@ -1,57 +1,57 @@
1
- // dsh-remote — ~/.ssh/config parser for the "import from ssh config" feature.
2
- // Pure parsing (no auth material is ever read: only Host/HostName/User/Port/
3
- // IdentityFile/ProxyJump). IdentityFile is imported as a PATH reference only —
4
- // the plugin never auto-reads keys (v0.5.5 policy).
5
- import { readFileSync } from 'node:fs'
6
- import { homedir } from 'node:os'
7
- import path from 'node:path'
8
-
9
- export const sshConfigPath = () => path.join(homedir(), '.ssh', 'config')
10
-
11
- /** Read the user's ~/.ssh/config text ('' when absent/unreadable). */
12
- export function readSshConfigText(filePath = sshConfigPath()) {
13
- try {
14
- return readFileSync(filePath, 'utf8')
15
- } catch {
16
- return ''
17
- }
18
- }
19
-
20
- /**
21
- * Parse OpenSSH config text into host entries.
22
- * @returns {Array<{host: string, hostName: string, user: string, port: number, identityFile: string, proxyJump: string}>}
23
- * host = alias pattern (may be '*' / '!foo' / comma list — caller filters).
24
- */
25
- export function parseSshConfig(text) {
26
- const entries = []
27
- let cur = null
28
- const lines = String(text || '').split('\n')
29
- for (const raw of lines) {
30
- const line = raw.trim()
31
- if (!line || line.startsWith('#') || line.startsWith('Include ')) continue
32
- const m = line.match(/^(\S+)\s+(.*)$/)
33
- if (!m) continue
34
- const key = m[1].toLowerCase()
35
- const val = m[2].trim()
36
- if (key === 'host') {
37
- cur = { host: val, hostName: '', user: '', port: 22, identityFile: '', proxyJump: '' }
38
- entries.push(cur)
39
- continue
40
- }
41
- if (!cur) continue
42
- if (key === 'hostname') cur.hostName = val
43
- else if (key === 'user') cur.user = val
44
- else if (key === 'port') cur.port = Number(val) || 22
45
- else if (key === 'identityfile') cur.identityFile = val
46
- else if (key === 'proxyjump') cur.proxyJump = val
47
- }
48
- return entries
49
- }
50
-
51
- /** Entries a user can actually import: concrete aliases (skip wildcards). */
52
- export function importableEntries(text) {
53
- return parseSshConfig(text).filter((e) => {
54
- if (!e.host || e.host.includes('*') || e.host.includes('!') || e.host.includes(',')) return false
55
- return e.hostName || e.user || e.port !== 22 || e.identityFile || e.proxyJump
56
- })
57
- }
1
+ // dsh-remote — ~/.ssh/config parser for the "import from ssh config" feature.
2
+ // Pure parsing (no auth material is ever read: only Host/HostName/User/Port/
3
+ // IdentityFile/ProxyJump). IdentityFile is imported as a PATH reference only —
4
+ // the plugin never auto-reads keys (v0.5.5 policy).
5
+ import { readFileSync } from 'node:fs'
6
+ import { homedir } from 'node:os'
7
+ import path from 'node:path'
8
+
9
+ export const sshConfigPath = () => path.join(homedir(), '.ssh', 'config')
10
+
11
+ /** Read the user's ~/.ssh/config text ('' when absent/unreadable). */
12
+ export function readSshConfigText(filePath = sshConfigPath()) {
13
+ try {
14
+ return readFileSync(filePath, 'utf8')
15
+ } catch {
16
+ return ''
17
+ }
18
+ }
19
+
20
+ /**
21
+ * Parse OpenSSH config text into host entries.
22
+ * @returns {Array<{host: string, hostName: string, user: string, port: number, identityFile: string, proxyJump: string}>}
23
+ * host = alias pattern (may be '*' / '!foo' / comma list — caller filters).
24
+ */
25
+ export function parseSshConfig(text) {
26
+ const entries = []
27
+ let cur = null
28
+ const lines = String(text || '').split('\n')
29
+ for (const raw of lines) {
30
+ const line = raw.trim()
31
+ if (!line || line.startsWith('#') || line.startsWith('Include ')) continue
32
+ const m = line.match(/^(\S+)\s+(.*)$/)
33
+ if (!m) continue
34
+ const key = m[1].toLowerCase()
35
+ const val = m[2].trim()
36
+ if (key === 'host') {
37
+ cur = { host: val, hostName: '', user: '', port: 22, identityFile: '', proxyJump: '' }
38
+ entries.push(cur)
39
+ continue
40
+ }
41
+ if (!cur) continue
42
+ if (key === 'hostname') cur.hostName = val
43
+ else if (key === 'user') cur.user = val
44
+ else if (key === 'port') cur.port = Number(val) || 22
45
+ else if (key === 'identityfile') cur.identityFile = val
46
+ else if (key === 'proxyjump') cur.proxyJump = val
47
+ }
48
+ return entries
49
+ }
50
+
51
+ /** Entries a user can actually import: concrete aliases (skip wildcards). */
52
+ export function importableEntries(text) {
53
+ return parseSshConfig(text).filter((e) => {
54
+ if (!e.host || e.host.includes('*') || e.host.includes('!') || e.host.includes(',')) return false
55
+ return e.hostName || e.user || e.port !== 22 || e.identityFile || e.proxyJump
56
+ })
57
+ }