@zhangfengshun/dsh-remote-ssh 2.4.2 → 2.4.3
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 +5 -3
- package/README_EN.md +5 -3
- package/lib/index.js +71 -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
|
+
## [2.4.3] — 适配 better-sidebar 0.19 新增端点(fs.rename / fs.remove)+ 版本兼容性核查
|
|
6
|
+
### 新增
|
|
7
|
+
- **拦截 better-sidebar 0.19 新增的 `fs.rename` / `fs.remove` 端点**(exact 路由从 4 条增至 6 条):远程工作区的「重命名 / 删除」此前会落到本地镜像目录(0.19 之前 better-sidebar 没有这两个端点,操作走客户端 API,不存在该问题)。现在:
|
|
8
|
+
- 远程工作区 → 走 SSH(`mv -f --` / `rm -rf --`),语义对齐 better-sidebar(单个路径段校验、目标已存在返回 409、禁止操作工作区根),并失效相关读/列举缓存;
|
|
9
|
+
- 本地工作区 → 本地实现(不覆盖、lstat 判定符号链接、目录递归删除),契约与 better-sidebar 完全一致;
|
|
10
|
+
- 旧版 better-sidebar(无此端点)不调用这两个路由,注册无副作用。
|
|
11
|
+
|
|
12
|
+
### 兼容性核查(重要)
|
|
13
|
+
- **dsh-better-sidebar 0.19.0 / 0.19.1(以及 0.18.1)在当前 DSH Desktop(v2.0.9,DSH 0.1.5-rc.1)上主机半边无法加载**:其 `lib/index.js` 以**值**方式导入 `SessionLogOffset`(`import { SessionLogOffset } from "@deepseek-ai/dsh-session"`),而 DSH 2.0.9 面向插件的模块面只提供该符号的**类型**声明,运行时导入抛 `does not provide an export named 'SessionLogOffset'` → better-sidebar 主机半边(fs/git/terminal/jobs 等全部 API)整体失效,侧边栏文件页签因此回落到「这类内容还没有可用的查看方式。」
|
|
14
|
+
- **已验证的版本矩阵**:`0.19.1`(导入该符号)、`0.19.0`(导入)、`0.18.1`(导入)→ 在 DSH 2.0.9 上不可用;`0.18.0`、`0.17.1`(不导入)→ 可用。本插件在 `0.18.0` 上逐点验证:`fs.tree/read/write/search` 返回形状、`betterSidebar` 服务与 `registerTab`、`/sidebar/upload` 上传拦截全部咬合。
|
|
15
|
+
- 建议:在 better-sidebar 修复该导入(或 DSH 恢复该运行时导出)之前,desktop profile 使用 **dsh-better-sidebar@0.18.0**;本插件对 0.18/0.19 两套契约均已适配。
|
|
16
|
+
|
|
5
17
|
## [2.4.2] — 修复设置导航显示器图标「先齿轮后显示器」闪现
|
|
6
18
|
### 修复
|
|
7
19
|
- **图标替换从防抖路径改为同步执行**:此前设置面板挂载后,显示器图标替换走 500ms 防抖定时器,用户会先看到半秒壳层默认齿轮再变成显示器图标。现在在 MutationObserver 回调里**同步原位替换**——回调是微任务、在浏览器绘制前执行,齿轮刚插入 DOM 就被换掉,**第一帧即最终图标**,无闪现。
|
package/README.md
CHANGED
|
@@ -32,12 +32,14 @@
|
|
|
32
32
|
## 安装
|
|
33
33
|
|
|
34
34
|
```bash
|
|
35
|
-
dsh plugin --profile <name> add @zhangfengshun/dsh-remote-ssh@2.4.
|
|
35
|
+
dsh plugin --profile <name> add @zhangfengshun/dsh-remote-ssh@2.4.3
|
|
36
36
|
```
|
|
37
37
|
|
|
38
38
|
> 安装后需**重启 DSH**。`@zhangfengshun/dsh-remote-ssh` 必须在 bundles 列表中排在 `dsh-better-sidebar` **之后**。
|
|
39
39
|
>
|
|
40
|
-
> 内置「文件」页签的 SSH 直读依赖 **dsh-better-sidebar ≥ 0.15** 的文件 API(`/sidebar/api/fs.*`
|
|
40
|
+
> 内置「文件」页签的 SSH 直读依赖 **dsh-better-sidebar ≥ 0.15** 的文件 API(`/sidebar/api/fs.*` 端点),请勿使用更早版本。
|
|
41
|
+
>
|
|
42
|
+
> ⚠️ **版本兼容性(2026-09 实测)**:`dsh-better-sidebar` **0.18.1 / 0.19.0 / 0.19.1** 在 DSH Desktop v2.0.9(DSH 0.1.5-rc.1)上**主机半边无法加载**——它们以值方式导入 `SessionLogOffset`,而当前 DSH 面向插件的模块面只提供该类型声明,导入直接抛错,导致侧边栏文件页签显示「这类内容还没有可用的查看方式。」。请使用 **0.18.0**(本插件已逐点验证)直到上游修复;本插件对 0.18(4 端点)与 0.19(6 端点,含 `fs.rename`/`fs.remove`)两套契约均已适配。
|
|
41
43
|
|
|
42
44
|
## 使用
|
|
43
45
|
|
|
@@ -77,7 +79,7 @@ dsh plugin --profile <name> add @zhangfengshun/dsh-remote-ssh@2.4.1
|
|
|
77
79
|
|
|
78
80
|
## 原理
|
|
79
81
|
|
|
80
|
-
插件注册
|
|
82
|
+
插件注册 6 个 exact 路由(`/sidebar/api/fs.tree`、`fs.read`、`fs.write`、`fs.search`,以及 better-sidebar 0.19 新增的 `fs.rename`、`fs.remove`),在 better-sidebar 的 prefix 路由之前拦截。会话 cwd 含 `.remote-ssh.json` 时走 SSH,否则走本地 fs。客户端看到的是本地镜像路径,Host 自动转换为远程路径——对客户端完全透明。
|
|
81
83
|
|
|
82
84
|
远程读取采用**单往返合并读**:一条池化命令同时返回 `size/mtime` 帧与文件内容(文本类扩展名优先 raw 直传,字节长 + U+FFFD 双校验失败自动回退 base64,结果逐字节一致);配合主机侧结果缓存与变更失效(见下节)。
|
|
83
85
|
|
package/README_EN.md
CHANGED
|
@@ -32,12 +32,14 @@ A **DSH** plugin like **VSCode Remote-SSH**: connect to remote HPC / servers via
|
|
|
32
32
|
## Installation
|
|
33
33
|
|
|
34
34
|
```bash
|
|
35
|
-
dsh plugin --profile <name> add @zhangfengshun/dsh-remote-ssh@2.4.
|
|
35
|
+
dsh plugin --profile <name> add @zhangfengshun/dsh-remote-ssh@2.4.3
|
|
36
36
|
```
|
|
37
37
|
|
|
38
38
|
> **Restart DSH** after installation. `@zhangfengshun/dsh-remote-ssh` must come **after** `dsh-better-sidebar` in the bundles list.
|
|
39
39
|
>
|
|
40
|
-
> The built-in **Files** tab SSH interception relies on the file API of **dsh-better-sidebar ≥ 0.15** (`/sidebar/api/fs.*` endpoints) — do not use older versions
|
|
40
|
+
> The built-in **Files** tab SSH interception relies on the file API of **dsh-better-sidebar ≥ 0.15** (`/sidebar/api/fs.*` endpoints) — do not use older versions.
|
|
41
|
+
>
|
|
42
|
+
> ⚠️ **Version compatibility (measured 2026-09)**: `dsh-better-sidebar` **0.18.1 / 0.19.0 / 0.19.1** cannot load their host half on DSH Desktop v2.0.9 (DSH 0.1.5-rc.1) — they value-import `SessionLogOffset`, which the current DSH plugin-facing module surface exposes only as a type, so the import throws and the sidebar Files tab falls back to "Nothing here can view this kind of content yet." Use **0.18.0** (verified point-by-point) until upstream fixes it; this plugin supports both contracts (4 endpoints on 0.18, 6 including `fs.rename`/`fs.remove` on 0.19).
|
|
41
43
|
|
|
42
44
|
## Usage
|
|
43
45
|
|
|
@@ -77,7 +79,7 @@ All SSH commands default to a **120-second** timeout (issue #5): a hung remote c
|
|
|
77
79
|
|
|
78
80
|
## How It Works
|
|
79
81
|
|
|
80
|
-
The plugin registers
|
|
82
|
+
The plugin registers 6 exact routes (`/sidebar/api/fs.tree`, `fs.read`, `fs.write`, `fs.search`, plus `fs.rename` and `fs.remove` added by better-sidebar 0.19) that intercept better-sidebar's prefix route. When the session cwd contains `.remote-ssh.json`, requests go through SSH; otherwise local fs. The client sees local mirror paths — the Host transparently translates them to remote paths.
|
|
81
83
|
|
|
82
84
|
Remote reads use a **single-roundtrip merged read**: one pooled command returns the `size/mtime` frame plus the file content (text extensions prefer raw transfer with byte-length + U+FFFD validation and automatic base64 fallback — results are byte-identical), combined with host-side result caching and change invalidation (see below).
|
|
83
85
|
|
package/lib/index.js
CHANGED
|
@@ -10,10 +10,10 @@
|
|
|
10
10
|
*/
|
|
11
11
|
import z from "schemastery";
|
|
12
12
|
import { defineTool } from "@deepseek-ai/dsh-tools";
|
|
13
|
-
import { mkdir, rm, writeFile, readFile, opendir, stat, open, rename, readdir } from "node:fs/promises";
|
|
13
|
+
import { mkdir, rm, writeFile, readFile, opendir, stat, open, rename, readdir, lstat } from "node:fs/promises";
|
|
14
14
|
import { existsSync, readFileSync } from "node:fs";
|
|
15
15
|
import { homedir } from "node:os";
|
|
16
|
-
import { join, dirname } from "node:path";
|
|
16
|
+
import { join, dirname, basename } from "node:path";
|
|
17
17
|
|
|
18
18
|
/** Plugin identity for cordis.yml rows. */
|
|
19
19
|
const name = "@zhangfengshun/dsh-remote-ssh";
|
|
@@ -2139,6 +2139,28 @@ function apply(ctx, config) {
|
|
|
2139
2139
|
return { ok: true };
|
|
2140
2140
|
}
|
|
2141
2141
|
|
|
2142
|
+
// 本地 rename(对齐 better-sidebar 0.19 的 fs.rename 契约:单段名、不覆盖、返回新绝对路径)
|
|
2143
|
+
async function localRenameEntry(path, name) {
|
|
2144
|
+
if (!path) return { ok: false, error: "path 为必填项" };
|
|
2145
|
+
if (!name || name === "." || name === ".." || /[\\/]/.test(name)) {
|
|
2146
|
+
return { ok: false, error: "name 必须是单个路径段" };
|
|
2147
|
+
}
|
|
2148
|
+
if (basename(path) === name) return { ok: true, path: path };
|
|
2149
|
+
const dest = join(dirname(path), name);
|
|
2150
|
+
if (existsSync(dest)) return { ok: false, error: "\"" + name + "\" 已存在" };
|
|
2151
|
+
await rename(path, dest);
|
|
2152
|
+
return { ok: true, path: dest };
|
|
2153
|
+
}
|
|
2154
|
+
|
|
2155
|
+
// 本地 remove(文件 unlink / 目录递归;lstat 判定,符号链接只删链接本身)
|
|
2156
|
+
async function localRemoveEntry(path) {
|
|
2157
|
+
if (!path) return { ok: false, error: "path 为必填项" };
|
|
2158
|
+
const st = await lstat(path);
|
|
2159
|
+
if (st.isDirectory()) await rm(path, { recursive: true, force: true });
|
|
2160
|
+
else await rm(path, { force: true });
|
|
2161
|
+
return { ok: true, path: path };
|
|
2162
|
+
}
|
|
2163
|
+
|
|
2142
2164
|
// 本地 searchFiles(简单递归文件名搜索)
|
|
2143
2165
|
async function localSearchFiles(root, query) {
|
|
2144
2166
|
if (!query) return { entries: [] };
|
|
@@ -2247,6 +2269,46 @@ function apply(ctx, config) {
|
|
|
2247
2269
|
result = { entries: (r.files || []).map(function (f) {
|
|
2248
2270
|
return { path: join(remoteBase, f), isDir: false };
|
|
2249
2271
|
}), truncated: !!r.truncated };
|
|
2272
|
+
} else if (method === "fs.rename") {
|
|
2273
|
+
// better-sidebar 0.19 新增端点:重命名必须落在远端,绝不能让本地镜像目录被改。
|
|
2274
|
+
const localPath = payload.path || "";
|
|
2275
|
+
const name = String(payload.name || "");
|
|
2276
|
+
if (!localPath) { writeJson(res, 400, { ok: false, error: { code: "bad-request", message: "path is required" } }); return; }
|
|
2277
|
+
if (!name || name === "." || name === ".." || /[\\/]/.test(name)) {
|
|
2278
|
+
writeJson(res, 400, { ok: false, error: { code: "bad-request", message: "name must be a single path segment" } }); return;
|
|
2279
|
+
}
|
|
2280
|
+
const srcRemote = localToRemote(localPath, remoteBase, remoteInfo.remotePath);
|
|
2281
|
+
if (srcRemote === remoteInfo.remotePath) {
|
|
2282
|
+
writeJson(res, 400, { ok: false, error: { code: "fs-error", message: "cannot rename the workspace root" } }); return;
|
|
2283
|
+
}
|
|
2284
|
+
const dstRemote = posixDirname(srcRemote) + "/" + name;
|
|
2285
|
+
const exists = await runPooled(profile, "test -e " + shellQuotePath(dstRemote), undefined, 4096);
|
|
2286
|
+
if (exists.ok) {
|
|
2287
|
+
writeJson(res, 409, { ok: false, error: { code: "fs-error", message: "\"" + name + "\" already exists" } }); return;
|
|
2288
|
+
}
|
|
2289
|
+
const mv = await runPooled(profile, "mv -f -- " + shellQuotePath(srcRemote) + " " + shellQuotePath(dstRemote), undefined, 4096);
|
|
2290
|
+
if (!mv.ok) {
|
|
2291
|
+
writeJson(res, 400, { ok: false, error: { code: "fs-error", message: String(mv.stderr || mv.error || "rename failed").trim().slice(0, 500) } }); return;
|
|
2292
|
+
}
|
|
2293
|
+
invalidateRemoteCaches(profile, srcRemote, { subtree: true });
|
|
2294
|
+
invalidateRemoteCaches(profile, dstRemote, { subtree: true });
|
|
2295
|
+
result = { path: join(dirname(localPath), name) };
|
|
2296
|
+
} else if (method === "fs.remove") {
|
|
2297
|
+
// better-sidebar 0.19 新增端点:删除同样必须在远端执行(本地镜像仅供注册工作区)。
|
|
2298
|
+
const localPath = payload.path || "";
|
|
2299
|
+
if (!localPath) { writeJson(res, 400, { ok: false, error: { code: "bad-request", message: "path is required" } }); return; }
|
|
2300
|
+
const srcRemote = localToRemote(localPath, remoteBase, remoteInfo.remotePath);
|
|
2301
|
+
if (srcRemote === remoteInfo.remotePath) {
|
|
2302
|
+
writeJson(res, 400, { ok: false, error: { code: "fs-error", message: "cannot remove the workspace root" } }); return;
|
|
2303
|
+
}
|
|
2304
|
+
const rmv = await runPooled(profile, "rm -rf -- " + shellQuotePath(srcRemote), undefined, 4096);
|
|
2305
|
+
if (!rmv.ok) {
|
|
2306
|
+
writeJson(res, 400, { ok: false, error: { code: "fs-error", message: String(rmv.stderr || rmv.error || "remove failed").trim().slice(0, 500) } }); return;
|
|
2307
|
+
}
|
|
2308
|
+
// 本地镜像若有同名副本一并清理(镜像不是事实来源,失败可忽略)。
|
|
2309
|
+
try { await rm(localPath, { recursive: true, force: true }); } catch (e) {}
|
|
2310
|
+
invalidateRemoteCaches(profile, srcRemote, { subtree: true });
|
|
2311
|
+
result = { path: localPath };
|
|
2250
2312
|
} else {
|
|
2251
2313
|
writeJson(res, 404, { ok: false, error: { code: "not-found", message: "unknown method " + method } }); return;
|
|
2252
2314
|
}
|
|
@@ -2261,6 +2323,10 @@ function apply(ctx, config) {
|
|
|
2261
2323
|
result = await localWriteFile(payload.path, payload.content || "");
|
|
2262
2324
|
} else if (method === "fs.search") {
|
|
2263
2325
|
result = await localSearchFiles(sessionCwd, payload.query || "");
|
|
2326
|
+
} else if (method === "fs.rename") {
|
|
2327
|
+
result = await localRenameEntry(payload.path, String(payload.name || ""));
|
|
2328
|
+
} else if (method === "fs.remove") {
|
|
2329
|
+
result = await localRemoveEntry(payload.path);
|
|
2264
2330
|
} else {
|
|
2265
2331
|
writeJson(res, 404, { ok: false, error: { code: "not-found", message: "unknown method " + method } }); return;
|
|
2266
2332
|
}
|
|
@@ -2275,7 +2341,9 @@ function apply(ctx, config) {
|
|
|
2275
2341
|
}
|
|
2276
2342
|
}
|
|
2277
2343
|
|
|
2278
|
-
|
|
2344
|
+
// fs.rename / fs.remove 是 better-sidebar 0.19 新增端点:旧版客户端不会调用,
|
|
2345
|
+
// 注册它们对旧版无副作用;新版下远程工作区的重命名/删除才不会落到本地镜像。
|
|
2346
|
+
["fs.tree", "fs.read", "fs.write", "fs.search", "fs.rename", "fs.remove"].forEach(function (m) {
|
|
2279
2347
|
ctx.effect(() => ctx.webServer.register({
|
|
2280
2348
|
kind: "exact",
|
|
2281
2349
|
path: "/sidebar/api/" + m,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@zhangfengshun/dsh-remote-ssh",
|
|
3
|
-
"version": "2.4.
|
|
3
|
+
"version": "2.4.3",
|
|
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",
|