dsh-remote 0.8.14 → 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 +1993 -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/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 +2 -2
package/lib/credential.js
CHANGED
|
@@ -1,107 +1,177 @@
|
|
|
1
|
-
// dsh-remote — optional OS-keychain password storage.
|
|
2
|
-
// Machines keep their password in the OS credential store instead of the
|
|
3
|
-
// plaintext machines.json when the user enables "加密保存密码" per machine.
|
|
4
|
-
// Every backend is best-effort: any failure resolves to {ok:false} and the
|
|
5
|
-
// caller falls back to plaintext
|
|
6
|
-
//
|
|
7
|
-
//
|
|
8
|
-
//
|
|
9
|
-
//
|
|
10
|
-
//
|
|
11
|
-
//
|
|
12
|
-
//
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
import {
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
return
|
|
86
|
-
}
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
1
|
+
// dsh-remote — optional OS-keychain password storage.
|
|
2
|
+
// Machines keep their password in the OS credential store instead of the
|
|
3
|
+
// plaintext machines.json when the user enables "加密保存密码" per machine.
|
|
4
|
+
// Every backend is best-effort: any failure resolves to {ok:false} and the
|
|
5
|
+
// caller (persistPassword) falls back to plaintext + a user-visible warning
|
|
6
|
+
// (the feature must never block connecting, and must never silently drop the
|
|
7
|
+
// credential — issue #30).
|
|
8
|
+
//
|
|
9
|
+
// Backends:
|
|
10
|
+
// darwin → `security` (login keychain, generic password)
|
|
11
|
+
// win32 → DPAPI via PowerShell (CurrentUser scope), files under
|
|
12
|
+
// $DSH_HOME/remote-workspaces/.secrets/
|
|
13
|
+
// linux → `secret-tool` (libsecret / gnome-keyring), optional
|
|
14
|
+
// else → unsupported (plain)
|
|
15
|
+
import { execFile } from 'node:child_process'
|
|
16
|
+
import { mkdirSync, writeFileSync, readFileSync, unlinkSync } from 'node:fs'
|
|
17
|
+
import { join as joinPath } from 'node:path'
|
|
18
|
+
|
|
19
|
+
const SERVICE = 'dsh-remote'
|
|
20
|
+
const TIMEOUT = 8000
|
|
21
|
+
|
|
22
|
+
// Windows PowerShell 5.1 does not preload System.Security, so
|
|
23
|
+
// [System.Security.Cryptography.ProtectedData] is "not found" until the assembly
|
|
24
|
+
// is added (issue #30: without this the whole DPAPI call failed and the caller
|
|
25
|
+
// silently lost the password). `-ErrorAction SilentlyContinue` keeps the line
|
|
26
|
+
// harmless on PowerShell 7, where the type is already available.
|
|
27
|
+
const PS_SECURITY_ASSEMBLY = 'Add-Type -AssemblyName System.Security -ErrorAction SilentlyContinue;'
|
|
28
|
+
|
|
29
|
+
function run(bin, args, input) {
|
|
30
|
+
return new Promise((resolve, reject) => {
|
|
31
|
+
execFile(bin, args, { timeout: TIMEOUT, maxBuffer: 1 << 20, ...(input != null ? { input } : {}) }, (err, stdout, stderr) => {
|
|
32
|
+
if (err) return reject(err)
|
|
33
|
+
resolve(String(stdout || '').trim())
|
|
34
|
+
})
|
|
35
|
+
})
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function platformBackend() {
|
|
39
|
+
if (process.platform === 'darwin') return 'keychain'
|
|
40
|
+
if (process.platform === 'win32') return 'windows'
|
|
41
|
+
return 'secret' // linux (secret-tool may be absent → falls back)
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function account(machineId) {
|
|
45
|
+
// Only machine ids are stored; sanitize just in case.
|
|
46
|
+
return String(machineId || '').replace(/[^a-zA-Z0-9._-]/g, '_')
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Redact a failed command's diagnostics: execFile puts the FULL command line
|
|
50
|
+
* (which carries the password as an argv) into `err.message`, and this string is
|
|
51
|
+
* shown in the settings UI — so the secret must never survive it. */
|
|
52
|
+
function safeReason(err, password) {
|
|
53
|
+
const raw = [err && err.stderr, err && err.message, err && err.code]
|
|
54
|
+
.filter(Boolean).map(String).join(' ')
|
|
55
|
+
const secret = String(password == null ? '' : password)
|
|
56
|
+
const scrubbed = secret ? raw.split(secret).join('***') : raw
|
|
57
|
+
return scrubbed.replace(/\s+/g, ' ').trim().slice(0, 300)
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** PowerShell one-liner that DPAPI-protects a password (CurrentUser scope).
|
|
61
|
+
* Exported because the issue #30 fix (the mandatory Add-Type preamble) is only
|
|
62
|
+
* observable here — a Windows host cannot be exercised from the test suite. */
|
|
63
|
+
export function dpapiProtectScript(password) {
|
|
64
|
+
return PS_SECURITY_ASSEMBLY +
|
|
65
|
+
`$p='${String(password).replace(/'/g, "''")}';` +
|
|
66
|
+
`$s=[System.Security.Cryptography.ProtectedData]::Protect([Text.Encoding]::UTF8.GetBytes($p),$null,[System.Security.Cryptography.DataProtectionScope]::CurrentUser);` +
|
|
67
|
+
`[Convert]::ToBase64String($s)`
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** PowerShell one-liner that reverses dpapiProtectScript(). */
|
|
71
|
+
export function dpapiUnprotectScript(b64) {
|
|
72
|
+
return PS_SECURITY_ASSEMBLY +
|
|
73
|
+
`$s=[Convert]::FromBase64String('${String(b64).replace(/'/g, "''")}');` +
|
|
74
|
+
`[Text.Encoding]::UTF8.GetString([System.Security.Cryptography.ProtectedData]::Unprotect($s,$null,[System.Security.Cryptography.DataProtectionScope]::CurrentUser))`
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** Save a password to the OS store. Resolves {ok, backend, error} — ok:false on
|
|
78
|
+
* any failure (caller falls back to plaintext). `error` is a password-free
|
|
79
|
+
* diagnostic for the UI. */
|
|
80
|
+
export async function saveSecret(machineId, password, secretsDir) {
|
|
81
|
+
const acc = account(machineId)
|
|
82
|
+
try {
|
|
83
|
+
if (process.platform === 'darwin') {
|
|
84
|
+
await run('security', ['add-generic-password', '-U', '-s', SERVICE, '-a', acc, '-w', String(password)])
|
|
85
|
+
return { ok: true, backend: 'keychain', error: '' }
|
|
86
|
+
}
|
|
87
|
+
if (process.platform === 'win32') {
|
|
88
|
+
// DPAPI-encrypt and store under the harness home.
|
|
89
|
+
const script = dpapiProtectScript(password)
|
|
90
|
+
const b64 = await run('powershell', ['-NoProfile', '-STA', '-Command', script])
|
|
91
|
+
if (!b64) return { ok: false, backend: 'windows', error: 'DPAPI returned no ciphertext' }
|
|
92
|
+
mkdirSync(secretsDir || '.', { recursive: true })
|
|
93
|
+
writeFileSync(joinPath(secretsDir || '.', acc + '.bin'), b64, 'utf8')
|
|
94
|
+
return { ok: true, backend: 'windows', error: '' }
|
|
95
|
+
}
|
|
96
|
+
if (process.platform === 'linux') {
|
|
97
|
+
await run('secret-tool', ['store', '--label=' + SERVICE, 'service', SERVICE, 'account', acc], String(password) + '\n')
|
|
98
|
+
return { ok: true, backend: 'secret', error: '' }
|
|
99
|
+
}
|
|
100
|
+
} catch (err) {
|
|
101
|
+
return { ok: false, backend: platformBackend(), error: safeReason(err, password) || 'secret store write failed' }
|
|
102
|
+
}
|
|
103
|
+
return { ok: false, backend: platformBackend(), error: `unsupported platform: ${process.platform}` }
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** Decide where a machine password is persisted (issue #30).
|
|
107
|
+
*
|
|
108
|
+
* The contract the settings UI relies on: a password is NEVER dropped. When the
|
|
109
|
+
* requested OS store fails (missing DPAPI assembly, locked keychain, absent
|
|
110
|
+
* secret-tool), this falls back to plaintext in machines.json and reports
|
|
111
|
+
* `warning: 'secret-store-failed'` so the caller can tell the user instead of
|
|
112
|
+
* silently saving a credential-less machine.
|
|
113
|
+
*
|
|
114
|
+
* `save` is injectable so the fallback is unit-testable on any platform.
|
|
115
|
+
*
|
|
116
|
+
* @returns {Promise<{stored:'secret'|'plain'|'none', credentialBackend:string, password:string, warning:string, error:string}>}
|
|
117
|
+
*/
|
|
118
|
+
export async function persistPassword({ backend, machineId, password, secretsDir, save = saveSecret }) {
|
|
119
|
+
const pw = password == null ? '' : String(password)
|
|
120
|
+
const want = backend && backend !== 'plain' ? backend : 'plain'
|
|
121
|
+
if (!pw) return { stored: 'none', credentialBackend: want, password: '', warning: '', error: '' }
|
|
122
|
+
if (want === 'plain') return { stored: 'plain', credentialBackend: 'plain', password: pw, warning: '', error: '' }
|
|
123
|
+
let res
|
|
124
|
+
try {
|
|
125
|
+
res = await save(machineId, pw, secretsDir)
|
|
126
|
+
} catch (err) {
|
|
127
|
+
res = { ok: false, error: safeReason(err, pw) }
|
|
128
|
+
}
|
|
129
|
+
if (res && res.ok) {
|
|
130
|
+
return { stored: 'secret', credentialBackend: res.backend || want, password: '', warning: '', error: '' }
|
|
131
|
+
}
|
|
132
|
+
return {
|
|
133
|
+
stored: 'plain',
|
|
134
|
+
credentialBackend: 'plain',
|
|
135
|
+
password: pw,
|
|
136
|
+
warning: 'secret-store-failed',
|
|
137
|
+
error: String((res && res.error) || '') || 'secret store write failed',
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/** Fetch a stored password. Resolves string|null. */
|
|
142
|
+
export async function getSecret(machineId, secretsDir) {
|
|
143
|
+
const acc = account(machineId)
|
|
144
|
+
try {
|
|
145
|
+
if (process.platform === 'darwin') {
|
|
146
|
+
return await run('security', ['find-generic-password', '-s', SERVICE, '-a', acc, '-w'])
|
|
147
|
+
}
|
|
148
|
+
if (process.platform === 'win32') {
|
|
149
|
+
const b64 = readFileSync(joinPath(secretsDir || '.', acc + '.bin'), 'utf8').trim()
|
|
150
|
+
if (!b64) return null
|
|
151
|
+
const script = dpapiUnprotectScript(b64)
|
|
152
|
+
return await run('powershell', ['-NoProfile', '-STA', '-Command', script])
|
|
153
|
+
}
|
|
154
|
+
if (process.platform === 'linux') {
|
|
155
|
+
return await run('secret-tool', ['lookup', 'service', SERVICE, 'account', acc])
|
|
156
|
+
}
|
|
157
|
+
} catch { /* fall through */ }
|
|
158
|
+
return null
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/** Delete a stored password (idempotent). */
|
|
162
|
+
export async function deleteSecret(machineId, secretsDir) {
|
|
163
|
+
const acc = account(machineId)
|
|
164
|
+
try {
|
|
165
|
+
if (process.platform === 'darwin') {
|
|
166
|
+
await run('security', ['delete-generic-password', '-s', SERVICE, '-a', acc]).catch(() => {})
|
|
167
|
+
return
|
|
168
|
+
}
|
|
169
|
+
if (process.platform === 'win32') {
|
|
170
|
+
try { unlinkSync(joinPath(secretsDir || '.', acc + '.bin')) } catch { /* absent */ }
|
|
171
|
+
return
|
|
172
|
+
}
|
|
173
|
+
if (process.platform === 'linux') {
|
|
174
|
+
await run('secret-tool', ['clear', 'service', SERVICE, 'account', acc]).catch(() => {})
|
|
175
|
+
}
|
|
176
|
+
} catch { /* best effort */ }
|
|
177
|
+
}
|
package/lib/errors.js
CHANGED
|
@@ -1,78 +1,78 @@
|
|
|
1
|
-
// dsh-remote — SSH/SFTP error classification → actionable hints.
|
|
2
|
-
// Pure function so it is unit-testable and reused by tools, slash commands,
|
|
3
|
-
// the test-connect route and the settings UI.
|
|
4
|
-
|
|
5
|
-
/** Classify an error thrown by the ssh pool / sftp wrapper.
|
|
6
|
-
* @returns {{ category: 'auth'|'network'|'hostkey'|'timeout'|'sftp'|'credentials'|'other', hint: string, raw: string }}
|
|
7
|
-
*/
|
|
8
|
-
export function classifyError(err, opts = {}) {
|
|
9
|
-
const msg = String((err && err.message) || err || '')
|
|
10
|
-
const { host, port } = opts
|
|
11
|
-
const where = host ? ` (${host}:${port || 22})` : ''
|
|
12
|
-
|
|
13
|
-
if (/host key/i.test(msg) && /(change|mismatch|fingerprint|verify|unknown host)/i.test(msg)) {
|
|
14
|
-
return {
|
|
15
|
-
category: 'hostkey',
|
|
16
|
-
hint: `主机指纹校验失败${where}:可能遭遇中间人攻击,或该主机重装/更换过密钥。确认无异常后执行 /remote-forget-key 重新信任。`,
|
|
17
|
-
raw: msg,
|
|
18
|
-
}
|
|
19
|
-
}
|
|
20
|
-
if (/permission denied|all configured authentication methods failed|authentication failed/i.test(msg)) {
|
|
21
|
-
return {
|
|
22
|
-
category: 'auth',
|
|
23
|
-
hint: `认证失败${where}:检查用户名/密码/私钥路径,私钥是否设了 passphrase;若公司机器要求 OTP/动态码,请确认机器配置了 keyboard-interactive 或对应密码。`,
|
|
24
|
-
raw: msg,
|
|
25
|
-
}
|
|
26
|
-
}
|
|
27
|
-
if (/no credentials/i.test(msg)) {
|
|
28
|
-
return {
|
|
29
|
-
category: 'credentials',
|
|
30
|
-
hint: `缺少凭据:请设置 password 或 privateKeyPath(私钥只在显式提供时使用),或为该机器启用 SSH agent。`,
|
|
31
|
-
raw: msg,
|
|
32
|
-
}
|
|
33
|
-
}
|
|
34
|
-
if (/ENOTFOUND|EAI_AGAIN|getaddrinfo/i.test(msg)) {
|
|
35
|
-
return {
|
|
36
|
-
category: 'network',
|
|
37
|
-
hint: `主机不可达${where}:检查 host 拼写 / IP / DNS;内网机器可能需要走跳板机(配置 proxy)或 VPN。`,
|
|
38
|
-
raw: msg,
|
|
39
|
-
}
|
|
40
|
-
}
|
|
41
|
-
if (/ECONNREFUSED|connection refused/i.test(msg)) {
|
|
42
|
-
return {
|
|
43
|
-
category: 'network',
|
|
44
|
-
hint: `连接被拒绝${where}:确认端口 ${port || 22} 正确、远端 sshd 在监听;检查防火墙/安全组是否放行。`,
|
|
45
|
-
raw: msg,
|
|
46
|
-
}
|
|
47
|
-
}
|
|
48
|
-
if (/sftp operation timed out|sftp failed|readdir|writeFile|fastGet|fastPut/i.test(msg)) {
|
|
49
|
-
return {
|
|
50
|
-
category: 'sftp',
|
|
51
|
-
hint: `SFTP 操作失败:可能是远端磁盘/权限问题或服务器卡死(已自动超时保护)。检查目标路径权限。`,
|
|
52
|
-
raw: msg,
|
|
53
|
-
}
|
|
54
|
-
}
|
|
55
|
-
if (/ETIMEDOUT|timed out|timeout/i.test(msg)) {
|
|
56
|
-
return {
|
|
57
|
-
category: 'timeout',
|
|
58
|
-
hint: `连接/操作超时${where}:检查端口、防火墙、路由;或调大 connectTimeoutMs / commandTimeoutMs。`,
|
|
59
|
-
raw: msg,
|
|
60
|
-
}
|
|
61
|
-
}
|
|
62
|
-
if (/cannot read private key/i.test(msg)) {
|
|
63
|
-
return {
|
|
64
|
-
category: 'credentials',
|
|
65
|
-
hint: `无法读取私钥文件:检查 privateKeyPath 是否存在、当前用户是否有读取权限。`,
|
|
66
|
-
raw: msg,
|
|
67
|
-
}
|
|
68
|
-
}
|
|
69
|
-
return { category: 'other', hint: msg, raw: msg }
|
|
70
|
-
}
|
|
71
|
-
|
|
72
|
-
/** One-line friendly message: hint (+ raw when it adds information). */
|
|
73
|
-
export function friendlyMessage(err, opts = {}) {
|
|
74
|
-
const c = classifyError(err, opts)
|
|
75
|
-
if (c.category === 'other') return c.raw
|
|
76
|
-
if (c.raw && c.raw !== c.hint) return `${c.hint}(原始错误:${c.raw})`
|
|
77
|
-
return c.hint
|
|
78
|
-
}
|
|
1
|
+
// dsh-remote — SSH/SFTP error classification → actionable hints.
|
|
2
|
+
// Pure function so it is unit-testable and reused by tools, slash commands,
|
|
3
|
+
// the test-connect route and the settings UI.
|
|
4
|
+
|
|
5
|
+
/** Classify an error thrown by the ssh pool / sftp wrapper.
|
|
6
|
+
* @returns {{ category: 'auth'|'network'|'hostkey'|'timeout'|'sftp'|'credentials'|'other', hint: string, raw: string }}
|
|
7
|
+
*/
|
|
8
|
+
export function classifyError(err, opts = {}) {
|
|
9
|
+
const msg = String((err && err.message) || err || '')
|
|
10
|
+
const { host, port } = opts
|
|
11
|
+
const where = host ? ` (${host}:${port || 22})` : ''
|
|
12
|
+
|
|
13
|
+
if (/host key/i.test(msg) && /(change|mismatch|fingerprint|verify|unknown host)/i.test(msg)) {
|
|
14
|
+
return {
|
|
15
|
+
category: 'hostkey',
|
|
16
|
+
hint: `主机指纹校验失败${where}:可能遭遇中间人攻击,或该主机重装/更换过密钥。确认无异常后执行 /remote-forget-key 重新信任。`,
|
|
17
|
+
raw: msg,
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
if (/permission denied|all configured authentication methods failed|authentication failed/i.test(msg)) {
|
|
21
|
+
return {
|
|
22
|
+
category: 'auth',
|
|
23
|
+
hint: `认证失败${where}:检查用户名/密码/私钥路径,私钥是否设了 passphrase;若公司机器要求 OTP/动态码,请确认机器配置了 keyboard-interactive 或对应密码。`,
|
|
24
|
+
raw: msg,
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
if (/no credentials/i.test(msg)) {
|
|
28
|
+
return {
|
|
29
|
+
category: 'credentials',
|
|
30
|
+
hint: `缺少凭据:请设置 password 或 privateKeyPath(私钥只在显式提供时使用),或为该机器启用 SSH agent。`,
|
|
31
|
+
raw: msg,
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
if (/ENOTFOUND|EAI_AGAIN|getaddrinfo/i.test(msg)) {
|
|
35
|
+
return {
|
|
36
|
+
category: 'network',
|
|
37
|
+
hint: `主机不可达${where}:检查 host 拼写 / IP / DNS;内网机器可能需要走跳板机(配置 proxy)或 VPN。`,
|
|
38
|
+
raw: msg,
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
if (/ECONNREFUSED|connection refused/i.test(msg)) {
|
|
42
|
+
return {
|
|
43
|
+
category: 'network',
|
|
44
|
+
hint: `连接被拒绝${where}:确认端口 ${port || 22} 正确、远端 sshd 在监听;检查防火墙/安全组是否放行。`,
|
|
45
|
+
raw: msg,
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
if (/sftp operation timed out|sftp failed|readdir|writeFile|fastGet|fastPut/i.test(msg)) {
|
|
49
|
+
return {
|
|
50
|
+
category: 'sftp',
|
|
51
|
+
hint: `SFTP 操作失败:可能是远端磁盘/权限问题或服务器卡死(已自动超时保护)。检查目标路径权限。`,
|
|
52
|
+
raw: msg,
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
if (/ETIMEDOUT|timed out|timeout/i.test(msg)) {
|
|
56
|
+
return {
|
|
57
|
+
category: 'timeout',
|
|
58
|
+
hint: `连接/操作超时${where}:检查端口、防火墙、路由;或调大 connectTimeoutMs / commandTimeoutMs。`,
|
|
59
|
+
raw: msg,
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
if (/cannot read private key/i.test(msg)) {
|
|
63
|
+
return {
|
|
64
|
+
category: 'credentials',
|
|
65
|
+
hint: `无法读取私钥文件:检查 privateKeyPath 是否存在、当前用户是否有读取权限。`,
|
|
66
|
+
raw: msg,
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
return { category: 'other', hint: msg, raw: msg }
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** One-line friendly message: hint (+ raw when it adds information). */
|
|
73
|
+
export function friendlyMessage(err, opts = {}) {
|
|
74
|
+
const c = classifyError(err, opts)
|
|
75
|
+
if (c.category === 'other') return c.raw
|
|
76
|
+
if (c.raw && c.raw !== c.hint) return `${c.hint}(原始错误:${c.raw})`
|
|
77
|
+
return c.hint
|
|
78
|
+
}
|