zcode-pro 0.16.0__py3-none-any.whl

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.
Files changed (41) hide show
  1. zcode_pro/__init__.py +3 -0
  2. zcode_pro/__main__.py +4 -0
  3. zcode_pro/app/cli.mjs +9 -0
  4. zcode_pro/app/dist/inject.js +5821 -0
  5. zcode_pro/app/src/host/cdp.mjs +126 -0
  6. zcode_pro/app/src/host/helper.mjs +2134 -0
  7. zcode_pro/app/src/host/main.mjs +262 -0
  8. zcode_pro/app/src/host/paths.mjs +73 -0
  9. zcode_pro/app/src/host/pickFolder.mjs +152 -0
  10. zcode_pro/app/src/host/settings.mjs +61 -0
  11. zcode_pro/app/src/host/taskIndex.mjs +255 -0
  12. zcode_pro/app/src/host/taskOrder.mjs +117 -0
  13. zcode_pro/app/src/host/webdav.mjs +105 -0
  14. zcode_pro/app/src/inject/core.js +751 -0
  15. zcode_pro/app/src/inject/features/alias.js +211 -0
  16. zcode_pro/app/src/inject/features/copy-path.js +78 -0
  17. zcode_pro/app/src/inject/features/file-menu.js +147 -0
  18. zcode_pro/app/src/inject/features/idle-reclaim.js +179 -0
  19. zcode_pro/app/src/inject/features/image-menu.js +215 -0
  20. zcode_pro/app/src/inject/features/open-folder.js +43 -0
  21. zcode_pro/app/src/inject/features/pinned-collapse.js +132 -0
  22. zcode_pro/app/src/inject/features/pinned-expand.js +142 -0
  23. zcode_pro/app/src/inject/features/plugin-updates.js +28 -0
  24. zcode_pro/app/src/inject/features/project-menu.js +58 -0
  25. zcode_pro/app/src/inject/features/relocate-dialog.js +156 -0
  26. zcode_pro/app/src/inject/features/session-switch.js +244 -0
  27. zcode_pro/app/src/inject/features/settings-dialog.js +1503 -0
  28. zcode_pro/app/src/inject/features/settings-entry.js +21 -0
  29. zcode_pro/app/src/inject/features/styles.js +311 -0
  30. zcode_pro/app/src/inject/features/task-order.js +78 -0
  31. zcode_pro/app/src/inject/features/title-lock.js +242 -0
  32. zcode_pro/app/src/inject/features/toolbar-icons.js +243 -0
  33. zcode_pro/app/src/inject/features/ws-running.js +472 -0
  34. zcode_pro/app/src/inject/index.js +55 -0
  35. zcode_pro/app/src/inject/ui.js +734 -0
  36. zcode_pro/cli.py +71 -0
  37. zcode_pro-0.16.0.dist-info/METADATA +45 -0
  38. zcode_pro-0.16.0.dist-info/RECORD +41 -0
  39. zcode_pro-0.16.0.dist-info/WHEEL +4 -0
  40. zcode_pro-0.16.0.dist-info/entry_points.txt +2 -0
  41. zcode_pro-0.16.0.dist-info/licenses/LICENSE +201 -0
