dsh-remote 0.8.21 → 0.8.22

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/README.md CHANGED
@@ -5,9 +5,13 @@
5
5
  # dsh-remote
6
6
 
7
7
  [![npm version](https://img.shields.io/npm/v/dsh-remote)](https://www.npmjs.com/package/dsh-remote)
8
+ [![downloads](https://img.shields.io/npm/dw/dsh-remote)](https://www.npmjs.com/package/dsh-remote)
9
+ [![downloads](https://img.shields.io/npm/dm/dsh-remote)](https://www.npmjs.com/package/dsh-remote)
8
10
  [![license](https://img.shields.io/github/license/flymysql/dsh-remote)](LICENSE)
9
11
  [![dsh-plugin](https://img.shields.io/badge/topic-dsh--plugin-7a3ef3)](https://github.com/topics/dsh-plugin)
10
12
 
13
+ Maintained by [@flymysql](https://github.com/flymysql) · [Blog](https://gitpull.cn) · [Discussions](https://github.com/flymysql/dsh-remote/discussions) · [Issues](https://github.com/flymysql/dsh-remote/issues) · [中文说明](./README.zh.md)
14
+
11
15
  ![dsh-remote — make any SSH machine a real DSH workspace](docs/cover.png)
12
16
 
13
17
  **Remote-work assistant for [DeepSeek Harness](https://github.com/deepseek-ai/deepseek-harness) (DSH).**
@@ -35,12 +39,13 @@ Real capture (host scrubbed to a placeholder):
35
39
  ## Features
36
40
 
37
41
  - **Multi-machine SSH** — save any number of hosts (`host`/`port`/`user` + **private key** or **password**). Passwords are stored locally and never shown back in the UI. Switch with one click in Settings. Per-machine **passphrase / host-key mode / SSH agent / keyboard-interactive (OTP) / proxy jump (bastion)** and an **optional OS-keychain password** (`加密保存密码` — macOS Keychain / Windows DPAPI / Linux secret-tool).
38
- - **`~/.ssh/config` import** — the Settings page lists your `Host` aliases; one click fills the form (path reference only, the plugin never reads key material).
42
+ - **`~/.ssh/config` aliases (resolved live, never copied)** — a machine can be saved as just a **Host alias** (`useSshConfig`): hostname/user/port/key/jump host are read from `~/.ssh/config` **at every connect**, so editing that file takes effect immediately and there is nothing to re-import; the registry stores **no copy** of those values (the key stays a path reference, its content is never read). Full OpenSSH semantics: multi-alias `Host a b`, `*`/`?` wildcards, `!` negation, `Include` (globbed, relative to `~/.ssh`), trailing-`\` continuations and ssh_config(5)'s *first-obtained-value-wins*. In Settings, **Import from ~/.ssh/config** saves an alias in one click (or **Copy fields** materialises a normal machine), the alias list and machine rows show **alias → what it actually resolves to**, and anything the plugin cannot honour (`ProxyJump` with several hops, `ProxyCommand`) is surfaced as a warning instead of silently degrading.
39
43
  - **Two-tab workspace picker** (fills the native "Add workspace" flow):
40
44
  - **本机 / Local** — opens the **native OS folder chooser** over the host (macOS `osascript` / Linux `zenity`→`kdialog` / **Windows `FolderBrowserDialog`**), or lets you type a local path → adopted directly as a normal DSH local workspace.
41
45
  - **远程 / Remote** — the picker is a **centered modal**. Pick a **machine** → on Windows hosts the root shows a **"This PC" drive view** (`C:\`, `D:\`, `E:\`… instead of the Git Bash MSYS root) and the path field live **autocompletes** directories (accepts `C:\Users\…` or `/c/Users/…` — Windows paths are rewritten to the Git Bash form underneath); selecting a directory immediately lists its next level. A **浏览…** floating browser (Windows-aware breadcrumb `此电脑 / C:\ / Users / dev`, drive rows, size + mtime, dirs first, follows symlinks) fills the field without committing; the **回上一级** button works at any depth (even when the browser was opened at the path bar's value). **最近 workspaces** quick-pick, **`~` 主目录** shortcut and **新建目录** are one click away. On confirm it creates a **real local mirror** under `$DSH_HOME/remote-workspaces/<host>-<user>-<port>/<base>` that passes `fs.realpath` → the harness adopts it as a real workspace while dsh-remote keeps it synced over SFTP.
42
46
  - **Git Bash default terminal (Windows remotes)** — the remote platform is auto-detected (`cmd /c ver`, plus an `uname -s` MINGW/MSYS probe as fallback); on Windows the plugin locates Git Bash (`config.shell` can pin a path or `native` disables wrapping) and pipes every command to `bash -s` over the exec channel, so quoting/backslash escaping is never an issue regardless of the SSH default shell. `rw_exec` runs with a Git Bash cwd (`/c/Users/…` form). `/dsh-remote/status`, `rw_info` and the 测试连接 button report the detected platform + shell.
43
47
  - **Windows path auto-conversion** — typing `C:\Users\dev\project` (or `C:/…`, `/c/…`, `/C:/…`) is normalized underneath to the Git Bash form `/c/Users/dev/project` for shell commands, while workspaces are stored and shown Windows-style (`C:\Users\dev\project`). All model tools accept and report both forms; SFTP access uses the Win32-OpenSSH `/D:/…` form (see `toSftpPath`).
48
+ - **Remote `@` completion (issue #39)** — in a remote session `@` lists the **remote** tree (read live over SFTP, not the local mirror): directories drill down, a slash-free query fuzzy-matches the whole tree, and candidates are **workspace-relative paths** (`@src/main.c`) exactly like a local session. The `rw_*` tools accept those relative paths and resolve them against the remote workspace root. The index is bounded (entries/directories/deadline + cache + failure breaker) and **falls back to the local mirror when the host is unreachable** — never a silent empty list. Local sessions are untouched.
44
49
  - **Bidirectional SFTP sync, conflict-aware** — `rw_sync` (remote → mirror) and `rw_push` (mirror → remote) are **three-way** (remote vs local vs last-synced snapshot): files changed on both sides are **reported as conflicts and never silently overwritten** (`force=true` overrides). Defaults are **depth 8 / 2000 files**; hitting a cap is reported as **`TRUNCATED`**. Both support **dry-run**, **background tasks**, and honor **gitignore-style ignore rules**.
45
50
  - **Model tools** — 20 tools, all Windows/POSIX portable via SFTP: `rw_info`, `rw_connect` (with `save`), `rw_pick_workspace`, `rw_list_dir` (size+mtime), `rw_stat`, `rw_read_file` (encoding-aware: utf-8/gbk), `rw_write_file`, **`rw_edit`** (literal replace + mtime optimistic lock), `rw_append`, `rw_mkdir`, `rw_remove` (recursive, bounded), `rw_move`, `rw_exec` (pty/env), **`rw_search`** (SFTP tree walk — works on Windows too, honors ignore rules, context lines), `rw_download`/`rw_upload` (streaming fastGet/fastPut + size caps), **`rw_forward`** (SSH tunnels), `rw_sync`, `rw_push`, `rw_disconnect`.
46
51
  - **Port forwarding panel** — create/start/stop/remove **local** (`127.0.0.1:port → remote`) and **reverse** (`remote → local`) tunnels in the Settings page or via `rw_forward`; definitions persist, auto-restart on reconnect when enabled, all tunnels stop on disconnect.
@@ -237,9 +242,16 @@ and should be done only when you intend to release.
237
242
  | `autoPush` | bool | `false` | auto-push edited mirror files back to the remote (watcher, debounced) |
238
243
  | `auditLog` | bool | `true` | append executed commands to `$DSH_HOME/remote-workspaces/audit.log` |
239
244
  | `encoding` | string | `utf-8` | text encoding for remote file reads/writes (e.g. `gbk`) |
245
+ | `fileReference` | bool | `true` | remote `@` completion: in a remote session `@` lists the **remote** tree over SFTP (issue #39); off → only the local mirror |
246
+ | `fileReferenceMaxResults` | int | `20` | max `@` candidates rendered for one query |
247
+ | `fileReferenceMaxEntries` | int | `3000` | max entries retained in one remote workspace's `@` index |
248
+ | `fileReferenceExcludedDirectories` | string[] | `[.git, node_modules, dist, build, out, coverage, target, .next, .nuxt, .turbo, .venv, __pycache__, .pytest_cache, .mypy_cache, .gradle]` | directory basenames the remote `@` traversal skips |
249
+ | `fileReferenceTimeoutMs` | int | `4000` | wall-clock budget for one remote `@` index pass (on expiry the partial index answers rather than making the caret wait) |
240
250
 
241
251
  ## FAQ / troubleshooting
242
252
 
253
+ **`@` lists remote files but the built-in read tool cannot open them** — the harness's own file tools see the session's **local mirror** (`$DSH_HOME/remote-workspaces/…`), which stays empty until `rw_sync` downloads it. Read remote files with `rw_read_file` / the sidebar remote tab: `@src/main.c` in a remote session means `<remote workspace>/src/main.c`, and every `rw_*` tool resolves such a relative path against the remote workspace root. Seeing nothing at all? The remote `@` index falls back to the mirror when the host is unreachable, and the settings page's 测试连接 shows why.
254
+
243
255
  **Host key 变了 / 提示可能中间人** — 主机重装过或密钥更换过:`/remote-forget-key`(或设置页 → 机器 → 重新信任),下次连接重新记录。
244
256
 
245
257
  **连接报"认证失败"** — 检查用户名/密码/私钥路径;私钥加密了要填 Passphrase;公司机器要求 OTP/动态码时勾选 keyboard-interactive。
@@ -264,6 +276,21 @@ Giving the plugin a machine's credentials lets the agent run **shell commands as
264
276
 
265
277
  MIT
266
278
 
279
+ ## Contributing
280
+
281
+ Contributions are welcome — see [CONTRIBUTING.md](./CONTRIBUTING.md). Questions, setups and "is this supported?" go to [Discussions](https://github.com/flymysql/dsh-remote/discussions); reproducible bugs go to [Issues](https://github.com/flymysql/dsh-remote/issues).
282
+
283
+ Thanks to everyone who has landed a change here (merged PRs in parentheses):
284
+
285
+ [@dahaipeng](https://github.com/dahaipeng) (#31) ·
286
+ [@YiHui-Liu](https://github.com/YiHui-Liu) (#28) ·
287
+ [@nekomona](https://github.com/nekomona) (#24) ·
288
+ [FoolishWiser](https://github.com/FoolishWiser) (#17) ·
289
+ [@jace1cch](https://github.com/jace1cch) (#16) ·
290
+ [@Minggle](https://github.com/Minggle) (#10) ·
291
+ [4FMTWRV](https://github.com/4FMTWRV) (#6) ·
292
+ [glzhangzhi](https://github.com/glzhangzhi) (per-session SSH pool fix)
293
+
267
294
  ## Changelog
268
295
 
269
296
  See [CHANGELOG.md](./CHANGELOG.md).
package/README.zh.md CHANGED
@@ -4,6 +4,8 @@
4
4
 
5
5
  # dsh-remote
6
6
 
7
+ 由 [@flymysql](https://github.com/flymysql) 维护 · [博客](https://gitpull.cn) · [讨论区](https://github.com/flymysql/dsh-remote/discussions) · [Issue](https://github.com/flymysql/dsh-remote/issues) · [English](./README.md)
8
+
7
9
  ## 官方 Desktop 兼容适配(实验性,尚未发布)
8
10
 
9
11
  本分支增加对 [DeepSeek 官方 Desktop](https://github.com/deepseek-ai/deepseek-harness)
@@ -26,6 +28,8 @@ Desktop 安装器还可能要求明确配置 `ssh2` / `cpu-features` 可选构
26
28
  隔离验证中禁用了这些可选脚本;本改动不放宽应用的构建白名单,也不自动批准脚本。
27
29
 
28
30
  [![npm version](https://img.shields.io/npm/v/dsh-remote)](https://www.npmjs.com/package/dsh-remote)
31
+ [![downloads](https://img.shields.io/npm/dw/dsh-remote)](https://www.npmjs.com/package/dsh-remote)
32
+ [![downloads](https://img.shields.io/npm/dm/dsh-remote)](https://www.npmjs.com/package/dsh-remote)
29
33
  [![license](https://img.shields.io/github/license/flymysql/dsh-remote)](LICENSE)
30
34
  [![dsh-plugin](https://img.shields.io/badge/topic-dsh--plugin-7a)](https://github.com/topics/dsh-plugin)
31
35
 
@@ -56,12 +60,13 @@ DSH 的 Web 界面刻意只监听 `127.0.0.1`(CLI 为安全拒绝 `--host 0.0.
56
60
  ## 功能
57
61
 
58
62
  - **多机 SSH** —— 可存任意多台主机(host/port/user + **私钥**或**密码**)。密码只存在本地,界面不回显;在设置里一键切当前机。每机可配 passphrase / 主机指纹策略 / SSH agent / keyboard-interactive(OTP)/ 跳板机,以及可选的 **系统钥匙串加密密码**。
59
- - **`~/.ssh/config` 导入** —— 设置页列出 Host 别名,一点填入表单(只引用路径,不读密钥材料)。
63
+ - **`~/.ssh/config` 别名(实时解析,不存副本)** —— 机器可以只保存一个 **Host 别名**(`useSshConfig`):主机名/用户/端口/私钥/跳板机**每次连接都从 `~/.ssh/config` 实时解析**,改配置立刻生效、无需重新导入;注册表里**不存这些值的副本**(私钥只引用路径,永不读内容)。支持 OpenSSH 语义:`Host a b` 多别名、`*`/`?` 通配、`!` 取反、`Include`(含通配、相对 `~/.ssh`)、行尾 `\` 续行、以及 ssh_config(5) 的**首个取值优先**规则。设置页「从 ~/.ssh/config 导入」列表里点别名即按别名保存(也可以「复制字段」成普通机器);列表与机器行都会显示 **别名 → 实际解析到哪台机**,`ProxyJump` 多跳、`ProxyCommand` 等插件无法照做的事会**显式告警**而不是静默降级。
60
64
  - **双 tab 工作区选择器**(填充原生「Add workspace」流程):
61
65
  - **本机** —— 走 **host 端原生系统文件夹对话框**选本地目录(或直接输入本地路径)→ 直接成为普通 DSH 本地工作区(与本地工作区共存)。优先用 DSH 的 `directoryPicker` 服务,服务缺失时**回退到插件自持的原生选择器**(macOS `osascript` / Linux `zenity`→`kdialog`)——桌面启动路径上框架服务不注册也能用。
62
66
  - **远程** —— 选择器是**居中弹窗**(窄侧边栏也不会被挤压)。先**选机器** → Windows 主机根级显示 **「此电脑」多盘视图**(`C:\`、`D:\`、`E:\`…,而不是 Git Bash 的 MSYS 根),路径框**实时补全**目录(支持 `C:\Users\…` 或 `/c/Users/…` 任意写法,Windows 路径在底层自动改写为 Git Bash 形式);**选中一个目录立即列出它下一级**(OS/VSCode 式级联)。另有 **「浏览…」文件选择式浮层**(Windows 面包屑 `此电脑 / C:\ / Users / dev` 可点击跳级、驱动器行、大小/时间、跟随软链),选中**回填输入框不提交**,你复核/修改后再确定;「回上一级」任意深度可用(包括浮层直接打开在路径栏当前路径时)。**最近工作区**快捷入口、**`~` 主目录**、**新建目录**一键可达。确定会创建**真实本地镜像**(`$DSH_HOME/remote-workspaces/<host>-<user>-<port>/<basename>`;仅当同主机上**别的远端路径**已占用同名 basename 时才追加短路径 hash)→ harness 把它当真实工作区收养,同时 dsh-remote 通过 SFTP 保持同步。所选工作区会**持久化到该机器**,重启不丢。
63
67
  - **Git Bash 默认终端(Windows 主机)** —— 自动探测远程平台(`cmd /c ver`,附 `uname -s` 的 MINGW/MSYS 探测兜底);Windows 机器自动定位 Git Bash(`config.shell` 可显式指定或 `native` 关闭),所有命令经 `bash -s` 从 SSH 通道 stdin 管道执行,不依赖 cmd/PowerShell,也不受引号/反斜杠转义困扰;`rw_exec` 默认在 Git Bash 形式的 cwd(`/c/Users/…`)下执行。`/dsh-remote/status`、`rw_info`、设置页「测试连接」都会报告检测到的平台与 shell。
64
68
  - **Windows 路径自动改写** —— 用户输入 `C:\Users\dev\project`(或 `C:/…`、`/c/…`、`/C:/…`)时底层自动规范为 Git Bash 形式 `/c/Users/dev/project` 执行;工作区存储与展示为 Windows 形式 `C:\Users\dev\project`。模型工具全部接受并展示两种写法;SFTP 访问使用 Win32-OpenSSH 的 `/D:/…` 形式(见 `toSftpPath`)。
69
+ - **远程 `@` 补全(issue #39)** —— 远程会话里输入 `@` 会**列出远端目录树**(走 SFTP 实时读,不是本地镜像)。目录逐级下钻、无斜杠时在整棵树上模糊匹配,候选是**相对远程工作区根的路径**(`@src/main.c`),与本地会话的写法一致;`rw_*` 工具接受这种相对路径并自动拼到远程工作区根上。索引有预算保护(条目/目录/时限 + 缓存 + 失败熔断),**远端不可达时自动回退到本地镜像**(不会静默变成空列表)。本地会话完全不受影响。
65
70
  - **双向 SFTP 同步(三路冲突检测)** —— `rw_sync`(远程→镜像)、`rw_push`(镜像→远程)。两边都改过的文件会列出冲突、绝不静默覆盖(`force=true` 覆盖)。默认 **深度 8 / 2000 文件**,触顶会标明 **`TRUNCATED`**。支持 dry-run、后台任务、gitignore 风格 ignore 规则。
66
71
  - **模型工具(20 个)** —— `rw_info`、`rw_connect`、`rw_pick_workspace`、`rw_list_dir`、`rw_stat`、`rw_read_file`(utf-8/gbk)、`rw_write_file`、`rw_edit`(字面替换 + mtime 乐观锁)、`rw_append`、`rw_mkdir`、`rw_remove`、`rw_move`、`rw_exec`、`rw_search`(POSIX 优先 rg/grep,否则 SFTP 遍历)、`rw_download`/`rw_upload`、`rw_forward`、`rw_sync`、`rw_push`、`rw_disconnect`。
67
72
  - **端口转发面板** —— 设置页或 `rw_forward` 创建/启停本地与反向隧道。
@@ -212,6 +217,11 @@ scripts/dev-run.sh --status # 是否在运行
212
217
  | `connectTimeoutMs` | int | 15000 | SSH 连接超时 |
213
218
  | `maxFileBytes` | int | 52428800 | 镜像同步时跳过超过该大小的文件(0=不设上限) |
214
219
  | `hostKeyMode` | string | `accept-new` | 主机指纹策略:`accept-new`(首次信任)、`verify`(拒绝未知主机)、`off`(跳过校验) |
220
+ | `fileReference` | bool | `true` | 远程 `@` 补全:远程会话的 `@` 列出**远端**目录树(issue #39);关闭则只有本地镜像 |
221
+ | `fileReferenceMaxResults` | int | `20` | 一次 `@` 查询最多返回多少候选 |
222
+ | `fileReferenceMaxEntries` | int | `3000` | 一棵远程工作区索引最多保留多少条目 |
223
+ | `fileReferenceExcludedDirectories` | string[] | `[.git, node_modules, dist, build, out, coverage, target, .next, .nuxt, .turbo, .venv, __pycache__, .pytest_cache, .mypy_cache, .gradle]` | 远程 `@` 遍历跳过的目录名 |
224
+ | `fileReferenceTimeoutMs` | int | `4000` | 一次远程索引遍历的墙钟预算(超时用已扫到的部分结果,不让光标等) |
215
225
 
216
226
  ## 安全提醒
217
227
 
@@ -221,6 +231,21 @@ scripts/dev-run.sh --status # 是否在运行
221
231
 
222
232
  MIT
223
233
 
234
+ ## 参与贡献
235
+
236
+ 欢迎贡献,请先阅读 [CONTRIBUTING.md](./CONTRIBUTING.md)。使用问题、环境配置、「支持 XX 吗」这类讨论请走 [讨论区](https://github.com/flymysql/dsh-remote/discussions);可复现的缺陷请提 [Issue](https://github.com/flymysql/dsh-remote/issues)。
237
+
238
+ 感谢以下已合并 PR 的贡献者:
239
+
240
+ [@dahaipeng](https://github.com/dahaipeng) (#31) ·
241
+ [@YiHui-Liu](https://github.com/YiHui-Liu) (#28) ·
242
+ [@nekomona](https://github.com/nekomona) (#24) ·
243
+ [FoolishWiser](https://github.com/FoolishWiser) (#17) ·
244
+ [@jace1cch](https://github.com/jace1cch) (#16) ·
245
+ [@Minggle](https://github.com/Minggle) (#10) ·
246
+ [4FMTWRV](https://github.com/4FMTWRV) (#6) ·
247
+ [glzhangzhi](https://github.com/glzhangzhi)(per-session SSH 连接池修复)
248
+
224
249
  ## 变更记录
225
250
 
226
251
  见 [CHANGELOG.md](./CHANGELOG.md)。
package/lib/binding.js CHANGED
@@ -38,9 +38,11 @@ const isUnder = (base, dir) => base === dir || base.startsWith(dir + path.sep) |
38
38
  *
39
39
  * @param {string} local - absolute local path, typically a session's cwd.
40
40
  * @param {string} root - the mirror registry root (`$DSH_HOME/remote-workspaces`).
41
- * @returns {{mirrorDir: string|null, remotePath: string, machine: {host: string, port: number, username: string}|null}}
42
- * `machine` is the mirror-recorded origin, or null when `local` is not inside
43
- * a usable mirror (in which case `remotePath` is empty too).
41
+ * @returns {{mirrorDir: string|null, remotePath: string, machine: {host: string, port: number, username: string, alias: string}|null}}
42
+ * `machine` is the mirror-recorded origin (`alias` is the ~/.ssh/config alias
43
+ * it was created through, '' when the machine uses literal values), or null
44
+ * when `local` is not inside a usable mirror (in which case `remotePath` is
45
+ * empty too).
44
46
  */
45
47
  export function resolveMirror(local, root) {
46
48
  let mirrorDir = null
@@ -79,6 +81,10 @@ export function resolveMirror(local, root) {
79
81
  host: String(meta.host),
80
82
  port: Number(meta.port) || 22,
81
83
  username: String(meta.username || ''),
84
+ // The ~/.ssh/config alias this mirror was created through, when the
85
+ // machine was saved as one (issue #38): it keeps the mirror bound to
86
+ // its machine even if that alias's HostName/port later change.
87
+ alias: String(meta.alias || ''),
82
88
  }
83
89
  } catch { /* unparsable meta → not a usable binding */ }
84
90
  }
package/lib/client.js CHANGED
@@ -48,9 +48,19 @@ window.__ModuleLoader__.load({
48
48
  'settings.noActive': '当前没有活动远程机:保存的机器只是备用连接,不会自动进入会话上下文(本地会话不受影响)。',
49
49
  'settings.addMachine': '添加机器',
50
50
  'settings.editMachine': '编辑机器',
51
+ 'settings.badgeSshConfig': 'ssh cfg',
51
52
  'settings.sshImport': '从 ~/.ssh/config 导入',
52
53
  'settings.sshCollapse': '收起',
53
54
  'settings.sshNoEntries': '~/.ssh/config 里没有可导入的 Host 条目',
55
+ 'settings.sshUseAlias': '用别名(不复制)',
56
+ 'settings.sshCopyFields': '复制字段',
57
+ 'settings.sshAliasHint': '别名模式:机器只记住「{alias}」这个名字,主机/用户/端口/私钥/跳板每次连接都从 ~/.ssh/config 实时解析(改配置立刻生效)。',
58
+ 'settings.useSshConfig': '使用 ~/.ssh/config 别名(只存别名,不存副本)',
59
+ 'settings.useSshConfigHint': '留空或保持默认的字段(用户 root、端口 22、私钥、跳板)将从 ~/.ssh/config 实时解析;填了非默认值则覆盖。',
60
+ 'settings.sshResolvedTo': '→ {user}@{host}:{port}',
61
+ 'settings.sshResolvedNone': '~/.ssh/config 中没有匹配「{alias}」的 Host 条目',
62
+ 'settings.sshResolvedWarn': '注意:{msg}',
63
+ 'settings.sshAliasSaved': '已按别名保存(连接时解析)',
54
64
  'settings.name': '名称',
55
65
  'settings.host': '主机',
56
66
  'settings.port': '端口',
@@ -262,9 +272,19 @@ window.__ModuleLoader__.load({
262
272
  'settings.noActive': 'No active remote machine: saved machines are standby connections and never enter a session context automatically (local sessions are unaffected).',
263
273
  'settings.addMachine': 'Add machine',
264
274
  'settings.editMachine': 'Edit machine',
275
+ 'settings.badgeSshConfig': 'ssh cfg',
265
276
  'settings.sshImport': 'Import from ~/.ssh/config',
266
277
  'settings.sshCollapse': 'Collapse',
267
278
  'settings.sshNoEntries': 'No importable Host entries in ~/.ssh/config',
279
+ 'settings.sshUseAlias': 'Use alias (no copy)',
280
+ 'settings.sshCopyFields': 'Copy fields',
281
+ 'settings.sshAliasHint': 'Alias mode: the machine remembers only the name "{alias}"; host/user/port/key/jump host are resolved from ~/.ssh/config at every connect, so editing that file takes effect immediately.',
282
+ 'settings.useSshConfig': 'Use a ~/.ssh/config alias (keep the alias, not a copy)',
283
+ 'settings.useSshConfigHint': 'Fields left empty or at their default (user root, port 22, key, jump host) come from ~/.ssh/config at connect time; a non-default value overrides it.',
284
+ 'settings.sshResolvedTo': '→ {user}@{host}:{port}',
285
+ 'settings.sshResolvedNone': 'no ~/.ssh/config block matches "{alias}"',
286
+ 'settings.sshResolvedWarn': 'Heads-up: {msg}',
287
+ 'settings.sshAliasSaved': 'Saved as an alias (resolved on connect)',
268
288
  'settings.name': 'Name',
269
289
  'settings.host': 'Host',
270
290
  'settings.port': 'Port',
@@ -741,6 +761,9 @@ window.__ModuleLoader__.load({
741
761
  name: '', host: '', port: '22', username: 'root', password: '', privateKeyPath: '',
742
762
  passphrase: '', workspace: '', hostKeyMode: '', useAgent: false, keyboardInteractive: false,
743
763
  encryptPassword: false, proxyHost: '', proxyPort: '22', proxyUser: '', proxyPassword: '', proxyKey: '', id: '',
764
+ // Issue #38: alias mode — `host` is a ~/.ssh/config Host name and the
765
+ // resolved values are never copied into the registry.
766
+ useSshConfig: false,
744
767
  })
745
768
  const [busy, setBusy] = React.useState(false)
746
769
  const [msg, setMsg] = React.useState('')
@@ -762,12 +785,20 @@ window.__ModuleLoader__.load({
762
785
  api('POST', '/dsh-remote/test-connect', {
763
786
  host: form.host, port: Number(form.port) || 22, username: form.username || 'root',
764
787
  password: form.password, privateKeyPath: form.privateKeyPath, passphrase: form.passphrase,
788
+ useSshConfig: !!form.useSshConfig,
765
789
  proxy: form.proxyHost ? { host: form.proxyHost, port: Number(form.proxyPort) || 22, username: form.proxyUser || '', password: form.proxyPassword || '', privateKeyPath: form.proxyKey || '' } : undefined,
766
790
  })
767
791
  .then((r) => {
768
792
  if (r && r.ok) {
769
793
  const shellTag = r.platform === 'windows' ? (r.gitBash ? tr('settings.shellWin') : tr('settings.shellWinNoGit')) : (r.platform === 'posix' ? tr('settings.shellPosix') : '')
770
- setTst('ok'); setMsg(r.latencyMs != null ? tr('settings.connOkWith', { user: r.user, host: r.host, latency: r.latencyMs, shell: shellTag }) : tr('settings.connOk')); refresh()
794
+ setTst('ok'); setMsg(r.latencyMs != null ? tr('settings.connOkWith', { user: r.user, host: r.host, latency: r.latencyMs, shell: shellTag }) : tr('settings.connOk'))
795
+ // Issue #38: show where an alias resolved to (and what could not
796
+ // be honoured), instead of leaving the user guessing.
797
+ if (r.alias) {
798
+ setMsg((m) => m + ' ' + tr('settings.sshResolvedTo', { user: r.alias.user || '?', host: r.alias.host, port: r.alias.port }))
799
+ if (r.alias.warnings && r.alias.warnings.length) setErr(tr('settings.sshResolvedWarn', { msg: r.alias.warnings.join(' ') }))
800
+ }
801
+ refresh()
771
802
  }
772
803
  else { setTst('failing'); setErr((r && r.error) || tr('settings.connFail')) }
773
804
  })
@@ -832,13 +863,14 @@ window.__ModuleLoader__.load({
832
863
  proxyHost: (m.proxy && m.proxy.host) || '', proxyPort: String((m.proxy && m.proxy.port) || 22),
833
864
  proxyUser: (m.proxy && m.proxy.username) || '', proxyPassword: '', proxyKey: (m.proxy && m.proxy.privateKeyPath) || '',
834
865
  id: m.id,
866
+ useSshConfig: !!m.useSshConfig,
835
867
  })
836
868
  // 编辑时若该机器用了高级配置(私钥/agent/跳板/钥匙串等),自动展开折叠区
837
869
  if (m.privateKeyPath || m.useAgent || m.keyboardInteractive || m.workspace || (m.proxy && m.proxy.host) || (m.credentialBackend && m.credentialBackend !== 'plain')) {
838
870
  setAdvOpen(true)
839
871
  }
840
872
  }
841
- const clearForm = () => setForm({ name: '', host: '', port: '22', username: 'root', password: '', privateKeyPath: '', passphrase: '', workspace: '', hostKeyMode: '', useAgent: false, keyboardInteractive: false, encryptPassword: false, proxyHost: '', proxyPort: '22', proxyUser: '', proxyPassword: '', proxyKey: '', id: '' })
873
+ const clearForm = () => setForm({ name: '', host: '', port: '22', username: 'root', password: '', privateKeyPath: '', passphrase: '', workspace: '', hostKeyMode: '', useAgent: false, keyboardInteractive: false, encryptPassword: false, proxyHost: '', proxyPort: '22', proxyUser: '', proxyPassword: '', proxyKey: '', id: '', useSshConfig: false })
842
874
  const save = (action) => {
843
875
  setBusy(true); setErr(''); setMsg('')
844
876
  if (action === 'delete') {
@@ -849,7 +881,7 @@ window.__ModuleLoader__.load({
849
881
  action: form.id ? 'update' : 'add', id: form.id || undefined, name: form.name, host: form.host, port: Number(form.port) || 22,
850
882
  username: form.username, password: form.password, privateKeyPath: form.privateKeyPath, passphrase: form.passphrase,
851
883
  workspace: form.workspace, hostKeyMode: form.hostKeyMode, useAgent: form.useAgent, keyboardInteractive: form.keyboardInteractive,
852
- encryptPassword: form.encryptPassword,
884
+ encryptPassword: form.encryptPassword, useSshConfig: !!form.useSshConfig,
853
885
  proxy: form.proxyHost ? { host: form.proxyHost, port: Number(form.proxyPort) || 22, username: form.proxyUser || '', password: form.proxyPassword || '', privateKeyPath: form.proxyKey || '' } : undefined,
854
886
  }).then((r) => {
855
887
  refresh(); clearForm()
@@ -869,10 +901,25 @@ window.__ModuleLoader__.load({
869
901
  api('GET', '/dsh-remote/ssh-config').then((r) => setSshEntries(r && r.present ? (r.entries || []) : []))
870
902
  .catch((e) => setErr(tr('settings.sshReadFail', { msg: String((e && e.message) || e) })))
871
903
  }
904
+ // Issue #38, primary action: save the machine as an ALIAS. Nothing from
905
+ // ~/.ssh/config is copied into the registry — the host/user/port/key/jump
906
+ // host are re-read at every connect, so editing that file is enough.
907
+ const useSshAlias = (e) => {
908
+ setForm({
909
+ ...form,
910
+ name: e.host, host: e.host, port: '22', username: 'root',
911
+ privateKeyPath: '', passphrase: '', useSshConfig: true,
912
+ })
913
+ setSshEntries(null)
914
+ setMsg(tr('settings.sshAliasHint', { alias: e.host }))
915
+ }
916
+ // Legacy/escape hatch: materialise the current values as a normal machine
917
+ // (for users who want to override them individually).
872
918
  const applySshEntry = (e) => {
873
- setForm({ ...form,
919
+ setForm({
920
+ ...form,
874
921
  name: e.host, host: e.hostName || e.host, port: String(e.port || 22),
875
- username: e.user || 'root', privateKeyPath: e.identityFile || '',
922
+ username: e.user || 'root', privateKeyPath: e.identityFile || '', useSshConfig: false,
876
923
  })
877
924
  setSshEntries(null)
878
925
  setMsg(tr('settings.filledSsh'))
@@ -921,13 +968,21 @@ window.__ModuleLoader__.load({
921
968
  React.createElement('div', { style: { flex: 1, fontSize: 13, minWidth: 180 } },
922
969
  React.createElement('div', { style: { display: 'flex', gap: 6, alignItems: 'center', flexWrap: 'wrap' } },
923
970
  React.createElement('span', { style: { fontWeight: 600 } }, m.name),
924
- React.createElement('code', { style: { fontSize: 12, opacity: 0.8 } }, m.username + '@' + m.host + ':' + m.port),
971
+ React.createElement('code', { style: { fontSize: 12, opacity: 0.8 } },
972
+ m.useSshConfig ? (m.host + (m.sshConfigResolved && m.sshConfigResolved.matched ? ' ' + tr('settings.sshResolvedTo', { user: m.sshConfigResolved.username || '?', host: m.sshConfigResolved.host, port: m.sshConfigResolved.port }) : ''))
973
+ : (m.username + '@' + m.host + ':' + m.port)),
974
+ m.useSshConfig ? pill('', tr('settings.badgeSshConfig')) : null,
925
975
  m.id === currentId ? pill('is-ok', tr('settings.current')) : null,
926
976
  m.passwordSet ? pill('', tr('settings.badgePwd')) : null,
927
977
  (m.credentialBackend && m.credentialBackend !== 'plain') ? pill('', tr('settings.badgeKeychain')) : null,
928
978
  m.proxy && m.proxy.host ? pill('', tr('settings.badgeProxy')) : null,
929
979
  m.latencyMs != null ? React.createElement('span', { style: { fontSize: 11, opacity: 0.6 } }, m.latencyMs + 'ms') : null,
930
- )),
980
+ ),
981
+ // Issue #38: an alias that no longer resolves (or a ProxyJump
982
+ // the plugin had to trim) must be visible, not silent.
983
+ (m.sshConfigResolved && m.sshConfigResolved.warnings && m.sshConfigResolved.warnings.length)
984
+ ? React.createElement('div', { style: { fontSize: 11, color: T.warn, marginTop: 2 } }, tr('settings.sshResolvedWarn', { msg: m.sshConfigResolved.warnings.join(' ') }))
985
+ : null),
931
986
  React.createElement('div', { style: { display: 'flex', gap: 6, flexShrink: 0 } },
932
987
  React.createElement('button', { type: 'button', className: 'dsh-rw-btn', style: buttonS, onClick: () => startEdit(m) }, tr('settings.edit')),
933
988
  React.createElement('button', { type: 'button', className: 'dsh-rw-btn', style: buttonS, onClick: () => del(m.id) }, tr('settings.delete')),
@@ -943,13 +998,30 @@ window.__ModuleLoader__.load({
943
998
  React.createElement('div', { style: { display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 6 } },
944
999
  React.createElement('div', { style: { fontSize: 13, fontWeight: 600 } }, form.id ? tr('settings.editMachine') : tr('settings.addMachine')),
945
1000
  React.createElement('button', { type: 'button', className: 'dsh-rw-btn', style: { ...buttonS, fontSize: 12, padding: '3px 8px' }, onClick: () => (sshEntries ? setSshEntries(null) : importSsh()) }, sshEntries ? tr('settings.sshCollapse') : tr('settings.sshImport'))),
946
- sshEntries ? React.createElement('div', { style: { marginBottom: 8, maxHeight: 160, overflowY: 'auto', border: '1px solid ' + T.border, borderRadius: T.radius } },
947
- sshEntries.length ? sshEntries.map((e) => React.createElement('div', { key: e.host, className: 'dsh-rw-list-item', onClick: () => applySshEntry(e), style: { padding: '6px 10px', cursor: 'pointer', fontSize: 12, borderBottom: '1px solid ' + T.border } },
948
- e.host + ' → ' + (e.hostName || '?') + (e.user ? ' (' + e.user + ')' : '') + (e.identityFile ? ' [key]' : '') + (e.proxyJump ? ' ⛳' : '')))
1001
+ sshEntries ? React.createElement('div', { style: { marginBottom: 8, maxHeight: 200, overflowY: 'auto', border: '1px solid ' + T.border, borderRadius: T.radius } },
1002
+ sshEntries.length ? sshEntries.map((e) => React.createElement('div', { key: e.host, className: 'dsh-rw-list-item', style: { padding: '6px 10px', fontSize: 12, borderBottom: '1px solid ' + T.border, display: 'flex', gap: 8, alignItems: 'center' } },
1003
+ React.createElement('div', { onClick: () => useSshAlias(e), style: { flex: 1, cursor: 'pointer' }, title: tr('settings.sshUseAlias') },
1004
+ e.host + ' → ' + (e.hostName || '?') + (e.user ? ' (' + e.user + ')' : '') + (e.identityFile ? ' [key]' : '') + (e.proxyJump ? ' ⛳' : '')),
1005
+ React.createElement('button', { type: 'button', className: 'dsh-rw-btn', style: { ...buttonS, fontSize: 11, padding: '2px 8px', whiteSpace: 'nowrap' }, onClick: () => useSshAlias(e) }, tr('settings.sshUseAlias')),
1006
+ React.createElement('button', { type: 'button', className: 'dsh-rw-btn', style: { ...buttonS, fontSize: 11, padding: '2px 8px', whiteSpace: 'nowrap', opacity: 0.8 }, onClick: () => applySshEntry(e) }, tr('settings.sshCopyFields'))))
949
1007
  : React.createElement('div', { style: { padding: 8, fontSize: 12, opacity: 0.6 } }, tr('settings.sshNoEntries')))
950
1008
  : null,
951
1009
  row(tr('settings.name'), React.createElement('input', { className: 'dsh-rw-input', value: form.name, onChange: setF('name'), placeholder: tr('settings.name') + ' (e.g. build-server)', style: inputS }), 'n'),
952
- row(tr('settings.host'), React.createElement('input', { className: 'dsh-rw-input', value: form.host, onChange: setF('host'), placeholder: 'IP or hostname', style: inputS }), 'h'),
1010
+ row(tr('settings.host'), React.createElement('input', { className: 'dsh-rw-input', value: form.host, onChange: setF('host'), placeholder: form.useSshConfig ? tr('settings.sshImport') + ' Host 别名' : 'IP or hostname', style: inputS }), 'h'),
1011
+ row(tr('settings.useSshConfig'), React.createElement('input', { type: 'checkbox', checked: !!form.useSshConfig, onChange: setFb('useSshConfig') }), 'sshc'),
1012
+ form.useSshConfig ? React.createElement('div', { style: { fontSize: 11, opacity: 0.8, marginBottom: 8, lineHeight: 1.5 } }, tr('settings.useSshConfigHint')) : null,
1013
+ form.useSshConfig ? (() => {
1014
+ // Preview where the alias points, from the same resolution the
1015
+ // connect path uses (the host list carries it — issue #38).
1016
+ const preview = machines.find((m) => m.host === form.host && m.useSshConfig)
1017
+ const res = preview && preview.sshConfigResolved
1018
+ if (!res) return null
1019
+ return React.createElement('div', { style: { fontSize: 11, marginBottom: 8, color: res.matched ? T.ok : T.warn, lineHeight: 1.5 } },
1020
+ res.matched
1021
+ ? form.host + ' ' + tr('settings.sshResolvedTo', { user: res.username || '?', host: res.host, port: res.port })
1022
+ : tr('settings.sshResolvedNone', { alias: form.host }),
1023
+ (res.warnings && res.warnings.length) ? React.createElement('div', { style: { opacity: 0.85 } }, tr('settings.sshResolvedWarn', { msg: res.warnings.join(' ') })) : null)
1024
+ })() : null,
953
1025
  row(tr('settings.port'), React.createElement('input', { className: 'dsh-rw-input', value: form.port, onChange: setF('port'), placeholder: '22', style: { ...inputS, width: 70 } }), 'p'),
954
1026
  row(tr('settings.user'), React.createElement('input', { className: 'dsh-rw-input', value: form.username, onChange: setF('username'), placeholder: 'root', style: inputS }), 'u'),
955
1027
  row(tr('settings.password'), React.createElement('input', { className: 'dsh-rw-input', type: 'password', value: form.password, onChange: setF('password'), placeholder: 'SSH password (hidden)', style: inputS }), 'w'),