@zhangfengshun/dsh-remote-ssh 1.9.2 → 2.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,19 @@
2
2
 
3
3
  本文件的版本号与 `package.json` 的 `version` 保持一致。每个版本对应一个 Cordis Package 快照(`pkg-N`)。
4
4
 
5
+ ## [2.0.0] — 内置文件页签直接 SSH 读写远程文件(不再需要同步)
6
+ ### 重大变更
7
+ - **内置「文件」页签直接操作远程文件**:通过注册更长前缀 `/sidebar/api/fs.` 拦截 better-sidebar 的文件 API(`fs.tree`/`fs.read`/`fs.write`/`fs.search`),在远程工作区中直接通过 SSH 读写远程文件,不再需要 sync/push 同步。
8
+ - **透明路径映射**:客户端看到的是本地镜像路径,host 拦截器自动转换为远程路径,通过 SSH 执行操作后返回结果。
9
+ - **本地工作区照常**:非远程工作区的请求走本地 fs,行为与 better-sidebar 原始实现一致。
10
+ - 不再依赖 tar 同步——打开文件 = 直接读远程,保存文件 = 直接写远程。
11
+
12
+ ## [1.9.3] — 修复 syncDown 删除 .remote-ssh.json 导致终端不工作
13
+ ### 修复
14
+ - **`remoteSyncDown` 会清空镜像目录**:syncDown 先 `rm -rf` 镜像目录再 `tar xf` 展开,导致之前写入的 `.remote-ssh.json`(供 shell wrapper 读取连接信息)被删掉,终端 wrapper 检测不到远程工作区,降级到本地 shell。
15
+ - **修复**:在 `createRemoteWorkspace`、`syncDown` API、`remote_ssh_sync` 工具三处,都在 `remoteSyncDown` 完成后重新写入 `.remote-ssh.json`。
16
+ - 已为现有工作区补写 `.remote-ssh.json`(无需重新创建工作区)。
17
+
5
18
  ## [1.9.2] — 修复自动配置 patch 不生效(bundles 顺序)
6
19
  ### 修复
7
20
  - **config 覆盖被跳过**:`cordis.patch.yml` 中的 `id: better-sidebar` config 覆盖需要在 `better-sidebar` 条目被 insert 之后才能生效。如果 `@zhangfengshun/dsh-remote-ssh` 在 bundles 列表中排在 `dsh-better-sidebar` 之前,patch 会因"entry not found"被跳过。
package/README.md CHANGED
@@ -29,7 +29,7 @@ dsh plugin --profile <name> add /absolute/path/to/dsh-remote-ssh
29
29
  ### 发布后安装
30
30
 
31
31
  ```bash
32
- dsh plugin --profile <name> add @zhangfengshun/dsh-remote-ssh@1.9.2
32
+ dsh plugin --profile <name> add @zhangfengshun/dsh-remote-ssh@2.0.0
33
33
  ```
34
34
 
35
35
  > ⚠️ 安装后需**重启 DSH** 才生效;后续仅修改 Client 半边时刷新浏览器即可。
package/cordis.patch.yml CHANGED
@@ -13,9 +13,10 @@
13
13
  - id: better-sidebar
14
14
  config:
15
15
  shell: !!js |
16
- var os = require('os');
17
- var path = require('path');
18
- var dir = path.join(os.homedir(), '.dsh', 'remote-ssh');
19
- var isWin = process.platform === 'win32';
20
- var wrapper = isWin ? path.join(dir, 'dsh-remote-shell.cmd') : path.join(dir, 'dsh-remote-shell');
21
- return wrapper;
16
+ (() => {
17
+ var homedir = process.env.USERPROFILE || process.env.HOME || process.env.HOMEDRIVE + process.env.HOMEPATH;
18
+ var isWin = process.platform === 'win32';
19
+ var sep = isWin ? '\\' : '/';
20
+ var dir = homedir + sep + '.dsh' + sep + 'remote-ssh';
21
+ return isWin ? dir + sep + 'dsh-remote-shell.cmd' : dir + sep + 'dsh-remote-shell';
22
+ })()
package/lib/index.js CHANGED
@@ -11,14 +11,14 @@
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, readFile } from "node:fs/promises";
14
+ import { mkdir, rm, writeFile, readFile, opendir, stat, open, rename, readdir } from "node:fs/promises";
15
15
  import { homedir } from "node:os";
