dsh-zspace 0.0.0-stage → 0.1.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/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,109 @@
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
+ ```sh
97
+ node --test test/client.test.js test/tools.test.js test/adaptor.test.js # 27 项单测/集成(mock 代理)
98
+ node scripts/live-selftest.js # 真机端到端自检(会创建并清理临时目录)
99
+ ```
100
+
101
+ `live-selftest.js` 覆盖:探活 → 存储池 → 定位个人/公共空间 → 建目录 → 中文名单请求上传 → 强制多分片上传 → 详情 → 读取 → 下载并校验 sha256 → 服务端复制 → 重命名 → 遍历查找 → 移动 → 删除 → 确认消失。
102
+
103
+ ## 与已有极空间 skill 的关系
104
+
105
+ 本机的 `zspace-nas` / `zspace-public-access` 等 skill 依赖 `zspace-cli`(Python)或独立 Node 脚本;本插件把同一套协议**内置进 DSH**,不依赖 Python、不依赖额外命令,并且带输出 schema、错误提示与安全护栏。两者可以共存。
106
+
107
+ ## 许可
108
+
109
+ 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/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,63 @@
1
+ /** Type surface for the dependency-free ZSpace client. */
2
+
3
+ /** NAS business code or synthetic transport marker. */
4
+ export type ZSpaceErrorCode = string;
5
+
6
+ /** One ZSpace API or transport failure. */
7
+ export declare class ZSpaceError extends Error {
8
+ readonly code: ZSpaceErrorCode;
9
+ readonly endpoint: string;
10
+ /** Actionable next step for known codes. */
11
+ readonly hint: string | undefined;
12
+ constructor(code: string, message: string, options?: { endpoint?: string; cause?: unknown });
13
+ }
14
+
15
+ /** Normalized NAS entry. */
16
+ export interface ZSpaceEntry {
17
+ name: string;
18
+ path: string;
19
+ dir: boolean;
20
+ size: number;
21
+ modified: string;
22
+ created: string;
23
+ ext: string;
24
+ }
25
+
26
+ /** Client construction options. */
27
+ export interface ZSpaceClientOptions {
28
+ baseUrl?: string;
29
+ configDir?: string;
30
+ apiVersion?: string;
31
+ credentials?: { token: string; nasId: string; deviceId: string; device?: string; appVersion?: string; username?: string };
32
+ homePath?: string;
33
+ publicPath?: string;
34
+ timeoutMs?: number;
35
+ maxRetries?: number;
36
+ retryDelayMs?: number;
37
+ pageSize?: number;
38
+ listMaxEntries?: number;
39
+ smallUploadMaxBytes?: number;
40
+ sliceSize?: number;
41
+ }
42
+
43
+ /** Cross-network ZSpace NAS client (desktop client local proxy). */
44
+ export declare class ZSpaceClient {
45
+ readonly baseUrl: string;
46
+ readonly identity: { username: string; nasId: string; deviceId: string; vuexPath: string };
47
+ constructor(options?: ZSpaceClientOptions);
48
+ check(): Promise<boolean>;
49
+ pools(): Promise<Array<{ name: string; status: string; totalSize: number; freeSize: number }>>;
50
+ isDirectory(remotePath: string): Promise<boolean>;
51
+ homePath(): Promise<string>;
52
+ publicPath(): Promise<string>;
53
+ list(remotePath: string, options?: { showHidden?: boolean; maxEntries?: number }): Promise<{ entries: ZSpaceEntry[]; truncated: boolean }>;
54
+ info(remotePath: string): Promise<ZSpaceEntry>;
55
+ mkdir(remotePath: string): Promise<ZSpaceEntry>;
56
+ rename(remotePath: string, newName: string): Promise<ZSpaceEntry>;
57
+ move(remotePaths: string[], to: string): Promise<void>;
58
+ copy(remotePaths: string[], to: string): Promise<void>;
59
+ remove(remotePaths: string[]): Promise<void>;
60
+ download(remotePath: string, localDir: string, options?: { name?: string }): Promise<{ localPath: string; bytes: number }>;
61
+ readFile(remotePath: string, options?: { maxBytes?: number }): Promise<{ buffer: Buffer; truncated: boolean; bytes: number }>;
62
+ upload(localPath: string, remoteDir: string, options?: { name?: string; onProgress?: (sent: number, total: number) => void }): Promise<{ remotePath: string; bytes: number; method: "create" | "sliced" }>;
63
+ }