dsh-remote 0.8.14 → 0.8.16
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.md +290 -235
- package/README.zh.md +231 -191
- package/cordis.patch.yml +7 -1
- package/lib/binding.js +97 -97
- package/lib/client.js +2053 -1985
- package/lib/credential.js +177 -107
- package/lib/errors.js +78 -78
- package/lib/forwards.js +219 -219
- package/lib/hostkey.js +48 -48
- package/lib/http-transport.js +104 -0
- package/lib/ignore.js +123 -123
- package/lib/index.js +3284 -3230
- package/lib/paths.js +217 -217
- package/lib/registry.js +89 -89
- package/lib/search.js +127 -127
- package/lib/sshconfig.js +57 -57
- package/lib/sync.js +270 -270
- package/lib/tasks.js +95 -95
- package/lib/update.js +196 -196
- package/package.json +11 -2
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
|
+
}
|