16
16
  import { join } from "node:path";
17
17
 
18
18
  /** Plugin identity for cordis.yml rows. */
19
19
  const name = "@zhangfengshun/dsh-remote-ssh";
20
20
  /** Services required before mounting. */
21
- const inject = ["webServer", "subprocess", "tools"];
21
+ const inject = ["webServer", "subprocess", "tools", "sessions", "webRuntime"];
22
22
  /** Composition config schema(本插件暂无配置项)。 */
23
23
  const Config = z.object({});
24
24
 
@@ -819,7 +819,13 @@ function apply(ctx, config) {
819
819
  try {
820
820
  await writeFile(join(mirrorDir, "README.md"), remoteWorkspaceReadme(p.name, a.remotePath), "utf8");
821
821
  } catch (e) {}
822
+ // 自动同步:把远程文件拉到本地镜像,使内置「文件」页签可见真实远程文件。
823
+ // 注意:remoteSyncDown 会先清空镜像目录,所以 .remote-ssh.json 必须在同步之后写。
824
+ try {
825
+ await remoteSyncDown(subprocess, p, a.remotePath, mirrorDir);
826
+ } catch (e) {}
822
827
  // 写 .remote-ssh.json:供 shell wrapper 读取连接信息(仅密钥认证,不含密码)。
828
+ // 必须在 remoteSyncDown 之后写,否则会被同步过程清空。
823
829
  try {
824
830
  const connInfo = {
825
831
  profileId: p.id, host: p.host, port: p.port || 22, user: p.user,
@@ -827,10 +833,6 @@ function apply(ctx, config) {
827
833
  };
828
834
  await writeFile(join(mirrorDir, ".remote-ssh.json"), JSON.stringify(connInfo, null, 2), "utf8");
829
835
  } catch (e) {}
830
- // 自动同步:把远程文件拉到本地镜像,使内置「文件」页签可见真实远程文件。
831
- try {
832
- await remoteSyncDown(subprocess, p, a.remotePath, mirrorDir);
833
- } catch (e) {}
834
836
  let workspaceId = null;
835
837
  if (workspaceRegistry) {
836
838
  try {
@@ -936,7 +938,18 @@ function apply(ctx, config) {
936
938
  if (!ws) return { ok: false, error: "未找到远程工作区" };
937
939
  const p = getProfile(ws.profileId);
938
940
  if (!p) return { ok: false, error: "未找到连接配置" };
939
- return await remoteSyncDown(subprocess, p, ws.remotePath, ws.mirrorPath);
941
+ const r = await remoteSyncDown(subprocess, p, ws.remotePath, ws.mirrorPath);
942
+ // remoteSyncDown 会清空镜像目录,需要重新写入 .remote-ssh.json
943
+ if (r.ok) {
944
+ try {
945
+ const connInfo = {
946
+ profileId: p.id, host: p.host, port: p.port || 22, user: p.user,
947
+ keyPath: p.keyPath || "", proxyJump: p.proxyJump || "", remotePath: ws.remotePath
948
+ };
949
+ await writeFile(join(ws.mirrorPath, ".remote-ssh.json"), JSON.stringify(connInfo, null, 2), "utf8");
950
+ } catch (e) {}
951
+ }
952
+ return r;
940
953
  },
941
954
  /** 本地镜像 → 远端(按工作区)。 */
942
955
  syncUp: async (args) => {
@@ -1168,7 +1181,18 @@ function apply(ctx, config) {
1168
1181
  if (!ws) return { ok: false, error: "未找到远程工作区(workspaceId 必填,或当前会话非远程工作区)" };
1169
1182
  const p = getProfile(ws.profileId);
1170
1183
  if (!p) return { ok: false, error: "未找到连接配置" };
1171
- return await remoteSyncDown(subprocess, p, ws.remotePath, ws.mirrorPath);
1184
+ const r = await remoteSyncDown(subprocess, p, ws.remotePath, ws.mirrorPath);
1185
+ // remoteSyncDown 会清空镜像目录,需要重新写入 .remote-ssh.json
1186
+ if (r.ok) {
1187
+ try {
1188
+ const connInfo = {
1189
+ profileId: p.id, host: p.host, port: p.port || 22, user: p.user,
1190
+ keyPath: p.keyPath || "", proxyJump: p.proxyJump || "", remotePath: ws.remotePath
1191
+ };
1192
+ await writeFile(join(ws.mirrorPath, ".remote-ssh.json"), JSON.stringify(connInfo, null, 2), "utf8");
1193
+ } catch (e) {}
1194
+ }
1195
+ return r;
1172
1196
  }
1173
1197
  });
1174
1198
  register({
@@ -1369,6 +1393,231 @@ function apply(ctx, config) {
1369
1393
  }
1370
1394
  })();
1371
1395
 
1396
+ // ---- 拦截 better-sidebar 的 fs.* API:远程工作区走 SSH,本地走本地 fs ----
1397
+ // 注册更长前缀 /sidebar/api/fs. 优先于 better-sidebar 的 /sidebar/api 匹配。
1398
+ const READ_LIMIT = 524288;
1399
+ const LIST_LIMIT = 1000;
1400
+
1401
+ // 读取镜像目录下的 .remote-ssh.json,返回连接信息或 null
1402
+ function readRemoteInfoSync(mirrorCwd) {
1403
+ if (!mirrorCwd) return null;
1404
+ try {
1405
+ const fs2 = require("fs");
1406
+ const jsonPath = require("path").join(mirrorCwd, ".remote-ssh.json");
1407
+ if (fs2.existsSync(jsonPath)) {
1408
+ return JSON.parse(fs2.readFileSync(jsonPath, "utf8"));
1409
+ }
1410
+ } catch (e) {}
1411
+ return null;
1412
+ }
1413
+
1414
+ // 把本地镜像路径转换为远程路径
1415
+ function localToRemote(localPath, mirrorCwd, remoteBase) {
1416
+ if (!localPath) return remoteBase;
1417
+ // 规范化:去掉前缀 mirrorCwd,剩余部分拼到 remoteBase
1418
+ let rel = String(localPath);
1419
+ const mc = String(mirrorCwd).replace(/\\/g, "/");
1420
+ rel = rel.replace(/\\/g, "/");
1421
+ if (rel.toLowerCase().startsWith(mc.toLowerCase())) {
1422
+ rel = rel.slice(mc.length);
1423
+ } else {
1424
+ // 可能是相对于 cwd 的路径
1425
+ }
1426
+ if (rel.startsWith("/")) rel = rel.slice(1);
1427
+ if (!rel) return remoteBase;
1428
+ // 拼接远程路径
1429
+ const rb = String(remoteBase).replace(/\/+$/, "");
1430
+ return rb + "/" + rel;
1431
+ }
1432
+
1433
+ // 本地 listDirectory(复制 better-sidebar 的逻辑)
1434
+ async function localListDir(path, maxEntries) {
1435
+ let level;
1436
+ try { level = await opendir(path); }
1437
+ catch (e) { return { ok: false, error: "cannot list: " + String(e && e.message ? e.message : e) }; }
1438
+ const rows = [];
1439
+ let overflow = 0;
1440
+ try {
1441
+ for await (const dirent of level) {
1442
+ if (rows.length >= (maxEntries || 1000)) { overflow++; continue; }
1443
+ rows.push({
1444
+ name: dirent.name,
1445
+ path: join(path, dirent.name),
1446
+ isDir: dirent.isDirectory(),
1447
+ isSymlink: dirent.isSymbolicLink(),
1448
+ broken: false,
1449
+ hidden: dirent.name.startsWith(".")
1450
+ });
1451
+ }
1452
+ } catch (e) { return { ok: false, error: "cannot list: " + String(e && e.message ? e.message : e) }; }
1453
+ rows.sort(function (a, b) {
1454
+ if (a.isDir !== b.isDir) return a.isDir ? -1 : 1;
1455
+ return a.name.localeCompare(b.name, undefined, { sensitivity: "base" });
1456
+ });
1457
+ return { path: path, entries: rows, truncated: overflow > 0 };
1458
+ }
1459
+
1460
+ // 本地 readText(复制 better-sidebar 的逻辑)
1461
+ async function localReadText(path, readLimit) {
1462
+ const info = await stat(path).catch(function (e) { throw new Error("cannot read: " + String(e && e.message ? e.message : e)); });
1463
+ if (info.isDirectory()) throw new Error("is a directory");
1464
+ const size = info.size;
1465
+ const truncated = size > readLimit;
1466
+ const handle = await open(path, "r");
1467
+ try {
1468
+ const buffer = Buffer.alloc(Math.min(size, readLimit));
1469
+ const r = await handle.read(buffer, 0, buffer.length, 0);
1470
+ const slice = buffer.subarray(0, r.bytesRead);
1471
+ const binary = slice.includes(0);
1472
+ if (binary) {
1473
+ return { kind: "binary", size: size, truncated: truncated, head: slice.subarray(0, Math.min(slice.length, 4096)).toString("base64") };
1474
+ }
1475
+ return { kind: "text", content: slice.toString("utf8"), truncated: truncated };
1476
+ } finally { await handle.close(); }
1477
+ }
1478
+
1479
+ // 本地 writeFile(原子写入)
1480
+ async function localWriteFile(path, content) {
1481
+ const tmp = path + ".dsh-rssh-tmp-" + process.pid;
1482
+ try {
1483
+ await mkdir(require("path").dirname(path), { recursive: true });
1484
+ await writeFile(tmp, content, "utf8");
1485
+ await rename(tmp, path);
1486
+ } catch (e) {
1487
+ try { await rm(tmp, { force: true }); } catch (e2) {}
1488
+ throw e;
1489
+ }
1490
+ return { ok: true };
1491
+ }
1492
+
1493
+ // 本地 searchFiles(简单递归文件名搜索)
1494
+ async function localSearchFiles(root, query) {
1495
+ if (!query) return { entries: [] };
1496
+ const q = query.toLowerCase();
1497
+ const results = [];
1498
+ async function walk(dir, depth) {
1499
+ if (depth > 10 || results.length > 500) return;
1500
+ let entries;
1501
+ try { entries = await readdir(dir, { withFileTypes: true }); }
1502
+ catch (e) { return; }
1503
+ for (const e of entries) {
1504
+ if (e.name.toLowerCase().includes(q)) {
1505
+ results.push({ path: join(dir, e.name), isDir: e.isDirectory() });
1506
+ if (results.length > 500) return;
1507
+ }
1508
+ if (e.isDirectory() && !e.name.startsWith(".") && e.name !== "node_modules") {
1509
+ await walk(join(dir, e.name), depth + 1);
1510
+ }
1511
+ }
1512
+ }
1513
+ await walk(root, 0);
1514
+ return { entries: results, truncated: false };
1515
+ }
1516
+
1517
+ // 远程 listDir → better-sidebar 格式
1518
+ async function remoteListForSidebar(profile, remotePath, localCwd) {
1519
+ const r = await remoteListDir(runPooled, profile, remotePath);
1520
+ if (!r.ok) return { ok: false, error: r.error || "list failed" };
1521
+ const entries = (r.entries || []).map(function (e) {
1522
+ return {
1523
+ name: e.name,
1524
+ path: join(localCwd, e.name), // 返回本地镜像路径格式,保持客户端兼容
1525
+ isDir: e.type === "directory",
1526
+ isSymlink: false,
1527
+ broken: false,
1528
+ hidden: e.name.startsWith(".")
1529
+ };
1530
+ });
1531
+ entries.sort(function (a, b) {
1532
+ if (a.isDir !== b.isDir) return a.isDir ? -1 : 1;
1533
+ return a.name.localeCompare(b.name, undefined, { sensitivity: "base" });
1534
+ });
1535
+ return { path: localCwd, entries: entries, truncated: false };
1536
+ }
1537
+
1538
+ // 远程 readText → better-sidebar 格式
1539
+ async function remoteReadForSidebar(profile, remotePath) {
1540
+ const r = await remoteReadFile(runPooled, profile, remotePath);
1541
+ if (!r.ok) return { ok: false, error: r.error || "read failed" };
1542
+ if (r.binary) {
1543
+ return { kind: "binary", size: 0, truncated: !!r.truncated, head: (r.content || "").slice(0, 4096) };
1544
+ }
1545
+ return { kind: "text", content: r.content || "", truncated: !!r.truncated };
1546
+ }
1547
+
1548
+ ctx.effect(() => ctx.webServer.register({
1549
+ kind: "prefix",
1550
+ path: "/sidebar/api/fs.",
1551
+ handler: async (req, res) => {
1552
+ if (!isTrusted(req)) { writeJson(res, 403, { ok: false, error: { code: "forbidden", message: "forbidden" } }); return; }
1553
+ if (req.method !== "POST") { writeJson(res, 405, { ok: false, error: { code: "method-error", message: "method not allowed" } }); return; }
1554
+ const pathname = new URL(req.url || "/", "http://dsh.internal").pathname;
1555
+ const method = "fs." + pathname.slice("/sidebar/api/fs.".length);
1556
+ let payload;
1557
+ try { payload = await readJsonBody(req); }
1558
+ catch (e) { writeJson(res, 400, { ok: false, error: { code: "bad-request", message: String(e && e.message ? e.message : e) } }); return; }
1559
+ try {
1560
+ // 获取会话 cwd
1561
+ const sessionId = payload && payload.sessionId;
1562
+ let sessionCwd = payload && payload.cwd;
1563
+ if ((!sessionCwd || sessionCwd === "") && sessionId && ctx.sessions) {
1564
+ const session = ctx.sessions.get(sessionId);
1565
+ sessionCwd = session && session.header && session.header.cwd;
1566
+ }
1567
+ // 检查是否远程工作区
1568
+ const remoteInfo = readRemoteInfoSync(sessionCwd);
1569
+ let result;
1570
+ if (remoteInfo && remoteInfo.keyPath && remoteInfo.host && remoteInfo.user) {
1571
+ // ---- 远程工作区:走 SSH ----
1572
+ const profile = getProfile(remoteInfo.profileId);
1573
+ if (!profile) { writeJson(res, 500, { ok: false, error: { code: "internal", message: "remote profile not found" } }); return; }
1574
+ if (method === "fs.tree") {
1575
+ const localPath = payload.path || sessionCwd;
1576
+ const rp = localToRemote(localPath, sessionCwd, remoteInfo.remotePath);
1577
+ result = await remoteListForSidebar(profile, rp, localPath);
1578
+ } else if (method === "fs.read") {
1579
+ const localPath = payload.path || sessionCwd;
1580
+ const rp = localToRemote(localPath, sessionCwd, remoteInfo.remotePath);
1581
+ result = await remoteReadForSidebar(profile, rp);
1582
+ } else if (method === "fs.write") {
1583
+ const localPath = payload.path || sessionCwd;
1584
+ const rp = localToRemote(localPath, sessionCwd, remoteInfo.remotePath);
1585
+ result = await remoteWriteFile(runPooled, profile, rp, payload.content || "");
1586
+ } else if (method === "fs.search") {
1587
+ const rp = remoteInfo.remotePath;
1588
+ const r = await remoteGlob(runPooled, profile, "*" + (payload.query || "") + "*", rp);
1589
+ result = { entries: (r.files || []).map(function (f) {
1590
+ return { path: join(sessionCwd, f), isDir: false };
1591
+ }), truncated: !!r.truncated };
1592
+ } else {
1593
+ writeJson(res, 404, { ok: false, error: { code: "not-found", message: "unknown method " + method } }); return;
1594
+ }
1595
+ } else {
1596
+ // ---- 本地工作区:走本地 fs ----
1597
+ if (method === "fs.tree") {
1598
+ const p = payload.path || sessionCwd;
1599
+ result = await localListDir(p, LIST_LIMIT);
1600
+ } else if (method === "fs.read") {
1601
+ result = await localReadText(payload.path, READ_LIMIT);
1602
+ } else if (method === "fs.write") {
1603
+ result = await localWriteFile(payload.path, payload.content || "");
1604
+ } else if (method === "fs.search") {
1605
+ result = await localSearchFiles(sessionCwd, payload.query || "");
1606
+ } else {
1607
+ writeJson(res, 404, { ok: false, error: { code: "not-found", message: "unknown method " + method } }); return;
1608
+ }
1609
+ }
1610
+ if (result && result.ok === false) {
1611
+ writeJson(res, 400, { ok: false, error: { code: "fs-error", message: result.error || "operation failed" } });
1612
+ } else {
1613
+ writeOk(res, result);
1614
+ }
1615
+ } catch (e) {
1616
+ writeJson(res, 400, { ok: false, error: { code: "fs-error", message: String(e && e.message ? e.message : e) } });
1617
+ }
1618
+ }
1619
+ }), "dsh-remote-ssh: intercept /sidebar/api/fs.* for remote workspaces");
1620
+
1372
1621
  // ---- 清理 ----
1373
1622
  ctx.effect(() => () => {
1374
1623
  clearInterval(idleTimer);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zhangfengshun/dsh-remote-ssh",
3
- "version": "1.9.2",
3
+ "version": "2.0.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",