dsh-ssh-vault 0.0.0-stage → 2.0.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,65 @@
1
+ /**
2
+ * 后台任务适配 —— 把一次 SSH 执行注册进 `ctx.jobs`。
3
+ *
4
+ * 长命令(改防火墙、装软件、跑扫描)最怕两件事:调用被中断后不知道远端是否还在跑,
5
+ * 以及中断即丢掉已经产生的输出。注册成后台任务后,进程脱离单次工具调用的寿命独立
6
+ * 运行,输出实时进入任务环形缓冲,读取与终止交给通用的 job_output / job_kill。
7
+ *
8
+ * @module dsh-ssh-vault/background
9
+ */
10
+
11
+ import { t } from './i18n.js'
12
+ import { runRemote } from './ssh.js'
13
+
14
+ /**
15
+ * 把一次远程执行注册为后台任务。
16
+ *
17
+ * @param registry - `ctx.jobs` 注册表。
18
+ * @param spec - 传给 {@link runRemote} 的连接与命令参数。
19
+ * @param options - 归属会话与可替换的执行器(便于测试)。
20
+ * @returns 注册表签发的任务 id。
21
+ */
22
+ export function registerSshJob(registry, spec, { owner, runner = runRemote } = {}) {
23
+ return registry.start({
24
+ kind: 'ssh',
25
+ label: `${spec.record.alias}: ${spec.command.replace(/\s+/g, ' ').slice(0, 80)}`,
26
+ ...(owner !== undefined ? { owner } : {}),
27
+ run: (job) => {
28
+ const controller = new AbortController()
29
+ let child
30
+ const done = runner({
31
+ ...spec,
32
+ timeoutMs: 0,
33
+ signal: controller.signal,
34
+ onSpawn: (spawned) => { child = spawned },
35
+ onData: (chunk, channel) => job.append(chunk, { channel }),
36
+ }).then((result) => {
37
+ if (result.exitCode === 0) return { status: 'completed' }
38
+ const killed = controller.signal.aborted
39
+ // 本地 ssh 客户端的退出码并不等于远端命令的状态:255 是连接层失败,
40
+ // 此时远端可能根本没启动,也可能仍在运行——必须说清楚,别让人误读成"命令失败"。
41
+ const detail = killed
42
+ ? t('bg.cancelled')
43
+ : result.exitCode === 255
44
+ ? t('bg.transportFailed')
45
+ : t('bg.remoteExit', { code: result.exitCode })
46
+ const failed = { status: killed ? 'killed' : 'failed', detail }
47
+ return result.notes.length > 0 ? { ...failed, result: result.notes.join('\n') } : failed
48
+ }).catch((error) => ({
49
+ status: controller.signal.aborted ? 'killed' : 'failed',
50
+ detail: error instanceof Error ? error.message : String(error),
51
+ }))
52
+ return {
53
+ cancel: () => {
54
+ controller.abort('killed by request')
55
+ try {
56
+ child?.kill()
57
+ } catch {
58
+ // 进程可能已经自行退出。
59
+ }
60
+ },
61
+ done,
62
+ }
63
+ },
64
+ })
65
+ }
package/lib/i18n.js ADDED
@@ -0,0 +1,589 @@
1
+ /**
2
+ * Host 侧的双语文本表与 locale 解析。
3
+ *
4
+ * 为什么 Host 需要知道语言:工具输出、`/hosts` 结果、设置接口的错误消息都由这里生成,
5
+ * 而它们的读者是使用界面的人。DSH 的语言**不是**浏览器语言猜出来的,而是用户在选择
6
+ * 「设置 → 常规 → 语言」后写进 Host 设置里的 `locale.preference`;`index.js` 通过
7
+ * `settings.describe()` 读取它,并订阅 `settings/document-updated` 跟随切换。
8
+ * 只有在完全没有显式偏好时,才退回 HTTP 请求的 `Accept-Language`(它与 DSH 未设置
9
+ * 偏好时的浏览器派生语言一致)。
10
+ *
11
+ * 保持零依赖:表就是几个普通对象,`t()` 只是查表 + 回退。
12
+ *
13
+ * @module dsh-ssh-vault/i18n
14
+ */
15
+
16
+ import { SSH_MESSAGES } from './locales/ssh.js'
17
+
18
+ /** 支持的 locale 标识。 */
19
+ export const LOCALES = ['zh', 'en']
20
+
21
+ /** 没有任何信号时的默认语言,与插件历史行为一致。 */
22
+ export const DEFAULT_LOCALE = 'zh'
23
+
24
+ /** DSH locale 设置条目(Loader 行 id)与字段名。 */
25
+ export const LOCALE_SETTINGS_NAMESPACE = 'locale'
26
+ /** 该条目里承载用户选择的字段。 */
27
+ export const LOCALE_PREFERENCE_FIELD = 'preference'
28
+
29
+ /** 当前生效的 locale。 */
30
+ let currentLocale = DEFAULT_LOCALE
31
+ /** 是否已经拿到过显式偏好(拿到后不再被 Accept-Language 覆盖)。 */
32
+ let explicitPreference = false
33
+ /** locale 变化订阅者。 */
34
+ const listeners = new Set()
35
+
36
+ /**
37
+ * 把任意标识归一化成我们支持的 locale。
38
+ *
39
+ * DSH 允许安装 `zh`/`en` 之外的语言包(它们的回退链最终都以 en 收尾),
40
+ * 本插件没有那些语言的文案,因此除中文外一律按英文输出。
41
+ *
42
+ * @param value - 语言标识,例如 `zh`、`zh-CN`、`en-US`、`ja`。
43
+ * @returns `zh` 或 `en`。
44
+ */
45
+ export function normalizeLocale(value) {
46
+ const text = typeof value === 'string' ? value.trim().toLowerCase() : ''
47
+ return text.startsWith('zh') ? 'zh' : 'en'
48
+ }
49
+
50
+ /**
51
+ * 读取当前 locale。
52
+ * @returns `zh` 或 `en`。
53
+ */
54
+ export function getLocale() {
55
+ return currentLocale
56
+ }
57
+
58
+ /**
59
+ * 是否已经拿到显式偏好。
60
+ * @returns 是否有显式偏好。
61
+ */
62
+ export function hasExplicitPreference() {
63
+ return explicitPreference
64
+ }
65
+
66
+ /**
67
+ * 设置 locale;只有值真的变化时才通知订阅者。
68
+ * @param value - 语言标识。
69
+ * @returns 生效后的 locale。
70
+ */
71
+ export function setLocale(value) {
72
+ const next = value === 'zh' || value === 'en' ? value : normalizeLocale(value)
73
+ if (next === currentLocale) return currentLocale
74
+ currentLocale = next
75
+ for (const listener of [...listeners]) {
76
+ try {
77
+ listener(currentLocale)
78
+ } catch {
79
+ // 订阅者是命令重注册之类的副作用;它失败不该影响文案解析。
80
+ }
81
+ }
82
+ return currentLocale
83
+ }
84
+
85
+ /**
86
+ * 采用 DSH 设置里的显式偏好。
87
+ * @param preference - `locale.preference` 的值;`undefined` 表示用户没有显式选择。
88
+ * @returns 是否记录到了偏好。
89
+ */
90
+ export function applyPreference(preference) {
91
+ if (typeof preference !== 'string' || preference.trim().length === 0) return false
92
+ explicitPreference = true
93
+ setLocale(preference)
94
+ return true
95
+ }
96
+
97
+ /**
98
+ * 从 `Accept-Language` 头解析出第一个受支持的语言。
99
+ *
100
+ * 只在没有显式偏好时作为兜底使用:它反映的是浏览器的语言,而 DSH 在没有用户选择
101
+ * 时正是按浏览器语言派生的,所以两者通常一致。
102
+ *
103
+ * @param header - 请求头值,可能是字符串或数组。
104
+ * @returns `zh`、`en`,或 `undefined`(头里没有可用语言)。
105
+ */
106
+ export function localeFromHeader(header) {
107
+ const raw = Array.isArray(header) ? header.join(',') : String(header ?? '')
108
+ const entries = raw
109
+ .split(',')
110
+ .map((part, index) => {
111
+ const [tag, ...params] = part.trim().split(';')
112
+ const qParam = params.map((param) => param.trim()).find((param) => param.startsWith('q='))
113
+ const quality = qParam === undefined ? 1 : Number.parseFloat(qParam.slice(2))
114
+ return { tag: tag.trim(), index, quality: Number.isFinite(quality) ? quality : 1 }
115
+ })
116
+ .filter((entry) => entry.tag.length > 0 && entry.quality > 0)
117
+ entries.sort((a, b) => (b.quality - a.quality) || (a.index - b.index))
118
+ for (const entry of entries) {
119
+ const lower = entry.tag.toLowerCase()
120
+ if (lower.startsWith('zh')) return 'zh'
121
+ if (lower.startsWith('en')) return 'en'
122
+ }
123
+ return undefined
124
+ }
125
+
126
+ /**
127
+ * 记录一次请求使用的语言;已有显式偏好时忽略。
128
+ * @param header - `Accept-Language` 头。
129
+ * @returns 生效后的 locale。
130
+ */
131
+ export function learnLocaleFromHeader(header) {
132
+ if (explicitPreference) return currentLocale
133
+ const parsed = localeFromHeader(header)
134
+ if (parsed !== undefined) setLocale(parsed)
135
+ return currentLocale
136
+ }
137
+
138
+ /**
139
+ * 订阅 locale 变化。
140
+ * @param listener - 变化回调,参数为新 locale。
141
+ * @returns 取消订阅函数。
142
+ */
143
+ export function onLocaleChange(listener) {
144
+ if (typeof listener !== 'function') return () => {}
145
+ listeners.add(listener)
146
+ return () => {
147
+ listeners.delete(listener)
148
+ }
149
+ }
150
+
151
+ /**
152
+ * 从设置服务的描述里读出一条配置条目的当前值。
153
+ *
154
+ * DSH 的 `settings.describe()` 返回所有活动插件条目的 volatile 表单值;
155
+ * 语言条目就是 `@deepseek-ai/dsh-client-locale` 注册的 `locale` 命名空间。
156
+ *
157
+ * @param settings - Host 的 settings 服务。
158
+ * @param namespace - Loader 行 id。
159
+ * @returns 该条目的 value;读不到时为 `undefined`。
160
+ */
161
+ export function readSettingsValue(settings, namespace = LOCALE_SETTINGS_NAMESPACE) {
162
+ if (settings === undefined || settings === null || typeof settings.describe !== 'function') return undefined
163
+ const rows = settings.describe()
164
+ const entry = rows.find((row) => String(row?.ns) === namespace)
165
+ return entry?.value
166
+ }
167
+
168
+ /**
169
+ * 读取 DSH 当前的界面语言偏好。
170
+ * @param settings - Host 的 settings 服务。
171
+ * @returns `locale.preference` 的值,未设置时为 `undefined`。
172
+ */
173
+ export function readLocalePreference(settings) {
174
+ const value = readSettingsValue(settings, LOCALE_SETTINGS_NAMESPACE)
175
+ const preference = value?.[LOCALE_PREFERENCE_FIELD]
176
+ return typeof preference === 'string' && preference.length > 0 ? preference : undefined
177
+ }
178
+
179
+ /** 基础文本表:键名按模块分组,值形如 `{ zh, en }`。 */
180
+ const CORE_MESSAGES = {
181
+ // —— `/hosts` 命令 ——
182
+ 'hosts.description': {
183
+ zh: '列出已配置的 SSH 主机与在线状态,或探测指定主机、在指定主机上执行 shell 命令',
184
+ en: 'List configured SSH hosts and their online state, or probe hosts and run shell commands on them',
185
+ },
186
+ 'hosts.hint': {
187
+ zh: '别名,别名; shell 命令(只列出主机就再按一次回车;自然语言直接说,不要加 /hosts)',
188
+ en: 'alias,alias; shell command (press Enter again to just list hosts; for plain language, ask without /hosts)',
189
+ },
190
+ 'hosts.usage': {
191
+ zh: '用法:`/hosts 别名,别名; shell 命令` —— 逗号分隔多台(中英文都行),分号之后是要执行的命令,留空则只探测。',
192
+ en: 'Usage: `/hosts alias,alias; shell command` — comma-separated hosts (ASCII or full-width); everything after the semicolon is executed, leave it empty to probe only.',
193
+ },
194
+ 'hosts.vaultEmpty': {
195
+ zh: 'SSH 保险库为空。可在「设置 → SSH 连接」中添加主机。',
196
+ en: 'The SSH vault is empty. Add a host in Settings → SSH Connections.',
197
+ },
198
+ 'hosts.noReachable': {
199
+ zh: '当前没有可达主机。',
200
+ en: 'No reachable host right now.',
201
+ },
202
+ 'hosts.onlineCount': {
203
+ zh: '在线 {n} 台:',
204
+ en: 'Online ({n}):',
205
+ },
206
+ 'hosts.offlineCount': {
207
+ zh: '离线 {n} 台:',
208
+ en: 'Offline ({n}):',
209
+ },
210
+ 'hosts.needHost': {
211
+ zh: '分号前没有指定主机。',
212
+ en: 'No host was named before the semicolon.',
213
+ },
214
+ 'hosts.needHostWithUsage': {
215
+ zh: '{head}{usage}',
216
+ en: '{head} {usage}',
217
+ },
218
+ 'hosts.unknownWithUsage': {
219
+ zh: '未找到:{list}({usage})',
220
+ en: 'Not found: {list} ({usage})',
221
+ },
222
+ 'hosts.listSeparator': {
223
+ zh: '、',
224
+ en: ', ',
225
+ },
226
+ 'hosts.notNatural': {
227
+ zh: '「{text}」看起来是自然语言,不是 shell 命令,我没有执行它。\n\n'
228
+ + '命令的运行结果不会回流给我(模型),所以这种请求不能塞进分号——把这句话直接发给对话就行,'
229
+ + '我会自己挑主机并执行。\n\n'
230
+ + '如果确实要跑命令,写成:`/hosts {alias}; <shell 命令>`',
231
+ en: '"{text}" looks like natural language rather than a shell command, so it was not executed.\n\n'
232
+ + 'Command output never flows back to me (the model), so a request like this cannot go after the '
233
+ + 'semicolon — send that sentence to the conversation instead and I will pick a host and run it.\n\n'
234
+ + 'If you did mean a command, write: `/hosts {alias}; <shell command>`',
235
+ },
236
+ 'hosts.probeFailed': {
237
+ zh: '连接失败',
238
+ en: 'Connection failed',
239
+ },
240
+ 'hosts.aliasPlaceholder': {
241
+ zh: '<别名>',
242
+ en: 'alias',
243
+ },
244
+ 'hosts.outputTruncated': {
245
+ zh: '…(输出已截断)',
246
+ en: '…(output truncated)',
247
+ },
248
+ 'hosts.timeout': {
249
+ zh: '[超时:{ms}ms 内未结束]',
250
+ en: '[timed out: did not finish within {ms}ms]',
251
+ },
252
+ 'hosts.listLine': {
253
+ zh: '{alias}({host}:{port},{user},{auth})',
254
+ en: '{alias} ({host}:{port}, {user}, {auth})',
255
+ },
256
+ 'hosts.probeSection': {
257
+ zh: '连接状态',
258
+ en: 'Connection',
259
+ },
260
+ 'hosts.execSection': {
261
+ zh: '命令执行',
262
+ en: 'Command',
263
+ },
264
+
265
+ // —— 探测 ——
266
+ 'probe.timeoutDetail': {
267
+ zh: '连接在 {ms}ms 内未建立:主机可能在线但 SSH 端口不通,或地址已变',
268
+ en: 'The connection was not established within {ms}ms: the host may be up with the SSH port closed, or its address changed',
269
+ },
270
+
271
+ // —— 工具参数与主机查找 ——
272
+ 'err.aliasRequired': {
273
+ zh: '必须提供主机别名 alias。',
274
+ en: 'A host alias is required.',
275
+ },
276
+ 'err.aliasNotFound': {
277
+ zh: '保险库中没有名为 {alias} 的主机。{hint}',
278
+ en: 'No host named {alias} in the vault. {hint}',
279
+ },
280
+ 'err.knownHosts': {
281
+ zh: '现有主机:{list}。',
282
+ en: 'Known hosts: {list}.',
283
+ },
284
+ 'err.vaultEmptyHint': {
285
+ zh: '当前保险库为空,请先用 ssh_hosts 的 add 动作添加主机。',
286
+ en: 'The vault is empty; add a host with ssh_hosts action "add" first.',
287
+ },
288
+ 'err.keyEmpty': {
289
+ zh: 'privateKeyPath 不能为空。',
290
+ en: 'privateKeyPath must not be empty.',
291
+ },
292
+ 'err.keyPublicSuffix': {
293
+ zh: '{path} 以 .pub 结尾,是公钥文件。公钥不能用于登录,请提供配套的私钥文件(如 id_rsa)。',
294
+ en: '{path} ends with .pub, so it is a public key. Public keys cannot log in; provide the matching private key (for example id_rsa).',
295
+ },
296
+ 'err.keyReadFailed': {
297
+ zh: '无法读取 {path}:{message}',
298
+ en: 'Could not read {path}: {message}',
299
+ },
300
+ 'err.keyPublicContent': {
301
+ zh: '{path} 的内容是公钥(以 {prefix} 开头),不能用于登录。',
302
+ en: 'The content of {path} is a public key (starts with {prefix}); it cannot log in.',
303
+ },
304
+ 'err.keyPpkPath': {
305
+ zh: '{path} 是 PuTTY 的 .ppk 格式,OpenSSH 无法直接使用;请先用 puttygen 导出 OpenSSH 私钥。',
306
+ en: '{path} is a PuTTY .ppk file, which OpenSSH cannot use directly; export an OpenSSH private key with puttygen first.',
307
+ },
308
+ 'err.keyNotPemPath': {
309
+ zh: '{path} 看起来不是 OpenSSH/PEM 私钥文件。',
310
+ en: '{path} does not look like an OpenSSH/PEM private key.',
311
+ },
312
+
313
+ // —— 凭据解析 ——
314
+ 'err.credentialRefFallback': {
315
+ zh: '主机 {alias} 配置了 credentialRef={ref},但该引用当前不可用{reason};本次已回退使用保险库中保存的口令。',
316
+ en: 'Host {alias} declares credentialRef={ref}, but that reference is unavailable{reason}; this call fell back to the password stored in the vault.',
317
+ },
318
+ 'err.credentialsServiceAbsent': {
319
+ zh: '(当前组合没有凭据服务)',
320
+ en: ' (this composition has no credential service)',
321
+ },
322
+ 'err.credentialsNotConfigured': {
323
+ zh: '(尚未配置,或写入凭据文件后未重启 DSH Desktop)',
324
+ en: ' (it is not configured, or DSH Desktop has not been restarted since the credentials file was written)',
325
+ },
326
+ 'err.credentialRefUnavailable': {
327
+ zh: '主机 {alias} 引用的凭据 {ref} 当前不可用,且保险库中没有可回退的口令。'
328
+ + '请在 Harness 中配置该引用(新增引用需要重启 DSH Desktop),'
329
+ + '或用 scripts/set-secret.mjs {alias} 把口令写入保险库。',
330
+ en: 'The credential {ref} referenced by host {alias} is unavailable and the vault has no fallback password. '
331
+ + 'Configure that reference in the Harness (new references need a DSH Desktop restart), '
332
+ + 'or store the password with scripts/set-secret.mjs {alias}.',
333
+ },
334
+ 'err.noPassword': {
335
+ zh: '主机 {alias} 未保存口令,且没有可用的 credentialRef。',
336
+ en: 'Host {alias} has no stored password and no usable credentialRef.',
337
+ },
338
+
339
+ // —— 执行过程提示 ——
340
+ 'msg.cancelledWithOutput': {
341
+ zh: '工具调用被取消;本地 ssh 客户端已结束,但远端命令可能仍在运行。\n取消前已收到的输出:\n{output}',
342
+ en: 'The tool call was cancelled; the local ssh client has exited, but the remote command may still be running.\nOutput received before the cancellation:\n{output}',
343
+ },
344
+ 'note.sudoInline': {
345
+ zh: '提示:命令里出现了 `sudo -S` 手写口令。改用 ssh_run 的 sudo: true——插件会从保险库取口令预置 sudo 缓存,'
346
+ + '口令不进命令文本、不进两端命令行,也不会出现在远端 ps 里。',
347
+ en: 'Note: the command writes a password inline via `sudo -S`. Use ssh_run with sudo: true instead — the plugin primes the sudo cache from the vault, '
348
+ + 'so the password never enters the command text, either command line, or the remote process list.',
349
+ },
350
+ 'note.accountLockRisk': {
351
+ zh: '⚠ 这条命令会修改账户状态或锁定策略。若同时在同一台机器上反复用某个账号试登录,'
352
+ + '可能把它锁死——Windows 账号一旦锁定,连密钥登录都会一起失效,只能从另一台机器用别的账号解锁。'
353
+ + '执行前请确认已有一条可用的备用管理通道。',
354
+ en: '⚠ This command changes account state or lockout policy. Repeated login attempts with some account on the same machine at the same time '
355
+ + 'could lock it out — on Windows a locked account also blocks key-based login, and recovery requires another account from another machine. '
356
+ + 'Make sure a working fallback management path exists first.',
357
+ },
358
+ 'err.noSudoOnWindows': {
359
+ zh: '主机 {alias} 的远端 shell 是 {shell},没有 sudo。Windows 目标请改用管理员凭据或计划任务执行特权操作。',
360
+ en: 'Host {alias} runs {shell} remotely and has no sudo. On Windows, perform privileged actions through administrator credentials or a scheduled task.',
361
+ },
362
+ 'err.noSudoPassword': {
363
+ zh: '主机 {alias} 没有可用于 sudo 的口令:可用 ssh_hosts 的 update 传 sudoPassword,'
364
+ + '或用 scripts/set-secret.mjs {alias} --sudo 从终端录入,'
365
+ + '也可以让该主机使用口令认证(保险库里的登录口令会被复用)。',
366
+ en: 'Host {alias} has no password usable for sudo: pass sudoPassword via ssh_hosts update, '
367
+ + 'record one with scripts/set-secret.mjs {alias} --sudo, or switch the host to password authentication '
368
+ + '(its vault login password is reused).',
369
+ },
370
+ 'err.jobsUnavailable': {
371
+ zh: '后台执行不可用:当前组合没有 jobs 服务(需要 @deepseek-ai/dsh-jobs 与 @deepseek-ai/dsh-tool-jobs)。'
372
+ + '去掉 run_in_background 并提高 timeoutMs 也能得到中断前的全部输出。',
373
+ en: 'Background execution is unavailable: this composition has no jobs service (requires @deepseek-ai/dsh-jobs and @deepseek-ai/dsh-tool-jobs). '
374
+ + 'Drop run_in_background and raise timeoutMs to get all output produced before an interruption.',
375
+ },
376
+ 'err.backgroundRegister': {
377
+ zh: '后台任务注册失败:{message}。可以去掉 run_in_background 并为该命令提高 timeoutMs。',
378
+ en: 'Registering the background job failed: {message}. Drop run_in_background and raise timeoutMs for this command.',
379
+ },
380
+ 'err.transferCancelled': {
381
+ zh: '传输被取消;远端可能已收到部分文件。\n{stderr}',
382
+ en: 'The transfer was cancelled; the remote side may have received some files.\n{stderr}',
383
+ },
384
+ 'err.probeCancelled': {
385
+ zh: '探测被取消。\n{output}',
386
+ en: 'The probe was cancelled.\n{output}',
387
+ },
388
+ 'marker.timedOut': {
389
+ zh: '[本地客户端已按时限中断]',
390
+ en: '[local client interrupted at the timeout]',
391
+ },
392
+ 'marker.cancelled': {
393
+ zh: '[调用被取消]',
394
+ en: '[call cancelled]',
395
+ },
396
+
397
+ // —— 设置接口 ——
398
+ 'api.aliasRequired': {
399
+ zh: '别名不能为空。',
400
+ en: 'Alias must not be empty.',
401
+ },
402
+ 'api.aliasInvalid': {
403
+ zh: '别名只能包含字母、数字、点、下划线和连字符。',
404
+ en: 'Alias may contain only letters, digits, dot, underscore and hyphen.',
405
+ },
406
+ 'api.renameSourceMissing': {
407
+ zh: '找不到要改名的原记录 {alias}。',
408
+ en: 'Cannot find the entry to rename: {alias}.',
409
+ },
410
+ 'api.aliasTaken': {
411
+ zh: '别名 {alias} 已被占用。',
412
+ en: 'Alias {alias} is already in use.',
413
+ },
414
+ 'api.hostRequired': {
415
+ zh: '主机地址不能为空。',
416
+ en: 'Host address must not be empty.',
417
+ },
418
+ 'api.usernameRequired': {
419
+ zh: '账号不能为空。',
420
+ en: 'Account must not be empty.',
421
+ },
422
+ 'api.portInvalid': {
423
+ zh: '端口必须在 1 到 65535 之间。',
424
+ en: 'Port must be an integer between 1 and 65535.',
425
+ },
426
+ 'api.keyEmpty': {
427
+ zh: '私钥内容为空。',
428
+ en: 'The private key is empty.',
429
+ },
430
+ 'api.keyTooLarge': {
431
+ zh: '私钥文件过大。',
432
+ en: 'The private key file is too large.',
433
+ },
434
+ 'api.keyBadBase64': {
435
+ zh: '私钥内容不是合法的 base64。',
436
+ en: 'The private key is not valid base64.',
437
+ },
438
+ 'api.keyPublic': {
439
+ zh: '上传的是公钥,不是私钥。请选择不带 .pub 后缀的私钥文件。',
440
+ en: 'The uploaded file is a public key, not a private key. Choose the private key without the .pub suffix.',
441
+ },
442
+ 'api.keyPpk': {
443
+ zh: '上传的是 PuTTY 的 .ppk 私钥,OpenSSH 无法直接使用;请先用 puttygen 导出 OpenSSH 私钥。',
444
+ en: 'The uploaded file is a PuTTY .ppk private key, which OpenSSH cannot use directly; export an OpenSSH private key with puttygen first.',
445
+ },
446
+ 'api.keyNotPrivate': {
447
+ zh: '上传的文件不是 OpenSSH/PEM 私钥:缺少 BEGIN … PRIVATE KEY 头。',
448
+ en: 'The uploaded file is not an OpenSSH/PEM private key: it lacks a BEGIN … PRIVATE KEY header.',
449
+ },
450
+ 'api.keyRequired': {
451
+ zh: '密钥认证需要提供私钥:上传文件或填写私钥路径。',
452
+ en: 'Key authentication needs a private key: upload a file or give a path.',
453
+ },
454
+ 'api.passwordRequired': {
455
+ zh: '口令认证需要输入密码(编辑时留空表示保持原密码)。',
456
+ en: 'Password authentication needs a password (leave it blank when editing to keep the current one).',
457
+ },
458
+ 'api.removeAliasRequired': {
459
+ zh: '缺少别名。',
460
+ en: 'Alias is missing.',
461
+ },
462
+ 'api.hostNotFound': {
463
+ zh: '保险库中没有名为 {alias} 的主机。',
464
+ en: 'No host named {alias} in the vault.',
465
+ },
466
+ 'api.openFolderFailed': {
467
+ zh: '无法打开保险库目录:{message}',
468
+ en: 'Could not open the vault directory: {message}',
469
+ },
470
+ 'api.unknownAction': {
471
+ zh: '未知操作:{action}',
472
+ en: 'Unknown action: {action}',
473
+ },
474
+ 'api.methodPostOnly': {
475
+ zh: '只接受 POST。',
476
+ en: 'Only POST is accepted.',
477
+ },
478
+ 'api.routeUnknown': {
479
+ zh: '未知路由。',
480
+ en: 'Unknown route.',
481
+ },
482
+ 'api.bodyInvalid': {
483
+ zh: '请求体不是合法 JSON 或超出大小限制。',
484
+ en: 'The request body is not valid JSON or exceeds the size limit.',
485
+ },
486
+
487
+ // —— 后台任务 ——
488
+ 'bg.cancelled': {
489
+ zh: '调用被取消;本地客户端已结束,远端命令是否仍在运行无法从本机判断',
490
+ en: 'The call was cancelled; the local client has exited, but whether the remote command still runs cannot be determined from here',
491
+ },
492
+ 'bg.transportFailed': {
493
+ zh: 'SSH 连接层失败(本地客户端退出码 255);远端命令是否已启动、是否仍在运行都无法从本机判断',
494
+ en: 'The SSH transport failed (local client exit code 255); from here it cannot be determined whether the remote command started or still runs',
495
+ },
496
+ 'bg.remoteExit': {
497
+ zh: '远端退出码 {code}',
498
+ en: 'Remote exit code {code}',
499
+ },
500
+
501
+ // —— 保险库自身 ——
502
+ 'vault.dpapiTimeout': {
503
+ zh: 'DPAPI 调用超时(15s)',
504
+ en: 'The DPAPI call timed out (15s)',
505
+ },
506
+ 'vault.dpapiFailed': {
507
+ zh: 'DPAPI 调用失败(exit {code}):{output}',
508
+ en: 'The DPAPI call failed (exit {code}): {output}',
509
+ },
510
+ 'vault.noOutput': {
511
+ zh: '无输出',
512
+ en: 'no output',
513
+ },
514
+ 'vault.masterPasswordMissing': {
515
+ zh: 'SSH 保险库由主口令保护,但插件配置缺少 masterPassword;请在 cordis.patch.yml 的 ssh-vault 行中补上 masterPassword 后重试。',
516
+ en: 'The SSH vault is protected by a master password, but the plugin config has no masterPassword; add it to the ssh-vault entry in cordis.patch.yml and retry.',
517
+ },
518
+ 'vault.dpapiUnwrapFailed': {
519
+ zh: 'SSH 保险库的数据密钥无法用 Windows DPAPI 解开({message})。'
520
+ + '该密钥只能由创建它的 Windows 用户解开;若主机清单可以重建,删除 key.json 与 vault.json 后重新录入即可。',
521
+ en: 'The SSH vault data key cannot be unwrapped with Windows DPAPI ({message}). '
522
+ + 'Only the Windows user that created it can unwrap it; if the host list can be rebuilt, delete key.json and vault.json and enter the hosts again.',
523
+ },
524
+ 'vault.masterPasswordRequired': {
525
+ zh: 'keyProtection 为 passphrase 时必须提供 masterPassword。',
526
+ en: 'keyProtection "passphrase" requires masterPassword.',
527
+ },
528
+ 'vault.dpapiUnavailable': {
529
+ zh: 'ssh-vault: Windows DPAPI 不可用({message}),数据密钥将以明文文件保存,安全边界降级为文件系统权限。',
530
+ en: 'ssh-vault: Windows DPAPI is unavailable ({message}); the data key will be stored as a plaintext file and the security boundary degrades to filesystem permissions.',
531
+ },
532
+ 'vault.decryptFailed': {
533
+ zh: 'SSH 保险库解密失败:密文与当前数据密钥不匹配,或文件已被篡改。若主机清单可以重建,删除 vault.json 后重新录入。',
534
+ en: 'Decrypting the SSH vault failed: the ciphertext does not match the current data key, or the file was tampered with. If the host list can be rebuilt, delete vault.json and enter the hosts again.',
535
+ },
536
+
537
+ // —— 平台支持 ——
538
+ 'platform.unsupported': {
539
+ zh: 'dsh-ssh-vault 仅支持 Windows,当前平台是 {platform}({raw})。'
540
+ + '插件依赖 Windows DPAPI 保护数据密钥、并使用 Windows 的 askpass 包装器,'
541
+ + '非 Windows 上继续运行只会把密钥退化成明文文件。请在 Windows 上运行 DSH。',
542
+ en: 'dsh-ssh-vault supports Windows only; the current platform is {platform} ({raw}). '
543
+ + 'It relies on Windows DPAPI to protect the data key and on the Windows askpass wrapper; '
544
+ + 'running elsewhere would only degrade the key to a plaintext file. Run DSH on Windows.',
545
+ },
546
+ }
547
+
548
+ /** 合并后的完整字典。 */
549
+ const MESSAGES = { ...CORE_MESSAGES, ...SSH_MESSAGES }
550
+
551
+ /**
552
+ * 取一条文本。
553
+ * @param key - 字典键。
554
+ * @param vars - 需要替换的 `{name}` 占位符。
555
+ * @param locale - 强制使用的 locale;省略则用当前值。
556
+ * @returns 该 locale 下的文本,缺失时回退中文,再回退键名。
557
+ */
558
+ export function t(key, vars = {}, locale = currentLocale) {
559
+ const entry = MESSAGES[key]
560
+ if (entry === undefined) return key
561
+ const template = entry[locale] ?? entry.zh ?? key
562
+ return template.replace(/\{(\w+)\}/g, (match, name) => (vars[name] === undefined ? match : String(vars[name])))
563
+ }
564
+
565
+ /**
566
+ * 该键是否有任何文案(测试与静态检查用)。
567
+ * @param key - 字典键。
568
+ * @returns 是否存在。
569
+ */
570
+ export function hasMessage(key) {
571
+ return Object.prototype.hasOwnProperty.call(MESSAGES, key)
572
+ }
573
+
574
+ /**
575
+ * 列出所有字典键(测试用)。
576
+ * @returns 键名数组。
577
+ */
578
+ export function messageKeys() {
579
+ return Object.keys(MESSAGES)
580
+ }
581
+
582
+ /**
583
+ * 测试专用:把进程内状态恢复到「没有收到任何信号」。
584
+ */
585
+ export function resetLocaleForTests() {
586
+ currentLocale = DEFAULT_LOCALE
587
+ explicitPreference = false
588
+ listeners.clear()
589
+ }