@zhangfengshun/dsh-remote-ssh 1.4.0 → 1.5.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.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,14 @@
2
2
 
3
3
  本文件的版本号与 `package.json` 的 `version` 保持一致。每个版本对应一个 Cordis Package 快照(`pkg-N`)。
4
4
 
5
+ ## [1.5.0] — 导入 OpenSSH 配置 + 双向同步
6
+ ### 新增
7
+ - **从 `~/.ssh/config` 导入连接**:设置小节「从 ~/.ssh/config 导入…」按钮读取本机 OpenSSH 用户配置(递归解析 `Include`),多选主机后批量导入为连接配置(`HostName` / `User` / `Port` / `IdentityFile` / `ProxyJump` 一并带入)。借鉴 Yan-Zero `dsh-remote-ssh`,免去逐条手动录入。
8
+ - **跳板机(ProxyJump)**:连接配置新增可选 `ProxyJump` 字段,`sshArgv` 追加 `-o ProxyJump=...`,OpenSSH 原生支持;导入的带跳板机主机开箱即用。
9
+ - **tar-over-ssh 双向同步**:新增 `syncDown`(远端 → 本地镜像)/ `syncUp`(本地镜像 → 远端)HTTP API 与 `remote_ssh_sync` / `remote_ssh_push` 模型工具,用流式管道把一条 ssh 的 stdout 喂给本地 `tar` 的 stdin(反之亦然),不经过 4MB 缓冲上限。借鉴 flymysql `dsh-remote` 的 `rw_sync` / `rw_push`,但复用现有 `ssh.exe` + `tar`,不引入 `ssh2` 依赖。
10
+ - 「远程文件」页签在选中工作区时显示「⬇ 同步 / ⬆ 推送」按钮,同步后镜像目录装下真实远程文件,内置 `read`/`grep`/`glob` 工具可直接读取镜像。
11
+ - `remote_ssh_sync` / `remote_ssh_push` 工具会话感知:在远程工作区会话中无需 `workspaceId`,自动识别当前工作区。
12
+
5
13
  ## [1.4.0] — 远程文件单开一栏预览
6
14
  ### 新增
7
15
  - 点击「远程文件」文件树中的文件,改为在 better-sidebar 中**单开一栏(独立页签)预览/编辑**,与内置「文件」页签一致,不再在文件树下方内嵌预览。新增隐藏页签类型 `remssh:editor`,同一远程文件按 (profileId, path) 去重复用。
package/README.md CHANGED
@@ -8,11 +8,13 @@
8
8
 
9
9
  | 能力 | 说明 |
10
10
  | --- | --- |
11
- | 🔌 远程连接 | SSH 连接超算 / 服务器,密钥认证(推荐)或密码认证(需本机 `sshpass`),内置「测试连接」 |
12
- | 🗂️ 远程文件 | better-sidebar「远程文件」页签浏览 / 打开 / 编辑 / 保存远程文件 |
11
+ | 🔌 远程连接 | SSH 连接超算 / 服务器,密钥认证(推荐)或密码认证(需本机 `sshpass`),内置「测试连接」;支持 `ProxyJump` 跳板机 |
12
+ | 📥 配置导入 | 一键从 `~/.ssh/config`(递归 `Include`)发现主机并批量导入连接配置 |
13
+ | 🗂️ 远程文件 | 在 better-sidebar「远程文件」页签浏览 / 打开 / 编辑 / 保存远程文件;选中工作区后可双向同步 |
13
14
  | 💻 远程终端 | 在 better-sidebar「远程终端」页签打开 `ssh -tt` 集成终端,支持多终端并发 |
15
+ | 🔄 双向同步 | `remote_ssh_sync`(远端 → 本地镜像)/ `remote_ssh_push`(本地镜像 → 远端),tar 流式管道,同步后内置文件工具可见 |
14
16
  | 🌐 远程工作区 | 选择远程目录创建**原生工作区**(本地镜像目录 + 原生注册),一键打开进入远程环境 |
15
- | 🤖 模型工具 | 5 个 `remote_ssh_*` 工具,模型可读写远程文件、执行远程命令;在远程工作区会话中免填连接参数 |
17
+ | 🤖 模型工具 | 7 个 `remote_ssh_*` 工具,模型可读写远程文件、执行远程命令、双向同步;在远程工作区会话中免填连接参数 |
16
18
  | 🌍 国际化 | 界面文案 + 工具描述中英双语,通过 `ctx.locale` 跟随 DSH 语言设置自动切换 |
17
19
  | 🧠 会话记忆 | 切换会话后,「远程文件」「远程终端」页签记住已选连接、浏览目录、打开的文件与终端 |
18
20
 
@@ -27,7 +29,7 @@ dsh plugin --profile <name> add /absolute/path/to/dsh-remote-ssh
27
29
  ### 发布后安装
28
30
 
29
31
  ```bash
30
- dsh plugin --profile <name> add @zhangfengshun/dsh-remote-ssh@1.2.0
32
+ dsh plugin --profile <name> add @zhangfengshun/dsh-remote-ssh@1.5.0
31
33
  ```
32
34
 
33
35
  > ⚠️ 安装后需**重启 DSH** 才生效;后续仅修改 Client 半边时刷新浏览器即可。
