@zhangfengshun/dsh-remote-ssh 1.3.1 → 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 +12 -0
- package/README.md +15 -10
- package/lib/client.js +188 -32
- package/lib/index.js +254 -3
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,18 @@
|
|
|
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
|
+
|
|
13
|
+
## [1.4.0] — 远程文件单开一栏预览
|
|
14
|
+
### 新增
|
|
15
|
+
- 点击「远程文件」文件树中的文件,改为在 better-sidebar 中**单开一栏(独立页签)预览/编辑**,与内置「文件」页签一致,不再在文件树下方内嵌预览。新增隐藏页签类型 `remssh:editor`,同一远程文件按 (profileId, path) 去重复用。
|
|
16
|
+
|
|
5
17
|
## [1.3.1] — 修复远程文件树滚动
|
|
6
18
|
### 修复
|
|
7
19
|
- 「远程文件」页签改为 flex 布局:文件树在文件很多时占据剩余高度并内部滚动(`flex:1; overflow-y:auto`),不再因父容器 `overflow:hidden` 而无法往下拉。
|
package/README.md
CHANGED
|
@@ -8,11 +8,13 @@
|
|
|
8
8
|
|
|
9
9
|
| 能力 | 说明 |
|
|
10
10
|
| --- | --- |
|
|
11
|
-
| 🔌 远程连接 | SSH 连接超算 / 服务器,密钥认证(推荐)或密码认证(需本机 `sshpass
|
|
12
|
-
|
|
|
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
|
-
| 🤖 模型工具 |
|
|
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.
|
|
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:
|
|
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.
|
|
136
|
-
5.
|
|
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": "连接成功",
|
|
@@ -96,6 +97,7 @@ window.__ModuleLoader__.load({
|
|
|
96
97
|
"files.errRead": "读取失败",
|
|
97
98
|
"files.errSave": "保存失败",
|
|
98
99
|
"files.errSaveDefaultDir": "保存默认目录失败",
|
|
100
|
+
"files.binary": "二进制文件,无法预览",
|
|
99
101
|
"term.title": "终端 {id}",
|
|
100
102
|
"term.exited": " (已退出)",
|
|
101
103
|
"term.connecting": "正在连接…",
|
|
@@ -114,7 +116,17 @@ window.__ModuleLoader__.load({
|
|
|
114
116
|
"flow.remoteDir": "远程目录",
|
|
115
117
|
"flow.chooseRemote": "选择远程目录…",
|
|
116
118
|
"flow.backLocal": "← 选择本地目录",
|
|
117
|
-
"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": "同步失败"
|
|
118
130
|
};
|
|
119
131
|
var en = {
|
|
120
132
|
"tabs.files": "Remote Files",
|
|
@@ -137,6 +149,7 @@ window.__ModuleLoader__.load({
|
|
|
137
149
|
"settings.keyPathPlaceholder": "~/.ssh/id_rsa",
|
|
138
150
|
"settings.passwordField": "Password",
|
|
139
151
|
"settings.remoteRoot": "Remote root (remoteRoot)",
|
|
152
|
+
"settings.proxyJump": "ProxyJump (jump host)",
|
|
140
153
|
"settings.test": "Test Connection",
|
|
141
154
|
"settings.saved": "Saved",
|
|
142
155
|
"settings.connOk": "Connected",
|
|
@@ -160,6 +173,7 @@ window.__ModuleLoader__.load({
|
|
|
160
173
|
"files.errRead": "Failed to read",
|
|
161
174
|
"files.errSave": "Failed to save",
|
|
162
175
|
"files.errSaveDefaultDir": "Failed to save default directory",
|
|
176
|
+
"files.binary": "Binary file — preview not available",
|
|
163
177
|
"term.title": "Terminal {id}",
|
|
164
178
|
"term.exited": " (exited)",
|
|
165
179
|
"term.connecting": "Connecting…",
|
|
@@ -178,7 +192,17 @@ window.__ModuleLoader__.load({
|
|
|
178
192
|
"flow.remoteDir": "Remote directory",
|
|
179
193
|
"flow.chooseRemote": "Choose remote directory…",
|
|
180
194
|
"flow.backLocal": "← Choose local directory",
|
|
181
|
-
"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"
|
|
182
206
|
};
|
|
183
207
|
var i18n = {
|
|
184
208
|
t: function (key) { return key; },
|
|
@@ -255,6 +279,7 @@ window.__ModuleLoader__.load({
|
|
|
255
279
|
function FilesTab(props) {
|
|
256
280
|
var t = useT();
|
|
257
281
|
var sessionId = props.sessionId;
|
|
282
|
+
var betterSidebar = props.betterSidebar;
|
|
258
283
|
var [workspaces, setWorkspaces] = React.useState([]);
|
|
259
284
|
var profiles = useProfiles();
|
|
260
285
|
var [wsId, setWsId] = useSessionMemory(sessionId, "wsId", sel.workspaceId);
|
|
@@ -263,8 +288,8 @@ window.__ModuleLoader__.load({
|
|
|
263
288
|
var [entries, setEntries] = useSessionMemory(sessionId, "entries", []);
|
|
264
289
|
var [err, setErr] = React.useState(null);
|
|
265
290
|
var [loading, setLoading] = React.useState(false);
|
|
266
|
-
var [openFile, setOpenFile] = useSessionMemory(sessionId, "openFile", null);
|
|
267
291
|
var [defaultDir, setDefaultDir] = useSessionMemory(sessionId, "defaultDir", "");
|
|
292
|
+
var [syncSt, setSyncSt] = React.useState(null); // { busy, msg, ok }
|
|
268
293
|
|
|
269
294
|
React.useEffect(function () {
|
|
270
295
|
api("listWorkspaces").then(function (r) { if (r && r.ok) setWorkspaces(r.workspaces || []); });
|
|
@@ -310,17 +335,34 @@ window.__ModuleLoader__.load({
|
|
|
310
335
|
});
|
|
311
336
|
}
|
|
312
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
|
+
|
|
313
350
|
function openEntry(e) {
|
|
314
351
|
var base = path ? (path.replace(/\/+$/, "") + "/" + e.name) : e.name;
|
|
315
352
|
if (e.type === "directory") {
|
|
316
353
|
setPath(base);
|
|
317
354
|
load(profileId, base);
|
|
318
355
|
} else {
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
356
|
+
// 用独立页签预览远程文件(与内置「文件」页签一致),而非在文件树下方预览。
|
|
357
|
+
if (betterSidebar && betterSidebar.openTab) {
|
|
358
|
+
betterSidebar.openTab({
|
|
359
|
+
type: "remssh:editor",
|
|
360
|
+
id: "remssh:editor:" + profileId + ":" + base,
|
|
361
|
+
title: e.name,
|
|
362
|
+
path: base,
|
|
363
|
+
meta: { profileId: profileId, remotePath: base }
|
|
364
|
+
}, { sessionId: sessionId });
|
|
365
|
+
}
|
|
324
366
|
}
|
|
325
367
|
}
|
|
326
368
|
|
|
@@ -333,14 +375,6 @@ window.__ModuleLoader__.load({
|
|
|
333
375
|
load(profileId, up);
|
|
334
376
|
}
|
|
335
377
|
|
|
336
|
-
function saveFile() {
|
|
337
|
-
if (!openFile) return;
|
|
338
|
-
api("writeFile", { profileId: profileId, path: openFile.path, content: openFile.content }).then(function (r) {
|
|
339
|
-
if (r && r.ok) setOpenFile(Object.assign({}, openFile, { dirty: false }));
|
|
340
|
-
else setErr((r && r.error) || t("files.errSave"));
|
|
341
|
-
});
|
|
342
|
-
}
|
|
343
|
-
|
|
344
378
|
return h("div", { style: { display: "flex", flexDirection: "column", flex: 1, minHeight: 0, overflow: "hidden" } },
|
|
345
379
|
h("div", { style: { flex: "none" } },
|
|
346
380
|
h("div", { className: "rssh-row" },
|
|
@@ -369,6 +403,11 @@ window.__ModuleLoader__.load({
|
|
|
369
403
|
h("button", { className: "rssh-btn", onClick: function () { load(profileId, path); } }, t("common.open")),
|
|
370
404
|
h("button", { className: "rssh-btn", onClick: goUp }, t("common.up"))
|
|
371
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,
|
|
372
411
|
loading ? h("div", { className: "rssh-muted" }, t("common.loading")) : null,
|
|
373
412
|
err ? h("div", { className: "rssh-err", style: { whiteSpace: "pre-wrap" } }, err) : null
|
|
374
413
|
),
|
|
@@ -379,18 +418,56 @@ window.__ModuleLoader__.load({
|
|
|
379
418
|
e.type === "file" ? h("span", { className: "rssh-tree-size" }, String(e.size)) : null
|
|
380
419
|
);
|
|
381
420
|
})
|
|
421
|
+
)
|
|
422
|
+
);
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
// ======================================================================
|
|
426
|
+
// 远程文件编辑器页签:点击文件树中的文件后单开一栏预览/编辑(与内置「文件」页签一致)
|
|
427
|
+
// ======================================================================
|
|
428
|
+
function RemoteEditor(props) {
|
|
429
|
+
var t = useT();
|
|
430
|
+
var tab = props.tab || {};
|
|
431
|
+
var ctx = props.ctx;
|
|
432
|
+
var meta = tab.meta || {};
|
|
433
|
+
var profileId = meta.profileId || "";
|
|
434
|
+
var remotePath = tab.path || meta.remotePath || "";
|
|
435
|
+
var [data, setData] = React.useState({ loading: true, content: "", binary: false, dirty: false, error: null });
|
|
436
|
+
|
|
437
|
+
React.useEffect(function () {
|
|
438
|
+
var alive = true;
|
|
439
|
+
setData({ loading: true, content: "", binary: false, dirty: false, error: null });
|
|
440
|
+
api("readFile", { profileId: profileId, path: remotePath }).then(function (r) {
|
|
441
|
+
if (!alive) return;
|
|
442
|
+
if (r && r.ok) setData({ loading: false, content: r.content || "", binary: !!r.binary, dirty: false, error: null });
|
|
443
|
+
else setData({ loading: false, content: "", binary: false, dirty: false, error: (r && r.error) || t("files.errRead") });
|
|
444
|
+
});
|
|
445
|
+
return function () { alive = false; };
|
|
446
|
+
}, [profileId, remotePath]);
|
|
447
|
+
|
|
448
|
+
function save() {
|
|
449
|
+
api("writeFile", { profileId: profileId, path: remotePath, content: data.content }).then(function (r) {
|
|
450
|
+
if (r && r.ok) setData(Object.assign({}, data, { dirty: false, error: null }));
|
|
451
|
+
else setData(Object.assign({}, data, { error: (r && r.error) || t("files.errSave") }));
|
|
452
|
+
});
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
function close() {
|
|
456
|
+
if (ctx && ctx.betterSidebar && tab.id) ctx.betterSidebar.closeTab(tab.id);
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
if (data.loading) return h("div", { className: "rssh-muted", style: { padding: 8 } }, t("files.reading"));
|
|
460
|
+
if (data.error) return h("div", { className: "rssh-err", style: { padding: 8, whiteSpace: "pre-wrap" } }, data.error);
|
|
461
|
+
if (data.binary) return h("div", { className: "rssh-muted", style: { padding: 8 } }, t("files.binary"));
|
|
462
|
+
|
|
463
|
+
return h("div", { style: { display: "flex", flexDirection: "column", flex: 1, minHeight: 0, padding: 8, overflow: "hidden" } },
|
|
464
|
+
h("div", { className: "rssh-pathbar", style: { flex: "none" } },
|
|
465
|
+
h("span", { className: "rssh-title", style: { flex: 1, wordBreak: "break-all" } }, remotePath),
|
|
466
|
+
h("button", { className: "rssh-btn", onClick: save }, t("common.save")),
|
|
467
|
+
h("button", { className: "rssh-btn", onClick: close }, t("common.close"))
|
|
382
468
|
),
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
h("span", { className: "rssh-title", style: { flex: 1, wordBreak: "break-all" } }, openFile.path),
|
|
386
|
-
h("button", { className: "rssh-btn", onClick: saveFile }, t("common.save")),
|
|
387
|
-
h("button", { className: "rssh-btn", onClick: function () { setOpenFile(null); } }, t("common.close"))
|
|
388
|
-
),
|
|
389
|
-
openFile.loading ? h("div", { className: "rssh-muted" }, t("files.reading")) :
|
|
390
|
-
openFile.error ? h("div", { className: "rssh-err" }, openFile.error) :
|
|
391
|
-
h("textarea", { className: "rssh-content", style: { width: "100%", boxSizing: "border-box", minHeight: 200, background: "var(--dsw-alias-markdown-code-block)", color: "var(--dsw-alias-label-primary)", border: "1px solid var(--dsw-alias-border-l2)" },
|
|
392
|
-
value: openFile.content, onChange: function (e) { setOpenFile(Object.assign({}, openFile, { content: e.target.value, dirty: true })); } })
|
|
393
|
-
) : null
|
|
469
|
+
h("textarea", { className: "rssh-content", style: { flex: 1, minHeight: 0, maxHeight: "none", width: "100%", boxSizing: "border-box", background: "var(--dsw-alias-markdown-code-block)", color: "var(--dsw-alias-label-primary)", border: "1px solid var(--dsw-alias-border-l2)" },
|
|
470
|
+
value: data.content, onChange: function (e) { setData(Object.assign({}, data, { content: e.target.value, dirty: true })); } })
|
|
394
471
|
);
|
|
395
472
|
}
|
|
396
473
|
|
|
@@ -629,9 +706,10 @@ window.__ModuleLoader__.load({
|
|
|
629
706
|
function SettingsSection(props) {
|
|
630
707
|
var t = useT();
|
|
631
708
|
var [profiles, setProfiles] = React.useState([]);
|
|
632
|
-
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: "" });
|
|
633
710
|
var [result, setResult] = React.useState(null);
|
|
634
711
|
var [busy, setBusy] = React.useState(false);
|
|
712
|
+
var [importDlg, setImportDlg] = React.useState(null);
|
|
635
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; }); };
|
|
636
714
|
|
|
637
715
|
function reload() { api("listProfiles").then(function (r) { if (r && r.ok) setProfiles(r.profiles || []); }); }
|
|
@@ -643,7 +721,7 @@ window.__ModuleLoader__.load({
|
|
|
643
721
|
setBusy(false);
|
|
644
722
|
if (r && r.ok) {
|
|
645
723
|
setProfiles(r.profiles || []);
|
|
646
|
-
setForm({ name: "", host: "", port: "22", user: "", authMethod: "key", keyPath: "", password: "", remoteRoot: "~" });
|
|
724
|
+
setForm({ name: "", host: "", port: "22", user: "", authMethod: "key", keyPath: "", password: "", remoteRoot: "~", proxyJump: "" });
|
|
647
725
|
setResult({ ok: true, msg: t("settings.saved") });
|
|
648
726
|
} else setResult({ ok: false, msg: (r && r.error) || t("settings.errSave") });
|
|
649
727
|
});
|
|
@@ -660,6 +738,47 @@ window.__ModuleLoader__.load({
|
|
|
660
738
|
api("deleteProfile", { id: p.id }).then(function (r) { if (r && r.ok) { setProfiles(r.profiles || []); } });
|
|
661
739
|
}
|
|
662
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
|
+
|
|
663
782
|
return h("div", null,
|
|
664
783
|
h("p", { className: "rssh-muted" }, t("settings.desc")),
|
|
665
784
|
profiles.map(function (p) {
|
|
@@ -689,9 +808,33 @@ window.__ModuleLoader__.load({
|
|
|
689
808
|
form.authMethod === "key" ? Field({ label: t("settings.keyPath"), value: form.keyPath, onChange: function (v) { set("keyPath", v); }, placeholder: t("settings.keyPathPlaceholder") }) : null,
|
|
690
809
|
form.authMethod === "password" ? Field({ label: t("settings.passwordField"), type: "password", value: form.password, onChange: function (v) { set("password", v); } }) : null,
|
|
691
810
|
Field({ label: t("settings.remoteRoot"), value: form.remoteRoot, onChange: function (v) { set("remoteRoot", v); }, placeholder: "~/project" }),
|
|
692
|
-
|
|
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
|
+
)
|
|
693
816
|
),
|
|
694
|
-
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
|
|
695
838
|
);
|
|
696
839
|
}
|
|
697
840
|
|
|
@@ -728,7 +871,20 @@ window.__ModuleLoader__.load({
|
|
|
728
871
|
icon: function () { return h("span", null, "🗂️"); },
|
|
729
872
|
component: function (props) {
|
|
730
873
|
var sid = props && props.scope ? props.scope.sessionId : null;
|
|
731
|
-
return h(FilesTab, { key: "files:" + (sid || "global"), sessionId: sid });
|
|
874
|
+
return h(FilesTab, { key: "files:" + (sid || "global"), sessionId: sid, betterSidebar: props && props.ctx ? props.ctx.betterSidebar : null });
|
|
875
|
+
}
|
|
876
|
+
});
|
|
877
|
+
});
|
|
878
|
+
// 远程文件编辑器页签(隐藏页签,仅由文件树点击打开,每个远程文件单开一栏)。
|
|
879
|
+
ctx.effect(function () {
|
|
880
|
+
return betterSidebar.registerTab({
|
|
881
|
+
id: "remssh:editor",
|
|
882
|
+
title: function () { return i18n.t("tabs.files"); },
|
|
883
|
+
hidden: true,
|
|
884
|
+
dedupeKey: function (tab) { return (tab && tab.meta && tab.meta.profileId || "") + "\u0000" + (tab && tab.path || ""); },
|
|
885
|
+
icon: function () { return h("span", null, "📄"); },
|
|
886
|
+
component: function (props) {
|
|
887
|
+
return h(RemoteEditor, { tab: props && props.tab, ctx: props && props.ctx, scope: props && props.scope });
|
|
732
888
|
}
|
|
733
889
|
});
|
|
734
890
|
});
|
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.
|
|
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",
|