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.
@@ -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
+ }