dsh-remote 0.8.72 → 0.8.74

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.
Files changed (44) hide show
  1. package/LICENSE +21 -21
  2. package/README.en.md +470 -389
  3. package/README.md +529 -462
  4. package/cordis.patch.yml +9 -9
  5. package/lib/binding.js +172 -172
  6. package/lib/client.js +4039 -4022
  7. package/lib/credential.js +263 -263
  8. package/lib/errors.js +141 -141
  9. package/lib/exec-job.js +235 -235
  10. package/lib/file-reference.js +457 -457
  11. package/lib/forwards.js +258 -258
  12. package/lib/host-api.js +808 -0
  13. package/lib/hostkey.js +108 -108
  14. package/lib/http-transport.js +196 -196
  15. package/lib/ignore.js +202 -202
  16. package/lib/index.js +4923 -4541
  17. package/lib/instructions-dynamic.js +415 -415
  18. package/lib/openssh.js +879 -879
  19. package/lib/paths.js +252 -252
  20. package/lib/pool.js +963 -893
  21. package/lib/registry.js +296 -293
  22. package/lib/remote-fs.js +173 -173
  23. package/lib/remote-sandbox.js +183 -183
  24. package/lib/remote-shell-child.cjs +344 -305
  25. package/lib/remote-shell.js +542 -518
  26. package/lib/routes-fs.js +245 -245
  27. package/lib/routes-term.js +169 -169
  28. package/lib/search.js +235 -235
  29. package/lib/sftp-surface.js +119 -119
  30. package/lib/sshconfig.js +779 -705
  31. package/lib/sync.js +275 -275
  32. package/lib/tasks.js +95 -95
  33. package/lib/telemetry.js +213 -213
  34. package/lib/terminal.js +205 -205
  35. package/lib/tui/flow.js +130 -0
  36. package/lib/tui/format.js +332 -0
  37. package/lib/tui/i18n.js +344 -0
  38. package/lib/tui/index.js +322 -0
  39. package/lib/tui/provider.js +327 -0
  40. package/lib/tui/scene.js +996 -0
  41. package/lib/update.js +472 -472
  42. package/lib/web-attach.js +850 -850
  43. package/lib/workspace-cleanup.js +337 -0
  44. package/package.json +100 -99
