dsh-remote-workspaces 0.1.0
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 -0
- package/README.md +149 -0
- package/README.zh.md +151 -0
- package/cordis.patch.yml +25 -0
- package/package.json +44 -0
- package/src/anchor.js +67 -0
- package/src/client.js +756 -0
- package/src/containment.js +71 -0
- package/src/errors.js +17 -0
- package/src/fs-sftp.js +231 -0
- package/src/index.js +327 -0
- package/src/local-backend.js +224 -0
- package/src/machine-store.js +238 -0
- package/src/registry.js +76 -0
- package/src/routing-fs.js +230 -0
- package/src/search.js +286 -0
- package/src/shell-exec.js +423 -0
- package/src/ssh-config.js +52 -0
- package/src/ssh-uri.js +19 -0
- package/src/transport.js +401 -0
package/src/transport.js
ADDED
|
@@ -0,0 +1,401 @@
|
|
|
1
|
+
import { Client } from 'ssh2'
|
|
2
|
+
import { existsSync, readFileSync } from 'node:fs'
|
|
3
|
+
import { homedir } from 'node:os'
|
|
4
|
+
import { join as joinPath } from 'node:path'
|
|
5
|
+
import { basename, dirname, join } from 'node:path/posix'
|
|
6
|
+
import { parseSshConfig } from './ssh-config.js'
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* POSIX shell single-quote escaping for a remote path/command fragment.
|
|
10
|
+
*/
|
|
11
|
+
export function shellQuote(value) {
|
|
12
|
+
return `'${String(value).replace(/'/g, `'\\''`)}'`
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/** Bounded output collector: keeps the TAIL of a stream, flags truncation. */
|
|
16
|
+
class CapCollector {
|
|
17
|
+
constructor(maxBytes) {
|
|
18
|
+
this.maxBytes = maxBytes
|
|
19
|
+
this.tail = ''
|
|
20
|
+
this.truncated = false
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
push(chunk) {
|
|
24
|
+
const piece = String(chunk)
|
|
25
|
+
if (this.tail.length + piece.length > this.maxBytes) {
|
|
26
|
+
this.truncated = true
|
|
27
|
+
this.tail = (this.tail + piece).slice(-this.maxBytes)
|
|
28
|
+
} else {
|
|
29
|
+
this.tail += piece
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
output() {
|
|
34
|
+
return { text: this.tail, truncated: this.truncated }
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** Expand a leading `~` in a path (system ssh did this for us; ssh2 does not). */
|
|
39
|
+
function expandTilde(value) {
|
|
40
|
+
if (typeof value !== 'string' || value.length === 0) return value
|
|
41
|
+
if (value === '~') return homedir()
|
|
42
|
+
if (value.startsWith('~/') || value.startsWith('~\\')) return joinPath(homedir(), value.slice(2))
|
|
43
|
+
return value
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Default location of the user's OpenSSH client config.
|
|
48
|
+
*/
|
|
49
|
+
export function defaultSshConfigPath(home = homedir()) {
|
|
50
|
+
return joinPath(home, '.ssh', 'config')
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Read and parse `~/.ssh/config` into host blocks (empty array when absent).
|
|
55
|
+
*/
|
|
56
|
+
export function hostsFromConfig(configPath = defaultSshConfigPath()) {
|
|
57
|
+
if (!existsSync(configPath)) return []
|
|
58
|
+
return parseSshConfig(readFileSync(configPath, 'utf8'))
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Build an `SshClient` for an ssh-config alias, falling back to using the
|
|
63
|
+
* alias directly as the host when it is not declared.
|
|
64
|
+
*/
|
|
65
|
+
export function clientForHost(alias, configPath = defaultSshConfigPath()) {
|
|
66
|
+
const host = hostsFromConfig(configPath).find((entry) => entry.alias === alias)
|
|
67
|
+
return host ? new SshClient(host) : new SshClient({ alias })
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** Human-readable summary of an ssh2 connection/auth error. */
|
|
71
|
+
function describeError(error) {
|
|
72
|
+
const message = error && error.message ? error.message : String(error)
|
|
73
|
+
if (/all configured authentication methods failed/i.test(message)) {
|
|
74
|
+
return '认证失败:密钥/口令不匹配或服务器未授权该密钥'
|
|
75
|
+
}
|
|
76
|
+
if (/no suitable authentication methods/i.test(message)) {
|
|
77
|
+
return '服务器不接受可用的认证方式'
|
|
78
|
+
}
|
|
79
|
+
if (/cannot parse privatekey/i.test(message)) {
|
|
80
|
+
return `私钥解析失败(口令错误或格式不支持):${message}`
|
|
81
|
+
}
|
|
82
|
+
if (/encrypted private keys?|passphrase/i.test(message) && /incorrect/i.test(message)) {
|
|
83
|
+
return `私钥口令错误:${message}`
|
|
84
|
+
}
|
|
85
|
+
if (/ECONNREFUSED|connect refused/i.test(message)) return '连接被拒绝(主机或端口不可达)'
|
|
86
|
+
if (/ETIMEDOUT|timeout/i.test(message)) return '连接超时'
|
|
87
|
+
if (/ENOTFOUND|getaddrinfo/i.test(message)) return '无法解析主机地址'
|
|
88
|
+
if (/key exchange failed/i.test(message)) return '密钥交换失败(服务器可能不支持所选算法)'
|
|
89
|
+
return message
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* One SSH connection target, executed in-process through the `ssh2` client.
|
|
94
|
+
*
|
|
95
|
+
* Unlike the previous system-`ssh` transport, this supports every auth method
|
|
96
|
+
* the settings form collects — password, private key (with an optional
|
|
97
|
+
* passphrase), and the local agent — so passphrase-protected keys and password
|
|
98
|
+
* logins work identically on every platform with no `BatchMode` restriction.
|
|
99
|
+
*/
|
|
100
|
+
export class SshClient {
|
|
101
|
+
constructor({
|
|
102
|
+
alias,
|
|
103
|
+
host,
|
|
104
|
+
user,
|
|
105
|
+
port,
|
|
106
|
+
identityFile,
|
|
107
|
+
password,
|
|
108
|
+
passphrase,
|
|
109
|
+
timeoutMs = 30000,
|
|
110
|
+
readyTimeoutMs = 10000,
|
|
111
|
+
} = {}) {
|
|
112
|
+
this.alias = alias
|
|
113
|
+
this.host = host ?? alias
|
|
114
|
+
this.user = user
|
|
115
|
+
this.port = port
|
|
116
|
+
this.identityFile = identityFile ? expandTilde(identityFile) : undefined
|
|
117
|
+
this.password = password || undefined
|
|
118
|
+
this.passphrase = passphrase || undefined
|
|
119
|
+
this.timeoutMs = timeoutMs
|
|
120
|
+
this.readyTimeoutMs = readyTimeoutMs
|
|
121
|
+
this._os = undefined
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
connectConfig() {
|
|
125
|
+
const config = {
|
|
126
|
+
host: this.host,
|
|
127
|
+
port: Number(this.port) || 22,
|
|
128
|
+
readyTimeout: this.readyTimeoutMs,
|
|
129
|
+
}
|
|
130
|
+
if (this.user) config.username = this.user
|
|
131
|
+
if (this.password) {
|
|
132
|
+
config.password = this.password
|
|
133
|
+
} else if (this.identityFile) {
|
|
134
|
+
config.privateKey = readFileSync(this.identityFile)
|
|
135
|
+
if (this.passphrase) config.passphrase = this.passphrase
|
|
136
|
+
} else {
|
|
137
|
+
// No explicit credential: use the running agent when present, and let
|
|
138
|
+
// ssh2 fall back to the platform default keys otherwise.
|
|
139
|
+
if (process.env.SSH_AUTH_SOCK) config.agent = process.env.SSH_AUTH_SOCK
|
|
140
|
+
}
|
|
141
|
+
return config
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* Run one remote command and capture its stdout/stderr. Returns the same
|
|
146
|
+
* `{ ok, ms, exitCode, stdout, stderr, error }` shape the system-ssh
|
|
147
|
+
* transport produced, so every higher-level method is unchanged.
|
|
148
|
+
*/
|
|
149
|
+
async run(command, { input, timeoutMs } = {}) {
|
|
150
|
+
const started = Date.now()
|
|
151
|
+
const deadline = timeoutMs ?? this.timeoutMs
|
|
152
|
+
return await new Promise((resolve) => {
|
|
153
|
+
const conn = new Client()
|
|
154
|
+
let settled = false
|
|
155
|
+
let timer
|
|
156
|
+
const settle = (result) => {
|
|
157
|
+
if (settled) return
|
|
158
|
+
settled = true
|
|
159
|
+
if (timer !== undefined) clearTimeout(timer)
|
|
160
|
+
try { conn.end() } catch {}
|
|
161
|
+
resolve({ ...result, ms: Date.now() - started })
|
|
162
|
+
}
|
|
163
|
+
timer = setTimeout(() => settle({ ok: false, error: 'SSH 命令执行超时' }), deadline)
|
|
164
|
+
|
|
165
|
+
conn.on('ready', () => {
|
|
166
|
+
conn.exec(command, (err, stream) => {
|
|
167
|
+
if (err) { settle({ ok: false, error: err.message }); return }
|
|
168
|
+
let stdout = ''
|
|
169
|
+
let stderr = ''
|
|
170
|
+
stream.on('data', (data) => { stdout += data })
|
|
171
|
+
stream.stderr.on('data', (data) => { stderr += data })
|
|
172
|
+
stream.on('close', (code) => { settle({ ok: code === 0, exitCode: code, stdout, stderr }) })
|
|
173
|
+
if (input === undefined) stream.end()
|
|
174
|
+
else stream.end(String(input))
|
|
175
|
+
})
|
|
176
|
+
})
|
|
177
|
+
conn.on('error', (error) => { settle({ ok: false, error: describeError(error) }) })
|
|
178
|
+
|
|
179
|
+
try {
|
|
180
|
+
conn.connect(this.connectConfig())
|
|
181
|
+
} catch (error) {
|
|
182
|
+
settle({ ok: false, error: describeError(error) })
|
|
183
|
+
}
|
|
184
|
+
})
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
async exec(command, opts) {
|
|
188
|
+
return this.run(command, opts)
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* Run one remote command with the shell-executor contract: cwd, timeout,
|
|
193
|
+
* bounded (tail-kept) stdout/stderr, stdin, and an abort signal that closes
|
|
194
|
+
* the exec channel (SIGHUP on the remote). Resolves with exitCode/signal,
|
|
195
|
+
* timedOut/aborted first-cause, and `{ text, truncated }` outputs.
|
|
196
|
+
*/
|
|
197
|
+
async execShell(command, { cwd, timeoutMs = 60000, stdoutMaxBytes = 64000, stderrMaxBytes = 64000, stdin, signal } = {}) {
|
|
198
|
+
const script = cwd ? `cd ${shellQuote(cwd)} || exit 1\n${command}` : command
|
|
199
|
+
return await new Promise((resolve) => {
|
|
200
|
+
const conn = new Client()
|
|
201
|
+
let settled = false
|
|
202
|
+
let timedOut = false
|
|
203
|
+
let timer
|
|
204
|
+
let stream
|
|
205
|
+
const finish = (result) => {
|
|
206
|
+
if (settled) return
|
|
207
|
+
settled = true
|
|
208
|
+
if (timer !== undefined) clearTimeout(timer)
|
|
209
|
+
try { conn.end() } catch {}
|
|
210
|
+
resolve(result)
|
|
211
|
+
}
|
|
212
|
+
const killRemote = () => {
|
|
213
|
+
try { if (stream) stream.close() } catch {}
|
|
214
|
+
try { conn.end() } catch {}
|
|
215
|
+
}
|
|
216
|
+
timer = setTimeout(() => { timedOut = true; killRemote() }, timeoutMs)
|
|
217
|
+
if (signal !== undefined) {
|
|
218
|
+
if (signal.aborted) killRemote()
|
|
219
|
+
else signal.addEventListener('abort', killRemote, { once: true })
|
|
220
|
+
}
|
|
221
|
+
conn.on('ready', () => {
|
|
222
|
+
conn.exec(script, (err, s) => {
|
|
223
|
+
if (err) { finish({ ok: false, error: err.message }); return }
|
|
224
|
+
stream = s
|
|
225
|
+
const out = new CapCollector(stdoutMaxBytes)
|
|
226
|
+
const errc = new CapCollector(stderrMaxBytes)
|
|
227
|
+
s.on('data', (d) => out.push(d))
|
|
228
|
+
s.stderr.on('data', (d) => errc.push(d))
|
|
229
|
+
s.on('close', (code) => {
|
|
230
|
+
const aborted = signal !== undefined && signal.aborted
|
|
231
|
+
finish({
|
|
232
|
+
ok: true,
|
|
233
|
+
exitCode: code,
|
|
234
|
+
signal: null,
|
|
235
|
+
timedOut: timedOut && !aborted,
|
|
236
|
+
aborted: aborted && !timedOut,
|
|
237
|
+
stdout: out.output(),
|
|
238
|
+
stderr: errc.output(),
|
|
239
|
+
})
|
|
240
|
+
})
|
|
241
|
+
if (stdin === undefined) s.end()
|
|
242
|
+
else s.end(String(stdin))
|
|
243
|
+
})
|
|
244
|
+
})
|
|
245
|
+
conn.on('error', (error) => finish({ ok: false, error: describeError(error) }))
|
|
246
|
+
try { conn.connect(this.connectConfig()) } catch (error) { finish({ ok: false, error: describeError(error) }) }
|
|
247
|
+
})
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
/**
|
|
251
|
+
* Open an SFTP channel and resolve a promise-wrapped facade over it.
|
|
252
|
+
* Resolves `{ conn, readdir, stat, readFile, writeFile, mkdir, unlink, realpath, end }`.
|
|
253
|
+
*/
|
|
254
|
+
sftp() {
|
|
255
|
+
return new Promise((resolve, reject) => {
|
|
256
|
+
const conn = new Client()
|
|
257
|
+
let settled = false
|
|
258
|
+
const fail = (error) => {
|
|
259
|
+
if (settled) return
|
|
260
|
+
settled = true
|
|
261
|
+
try { conn.end() } catch {}
|
|
262
|
+
reject(error instanceof Error ? error : new Error(String(error)))
|
|
263
|
+
}
|
|
264
|
+
const timer = setTimeout(() => fail(new Error('SFTP 连接超时')), this.readyTimeoutMs)
|
|
265
|
+
conn.on('ready', () => {
|
|
266
|
+
conn.sftp((err, sftp) => {
|
|
267
|
+
if (err) { clearTimeout(timer); fail(err); return }
|
|
268
|
+
clearTimeout(timer)
|
|
269
|
+
settled = true
|
|
270
|
+
const call = (method) => (...args) => new Promise((res, rej) => {
|
|
271
|
+
sftp[method](...args, (e, out) => { if (e) rej(e); else res(out) })
|
|
272
|
+
})
|
|
273
|
+
resolve({
|
|
274
|
+
conn,
|
|
275
|
+
raw: sftp,
|
|
276
|
+
readdir: call('readdir'),
|
|
277
|
+
stat: call('stat'),
|
|
278
|
+
readFile: call('readFile'),
|
|
279
|
+
writeFile: (path, data) => new Promise((res, rej) => {
|
|
280
|
+
sftp.writeFile(path, data, (e) => { if (e) rej(e); else res() })
|
|
281
|
+
}),
|
|
282
|
+
mkdir: call('mkdir'),
|
|
283
|
+
unlink: call('unlink'),
|
|
284
|
+
rename: call('rename'),
|
|
285
|
+
rmdir: call('rmdir'),
|
|
286
|
+
realpath: call('realpath'),
|
|
287
|
+
end: () => { try { conn.end() } catch {} },
|
|
288
|
+
})
|
|
289
|
+
})
|
|
290
|
+
})
|
|
291
|
+
conn.on('error', (err) => { clearTimeout(timer); fail(err) })
|
|
292
|
+
try { conn.connect(this.connectConfig()) } catch (err) { clearTimeout(timer); fail(err) }
|
|
293
|
+
})
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
async remoteOs() {
|
|
297
|
+
if (this._os === undefined) {
|
|
298
|
+
const res = await this.run('uname -s')
|
|
299
|
+
this._os = (res.stdout ?? '').trim().toLowerCase().startsWith('darwin') ? 'darwin' : 'linux'
|
|
300
|
+
}
|
|
301
|
+
return this._os
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
async listDir(path) {
|
|
305
|
+
const res = await this.run(`ls -1a ${shellQuote(path)}`)
|
|
306
|
+
const entries = res.ok
|
|
307
|
+
? res.stdout.split('\n').filter((name) => name !== '' && name !== '.' && name !== '..')
|
|
308
|
+
: []
|
|
309
|
+
return { ...res, entries }
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
async readFile(path) {
|
|
313
|
+
return this.run(`cat ${shellQuote(path)}`)
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
/** Simple, non-atomic write (P0). Prefer `writeAtomic`. */
|
|
317
|
+
async writeFile(path, content) {
|
|
318
|
+
return this.run(`cat > ${shellQuote(path)}`, { input: content })
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
/**
|
|
322
|
+
* Atomic write: stream content into a private temp file in the same
|
|
323
|
+
* directory, then rename over the target. A failed transfer never leaves a
|
|
324
|
+
* half-written target.
|
|
325
|
+
*/
|
|
326
|
+
async writeAtomic(path, content) {
|
|
327
|
+
const template = join(dirname(path), `.dsh-${basename(path)}.tmp.XXXXXX`)
|
|
328
|
+
const script = [
|
|
329
|
+
`tmp=$(mktemp ${shellQuote(template)}) || exit 1`,
|
|
330
|
+
`cat > "$tmp" || { rm -f "$tmp"; exit 1; }`,
|
|
331
|
+
`mv -f "$tmp" ${shellQuote(path)} || { rm -f "$tmp"; exit 1; }`,
|
|
332
|
+
].join('\n')
|
|
333
|
+
return this.run(script, { input: content })
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
/** Remote metadata: mtime (ms), size, and type. Absent path ⇒ ok=false. */
|
|
337
|
+
async stat(path) {
|
|
338
|
+
const os = await this.remoteOs()
|
|
339
|
+
const fmt = os === 'darwin' ? "stat -f '%m|%z|%HT'" : "stat -c '%Y|%s|%F'"
|
|
340
|
+
const res = await this.run(`${fmt} ${shellQuote(path)}`)
|
|
341
|
+
if (!res.ok) return res
|
|
342
|
+
const parts = (res.stdout ?? '').trim().split('|')
|
|
343
|
+
if (parts.length < 3) return { ...res, ok: false, error: `unexpected stat output: ${res.stdout}` }
|
|
344
|
+
const rawType = (parts[2] ?? '').toLowerCase()
|
|
345
|
+
return {
|
|
346
|
+
...res,
|
|
347
|
+
mtimeMs: Number(parts[0]) * 1000,
|
|
348
|
+
size: Number(parts[1]),
|
|
349
|
+
type: rawType.includes('director') ? 'directory' : rawType.includes('regular') ? 'file' : 'other',
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
/**
|
|
354
|
+
* Literal find-and-replace: read, replace locally, write atomically.
|
|
355
|
+
* `replaceAll=false` requires exactly one match; true replaces every match.
|
|
356
|
+
*/
|
|
357
|
+
async editText(path, oldString, newString, replaceAll = false) {
|
|
358
|
+
const read = await this.readFile(path)
|
|
359
|
+
if (!read.ok) return read
|
|
360
|
+
const content = read.stdout
|
|
361
|
+
let matches = 0
|
|
362
|
+
let offset = 0
|
|
363
|
+
while (true) {
|
|
364
|
+
const found = content.indexOf(oldString, offset)
|
|
365
|
+
if (found < 0) break
|
|
366
|
+
matches += 1
|
|
367
|
+
offset = found + oldString.length
|
|
368
|
+
}
|
|
369
|
+
if (matches === 0) return { ok: false, ms: read.ms, error: 'old_string not found' }
|
|
370
|
+
if (!replaceAll && matches !== 1) {
|
|
371
|
+
return { ok: false, ms: read.ms, error: `old_string matched ${matches} times` }
|
|
372
|
+
}
|
|
373
|
+
const next = replaceAll ? content.split(oldString).join(newString) : content.replace(oldString, newString)
|
|
374
|
+
return this.writeAtomic(path, next)
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
async canonicalPath(path) {
|
|
378
|
+
return this.run(`realpath ${shellQuote(path)}`)
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
async remove(path) {
|
|
382
|
+
return this.run(`rm -f ${shellQuote(path)}`)
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
/**
|
|
386
|
+
* Remote file content hash for post-write verification. Tries GNU
|
|
387
|
+
* `sha256sum` then BSD `shasum -a 256`; returns the lowercase hex digest or
|
|
388
|
+
* `undefined` when no such tool exists (verification is then skipped).
|
|
389
|
+
* Locale-independent: the digest is hex, never localized text.
|
|
390
|
+
*/
|
|
391
|
+
async sha256(path) {
|
|
392
|
+
for (const cmd of [`sha256sum ${shellQuote(path)}`, `shasum -a 256 ${shellQuote(path)}`]) {
|
|
393
|
+
const res = await this.run(`${cmd} 2>/dev/null`)
|
|
394
|
+
if (res.ok) {
|
|
395
|
+
const hash = (res.stdout ?? '').trim().split(/\s+/)[0]
|
|
396
|
+
if (/^[0-9a-f]{64}$/i.test(hash)) return hash.toLowerCase()
|
|
397
|
+
}
|
|
398
|
+
}
|
|
399
|
+
return undefined
|
|
400
|
+
}
|
|
401
|
+
}
|