@zhangfengshun/dsh-remote-ssh 1.9.3 → 2.0.1
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 +1 -1
- package/lib/index.js +239 -2
- 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
|
+
## [2.0.1] — 修复远程检测:从 payload.path 也查找 .remote-ssh.json
|
|
6
|
+
### 修复
|
|
7
|
+
- **sessionCwd 为空时无法检测远程工作区**:客户端请求有时只带 `payload.path` 不带 `cwd`,而 `ctx.sessions.get(sessionId)` 可能返回 undefined(session 未创建或 ID 不匹配)。现改为:先尝试 `sessionCwd`,再尝试 `payload.path`,两者都会检查 `.remote-ssh.json`。
|
|
8
|
+
- 添加调试日志(`ctx.logger.info`)记录每次拦截的方法、sessionId、cwd 和远程检测结果。
|
|
9
|
+
|
|
10
|
+
## [2.0.0] — 内置文件页签直接 SSH 读写远程文件(不再需要同步)
|
|
11
|
+
### 重大变更
|
|
12
|
+
- **内置「文件」页签直接操作远程文件**:通过注册更长前缀 `/sidebar/api/fs.` 拦截 better-sidebar 的文件 API(`fs.tree`/`fs.read`/`fs.write`/`fs.search`),在远程工作区中直接通过 SSH 读写远程文件,不再需要 sync/push 同步。
|
|
13
|
+
- **透明路径映射**:客户端看到的是本地镜像路径,host 拦截器自动转换为远程路径,通过 SSH 执行操作后返回结果。
|
|
14
|
+
- **本地工作区照常**:非远程工作区的请求走本地 fs,行为与 better-sidebar 原始实现一致。
|
|
15
|
+
- 不再依赖 tar 同步——打开文件 = 直接读远程,保存文件 = 直接写远程。
|
|
16
|
+
|
|
5
17
|
## [1.9.3] — 修复 syncDown 删除 .remote-ssh.json 导致终端不工作
|
|
6
18
|
### 修复
|
|
7
19
|
- **`remoteSyncDown` 会清空镜像目录**:syncDown 先 `rm -rf` 镜像目录再 `tar xf` 展开,导致之前写入的 `.remote-ssh.json`(供 shell wrapper 读取连接信息)被删掉,终端 wrapper 检测不到远程工作区,降级到本地 shell。
|
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@
|
|
32
|
+
dsh plugin --profile <name> add @zhangfengshun/dsh-remote-ssh@2.0.0
|
|
33
33
|
```
|
|
34
34
|
|
|
35
35
|
> ⚠️ 安装后需**重启 DSH** 才生效;后续仅修改 Client 半边时刷新浏览器即可。
|
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
|
|
|
@@ -1393,6 +1393,243 @@ function apply(ctx, config) {
|
|
|
1393
1393
|
}
|
|
1394
1394
|
})();
|
|
1395
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
|
+
// 如果 sessionCwd 还是空,尝试用 payload.path 的父目录(文件操作时 path 是文件路径)
|
|
1568
|
+
if ((!sessionCwd || sessionCwd === "") && payload && payload.path) {
|
|
1569
|
+
sessionCwd = payload.path;
|
|
1570
|
+
}
|
|
1571
|
+
// 检查是否远程工作区
|
|
1572
|
+
const remoteInfo = readRemoteInfoSync(sessionCwd);
|
|
1573
|
+
// 如果 cwd 本身没有 .remote-ssh.json,尝试从 payload.path 向上查找
|
|
1574
|
+
let remoteInfoFromPath = remoteInfo;
|
|
1575
|
+
if (!remoteInfo && payload && payload.path) {
|
|
1576
|
+
remoteInfoFromPath = readRemoteInfoSync(payload.path);
|
|
1577
|
+
}
|
|
1578
|
+
ctx.logger?.info("[dsh-remote-ssh] fs.* intercept: method=" + method + " sessionId=" + sessionId + " cwd=" + sessionCwd + " payload.path=" + (payload && payload.path) + " remoteInfo=" + (remoteInfo ? "YES" : "NO") + " remoteInfoFromPath=" + (remoteInfoFromPath ? "YES" : "NO"));
|
|
1579
|
+
let result;
|
|
1580
|
+
if (remoteInfoFromPath && remoteInfoFromPath.keyPath && remoteInfoFromPath.host && remoteInfoFromPath.user) {
|
|
1581
|
+
const actualRemoteInfo = remoteInfo || remoteInfoFromPath;
|
|
1582
|
+
const actualCwd = sessionCwd || (payload && payload.path) || "";
|
|
1583
|
+
// ---- 远程工作区:走 SSH ----
|
|
1584
|
+
const profile = getProfile(actualRemoteInfo.profileId);
|
|
1585
|
+
if (!profile) { writeJson(res, 500, { ok: false, error: { code: "internal", message: "remote profile not found" } }); return; }
|
|
1586
|
+
if (method === "fs.tree") {
|
|
1587
|
+
const localPath = payload.path || actualCwd;
|
|
1588
|
+
const rp = localToRemote(localPath, actualCwd, actualRemoteInfo.remotePath);
|
|
1589
|
+
result = await remoteListForSidebar(profile, rp, localPath);
|
|
1590
|
+
} else if (method === "fs.read") {
|
|
1591
|
+
const localPath = payload.path || actualCwd;
|
|
1592
|
+
const rp = localToRemote(localPath, actualCwd, actualRemoteInfo.remotePath);
|
|
1593
|
+
result = await remoteReadForSidebar(profile, rp);
|
|
1594
|
+
} else if (method === "fs.write") {
|
|
1595
|
+
const localPath = payload.path || actualCwd;
|
|
1596
|
+
const rp = localToRemote(localPath, actualCwd, actualRemoteInfo.remotePath);
|
|
1597
|
+
result = await remoteWriteFile(runPooled, profile, rp, payload.content || "");
|
|
1598
|
+
} else if (method === "fs.search") {
|
|
1599
|
+
const rp = actualRemoteInfo.remotePath;
|
|
1600
|
+
const r = await remoteGlob(runPooled, profile, "*" + (payload.query || "") + "*", rp);
|
|
1601
|
+
result = { entries: (r.files || []).map(function (f) {
|
|
1602
|
+
return { path: join(actualCwd, f), isDir: false };
|
|
1603
|
+
}), truncated: !!r.truncated };
|
|
1604
|
+
} else {
|
|
1605
|
+
writeJson(res, 404, { ok: false, error: { code: "not-found", message: "unknown method " + method } }); return;
|
|
1606
|
+
}
|
|
1607
|
+
} else {
|
|
1608
|
+
// ---- 本地工作区:走本地 fs ----
|
|
1609
|
+
if (method === "fs.tree") {
|
|
1610
|
+
const p = payload.path || sessionCwd;
|
|
1611
|
+
result = await localListDir(p, LIST_LIMIT);
|
|
1612
|
+
} else if (method === "fs.read") {
|
|
1613
|
+
result = await localReadText(payload.path, READ_LIMIT);
|
|
1614
|
+
} else if (method === "fs.write") {
|
|
1615
|
+
result = await localWriteFile(payload.path, payload.content || "");
|
|
1616
|
+
} else if (method === "fs.search") {
|
|
1617
|
+
result = await localSearchFiles(sessionCwd, payload.query || "");
|
|
1618
|
+
} else {
|
|
1619
|
+
writeJson(res, 404, { ok: false, error: { code: "not-found", message: "unknown method " + method } }); return;
|
|
1620
|
+
}
|
|
1621
|
+
}
|
|
1622
|
+
if (result && result.ok === false) {
|
|
1623
|
+
writeJson(res, 400, { ok: false, error: { code: "fs-error", message: result.error || "operation failed" } });
|
|
1624
|
+
} else {
|
|
1625
|
+
writeOk(res, result);
|
|
1626
|
+
}
|
|
1627
|
+
} catch (e) {
|
|
1628
|
+
writeJson(res, 400, { ok: false, error: { code: "fs-error", message: String(e && e.message ? e.message : e) } });
|
|
1629
|
+
}
|
|
1630
|
+
}
|
|
1631
|
+
}), "dsh-remote-ssh: intercept /sidebar/api/fs.* for remote workspaces");
|
|
1632
|
+
|
|
1396
1633
|
// ---- 清理 ----
|
|
1397
1634
|
ctx.effect(() => () => {
|
|
1398
1635
|
clearInterval(idleTimer);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@zhangfengshun/dsh-remote-ssh",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "2.0.1",
|
|
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",
|