@@ -0,0 +1,332 @@
1
+ // dsh-remote TUI — 纯函数工具(URI 编解码 / target 构造 / 表单校验 / 文案)。
2
+ //
3
+ // 这里**只放纯计算**:不读写文件、不连网、不 import cordis。provider / scene /
4
+ // 测试共用这些函数,因此它们必须能在没有 TUI、没有 DSH 进程的环境里直接调用。
5
+ //
6
+ // 远程路径同时支持 POSIX(`/home/u/proj`)与 Windows(`C:\work`、`D:\`、
7
+ // `\\server\share`)两种形态;具体归一化复用 lib/paths.js(已被 issue #58/#60
8
+ // 等测试覆盖),避免 TUI 侧再造一套分隔符逻辑。
9
+
10
+ import { normalizeRemotePath, remoteWorkspaceTitle } from '../paths.js'
11
+ import { createI18n } from './i18n.js'
12
+
13
+ /** 本 provider 拥有的 URI scheme(不带冒号)。 */
14
+ export const URI_SCHEME = 'dshremote'
15
+
16
+ /** 表单支持的认证方式。 */
17
+ export const AUTH_MODES = ['password', 'key', 'agent']
18
+
19
+ /** 默认的 zh 文案渲染器(statusLine / validateMachineForm 的默认 t)。 */
20
+ const defaultT = createI18n('zh').t
21
+
22
+ /**
23
+ * 组合一个 provider URI:`dshremote://<编码后的机器 id>/<编码后的远程绝对路径>`。
24
+ *
25
+ * 机器段与路径段都整体 encodeURIComponent(路径里的 `/`、`\`、`:`、空格、中文
26
+ * 全部转义),因此 URI 里**不会出现裸斜杠**,解析时不存在歧义。
27
+ *
28
+ * @param {string} machineId - 机器 id(provider 内部身份,也接受机器名)。
29
+ * @param {string} remotePath - 远程绝对路径(POSIX 或 Windows)。
30
+ * @returns {string} 可粘贴、可持久化的 URI。
31
+ */
32
+ export function buildUri(machineId, remotePath) {
33
+ const id = encodeURIComponent(String(machineId === undefined || machineId === null ? '' : machineId).trim())
34
+ const path = encodeURIComponent(String(remotePath === undefined || remotePath === null ? '' : remotePath))
35
+ return `${URI_SCHEME}://${id}/${path}`
36
+ }
37
+
38
+ /**
39
+ * 解析 provider URI(scheme 大小写不敏感;两段都做 URL 解码)。
40
+ *
41
+ * Windows 盘符路径(`C:\work`)编码后是 `C%3A%5Cwork`,解码回来仍是
42
+ * `C:\work`;机器段可以是 id 也可以是机器名,解析层不区分。
43
+ *
44
+ * @param {string} uri - 形如 `dshremote://m1/%2Fhome%2Fu%2Fproj`。
45
+ * @returns {{ machineId: string, remotePath: string } | undefined} 解析失败返回
46
+ * undefined(不抛错:URI 来自用户输入,不能让它炸掉渲染)。
47
+ */
48
+ export function parseUri(uri) {
49
+ const raw = String(uri === undefined || uri === null ? '' : uri).trim()
50
+ const match = /^([A-Za-z][A-Za-z0-9+.-]*):\/\/([^/]*)\/(.*)$/s.exec(raw)
51
+ if (!match) return undefined
52
+ if (match[1].toLowerCase() !== URI_SCHEME) return undefined
53
+ let machineId
54
+ let remotePath
55
+ try {
56
+ machineId = decodeURIComponent(match[2])
57
+ remotePath = decodeURIComponent(match[3])
58
+ } catch {
59
+ return undefined
60
+ }
61
+ if (!machineId.trim() || !remotePath.trim()) return undefined
62
+ return { machineId: machineId.trim(), remotePath }
63
+ }
64
+
65
+ /**
66
+ * 把机器名压成列表里的紧凑 badge:最多 12 个字符(按码点计,中文算 1 个),
67
+ * 超长用 `…` 截断。空名回落 `remote`,保证 badge 非空。
68
+ *
69
+ * @param {string} name - 机器显示名。
70
+ * @returns {string} ≤12 个字符的 badge 文本。
71
+ */
72
+ export function dbadge(name) {
73
+ const chars = Array.from(String(name === undefined || name === null ? '' : name).trim())
74
+ if (chars.length === 0) return 'remote'
75
+ if (chars.length <= 12) return chars.join('')
76
+ return chars.slice(0, 11).join('') + '…'
77
+ }
78
+
79
+ /**
80
+ * 远程路径的 basename(POSIX 与 Windows 盘符都处理)。
81
+ *
82
+ * 根路径没有 basename:POSIX `/` 与 Windows `D:\` 分别返回 `/` 与 `D:`,
83
+ * 保证调用方拿到的是非空字符串(workspace label 需要它)。
84
+ *
85
+ * @param {string} remotePath - 远程路径。
86
+ * @returns {string} 最后一段(或根标记)。
87
+ */
88
+ export function basenameRemote(remotePath) {
89
+ const normalized = normalizeRemotePath(String(remotePath === undefined || remotePath === null ? '' : remotePath))
90
+ const trimmed = normalized.replace(/[\\/]+$/, '')
91
+ if (!trimmed) return '/'
92
+ const parts = trimmed.split(/[\\/]+/)
93
+ return parts[parts.length - 1] || trimmed
94
+ }
95
+
96
+ /**
97
+ * 把相对路径解析到远程绝对路径上;绝对路径(POSIX `/…`、Windows `C:\…`、
98
+ * UNC `\\server\share`)原样归一化后返回。
99
+ *
100
+ * 相对段允许 `..` / `.`,`normalizeRemotePath` 会折叠它们;分隔符跟随 base
101
+ * 的风格(base 是 Windows 路径时用 `\`,否则用 `/`)。
102
+ *
103
+ * @param {string} remotePath - 远程 base 绝对路径。
104
+ * @param {string} relative - 相对路径,或另一个绝对远程路径。
105
+ * @returns {string} 归一化后的远程绝对路径。
106
+ */
107
+ export function joinRemote(remotePath, relative) {
108
+ const base = normalizeRemotePath(String(remotePath === undefined || remotePath === null ? '' : remotePath))
109
+ const rel = String(relative === undefined || relative === null ? '' : relative).trim()
110
+ if (!rel || rel === '.') return base
111
+ // 绝对路径:POSIX 根、Windows 盘符、UNC、以及 `C:` 形式。
112
+ if (/^[\\/]/.test(rel) || /^[A-Za-z]:([\\/]|$)/.test(rel)) return normalizeRemotePath(rel)
113
+ const sep = base.includes('\\') ? '\\' : '/'
114
+ const joined = base.endsWith(sep) ? base + rel : base + sep + rel
115
+ return normalizeRemotePath(joined)
116
+ }
117
+
118
+ /**
119
+ * 由「机器 + 本地镜像 + 远程路径」构造 `TuiWorkspaceTarget`。
120
+ *
121
+ * `cwd` 必须是本地真实存在的镜像目录(会话身份由 session header.cwd 落在哪个
122
+ * 镜像决定);`uri` 表达远程身份;`description` 显示 `user@host:remote/path`;
123
+ * `badge` 是宿主不解释的紧凑机器名。
124
+ *
125
+ * @param {{ machine?: object, localMirror?: string, remotePath?: string, title?: string }} input
126
+ * @returns {{ uri: string, cwd: string, label: string, description: string,
127
+ * kind: 'provider', badge: string } | undefined} 缺机器身份或本地镜像时 undefined。
128
+ */
129
+ export function targetFromWorkspace(input) {
130
+ const source = input || {}
131
+ const machine = source.machine || {}
132
+ const remotePath = normalizeRemotePath(String(source.remotePath || ''))
133
+ const localMirror = String(source.localMirror || '')
134
+ const id = String(machine.id || machine.alias || machine.host || '').trim()
135
+ if (!id || !localMirror) return undefined
136
+ const host = String(machine.host || '').trim()
137
+ const user = String(machine.username || '').trim()
138
+ const target = user ? `${user}@${host}:${remotePath}` : `${host}:${remotePath}`
139
+ const title = String(source.title || '').trim()
140
+ return {
141
+ uri: buildUri(id, remotePath),
142
+ cwd: localMirror,
143
+ label: title || remoteWorkspaceTitle(remotePath, machine) || basenameRemote(remotePath),
144
+ description: target,
145
+ kind: 'provider',
146
+ badge: dbadge(machine.name || machine.host || id),
147
+ }
148
+ }
149
+
150
+ /** hostname / IPv4 / IPv6(可带方括号)的宽松字符校验。 */
151
+ const HOST_TOKEN_RE = /^(?:\[[0-9A-Fa-f:.]+\]|[A-Za-z0-9._:-]+)$/
152
+ /** ~/.ssh/config 的 Host alias(字母数字与 `._-`;允许 `*`/`?` 通配符)。 */
153
+ const ALIAS_TOKEN_RE = /^[A-Za-z0-9._*?-]+$/
154
+ /** 端口:1–5 位数字,范围另判。 */
155
+ const PORT_RE = /^\d{1,5}$/
156
+
157
+ /**
158
+ * 校验机器表单,返回 `{ errors, patch }`。
159
+ *
160
+ * - `errors` 是**文案 key**(调用方用 i18n 渲染;测试可直接断言 key),也可传
161
+ * 入 `t` 让函数自己翻译成人类可读的错误串。
162
+ * - `patch` 直接可作为 `api.upsertMachine()` 的参数(MachinePatch);密码 /
163
+ * passphrase 为空时**不写进 patch**(“留空表示不修改”),从不回显。
164
+ * - `useSshConfig === true` 时 `host` 是 `~/.ssh/config` 的 Host alias:仍必填,
165
+ * 但按 alias 字符集校验(不要求是 hostname/IP);用户名/端口可留空(连接时
166
+ * 由 ssh 配置解析)。
167
+ *
168
+ * @param {object} form - 表单字段(name/host/port/username/auth/password/
169
+ * privateKeyPath/passphrase/group/useSshConfig/id)。
170
+ * @param {(key: string) => string} [t] - 可选的文案渲染器,省略时 errors 保留 key。
171
+ * @returns {{ errors: string[], patch: object }}
172
+ */
173
+ export function validateMachineForm(form, t) {
174
+ const tr = typeof t === 'function' ? t : (key) => key
175
+ const input = form || {}
176
+ const useSshConfig = input.useSshConfig === true
177
+ const errors = []
178
+
179
+ const host = String(input.host === undefined || input.host === null ? '' : input.host).trim()
180
+ if (!host) {
181
+ errors.push(tr('form.errHostRequired'))
182
+ } else if (useSshConfig) {
183
+ if (!ALIAS_TOKEN_RE.test(host)) errors.push(tr('form.errAliasInvalid'))
184
+ } else if (!HOST_TOKEN_RE.test(host)) {
185
+ errors.push(tr('form.errHostInvalid'))
186
+ }
187
+
188
+ let port = 22
189
+ const rawPort = String(input.port === undefined || input.port === null ? '' : input.port).trim()
190
+ if (rawPort) {
191
+ const numeric = Number(rawPort)
192
+ if (!PORT_RE.test(rawPort) || !Number.isInteger(numeric) || numeric < 1 || numeric > 65535) {
193
+ errors.push(tr('form.errPortRange'))
194
+ } else {
195
+ port = numeric
196
+ }
197
+ }
198
+
199
+ const auth = String(input.auth === undefined || input.auth === null ? '' : input.auth).trim()
200
+ if (auth && !AUTH_MODES.includes(auth)) errors.push(tr('form.errAuth'))
201
+ const resolvedAuth = AUTH_MODES.includes(auth) ? auth : 'password'
202
+
203
+ const privateKeyPath = String(
204
+ input.privateKeyPath === undefined || input.privateKeyPath === null ? '' : input.privateKeyPath,
205
+ ).trim()
206
+ if (resolvedAuth === 'key' && !privateKeyPath) errors.push(tr('form.errKeyPath'))
207
+
208
+ const username = String(input.username === undefined || input.username === null ? '' : input.username).trim()
209
+ const group = String(input.group === undefined || input.group === null ? '' : input.group).trim()
210
+
211
+ const patch = {
212
+ name: String(input.name === undefined || input.name === null ? '' : input.name).trim() || host,
213
+ host,
214
+ port,
215
+ username: username || 'root',
216
+ useSshConfig,
217
+ useAgent: resolvedAuth === 'agent',
218
+ privateKeyPath,
219
+ group,
220
+ }
221
+ // 表单没有编辑、但宿主 upsert 会用默认值覆盖的机器级字段:编辑时从 MachineView
222
+ // 原样带回(formFromMachine 写入隐藏字段),否则一次 TUI 编辑会把 backend 重置成
223
+ // native、清空 workspace / hostKeyMode / sshBinary。调用方没给这些键时不写入
224
+ // patch,保持“省略=保留旧值”的契约。
225
+ if (input.workspace !== undefined && input.workspace !== null) patch.workspace = String(input.workspace).trim()
226
+ if (input.hostKeyMode !== undefined && input.hostKeyMode !== null) patch.hostKeyMode = String(input.hostKeyMode).trim()
227
+ if (input.backend !== undefined && input.backend !== null) {
228
+ patch.backend = String(input.backend) === 'openssh' ? 'openssh' : 'native'
229
+ }
230
+ if (input.sshBinary !== undefined && input.sshBinary !== null) patch.sshBinary = String(input.sshBinary).trim()
231
+ if (input.keyboardInteractive !== undefined && input.keyboardInteractive !== null) {
232
+ patch.keyboardInteractive = input.keyboardInteractive === true
233
+ }
234
+ if (input.id !== undefined && input.id !== null && String(input.id).trim()) patch.id = String(input.id).trim()
235
+ // 密码 / 口令只写不读:表单留空 = 保持原值,绝不把空串写进 patch。
236
+ const password = input.password === undefined || input.password === null ? '' : String(input.password)
237
+ if (password) patch.password = password
238
+ const passphrase = input.passphrase === undefined || input.passphrase === null ? '' : String(input.passphrase)
239
+ if (passphrase) patch.passphrase = passphrase
240
+
241
+ return { errors, patch }
242
+ }
243
+
244
+ /**
245
+ * MachineView → 可编辑表单的初值。
246
+ *
247
+ * 密码与私钥口令**永远不预填**(`''` 表示“不修改”);认证方式由 `useAgent` /
248
+ * `privateKeyPath` 反推。
249
+ *
250
+ * @param {object} machine - MachineView(来自宿主 api,绝无 secret)。
251
+ * @returns {object} 表单对象。
252
+ */
253
+ export function formFromMachine(machine) {
254
+ const source = machine || {}
255
+ const auth = source.useAgent ? 'agent' : source.privateKeyPath ? 'key' : 'password'
256
+ return {
257
+ id: String(source.id || ''),
258
+ name: String(source.name || ''),
259
+ host: String(source.host || ''),
260
+ port: String(Number(source.port) || 22),
261
+ username: String(source.username || ''),
262
+ useSshConfig: source.useSshConfig === true,
263
+ auth,
264
+ password: '',
265
+ privateKeyPath: String(source.privateKeyPath || ''),
266
+ passphrase: '',
267
+ group: String(source.group || ''),
268
+ // 不在 TUI 表单里编辑、但保存时必须原样带回去的机器级字段(见
269
+ // validateMachineForm 的同一段说明):否则一次编辑会重置 backend 等设置。
270
+ workspace: String(source.workspace || ''),
271
+ hostKeyMode: String(source.hostKeyMode || ''),
272
+ backend: String(source.backend || '') === 'openssh' ? 'openssh' : 'native',
273
+ sshBinary: String(source.sshBinary || ''),
274
+ keyboardInteractive: source.keyboardInteractive === true,
275
+ }
276
+ }
277
+
278
+ /** 新增机器的空表单(端口默认 22、认证方式默认 password)。 */
279
+ export function emptyMachineForm() {
280
+ return {
281
+ id: '',
282
+ name: '',
283
+ host: '',
284
+ port: '22',
285
+ username: '',
286
+ useSshConfig: false,
287
+ auth: 'password',
288
+ password: '',
289
+ privateKeyPath: '',
290
+ passphrase: '',
291
+ group: '',
292
+ workspace: '',
293
+ hostKeyMode: '',
294
+ backend: 'native',
295
+ sshBinary: '',
296
+ keyboardInteractive: false,
297
+ }
298
+ }
299
+
300
+ /**
301
+ * 顶部状态行文案(`user@host` + 连接状态)。
302
+ *
303
+ * @param {object} status - 宿主 `status()` 返回值。
304
+ * @param {(key: string, params?: object) => string} [t] - 文案渲染器(默认中文)。
305
+ * @returns {string} 例如 `已连接 root@10.0.0.2`。
306
+ */
307
+ export function statusLine(status, t) {
308
+ const tr = typeof t === 'function' ? t : defaultT
309
+ if (!status || (!status.host && !status.connected)) return tr('status.none')
310
+ const host = String(status.host || '')
311
+ const user = String(status.username || '')
312
+ const target = user ? `${user}@${host}` : host
313
+ return tr(status.connected ? 'status.connected' : 'status.disconnected', { target })
314
+ }
315
+
316
+ /** Human byte size for the cleanup page (binary units, one decimal).
317
+ *
318
+ * @param {number} value - byte count.
319
+ * @returns {string} e.g. `0 B`, `943 B`, `12.4 KB`, `1.8 MB`.
320
+ */
321
+ export function formatBytes(value) {
322
+ const bytes = Math.max(0, Number(value) || 0)
323
+ if (bytes < 1024) return `${Math.round(bytes)} B`
324
+ const units = ['KB', 'MB', 'GB', 'TB']
325
+ let size = bytes / 1024
326
+ let index = 0
327
+ while (size >= 1024 && index < units.length - 1) {
328
+ size /= 1024
329
+ index += 1
330
+ }
331
+ return `${size >= 10 ? size.toFixed(0) : size.toFixed(1)} ${units[index]}`
332
+ }