dsh-zspace 0.0.0-stage → 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md ADDED
@@ -0,0 +1,21 @@
1
+ # 变更日志
2
+
3
+ ## 0.1.1 — 2026-10-06
4
+
5
+ 纯内部重构,**对外行为与 API 不变**(`ZSpaceClient` / `ZSpaceError` / `createToolSpecs` / 12 个工具名与参数完全保持):
6
+
7
+ - `lib/client.js` 857 → 197 行,按能力拆到 `lib/client/`:`errors` / `transport` / `session` / `spaces` / `browse` / `mutate` / `upload` / `download`;
8
+ - `lib/tools.js` 805 → 66 行:12 个工具各自成文件(`lib/tools/<工具名>.js`),公共能力抽成 `shared` / `paths` / `walk`;
9
+ - `lib/index.js` 206 → 87 行,配置抽到 `lib/config.js`、系统提示文案抽到 `lib/prompt.js`;
10
+ - 新增 `lib/README.md`:目录职责表 + 依赖方向 + 「加一个工具/加一个客户端能力」的步骤;
11
+ - 测试仍为 28 项 mock 用例,拆分前后全绿;真机自检脚本未改。
12
+
13
+ ## 0.1.0 — 2026-10-05
14
+
15
+ 首个版本:
16
+
17
+ - 跨网络通道:桌面客户端本地代理 `127.0.0.1:13579` → 极空间云中转 → NAS,无需同一局域网 / WebDAV / SSH / Python;
18
+ - 12 个工具:`zspace_status` `ls` `stat` `find` `read` `download` `upload` `mkdir` `rename` `move` `copy` `remove`;
19
+ - 路径约定:`home:` / `public:` / 相对个人空间 / NAS 绝对路径,空间根按存储池自动探测;
20
+ - 上传:≤8 MB 走 `/v2/file/create`,更大或遇 HTTP 413 自动回退桌面客户端分片协议(2 MB/片),中文文件名可用;
21
+ - 安全:凭据实时读桌面端 `vuex.json`(不落库);`zspace_remove` 强制 `confirm: true` 且 NAS 侧进回收站;错误码映射为可执行提示。
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 dsh-zspace contributors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,123 @@
1
- # Temporary Holding Version
1
+ # dsh-zspace
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ [![npm](https://img.shields.io/npm/v/dsh-zspace.svg)](https://www.npmjs.com/package/dsh-zspace)
4
+ [![license](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE)
5
+ [![dsh-plugin](https://img.shields.io/badge/awesome-dsh--plugin-listed-8a2be2.svg)](https://github.com/awesome-dsh-plugin/awesome-dsh-plugin)
6
+
7
+ 极空间(ZSpace)NAS 的 **DeepSeek Harness 插件**:把 NAS 变成 agent 的原生工具 —— **跨网络可用**,不需要同一局域网、不需要 WebDAV、不需要 SSH、不需要 Python。
8
+
9
+ ```
10
+ DSH 插件 → http://127.0.0.1:13579 → 极空间桌面客户端(已登录) → 极空间云中转 / P2P → NAS
11
+ ```
12
+
13
+ 认证直接复用桌面客户端自己的登录态(`vuex.json`),所以**换任何网络都能用**:只要跑 DSH 的这台机器上极空间桌面客户端在线即可。这正好补上 `dsh-webdav` 那类"必须同网段"方案的短板。
14
+
15
+ ## 安装
16
+
17
+ ```sh
18
+ # 从 npm 安装(推荐)
19
+ dsh plugin --profile desktop add dsh-zspace@latest
20
+
21
+ # 或用仓库里的本地包
22
+ dsh plugin --profile desktop add /绝对路径/dsh-zspace-0.1.0.tgz
23
+ ```
24
+
25
+ 装完**重启 DSH**(或在插件市场点「一键重启」)即可在会话里看到 `zspace_*` 工具。
26
+
27
+ 前置条件:
28
+
29
+ 1. 极空间桌面客户端已安装、**已登录并正在运行**(跨网络访问靠它做云中转);
30
+ 2. 该账号对目标空间有权限(个人空间 / 公共空间);
31
+ 3. 不需要给 NAS 开任何端口,也不需要 DDNS。
32
+
33
+ ## 工具
34
+
35
+ | 工具 | 作用 |
36
+ |---|---|
37
+ | `zspace_status` | 探活:代理、账号、NAS、存储池余量、个人/公共空间根路径与条目数。其它工具报错时先跑它 |
38
+ | `zspace_ls` | 列目录,支持 `depth` 递归与 `limit` 预算;自动分页(NAS 单页 50 条) |
39
+ | `zspace_stat` | 单个文件/目录的元信息(大小、修改/创建时间) |
40
+ | `zspace_find` | 按名称在目录树里查找(大小写不敏感子串),会报告扫描了多少条目、是否提前停止 |
41
+ | `zspace_read` | 把小文本文件读进上下文(默认上限 256 KB,二进制自动识别) |
42
+ | `zspace_download` | 从 NAS 下载到本机(默认落到 `~/Downloads/zspace`) |
43
+ | `zspace_upload` | 上传本地文件,**大文件自动走分片协议**,中文文件名可用 |
44
+ | `zspace_mkdir` | 建目录 |
45
+ | `zspace_rename` | 就地重命名 |
46
+ | `zspace_move` | 移动到另一个目录(服务端操作,不下载) |
47
+ | `zspace_copy` | 服务端复制 |
48
+ | `zspace_remove` | 删除(NAS 侧进回收站);**必须显式传 `confirm: true`** |
49
+
50
+ ### 路径写法
51
+
52
+ | 写法 | 含义 |
53
+ |---|---|
54
+ | 省略 / `""` | 个人空间根(本机实测 `/sata1/my/data`) |
55
+ | `home:相册` | 个人空间下的 `相册` |
56
+ | `public:公共分组/skills` | 公共空间下的路径(本机 `/sata1/public/...`) |
57
+ | `docs/2026` | 相对个人空间根 |
58
+ | `/sata1/my/data/x.txt` | NAS 绝对路径,原样使用 |
59
+
60
+ 两个根路径由插件按存储池自动探测(`/<pool>/my/data`、`/<pool>/public`),也可在配置里写死。
61
+
62
+ ### 本地路径
63
+
64
+ `zspace_upload` 的 `localPath`、`zspace_download` 的 `dir` 建议传**绝对路径**或 `~/…`:插件跑在 DSH 宿主进程里,普通相对路径是按**宿主进程的工作目录**解析的(不是会话工作区)。找不到文件时的报错会把解析后的绝对路径打出来。
65
+
66
+ ## 配置
67
+
68
+ 写在 profile 的补丁层(`~/.dsh/profiles/<profile>/cordis.patch.yml`)里 `id: zspace` 那一条的 `config` 下,改完热加载:
69
+
70
+ | 配置项 | 默认 | 说明 |
71
+ |---|---|---|
72
+ | `baseUrl` | `http://127.0.0.1:13579` | 桌面客户端本地代理(可用 `ZS_BASE_URL` 覆盖) |
73
+ | `configDir` | 自动探测 | `vuex.json` 所在目录(可用 `ZS_CONFIG_DIR` 覆盖) |
74
+ | `apiVersion` | `2.3.2026042401` | NAS Web API 版本号参数 |
75
+ | `homePath` / `publicPath` | 空=自动探测 | 写死个人/公共空间根 |
76
+ | `downloadDir` | 空=`~/Downloads/zspace` | 默认下载目录 |
77
+ | `readMaxBytes` | `262144` | `zspace_read` 上限 |
78
+ | `listMaxEntries` | `2000` | 单次列目录/树的上限 |
79
+ | `timeoutMs` / `maxRetries` | `60000` / `2` | 请求超时与瞬时失败重试 |
80
+ | `smallUploadMaxBytes` | `8388608` | 超过就用分片上传 |
81
+ | `sliceSize` | `2097152` | 分片大小(远端模式上限 2 MB) |
82
+ | `promptEnabled` / `promptOrder` | `true` / `60` | 是否向系统提示注入使用说明 |
83
+
84
+ ## 安全与边界
85
+
86
+ - **只读别人的东西是不可能的**:插件不存任何凭据,每次调用从桌面客户端的 `vuex.json` 现读 token(按 mtime 缓存),客户端退出登录即失效。
87
+ - **删除是两段式的**:`zspace_remove` 必须 `confirm: true`,且 NAS 侧进回收站,不是硬删。
88
+ - **不做端口暴露**:所有流量经本机代理走极空间云通道,NAS 不需要公网入口。
89
+ - 已知限制:
90
+ - 极空间**没有官方公开 API**,本插件走的是社区整理的桌面客户端接口,客户端大版本更新可能改动;
91
+ - NAS 网页层的 `/file_search/file_search` 在当前固件上**忽略关键字**(换任何关键词都返回同一批 100 项),所以 `zspace_find` 用的是**受限目录遍历**而不是该接口,结果会明确报告扫描范围;
92
+ - 单次 list 最多 50 条(插件已自动分页);远端模式分片上限 2 MB/片。
93
+
94
+ ## 代码结构
95
+
96
+ 按「**一个文件 = 一个能独立解释的能力**」组织,完整职责表与扩展步骤见 [lib/README.md](lib/README.md):
97
+
98
+ ```
99
+ lib/index.js 插件接线(name/inject 转发 Config + apply) lib/client/ 协议按能力拆分
100
+ lib/config.js 配置 schema、默认值、边界校验 transport URL/编码/重试/校验
101
+ lib/prompt.js 系统提示里的用法说明 spaces 探活、存储池、空间根
102
+ lib/tools.js 工具注册表(只做注册) browse/mutate 列表 / 增删改
103
+ lib/tools/*.js 12 个工具,各一个文件 upload/download 分片上传 / 流式下载
104
+ ```
105
+
106
+ 依赖是严格单向无环:`index → tools → client → {errors,transport} → format/auth`。加一个工具 = 新建 `lib/tools/<名字>.js` + 在 `lib/tools.js` 注册一行。
107
+
108
+ ## 测试
109
+
110
+ ```sh
111
+ node --test test/client.test.js test/tools.test.js test/adaptor.test.js # 28 项单测/集成(mock 代理)
112
+ node scripts/live-selftest.js # 真机端到端自检(会创建并清理临时目录)
113
+ ```
114
+
115
+ `live-selftest.js` 覆盖:探活 → 存储池 → 定位个人/公共空间 → 建目录 → 中文名单请求上传 → 强制多分片上传 → 详情 → 读取 → 下载并校验 sha256 → 服务端复制 → 重命名 → 遍历查找 → 移动 → 删除 → 确认消失。
116
+
117
+ ## 与已有极空间 skill 的关系
118
+
119
+ 本机的 `zspace-nas` / `zspace-public-access` 等 skill 依赖 `zspace-cli`(Python)或独立 Node 脚本;本插件把同一套协议**内置进 DSH**,不依赖 Python、不依赖额外命令,并且带输出 schema、错误提示与安全护栏。两者可以共存。
120
+
121
+ ## 许可
122
+
123
+ MIT
@@ -0,0 +1,28 @@
1
+ # dsh-zspace bundle patch: one row inserted over whatever layers came before it.
2
+ #
3
+ # A patch replaces the targeted row's whole `config`, so a profile that
4
+ # overrides this row must restate every key it wants to keep.
5
+ #
6
+ # Every value below has a code-side default, so this patch stays valid even on
7
+ # a host without patch-side helper evaluation. `homePath` / `publicPath` are
8
+ # intentionally empty: the plugin discovers them from the NAS storage pools at
9
+ # runtime (e.g. /sata1/my/data and /sata1/public), which keeps the same file
10
+ # working on NAS models that name their pools differently.
11
+
12
+ - insert:
13
+ - id: zspace
14
+ name: dsh-zspace
15
+ config:
16
+ baseUrl: http://127.0.0.1:13579
17
+ apiVersion: "2.3.2026042401"
18
+ homePath: ""
19
+ publicPath: ""
20
+ downloadDir: ""
21
+ readMaxBytes: 262144
22
+ listMaxEntries: 2000
23
+ timeoutMs: 60000
24
+ maxRetries: 2
25
+ smallUploadMaxBytes: 8388608
26
+ sliceSize: 2097152
27
+ promptEnabled: true
28
+ promptOrder: 60
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 66 行 工具注册表:建 ctx、按顺序调用 12 个工厂
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-116 行 每个工具一个文件:status ls stat find read
28
+ download upload 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` 真机验证。
package/lib/auth.js ADDED
@@ -0,0 +1,174 @@
1
+ /**
2
+ * ZSpace desktop-client credential discovery.
3
+ *
4
+ * ZSpace (极空间) has no official public API. Every community integration —
5
+ * including the `zspace-cli` SDK and the published `zspace-*` skills — goes
6
+ * through the **logged-in desktop client's local proxy**:
7
+ *
8
+ * DSH plugin → http://127.0.0.1:13579 → 极空间桌面客户端 → 云中转/P2P → NAS
9
+ *
10
+ * That relay is what makes cross-network access work: the DSH host never needs
11
+ * a LAN address, a port-forward, WebDAV or SSH, only a running, logged-in
12
+ * desktop client on the same machine.
13
+ *
14
+ * The proxy authenticates from the client's own `vuex.json` login state, so
15
+ * this module reads `token` / `nasId` / `deviceId` (plus the `device` and
16
+ * `version` fields the sliced-upload protocol needs) from that file instead of
17
+ * asking the user for a password.
18
+ *
19
+ * @module dsh-zspace/auth
20
+ */
21
+
22
+ import fs from "node:fs";
23
+ import os from "node:os";
24
+ import path from "node:path";
25
+
26
+ /** Default local proxy address of the ZSpace desktop client. */
27
+ export const DEFAULT_BASE_URL = "http://127.0.0.1:13579";
28
+
29
+ /**
30
+ * NAS API version string sent as the `version` common parameter. This is the
31
+ * desktop client's web-API version, not the client's own app version (which
32
+ * `vuex.json` carries separately and which the sliced upload protocol wants).
33
+ */
34
+ export const DEFAULT_API_VERSION = "2.3.2026042401";
35
+
36
+ /** Environment variable overriding the client config directory. */
37
+ export const CONFIG_DIR_ENV = "ZS_CONFIG_DIR";
38
+
39
+ /** Environment variable overriding the local proxy URL. */
40
+ export const BASE_URL_ENV = "ZS_BASE_URL";
41
+
42
+ const VUEX_FILENAME = "vuex.json";
43
+
44
+ /**
45
+ * Raised when the desktop client's login state cannot be located or parsed.
46
+ * The message is written for the model to act on, not just for a log.
47
+ */
48
+ export class ZSpaceAuthError extends Error {
49
+ /**
50
+ * @param message - actionable explanation.
51
+ * @param options - optional underlying cause.
52
+ */
53
+ constructor(message, options) {
54
+ super(message, options);
55
+ this.name = "ZSpaceAuthError";
56
+ }
57
+ }
58
+
59
+ /**
60
+ * Candidate directories holding `vuex.json`, most likely first.
61
+ *
62
+ * macOS is where the desktop client is confirmed to store login state;
63
+ * Windows/Linux paths mirror `zspace-cli` so the same plugin keeps working
64
+ * when the DSH host runs there.
65
+ *
66
+ * @returns {string[]} absolute candidate directories.
67
+ */
68
+ export function candidateConfigDirs() {
69
+ const home = os.homedir();
70
+ const dirs = [];
71
+ if (process.platform === "win32") {
72
+ for (const base of [process.env.APPDATA, process.env.LOCALAPPDATA, process.env.USERPROFILE]) {
73
+ if (base) dirs.push(path.join(base, "zspace"));
74
+ }
75
+ } else if (process.platform === "darwin") {
76
+ dirs.push(path.join(home, "Library", "Application Support", "zspace"));
77
+ } else {
78
+ dirs.push(path.join(home, ".zspace"), path.join(home, ".config", "zspace"), path.join(home, "zspace"));
79
+ }
80
+ // De-duplicate while preserving order (APPDATA and LOCALAPPDATA can alias).
81
+ return [...new Set(dirs)];
82
+ }
83
+
84
+ /**
85
+ * Locate `vuex.json`.
86
+ *
87
+ * Resolution order: the explicit `configDir` argument, then `$ZS_CONFIG_DIR`,
88
+ * then the platform defaults.
89
+ *
90
+ * @param {string} [configDir] - explicit directory override.
91
+ * @returns {string} path to `vuex.json`.
92
+ * @throws {ZSpaceAuthError} when no candidate holds the file.
93
+ */
94
+ export function locateVuex(configDir) {
95
+ const candidates = configDir
96
+ ? [configDir]
97
+ : process.env[CONFIG_DIR_ENV]
98
+ ? [process.env[CONFIG_DIR_ENV]]
99
+ : candidateConfigDirs();
100
+ for (const dir of candidates) {
101
+ const file = path.join(dir, VUEX_FILENAME);
102
+ if (fs.existsSync(file)) return file;
103
+ }
104
+ const tried = candidates.map(dir => ` - ${path.join(dir, VUEX_FILENAME)}`).join("\n");
105
+ throw new ZSpaceAuthError(
106
+ `极空间登录态未找到(vuex.json)。请确认极空间桌面客户端已安装、已登录并在运行;` +
107
+ `或用环境变量 ${CONFIG_DIR_ENV} 指定配置目录。已尝试:\n${tried}`,
108
+ );
109
+ }
110
+
111
+ /** Cache of parsed credentials keyed by vuex.json path. @type {Map<string, {stamp: string, creds: object}>} */
112
+ const credentialCache = new Map();
113
+
114
+ /**
115
+ * Load the desktop client's login state.
116
+ *
117
+ * Parsed results are cached per file and invalidated when `vuex.json` changes
118
+ * (re-login or token refresh), so a long-lived DSH session does not re-read
119
+ * the file on every tool call.
120
+ *
121
+ * @param {string} [configDir] - explicit config directory override.
122
+ * @returns {{token: string, nasId: string, deviceId: string, username: string, device: string, appVersion: string, vuexPath: string}}
123
+ * @throws {ZSpaceAuthError} when the file is missing, unparseable or has no token.
124
+ */
125
+ export function loadCredentials(configDir) {
126
+ const vuexPath = locateVuex(configDir);
127
+ const stat = fs.statSync(vuexPath);
128
+ const stamp = `${stat.mtimeMs}:${stat.size}`;
129
+ const cached = credentialCache.get(vuexPath);
130
+ if (cached && cached.stamp === stamp) return cached.creds;
131
+
132
+ let parsed;
133
+ try {
134
+ // The client writes a UTF-8 BOM on some versions.
135
+ parsed = JSON.parse(fs.readFileSync(vuexPath, "utf8").replace(/^\uFEFF/, ""));
136
+ } catch (error) {
137
+ throw new ZSpaceAuthError(`极空间登录态无法解析:${vuexPath}(${error.message})`, { cause: error });
138
+ }
139
+ const state = parsed.state ?? parsed;
140
+ const user = state.user ?? {};
141
+ const nas = state.nas ?? {};
142
+ const app = state.app ?? {};
143
+ if (!user.token) {
144
+ throw new ZSpaceAuthError(`极空间登录态缺少 token(${vuexPath})。请在桌面客户端重新登录后重试。`);
145
+ }
146
+ const creds = {
147
+ token: user.token,
148
+ nasId: nas.nasId ?? "",
149
+ deviceId: app.deviceId ?? "",
150
+ username: user.username ?? "",
151
+ device: app.device ?? "",
152
+ appVersion: app.version ?? "1.0",
153
+ vuexPath,
154
+ };
155
+ credentialCache.set(vuexPath, { stamp, creds });
156
+ return creds;
157
+ }
158
+
159
+ /**
160
+ * Resolve the local proxy URL: explicit setting, then `$ZS_BASE_URL`, then the
161
+ * default desktop-client port.
162
+ *
163
+ * @param {string} [configured] - value from the plugin config.
164
+ * @returns {string} base URL without a trailing slash.
165
+ */
166
+ export function resolveBaseUrl(configured) {
167
+ const value = configured || process.env[BASE_URL_ENV] || DEFAULT_BASE_URL;
168
+ return String(value).replace(/\/+$/, "");
169
+ }
170
+
171
+ /** Drop the credential cache (tests and explicit re-login recovery). */
172
+ export function clearCredentialCache() {
173
+ credentialCache.clear();
174
+ }
@@ -0,0 +1,65 @@
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
+
49
+ /**
50
+ * File or directory metadata.
51
+ *
52
+ * @param {string} remotePath - absolute remote path.
53
+ * @returns {Promise<ReturnType<typeof toEntry>>} normalized entry.
54
+ */
55
+ export async function info(client, remotePath) {
56
+ const data = await client.post("/v2/file/info", { path: remotePath });
57
+ return toEntry(data ?? {});
58
+ }
59
+
60
+ /**
61
+ * Create one directory.
62
+ *
63
+ * @param {string} remotePath - absolute path of the new directory.
64
+ * @returns {Promise<ReturnType<typeof toEntry>>} the created entry.
65
+ */
@@ -0,0 +1,130 @@
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
+
54
+ /**
55
+ * Read the head of a remote file into memory.
56
+ *
57
+ * @param {string} remotePath - absolute remote file.
58
+ * @param {{maxBytes?: number}} [options] - byte cap.
59
+ * @returns {Promise<{buffer: Buffer, truncated: boolean, bytes: number}>} content.
60
+ */
61
+ export async function readFile(client, remotePath, options = {}) {
62
+ const maxBytes = options.maxBytes ?? 262_144;
63
+ const response = await client.openDownload(remotePath);
64
+ const status = response.statusCode ?? 0;
65
+ const contentType = String(response.headers["content-type"] ?? "");
66
+ if (status >= 400 || contentType.includes("json")) {
67
+ const { buffer } = await readStream(response);
68
+ client.parse({ status, text: buffer.toString("utf8") }, "/v2/file/download");
69
+ }
70
+ const { buffer, truncated } = await readStream(response, maxBytes);
71
+ response.destroy();
72
+ return { buffer, truncated, bytes: buffer.length };
73
+ }
74
+
75
+ /**
76
+ * Open a download response, retrying transient proxy failures.
77
+ *
78
+ * @param {string} remotePath - absolute remote file.
79
+ * @returns {Promise<import("node:http").IncomingMessage>} the open response (caller consumes it).
80
+ */
81
+
82
+ /**
83
+ * Open a download response, retrying transient proxy failures.
84
+ *
85
+ * @param {string} remotePath - absolute remote file.
86
+ * @returns {Promise<import("node:http").IncomingMessage>} the open response (caller consumes it).
87
+ */
88
+ export async function openDownload(client, remotePath) {
89
+ const url = client.endpointUrl("/v2/file/download", { path: remotePath, remote_port: "8050" });
90
+ return await client.retrying(
91
+ () =>
92
+ new Promise((resolve, reject) => {
93
+ const request = (client.baseUrl.startsWith("https:") ? https : http).request(
94
+ url,
95
+ { method: "GET", headers: client.authHeaders() },
96
+ (response) => {
97
+ const status = response.statusCode ?? 0;
98
+ if (status >= 500 || status === 429) {
99
+ response.resume();
100
+ reject(new ZSpaceError(`HTTP${status}`, `HTTP ${status} 下载失败:${remotePath}`, { endpoint: "/v2/file/download" }));
101
+ return;
102
+ }
103
+ resolve(response);
104
+ },
105
+ );
106
+ request.setTimeout(client.timeoutMs, () => request.destroy(new ZSpaceError("ETIMEDOUT", `下载超时:${remotePath}`)));
107
+ request.on("error", (error) => {
108
+ if (error instanceof ZSpaceError) reject(error);
109
+ else reject(new ZSpaceError("EPROXY", `下载失败(${remotePath}):${error.message}`, { endpoint: "/v2/file/download" }));
110
+ });
111
+ request.end();
112
+ }),
113
+ error =>
114
+ error instanceof ZSpaceError &&
115
+ (error.code === "ETIMEDOUT" || error.code === "EPROXY" || /^HTTP(5\d\d|429)$/.test(error.code)),
116
+ );
117
+ }
118
+
119
+ /**
120
+ * Upload a local file into a remote directory.
121
+ *
122
+ * Files at or below `smallUploadMaxBytes` use `/v2/file/create`; larger ones
123
+ * (and any request the local proxy rejects with HTTP 413) use the desktop
124
+ * client's sliced `/v2/file/upload` protocol.
125
+ *
126
+ * @param {string} localPath - local file.
127
+ * @param {string} remoteDir - absolute remote directory.
128
+ * @param {{name?: string, onProgress?: (sent: number, total: number) => void}} [options] - upload options.
129
+ * @returns {Promise<{remotePath: string, bytes: number, method: "create"|"sliced"}>} upload result.
130
+ */