dsh-zspace 0.1.2 → 0.3.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 +11 -0
- package/README.md +24 -1
- package/cordis.patch.yml +12 -0
- package/lib/README.md +3 -0
- package/lib/client/relay.js +30 -0
- package/lib/client/router.js +132 -0
- package/lib/client/webdav.js +622 -0
- package/lib/client.js +55 -22
- package/lib/config.js +34 -0
- package/lib/index.js +7 -0
- package/lib/tools/status.js +21 -5
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,16 @@
|
|
|
1
1
|
# 变更日志
|
|
2
2
|
|
|
3
|
+
## 0.3.0 — 2026-10-07
|
|
4
|
+
|
|
5
|
+
**双通道**:同一网络时直连 NAS 的 WebDAV,跨网络继续走桌面客户端云中转,插件自动选路。工具名与参数**完全不变**(仍是 13 个),既有配置继续可用。
|
|
6
|
+
|
|
7
|
+
- 新增 `lib/client/webdav.js`:WebDAV 传输层(PROPFIND 列目录/取元信息、MKCOL、PUT、GET(含 Range)、MOVE、COPY、DELETE、Basic 鉴权、namespace 无关的 multistatus 解析、NAS 路径 ↔ DAV 路径双向映射)。
|
|
8
|
+
- 新增 `lib/client/router.js`:选路与回退。`auto`(默认)按**可达性**探测(1.5s PROPFIND,结果缓存 60s);**运行中** WebDAV 失败(超时/拒绝连接/5xx)会标记下线 30s 并在**同一次调用内**改用中转重试,业务错误(401/403/404/405/409、`N00…`)不触发回退。
|
|
9
|
+
- 新增配置:`transportMode`(auto/webdav/relay)、`webdavUrl`、`webdavUser`、`webdavPassword`、`webdavHomePath`、`webdavPublicPath`、`webdavProbeTimeoutMs`。**密码建议只放环境变量 `ZS_WEBDAV_PASSWORD`(可选 `ZS_WEBDAV_USER`),不要写进 patch**。
|
|
10
|
+
- `zspace_status` 增加 `transport` / `webdavUrl` / `webdavProbe` 字段,并说明当前在用哪条通道、不可用时两边的原因。
|
|
11
|
+
- WebDAV 通道的收益:直连低延迟、不依赖桌面客户端在线、目录列表一次 `PROPFIND` 拿全(没有中转 50 行分页)。注意 `zspace_remove` 在直连通道下是 NAS 侧删除语义(是否进回收站由 NAS 决定)。
|
|
12
|
+
- 验证:**36 项测试**(新增 `test/webdav.test.js` 7 项,含真 socket 的 mock WebDAV 服务器、鉴权失败/不可达探测、auto 选路、运行期回退、强制模式不回退);真机自检新增通道诊断步骤(配了密码时还会跑一遍 WebDAV 直连写→读往返)。
|
|
13
|
+
|
|
3
14
|
## 0.1.2 — 2026-10-06
|
|
4
15
|
|
|
5
16
|
新增一个写入类工具 + 一个配置项,并把文档里的真实池名换成占位符(**无破坏性变更**,工具表新增一行):
|
package/README.md
CHANGED
|
@@ -30,11 +30,28 @@ dsh plugin --profile desktop add /绝对路径/dsh-zspace-0.1.0.tgz
|
|
|
30
30
|
2. 该账号对目标空间有权限(个人空间 / 公共空间);
|
|
31
31
|
3. 不需要给 NAS 开任何端口,也不需要 DDNS。
|
|
32
32
|
|
|
33
|
+
## 双通道:同网络直连 WebDAV,跨网络走云中转
|
|
34
|
+
|
|
35
|
+
插件对同一套工具**自动选路**,判断依据是**可达性**而不是 IP 网段(本机在 `192.168.31.x` 也能直连 `<nas-ip>` 的 NAS):
|
|
36
|
+
|
|
37
|
+
| 情况 | 通道 | 条件 |
|
|
38
|
+
|---|---|---|
|
|
39
|
+
| 能直连 NAS(同网络) | **WebDAV 直连** | 配了 `webdavUrl` + 环境变量 `ZS_WEBDAV_PASSWORD`;1.5s PROPFIND 探测通过即启用 |
|
|
40
|
+
| 跨网络 / WebDAV 不可达 | 桌面客户端云中转(默认) | 桌面客户端已登录并在运行 |
|
|
41
|
+
|
|
42
|
+
- WebDAV 通道的好处:延迟更低、不依赖桌面客户端在线、目录列表一次 `PROPFIND` 拿全(没有中转的 50 行分页)。
|
|
43
|
+
- 运行中 WebDAV 掉了(换网、NAS 重启)会**自动回退到中转并在同一次调用内重试**,只损失一点延迟,不会让工具调用失败;30s 后重新探测。
|
|
44
|
+
- `transportMode`:`auto`(默认)/ `webdav`(强制,失败直接报错)/ `relay`(保持旧行为)。
|
|
45
|
+
- 路径映射:NAS 的 `/sata1/my/data/...` ↔ WebDAV 的 `<webdavHomePath>/...`(默认根 `/`);公共空间由 `webdavPublicPath` 指定(默认空=不在 WebDAV 暴露,相关路径自动回到中转)。
|
|
46
|
+
- 极空间要在「系统设置 → 文件服务」里开启 WebDAV(默认端口 `5005`,本机实测可用;`5006` 是自签 HTTPS,证书校验会失败)。
|
|
47
|
+
- 密码只走环境变量(`ZS_WEBDAV_PASSWORD`,可选 `ZS_WEBDAV_USER`),**不要写进 patch 文件**——patch 是明文,会被备份/截图带走。
|
|
48
|
+
- `zspace_remove` 在 WebDAV 通道下是 NAS 侧的删除语义(是否进回收站由 NAS 决定);要确保进回收站请用 `transportMode: relay`。
|
|
49
|
+
|
|
33
50
|
## 工具
|
|
34
51
|
|
|
35
52
|
| 工具 | 作用 |
|
|
36
53
|
|---|---|
|
|
37
|
-
| `zspace_status` |
|
|
54
|
+
| `zspace_status` | 探活:**当前通道**(WebDAV 直连 / 云中转)、代理、账号、NAS、存储池余量、个人/公共空间根路径与条目数。其它工具报错时先跑它 |
|
|
38
55
|
| `zspace_ls` | 列目录,支持 `depth` 递归与 `limit` 预算;自动分页(NAS 单页 50 条) |
|
|
39
56
|
| `zspace_stat` | 单个文件/目录的元信息(大小、修改/创建时间) |
|
|
40
57
|
| `zspace_find` | 按名称在目录树里查找(大小写不敏感子串),会报告扫描了多少条目、是否提前停止 |
|
|
@@ -75,6 +92,12 @@ dsh plugin --profile desktop add /绝对路径/dsh-zspace-0.1.0.tgz
|
|
|
75
92
|
| `apiVersion` | `2.3.2026042401` | NAS Web API 版本号参数 |
|
|
76
93
|
| `homePath` / `publicPath` | 空=自动探测 | 写死个人/公共空间根 |
|
|
77
94
|
| `downloadDir` | 空=`~/Downloads/zspace` | 默认下载目录 |
|
|
95
|
+
| `transportMode` | `auto` | 通道:`auto` / `webdav` / `relay` |
|
|
96
|
+
| `webdavUrl` | 空=关闭 | WebDAV 地址,如 `http://<nas-ip>:5005/` |
|
|
97
|
+
| `webdavUser` | 空 | WebDAV 账号(NAS 账号);密码走 `ZS_WEBDAV_PASSWORD` |
|
|
98
|
+
| `webdavPassword` | 空 | 密码兜底项,建议留空改用环境变量 |
|
|
99
|
+
| `webdavHomePath` / `webdavPublicPath` | `/` / 空 | 个人/公共空间在 WebDAV 里的路径(空=公共空间不暴露) |
|
|
100
|
+
| `webdavProbeTimeoutMs` | `1500` | 直连探测超时 |
|
|
78
101
|
| `readMaxBytes` | `262144` | `zspace_read` 上限 |
|
|
79
102
|
| `writeMaxBytes` | `5242880` | `zspace_write` 单次写入上限(更大的内容用 `zspace_upload`) |
|
|
80
103
|
| `listMaxEntries` | `2000` | 单次列目录/树的上限 |
|
package/cordis.patch.yml
CHANGED
|
@@ -3,6 +3,11 @@
|
|
|
3
3
|
# A patch replaces the targeted row's whole `config`, so a profile that
|
|
4
4
|
# overrides this row must restate every key it wants to keep.
|
|
5
5
|
#
|
|
6
|
+
# WebDAV fast path: when the machine can reach the NAS directly (same network),
|
|
7
|
+
# `transportMode: auto` probes this URL and uses it; otherwise every call falls
|
|
8
|
+
# back to the desktop client relay. The password is NOT stored here — set
|
|
9
|
+
# ZS_WEBDAV_PASSWORD in the host environment (and optionally ZS_WEBDAV_USER).
|
|
10
|
+
#
|
|
6
11
|
# Every value below has a code-side default, so this patch stays valid even on
|
|
7
12
|
# a host without patch-side helper evaluation. `homePath` / `publicPath` are
|
|
8
13
|
# intentionally empty: the plugin discovers them from the NAS storage pools at
|
|
@@ -26,5 +31,12 @@
|
|
|
26
31
|
maxRetries: 2
|
|
27
32
|
smallUploadMaxBytes: 8388608
|
|
28
33
|
sliceSize: 2097152
|
|
34
|
+
transportMode: auto
|
|
35
|
+
webdavUrl: ""
|
|
36
|
+
webdavUser: ""
|
|
37
|
+
webdavPassword: ""
|
|
38
|
+
webdavHomePath: /
|
|
39
|
+
webdavPublicPath: ""
|
|
40
|
+
webdavProbeTimeoutMs: 1500
|
|
29
41
|
promptEnabled: true
|
|
30
42
|
promptOrder: 60
|
package/lib/README.md
CHANGED
|
@@ -17,6 +17,9 @@ lib/
|
|
|
17
17
|
│ ├── spaces.js 153 行 代理探活、存储池、个人/公共空间根探测
|
|
18
18
|
│ ├── browse.js 64 行 分页列目录、单条元信息
|
|
19
19
|
│ ├── mutate.js 102 行 mkdir / rename / move / copy / remove
|
|
20
|
+
│ ├── relay.js 31 行 中转通道聚合(把下面各能力汇总成一个面)
|
|
21
|
+
│ ├── webdav.js 520 行 WebDAV 直连通道(PROPFIND/PUT/MOVE/COPY + 路径双向映射)
|
|
22
|
+
│ ├── router.js 99 行 选路:auto 探测 / 强制 / 运行期回退
|
|
20
23
|
│ ├── upload.js 129 行 单请求上传 + 分片协议
|
|
21
24
|
│ └── download.js 128 行 流式下载、限量读入内存
|
|
22
25
|
├── tools.js 68 行 工具注册表:建 ctx、按顺序调用 13 个工厂
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Relay transport: everything through the desktop client's local proxy.
|
|
3
|
+
*
|
|
4
|
+
* This is the original (cross-network) path, kept as one uniform surface so the
|
|
5
|
+
* router can treat it and the WebDAV transport interchangeably. Each function
|
|
6
|
+
* here is a straight re-export of the capability module that implements it.
|
|
7
|
+
*
|
|
8
|
+
* @module dsh-zspace/client/relay
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
export { check, homePath, isDirectory, pools, publicPath } from "./spaces.js";
|
|
12
|
+
export { info, list } from "./browse.js";
|
|
13
|
+
export { copy, mkdir, move, remove, rename } from "./mutate.js";
|
|
14
|
+
export { upload, uploadSliced } from "./upload.js";
|
|
15
|
+
export { download, openDownload, readFile } from "./download.js";
|
|
16
|
+
|
|
17
|
+
/** Transport name used by the router and status output. */
|
|
18
|
+
export const name = "relay";
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* How to probe this transport. The relay is "up" when the desktop client's
|
|
22
|
+
* local proxy answers — the same check the plugin has always used.
|
|
23
|
+
*
|
|
24
|
+
* @param {import("../client.js").ZSpaceClient} client - live client.
|
|
25
|
+
* @returns {Promise<boolean>} whether the proxy answers.
|
|
26
|
+
*/
|
|
27
|
+
export async function probe(client) {
|
|
28
|
+
const { check } = await import("./spaces.js");
|
|
29
|
+
return await check(client);
|
|
30
|
+
}
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Transport router — WebDAV fast path vs. desktop-client relay.
|
|
3
|
+
*
|
|
4
|
+
* The plugin always speaks the same tool surface; this module decides which
|
|
5
|
+
* transport serves a call:
|
|
6
|
+
*
|
|
7
|
+
* | mode | behaviour |
|
|
8
|
+
* |---|---|
|
|
9
|
+
* | `auto` (default) | probe WebDAV once per minute; usable → WebDAV, otherwise relay |
|
|
10
|
+
* | `webdav` | always WebDAV (failures surface loudly) |
|
|
11
|
+
* | `relay` | always the desktop-client relay (previous behaviour) |
|
|
12
|
+
*
|
|
13
|
+
* "Same network" is decided by **reachability**, not by comparing IP prefixes:
|
|
14
|
+
* a machine on `192.168.31.x` may still route straight to a NAS on
|
|
15
|
+
* `<nas-ip>`, and a machine on the NAS's own subnet may have WebDAV
|
|
16
|
+
* disabled. A 1.5s PROPFIND answers that question directly.
|
|
17
|
+
*
|
|
18
|
+
* Runtime failures on the WebDAV path (timeouts, refused connections, 5xx)
|
|
19
|
+
* mark it down for a cooldown and retry the same call over the relay, so losing
|
|
20
|
+
* the LAN mid-session only costs latency — never a failed tool call. Business
|
|
21
|
+
* errors (401/403/404/405/409, `N00…` codes) are *not* transport failures and
|
|
22
|
+
* are returned as-is.
|
|
23
|
+
*
|
|
24
|
+
* @module dsh-zspace/client/router
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
import * as relay from "./relay.js";
|
|
28
|
+
import { ZSpaceError } from "./errors.js";
|
|
29
|
+
import * as webdav from "./webdav.js";
|
|
30
|
+
|
|
31
|
+
/** How long a probe result is trusted. */
|
|
32
|
+
const PROBE_TTL_MS = 60_000;
|
|
33
|
+
|
|
34
|
+
/** How long to stay on the relay after a WebDAV runtime failure. */
|
|
35
|
+
const DOWN_COOLDOWN_MS = 30_000;
|
|
36
|
+
|
|
37
|
+
/** Methods served by a transport (the rest are relay internals). */
|
|
38
|
+
export const ROUTED_METHODS = [
|
|
39
|
+
"check",
|
|
40
|
+
"pools",
|
|
41
|
+
"isDirectory",
|
|
42
|
+
"homePath",
|
|
43
|
+
"publicPath",
|
|
44
|
+
"list",
|
|
45
|
+
"info",
|
|
46
|
+
"mkdir",
|
|
47
|
+
"rename",
|
|
48
|
+
"move",
|
|
49
|
+
"copy",
|
|
50
|
+
"remove",
|
|
51
|
+
"download",
|
|
52
|
+
"readFile",
|
|
53
|
+
"upload",
|
|
54
|
+
];
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Whether an error means "this transport is unusable right now" (as opposed to
|
|
58
|
+
* a legitimate answer about the path or the credentials).
|
|
59
|
+
*
|
|
60
|
+
* @param {unknown} error - thrown value.
|
|
61
|
+
* @returns {boolean} true when falling back to the relay is the right move.
|
|
62
|
+
*/
|
|
63
|
+
export function isTransportFailure(error) {
|
|
64
|
+
const code = String(/** @type {{code?: string}} */ (error)?.code ?? "");
|
|
65
|
+
if (["ETIMEDOUT", "EDAV", "EPROXY", "ECONNREFUSED", "ECONNRESET", "ENETUNREACH", "EAI_AGAIN"].includes(code)) return true;
|
|
66
|
+
if (/^HTTP(5\d\d)$/.test(code)) return true;
|
|
67
|
+
return error instanceof TypeError; // undici network failures
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Which transport should serve the next call.
|
|
72
|
+
*
|
|
73
|
+
* @param {import("../client.js").ZSpaceClient} client - live client.
|
|
74
|
+
* @returns {Promise<"webdav"|"relay">} chosen transport.
|
|
75
|
+
*/
|
|
76
|
+
export async function selectTransport(client) {
|
|
77
|
+
const mode = client.transportMode || "auto";
|
|
78
|
+
if (mode !== "webdav" && mode !== "auto") return "relay";
|
|
79
|
+
if (!webdav.configured(client)) return "relay";
|
|
80
|
+
if (mode === "webdav") return "webdav";
|
|
81
|
+
if (client._webdavDownUntil && Date.now() < client._webdavDownUntil) return "relay";
|
|
82
|
+
|
|
83
|
+
const cached = client._webdavProbe;
|
|
84
|
+
if (cached && Date.now() - cached.at < PROBE_TTL_MS) return cached.usable ? "webdav" : "relay";
|
|
85
|
+
|
|
86
|
+
const result = await webdav.probe(client);
|
|
87
|
+
client._webdavProbe = { at: Date.now(), ...result };
|
|
88
|
+
return result.usable ? "webdav" : "relay";
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Human-readable transport state for `zspace_status`.
|
|
93
|
+
*
|
|
94
|
+
* @param {import("../client.js").ZSpaceClient} client - live client.
|
|
95
|
+
* @returns {Promise<{transport: string, configured: boolean, probe: Record<string, any>|null}>} report.
|
|
96
|
+
*/
|
|
97
|
+
export async function transportReport(client) {
|
|
98
|
+
const transport = await selectTransport(client);
|
|
99
|
+
return {
|
|
100
|
+
transport,
|
|
101
|
+
configured: webdav.configured(client),
|
|
102
|
+
probe: client._webdavProbe ?? null,
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Run one capability through the selected transport, with relay fallback.
|
|
108
|
+
*
|
|
109
|
+
* @param {import("../client.js").ZSpaceClient} client - live client.
|
|
110
|
+
* @param {string} method - capability name (see {@link ROUTED_METHODS}).
|
|
111
|
+
* @param {unknown[]} args - arguments after the client.
|
|
112
|
+
* @returns {Promise<any>} capability result.
|
|
113
|
+
*/
|
|
114
|
+
export async function dispatch(client, method, args) {
|
|
115
|
+
const transport = await selectTransport(client);
|
|
116
|
+
const implementation = transport === "webdav" ? webdav : relay;
|
|
117
|
+
const fn = /** @type {Record<string, Function>} */ (implementation)[method];
|
|
118
|
+
if (typeof fn !== "function") throw new ZSpaceError("ENOTIMPL", `传输 ${transport} 未实现 ${method}`);
|
|
119
|
+
try {
|
|
120
|
+
return await fn(client, ...args);
|
|
121
|
+
} catch (error) {
|
|
122
|
+
if (transport !== "webdav" || client.transportMode === "webdav" || !isTransportFailure(error)) throw error;
|
|
123
|
+
client._webdavDownUntil = Date.now() + DOWN_COOLDOWN_MS;
|
|
124
|
+
client._webdavProbe = {
|
|
125
|
+
at: Date.now(),
|
|
126
|
+
reachable: false,
|
|
127
|
+
usable: false,
|
|
128
|
+
reason: `运行中失败,${Math.round(DOWN_COOLDOWN_MS / 1000)}s 内走中转:${error?.message ?? error}`,
|
|
129
|
+
};
|
|
130
|
+
return await /** @type {Record<string, Function>} */ (relay)[method](client, ...args);
|
|
131
|
+
}
|
|
132
|
+
}
|
|
@@ -0,0 +1,622 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* WebDAV transport — the same-network fast path.
|
|
3
|
+
*
|
|
4
|
+
* When the machine can reach the NAS directly (typical on the same LAN), every
|
|
5
|
+
* file operation can go over the NAS's built-in WebDAV service
|
|
6
|
+
* (`http://<nas>:5005/`) instead of the desktop client's cloud relay: lower
|
|
7
|
+
* latency, no dependency on the client being logged in, and directory listing
|
|
8
|
+
* without the relay's 50-row page limit.
|
|
9
|
+
*
|
|
10
|
+
* The plugin's tools speak **NAS paths** (`/sata1/my/data/...`), so this module
|
|
11
|
+
* translates between those and the WebDAV layout:
|
|
12
|
+
*
|
|
13
|
+
* ```
|
|
14
|
+
* NAS /sata1/my/data/相册/a.jpg <-> WebDAV <webdavHomePath>/相册/a.jpg
|
|
15
|
+
* NAS /sata1/public/img/b.png <-> WebDAV <webdavPublicPath>/img/b.png
|
|
16
|
+
* ```
|
|
17
|
+
*
|
|
18
|
+
* The NAS-side roots come from config (`homePath` / `publicPath`) or, failing
|
|
19
|
+
* that, a one-off relay lookup — so results look identical no matter which
|
|
20
|
+
* transport served them.
|
|
21
|
+
*
|
|
22
|
+
* @module dsh-zspace/client/webdav
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
import fs from "node:fs";
|
|
26
|
+
import fsp from "node:fs/promises";
|
|
27
|
+
import path from "node:path";
|
|
28
|
+
import { Readable } from "node:stream";
|
|
29
|
+
import { pipeline } from "node:stream/promises";
|
|
30
|
+
|
|
31
|
+
import { joinRemote, toEntry } from "../format.js";
|
|
32
|
+
import { ZSpaceError } from "./errors.js";
|
|
33
|
+
|
|
34
|
+
/** Environment variable carrying the WebDAV password (kept out of config files). */
|
|
35
|
+
export const PASSWORD_ENV = "ZS_WEBDAV_PASSWORD";
|
|
36
|
+
|
|
37
|
+
/** Environment variable carrying the WebDAV user name. */
|
|
38
|
+
export const USER_ENV = "ZS_WEBDAV_USER";
|
|
39
|
+
|
|
40
|
+
export const name = "webdav";
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Resolve WebDAV credentials: environment first, config as a fallback.
|
|
44
|
+
*
|
|
45
|
+
* @param {import("../client.js").ZSpaceClient} client - live client.
|
|
46
|
+
* @returns {{user: string, password: string, complete: boolean}} credentials.
|
|
47
|
+
*/
|
|
48
|
+
export function credentials(client) {
|
|
49
|
+
const user = process.env[USER_ENV] || client.webdavUser || "";
|
|
50
|
+
const password = process.env[PASSWORD_ENV] || client.webdavPassword || "";
|
|
51
|
+
return { user, password, complete: Boolean(user && password) };
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Whether this transport has everything it needs to be used at all.
|
|
56
|
+
*
|
|
57
|
+
* @param {import("../client.js").ZSpaceClient} client - live client.
|
|
58
|
+
* @returns {boolean} true when a URL and complete credentials are configured.
|
|
59
|
+
*/
|
|
60
|
+
export function configured(client) {
|
|
61
|
+
return Boolean(String(client.webdavUrl ?? "").trim()) && credentials(client).complete;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Base URL without a trailing slash.
|
|
66
|
+
*
|
|
67
|
+
* @param {import("../client.js").ZSpaceClient} client - live client.
|
|
68
|
+
* @returns {string} base URL.
|
|
69
|
+
*/
|
|
70
|
+
function baseUrl(client) {
|
|
71
|
+
return String(client.webdavUrl ?? "").replace(/\/+$/, "");
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Percent-encode every path segment (keeps the separators).
|
|
76
|
+
*
|
|
77
|
+
* @param {string} remotePath - DAV path.
|
|
78
|
+
* @returns {string} encoded path.
|
|
79
|
+
*/
|
|
80
|
+
function encodePath(remotePath) {
|
|
81
|
+
return String(remotePath)
|
|
82
|
+
.split("/")
|
|
83
|
+
.map(segment => encodeURIComponent(segment))
|
|
84
|
+
.join("/");
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Join two DAV paths with single slashes.
|
|
89
|
+
*
|
|
90
|
+
* @param {string} base - base path.
|
|
91
|
+
* @param {string} rest - remainder.
|
|
92
|
+
* @returns {string} joined path, always starting with `/`.
|
|
93
|
+
*/
|
|
94
|
+
function joinDav(base, rest) {
|
|
95
|
+
const left = `/${String(base).replace(/^\/+|\/+$/g, "")}`;
|
|
96
|
+
const right = String(rest).replace(/^\/+/, "");
|
|
97
|
+
const joined = right === "" ? left : `${left === "/" ? "" : left}/${right}`;
|
|
98
|
+
return joined.startsWith("/") ? joined : `/${joined}`;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* One HTTP request against the WebDAV endpoint.
|
|
103
|
+
*
|
|
104
|
+
* @param {import("../client.js").ZSpaceClient} client - live client.
|
|
105
|
+
* @param {object} options - request options.
|
|
106
|
+
* @param {string} options.method - HTTP method (PROPFIND/MKCOL/MOVE/... allowed).
|
|
107
|
+
* @param {string} options.davPath - DAV path (already NAS-relative, not encoded).
|
|
108
|
+
* @param {Record<string, string>} [options.headers] - extra headers.
|
|
109
|
+
* @param {BodyInit|null} [options.body] - request body.
|
|
110
|
+
* @param {number} [options.timeoutMs] - per-request timeout.
|
|
111
|
+
* @returns {Promise<Response>} fetch response.
|
|
112
|
+
*/
|
|
113
|
+
async function request(client, { method, davPath, headers = {}, body = null, timeoutMs }) {
|
|
114
|
+
const { user, password } = credentials(client);
|
|
115
|
+
const authorization = `Basic ${Buffer.from(`${user}:${password}`, "utf8").toString("base64")}`;
|
|
116
|
+
const init = {
|
|
117
|
+
method,
|
|
118
|
+
headers: { Authorization: authorization, ...headers },
|
|
119
|
+
signal: AbortSignal.timeout(timeoutMs ?? client.timeoutMs ?? 60_000),
|
|
120
|
+
};
|
|
121
|
+
if (body !== null) {
|
|
122
|
+
init.body = body;
|
|
123
|
+
init.duplex = "half";
|
|
124
|
+
}
|
|
125
|
+
try {
|
|
126
|
+
return await fetch(`${baseUrl(client)}${encodePath(davPath) || "/"}`, init);
|
|
127
|
+
} catch (error) {
|
|
128
|
+
const timedOut = error?.name === "TimeoutError" || error?.name === "AbortError";
|
|
129
|
+
throw new ZSpaceError(
|
|
130
|
+
timedOut ? "ETIMEDOUT" : "EDAV",
|
|
131
|
+
timedOut ? `WebDAV 请求超时:${method} ${davPath}` : `WebDAV 请求失败(${method} ${davPath}):${error?.message ?? error}`,
|
|
132
|
+
{ endpoint: davPath, cause: error },
|
|
133
|
+
);
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Turn a non-2xx response into a ZSpaceError with an actionable message.
|
|
139
|
+
*
|
|
140
|
+
* @param {Response} response - fetch response.
|
|
141
|
+
* @param {string} action - what was attempted (for the message).
|
|
142
|
+
* @returns {Promise<void>} resolves for success statuses.
|
|
143
|
+
* @throws {ZSpaceError} for failures.
|
|
144
|
+
*/
|
|
145
|
+
async function assertOk(response, action) {
|
|
146
|
+
if (response.ok || response.status === 207) return;
|
|
147
|
+
const status = response.status;
|
|
148
|
+
const hints = {
|
|
149
|
+
401: "WebDAV 凭据被拒:检查 webdavUser 与 ZS_WEBDAV_PASSWORD(极空间用 NAS 账号密码,不是桌面客户端 token)",
|
|
150
|
+
403: "WebDAV 无权限:确认该账号对该目录可读写",
|
|
151
|
+
404: "路径不存在:先 zspace_ls 上级目录确认拼写",
|
|
152
|
+
405: "目标已存在或方法不被支持",
|
|
153
|
+
409: "父目录不存在或状态冲突:先建父目录",
|
|
154
|
+
507: "NAS 空间不足",
|
|
155
|
+
};
|
|
156
|
+
throw new ZSpaceError(`HTTP${status}`, `${action} 失败:HTTP ${status}${hints[status] ? ` — ${hints[status]}` : ""}`, { endpoint: action });
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* Local name of a DAV href, decoded and stripped down to a DAV path.
|
|
161
|
+
*
|
|
162
|
+
* @param {string} href - raw href from a PROPFIND response.
|
|
163
|
+
* @returns {string} decoded DAV path.
|
|
164
|
+
*/
|
|
165
|
+
function hrefToDavPath(href) {
|
|
166
|
+
let value = String(href ?? "");
|
|
167
|
+
try {
|
|
168
|
+
value = new URL(value, "http://placeholder").pathname;
|
|
169
|
+
} catch {
|
|
170
|
+
/* keep raw value */
|
|
171
|
+
}
|
|
172
|
+
try {
|
|
173
|
+
value = decodeURIComponent(value);
|
|
174
|
+
} catch {
|
|
175
|
+
/* keep encoded value */
|
|
176
|
+
}
|
|
177
|
+
if (value.length > 1) value = value.replace(/\/+$/, "");
|
|
178
|
+
return value;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* Parse a PROPFIND multistatus body without a full XML dependency.
|
|
183
|
+
*
|
|
184
|
+
* Namespace prefixes differ between servers (`D:`, `d:`, `lp1:`), so every tag
|
|
185
|
+
* is matched by its local name.
|
|
186
|
+
*
|
|
187
|
+
* @param {string} xml - response body.
|
|
188
|
+
* @returns {Array<{href: string, name: string, dir: boolean, size: number, modified: string}>} parsed entries.
|
|
189
|
+
*/
|
|
190
|
+
export function parseMultiStatus(xml) {
|
|
191
|
+
const entries = [];
|
|
192
|
+
for (const block of String(xml).matchAll(/<(?:[\w-]+:)?response\b[\s\S]*?<\/(?:[\w-]+:)?response>/gi)) {
|
|
193
|
+
const chunk = block[0];
|
|
194
|
+
const href = /<(?:[\w-]+:)?href[^>]*>([\s\S]*?)<\/(?:[\w-]+:)?href>/i.exec(chunk)?.[1] ?? "";
|
|
195
|
+
const type = /<(?:[\w-]+:)?resourcetype[^>]*>([\s\S]*?)<\/(?:[\w-]+:)?resourcetype>/i.exec(chunk)?.[1] ?? "";
|
|
196
|
+
const size = /<(?:[\w-]+:)?getcontentlength[^>]*>(\d*)</i.exec(chunk)?.[1] ?? "0";
|
|
197
|
+
const modified = /<(?:[\w-]+:)?getlastmodified[^>]*>([\s\S]*?)</i.exec(chunk)?.[1] ?? "";
|
|
198
|
+
const davPath = hrefToDavPath(href);
|
|
199
|
+
const stamp = Date.parse(modified);
|
|
200
|
+
entries.push({
|
|
201
|
+
href: davPath,
|
|
202
|
+
name: davPath === "/" ? "/" : davPath.slice(davPath.lastIndexOf("/") + 1),
|
|
203
|
+
dir: /collection/i.test(type),
|
|
204
|
+
size: Number(size) || 0,
|
|
205
|
+
modified: Number.isFinite(stamp) ? String(Math.floor(stamp / 1000)) : "",
|
|
206
|
+
});
|
|
207
|
+
}
|
|
208
|
+
return entries;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/**
|
|
212
|
+
* Translate a NAS path the tools use into a WebDAV path.
|
|
213
|
+
*
|
|
214
|
+
* @param {import("../client.js").ZSpaceClient} client - live client.
|
|
215
|
+
* @param {string} nasPath - NAS path such as `/sata1/my/data/a.txt`.
|
|
216
|
+
* @returns {Promise<string>} DAV path.
|
|
217
|
+
* @throws {ZSpaceError} when the path is outside both known roots.
|
|
218
|
+
*/
|
|
219
|
+
export async function toDavPath(client, nasPath) {
|
|
220
|
+
const roots = await nasRoots(client);
|
|
221
|
+
const homeDav = client.webdavHomePath || "/";
|
|
222
|
+
const publicDav = client.webdavPublicPath || "";
|
|
223
|
+
const value = String(nasPath);
|
|
224
|
+
if (roots.home && (value === roots.home || value.startsWith(`${roots.home}/`))) {
|
|
225
|
+
return joinDav(homeDav, value.slice(roots.home.length));
|
|
226
|
+
}
|
|
227
|
+
if (publicDav && roots.public && (value === roots.public || value.startsWith(`${roots.public}/`))) {
|
|
228
|
+
return joinDav(publicDav, value.slice(roots.public.length));
|
|
229
|
+
}
|
|
230
|
+
throw new ZSpaceError(
|
|
231
|
+
"DAV_PATH",
|
|
232
|
+
`WebDAV 通道无法映射该路径(不在个人/公共空间根下):${value}。可在配置里写死 homePath / publicPath,或改用 home: / public: 前缀。`,
|
|
233
|
+
);
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/**
|
|
237
|
+
* Translate a WebDAV path back into the NAS path shape the tools expect.
|
|
238
|
+
*
|
|
239
|
+
* @param {import("../client.js").ZSpaceClient} client - live client.
|
|
240
|
+
* @param {string} davPath - DAV path.
|
|
241
|
+
* @returns {Promise<string>} NAS path.
|
|
242
|
+
*/
|
|
243
|
+
export async function fromDavPath(client, davPath) {
|
|
244
|
+
const roots = await nasRoots(client);
|
|
245
|
+
const homeDav = (client.webdavHomePath || "/").replace(/\/+$/, "") || "/";
|
|
246
|
+
const publicDav = (client.webdavPublicPath || "").replace(/\/+$/, "");
|
|
247
|
+
const value = davPath === "/" ? "/" : davPath.replace(/\/+$/, "");
|
|
248
|
+
if (publicDav && (value === publicDav || value.startsWith(`${publicDav}/`))) {
|
|
249
|
+
return joinRemote(roots.public || "/public", value.slice(publicDav.length));
|
|
250
|
+
}
|
|
251
|
+
const rest = homeDav === "/" ? value : value.slice(homeDav.length);
|
|
252
|
+
return joinRemote(roots.home || "", rest);
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
/**
|
|
256
|
+
* NAS-side roots, resolved once: config first, then a relay lookup.
|
|
257
|
+
*
|
|
258
|
+
* @param {import("../client.js").ZSpaceClient} client - live client.
|
|
259
|
+
* @returns {Promise<{home: string, public: string}>} NAS roots ("" when unknown).
|
|
260
|
+
*/
|
|
261
|
+
export async function nasRoots(client) {
|
|
262
|
+
if (client._davRoots) return client._davRoots;
|
|
263
|
+
let home = client.configuredHomePath || client._homePath || "";
|
|
264
|
+
let publicSpace = client.configuredPublicPath || client._publicPath || "";
|
|
265
|
+
if (!home || !publicSpace) {
|
|
266
|
+
// The relay knows the real roots; one lookup is cheaper than making the
|
|
267
|
+
// user restate them. It is fine for this to fail — the caller then gets a
|
|
268
|
+
// clear "set homePath/publicPath" error.
|
|
269
|
+
const relay = await import("./relay.js");
|
|
270
|
+
if (!home) home = await relay.homePath(client).catch(() => "");
|
|
271
|
+
if (!publicSpace) publicSpace = await relay.publicPath(client).catch(() => "");
|
|
272
|
+
}
|
|
273
|
+
client._davRoots = { home, public: publicSpace };
|
|
274
|
+
return client._davRoots;
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
/**
|
|
278
|
+
* Reachability + credential probe, used by the router.
|
|
279
|
+
*
|
|
280
|
+
* @param {import("../client.js").ZSpaceClient} client - live client.
|
|
281
|
+
* @returns {Promise<{reachable: boolean, usable: boolean, status?: number, reason: string}>} probe result.
|
|
282
|
+
*/
|
|
283
|
+
export async function probe(client) {
|
|
284
|
+
if (!String(client.webdavUrl ?? "").trim()) {
|
|
285
|
+
return { reachable: false, usable: false, reason: "未配置 webdavUrl" };
|
|
286
|
+
}
|
|
287
|
+
if (!credentials(client).complete) {
|
|
288
|
+
return { reachable: false, usable: false, reason: `缺少 WebDAV 凭据(设置 ${USER_ENV} 与 ${PASSWORD_ENV})` };
|
|
289
|
+
}
|
|
290
|
+
try {
|
|
291
|
+
const response = await request(client, {
|
|
292
|
+
method: "PROPFIND",
|
|
293
|
+
davPath: "/",
|
|
294
|
+
headers: { Depth: "0" },
|
|
295
|
+
timeoutMs: client.webdavProbeTimeoutMs ?? 1500,
|
|
296
|
+
});
|
|
297
|
+
if (response.status === 401 || response.status === 403) {
|
|
298
|
+
return { reachable: true, usable: false, status: response.status, reason: `可达但凭据被拒(HTTP ${response.status})` };
|
|
299
|
+
}
|
|
300
|
+
if (response.status >= 500) {
|
|
301
|
+
return { reachable: true, usable: false, status: response.status, reason: `服务端错误(HTTP ${response.status})` };
|
|
302
|
+
}
|
|
303
|
+
return { reachable: true, usable: true, status: response.status, reason: `可达(HTTP ${response.status})` };
|
|
304
|
+
} catch (error) {
|
|
305
|
+
return {
|
|
306
|
+
reachable: false,
|
|
307
|
+
usable: false,
|
|
308
|
+
reason: error?.code === "ETIMEDOUT" ? "探测超时(多半不在同一网络)" : `不可达:${error?.message ?? error}`,
|
|
309
|
+
};
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
/**
|
|
314
|
+
* Connectivity in the shape the tools expect.
|
|
315
|
+
*
|
|
316
|
+
* @param {import("../client.js").ZSpaceClient} client - live client.
|
|
317
|
+
* @returns {Promise<boolean>} whether WebDAV is usable right now.
|
|
318
|
+
*/
|
|
319
|
+
export async function check(client) {
|
|
320
|
+
return (await probe(client)).usable;
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
/**
|
|
324
|
+
* Storage pools are a relay-only concept (WebDAV does not expose them).
|
|
325
|
+
*
|
|
326
|
+
* @returns {Promise<Array<never>>} always empty.
|
|
327
|
+
*/
|
|
328
|
+
export async function pools() {
|
|
329
|
+
return [];
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
/**
|
|
333
|
+
* Whether a path exists and is a collection.
|
|
334
|
+
*
|
|
335
|
+
* @param {import("../client.js").ZSpaceClient} client - live client.
|
|
336
|
+
* @param {string} nasPath - NAS path.
|
|
337
|
+
* @returns {Promise<boolean>} true for existing directories.
|
|
338
|
+
*/
|
|
339
|
+
export async function isDirectory(client, nasPath) {
|
|
340
|
+
try {
|
|
341
|
+
const entry = await info(client, nasPath);
|
|
342
|
+
return entry.dir === true;
|
|
343
|
+
} catch {
|
|
344
|
+
return false;
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
/**
|
|
349
|
+
* NAS path of the personal-space root.
|
|
350
|
+
*
|
|
351
|
+
* @param {import("../client.js").ZSpaceClient} client - live client.
|
|
352
|
+
* @returns {Promise<string>} NAS path.
|
|
353
|
+
*/
|
|
354
|
+
export async function homePath(client) {
|
|
355
|
+
const roots = await nasRoots(client);
|
|
356
|
+
if (!roots.home) throw new ZSpaceError("NOHOME", "WebDAV 模式下无法确定个人空间根:请在配置里设置 homePath");
|
|
357
|
+
return roots.home;
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
/**
|
|
361
|
+
* NAS path of the public-space root.
|
|
362
|
+
*
|
|
363
|
+
* @param {import("../client.js").ZSpaceClient} client - live client.
|
|
364
|
+
* @returns {Promise<string>} NAS path.
|
|
365
|
+
*/
|
|
366
|
+
export async function publicPath(client) {
|
|
367
|
+
const roots = await nasRoots(client);
|
|
368
|
+
if (!roots.public) throw new ZSpaceError("NOPUBLIC", "WebDAV 模式下无法确定公共空间根:请在配置里设置 publicPath(或它在 WebDAV 里不可见)");
|
|
369
|
+
return roots.public;
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
/**
|
|
373
|
+
* List one directory (single PROPFIND, no 50-row paging).
|
|
374
|
+
*
|
|
375
|
+
* @param {import("../client.js").ZSpaceClient} client - live client.
|
|
376
|
+
* @param {string} nasPath - NAS directory.
|
|
377
|
+
* @param {{showHidden?: boolean, maxEntries?: number}} [options] - listing options.
|
|
378
|
+
* @returns {Promise<{entries: Array<ReturnType<typeof toEntry>>, truncated: boolean}>} listing.
|
|
379
|
+
*/
|
|
380
|
+
export async function list(client, nasPath, options = {}) {
|
|
381
|
+
const davPath = await toDavPath(client, nasPath);
|
|
382
|
+
const response = await request(client, { method: "PROPFIND", davPath, headers: { Depth: "1" } });
|
|
383
|
+
await assertOk(response, `PROPFIND ${nasPath}`);
|
|
384
|
+
const xml = await response.text();
|
|
385
|
+
const self = davPath === "/" ? "/" : davPath.replace(/\/+$/, "");
|
|
386
|
+
const maxEntries = options.maxEntries ?? client.listMaxEntries ?? 2000;
|
|
387
|
+
const entries = [];
|
|
388
|
+
let truncated = false;
|
|
389
|
+
for (const raw of parseMultiStatus(xml)) {
|
|
390
|
+
if (raw.href === self || raw.href === `${self}/`) continue;
|
|
391
|
+
if (!raw.name) continue;
|
|
392
|
+
if (!options.showHidden && raw.name.startsWith(".")) continue;
|
|
393
|
+
if (entries.length >= maxEntries) {
|
|
394
|
+
truncated = true;
|
|
395
|
+
break;
|
|
396
|
+
}
|
|
397
|
+
const nas = await fromDavPath(client, raw.href);
|
|
398
|
+
entries.push(
|
|
399
|
+
toEntry({
|
|
400
|
+
name: raw.name,
|
|
401
|
+
path: nas,
|
|
402
|
+
is_dir: raw.dir ? "1" : "0",
|
|
403
|
+
size: String(raw.size),
|
|
404
|
+
modify_time: raw.modified,
|
|
405
|
+
}),
|
|
406
|
+
);
|
|
407
|
+
}
|
|
408
|
+
entries.sort((a, b) => Number(b.dir) - Number(a.dir) || a.name.localeCompare(b.name, "zh"));
|
|
409
|
+
return { entries, truncated };
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
/**
|
|
413
|
+
* Metadata for one path.
|
|
414
|
+
*
|
|
415
|
+
* @param {import("../client.js").ZSpaceClient} client - live client.
|
|
416
|
+
* @param {string} nasPath - NAS path.
|
|
417
|
+
* @returns {Promise<ReturnType<typeof toEntry>>} normalized entry.
|
|
418
|
+
*/
|
|
419
|
+
export async function info(client, nasPath) {
|
|
420
|
+
const davPath = await toDavPath(client, nasPath);
|
|
421
|
+
const response = await request(client, { method: "PROPFIND", davPath, headers: { Depth: "0" } });
|
|
422
|
+
await assertOk(response, `PROPFIND ${nasPath}`);
|
|
423
|
+
const [first] = parseMultiStatus(await response.text());
|
|
424
|
+
if (!first) throw new ZSpaceError("N001315", `文件不存在(${nasPath})`);
|
|
425
|
+
return toEntry({
|
|
426
|
+
name: first.name === "/" ? davPath.slice(davPath.lastIndexOf("/") + 1) : first.name,
|
|
427
|
+
path: nasPath,
|
|
428
|
+
is_dir: first.dir ? "1" : "0",
|
|
429
|
+
size: String(first.size),
|
|
430
|
+
modify_time: first.modified,
|
|
431
|
+
});
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
/**
|
|
435
|
+
* Create a directory (MKCOL).
|
|
436
|
+
*
|
|
437
|
+
* @param {import("../client.js").ZSpaceClient} client - live client.
|
|
438
|
+
* @param {string} nasPath - NAS directory to create.
|
|
439
|
+
* @returns {Promise<ReturnType<typeof toEntry>>} created entry.
|
|
440
|
+
*/
|
|
441
|
+
export async function mkdir(client, nasPath) {
|
|
442
|
+
const davPath = await toDavPath(client, nasPath);
|
|
443
|
+
const response = await request(client, { method: "MKCOL", davPath });
|
|
444
|
+
if (response.status === 405) throw new ZSpaceError("N001212", `目录已存在:${nasPath}`);
|
|
445
|
+
await assertOk(response, `MKCOL ${nasPath}`);
|
|
446
|
+
return toEntry({ name: nasPath.slice(nasPath.lastIndexOf("/") + 1), path: nasPath, is_dir: "1", size: "0" });
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
/**
|
|
450
|
+
* Rename in place (MOVE within the same directory).
|
|
451
|
+
*
|
|
452
|
+
* @param {import("../client.js").ZSpaceClient} client - live client.
|
|
453
|
+
* @param {string} nasPath - NAS path to rename.
|
|
454
|
+
* @param {string} newName - new base name.
|
|
455
|
+
* @returns {Promise<ReturnType<typeof toEntry>>} renamed entry.
|
|
456
|
+
*/
|
|
457
|
+
export async function rename(client, nasPath, newName) {
|
|
458
|
+
const parent = nasPath.slice(0, nasPath.lastIndexOf("/"));
|
|
459
|
+
const target = `${parent}/${newName}`;
|
|
460
|
+
await movePath(client, nasPath, target);
|
|
461
|
+
return toEntry({ name: newName, path: target, is_dir: "0", size: "0" });
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
/**
|
|
465
|
+
* MOVE one NAS path to an absolute destination (used by rename/move).
|
|
466
|
+
*
|
|
467
|
+
* @param {import("../client.js").ZSpaceClient} client - live client.
|
|
468
|
+
* @param {string} from - source NAS path.
|
|
469
|
+
* @param {string} to - destination NAS path.
|
|
470
|
+
* @returns {Promise<void>} resolves when accepted.
|
|
471
|
+
*/
|
|
472
|
+
async function movePath(client, from, to) {
|
|
473
|
+
const source = await toDavPath(client, from);
|
|
474
|
+
const destination = await toDavPath(client, to);
|
|
475
|
+
const response = await request(client, {
|
|
476
|
+
method: "MOVE",
|
|
477
|
+
davPath: source,
|
|
478
|
+
headers: { Destination: `${baseUrl(client)}${encodePath(destination)}`, Overwrite: "F" },
|
|
479
|
+
});
|
|
480
|
+
if (response.status === 412) throw new ZSpaceError("N001212", `目标已存在:${to}`);
|
|
481
|
+
await assertOk(response, `MOVE ${from} -> ${to}`);
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
/**
|
|
485
|
+
* Move several paths into a target directory.
|
|
486
|
+
*
|
|
487
|
+
* @param {import("../client.js").ZSpaceClient} client - live client.
|
|
488
|
+
* @param {string[]} nasPaths - sources.
|
|
489
|
+
* @param {string} to - destination directory.
|
|
490
|
+
* @returns {Promise<void>} resolves when accepted.
|
|
491
|
+
*/
|
|
492
|
+
export async function move(client, nasPaths, to) {
|
|
493
|
+
for (const source of nasPaths) {
|
|
494
|
+
const name = source.slice(source.lastIndexOf("/") + 1);
|
|
495
|
+
await movePath(client, source, `${to.replace(/\/+$/, "")}/${name}`);
|
|
496
|
+
}
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
/**
|
|
500
|
+
* Copy several paths into a target directory.
|
|
501
|
+
*
|
|
502
|
+
* @param {import("../client.js").ZSpaceClient} client - live client.
|
|
503
|
+
* @param {string[]} nasPaths - sources.
|
|
504
|
+
* @param {string} to - destination directory.
|
|
505
|
+
* @returns {Promise<void>} resolves when accepted.
|
|
506
|
+
*/
|
|
507
|
+
export async function copy(client, nasPaths, to) {
|
|
508
|
+
for (const source of nasPaths) {
|
|
509
|
+
const name = source.slice(source.lastIndexOf("/") + 1);
|
|
510
|
+
const from = await toDavPath(client, source);
|
|
511
|
+
const target = await toDavPath(client, `${to.replace(/\/+$/, "")}/${name}`);
|
|
512
|
+
const response = await request(client, {
|
|
513
|
+
method: "COPY",
|
|
514
|
+
davPath: from,
|
|
515
|
+
headers: { Destination: `${baseUrl(client)}${encodePath(target)}`, Overwrite: "F" },
|
|
516
|
+
});
|
|
517
|
+
if (response.status === 412) throw new ZSpaceError("N001212", `目标已存在:${to}/${name}`);
|
|
518
|
+
await assertOk(response, `COPY ${source} -> ${to}`);
|
|
519
|
+
}
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
/**
|
|
523
|
+
* Delete paths (WebDAV DELETE — recycle-bin behaviour is the NAS's call).
|
|
524
|
+
*
|
|
525
|
+
* @param {import("../client.js").ZSpaceClient} client - live client.
|
|
526
|
+
* @param {string[]} nasPaths - paths to delete.
|
|
527
|
+
* @returns {Promise<void>} resolves when accepted.
|
|
528
|
+
*/
|
|
529
|
+
export async function remove(client, nasPaths) {
|
|
530
|
+
for (const target of nasPaths) {
|
|
531
|
+
const davPath = await toDavPath(client, target);
|
|
532
|
+
const response = await request(client, { method: "DELETE", davPath });
|
|
533
|
+
await assertOk(response, `DELETE ${target}`);
|
|
534
|
+
}
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
/**
|
|
538
|
+
* Download a file to a local directory.
|
|
539
|
+
*
|
|
540
|
+
* @param {import("../client.js").ZSpaceClient} client - live client.
|
|
541
|
+
* @param {string} nasPath - NAS file.
|
|
542
|
+
* @param {string} localDir - local destination directory.
|
|
543
|
+
* @param {{name?: string}} [options] - name override.
|
|
544
|
+
* @returns {Promise<{localPath: string, bytes: number}>} written file.
|
|
545
|
+
*/
|
|
546
|
+
export async function download(client, nasPath, localDir, options = {}) {
|
|
547
|
+
const davPath = await toDavPath(client, nasPath);
|
|
548
|
+
const response = await request(client, { method: "GET", davPath });
|
|
549
|
+
await assertOk(response, `GET ${nasPath}`);
|
|
550
|
+
if (!response.body) throw new ZSpaceError("EDAV", `WebDAV 未返回内容:${nasPath}`);
|
|
551
|
+
const dest = path.join(localDir, options.name || path.posix.basename(nasPath));
|
|
552
|
+
await fsp.mkdir(localDir, { recursive: true });
|
|
553
|
+
try {
|
|
554
|
+
await pipeline(Readable.fromWeb(response.body), fs.createWriteStream(dest));
|
|
555
|
+
} catch (error) {
|
|
556
|
+
await fsp.rm(dest, { force: true });
|
|
557
|
+
throw new ZSpaceError("EDOWNLOAD", `写入本地文件失败:${dest}(${error.message})`, { cause: error });
|
|
558
|
+
}
|
|
559
|
+
const stat = await fsp.stat(dest);
|
|
560
|
+
return { localPath: dest, bytes: stat.size };
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
/**
|
|
564
|
+
* Read the head of a file into memory.
|
|
565
|
+
*
|
|
566
|
+
* @param {import("../client.js").ZSpaceClient} client - live client.
|
|
567
|
+
* @param {string} nasPath - NAS file.
|
|
568
|
+
* @param {{maxBytes?: number}} [options] - byte cap.
|
|
569
|
+
* @returns {Promise<{buffer: Buffer, truncated: boolean, bytes: number}>} content.
|
|
570
|
+
*/
|
|
571
|
+
export async function readFile(client, nasPath, options = {}) {
|
|
572
|
+
const maxBytes = options.maxBytes ?? 262_144;
|
|
573
|
+
const davPath = await toDavPath(client, nasPath);
|
|
574
|
+
const response = await request(client, { method: "GET", davPath, headers: { Range: `bytes=0-${maxBytes - 1}` } });
|
|
575
|
+
await assertOk(response, `GET ${nasPath}`);
|
|
576
|
+
if (!response.body) return { buffer: Buffer.alloc(0), truncated: false, bytes: 0 };
|
|
577
|
+
const buffer = Buffer.from(await response.arrayBuffer());
|
|
578
|
+
const truncated = response.status === 206 ? buffer.length >= maxBytes : buffer.length > maxBytes;
|
|
579
|
+
return { buffer: buffer.subarray(0, maxBytes), truncated, bytes: Math.min(buffer.length, maxBytes) };
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
/**
|
|
583
|
+
* Upload a local file (single PUT).
|
|
584
|
+
*
|
|
585
|
+
* @param {import("../client.js").ZSpaceClient} client - live client.
|
|
586
|
+
* @param {string} localPath - local file.
|
|
587
|
+
* @param {string} remoteDir - NAS destination directory.
|
|
588
|
+
* @param {{name?: string}} [options] - name override.
|
|
589
|
+
* @returns {Promise<{remotePath: string, bytes: number, method: "put"}>} upload result.
|
|
590
|
+
*/
|
|
591
|
+
export async function upload(client, localPath, remoteDir, options = {}) {
|
|
592
|
+
const stat = await fsp.stat(localPath);
|
|
593
|
+
const name = options.name || path.basename(localPath);
|
|
594
|
+
const target = joinRemote(remoteDir, name);
|
|
595
|
+
const davPath = await toDavPath(client, target);
|
|
596
|
+
const response = await request(client, {
|
|
597
|
+
method: "PUT",
|
|
598
|
+
davPath,
|
|
599
|
+
headers: { "Content-Type": "application/octet-stream", "Content-Length": String(stat.size) },
|
|
600
|
+
body: Readable.toWeb(fs.createReadStream(localPath)),
|
|
601
|
+
});
|
|
602
|
+
await assertOk(response, `PUT ${target}`);
|
|
603
|
+
return { remotePath: target, bytes: stat.size, method: "put" };
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
/**
|
|
607
|
+
* Not used by the WebDAV transport (PUT is never sliced).
|
|
608
|
+
*
|
|
609
|
+
* @returns {Promise<never>} always throws.
|
|
610
|
+
*/
|
|
611
|
+
export async function uploadSliced() {
|
|
612
|
+
throw new ZSpaceError("EDAV", "WebDAV 通道不使用分片上传");
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
/**
|
|
616
|
+
* Not used by the WebDAV transport (download() opens its own request).
|
|
617
|
+
*
|
|
618
|
+
* @returns {Promise<never>} always throws.
|
|
619
|
+
*/
|
|
620
|
+
export async function openDownload() {
|
|
621
|
+
throw new ZSpaceError("EDAV", "WebDAV 通道请使用 download()");
|
|
622
|
+
}
|
package/lib/client.js
CHANGED
|
@@ -24,13 +24,12 @@
|
|
|
24
24
|
*/
|
|
25
25
|
|
|
26
26
|
import { DEFAULT_API_VERSION, loadCredentials, resolveBaseUrl } from "./auth.js";
|
|
27
|
-
import {
|
|
28
|
-
import {
|
|
29
|
-
import {
|
|
30
|
-
import {
|
|
31
|
-
import {
|
|
32
|
-
import
|
|
33
|
-
import { endpointUrl, formBody, retrying, send, parse, post, DEFAULT_PAGE_SIZE } from "./client/transport.js";
|
|
27
|
+
import { authHeaders, commonParams, identity } from "./client/session.js";
|
|
28
|
+
import { DEFAULT_PAGE_SIZE, endpointUrl, formBody, parse, post, retrying, send } from "./client/transport.js";
|
|
29
|
+
import { uploadSliced } from "./client/upload.js";
|
|
30
|
+
import { openDownload } from "./client/download.js";
|
|
31
|
+
import { dispatch, transportReport } from "./client/router.js";
|
|
32
|
+
import * as webdav from "./client/webdav.js";
|
|
34
33
|
export { ZSpaceError } from "./client/errors.js";
|
|
35
34
|
|
|
36
35
|
export class ZSpaceClient {
|
|
@@ -49,6 +48,12 @@ export class ZSpaceClient {
|
|
|
49
48
|
* @param {number} [options.listMaxEntries] - directory listing ceiling.
|
|
50
49
|
* @param {number} [options.smallUploadMaxBytes] - above this, upload sliced.
|
|
51
50
|
* @param {number} [options.sliceSize] - sliced upload chunk size.
|
|
51
|
+
* @param {"auto"|"webdav"|"relay"} [options.transportMode] - transport preference (default `auto`).
|
|
52
|
+
* @param {string} [options.webdavUrl] - WebDAV base URL, e.g. `http://<nas-ip>:5005/`.
|
|
53
|
+
* @param {string} [options.webdavUser] - WebDAV user (NAS account); password from `ZS_WEBDAV_PASSWORD`.
|
|
54
|
+
* @param {string} [options.webdavHomePath] - DAV path mapped to the personal space (default `/`).
|
|
55
|
+
* @param {string} [options.webdavPublicPath] - DAV path mapped to the public space (empty = not exposed).
|
|
56
|
+
* @param {number} [options.webdavProbeTimeoutMs] - LAN probe timeout (default 1500).
|
|
52
57
|
*/
|
|
53
58
|
constructor(options = {}) {
|
|
54
59
|
this.baseUrl = resolveBaseUrl(options.baseUrl);
|
|
@@ -61,12 +66,22 @@ export class ZSpaceClient {
|
|
|
61
66
|
this.listMaxEntries = options.listMaxEntries ?? 2000;
|
|
62
67
|
this.smallUploadMaxBytes = options.smallUploadMaxBytes ?? 8 * 1024 * 1024;
|
|
63
68
|
this.sliceSize = options.sliceSize ?? 2 * 1024 * 1024;
|
|
69
|
+
this.transportMode = options.transportMode || "auto";
|
|
70
|
+
this.webdavUrl = options.webdavUrl || "";
|
|
71
|
+
this.webdavUser = options.webdavUser || "";
|
|
72
|
+
this.webdavPassword = options.webdavPassword || "";
|
|
73
|
+
this.webdavHomePath = options.webdavHomePath || "/";
|
|
74
|
+
this.webdavPublicPath = options.webdavPublicPath || "";
|
|
75
|
+
this.webdavProbeTimeoutMs = options.webdavProbeTimeoutMs ?? 1500;
|
|
64
76
|
this.configuredHomePath = options.homePath || "";
|
|
65
77
|
this.configuredPublicPath = options.publicPath || "";
|
|
66
78
|
this._credentials = options.credentials ?? null;
|
|
67
79
|
this._homePath = null;
|
|
68
80
|
this._publicPath = null;
|
|
69
81
|
this._pools = null;
|
|
82
|
+
this._webdavProbe = null;
|
|
83
|
+
this._webdavDownUntil = 0;
|
|
84
|
+
this._davRoots = null;
|
|
70
85
|
}
|
|
71
86
|
|
|
72
87
|
/**
|
|
@@ -98,55 +113,73 @@ export class ZSpaceClient {
|
|
|
98
113
|
}
|
|
99
114
|
|
|
100
115
|
async check() {
|
|
101
|
-
return await
|
|
116
|
+
return await dispatch(this, "check", []);
|
|
102
117
|
}
|
|
103
118
|
|
|
104
119
|
async pools() {
|
|
105
|
-
return await
|
|
120
|
+
return await dispatch(this, "pools", []);
|
|
106
121
|
}
|
|
107
122
|
|
|
108
123
|
async isDirectory(remotePath) {
|
|
109
|
-
return await
|
|
124
|
+
return await dispatch(this, "isDirectory", [remotePath]);
|
|
110
125
|
}
|
|
111
126
|
|
|
112
127
|
async homePath() {
|
|
113
|
-
return await
|
|
128
|
+
return await dispatch(this, "homePath", []);
|
|
114
129
|
}
|
|
115
130
|
|
|
116
131
|
async publicPath() {
|
|
117
|
-
return await
|
|
132
|
+
return await dispatch(this, "publicPath", []);
|
|
118
133
|
}
|
|
119
134
|
|
|
120
135
|
async list(remotePath, options = {}) {
|
|
121
|
-
return await
|
|
136
|
+
return await dispatch(this, "list", [remotePath, options]);
|
|
122
137
|
}
|
|
123
138
|
|
|
124
139
|
async info(remotePath) {
|
|
125
|
-
return await
|
|
140
|
+
return await dispatch(this, "info", [remotePath]);
|
|
126
141
|
}
|
|
127
142
|
|
|
128
143
|
async mkdir(remotePath) {
|
|
129
|
-
return await
|
|
144
|
+
return await dispatch(this, "mkdir", [remotePath]);
|
|
130
145
|
}
|
|
131
146
|
|
|
132
147
|
async rename(remotePath, newName) {
|
|
133
|
-
return await
|
|
148
|
+
return await dispatch(this, "rename", [remotePath, newName]);
|
|
134
149
|
}
|
|
135
150
|
|
|
136
151
|
async move(remotePaths, to) {
|
|
137
|
-
return await
|
|
152
|
+
return await dispatch(this, "move", [remotePaths, to]);
|
|
138
153
|
}
|
|
139
154
|
|
|
140
155
|
async copy(remotePaths, to) {
|
|
141
|
-
return await
|
|
156
|
+
return await dispatch(this, "copy", [remotePaths, to]);
|
|
142
157
|
}
|
|
143
158
|
|
|
144
159
|
async remove(remotePaths) {
|
|
145
|
-
return await
|
|
160
|
+
return await dispatch(this, "remove", [remotePaths]);
|
|
146
161
|
}
|
|
147
162
|
|
|
148
163
|
async upload(localPath, remoteDir, options = {}) {
|
|
149
|
-
return await
|
|
164
|
+
return await dispatch(this, "upload", [localPath, remoteDir, options]);
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* Probe the WebDAV fast path (reachability + credentials), as reported by `zspace_status`.
|
|
169
|
+
*
|
|
170
|
+
* @returns {Promise<{reachable: boolean, usable: boolean, status?: number, reason: string}>} probe result.
|
|
171
|
+
*/
|
|
172
|
+
async probeWebdav() {
|
|
173
|
+
return await webdav.probe(this);
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* Which transport the next call will use, plus the last probe result.
|
|
178
|
+
*
|
|
179
|
+
* @returns {Promise<{transport: string, configured: boolean, probe: Record<string, any>|null}>} transport report.
|
|
180
|
+
*/
|
|
181
|
+
async transportReport() {
|
|
182
|
+
return await transportReport(this);
|
|
150
183
|
}
|
|
151
184
|
|
|
152
185
|
async uploadSliced(localPath, target, total, stat, onProgress) {
|
|
@@ -154,11 +187,11 @@ export class ZSpaceClient {
|
|
|
154
187
|
}
|
|
155
188
|
|
|
156
189
|
async download(remotePath, localDir, options = {}) {
|
|
157
|
-
return await
|
|
190
|
+
return await dispatch(this, "download", [remotePath, localDir, options]);
|
|
158
191
|
}
|
|
159
192
|
|
|
160
193
|
async readFile(remotePath, options = {}) {
|
|
161
|
-
return await
|
|
194
|
+
return await dispatch(this, "readFile", [remotePath, options]);
|
|
162
195
|
}
|
|
163
196
|
|
|
164
197
|
async openDownload(remotePath) {
|
package/lib/config.js
CHANGED
|
@@ -33,6 +33,13 @@ const DEFAULTS = Object.freeze({
|
|
|
33
33
|
maxRetries: 2,
|
|
34
34
|
smallUploadMaxBytes: 8 * 1024 * 1024,
|
|
35
35
|
sliceSize: 2 * 1024 * 1024,
|
|
36
|
+
transportMode: "auto",
|
|
37
|
+
webdavUrl: "",
|
|
38
|
+
webdavUser: "",
|
|
39
|
+
webdavPassword: "",
|
|
40
|
+
webdavHomePath: "/",
|
|
41
|
+
webdavPublicPath: "",
|
|
42
|
+
webdavProbeTimeoutMs: 1500,
|
|
36
43
|
promptEnabled: true,
|
|
37
44
|
promptOrder: 60,
|
|
38
45
|
});
|
|
@@ -64,6 +71,20 @@ export const Config = z.object({
|
|
|
64
71
|
smallUploadMaxBytes: z.natural().default(8 * 1024 * 1024),
|
|
65
72
|
/** Slice size for the sliced upload protocol (NAS remote mode caps at 2 MB). */
|
|
66
73
|
sliceSize: z.natural().default(2 * 1024 * 1024),
|
|
74
|
+
/** Transport preference: `auto` (LAN WebDAV when reachable), `webdav`, or `relay`. */
|
|
75
|
+
transportMode: z.string().default("auto"),
|
|
76
|
+
/** WebDAV base URL, e.g. `http://<nas-ip>:5005/`; empty disables the fast path. */
|
|
77
|
+
webdavUrl: z.string().default(""),
|
|
78
|
+
/** WebDAV user (the NAS account). The password belongs in `ZS_WEBDAV_PASSWORD`, not here. */
|
|
79
|
+
webdavUser: z.string().default(""),
|
|
80
|
+
/** WebDAV password fallback; prefer the `ZS_WEBDAV_PASSWORD` environment variable. */
|
|
81
|
+
webdavPassword: z.string().default(""),
|
|
82
|
+
/** DAV path that maps to the personal space (default `/` = WebDAV root). */
|
|
83
|
+
webdavHomePath: z.string().default("/"),
|
|
84
|
+
/** DAV path that maps to the public space; empty means it is not exposed over WebDAV. */
|
|
85
|
+
webdavPublicPath: z.string().default(""),
|
|
86
|
+
/** LAN probe timeout in milliseconds. */
|
|
87
|
+
webdavProbeTimeoutMs: z.natural().default(1500),
|
|
67
88
|
/** Whether to contribute the NAS guide to the system prompt. */
|
|
68
89
|
promptEnabled: z.boolean().default(true),
|
|
69
90
|
/** Ordering of that section. */
|
|
@@ -96,6 +117,7 @@ export function validateConfig(config) {
|
|
|
96
117
|
["maxRetries", config.maxRetries],
|
|
97
118
|
["smallUploadMaxBytes", config.smallUploadMaxBytes],
|
|
98
119
|
["sliceSize", config.sliceSize],
|
|
120
|
+
["webdavProbeTimeoutMs", config.webdavProbeTimeoutMs],
|
|
99
121
|
["promptOrder", config.promptOrder],
|
|
100
122
|
];
|
|
101
123
|
for (const [field, value] of bounds) {
|
|
@@ -103,6 +125,18 @@ export function validateConfig(config) {
|
|
|
103
125
|
throw new Error(`zspace: 配置项 ${field}=${value} 必须是非负整数(maxRetries 可为 0,其余至少为 1)`);
|
|
104
126
|
}
|
|
105
127
|
}
|
|
128
|
+
if (!["auto", "webdav", "relay"].includes(config.transportMode)) {
|
|
129
|
+
throw new Error(`zspace: transportMode=${config.transportMode} 必须是 auto / webdav / relay 之一`);
|
|
130
|
+
}
|
|
131
|
+
if (config.transportMode === "webdav" && !config.webdavUrl) {
|
|
132
|
+
throw new Error("zspace: transportMode=webdav 时必须配置 webdavUrl");
|
|
133
|
+
}
|
|
134
|
+
if (config.webdavUrl && !/^https?:\/\//.test(config.webdavUrl)) {
|
|
135
|
+
throw new Error(`zspace: webdavUrl 必须以 http:// 或 https:// 开头(当前 ${config.webdavUrl})`);
|
|
136
|
+
}
|
|
137
|
+
if (!config.webdavHomePath.startsWith("/")) {
|
|
138
|
+
throw new Error(`zspace: webdavHomePath 必须以 / 开头(当前 ${config.webdavHomePath})`);
|
|
139
|
+
}
|
|
106
140
|
if (!/^https?:\/\//.test(config.baseUrl)) {
|
|
107
141
|
throw new Error(`zspace: baseUrl 必须以 http:// 或 https:// 开头(当前 ${config.baseUrl})`);
|
|
108
142
|
}
|
package/lib/index.js
CHANGED
|
@@ -44,6 +44,13 @@ export function apply(ctx, config) {
|
|
|
44
44
|
listMaxEntries: settings.listMaxEntries,
|
|
45
45
|
smallUploadMaxBytes: settings.smallUploadMaxBytes,
|
|
46
46
|
sliceSize: settings.sliceSize,
|
|
47
|
+
transportMode: settings.transportMode,
|
|
48
|
+
webdavUrl: settings.webdavUrl,
|
|
49
|
+
webdavUser: settings.webdavUser,
|
|
50
|
+
webdavPassword: settings.webdavPassword,
|
|
51
|
+
webdavHomePath: settings.webdavHomePath,
|
|
52
|
+
webdavPublicPath: settings.webdavPublicPath,
|
|
53
|
+
webdavProbeTimeoutMs: settings.webdavProbeTimeoutMs,
|
|
47
54
|
});
|
|
48
55
|
return () => {
|
|
49
56
|
client = undefined;
|
package/lib/tools/status.js
CHANGED
|
@@ -11,7 +11,7 @@ export function createStatusTool(ctx) {
|
|
|
11
11
|
return {
|
|
12
12
|
name: "zspace_status",
|
|
13
13
|
description:
|
|
14
|
-
"Check the 极空间 / ZSpace NAS link behind this plugin.
|
|
14
|
+
"Check the 极空间 / ZSpace NAS link behind this plugin. Reports which transport is in use (direct WebDAV on the same network, or the desktop client's cloud relay when away), probes the relay, resolves the personal-space and public-space roots, reports storage pools with free space, and lists both roots once. Run it first when another zspace_* tool fails.",
|
|
15
15
|
parameters: {},
|
|
16
16
|
outputSchema: {
|
|
17
17
|
type: "object",
|
|
@@ -39,6 +39,9 @@ export function createStatusTool(ctx) {
|
|
|
39
39
|
},
|
|
40
40
|
},
|
|
41
41
|
},
|
|
42
|
+
transport: { type: "string", required: true },
|
|
43
|
+
webdavUrl: { type: "string", required: true },
|
|
44
|
+
webdavProbe: { type: "string", required: true },
|
|
42
45
|
message: { type: "string", required: true },
|
|
43
46
|
},
|
|
44
47
|
},
|
|
@@ -52,7 +55,10 @@ export function createStatusTool(ctx) {
|
|
|
52
55
|
{
|
|
53
56
|
type: "text",
|
|
54
57
|
text: [
|
|
55
|
-
`极空间在线:${value.username || "(未知账号)"} @ ${value.nasId}
|
|
58
|
+
`极空间在线:${value.username || "(未知账号)"} @ ${value.nasId}`,
|
|
59
|
+
value.transport === "webdav"
|
|
60
|
+
? `通道:WebDAV 直连(${value.webdavUrl})— 同网络快路径`
|
|
61
|
+
: `通道:桌面客户端云中转(${value.baseUrl})${value.webdavUrl ? `;WebDAV 未启用:${value.webdavProbe}` : ""}`,
|
|
56
62
|
`个人空间 ${value.homePath}(${value.homeEntries} 项)`,
|
|
57
63
|
`公共空间 ${value.publicPath}(${value.publicEntries} 项)`,
|
|
58
64
|
pools ? `存储池:\n${pools}` : "存储池:未获取到",
|
|
@@ -64,11 +70,16 @@ export function createStatusTool(ctx) {
|
|
|
64
70
|
guarded(async () => {
|
|
65
71
|
const client = getClient();
|
|
66
72
|
const baseUrl = client.baseUrl;
|
|
67
|
-
const
|
|
68
|
-
|
|
73
|
+
const transport = await client.transportReport();
|
|
74
|
+
const webdavProbe = transport.probe?.reason ?? (transport.configured ? "尚未探测" : `未完整配置(需要 webdavUrl + ZS_WEBDAV_PASSWORD)`);
|
|
75
|
+
const reachable = await client.check().catch(() => false);
|
|
76
|
+
if (!reachable) {
|
|
69
77
|
return {
|
|
70
78
|
ok: false,
|
|
71
79
|
baseUrl,
|
|
80
|
+
transport: transport.transport,
|
|
81
|
+
webdavUrl: client.webdavUrl ?? "",
|
|
82
|
+
webdavProbe,
|
|
72
83
|
username: "",
|
|
73
84
|
nasId: "",
|
|
74
85
|
homePath: "",
|
|
@@ -76,7 +87,9 @@ export function createStatusTool(ctx) {
|
|
|
76
87
|
homeEntries: 0,
|
|
77
88
|
publicEntries: 0,
|
|
78
89
|
pools: [],
|
|
79
|
-
message:
|
|
90
|
+
message:
|
|
91
|
+
`两条通道都不可用:WebDAV ${client.webdavUrl ? webdavProbe : "未配置"};桌面客户端本地代理 ${baseUrl} 无响应。` +
|
|
92
|
+
" 同网络时请在极空间开启 WebDAV 并设置 ZS_WEBDAV_PASSWORD;跨网络时需要桌面客户端已登录并运行。",
|
|
80
93
|
};
|
|
81
94
|
}
|
|
82
95
|
const identity = client.identity;
|
|
@@ -102,6 +115,9 @@ export function createStatusTool(ctx) {
|
|
|
102
115
|
return {
|
|
103
116
|
ok: home.error === "" || publicSpace.error === "",
|
|
104
117
|
baseUrl,
|
|
118
|
+
transport: transport.transport,
|
|
119
|
+
webdavUrl: client.webdavUrl ?? "",
|
|
120
|
+
webdavProbe,
|
|
105
121
|
username: identity.username,
|
|
106
122
|
nasId: identity.nasId,
|
|
107
123
|
homePath: home.value.root,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-zspace",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.0",
|
|
4
4
|
"description": "极空间(ZSpace)NAS plugin for DeepSeek Harness: cross-network agent tools for browsing, searching, reading, uploading and organizing NAS files through the desktop client's relay — no LAN, no WebDAV, no SSH, no Python.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|