@@ -0,0 +1,255 @@
1
+ // 任务索引(~/.zcode/v2/tasks-index.sqlite)的路径重映射。
2
+ // 项目切换文件夹后,侧边栏项目列表与任务历史由该库派生,需同步更新其中的路径引用。
3
+ //
4
+ // workspace 列分布(逆向自真实库 schema):
5
+ // - 仅 workspace_key:task_group_workspace_bootstraps(主键)、automation_runs;
6
+ // - workspace_key + workspace_path + workspace_identity:
7
+ // tasks(key 为联合主键)、automations、off_peak_tasks、task_group_members(key 为联合主键);
8
+ // - 另有 tasks.meta_json 内嵌 "workspacePath":"<路径>";
9
+ // task_group_view_node_orders.node_key(node_type='task')内嵌 JSON 数组 ["<workspace_key>","<task_id>"];
10
+ // - workspace_identity 格式未详,按“引号界定的精确串”替换(哈希值不会含路径,LIKE 只做免更新过滤);
11
+ // - tasks.searchable_text 是会话内容文本(可能提及任何路径),不属于路径引用,不动。
12
+ //
13
+ // 驱动:优先 node:sqlite(Node ≥23.4 内置),退回 sqlite3 CLI;两者都没有则返回 null,
14
+ // 调用方自行降级(只移动目录与更新 setting.json)。
15
+ // 并发:ZCode 宿主进程持有该库(WAL 模式),所有写入走 BEGIN IMMEDIATE + busy_timeout。
16
+
17
+ import { spawnSync } from 'node:child_process';
18
+ import { existsSync } from 'node:fs';
19
+ import { join } from 'node:path';
20
+
21
+ const BUSY_TIMEOUT_MS = 5000;
22
+
23
+ export function taskIndexPath(dataRoot) {
24
+ return join(dataRoot, 'v2', 'tasks-index.sqlite');
25
+ }
26
+
27
+ export async function taskIndexDriverAvailable() {
28
+ return (await loadDriver()) !== null;
29
+ }
30
+
31
+ async function loadDriver() {
32
+ // ZCODEPRO_SQLITE_DRIVER=cli:调试/测试用,强制走 sqlite3 CLI 驱动
33
+ if (String(process.env.ZCODEPRO_SQLITE_DRIVER || '').toLowerCase() !== 'cli') {
34
+ try {
35
+ const { DatabaseSync } = await import('node:sqlite');
36
+ return { kind: 'node', DatabaseSync };
37
+ } catch { /* Node <23.4:退回 CLI */ }
38
+ }
39
+ try {
40
+ const r = spawnSync('sqlite3', ['--version'], { encoding: 'utf8' });
41
+ if (r.status === 0) return { kind: 'cli' };
42
+ } catch { /* 没有 sqlite3 可执行文件 */ }
43
+ return null;
44
+ }
45
+
46
+ // 探测:能在 BUSY_TIMEOUT 内拿到写锁。失败抛 { code: 'index-busy' | 'index-error' }。
47
+ export async function probeTaskIndexWritable(dataRoot) {
48
+ const dbFile = taskIndexPath(dataRoot);
49
+ const driver = await loadDriver();
50
+ if (!driver) {
51
+ const err = new Error('缺少 SQLite 支持(需要 Node ≥23.4 或 sqlite3 命令)');
52
+ err.code = 'index-error';
53
+ throw err;
54
+ }
55
+ try {
56
+ if (driver.kind === 'node') {
57
+ const db = new driver.DatabaseSync(dbFile);
58
+ try {
59
+ db.exec(`PRAGMA busy_timeout = ${BUSY_TIMEOUT_MS}`);
60
+ db.exec('BEGIN IMMEDIATE');
61
+ db.exec('ROLLBACK');
62
+ } finally { db.close(); }
63
+ } else {
64
+ runCli(dbFile, 'BEGIN IMMEDIATE;\nROLLBACK;');
65
+ }
66
+ } catch (err) {
67
+ if (!isBusyError(err)) {
68
+ const e = new Error('任务索引不可写: ' + (err?.message || err));
69
+ e.code = 'index-error';
70
+ throw e;
71
+ }
72
+ const e = new Error('任务索引正被占用(ZCode 可能正在写入),请稍后重试');
73
+ e.code = 'index-busy';
74
+ throw e;
75
+ }
76
+ }
77
+
78
+ // 把索引中所有引用 oldPath 的 workspace 键/字段重映射为 newPath,单事务。
79
+ // 返回 { changed }(SQLite changes() 计数之和,CLI 驱动返回 null)。
80
+ export async function remapTaskIndexPaths(dataRoot, oldPath, newPath) {
81
+ const dbFile = taskIndexPath(dataRoot);
82
+ const driver = await loadDriver();
83
+ if (!driver) {
84
+ const err = new Error('缺少 SQLite 支持(需要 Node ≥23.4 或 sqlite3 命令)');
85
+ err.code = 'index-error';
86
+ throw err;
87
+ }
88
+ const sep = process.platform === 'win32' ? '\\' : '/';
89
+ // workspace_key 的两种拼写(正常化尾分隔符差异)
90
+ const variants = [
91
+ [oldPath, newPath],
92
+ [oldPath + sep, newPath + sep],
93
+ ];
94
+ // workspace_key 所在的表(精确等值替换,无前缀误伤风险)
95
+ const KEY_TABLES = ['tasks', 'task_group_members', 'task_group_workspace_bootstraps', 'automations', 'automation_runs', 'off_peak_tasks'];
96
+ // 带 workspace_path / workspace_identity 的表
97
+ const PATH_TABLES = ['tasks', 'automations', 'off_peak_tasks', 'task_group_members'];
98
+ // 引号界定的 JSON 字符串替换:"/a/b/old" 不会命中 "/a/b/old2"
99
+ const quoted = (p) => JSON.stringify(p);
100
+ const metaToken = (p) => '"workspacePath":' + JSON.stringify(p);
101
+
102
+ if (driver.kind === 'node') return remapWithNode(driver.DatabaseSync, dbFile);
103
+ return remapWithCli(dbFile);
104
+
105
+ function remapWithNode(DatabaseSync, file) {
106
+ const db = new DatabaseSync(file);
107
+ let changed = 0;
108
+ try {
109
+ db.exec(`PRAGMA busy_timeout = ${BUSY_TIMEOUT_MS}`);
110
+ db.exec('BEGIN IMMEDIATE');
111
+ try {
112
+ const run = (sql, params) => {
113
+ changed += db.prepare(sql).run(...params).changes;
114
+ };
115
+ for (const [oldKey, newKey] of variants) {
116
+ for (const table of KEY_TABLES) {
117
+ run(`UPDATE ${table} SET workspace_key = ? WHERE workspace_key = ?`, [newKey, oldKey]);
118
+ }
119
+ for (const table of PATH_TABLES) {
120
+ run(`UPDATE ${table} SET workspace_path = ? WHERE workspace_path = ?`, [newKey, oldKey]);
121
+ run(`UPDATE ${table} SET workspace_identity = replace(workspace_identity, ?, ?)
122
+ WHERE instr(workspace_identity, ?) > 0`, [quoted(oldKey), quoted(newKey), oldKey]);
123
+ }
124
+ run(`UPDATE tasks SET meta_json = replace(meta_json, ?, ?)
125
+ WHERE instr(meta_json, ?) > 0`, [metaToken(oldKey), metaToken(newKey), oldKey]);
126
+ run(`UPDATE task_group_view_node_orders SET node_key = replace(node_key, ?, ?)
127
+ WHERE instr(node_key, ?) > 0`, [quoted(oldKey), quoted(newKey), oldKey]);
128
+ }
129
+ db.exec('COMMIT');
130
+ } catch (err) {
131
+ try { db.exec('ROLLBACK'); } catch { /* 已回滚/连接失效 */ }
132
+ throw asRemapError(err);
133
+ }
134
+ } finally { db.close(); }
135
+ return { changed };
136
+ }
137
+
138
+ function remapWithCli(file) {
139
+ const esc = (v) => "'" + String(v).replace(/'/g, "''") + "'";
140
+ const stmts = [];
141
+ for (const [oldKey, newKey] of variants) {
142
+ for (const table of KEY_TABLES) {
143
+ stmts.push(`UPDATE ${table} SET workspace_key = ${esc(newKey)} WHERE workspace_key = ${esc(oldKey)};`);
144
+ }
145
+ for (const table of PATH_TABLES) {
146
+ stmts.push(`UPDATE ${table} SET workspace_path = ${esc(newKey)} WHERE workspace_path = ${esc(oldKey)};`);
147
+ stmts.push(`UPDATE ${table} SET workspace_identity = replace(workspace_identity, ${esc(quoted(oldKey))}, ${esc(quoted(newKey))})
148
+ WHERE instr(workspace_identity, ${esc(oldKey)}) > 0;`);
149
+ }
150
+ stmts.push(`UPDATE tasks SET meta_json = replace(meta_json, ${esc(metaToken(oldKey))}, ${esc(metaToken(newKey))})
151
+ WHERE instr(meta_json, ${esc(oldKey)}) > 0;`);
152
+ stmts.push(`UPDATE task_group_view_node_orders SET node_key = replace(node_key, ${esc(quoted(oldKey))}, ${esc(quoted(newKey))})
153
+ WHERE instr(node_key, ${esc(oldKey)}) > 0;`);
154
+ }
155
+ try {
156
+ runCli(file, 'BEGIN IMMEDIATE;\n' + stmts.join('\n') + '\nCOMMIT;');
157
+ } catch (err) {
158
+ throw asRemapError(err);
159
+ }
160
+ return { changed: null };
161
+ }
162
+
163
+ function asRemapError(err) {
164
+ if (isBusyError(err)) {
165
+ const e = new Error('任务索引正被占用(ZCode 可能正在写入),请稍后重试');
166
+ e.code = 'index-busy';
167
+ return e;
168
+ }
169
+ const e = new Error(String(err?.message || err));
170
+ e.code = 'index-remap-failed';
171
+ return e;
172
+ }
173
+ }
174
+
175
+ // 已重命名会话的标题表:task_id → title(title_overridden = 1 且未删除)。
176
+ // 只读单条 SELECT,不开事务;库不存在或没有 SQLite 支持时返回空表,
177
+ // 调用方(会话名锁定)自然降级为不干预。
178
+ export async function listOverriddenTitles(dataRoot) {
179
+ const dbFile = taskIndexPath(dataRoot);
180
+ if (!existsSync(dbFile)) return { titles: {} };
181
+ const driver = await loadDriver();
182
+ if (!driver) return { titles: {} };
183
+ const titles = {};
184
+ try {
185
+ if (driver.kind === 'node') {
186
+ const db = new driver.DatabaseSync(dbFile);
187
+ try {
188
+ db.exec(`PRAGMA busy_timeout = ${BUSY_TIMEOUT_MS}`);
189
+ for (const row of db.prepare(
190
+ 'SELECT task_id, title FROM tasks WHERE title_overridden = 1 AND deleted = 0'
191
+ ).all()) {
192
+ titles[row.task_id] = row.title;
193
+ }
194
+ } finally { db.close(); }
195
+ } else {
196
+ // json_group_object:标题里的任意字符(含引号竖线)都被正确转义,整行即一个 JSON
197
+ const r = runCli(dbFile,
198
+ 'SELECT coalesce(json_group_object(task_id, title), \'{}\') FROM tasks WHERE title_overridden = 1 AND deleted = 0;');
199
+ Object.assign(titles, JSON.parse(r.stdout.trim() || '{}'));
200
+ }
201
+ } catch (err) {
202
+ const e = new Error('读取任务索引失败: ' + (err?.message || err));
203
+ e.code = 'index-error';
204
+ return { error: e.message, code: e.code };
205
+ }
206
+ return { titles };
207
+ }
208
+
209
+ function isBusyError(err) {
210
+ const s = String(err?.code || '') + String(err?.message || '');
211
+ return /SQLITE_BUSY|database is locked/i.test(s);
212
+ }
213
+
214
+ // 任务(会话)ID → 工作区路径(无尾分隔符):空闲回收在 Windows 上的后台命令
215
+ // 归属判定用(命令行里只带会话 ID,需经索引反查它属于哪个项目)。
216
+ // 读取失败返回空表,调用方按“无法归属”保守处理
217
+ export async function listTaskWorkspaceMap(dataRoot) {
218
+ const out = {};
219
+ const dbFile = taskIndexPath(dataRoot);
220
+ if (!existsSync(dbFile)) return out;
221
+ const driver = await loadDriver();
222
+ if (!driver) return out;
223
+ try {
224
+ let rows = [];
225
+ if (driver.kind === 'node') {
226
+ const db = new driver.DatabaseSync(dbFile);
227
+ try {
228
+ db.exec(`PRAGMA busy_timeout = ${BUSY_TIMEOUT_MS}`);
229
+ rows = db.prepare('SELECT task_id, workspace_path, workspace_key FROM tasks').all();
230
+ } finally { db.close(); }
231
+ } else {
232
+ const r = runCli(dbFile,
233
+ "SELECT coalesce(json_group_array(json_object('id', task_id, 'path', workspace_path, 'key', workspace_key)), '[]') FROM tasks;");
234
+ rows = JSON.parse(r.stdout.trim() || '[]');
235
+ }
236
+ for (const row of rows) {
237
+ const id = row.task_id ?? row.id;
238
+ const ws = String(row.workspace_path ?? row.path ?? row.workspace_key ?? row.key ?? '').replace(/[\\/]+$/, '');
239
+ if (id && ws) out[id] = ws;
240
+ }
241
+ } catch { /* 读取失败按空表处理 */ }
242
+ return out;
243
+ }
244
+
245
+ function runCli(dbFile, script) {
246
+ const r = spawnSync('sqlite3', ['-bail', '-batch', dbFile], {
247
+ windowsHide: true,
248
+ input: `.timeout ${BUSY_TIMEOUT_MS}\n` + script,
249
+ encoding: 'utf8',
250
+ maxBuffer: 1 << 20,
251
+ });
252
+ if (r.error) throw r.error;
253
+ if (r.status !== 0) throw new Error('sqlite3 CLI 执行失败: ' + (r.stderr || r.stdout || ('exit ' + r.status)));
254
+ return r;
255
+ }
@@ -0,0 +1,117 @@
1
+ // 会话(任务)手动排序持久化。
2
+ // 侧边栏两个列表来源(逆向自应用查询):
3
+ // - 置顶 / 项目中的会话:按 tasks.updated_at DESC 排序,
4
+ // 重排 = 按新顺序重盖 updated_at(同步 meta_json.updatedAt,避免下次合并回跳);
5
+ // - 分组中的会话:按 task_group_members.sort_order 升序(compareGroupTasks),
6
+ // 重排 = 重写该组成员的 sort_order。
7
+ // 两种都采用“子序列拼接”策略:仅调整拖动子集的相对位置,
8
+ // 未出现在拖动结果里的任务保持原有相对次序。
9
+ // 会话键格式与页面 data-task-item-key 一致:`<workspace_path>:<task_id>`(按最后一个 ':' 分割)。
10
+
11
+ import { DatabaseSync } from 'node:sqlite';
12
+ import { join } from 'node:path';
13
+
14
+ function splitKey(k) {
15
+ const sep = k.lastIndexOf(':');
16
+ if (sep <= 0) return null;
17
+ return [k.slice(0, sep), k.slice(sep + 1)];
18
+ }
19
+
20
+ function withDb(dataRoot, fn) {
21
+ const db = new DatabaseSync(join(dataRoot, 'v2', 'tasks-index.sqlite'));
22
+ try {
23
+ db.exec('PRAGMA busy_timeout = 5000');
24
+ return fn(db);
25
+ } finally {
26
+ db.close();
27
+ }
28
+ }
29
+
30
+ // 在 current 全序中剔除子集后,把子集按新顺序插回其原有最小位置
31
+ function spliceSubset(current, orderedSubset) {
32
+ const subset = new Set(orderedSubset);
33
+ let anchor = Infinity;
34
+ current.forEach((k, i) => { if (subset.has(k)) anchor = Math.min(anchor, i); });
35
+ if (!isFinite(anchor)) anchor = current.length;
36
+ const head = current.filter((k, i) => !subset.has(k) && i < anchor);
37
+ const tail = current.filter((k, i) => !subset.has(k) && i >= anchor);
38
+ return [...head, ...orderedSubset.filter((k) => subset.has(k)), ...tail];
39
+ }
40
+
41
+ export async function reorderWorkspaceTasks(dataRoot, orderedKeys) {
42
+ const first = splitKey(orderedKeys[0] || '');
43
+ if (!first) return { code: 'invalid-request', error: '无效的会话标识' };
44
+ const workspace = first[0];
45
+
46
+ return withDb(dataRoot, (db) => {
47
+ const current = db.prepare(
48
+ `SELECT task_id FROM tasks
49
+ WHERE workspace_key = ? AND deleted = 0
50
+ ORDER BY updated_at DESC, created_at DESC, task_id DESC`
51
+ ).all(workspace).map((r) => r.task_id);
52
+ if (current.length === 0) return { code: 'workspace-empty', error: '工作区不存在或没有会话' };
53
+
54
+ const next = spliceSubset(current, orderedKeys.map((k) => splitKey(k)[1]));
55
+ const now = Date.now();
56
+ const upd = db.prepare(
57
+ `UPDATE tasks SET updated_at = ?, meta_json = json_set(meta_json, '$.updatedAt', ?)
58
+ WHERE workspace_key = ? AND task_id = ?`
59
+ );
60
+ db.exec('BEGIN IMMEDIATE');
61
+ try {
62
+ next.forEach((taskId, idx) => upd.run(now - idx * 1000, now - idx * 1000, workspace, taskId));
63
+ db.exec('COMMIT');
64
+ } catch (err) {
65
+ try { db.exec('ROLLBACK'); } catch { /* ignore */ }
66
+ return { code: 'order-write', error: '写入排序失败: ' + (err?.message || err) };
67
+ }
68
+ return { reordered: Math.min(orderedKeys.length, current.length), workspace };
69
+ });
70
+ }
71
+
72
+ export async function reorderGroupMembers(dataRoot, orderedKeys) {
73
+ return withDb(dataRoot, (db) => {
74
+ // 定位同时包含全部所列会话的组(应当唯一)
75
+ const counts = new Map();
76
+ const stmt = db.prepare(`SELECT group_id FROM task_group_members WHERE workspace_key = ? AND task_id = ?`);
77
+ for (const k of orderedKeys) {
78
+ const pair = splitKey(k);
79
+ if (!pair) return { code: 'invalid-request', error: '无效的会话标识: ' + k };
80
+ for (const r of stmt.all(...pair)) {
81
+ counts.set(r.group_id, (counts.get(r.group_id) || 0) + 1);
82
+ }
83
+ }
84
+ const groupIds = [...counts.entries()].filter(([, c]) => c === orderedKeys.length).map(([g]) => g);
85
+ if (groupIds.length !== 1) {
86
+ return groupIds.length === 0
87
+ ? { code: 'group-not-found', error: '未找到包含这些会话的分组' }
88
+ : { code: 'group-ambiguous', error: '无法唯一确定分组' };
89
+ }
90
+ const groupId = groupIds[0];
91
+
92
+ const current = db.prepare(
93
+ `SELECT workspace_key || ':' || task_id AS k
94
+ FROM task_group_members WHERE group_id = ?
95
+ ORDER BY COALESCE(sort_order, 2147483647) ASC, added_at ASC`
96
+ ).all(groupId).map((r) => r.k);
97
+
98
+ const next = spliceSubset(current, orderedKeys);
99
+ const upd = db.prepare(
100
+ `UPDATE task_group_members SET sort_order = ?, updated_at = ?
101
+ WHERE group_id = ? AND workspace_key = ? AND task_id = ?`
102
+ );
103
+ const now = Date.now();
104
+ db.exec('BEGIN IMMEDIATE');
105
+ try {
106
+ next.forEach((k, idx) => {
107
+ const [w, t] = splitKey(k);
108
+ upd.run(idx, now, groupId, w, t);
109
+ });
110
+ db.exec('COMMIT');
111
+ } catch (err) {
112
+ try { db.exec('ROLLBACK'); } catch { /* ignore */ }
113
+ return { code: 'order-write', error: '写入分组排序失败: ' + (err?.message || err) };
114
+ }
115
+ return { reordered: Math.min(orderedKeys.length, current.length), groupId };
116
+ });
117
+ }
@@ -0,0 +1,105 @@
1
+ // WebDAV 传输(设置同步用):用 curl 子进程完成 GET / PUT / MKCOL。
2
+ // Basic 凭据经 stdin 的 --config 文件传入,不进命令行参数;代理由调用方以
3
+ // proxyUrl 注入(与 helper 其他网络访问一致,空 = 不用代理)。
4
+ import { spawn } from 'node:child_process';
5
+ import { mkdtemp } from 'node:fs/promises'; // fs 的 mkdtemp 是回调版,await 它会把 undefined 当回调
6
+ import { readFileSync, rmSync } from 'node:fs';
7
+ import { tmpdir } from 'node:os';
8
+ import { join } from 'node:path';
9
+
10
+ // curl 退出码 → 人话(err.message 是整条命令行,对用户没有信息量)
11
+ const CURL_EXIT = {
12
+ 3: 'URL 格式错误',
13
+ 6: '无法解析服务器地址',
14
+ 7: '无法连接服务器',
15
+ 28: '超时',
16
+ 35: 'SSL 握手失败',
17
+ 47: '重定向过多',
18
+ 60: 'SSL 证书无效',
19
+ };
20
+
21
+ // HTTP 状态 → 同步场景的人话(404 不算失败,由调用方按“云端没有数据”处理)
22
+ export function webdavHttpText(code) {
23
+ if (code === 401 || code === 403) return `登录或密码被拒绝(HTTP ${code};坚果云等网盘需使用应用密码,不是登录密码)`;
24
+ if (code === 405) return '服务器不允许该操作(HTTP 405)';
25
+ if (code === 409) return '目标目录不存在(HTTP 409)';
26
+ if (code === 507) return '网盘空间不足(HTTP 507)';
27
+ return `HTTP ${code}`;
28
+ }
29
+
30
+ // curl 配置文件语法:双引号包裹,\ 与 " 需转义;换行无法出现在单行值里,替换为空格
31
+ function curlQuote(v) {
32
+ return '"' + String(v).replace(/([\\"])/g, '\\$1').replace(/[\r\n]+/g, ' ') + '"';
33
+ }
34
+
35
+ function proxyEnvVars(proxyUrl) {
36
+ if (!proxyUrl) return {};
37
+ return {
38
+ HTTP_PROXY: proxyUrl, HTTPS_PROXY: proxyUrl,
39
+ http_proxy: proxyUrl, https_proxy: proxyUrl,
40
+ };
41
+ }
42
+
43
+ // 执行一次 WebDAV 请求。method 为 GET / PUT / MKCOL;PUT 用 uploadFile 上传
44
+ // (-T 隐含 PUT)。返回 { ok, httpCode, body, error }:ok 表示 curl 本身执行
45
+ // 成功(HTTP 4xx/5xx 也算 ok,由调用方看 httpCode 分支);error 为中文原因。
46
+ export async function webdavRequest({ method, url, login = '', password = '', uploadFile = null, proxyUrl = '', timeoutMs = 45000 }) {
47
+ const dir = await mkdtemp(join(tmpdir(), 'zcodepro-webdav-'));
48
+ const bodyFile = join(dir, 'body');
49
+ try {
50
+ const args = ['-sS', '--connect-timeout', '10', '--max-time', String(Math.max(10, Math.ceil(timeoutMs / 1000))),
51
+ '-w', '%{http_code}', '-o', bodyFile, '--config', '-'];
52
+ if (method === 'PUT' && uploadFile) {
53
+ args.push('-H', 'Content-Type: application/json', '-T', uploadFile);
54
+ } else {
55
+ args.push('-X', method);
56
+ }
57
+ args.push(url);
58
+ const cfg = login || password ? `user = ${curlQuote(`${login}:${password}`)}\n` : '';
59
+ const r = await new Promise((resolveRun) => {
60
+ const child = spawn('curl', args, { env: { ...process.env, ...proxyEnvVars(proxyUrl) }, stdio: ['pipe', 'pipe', 'pipe'], windowsHide: true });
61
+ let stdout = '';
62
+ let stderr = '';
63
+ child.stdout.on('data', (d) => { stdout += d.toString(); });
64
+ child.stderr.on('data', (d) => { stderr += d.toString(); });
65
+ child.on('error', (err) => {
66
+ if (err.code === 'ENOENT') resolveRun({ code: -1, stdout: '', stderr: '' });
67
+ else resolveRun({ code: -2, stdout: '', stderr: String(err.message || err) });
68
+ });
69
+ child.on('close', (code) => resolveRun({ code: code == null ? -2 : code, stdout, stderr }));
70
+ child.stdin.write(cfg);
71
+ child.stdin.end();
72
+ });
73
+ if (r.code === -1) return { ok: false, httpCode: null, body: '', error: '未找到 curl 命令(WebDAV 同步依赖 curl)' };
74
+ if (r.code !== 0) {
75
+ const why = CURL_EXIT[r.code] || `curl 退出码 ${r.code}`;
76
+ const hint = (r.stderr.split('\n').find((l) => l.trim()) || '').trim();
77
+ return { ok: false, httpCode: null, body: '', error: hint ? `${why}(${hint})` : why };
78
+ }
79
+ let body = '';
80
+ try { body = readFileSync(bodyFile, 'utf8'); } catch { /* 无响应体 */ }
81
+ const httpCode = Number(parseInt(r.stdout.trim(), 10)) || 0;
82
+ return { ok: true, httpCode, body, error: '' };
83
+ } finally {
84
+ try { rmSync(dir, { recursive: true, force: true }); } catch { /* ignore */ }
85
+ }
86
+ }
87
+
88
+ // PUT 前确保云端目录存在:409(父目录缺失)时逐段 MKCOL 再重试一次。
89
+ // 已存在的目录 MKCOL 多半回 405,忽略即可——这里是尽力而为,最终成败仍看 PUT。
90
+ export async function webdavPutWithMkcol({ url, login, password, uploadFile, proxyUrl, timeoutMs }) {
91
+ const auth = { login, password, proxyUrl };
92
+ const first = await webdavRequest({ method: 'PUT', url, uploadFile, ...auth, timeoutMs });
93
+ if (!first.ok || (first.httpCode >= 200 && first.httpCode < 300) || first.httpCode !== 409) return first;
94
+ try {
95
+ const u = new URL(url);
96
+ const segs = u.pathname.split('/').filter(Boolean);
97
+ segs.pop(); // 最后一段是文件名
98
+ let cur = u.origin;
99
+ for (const s of segs) {
100
+ cur += '/' + s;
101
+ await webdavRequest({ method: 'MKCOL', url: cur, ...auth, timeoutMs: 15000 });
102
+ }
103
+ } catch { /* URL 解析失败:直接重试原 PUT */ }
104
+ return webdavRequest({ method: 'PUT', url, uploadFile, ...auth, timeoutMs });
105
+ }