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/LICENSE +21 -21
- package/README.md +236 -234
- package/README.zh.md +195 -190
- package/lib/binding.js +97 -97
- package/lib/client.js +28 -26
- 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/ignore.js +123 -123
- package/lib/index.js +3288 -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 +74 -66
package/lib/paths.js
CHANGED
|
@@ -1,217 +1,217 @@
|
|
|
1
|
-
// dsh-remote — pure path / shell helpers (no fs, no ssh).
|
|
2
|
-
// Split out of lib/index.js so they are unit-testable without loading the
|
|
3
|
-
// plugin. Both POSIX (`/a/b`) and Windows (`D:\Code`, `\\server\share`) forms
|
|
4
|
-
// are supported everywhere.
|
|
5
|
-
|
|
6
|
-
/** Single-quote one shell argument verbatim. */
|
|
7
|
-
export function shq(s) {
|
|
8
|
-
return "'" + String(s).replace(/'/g, "'\\''") + "'"
|
|
9
|
-
}
|
|
10
|
-
|
|
11
|
-
/**
|
|
12
|
-
* Normalize a remote path into a clean absolute path, supporting BOTH POSIX
|
|
13
|
-
* (`/home/user`) and Windows (`D:\Code`, `C:/Users/x`, `\\server\share`) forms.
|
|
14
|
-
*
|
|
15
|
-
* - POSIX paths keep `/` separators and a leading `/`.
|
|
16
|
-
* - Windows drive paths keep their `X:` prefix and are returned with `\`
|
|
17
|
-
* separators (the native form on a Windows remote); a leading `/` is NOT
|
|
18
|
-
* added, so `D:\Code` stays `D:\Code` instead of becoming `/D:\Code`.
|
|
19
|
-
* - UNC paths (`\\server\share\…`) keep the `\\server\share` prefix.
|
|
20
|
-
* - `.` / `..` segments are collapsed in both separator styles.
|
|
21
|
-
*/
|
|
22
|
-
export function normalizeRemotePath(p) {
|
|
23
|
-
const s = String(p)
|
|
24
|
-
// Windows UNC: \\server\share\…
|
|
25
|
-
const unc = s.match(/^(\\\\[^\\]+(?:\\[^\\]+)?)(?:\\|$)(.*)$/s)
|
|
26
|
-
if (unc) {
|
|
27
|
-
const [prefix, rest] = [unc[1], unc[2]]
|
|
28
|
-
const parts = []
|
|
29
|
-
for (const seg of rest.split(/[\\/]+/)) {
|
|
30
|
-
if (seg === '' || seg === '.') continue
|
|
31
|
-
if (seg === '..') { parts.pop(); continue }
|
|
32
|
-
parts.push(seg)
|
|
33
|
-
}
|
|
34
|
-
return parts.length ? prefix + '\\' + parts.join('\\') : prefix
|
|
35
|
-
}
|
|
36
|
-
// Windows drive: X:\… or X:/…
|
|
37
|
-
const drive = s.match(/^([a-zA-Z]:)(?:[\\/]|$)(.*)$/s)
|
|
38
|
-
if (drive) {
|
|
39
|
-
const [prefix, rest] = [drive[1], drive[2]]
|
|
40
|
-
const parts = []
|
|
41
|
-
for (const seg of rest.split(/[\\/]+/)) {
|
|
42
|
-
if (seg === '' || seg === '.') continue
|
|
43
|
-
if (seg === '..') { parts.pop(); continue }
|
|
44
|
-
parts.push(seg)
|
|
45
|
-
}
|
|
46
|
-
return prefix + '\\' + parts.join('\\')
|
|
47
|
-
}
|
|
48
|
-
// POSIX: /a/b/c
|
|
49
|
-
const parts = []
|
|
50
|
-
for (const seg of s.split('/')) {
|
|
51
|
-
if (seg === '' || seg === '.') continue
|
|
52
|
-
if (seg === '..') {
|
|
53
|
-
parts.pop()
|
|
54
|
-
continue
|
|
55
|
-
}
|
|
56
|
-
parts.push(seg)
|
|
57
|
-
}
|
|
58
|
-
return '/' + parts.join('/')
|
|
59
|
-
}
|
|
60
|
-
|
|
61
|
-
/** Join a remote dir + entry name, honoring the dir's own separator style. */
|
|
62
|
-
export function joinRemotePath(dir, name) {
|
|
63
|
-
const d = String(dir)
|
|
64
|
-
if (!d) return String(name)
|
|
65
|
-
if (d.endsWith('/') || d.endsWith('\\')) return d + String(name)
|
|
66
|
-
return d + (d.includes('\\') ? '\\' : '/') + String(name)
|
|
67
|
-
}
|
|
68
|
-
|
|
69
|
-
/** Parent dir of a remote absolute path (string-level; POSIX or Windows). */
|
|
70
|
-
export function remoteDirname(p) {
|
|
71
|
-
const norm = normalizeRemotePath(p)
|
|
72
|
-
// Windows drive/UNC root: D:\ or \\server\share
|
|
73
|
-
if (norm === '/' || /^[a-zA-Z]:\\?$/.test(norm) || /^\\\\[^\\]+\\[^\\]+$/.test(norm)) {
|
|
74
|
-
// Ensure a drive root ends with a backslash: D: → D:\
|
|
75
|
-
const m = norm.match(/^([a-zA-Z]:)$/)
|
|
76
|
-
return m ? m[1] + '\\' : norm
|
|
77
|
-
}
|
|
78
|
-
const sep = norm.includes('\\') ? '\\' : '/'
|
|
79
|
-
const idx = norm.lastIndexOf(sep)
|
|
80
|
-
if (idx <= 0) {
|
|
81
|
-
// Windows drive with a child (D:\Code) → D:\
|
|
82
|
-
const m = norm.match(/^([a-zA-Z]:)\\/)
|
|
83
|
-
return m ? m[1] + '\\' : sep === '/' && norm.startsWith('/') ? '/' : norm
|
|
84
|
-
}
|
|
85
|
-
// A parent that is a Windows drive root also needs the trailing backslash.
|
|
86
|
-
const parent = norm.slice(0, idx)
|
|
87
|
-
if (sep === '\\' && /^[a-zA-Z]:$/.test(parent)) return parent + '\\'
|
|
88
|
-
return parent
|
|
89
|
-
}
|
|
90
|
-
|
|
91
|
-
/** Create every level of a remote dir via SFTP (true mkdir -p: parents AND the
|
|
92
|
-
* target itself), working for both POSIX (`/a/b`) and Windows (`D:\a\b`)
|
|
93
|
-
* paths. Best effort: an existing or permission-denied level is skipped.
|
|
94
|
-
* FILE-oriented callers must pass the parent dir (remoteDirname(filePath)). */
|
|
95
|
-
export async function mkdirRemoteDirs(sftp, dir) {
|
|
96
|
-
const target = normalizeRemotePath(dir)
|
|
97
|
-
const isWin = target.includes('\\')
|
|
98
|
-
// Split off a Windows drive prefix (D:) so it is not treated as a segment.
|
|
99
|
-
const drive = isWin ? (target.match(/^([a-zA-Z]:)[\\/]?(.*)$/s) || null) : null
|
|
100
|
-
const segs = drive ? drive[2].split(/[\\/]/).filter(Boolean) : target.split(/[\\/]/).filter(Boolean)
|
|
101
|
-
let cur = isWin ? (drive ? drive[1] + '\\' : '') : ''
|
|
102
|
-
// Drive root (D:\) itself, best effort (usually exists).
|
|
103
|
-
if (isWin && cur) { try { await sftp.mkdir(cur) } catch { /* exists */ } }
|
|
104
|
-
for (const s of segs) {
|
|
105
|
-
if (cur) {
|
|
106
|
-
if (!cur.endsWith('\\') && !cur.endsWith('/')) cur += isWin ? '\\' : '/'
|
|
107
|
-
cur += s
|
|
108
|
-
} else {
|
|
109
|
-
cur = (isWin ? s + ':' : '/' + s)
|
|
110
|
-
}
|
|
111
|
-
try { await sftp.mkdir(cur) } catch { /* exists or no perms */ }
|
|
112
|
-
}
|
|
113
|
-
}
|
|
114
|
-
|
|
115
|
-
/**
|
|
116
|
-
* Convert an internal remote path (POSIX `/a/b` or Windows `D:\Code`) into the
|
|
117
|
-
* path format the SFTP server understands.
|
|
118
|
-
*
|
|
119
|
-
* Windows OpenSSH's sftp-server accepts POSIX-style paths: a drive letter is
|
|
120
|
-
* written `/D:/…` (equivalent to `D:\…`). POSIX paths pass through unchanged.
|
|
121
|
-
* UNC paths are kept as-is (the server resolves `\\server\share` itself).
|
|
122
|
-
*/
|
|
123
|
-
export function toSftpPath(p) {
|
|
124
|
-
const norm = normalizeRemotePath(p)
|
|
125
|
-
const m = norm.match(/^([a-zA-Z]):\\(.*)$/s)
|
|
126
|
-
if (m) return '/' + m[1] + ':/' + m[2].replace(/\\/g, '/')
|
|
127
|
-
return norm
|
|
128
|
-
}
|
|
129
|
-
|
|
130
|
-
/**
|
|
131
|
-
* Convert a remote path to the form used in SHELL commands on the remote.
|
|
132
|
-
*
|
|
133
|
-
* On Windows remotes running Git Bash, native `D:\Code` (or `D:/Code`,
|
|
134
|
-
* `/D:/Code`, `/d/Code`) becomes the Git-Bash mount form `/d/Code`; a path
|
|
135
|
-
* already in Git-Bash form passes through unchanged. UNC (`\\server\share`)
|
|
136
|
-
* maps to `//server/share`. POSIX paths are normalized as-is. The conversion
|
|
137
|
-
* is syntactic, so it is safe on POSIX remotes too (only unambiguous Windows
|
|
138
|
-
* forms are rewritten).
|
|
139
|
-
*/
|
|
140
|
-
export function toShellPath(p) {
|
|
141
|
-
let t = String(p || '').replace(/\\/g, '/')
|
|
142
|
-
const unc = t.match(/^\/\/([^/]+)(?:\/(.*))?$/)
|
|
143
|
-
if (unc) return '//' + unc[1] + (unc[2] ? '/' + unc[2] : '')
|
|
144
|
-
const drive = t.match(/^([a-zA-Z]):(\/|$)/) || t.match(/^\/([a-zA-Z]):(\/|$)/)
|
|
145
|
-
if (drive) {
|
|
146
|
-
t = '/' + drive[1].toLowerCase() + (drive[2] ? '/' : '') + t.slice(drive[0].length)
|
|
147
|
-
}
|
|
148
|
-
const parts = []
|
|
149
|
-
for (const seg of t.split('/')) {
|
|
150
|
-
if (seg === '' || seg === '.') continue
|
|
151
|
-
if (seg === '..') { parts.pop(); continue }
|
|
152
|
-
parts.push(seg)
|
|
153
|
-
}
|
|
154
|
-
return '/' + parts.join('/')
|
|
155
|
-
}
|
|
156
|
-
|
|
157
|
-
/**
|
|
158
|
-
* Display form of a path for a Windows remote: a Git-Bash mount `/d/Code` (or
|
|
159
|
-
* `/D:/Code`, `D:/Code`) renders as `D:\Code`; anything else (POSIX paths,
|
|
160
|
-
* native Windows paths) is returned normalized. POSIX remotes always pass
|
|
161
|
-
* through unchanged.
|
|
162
|
-
*/
|
|
163
|
-
export function toDisplayPath(p, platform) {
|
|
164
|
-
if (platform !== 'windows') return normalizeRemotePath(p)
|
|
165
|
-
const t = String(p || '').replace(/\\/g, '/')
|
|
166
|
-
// /X:/rest (Win32-OpenSSH SFTP form)
|
|
167
|
-
const gs = t.match(/^\/([a-zA-Z]):\/(.*)$/)
|
|
168
|
-
if (gs) return gs[1].toUpperCase() + ':\\' + gs[2].replace(/\//g, '\\')
|
|
169
|
-
// /x/rest (Git-Bash mount form) and bare /x
|
|
170
|
-
const git = t.match(/^\/([a-zA-Z])(?:\/(.*))?$/)
|
|
171
|
-
if (git) return git[1].toUpperCase() + ':\\' + (git[2] || '').replace(/\//g, '\\')
|
|
172
|
-
// native X:/rest
|
|
173
|
-
const drive = t.match(/^([a-zA-Z]):(?:\/(.*))?$/)
|
|
174
|
-
if (drive) return drive[1].toUpperCase() + ':\\' + (drive[2] || '').replace(/\//g, '\\')
|
|
175
|
-
return t
|
|
176
|
-
}
|
|
177
|
-
|
|
178
|
-
/** Basename of a remote absolute path (POSIX or Windows). */
|
|
179
|
-
export function remotePathBase(p) {
|
|
180
|
-
const norm = normalizeRemotePath(p).replace(/[\\/]+$/, '')
|
|
181
|
-
const base = norm.split(/[\\/]/).pop()
|
|
182
|
-
// Windows drive root (D:\) has no meaningful basename
|
|
183
|
-
if (!base || /^[a-zA-Z]:$/.test(base)) return 'workspace'
|
|
184
|
-
return base
|
|
185
|
-
}
|
|
186
|
-
|
|
187
|
-
/** Relative path of `p` under `root` (POSIX form, no leading slash), or null
|
|
188
|
-
* when `p` is not inside `root`. Both POSIX and Windows drive paths work
|
|
189
|
-
* (Windows is compared in its `/D:/…` POSIX-equivalent form). */
|
|
190
|
-
export function relPathUnder(root, p) {
|
|
191
|
-
const posix = (x) => {
|
|
192
|
-
let n = normalizeRemotePath(x).replace(/[\\/]+$/, '')
|
|
193
|
-
const m = n.match(/^([a-zA-Z]):(.*)$/s)
|
|
194
|
-
if (m) return '/' + m[1].toLowerCase() + ':/' + (m[2] ? m[2].replace(/\\/g, '/').replace(/^\/+/, '') : '')
|
|
195
|
-
return n.replace(/\\/g, '/')
|
|
196
|
-
}
|
|
197
|
-
const r = posix(root)
|
|
198
|
-
const n = posix(p)
|
|
199
|
-
if (n === r) return ''
|
|
200
|
-
const prefix = r === '/' ? '/' : r + '/'
|
|
201
|
-
if (n.startsWith(prefix)) return n.slice(prefix.length)
|
|
202
|
-
return null
|
|
203
|
-
}
|
|
204
|
-
|
|
205
|
-
export function truncate(s, max) {
|
|
206
|
-
if (s.length <= max) return s
|
|
207
|
-
return s.slice(0, max) + `\n…[truncated: ${s.length - max} more chars]`
|
|
208
|
-
}
|
|
209
|
-
|
|
210
|
-
/** 32-bit string hash rendered in base36 (short, collision-safe enough). */
|
|
211
|
-
export function shortHash(s) {
|
|
212
|
-
let h = 0
|
|
213
|
-
for (let i = 0; i < s.length; i++) {
|
|
214
|
-
h = ((h << 5) - h + s.charCodeAt(i)) | 0
|
|
215
|
-
}
|
|
216
|
-
return (h >>> 0).toString(36)
|
|
217
|
-
}
|
|
1
|
+
// dsh-remote — pure path / shell helpers (no fs, no ssh).
|
|
2
|
+
// Split out of lib/index.js so they are unit-testable without loading the
|
|
3
|
+
// plugin. Both POSIX (`/a/b`) and Windows (`D:\Code`, `\\server\share`) forms
|
|
4
|
+
// are supported everywhere.
|
|
5
|
+
|
|
6
|
+
/** Single-quote one shell argument verbatim. */
|
|
7
|
+
export function shq(s) {
|
|
8
|
+
return "'" + String(s).replace(/'/g, "'\\''") + "'"
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Normalize a remote path into a clean absolute path, supporting BOTH POSIX
|
|
13
|
+
* (`/home/user`) and Windows (`D:\Code`, `C:/Users/x`, `\\server\share`) forms.
|
|
14
|
+
*
|
|
15
|
+
* - POSIX paths keep `/` separators and a leading `/`.
|
|
16
|
+
* - Windows drive paths keep their `X:` prefix and are returned with `\`
|
|
17
|
+
* separators (the native form on a Windows remote); a leading `/` is NOT
|
|
18
|
+
* added, so `D:\Code` stays `D:\Code` instead of becoming `/D:\Code`.
|
|
19
|
+
* - UNC paths (`\\server\share\…`) keep the `\\server\share` prefix.
|
|
20
|
+
* - `.` / `..` segments are collapsed in both separator styles.
|
|
21
|
+
*/
|
|
22
|
+
export function normalizeRemotePath(p) {
|
|
23
|
+
const s = String(p)
|
|
24
|
+
// Windows UNC: \\server\share\…
|
|
25
|
+
const unc = s.match(/^(\\\\[^\\]+(?:\\[^\\]+)?)(?:\\|$)(.*)$/s)
|
|
26
|
+
if (unc) {
|
|
27
|
+
const [prefix, rest] = [unc[1], unc[2]]
|
|
28
|
+
const parts = []
|
|
29
|
+
for (const seg of rest.split(/[\\/]+/)) {
|
|
30
|
+
if (seg === '' || seg === '.') continue
|
|
31
|
+
if (seg === '..') { parts.pop(); continue }
|
|
32
|
+
parts.push(seg)
|
|
33
|
+
}
|
|
34
|
+
return parts.length ? prefix + '\\' + parts.join('\\') : prefix
|
|
35
|
+
}
|
|
36
|
+
// Windows drive: X:\… or X:/…
|
|
37
|
+
const drive = s.match(/^([a-zA-Z]:)(?:[\\/]|$)(.*)$/s)
|
|
38
|
+
if (drive) {
|
|
39
|
+
const [prefix, rest] = [drive[1], drive[2]]
|
|
40
|
+
const parts = []
|
|
41
|
+
for (const seg of rest.split(/[\\/]+/)) {
|
|
42
|
+
if (seg === '' || seg === '.') continue
|
|
43
|
+
if (seg === '..') { parts.pop(); continue }
|
|
44
|
+
parts.push(seg)
|
|
45
|
+
}
|
|
46
|
+
return prefix + '\\' + parts.join('\\')
|
|
47
|
+
}
|
|
48
|
+
// POSIX: /a/b/c
|
|
49
|
+
const parts = []
|
|
50
|
+
for (const seg of s.split('/')) {
|
|
51
|
+
if (seg === '' || seg === '.') continue
|
|
52
|
+
if (seg === '..') {
|
|
53
|
+
parts.pop()
|
|
54
|
+
continue
|
|
55
|
+
}
|
|
56
|
+
parts.push(seg)
|
|
57
|
+
}
|
|
58
|
+
return '/' + parts.join('/')
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** Join a remote dir + entry name, honoring the dir's own separator style. */
|
|
62
|
+
export function joinRemotePath(dir, name) {
|
|
63
|
+
const d = String(dir)
|
|
64
|
+
if (!d) return String(name)
|
|
65
|
+
if (d.endsWith('/') || d.endsWith('\\')) return d + String(name)
|
|
66
|
+
return d + (d.includes('\\') ? '\\' : '/') + String(name)
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** Parent dir of a remote absolute path (string-level; POSIX or Windows). */
|
|
70
|
+
export function remoteDirname(p) {
|
|
71
|
+
const norm = normalizeRemotePath(p)
|
|
72
|
+
// Windows drive/UNC root: D:\ or \\server\share
|
|
73
|
+
if (norm === '/' || /^[a-zA-Z]:\\?$/.test(norm) || /^\\\\[^\\]+\\[^\\]+$/.test(norm)) {
|
|
74
|
+
// Ensure a drive root ends with a backslash: D: → D:\
|
|
75
|
+
const m = norm.match(/^([a-zA-Z]:)$/)
|
|
76
|
+
return m ? m[1] + '\\' : norm
|
|
77
|
+
}
|
|
78
|
+
const sep = norm.includes('\\') ? '\\' : '/'
|
|
79
|
+
const idx = norm.lastIndexOf(sep)
|
|
80
|
+
if (idx <= 0) {
|
|
81
|
+
// Windows drive with a child (D:\Code) → D:\
|
|
82
|
+
const m = norm.match(/^([a-zA-Z]:)\\/)
|
|
83
|
+
return m ? m[1] + '\\' : sep === '/' && norm.startsWith('/') ? '/' : norm
|
|
84
|
+
}
|
|
85
|
+
// A parent that is a Windows drive root also needs the trailing backslash.
|
|
86
|
+
const parent = norm.slice(0, idx)
|
|
87
|
+
if (sep === '\\' && /^[a-zA-Z]:$/.test(parent)) return parent + '\\'
|
|
88
|
+
return parent
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** Create every level of a remote dir via SFTP (true mkdir -p: parents AND the
|
|
92
|
+
* target itself), working for both POSIX (`/a/b`) and Windows (`D:\a\b`)
|
|
93
|
+
* paths. Best effort: an existing or permission-denied level is skipped.
|
|
94
|
+
* FILE-oriented callers must pass the parent dir (remoteDirname(filePath)). */
|
|
95
|
+
export async function mkdirRemoteDirs(sftp, dir) {
|
|
96
|
+
const target = normalizeRemotePath(dir)
|
|
97
|
+
const isWin = target.includes('\\')
|
|
98
|
+
// Split off a Windows drive prefix (D:) so it is not treated as a segment.
|
|
99
|
+
const drive = isWin ? (target.match(/^([a-zA-Z]:)[\\/]?(.*)$/s) || null) : null
|
|
100
|
+
const segs = drive ? drive[2].split(/[\\/]/).filter(Boolean) : target.split(/[\\/]/).filter(Boolean)
|
|
101
|
+
let cur = isWin ? (drive ? drive[1] + '\\' : '') : ''
|
|
102
|
+
// Drive root (D:\) itself, best effort (usually exists).
|
|
103
|
+
if (isWin && cur) { try { await sftp.mkdir(cur) } catch { /* exists */ } }
|
|
104
|
+
for (const s of segs) {
|
|
105
|
+
if (cur) {
|
|
106
|
+
if (!cur.endsWith('\\') && !cur.endsWith('/')) cur += isWin ? '\\' : '/'
|
|
107
|
+
cur += s
|
|
108
|
+
} else {
|
|
109
|
+
cur = (isWin ? s + ':' : '/' + s)
|
|
110
|
+
}
|
|
111
|
+
try { await sftp.mkdir(cur) } catch { /* exists or no perms */ }
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Convert an internal remote path (POSIX `/a/b` or Windows `D:\Code`) into the
|
|
117
|
+
* path format the SFTP server understands.
|
|
118
|
+
*
|
|
119
|
+
* Windows OpenSSH's sftp-server accepts POSIX-style paths: a drive letter is
|
|
120
|
+
* written `/D:/…` (equivalent to `D:\…`). POSIX paths pass through unchanged.
|
|
121
|
+
* UNC paths are kept as-is (the server resolves `\\server\share` itself).
|
|
122
|
+
*/
|
|
123
|
+
export function toSftpPath(p) {
|
|
124
|
+
const norm = normalizeRemotePath(p)
|
|
125
|
+
const m = norm.match(/^([a-zA-Z]):\\(.*)$/s)
|
|
126
|
+
if (m) return '/' + m[1] + ':/' + m[2].replace(/\\/g, '/')
|
|
127
|
+
return norm
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Convert a remote path to the form used in SHELL commands on the remote.
|
|
132
|
+
*
|
|
133
|
+
* On Windows remotes running Git Bash, native `D:\Code` (or `D:/Code`,
|
|
134
|
+
* `/D:/Code`, `/d/Code`) becomes the Git-Bash mount form `/d/Code`; a path
|
|
135
|
+
* already in Git-Bash form passes through unchanged. UNC (`\\server\share`)
|
|
136
|
+
* maps to `//server/share`. POSIX paths are normalized as-is. The conversion
|
|
137
|
+
* is syntactic, so it is safe on POSIX remotes too (only unambiguous Windows
|
|
138
|
+
* forms are rewritten).
|
|
139
|
+
*/
|
|
140
|
+
export function toShellPath(p) {
|
|
141
|
+
let t = String(p || '').replace(/\\/g, '/')
|
|
142
|
+
const unc = t.match(/^\/\/([^/]+)(?:\/(.*))?$/)
|
|
143
|
+
if (unc) return '//' + unc[1] + (unc[2] ? '/' + unc[2] : '')
|
|
144
|
+
const drive = t.match(/^([a-zA-Z]):(\/|$)/) || t.match(/^\/([a-zA-Z]):(\/|$)/)
|
|
145
|
+
if (drive) {
|
|
146
|
+
t = '/' + drive[1].toLowerCase() + (drive[2] ? '/' : '') + t.slice(drive[0].length)
|
|
147
|
+
}
|
|
148
|
+
const parts = []
|
|
149
|
+
for (const seg of t.split('/')) {
|
|
150
|
+
if (seg === '' || seg === '.') continue
|
|
151
|
+
if (seg === '..') { parts.pop(); continue }
|
|
152
|
+
parts.push(seg)
|
|
153
|
+
}
|
|
154
|
+
return '/' + parts.join('/')
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* Display form of a path for a Windows remote: a Git-Bash mount `/d/Code` (or
|
|
159
|
+
* `/D:/Code`, `D:/Code`) renders as `D:\Code`; anything else (POSIX paths,
|
|
160
|
+
* native Windows paths) is returned normalized. POSIX remotes always pass
|
|
161
|
+
* through unchanged.
|
|
162
|
+
*/
|
|
163
|
+
export function toDisplayPath(p, platform) {
|
|
164
|
+
if (platform !== 'windows') return normalizeRemotePath(p)
|
|
165
|
+
const t = String(p || '').replace(/\\/g, '/')
|
|
166
|
+
// /X:/rest (Win32-OpenSSH SFTP form)
|
|
167
|
+
const gs = t.match(/^\/([a-zA-Z]):\/(.*)$/)
|
|
168
|
+
if (gs) return gs[1].toUpperCase() + ':\\' + gs[2].replace(/\//g, '\\')
|
|
169
|
+
// /x/rest (Git-Bash mount form) and bare /x
|
|
170
|
+
const git = t.match(/^\/([a-zA-Z])(?:\/(.*))?$/)
|
|
171
|
+
if (git) return git[1].toUpperCase() + ':\\' + (git[2] || '').replace(/\//g, '\\')
|
|
172
|
+
// native X:/rest
|
|
173
|
+
const drive = t.match(/^([a-zA-Z]):(?:\/(.*))?$/)
|
|
174
|
+
if (drive) return drive[1].toUpperCase() + ':\\' + (drive[2] || '').replace(/\//g, '\\')
|
|
175
|
+
return t
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/** Basename of a remote absolute path (POSIX or Windows). */
|
|
179
|
+
export function remotePathBase(p) {
|
|
180
|
+
const norm = normalizeRemotePath(p).replace(/[\\/]+$/, '')
|
|
181
|
+
const base = norm.split(/[\\/]/).pop()
|
|
182
|
+
// Windows drive root (D:\) has no meaningful basename
|
|
183
|
+
if (!base || /^[a-zA-Z]:$/.test(base)) return 'workspace'
|
|
184
|
+
return base
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/** Relative path of `p` under `root` (POSIX form, no leading slash), or null
|
|
188
|
+
* when `p` is not inside `root`. Both POSIX and Windows drive paths work
|
|
189
|
+
* (Windows is compared in its `/D:/…` POSIX-equivalent form). */
|
|
190
|
+
export function relPathUnder(root, p) {
|
|
191
|
+
const posix = (x) => {
|
|
192
|
+
let n = normalizeRemotePath(x).replace(/[\\/]+$/, '')
|
|
193
|
+
const m = n.match(/^([a-zA-Z]):(.*)$/s)
|
|
194
|
+
if (m) return '/' + m[1].toLowerCase() + ':/' + (m[2] ? m[2].replace(/\\/g, '/').replace(/^\/+/, '') : '')
|
|
195
|
+
return n.replace(/\\/g, '/')
|
|
196
|
+
}
|
|
197
|
+
const r = posix(root)
|
|
198
|
+
const n = posix(p)
|
|
199
|
+
if (n === r) return ''
|
|
200
|
+
const prefix = r === '/' ? '/' : r + '/'
|
|
201
|
+
if (n.startsWith(prefix)) return n.slice(prefix.length)
|
|
202
|
+
return null
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
export function truncate(s, max) {
|
|
206
|
+
if (s.length <= max) return s
|
|
207
|
+
return s.slice(0, max) + `\n…[truncated: ${s.length - max} more chars]`
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/** 32-bit string hash rendered in base36 (short, collision-safe enough). */
|
|
211
|
+
export function shortHash(s) {
|
|
212
|
+
let h = 0
|
|
213
|
+
for (let i = 0; i < s.length; i++) {
|
|
214
|
+
h = ((h << 5) - h + s.charCodeAt(i)) | 0
|
|
215
|
+
}
|
|
216
|
+
return (h >>> 0).toString(36)
|
|
217
|
+
}
|
package/lib/registry.js
CHANGED
|
@@ -1,89 +1,89 @@
|
|
|
1
|
-
// Machine-registry pure logic for dsh-remote.
|
|
2
|
-
//
|
|
3
|
-
// Issue #13: a SAVED machine is only a standby connection — it must never
|
|
4
|
-
// silently become the "active remote context" of every session. The registry
|
|
5
|
-
// therefore keeps `currentId` as an EXPLICIT user choice:
|
|
6
|
-
// • loadMachines never falls back to the first saved machine,
|
|
7
|
-
// • a stale currentId (machine deleted meanwhile) resolves to null,
|
|
8
|
-
// • add/update never auto-activate; only an explicit "set current" does,
|
|
9
|
-
// • deleting the current machine leaves NO current (no auto-promotion).
|
|
10
|
-
// Keeping this logic in one module makes the guarantee unit-testable and
|
|
11
|
-
// shared between the HTTP routes and the tools (rw_connect).
|
|
12
|
-
|
|
13
|
-
import { readFileSync, writeFileSync, mkdirSync } from 'node:fs'
|
|
14
|
-
import path from 'node:path'
|
|
15
|
-
|
|
16
|
-
/** Load { list, currentId, explicitNone } from the registry file. */
|
|
17
|
-
export function loadMachines(file) {
|
|
18
|
-
try {
|
|
19
|
-
const j = JSON.parse(readFileSync(file, 'utf8'))
|
|
20
|
-
if (Array.isArray(j.list)) {
|
|
21
|
-
const list = j.list
|
|
22
|
-
// Explicit choice only — no fallback to the first machine, no activation
|
|
23
|
-
// of a stale/deleted id (issue #13).
|
|
24
|
-
const currentId = j.currentId && list.some((m) => m.id === j.currentId) ? j.currentId : null
|
|
25
|
-
// A persisted `currentId: null` is a deliberate "active remote = none"
|
|
26
|
-
// (the user cleared the current machine). It must survive restarts so a
|
|
27
|
-
// config-level default host cannot silently reactivate a context the user
|
|
28
|
-
// explicitly turned off. `explicitNone` is true when the file carries an
|
|
29
|
-
// explicit null (vs. a fresh file where currentId is simply absent).
|
|
30
|
-
const explicitNone = Object.prototype.hasOwnProperty.call(j, 'currentId') && j.currentId == null
|
|
31
|
-
return { list, currentId, explicitNone }
|
|
32
|
-
}
|
|
33
|
-
} catch {}
|
|
34
|
-
return { list: [], currentId: null, explicitNone: false }
|
|
35
|
-
}
|
|
36
|
-
|
|
37
|
-
/** Persist { list, currentId }. `currentId` may be null → active remote = none.
|
|
38
|
-
* Pass `keepCurrentKey: false` (add/update a machine without touching the
|
|
39
|
-
* current selection) to PRESERVE whatever `currentId` the file already holds
|
|
40
|
-
* — so merely saving a machine never flips a fresh registry into "explicit
|
|
41
|
-
* none", nor clears an explicit choice. When no previous file exists the
|
|
42
|
-
* `currentId` key is omitted entirely (fresh registry = "no choice yet"). */
|
|
43
|
-
export function saveMachines(file, list, currentId, keepCurrentKey = true) {
|
|
44
|
-
try { mkdirSync(path.dirname(file), { recursive: true }) } catch {}
|
|
45
|
-
let stored = currentId
|
|
46
|
-
let hasStored = keepCurrentKey
|
|
47
|
-
if (!keepCurrentKey) {
|
|
48
|
-
try {
|
|
49
|
-
const prev = JSON.parse(readFileSync(file, 'utf8'))
|
|
50
|
-
if (prev && Object.prototype.hasOwnProperty.call(prev, 'currentId')) {
|
|
51
|
-
stored = prev.currentId
|
|
52
|
-
hasStored = true
|
|
53
|
-
}
|
|
54
|
-
} catch { /* no previous file → omit the key */ }
|
|
55
|
-
}
|
|
56
|
-
const payload = { list }
|
|
57
|
-
if (hasStored) payload.currentId = stored
|
|
58
|
-
writeFileSync(file, JSON.stringify(payload, null, 2))
|
|
59
|
-
}
|
|
60
|
-
|
|
61
|
-
/** Strip secrets before sending a machine to the client. */
|
|
62
|
-
export function sanitizeMachine(m) {
|
|
63
|
-
if (!m) return m
|
|
64
|
-
const { password, proxy, ...rest } = m
|
|
65
|
-
const out = { ...rest, passwordSet: !!(m.password && m.password.length) }
|
|
66
|
-
if (proxy) out.proxy = { ...proxy, password: proxy.password ? '' : undefined, passwordSet: !!(proxy.password && proxy.password.length) }
|
|
67
|
-
return out
|
|
68
|
-
}
|
|
69
|
-
|
|
70
|
-
/** Apply a machine's fields onto the live config object (pool + tools read it). */
|
|
71
|
-
export function applyMachine(config, m) {
|
|
72
|
-
if (!m) return
|
|
73
|
-
config.host = m.host
|
|
74
|
-
config.port = Number(m.port) || 22
|
|
75
|
-
config.username = m.username || ''
|
|
76
|
-
config.password = m.password || ''
|
|
77
|
-
config.privateKeyPath = m.privateKeyPath || ''
|
|
78
|
-
config.passphrase = m.passphrase || ''
|
|
79
|
-
config.workspace = m.workspace || (config.workspace || '')
|
|
80
|
-
if (m.hostKeyMode) config.hostKeyMode = m.hostKeyMode
|
|
81
|
-
if (typeof m.useAgent === 'boolean') config.useAgent = m.useAgent
|
|
82
|
-
if (typeof m.keyboardInteractive === 'boolean') config.keyboardInteractive = m.keyboardInteractive
|
|
83
|
-
config.proxy = m.proxy && m.proxy.host ? m.proxy : undefined
|
|
84
|
-
}
|
|
85
|
-
|
|
86
|
-
/** Mint a fresh machine id. */
|
|
87
|
-
export function machineId() {
|
|
88
|
-
return 'm-' + Date.now().toString(36) + '-' + Math.random().toString(36).slice(2, 6)
|
|
89
|
-
}
|
|
1
|
+
// Machine-registry pure logic for dsh-remote.
|
|
2
|
+
//
|
|
3
|
+
// Issue #13: a SAVED machine is only a standby connection — it must never
|
|
4
|
+
// silently become the "active remote context" of every session. The registry
|
|
5
|
+
// therefore keeps `currentId` as an EXPLICIT user choice:
|
|
6
|
+
// • loadMachines never falls back to the first saved machine,
|
|
7
|
+
// • a stale currentId (machine deleted meanwhile) resolves to null,
|
|
8
|
+
// • add/update never auto-activate; only an explicit "set current" does,
|
|
9
|
+
// • deleting the current machine leaves NO current (no auto-promotion).
|
|
10
|
+
// Keeping this logic in one module makes the guarantee unit-testable and
|
|
11
|
+
// shared between the HTTP routes and the tools (rw_connect).
|
|
12
|
+
|
|
13
|
+
import { readFileSync, writeFileSync, mkdirSync } from 'node:fs'
|
|
14
|
+
import path from 'node:path'
|
|
15
|
+
|
|
16
|
+
/** Load { list, currentId, explicitNone } from the registry file. */
|
|
17
|
+
export function loadMachines(file) {
|
|
18
|
+
try {
|
|
19
|
+
const j = JSON.parse(readFileSync(file, 'utf8'))
|
|
20
|
+
if (Array.isArray(j.list)) {
|
|
21
|
+
const list = j.list
|
|
22
|
+
// Explicit choice only — no fallback to the first machine, no activation
|
|
23
|
+
// of a stale/deleted id (issue #13).
|
|
24
|
+
const currentId = j.currentId && list.some((m) => m.id === j.currentId) ? j.currentId : null
|
|
25
|
+
// A persisted `currentId: null` is a deliberate "active remote = none"
|
|
26
|
+
// (the user cleared the current machine). It must survive restarts so a
|
|
27
|
+
// config-level default host cannot silently reactivate a context the user
|
|
28
|
+
// explicitly turned off. `explicitNone` is true when the file carries an
|
|
29
|
+
// explicit null (vs. a fresh file where currentId is simply absent).
|
|
30
|
+
const explicitNone = Object.prototype.hasOwnProperty.call(j, 'currentId') && j.currentId == null
|
|
31
|
+
return { list, currentId, explicitNone }
|
|
32
|
+
}
|
|
33
|
+
} catch {}
|
|
34
|
+
return { list: [], currentId: null, explicitNone: false }
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** Persist { list, currentId }. `currentId` may be null → active remote = none.
|
|
38
|
+
* Pass `keepCurrentKey: false` (add/update a machine without touching the
|
|
39
|
+
* current selection) to PRESERVE whatever `currentId` the file already holds
|
|
40
|
+
* — so merely saving a machine never flips a fresh registry into "explicit
|
|
41
|
+
* none", nor clears an explicit choice. When no previous file exists the
|
|
42
|
+
* `currentId` key is omitted entirely (fresh registry = "no choice yet"). */
|
|
43
|
+
export function saveMachines(file, list, currentId, keepCurrentKey = true) {
|
|
44
|
+
try { mkdirSync(path.dirname(file), { recursive: true }) } catch {}
|
|
45
|
+
let stored = currentId
|
|
46
|
+
let hasStored = keepCurrentKey
|
|
47
|
+
if (!keepCurrentKey) {
|
|
48
|
+
try {
|
|
49
|
+
const prev = JSON.parse(readFileSync(file, 'utf8'))
|
|
50
|
+
if (prev && Object.prototype.hasOwnProperty.call(prev, 'currentId')) {
|
|
51
|
+
stored = prev.currentId
|
|
52
|
+
hasStored = true
|
|
53
|
+
}
|
|
54
|
+
} catch { /* no previous file → omit the key */ }
|
|
55
|
+
}
|
|
56
|
+
const payload = { list }
|
|
57
|
+
if (hasStored) payload.currentId = stored
|
|
58
|
+
writeFileSync(file, JSON.stringify(payload, null, 2))
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** Strip secrets before sending a machine to the client. */
|
|
62
|
+
export function sanitizeMachine(m) {
|
|
63
|
+
if (!m) return m
|
|
64
|
+
const { password, proxy, ...rest } = m
|
|
65
|
+
const out = { ...rest, passwordSet: !!(m.password && m.password.length) }
|
|
66
|
+
if (proxy) out.proxy = { ...proxy, password: proxy.password ? '' : undefined, passwordSet: !!(proxy.password && proxy.password.length) }
|
|
67
|
+
return out
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** Apply a machine's fields onto the live config object (pool + tools read it). */
|
|
71
|
+
export function applyMachine(config, m) {
|
|
72
|
+
if (!m) return
|
|
73
|
+
config.host = m.host
|
|
74
|
+
config.port = Number(m.port) || 22
|
|
75
|
+
config.username = m.username || ''
|
|
76
|
+
config.password = m.password || ''
|
|
77
|
+
config.privateKeyPath = m.privateKeyPath || ''
|
|
78
|
+
config.passphrase = m.passphrase || ''
|
|
79
|
+
config.workspace = m.workspace || (config.workspace || '')
|
|
80
|
+
if (m.hostKeyMode) config.hostKeyMode = m.hostKeyMode
|
|
81
|
+
if (typeof m.useAgent === 'boolean') config.useAgent = m.useAgent
|
|
82
|
+
if (typeof m.keyboardInteractive === 'boolean') config.keyboardInteractive = m.keyboardInteractive
|
|
83
|
+
config.proxy = m.proxy && m.proxy.host ? m.proxy : undefined
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** Mint a fresh machine id. */
|
|
87
|
+
export function machineId() {
|
|
88
|
+
return 'm-' + Date.now().toString(36) + '-' + Math.random().toString(36).slice(2, 6)
|
|
89
|
+
}
|