dsh-zspace 0.1.0 → 0.1.2
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 +31 -0
- package/README.md +20 -4
- package/cordis.patch.yml +4 -2
- package/lib/README.md +74 -0
- package/lib/client/browse.js +58 -0
- package/lib/client/download.js +115 -0
- package/lib/client/errors.js +56 -0
- package/lib/client/mutate.js +72 -0
- package/lib/client/session.js +72 -0
- package/lib/client/spaces.js +122 -0
- package/lib/client/transport.js +285 -0
- package/lib/client/upload.js +117 -0
- package/lib/client.js +71 -737
- package/lib/config.js +109 -0
- package/lib/index.js +7 -126
- package/lib/prompt.js +26 -0
- package/lib/tools/copy.js +37 -0
- package/lib/tools/download.js +44 -0
- package/lib/tools/find.js +76 -0
- package/lib/tools/ls.js +72 -0
- package/lib/tools/mkdir.js +34 -0
- package/lib/tools/move.js +37 -0
- package/lib/tools/paths.js +84 -0
- package/lib/tools/read.js +55 -0
- package/lib/tools/remove.js +40 -0
- package/lib/tools/rename.js +39 -0
- package/lib/tools/shared.js +52 -0
- package/lib/tools/stat.js +53 -0
- package/lib/tools/status.js +116 -0
- package/lib/tools/upload.js +58 -0
- package/lib/tools/walk.js +117 -0
- package/lib/tools/write.js +73 -0
- package/lib/tools.js +51 -788
- package/package.json +3 -1
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
# 变更日志
|
|
2
|
+
|
|
3
|
+
## 0.1.2 — 2026-10-06
|
|
4
|
+
|
|
5
|
+
新增一个写入类工具 + 一个配置项,并把文档里的真实池名换成占位符(**无破坏性变更**,工具表新增一行):
|
|
6
|
+
|
|
7
|
+
- 新增 `zspace_write`:把文本直接写到 NAS,省掉"先生成本地临时文件再上传"这一步。默认覆盖同名文件,`overwrite: false` 时先探测存在性并拒绝;体积受 `writeMaxBytes` 约束;底层复用同一条上传路径(自动决定单请求/分片),临时文件无论成败都会清理。
|
|
8
|
+
- 新增配置项 `writeMaxBytes`(默认 5 MB),四处同步:Config schema、DEFAULTS、加载期校验、`cordis.patch.yml`。
|
|
9
|
+
- 文档与工具描述里的示例路径由真实的 `/<具体池>/...` 泛化为 `/<pool>/...`(探测候选路径代码保留,那是发现算法的一部分)。
|
|
10
|
+
- 清掉 0.1.1 拆分脚本引入的 19 处重复 JSDoc。
|
|
11
|
+
- 验证:29 项 mock 测试(新增 zspace_write 的 4 类断言)+ 真机 21 步自检(新增"直写文本→读回内容比对")。
|
|
12
|
+
|
|
13
|
+
## 0.1.1 — 2026-10-06
|
|
14
|
+
|
|
15
|
+
纯内部重构,**对外行为与 API 不变**(`ZSpaceClient` / `ZSpaceError` / `createToolSpecs` / 12 个工具名与参数完全保持):
|
|
16
|
+
|
|
17
|
+
- `lib/client.js` 857 → 197 行,按能力拆到 `lib/client/`:`errors` / `transport` / `session` / `spaces` / `browse` / `mutate` / `upload` / `download`;
|
|
18
|
+
- `lib/tools.js` 805 → 66 行:12 个工具各自成文件(`lib/tools/<工具名>.js`),公共能力抽成 `shared` / `paths` / `walk`;
|
|
19
|
+
- `lib/index.js` 206 → 87 行,配置抽到 `lib/config.js`、系统提示文案抽到 `lib/prompt.js`;
|
|
20
|
+
- 新增 `lib/README.md`:目录职责表 + 依赖方向 + 「加一个工具/加一个客户端能力」的步骤;
|
|
21
|
+
- 测试仍为 28 项 mock 用例,拆分前后全绿;真机自检脚本未改。
|
|
22
|
+
|
|
23
|
+
## 0.1.0 — 2026-10-05
|
|
24
|
+
|
|
25
|
+
首个版本:
|
|
26
|
+
|
|
27
|
+
- 跨网络通道:桌面客户端本地代理 `127.0.0.1:13579` → 极空间云中转 → NAS,无需同一局域网 / WebDAV / SSH / Python;
|
|
28
|
+
- 12 个工具:`zspace_status` `ls` `stat` `find` `read` `download` `upload` `mkdir` `rename` `move` `copy` `remove`;
|
|
29
|
+
- 路径约定:`home:` / `public:` / 相对个人空间 / NAS 绝对路径,空间根按存储池自动探测;
|
|
30
|
+
- 上传:≤8 MB 走 `/v2/file/create`,更大或遇 HTTP 413 自动回退桌面客户端分片协议(2 MB/片),中文文件名可用;
|
|
31
|
+
- 安全:凭据实时读桌面端 `vuex.json`(不落库);`zspace_remove` 强制 `confirm: true` 且 NAS 侧进回收站;错误码映射为可执行提示。
|
package/README.md
CHANGED
|
@@ -41,6 +41,7 @@ dsh plugin --profile desktop add /绝对路径/dsh-zspace-0.1.0.tgz
|
|
|
41
41
|
| `zspace_read` | 把小文本文件读进上下文(默认上限 256 KB,二进制自动识别) |
|
|
42
42
|
| `zspace_download` | 从 NAS 下载到本机(默认落到 `~/Downloads/zspace`) |
|
|
43
43
|
| `zspace_upload` | 上传本地文件,**大文件自动走分片协议**,中文文件名可用 |
|
|
44
|
+
| `zspace_write` | 把文本**直接写到 NAS**(省掉本地临时文件),默认覆盖、可传 `overwrite: false` 拒绝覆盖 |
|
|
44
45
|
| `zspace_mkdir` | 建目录 |
|
|
45
46
|
| `zspace_rename` | 就地重命名 |
|
|
46
47
|
| `zspace_move` | 移动到另一个目录(服务端操作,不下载) |
|
|
@@ -51,11 +52,11 @@ dsh plugin --profile desktop add /绝对路径/dsh-zspace-0.1.0.tgz
|
|
|
51
52
|
|
|
52
53
|
| 写法 | 含义 |
|
|
53
54
|
|---|---|
|
|
54
|
-
| 省略 / `""` |
|
|
55
|
+
| 省略 / `""` | 个人空间根(形如 `/<pool>/my/data`,实际值由插件探测) |
|
|
55
56
|
| `home:相册` | 个人空间下的 `相册` |
|
|
56
|
-
| `public:公共分组/skills` |
|
|
57
|
+
| `public:公共分组/skills` | 公共空间下的路径(形如 `/<pool>/public/...`) |
|
|
57
58
|
| `docs/2026` | 相对个人空间根 |
|
|
58
|
-
|
|
|
59
|
+
| `/<pool>/my/data/x.txt` | NAS 绝对路径,原样使用 |
|
|
59
60
|
|
|
60
61
|
两个根路径由插件按存储池自动探测(`/<pool>/my/data`、`/<pool>/public`),也可在配置里写死。
|
|
61
62
|
|
|
@@ -75,6 +76,7 @@ dsh plugin --profile desktop add /绝对路径/dsh-zspace-0.1.0.tgz
|
|
|
75
76
|
| `homePath` / `publicPath` | 空=自动探测 | 写死个人/公共空间根 |
|
|
76
77
|
| `downloadDir` | 空=`~/Downloads/zspace` | 默认下载目录 |
|
|
77
78
|
| `readMaxBytes` | `262144` | `zspace_read` 上限 |
|
|
79
|
+
| `writeMaxBytes` | `5242880` | `zspace_write` 单次写入上限(更大的内容用 `zspace_upload`) |
|
|
78
80
|
| `listMaxEntries` | `2000` | 单次列目录/树的上限 |
|
|
79
81
|
| `timeoutMs` / `maxRetries` | `60000` / `2` | 请求超时与瞬时失败重试 |
|
|
80
82
|
| `smallUploadMaxBytes` | `8388608` | 超过就用分片上传 |
|
|
@@ -91,10 +93,24 @@ dsh plugin --profile desktop add /绝对路径/dsh-zspace-0.1.0.tgz
|
|
|
91
93
|
- NAS 网页层的 `/file_search/file_search` 在当前固件上**忽略关键字**(换任何关键词都返回同一批 100 项),所以 `zspace_find` 用的是**受限目录遍历**而不是该接口,结果会明确报告扫描范围;
|
|
92
94
|
- 单次 list 最多 50 条(插件已自动分页);远端模式分片上限 2 MB/片。
|
|
93
95
|
|
|
96
|
+
## 代码结构
|
|
97
|
+
|
|
98
|
+
按「**一个文件 = 一个能独立解释的能力**」组织,完整职责表与扩展步骤见 [lib/README.md](lib/README.md):
|
|
99
|
+
|
|
100
|
+
```
|
|
101
|
+
lib/index.js 插件接线(name/inject 转发 Config + apply) lib/client/ 协议按能力拆分
|
|
102
|
+
lib/config.js 配置 schema、默认值、边界校验 transport URL/编码/重试/校验
|
|
103
|
+
lib/prompt.js 系统提示里的用法说明 spaces 探活、存储池、空间根
|
|
104
|
+
lib/tools.js 工具注册表(只做注册) browse/mutate 列表 / 增删改
|
|
105
|
+
lib/tools/*.js 12 个工具,各一个文件 upload/download 分片上传 / 流式下载
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
依赖是严格单向无环:`index → tools → client → {errors,transport} → format/auth`。加一个工具 = 新建 `lib/tools/<名字>.js` + 在 `lib/tools.js` 注册一行。
|
|
109
|
+
|
|
94
110
|
## 测试
|
|
95
111
|
|
|
96
112
|
```sh
|
|
97
|
-
node --test test/client.test.js test/tools.test.js test/adaptor.test.js #
|
|
113
|
+
node --test test/client.test.js test/tools.test.js test/adaptor.test.js # 28 项单测/集成(mock 代理)
|
|
98
114
|
node scripts/live-selftest.js # 真机端到端自检(会创建并清理临时目录)
|
|
99
115
|
```
|
|
100
116
|
|
package/cordis.patch.yml
CHANGED
|
@@ -6,8 +6,9 @@
|
|
|
6
6
|
# Every value below has a code-side default, so this patch stays valid even on
|
|
7
7
|
# a host without patch-side helper evaluation. `homePath` / `publicPath` are
|
|
8
8
|
# intentionally empty: the plugin discovers them from the NAS storage pools at
|
|
9
|
-
# runtime (
|
|
10
|
-
# working on NAS models that name
|
|
9
|
+
# runtime (personal space looks like /<pool>/my/data, public space like
|
|
10
|
+
# /<pool>/public), which keeps the same file working on NAS models that name
|
|
11
|
+
# their pools differently.
|
|
11
12
|
|
|
12
13
|
- insert:
|
|
13
14
|
- id: zspace
|
|
@@ -19,6 +20,7 @@
|
|
|
19
20
|
publicPath: ""
|
|
20
21
|
downloadDir: ""
|
|
21
22
|
readMaxBytes: 262144
|
|
23
|
+
writeMaxBytes: 5242880
|
|
22
24
|
listMaxEntries: 2000
|
|
23
25
|
timeoutMs: 60000
|
|
24
26
|
maxRetries: 2
|
package/lib/README.md
ADDED
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
# lib/ 结构与约定
|
|
2
|
+
|
|
3
|
+
一句话:**一个文件 = 一个能独立解释的能力**。入口只做接线,协议只管传输,工具一个文件一个。
|
|
4
|
+
|
|
5
|
+
```
|
|
6
|
+
lib/
|
|
7
|
+
├── index.js 87 行 插件入口:name / inject / Config 转发 / apply(生命周期 + 注册)
|
|
8
|
+
├── config.js 105 行 配置:schemastery 的 Config、代码侧 DEFAULTS、边界校验
|
|
9
|
+
├── prompt.js 26 行 系统提示里的用法说明(promptEnabled 开关)
|
|
10
|
+
├── auth.js 174 行 从桌面客户端 vuex.json 取登录态(按 mtime 缓存)
|
|
11
|
+
├── format.js 91 行 时间/大小/路径小工具(无 host 依赖,纯 Node)
|
|
12
|
+
├── client.js 197 行 客户端 facade:持有会话状态,方法薄委托到 client/*
|
|
13
|
+
├── client/
|
|
14
|
+
│ ├── errors.js 56 行 NAS 业务码 + ZSpaceError(含 hint 提示)
|
|
15
|
+
│ ├── transport.js 335 行 URL 形状、表单编码、重试、响应校验、流读取
|
|
16
|
+
│ ├── session.js 79 行 Cookie、公共参数、安全身份
|
|
17
|
+
│ ├── spaces.js 153 行 代理探活、存储池、个人/公共空间根探测
|
|
18
|
+
│ ├── browse.js 64 行 分页列目录、单条元信息
|
|
19
|
+
│ ├── mutate.js 102 行 mkdir / rename / move / copy / remove
|
|
20
|
+
│ ├── upload.js 129 行 单请求上传 + 分片协议
|
|
21
|
+
│ └── download.js 128 行 流式下载、限量读入内存
|
|
22
|
+
├── tools.js 68 行 工具注册表:建 ctx、按顺序调用 13 个工厂
|
|
23
|
+
└── tools/
|
|
24
|
+
├── shared.js 52 行 clampInt / entryLine / guarded
|
|
25
|
+
├── paths.js 84 行 home:/public:/相对/绝对 解析 + 本地路径(含 ~/)
|
|
26
|
+
├── walk.js 118 行 walkDirectory(ls 递归)、findByName(受限查找)
|
|
27
|
+
└── <工具名>.js 35-120 行 每个工具一个文件:status ls stat find read
|
|
28
|
+
download upload write mkdir rename move copy remove
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
## 依赖方向(严格单向,无环)
|
|
32
|
+
|
|
33
|
+
```
|
|
34
|
+
index.js ──> config.js ──> auth.js
|
|
35
|
+
│ prompt.js
|
|
36
|
+
└──> tools.js ──> tools/<工具>.js ──> tools/{shared,paths,walk}.js
|
|
37
|
+
└──> client.js ──> client/<能力>.js ──> client/{errors,transport}.js
|
|
38
|
+
└──> format.js / auth.js
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
- `tools/<工具>.js` **互不 import**;公共能力走 `shared/paths/walk`。
|
|
42
|
+
- `tools/<工具>.js` 通过工厂参数拿到依赖(`ctx`),不直接摸 client 单例:
|
|
43
|
+
|
|
44
|
+
```js
|
|
45
|
+
export function createLsTool(ctx) {
|
|
46
|
+
const { clampInt, config, entryLine, getClient, guarded, resolve, walkDirectory } = ctx;
|
|
47
|
+
return { name: "zspace_ls", description, parameters, outputSchema, render, call, execute };
|
|
48
|
+
}
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
- `client/<能力>.js` 导出 `fn(client, ...args)` 纯函数,`client.js` 把方法名绑到同一个 `client` 实例;
|
|
52
|
+
好处是每块能力都能脱离 class 单测,而对外仍然是 `new ZSpaceClient()`。
|
|
53
|
+
|
|
54
|
+
## 加东西的流程
|
|
55
|
+
|
|
56
|
+
**加一个工具**(三步)
|
|
57
|
+
|
|
58
|
+
1. `lib/tools/<名字>.js`:导出 `create<Name>Tool(ctx)`,返回
|
|
59
|
+
`{ name, description, parameters, outputSchema, render, call, execute }`;
|
|
60
|
+
2. `lib/tools.js`:`import` 该工厂,并在 `createToolSpecs` 的 `return [...]` 里加一行 —— **顺序即模型看到的工具表顺序**,破坏性写入放后面;
|
|
61
|
+
3. `test/tools.test.js`:补一条断言(路径解析/参数护栏/outputSchema 自洽)。
|
|
62
|
+
|
|
63
|
+
**加一个客户端能力**
|
|
64
|
+
|
|
65
|
+
1. `lib/client/<能力>.js`:`export async function foo(client, ...)`;
|
|
66
|
+
2. `lib/client.js`:`import` 后加一个薄委托方法;
|
|
67
|
+
3. `lib/client.d.ts`(对外 API 变了才动)。
|
|
68
|
+
|
|
69
|
+
## 约定
|
|
70
|
+
|
|
71
|
+
- 单文件控制在 ~200 行内(`transport.js` 是唯一例外:编码→发送→校验逻辑内聚在一起);
|
|
72
|
+
- 每个文件顶部一句话职责 + `@module dsh-zspace/...`;
|
|
73
|
+
- 对外 API 只有 `lib/index.js`(插件)与 `lib/client.js`(`ZSpaceClient` / `ZSpaceError`)两个入口,子模块视为内部结构,不进 `package.json` 的 `exports`;
|
|
74
|
+
- 逻辑改动必须让 28 项 mock 测试保持全绿,触及传输/上传时另跑 `node scripts/live-selftest.js` 真机验证。
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Read-only directory access: paged listing and single-entry metadata.
|
|
3
|
+
*
|
|
4
|
+
* @module dsh-zspace/client
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { toEntry } from "../format.js";
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* List one directory, paging until the NAS has no more rows.
|
|
11
|
+
*
|
|
12
|
+
* @param {string} remotePath - absolute remote directory.
|
|
13
|
+
* @param {{showHidden?: boolean, maxEntries?: number}} [options] - listing options.
|
|
14
|
+
* @returns {Promise<{entries: Array<ReturnType<typeof toEntry>>, truncated: boolean}>} listing.
|
|
15
|
+
*/
|
|
16
|
+
export async function list(client, remotePath, options = {}) {
|
|
17
|
+
const maxEntries = options.maxEntries ?? client.listMaxEntries;
|
|
18
|
+
/** @type {Array<ReturnType<typeof toEntry>>} */
|
|
19
|
+
const entries = [];
|
|
20
|
+
let start = 0;
|
|
21
|
+
let truncated = false;
|
|
22
|
+
for (;;) {
|
|
23
|
+
const data = await client.post("/v2/file/list", {
|
|
24
|
+
path: remotePath,
|
|
25
|
+
show_hidden: options.showHidden ? "1" : "0",
|
|
26
|
+
start: String(start),
|
|
27
|
+
limit: String(client.pageSize),
|
|
28
|
+
});
|
|
29
|
+
const page = Array.isArray(data?.list) ? data.list : [];
|
|
30
|
+
for (const row of page) entries.push(toEntry(row));
|
|
31
|
+
if (page.length < client.pageSize) break;
|
|
32
|
+
start += page.length;
|
|
33
|
+
if (entries.length >= maxEntries) {
|
|
34
|
+
truncated = true;
|
|
35
|
+
break;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
if (entries.length > maxEntries) return { entries: entries.slice(0, maxEntries), truncated: true };
|
|
39
|
+
return { entries, truncated };
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* File or directory metadata.
|
|
44
|
+
*
|
|
45
|
+
* @param {string} remotePath - absolute remote path.
|
|
46
|
+
* @returns {Promise<ReturnType<typeof toEntry>>} normalized entry.
|
|
47
|
+
*/
|
|
48
|
+
export async function info(client, remotePath) {
|
|
49
|
+
const data = await client.post("/v2/file/info", { path: remotePath });
|
|
50
|
+
return toEntry(data ?? {});
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Create one directory.
|
|
55
|
+
*
|
|
56
|
+
* @param {string} remotePath - absolute path of the new directory.
|
|
57
|
+
* @returns {Promise<ReturnType<typeof toEntry>>} the created entry.
|
|
58
|
+
*/
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Downloads: streamed file download and capped reads into memory.
|
|
3
|
+
*
|
|
4
|
+
* @module dsh-zspace/client
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import fsp from "node:fs/promises";
|
|
8
|
+
import fs from "node:fs";
|
|
9
|
+
import https from "node:https";
|
|
10
|
+
import http from "node:http";
|
|
11
|
+
import path from "node:path";
|
|
12
|
+
import { pipeline } from "node:stream/promises";
|
|
13
|
+
import { ZSpaceError } from "./errors.js";
|
|
14
|
+
import { readStream } from "./transport.js";
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Download one file to a local directory.
|
|
18
|
+
*
|
|
19
|
+
* @param {string} remotePath - absolute remote file.
|
|
20
|
+
* @param {string} localDir - local destination directory (created if needed).
|
|
21
|
+
* @param {{name?: string}} [options] - destination name override.
|
|
22
|
+
* @returns {Promise<{localPath: string, bytes: number}>} written file.
|
|
23
|
+
*/
|
|
24
|
+
export async function download(client, remotePath, localDir, options = {}) {
|
|
25
|
+
const name = options.name || path.posix.basename(remotePath);
|
|
26
|
+
const dest = path.join(localDir, name);
|
|
27
|
+
await fsp.mkdir(localDir, { recursive: true });
|
|
28
|
+
const response = await client.openDownload(remotePath);
|
|
29
|
+
|
|
30
|
+
const status = response.statusCode ?? 0;
|
|
31
|
+
const contentType = String(response.headers["content-type"] ?? "");
|
|
32
|
+
if (status >= 400 || contentType.includes("json")) {
|
|
33
|
+
const { buffer } = await readStream(response);
|
|
34
|
+
client.parse({ status, text: buffer.toString("utf8") }, "/v2/file/download");
|
|
35
|
+
}
|
|
36
|
+
try {
|
|
37
|
+
await pipeline(response, fs.createWriteStream(dest));
|
|
38
|
+
} catch (error) {
|
|
39
|
+
await fsp.rm(dest, { force: true });
|
|
40
|
+
throw new ZSpaceError("EDOWNLOAD", `写入本地文件失败:${dest}(${error.message})`, { cause: error });
|
|
41
|
+
}
|
|
42
|
+
const stat = await fsp.stat(dest);
|
|
43
|
+
return { localPath: dest, bytes: stat.size };
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Read the head of a remote file into memory.
|
|
48
|
+
*
|
|
49
|
+
* @param {string} remotePath - absolute remote file.
|
|
50
|
+
* @param {{maxBytes?: number}} [options] - byte cap.
|
|
51
|
+
* @returns {Promise<{buffer: Buffer, truncated: boolean, bytes: number}>} content.
|
|
52
|
+
*/
|
|
53
|
+
export async function readFile(client, remotePath, options = {}) {
|
|
54
|
+
const maxBytes = options.maxBytes ?? 262_144;
|
|
55
|
+
const response = await client.openDownload(remotePath);
|
|
56
|
+
const status = response.statusCode ?? 0;
|
|
57
|
+
const contentType = String(response.headers["content-type"] ?? "");
|
|
58
|
+
if (status >= 400 || contentType.includes("json")) {
|
|
59
|
+
const { buffer } = await readStream(response);
|
|
60
|
+
client.parse({ status, text: buffer.toString("utf8") }, "/v2/file/download");
|
|
61
|
+
}
|
|
62
|
+
const { buffer, truncated } = await readStream(response, maxBytes);
|
|
63
|
+
response.destroy();
|
|
64
|
+
return { buffer, truncated, bytes: buffer.length };
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Open a download response, retrying transient proxy failures.
|
|
69
|
+
*
|
|
70
|
+
* @param {string} remotePath - absolute remote file.
|
|
71
|
+
* @returns {Promise<import("node:http").IncomingMessage>} the open response (caller consumes it).
|
|
72
|
+
*/
|
|
73
|
+
export async function openDownload(client, remotePath) {
|
|
74
|
+
const url = client.endpointUrl("/v2/file/download", { path: remotePath, remote_port: "8050" });
|
|
75
|
+
return await client.retrying(
|
|
76
|
+
() =>
|
|
77
|
+
new Promise((resolve, reject) => {
|
|
78
|
+
const request = (client.baseUrl.startsWith("https:") ? https : http).request(
|
|
79
|
+
url,
|
|
80
|
+
{ method: "GET", headers: client.authHeaders() },
|
|
81
|
+
(response) => {
|
|
82
|
+
const status = response.statusCode ?? 0;
|
|
83
|
+
if (status >= 500 || status === 429) {
|
|
84
|
+
response.resume();
|
|
85
|
+
reject(new ZSpaceError(`HTTP${status}`, `HTTP ${status} 下载失败:${remotePath}`, { endpoint: "/v2/file/download" }));
|
|
86
|
+
return;
|
|
87
|
+
}
|
|
88
|
+
resolve(response);
|
|
89
|
+
},
|
|
90
|
+
);
|
|
91
|
+
request.setTimeout(client.timeoutMs, () => request.destroy(new ZSpaceError("ETIMEDOUT", `下载超时:${remotePath}`)));
|
|
92
|
+
request.on("error", (error) => {
|
|
93
|
+
if (error instanceof ZSpaceError) reject(error);
|
|
94
|
+
else reject(new ZSpaceError("EPROXY", `下载失败(${remotePath}):${error.message}`, { endpoint: "/v2/file/download" }));
|
|
95
|
+
});
|
|
96
|
+
request.end();
|
|
97
|
+
}),
|
|
98
|
+
error =>
|
|
99
|
+
error instanceof ZSpaceError &&
|
|
100
|
+
(error.code === "ETIMEDOUT" || error.code === "EPROXY" || /^HTTP(5\d\d|429)$/.test(error.code)),
|
|
101
|
+
);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Upload a local file into a remote directory.
|
|
106
|
+
*
|
|
107
|
+
* Files at or below `smallUploadMaxBytes` use `/v2/file/create`; larger ones
|
|
108
|
+
* (and any request the local proxy rejects with HTTP 413) use the desktop
|
|
109
|
+
* client's sliced `/v2/file/upload` protocol.
|
|
110
|
+
*
|
|
111
|
+
* @param {string} localPath - local file.
|
|
112
|
+
* @param {string} remoteDir - absolute remote directory.
|
|
113
|
+
* @param {{name?: string, onProgress?: (sent: number, total: number) => void}} [options] - upload options.
|
|
114
|
+
* @returns {Promise<{remotePath: string, bytes: number, method: "create"|"sliced"}>} upload result.
|
|
115
|
+
*/
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* NAS business codes and the single error type every layer raises.
|
|
3
|
+
*
|
|
4
|
+
* @module dsh-zspace/client
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Business codes the desktop client treats as fatal during sliced uploads —
|
|
9
|
+
* retrying them only burns time (permission / conflicting dir / safe-box).
|
|
10
|
+
*/
|
|
11
|
+
export const UPLOAD_FATAL_CODES = new Set(["N001302", "N001331", "N001603", "N001397"]);
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* One ZSpace API or transport failure.
|
|
15
|
+
*
|
|
16
|
+
* `code` is the NAS business code (`N001411`, …) when the response parsed,
|
|
17
|
+
* or a synthetic marker (`HTTP413`, `EPROXY`, …) otherwise.
|
|
18
|
+
*/
|
|
19
|
+
export class ZSpaceError extends Error {
|
|
20
|
+
/**
|
|
21
|
+
* @param {string} code - NAS business code or synthetic marker.
|
|
22
|
+
* @param {string} message - human-readable message.
|
|
23
|
+
* @param {{endpoint?: string, cause?: unknown}} [options] - context.
|
|
24
|
+
*/
|
|
25
|
+
constructor(code, message, options = {}) {
|
|
26
|
+
super(message, options.cause === undefined ? undefined : { cause: options.cause });
|
|
27
|
+
this.name = "ZSpaceError";
|
|
28
|
+
this.code = String(code);
|
|
29
|
+
this.endpoint = options.endpoint ?? "";
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Actionable next step for known codes, or `undefined`.
|
|
34
|
+
*
|
|
35
|
+
* @returns {string|undefined} hint text.
|
|
36
|
+
*/
|
|
37
|
+
get hint() {
|
|
38
|
+
const message = this.message;
|
|
39
|
+
if (this.code === "N001411" || /无权限/.test(message)) {
|
|
40
|
+
return "没有权限:确认该账号对目标路径可访问(「/」与裸盘根目录总是拒绝的,先 ls 已知的空间根,例如 home: 或 public:)。";
|
|
41
|
+
}
|
|
42
|
+
if (this.code === "N001315" || /不存在/.test(message)) {
|
|
43
|
+
return "路径不存在:先列出上级目录确认拼写,注意大小写与中文全角字符。";
|
|
44
|
+
}
|
|
45
|
+
if (this.code === "N001212") {
|
|
46
|
+
return "参数有误:路径必须以 / 开头(或用 home: / public: / 相对路径)。";
|
|
47
|
+
}
|
|
48
|
+
if (this.code === "HTTP413") {
|
|
49
|
+
return "本地代理拒绝超大请求体:大文件会自动改走分片上传,若仍失败请降低分片大小。";
|
|
50
|
+
}
|
|
51
|
+
if (this.code === "EPROXY") {
|
|
52
|
+
return "连不上极空间桌面客户端本地代理:确认客户端已安装、已登录并在运行(端口 13579)。";
|
|
53
|
+
}
|
|
54
|
+
return undefined;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Server-side mutations: mkdir, rename, move, copy, remove.
|
|
3
|
+
*
|
|
4
|
+
* @module dsh-zspace/client
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { splitRemote, toEntry } from "../format.js";
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Create one directory.
|
|
11
|
+
*
|
|
12
|
+
* @param {string} remotePath - absolute path of the new directory.
|
|
13
|
+
* @returns {Promise<ReturnType<typeof toEntry>>} the created entry.
|
|
14
|
+
*/
|
|
15
|
+
export async function mkdir(client, remotePath) {
|
|
16
|
+
const { parent, name } = splitRemote(remotePath);
|
|
17
|
+
const data = await client.post("/v2/file/newdir", { parent, name, rename: "0" });
|
|
18
|
+
return toEntry(data ?? {});
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Rename a file or directory in place.
|
|
23
|
+
*
|
|
24
|
+
* @param {string} remotePath - absolute current path.
|
|
25
|
+
* @param {string} newName - new base name (no directory part).
|
|
26
|
+
* @returns {Promise<ReturnType<typeof toEntry>>} the renamed entry.
|
|
27
|
+
*/
|
|
28
|
+
export async function rename(client, remotePath, newName) {
|
|
29
|
+
const data = await client.post("/v2/file/modify", { path: remotePath, newname: newName });
|
|
30
|
+
return toEntry(data ?? {});
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Move files or directories into a target directory.
|
|
35
|
+
*
|
|
36
|
+
* @param {string[]} remotePaths - absolute source paths.
|
|
37
|
+
* @param {string} to - absolute destination directory.
|
|
38
|
+
* @returns {Promise<void>} resolves when the NAS accepted the move.
|
|
39
|
+
*/
|
|
40
|
+
export async function move(client, remotePaths, to) {
|
|
41
|
+
await client.post("/v2/file/move", { paths: remotePaths, to });
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Copy files or directories into a target directory.
|
|
46
|
+
*
|
|
47
|
+
* @param {string[]} remotePaths - absolute source paths.
|
|
48
|
+
* @param {string} to - absolute destination directory.
|
|
49
|
+
* @returns {Promise<void>} resolves when the NAS accepted the copy.
|
|
50
|
+
*/
|
|
51
|
+
export async function copy(client, remotePaths, to) {
|
|
52
|
+
await client.post("/v2/file/copy", { paths: remotePaths, to });
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Delete files or directories (the NAS moves them to its recycle bin).
|
|
57
|
+
*
|
|
58
|
+
* @param {string[]} remotePaths - absolute paths to remove.
|
|
59
|
+
* @returns {Promise<void>} resolves when the NAS accepted the deletion.
|
|
60
|
+
*/
|
|
61
|
+
export async function remove(client, remotePaths) {
|
|
62
|
+
await client.post("/v2/file/remove", { paths: remotePaths });
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Download one file to a local directory.
|
|
67
|
+
*
|
|
68
|
+
* @param {string} remotePath - absolute remote file.
|
|
69
|
+
* @param {string} localDir - local destination directory (created if needed).
|
|
70
|
+
* @param {{name?: string}} [options] - destination name override.
|
|
71
|
+
* @returns {Promise<{localPath: string, bytes: number}>} written file.
|
|
72
|
+
*/
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Session view of the desktop client's login state: cookies, common params, safe identity.
|
|
3
|
+
*
|
|
4
|
+
* @module dsh-zspace/client
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Auth cookies for every request.
|
|
9
|
+
*
|
|
10
|
+
* @returns {Record<string, string>} request headers carrying the session.
|
|
11
|
+
*/
|
|
12
|
+
export function authHeaders(client) {
|
|
13
|
+
const creds = client.credentials;
|
|
14
|
+
return {
|
|
15
|
+
Cookie: [
|
|
16
|
+
`token=${creds.token}`,
|
|
17
|
+
`zenithtoken=${creds.token}`,
|
|
18
|
+
`nas_id=${creds.nasId}`,
|
|
19
|
+
`nasid=${creds.nasId}`,
|
|
20
|
+
`device_id=${creds.deviceId}`,
|
|
21
|
+
].join("; "),
|
|
22
|
+
};
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Common form fields. These belong in the request **body**; the NAS rejects
|
|
27
|
+
* them in the query string with `N001212`.
|
|
28
|
+
*
|
|
29
|
+
* @returns {Record<string, string>} common fields.
|
|
30
|
+
*/
|
|
31
|
+
export function commonParams(client) {
|
|
32
|
+
const creds = client.credentials;
|
|
33
|
+
return {
|
|
34
|
+
token: creds.token,
|
|
35
|
+
nasid: creds.nasId,
|
|
36
|
+
plat: "web",
|
|
37
|
+
version: client.apiVersion,
|
|
38
|
+
device_id: creds.deviceId,
|
|
39
|
+
_l: "zh_cn",
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Build an endpoint URL with the mandatory random query suffix.
|
|
45
|
+
*
|
|
46
|
+
* @param {string} endpoint - API path, e.g. `/v2/file/list`.
|
|
47
|
+
* @param {Record<string, unknown>} [query] - extra query values.
|
|
48
|
+
* @returns {string} absolute URL.
|
|
49
|
+
*/
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Identity of the account this client acts as (no secrets).
|
|
53
|
+
*
|
|
54
|
+
* @returns {{username: string, nasId: string, deviceId: string, vuexPath: string}} safe identity fields.
|
|
55
|
+
*/
|
|
56
|
+
export function identity(client) {
|
|
57
|
+
const creds = client.credentials;
|
|
58
|
+
return {
|
|
59
|
+
username: creds.username ?? "",
|
|
60
|
+
nasId: creds.nasId ?? "",
|
|
61
|
+
deviceId: creds.deviceId ?? "",
|
|
62
|
+
vuexPath: creds.vuexPath ?? "",
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// ── transport ────────────────────────────────────────────────────────────
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Auth cookies for every request.
|
|
70
|
+
*
|
|
71
|
+
* @returns {Record<string, string>} request headers carrying the session.
|
|
72
|
+
*/
|