@@ -86,6 +88,8 @@ dsh plugin --profile <name> add @zhangfengshun/dsh-remote-ssh@1.2.0
86
88
  | `remote_ssh_ls` | 列举远程目录 |
87
89
  | `remote_ssh_cat` | 读取远程文本文件(base64 传输,二进制安全) |
88
90
  | `remote_ssh_write` | 写入远程文件(覆盖写入) |
91
+ | `remote_ssh_sync` | 远端文件同步到本地镜像目录(tar 流式管道) |
92
+ | `remote_ssh_push` | 本地镜像目录推送回远端(tar 流式管道) |
89
93
 
90
94
  **会话感知**:当会话是从远程工作区创建时,模型调用这些工具可**不填** `profileId` / `host` / `user` 等连接参数,自动复用该工作区的连接,相对路径基于该工作区远程目录解析。
91
95
 
@@ -112,7 +116,7 @@ dsh-remote-ssh/
112
116
  │ · 远程文件 / 远程终端 │ ◀────── │ · SSH:listDir/readFile/… │
113
117
  │ ctx.slots: settings.section │ JSON │ · settings:profiles/workspaces │
114
118
  │ · DSH 设置页「远程连接」 │ │ · terminals:ssh -tt 管道 │
115
- └───────────────────────────────┘ │ ctx.tools:5 个 remote_ssh_* │
119
+ └───────────────────────────────┘ │ ctx.tools:7 个 remote_ssh_* │
116
120
  └───────────────┬──────────────────┘
117
121
  │ SSH
118
122
  远程超算 / 服务器
@@ -132,15 +136,16 @@ dsh-remote-ssh/
132
136
  1. **终端非本地 PTY**:走 `ssh -tt` 管道通道(跨平台可用);`vim` / `htop` 等全屏程序体验受限,后续可接入 `terminals.registerBackend` + `node-pty` 真 PTY。
133
137
  2. **文件操作依赖 GNU 工具**(`find -printf`、`base64 -w0`):目标为 Linux 超算时通用。
134
138
  3. **密码认证需本机 `sshpass`**(Windows 默认没有);密钥认证无此依赖。
135
- 4. **无大文件上传 / 下载**:当前读 / 写为文本流,可后续加 `scp` / `sftp`。
136
- 5. **镜像目录对内置文件工具不可见**:远程工作区的本地镜像目录是注册占位,模型对远程文件应使用 `remote_ssh_*` 工具(镜像目录内已放置说明文件)。
139
+ 4. **同步为全量 tar**:`remote_ssh_sync` / `remote_ssh_push` `tar` 流式全量同步,单次同步大目录耗时与传输量较高,后续可换 `rsync -e ssh` 增量。
140
+ 5. **原生工具编辑的是本地镜像副本**,不会自动回传 —— 须点「推送」或调用 `remote_ssh_push` 同步回远端(与 `dsh-remote` 的 `rw_sync` / `rw_push` 模型一致)。
137
141
 
138
142
  ## 路线图
139
143
 
144
+ - [x] 从 `~/.ssh/config` 自动导入连接
145
+ - [x] ProxyJump / 跳板机、SSH config 复用
146
+ - [x] `tar`-over-ssh 双向同步(`scp` / `sftp` 增量同步为后续优化)
147
+ - [ ] `rsync -e ssh` 增量同步
140
148
  - [ ] 真 PTY 终端(`terminals.registerBackend` + `node-pty`)
141
- - [ ] `scp` / `sftp` 上传下载
142
- - [ ] 从 `~/.ssh/config` 自动导入连接
143
- - [ ] ProxyJump / 跳板机、SSH config 复用
144
149
  - [ ] 密码凭据走 `credentials` 服务(避免明文进 argv)
145
150
 
146
151
  ## 许可证
