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,2134 @@
1
+ // 本地辅助服务:注入到页面里的增强脚本通过 http://127.0.0.1:<port> 调用这里,
2
+ // 完成文件系统操作(页面沙箱内做不了的事)与配置持久化。
3
+ // 仅绑定 127.0.0.1,并通过每次启动随机生成的 token 鉴权。
4
+
5
+ import { createServer } from 'node:http';
6
+ import { execFile, spawn } from 'node:child_process';
7
+ import { chmodSync, copyFileSync, existsSync, mkdirSync, readdirSync, readFileSync, readlinkSync, realpathSync, renameSync, rmSync, statSync, unlinkSync, writeFileSync } from 'node:fs';
8
+ import { mkdtemp } from 'node:fs/promises'; // 注意:fs 的 mkdtemp 是回调版,await 它会把 undefined 当回调
9
+ import { basename, delimiter, dirname, isAbsolute, join, resolve, sep } from 'node:path';
10
+ import { homedir, tmpdir } from 'node:os';
11
+ import { pathToFileURL } from 'node:url';
12
+ import { readSettings, writeSettingsAtomic, remapSettingsPaths, isProjectOpenInTabs } from './settings.mjs';
13
+ import { taskIndexPath, probeTaskIndexWritable, remapTaskIndexPaths, taskIndexDriverAvailable, listOverriddenTitles, listTaskWorkspaceMap } from './taskIndex.mjs';
14
+ import { pickFolderSystem } from './pickFolder.mjs';
15
+ import { reorderWorkspaceTasks, reorderGroupMembers } from './taskOrder.mjs';
16
+ import { webdavRequest, webdavPutWithMkcol, webdavHttpText } from './webdav.mjs';
17
+
18
+ const VERSION = '0.16.0';
19
+
20
+ // 全局提示词固定在用户主目录:官方加载器按 HOME/USERPROFILE 拼 .zcode/AGENTS.md,
21
+ // 不读 ZCODE_DATA_BASE_DIR(数据根迁走时全局指令仍在原位)。
22
+ function defaultAgentsFile() {
23
+ return join(homedir(), '.zcode', 'AGENTS.md');
24
+ }
25
+
26
+ // zcode-vision 插件的配置(与 /vision-* 命令共用同一文件)
27
+ function defaultVisionFile() {
28
+ return join(homedir(), '.zcode', 'zcode-vision.json');
29
+ }
30
+
31
+ // rtk 插件的状态目录(钩子按 ${RTK_DIR:-~/.zcode-rtk} 解析,这里保持一致)
32
+ function defaultRtkDir() {
33
+ return process.env.RTK_DIR || join(homedir(), '.zcode-rtk');
34
+ }
35
+
36
+ // headroom 插件的配置文件(与 /hr-* 命令、钩子共用,headroom-lib.sh 按 HOME 定位)
37
+ function defaultHeadroomFile() {
38
+ return join(homedir(), '.zcode', 'headroom.json');
39
+ }
40
+
41
+ // 本插件市场(zcode-plugins 仓库)在 ZCode CLI 数据目录里的落点
42
+ const ZCODE_PLUGINS_MARKETPLACE = 'duanluan-zcode-plugins';
43
+
44
+ function cliPluginsDir() {
45
+ return join(homedir(), '.zcode', 'cli', 'plugins');
46
+ }
47
+
48
+ export function defaultConfig() {
49
+ return {
50
+ version: VERSION,
51
+ features: {
52
+ projectMenu: true, // 项目菜单增强:自定义别名 + 切换文件夹 + 打开文件夹 + 复制路径(含侧栏别名渲染)
53
+ taskOrder: true, // 侧边栏会话拖动排序持久化
54
+ fileActions: true, // 文件菜单:默认应用打开 / 打开所在目录
55
+ pinnedKeepCollapsed: false, // 点击置顶会话保持项目折叠(实验性:会先展开再缩起,有闪烁)
56
+ autoUpdatePlugins: false, // 启动时自动更新已装的 zcode-plugins 插件(含安装市场里新增的)
57
+ sessionSwitch: true, // 会话快捷切换(alt+z 上次会话;按住 alt x/c 弹窗导航,类 alt+tab)
58
+ titleLock: true, // 会话名锁定:手动重命名过的会话不被运行时推送的自动标题覆盖(侧栏钉回索引库中的名字)
59
+ wsRunningSpin: true, // 折叠项目运行提示:折叠后其中仍有会话运行时项目图标旋转
60
+ idleReclaim: true, // 空闲项目内存回收:长时间无活动的项目释放其会话进程(时长 idleReclaimMinutes)
61
+ toolbarIcons: true, // 侧栏菜单并入顶栏:新建任务/搜索/自动化/插件市场收成顶栏图标按钮(原菜单隐藏,点击转发)
62
+ pinnedCollapse: true, // 已置顶分区可折叠:标题可点折叠/展开任务列表(状态记 localStorage)
63
+ },
64
+ // 空闲项目内存回收的判定时长(分钟,1–1440):项目无运行中会话、无后台命令
65
+ // 且非当前选中,持续满该时长后释放其会话进程
66
+ idleReclaimMinutes: 30,
67
+ // 启动一次性回收的延后分钟(0–1440):应用启动会为每个恢复的项目预热进程,
68
+ // 到点把当时符合条件的项目一次收掉;0 = 启动后尽快回收一次
69
+ idleReclaimStartupMinutes: 5,
70
+ // 样式调整(设置弹窗“样式调整”标签页)。null = 不覆盖,跟随应用默认。
71
+ styles: {
72
+ rowGap: null, // 段落间距:会话内各块之间的垂直间距(应用默认 20px)
73
+ listSpacing: null, // 列表上下留白(应用默认 12px)
74
+ listItemSpacing: null, // 列表项之间的间距(应用默认 6px)
75
+ quoteCodeSpacing: null, // 引用/代码块上下留白(应用默认 16px,my-4)
76
+ lineHeight: null, // 回答行高,倍数(应用默认 1.75)
77
+ codeLineHeight: null, // 代码块行高,倍数(应用默认 12px 字号 × 20px 行盒,约 1.65)
78
+ tableSpacing: null, // 表格上下边距(未设置时跟随段落间距)
79
+ tableCellPaddingV: null, // 单元格上下边距(应用默认 3px)
80
+ tableCellPaddingH: null, // 单元格左右边距(应用默认 3px)
81
+ userLineHeight: null, // 提问行高,倍数(应用默认 1.5)
82
+ contentWidth: null, // 内容宽度:{ value, unit },unit 为 'px'(320–3840)或 '%'(20–100)
83
+ sidebarProjectSpacing: null, // 侧栏项目间距:视觉总量(行内留白+边距,应用默认 20px)
84
+ sidebarTaskSpacing: null, // 侧栏任务间距:行内留白+行间边距的视觉总量(应用默认 10px)
85
+ uiFont: null, // 界面字体:除终端外所有界面文字的字体栈(终端字体走官方设置)
86
+ userFont: null, // 提问字体:会话中提问内容的字体栈
87
+ assistantFont: null, // 回答字体:会话中回答正文的字体栈(代码块仍用等宽字体)
88
+ },
89
+ // 项目路径(规范化,无尾分隔符)→ 自定义别名。只影响界面渲染,不改动任何真实数据。
90
+ aliases: {},
91
+ // HTTP 代理(http(s)://host:port):helper 发起的网络访问走它——插件市场更新/安装
92
+ // (zcode CLI → git)、headroom 本体的检查更新与升级(pip)。空 = 不用代理。
93
+ proxy: '',
94
+ // 设置同步(设置弹窗“同步”标签页,交互参考 Tampermonkey 的手动同步):
95
+ // 仅 WebDAV、仅在点击按钮时传输(type 字段预留多后端扩展,当前恒为 webdav)。
96
+ // url 为 WebDAV 服务器地址;dir 为其下的上传目录(默认 zcode-pro,避免直接
97
+ // 写进网盘根目录);include 选择同步内容;凭据只存本机、不同步出去;
98
+ // lastPushAt/lastPullAt 只记录本机操作时间。
99
+ sync: {
100
+ type: 'webdav',
101
+ url: '',
102
+ dir: 'zcode-pro',
103
+ login: '',
104
+ password: '',
105
+ include: { zcodepro: true, model: true },
106
+ lastPushAt: null,
107
+ lastPullAt: null,
108
+ },
109
+ // 一次性迁移标记:migrateConfigOnce 执行过哪些纠偏(见 startHelper),
110
+ // 有标记的迁移不再重复,用户此后手动改回的设置不再被覆盖
111
+ migrations: {},
112
+ };
113
+ }
114
+
115
+ export function loadConfig(configFile) {
116
+ try {
117
+ if (!existsSync(configFile)) return defaultConfig();
118
+ const saved = JSON.parse(readFileSyncText(configFile));
119
+ const minutes = Number(saved.idleReclaimMinutes);
120
+ const startupMinutes = Number(saved.idleReclaimStartupMinutes);
121
+ return {
122
+ ...defaultConfig(),
123
+ ...saved,
124
+ idleReclaimMinutes: Number.isFinite(minutes) && minutes >= 1 ? Math.min(1440, Math.round(minutes)) : 30,
125
+ idleReclaimStartupMinutes: Number.isFinite(startupMinutes) && startupMinutes >= 0 ? Math.min(1440, Math.round(startupMinutes)) : 5,
126
+ features: { ...defaultConfig().features, ...(saved.features || {}) },
127
+ styles: { ...defaultConfig().styles, ...(saved.styles && typeof saved.styles === 'object' ? saved.styles : {}) },
128
+ aliases: { ...((saved.aliases && typeof saved.aliases === 'object') ? saved.aliases : {}) },
129
+ sync: mergeSyncConfig(saved.sync),
130
+ };
131
+ } catch {
132
+ return defaultConfig();
133
+ }
134
+ }
135
+
136
+ function readFileSyncText(p) {
137
+ return readFileSync(p, 'utf8');
138
+ }
139
+
140
+ export function saveConfig(configFile, config) {
141
+ // 原子写:先写临时文件再改名,避免崩溃留下半截 JSON
142
+ const tmp = configFile + '.tmp';
143
+ writeFileSync(tmp, JSON.stringify(config, null, 2), 'utf8');
144
+ renameSync(tmp, configFile);
145
+ }
146
+
147
+ // 一次性配置迁移:升级后对历史设置做纠偏,执行过的迁移记入 migrations 标记,
148
+ // 之后不再重复(用户手动改回的设置不会被再次覆盖)。失败静默、下次启动再试。
149
+ // - wsRunningSpinOff:“折叠项目运行提示”实验性功能会在后台周期性短暂展开
150
+ // 项目复核运行状态,新版本不再默认开启,历史开启过的用户升级后强制关闭一次
151
+ function migrateConfigOnce(configFile) {
152
+ try {
153
+ const config = loadConfig(configFile);
154
+ if (config.migrations && config.migrations.wsRunningSpinOff) return;
155
+ config.features = { ...config.features, wsRunningSpin: false };
156
+ config.migrations = { ...(config.migrations || {}), wsRunningSpinOff: true };
157
+ saveConfig(configFile, config);
158
+ } catch { /* 配置不可读/不可写时跳过,下次启动再试 */ }
159
+ }
160
+
161
+ function json(res, status, obj) {
162
+ const body = JSON.stringify(obj);
163
+ res.writeHead(status, {
164
+ 'Content-Type': 'application/json; charset=utf-8',
165
+ 'Access-Control-Allow-Origin': '*',
166
+ 'Access-Control-Allow-Methods': 'GET, POST, OPTIONS',
167
+ 'Access-Control-Allow-Headers': 'Content-Type, X-ZCodePro-Token',
168
+ });
169
+ res.end(body);
170
+ }
171
+
172
+ function readBody(req) {
173
+ return new Promise((resolveBody, rejectBody) => {
174
+ const chunks = [];
175
+ let size = 0;
176
+ req.on('data', (c) => {
177
+ size += c.length;
178
+ if (size > 1 << 20) { rejectBody(new Error('请求体过大')); req.destroy(); return; }
179
+ chunks.push(c);
180
+ });
181
+ req.on('end', () => {
182
+ try { resolveBody(chunks.length ? JSON.parse(Buffer.concat(chunks).toString('utf8')) : {}); }
183
+ catch (e) { rejectBody(new Error('无效的 JSON 请求体')); }
184
+ });
185
+ req.on('error', rejectBody);
186
+ });
187
+ }
188
+
189
+ // 软件代理(设置弹窗“代理”标签页):helper 发起的网络访问(zcode CLI→git、pip)
190
+ // 统一走这里;startHelper 启动时读配置,保存代理时即时更新
191
+ let activeProxyUrl = '';
192
+
193
+ function proxyEnv(extra = {}) {
194
+ if (!activeProxyUrl) return extra;
195
+ return {
196
+ HTTP_PROXY: activeProxyUrl, HTTPS_PROXY: activeProxyUrl,
197
+ http_proxy: activeProxyUrl, https_proxy: activeProxyUrl,
198
+ ...extra,
199
+ };
200
+ }
201
+
202
+ export function startHelper({ port, token, dataRoot, state, agentsFile = defaultAgentsFile() }) {
203
+ const configFile = join(dataRoot, 'zcodepro.json');
204
+ const settingsFile = join(dataRoot, 'v2', 'setting.json');
205
+ migrateConfigOnce(configFile);
206
+ activeProxyUrl = String(loadConfig(configFile).proxy || '').trim();
207
+
208
+ const server = createServer(async (req, res) => {
209
+ const url = new URL(req.url, `http://127.0.0.1:${port}`);
210
+ try {
211
+ // 防 DNS rebinding:只接受回环 Host。恶意页面把域名解析到 127.0.0.1 时,
212
+ // 浏览器发出的 Host 是攻击者域名,在此拒绝。
213
+ const host = String(req.headers.host || '');
214
+ if (!/^127\.0\.0\.1(:|$)/.test(host) && !/^localhost(:|$)/i.test(host)) {
215
+ json(res, 403, { ok: false, error: 'forbidden host' });
216
+ return;
217
+ }
218
+ if (req.method === 'OPTIONS') {
219
+ json(res, 204, {});
220
+ return;
221
+ }
222
+ if (token && req.headers['x-zcodepro-token'] !== token) {
223
+ json(res, 401, { ok: false, error: 'unauthorized' });
224
+ return;
225
+ }
226
+ if (req.method === 'GET' && url.pathname === '/health') {
227
+ const config = loadConfig(configFile);
228
+ json(res, 200, { ok: true, app: 'zcodepro-helper', version: VERSION, features: config.features, injectedPages: state.injectedPages });
229
+ return;
230
+ }
231
+ if (req.method === 'GET' && url.pathname === '/config') {
232
+ json(res, 200, { ok: true, config: loadConfig(configFile) });
233
+ return;
234
+ }
235
+ if (req.method === 'POST' && url.pathname === '/config') {
236
+ const body = await readBody(req);
237
+ const current = loadConfig(configFile);
238
+ if (body && typeof body === 'object' && typeof body.proxy === 'string') {
239
+ const v = body.proxy.trim();
240
+ if (v === '') current.proxy = '';
241
+ else if (/^https?:\/\/\S+:\d+$/.test(v) && v.length <= 300) current.proxy = v;
242
+ else {
243
+ json(res, 400, { ok: false, code: 'proxy-invalid', error: '代理地址需形如 http://127.0.0.1:7890,或留空清除' });
244
+ return;
245
+ }
246
+ activeProxyUrl = current.proxy;
247
+ }
248
+ if (body && typeof body === 'object' && body.features && typeof body.features === 'object') {
249
+ applyFeaturesPartial(current.features, body.features);
250
+ }
251
+ if (body && typeof body === 'object' && typeof body.idleReclaimMinutes === 'number' && Number.isFinite(body.idleReclaimMinutes)) {
252
+ current.idleReclaimMinutes = Math.min(1440, Math.max(1, Math.round(body.idleReclaimMinutes)));
253
+ }
254
+ if (body && typeof body === 'object' && typeof body.idleReclaimStartupMinutes === 'number' && Number.isFinite(body.idleReclaimStartupMinutes)) {
255
+ current.idleReclaimStartupMinutes = Math.min(1440, Math.max(0, Math.round(body.idleReclaimStartupMinutes)));
256
+ }
257
+ if (body && typeof body === 'object' && body.styles && typeof body.styles === 'object') {
258
+ current.styles = applyStylesPartial(current.styles, body.styles);
259
+ }
260
+ saveConfig(configFile, current);
261
+ json(res, 200, { ok: true, config: current });
262
+ return;
263
+ }
264
+ // 空闲项目内存回收(idle-reclaim.js):判断各项目的会话进程(zcode-cli)是否
265
+ // 仍挂着“运行中的用户命令”。按平台走不同适配器(Linux /proc、macOS ps+lsof、
266
+ // Windows PowerShell);scan=false 表示当前环境不可用,注入层据此停用整个
267
+ // 回收功能,不做盲回收
268
+ if (req.method === 'POST' && url.pathname === '/idle-busy') {
269
+ const body = await readBody(req);
270
+ const paths = Array.isArray(body?.paths)
271
+ ? body.paths.filter((p) => typeof p === 'string' && p.length > 0 && p.length < 4096).slice(0, 500)
272
+ : [];
273
+ json(res, 200, await scanIdleBusy(paths, dataRoot));
274
+ return;
275
+ }
276
+ // 全局提示词(~/.zcode/AGENTS.md):设置弹窗“全局提示词”标签页读写
277
+ if (req.method === 'GET' && url.pathname === '/agents') {
278
+ json(res, ...readAgents(agentsFile));
279
+ return;
280
+ }
281
+ // 在文件管理器中定位文件(宿主 openInFileManager 在 Linux/Windows 只是打开路径,不是定位)
282
+ if (req.method === 'POST' && url.pathname === '/reveal-path') {
283
+ const body = await readBody(req);
284
+ json(res, ...await revealInFileManager(body?.path));
285
+ return;
286
+ }
287
+ // 用系统默认应用打开文件(会话文件菜单的“默认应用打开”;
288
+ // 新版 ZCode 桥已不再暴露 openExternalFile,改由 helper 代开)
289
+ if (req.method === 'POST' && url.pathname === '/open-path') {
290
+ const body = await readBody(req);
291
+ json(res, ...await openWithDefaultApp(body?.path));
292
+ return;
293
+ }
294
+ // 在系统文件管理器中打开文件夹(项目“更多”菜单的“打开文件夹”)
295
+ if (req.method === 'POST' && url.pathname === '/open-folder') {
296
+ const body = await readBody(req);
297
+ json(res, ...await openFolder(body?.path));
298
+ return;
299
+ }
300
+ if (req.method === 'POST' && url.pathname === '/agents') {
301
+ const body = await readBody(req);
302
+ json(res, ...writeAgents(body, agentsFile));
303
+ return;
304
+ }
305
+ if (req.method === 'GET' && url.pathname === '/projects') {
306
+ const settings = readSettings(settingsFile);
307
+ const projects = (settings.recentProjects || []).map((p) => {
308
+ let exists = false;
309
+ try { exists = existsSync(p) && statSync(p).isDirectory(); } catch { /* ignore */ }
310
+ return { path: p, name: basename(p.replace(/[\\/]+$/, '')), exists, openInTabs: isProjectOpenInTabs(settings, p) };
311
+ });
312
+ json(res, 200, { ok: true, projects });
313
+ return;
314
+ }
315
+ if (req.method === 'POST' && url.pathname === '/project/alias') {
316
+ const body = await readBody(req);
317
+ json(res, ...setAlias(body, configFile));
318
+ return;
319
+ }
320
+ if (req.method === 'POST' && url.pathname === '/project/relocate') {
321
+ const body = await readBody(req);
322
+ json(res, ...(await relocateProject(body, dataRoot)));
323
+ return;
324
+ }
325
+ // 系统原生目录选择器(弹窗式,请求保持至用户选择/取消);start 仅作为起始目录
326
+ if (req.method === 'GET' && url.pathname === '/pick-folder') {
327
+ const start = url.searchParams.get('start') || '/';
328
+ const title = (url.searchParams.get('title') || '选择文件夹').slice(0, 80);
329
+ json(res, 200, { ok: true, ...(await pickFolderSystem(start, title)) });
330
+ return;
331
+ }
332
+ // 会话拖动排序持久化:scope = workspace(置顶/项目列表,重盖 updated_at)
333
+ // 或 group-members(分组会话,重写 members.sort_order)
334
+ if (req.method === 'POST' && url.pathname === '/task-order') {
335
+ const body = await readBody(req);
336
+ const ordered = Array.isArray(body?.ordered) ? body.ordered.filter((k) => typeof k === 'string') : [];
337
+ if (ordered.length === 0) {
338
+ json(res, 400, { ok: false, code: 'invalid-request', error: 'ordered 不能为空' });
339
+ return;
340
+ }
341
+ let result;
342
+ if (body.scope === 'workspace') result = await reorderWorkspaceTasks(dataRoot, ordered);
343
+ else if (body.scope === 'group-members') result = await reorderGroupMembers(dataRoot, ordered);
344
+ else {
345
+ json(res, 400, { ok: false, code: 'invalid-request', error: '无效的 scope' });
346
+ return;
347
+ }
348
+ if (result.error) {
349
+ json(res, 400, { ok: false, code: result.code, error: result.error });
350
+ return;
351
+ }
352
+ json(res, 200, { ok: true, reload: true, ...result });
353
+ return;
354
+ }
355
+ // 会话名锁定:读任务索引里重命名过的会话(title_overridden=1)标题表,
356
+ // 注入侧据此把被自动标题覆盖的侧栏行钉回重命名值;响应带开关,
357
+ // 注入侧按需拉取这一个端点即可(无轮询)
358
+ if (req.method === 'GET' && url.pathname === '/pinned-titles') {
359
+ const r = await listOverriddenTitles(dataRoot);
360
+ if (r.error) {
361
+ json(res, 500, { ok: false, code: r.code, error: r.error });
362
+ return;
363
+ }
364
+ const config = loadConfig(configFile);
365
+ json(res, 200, { ok: true, enabled: config.features.titleLock !== false, titles: r.titles });
366
+ return;
367
+ }
368
+ // —— zcode-vision 插件(图片视觉代理):设置弹窗“视觉代理”标签页读写 ————
369
+ if (req.method === 'GET' && url.pathname === '/vision') {
370
+ const file = defaultVisionFile();
371
+ const parsed = readJsonFile(file);
372
+ if (parsed === null && existsSync(file)) {
373
+ json(res, 500, { ok: false, code: 'vision-read', error: 'zcode-vision.json 不是合法 JSON' });
374
+ return;
375
+ }
376
+ json(res, 200, { ok: true, config: parsed, file });
377
+ return;
378
+ }
379
+ if (req.method === 'POST' && url.pathname === '/vision') {
380
+ const body = await readBody(req);
381
+ const [cfg, err] = validateVisionConfig(body?.config);
382
+ if (err) {
383
+ json(res, 400, { ok: false, code: 'vision-invalid', error: err });
384
+ return;
385
+ }
386
+ try {
387
+ const file = defaultVisionFile();
388
+ const tmp = file + '.tmp';
389
+ writeFileSync(tmp, JSON.stringify(cfg, null, 2) + '\n', 'utf-8');
390
+ renameSync(tmp, file);
391
+ } catch (err2) {
392
+ json(res, 500, { ok: false, code: 'vision-write', error: '写入 zcode-vision.json 失败: ' + (err2?.message || err2) });
393
+ return;
394
+ }
395
+ json(res, 200, { ok: true, config: cfg });
396
+ return;
397
+ }
398
+ // 供应商下拉取数(视觉代理“供应商”组合框用):合并两张供应商表,只回 id/名称/别名,不含密钥
399
+ if (req.method === 'GET' && url.pathname === '/vision/providers') {
400
+ json(res, 200, { ok: true, providers: listVisionProviders() });
401
+ return;
402
+ }
403
+ // 用最近一张会话图片跑通视觉识别(zcode-vision 钩子的 --test 模式)
404
+ if (req.method === 'POST' && url.pathname === '/vision/test') {
405
+ const hook = findVisionHook();
406
+ if (!hook) {
407
+ json(res, 400, { ok: false, code: 'vision-test', error: '未找到 zcode-vision 插件(先在插件市场安装)' });
408
+ return;
409
+ }
410
+ const r = await runNodeScript([hook, '--test'], 150000);
411
+ if (!r.ok && r.code === 'ENOENT') {
412
+ json(res, 500, { ok: false, code: 'vision-test', error: '未找到可用的 node' });
413
+ return;
414
+ }
415
+ json(res, r.ok ? 200 : 500, { ok: r.ok, output: r.output, error: r.error });
416
+ return;
417
+ }
418
+ // —— zcode-rtk 插件(命令输出压缩):设置弹窗“rtk 压缩”标签页读写 ——
419
+ // 状态目录与文件格式由 rtk 插件钩子定义:mode(hint|off)、whitelist(每行 name 或 git:name)
420
+ if (req.method === 'GET' && url.pathname === '/rtk') {
421
+ const dir = defaultRtkDir();
422
+ const mode = readRtkMode(join(dir, 'mode'));
423
+ const info = await rtkBinaryInfo();
424
+ // rtkBinaryInfo 走 PATH 命中时 binPath 是命令名;换绝对路径用于面板展示
425
+ json(res, 200, {
426
+ ok: true, mode, whitelist: readRtkWhitelist(join(dir, 'whitelist')),
427
+ dir, ...readRtkBuiltinWhitelist(), ...info,
428
+ binPath: info.binPath ? (rtkFindBin() || info.binPath) : null,
429
+ });
430
+ return;
431
+ }
432
+ if (req.method === 'GET' && url.pathname === '/rtk/upgrade') {
433
+ json(res, 200, { ok: true, upgrade: rtkUpgradeSnapshot() });
434
+ return;
435
+ }
436
+ if (req.method === 'POST' && url.pathname === '/rtk') {
437
+ const body = await readBody(req);
438
+ // —— rtk 本体(独立二进制):检查更新 / 升级(后台任务)/ 停止 ——
439
+ if (body && body.checkUpdate === true) {
440
+ const bin = rtkFindBin();
441
+ if (!bin) {
442
+ json(res, 400, { ok: false, code: 'rtk-update', error: '未找到 rtk 程序' });
443
+ return;
444
+ }
445
+ const current = await binVersionOf(bin);
446
+ const info = await rtkLatestInfo();
447
+ if (info.code) {
448
+ json(res, 500, { ok: false, code: info.code, error: info.error });
449
+ return;
450
+ }
451
+ json(res, 200, { ok: true, current, latest: info.tag, upToDate: current === info.tag });
452
+ return;
453
+ }
454
+ if (body && body.upgrade === true) {
455
+ json(res, 200, { ok: true, upgrade: startRtkUpgrade() });
456
+ return;
457
+ }
458
+ if (body && body.cancelUpgrade === true) {
459
+ json(res, 200, { ok: true, upgrade: cancelRtkUpgrade() });
460
+ return;
461
+ }
462
+ const dir = defaultRtkDir();
463
+ const next = { mode: readRtkMode(join(dir, 'mode')), whitelist: readRtkWhitelist(join(dir, 'whitelist')) };
464
+ if ('mode' in body) {
465
+ if (body.mode !== 'hint' && body.mode !== 'off') {
466
+ json(res, 400, { ok: false, code: 'rtk-invalid', error: 'mode 只能是 hint 或 off' });
467
+ return;
468
+ }
469
+ next.mode = body.mode;
470
+ }
471
+ if ('whitelist' in body) {
472
+ const wl = validateRtkWhitelist(body.whitelist);
473
+ if (wl.error) {
474
+ json(res, 400, { ok: false, code: 'rtk-invalid', error: wl.error });
475
+ return;
476
+ }
477
+ next.whitelist = wl.entries;
478
+ }
479
+ try {
480
+ writeRtkState(dir, next);
481
+ } catch (err2) {
482
+ json(res, 500, { ok: false, code: 'rtk-write', error: '写入 rtk 配置失败: ' + (err2?.message || err2) });
483
+ return;
484
+ }
485
+ json(res, 200, { ok: true, ...next });
486
+ return;
487
+ }
488
+ // —— headroom 插件(本地压缩代理):设置弹窗“Headroom 省流”标签页读写 ——
489
+ // 配置 ~/.zcode/headroom.json 三个键(kompressBackend/powerSaveCpu/powerWatchInterval)。
490
+ // 后端/省电切换/生命周期动作经钩子 ensure-proxy.sh 执行(写配置并立即生效,
491
+ // 兼容 systemd 托管);间隔直写配置即可——监视器每轮巡检重读配置。
492
+ if (req.method === 'GET' && url.pathname === '/headroom') {
493
+ const file = defaultHeadroomFile();
494
+ let config = null;
495
+ try {
496
+ config = readHeadroomConfig(file);
497
+ } catch (err) {
498
+ json(res, 500, { ok: false, code: 'headroom-read', error: '读取 headroom.json 失败: ' + (err?.message || err) });
499
+ return;
500
+ }
501
+ const hook = findMarketplaceHook('headroom', join('hooks', 'ensure-proxy.sh'));
502
+ // 插件版本取自缓存目录名(…/headroom/1.0.2/hooks/…)
503
+ const vm = hook ? /[/\\]headroom[/\\](\d+(?:\.\d+){0,3})[/\\]/.exec(hook) : null;
504
+ json(res, 200, {
505
+ ok: true, config, file, hook,
506
+ pluginVersion: vm ? vm[1] : null,
507
+ ...(await headroomBinaryInfo()),
508
+ });
509
+ return;
510
+ }
511
+ if (req.method === 'GET' && url.pathname === '/headroom/upgrade') {
512
+ json(res, 200, { ok: true, upgrade: headroomUpgradeSnapshot() });
513
+ return;
514
+ }
515
+ if (req.method === 'GET' && url.pathname === '/headroom/status') {
516
+ const r = await runHeadroomHook(['status']);
517
+ if (r.code === 'headroom-plugin' || r.code === 'headroom-sh') {
518
+ json(res, 400, { ok: false, code: r.code, error: r.error });
519
+ return;
520
+ }
521
+ json(res, r.ok ? 200 : 500, {
522
+ ok: r.ok, code: r.ok ? undefined : 'headroom-status',
523
+ status: parseHeadroomStatus(r.output),
524
+ error: r.error || null, output: r.output,
525
+ });
526
+ return;
527
+ }
528
+ if (req.method === 'POST' && url.pathname === '/headroom') {
529
+ const body = await readBody(req);
530
+ const file = defaultHeadroomFile();
531
+ let output = '';
532
+ if (body && typeof body.action === 'string') {
533
+ if (!['start', 'stop', 'restart', 'ensure'].includes(body.action)) {
534
+ json(res, 400, { ok: false, code: 'headroom-invalid', error: 'action 只能是 start/stop/restart/ensure' });
535
+ return;
536
+ }
537
+ const r = await runHeadroomHook([body.action]);
538
+ if (!r.ok) {
539
+ json(res, 500, { ok: false, code: 'headroom-action', error: r.output || r.error || '代理操作失败' });
540
+ return;
541
+ }
542
+ output = r.output;
543
+ } else if (body && typeof body.backend === 'string') {
544
+ const v = body.backend.trim();
545
+ if (!/^[a-z0-9_]+$/i.test(v)) {
546
+ json(res, 400, { ok: false, code: 'headroom-invalid', error: '无效的 backend 值' });
547
+ return;
548
+ }
549
+ const r = await runHeadroomHook(['backend', v]);
550
+ if (!r.ok) {
551
+ json(res, 500, { ok: false, code: 'headroom-action', error: r.output || r.error || '代理操作失败' });
552
+ return;
553
+ }
554
+ output = r.output;
555
+ } else if (body && typeof body.power === 'string') {
556
+ if (!['battery', 'saver', 'off'].includes(body.power)) {
557
+ json(res, 400, { ok: false, code: 'headroom-invalid', error: 'power 只能是 battery/saver/off' });
558
+ return;
559
+ }
560
+ const r = await runHeadroomHook(['power', body.power]);
561
+ if (!r.ok) {
562
+ json(res, 500, { ok: false, code: 'headroom-action', error: r.output || r.error || '代理操作失败' });
563
+ return;
564
+ }
565
+ output = r.output;
566
+ } else if (body && body.cancelUpgrade === true) {
567
+ json(res, 200, { ok: true, upgrade: cancelHeadroomUpgrade() });
568
+ return;
569
+ } else if (body && (body.checkUpdate === true || body.upgrade === true)) {
570
+ // headroom 本体(pip 安装)的检查更新 / 升级,走其安装环境的 pip
571
+ const ch = await headroomPipChannel();
572
+ if (ch.error) {
573
+ json(res, 400, { ok: false, code: ch.code, error: ch.error });
574
+ return;
575
+ }
576
+ if (body.checkUpdate === true) {
577
+ const r = await execCapture(ch.py, ['-m', 'pip', 'index', 'versions', ch.dist], 45000);
578
+ if (!r.ok) {
579
+ json(res, 500, { ok: false, code: 'headroom-update', error: '查询可用版本失败: ' + (r.output || r.error || '').split('\n').slice(-3).join(' ') });
580
+ return;
581
+ }
582
+ // pip index versions 输出 "Available versions: 0.38.0, 0.37.0, …"(新版本在前)
583
+ const m = /Available versions:\s*([^\s,]+)/.exec(r.output);
584
+ const latest = m ? m[1] : ch.current;
585
+ json(res, 200, { ok: true, current: ch.current, latest, upToDate: latest === ch.current, dist: ch.dist });
586
+ return;
587
+ }
588
+ // 升级后台执行,立即返回快照;进度经 GET /headroom/upgrade 轮询
589
+ json(res, 200, { ok: true, upgrade: startHeadroomUpgrade(ch) });
590
+ return;
591
+ } else if (body && 'interval' in body) {
592
+ try {
593
+ const cfg = readHeadroomConfig(file);
594
+ cfg.powerWatchInterval = clampInt(body.interval, 10, 3600, cfg.powerWatchInterval);
595
+ writeHeadroomConfig(file, cfg);
596
+ } catch (err) {
597
+ json(res, 500, { ok: false, code: 'headroom-write', error: '写入 headroom.json 失败: ' + (err?.message || err) });
598
+ return;
599
+ }
600
+ } else {
601
+ json(res, 400, { ok: false, code: 'invalid-request', error: 'body 需包含 action/backend/power/interval 之一' });
602
+ return;
603
+ }
604
+ let config = null;
605
+ try { config = readHeadroomConfig(file); } catch { /* 已应用,读不回仅缺回显 */ }
606
+ json(res, 200, { ok: true, output, config });
607
+ return;
608
+ }
609
+ // —— 软件代理连通性检测:经 curl -x 走代理访问插件市场/本体升级实际要用的站点 ——
610
+ if (req.method === 'POST' && url.pathname === '/proxy/test') {
611
+ const body = await readBody(req);
612
+ const proxy = typeof body?.proxy === 'string' && body.proxy.trim() ? body.proxy.trim() : activeProxyUrl;
613
+ if (!proxy) {
614
+ json(res, 400, { ok: false, code: 'proxy-not-set', error: '请先填写代理地址' });
615
+ return;
616
+ }
617
+ if (!/^https?:\/\/\S+:\d+$/.test(proxy)) {
618
+ json(res, 400, { ok: false, code: 'proxy-invalid', error: '代理地址需形如 http://127.0.0.1:7890' });
619
+ return;
620
+ }
621
+ // PyPI 检测目标用 pip 实际使用的源(环境变量或 pip 配置的国内镜像):
622
+ // 直测 pypi.org 不能代表 pip 的真实连通性——镜像域名常被代理分流为直连
623
+ // 用单包索引页而非整站 /simple/(后者约 46MB,8 秒必然超时误报)
624
+ let pypiTarget = 'https://pypi.org/simple/pip/';
625
+ const envIdx = String(process.env.PIP_INDEX_URL || '').trim();
626
+ if (/^https?:\/\/\S+$/.test(envIdx)) {
627
+ pypiTarget = envIdx;
628
+ } else {
629
+ try {
630
+ const ch = await headroomPipChannel();
631
+ if (!ch.error) {
632
+ const r = await execCapture(ch.py, ['-m', 'pip', 'config', 'get', 'global.index-url'], 8000);
633
+ const idx = r.output.trim().split('\n')[0] || '';
634
+ if (r.ok && /^https?:\/\/\S+$/.test(idx)) pypiTarget = idx;
635
+ }
636
+ } catch { /* 探测失败保持官方源 */ }
637
+ }
638
+ const targets = [
639
+ ['GitHub', 'https://github.com'],
640
+ ['PyPI', pypiTarget],
641
+ ];
642
+ const results = [];
643
+ for (const [name, targetUrl] of targets) {
644
+ results.push(await new Promise((resolveT) => {
645
+ const started = Date.now();
646
+ execFile('curl', ['-s', '-o', '/dev/null', '-w', '%{http_code}', '--max-time', '8', '-x', proxy, targetUrl],
647
+ { timeout: 10000, windowsHide: true }, (err, stdout) => {
648
+ if (err && err.code === 'ENOENT') {
649
+ resolveT({ name, httpCode: null, ms: null, ok: false, error: '未找到 curl' });
650
+ return;
651
+ }
652
+ const code = Number(stdout);
653
+ // curl 退出码 → 人话(err.message 是整条命令行,对用户没有信息量)
654
+ const CURL_FAIL = { 5: '无法解析代理地址', 7: '无法连接代理', 22: 'HTTP 错误', 28: '超时', 35: 'SSL 握手失败', 56: '连接被重置' };
655
+ const fail = err ? (err.signal === 'SIGTERM' ? '超时' : (CURL_FAIL[err.code] || `curl 退出码 ${err.code ?? '?'}`)) : null;
656
+ resolveT({
657
+ name,
658
+ httpCode: err || !code ? null : code,
659
+ ms: err ? null : Date.now() - started,
660
+ ok: !err && code >= 200 && code < 400,
661
+ error: fail,
662
+ });
663
+ });
664
+ }));
665
+ }
666
+ json(res, 200, { ok: true, proxy, targets: results });
667
+ return;
668
+ }
669
+ // —— 设置同步(设置弹窗“同步”标签页):配置读写 + 手动上传/下载 ——
670
+ if (req.method === 'GET' && url.pathname === '/sync') {
671
+ json(res, 200, { ok: true, sync: loadConfig(configFile).sync });
672
+ return;
673
+ }
674
+ if (req.method === 'POST' && url.pathname === '/sync') {
675
+ const body = await readBody(req);
676
+ const current = loadConfig(configFile);
677
+ // 表单(类型/URL/登录/密码)与同步内容开关分开提交:表单全量替换,开关只改单项
678
+ if (body && typeof body === 'object' && body.sync && typeof body.sync === 'object') {
679
+ const v = validateSyncForm(body.sync);
680
+ if (v.error) {
681
+ json(res, 400, { ok: false, code: 'sync-invalid', error: v.error });
682
+ return;
683
+ }
684
+ Object.assign(current.sync, v.sync);
685
+ }
686
+ if (body && typeof body === 'object' && body.include && typeof body.include === 'object') {
687
+ if (typeof body.include.zcodepro === 'boolean') current.sync.include.zcodepro = body.include.zcodepro;
688
+ if (typeof body.include.model === 'boolean') current.sync.include.model = body.include.model;
689
+ }
690
+ saveConfig(configFile, current);
691
+ json(res, 200, { ok: true, sync: current.sync });
692
+ return;
693
+ }
694
+ if (req.method === 'POST' && url.pathname === '/sync/run') {
695
+ const body = await readBody(req);
696
+ const direction = body?.direction === 'pull' ? 'pull' : body?.direction === 'push' ? 'push' : '';
697
+ if (!direction) {
698
+ json(res, 400, { ok: false, code: 'invalid-request', error: 'direction 只能是 push 或 pull' });
699
+ return;
700
+ }
701
+ const config = loadConfig(configFile);
702
+ const r = await runSettingsSync({ direction, dataRoot, configFile, config, proxyUrl: activeProxyUrl });
703
+ if (r.error) {
704
+ json(res, r.status || 500, { ok: false, code: r.code || 'sync-run', error: r.error });
705
+ return;
706
+ }
707
+ json(res, 200, { ok: true, direction, applied: r.applied, sync: loadConfig(configFile).sync });
708
+ return;
709
+ }
710
+ // —— zcode-plugins 市场更新:读状态(带 30 分钟节流的市场同步)与执行更新 ————
711
+ if (req.method === 'GET' && url.pathname === '/plugins/status') {
712
+ if (Date.now() - lastMarketplaceSync > 30 * 60 * 1000) {
713
+ await runZcodeCli(['plugins', 'marketplace', 'update', ZCODE_PLUGINS_MARKETPLACE], 60000);
714
+ lastMarketplaceSync = Date.now();
715
+ }
716
+ const status = pluginUpdateStatus();
717
+ if (!status) {
718
+ json(res, 500, { ok: false, code: 'plugins-status', error: '读取插件清单失败(未安装过 zcode-plugins 市场?)' });
719
+ return;
720
+ }
721
+ json(res, 200, { ok: true, ...status });
722
+ return;
723
+ }
724
+ if (req.method === 'POST' && url.pathname === '/plugins/update') {
725
+ const body = await readBody(req);
726
+ // name 指定时只处理该插件:已装且有更新则更新,未装则安装(各插件面板的单插件操作)
727
+ const only = typeof body?.name === 'string' && body.name.trim() ? body.name.trim() : null;
728
+ const steps = [];
729
+ const sync = await runZcodeCli(['plugins', 'marketplace', 'update', ZCODE_PLUGINS_MARKETPLACE], 60000);
730
+ lastMarketplaceSync = Date.now();
731
+ steps.push({ step: 'marketplace update', ok: sync.ok, output: sync.output });
732
+ if (!sync.ok && sync.code !== 'cli-not-found') {
733
+ json(res, 500, { ok: false, code: 'plugins-update', error: sync.error || sync.output || 'marketplace update 失败', steps });
734
+ return;
735
+ }
736
+ const status = pluginUpdateStatus();
737
+ if (status) {
738
+ let updated = 0;
739
+ for (const p of status.plugins) {
740
+ if (only && p.name !== only) continue;
741
+ if (p.installed && p.hasUpdate) {
742
+ const r = await runZcodeCli(['plugins', 'update', `${p.name}@${ZCODE_PLUGINS_MARKETPLACE}`], 120000);
743
+ steps.push({ step: `update ${p.name}`, ok: r.ok, output: r.output });
744
+ if (r.ok) updated += 1;
745
+ } else if (!p.installed && (body?.installMissing || only)) {
746
+ const r = await runZcodeCli(['plugins', 'install', `${p.name}@${ZCODE_PLUGINS_MARKETPLACE}`], 120000);
747
+ steps.push({ step: `install ${p.name}`, ok: r.ok, output: r.output });
748
+ if (r.ok) updated += 1;
749
+ }
750
+ }
751
+ json(res, 200, { ok: steps.every((s) => s.ok), updated, steps, ...pluginUpdateStatus() });
752
+ return;
753
+ }
754
+ json(res, 200, { ok: sync.ok, updated: 0, steps });
755
+ return;
756
+ }
757
+ json(res, 404, { ok: false, error: 'not found' });
758
+ } catch (err) {
759
+ json(res, 500, { ok: false, error: String(err?.message || err) });
760
+ }
761
+ });
762
+
763
+ return new Promise((resolveStarted, rejectStarted) => {
764
+ server.once('error', rejectStarted);
765
+ server.listen(port, '127.0.0.1', () => resolveStarted(server));
766
+ });
767
+ }
768
+
769
+ // 市场快照同步节流:helper 进程内 30 分钟最多主动 git pull 一次
770
+ let lastMarketplaceSync = 0;
771
+
772
+ function readJsonFile(p) {
773
+ try {
774
+ return JSON.parse(readFileSync(p, 'utf-8'));
775
+ } catch {
776
+ return null;
777
+ }
778
+ }
779
+
780
+ function clampInt(v, min, max, dflt) {
781
+ const n = typeof v === 'number' ? v : Number(v);
782
+ if (!Number.isFinite(n)) return dflt;
783
+ return Math.min(max, Math.max(min, Math.round(n)));
784
+ }
785
+
786
+ // —— 设置同步(“同步”标签页)的实现 ——
787
+
788
+ // loadConfig 时把磁盘上的 sync 段按默认值归一(长度限幅、时间戳只收数字)。
789
+ // 当前唯一后端是 WebDAV,type 一律归为 webdav(不做“禁用”态:同步本就仅手动触发)
790
+ function mergeSyncConfig(saved) {
791
+ const d = defaultConfig().sync;
792
+ if (!saved || typeof saved !== 'object') return d;
793
+ const inc = (saved.include && typeof saved.include === 'object') ? saved.include : {};
794
+ return {
795
+ type: 'webdav',
796
+ url: typeof saved.url === 'string' ? saved.url.slice(0, 500) : '',
797
+ dir: typeof saved.dir === 'string' ? saved.dir.trim().replace(/^\/+|\/+$/g, '').slice(0, 200) : defaultConfig().sync.dir,
798
+ login: typeof saved.login === 'string' ? saved.login.slice(0, 300) : '',
799
+ password: typeof saved.password === 'string' ? saved.password.slice(0, 300) : '',
800
+ include: { zcodepro: inc.zcodepro !== false, model: inc.model !== false },
801
+ lastPushAt: Number.isFinite(saved.lastPushAt) ? saved.lastPushAt : null,
802
+ lastPullAt: Number.isFinite(saved.lastPullAt) ? saved.lastPullAt : null,
803
+ };
804
+ }
805
+
806
+ // POST /sync 表单校验:url/login/password/dir 全量替换(dir 缺省时不动已有值)。
807
+ // URL 必填,指向 WebDAV 服务器(可含路径前缀);dir 为其下的上传目录,可多级
808
+ function validateSyncForm(raw) {
809
+ const url = typeof raw.url === 'string' ? raw.url.trim() : '';
810
+ const login = typeof raw.login === 'string' ? raw.login.trim() : '';
811
+ const password = typeof raw.password === 'string' ? raw.password : '';
812
+ if (!url) return { error: '请先填写 URL(如 https://dav.jianguoyun.com/dav/)' };
813
+ if (!/^https?:\/\/\S+$/.test(url)) return { error: 'URL 需为 WebDAV 服务器地址,如 https://dav.example.com/dav/' };
814
+ if (url.length > 500) return { error: 'URL 过长(最多 500 字符)' };
815
+ if (login.length > 300 || password.length > 300) return { error: '登录名或密码过长(最多 300 字符)' };
816
+ const out = { type: 'webdav', url, login, password };
817
+ if (typeof raw.dir === 'string') {
818
+ // 留空 = 不用子目录,直接存到 URL 路径下(用户显式清空时允许)
819
+ const dir = raw.dir.trim().replace(/^\/+|\/+$/g, '');
820
+ if (dir.length > 200) return { error: '上传目录过长(最多 200 字符)' };
821
+ for (const seg of dir ? dir.split('/') : []) {
822
+ if (!seg || seg === '.' || seg === '..' || /[\r\n\0]/.test(seg)) {
823
+ return { error: '上传目录需为文件夹名(可多级,如 backup/zcode-pro),不能包含 . / .. 或特殊字符' };
824
+ }
825
+ }
826
+ out.dir = dir;
827
+ }
828
+ return { sync: out };
829
+ }
830
+
831
+ // —— 同步内容在云端目录里的文件名:ZCode Pro 设置与模型设置各一个文件,
832
+ // 上传/下载按勾选逐文件进行,可单独覆盖其中一项 ——
833
+ const SYNC_FILES = { zcodepro: 'zcodepro.json', model: 'model.json' };
834
+
835
+ // 云端目录 = URL(服务器地址,容忍尾斜杠)+ 上传目录(默认 zcode-pro,可多级)。
836
+ // 目录段逐段编码,中文/空格等文件夹名也能正确落到地址里
837
+ function syncBaseDir(sync) {
838
+ const base = sync.url.replace(/\/+$/, '');
839
+ const segs = String(sync.dir || '').split('/').map((s) => s.trim()).filter((s) => s && s !== '.' && s !== '..');
840
+ if (!segs.length) return base;
841
+ return base + '/' + segs.map(encodeURIComponent).join('/');
842
+ }
843
+
844
+ // 拼出各组同步文件(zcodepro.json / model.json)的完整地址
845
+ function syncFileUrl(sync, kind) {
846
+ return syncBaseDir(sync) + '/' + SYNC_FILES[kind];
847
+ }
848
+
849
+ // 读云端某个同步文件:{ doc } 成功 | { missing } 云端没有该组数据 | { error, code, status } 失败。
850
+ // 文件里已是其他内容(或组别不符)时拒绝覆盖;4MB 上限挡住异常大文件。
851
+ async function fetchSyncDoc(sync, proxyUrl, kind) {
852
+ const r = await webdavRequest({ method: 'GET', url: syncFileUrl(sync, kind), login: sync.login, password: sync.password, proxyUrl });
853
+ if (!r.ok) return { error: r.error, code: 'sync-request', status: 502 };
854
+ // 404 之外,坚果云对父目录不存在的路径返回 409(AncestorsNotFound)——都视为云端没有数据,
855
+ // 让上传继续走 PUT(自动建目录)而不是误报失败
856
+ if (r.httpCode === 404 || r.httpCode === 409) return { missing: true };
857
+ if (r.httpCode < 200 || r.httpCode >= 300) return { error: webdavHttpText(r.httpCode), code: 'sync-request', status: 502 };
858
+ if (!r.body.trim()) return { missing: true }; // 个别网盘对不存在的文件返回 200 空内容
859
+ if (r.body.length > 4 * 1024 * 1024) {
860
+ return { error: `云端 ${SYNC_FILES[kind]} 超过 4MB,已拒绝应用`, code: 'sync-remote-invalid', status: 400 };
861
+ }
862
+ let doc = null;
863
+ try { doc = JSON.parse(r.body); } catch { /* 落到统一报错 */ }
864
+ if (!doc || typeof doc !== 'object' || doc.app !== 'zcode-pro' || doc.kind !== kind || !doc.data || typeof doc.data !== 'object') {
865
+ return { error: `云端已存在其他内容的 ${SYNC_FILES[kind]},请换一个目录`, code: 'sync-remote-invalid', status: 409 };
866
+ }
867
+ return { doc };
868
+ }
869
+
870
+ async function runSettingsSync({ direction, dataRoot, configFile, config, proxyUrl }) {
871
+ const sync = config.sync;
872
+ if (!sync.url || !/^https?:\/\/\S+$/.test(sync.url)) {
873
+ return { code: 'sync-not-configured', status: 400, error: '请先填写 WebDAV URL 并保存' };
874
+ }
875
+ if (!sync.include.zcodepro && !sync.include.model) {
876
+ return { code: 'sync-not-configured', status: 400, error: '请至少选择一项同步内容' };
877
+ }
878
+ return direction === 'push'
879
+ ? runSyncPush({ dataRoot, configFile, config, sync, proxyUrl })
880
+ : runSyncPull({ dataRoot, configFile, config, sync, proxyUrl });
881
+ }
882
+
883
+ // 上传:每组独立一个云端文件(zcodepro.json / model.json),勾选几组写几个;
884
+ // 写前预读只为确认没顶到别人的文件,自己的文件直接整份覆盖
885
+ async function runSyncPush({ dataRoot, configFile, config, sync, proxyUrl }) {
886
+ const docs = {};
887
+ if (sync.include.zcodepro) {
888
+ // sync(本机的同步配置与凭据)与 version(本机 helper 版本)不外发
889
+ const { sync: _s, version: _v, ...rest } = config;
890
+ docs.zcodepro = rest;
891
+ }
892
+ if (sync.include.model) {
893
+ const v2conf = readJsonFile(join(dataRoot, 'v2', 'config.json'));
894
+ if (v2conf && typeof v2conf.provider === 'object' && v2conf.provider) {
895
+ docs.model = { provider: v2conf.provider };
896
+ const pc = readJsonFile(join(dataRoot, 'v2', 'provider_config.json'));
897
+ if (pc && typeof pc === 'object') docs.model.providerConfig = pc;
898
+ // 注意:v2/credentials.json 不参与同步——里面是账号登录态(BigModel OAuth、
899
+ // JWT、Coding Plan 账号 key)与应用自身数据,各机器用各自账号登录;
900
+ // 自定义模型的 API Key 存在 config.json 的 provider.options.apiKey,随 provider 同步
901
+ }
902
+ }
903
+ if (!Object.keys(docs).length) {
904
+ return { code: 'sync-run', status: 400, error: '本机没有可同步的内容' };
905
+ }
906
+ const pushed = [];
907
+ const dir = await mkdtemp(join(tmpdir(), 'zcodepro-sync-'));
908
+ try {
909
+ for (const kind of Object.keys(docs)) {
910
+ const remote = await fetchSyncDoc(sync, proxyUrl, kind);
911
+ if (remote.error) return remote;
912
+ const doc = { app: 'zcode-pro', kind, format: 1, savedAt: new Date().toISOString(), data: docs[kind] };
913
+ const docPath = join(dir, SYNC_FILES[kind]);
914
+ writeFileSync(docPath, JSON.stringify(doc, null, 2), 'utf8');
915
+ const put = await webdavPutWithMkcol({
916
+ url: syncFileUrl(sync, kind), login: sync.login, password: sync.password,
917
+ uploadFile: docPath, proxyUrl, timeoutMs: 60000,
918
+ });
919
+ if (!put.ok) return { code: 'sync-request', status: 502, error: put.error };
920
+ if (put.httpCode < 200 || put.httpCode >= 300) {
921
+ return { code: 'sync-request', status: 502, error: webdavHttpText(put.httpCode) };
922
+ }
923
+ pushed.push(kind);
924
+ }
925
+ } finally {
926
+ try { rmSync(dir, { recursive: true, force: true }); } catch { /* ignore */ }
927
+ }
928
+ sync.lastPushAt = Date.now();
929
+ saveConfig(configFile, config);
930
+ return { applied: pushed };
931
+ }
932
+
933
+ // 下载:把云端所选组应用到本机(各组独立文件,勾选几组读几组,可只覆盖模型设置)。
934
+ // 覆盖前留 .zcodepro-backup 备份;zcodepro 组只覆盖功能/样式/别名/代理,
935
+ // sync 配置与凭据始终保留本机的
936
+ async function runSyncPull({ dataRoot, configFile, config, sync, proxyUrl }) {
937
+ // 先把勾选组的云端数据全部读到(任何一组读取失败都不动本机文件),再逐组应用
938
+ const docs = {};
939
+ for (const kind of ['zcodepro', 'model']) {
940
+ if (!sync.include[kind]) continue;
941
+ const remote = await fetchSyncDoc(sync, proxyUrl, kind);
942
+ if (remote.error) return remote;
943
+ if (remote.doc) docs[kind] = remote.doc.data;
944
+ }
945
+ if (!Object.keys(docs).length) {
946
+ return { code: 'sync-remote-missing', status: 404, error: '云端没有同步数据,请先在其他电脑上传' };
947
+ }
948
+ const applied = [];
949
+ const backupFile = (p) => {
950
+ try { if (existsSync(p)) copyFileSync(p, p + '.zcodepro-backup'); } catch { /* 备份失败不阻塞 */ }
951
+ };
952
+ const atomicWrite = (p, obj) => {
953
+ const tmp = p + '.zcodepro-tmp';
954
+ writeFileSync(tmp, JSON.stringify(obj, null, 2) + '\n', 'utf8');
955
+ renameSync(tmp, p);
956
+ };
957
+ sync.lastPullAt = Date.now();
958
+
959
+ if (docs.zcodepro) {
960
+ const rz = docs.zcodepro;
961
+ const next = loadConfig(configFile);
962
+ applyFeaturesPartial(next.features, rz.features);
963
+ next.styles = applyStylesPartial(next.styles, rz.styles);
964
+ const aliases = sanitizeAliases(rz.aliases);
965
+ if (aliases) next.aliases = aliases;
966
+ if (typeof rz.proxy === 'string') {
967
+ const v = rz.proxy.trim();
968
+ if (v === '' || /^https?:\/\/\S+:\d+$/.test(v)) next.proxy = v;
969
+ }
970
+ next.sync = sync;
971
+ backupFile(configFile);
972
+ saveConfig(configFile, next);
973
+ applied.push('zcodepro');
974
+ }
975
+ if (docs.model) {
976
+ const provider = docs.model.provider;
977
+ if (provider && typeof provider === 'object' && !Array.isArray(provider)) {
978
+ const v2confFile = join(dataRoot, 'v2', 'config.json');
979
+ const localDoc = readJsonFile(v2confFile) || {};
980
+ backupFile(v2confFile);
981
+ atomicWrite(v2confFile, { ...localDoc, provider });
982
+ }
983
+ const pc = docs.model.providerConfig;
984
+ if (pc && typeof pc === 'object' && !Array.isArray(pc)) {
985
+ const pcFile = join(dataRoot, 'v2', 'provider_config.json');
986
+ backupFile(pcFile);
987
+ atomicWrite(pcFile, pc);
988
+ }
989
+ applied.push('model');
990
+ }
991
+ if (!applied.includes('zcodepro')) saveConfig(configFile, config);
992
+ return { applied };
993
+ }
994
+
995
+ // 别名表清洗(同步下载用):只收 路径字符串 → 短字符串 的条目,数量限幅
996
+ function sanitizeAliases(raw) {
997
+ if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return null;
998
+ const out = {};
999
+ let n = 0;
1000
+ for (const [k, v] of Object.entries(raw)) {
1001
+ if (typeof k !== 'string' || k.length > 500 || k.includes('\0')) continue;
1002
+ if (typeof v !== 'string' || v.length > 200 || /[\r\n\0]/.test(v)) continue;
1003
+ out[k] = v;
1004
+ if (++n >= 2000) break;
1005
+ }
1006
+ return out;
1007
+ }
1008
+
1009
+ // features 部分更新(POST /config 与同步下载共用):只收已知开关的布尔值
1010
+ function applyFeaturesPartial(base, partial) {
1011
+ const out = (base && typeof base === 'object') ? base : {};
1012
+ if (!partial || typeof partial !== 'object') return out;
1013
+ for (const key of Object.keys(defaultConfig().features)) {
1014
+ if (typeof partial[key] === 'boolean') out[key] = partial[key];
1015
+ }
1016
+ return out;
1017
+ }
1018
+
1019
+ // 空闲回收的后台命令判定:项目会话进程(zcode-cli)的子进程里,插件/MCP 与应用
1020
+ // 自身的基础设施不算活动;其余(应用的 shell 包装 exec/、用户直接启动的进程)
1021
+ // 一律视为仍在运行的后台命令。
1022
+ // 匹配前把命令行的反斜杠统一为正斜杠,一份模式通吃三平台。
1023
+ const IDLE_INFRA_CMDLINE = [
1024
+ '/.zcode/cli/plugins/', // 插件市场安装的 MCP/hooks(vision、headroom、rtk 等)
1025
+ '/.zcode/cli/image-cache', // 会话图片缓存的本地 http 服务
1026
+ 'node-repl', // 内置 node-repl MCP(进程改过名,命令行只剩标题)
1027
+ '/opt/ZCode/', // 应用自带组件(Linux)
1028
+ '/Applications/ZCode.app', // 应用自带组件(macOS)
1029
+ 'Programs/ZCode', // 应用自带组件(Windows)
1030
+ 'crashpad', // 崩溃收集
1031
+ 'conhost', // Windows 控制台宿主:每个控制台进程标配的子进程(实测捕获)
1032
+ ];
1033
+
1034
+ const SCAN_OFF = { ok: true, scan: false, busy: [] };
1035
+
1036
+ function normCmdline(s) {
1037
+ return String(s || '').replace(/\\/g, '/');
1038
+ }
1039
+
1040
+ function isInfraCmdline(cmdline) {
1041
+ return IDLE_INFRA_CMDLINE.some((pat) => cmdline.includes(pat));
1042
+ }
1043
+
1044
+ // 执行外部命令(macOS/Windows 适配器用):失败返回 err,不抛
1045
+ function runCmd(cmd, args, timeoutMs = 15000) {
1046
+ return new Promise((resolve) => {
1047
+ execFile(cmd, args, { timeout: timeoutMs, maxBuffer: 16 * 1024 * 1024, encoding: 'utf8', windowsHide: true },
1048
+ (err, stdout, stderr) => {
1049
+ resolve({ err: err ? String(err.message || err) : null, stdout: String(stdout || ''), stderr: String(stderr || '') });
1050
+ });
1051
+ });
1052
+ }
1053
+
1054
+ // 请求路径 → 进程 cwd 的匹配表:cwd → 命中的请求路径列表。
1055
+ // 符号链接打开的项目 cwd 是解析后的真实路径,两种形态都登记,命中任一即算该项目;
1056
+ // 多个请求路径落到同一 cwd 时全部标记(宁可多判忙,不漏保护)
1057
+ function buildWantedByCwd(paths) {
1058
+ const map = new Map();
1059
+ const add = (cwd, req) => {
1060
+ const arr = map.get(cwd);
1061
+ if (arr) { if (!arr.includes(req)) arr.push(req); }
1062
+ else map.set(cwd, [req]);
1063
+ };
1064
+ for (const p of paths) {
1065
+ const plain = p.replace(/[\\/]+$/, '');
1066
+ add(plain, plain);
1067
+ try {
1068
+ const real = realpathSync(plain);
1069
+ if (real !== plain) add(real, plain);
1070
+ } catch { /* 目录不存在:也不会有对应进程 */ }
1071
+ }
1072
+ return map;
1073
+ }
1074
+
1075
+ export async function scanIdleBusy(paths, dataRoot) {
1076
+ if (process.platform === 'linux') return scanIdleBusyLinux(paths);
1077
+ if (process.platform === 'darwin') return scanIdleBusyDarwin(paths);
1078
+ if (process.platform === 'win32') return scanIdleBusyWindows(paths, dataRoot);
1079
+ return SCAN_OFF;
1080
+ }
1081
+
1082
+ // Linux:/proc 直读(comm 定位 CLI、cwd 定位项目、task/<pid>/children 拿子进程)
1083
+ function scanIdleBusyLinux(paths) {
1084
+ let pids;
1085
+ try { pids = readdirSync('/proc').filter((d) => /^\d+$/.test(d)); }
1086
+ catch { return SCAN_OFF; }
1087
+ const wantedByCwd = buildWantedByCwd(paths);
1088
+ const cliPidByCwd = new Map();
1089
+ for (const pid of pids) {
1090
+ try {
1091
+ if (readFileSync(`/proc/${pid}/comm`, 'utf8').trim() !== 'zcode-cli') continue;
1092
+ cliPidByCwd.set(readlinkSync(`/proc/${pid}/cwd`), pid);
1093
+ } catch { /* 进程退出/权限:跳过 */ }
1094
+ }
1095
+ const busy = new Set();
1096
+ for (const [cwd, pid] of cliPidByCwd) {
1097
+ const wantedPaths = wantedByCwd.get(cwd);
1098
+ if (!wantedPaths) continue;
1099
+ let children = '';
1100
+ try { children = readFileSync(`/proc/${pid}/task/${pid}/children`, 'utf8'); }
1101
+ catch { continue; }
1102
+ for (const childPidStr of children.split(/\s+/)) {
1103
+ const childPid = Number(childPidStr);
1104
+ if (!Number.isFinite(childPid)) continue;
1105
+ let cmdline = '';
1106
+ let zombie = false;
1107
+ try {
1108
+ cmdline = normCmdline(readFileSync(`/proc/${childPid}/cmdline`, 'utf8').replace(/\0/g, ' ').trim());
1109
+ const stat = readFileSync(`/proc/${childPid}/stat`, 'utf8');
1110
+ zombie = /\) Z /.test(stat);
1111
+ } catch { continue; }
1112
+ if (zombie) continue;
1113
+ if (!cmdline || !isInfraCmdline(cmdline)) {
1114
+ for (const wp of wantedPaths) busy.add(wp); // 认不出的进程宁可误判为忙,也不回收可能在跑命令的项目
1115
+ break;
1116
+ }
1117
+ }
1118
+ }
1119
+ return { ok: true, scan: true, busy: [...busy] };
1120
+ }
1121
+
1122
+ // macOS:ps 拿进程表(pid/父 pid/状态/名称/命令行),lsof 拿 CLI 进程的工作目录。
1123
+ // 都是系统自带工具、本用户进程无需提权。任何一个 CLI 的工作目录取不到就本轮放弃
1124
+ // (scan=false),宁可不动也不猜
1125
+ async function scanIdleBusyDarwin(paths) {
1126
+ const psInfo = await runCmd('ps', ['-axo', 'pid=,ppid=,stat=,comm=']);
1127
+ if (psInfo.err) return SCAN_OFF;
1128
+ const procs = [];
1129
+ for (const line of psInfo.stdout.split('\n')) {
1130
+ const m = line.trim().match(/^(\d+)\s+(\d+)\s+(\S+)\s+(.+)$/);
1131
+ if (m) procs.push({ pid: Number(m[1]), ppid: Number(m[2]), stat: m[3], comm: m[4].trim() });
1132
+ }
1133
+ const clis = procs.filter((p) => /(^|\/)zcode-cli$/.test(p.comm));
1134
+ if (!clis.length) return SCAN_OFF; // 定位不到会话进程:可能名称对不上,不猜
1135
+ const psArgs = await runCmd('ps', ['-axo', 'pid=,command=']);
1136
+ if (psArgs.err) return SCAN_OFF;
1137
+ const cmdByPid = new Map();
1138
+ for (const line of psArgs.stdout.split('\n')) {
1139
+ const m = line.trim().match(/^(\d+)\s+(.+)$/);
1140
+ if (m) cmdByPid.set(Number(m[1]), m[2]);
1141
+ }
1142
+ const wantedByCwd = buildWantedByCwd(paths);
1143
+ const busy = new Set();
1144
+ for (const cli of clis) {
1145
+ const lsof = await runCmd('lsof', ['-a', '-p', String(cli.pid), '-d', 'cwd', '-Fn', '-w']);
1146
+ const cwd = (lsof.stdout.match(/^n(.+)$/m) || [])[1];
1147
+ if (!cwd) continue; // 进程多半刚退出:跳过即可,它已不可能挂着命令
1148
+ const wantedPaths = wantedByCwd.get(cwd);
1149
+ if (!wantedPaths) continue;
1150
+ for (const child of procs) {
1151
+ if (child.ppid !== cli.pid || child.stat.includes('Z')) continue;
1152
+ const cmdline = normCmdline(cmdByPid.get(child.pid) || '');
1153
+ if (!cmdline || !isInfraCmdline(cmdline)) {
1154
+ for (const wp of wantedPaths) busy.add(wp);
1155
+ break;
1156
+ }
1157
+ }
1158
+ }
1159
+ return { ok: true, scan: true, busy: [...busy] };
1160
+ }
1161
+
1162
+ // Windows:PowerShell 的 Win32_Process 拿 pid/父 pid/命令行(系统自带,无需提权)。
1163
+ // 拿不到其他进程的工作目录,改用两级归属:命令行带会话 ID(应用的 exec/ 包装)→
1164
+ // 查任务索引反查工作区;认不出且无会话 ID 的活动子进程无法归属 → 全部请求判忙(保守)。
1165
+ // 路径比较忽略大小写与分隔符差异
1166
+ async function scanIdleBusyWindows(paths, dataRoot) {
1167
+ const ps = await runCmd('powershell.exe', ['-NoProfile', '-NonInteractive', '-Command',
1168
+ 'Get-CimInstance Win32_Process | Select-Object ProcessId,ParentProcessId,Name,CommandLine | ConvertTo-Json -Compress'], 30000);
1169
+ if (ps.err || !ps.stdout.trim()) return SCAN_OFF;
1170
+ let list;
1171
+ try {
1172
+ list = JSON.parse(ps.stdout);
1173
+ if (!Array.isArray(list)) list = [list];
1174
+ } catch { return SCAN_OFF; }
1175
+ const procs = list
1176
+ .filter((p) => p && Number.isFinite(Number(p.ProcessId)))
1177
+ .map((p) => ({
1178
+ pid: Number(p.ProcessId),
1179
+ ppid: Number(p.ParentProcessId) || 0,
1180
+ name: String(p.Name || ''),
1181
+ cmdline: normCmdline(p.CommandLine || ''),
1182
+ }));
1183
+ let wsMap = null; // 懒加载:任务 ID → 规范化工作区
1184
+ const lookupTaskWs = async (taskId) => {
1185
+ if (wsMap === null) {
1186
+ const raw = await listTaskWorkspaceMap(dataRoot);
1187
+ wsMap = {};
1188
+ for (const [k, v] of Object.entries(raw)) wsMap[k] = normCmdline(v).replace(/\/+$/, '').toLowerCase();
1189
+ }
1190
+ return wsMap[taskId];
1191
+ };
1192
+ return classifyIdleBusyWindows(procs, paths, lookupTaskWs);
1193
+ }
1194
+
1195
+ // Windows 判定逻辑(与进程表获取解耦,可直接喂真实 CIM 数据测试)
1196
+ export async function classifyIdleBusyWindows(procs, paths, lookupTaskWs) {
1197
+ const clis = procs.filter((p) => /^zcode-cli(\.exe)?$/i.test(p.name));
1198
+ if (!clis.length) return SCAN_OFF; // 定位不到会话进程:可能名称对不上,不猜
1199
+ const reqByNorm = new Map();
1200
+ for (const p of paths) reqByNorm.set(normCmdline(p).replace(/\/+$/, '').toLowerCase(), p);
1201
+ let unattributed = false;
1202
+ const busy = new Set();
1203
+ const markByTask = async (taskId) => {
1204
+ const normWs = await lookupTaskWs(taskId);
1205
+ const req = normWs ? reqByNorm.get(normWs) : undefined;
1206
+ if (req) busy.add(req);
1207
+ else unattributed = true; // 索引里查不到该会话:归属不明,保守处理
1208
+ };
1209
+ for (const cli of clis) {
1210
+ for (const child of procs) {
1211
+ if (child.ppid !== cli.pid) continue;
1212
+ if (isInfraCmdline(child.cmdline)) continue;
1213
+ if (!child.cmdline) { unattributed = true; continue; }
1214
+ const sess = child.cmdline.match(/sess_[A-Za-z0-9-]+/);
1215
+ if (sess) await markByTask(sess[0]);
1216
+ else unattributed = true; // 用户进程但无会话线索:归属不到具体项目
1217
+ }
1218
+ }
1219
+ if (unattributed) return { ok: true, scan: true, busy: paths }; // 有归属不明的活动进程:全部判忙
1220
+ return { ok: true, scan: true, busy: [...busy] };
1221
+ }
1222
+
1223
+ // styles 部分更新的清洗(POST /config 与同步下载共用):只收已知键;
1224
+ // null 恢复默认;px 类 0–96 取整,行距 0.8–4 保留两位小数,宽度按单位限幅,字体栈经 sanitizeFontStack
1225
+ function applyStylesPartial(base, partial) {
1226
+ const out = (base && typeof base === 'object') ? base : {};
1227
+ if (!partial || typeof partial !== 'object') return out;
1228
+ for (const key of ['rowGap', 'listSpacing', 'listItemSpacing', 'quoteCodeSpacing', 'tableSpacing', 'tableCellPaddingV', 'tableCellPaddingH', 'sidebarProjectSpacing', 'sidebarTaskSpacing']) {
1229
+ if (key in partial) {
1230
+ const v = partial[key];
1231
+ if (v === null) out[key] = null;
1232
+ else if (typeof v === 'number' && Number.isFinite(v) && v >= 0 && v <= 96) out[key] = Math.round(v);
1233
+ }
1234
+ }
1235
+ for (const key of ['lineHeight', 'codeLineHeight', 'userLineHeight']) {
1236
+ if (key in partial) {
1237
+ const v = partial[key];
1238
+ if (v === null) out[key] = null;
1239
+ else if (typeof v === 'number' && Number.isFinite(v) && v >= 0.8 && v <= 4) out[key] = Math.round(v * 100) / 100;
1240
+ }
1241
+ }
1242
+ if ('contentWidth' in partial) {
1243
+ const v = partial.contentWidth;
1244
+ const ok = v && (v.unit === 'px' || v.unit === '%') && typeof v.value === 'number' && Number.isFinite(v.value)
1245
+ && v.value >= (v.unit === 'px' ? 320 : 20) && v.value <= (v.unit === 'px' ? 3840 : 100);
1246
+ if (v === null) out.contentWidth = null;
1247
+ else if (ok) out.contentWidth = { value: v.unit === 'px' ? Math.round(v.value) : Math.round(v.value * 10) / 10, unit: v.unit };
1248
+ }
1249
+ for (const key of ['uiFont', 'userFont', 'assistantFont']) {
1250
+ if (!(key in partial)) continue;
1251
+ const s = sanitizeFontStack(partial[key]);
1252
+ if (s !== undefined) out[key] = s;
1253
+ }
1254
+ return out;
1255
+ }
1256
+
1257
+ // 字体栈清洗:去控制字符、限 200 字符;拒绝能破坏 CSS 声明的字符({};<>\)
1258
+ // 与引号不配对的值(返回 undefined = 忽略该次更新)。空串/null = 恢复默认(null)
1259
+ function sanitizeFontStack(v) {
1260
+ if (v === null || v === '') return null;
1261
+ if (typeof v !== 'string') return undefined;
1262
+ const s = v.replace(/[\x00-\x1f\x7f]/g, '').trim().slice(0, 200);
1263
+ if (!s) return null;
1264
+ if (/[{};<>\\]/.test(s)) return undefined;
1265
+ if (((s.match(/"/g) || []).length) % 2 !== 0 || ((s.match(/'/g) || []).length) % 2 !== 0) return undefined;
1266
+ return s;
1267
+ }
1268
+
1269
+ // 跟随供应商下拉取数:合并 ~/.zcode/v2/config.json 的 provider(优先)与
1270
+ // ~/.zcode/v2/provider_config.json 的 providerRules(同 id 时规则表的名字保留为别名);只回 id/名称/别名,不含密钥。
1271
+ // 导出以便测试脚本直接驱动。
1272
+ export function listVisionProviders() {
1273
+ const list = [];
1274
+ const byId = new Map();
1275
+ const v2 = readJsonFile(join(homedir(), '.zcode', 'v2', 'config.json'));
1276
+ for (const [id, p] of Object.entries((v2 && typeof v2.provider === 'object' && v2.provider) || {})) {
1277
+ const entry = { id, name: (p && typeof p.name === 'string' && p.name.trim()) || id, aliases: [] };
1278
+ list.push(entry);
1279
+ byId.set(id, entry);
1280
+ }
1281
+ const rulesDoc = readJsonFile(join(homedir(), '.zcode', 'v2', 'provider_config.json'));
1282
+ const rules = (rulesDoc && rulesDoc.config?.providerConfigRules?.providerRules) || [];
1283
+ for (const r of rules) {
1284
+ if (!r || typeof r.providerId !== 'string' || !r.providerId) continue;
1285
+ const existing = byId.get(r.providerId);
1286
+ if (existing) {
1287
+ const alias = (typeof r.providerName === 'string' && r.providerName.trim()) || r.providerId;
1288
+ if (alias !== existing.name && !existing.aliases.includes(alias)) existing.aliases.push(alias);
1289
+ continue;
1290
+ }
1291
+ const entry = { id: r.providerId, name: (typeof r.providerName === 'string' && r.providerName.trim()) || r.providerId, aliases: [] };
1292
+ list.push(entry);
1293
+ byId.set(r.providerId, entry);
1294
+ }
1295
+ return list;
1296
+ }
1297
+
1298
+ // zcode-vision 配置校验:只保留已知字段并规范化;出错时返回中文原因。
1299
+ // 导出以便测试脚本直接驱动。
1300
+ export function validateVisionConfig(raw) {
1301
+ if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return [null, 'config 必须是对象'];
1302
+ if (raw.chainMode !== 'fallback' && raw.chainMode !== 'pipeline') return [null, 'chainMode 只能是 fallback 或 pipeline'];
1303
+ if (!Array.isArray(raw.chain) || raw.chain.some((n) => typeof n !== 'string')) return [null, 'chain 必须是代理名数组'];
1304
+ if (!Array.isArray(raw.proxies)) return [null, 'proxies 必须是数组'];
1305
+ const cfg = {
1306
+ enabled: raw.enabled !== false,
1307
+ chainMode: raw.chainMode,
1308
+ chain: raw.chain.map((s) => s.trim()).filter(Boolean),
1309
+ proxies: [],
1310
+ pollMs: clampInt(raw.pollMs, 0, 60000, 3000),
1311
+ apiTimeoutMs: clampInt(raw.apiTimeoutMs, 1000, 600000, 120000),
1312
+ compressThresholdKB: clampInt(raw.compressThresholdKB, 0, 102400, 1024),
1313
+ // 连续失败跳过(0 = 不跳过);跳过时长(分钟)
1314
+ skipAfterFailures: clampInt(raw.skipAfterFailures, 0, 100, 4),
1315
+ skipMinutes: clampInt(raw.skipMinutes, 1, 10080, 30),
1316
+ // 主模型能看图时是否也拦截识别(false = 跳过;flash 主模型始终不触发,见插件)
1317
+ forceIntercept: raw.forceIntercept !== false,
1318
+ };
1319
+ const names = new Set();
1320
+ for (const p of raw.proxies) {
1321
+ if (!p || typeof p !== 'object') return [null, '代理必须是对象'];
1322
+ const proxy = {};
1323
+ for (const k of ['name', 'baseUrl', 'model', 'apiKey', 'format', 'useProvider', 'prompt']) {
1324
+ const v = p[k];
1325
+ if (v === undefined || v === null) continue;
1326
+ if (typeof v !== 'string') return [null, `代理字段 ${k} 必须是字符串`];
1327
+ if (k === 'prompt' ? v.length > 20000 : v.length > 500) return [null, `代理字段 ${k} 过长`];
1328
+ proxy[k] = v;
1329
+ }
1330
+ // 单代理超时(毫秒):0/缺省 = 用全局 apiTimeoutMs;面板没有输入框,但保存时不能弄丢(/vision-proxy edit 可改)
1331
+ if (p.timeoutMs !== undefined && p.timeoutMs !== null && p.timeoutMs !== '') {
1332
+ proxy.timeoutMs = clampInt(p.timeoutMs, 0, 600000, 0);
1333
+ }
1334
+ const name = (proxy.name || '').trim();
1335
+ if (!name) return [null, '每个代理都需要名称'];
1336
+ if (names.has(name)) return [null, `代理名称重复:${name}`];
1337
+ names.add(name);
1338
+ proxy.name = name;
1339
+ if (!(proxy.baseUrl || '').trim() && !(proxy.useProvider || '').trim()) return [null, `代理 ${name} 缺少 baseUrl(或设置 useProvider 跟随供应商)`];
1340
+ if (!(proxy.model || '').trim()) return [null, `代理 ${name} 缺少 model`];
1341
+ if (proxy.baseUrl) proxy.baseUrl = proxy.baseUrl.trim();
1342
+ if (proxy.model) proxy.model = proxy.model.trim();
1343
+ if (proxy.useProvider) proxy.useProvider = proxy.useProvider.trim();
1344
+ if (proxy.format) proxy.format = proxy.format.trim();
1345
+ if (proxy.format && proxy.format !== 'openai' && proxy.format !== 'anthropic') {
1346
+ return [null, `代理 ${name} 的 format 只能是 openai 或 anthropic`];
1347
+ }
1348
+ cfg.proxies.push(proxy);
1349
+ }
1350
+ for (const n of cfg.chain) {
1351
+ if (!names.has(n)) return [null, `链中代理“${n}”未在 proxies 里定义`];
1352
+ }
1353
+ return [cfg, null];
1354
+ }
1355
+
1356
+ // —— zcode-rtk 状态读写(mode / whitelist),与 rtk 插件钩子共用同一文件 ——
1357
+
1358
+ function readRtkMode(file) {
1359
+ try {
1360
+ return readFileSync(file, 'utf8').trim() === 'off' ? 'off' : 'hint';
1361
+ } catch {
1362
+ return 'hint'; // 钩子默认:文件缺失视为 hint
1363
+ }
1364
+ }
1365
+
1366
+ function readRtkWhitelist(file) {
1367
+ try {
1368
+ return readFileSync(file, 'utf8').split(/\r?\n/)
1369
+ .map((l) => l.trim())
1370
+ .filter((l) => l && !l.startsWith('#'));
1371
+ } catch {
1372
+ return [];
1373
+ }
1374
+ }
1375
+
1376
+ // 白名单校验:只收 name 或 git:name(与钩子的解析规则一致),去重保序。
1377
+ // 导出以便测试脚本直接驱动。
1378
+ export function validateRtkWhitelist(raw) {
1379
+ if (!Array.isArray(raw)) return { error: 'whitelist 必须是字符串数组' };
1380
+ if (raw.length > 200) return { error: '白名单最多 200 条' };
1381
+ const seen = new Set();
1382
+ const entries = [];
1383
+ for (const e of raw) {
1384
+ if (typeof e !== 'string' || !/^(git:)?[A-Za-z0-9._-]+$/.test(e)) {
1385
+ return { error: `无效的白名单条目:${String(e).slice(0, 50)}` };
1386
+ }
1387
+ if (!seen.has(e)) { seen.add(e); entries.push(e); }
1388
+ }
1389
+ return { entries };
1390
+ }
1391
+
1392
+ // 写入 mode 与 whitelist(临时文件 + 改名原子写)。whitelist 为空时删除文件
1393
+ // (钩子视为未自定义);文件头带注释说明格式,方便用户直接编辑。
1394
+ function writeRtkState(dir, { mode, whitelist }) {
1395
+ mkdirSync(dir, { recursive: true });
1396
+ const modeFile = join(dir, 'mode');
1397
+ const tmp = modeFile + '.tmp';
1398
+ writeFileSync(tmp, mode, 'utf8');
1399
+ renameSync(tmp, modeFile);
1400
+ const wlFile = join(dir, 'whitelist');
1401
+ if (whitelist.length === 0) {
1402
+ try { unlinkSync(wlFile); } catch { /* 不存在即目标状态 */ }
1403
+ return;
1404
+ }
1405
+ const wlTmp = wlFile + '.tmp';
1406
+ writeFileSync(wlTmp, '# rtk 白名单(不压缩直接放行):每行 name 或 git:name(git 子命令)。删除本文件即恢复默认。\n'
1407
+ + whitelist.join('\n') + '\n', 'utf8');
1408
+ renameSync(wlTmp, wlFile);
1409
+ }
1410
+
1411
+ // 探测 rtk 安装与版本:先 PATH(execFile 自带 Windows 扩展名解析),
1412
+ // 再常见安装位置(与 rtk 插件钩子的查找清单一致)。
1413
+ async function rtkBinaryInfo() {
1414
+ const attempt = (cmd) => new Promise((resolveV) => {
1415
+ execFile(cmd, ['--version'], { timeout: 5000, windowsHide: true }, (err, stdout) => {
1416
+ resolveV(err ? null : { binPath: cmd, version: String(stdout || '').trim().split('\n')[0] });
1417
+ });
1418
+ });
1419
+ const direct = await attempt('rtk');
1420
+ if (direct) return { installed: true, ...direct };
1421
+ // PATH 未命中时按与 rtkFindBin 相同的清单找绝对路径再试
1422
+ const bin = rtkFindBin();
1423
+ if (bin && bin !== 'rtk') {
1424
+ const r = await attempt(bin);
1425
+ if (r) return { installed: true, ...r };
1426
+ }
1427
+ return { installed: false, version: null, binPath: null };
1428
+ }
1429
+
1430
+ // 内置放行清单(钩子 python 里的 GIT_MUTATIONS/PLAIN_MUTATIONS):从钩子脚本解析,
1431
+ // 随插件更新自动跟进;面板只读展示。解析失败返回空列表,不影响其他功能。
1432
+ function readRtkBuiltinWhitelist() {
1433
+ const hook = findMarketplaceHook('rtk', join('hooks', 'rtk-pretooluse.sh'));
1434
+ if (!hook) return { builtinGit: [], builtinPlain: [] };
1435
+ try {
1436
+ const src = readFileSync(hook, 'utf8');
1437
+ const grab = (name) => {
1438
+ const m = new RegExp(name + '\\s*=\\s*\\{([^}]*)\\}').exec(src);
1439
+ return m ? [...m[1].matchAll(/'([^']+)'/g)].map((x) => x[1]) : [];
1440
+ };
1441
+ return { builtinGit: grab('GIT_MUTATIONS'), builtinPlain: grab('PLAIN_MUTATIONS') };
1442
+ } catch {
1443
+ return { builtinGit: [], builtinPlain: [] };
1444
+ }
1445
+ }
1446
+
1447
+ // —— rtk 本体(独立二进制)的检查更新与升级:GitHub Releases 下载 → sha256 校验 → 原子替换 ——
1448
+
1449
+ function rtkFindBin() {
1450
+ return findBinFile(
1451
+ process.platform === 'win32' ? ['rtk.exe', 'rtk.cmd'] : ['rtk'],
1452
+ [join(homedir(), '.local', 'bin', 'rtk'), join(homedir(), 'miniforge3', 'bin', 'rtk'),
1453
+ '/usr/local/bin/rtk', '/usr/bin/rtk']);
1454
+ }
1455
+
1456
+ // 最新版本探测(两条互补通道,任一成功即用):
1457
+ // ① releases/latest 的 302 重定向拿 tag——不受 API 速率限制,但 github.com 网页在
1458
+ // 部分网络直连不通(超时);② 匿名 GitHub API——直连常可用,但走代理时出口 IP
1459
+ // 常被限流(403 无 tag_name)。资产与 checksums 的下载地址按官方命名规则构造。
1460
+ async function rtkLatestInfo() {
1461
+ const assetName = rtkAssetName();
1462
+ if (!assetName) return { code: 'rtk-update', error: '当前平台不支持自动升级' };
1463
+ const mkInfo = (tag) => {
1464
+ const base = `https://github.com/rtk-ai/rtk/releases/download/v${tag}`;
1465
+ return { tag, assetName, assetUrl: `${base}/${assetName}`, sumsUrl: `${base}/checksums.txt` };
1466
+ };
1467
+ let redirErr = '';
1468
+ const redir = await execCapture('curl', ['-sS', '-o', '/dev/null', '-w', '%{url_effective}', '-L', '--max-time', '6',
1469
+ 'https://github.com/rtk-ai/rtk/releases/latest'], 8000);
1470
+ if (redir.ok) {
1471
+ const m = /\/tag\/v?(\d[\w.+-]*)\s*$/.exec(redir.output.trim());
1472
+ if (m) return mkInfo(m[1]);
1473
+ redirErr = '重定向未携带版本';
1474
+ } else {
1475
+ redirErr = (redir.output || redir.error || '').split('\n').slice(-1)[0];
1476
+ }
1477
+ const api = await execCapture('curl', ['-s', '--max-time', '8',
1478
+ 'https://api.github.com/repos/rtk-ai/rtk/releases/latest'], 10000);
1479
+ if (api.ok) {
1480
+ try {
1481
+ const tag = String((JSON.parse(api.output) || {}).tag_name || '').replace(/^v/, '');
1482
+ if (tag) return mkInfo(tag);
1483
+ } catch { /* 落到统一报错 */ }
1484
+ }
1485
+ return { code: 'rtk-update', error: '查询 GitHub Releases 失败(可尝试在“代理”标签页设置代理)。重定向: ' + redirErr };
1486
+ }
1487
+
1488
+ // 按平台/架构选资产文件名(与官方 Release 命名一致)
1489
+ function rtkAssetName() {
1490
+ if (process.platform === 'darwin') return process.arch === 'arm64' ? 'rtk-aarch64-apple-darwin.tar.gz' : 'rtk-x86_64-apple-darwin.tar.gz';
1491
+ if (process.platform === 'linux') return process.arch === 'arm64' ? 'rtk-aarch64-unknown-linux-gnu.tar.gz' : 'rtk-x86_64-unknown-linux-musl.tar.gz';
1492
+ if (process.platform === 'win32') return 'rtk-x86_64-pc-windows-msvc.zip';
1493
+ return null;
1494
+ }
1495
+
1496
+ let rtkUpgradeJob = null;
1497
+
1498
+ export function rtkUpgradeSnapshot() { return upgradeJobSnapshot(rtkUpgradeJob); }
1499
+
1500
+ function cancelRtkUpgrade() { return cancelUpgradeJob(rtkUpgradeJob); }
1501
+
1502
+ // 启动 rtk 本体升级(后台任务,面板经 GET /rtk/upgrade 轮询):
1503
+ // 下载资产与 checksums → sha256 校验 → 解压 → 试运行 → 原子替换旧二进制
1504
+ function startRtkUpgrade() {
1505
+ if (rtkUpgradeJob && rtkUpgradeJob.running) return rtkUpgradeSnapshot();
1506
+ const j = newUpgradeJob();
1507
+ rtkUpgradeJob = j;
1508
+ void (async () => {
1509
+ let dir = null;
1510
+ let bin = null;
1511
+ try {
1512
+ if (process.platform === 'win32') throw new Error('Windows 暂不支持自动升级,请手动下载 Release 覆盖安装');
1513
+ bin = rtkFindBin();
1514
+ if (!bin) throw new Error('未找到 rtk 程序');
1515
+ const info = await rtkLatestInfo();
1516
+ if (info.code) throw new Error(info.error);
1517
+ dir = await mkdtemp(join(tmpdir(), 'rtk-upgrade-'));
1518
+ j.output += `下载 ${info.assetName}…\n`;
1519
+ const archPath = join(dir, info.assetName);
1520
+ await jobRun(j, 'curl', ['-L', '--fail', '--progress-bar', '-o', archPath, info.assetUrl], 600000);
1521
+ // sha256 校验(checksums.txt 与本机摘要工具;失败即中止,不替换)
1522
+ if (info.sumsUrl) {
1523
+ const sumPath = join(dir, 'checksums.txt');
1524
+ await jobRun(j, 'curl', ['-sL', '--fail', '-o', sumPath, info.sumsUrl], 30000);
1525
+ const want = readFileSync(sumPath, 'utf8').split('\n')
1526
+ .find((l) => l.trim().endsWith(info.assetName))?.trim().split(/\s+/)[0];
1527
+ const sumCmd = process.platform === 'darwin' ? 'shasum' : 'sha256sum';
1528
+ const sumArgs = process.platform === 'darwin' ? ['-a', '256', archPath] : [archPath];
1529
+ const local = await execCapture(sumCmd, sumArgs, 30000);
1530
+ const got = local.ok ? (local.output.split(/\s+/)[0] || '') : '';
1531
+ if (!want || !got || want.toLowerCase() !== got.toLowerCase()) {
1532
+ throw new Error(`sha256 校验失败(期望 ${want || '?'},实际 ${got || '?'})`);
1533
+ }
1534
+ j.output += 'sha256 校验通过\n';
1535
+ }
1536
+ // 解压并定位二进制(包内根目录或一级子目录)
1537
+ await jobRun(j, 'tar', ['-xzf', archPath, '-C', dir], 60000);
1538
+ let newBin = join(dir, 'rtk');
1539
+ if (!existsSync(newBin)) {
1540
+ for (const ent of readdirSync(dir, { withFileTypes: true })) {
1541
+ const cand = join(dir, ent.name, 'rtk');
1542
+ if (ent.isDirectory() && existsSync(cand)) { newBin = cand; break; }
1543
+ }
1544
+ }
1545
+ if (!existsSync(newBin)) throw new Error('解压后未找到 rtk 二进制');
1546
+ chmodSync(newBin, 0o755);
1547
+ const v = await binVersionOf(newBin);
1548
+ if (!v) throw new Error('新版本无法运行(--version 无输出)');
1549
+ // 原子替换:旧文件先挪走,失败可回滚
1550
+ const backup = bin + '.rtk-old';
1551
+ try { rmSync(backup, { force: true }); } catch { /* ignore */ }
1552
+ renameSync(bin, backup);
1553
+ try {
1554
+ try {
1555
+ renameSync(newBin, bin);
1556
+ } catch (renameErr) {
1557
+ // 跨文件系统(EXDEV)等 rename 失败:退化为复制(复制再失败由外层 catch 回滚)
1558
+ copyFileSync(newBin, bin);
1559
+ chmodSync(bin, 0o755);
1560
+ }
1561
+ } catch (err) {
1562
+ renameSync(backup, bin);
1563
+ throw err;
1564
+ }
1565
+ try { rmSync(backup, { force: true }); } catch { /* ignore */ }
1566
+ j.version = v;
1567
+ j.output += `已升级到 ${v}\n`;
1568
+ } catch (err) {
1569
+ if (!j.canceled) j.error = j.timedOut ? '升级超时已终止' : String(err?.stack || err?.message || err);
1570
+ } finally {
1571
+ j.running = false;
1572
+ j.done = true;
1573
+ j.kill = null;
1574
+ if (dir) { try { rmSync(dir, { recursive: true, force: true }); } catch { /* ignore */ } }
1575
+ }
1576
+ })();
1577
+ return rtkUpgradeSnapshot();
1578
+ }
1579
+
1580
+ // 在插件缓存里找市场插件的钩子脚本(取版本号最大的目录,按数值比较,0.10.0 > 0.9.0)。
1581
+ // 只认纯数字版本目录:手动安装/升级的残留(如 1.0.2.bak-1.0.2)内容不全,不能选中。
1582
+ // 注意只认正式市场 duanluan-zcode-plugins:本地临时市场(如 duanluan-local)装的副本不在查找范围,
1583
+ // 属预期限制——正式发布从 duanluan-zcode-plugins 安装后即可用。
1584
+ function findMarketplaceHook(pluginName, hookRelPath) {
1585
+ const base = join(cliPluginsDir(), 'cache', ZCODE_PLUGINS_MARKETPLACE, pluginName);
1586
+ try {
1587
+ const versions = readdirSync(base)
1588
+ .filter((d) => /^\d+(\.\d+){0,3}$/.test(d))
1589
+ .sort((a, b) => {
1590
+ const pa = a.split('.').map(Number);
1591
+ const pb = b.split('.').map(Number);
1592
+ for (let i = 0; i < 3; i++) if ((pa[i] || 0) !== (pb[i] || 0)) return (pa[i] || 0) - (pb[i] || 0);
1593
+ return 0;
1594
+ });
1595
+ for (let i = versions.length - 1; i >= 0; i--) {
1596
+ const hook = join(base, versions[i], hookRelPath);
1597
+ if (existsSync(hook)) return hook;
1598
+ }
1599
+ } catch { /* ignore */ }
1600
+ return null;
1601
+ }
1602
+
1603
+ function findVisionHook() {
1604
+ return findMarketplaceHook('zcode-vision', join('hooks', 'vision-hook.mjs'));
1605
+ }
1606
+
1607
+ // —— headroom 配置读写与状态解析(与 headroom-lib.sh 共用 ~/.zcode/headroom.json)——
1608
+
1609
+ // 读配置并按插件默认值归一(文件缺失/键非法 → 默认)。导出以便测试脚本直接驱动。
1610
+ export function readHeadroomConfig(file) {
1611
+ const raw = readJsonFile(file);
1612
+ const backend = typeof raw?.kompressBackend === 'string' ? raw.kompressBackend.trim() : '';
1613
+ const power = typeof raw?.powerSaveCpu === 'string' ? raw.powerSaveCpu.trim() : '';
1614
+ return {
1615
+ kompressBackend: /^[a-z0-9_]+$/i.test(backend) ? backend : 'auto',
1616
+ powerSaveCpu: power === 'battery' || power === 'saver' ? power : 'off',
1617
+ powerWatchInterval: clampInt(raw?.powerWatchInterval, 10, 3600, 60),
1618
+ };
1619
+ }
1620
+
1621
+ // 写配置:与插件 hr_config_set 相同的三键整文件形状(该函数本就只保留这三个键)
1622
+ function writeHeadroomConfig(file, cfg) {
1623
+ const tmp = file + '.tmp';
1624
+ writeFileSync(tmp, JSON.stringify({
1625
+ kompressBackend: cfg.kompressBackend,
1626
+ powerSaveCpu: cfg.powerSaveCpu,
1627
+ powerWatchInterval: cfg.powerWatchInterval,
1628
+ }, null, 2) + '\n', 'utf8');
1629
+ renameSync(tmp, file);
1630
+ }
1631
+
1632
+ // —— 本体探测与升级任务的共用机制(headroom 的 pip 与 rtk 的 Releases 下载共用)——
1633
+
1634
+ // PATH + 常见安装位置找可执行文件
1635
+ function findBinFile(names, extraCandidates) {
1636
+ const dirs = String(process.env.PATH || '').split(delimiter).filter(Boolean);
1637
+ for (const c of [...dirs.flatMap((d) => names.map((n) => join(d, n))), ...extraCandidates]) {
1638
+ try { if (existsSync(c)) return c; } catch { /* ignore */ }
1639
+ }
1640
+ return null;
1641
+ }
1642
+
1643
+ // 执行 --version 并提取首个版本号(原样输出如 "rtk 0.49.0"、"headroom, version 0.37.0")
1644
+ async function binVersionOf(bin) {
1645
+ const r = await execCapture(bin, ['--version'], 10000);
1646
+ if (!r.ok) return null;
1647
+ const m = /(\d+(?:\.\d+){1,3}(?:[A-Za-z0-9.+-]*)?)/.exec(r.output);
1648
+ return m ? m[1] : null;
1649
+ }
1650
+
1651
+ // 升级任务快照/创建/停止:任务在 helper 进程内后台执行,面板轮询展示进度
1652
+ function upgradeJobSnapshot(j) {
1653
+ if (!j) return { running: false, done: false, canceled: false };
1654
+ return {
1655
+ running: j.running,
1656
+ done: j.done,
1657
+ canceled: j.canceled || false,
1658
+ error: j.error || null,
1659
+ version: j.version || null,
1660
+ startedAt: j.startedAt,
1661
+ output: j.output.length > 4000 ? j.output.slice(-4000) : j.output,
1662
+ };
1663
+ }
1664
+
1665
+ function newUpgradeJob() {
1666
+ return { running: true, done: false, canceled: false, timedOut: false, error: null, version: null, output: '', startedAt: Date.now(), kill: null };
1667
+ }
1668
+
1669
+ // 停止进行中的升级(SIGKILL 当前子进程;任务随即以 canceled 收尾,不算失败)
1670
+ function cancelUpgradeJob(job) {
1671
+ if (job && job.running) {
1672
+ job.canceled = true;
1673
+ try { job.kill && job.kill(); } catch { /* ignore */ }
1674
+ }
1675
+ return upgradeJobSnapshot(job);
1676
+ }
1677
+
1678
+ // 升级任务的可取消子进程执行:输出喂给任务对象供面板轮询,环境注入代理
1679
+ function jobRun(j, cmd, args, timeoutMs) {
1680
+ return new Promise((resolveRun, rejectRun) => {
1681
+ if (j.canceled) { rejectRun(new Error('canceled')); return; }
1682
+ const child = spawn(cmd, args, { env: { ...process.env, ...proxyEnv() }, windowsHide: true });
1683
+ j.kill = () => { try { child.kill('SIGKILL'); } catch { /* ignore */ } };
1684
+ const feed = (d) => { j.output = (j.output + d.toString()).slice(-65536); };
1685
+ child.stdout.on('data', feed);
1686
+ child.stderr.on('data', feed);
1687
+ const timer = setTimeout(() => {
1688
+ j.timedOut = true;
1689
+ try { child.kill('SIGKILL'); } catch { /* ignore */ }
1690
+ }, timeoutMs);
1691
+ child.on('error', (err) => { clearTimeout(timer); j.kill = null; rejectRun(err); });
1692
+ child.on('close', (code) => {
1693
+ clearTimeout(timer);
1694
+ j.kill = null;
1695
+ if (code === 0) resolveRun();
1696
+ else rejectRun(new Error(`${cmd} 退出码 ${code}`));
1697
+ });
1698
+ });
1699
+ }
1700
+
1701
+ // headroom 程序探测:镜像钩子 hr_find_headroom 的查找清单(PATH + 常见安装位置)
1702
+ function headroomFindBin() {
1703
+ return findBinFile(
1704
+ process.platform === 'win32' ? ['headroom.exe', 'headroom.cmd'] : ['headroom'],
1705
+ [join(homedir(), '.local', 'bin', 'headroom'), join(homedir(), 'miniforge3', 'bin', 'headroom'),
1706
+ join(homedir(), '.cargo', 'bin', 'headroom'), '/usr/local/bin/headroom', '/usr/bin/headroom']);
1707
+ }
1708
+
1709
+ async function headroomBinaryInfo() {
1710
+ const binPath = headroomFindBin();
1711
+ if (!binPath) return { installed: false, binPath: null, version: null };
1712
+ return { installed: true, binPath, version: await binVersionOf(binPath) };
1713
+ }
1714
+
1715
+ // 通用命令执行(headroom 本体的 pip 检查/升级用),输出合并 stdout+stderr
1716
+ function execCapture(cmd, args, timeoutMs = 30000) {
1717
+ return new Promise((resolveRun) => {
1718
+ execFile(cmd, args, { timeout: timeoutMs, env: { ...process.env, ...proxyEnv() }, windowsHide: true }, (err, stdout, stderr) => {
1719
+ resolveRun({
1720
+ ok: !err,
1721
+ code: err ? (err.code ?? 'error') : 0,
1722
+ error: err ? err.message : null,
1723
+ output: `${stdout || ''}${stderr && stderr.trim() ? `${stdout ? '\n' : ''}${stderr.trim()}` : ''}`.trim(),
1724
+ });
1725
+ });
1726
+ });
1727
+ }
1728
+
1729
+ // headroom 本体(pip 安装)的升级通道:控制台脚本 shebang 指向安装它的 Python
1730
+ // 解释器,检查更新与升级都通过该环境的 pip 执行(尊重用户 pip 源配置)。
1731
+ function headroomPythonOf(binPath) {
1732
+ try {
1733
+ const first = (readFileSync(binPath, 'utf8').split('\n')[0] || '').trim();
1734
+ const m = /^#!\s*(.*python\S*)/.exec(first);
1735
+ return m ? m[1].trim() : null;
1736
+ } catch {
1737
+ return null;
1738
+ }
1739
+ }
1740
+
1741
+ // 找到 headroom 所属的 pip 发行版(发行版名未必是 headroom,如 headroom-ai)
1742
+ const HEADROOM_DIST_PY = [
1743
+ 'import importlib.metadata as m',
1744
+ 'for d in m.distributions():',
1745
+ " n = d.metadata['Name'] or ''",
1746
+ " if 'headroom' in n.lower() or any(str(f).startswith('headroom') for f in (d.files or ())):",
1747
+ " print(n + '\\t' + d.version); break",
1748
+ ].join('\n');
1749
+
1750
+ async function headroomPipChannel() {
1751
+ const bin = headroomFindBin();
1752
+ if (!bin) return { code: 'headroom-update', error: '未找到 headroom 程序' };
1753
+ const py = headroomPythonOf(bin);
1754
+ if (!py) return { code: 'headroom-update', error: '无法确定 headroom 的 Python 安装来源(不是 pip 控制台脚本)' };
1755
+ const r = await execCapture(py, ['-c', HEADROOM_DIST_PY], 20000);
1756
+ if (!r.ok || !r.output.includes('\t')) {
1757
+ return { code: 'headroom-update', error: '未找到 headroom 的 pip 发行版: ' + (r.output || r.error || '').split('\n')[0] };
1758
+ }
1759
+ const [dist, current] = r.output.split('\n')[0].split('\t');
1760
+ return { py, dist, current };
1761
+ }
1762
+
1763
+ // headroom 本体升级任务:pip install 后台执行,输出累积到任务对象供面板轮询。
1764
+ // 大包下载可持续数分钟——同步等待 HTTP 会把按钮卡在“升级中”且关弹窗即失联;
1765
+ // 任务状态存在 helper 进程内,重开弹窗可恢复显示。
1766
+ let headroomUpgradeJob = null;
1767
+
1768
+ export function headroomUpgradeSnapshot() { return upgradeJobSnapshot(headroomUpgradeJob); }
1769
+
1770
+ function cancelHeadroomUpgrade() { return cancelUpgradeJob(headroomUpgradeJob); }
1771
+
1772
+ // 启动升级(已在跑则直接返回现状快照);pip 正常结束后自动重取本体版本
1773
+ function startHeadroomUpgrade(channel) {
1774
+ if (headroomUpgradeJob && headroomUpgradeJob.running) return headroomUpgradeSnapshot();
1775
+ const j = newUpgradeJob();
1776
+ headroomUpgradeJob = j;
1777
+ void (async () => {
1778
+ try {
1779
+ await jobRun(j, channel.py, ['-m', 'pip', 'install', '--upgrade', channel.dist], 10 * 60 * 1000);
1780
+ const bin = headroomFindBin();
1781
+ if (bin) j.version = await binVersionOf(bin);
1782
+ } catch (err) {
1783
+ // 手动停止(canceled)不算失败;超时给出明确提示而非裸退出码
1784
+ if (!j.canceled) j.error = j.timedOut ? '升级超时(10 分钟)已终止' : String(err?.message || err);
1785
+ } finally {
1786
+ j.running = false;
1787
+ j.done = true;
1788
+ j.kill = null;
1789
+ }
1790
+ })();
1791
+ return headroomUpgradeSnapshot();
1792
+ }
1793
+
1794
+ // 运行 headroom 插件钩子(ensure-proxy.sh,POSIX sh):backend/power 写配置并立即
1795
+ // 生效,status/start/stop/restart 管理代理生命周期
1796
+ function runHeadroomHook(args, timeoutMs = 20000) {
1797
+ const hook = findMarketplaceHook('headroom', join('hooks', 'ensure-proxy.sh'));
1798
+ if (!hook) {
1799
+ return Promise.resolve({ ok: false, code: 'headroom-plugin', error: '未找到 headroom 插件(先在插件市场安装)' });
1800
+ }
1801
+ return new Promise((resolveRun) => {
1802
+ execFile('sh', [hook, ...args], { timeout: timeoutMs, windowsHide: true }, (err, stdout, stderr) => {
1803
+ if (err && err.code === 'ENOENT') {
1804
+ resolveRun({ ok: false, code: 'headroom-sh', error: '未找到 sh,无法运行 headroom 插件脚本' });
1805
+ return;
1806
+ }
1807
+ resolveRun({
1808
+ ok: !err,
1809
+ code: err ? (err.code ?? 'error') : 0,
1810
+ error: err ? err.message : null,
1811
+ output: `${stdout || ''}${stderr && stderr.trim() ? `${stdout ? '\n' : ''}${stderr.trim()}` : ''}`.trim(),
1812
+ });
1813
+ });
1814
+ });
1815
+ }
1816
+
1817
+ // 解析 ensure-proxy.sh status 输出为结构化字段(字段名以脚本 hr_status 的输出为准)。
1818
+ // 导出以便测试脚本直接驱动。
1819
+ export function parseHeadroomStatus(text) {
1820
+ const raw = String(text || '');
1821
+ const line = (key) => {
1822
+ const m = new RegExp('^' + key + ': *(.*)$', 'm').exec(raw);
1823
+ return m ? m[1].trim() : null;
1824
+ };
1825
+ const proxy = line('proxy') || '';
1826
+ const ps = line('power-save-auto-cpu') || '';
1827
+ return {
1828
+ up: /^up\b/.test(proxy),
1829
+ port: Number(/(\d+)\)?$/.exec(proxy)?.[1]) || null,
1830
+ managed: line('managed-by-plugin'),
1831
+ backend: line('current-backend'),
1832
+ desiredBackend: line('desired-backend'),
1833
+ powerMode: line('power-mode'),
1834
+ saverDetect: line('saver-detect'),
1835
+ watcherRunning: /^watcher: running/m.test(raw),
1836
+ saverHint: (raw.split('\n').find((l) => l.startsWith('注意:')) || '').trim() || null,
1837
+ powerSaveMode: (/^(battery|saver|off)/.exec(ps) || [])[1] || null,
1838
+ watchInterval: Number(/\(interval (\d+)s\)/.exec(ps)?.[1]) || null,
1839
+ raw,
1840
+ };
1841
+ }
1842
+
1843
+ // 用 node 跑脚本:优先 PATH 里的 node;桌面环境没有时用自身进程
1844
+ // (zcode-pro 本就跑在 ELECTRON_RUN_AS_NODE=1 的 ZCode 二进制上)。导出以便测试。
1845
+ export function runNodeScript(scriptArgs, timeoutMs = 120000) {
1846
+ const attempt = (cmd, extraEnv) => new Promise((resolveRun) => {
1847
+ execFile(cmd, scriptArgs, { timeout: timeoutMs, env: { ...process.env, ...proxyEnv(), ...extraEnv }, windowsHide: true }, (err, stdout, stderr) => {
1848
+ if (err && err.code === 'ENOENT') { resolveRun(null); return; }
1849
+ resolveRun({
1850
+ ok: !err,
1851
+ code: err ? (err.code ?? 'error') : 0,
1852
+ error: err ? err.message : null,
1853
+ output: `${stdout || ''}${stderr && stderr.trim() ? `${stdout ? '\n' : ''}${stderr.trim()}` : ''}`.trim(),
1854
+ });
1855
+ });
1856
+ });
1857
+ return attempt('node', {}).then((r) => r || attempt(process.execPath, { ELECTRON_RUN_AS_NODE: '1' }));
1858
+ }
1859
+
1860
+ // ZCode CLI bundle(plugins 子命令入口):按常见安装位置查找,可用环境变量覆盖
1861
+ function zcodeCliBundleCandidates() {
1862
+ return [
1863
+ process.env.ZCODEPRO_ZCODE_CLI,
1864
+ '/opt/ZCode/resources/glm/zcode.cjs',
1865
+ '/usr/lib/zcode/resources/glm/zcode.cjs',
1866
+ '/usr/share/zcode/resources/glm/zcode.cjs',
1867
+ '/Applications/ZCode.app/Contents/Resources/glm/zcode.cjs',
1868
+ process.env.LOCALAPPDATA ? join(process.env.LOCALAPPDATA, 'Programs', 'ZCode', 'resources', 'glm', 'zcode.cjs') : null,
1869
+ ].filter(Boolean);
1870
+ }
1871
+
1872
+ // 运行 zcode CLI 子命令(plugins …)
1873
+ function runZcodeCli(subArgs, timeoutMs = 120000) {
1874
+ const bundle = zcodeCliBundleCandidates().find((c) => existsSync(c));
1875
+ if (!bundle) {
1876
+ return Promise.resolve({ ok: false, code: 'cli-not-found', error: '未找到 ZCode CLI(resources/glm/zcode.cjs)' });
1877
+ }
1878
+ return runNodeScript([bundle, ...subArgs], timeoutMs);
1879
+ }
1880
+
1881
+ // 比对市场清单与已装清单:每个插件的已装版本与最新版本。导出以便测试。
1882
+ export function pluginUpdateStatus() {
1883
+ const installedRaw = readJsonFile(join(cliPluginsDir(), 'installed_plugins.json'));
1884
+ const mine = new Map();
1885
+ for (const p of installedRaw?.plugins || []) {
1886
+ if (p?.marketplace === ZCODE_PLUGINS_MARKETPLACE && p.name) mine.set(p.name, p.version || '');
1887
+ }
1888
+ const mkt = readJsonFile(join(cliPluginsDir(), 'marketplaces', ZCODE_PLUGINS_MARKETPLACE, 'marketplace.json'));
1889
+ if (!mkt || !Array.isArray(mkt.plugins)) return null;
1890
+ const plugins = mkt.plugins.map((e) => ({
1891
+ name: e.name,
1892
+ latest: e.version || '',
1893
+ installed: mine.get(e.name) || null,
1894
+ hasUpdate: mine.has(e.name) && mine.get(e.name) !== (e.version || ''),
1895
+ }));
1896
+ return { plugins, updates: plugins.filter((e) => e.hasUpdate) };
1897
+ }
1898
+
1899
+ // 读全局提示词(~/.zcode/AGENTS.md);文件不存在视为空内容。
1900
+ export function readAgents(agentsFile) {
1901
+ try {
1902
+ return [200, { ok: true, content: existsSync(agentsFile) ? readFileSync(agentsFile, 'utf8') : '' }];
1903
+ } catch (err) {
1904
+ return [500, { ok: false, code: 'agents-read', error: '读取 AGENTS.md 失败: ' + (err?.message || err) }];
1905
+ }
1906
+ }
1907
+
1908
+ // 写全局提示词:覆盖前备份,临时文件 + 改名原子写;内容为空时移除文件(= 无全局提示词)。
1909
+ // 导出以便测试脚本直接驱动。
1910
+ export function writeAgents(body, agentsFile) {
1911
+ if (!body || typeof body.content !== 'string') {
1912
+ return [400, { ok: false, code: 'invalid-content', error: 'content 必须是字符串' }];
1913
+ }
1914
+ const empty = body.content.trim() === '';
1915
+ try {
1916
+ if (existsSync(agentsFile)) {
1917
+ try { copyFileSync(agentsFile, agentsFile + '.zcodepro-backup'); } catch { /* 备份失败不阻塞 */ }
1918
+ if (empty) {
1919
+ unlinkSync(agentsFile);
1920
+ return [200, { ok: true, content: '' }];
1921
+ }
1922
+ } else if (empty) {
1923
+ return [200, { ok: true, content: '' }];
1924
+ }
1925
+ const tmp = agentsFile + '.zcodepro-tmp';
1926
+ writeFileSync(tmp, body.content, 'utf8');
1927
+ renameSync(tmp, agentsFile);
1928
+ } catch (err) {
1929
+ return [500, { ok: false, code: 'agents-write', error: '写入 AGENTS.md 失败: ' + (err?.message || err) }];
1930
+ }
1931
+ return [200, { ok: true, content: body.content }];
1932
+ }
1933
+
1934
+ // 在系统文件管理器中定位文件:
1935
+ // Linux 走 freedesktop FileManager1.ShowItems(Dolphin/Nautilus 均支持),无会话服务时回退打开所在目录;
1936
+ // macOS 用 open -R;Windows 用 explorer /select。导出以便测试脚本直接驱动。
1937
+ export function revealInFileManager(rawPath) {
1938
+ return new Promise((resolveReveal) => {
1939
+ const p = typeof rawPath === 'string' ? rawPath.trim() : '';
1940
+ if (!p || !isAbsolute(p)) {
1941
+ resolveReveal([400, { ok: false, code: 'invalid-path', error: '无效的路径' }]);
1942
+ return;
1943
+ }
1944
+ if (process.platform === 'darwin') {
1945
+ execFile('open', ['-R', p], { timeout: 5000 }, (err) => resolveReveal(err ? [500, { ok: false, error: err.message }] : [200, { ok: true }]));
1946
+ return;
1947
+ }
1948
+ if (process.platform === 'win32') {
1949
+ const child = spawn('explorer.exe', ['/select,' + p], { detached: true, stdio: 'ignore' });
1950
+ child.on('error', (err) => resolveReveal([500, { ok: false, error: err.message }]));
1951
+ child.on('close', () => resolveReveal([200, { ok: true }]));
1952
+ return;
1953
+ }
1954
+ const uri = pathToFileURL(p).href;
1955
+ execFile('dbus-send', ['--session', '--print-reply', '--dest=org.freedesktop.FileManager1', '/org/freedesktop/FileManager1', 'org.freedesktop.FileManager1.ShowItems', `array:string:${uri}`, 'string:'], { timeout: 5000 }, (err) => {
1956
+ if (!err) {
1957
+ resolveReveal([200, { ok: true }]);
1958
+ return;
1959
+ }
1960
+ // 无 freedesktop 文件管理器服务时退而打开所在目录
1961
+ execFile('xdg-open', [dirname(p)], { timeout: 5000 }, (err2) => resolveReveal(err2 ? [500, { ok: false, error: err2.message }] : [200, { ok: true }]));
1962
+ });
1963
+ });
1964
+ }
1965
+
1966
+ // 在系统文件管理器中打开文件夹本身(与 revealInFileManager 的定位不同)。
1967
+ // macOS 用 open;Windows 用 explorer;Linux 用 xdg-open。导出以便测试脚本直接驱动。
1968
+ export function openFolder(rawPath) {
1969
+ return new Promise((resolveOpen) => {
1970
+ const p = typeof rawPath === 'string' ? rawPath.trim() : '';
1971
+ if (!p || !isAbsolute(p)) {
1972
+ resolveOpen([400, { ok: false, code: 'invalid-path', error: '无效的路径' }]);
1973
+ return;
1974
+ }
1975
+ try {
1976
+ if (!existsSync(p) || !statSync(p).isDirectory()) {
1977
+ resolveOpen([400, { ok: false, code: 'not-found', error: '文件夹不存在' }]);
1978
+ return;
1979
+ }
1980
+ } catch (err) {
1981
+ resolveOpen([500, { ok: false, error: err?.message || String(err) }]);
1982
+ return;
1983
+ }
1984
+ const done = (err) => resolveOpen(err ? [500, { ok: false, error: err.message }] : [200, { ok: true }]);
1985
+ if (process.platform === 'darwin') execFile('open', [p], { timeout: 5000 }, done);
1986
+ else if (process.platform === 'win32') {
1987
+ const child = spawn('explorer.exe', [p], { detached: true, stdio: 'ignore' });
1988
+ child.on('error', done);
1989
+ child.on('close', () => done(null));
1990
+ } else execFile('xdg-open', [p], { timeout: 5000 }, done);
1991
+ });
1992
+ }
1993
+
1994
+ // 用系统默认应用打开文件(或文件夹):macOS 用 open;Windows 用 explorer;
1995
+ // Linux 用 xdg-open。导出以便测试脚本直接驱动。
1996
+ export function openWithDefaultApp(rawPath) {
1997
+ return new Promise((resolveOpen) => {
1998
+ const p = typeof rawPath === 'string' ? rawPath.trim() : '';
1999
+ if (!p || !isAbsolute(p)) {
2000
+ resolveOpen([400, { ok: false, code: 'invalid-path', error: '无效的路径' }]);
2001
+ return;
2002
+ }
2003
+ if (!existsSync(p)) {
2004
+ resolveOpen([400, { ok: false, code: 'not-found', error: '文件不存在' }]);
2005
+ return;
2006
+ }
2007
+ const done = (err) => resolveOpen(err ? [500, { ok: false, error: err.message }] : [200, { ok: true }]);
2008
+ if (process.platform === 'darwin') execFile('open', [p], { timeout: 5000 }, done);
2009
+ else if (process.platform === 'win32') {
2010
+ const child = spawn('explorer.exe', [p], { detached: true, stdio: 'ignore' });
2011
+ child.on('error', done);
2012
+ child.on('close', () => done(null));
2013
+ } else execFile('xdg-open', [p], { timeout: 5000 }, done);
2014
+ });
2015
+ }
2016
+
2017
+ // 设置/清除项目自定义别名(纯渲染层,不动磁盘与任何 ZCode 数据)。
2018
+ // alias 传空字符串/null 即清除该项目条目。
2019
+ export function setAlias(body, configFile) {
2020
+ const raw = typeof body?.path === 'string' ? body.path.trim() : '';
2021
+ // 先校验再 resolve:resolve 会把相对路径悄悄变绝对,绕过校验
2022
+ if (!raw || !isAbsolute(raw)) return [400, { ok: false, error: '无效的项目路径' }];
2023
+ const path = resolve(raw);
2024
+ const name = typeof body?.alias === 'string' ? body.alias.trim() : '';
2025
+ if (name) {
2026
+ if (name.length > 100) return [400, { ok: false, error: '别名过长(最多 100 字符)', code: 'name-too-long' }];
2027
+ if (/[\r\n\0]/.test(name)) return [400, { ok: false, error: '别名不能包含换行等控制字符', code: 'name-invalid-chars' }];
2028
+ }
2029
+ const config = loadConfig(configFile);
2030
+ if (!config.aliases || typeof config.aliases !== 'object') config.aliases = {};
2031
+ if (name) config.aliases[path] = name;
2032
+ else delete config.aliases[path];
2033
+ try {
2034
+ saveConfig(configFile, config);
2035
+ } catch (err) {
2036
+ return [500, { ok: false, code: 'config-save', error: '保存配置失败: ' + (err?.message || err) }];
2037
+ }
2038
+ return [200, { ok: true, path, alias: name, aliases: config.aliases }];
2039
+ }
2040
+
2041
+ function invalidProjectName(name) {
2042
+ if (typeof name !== 'string') return '名称必须是字符串';
2043
+ const n = name.trim();
2044
+ if (!n) return '名称不能为空';
2045
+ if (n === '.' || n === '..') return '名称不能是 . 或 ..';
2046
+ if (n.length > 100) return '名称过长(最多 100 字符)';
2047
+ if (/[\\/:*?"<>|\0]/.test(n)) return '名称不能包含 \\ / : * ? " < > | 等字符';
2048
+ if (/^\s|\s$/.test(name)) return '名称首尾不能有空格';
2049
+ return null;
2050
+ }
2051
+
2052
+ // 修改项目位置:把项目的记录(最近项目/标签页/任务索引/别名)重新指向另一个文件夹。
2053
+ // 不移动、不修改任何目录;目标文件夹必须已存在。导出以便测试脚本直接驱动。
2054
+ export async function relocateProject(body, dataRoot) {
2055
+ const rawOld = typeof body?.path === 'string' ? body.path.trim() : '';
2056
+ const rawNew = typeof body?.newPath === 'string' ? body.newPath.trim() : '';
2057
+ if (!rawOld || !isAbsolute(rawOld)) return [400, { ok: false, error: '无效的项目路径' }];
2058
+ if (!rawNew || !isAbsolute(rawNew)) return [400, { ok: false, code: 'invalid-path', error: '无效的新位置' }];
2059
+ const norm = (p) => p.replace(/[\\/]+$/, '');
2060
+ const oldPath = resolve(rawOld);
2061
+ const newPath = resolve(rawNew);
2062
+ if (norm(newPath) === norm(oldPath)) return [400, { ok: false, code: 'same-path', error: '新位置与当前位置相同' }];
2063
+ const nameError = invalidProjectName(basename(norm(newPath)));
2064
+ if (nameError) return [400, { ok: false, code: 'invalid-path', error: nameError }];
2065
+ // 数据目录护栏:不得指向 ZCode 数据目录内部(优先于存在性检查)
2066
+ if (newPath === dataRoot || newPath.startsWith(norm(dataRoot) + sep)) {
2067
+ return [400, { ok: false, code: 'protected-path', error: '拒绝指向 ZCode 数据目录内部路径' }];
2068
+ }
2069
+
2070
+ let isDir = false;
2071
+ try { isDir = existsSync(newPath) && statSync(newPath).isDirectory(); } catch { /* ignore */ }
2072
+ if (!isDir) return [404, { ok: false, code: 'new-not-found', error: '目标文件夹不存在: ' + newPath }];
2073
+
2074
+ // 任务索引:存在且有驱动时同步。先预检写锁——任何写入前发现拿不到锁直接失败。
2075
+ const indexFile = taskIndexPath(dataRoot);
2076
+ const indexExists = existsSync(indexFile);
2077
+ const syncIndex = indexExists && (await taskIndexDriverAvailable());
2078
+ if (syncIndex) {
2079
+ try {
2080
+ await probeTaskIndexWritable(dataRoot);
2081
+ } catch (err) {
2082
+ return [503, { ok: false, code: err.code || 'index-error', error: String(err?.message || err) }];
2083
+ }
2084
+ }
2085
+
2086
+ // setting.json 路径引用同步(recentProjects / lastWorkspaceSession / webRemoteControl…)
2087
+ const settingsFile = join(dataRoot, 'v2', 'setting.json');
2088
+ try {
2089
+ const settings = readSettings(settingsFile);
2090
+ writeSettingsAtomic(settingsFile, remapSettingsPaths(settings, oldPath, newPath));
2091
+ } catch (err) {
2092
+ return [500, { ok: false, error: '更新 setting.json 失败: ' + (err?.message || err) }];
2093
+ }
2094
+
2095
+ // 任务索引同步;失败则回滚 setting.json,保持两边一致
2096
+ if (syncIndex) {
2097
+ try {
2098
+ await remapTaskIndexPaths(dataRoot, oldPath, newPath);
2099
+ } catch (err) {
2100
+ try {
2101
+ const cur = readSettings(settingsFile);
2102
+ writeSettingsAtomic(settingsFile, remapSettingsPaths(cur, newPath, oldPath));
2103
+ } catch { /* 回滚失败:setting.json.zcodepro-backup 仍有兜底 */ }
2104
+ return [500, {
2105
+ ok: false,
2106
+ code: err.code || 'index-remap-failed',
2107
+ error: '更新任务索引失败(已回滚配置更新): ' + (err?.message || err),
2108
+ }];
2109
+ }
2110
+ }
2111
+
2112
+ // 别名随迁(仅渲染层数据,失败不影响结果)
2113
+ try {
2114
+ const config = loadConfig(join(dataRoot, 'zcodepro.json'));
2115
+ const aliases = config.aliases || {};
2116
+ if (Object.prototype.hasOwnProperty.call(aliases, oldPath)) {
2117
+ const v = aliases[oldPath];
2118
+ delete aliases[oldPath];
2119
+ aliases[newPath] = v;
2120
+ saveConfig(join(dataRoot, 'zcodepro.json'), config);
2121
+ }
2122
+ } catch { /* ignore */ }
2123
+
2124
+ return [200, {
2125
+ ok: true,
2126
+ oldPath,
2127
+ newPath,
2128
+ reload: true,
2129
+ indexSynced: syncIndex || !indexExists,
2130
+ ...(indexExists && !syncIndex
2131
+ ? { warning: '任务索引未更新:本机缺少 SQLite 支持(需 Node ≥23.4 或 sqlite3 命令),旧任务条目可能仍指向旧路径。' }
2132
+ : {}),
2133
+ }];
2134
+ }