package/lib/client.js CHANGED
@@ -73,6 +73,7 @@ window.__ModuleLoader__.load({
73
73
  "settings.keyPathPlaceholder": "~/.ssh/id_rsa",
74
74
  "settings.passwordField": "密码 password",
75
75
  "settings.remoteRoot": "远程根目录 remoteRoot",
76
+ "settings.proxyJump": "跳板机 ProxyJump",
76
77
  "settings.test": "测试连接",
77
78
  "settings.saved": "已保存",
78
79
  "settings.connOk": "连接成功",
@@ -115,7 +116,17 @@ window.__ModuleLoader__.load({
115
116
  "flow.remoteDir": "远程目录",
116
117
  "flow.chooseRemote": "选择远程目录…",
117
118
  "flow.backLocal": "← 选择本地目录",
118
- "flow.errCreate": "创建远程工作区失败"
119
+ "flow.errCreate": "创建远程工作区失败",
120
+ "settings.importSshConfig": "从 ~/.ssh/config 导入…",
121
+ "settings.importTitle": "导入 ~/.ssh/config 主机",
122
+ "settings.noneFound": "未在 ~/.ssh/config 中发现可用主机",
123
+ "settings.imported": "已导入 {n} 个连接",
124
+ "sync.down": "⬇ 同步",
125
+ "sync.up": "⬆ 推送",
126
+ "sync.syncing": "同步中…",
127
+ "sync.pushing": "推送中…",
128
+ "sync.done": "同步完成",
129
+ "sync.fail": "同步失败"
119
130
  };
120
131
  var en = {
121
132
  "tabs.files": "Remote Files",
@@ -138,6 +149,7 @@ window.__ModuleLoader__.load({
138
149
  "settings.keyPathPlaceholder": "~/.ssh/id_rsa",
139
150
  "settings.passwordField": "Password",
140
151
  "settings.remoteRoot": "Remote root (remoteRoot)",
152
+ "settings.proxyJump": "ProxyJump (jump host)",
141
153
  "settings.test": "Test Connection",
142
154
  "settings.saved": "Saved",
143
155
  "settings.connOk": "Connected",
@@ -180,7 +192,17 @@ window.__ModuleLoader__.load({
180
192
  "flow.remoteDir": "Remote directory",
181
193
  "flow.chooseRemote": "Choose remote directory…",
182
194
  "flow.backLocal": "← Choose local directory",
183
- "flow.errCreate": "Failed to create remote workspace"
195
+ "flow.errCreate": "Failed to create remote workspace",
196
+ "settings.importSshConfig": "Import from ~/.ssh/config…",
197
+ "settings.importTitle": "Import ~/.ssh/config hosts",
198
+ "settings.noneFound": "No usable hosts found in ~/.ssh/config",
199
+ "settings.imported": "Imported {n} connection(s)",
200
+ "sync.down": "⬇ Sync",
201
+ "sync.up": "⬆ Push",
202
+ "sync.syncing": "Syncing…",
203
+ "sync.pushing": "Pushing…",
204
+ "sync.done": "Sync complete",
205
+ "sync.fail": "Sync failed"
184
206
  };
185
207
  var i18n = {
186
208
  t: function (key) { return key; },
@@ -267,6 +289,7 @@ window.__ModuleLoader__.load({
267
289
  var [err, setErr] = React.useState(null);
268
290
  var [loading, setLoading] = React.useState(false);
269
291
  var [defaultDir, setDefaultDir] = useSessionMemory(sessionId, "defaultDir", "");
292
+ var [syncSt, setSyncSt] = React.useState(null); // { busy, msg, ok }
270
293
 
271
294
  React.useEffect(function () {
272
295
  api("listWorkspaces").then(function (r) { if (r && r.ok) setWorkspaces(r.workspaces || []); });
@@ -312,6 +335,18 @@ window.__ModuleLoader__.load({
312
335
  });
313
336
  }
314
337
 
338
+ function doSync(direction) {
339
+ if (!wsId) return;
340
+ var ws = workspaces.find(function (w) { return w.id === wsId; });
341
+ if (!ws) return;
342
+ setSyncSt({ busy: true, msg: direction === "down" ? t("sync.syncing") : t("sync.pushing"), ok: null });
343
+ var method = direction === "down" ? "syncDown" : "syncUp";
344
+ api(method, { workspaceId: wsId }).then(function (r) {
345
+ if (r && r.ok) setSyncSt({ busy: false, msg: direction === "down" ? t("sync.done") : t("sync.done"), ok: true });
346
+ else setSyncSt({ busy: false, msg: (r && r.error) || t("sync.fail"), ok: false });
347
+ });
348
+ }
349
+
315
350
  function openEntry(e) {
316
351
  var base = path ? (path.replace(/\/+$/, "") + "/" + e.name) : e.name;
317
352
  if (e.type === "directory") {
@@ -368,6 +403,11 @@ window.__ModuleLoader__.load({
368
403
  h("button", { className: "rssh-btn", onClick: function () { load(profileId, path); } }, t("common.open")),
369
404
  h("button", { className: "rssh-btn", onClick: goUp }, t("common.up"))
370
405
  ),
406
+ wsId ? h("div", { className: "rssh-pathbar" },
407
+ h("button", { className: "rssh-btn", onClick: function () { doSync("down"); }, disabled: syncSt && syncSt.busy }, t("sync.down")),
408
+ h("button", { className: "rssh-btn", onClick: function () { doSync("up"); }, disabled: syncSt && syncSt.busy }, t("sync.up")),
409
+ syncSt ? h("span", { className: syncSt.ok === false ? "rssh-err" : (syncSt.ok ? "rssh-ok" : "rssh-muted"), style: { flex: 1, fontSize: 11 } }, syncSt.msg) : null
410
+ ) : null,
371
411
  loading ? h("div", { className: "rssh-muted" }, t("common.loading")) : null,
372
412
  err ? h("div", { className: "rssh-err", style: { whiteSpace: "pre-wrap" } }, err) : null
373
413
  ),
@@ -666,9 +706,10 @@ window.__ModuleLoader__.load({
666
706
  function SettingsSection(props) {
667
707
  var t = useT();
668
708
  var [profiles, setProfiles] = React.useState([]);
669
- var [form, setForm] = React.useState({ name: "", host: "", port: "22", user: "", authMethod: "key", keyPath: "", password: "", remoteRoot: "~" });
709
+ var [form, setForm] = React.useState({ name: "", host: "", port: "22", user: "", authMethod: "key", keyPath: "", password: "", remoteRoot: "~", proxyJump: "" });
670
710
  var [result, setResult] = React.useState(null);
671
711
  var [busy, setBusy] = React.useState(false);
712
+ var [importDlg, setImportDlg] = React.useState(null);
672
713
  var set = function (k, v) { setForm(function (f) { var n = {}; for (var key in f) n[key] = f[key]; n[k] = v; return n; }); };
673
714
 
674
715
  function reload() { api("listProfiles").then(function (r) { if (r && r.ok) setProfiles(r.profiles || []); }); }
@@ -680,7 +721,7 @@ window.__ModuleLoader__.load({
680
721
  setBusy(false);
681
722
  if (r && r.ok) {
682
723
  setProfiles(r.profiles || []);
683
- setForm({ name: "", host: "", port: "22", user: "", authMethod: "key", keyPath: "", password: "", remoteRoot: "~" });
724
+ setForm({ name: "", host: "", port: "22", user: "", authMethod: "key", keyPath: "", password: "", remoteRoot: "~", proxyJump: "" });
684
725
  setResult({ ok: true, msg: t("settings.saved") });
685
726
  } else setResult({ ok: false, msg: (r && r.error) || t("settings.errSave") });
686
727
  });
@@ -697,6 +738,47 @@ window.__ModuleLoader__.load({
697
738
  api("deleteProfile", { id: p.id }).then(function (r) { if (r && r.ok) { setProfiles(r.profiles || []); } });
698
739
  }
699
740
 
741
+ function openImport() {
742
+ setImportDlg({ loading: true, hosts: [], selected: {} });
743
+ api("listSshConfigHosts").then(function (r) {
744
+ if (r && r.ok) {
745
+ var hosts = r.hosts || [];
746
+ var sel = {};
747
+ hosts.forEach(function (h) { sel[h.name + "|" + h.hostName] = true; });
748
+ setImportDlg({ loading: false, hosts: hosts, selected: sel });
749
+ } else {
750
+ setImportDlg({ loading: false, hosts: [], selected: {}, error: (r && r.error) || t("settings.noneFound") });
751
+ }
752
+ });
753
+ }
754
+ function toggleImport(key) {
755
+ setImportDlg(function (d) {
756
+ var sel = {}; for (var k in d.selected) sel[k] = d.selected[k];
757
+ sel[key] = !sel[key];
758
+ return Object.assign({}, d, { selected: sel });
759
+ });
760
+ }
761
+ function doImport() {
762
+ if (!importDlg) return;
763
+ var picked = importDlg.hosts.filter(function (h) { return importDlg.selected[h.name + "|" + h.hostName]; });
764
+ setBusy(true);
765
+ var i = 0;
766
+ function next() {
767
+ if (i >= picked.length) {
768
+ setBusy(false); setImportDlg(null);
769
+ setResult({ ok: true, msg: t("settings.imported").replace("{n}", String(picked.length)) });
770
+ reload();
771
+ return;
772
+ }
773
+ var h = picked[i++];
774
+ api("saveProfile", {
775
+ name: h.name, host: h.hostName, port: h.port || 22, user: h.user || "",
776
+ authMethod: "key", keyPath: h.identityFile || "", password: "", remoteRoot: "~", proxyJump: h.proxyJump || ""
777
+ }).then(next);
778
+ }
779
+ next();
780
+ }
781
+
700
782
  return h("div", null,
701
783
  h("p", { className: "rssh-muted" }, t("settings.desc")),
702
784
  profiles.map(function (p) {
@@ -726,9 +808,33 @@ window.__ModuleLoader__.load({
726
808
  form.authMethod === "key" ? Field({ label: t("settings.keyPath"), value: form.keyPath, onChange: function (v) { set("keyPath", v); }, placeholder: t("settings.keyPathPlaceholder") }) : null,
727
809
  form.authMethod === "password" ? Field({ label: t("settings.passwordField"), type: "password", value: form.password, onChange: function (v) { set("password", v); } }) : null,
728
810
  Field({ label: t("settings.remoteRoot"), value: form.remoteRoot, onChange: function (v) { set("remoteRoot", v); }, placeholder: "~/project" }),
729
- h("button", { className: "rssh-btn", onClick: save, disabled: busy }, busy ? t("common.processing") : t("common.save"))
811
+ Field({ label: t("settings.proxyJump"), value: form.proxyJump, onChange: function (v) { set("proxyJump", v); }, placeholder: "bastion.example.com" }),
812
+ h("div", { className: "rssh-actions" },
813
+ h("button", { className: "rssh-btn", onClick: save, disabled: busy }, busy ? t("common.processing") : t("common.save")),
814
+ h("button", { className: "rssh-btn", onClick: openImport, disabled: busy }, t("settings.importSshConfig"))
815
+ )
730
816
  ),
731
- result ? h("div", { className: result.ok ? "rssh-ok" : "rssh-err", style: { whiteSpace: "pre-wrap", marginTop: 6 } }, result.msg) : null
817
+ result ? h("div", { className: result.ok ? "rssh-ok" : "rssh-err", style: { whiteSpace: "pre-wrap", marginTop: 6 } }, result.msg) : null,
818
+ importDlg ? h(Modal, { open: true, onClose: function () { if (!busy) setImportDlg(null); }, title: t("settings.importTitle"), closeLabel: t("common.close") },
819
+ importDlg.loading ? h("div", { className: "rssh-muted" }, t("common.loading")) :
820
+ importDlg.error ? h("div", { className: "rssh-err" }, importDlg.error) :
821
+ (importDlg.hosts.length === 0 ? h("div", { className: "rssh-muted" }, t("settings.noneFound")) :
822
+ h("div", null,
823
+ importDlg.hosts.map(function (hh) {
824
+ var key = hh.name + "|" + hh.hostName;
825
+ return h("label", { key: key, className: "rssh-tree-item", style: { cursor: "pointer" } },
826
+ h("input", { type: "checkbox", checked: !!importDlg.selected[key], onChange: function () { toggleImport(key); } }),
827
+ h("span", { className: "rssh-tree-dir" }, hh.name),
828
+ h("span", { className: "rssh-sub" }, (hh.user || "?") + "@" + hh.hostName + ":" + (hh.port || 22) + (hh.proxyJump ? " → " + hh.proxyJump : ""))
829
+ );
830
+ }),
831
+ h("div", { className: "rssh-actions", style: { marginTop: 8 } },
832
+ h("button", { className: "rssh-btn", onClick: doImport, disabled: busy }, busy ? t("common.processing") : t("common.save")),
833
+ h("button", { className: "rssh-btn", onClick: function () { setImportDlg(null); }, disabled: busy }, t("common.close"))
834
+ )
835
+ )
836
+ )
837
+ ) : null
732
838
  );
733
839
  }
734
840
 
package/lib/index.js CHANGED
@@ -11,7 +11,7 @@
11
11
  import z from "schemastery";
12
12
  import { defineTool } from "@deepseek-ai/dsh-tools";
13
13
  import { settingsNamespace } from "@deepseek-ai/dsh-settings";
14
- import { mkdir, rm, writeFile } from "node:fs/promises";
14
+ import { mkdir, rm, writeFile, readFile } from "node:fs/promises";
15
15
  import { homedir } from "node:os";
16
16
  import { join } from "node:path";
17
17
 
@@ -36,7 +36,8 @@ const ProfileSchema = z.object({
36
36
  authMethod: z.string().default("key"),
37
37
  keyPath: z.string().default(""),
38
38
  password: z.string().default("").role("secret"),
39
- remoteRoot: z.string().default("~")
39
+ remoteRoot: z.string().default("~"),
40
+ proxyJump: z.string().default("")
40
41
  });
41
42
  /** 远程工作区 schema(连接配置 + 远程根目录 + 本地镜像目录)。 */
42
43
  const WorkspaceSchema = z.object({
@@ -68,6 +69,92 @@ function shellQuotePath(s) {
68
69
  return shellQuote(str);
69
70
  }
70
71
 
72
+ /**
73
+ * 解析 OpenSSH 用户配置(~/.ssh/config),递归处理 Include,发现其中的具体主机。
74
+ * 借鉴 Yan-Zero dsh-remote-ssh:自动读取 OpenSSH 配置发现主机,免去手动录入。
75
+ * 返回 [{ name, hostName, user, port, identityFile, proxyJump }]。
76
+ */
77
+ function expandSshPath(raw) {
78
+ const s = String(raw || "").trim();
79
+ if (s === "~") return join(homedir());
80
+ if (s.startsWith("~/")) return join(homedir(), s.slice(2));
81
+ return s;
82
+ }
83
+
84
+ async function parseSshConfigFile(filePath, seen) {
85
+ const real = filePath;
86
+ if (seen.has(real)) return [];
87
+ seen.add(real);
88
+ let text;
89
+ try {
90
+ text = await readFile(filePath, "utf8");
91
+ } catch (e) {
92
+ return [];
93
+ }
94
+ const hosts = [];
95
+ let cur = null;
96
+ const lines = text.split(/\r?\n/);
97
+ for (let i = 0; i < lines.length; i++) {
98
+ const raw = lines[i];
99
+ const hash = raw.indexOf("#");
100
+ const line = (hash >= 0 ? raw.slice(0, hash) : raw).trim();
101
+ if (!line) continue;
102
+ const sp = line.search(/\s+/);
103
+ if (sp < 0) continue;
104
+ const key = line.slice(0, sp).toLowerCase();
105
+ const val = line.slice(sp).trim();
106
+ if (key === "host") {
107
+ // 多个名字空格分隔;含通配符 * / ? 的跳过
108
+ const names = val.split(/\s+/);
109
+ cur = null;
110
+ for (const n of names) {
111
+ if (n && !/[*?]/.test(n)) {
112
+ cur = { name: n, hostName: "", user: "", port: 22, identityFile: "", proxyJump: "" };
113
+ hosts.push(cur);
114
+ break;
115
+ }
116
+ }
117
+ } else if (cur) {
118
+ if (key === "hostname") cur.hostName = val;
119
+ else if (key === "user") cur.user = val;
120
+ else if (key === "port") { const pn = parseInt(val, 10); if (!isNaN(pn)) cur.port = pn; }
121
+ else if (key === "identityfile") cur.identityFile = expandSshPath(val);
122
+ else if (key === "proxyjump") cur.proxyJump = val;
123
+ else if (key === "include") {
124
+ // 递归 Include(相对 ~/.ssh/ 或绝对)
125
+ for (const inc of val.split(/\s+/)) {
126
+ if (!inc) continue;
127
+ const incPath = expandSshPath(inc);
128
+ const sub = await parseSshConfigFile(incPath, seen);
129
+ for (const h of sub) hosts.push(h);
130
+ }
131
+ }
132
+ }
133
+ }
134
+ return hosts;
135
+ }
136
+
137
+ async function listSshConfigHosts() {
138
+ const cfg = join(homedir(), ".ssh", "config");
139
+ const seen = new Set();
140
+ let hosts = [];
141
+ try {
142
+ hosts = await parseSshConfigFile(cfg, seen);
143
+ } catch (e) {
144
+ return { ok: false, error: "读取 ~/.ssh/config 失败: " + String(e && e.message ? e.message : e) };
145
+ }
146
+ // 过滤掉没有 hostName 的占位条目;保留 user 默认
147
+ const out = hosts.filter((h) => h.hostName).map((h) => ({
148
+ name: h.name,
149
+ hostName: h.hostName,
150
+ user: h.user || "",
151
+ port: h.port || 22,
152
+ identityFile: h.identityFile || "",
153
+ proxyJump: h.proxyJump || ""
154
+ }));
155
+ return { ok: true, hosts: out };
156
+ }
157
+
71
158
  function sshArgv(p, remoteCmd, tty) {
72
159
  const opts = ["ssh"];
73
160
  if (tty) opts.push("-tt");
@@ -76,6 +163,7 @@ function sshArgv(p, remoteCmd, tty) {
76
163
  opts.push("-o", "ConnectTimeout=15");
77
164
  opts.push("-o", "ServerAliveInterval=30");
78
165
  opts.push("-o", "ServerAliveCountMax=3");
166
+ if (p.proxyJump) opts.push("-o", "ProxyJump=" + String(p.proxyJump));
79
167
  if (p.authMethod === "key" && p.keyPath) opts.push("-i", p.keyPath);
80
168
  const target = String(p.user || "") + "@" + String(p.host || "");
81
169
  const head = (p.authMethod === "password" && p.password) ? ["sshpass", "-p", String(p.password)] : [];
@@ -171,6 +259,96 @@ async function remoteWriteFile(subprocess, p, path, content) {
171
259
  return await runRemote(subprocess, p, "cat > " + shellQuotePath(path), c, MAX_BYTES);
172
260
  }
173
261
 
262
+ /**
263
+ * tar-over-ssh 双向同步(借鉴 flymysql dsh-remote 的 SFTP 镜像同步,但复用现有 ssh.exe + tar,
264
+ * 不引入 ssh2 依赖)。用流式管道把一条 ssh 的 stdout 喂给本地 tar 的 stdin(反之亦然),
265
+ * 不经过 4MB Buffer 上限。
266
+ */
267
+ function spawnOne(subprocess, argv, stdio, cwd) {
268
+ return subprocess.spawn({ argv: argv, cwd: cwd || process.cwd(), stdio: stdio, graceMs: 60000 });
269
+ }
270
+
271
+ function readCollected(handle, name) {
272
+ if (!handle || !handle.collected || !handle.collected[name]) return "";
273
+ const r = handle.collected[name].readFrom(0);
274
+ return r ? r.text : "";
275
+ }
276
+
277
+ /** 远端 → 本地镜像:先清空 mirror,再 tar 展开到 mirror。 */
278
+ async function remoteSyncDown(subprocess, p, remotePath, mirrorPath) {
279
+ if (!remotePath) return { ok: false, error: "remotePath 为必填项" };
280
+ if (!mirrorPath) return { ok: false, error: "mirrorPath 为必填项" };
281
+ try { await rm(mirrorPath, { recursive: true, force: true }); } catch (e) {}
282
+ try { await mkdir(mirrorPath, { recursive: true }); } catch (e) {
283
+ return { ok: false, error: "创建镜像目录失败: " + String(e && e.message ? e.message : e) };
284
+ }
285
+ const COLLECT = { maxBytes: 1024 * 1024, spill: { maxBytes: 1024 * 1024 } };
286
+ let sshH, tarH;
287
+ try {
288
+ sshH = spawnOne(subprocess, sshArgv(p, "tar cf - -C " + shellQuotePath(remotePath) + " ."), {
289
+ stdin: "ignore", stdout: "pipe", stderr: COLLECT
290
+ });
291
+ tarH = spawnOne(subprocess, ["tar", "xf", "-", "-C", mirrorPath], {
292
+ stdin: "pipe", stdout: "ignore", stderr: COLLECT
293
+ });
294
+ } catch (e) {
295
+ return { ok: false, error: "spawn 失败: " + String(e && e.message ? e.message : e) };
296
+ }
297
+ let pipeErr = "";
298
+ try {
299
+ if (sshH.stdout && tarH.stdin) sshH.stdout.pipe(tarH.stdin);
300
+ else pipeErr = "stdout/stdin 管道不可用(subprocess 未暴露原始流)";
301
+ } catch (e) { pipeErr = String(e && e.message ? e.message : e); }
302
+ let sshOut, tarOut;
303
+ try { sshOut = await sshH.done; } catch (e) { sshOut = { exitCode: -1 }; }
304
+ try { tarOut = await tarH.done; } catch (e) { tarOut = { exitCode: -1 }; }
305
+ const sshErr = readCollected(sshH, "stderr");
306
+ const tarErr = readCollected(tarH, "stderr");
307
+ // ssh 正常退出且 tar 正常退出才算成功
308
+ if (sshOut.exitCode === 0 && tarOut.exitCode === 0) return { ok: true, mirrorPath: mirrorPath };
309
+ const parts = [];
310
+ if (pipeErr) parts.push("管道: " + pipeErr);
311
+ if (sshOut.exitCode !== 0) parts.push("ssh 退出码 " + sshOut.exitCode + (sshErr ? ": " + sshErr.slice(0, 500) : ""));
312
+ if (tarOut.exitCode !== 0) parts.push("tar 退出码 " + tarOut.exitCode + (tarErr ? ": " + tarErr.slice(0, 500) : ""));
313
+ if (!parts.length) parts.push("远端可能缺少 tar,或本机无 tar.exe");
314
+ return { ok: false, error: "同步失败: " + parts.join(";") };
315
+ }
316
+
317
+ /** 本地镜像 → 远端:本地 tar 打包喂给远端 tar 展开。 */
318
+ async function remoteSyncUp(subprocess, p, remotePath, mirrorPath) {
319
+ if (!remotePath) return { ok: false, error: "remotePath 为必填项" };
320
+ if (!mirrorPath) return { ok: false, error: "mirrorPath 为必填项" };
321
+ const COLLECT = { maxBytes: 1024 * 1024, spill: { maxBytes: 1024 * 1024 } };
322
+ let tarH, sshH;
323
+ try {
324
+ tarH = spawnOne(subprocess, ["tar", "cf", "-", "-C", mirrorPath, "."], {
325
+ stdin: "ignore", stdout: "pipe", stderr: COLLECT
326
+ });
327
+ sshH = spawnOne(subprocess, sshArgv(p, "tar xf - -C " + shellQuotePath(remotePath)), {
328
+ stdin: "pipe", stdout: "ignore", stderr: COLLECT
329
+ });
330
+ } catch (e) {
331
+ return { ok: false, error: "spawn 失败: " + String(e && e.message ? e.message : e) };
332
+ }
333
+ let pipeErr = "";
334
+ try {
335
+ if (tarH.stdout && sshH.stdin) tarH.stdout.pipe(sshH.stdin);
336
+ else pipeErr = "stdout/stdin 管道不可用(subprocess 未暴露原始流)";
337
+ } catch (e) { pipeErr = String(e && e.message ? e.message : e); }
338
+ let tarOut, sshOut;
339
+ try { tarOut = await tarH.done; } catch (e) { tarOut = { exitCode: -1 }; }
340
+ try { sshOut = await sshH.done; } catch (e) { sshOut = { exitCode: -1 }; }
341
+ const tarErr = readCollected(tarH, "stderr");
342
+ const sshErr = readCollected(sshH, "stderr");
343
+ if (tarOut.exitCode === 0 && sshOut.exitCode === 0) return { ok: true, remotePath: remotePath };
344
+ const parts = [];
345
+ if (pipeErr) parts.push("管道: " + pipeErr);
346
+ if (tarOut.exitCode !== 0) parts.push("tar 退出码 " + tarOut.exitCode + (tarErr ? ": " + tarErr.slice(0, 500) : ""));
347
+ if (sshOut.exitCode !== 0) parts.push("ssh 退出码 " + sshOut.exitCode + (sshErr ? ": " + sshErr.slice(0, 500) : ""));
348
+ if (!parts.length) parts.push("远端可能缺少 tar,或本机无 tar.exe");
349
+ return { ok: false, error: "推送失败: " + parts.join(";") };
350
+ }
351
+
174
352
  function normExec(r) {
175
353
  return {
176
354
  ok: !!r.ok,
@@ -346,6 +524,16 @@ function apply(ctx, config) {
346
524
  return { profile: null, remotePath: undefined };
347
525
  }
348
526
 
527
+ /** 解析同步工具的目标工作区:显式 workspaceId 优先,其次当前会话的远程工作区。 */
528
+ function resolveWorkspace(args, exec) {
529
+ const a = args || {};
530
+ const workspaces = settingsFace.read().workspaces;
531
+ if (a.workspaceId) {
532
+ return workspaces.find((w) => w.id === a.workspaceId) || null;
533
+ }
534
+ return remoteContextFor(exec);
535
+ }
536
+
349
537
  /** 相对路径拼到远程工作目录下,绝对路径(/、~、盘符)原样返回。 */
350
538
  function resolveRemotePath(path, base) {
351
539
  if (!base) return path || "";
@@ -357,6 +545,8 @@ function apply(ctx, config) {
357
545
 
358
546
  const api = {
359
547
  listProfiles: async () => ({ ok: true, profiles: settingsFace.read().profiles }),
548
+ /** 读取本机 ~/.ssh/config 发现的主机(借鉴 Yan-Zero dsh-remote-ssh)。 */
549
+ listSshConfigHosts: async () => listSshConfigHosts(),
360
550
  saveProfile: async (args) => {
361
551
  const p = args || {};
362
552
  if (!p.host || !p.user) return { ok: false, error: "host 和 user 为必填项" };
@@ -372,7 +562,8 @@ function apply(ctx, config) {
372
562
  authMethod: p.authMethod || "key",
373
563
  keyPath: p.keyPath || "",
374
564
  password: p.password || "",
375
- remoteRoot: p.remoteRoot || "~"
565
+ remoteRoot: p.remoteRoot || "~",
566
+ proxyJump: p.proxyJump || ""
376
567
  };
377
568
  if (existing) {
378
569
  const i = profiles.indexOf(existing);
@@ -485,6 +676,24 @@ function apply(ctx, config) {
485
676
  if (!p) return { ok: false, error: "需要 profileId 或 host+user" };
486
677
  return await remoteWriteFile(subprocess, p, args && args.path, args && args.content);
487
678
  },
679
+ /** 远端 → 本地镜像(按工作区)。 */
680
+ syncDown: async (args) => {
681
+ const a = args || {};
682
+ const ws = settingsFace.read().workspaces.find((w) => w.id === (a.workspaceId || a.id));
683
+ if (!ws) return { ok: false, error: "未找到远程工作区" };
684
+ const p = getProfile(ws.profileId);
685
+ if (!p) return { ok: false, error: "未找到连接配置" };
686
+ return await remoteSyncDown(subprocess, p, ws.remotePath, ws.mirrorPath);
687
+ },
688
+ /** 本地镜像 → 远端(按工作区)。 */
689
+ syncUp: async (args) => {
690
+ const a = args || {};
691
+ const ws = settingsFace.read().workspaces.find((w) => w.id === (a.workspaceId || a.id));
692
+ if (!ws) return { ok: false, error: "未找到远程工作区" };
693
+ const p = getProfile(ws.profileId);
694
+ if (!p) return { ok: false, error: "未找到连接配置" };
695
+ return await remoteSyncUp(subprocess, p, ws.remotePath, ws.mirrorPath);
696
+ },
488
697
  spawnTerminal: async (args) => {
489
698
  const p = resolveProfile(args);
490
699
  if (!p) return { ok: false, error: "需要 profileId 或 host+user" };
@@ -681,6 +890,48 @@ function apply(ctx, config) {
681
890
  }
682
891
  });
683
892
 
893
+ // 同步:把远程工作区的文件同步到本地镜像目录(syncDown),或把本地镜像改动推回远端(syncUp)。
894
+ // 借鉴 flymysql dsh-remote 的 rw_sync / rw_push,但用 tar-over-ssh 流式管道,不引入 ssh2。
895
+ const syncDesc = "按远程工作区双向同步:sync 把远端文件同步到本地镜像目录,push 把本地镜像改动推回远端。" +
896
+ "用 workspaceId 指定工作区,或留空在当前远程工作区会话中自动识别。" +
897
+ "Sync/push a remote workspace to its local mirror (or back). Pass workspaceId, or omit it inside a remote-workspace session.";
898
+ const syncParams = {
899
+ workspaceId: { type: "string", description: "远程工作区 id(留空则在当前远程工作区会话中自动识别)。" }
900
+ };
901
+ const syncOutput = {
902
+ schema: { type: "object", additionalProperties: false, properties: {
903
+ ok: { type: "boolean", required: true },
904
+ error: { type: "string", required: true }
905
+ } },
906
+ render: textRender(function (a, v) { return v.ok ? "同步完成" : (v.error || "同步失败"); })
907
+ };
908
+ register({
909
+ name: "remote_ssh_sync",
910
+ description: syncDesc,
911
+ parameters: syncParams,
912
+ output: syncOutput,
913
+ execute: async function (args, exec) {
914
+ const ws = resolveWorkspace(args, exec);
915
+ if (!ws) return { ok: false, error: "未找到远程工作区(workspaceId 必填,或当前会话非远程工作区)" };
916
+ const p = getProfile(ws.profileId);
917
+ if (!p) return { ok: false, error: "未找到连接配置" };
918
+ return await remoteSyncDown(subprocess, p, ws.remotePath, ws.mirrorPath);
919
+ }
920
+ });
921
+ register({
922
+ name: "remote_ssh_push",
923
+ description: syncDesc,
924
+ parameters: syncParams,
925
+ output: syncOutput,
926
+ execute: async function (args, exec) {
927
+ const ws = resolveWorkspace(args, exec);
928
+ if (!ws) return { ok: false, error: "未找到远程工作区(workspaceId 必填,或当前会话非远程工作区)" };
929
+ const p = getProfile(ws.profileId);
930
+ if (!p) return { ok: false, error: "未找到连接配置" };
931
+ return await remoteSyncUp(subprocess, p, ws.remotePath, ws.mirrorPath);
932
+ }
933
+ });
934
+
684
935
  // ---- 清理 ----
685
936
  ctx.effect(() => () => {
686
937
  terminals.forEach(function (s) { try { s.handle.terminate(); } catch (e) {} });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zhangfengshun/dsh-remote-ssh",
3
- "version": "1.4.0",
3
+ "version": "1.5.0",
4
4
  "description": "DSH web plugin: VSCode Remote-SSH-like remote development (SSH to supercomputers/servers, remote workspace, file explorer, integrated terminal), integrated with dsh-better-sidebar and DSH settings.",
5
5
  "keywords": [
6
6
  "dsh",